mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Compare commits
1 Commits
v0.10.0
...
feat/debug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d08c2b5b |
3
.claude/.gitignore
vendored
3
.claude/.gitignore
vendored
@@ -1,2 +1 @@
|
|||||||
hans/
|
hans/
|
||||||
skills/
|
|
||||||
@@ -1,19 +1 @@
|
|||||||
# Dependencies
|
node_modules/
|
||||||
node_modules/
|
|
||||||
**/node_modules/
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
dist/
|
|
||||||
**/dist/
|
|
||||||
dist-electron/
|
|
||||||
**/dist-electron/
|
|
||||||
build/
|
|
||||||
**/build/
|
|
||||||
.next/
|
|
||||||
**/.next/
|
|
||||||
.nuxt/
|
|
||||||
**/.nuxt/
|
|
||||||
out/
|
|
||||||
**/out/
|
|
||||||
.cache/
|
|
||||||
**/.cache/
|
|
||||||
117
.github/workflows/e2e-tests.yml
vendored
117
.github/workflows/e2e-tests.yml
vendored
@@ -31,99 +31,24 @@ jobs:
|
|||||||
- name: Build server
|
- name: Build server
|
||||||
run: npm run build --workspace=apps/server
|
run: npm run build --workspace=apps/server
|
||||||
|
|
||||||
- name: Set up Git user
|
|
||||||
run: |
|
|
||||||
git config --global user.name "GitHub CI"
|
|
||||||
git config --global user.email "ci@example.com"
|
|
||||||
|
|
||||||
- name: Start backend server
|
- name: Start backend server
|
||||||
run: |
|
run: npm run start --workspace=apps/server &
|
||||||
echo "Starting backend server..."
|
|
||||||
# Start server in background and save PID
|
|
||||||
npm run start --workspace=apps/server > backend.log 2>&1 &
|
|
||||||
SERVER_PID=$!
|
|
||||||
echo "Server started with PID: $SERVER_PID"
|
|
||||||
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PORT: 3008
|
PORT: 3008
|
||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
# Use a deterministic API key so Playwright can log in reliably
|
|
||||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
|
||||||
# Reduce log noise in CI
|
|
||||||
AUTOMAKER_HIDE_API_KEY: 'true'
|
|
||||||
# Avoid real API calls during CI
|
|
||||||
AUTOMAKER_MOCK_AGENT: 'true'
|
|
||||||
# Simulate containerized environment to skip sandbox confirmation dialogs
|
|
||||||
IS_CONTAINERIZED: 'true'
|
|
||||||
|
|
||||||
- name: Wait for backend server
|
- name: Wait for backend server
|
||||||
run: |
|
run: |
|
||||||
echo "Waiting for backend server to be ready..."
|
echo "Waiting for backend server to be ready..."
|
||||||
|
for i in {1..30}; do
|
||||||
# Check if server process is running
|
if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||||
if [ -z "$SERVER_PID" ]; then
|
|
||||||
echo "ERROR: Server PID not found in environment"
|
|
||||||
cat backend.log 2>/dev/null || echo "No backend log found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if process is actually running
|
|
||||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
|
||||||
echo "ERROR: Server process $SERVER_PID is not running!"
|
|
||||||
echo "=== Backend logs ==="
|
|
||||||
cat backend.log
|
|
||||||
echo ""
|
|
||||||
echo "=== Recent system logs ==="
|
|
||||||
dmesg 2>/dev/null | tail -20 || echo "No dmesg available"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wait for health endpoint
|
|
||||||
for i in {1..60}; do
|
|
||||||
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
|
|
||||||
echo "Backend server is ready!"
|
echo "Backend server is ready!"
|
||||||
echo "=== Backend logs ==="
|
|
||||||
cat backend.log
|
|
||||||
echo ""
|
|
||||||
echo "Health check response:"
|
|
||||||
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
echo "Waiting... ($i/30)"
|
||||||
# Check if server process is still running
|
|
||||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
|
||||||
echo "ERROR: Server process died during wait!"
|
|
||||||
echo "=== Backend logs ==="
|
|
||||||
cat backend.log
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Waiting... ($i/60)"
|
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
echo "Backend server failed to start!"
|
||||||
echo "ERROR: Backend server failed to start within 60 seconds!"
|
|
||||||
echo "=== Backend logs ==="
|
|
||||||
cat backend.log
|
|
||||||
echo ""
|
|
||||||
echo "=== Process status ==="
|
|
||||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
|
||||||
echo ""
|
|
||||||
echo "=== Port status ==="
|
|
||||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
|
||||||
lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use"
|
|
||||||
echo ""
|
|
||||||
echo "=== Health endpoint test ==="
|
|
||||||
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
|
|
||||||
|
|
||||||
# Kill the server process if it's still hanging
|
|
||||||
if kill -0 $SERVER_PID 2>/dev/null; then
|
|
||||||
echo ""
|
|
||||||
echo "Killing stuck server process..."
|
|
||||||
kill -9 $SERVER_PID 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Run E2E tests
|
- name: Run E2E tests
|
||||||
@@ -134,20 +59,6 @@ jobs:
|
|||||||
CI: true
|
CI: true
|
||||||
VITE_SERVER_URL: http://localhost:3008
|
VITE_SERVER_URL: http://localhost:3008
|
||||||
VITE_SKIP_SETUP: 'true'
|
VITE_SKIP_SETUP: 'true'
|
||||||
# Keep UI-side login/defaults consistent
|
|
||||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
|
||||||
|
|
||||||
- name: Print backend logs on failure
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
echo "=== E2E Tests Failed - Backend Logs ==="
|
|
||||||
cat backend.log 2>/dev/null || echo "No backend log found"
|
|
||||||
echo ""
|
|
||||||
echo "=== Process status at failure ==="
|
|
||||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
|
||||||
echo ""
|
|
||||||
echo "=== Port status ==="
|
|
||||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -157,22 +68,10 @@ jobs:
|
|||||||
path: apps/ui/playwright-report/
|
path: apps/ui/playwright-report/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload test results (screenshots, traces, videos)
|
- name: Upload test results
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
path: |
|
path: apps/ui/test-results/
|
||||||
apps/ui/test-results/
|
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
if-no-files-found: ignore
|
|
||||||
|
|
||||||
- name: Cleanup - Kill backend server
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
if [ -n "$SERVER_PID" ]; then
|
|
||||||
echo "Cleaning up backend server (PID: $SERVER_PID)..."
|
|
||||||
kill $SERVER_PID 2>/dev/null || true
|
|
||||||
kill -9 $SERVER_PID 2>/dev/null || true
|
|
||||||
echo "Backend server cleanup complete"
|
|
||||||
fi
|
|
||||||
|
|||||||
2
.github/workflows/security-audit.yml
vendored
2
.github/workflows/security-audit.yml
vendored
@@ -26,5 +26,5 @@ jobs:
|
|||||||
check-lockfile: 'true'
|
check-lockfile: 'true'
|
||||||
|
|
||||||
- name: Run npm audit
|
- name: Run npm audit
|
||||||
run: npm audit --audit-level=critical
|
run: npm audit --audit-level=moderate
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -73,9 +73,6 @@ blob-report/
|
|||||||
!.env.example
|
!.env.example
|
||||||
!.env.local.example
|
!.env.local.example
|
||||||
|
|
||||||
# Codex config (contains API keys)
|
|
||||||
.codex/config.toml
|
|
||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
@@ -87,12 +84,4 @@ docker-compose.override.yml
|
|||||||
.claude/hans/
|
.claude/hans/
|
||||||
|
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
# Fork-specific workflow files (should never be committed)
|
|
||||||
DEVELOPMENT_WORKFLOW.md
|
|
||||||
check-sync.sh
|
|
||||||
# API key files
|
|
||||||
data/.api-key
|
|
||||||
data/credentials.json
|
|
||||||
data/
|
|
||||||
41
CLAUDE.md
41
CLAUDE.md
@@ -170,3 +170,44 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
|
|||||||
- `DATA_DIR` - Data storage directory (default: ./data)
|
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||||
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
|
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
|
||||||
|
- `ENABLE_DEBUG_PANEL=true` - Enable debug panel in non-development builds
|
||||||
|
|
||||||
|
## Debug System (Development Only)
|
||||||
|
|
||||||
|
The debug system provides real-time monitoring of server performance. Toggle with `Cmd/Ctrl+Shift+D`.
|
||||||
|
|
||||||
|
### Debug Services (`apps/server/src/services/`)
|
||||||
|
|
||||||
|
- `PerformanceMonitorService` - Collects memory/CPU metrics, detects leaks via linear regression
|
||||||
|
- `ProcessRegistryService` - Tracks spawned agents, terminals, CLIs with lifecycle events
|
||||||
|
|
||||||
|
### Debug API (`apps/server/src/routes/debug/`)
|
||||||
|
|
||||||
|
- `GET /api/debug/metrics` - Current metrics snapshot
|
||||||
|
- `POST /api/debug/metrics/start` - Start collection with optional config
|
||||||
|
- `POST /api/debug/metrics/stop` - Stop collection
|
||||||
|
- `GET /api/debug/processes` - List tracked processes with filters
|
||||||
|
|
||||||
|
### Debug Types (`libs/types/src/debug.ts`)
|
||||||
|
|
||||||
|
All debug types are exported from `@automaker/types`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type {
|
||||||
|
DebugMetricsSnapshot,
|
||||||
|
TrackedProcess,
|
||||||
|
MemoryTrend,
|
||||||
|
ProcessSummary,
|
||||||
|
} from '@automaker/types';
|
||||||
|
import { formatBytes, formatDuration } from '@automaker/types';
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI Components (`apps/ui/src/components/debug/`)
|
||||||
|
|
||||||
|
- `DebugPanel` - Main draggable container with tabs
|
||||||
|
- `MemoryMonitor` - Heap usage charts and leak indicators
|
||||||
|
- `CPUMonitor` - CPU gauge and event loop lag display
|
||||||
|
- `ProcessKanban` - Visual board of tracked processes
|
||||||
|
- `RenderTracker` - React component render statistics
|
||||||
|
|
||||||
|
See `docs/server/debug-api.md` for full API documentation.
|
||||||
|
|||||||
89
Dockerfile
89
Dockerfile
@@ -8,12 +8,10 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
|
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM node:22-slim AS base
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
# Install build dependencies for native modules (node-pty)
|
# Install build dependencies for native modules (node-pty)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apk add --no-cache python3 make g++
|
||||||
python3 make g++ \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -53,64 +51,31 @@ RUN npm run build:packages && npm run build --workspace=apps/server
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# SERVER PRODUCTION STAGE
|
# SERVER PRODUCTION STAGE
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM node:22-slim AS server
|
FROM node:22-alpine AS server
|
||||||
|
|
||||||
# Build argument for tracking which commit this image was built from
|
# Install git, curl, bash (for terminal), and GitHub CLI (pinned version, multi-arch)
|
||||||
ARG GIT_COMMIT_SHA=unknown
|
RUN apk add --no-cache git curl bash && \
|
||||||
LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
|
GH_VERSION="2.63.2" && \
|
||||||
|
ARCH=$(uname -m) && \
|
||||||
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
|
case "$ARCH" in \
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
git curl bash gosu ca-certificates openssh-client \
|
|
||||||
&& GH_VERSION="2.63.2" \
|
|
||||||
&& ARCH=$(uname -m) \
|
|
||||||
&& case "$ARCH" in \
|
|
||||||
x86_64) GH_ARCH="amd64" ;; \
|
x86_64) GH_ARCH="amd64" ;; \
|
||||||
aarch64|arm64) GH_ARCH="arm64" ;; \
|
aarch64|arm64) GH_ARCH="arm64" ;; \
|
||||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
||||||
esac \
|
esac && \
|
||||||
&& curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \
|
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz && \
|
||||||
&& tar -xzf gh.tar.gz \
|
tar -xzf gh.tar.gz && \
|
||||||
&& mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \
|
mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh && \
|
||||||
&& rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
|
rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH}
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Claude CLI globally (available to all users via npm global bin)
|
# Install Claude CLI globally
|
||||||
RUN npm install -g @anthropic-ai/claude-code
|
RUN npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
# Create non-root user with home directory BEFORE installing Cursor CLI
|
|
||||||
RUN groupadd -g 1001 automaker && \
|
|
||||||
useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \
|
|
||||||
mkdir -p /home/automaker/.local/bin && \
|
|
||||||
mkdir -p /home/automaker/.cursor && \
|
|
||||||
chown -R automaker:automaker /home/automaker && \
|
|
||||||
chmod 700 /home/automaker/.cursor
|
|
||||||
|
|
||||||
# Install Cursor CLI as the automaker user
|
|
||||||
# Set HOME explicitly and install to /home/automaker/.local/bin/
|
|
||||||
USER automaker
|
|
||||||
ENV HOME=/home/automaker
|
|
||||||
RUN curl https://cursor.com/install -fsS | bash && \
|
|
||||||
echo "=== Checking Cursor CLI installation ===" && \
|
|
||||||
ls -la /home/automaker/.local/bin/ && \
|
|
||||||
echo "=== PATH is: $PATH ===" && \
|
|
||||||
(which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)"
|
|
||||||
USER root
|
|
||||||
|
|
||||||
# Add PATH to profile so it's available in all interactive shells (for login shells)
|
|
||||||
RUN mkdir -p /etc/profile.d && \
|
|
||||||
echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \
|
|
||||||
chmod +x /etc/profile.d/cursor-cli.sh
|
|
||||||
|
|
||||||
# Add to automaker's .bashrc for bash interactive shells
|
|
||||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \
|
|
||||||
chown automaker:automaker /home/automaker/.bashrc
|
|
||||||
|
|
||||||
# Also add to root's .bashrc since docker exec defaults to root
|
|
||||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S automaker && \
|
||||||
|
adduser -S automaker -u 1001
|
||||||
|
|
||||||
# Copy root package.json (needed for workspace resolution)
|
# Copy root package.json (needed for workspace resolution)
|
||||||
COPY --from=server-builder /app/package*.json ./
|
COPY --from=server-builder /app/package*.json ./
|
||||||
|
|
||||||
@@ -133,19 +98,12 @@ RUN git config --system --add safe.directory '*' && \
|
|||||||
# Use gh as credential helper (works with GH_TOKEN env var)
|
# Use gh as credential helper (works with GH_TOKEN env var)
|
||||||
git config --system credential.helper '!gh auth git-credential'
|
git config --system credential.helper '!gh auth git-credential'
|
||||||
|
|
||||||
# Copy entrypoint script for fixing permissions on mounted volumes
|
# Switch to non-root user
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
USER automaker
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
||||||
|
|
||||||
# Note: We stay as root here so entrypoint can fix permissions
|
|
||||||
# The entrypoint script will switch to automaker user before running the command
|
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
ENV PORT=3008
|
ENV PORT=3008
|
||||||
ENV DATA_DIR=/data
|
ENV DATA_DIR=/data
|
||||||
ENV HOME=/home/automaker
|
|
||||||
# Add user's local bin to PATH for cursor-agent
|
|
||||||
ENV PATH="/home/automaker/.local/bin:${PATH}"
|
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3008
|
EXPOSE 3008
|
||||||
@@ -154,9 +112,6 @@ EXPOSE 3008
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD curl -f http://localhost:3008/api/health || exit 1
|
CMD curl -f http://localhost:3008/api/health || exit 1
|
||||||
|
|
||||||
# Use entrypoint to fix permissions before starting
|
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
|
||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
CMD ["node", "apps/server/dist/index.js"]
|
CMD ["node", "apps/server/dist/index.js"]
|
||||||
|
|
||||||
@@ -188,10 +143,6 @@ RUN npm run build:packages && npm run build --workspace=apps/ui
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM nginx:alpine AS ui
|
FROM nginx:alpine AS ui
|
||||||
|
|
||||||
# Build argument for tracking which commit this image was built from
|
|
||||||
ARG GIT_COMMIT_SHA=unknown
|
|
||||||
LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
|
|
||||||
|
|
||||||
# Copy built files
|
# Copy built files
|
||||||
COPY --from=ui-builder /app/apps/ui/dist /usr/share/nginx/html
|
COPY --from=ui-builder /app/apps/ui/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
# Automaker Development Dockerfile
|
|
||||||
# For development with live reload via volume mounting
|
|
||||||
# Source code is NOT copied - it's mounted as a volume
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# docker compose -f docker-compose.dev.yml up
|
|
||||||
|
|
||||||
FROM node:22-slim
|
|
||||||
|
|
||||||
# Install build dependencies for native modules (node-pty) and runtime tools
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
python3 make g++ \
|
|
||||||
git curl bash gosu ca-certificates openssh-client \
|
|
||||||
&& GH_VERSION="2.63.2" \
|
|
||||||
&& ARCH=$(uname -m) \
|
|
||||||
&& case "$ARCH" in \
|
|
||||||
x86_64) GH_ARCH="amd64" ;; \
|
|
||||||
aarch64|arm64) GH_ARCH="arm64" ;; \
|
|
||||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
|
||||||
esac \
|
|
||||||
&& curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \
|
|
||||||
&& tar -xzf gh.tar.gz \
|
|
||||||
&& mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \
|
|
||||||
&& rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Claude CLI globally
|
|
||||||
RUN npm install -g @anthropic-ai/claude-code
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN groupadd -g 1001 automaker && \
|
|
||||||
useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \
|
|
||||||
mkdir -p /home/automaker/.local/bin && \
|
|
||||||
mkdir -p /home/automaker/.cursor && \
|
|
||||||
chown -R automaker:automaker /home/automaker && \
|
|
||||||
chmod 700 /home/automaker/.cursor
|
|
||||||
|
|
||||||
# Install Cursor CLI as automaker user
|
|
||||||
USER automaker
|
|
||||||
ENV HOME=/home/automaker
|
|
||||||
RUN curl https://cursor.com/install -fsS | bash || true
|
|
||||||
USER root
|
|
||||||
|
|
||||||
# Add PATH to profile for Cursor CLI
|
|
||||||
RUN mkdir -p /etc/profile.d && \
|
|
||||||
echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \
|
|
||||||
chmod +x /etc/profile.d/cursor-cli.sh
|
|
||||||
|
|
||||||
# Add to user bashrc files
|
|
||||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \
|
|
||||||
chown automaker:automaker /home/automaker/.bashrc
|
|
||||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Create directories with proper permissions
|
|
||||||
RUN mkdir -p /data /projects && chown automaker:automaker /data /projects
|
|
||||||
|
|
||||||
# Configure git for mounted volumes
|
|
||||||
RUN git config --system --add safe.directory '*' && \
|
|
||||||
git config --system credential.helper '!gh auth git-credential'
|
|
||||||
|
|
||||||
# Copy entrypoint script
|
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
ENV PORT=3008
|
|
||||||
ENV DATA_DIR=/data
|
|
||||||
ENV HOME=/home/automaker
|
|
||||||
ENV PATH="/home/automaker/.local/bin:${PATH}"
|
|
||||||
|
|
||||||
# Expose both dev ports
|
|
||||||
EXPOSE 3007 3008
|
|
||||||
|
|
||||||
# Use entrypoint for permission handling
|
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
|
||||||
|
|
||||||
# Default command - will be overridden by docker-compose
|
|
||||||
CMD ["npm", "run", "dev:web"]
|
|
||||||
21
README.md
21
README.md
@@ -117,16 +117,24 @@ cd automaker
|
|||||||
# 2. Install dependencies
|
# 2. Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 3. Build shared packages (can be skipped - npm run dev does it automatically)
|
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly)
|
||||||
npm run build:packages
|
npm run build:packages
|
||||||
|
|
||||||
# 4. Start Automaker
|
# 4. Start Automaker (production mode)
|
||||||
npm run dev
|
npm run start
|
||||||
# Choose between:
|
# Choose between:
|
||||||
# 1. Web Application (browser at localhost:3007)
|
# 1. Web Application (browser at localhost:3007)
|
||||||
# 2. Desktop Application (Electron - recommended)
|
# 2. Desktop Application (Electron - recommended)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note:** The `npm run start` command will:
|
||||||
|
|
||||||
|
- Check for dependencies and install if needed
|
||||||
|
- Build the application if needed
|
||||||
|
- Kill any processes on ports 3007/3008
|
||||||
|
- Present an interactive menu to choose your run mode
|
||||||
|
- Run in production mode (no hot reload)
|
||||||
|
|
||||||
**Authentication Setup:** On first run, Automaker will automatically show a setup wizard where you can configure authentication. You can choose to:
|
**Authentication Setup:** On first run, Automaker will automatically show a setup wizard where you can configure authentication. You can choose to:
|
||||||
|
|
||||||
- Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically
|
- Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically
|
||||||
@@ -142,7 +150,7 @@ export ANTHROPIC_API_KEY="sk-ant-..."
|
|||||||
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
|
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
|
||||||
```
|
```
|
||||||
|
|
||||||
**For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes.
|
**For Development:** If you want to develop on Automaker with Vite live reload and hot module replacement, use `npm run dev` instead. This will start the development server with fast refresh and instant updates as you make changes.
|
||||||
|
|
||||||
## How to Run
|
## How to Run
|
||||||
|
|
||||||
@@ -186,6 +194,9 @@ npm run dev:web
|
|||||||
```bash
|
```bash
|
||||||
# Build for web deployment (uses Vite)
|
# Build for web deployment (uses Vite)
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
# Run production build
|
||||||
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Desktop Application
|
#### Desktop Application
|
||||||
@@ -363,6 +374,7 @@ npm run lint
|
|||||||
|
|
||||||
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
|
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
|
||||||
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
|
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
|
||||||
|
- `ENABLE_DEBUG_PANEL` - Enable the debug panel in non-development builds (for staging environments)
|
||||||
|
|
||||||
### Authentication Setup
|
### Authentication Setup
|
||||||
|
|
||||||
@@ -444,6 +456,7 @@ The application can store your API key securely in the settings UI. The key is p
|
|||||||
- 🎨 **Theme System** - 25+ themes including Dark, Light, Dracula, Nord, Catppuccin, and more
|
- 🎨 **Theme System** - 25+ themes including Dark, Light, Dracula, Nord, Catppuccin, and more
|
||||||
- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64)
|
- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64)
|
||||||
- 🌐 **Web Mode** - Run in browser or as Electron desktop app
|
- 🌐 **Web Mode** - Run in browser or as Electron desktop app
|
||||||
|
- 🐛 **Debug Panel** - Floating debug overlay for monitoring memory, CPU, and process performance (dev mode only, toggle with `Cmd/Ctrl+Shift+D`)
|
||||||
|
|
||||||
### Advanced Features
|
### Advanced Features
|
||||||
|
|
||||||
|
|||||||
@@ -8,20 +8,6 @@
|
|||||||
# Your Anthropic API key for Claude models
|
# Your Anthropic API key for Claude models
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# OPTIONAL - Additional API Keys
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
# OpenAI API key for Codex/GPT models
|
|
||||||
OPENAI_API_KEY=sk-...
|
|
||||||
|
|
||||||
# Cursor API key for Cursor models
|
|
||||||
CURSOR_API_KEY=...
|
|
||||||
|
|
||||||
# OAuth credentials for CLI authentication (extracted automatically)
|
|
||||||
CLAUDE_OAUTH_CREDENTIALS=
|
|
||||||
CURSOR_AUTH_TOKEN=
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# OPTIONAL - Security
|
# OPTIONAL - Security
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.10.0",
|
"version": "0.7.3",
|
||||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||||
"author": "AutoMaker Team",
|
"author": "AutoMaker Team",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
@@ -32,8 +32,7 @@
|
|||||||
"@automaker/prompts": "1.0.0",
|
"@automaker/prompts": "1.0.0",
|
||||||
"@automaker/types": "1.0.0",
|
"@automaker/types": "1.0.0",
|
||||||
"@automaker/utils": "1.0.0",
|
"@automaker/utils": "1.0.0",
|
||||||
"@modelcontextprotocol/sdk": "1.25.2",
|
"@modelcontextprotocol/sdk": "1.25.1",
|
||||||
"@openai/codex-sdk": "^0.77.0",
|
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dotenv": "17.2.3",
|
"dotenv": "17.2.3",
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ import { SettingsService } from './services/settings-service.js';
|
|||||||
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
|
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
|
||||||
import { createClaudeRoutes } from './routes/claude/index.js';
|
import { createClaudeRoutes } from './routes/claude/index.js';
|
||||||
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
||||||
import { createCodexRoutes } from './routes/codex/index.js';
|
|
||||||
import { CodexUsageService } from './services/codex-usage-service.js';
|
|
||||||
import { CodexAppServerService } from './services/codex-app-server-service.js';
|
|
||||||
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
|
|
||||||
import { createGitHubRoutes } from './routes/github/index.js';
|
import { createGitHubRoutes } from './routes/github/index.js';
|
||||||
import { createContextRoutes } from './routes/context/index.js';
|
import { createContextRoutes } from './routes/context/index.js';
|
||||||
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||||
@@ -67,6 +63,12 @@ import { createPipelineRoutes } from './routes/pipeline/index.js';
|
|||||||
import { pipelineService } from './services/pipeline-service.js';
|
import { pipelineService } from './services/pipeline-service.js';
|
||||||
import { createIdeationRoutes } from './routes/ideation/index.js';
|
import { createIdeationRoutes } from './routes/ideation/index.js';
|
||||||
import { IdeationService } from './services/ideation-service.js';
|
import { IdeationService } from './services/ideation-service.js';
|
||||||
|
import {
|
||||||
|
createDebugRoutes,
|
||||||
|
createDebugServices,
|
||||||
|
stopDebugServices,
|
||||||
|
type DebugServices,
|
||||||
|
} from './routes/debug/index.js';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -74,6 +76,8 @@ dotenv.config();
|
|||||||
const PORT = parseInt(process.env.PORT || '3008', 10);
|
const PORT = parseInt(process.env.PORT || '3008', 10);
|
||||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||||
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
|
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
|
||||||
|
const ENABLE_DEBUG_PANEL =
|
||||||
|
process.env.NODE_ENV !== 'production' || process.env.ENABLE_DEBUG_PANEL === 'true';
|
||||||
|
|
||||||
// Check for required environment variables
|
// Check for required environment variables
|
||||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||||
@@ -170,21 +174,20 @@ const agentService = new AgentService(DATA_DIR, events, settingsService);
|
|||||||
const featureLoader = new FeatureLoader();
|
const featureLoader = new FeatureLoader();
|
||||||
const autoModeService = new AutoModeService(events, settingsService);
|
const autoModeService = new AutoModeService(events, settingsService);
|
||||||
const claudeUsageService = new ClaudeUsageService();
|
const claudeUsageService = new ClaudeUsageService();
|
||||||
const codexAppServerService = new CodexAppServerService();
|
|
||||||
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
|
|
||||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
|
||||||
const mcpTestService = new MCPTestService(settingsService);
|
const mcpTestService = new MCPTestService(settingsService);
|
||||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||||
|
|
||||||
|
// Create debug services (dev mode only)
|
||||||
|
let debugServices: DebugServices | null = null;
|
||||||
|
if (ENABLE_DEBUG_PANEL) {
|
||||||
|
debugServices = createDebugServices(events);
|
||||||
|
logger.info('Debug services enabled');
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
(async () => {
|
(async () => {
|
||||||
await agentService.initialize();
|
await agentService.initialize();
|
||||||
logger.info('Agent service initialized');
|
logger.info('Agent service initialized');
|
||||||
|
|
||||||
// Bootstrap Codex model cache in background (don't block server startup)
|
|
||||||
void codexModelCacheService.getModels().catch((err) => {
|
|
||||||
logger.error('Failed to bootstrap Codex model cache:', err);
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
|
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
|
||||||
@@ -200,10 +203,9 @@ setInterval(() => {
|
|||||||
// This helps prevent CSRF and content-type confusion attacks
|
// This helps prevent CSRF and content-type confusion attacks
|
||||||
app.use('/api', requireJsonContentType);
|
app.use('/api', requireJsonContentType);
|
||||||
|
|
||||||
// Mount API routes - health, auth, and setup are unauthenticated
|
// Mount API routes - health and auth are unauthenticated
|
||||||
app.use('/api/health', createHealthRoutes());
|
app.use('/api/health', createHealthRoutes());
|
||||||
app.use('/api/auth', createAuthRoutes());
|
app.use('/api/auth', createAuthRoutes());
|
||||||
app.use('/api/setup', createSetupRoutes());
|
|
||||||
|
|
||||||
// Apply authentication to all other routes
|
// Apply authentication to all other routes
|
||||||
app.use('/api', authMiddleware);
|
app.use('/api', authMiddleware);
|
||||||
@@ -217,8 +219,9 @@ app.use('/api/sessions', createSessionsRoutes(agentService));
|
|||||||
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
||||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||||
app.use('/api/worktree', createWorktreeRoutes(events));
|
app.use('/api/worktree', createWorktreeRoutes());
|
||||||
app.use('/api/git', createGitRoutes());
|
app.use('/api/git', createGitRoutes());
|
||||||
|
app.use('/api/setup', createSetupRoutes());
|
||||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||||
app.use('/api/models', createModelsRoutes());
|
app.use('/api/models', createModelsRoutes());
|
||||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||||
@@ -228,7 +231,6 @@ app.use('/api/templates', createTemplatesRoutes());
|
|||||||
app.use('/api/terminal', createTerminalRoutes());
|
app.use('/api/terminal', createTerminalRoutes());
|
||||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||||
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
|
|
||||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||||
app.use('/api/context', createContextRoutes(settingsService));
|
app.use('/api/context', createContextRoutes(settingsService));
|
||||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||||
@@ -236,6 +238,12 @@ app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
|||||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||||
|
|
||||||
|
// Debug routes (dev mode only)
|
||||||
|
if (debugServices) {
|
||||||
|
app.use('/api/debug', createDebugRoutes(debugServices));
|
||||||
|
logger.info('Debug API routes mounted at /api/debug');
|
||||||
|
}
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|
||||||
@@ -597,30 +605,13 @@ const startServer = (port: number) => {
|
|||||||
|
|
||||||
startServer(PORT);
|
startServer(PORT);
|
||||||
|
|
||||||
// Global error handlers to prevent crashes from uncaught errors
|
|
||||||
process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => {
|
|
||||||
logger.error('Unhandled Promise Rejection:', {
|
|
||||||
reason: reason instanceof Error ? reason.message : String(reason),
|
|
||||||
stack: reason instanceof Error ? reason.stack : undefined,
|
|
||||||
});
|
|
||||||
// Don't exit - log the error and continue running
|
|
||||||
// This prevents the server from crashing due to unhandled rejections
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('uncaughtException', (error: Error) => {
|
|
||||||
logger.error('Uncaught Exception:', {
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
});
|
|
||||||
// Exit on uncaught exceptions to prevent undefined behavior
|
|
||||||
// The process is in an unknown state after an uncaught exception
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
logger.info('SIGTERM received, shutting down...');
|
logger.info('SIGTERM received, shutting down...');
|
||||||
terminalService.cleanup();
|
terminalService.cleanup();
|
||||||
|
if (debugServices) {
|
||||||
|
stopDebugServices(debugServices);
|
||||||
|
}
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
logger.info('Server closed');
|
logger.info('Server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -630,6 +621,9 @@ process.on('SIGTERM', () => {
|
|||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
logger.info('SIGINT received, shutting down...');
|
logger.info('SIGINT received, shutting down...');
|
||||||
terminalService.cleanup();
|
terminalService.cleanup();
|
||||||
|
if (debugServices) {
|
||||||
|
stopDebugServices(debugServices);
|
||||||
|
}
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
logger.info('Server closed');
|
logger.info('Server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
/**
|
|
||||||
* Agent Discovery - Scans filesystem for AGENT.md files
|
|
||||||
*
|
|
||||||
* Discovers agents from:
|
|
||||||
* - ~/.claude/agents/ (user-level, global)
|
|
||||||
* - .claude/agents/ (project-level)
|
|
||||||
*
|
|
||||||
* Similar to Skills, but for custom subagents defined in AGENT.md files.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import { secureFs, systemPaths } from '@automaker/platform';
|
|
||||||
import type { AgentDefinition } from '@automaker/types';
|
|
||||||
|
|
||||||
const logger = createLogger('AgentDiscovery');
|
|
||||||
|
|
||||||
export interface FilesystemAgent {
|
|
||||||
name: string; // Directory name (e.g., 'code-reviewer')
|
|
||||||
definition: AgentDefinition;
|
|
||||||
source: 'user' | 'project';
|
|
||||||
filePath: string; // Full path to AGENT.md
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse agent content string into AgentDefinition
|
|
||||||
* Format:
|
|
||||||
* ---
|
|
||||||
* name: agent-name # Optional
|
|
||||||
* description: When to use this agent
|
|
||||||
* tools: tool1, tool2, tool3 # Optional (comma or space separated list)
|
|
||||||
* model: sonnet # Optional: sonnet, opus, haiku
|
|
||||||
* ---
|
|
||||||
* System prompt content here...
|
|
||||||
*/
|
|
||||||
function parseAgentContent(content: string, filePath: string): AgentDefinition | null {
|
|
||||||
// Extract frontmatter
|
|
||||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
||||||
if (!frontmatterMatch) {
|
|
||||||
logger.warn(`Invalid agent file format (missing frontmatter): ${filePath}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, frontmatter, prompt] = frontmatterMatch;
|
|
||||||
|
|
||||||
// Parse description (required)
|
|
||||||
const description = frontmatter.match(/description:\s*(.+)/)?.[1]?.trim();
|
|
||||||
if (!description) {
|
|
||||||
logger.warn(`Missing description in agent file: ${filePath}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse tools (optional) - supports both comma-separated and space-separated
|
|
||||||
const toolsMatch = frontmatter.match(/tools:\s*(.+)/);
|
|
||||||
const tools = toolsMatch
|
|
||||||
? toolsMatch[1]
|
|
||||||
.split(/[,\s]+/) // Split by comma or whitespace
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter((t) => t && t !== '')
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Parse model (optional) - validate against allowed values
|
|
||||||
const modelMatch = frontmatter.match(/model:\s*(\w+)/);
|
|
||||||
const modelValue = modelMatch?.[1]?.trim();
|
|
||||||
const validModels = ['sonnet', 'opus', 'haiku', 'inherit'] as const;
|
|
||||||
const model =
|
|
||||||
modelValue && validModels.includes(modelValue as (typeof validModels)[number])
|
|
||||||
? (modelValue as 'sonnet' | 'opus' | 'haiku' | 'inherit')
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (modelValue && !model) {
|
|
||||||
logger.warn(
|
|
||||||
`Invalid model "${modelValue}" in agent file: ${filePath}. Expected one of: ${validModels.join(', ')}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
description,
|
|
||||||
prompt: prompt.trim(),
|
|
||||||
tools,
|
|
||||||
model,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Directory entry with type information
|
|
||||||
*/
|
|
||||||
interface DirEntry {
|
|
||||||
name: string;
|
|
||||||
isFile: boolean;
|
|
||||||
isDirectory: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filesystem adapter interface for abstracting systemPaths vs secureFs
|
|
||||||
*/
|
|
||||||
interface FsAdapter {
|
|
||||||
exists: (filePath: string) => Promise<boolean>;
|
|
||||||
readdir: (dirPath: string) => Promise<DirEntry[]>;
|
|
||||||
readFile: (filePath: string) => Promise<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a filesystem adapter for system paths (user directory)
|
|
||||||
*/
|
|
||||||
function createSystemPathAdapter(): FsAdapter {
|
|
||||||
return {
|
|
||||||
exists: (filePath) => Promise.resolve(systemPaths.systemPathExists(filePath)),
|
|
||||||
readdir: async (dirPath) => {
|
|
||||||
const entryNames = await systemPaths.systemPathReaddir(dirPath);
|
|
||||||
const entries: DirEntry[] = [];
|
|
||||||
for (const name of entryNames) {
|
|
||||||
const stat = await systemPaths.systemPathStat(path.join(dirPath, name));
|
|
||||||
entries.push({
|
|
||||||
name,
|
|
||||||
isFile: stat.isFile(),
|
|
||||||
isDirectory: stat.isDirectory(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
},
|
|
||||||
readFile: (filePath) => systemPaths.systemPathReadFile(filePath, 'utf-8') as Promise<string>,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a filesystem adapter for project paths (secureFs)
|
|
||||||
*/
|
|
||||||
function createSecureFsAdapter(): FsAdapter {
|
|
||||||
return {
|
|
||||||
exists: (filePath) =>
|
|
||||||
secureFs
|
|
||||||
.access(filePath)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false),
|
|
||||||
readdir: async (dirPath) => {
|
|
||||||
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
|
|
||||||
return entries.map((entry) => ({
|
|
||||||
name: entry.name,
|
|
||||||
isFile: entry.isFile(),
|
|
||||||
isDirectory: entry.isDirectory(),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
readFile: (filePath) => secureFs.readFile(filePath, 'utf-8') as Promise<string>,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse agent file using the provided filesystem adapter
|
|
||||||
*/
|
|
||||||
async function parseAgentFileWithAdapter(
|
|
||||||
filePath: string,
|
|
||||||
fsAdapter: FsAdapter
|
|
||||||
): Promise<AgentDefinition | null> {
|
|
||||||
try {
|
|
||||||
const content = await fsAdapter.readFile(filePath);
|
|
||||||
return parseAgentContent(content, filePath);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to parse agent file: ${filePath}`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan a directory for agent .md files
|
|
||||||
* Agents can be in two formats:
|
|
||||||
* 1. Flat: agent-name.md (file directly in agents/)
|
|
||||||
* 2. Subdirectory: agent-name/AGENT.md (folder + file, similar to Skills)
|
|
||||||
*/
|
|
||||||
async function scanAgentsDirectory(
|
|
||||||
baseDir: string,
|
|
||||||
source: 'user' | 'project'
|
|
||||||
): Promise<FilesystemAgent[]> {
|
|
||||||
const agents: FilesystemAgent[] = [];
|
|
||||||
const fsAdapter = source === 'user' ? createSystemPathAdapter() : createSecureFsAdapter();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if directory exists
|
|
||||||
const exists = await fsAdapter.exists(baseDir);
|
|
||||||
if (!exists) {
|
|
||||||
logger.debug(`Directory does not exist: ${baseDir}`);
|
|
||||||
return agents;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read all entries in the directory
|
|
||||||
const entries = await fsAdapter.readdir(baseDir);
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
// Check for flat .md file format (agent-name.md)
|
|
||||||
if (entry.isFile && entry.name.endsWith('.md')) {
|
|
||||||
const agentName = entry.name.slice(0, -3); // Remove .md extension
|
|
||||||
const agentFilePath = path.join(baseDir, entry.name);
|
|
||||||
const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter);
|
|
||||||
if (definition) {
|
|
||||||
agents.push({
|
|
||||||
name: agentName,
|
|
||||||
definition,
|
|
||||||
source,
|
|
||||||
filePath: agentFilePath,
|
|
||||||
});
|
|
||||||
logger.debug(`Discovered ${source} agent (flat): ${agentName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check for subdirectory format (agent-name/AGENT.md)
|
|
||||||
else if (entry.isDirectory) {
|
|
||||||
const agentFilePath = path.join(baseDir, entry.name, 'AGENT.md');
|
|
||||||
const agentFileExists = await fsAdapter.exists(agentFilePath);
|
|
||||||
|
|
||||||
if (agentFileExists) {
|
|
||||||
const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter);
|
|
||||||
if (definition) {
|
|
||||||
agents.push({
|
|
||||||
name: entry.name,
|
|
||||||
definition,
|
|
||||||
source,
|
|
||||||
filePath: agentFilePath,
|
|
||||||
});
|
|
||||||
logger.debug(`Discovered ${source} agent (subdirectory): ${entry.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to scan agents directory: ${baseDir}`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return agents;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover all filesystem-based agents from user and project sources
|
|
||||||
*/
|
|
||||||
export async function discoverFilesystemAgents(
|
|
||||||
projectPath?: string,
|
|
||||||
sources: Array<'user' | 'project'> = ['user', 'project']
|
|
||||||
): Promise<FilesystemAgent[]> {
|
|
||||||
const agents: FilesystemAgent[] = [];
|
|
||||||
|
|
||||||
// Discover user-level agents from ~/.claude/agents/
|
|
||||||
if (sources.includes('user')) {
|
|
||||||
const userAgentsDir = path.join(os.homedir(), '.claude', 'agents');
|
|
||||||
const userAgents = await scanAgentsDirectory(userAgentsDir, 'user');
|
|
||||||
agents.push(...userAgents);
|
|
||||||
logger.info(`Discovered ${userAgents.length} user-level agents from ${userAgentsDir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discover project-level agents from .claude/agents/
|
|
||||||
if (sources.includes('project') && projectPath) {
|
|
||||||
const projectAgentsDir = path.join(projectPath, '.claude', 'agents');
|
|
||||||
const projectAgents = await scanAgentsDirectory(projectAgentsDir, 'project');
|
|
||||||
agents.push(...projectAgents);
|
|
||||||
logger.info(`Discovered ${projectAgents.length} project-level agents from ${projectAgentsDir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return agents;
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
/**
|
|
||||||
* Secure authentication utilities that avoid environment variable race conditions
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('AuthUtils');
|
|
||||||
|
|
||||||
export interface SecureAuthEnv {
|
|
||||||
[key: string]: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthValidationResult {
|
|
||||||
isValid: boolean;
|
|
||||||
error?: string;
|
|
||||||
normalizedKey?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates API key format without modifying process.env
|
|
||||||
*/
|
|
||||||
export function validateApiKey(
|
|
||||||
key: string,
|
|
||||||
provider: 'anthropic' | 'openai' | 'cursor'
|
|
||||||
): AuthValidationResult {
|
|
||||||
if (!key || typeof key !== 'string' || key.trim().length === 0) {
|
|
||||||
return { isValid: false, error: 'API key is required' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedKey = key.trim();
|
|
||||||
|
|
||||||
switch (provider) {
|
|
||||||
case 'anthropic':
|
|
||||||
if (!trimmedKey.startsWith('sk-ant-')) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: 'Invalid Anthropic API key format. Should start with "sk-ant-"',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (trimmedKey.length < 20) {
|
|
||||||
return { isValid: false, error: 'Anthropic API key too short' };
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'openai':
|
|
||||||
if (!trimmedKey.startsWith('sk-')) {
|
|
||||||
return { isValid: false, error: 'Invalid OpenAI API key format. Should start with "sk-"' };
|
|
||||||
}
|
|
||||||
if (trimmedKey.length < 20) {
|
|
||||||
return { isValid: false, error: 'OpenAI API key too short' };
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'cursor':
|
|
||||||
// Cursor API keys might have different format
|
|
||||||
if (trimmedKey.length < 10) {
|
|
||||||
return { isValid: false, error: 'Cursor API key too short' };
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true, normalizedKey: trimmedKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a secure environment object for authentication testing
|
|
||||||
* without modifying the global process.env
|
|
||||||
*/
|
|
||||||
export function createSecureAuthEnv(
|
|
||||||
authMethod: 'cli' | 'api_key',
|
|
||||||
apiKey?: string,
|
|
||||||
provider: 'anthropic' | 'openai' | 'cursor' = 'anthropic'
|
|
||||||
): SecureAuthEnv {
|
|
||||||
const env: SecureAuthEnv = { ...process.env };
|
|
||||||
|
|
||||||
if (authMethod === 'cli') {
|
|
||||||
// For CLI auth, remove the API key to force CLI authentication
|
|
||||||
const envKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
|
|
||||||
delete env[envKey];
|
|
||||||
} else if (authMethod === 'api_key' && apiKey) {
|
|
||||||
// For API key auth, validate and set the provided key
|
|
||||||
const validation = validateApiKey(apiKey, provider);
|
|
||||||
if (!validation.isValid) {
|
|
||||||
throw new Error(validation.error);
|
|
||||||
}
|
|
||||||
const envKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
|
|
||||||
env[envKey] = validation.normalizedKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a temporary environment override for the current process
|
|
||||||
* WARNING: This should only be used in isolated contexts and immediately cleaned up
|
|
||||||
*/
|
|
||||||
export function createTempEnvOverride(authEnv: SecureAuthEnv): () => void {
|
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
|
|
||||||
// Apply the auth environment
|
|
||||||
Object.assign(process.env, authEnv);
|
|
||||||
|
|
||||||
// Return cleanup function
|
|
||||||
return () => {
|
|
||||||
// Restore original environment
|
|
||||||
Object.keys(process.env).forEach((key) => {
|
|
||||||
if (!(key in originalEnv)) {
|
|
||||||
delete process.env[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Object.assign(process.env, originalEnv);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawns a process with secure environment isolation
|
|
||||||
*/
|
|
||||||
export function spawnSecureAuth(
|
|
||||||
command: string,
|
|
||||||
args: string[],
|
|
||||||
authEnv: SecureAuthEnv,
|
|
||||||
options: {
|
|
||||||
cwd?: string;
|
|
||||||
timeout?: number;
|
|
||||||
} = {}
|
|
||||||
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const { cwd = process.cwd(), timeout = 30000 } = options;
|
|
||||||
|
|
||||||
logger.debug(`Spawning secure auth process: ${command} ${args.join(' ')}`);
|
|
||||||
|
|
||||||
const child = spawn(command, args, {
|
|
||||||
cwd,
|
|
||||||
env: authEnv,
|
|
||||||
stdio: 'pipe',
|
|
||||||
shell: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
let isResolved = false;
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
if (!isResolved) {
|
|
||||||
child.kill('SIGTERM');
|
|
||||||
isResolved = true;
|
|
||||||
reject(new Error('Authentication process timed out'));
|
|
||||||
}
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
child.stdout?.on('data', (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr?.on('data', (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('close', (code) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (!isResolved) {
|
|
||||||
isResolved = true;
|
|
||||||
resolve({ stdout, stderr, exitCode: code });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', (error) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (!isResolved) {
|
|
||||||
isResolved = true;
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely extracts environment variable without race conditions
|
|
||||||
*/
|
|
||||||
export function safeGetEnv(key: string): string | undefined {
|
|
||||||
return process.env[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if an environment variable would be modified without actually modifying it
|
|
||||||
*/
|
|
||||||
export function wouldModifyEnv(key: string, newValue: string): boolean {
|
|
||||||
const currentValue = safeGetEnv(key);
|
|
||||||
return currentValue !== newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Secure auth session management
|
|
||||||
*/
|
|
||||||
export class AuthSessionManager {
|
|
||||||
private static activeSessions = new Map<string, SecureAuthEnv>();
|
|
||||||
|
|
||||||
static createSession(
|
|
||||||
sessionId: string,
|
|
||||||
authMethod: 'cli' | 'api_key',
|
|
||||||
apiKey?: string,
|
|
||||||
provider: 'anthropic' | 'openai' | 'cursor' = 'anthropic'
|
|
||||||
): SecureAuthEnv {
|
|
||||||
const env = createSecureAuthEnv(authMethod, apiKey, provider);
|
|
||||||
this.activeSessions.set(sessionId, env);
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getSession(sessionId: string): SecureAuthEnv | undefined {
|
|
||||||
return this.activeSessions.get(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
static destroySession(sessionId: string): void {
|
|
||||||
this.activeSessions.delete(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
static cleanup(): void {
|
|
||||||
this.activeSessions.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting for auth attempts to prevent abuse
|
|
||||||
*/
|
|
||||||
export class AuthRateLimiter {
|
|
||||||
private attempts = new Map<string, { count: number; lastAttempt: number }>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private maxAttempts = 5,
|
|
||||||
private windowMs = 60000
|
|
||||||
) {}
|
|
||||||
|
|
||||||
canAttempt(identifier: string): boolean {
|
|
||||||
const now = Date.now();
|
|
||||||
const record = this.attempts.get(identifier);
|
|
||||||
|
|
||||||
if (!record || now - record.lastAttempt > this.windowMs) {
|
|
||||||
this.attempts.set(identifier, { count: 1, lastAttempt: now });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (record.count >= this.maxAttempts) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
record.count++;
|
|
||||||
record.lastAttempt = now;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
getRemainingAttempts(identifier: string): number {
|
|
||||||
const record = this.attempts.get(identifier);
|
|
||||||
if (!record) return this.maxAttempts;
|
|
||||||
return Math.max(0, this.maxAttempts - record.count);
|
|
||||||
}
|
|
||||||
|
|
||||||
getResetTime(identifier: string): Date | null {
|
|
||||||
const record = this.attempts.get(identifier);
|
|
||||||
if (!record) return null;
|
|
||||||
return new Date(record.lastAttempt + this.windowMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -262,7 +262,7 @@ export function getSessionCookieOptions(): {
|
|||||||
return {
|
return {
|
||||||
httpOnly: true, // JavaScript cannot access this cookie
|
httpOnly: true, // JavaScript cannot access this cookie
|
||||||
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
||||||
sameSite: 'lax', // Sent for same-site requests and top-level navigations, but not cross-origin fetch/XHR
|
sameSite: 'strict', // Only sent for same-site requests (CSRF protection)
|
||||||
maxAge: SESSION_MAX_AGE_MS,
|
maxAge: SESSION_MAX_AGE_MS,
|
||||||
path: '/',
|
path: '/',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,447 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified CLI Detection Framework
|
|
||||||
*
|
|
||||||
* Provides consistent CLI detection and management across all providers
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawn, execSync } from 'child_process';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as os from 'os';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('CliDetection');
|
|
||||||
|
|
||||||
export interface CliInfo {
|
|
||||||
name: string;
|
|
||||||
command: string;
|
|
||||||
version?: string;
|
|
||||||
path?: string;
|
|
||||||
installed: boolean;
|
|
||||||
authenticated: boolean;
|
|
||||||
authMethod: 'cli' | 'api_key' | 'none';
|
|
||||||
platform?: string;
|
|
||||||
architectures?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CliDetectionOptions {
|
|
||||||
timeout?: number;
|
|
||||||
includeWsl?: boolean;
|
|
||||||
wslDistribution?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CliDetectionResult {
|
|
||||||
cli: CliInfo;
|
|
||||||
detected: boolean;
|
|
||||||
issues: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnifiedCliDetection {
|
|
||||||
claude?: CliDetectionResult;
|
|
||||||
codex?: CliDetectionResult;
|
|
||||||
cursor?: CliDetectionResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CLI Configuration for different providers
|
|
||||||
*/
|
|
||||||
const CLI_CONFIGS = {
|
|
||||||
claude: {
|
|
||||||
name: 'Claude CLI',
|
|
||||||
commands: ['claude'],
|
|
||||||
versionArgs: ['--version'],
|
|
||||||
installCommands: {
|
|
||||||
darwin: 'brew install anthropics/claude/claude',
|
|
||||||
linux: 'curl -fsSL https://claude.ai/install.sh | sh',
|
|
||||||
win32: 'iwr https://claude.ai/install.ps1 -UseBasicParsing | iex',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codex: {
|
|
||||||
name: 'Codex CLI',
|
|
||||||
commands: ['codex', 'openai'],
|
|
||||||
versionArgs: ['--version'],
|
|
||||||
installCommands: {
|
|
||||||
darwin: 'npm install -g @openai/codex-cli',
|
|
||||||
linux: 'npm install -g @openai/codex-cli',
|
|
||||||
win32: 'npm install -g @openai/codex-cli',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cursor: {
|
|
||||||
name: 'Cursor CLI',
|
|
||||||
commands: ['cursor-agent', 'cursor'],
|
|
||||||
versionArgs: ['--version'],
|
|
||||||
installCommands: {
|
|
||||||
darwin: 'brew install cursor/cursor/cursor-agent',
|
|
||||||
linux: 'curl -fsSL https://cursor.sh/install.sh | sh',
|
|
||||||
win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect if a CLI is installed and available
|
|
||||||
*/
|
|
||||||
export async function detectCli(
|
|
||||||
provider: keyof typeof CLI_CONFIGS,
|
|
||||||
options: CliDetectionOptions = {}
|
|
||||||
): Promise<CliDetectionResult> {
|
|
||||||
const config = CLI_CONFIGS[provider];
|
|
||||||
const { timeout = 5000, includeWsl = false, wslDistribution } = options;
|
|
||||||
const issues: string[] = [];
|
|
||||||
|
|
||||||
const cliInfo: CliInfo = {
|
|
||||||
name: config.name,
|
|
||||||
command: '',
|
|
||||||
installed: false,
|
|
||||||
authenticated: false,
|
|
||||||
authMethod: 'none',
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Find the command in PATH
|
|
||||||
const command = await findCommand([...config.commands]);
|
|
||||||
if (command) {
|
|
||||||
cliInfo.command = command;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cliInfo.command) {
|
|
||||||
issues.push(`${config.name} not found in PATH`);
|
|
||||||
return { cli: cliInfo, detected: false, issues };
|
|
||||||
}
|
|
||||||
|
|
||||||
cliInfo.path = cliInfo.command;
|
|
||||||
cliInfo.installed = true;
|
|
||||||
|
|
||||||
// Get version
|
|
||||||
try {
|
|
||||||
cliInfo.version = await getCliVersion(cliInfo.command, [...config.versionArgs], timeout);
|
|
||||||
} catch (error) {
|
|
||||||
issues.push(`Failed to get ${config.name} version: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check authentication
|
|
||||||
cliInfo.authMethod = await checkCliAuth(provider, cliInfo.command);
|
|
||||||
cliInfo.authenticated = cliInfo.authMethod !== 'none';
|
|
||||||
|
|
||||||
return { cli: cliInfo, detected: true, issues };
|
|
||||||
} catch (error) {
|
|
||||||
issues.push(`Error detecting ${config.name}: ${error}`);
|
|
||||||
return { cli: cliInfo, detected: false, issues };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect all CLIs in the system
|
|
||||||
*/
|
|
||||||
export async function detectAllCLis(
|
|
||||||
options: CliDetectionOptions = {}
|
|
||||||
): Promise<UnifiedCliDetection> {
|
|
||||||
const results: UnifiedCliDetection = {};
|
|
||||||
|
|
||||||
// Detect all providers in parallel
|
|
||||||
const providers = Object.keys(CLI_CONFIGS) as Array<keyof typeof CLI_CONFIGS>;
|
|
||||||
const detectionPromises = providers.map(async (provider) => {
|
|
||||||
const result = await detectCli(provider, options);
|
|
||||||
return { provider, result };
|
|
||||||
});
|
|
||||||
|
|
||||||
const detections = await Promise.all(detectionPromises);
|
|
||||||
|
|
||||||
for (const { provider, result } of detections) {
|
|
||||||
results[provider] = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the first available command from a list of alternatives
|
|
||||||
*/
|
|
||||||
export async function findCommand(commands: string[]): Promise<string | null> {
|
|
||||||
for (const command of commands) {
|
|
||||||
try {
|
|
||||||
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
|
|
||||||
const result = execSync(`${whichCommand} ${command}`, {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: 2000,
|
|
||||||
}).trim();
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
return result.split('\n')[0]; // Take first result on Windows
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Command not found, try next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get CLI version
|
|
||||||
*/
|
|
||||||
export async function getCliVersion(
|
|
||||||
command: string,
|
|
||||||
args: string[],
|
|
||||||
timeout: number = 5000
|
|
||||||
): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(command, args, {
|
|
||||||
stdio: 'pipe',
|
|
||||||
timeout,
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
|
|
||||||
child.stdout?.on('data', (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr?.on('data', (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('close', (code) => {
|
|
||||||
if (code === 0 && stdout) {
|
|
||||||
resolve(stdout.trim());
|
|
||||||
} else if (stderr) {
|
|
||||||
reject(stderr.trim());
|
|
||||||
} else {
|
|
||||||
reject(`Command exited with code ${code}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check authentication status for a CLI
|
|
||||||
*/
|
|
||||||
export async function checkCliAuth(
|
|
||||||
provider: keyof typeof CLI_CONFIGS,
|
|
||||||
command: string
|
|
||||||
): Promise<'cli' | 'api_key' | 'none'> {
|
|
||||||
try {
|
|
||||||
switch (provider) {
|
|
||||||
case 'claude':
|
|
||||||
return await checkClaudeAuth(command);
|
|
||||||
case 'codex':
|
|
||||||
return await checkCodexAuth(command);
|
|
||||||
case 'cursor':
|
|
||||||
return await checkCursorAuth(command);
|
|
||||||
default:
|
|
||||||
return 'none';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check Claude CLI authentication
|
|
||||||
*/
|
|
||||||
async function checkClaudeAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
|
|
||||||
try {
|
|
||||||
// Check for environment variable
|
|
||||||
if (process.env.ANTHROPIC_API_KEY) {
|
|
||||||
return 'api_key';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try running a simple command to check CLI auth
|
|
||||||
const result = await getCliVersion(command, ['--version'], 3000);
|
|
||||||
if (result) {
|
|
||||||
return 'cli'; // If version works, assume CLI is authenticated
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Version command might work even without auth, so we need a better check
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try a more specific auth check
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const child = spawn(command, ['whoami'], {
|
|
||||||
stdio: 'pipe',
|
|
||||||
timeout: 3000,
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
|
|
||||||
child.stdout?.on('data', (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr?.on('data', (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('close', (code) => {
|
|
||||||
if (code === 0 && stdout && !stderr.includes('not authenticated')) {
|
|
||||||
resolve('cli');
|
|
||||||
} else {
|
|
||||||
resolve('none');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', () => {
|
|
||||||
resolve('none');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check Codex CLI authentication
|
|
||||||
*/
|
|
||||||
async function checkCodexAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
|
|
||||||
// Check for environment variable
|
|
||||||
if (process.env.OPENAI_API_KEY) {
|
|
||||||
return 'api_key';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try a simple auth check
|
|
||||||
const result = await getCliVersion(command, ['--version'], 3000);
|
|
||||||
if (result) {
|
|
||||||
return 'cli';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Version check failed
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check Cursor CLI authentication
|
|
||||||
*/
|
|
||||||
async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
|
|
||||||
// Check for environment variable
|
|
||||||
if (process.env.CURSOR_API_KEY) {
|
|
||||||
return 'api_key';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for credentials files
|
|
||||||
const credentialPaths = [
|
|
||||||
path.join(os.homedir(), '.cursor', 'credentials.json'),
|
|
||||||
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
|
|
||||||
path.join(os.homedir(), '.cursor', 'auth.json'),
|
|
||||||
path.join(os.homedir(), '.config', 'cursor', 'auth.json'),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const credPath of credentialPaths) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(credPath)) {
|
|
||||||
const content = fs.readFileSync(credPath, 'utf8');
|
|
||||||
const creds = JSON.parse(content);
|
|
||||||
if (creds.accessToken || creds.token || creds.apiKey) {
|
|
||||||
return 'cli';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Invalid credentials file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try a simple command
|
|
||||||
try {
|
|
||||||
const result = await getCliVersion(command, ['--version'], 3000);
|
|
||||||
if (result) {
|
|
||||||
return 'cli';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Version check failed
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get installation instructions for a provider
|
|
||||||
*/
|
|
||||||
export function getInstallInstructions(
|
|
||||||
provider: keyof typeof CLI_CONFIGS,
|
|
||||||
platform: NodeJS.Platform = process.platform
|
|
||||||
): string {
|
|
||||||
const config = CLI_CONFIGS[provider];
|
|
||||||
const command = config.installCommands[platform as keyof typeof config.installCommands];
|
|
||||||
|
|
||||||
if (!command) {
|
|
||||||
return `No installation instructions available for ${provider} on ${platform}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get platform-specific CLI paths and versions
|
|
||||||
*/
|
|
||||||
export function getPlatformCliPaths(provider: keyof typeof CLI_CONFIGS): string[] {
|
|
||||||
const config = CLI_CONFIGS[provider];
|
|
||||||
const platform = process.platform;
|
|
||||||
|
|
||||||
switch (platform) {
|
|
||||||
case 'darwin':
|
|
||||||
return [
|
|
||||||
`/usr/local/bin/${config.commands[0]}`,
|
|
||||||
`/opt/homebrew/bin/${config.commands[0]}`,
|
|
||||||
path.join(os.homedir(), '.local', 'bin', config.commands[0]),
|
|
||||||
];
|
|
||||||
|
|
||||||
case 'linux':
|
|
||||||
return [
|
|
||||||
`/usr/bin/${config.commands[0]}`,
|
|
||||||
`/usr/local/bin/${config.commands[0]}`,
|
|
||||||
path.join(os.homedir(), '.local', 'bin', config.commands[0]),
|
|
||||||
path.join(os.homedir(), '.npm', 'global', 'bin', config.commands[0]),
|
|
||||||
];
|
|
||||||
|
|
||||||
case 'win32':
|
|
||||||
return [
|
|
||||||
path.join(
|
|
||||||
os.homedir(),
|
|
||||||
'AppData',
|
|
||||||
'Local',
|
|
||||||
'Programs',
|
|
||||||
config.commands[0],
|
|
||||||
`${config.commands[0]}.exe`
|
|
||||||
),
|
|
||||||
path.join(process.env.ProgramFiles || '', config.commands[0], `${config.commands[0]}.exe`),
|
|
||||||
path.join(
|
|
||||||
process.env.ProgramFiles || '',
|
|
||||||
config.commands[0],
|
|
||||||
'bin',
|
|
||||||
`${config.commands[0]}.exe`
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate CLI installation
|
|
||||||
*/
|
|
||||||
export function validateCliInstallation(cliInfo: CliInfo): {
|
|
||||||
valid: boolean;
|
|
||||||
issues: string[];
|
|
||||||
} {
|
|
||||||
const issues: string[] = [];
|
|
||||||
|
|
||||||
if (!cliInfo.installed) {
|
|
||||||
issues.push('CLI is not installed');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cliInfo.installed && !cliInfo.version) {
|
|
||||||
issues.push('Could not determine CLI version');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cliInfo.installed && cliInfo.authMethod === 'none') {
|
|
||||||
issues.push('CLI is not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: issues.length === 0,
|
|
||||||
issues,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared utility for checking Codex CLI authentication status
|
|
||||||
*
|
|
||||||
* Uses 'codex login status' command to verify authentication.
|
|
||||||
* Never assumes authenticated - only returns true if CLI confirms.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawnProcess } from '@automaker/platform';
|
|
||||||
import { findCodexCliPath } from '@automaker/platform';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('CodexAuth');
|
|
||||||
|
|
||||||
const CODEX_COMMAND = 'codex';
|
|
||||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
|
||||||
|
|
||||||
export interface CodexAuthCheckResult {
|
|
||||||
authenticated: boolean;
|
|
||||||
method: 'api_key_env' | 'cli_authenticated' | 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check Codex authentication status using 'codex login status' command
|
|
||||||
*
|
|
||||||
* @param cliPath Optional CLI path. If not provided, will attempt to find it.
|
|
||||||
* @returns Authentication status and method
|
|
||||||
*/
|
|
||||||
export async function checkCodexAuthentication(
|
|
||||||
cliPath?: string | null
|
|
||||||
): Promise<CodexAuthCheckResult> {
|
|
||||||
const resolvedCliPath = cliPath || (await findCodexCliPath());
|
|
||||||
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
|
||||||
|
|
||||||
// If CLI is not installed, cannot be authenticated
|
|
||||||
if (!resolvedCliPath) {
|
|
||||||
logger.info('CLI not found');
|
|
||||||
return { authenticated: false, method: 'none' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await spawnProcess({
|
|
||||||
command: resolvedCliPath || CODEX_COMMAND,
|
|
||||||
args: ['login', 'status'],
|
|
||||||
cwd: process.cwd(),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TERM: 'dumb', // Avoid interactive output
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check both stdout and stderr for "logged in" - Codex CLI outputs to stderr
|
|
||||||
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
|
||||||
const isLoggedIn = combinedOutput.includes('logged in');
|
|
||||||
|
|
||||||
if (result.exitCode === 0 && isLoggedIn) {
|
|
||||||
// Determine auth method based on what we know
|
|
||||||
const method = hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
|
||||||
logger.info(`✓ Authenticated (${method})`);
|
|
||||||
return { authenticated: true, method };
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Not authenticated');
|
|
||||||
return { authenticated: false, method: 'none' };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to check authentication:', error);
|
|
||||||
return { authenticated: false, method: 'none' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Error Handling System for CLI Providers
|
|
||||||
*
|
|
||||||
* Provides consistent error classification, user-friendly messages, and debugging support
|
|
||||||
* across all AI providers (Claude, Codex, Cursor)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('ErrorHandler');
|
|
||||||
|
|
||||||
export enum ErrorType {
|
|
||||||
AUTHENTICATION = 'authentication',
|
|
||||||
BILLING = 'billing',
|
|
||||||
RATE_LIMIT = 'rate_limit',
|
|
||||||
NETWORK = 'network',
|
|
||||||
TIMEOUT = 'timeout',
|
|
||||||
VALIDATION = 'validation',
|
|
||||||
PERMISSION = 'permission',
|
|
||||||
CLI_NOT_FOUND = 'cli_not_found',
|
|
||||||
CLI_NOT_INSTALLED = 'cli_not_installed',
|
|
||||||
MODEL_NOT_SUPPORTED = 'model_not_supported',
|
|
||||||
INVALID_REQUEST = 'invalid_request',
|
|
||||||
SERVER_ERROR = 'server_error',
|
|
||||||
UNKNOWN = 'unknown',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ErrorSeverity {
|
|
||||||
LOW = 'low',
|
|
||||||
MEDIUM = 'medium',
|
|
||||||
HIGH = 'high',
|
|
||||||
CRITICAL = 'critical',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorClassification {
|
|
||||||
type: ErrorType;
|
|
||||||
severity: ErrorSeverity;
|
|
||||||
userMessage: string;
|
|
||||||
technicalMessage: string;
|
|
||||||
suggestedAction?: string;
|
|
||||||
retryable: boolean;
|
|
||||||
provider?: string;
|
|
||||||
context?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorPattern {
|
|
||||||
type: ErrorType;
|
|
||||||
severity: ErrorSeverity;
|
|
||||||
patterns: RegExp[];
|
|
||||||
userMessage: string;
|
|
||||||
suggestedAction?: string;
|
|
||||||
retryable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error patterns for different types of errors
|
|
||||||
*/
|
|
||||||
const ERROR_PATTERNS: ErrorPattern[] = [
|
|
||||||
// Authentication errors
|
|
||||||
{
|
|
||||||
type: ErrorType.AUTHENTICATION,
|
|
||||||
severity: ErrorSeverity.HIGH,
|
|
||||||
patterns: [
|
|
||||||
/unauthorized/i,
|
|
||||||
/authentication.*fail/i,
|
|
||||||
/invalid_api_key/i,
|
|
||||||
/invalid api key/i,
|
|
||||||
/not authenticated/i,
|
|
||||||
/please.*log/i,
|
|
||||||
/token.*revoked/i,
|
|
||||||
/oauth.*error/i,
|
|
||||||
/credentials.*invalid/i,
|
|
||||||
],
|
|
||||||
userMessage: 'Authentication failed. Please check your API key or login credentials.',
|
|
||||||
suggestedAction:
|
|
||||||
"Verify your API key is correct and hasn't expired, or run the CLI login command.",
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Billing errors
|
|
||||||
{
|
|
||||||
type: ErrorType.BILLING,
|
|
||||||
severity: ErrorSeverity.HIGH,
|
|
||||||
patterns: [
|
|
||||||
/credit.*balance.*low/i,
|
|
||||||
/insufficient.*credit/i,
|
|
||||||
/billing.*issue/i,
|
|
||||||
/payment.*required/i,
|
|
||||||
/usage.*exceeded/i,
|
|
||||||
/quota.*exceeded/i,
|
|
||||||
/add.*credit/i,
|
|
||||||
],
|
|
||||||
userMessage: 'Account has insufficient credits or billing issues.',
|
|
||||||
suggestedAction: 'Please add credits to your account or check your billing settings.',
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Rate limit errors
|
|
||||||
{
|
|
||||||
type: ErrorType.RATE_LIMIT,
|
|
||||||
severity: ErrorSeverity.MEDIUM,
|
|
||||||
patterns: [
|
|
||||||
/rate.*limit/i,
|
|
||||||
/too.*many.*request/i,
|
|
||||||
/limit.*reached/i,
|
|
||||||
/try.*later/i,
|
|
||||||
/429/i,
|
|
||||||
/reset.*time/i,
|
|
||||||
/upgrade.*plan/i,
|
|
||||||
],
|
|
||||||
userMessage: 'Rate limit reached. Please wait before trying again.',
|
|
||||||
suggestedAction: 'Wait a few minutes before retrying, or consider upgrading your plan.',
|
|
||||||
retryable: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Network errors
|
|
||||||
{
|
|
||||||
type: ErrorType.NETWORK,
|
|
||||||
severity: ErrorSeverity.MEDIUM,
|
|
||||||
patterns: [/network/i, /connection/i, /dns/i, /timeout/i, /econnrefused/i, /enotfound/i],
|
|
||||||
userMessage: 'Network connection issue.',
|
|
||||||
suggestedAction: 'Check your internet connection and try again.',
|
|
||||||
retryable: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Timeout errors
|
|
||||||
{
|
|
||||||
type: ErrorType.TIMEOUT,
|
|
||||||
severity: ErrorSeverity.MEDIUM,
|
|
||||||
patterns: [/timeout/i, /aborted/i, /time.*out/i],
|
|
||||||
userMessage: 'Operation timed out.',
|
|
||||||
suggestedAction: 'Try again with a simpler request or check your connection.',
|
|
||||||
retryable: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Permission errors
|
|
||||||
{
|
|
||||||
type: ErrorType.PERMISSION,
|
|
||||||
severity: ErrorSeverity.HIGH,
|
|
||||||
patterns: [/permission.*denied/i, /access.*denied/i, /forbidden/i, /403/i, /not.*authorized/i],
|
|
||||||
userMessage: 'Permission denied.',
|
|
||||||
suggestedAction: 'Check if you have the required permissions for this operation.',
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// CLI not found
|
|
||||||
{
|
|
||||||
type: ErrorType.CLI_NOT_FOUND,
|
|
||||||
severity: ErrorSeverity.HIGH,
|
|
||||||
patterns: [/command not found/i, /not recognized/i, /not.*installed/i, /ENOENT/i],
|
|
||||||
userMessage: 'CLI tool not found.',
|
|
||||||
suggestedAction: "Please install the required CLI tool and ensure it's in your PATH.",
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Model not supported
|
|
||||||
{
|
|
||||||
type: ErrorType.MODEL_NOT_SUPPORTED,
|
|
||||||
severity: ErrorSeverity.HIGH,
|
|
||||||
patterns: [/model.*not.*support/i, /unknown.*model/i, /invalid.*model/i],
|
|
||||||
userMessage: 'Model not supported.',
|
|
||||||
suggestedAction: 'Check available models and use a supported one.',
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Server errors
|
|
||||||
{
|
|
||||||
type: ErrorType.SERVER_ERROR,
|
|
||||||
severity: ErrorSeverity.HIGH,
|
|
||||||
patterns: [/internal.*server/i, /server.*error/i, /500/i, /502/i, /503/i, /504/i],
|
|
||||||
userMessage: 'Server error occurred.',
|
|
||||||
suggestedAction: 'Try again in a few minutes or contact support if the issue persists.',
|
|
||||||
retryable: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify an error into a specific type with user-friendly message
|
|
||||||
*/
|
|
||||||
export function classifyError(
|
|
||||||
error: unknown,
|
|
||||||
provider?: string,
|
|
||||||
context?: Record<string, any>
|
|
||||||
): ErrorClassification {
|
|
||||||
const errorText = getErrorText(error);
|
|
||||||
|
|
||||||
// Try to match against known patterns
|
|
||||||
for (const pattern of ERROR_PATTERNS) {
|
|
||||||
for (const regex of pattern.patterns) {
|
|
||||||
if (regex.test(errorText)) {
|
|
||||||
return {
|
|
||||||
type: pattern.type,
|
|
||||||
severity: pattern.severity,
|
|
||||||
userMessage: pattern.userMessage,
|
|
||||||
technicalMessage: errorText,
|
|
||||||
suggestedAction: pattern.suggestedAction,
|
|
||||||
retryable: pattern.retryable,
|
|
||||||
provider,
|
|
||||||
context,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown error
|
|
||||||
return {
|
|
||||||
type: ErrorType.UNKNOWN,
|
|
||||||
severity: ErrorSeverity.MEDIUM,
|
|
||||||
userMessage: 'An unexpected error occurred.',
|
|
||||||
technicalMessage: errorText,
|
|
||||||
suggestedAction: 'Please try again or contact support if the issue persists.',
|
|
||||||
retryable: true,
|
|
||||||
provider,
|
|
||||||
context,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a user-friendly error message
|
|
||||||
*/
|
|
||||||
export function getUserFriendlyErrorMessage(error: unknown, provider?: string): string {
|
|
||||||
const classification = classifyError(error, provider);
|
|
||||||
|
|
||||||
let message = classification.userMessage;
|
|
||||||
|
|
||||||
if (classification.suggestedAction) {
|
|
||||||
message += ` ${classification.suggestedAction}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add provider-specific context if available
|
|
||||||
if (provider) {
|
|
||||||
message = `[${provider.toUpperCase()}] ${message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an error is retryable
|
|
||||||
*/
|
|
||||||
export function isRetryableError(error: unknown): boolean {
|
|
||||||
const classification = classifyError(error);
|
|
||||||
return classification.retryable;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an error is authentication-related
|
|
||||||
*/
|
|
||||||
export function isAuthenticationError(error: unknown): boolean {
|
|
||||||
const classification = classifyError(error);
|
|
||||||
return classification.type === ErrorType.AUTHENTICATION;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an error is billing-related
|
|
||||||
*/
|
|
||||||
export function isBillingError(error: unknown): boolean {
|
|
||||||
const classification = classifyError(error);
|
|
||||||
return classification.type === ErrorType.BILLING;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an error is rate limit related
|
|
||||||
*/
|
|
||||||
export function isRateLimitError(error: unknown): boolean {
|
|
||||||
const classification = classifyError(error);
|
|
||||||
return classification.type === ErrorType.RATE_LIMIT;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get error text from various error types
|
|
||||||
*/
|
|
||||||
function getErrorText(error: unknown): string {
|
|
||||||
if (typeof error === 'string') {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof error === 'object' && error !== null) {
|
|
||||||
// Handle structured error objects
|
|
||||||
const errorObj = error as any;
|
|
||||||
|
|
||||||
if (errorObj.message) {
|
|
||||||
return errorObj.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorObj.error?.message) {
|
|
||||||
return errorObj.error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorObj.error) {
|
|
||||||
return typeof errorObj.error === 'string' ? errorObj.error : JSON.stringify(errorObj.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a standardized error response
|
|
||||||
*/
|
|
||||||
export function createErrorResponse(
|
|
||||||
error: unknown,
|
|
||||||
provider?: string,
|
|
||||||
context?: Record<string, any>
|
|
||||||
): {
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
errorType: ErrorType;
|
|
||||||
severity: ErrorSeverity;
|
|
||||||
retryable: boolean;
|
|
||||||
suggestedAction?: string;
|
|
||||||
} {
|
|
||||||
const classification = classifyError(error, provider, context);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: classification.userMessage,
|
|
||||||
errorType: classification.type,
|
|
||||||
severity: classification.severity,
|
|
||||||
retryable: classification.retryable,
|
|
||||||
suggestedAction: classification.suggestedAction,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log error with full context
|
|
||||||
*/
|
|
||||||
export function logError(
|
|
||||||
error: unknown,
|
|
||||||
provider?: string,
|
|
||||||
operation?: string,
|
|
||||||
additionalContext?: Record<string, any>
|
|
||||||
): void {
|
|
||||||
const classification = classifyError(error, provider, {
|
|
||||||
operation,
|
|
||||||
...additionalContext,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.error(`Error in ${provider || 'unknown'}${operation ? ` during ${operation}` : ''}`, {
|
|
||||||
type: classification.type,
|
|
||||||
severity: classification.severity,
|
|
||||||
message: classification.userMessage,
|
|
||||||
technicalMessage: classification.technicalMessage,
|
|
||||||
retryable: classification.retryable,
|
|
||||||
suggestedAction: classification.suggestedAction,
|
|
||||||
context: classification.context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider-specific error handlers
|
|
||||||
*/
|
|
||||||
export const ProviderErrorHandler = {
|
|
||||||
claude: {
|
|
||||||
classify: (error: unknown) => classifyError(error, 'claude'),
|
|
||||||
getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'claude'),
|
|
||||||
isAuth: (error: unknown) => isAuthenticationError(error),
|
|
||||||
isBilling: (error: unknown) => isBillingError(error),
|
|
||||||
isRateLimit: (error: unknown) => isRateLimitError(error),
|
|
||||||
},
|
|
||||||
|
|
||||||
codex: {
|
|
||||||
classify: (error: unknown) => classifyError(error, 'codex'),
|
|
||||||
getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'codex'),
|
|
||||||
isAuth: (error: unknown) => isAuthenticationError(error),
|
|
||||||
isBilling: (error: unknown) => isBillingError(error),
|
|
||||||
isRateLimit: (error: unknown) => isRateLimitError(error),
|
|
||||||
},
|
|
||||||
|
|
||||||
cursor: {
|
|
||||||
classify: (error: unknown) => classifyError(error, 'cursor'),
|
|
||||||
getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'cursor'),
|
|
||||||
isAuth: (error: unknown) => isAuthenticationError(error),
|
|
||||||
isBilling: (error: unknown) => isBillingError(error),
|
|
||||||
isRateLimit: (error: unknown) => isRateLimitError(error),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a retry handler for retryable errors
|
|
||||||
*/
|
|
||||||
export function createRetryHandler(maxRetries: number = 3, baseDelay: number = 1000) {
|
|
||||||
return async function <T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
shouldRetry: (error: unknown) => boolean = isRetryableError
|
|
||||||
): Promise<T> {
|
|
||||||
let lastError: unknown;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
|
|
||||||
if (attempt === maxRetries || !shouldRetry(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exponential backoff with jitter
|
|
||||||
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
|
|
||||||
logger.debug(`Retrying operation in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
/**
|
|
||||||
* Permission enforcement utilities for Cursor provider
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CursorCliConfigFile } from '@automaker/types';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('PermissionEnforcer');
|
|
||||||
|
|
||||||
export interface PermissionCheckResult {
|
|
||||||
allowed: boolean;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a tool call is allowed based on permissions
|
|
||||||
*/
|
|
||||||
export function checkToolCallPermission(
|
|
||||||
toolCall: any,
|
|
||||||
permissions: CursorCliConfigFile | null
|
|
||||||
): PermissionCheckResult {
|
|
||||||
if (!permissions || !permissions.permissions) {
|
|
||||||
// If no permissions are configured, allow everything (backward compatibility)
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { allow = [], deny = [] } = permissions.permissions;
|
|
||||||
|
|
||||||
// Check shell tool calls
|
|
||||||
if (toolCall.shellToolCall?.args?.command) {
|
|
||||||
const command = toolCall.shellToolCall.args.command;
|
|
||||||
const toolName = `Shell(${extractCommandName(command)})`;
|
|
||||||
|
|
||||||
// Check deny list first (deny takes precedence)
|
|
||||||
for (const denyRule of deny) {
|
|
||||||
if (matchesRule(toolName, denyRule)) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `Operation blocked by permission rule: ${denyRule}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check allow list
|
|
||||||
for (const allowRule of allow) {
|
|
||||||
if (matchesRule(toolName, allowRule)) {
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `Operation not in allow list: ${toolName}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check read tool calls
|
|
||||||
if (toolCall.readToolCall?.args?.path) {
|
|
||||||
const path = toolCall.readToolCall.args.path;
|
|
||||||
const toolName = `Read(${path})`;
|
|
||||||
|
|
||||||
// Check deny list first
|
|
||||||
for (const denyRule of deny) {
|
|
||||||
if (matchesRule(toolName, denyRule)) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `Read operation blocked by permission rule: ${denyRule}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check allow list
|
|
||||||
for (const allowRule of allow) {
|
|
||||||
if (matchesRule(toolName, allowRule)) {
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `Read operation not in allow list: ${toolName}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check write tool calls
|
|
||||||
if (toolCall.writeToolCall?.args?.path) {
|
|
||||||
const path = toolCall.writeToolCall.args.path;
|
|
||||||
const toolName = `Write(${path})`;
|
|
||||||
|
|
||||||
// Check deny list first
|
|
||||||
for (const denyRule of deny) {
|
|
||||||
if (matchesRule(toolName, denyRule)) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `Write operation blocked by permission rule: ${denyRule}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check allow list
|
|
||||||
for (const allowRule of allow) {
|
|
||||||
if (matchesRule(toolName, allowRule)) {
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `Write operation not in allow list: ${toolName}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other tool types, allow by default for now
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the base command name from a shell command
|
|
||||||
*/
|
|
||||||
function extractCommandName(command: string): string {
|
|
||||||
// Remove leading spaces and get the first word
|
|
||||||
const trimmed = command.trim();
|
|
||||||
const firstWord = trimmed.split(/\s+/)[0];
|
|
||||||
return firstWord || 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a tool name matches a permission rule
|
|
||||||
*/
|
|
||||||
function matchesRule(toolName: string, rule: string): boolean {
|
|
||||||
// Exact match
|
|
||||||
if (toolName === rule) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wildcard patterns
|
|
||||||
if (rule.includes('*')) {
|
|
||||||
const regex = new RegExp(rule.replace(/\*/g, '.*'));
|
|
||||||
return regex.test(toolName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefix match for shell commands (e.g., "Shell(git)" matches "Shell(git status)")
|
|
||||||
if (rule.startsWith('Shell(') && toolName.startsWith('Shell(')) {
|
|
||||||
const ruleCommand = rule.slice(6, -1); // Remove "Shell(" and ")"
|
|
||||||
const toolCommand = extractCommandName(toolName.slice(6, -1)); // Remove "Shell(" and ")"
|
|
||||||
return toolCommand.startsWith(ruleCommand);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log permission violations
|
|
||||||
*/
|
|
||||||
export function logPermissionViolation(toolCall: any, reason: string, sessionId?: string): void {
|
|
||||||
const sessionIdStr = sessionId ? ` [${sessionId}]` : '';
|
|
||||||
|
|
||||||
if (toolCall.shellToolCall?.args?.command) {
|
|
||||||
logger.warn(
|
|
||||||
`Permission violation${sessionIdStr}: Shell command blocked - ${toolCall.shellToolCall.args.command} (${reason})`
|
|
||||||
);
|
|
||||||
} else if (toolCall.readToolCall?.args?.path) {
|
|
||||||
logger.warn(
|
|
||||||
`Permission violation${sessionIdStr}: Read operation blocked - ${toolCall.readToolCall.args.path} (${reason})`
|
|
||||||
);
|
|
||||||
} else if (toolCall.writeToolCall?.args?.path) {
|
|
||||||
logger.warn(
|
|
||||||
`Permission violation${sessionIdStr}: Write operation blocked - ${toolCall.writeToolCall.args.path} (${reason})`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.warn(`Permission violation${sessionIdStr}: Tool call blocked (${reason})`, { toolCall });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
||||||
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
@@ -30,68 +31,6 @@ import {
|
|||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of sandbox compatibility check
|
|
||||||
*/
|
|
||||||
export interface SandboxCompatibilityResult {
|
|
||||||
/** Whether sandbox mode can be enabled for this path */
|
|
||||||
enabled: boolean;
|
|
||||||
/** Optional message explaining why sandbox is disabled */
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a working directory is compatible with sandbox mode.
|
|
||||||
* Some paths (like cloud storage mounts) may not work with sandboxed execution.
|
|
||||||
*
|
|
||||||
* @param cwd - The working directory to check
|
|
||||||
* @param sandboxRequested - Whether sandbox mode was requested by settings
|
|
||||||
* @returns Object indicating if sandbox can be enabled and why not if disabled
|
|
||||||
*/
|
|
||||||
export function checkSandboxCompatibility(
|
|
||||||
cwd: string,
|
|
||||||
sandboxRequested: boolean
|
|
||||||
): SandboxCompatibilityResult {
|
|
||||||
if (!sandboxRequested) {
|
|
||||||
return { enabled: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedCwd = path.resolve(cwd);
|
|
||||||
|
|
||||||
// Check for cloud storage paths that may not be compatible with sandbox
|
|
||||||
const cloudStoragePatterns = [
|
|
||||||
// macOS mounted volumes
|
|
||||||
/^\/Volumes\/GoogleDrive/i,
|
|
||||||
/^\/Volumes\/Dropbox/i,
|
|
||||||
/^\/Volumes\/OneDrive/i,
|
|
||||||
/^\/Volumes\/iCloud/i,
|
|
||||||
// macOS home directory
|
|
||||||
/^\/Users\/[^/]+\/Google Drive/i,
|
|
||||||
/^\/Users\/[^/]+\/Dropbox/i,
|
|
||||||
/^\/Users\/[^/]+\/OneDrive/i,
|
|
||||||
/^\/Users\/[^/]+\/Library\/Mobile Documents/i, // iCloud
|
|
||||||
// Linux home directory
|
|
||||||
/^\/home\/[^/]+\/Google Drive/i,
|
|
||||||
/^\/home\/[^/]+\/Dropbox/i,
|
|
||||||
/^\/home\/[^/]+\/OneDrive/i,
|
|
||||||
// Windows
|
|
||||||
/^C:\\Users\\[^\\]+\\Google Drive/i,
|
|
||||||
/^C:\\Users\\[^\\]+\\Dropbox/i,
|
|
||||||
/^C:\\Users\\[^\\]+\\OneDrive/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of cloudStoragePatterns) {
|
|
||||||
if (pattern.test(resolvedCwd)) {
|
|
||||||
return {
|
|
||||||
enabled: false,
|
|
||||||
message: `Sandbox disabled: Cloud storage path detected (${resolvedCwd}). Sandbox mode may not work correctly with cloud-synced directories.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { enabled: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY.
|
* Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY.
|
||||||
* This is the centralized security check for ALL AI model invocations.
|
* This is the centralized security check for ALL AI model invocations.
|
||||||
@@ -118,6 +57,139 @@ export function validateWorkingDirectory(cwd: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known cloud storage path patterns where sandbox mode is incompatible.
|
||||||
|
*
|
||||||
|
* The Claude CLI sandbox feature uses filesystem isolation that conflicts with
|
||||||
|
* cloud storage providers' virtual filesystem implementations. This causes the
|
||||||
|
* Claude process to exit with code 1 when sandbox is enabled for these paths.
|
||||||
|
*
|
||||||
|
* Affected providers (macOS paths):
|
||||||
|
* - Dropbox: ~/Library/CloudStorage/Dropbox-*
|
||||||
|
* - Google Drive: ~/Library/CloudStorage/GoogleDrive-*
|
||||||
|
* - OneDrive: ~/Library/CloudStorage/OneDrive-*
|
||||||
|
* - iCloud Drive: ~/Library/Mobile Documents/
|
||||||
|
* - Box: ~/Library/CloudStorage/Box-*
|
||||||
|
*
|
||||||
|
* Note: This is a known limitation when using cloud storage paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* macOS-specific cloud storage patterns that appear under ~/Library/
|
||||||
|
* These are specific enough to use with includes() safely.
|
||||||
|
*/
|
||||||
|
const MACOS_CLOUD_STORAGE_PATTERNS = [
|
||||||
|
'/Library/CloudStorage/', // Dropbox, Google Drive, OneDrive, Box on macOS
|
||||||
|
'/Library/Mobile Documents/', // iCloud Drive on macOS
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic cloud storage folder names that need to be anchored to the home directory
|
||||||
|
* to avoid false positives (e.g., /home/user/my-project-about-dropbox/).
|
||||||
|
*/
|
||||||
|
const HOME_ANCHORED_CLOUD_FOLDERS = [
|
||||||
|
'Google Drive', // Google Drive on some systems
|
||||||
|
'Dropbox', // Dropbox on Linux/alternative installs
|
||||||
|
'OneDrive', // OneDrive on Linux/alternative installs
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a path is within a cloud storage location.
|
||||||
|
*
|
||||||
|
* Cloud storage providers use virtual filesystem implementations that are
|
||||||
|
* incompatible with the Claude CLI sandbox feature, causing process crashes.
|
||||||
|
*
|
||||||
|
* Uses two detection strategies:
|
||||||
|
* 1. macOS-specific patterns (under ~/Library/) - checked via includes()
|
||||||
|
* 2. Generic folder names - anchored to home directory to avoid false positives
|
||||||
|
*
|
||||||
|
* @param cwd - The working directory path to check
|
||||||
|
* @returns true if the path is in a cloud storage location
|
||||||
|
*/
|
||||||
|
export function isCloudStoragePath(cwd: string): boolean {
|
||||||
|
const resolvedPath = path.resolve(cwd);
|
||||||
|
// Normalize to forward slashes for consistent pattern matching across platforms
|
||||||
|
let normalizedPath = resolvedPath.split(path.sep).join('/');
|
||||||
|
// Remove Windows drive letter if present (e.g., "C:/Users" -> "/Users")
|
||||||
|
// This ensures Unix paths in tests work the same on Windows
|
||||||
|
normalizedPath = normalizedPath.replace(/^[A-Za-z]:/, '');
|
||||||
|
|
||||||
|
// Check macOS-specific patterns (these are specific enough to use includes)
|
||||||
|
if (MACOS_CLOUD_STORAGE_PATTERNS.some((pattern) => normalizedPath.includes(pattern))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check home-anchored patterns to avoid false positives
|
||||||
|
// e.g., /home/user/my-project-about-dropbox/ should NOT match
|
||||||
|
const home = os.homedir();
|
||||||
|
for (const folder of HOME_ANCHORED_CLOUD_FOLDERS) {
|
||||||
|
const cloudPath = path.join(home, folder);
|
||||||
|
let normalizedCloudPath = cloudPath.split(path.sep).join('/');
|
||||||
|
// Remove Windows drive letter if present
|
||||||
|
normalizedCloudPath = normalizedCloudPath.replace(/^[A-Za-z]:/, '');
|
||||||
|
// Check if resolved path starts with the cloud storage path followed by a separator
|
||||||
|
// This ensures we match ~/Dropbox/project but not ~/Dropbox-archive or ~/my-dropbox-tool
|
||||||
|
if (
|
||||||
|
normalizedPath === normalizedCloudPath ||
|
||||||
|
normalizedPath.startsWith(normalizedCloudPath + '/')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of sandbox compatibility check
|
||||||
|
*/
|
||||||
|
export interface SandboxCheckResult {
|
||||||
|
/** Whether sandbox should be enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
/** If disabled, the reason why */
|
||||||
|
disabledReason?: 'cloud_storage' | 'user_setting';
|
||||||
|
/** Human-readable message for logging/UI */
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if sandbox mode should be enabled for a given configuration.
|
||||||
|
*
|
||||||
|
* Sandbox mode is automatically disabled for cloud storage paths because the
|
||||||
|
* Claude CLI sandbox feature is incompatible with virtual filesystem
|
||||||
|
* implementations used by cloud storage providers (Dropbox, Google Drive, etc.).
|
||||||
|
*
|
||||||
|
* @param cwd - The working directory
|
||||||
|
* @param enableSandboxMode - User's sandbox mode setting
|
||||||
|
* @returns SandboxCheckResult with enabled status and reason if disabled
|
||||||
|
*/
|
||||||
|
export function checkSandboxCompatibility(
|
||||||
|
cwd: string,
|
||||||
|
enableSandboxMode?: boolean
|
||||||
|
): SandboxCheckResult {
|
||||||
|
// User has explicitly disabled sandbox mode
|
||||||
|
if (enableSandboxMode === false) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
disabledReason: 'user_setting',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for cloud storage incompatibility (applies when enabled or undefined)
|
||||||
|
if (isCloudStoragePath(cwd)) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
disabledReason: 'cloud_storage',
|
||||||
|
message: `Sandbox mode auto-disabled: Project is in a cloud storage location (${cwd}). The Claude CLI sandbox feature is incompatible with cloud storage filesystems. To use sandbox mode, move your project to a local directory.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sandbox is compatible and enabled (true or undefined defaults to enabled)
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool presets for different use cases
|
* Tool presets for different use cases
|
||||||
*/
|
*/
|
||||||
@@ -200,31 +272,55 @@ export function getModelForUseCase(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Base options that apply to all SDK calls
|
* Base options that apply to all SDK calls
|
||||||
* AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
|
||||||
*/
|
*/
|
||||||
function getBaseOptions(): Partial<Options> {
|
function getBaseOptions(): Partial<Options> {
|
||||||
return {
|
return {
|
||||||
permissionMode: 'bypassPermissions',
|
permissionMode: 'acceptEdits',
|
||||||
allowDangerouslySkipPermissions: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP options result
|
* MCP permission options result
|
||||||
*/
|
*/
|
||||||
interface McpOptions {
|
interface McpPermissionOptions {
|
||||||
|
/** Whether tools should be restricted to a preset */
|
||||||
|
shouldRestrictTools: boolean;
|
||||||
|
/** Options to spread when MCP bypass is enabled */
|
||||||
|
bypassOptions: Partial<Options>;
|
||||||
/** Options to spread for MCP servers */
|
/** Options to spread for MCP servers */
|
||||||
mcpServerOptions: Partial<Options>;
|
mcpServerOptions: Partial<Options>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build MCP-related options based on configuration.
|
* Build MCP-related options based on configuration.
|
||||||
|
* Centralizes the logic for determining permission modes and tool restrictions
|
||||||
|
* when MCP servers are configured.
|
||||||
*
|
*
|
||||||
* @param config - The SDK options config
|
* @param config - The SDK options config
|
||||||
* @returns Object with MCP server settings to spread into final options
|
* @returns Object with MCP permission settings to spread into final options
|
||||||
*/
|
*/
|
||||||
function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
|
function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
|
||||||
|
const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0;
|
||||||
|
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||||
|
// via the security warning dialog that explains the risks.
|
||||||
|
const mcpAutoApprove = config.mcpAutoApproveTools ?? true;
|
||||||
|
const mcpUnrestricted = config.mcpUnrestrictedTools ?? true;
|
||||||
|
|
||||||
|
// Determine if we should bypass permissions based on settings
|
||||||
|
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
|
||||||
|
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
|
||||||
|
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
shouldRestrictTools,
|
||||||
|
// Only include bypass options when MCP is configured and auto-approve is enabled
|
||||||
|
bypassOptions: shouldBypassPermissions
|
||||||
|
? {
|
||||||
|
permissionMode: 'bypassPermissions' as const,
|
||||||
|
// Required flag when using bypassPermissions mode
|
||||||
|
allowDangerouslySkipPermissions: true,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
// Include MCP servers if configured
|
// Include MCP servers if configured
|
||||||
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
|
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
|
||||||
};
|
};
|
||||||
@@ -326,9 +422,18 @@ export interface CreateSdkOptionsConfig {
|
|||||||
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
|
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
|
||||||
autoLoadClaudeMd?: boolean;
|
autoLoadClaudeMd?: boolean;
|
||||||
|
|
||||||
|
/** Enable sandbox mode for bash command isolation */
|
||||||
|
enableSandboxMode?: boolean;
|
||||||
|
|
||||||
/** MCP servers to make available to the agent */
|
/** MCP servers to make available to the agent */
|
||||||
mcpServers?: Record<string, McpServerConfig>;
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
|
|
||||||
|
/** Auto-approve MCP tool calls without permission prompts */
|
||||||
|
mcpAutoApproveTools?: boolean;
|
||||||
|
|
||||||
|
/** Allow unrestricted tools when MCP servers are enabled */
|
||||||
|
mcpUnrestrictedTools?: boolean;
|
||||||
|
|
||||||
/** Extended thinking level for Claude models */
|
/** Extended thinking level for Claude models */
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
}
|
}
|
||||||
@@ -449,6 +554,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
|
|||||||
* - Full tool access for code modification
|
* - Full tool access for code modification
|
||||||
* - Standard turns for interactive sessions
|
* - Standard turns for interactive sessions
|
||||||
* - Model priority: explicit model > session model > chat default
|
* - Model priority: explicit model > session model > chat default
|
||||||
|
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
|
||||||
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||||
*/
|
*/
|
||||||
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||||
@@ -467,12 +573,24 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
// Build thinking options
|
// Build thinking options
|
||||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||||
|
|
||||||
|
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
||||||
|
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('chat', effectiveModel),
|
model: getModelForUseCase('chat', effectiveModel),
|
||||||
maxTurns: MAX_TURNS.standard,
|
maxTurns: MAX_TURNS.standard,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.chat],
|
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||||
|
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
|
||||||
|
// Apply MCP bypass options if configured
|
||||||
|
...mcpOptions.bypassOptions,
|
||||||
|
...(sandboxCheck.enabled && {
|
||||||
|
sandbox: {
|
||||||
|
enabled: true,
|
||||||
|
autoAllowBashIfSandboxed: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
...thinkingOptions,
|
...thinkingOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
@@ -487,6 +605,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
* - Full tool access for code modification and implementation
|
* - Full tool access for code modification and implementation
|
||||||
* - Extended turns for thorough feature implementation
|
* - Extended turns for thorough feature implementation
|
||||||
* - Uses default model (can be overridden)
|
* - Uses default model (can be overridden)
|
||||||
|
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
|
||||||
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||||
*/
|
*/
|
||||||
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||||
@@ -502,12 +621,24 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
// Build thinking options
|
// Build thinking options
|
||||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||||
|
|
||||||
|
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
||||||
|
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('auto', config.model),
|
model: getModelForUseCase('auto', config.model),
|
||||||
maxTurns: MAX_TURNS.maximum,
|
maxTurns: MAX_TURNS.maximum,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||||
|
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
|
||||||
|
// Apply MCP bypass options if configured
|
||||||
|
...mcpOptions.bypassOptions,
|
||||||
|
...(sandboxCheck.enabled && {
|
||||||
|
sandbox: {
|
||||||
|
enabled: true,
|
||||||
|
autoAllowBashIfSandboxed: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
...thinkingOptions,
|
...thinkingOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
@@ -525,6 +656,7 @@ export function createCustomOptions(
|
|||||||
config: CreateSdkOptionsConfig & {
|
config: CreateSdkOptionsConfig & {
|
||||||
maxTurns?: number;
|
maxTurns?: number;
|
||||||
allowedTools?: readonly string[];
|
allowedTools?: readonly string[];
|
||||||
|
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean };
|
||||||
}
|
}
|
||||||
): Options {
|
): Options {
|
||||||
// Validate working directory before creating options
|
// Validate working directory before creating options
|
||||||
@@ -539,17 +671,22 @@ export function createCustomOptions(
|
|||||||
// Build thinking options
|
// Build thinking options
|
||||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||||
|
|
||||||
// For custom options: use explicit allowedTools if provided, otherwise default to readOnly
|
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
|
||||||
const effectiveAllowedTools = config.allowedTools
|
const effectiveAllowedTools = config.allowedTools
|
||||||
? [...config.allowedTools]
|
? [...config.allowedTools]
|
||||||
: [...TOOL_PRESETS.readOnly];
|
: mcpOptions.shouldRestrictTools
|
||||||
|
? [...TOOL_PRESETS.readOnly]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('default', config.model),
|
model: getModelForUseCase('default', config.model),
|
||||||
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: effectiveAllowedTools,
|
...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }),
|
||||||
|
...(config.sandbox && { sandbox: config.sandbox }),
|
||||||
|
// Apply MCP bypass options if configured
|
||||||
|
...mcpOptions.bypassOptions,
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
...thinkingOptions,
|
...thinkingOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
|
|||||||
@@ -55,6 +55,34 @@ export async function getAutoLoadClaudeMdSetting(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the enableSandboxMode setting from global settings.
|
||||||
|
* Returns false if settings service is not available.
|
||||||
|
*
|
||||||
|
* @param settingsService - Optional settings service instance
|
||||||
|
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||||
|
* @returns Promise resolving to the enableSandboxMode setting value
|
||||||
|
*/
|
||||||
|
export async function getEnableSandboxModeSetting(
|
||||||
|
settingsService?: SettingsService | null,
|
||||||
|
logPrefix = '[SettingsHelper]'
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!settingsService) {
|
||||||
|
logger.info(`${logPrefix} SettingsService not available, sandbox mode disabled`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalSettings = await settingsService.getGlobalSettings();
|
||||||
|
const result = globalSettings.enableSandboxMode ?? false;
|
||||||
|
logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
|
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
|
||||||
* and rebuilds the formatted prompt without it.
|
* and rebuilds the formatted prompt without it.
|
||||||
@@ -241,83 +269,3 @@ export async function getPromptCustomization(
|
|||||||
enhancement: mergeEnhancementPrompts(customization.enhancement),
|
enhancement: mergeEnhancementPrompts(customization.enhancement),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Skills configuration from settings.
|
|
||||||
* Returns configuration for enabling skills and which sources to load from.
|
|
||||||
*
|
|
||||||
* @param settingsService - Settings service instance
|
|
||||||
* @returns Skills configuration with enabled state, sources, and tool inclusion flag
|
|
||||||
*/
|
|
||||||
export async function getSkillsConfiguration(settingsService: SettingsService): Promise<{
|
|
||||||
enabled: boolean;
|
|
||||||
sources: Array<'user' | 'project'>;
|
|
||||||
shouldIncludeInTools: boolean;
|
|
||||||
}> {
|
|
||||||
const settings = await settingsService.getGlobalSettings();
|
|
||||||
const enabled = settings.enableSkills ?? true; // Default enabled
|
|
||||||
const sources = settings.skillsSources ?? ['user', 'project']; // Default both sources
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled,
|
|
||||||
sources,
|
|
||||||
shouldIncludeInTools: enabled && sources.length > 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Subagents configuration from settings.
|
|
||||||
* Returns configuration for enabling subagents and which sources to load from.
|
|
||||||
*
|
|
||||||
* @param settingsService - Settings service instance
|
|
||||||
* @returns Subagents configuration with enabled state, sources, and tool inclusion flag
|
|
||||||
*/
|
|
||||||
export async function getSubagentsConfiguration(settingsService: SettingsService): Promise<{
|
|
||||||
enabled: boolean;
|
|
||||||
sources: Array<'user' | 'project'>;
|
|
||||||
shouldIncludeInTools: boolean;
|
|
||||||
}> {
|
|
||||||
const settings = await settingsService.getGlobalSettings();
|
|
||||||
const enabled = settings.enableSubagents ?? true; // Default enabled
|
|
||||||
const sources = settings.subagentsSources ?? ['user', 'project']; // Default both sources
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled,
|
|
||||||
sources,
|
|
||||||
shouldIncludeInTools: enabled && sources.length > 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get custom subagents from settings, merging global and project-level definitions.
|
|
||||||
* Project-level subagents take precedence over global ones with the same name.
|
|
||||||
*
|
|
||||||
* @param settingsService - Settings service instance
|
|
||||||
* @param projectPath - Path to the project for loading project-specific subagents
|
|
||||||
* @returns Record of agent names to definitions, or undefined if none configured
|
|
||||||
*/
|
|
||||||
export async function getCustomSubagents(
|
|
||||||
settingsService: SettingsService,
|
|
||||||
projectPath?: string
|
|
||||||
): Promise<Record<string, import('@automaker/types').AgentDefinition> | undefined> {
|
|
||||||
// Get global subagents
|
|
||||||
const globalSettings = await settingsService.getGlobalSettings();
|
|
||||||
const globalSubagents = globalSettings.customSubagents || {};
|
|
||||||
|
|
||||||
// If no project path, return only global subagents
|
|
||||||
if (!projectPath) {
|
|
||||||
return Object.keys(globalSubagents).length > 0 ? globalSubagents : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get project-specific subagents
|
|
||||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
|
||||||
const projectSubagents = projectSettings.customSubagents || {};
|
|
||||||
|
|
||||||
// Merge: project-level takes precedence
|
|
||||||
const merged = {
|
|
||||||
...globalSubagents,
|
|
||||||
...projectSubagents,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ export interface WorktreeMetadata {
|
|||||||
branch: string;
|
branch: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
pr?: WorktreePRInfo;
|
pr?: WorktreePRInfo;
|
||||||
/** Whether the init script has been executed for this worktree */
|
|
||||||
initScriptRan?: boolean;
|
|
||||||
/** Status of the init script execution */
|
|
||||||
initScriptStatus?: 'running' | 'success' | 'failed';
|
|
||||||
/** Error message if init script failed */
|
|
||||||
initScriptError?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { BaseProvider } from './base-provider.js';
|
|||||||
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const logger = createLogger('ClaudeProvider');
|
const logger = createLogger('ClaudeProvider');
|
||||||
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
|
import { getThinkingTokenBudget } from '@automaker/types';
|
||||||
import type {
|
import type {
|
||||||
ExecuteOptions,
|
ExecuteOptions,
|
||||||
ProviderMessage,
|
ProviderMessage,
|
||||||
@@ -53,10 +53,6 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
* Execute a query using Claude Agent SDK
|
* Execute a query using Claude Agent SDK
|
||||||
*/
|
*/
|
||||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||||
// Validate that model doesn't have a provider prefix
|
|
||||||
// AgentService should strip prefixes before passing to providers
|
|
||||||
validateBareModelId(options.model, 'ClaudeProvider');
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
prompt,
|
prompt,
|
||||||
model,
|
model,
|
||||||
@@ -74,6 +70,14 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||||
|
|
||||||
// Build Claude SDK options
|
// Build Claude SDK options
|
||||||
|
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||||
|
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
|
||||||
|
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||||
|
|
||||||
|
// AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools
|
||||||
|
// Only restrict tools when no MCP servers are configured
|
||||||
|
const shouldRestrictTools = !hasMcpServers;
|
||||||
|
|
||||||
const sdkOptions: Options = {
|
const sdkOptions: Options = {
|
||||||
model,
|
model,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
@@ -81,9 +85,10 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
cwd,
|
cwd,
|
||||||
// Pass only explicitly allowed environment variables to SDK
|
// Pass only explicitly allowed environment variables to SDK
|
||||||
env: buildEnv(),
|
env: buildEnv(),
|
||||||
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
|
||||||
...(allowedTools && { allowedTools }),
|
...(allowedTools && shouldRestrictTools && { allowedTools }),
|
||||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
|
||||||
|
// AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations
|
||||||
permissionMode: 'bypassPermissions',
|
permissionMode: 'bypassPermissions',
|
||||||
allowDangerouslySkipPermissions: true,
|
allowDangerouslySkipPermissions: true,
|
||||||
abortController,
|
abortController,
|
||||||
@@ -93,12 +98,12 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
: {}),
|
: {}),
|
||||||
// Forward settingSources for CLAUDE.md file loading
|
// Forward settingSources for CLAUDE.md file loading
|
||||||
...(options.settingSources && { settingSources: options.settingSources }),
|
...(options.settingSources && { settingSources: options.settingSources }),
|
||||||
|
// Forward sandbox configuration
|
||||||
|
...(options.sandbox && { sandbox: options.sandbox }),
|
||||||
// Forward MCP servers configuration
|
// Forward MCP servers configuration
|
||||||
...(options.mcpServers && { mcpServers: options.mcpServers }),
|
...(options.mcpServers && { mcpServers: options.mcpServers }),
|
||||||
// Extended thinking configuration
|
// Extended thinking configuration
|
||||||
...(maxThinkingTokens && { maxThinkingTokens }),
|
...(maxThinkingTokens && { maxThinkingTokens }),
|
||||||
// Subagents configuration for specialized task delegation
|
|
||||||
...(options.agents && { agents: options.agents }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build prompt payload
|
// Build prompt payload
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* Codex Config Manager - Writes MCP server configuration for Codex CLI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import path from 'path';
|
|
||||||
import type { McpServerConfig } from '@automaker/types';
|
|
||||||
import * as secureFs from '../lib/secure-fs.js';
|
|
||||||
|
|
||||||
const CODEX_CONFIG_DIR = '.codex';
|
|
||||||
const CODEX_CONFIG_FILENAME = 'config.toml';
|
|
||||||
const CODEX_MCP_SECTION = 'mcp_servers';
|
|
||||||
|
|
||||||
function formatTomlString(value: string): string {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTomlArray(values: string[]): string {
|
|
||||||
const formatted = values.map((value) => formatTomlString(value)).join(', ');
|
|
||||||
return `[${formatted}]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTomlInlineTable(values: Record<string, string>): string {
|
|
||||||
const entries = Object.entries(values).map(
|
|
||||||
([key, value]) => `${key} = ${formatTomlString(value)}`
|
|
||||||
);
|
|
||||||
return `{ ${entries.join(', ')} }`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTomlKey(key: string): string {
|
|
||||||
return `"${key.replace(/"/g, '\\"')}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildServerBlock(name: string, server: McpServerConfig): string[] {
|
|
||||||
const lines: string[] = [];
|
|
||||||
const section = `${CODEX_MCP_SECTION}.${formatTomlKey(name)}`;
|
|
||||||
lines.push(`[${section}]`);
|
|
||||||
|
|
||||||
if (server.type) {
|
|
||||||
lines.push(`type = ${formatTomlString(server.type)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('command' in server && server.command) {
|
|
||||||
lines.push(`command = ${formatTomlString(server.command)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('args' in server && server.args && server.args.length > 0) {
|
|
||||||
lines.push(`args = ${formatTomlArray(server.args)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('env' in server && server.env && Object.keys(server.env).length > 0) {
|
|
||||||
lines.push(`env = ${formatTomlInlineTable(server.env)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('url' in server && server.url) {
|
|
||||||
lines.push(`url = ${formatTomlString(server.url)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('headers' in server && server.headers && Object.keys(server.headers).length > 0) {
|
|
||||||
lines.push(`headers = ${formatTomlInlineTable(server.headers)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CodexConfigManager {
|
|
||||||
async configureMcpServers(
|
|
||||||
cwd: string,
|
|
||||||
mcpServers: Record<string, McpServerConfig>
|
|
||||||
): Promise<void> {
|
|
||||||
const configDir = path.join(cwd, CODEX_CONFIG_DIR);
|
|
||||||
const configPath = path.join(configDir, CODEX_CONFIG_FILENAME);
|
|
||||||
|
|
||||||
await secureFs.mkdir(configDir, { recursive: true });
|
|
||||||
|
|
||||||
const blocks: string[] = [];
|
|
||||||
for (const [name, server] of Object.entries(mcpServers)) {
|
|
||||||
blocks.push(...buildServerBlock(name, server), '');
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = blocks.join('\n').trim();
|
|
||||||
if (content) {
|
|
||||||
await secureFs.writeFile(configPath, content + '\n', 'utf-8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* Codex Model Definitions
|
|
||||||
*
|
|
||||||
* Official Codex CLI models as documented at https://developers.openai.com/codex/models/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CODEX_MODEL_MAP } from '@automaker/types';
|
|
||||||
import type { ModelDefinition } from './types.js';
|
|
||||||
|
|
||||||
const CONTEXT_WINDOW_256K = 256000;
|
|
||||||
const CONTEXT_WINDOW_128K = 128000;
|
|
||||||
const MAX_OUTPUT_32K = 32000;
|
|
||||||
const MAX_OUTPUT_16K = 16000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All available Codex models with their specifications
|
|
||||||
* Based on https://developers.openai.com/codex/models/
|
|
||||||
*/
|
|
||||||
export const CODEX_MODELS: ModelDefinition[] = [
|
|
||||||
// ========== Recommended Codex Models ==========
|
|
||||||
{
|
|
||||||
id: CODEX_MODEL_MAP.gpt52Codex,
|
|
||||||
name: 'GPT-5.2-Codex',
|
|
||||||
modelString: CODEX_MODEL_MAP.gpt52Codex,
|
|
||||||
provider: 'openai',
|
|
||||||
description:
|
|
||||||
'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).',
|
|
||||||
contextWindow: CONTEXT_WINDOW_256K,
|
|
||||||
maxOutputTokens: MAX_OUTPUT_32K,
|
|
||||||
supportsVision: true,
|
|
||||||
supportsTools: true,
|
|
||||||
tier: 'premium' as const,
|
|
||||||
default: true,
|
|
||||||
hasReasoning: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: CODEX_MODEL_MAP.gpt51CodexMax,
|
|
||||||
name: 'GPT-5.1-Codex-Max',
|
|
||||||
modelString: CODEX_MODEL_MAP.gpt51CodexMax,
|
|
||||||
provider: 'openai',
|
|
||||||
description: 'Optimized for long-horizon, agentic coding tasks in Codex.',
|
|
||||||
contextWindow: CONTEXT_WINDOW_256K,
|
|
||||||
maxOutputTokens: MAX_OUTPUT_32K,
|
|
||||||
supportsVision: true,
|
|
||||||
supportsTools: true,
|
|
||||||
tier: 'premium' as const,
|
|
||||||
hasReasoning: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: CODEX_MODEL_MAP.gpt51CodexMini,
|
|
||||||
name: 'GPT-5.1-Codex-Mini',
|
|
||||||
modelString: CODEX_MODEL_MAP.gpt51CodexMini,
|
|
||||||
provider: 'openai',
|
|
||||||
description: 'Smaller, more cost-effective version for faster workflows.',
|
|
||||||
contextWindow: CONTEXT_WINDOW_128K,
|
|
||||||
maxOutputTokens: MAX_OUTPUT_16K,
|
|
||||||
supportsVision: true,
|
|
||||||
supportsTools: true,
|
|
||||||
tier: 'basic' as const,
|
|
||||||
hasReasoning: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ========== General-Purpose GPT Models ==========
|
|
||||||
{
|
|
||||||
id: CODEX_MODEL_MAP.gpt52,
|
|
||||||
name: 'GPT-5.2',
|
|
||||||
modelString: CODEX_MODEL_MAP.gpt52,
|
|
||||||
provider: 'openai',
|
|
||||||
description: 'Best general agentic model for tasks across industries and domains.',
|
|
||||||
contextWindow: CONTEXT_WINDOW_256K,
|
|
||||||
maxOutputTokens: MAX_OUTPUT_32K,
|
|
||||||
supportsVision: true,
|
|
||||||
supportsTools: true,
|
|
||||||
tier: 'standard' as const,
|
|
||||||
hasReasoning: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: CODEX_MODEL_MAP.gpt51,
|
|
||||||
name: 'GPT-5.1',
|
|
||||||
modelString: CODEX_MODEL_MAP.gpt51,
|
|
||||||
provider: 'openai',
|
|
||||||
description: 'Great for coding and agentic tasks across domains.',
|
|
||||||
contextWindow: CONTEXT_WINDOW_256K,
|
|
||||||
maxOutputTokens: MAX_OUTPUT_32K,
|
|
||||||
supportsVision: true,
|
|
||||||
supportsTools: true,
|
|
||||||
tier: 'standard' as const,
|
|
||||||
hasReasoning: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get model definition by ID
|
|
||||||
*/
|
|
||||||
export function getCodexModelById(modelId: string): ModelDefinition | undefined {
|
|
||||||
return CODEX_MODELS.find((m) => m.id === modelId || m.modelString === modelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all models that support reasoning
|
|
||||||
*/
|
|
||||||
export function getReasoningModels(): ModelDefinition[] {
|
|
||||||
return CODEX_MODELS.filter((m) => m.hasReasoning);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get models by tier
|
|
||||||
*/
|
|
||||||
export function getModelsByTier(tier: 'premium' | 'standard' | 'basic'): ModelDefinition[] {
|
|
||||||
return CODEX_MODELS.filter((m) => m.tier === tier);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,173 +0,0 @@
|
|||||||
/**
|
|
||||||
* Codex SDK client - Executes Codex queries via official @openai/codex-sdk
|
|
||||||
*
|
|
||||||
* Used for programmatic control of Codex from within the application.
|
|
||||||
* Provides cleaner integration than spawning CLI processes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Codex } from '@openai/codex-sdk';
|
|
||||||
import { formatHistoryAsText, classifyError, getUserFriendlyErrorMessage } from '@automaker/utils';
|
|
||||||
import { supportsReasoningEffort } from '@automaker/types';
|
|
||||||
import type { ExecuteOptions, ProviderMessage } from './types.js';
|
|
||||||
|
|
||||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
|
||||||
const SDK_HISTORY_HEADER = 'Current request:\n';
|
|
||||||
const DEFAULT_RESPONSE_TEXT = '';
|
|
||||||
const SDK_ERROR_DETAILS_LABEL = 'Details:';
|
|
||||||
|
|
||||||
type PromptBlock = {
|
|
||||||
type: string;
|
|
||||||
text?: string;
|
|
||||||
source?: {
|
|
||||||
type?: string;
|
|
||||||
media_type?: string;
|
|
||||||
data?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveApiKey(): string {
|
|
||||||
const apiKey = process.env[OPENAI_API_KEY_ENV];
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error('OPENAI_API_KEY is not set.');
|
|
||||||
}
|
|
||||||
return apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePromptBlocks(prompt: ExecuteOptions['prompt']): PromptBlock[] {
|
|
||||||
if (Array.isArray(prompt)) {
|
|
||||||
return prompt as PromptBlock[];
|
|
||||||
}
|
|
||||||
return [{ type: 'text', text: prompt }];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPromptText(options: ExecuteOptions, systemPrompt: string | null): string {
|
|
||||||
const historyText =
|
|
||||||
options.conversationHistory && options.conversationHistory.length > 0
|
|
||||||
? formatHistoryAsText(options.conversationHistory)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const promptBlocks = normalizePromptBlocks(options.prompt);
|
|
||||||
const promptTexts: string[] = [];
|
|
||||||
|
|
||||||
for (const block of promptBlocks) {
|
|
||||||
if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
|
|
||||||
promptTexts.push(block.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const promptContent = promptTexts.join('\n\n');
|
|
||||||
if (!promptContent.trim()) {
|
|
||||||
throw new Error('Codex SDK prompt is empty.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (systemPrompt) {
|
|
||||||
parts.push(`System: ${systemPrompt}`);
|
|
||||||
}
|
|
||||||
if (historyText) {
|
|
||||||
parts.push(historyText);
|
|
||||||
}
|
|
||||||
parts.push(`${SDK_HISTORY_HEADER}${promptContent}`);
|
|
||||||
|
|
||||||
return parts.join('\n\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSdkErrorMessage(rawMessage: string, userMessage: string): string {
|
|
||||||
if (!rawMessage) {
|
|
||||||
return userMessage;
|
|
||||||
}
|
|
||||||
if (!userMessage || rawMessage === userMessage) {
|
|
||||||
return rawMessage;
|
|
||||||
}
|
|
||||||
return `${userMessage}\n\n${SDK_ERROR_DETAILS_LABEL} ${rawMessage}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a query using the official Codex SDK
|
|
||||||
*
|
|
||||||
* The SDK provides a cleaner interface than spawning CLI processes:
|
|
||||||
* - Handles authentication automatically
|
|
||||||
* - Provides TypeScript types
|
|
||||||
* - Supports thread management and resumption
|
|
||||||
* - Better error handling
|
|
||||||
*/
|
|
||||||
export async function* executeCodexSdkQuery(
|
|
||||||
options: ExecuteOptions,
|
|
||||||
systemPrompt: string | null
|
|
||||||
): AsyncGenerator<ProviderMessage> {
|
|
||||||
try {
|
|
||||||
const apiKey = resolveApiKey();
|
|
||||||
const codex = new Codex({ apiKey });
|
|
||||||
|
|
||||||
// Resume existing thread or start new one
|
|
||||||
let thread;
|
|
||||||
if (options.sdkSessionId) {
|
|
||||||
try {
|
|
||||||
thread = codex.resumeThread(options.sdkSessionId);
|
|
||||||
} catch {
|
|
||||||
// If resume fails, start a new thread
|
|
||||||
thread = codex.startThread();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
thread = codex.startThread();
|
|
||||||
}
|
|
||||||
|
|
||||||
const promptText = buildPromptText(options, systemPrompt);
|
|
||||||
|
|
||||||
// Build run options with reasoning effort if supported
|
|
||||||
const runOptions: {
|
|
||||||
signal?: AbortSignal;
|
|
||||||
reasoning?: { effort: string };
|
|
||||||
} = {
|
|
||||||
signal: options.abortController?.signal,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add reasoning effort if model supports it and reasoningEffort is specified
|
|
||||||
if (
|
|
||||||
options.reasoningEffort &&
|
|
||||||
supportsReasoningEffort(options.model) &&
|
|
||||||
options.reasoningEffort !== 'none'
|
|
||||||
) {
|
|
||||||
runOptions.reasoning = { effort: options.reasoningEffort };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the query
|
|
||||||
const result = await thread.run(promptText, runOptions);
|
|
||||||
|
|
||||||
// Extract response text (from finalResponse property)
|
|
||||||
const outputText = result.finalResponse ?? DEFAULT_RESPONSE_TEXT;
|
|
||||||
|
|
||||||
// Get thread ID (may be null if not populated yet)
|
|
||||||
const threadId = thread.id ?? undefined;
|
|
||||||
|
|
||||||
// Yield assistant message
|
|
||||||
yield {
|
|
||||||
type: 'assistant',
|
|
||||||
session_id: threadId,
|
|
||||||
message: {
|
|
||||||
role: 'assistant',
|
|
||||||
content: [{ type: 'text', text: outputText }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Yield result
|
|
||||||
yield {
|
|
||||||
type: 'result',
|
|
||||||
subtype: 'success',
|
|
||||||
session_id: threadId,
|
|
||||||
result: outputText,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errorInfo = classifyError(error);
|
|
||||||
const userMessage = getUserFriendlyErrorMessage(error);
|
|
||||||
const combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
|
|
||||||
console.error('[CodexSDK] executeQuery() error during execution:', {
|
|
||||||
type: errorInfo.type,
|
|
||||||
message: errorInfo.message,
|
|
||||||
isRateLimit: errorInfo.isRateLimit,
|
|
||||||
retryAfter: errorInfo.retryAfter,
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
});
|
|
||||||
yield { type: 'error', error: combinedMessage };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,436 +0,0 @@
|
|||||||
export type CodexToolResolution = {
|
|
||||||
name: string;
|
|
||||||
input: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CodexTodoItem = {
|
|
||||||
content: string;
|
|
||||||
status: 'pending' | 'in_progress' | 'completed';
|
|
||||||
activeForm?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TOOL_NAME_BASH = 'Bash';
|
|
||||||
const TOOL_NAME_READ = 'Read';
|
|
||||||
const TOOL_NAME_EDIT = 'Edit';
|
|
||||||
const TOOL_NAME_WRITE = 'Write';
|
|
||||||
const TOOL_NAME_GREP = 'Grep';
|
|
||||||
const TOOL_NAME_GLOB = 'Glob';
|
|
||||||
const TOOL_NAME_TODO = 'TodoWrite';
|
|
||||||
const TOOL_NAME_DELETE = 'Delete';
|
|
||||||
const TOOL_NAME_LS = 'Ls';
|
|
||||||
|
|
||||||
const INPUT_KEY_COMMAND = 'command';
|
|
||||||
const INPUT_KEY_FILE_PATH = 'file_path';
|
|
||||||
const INPUT_KEY_PATTERN = 'pattern';
|
|
||||||
|
|
||||||
const SHELL_WRAPPER_PATTERNS = [
|
|
||||||
/^\/bin\/bash\s+-lc\s+["']([\s\S]+)["']$/,
|
|
||||||
/^bash\s+-lc\s+["']([\s\S]+)["']$/,
|
|
||||||
/^\/bin\/sh\s+-lc\s+["']([\s\S]+)["']$/,
|
|
||||||
/^sh\s+-lc\s+["']([\s\S]+)["']$/,
|
|
||||||
/^cmd\.exe\s+\/c\s+["']?([\s\S]+)["']?$/i,
|
|
||||||
/^powershell(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i,
|
|
||||||
/^pwsh(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i,
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const COMMAND_SEPARATOR_PATTERN = /\s*(?:&&|\|\||;)\s*/;
|
|
||||||
const SEGMENT_SKIP_PREFIXES = ['cd ', 'export ', 'set ', 'pushd '] as const;
|
|
||||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'command']);
|
|
||||||
const READ_COMMANDS = new Set(['cat', 'sed', 'head', 'tail', 'less', 'more', 'bat', 'stat', 'wc']);
|
|
||||||
const SEARCH_COMMANDS = new Set(['rg', 'grep', 'ag', 'ack']);
|
|
||||||
const GLOB_COMMANDS = new Set(['ls', 'find', 'fd', 'tree']);
|
|
||||||
const DELETE_COMMANDS = new Set(['rm', 'del', 'erase', 'remove', 'unlink']);
|
|
||||||
const LIST_COMMANDS = new Set(['ls', 'dir', 'll', 'la']);
|
|
||||||
const WRITE_COMMANDS = new Set(['tee', 'touch', 'mkdir']);
|
|
||||||
const APPLY_PATCH_COMMAND = 'apply_patch';
|
|
||||||
const APPLY_PATCH_PATTERN = /\bapply_patch\b/;
|
|
||||||
const REDIRECTION_TARGET_PATTERN = /(?:>>|>)\s*([^\s]+)/;
|
|
||||||
const SED_IN_PLACE_FLAGS = new Set(['-i', '--in-place']);
|
|
||||||
const PERL_IN_PLACE_FLAG = /-.*i/;
|
|
||||||
const SEARCH_PATTERN_FLAGS = new Set(['-e', '--regexp']);
|
|
||||||
const SEARCH_VALUE_FLAGS = new Set([
|
|
||||||
'-g',
|
|
||||||
'--glob',
|
|
||||||
'--iglob',
|
|
||||||
'--type',
|
|
||||||
'--type-add',
|
|
||||||
'--type-clear',
|
|
||||||
'--encoding',
|
|
||||||
]);
|
|
||||||
const SEARCH_FILE_LIST_FLAGS = new Set(['--files']);
|
|
||||||
const TODO_LINE_PATTERN = /^[-*]\s*(?:\[(?<status>[ x~])\]\s*)?(?<content>.+)$/;
|
|
||||||
const TODO_STATUS_COMPLETED = 'completed';
|
|
||||||
const TODO_STATUS_IN_PROGRESS = 'in_progress';
|
|
||||||
const TODO_STATUS_PENDING = 'pending';
|
|
||||||
const PATCH_FILE_MARKERS = [
|
|
||||||
'*** Update File: ',
|
|
||||||
'*** Add File: ',
|
|
||||||
'*** Delete File: ',
|
|
||||||
'*** Move to: ',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function stripShellWrapper(command: string): string {
|
|
||||||
const trimmed = command.trim();
|
|
||||||
for (const pattern of SHELL_WRAPPER_PATTERNS) {
|
|
||||||
const match = trimmed.match(pattern);
|
|
||||||
if (match && match[1]) {
|
|
||||||
return unescapeCommand(match[1].trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unescapeCommand(command: string): string {
|
|
||||||
return command.replace(/\\(["'])/g, '$1');
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractPrimarySegment(command: string): string {
|
|
||||||
const segments = command
|
|
||||||
.split(COMMAND_SEPARATOR_PATTERN)
|
|
||||||
.map((segment) => segment.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
for (const segment of segments) {
|
|
||||||
const shouldSkip = SEGMENT_SKIP_PREFIXES.some((prefix) => segment.startsWith(prefix));
|
|
||||||
if (!shouldSkip) {
|
|
||||||
return segment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return command.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenizeCommand(command: string): string[] {
|
|
||||||
const tokens: string[] = [];
|
|
||||||
let current = '';
|
|
||||||
let inSingleQuote = false;
|
|
||||||
let inDoubleQuote = false;
|
|
||||||
let isEscaped = false;
|
|
||||||
|
|
||||||
for (const char of command) {
|
|
||||||
if (isEscaped) {
|
|
||||||
current += char;
|
|
||||||
isEscaped = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === '\\') {
|
|
||||||
isEscaped = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === "'" && !inDoubleQuote) {
|
|
||||||
inSingleQuote = !inSingleQuote;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === '"' && !inSingleQuote) {
|
|
||||||
inDoubleQuote = !inDoubleQuote;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inSingleQuote && !inDoubleQuote && /\s/.test(char)) {
|
|
||||||
if (current) {
|
|
||||||
tokens.push(current);
|
|
||||||
current = '';
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
current += char;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current) {
|
|
||||||
tokens.push(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripWrapperTokens(tokens: string[]): string[] {
|
|
||||||
let index = 0;
|
|
||||||
while (index < tokens.length && WRAPPER_COMMANDS.has(tokens[index].toLowerCase())) {
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
return tokens.slice(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractFilePathFromTokens(tokens: string[]): string | null {
|
|
||||||
const candidates = tokens.slice(1).filter((token) => token && !token.startsWith('-'));
|
|
||||||
if (candidates.length === 0) return null;
|
|
||||||
return candidates[candidates.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractSearchPattern(tokens: string[]): string | null {
|
|
||||||
const remaining = tokens.slice(1);
|
|
||||||
|
|
||||||
for (let index = 0; index < remaining.length; index += 1) {
|
|
||||||
const token = remaining[index];
|
|
||||||
if (token === '--') {
|
|
||||||
return remaining[index + 1] ?? null;
|
|
||||||
}
|
|
||||||
if (SEARCH_PATTERN_FLAGS.has(token)) {
|
|
||||||
return remaining[index + 1] ?? null;
|
|
||||||
}
|
|
||||||
if (SEARCH_VALUE_FLAGS.has(token)) {
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token.startsWith('-')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTeeTarget(tokens: string[]): string | null {
|
|
||||||
const teeIndex = tokens.findIndex((token) => token === 'tee');
|
|
||||||
if (teeIndex < 0) return null;
|
|
||||||
const candidate = tokens[teeIndex + 1];
|
|
||||||
return candidate && !candidate.startsWith('-') ? candidate : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractRedirectionTarget(command: string): string | null {
|
|
||||||
const match = command.match(REDIRECTION_TARGET_PATTERN);
|
|
||||||
return match?.[1] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractFilePathFromDeleteTokens(tokens: string[]): string | null {
|
|
||||||
// rm file.txt or rm /path/to/file.txt
|
|
||||||
// Skip flags and get the first non-flag argument
|
|
||||||
for (let i = 1; i < tokens.length; i++) {
|
|
||||||
const token = tokens[i];
|
|
||||||
if (token && !token.startsWith('-')) {
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSedInPlaceFlag(tokens: string[]): boolean {
|
|
||||||
return tokens.some((token) => SED_IN_PLACE_FLAGS.has(token) || token.startsWith('-i'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasPerlInPlaceFlag(tokens: string[]): boolean {
|
|
||||||
return tokens.some((token) => PERL_IN_PLACE_FLAG.test(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractPatchFilePath(command: string): string | null {
|
|
||||||
for (const marker of PATCH_FILE_MARKERS) {
|
|
||||||
const index = command.indexOf(marker);
|
|
||||||
if (index < 0) continue;
|
|
||||||
const start = index + marker.length;
|
|
||||||
const end = command.indexOf('\n', start);
|
|
||||||
const rawPath = (end === -1 ? command.slice(start) : command.slice(start, end)).trim();
|
|
||||||
if (rawPath) return rawPath;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInputWithFilePath(filePath: string | null): Record<string, unknown> {
|
|
||||||
return filePath ? { [INPUT_KEY_FILE_PATH]: filePath } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInputWithPattern(pattern: string | null): Record<string, unknown> {
|
|
||||||
return pattern ? { [INPUT_KEY_PATTERN]: pattern } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveCodexToolCall(command: string): CodexToolResolution {
|
|
||||||
const normalized = stripShellWrapper(command);
|
|
||||||
const primarySegment = extractPrimarySegment(normalized);
|
|
||||||
const tokens = stripWrapperTokens(tokenizeCommand(primarySegment));
|
|
||||||
const commandToken = tokens[0]?.toLowerCase() ?? '';
|
|
||||||
|
|
||||||
const redirectionTarget = extractRedirectionTarget(primarySegment);
|
|
||||||
if (redirectionTarget) {
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_WRITE,
|
|
||||||
input: buildInputWithFilePath(redirectionTarget),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (commandToken === APPLY_PATCH_COMMAND || APPLY_PATCH_PATTERN.test(primarySegment)) {
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_EDIT,
|
|
||||||
input: buildInputWithFilePath(extractPatchFilePath(primarySegment)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (commandToken === 'sed' && hasSedInPlaceFlag(tokens)) {
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_EDIT,
|
|
||||||
input: buildInputWithFilePath(extractFilePathFromTokens(tokens)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (commandToken === 'perl' && hasPerlInPlaceFlag(tokens)) {
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_EDIT,
|
|
||||||
input: buildInputWithFilePath(extractFilePathFromTokens(tokens)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WRITE_COMMANDS.has(commandToken)) {
|
|
||||||
const filePath =
|
|
||||||
commandToken === 'tee' ? extractTeeTarget(tokens) : extractFilePathFromTokens(tokens);
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_WRITE,
|
|
||||||
input: buildInputWithFilePath(filePath),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SEARCH_COMMANDS.has(commandToken)) {
|
|
||||||
if (tokens.some((token) => SEARCH_FILE_LIST_FLAGS.has(token))) {
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_GLOB,
|
|
||||||
input: buildInputWithPattern(extractFilePathFromTokens(tokens)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_GREP,
|
|
||||||
input: buildInputWithPattern(extractSearchPattern(tokens)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Delete commands (rm, del, erase, remove, unlink)
|
|
||||||
if (DELETE_COMMANDS.has(commandToken)) {
|
|
||||||
// Skip if -r or -rf flags (recursive delete should go to Bash)
|
|
||||||
if (
|
|
||||||
tokens.some((token) => token === '-r' || token === '-rf' || token === '-f' || token === '-rf')
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_BASH,
|
|
||||||
input: { [INPUT_KEY_COMMAND]: normalized },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Simple file deletion - extract the file path
|
|
||||||
const filePath = extractFilePathFromDeleteTokens(tokens);
|
|
||||||
if (filePath) {
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_DELETE,
|
|
||||||
input: { path: filePath },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Fall back to bash if we can't determine the file path
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_BASH,
|
|
||||||
input: { [INPUT_KEY_COMMAND]: normalized },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle simple Ls commands (just listing, not find/glob)
|
|
||||||
if (LIST_COMMANDS.has(commandToken)) {
|
|
||||||
const filePath = extractFilePathFromTokens(tokens);
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_LS,
|
|
||||||
input: { path: filePath || '.' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (GLOB_COMMANDS.has(commandToken)) {
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_GLOB,
|
|
||||||
input: buildInputWithPattern(extractFilePathFromTokens(tokens)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (READ_COMMANDS.has(commandToken)) {
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_READ,
|
|
||||||
input: buildInputWithFilePath(extractFilePathFromTokens(tokens)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: TOOL_NAME_BASH,
|
|
||||||
input: { [INPUT_KEY_COMMAND]: normalized },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTodoLines(lines: string[]): CodexTodoItem[] {
|
|
||||||
const todos: CodexTodoItem[] = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const match = line.match(TODO_LINE_PATTERN);
|
|
||||||
if (!match?.groups?.content) continue;
|
|
||||||
|
|
||||||
const statusToken = match.groups.status;
|
|
||||||
const status =
|
|
||||||
statusToken === 'x'
|
|
||||||
? TODO_STATUS_COMPLETED
|
|
||||||
: statusToken === '~'
|
|
||||||
? TODO_STATUS_IN_PROGRESS
|
|
||||||
: TODO_STATUS_PENDING;
|
|
||||||
|
|
||||||
todos.push({ content: match.groups.content.trim(), status });
|
|
||||||
}
|
|
||||||
|
|
||||||
return todos;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTodoFromArray(value: unknown[]): CodexTodoItem[] {
|
|
||||||
return value
|
|
||||||
.map((entry) => {
|
|
||||||
if (typeof entry === 'string') {
|
|
||||||
return { content: entry, status: TODO_STATUS_PENDING };
|
|
||||||
}
|
|
||||||
if (entry && typeof entry === 'object') {
|
|
||||||
const record = entry as Record<string, unknown>;
|
|
||||||
const content =
|
|
||||||
typeof record.content === 'string'
|
|
||||||
? record.content
|
|
||||||
: typeof record.text === 'string'
|
|
||||||
? record.text
|
|
||||||
: typeof record.title === 'string'
|
|
||||||
? record.title
|
|
||||||
: null;
|
|
||||||
if (!content) return null;
|
|
||||||
const status =
|
|
||||||
record.status === TODO_STATUS_COMPLETED ||
|
|
||||||
record.status === TODO_STATUS_IN_PROGRESS ||
|
|
||||||
record.status === TODO_STATUS_PENDING
|
|
||||||
? (record.status as CodexTodoItem['status'])
|
|
||||||
: TODO_STATUS_PENDING;
|
|
||||||
const activeForm = typeof record.activeForm === 'string' ? record.activeForm : undefined;
|
|
||||||
return { content, status, activeForm };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter((item): item is CodexTodoItem => Boolean(item));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractCodexTodoItems(item: Record<string, unknown>): CodexTodoItem[] | null {
|
|
||||||
const todosValue = item.todos;
|
|
||||||
if (Array.isArray(todosValue)) {
|
|
||||||
const todos = extractTodoFromArray(todosValue);
|
|
||||||
return todos.length > 0 ? todos : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemsValue = item.items;
|
|
||||||
if (Array.isArray(itemsValue)) {
|
|
||||||
const todos = extractTodoFromArray(itemsValue);
|
|
||||||
return todos.length > 0 ? todos : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const textValue =
|
|
||||||
typeof item.text === 'string'
|
|
||||||
? item.text
|
|
||||||
: typeof item.content === 'string'
|
|
||||||
? item.content
|
|
||||||
: null;
|
|
||||||
if (!textValue) return null;
|
|
||||||
|
|
||||||
const lines = textValue
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const todos = parseTodoLines(lines);
|
|
||||||
return todos.length > 0 ? todos : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCodexTodoToolName(): string {
|
|
||||||
return TOOL_NAME_TODO;
|
|
||||||
}
|
|
||||||
@@ -28,9 +28,7 @@ import type {
|
|||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
ContentBlock,
|
ContentBlock,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { validateBareModelId } from '@automaker/types';
|
import { stripProviderPrefix } from '@automaker/types';
|
||||||
import { validateApiKey } from '../lib/auth-utils.js';
|
|
||||||
import { getEffectivePermissions } from '../services/cursor-config-service.js';
|
|
||||||
import {
|
import {
|
||||||
type CursorStreamEvent,
|
type CursorStreamEvent,
|
||||||
type CursorSystemEvent,
|
type CursorSystemEvent,
|
||||||
@@ -317,25 +315,18 @@ export class CursorProvider extends CliProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildCliArgs(options: ExecuteOptions): string[] {
|
buildCliArgs(options: ExecuteOptions): string[] {
|
||||||
// Model is already bare (no prefix) - validated by executeQuery
|
// Extract model (strip 'cursor-' prefix if present)
|
||||||
const model = options.model || 'auto';
|
const model = stripProviderPrefix(options.model || 'auto');
|
||||||
|
|
||||||
// Build CLI arguments for cursor-agent
|
// Build CLI arguments for cursor-agent
|
||||||
// NOTE: Prompt is NOT included here - it's passed via stdin to avoid
|
// NOTE: Prompt is NOT included here - it's passed via stdin to avoid
|
||||||
// shell escaping issues when content contains $(), backticks, etc.
|
// shell escaping issues when content contains $(), backticks, etc.
|
||||||
const cliArgs: string[] = [];
|
const cliArgs: string[] = [
|
||||||
|
|
||||||
// If using Cursor IDE (cliPath is 'cursor' not 'cursor-agent'), add 'agent' subcommand
|
|
||||||
if (this.cliPath && !this.cliPath.includes('cursor-agent')) {
|
|
||||||
cliArgs.push('agent');
|
|
||||||
}
|
|
||||||
|
|
||||||
cliArgs.push(
|
|
||||||
'-p', // Print mode (non-interactive)
|
'-p', // Print mode (non-interactive)
|
||||||
'--output-format',
|
'--output-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
'--stream-partial-output' // Real-time streaming
|
'--stream-partial-output', // Real-time streaming
|
||||||
);
|
];
|
||||||
|
|
||||||
// Only add --force if NOT in read-only mode
|
// Only add --force if NOT in read-only mode
|
||||||
// Without --force, Cursor CLI suggests changes but doesn't apply them
|
// Without --force, Cursor CLI suggests changes but doesn't apply them
|
||||||
@@ -481,9 +472,7 @@ export class CursorProvider extends CliProvider {
|
|||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Override CLI detection to add Cursor-specific checks:
|
* Override CLI detection to add Cursor-specific versions directory check
|
||||||
* 1. Versions directory for cursor-agent installations
|
|
||||||
* 2. Cursor IDE with 'cursor agent' subcommand support
|
|
||||||
*/
|
*/
|
||||||
protected detectCli(): CliDetectionResult {
|
protected detectCli(): CliDetectionResult {
|
||||||
// First try standard detection (PATH, common paths, WSL)
|
// First try standard detection (PATH, common paths, WSL)
|
||||||
@@ -518,39 +507,6 @@ export class CursorProvider extends CliProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
|
|
||||||
// The Cursor IDE includes the agent as a subcommand: cursor agent
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
const cursorPaths = [
|
|
||||||
'/usr/bin/cursor',
|
|
||||||
'/usr/local/bin/cursor',
|
|
||||||
path.join(os.homedir(), '.local/bin/cursor'),
|
|
||||||
'/opt/cursor/cursor',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const cursorPath of cursorPaths) {
|
|
||||||
if (fs.existsSync(cursorPath)) {
|
|
||||||
// Verify cursor agent subcommand works
|
|
||||||
try {
|
|
||||||
execSync(`"${cursorPath}" agent --version`, {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: 5000,
|
|
||||||
stdio: 'pipe',
|
|
||||||
});
|
|
||||||
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
|
|
||||||
// Return cursor path but we'll use 'cursor agent' subcommand
|
|
||||||
return {
|
|
||||||
cliPath: cursorPath,
|
|
||||||
useWsl: false,
|
|
||||||
strategy: 'native',
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
// cursor agent subcommand doesn't work, try next path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,10 +605,6 @@ export class CursorProvider extends CliProvider {
|
|||||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||||
this.ensureCliDetected();
|
this.ensureCliDetected();
|
||||||
|
|
||||||
// Validate that model doesn't have a provider prefix
|
|
||||||
// AgentService should strip prefixes before passing to providers
|
|
||||||
validateBareModelId(options.model, 'CursorProvider');
|
|
||||||
|
|
||||||
if (!this.cliPath) {
|
if (!this.cliPath) {
|
||||||
throw this.createError(
|
throw this.createError(
|
||||||
CursorErrorCode.NOT_INSTALLED,
|
CursorErrorCode.NOT_INSTALLED,
|
||||||
@@ -690,9 +642,6 @@ export class CursorProvider extends CliProvider {
|
|||||||
|
|
||||||
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
|
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
|
||||||
|
|
||||||
// Get effective permissions for this project
|
|
||||||
const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
|
|
||||||
|
|
||||||
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
|
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
|
||||||
const debugRawEvents =
|
const debugRawEvents =
|
||||||
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
|
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
|
||||||
@@ -889,16 +838,9 @@ export class CursorProvider extends CliProvider {
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
const result = execSync(`"${this.cliPath}" --version`, {
|
||||||
// If using Cursor IDE, use 'cursor agent --version'
|
|
||||||
const versionCmd = this.cliPath.includes('cursor-agent')
|
|
||||||
? `"${this.cliPath}" --version`
|
|
||||||
: `"${this.cliPath}" agent --version`;
|
|
||||||
|
|
||||||
const result = execSync(versionCmd, {
|
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
stdio: 'pipe',
|
|
||||||
}).trim();
|
}).trim();
|
||||||
return result;
|
return result;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -915,13 +857,8 @@ export class CursorProvider extends CliProvider {
|
|||||||
return { authenticated: false, method: 'none' };
|
return { authenticated: false, method: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for API key in environment with validation
|
// Check for API key in environment
|
||||||
if (process.env.CURSOR_API_KEY) {
|
if (process.env.CURSOR_API_KEY) {
|
||||||
const validation = validateApiKey(process.env.CURSOR_API_KEY, 'cursor');
|
|
||||||
if (!validation.isValid) {
|
|
||||||
logger.warn('Cursor API key validation failed:', validation.error);
|
|
||||||
return { authenticated: false, method: 'api_key', error: validation.error };
|
|
||||||
}
|
|
||||||
return { authenticated: true, method: 'api_key' };
|
return { authenticated: true, method: 'api_key' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,5 @@ export { ClaudeProvider } from './claude-provider.js';
|
|||||||
export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider.js';
|
export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider.js';
|
||||||
export { CursorConfigManager } from './cursor-config-manager.js';
|
export { CursorConfigManager } from './cursor-config-manager.js';
|
||||||
|
|
||||||
// OpenCode provider
|
|
||||||
export { OpencodeProvider } from './opencode-provider.js';
|
|
||||||
|
|
||||||
// Provider factory
|
// Provider factory
|
||||||
export { ProviderFactory } from './provider-factory.js';
|
export { ProviderFactory } from './provider-factory.js';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,27 +7,7 @@
|
|||||||
|
|
||||||
import { BaseProvider } from './base-provider.js';
|
import { BaseProvider } from './base-provider.js';
|
||||||
import type { InstallationStatus, ModelDefinition } from './types.js';
|
import type { InstallationStatus, ModelDefinition } from './types.js';
|
||||||
import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types';
|
import { isCursorModel, type ModelProvider } from '@automaker/types';
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const DISCONNECTED_MARKERS: Record<string, string> = {
|
|
||||||
claude: '.claude-disconnected',
|
|
||||||
codex: '.codex-disconnected',
|
|
||||||
cursor: '.cursor-disconnected',
|
|
||||||
opencode: '.opencode-disconnected',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a provider CLI is disconnected from the app
|
|
||||||
*/
|
|
||||||
export function isProviderDisconnected(providerName: string): boolean {
|
|
||||||
const markerFile = DISCONNECTED_MARKERS[providerName.toLowerCase()];
|
|
||||||
if (!markerFile) return false;
|
|
||||||
|
|
||||||
const markerPath = path.join(process.cwd(), '.automaker', markerFile);
|
|
||||||
return fs.existsSync(markerPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider registration entry
|
* Provider registration entry
|
||||||
@@ -95,26 +75,10 @@ export class ProviderFactory {
|
|||||||
* Get the appropriate provider for a given model ID
|
* Get the appropriate provider for a given model ID
|
||||||
*
|
*
|
||||||
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto")
|
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto")
|
||||||
* @param options Optional settings
|
|
||||||
* @param options.throwOnDisconnected Throw error if provider is disconnected (default: true)
|
|
||||||
* @returns Provider instance for the model
|
* @returns Provider instance for the model
|
||||||
* @throws Error if provider is disconnected and throwOnDisconnected is true
|
|
||||||
*/
|
*/
|
||||||
static getProviderForModel(
|
static getProviderForModel(modelId: string): BaseProvider {
|
||||||
modelId: string,
|
const providerName = this.getProviderNameForModel(modelId);
|
||||||
options: { throwOnDisconnected?: boolean } = {}
|
|
||||||
): BaseProvider {
|
|
||||||
const { throwOnDisconnected = true } = options;
|
|
||||||
const providerName = this.getProviderForModelName(modelId);
|
|
||||||
|
|
||||||
// Check if provider is disconnected
|
|
||||||
if (throwOnDisconnected && isProviderDisconnected(providerName)) {
|
|
||||||
throw new Error(
|
|
||||||
`${providerName.charAt(0).toUpperCase() + providerName.slice(1)} CLI is disconnected from the app. ` +
|
|
||||||
`Please go to Settings > Providers and click "Sign In" to reconnect.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = this.getProviderByName(providerName);
|
const provider = this.getProviderByName(providerName);
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
@@ -129,35 +93,6 @@ export class ProviderFactory {
|
|||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the provider name for a given model ID (without creating provider instance)
|
|
||||||
*/
|
|
||||||
static getProviderForModelName(modelId: string): string {
|
|
||||||
const lowerModel = modelId.toLowerCase();
|
|
||||||
|
|
||||||
// Get all registered providers sorted by priority (descending)
|
|
||||||
const registrations = Array.from(providerRegistry.entries()).sort(
|
|
||||||
([, a], [, b]) => (b.priority ?? 0) - (a.priority ?? 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check each provider's canHandleModel function
|
|
||||||
for (const [name, reg] of registrations) {
|
|
||||||
if (reg.canHandleModel?.(lowerModel)) {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Check for explicit prefixes
|
|
||||||
for (const [name] of registrations) {
|
|
||||||
if (lowerModel.startsWith(`${name}-`)) {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to claude (first registered provider or claude)
|
|
||||||
return 'claude';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all available providers
|
* Get all available providers
|
||||||
*/
|
*/
|
||||||
@@ -221,41 +156,6 @@ export class ProviderFactory {
|
|||||||
static getRegisteredProviderNames(): string[] {
|
static getRegisteredProviderNames(): string[] {
|
||||||
return Array.from(providerRegistry.keys());
|
return Array.from(providerRegistry.keys());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a specific model supports vision/image input
|
|
||||||
*
|
|
||||||
* @param modelId Model identifier
|
|
||||||
* @returns Whether the model supports vision (defaults to true if model not found)
|
|
||||||
*/
|
|
||||||
static modelSupportsVision(modelId: string): boolean {
|
|
||||||
const provider = this.getProviderForModel(modelId);
|
|
||||||
const models = provider.getAvailableModels();
|
|
||||||
|
|
||||||
// Find the model in the available models list
|
|
||||||
for (const model of models) {
|
|
||||||
if (
|
|
||||||
model.id === modelId ||
|
|
||||||
model.modelString === modelId ||
|
|
||||||
model.id.endsWith(`-${modelId}`) ||
|
|
||||||
model.modelString.endsWith(`-${modelId}`) ||
|
|
||||||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
|
|
||||||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
|
|
||||||
) {
|
|
||||||
return model.supportsVision ?? true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also try exact match with model string from provider's model map
|
|
||||||
for (const model of models) {
|
|
||||||
if (model.modelString === modelId || model.id === modelId) {
|
|
||||||
return model.supportsVision ?? true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to true (Claude SDK supports vision by default)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -265,8 +165,6 @@ export class ProviderFactory {
|
|||||||
// Import providers for registration side-effects
|
// Import providers for registration side-effects
|
||||||
import { ClaudeProvider } from './claude-provider.js';
|
import { ClaudeProvider } from './claude-provider.js';
|
||||||
import { CursorProvider } from './cursor-provider.js';
|
import { CursorProvider } from './cursor-provider.js';
|
||||||
import { CodexProvider } from './codex-provider.js';
|
|
||||||
import { OpencodeProvider } from './opencode-provider.js';
|
|
||||||
|
|
||||||
// Register Claude provider
|
// Register Claude provider
|
||||||
registerProvider('claude', {
|
registerProvider('claude', {
|
||||||
@@ -286,18 +184,3 @@ registerProvider('cursor', {
|
|||||||
canHandleModel: (model: string) => isCursorModel(model),
|
canHandleModel: (model: string) => isCursorModel(model),
|
||||||
priority: 10, // Higher priority - check Cursor models first
|
priority: 10, // Higher priority - check Cursor models first
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register Codex provider
|
|
||||||
registerProvider('codex', {
|
|
||||||
factory: () => new CodexProvider(),
|
|
||||||
aliases: ['openai'],
|
|
||||||
canHandleModel: (model: string) => isCodexModel(model),
|
|
||||||
priority: 5, // Medium priority - check after Cursor but before Claude
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register OpenCode provider
|
|
||||||
registerProvider('opencode', {
|
|
||||||
factory: () => new OpencodeProvider(),
|
|
||||||
canHandleModel: (model: string) => isOpencodeModel(model),
|
|
||||||
priority: 3, // Between codex (5) and claude (0)
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -6,57 +6,26 @@ import { createLogger } from '@automaker/utils';
|
|||||||
|
|
||||||
const logger = createLogger('SpecRegeneration');
|
const logger = createLogger('SpecRegeneration');
|
||||||
|
|
||||||
// Shared state for tracking generation status - scoped by project path
|
// Shared state for tracking generation status - private
|
||||||
const runningProjects = new Map<string, boolean>();
|
let isRunning = false;
|
||||||
const abortControllers = new Map<string, AbortController>();
|
let currentAbortController: AbortController | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the running state for a specific project
|
* Get the current running state
|
||||||
*/
|
*/
|
||||||
export function getSpecRegenerationStatus(projectPath?: string): {
|
export function getSpecRegenerationStatus(): {
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
currentAbortController: AbortController | null;
|
currentAbortController: AbortController | null;
|
||||||
projectPath?: string;
|
|
||||||
} {
|
} {
|
||||||
if (projectPath) {
|
return { isRunning, currentAbortController };
|
||||||
return {
|
|
||||||
isRunning: runningProjects.get(projectPath) || false,
|
|
||||||
currentAbortController: abortControllers.get(projectPath) || null,
|
|
||||||
projectPath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Fallback: check if any project is running (for backward compatibility)
|
|
||||||
const isAnyRunning = Array.from(runningProjects.values()).some((running) => running);
|
|
||||||
return { isRunning: isAnyRunning, currentAbortController: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the project path that is currently running (if any)
|
* Set the running state and abort controller
|
||||||
*/
|
*/
|
||||||
export function getRunningProjectPath(): string | null {
|
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
|
||||||
for (const [path, running] of runningProjects.entries()) {
|
isRunning = running;
|
||||||
if (running) return path;
|
currentAbortController = controller;
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the running state and abort controller for a specific project
|
|
||||||
*/
|
|
||||||
export function setRunningState(
|
|
||||||
projectPath: string,
|
|
||||||
running: boolean,
|
|
||||||
controller: AbortController | null = null
|
|
||||||
): void {
|
|
||||||
if (running) {
|
|
||||||
runningProjects.set(projectPath, true);
|
|
||||||
if (controller) {
|
|
||||||
abortControllers.set(projectPath, controller);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
runningProjects.delete(projectPath);
|
|
||||||
abortControllers.delete(projectPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
|||||||
import * as secureFs from '../../lib/secure-fs.js';
|
import * as secureFs from '../../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
|
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
|
||||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||||
@@ -124,8 +124,6 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
|
|||||||
logger.info('[FeatureGeneration] Using Cursor provider');
|
logger.info('[FeatureGeneration] Using Cursor provider');
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(model);
|
|
||||||
|
|
||||||
// Add explicit instructions for Cursor to return JSON in response
|
// Add explicit instructions for Cursor to return JSON in response
|
||||||
const cursorPrompt = `${prompt}
|
const cursorPrompt = `${prompt}
|
||||||
@@ -137,7 +135,7 @@ CRITICAL INSTRUCTIONS:
|
|||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model: bareModel,
|
model,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxTurns: 250,
|
maxTurns: 250,
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type SpecOutput,
|
type SpecOutput,
|
||||||
} from '../../lib/app-spec-format.js';
|
} from '../../lib/app-spec-format.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
|
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
|
||||||
import { extractJson } from '../../lib/json-extractor.js';
|
import { extractJson } from '../../lib/json-extractor.js';
|
||||||
@@ -118,8 +118,6 @@ ${getStructuredSpecPromptInstruction()}`;
|
|||||||
logger.info('[SpecGeneration] Using Cursor provider');
|
logger.info('[SpecGeneration] Using Cursor provider');
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(model);
|
|
||||||
|
|
||||||
// For Cursor, include the JSON schema in the prompt with clear instructions
|
// For Cursor, include the JSON schema in the prompt with clear instructions
|
||||||
// to return JSON in the response (not write to a file)
|
// to return JSON in the response (not write to a file)
|
||||||
@@ -136,7 +134,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
|||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model: bareModel,
|
model,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxTurns: 250,
|
maxTurns: 250,
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
|
|||||||
@@ -47,17 +47,17 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
const { isRunning } = getSpecRegenerationStatus();
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
logger.warn('Generation already running for project:', projectPath);
|
logger.warn('Generation already running, rejecting request');
|
||||||
res.json({ success: false, error: 'Spec generation already running for this project' });
|
res.json({ success: false, error: 'Spec generation already running' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuthStatus('Before starting generation');
|
logAuthStatus('Before starting generation');
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setRunningState(projectPath, true, abortController);
|
setRunningState(true, abortController);
|
||||||
logger.info('Starting background generation task...');
|
logger.info('Starting background generation task...');
|
||||||
|
|
||||||
// Start generation in background
|
// Start generation in background
|
||||||
@@ -80,7 +80,7 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
logger.info('Generation task finished (success or error)');
|
logger.info('Generation task finished (success or error)');
|
||||||
setRunningState(projectPath, false, null);
|
setRunningState(false, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Returning success response (generation running in background)');
|
logger.info('Returning success response (generation running in background)');
|
||||||
|
|||||||
@@ -40,17 +40,17 @@ export function createGenerateFeaturesHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
const { isRunning } = getSpecRegenerationStatus();
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
logger.warn('Generation already running for project:', projectPath);
|
logger.warn('Generation already running, rejecting request');
|
||||||
res.json({ success: false, error: 'Generation already running for this project' });
|
res.json({ success: false, error: 'Generation already running' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuthStatus('Before starting feature generation');
|
logAuthStatus('Before starting feature generation');
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setRunningState(projectPath, true, abortController);
|
setRunningState(true, abortController);
|
||||||
logger.info('Starting background feature generation task...');
|
logger.info('Starting background feature generation task...');
|
||||||
|
|
||||||
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
|
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
|
||||||
@@ -63,7 +63,7 @@ export function createGenerateFeaturesHandler(
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
logger.info('Feature generation task finished (success or error)');
|
logger.info('Feature generation task finished (success or error)');
|
||||||
setRunningState(projectPath, false, null);
|
setRunningState(false, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Returning success response (generation running in background)');
|
logger.info('Returning success response (generation running in background)');
|
||||||
|
|||||||
@@ -48,17 +48,17 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
const { isRunning } = getSpecRegenerationStatus();
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
logger.warn('Generation already running for project:', projectPath);
|
logger.warn('Generation already running, rejecting request');
|
||||||
res.json({ success: false, error: 'Spec generation already running for this project' });
|
res.json({ success: false, error: 'Spec generation already running' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuthStatus('Before starting generation');
|
logAuthStatus('Before starting generation');
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setRunningState(projectPath, true, abortController);
|
setRunningState(true, abortController);
|
||||||
logger.info('Starting background generation task...');
|
logger.info('Starting background generation task...');
|
||||||
|
|
||||||
generateSpec(
|
generateSpec(
|
||||||
@@ -81,7 +81,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
logger.info('Generation task finished (success or error)');
|
logger.info('Generation task finished (success or error)');
|
||||||
setRunningState(projectPath, false, null);
|
setRunningState(false, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Returning success response (generation running in background)');
|
logger.info('Returning success response (generation running in background)');
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import type { Request, Response } from 'express';
|
|||||||
import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
|
import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
|
||||||
|
|
||||||
export function createStatusHandler() {
|
export function createStatusHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const projectPath = req.query.projectPath as string | undefined;
|
const { isRunning } = getSpecRegenerationStatus();
|
||||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
res.json({ success: true, isRunning });
|
||||||
res.json({ success: true, isRunning, projectPath });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,13 @@ import type { Request, Response } from 'express';
|
|||||||
import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
|
import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
|
||||||
|
|
||||||
export function createStopHandler() {
|
export function createStopHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath } = req.body as { projectPath?: string };
|
const { currentAbortController } = getSpecRegenerationStatus();
|
||||||
const { currentAbortController } = getSpecRegenerationStatus(projectPath);
|
|
||||||
if (currentAbortController) {
|
if (currentAbortController) {
|
||||||
currentAbortController.abort();
|
currentAbortController.abort();
|
||||||
}
|
}
|
||||||
if (projectPath) {
|
setRunningState(false, null);
|
||||||
setRunningState(projectPath, false, null);
|
|
||||||
}
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
|||||||
@@ -229,13 +229,12 @@ export function createAuthRoutes(): Router {
|
|||||||
await invalidateSession(sessionToken);
|
await invalidateSession(sessionToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the cookie by setting it to empty with immediate expiration
|
// Clear the cookie
|
||||||
// Using res.cookie() with maxAge: 0 is more reliable than clearCookie()
|
res.clearCookie(cookieName, {
|
||||||
// in cross-origin development environments
|
httpOnly: true,
|
||||||
res.cookie(cookieName, '', {
|
secure: process.env.NODE_ENV === 'production',
|
||||||
...getSessionCookieOptions(),
|
sameSite: 'strict',
|
||||||
maxAge: 0,
|
path: '/',
|
||||||
expires: new Date(0),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { createAnalyzeProjectHandler } from './routes/analyze-project.js';
|
|||||||
import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
|
import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
|
||||||
import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
||||||
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
||||||
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
|
||||||
|
|
||||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -64,11 +63,6 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
|||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
createApprovePlanHandler(autoModeService)
|
createApprovePlanHandler(autoModeService)
|
||||||
);
|
);
|
||||||
router.post(
|
|
||||||
'/resume-interrupted',
|
|
||||||
validatePathParams('projectPath'),
|
|
||||||
createResumeInterruptedHandler(autoModeService)
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
|||||||
// Start follow-up in background
|
// Start follow-up in background
|
||||||
// followUpFeature derives workDir from feature.branchName
|
// followUpFeature derives workDir from feature.branchName
|
||||||
autoModeService
|
autoModeService
|
||||||
// Default to false to match run-feature/resume-feature behavior.
|
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? true)
|
||||||
// Worktrees should only be used when explicitly enabled by the user.
|
|
||||||
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? false)
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
|
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* Resume Interrupted Features Handler
|
|
||||||
*
|
|
||||||
* Checks for features that were interrupted (in pipeline steps or in_progress)
|
|
||||||
* when the server was restarted and resumes them.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
|
||||||
|
|
||||||
const logger = createLogger('ResumeInterrupted');
|
|
||||||
|
|
||||||
interface ResumeInterruptedRequest {
|
|
||||||
projectPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createResumeInterruptedHandler(autoModeService: AutoModeService) {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
const { projectPath } = req.body as ResumeInterruptedRequest;
|
|
||||||
|
|
||||||
if (!projectPath) {
|
|
||||||
res.status(400).json({ error: 'Project path is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Checking for interrupted features in ${projectPath}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await autoModeService.resumeInterruptedFeatures(projectPath);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Resume check completed',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error resuming interrupted features:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -7,12 +7,7 @@
|
|||||||
|
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
|
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
|
||||||
import {
|
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
||||||
DEFAULT_PHASE_MODELS,
|
|
||||||
isCursorModel,
|
|
||||||
stripProviderPrefix,
|
|
||||||
type ThinkingLevel,
|
|
||||||
} from '@automaker/types';
|
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||||
@@ -125,8 +120,6 @@ export async function generateBacklogPlan(
|
|||||||
logger.info('[BacklogPlan] Using model:', effectiveModel);
|
logger.info('[BacklogPlan] Using model:', effectiveModel);
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(effectiveModel);
|
|
||||||
|
|
||||||
// Get autoLoadClaudeMd setting
|
// Get autoLoadClaudeMd setting
|
||||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||||
@@ -158,7 +151,7 @@ ${userPrompt}`;
|
|||||||
// Execute the query
|
// Execute the query
|
||||||
const stream = provider.executeQuery({
|
const stream = provider.executeQuery({
|
||||||
prompt: finalPrompt,
|
prompt: finalPrompt,
|
||||||
model: bareModel,
|
model: effectiveModel,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
systemPrompt: finalSystemPrompt,
|
systemPrompt: finalSystemPrompt,
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
|
|||||||
@@ -12,22 +12,11 @@ const featureLoader = new FeatureLoader();
|
|||||||
export function createApplyHandler() {
|
export function createApplyHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const {
|
const { projectPath, plan } = req.body as {
|
||||||
projectPath,
|
|
||||||
plan,
|
|
||||||
branchName: rawBranchName,
|
|
||||||
} = req.body as {
|
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
plan: BacklogPlanResult;
|
plan: BacklogPlanResult;
|
||||||
branchName?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate branchName: must be undefined or a non-empty trimmed string
|
|
||||||
const branchName =
|
|
||||||
typeof rawBranchName === 'string' && rawBranchName.trim().length > 0
|
|
||||||
? rawBranchName.trim()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||||
return;
|
return;
|
||||||
@@ -93,7 +82,6 @@ export function createApplyHandler() {
|
|||||||
dependencies: change.feature.dependencies,
|
dependencies: change.feature.dependencies,
|
||||||
priority: change.feature.priority,
|
priority: change.feature.priority,
|
||||||
status: 'backlog',
|
status: 'backlog',
|
||||||
branchName,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
appliedChanges.push(`added:${newFeature.id}`);
|
appliedChanges.push(`added:${newFeature.id}`);
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
|||||||
// Check if Claude CLI is available first
|
// Check if Claude CLI is available first
|
||||||
const isAvailable = await service.isAvailable();
|
const isAvailable = await service.isAvailable();
|
||||||
if (!isAvailable) {
|
if (!isAvailable) {
|
||||||
// IMPORTANT: This endpoint is behind Automaker session auth already.
|
res.status(503).json({
|
||||||
// Use a 200 + error payload for Claude CLI issues so the UI doesn't
|
|
||||||
// interpret it as an invalid Automaker session (401/403 triggers logout).
|
|
||||||
res.status(200).json({
|
|
||||||
error: 'Claude CLI not found',
|
error: 'Claude CLI not found',
|
||||||
message: "Please install Claude Code CLI and run 'claude login' to authenticate",
|
message: "Please install Claude Code CLI and run 'claude login' to authenticate",
|
||||||
});
|
});
|
||||||
@@ -29,13 +26,12 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
|||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
if (message.includes('Authentication required') || message.includes('token_expired')) {
|
if (message.includes('Authentication required') || message.includes('token_expired')) {
|
||||||
// Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
|
res.status(401).json({
|
||||||
res.status(200).json({
|
|
||||||
error: 'Authentication required',
|
error: 'Authentication required',
|
||||||
message: "Please run 'claude login' to authenticate",
|
message: "Please run 'claude login' to authenticate",
|
||||||
});
|
});
|
||||||
} else if (message.includes('timed out')) {
|
} else if (message.includes('timed out')) {
|
||||||
res.status(200).json({
|
res.status(504).json({
|
||||||
error: 'Command timed out',
|
error: 'Command timed out',
|
||||||
message: 'The Claude CLI took too long to respond',
|
message: 'The Claude CLI took too long to respond',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
|
||||||
import { CodexUsageService } from '../../services/codex-usage-service.js';
|
|
||||||
import { CodexModelCacheService } from '../../services/codex-model-cache-service.js';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('Codex');
|
|
||||||
|
|
||||||
export function createCodexRoutes(
|
|
||||||
usageService: CodexUsageService,
|
|
||||||
modelCacheService: CodexModelCacheService
|
|
||||||
): Router {
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// Get current usage (attempts to fetch from Codex CLI)
|
|
||||||
router.get('/usage', async (_req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
// Check if Codex CLI is available first
|
|
||||||
const isAvailable = await usageService.isAvailable();
|
|
||||||
if (!isAvailable) {
|
|
||||||
// IMPORTANT: This endpoint is behind Automaker session auth already.
|
|
||||||
// Use a 200 + error payload for Codex CLI issues so the UI doesn't
|
|
||||||
// interpret it as an invalid Automaker session (401/403 triggers logout).
|
|
||||||
res.status(200).json({
|
|
||||||
error: 'Codex CLI not found',
|
|
||||||
message: "Please install Codex CLI and run 'codex login' to authenticate",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = await usageService.fetchUsageData();
|
|
||||||
res.json(usage);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
|
|
||||||
if (message.includes('not authenticated') || message.includes('login')) {
|
|
||||||
// Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
|
|
||||||
res.status(200).json({
|
|
||||||
error: 'Authentication required',
|
|
||||||
message: "Please run 'codex login' to authenticate",
|
|
||||||
});
|
|
||||||
} else if (message.includes('not available') || message.includes('does not provide')) {
|
|
||||||
// This is the expected case - Codex doesn't provide usage stats
|
|
||||||
res.status(200).json({
|
|
||||||
error: 'Usage statistics not available',
|
|
||||||
message: message,
|
|
||||||
});
|
|
||||||
} else if (message.includes('timed out')) {
|
|
||||||
res.status(200).json({
|
|
||||||
error: 'Command timed out',
|
|
||||||
message: 'The Codex CLI took too long to respond',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.error('Error fetching usage:', error);
|
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get available Codex models (cached)
|
|
||||||
router.get('/models', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const forceRefresh = req.query.refresh === 'true';
|
|
||||||
const { models, cachedAt } = await modelCacheService.getModelsWithMetadata(forceRefresh);
|
|
||||||
|
|
||||||
if (models.length === 0) {
|
|
||||||
res.status(503).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Codex CLI not available or not authenticated',
|
|
||||||
message: "Please install Codex CLI and run 'codex login' to authenticate",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
models,
|
|
||||||
cachedAt,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error fetching models:', error);
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||||
import { PathNotAllowedError } from '@automaker/platform';
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||||
@@ -198,8 +198,6 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
|||||||
logger.info(`Using Cursor provider for model: ${model}`);
|
logger.info(`Using Cursor provider for model: ${model}`);
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(model);
|
|
||||||
|
|
||||||
// Build a simple text prompt for Cursor (no multi-part content blocks)
|
// Build a simple text prompt for Cursor (no multi-part content blocks)
|
||||||
const cursorPrompt = `${instructionText}\n\n--- FILE CONTENT ---\n${contentToAnalyze}`;
|
const cursorPrompt = `${instructionText}\n\n--- FILE CONTENT ---\n${contentToAnalyze}`;
|
||||||
@@ -207,7 +205,7 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
|||||||
let responseText = '';
|
let responseText = '';
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model: bareModel,
|
model,
|
||||||
cwd,
|
cwd,
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
@@ -234,6 +232,7 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
|||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
|
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||||
thinkingLevel, // Pass thinking level for extended thinking
|
thinkingLevel, // Pass thinking level for extended thinking
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||||
@@ -357,8 +357,6 @@ export function createDescribeImageHandler(
|
|||||||
logger.info(`[${requestId}] Using Cursor provider for model: ${model}`);
|
logger.info(`[${requestId}] Using Cursor provider for model: ${model}`);
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(model);
|
|
||||||
|
|
||||||
// Build prompt with image reference for Cursor
|
// Build prompt with image reference for Cursor
|
||||||
// Note: Cursor CLI may not support base64 image blocks directly,
|
// Note: Cursor CLI may not support base64 image blocks directly,
|
||||||
@@ -369,7 +367,7 @@ export function createDescribeImageHandler(
|
|||||||
const queryStart = Date.now();
|
const queryStart = Date.now();
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model: bareModel,
|
model,
|
||||||
cwd,
|
cwd,
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: ['Read'], // Allow Read tool so Cursor can read the image if needed
|
allowedTools: ['Read'], // Allow Read tool so Cursor can read the image if needed
|
||||||
@@ -396,13 +394,14 @@ export function createDescribeImageHandler(
|
|||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
|
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||||
thinkingLevel, // Pass thinking level for extended thinking
|
thinkingLevel, // Pass thinking level for extended thinking
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
|
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
|
||||||
sdkOptions.allowedTools
|
sdkOptions.allowedTools
|
||||||
)}`
|
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const promptGenerator = (async function* () {
|
const promptGenerator = (async function* () {
|
||||||
|
|||||||
332
apps/server/src/routes/debug/index.ts
Normal file
332
apps/server/src/routes/debug/index.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* Debug routes - HTTP API for debug panel and performance monitoring
|
||||||
|
*
|
||||||
|
* These routes are only enabled in development mode.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
|
import { PerformanceMonitorService } from '../../services/performance-monitor-service.js';
|
||||||
|
import { ProcessRegistryService } from '../../services/process-registry-service.js';
|
||||||
|
import {
|
||||||
|
createGetMetricsHandler,
|
||||||
|
createStartMetricsHandler,
|
||||||
|
createStopMetricsHandler,
|
||||||
|
createForceGCHandler,
|
||||||
|
createClearHistoryHandler,
|
||||||
|
} from './routes/metrics.js';
|
||||||
|
import {
|
||||||
|
createGetProcessesHandler,
|
||||||
|
createGetProcessHandler,
|
||||||
|
createGetSummaryHandler,
|
||||||
|
createGetAgentsHandler,
|
||||||
|
createGetAgentMetricsHandler,
|
||||||
|
createGetAgentSummaryHandler,
|
||||||
|
} from './routes/processes.js';
|
||||||
|
|
||||||
|
export interface DebugServices {
|
||||||
|
performanceMonitor: PerformanceMonitorService;
|
||||||
|
processRegistry: ProcessRegistryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and initialize debug services
|
||||||
|
*/
|
||||||
|
export function createDebugServices(events: EventEmitter): DebugServices {
|
||||||
|
// Create services
|
||||||
|
const processRegistry = new ProcessRegistryService(events);
|
||||||
|
const performanceMonitor = new PerformanceMonitorService(events);
|
||||||
|
|
||||||
|
// Wire them together - performance monitor gets processes from registry
|
||||||
|
performanceMonitor.setProcessProvider(processRegistry.getProcessProvider());
|
||||||
|
|
||||||
|
// Subscribe to AutoMode events to track feature execution as processes
|
||||||
|
// Events are wrapped in 'auto-mode:event' with the actual type in data.type
|
||||||
|
events.subscribe((eventType, data) => {
|
||||||
|
// Handle auto-mode:event
|
||||||
|
if (eventType === 'auto-mode:event') {
|
||||||
|
handleAutoModeEvent(processRegistry, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle agent:stream events for chat sessions
|
||||||
|
if (eventType === 'agent:stream') {
|
||||||
|
handleAgentStreamEvent(processRegistry, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle AutoMode events for feature execution tracking
|
||||||
|
*/
|
||||||
|
function handleAutoModeEvent(registry: ProcessRegistryService, data: unknown): void {
|
||||||
|
const eventData = data as { type?: string; [key: string]: unknown };
|
||||||
|
const innerType = eventData.type;
|
||||||
|
|
||||||
|
if (innerType === 'auto_mode_feature_start') {
|
||||||
|
const { featureId, projectPath, feature, model } = eventData as {
|
||||||
|
featureId: string;
|
||||||
|
projectPath: string;
|
||||||
|
feature?: { id: string; title: string; description?: string };
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register the feature as a tracked process
|
||||||
|
// Use -1 for pid since this isn't a real OS process
|
||||||
|
registry.registerProcess({
|
||||||
|
id: `agent-${featureId}`,
|
||||||
|
pid: -1,
|
||||||
|
type: 'agent',
|
||||||
|
name: feature?.title || `Feature ${featureId}`,
|
||||||
|
featureId,
|
||||||
|
cwd: projectPath,
|
||||||
|
command: model ? `claude ${model}` : 'claude agent',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize resource metrics
|
||||||
|
registry.initializeAgentMetrics(`agent-${featureId}`, { featureId });
|
||||||
|
|
||||||
|
// Mark it as running
|
||||||
|
registry.markRunning(`agent-${featureId}`);
|
||||||
|
} else if (innerType === 'auto_mode_feature_complete') {
|
||||||
|
const { featureId, passes, message } = eventData as {
|
||||||
|
featureId: string;
|
||||||
|
passes: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processId = `agent-${featureId}`;
|
||||||
|
if (registry.hasProcess(processId)) {
|
||||||
|
// Finalize the metrics before marking as stopped
|
||||||
|
registry.finalizeAgentMetrics(processId);
|
||||||
|
|
||||||
|
if (passes) {
|
||||||
|
registry.markStopped(processId, 0);
|
||||||
|
} else {
|
||||||
|
registry.markError(processId, message || 'Feature failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (innerType === 'auto_mode_error') {
|
||||||
|
const { featureId, error } = eventData as {
|
||||||
|
featureId?: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (featureId) {
|
||||||
|
const processId = `agent-${featureId}`;
|
||||||
|
if (registry.hasProcess(processId)) {
|
||||||
|
registry.finalizeAgentMetrics(processId);
|
||||||
|
registry.markError(processId, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (innerType === 'auto_mode_tool_use') {
|
||||||
|
// Track tool usage for the feature
|
||||||
|
const { featureId, tool } = eventData as {
|
||||||
|
featureId: string;
|
||||||
|
tool: { name: string; input?: unknown };
|
||||||
|
};
|
||||||
|
|
||||||
|
const processId = `agent-${featureId}`;
|
||||||
|
if (registry.hasProcess(processId)) {
|
||||||
|
registry.recordToolUse(processId, { toolName: tool.name });
|
||||||
|
|
||||||
|
// Record file operations based on tool type
|
||||||
|
if (tool.name === 'Read' && tool.input) {
|
||||||
|
const input = tool.input as { file_path?: string };
|
||||||
|
if (input.file_path) {
|
||||||
|
registry.recordFileOperation(processId, {
|
||||||
|
operation: 'read',
|
||||||
|
filePath: input.file_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (tool.name === 'Write' && tool.input) {
|
||||||
|
const input = tool.input as { file_path?: string; content?: string };
|
||||||
|
if (input.file_path) {
|
||||||
|
registry.recordFileOperation(processId, {
|
||||||
|
operation: 'write',
|
||||||
|
filePath: input.file_path,
|
||||||
|
bytes: input.content?.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (tool.name === 'Edit' && tool.input) {
|
||||||
|
const input = tool.input as { file_path?: string; new_string?: string };
|
||||||
|
if (input.file_path) {
|
||||||
|
registry.recordFileOperation(processId, {
|
||||||
|
operation: 'edit',
|
||||||
|
filePath: input.file_path,
|
||||||
|
bytes: input.new_string?.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (tool.name === 'Glob') {
|
||||||
|
const input = tool.input as { path?: string };
|
||||||
|
registry.recordFileOperation(processId, {
|
||||||
|
operation: 'glob',
|
||||||
|
filePath: input?.path || '.',
|
||||||
|
});
|
||||||
|
} else if (tool.name === 'Grep') {
|
||||||
|
const input = tool.input as { path?: string };
|
||||||
|
registry.recordFileOperation(processId, {
|
||||||
|
operation: 'grep',
|
||||||
|
filePath: input?.path || '.',
|
||||||
|
});
|
||||||
|
} else if (tool.name === 'Bash' && tool.input) {
|
||||||
|
const input = tool.input as { command?: string };
|
||||||
|
if (input.command) {
|
||||||
|
registry.recordBashCommand(processId, {
|
||||||
|
command: input.command,
|
||||||
|
executionTime: 0, // Will be updated on completion
|
||||||
|
exitCode: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle agent:stream events for chat session tracking
|
||||||
|
*/
|
||||||
|
function handleAgentStreamEvent(registry: ProcessRegistryService, data: unknown): void {
|
||||||
|
const eventData = data as {
|
||||||
|
sessionId?: string;
|
||||||
|
type?: string;
|
||||||
|
tool?: { name: string; input?: unknown };
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { sessionId, type } = eventData;
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
const processId = `chat-${sessionId}`;
|
||||||
|
|
||||||
|
// Register chat session as a process if not already tracked
|
||||||
|
if (!registry.hasProcess(processId) && type !== 'complete' && type !== 'error') {
|
||||||
|
registry.registerProcess({
|
||||||
|
id: processId,
|
||||||
|
pid: -1,
|
||||||
|
type: 'agent',
|
||||||
|
name: `Chat Session`,
|
||||||
|
sessionId,
|
||||||
|
command: 'claude chat',
|
||||||
|
});
|
||||||
|
registry.initializeAgentMetrics(processId, { sessionId });
|
||||||
|
registry.markRunning(processId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different event types
|
||||||
|
if (type === 'tool_use' && eventData.tool) {
|
||||||
|
const tool = eventData.tool;
|
||||||
|
registry.recordToolUse(processId, { toolName: tool.name });
|
||||||
|
|
||||||
|
// Record file operations based on tool type
|
||||||
|
if (tool.name === 'Read' && tool.input) {
|
||||||
|
const input = tool.input as { file_path?: string };
|
||||||
|
if (input.file_path) {
|
||||||
|
registry.recordFileOperation(processId, {
|
||||||
|
operation: 'read',
|
||||||
|
filePath: input.file_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (tool.name === 'Write' && tool.input) {
|
||||||
|
const input = tool.input as { file_path?: string; content?: string };
|
||||||
|
if (input.file_path) {
|
||||||
|
registry.recordFileOperation(processId, {
|
||||||
|
operation: 'write',
|
||||||
|
filePath: input.file_path,
|
||||||
|
bytes: input.content?.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (tool.name === 'Edit' && tool.input) {
|
||||||
|
const input = tool.input as { file_path?: string; new_string?: string };
|
||||||
|
if (input.file_path) {
|
||||||
|
registry.recordFileOperation(processId, {
|
||||||
|
operation: 'edit',
|
||||||
|
filePath: input.file_path,
|
||||||
|
bytes: input.new_string?.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (tool.name === 'Glob') {
|
||||||
|
const input = tool.input as { path?: string };
|
||||||
|
registry.recordFileOperation(processId, {
|
||||||
|
operation: 'glob',
|
||||||
|
filePath: input?.path || '.',
|
||||||
|
});
|
||||||
|
} else if (tool.name === 'Grep') {
|
||||||
|
const input = tool.input as { path?: string };
|
||||||
|
registry.recordFileOperation(processId, {
|
||||||
|
operation: 'grep',
|
||||||
|
filePath: input?.path || '.',
|
||||||
|
});
|
||||||
|
} else if (tool.name === 'Bash' && tool.input) {
|
||||||
|
const input = tool.input as { command?: string };
|
||||||
|
if (input.command) {
|
||||||
|
registry.recordBashCommand(processId, {
|
||||||
|
command: input.command,
|
||||||
|
executionTime: 0,
|
||||||
|
exitCode: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type === 'complete') {
|
||||||
|
if (registry.hasProcess(processId)) {
|
||||||
|
registry.finalizeAgentMetrics(processId);
|
||||||
|
// Keep the session as "idle" rather than "stopped" since it can receive more messages
|
||||||
|
registry.markIdle(processId);
|
||||||
|
}
|
||||||
|
} else if (type === 'error') {
|
||||||
|
if (registry.hasProcess(processId)) {
|
||||||
|
registry.finalizeAgentMetrics(processId);
|
||||||
|
const errorMsg = (eventData.error as string) || 'Unknown error';
|
||||||
|
registry.markError(processId, errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start services
|
||||||
|
processRegistry.start();
|
||||||
|
performanceMonitor.start();
|
||||||
|
|
||||||
|
return {
|
||||||
|
performanceMonitor,
|
||||||
|
processRegistry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop debug services
|
||||||
|
*/
|
||||||
|
export function stopDebugServices(services: DebugServices): void {
|
||||||
|
services.performanceMonitor.stop();
|
||||||
|
services.processRegistry.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create debug routes
|
||||||
|
*/
|
||||||
|
export function createDebugRoutes(services: DebugServices): Router {
|
||||||
|
const router = Router();
|
||||||
|
const { performanceMonitor, processRegistry } = services;
|
||||||
|
|
||||||
|
// Metrics routes
|
||||||
|
router.get('/metrics', createGetMetricsHandler(performanceMonitor));
|
||||||
|
router.post('/metrics/start', createStartMetricsHandler(performanceMonitor));
|
||||||
|
router.post('/metrics/stop', createStopMetricsHandler(performanceMonitor));
|
||||||
|
router.post('/metrics/gc', createForceGCHandler(performanceMonitor));
|
||||||
|
router.post('/metrics/clear', createClearHistoryHandler(performanceMonitor));
|
||||||
|
|
||||||
|
// Process routes
|
||||||
|
router.get('/processes', createGetProcessesHandler(processRegistry));
|
||||||
|
router.get('/processes/summary', createGetSummaryHandler(processRegistry));
|
||||||
|
router.get('/processes/:id', createGetProcessHandler(processRegistry));
|
||||||
|
|
||||||
|
// Agent resource metrics routes
|
||||||
|
router.get('/agents', createGetAgentsHandler(processRegistry));
|
||||||
|
router.get('/agents/summary', createGetAgentSummaryHandler(processRegistry));
|
||||||
|
router.get('/agents/:id/metrics', createGetAgentMetricsHandler(processRegistry));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export services for use elsewhere
|
||||||
|
export { PerformanceMonitorService } from '../../services/performance-monitor-service.js';
|
||||||
|
export { ProcessRegistryService } from '../../services/process-registry-service.js';
|
||||||
152
apps/server/src/routes/debug/routes/metrics.ts
Normal file
152
apps/server/src/routes/debug/routes/metrics.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Debug metrics route handler
|
||||||
|
*
|
||||||
|
* GET /api/debug/metrics - Get current metrics snapshot
|
||||||
|
* POST /api/debug/metrics/start - Start metrics collection
|
||||||
|
* POST /api/debug/metrics/stop - Stop metrics collection
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { PerformanceMonitorService } from '../../../services/performance-monitor-service.js';
|
||||||
|
import type { StartDebugMetricsRequest, DebugMetricsResponse } from '@automaker/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler for GET /api/debug/metrics
|
||||||
|
* Returns current metrics snapshot
|
||||||
|
*/
|
||||||
|
export function createGetMetricsHandler(performanceMonitor: PerformanceMonitorService) {
|
||||||
|
return (_req: Request, res: Response) => {
|
||||||
|
const snapshot = performanceMonitor.getLatestSnapshot();
|
||||||
|
const config = performanceMonitor.getConfig();
|
||||||
|
const active = performanceMonitor.isActive();
|
||||||
|
|
||||||
|
const response: DebugMetricsResponse = {
|
||||||
|
active,
|
||||||
|
config,
|
||||||
|
snapshot: snapshot ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and sanitize debug metrics config values
|
||||||
|
* Prevents DoS via extreme configuration values
|
||||||
|
*/
|
||||||
|
function sanitizeConfig(
|
||||||
|
config: Partial<import('@automaker/types').DebugMetricsConfig>
|
||||||
|
): Partial<import('@automaker/types').DebugMetricsConfig> {
|
||||||
|
const sanitized: Partial<import('@automaker/types').DebugMetricsConfig> = {};
|
||||||
|
|
||||||
|
// Collection interval: min 100ms, max 60s (prevents CPU exhaustion)
|
||||||
|
if (typeof config.collectionInterval === 'number') {
|
||||||
|
sanitized.collectionInterval = Math.min(
|
||||||
|
60000,
|
||||||
|
Math.max(100, Math.floor(config.collectionInterval))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max data points: min 10, max 10000 (prevents memory exhaustion)
|
||||||
|
if (typeof config.maxDataPoints === 'number') {
|
||||||
|
sanitized.maxDataPoints = Math.min(10000, Math.max(10, Math.floor(config.maxDataPoints)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leak threshold: min 1KB, max 100MB (reasonable bounds)
|
||||||
|
if (typeof config.leakThreshold === 'number') {
|
||||||
|
sanitized.leakThreshold = Math.min(
|
||||||
|
100 * 1024 * 1024,
|
||||||
|
Math.max(1024, Math.floor(config.leakThreshold))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean flags - only accept actual booleans
|
||||||
|
if (typeof config.memoryEnabled === 'boolean') {
|
||||||
|
sanitized.memoryEnabled = config.memoryEnabled;
|
||||||
|
}
|
||||||
|
if (typeof config.cpuEnabled === 'boolean') {
|
||||||
|
sanitized.cpuEnabled = config.cpuEnabled;
|
||||||
|
}
|
||||||
|
if (typeof config.processTrackingEnabled === 'boolean') {
|
||||||
|
sanitized.processTrackingEnabled = config.processTrackingEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler for POST /api/debug/metrics/start
|
||||||
|
* Starts metrics collection with optional config overrides
|
||||||
|
*/
|
||||||
|
export function createStartMetricsHandler(performanceMonitor: PerformanceMonitorService) {
|
||||||
|
return (req: Request, res: Response) => {
|
||||||
|
const body = req.body as StartDebugMetricsRequest | undefined;
|
||||||
|
|
||||||
|
// Update config if provided (with validation)
|
||||||
|
if (body?.config && typeof body.config === 'object') {
|
||||||
|
const sanitizedConfig = sanitizeConfig(body.config);
|
||||||
|
if (Object.keys(sanitizedConfig).length > 0) {
|
||||||
|
performanceMonitor.updateConfig(sanitizedConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start collection
|
||||||
|
performanceMonitor.start();
|
||||||
|
|
||||||
|
const response: DebugMetricsResponse = {
|
||||||
|
active: true,
|
||||||
|
config: performanceMonitor.getConfig(),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler for POST /api/debug/metrics/stop
|
||||||
|
* Stops metrics collection
|
||||||
|
*/
|
||||||
|
export function createStopMetricsHandler(performanceMonitor: PerformanceMonitorService) {
|
||||||
|
return (_req: Request, res: Response) => {
|
||||||
|
performanceMonitor.stop();
|
||||||
|
|
||||||
|
const response: DebugMetricsResponse = {
|
||||||
|
active: false,
|
||||||
|
config: performanceMonitor.getConfig(),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler for POST /api/debug/metrics/gc
|
||||||
|
* Forces garbage collection if available
|
||||||
|
*/
|
||||||
|
export function createForceGCHandler(performanceMonitor: PerformanceMonitorService) {
|
||||||
|
return (_req: Request, res: Response) => {
|
||||||
|
const success = performanceMonitor.forceGC();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success,
|
||||||
|
message: success
|
||||||
|
? 'Garbage collection triggered'
|
||||||
|
: 'Garbage collection not available (start Node.js with --expose-gc flag)',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler for POST /api/debug/metrics/clear
|
||||||
|
* Clears metrics history
|
||||||
|
*/
|
||||||
|
export function createClearHistoryHandler(performanceMonitor: PerformanceMonitorService) {
|
||||||
|
return (_req: Request, res: Response) => {
|
||||||
|
performanceMonitor.clearHistory();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Metrics history cleared',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
170
apps/server/src/routes/debug/routes/processes.ts
Normal file
170
apps/server/src/routes/debug/routes/processes.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Debug processes route handler
|
||||||
|
*
|
||||||
|
* GET /api/debug/processes - Get list of tracked processes
|
||||||
|
* GET /api/debug/processes/:id - Get specific process by ID
|
||||||
|
* POST /api/debug/processes/:id/terminate - Terminate a process
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { ProcessRegistryService } from '../../../services/process-registry-service.js';
|
||||||
|
import type {
|
||||||
|
GetProcessesRequest,
|
||||||
|
GetProcessesResponse,
|
||||||
|
ProcessType,
|
||||||
|
ProcessStatus,
|
||||||
|
} from '@automaker/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler for GET /api/debug/processes
|
||||||
|
* Returns list of tracked processes with optional filtering
|
||||||
|
*/
|
||||||
|
export function createGetProcessesHandler(processRegistry: ProcessRegistryService) {
|
||||||
|
return (req: Request, res: Response) => {
|
||||||
|
const query = req.query as {
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
includeStopped?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
featureId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build query options
|
||||||
|
const options: GetProcessesRequest = {};
|
||||||
|
|
||||||
|
if (query.type) {
|
||||||
|
options.type = query.type as ProcessType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.status) {
|
||||||
|
options.status = query.status as ProcessStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.includeStopped === 'true') {
|
||||||
|
options.includeStoppedProcesses = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processes = processRegistry.getProcesses({
|
||||||
|
type: options.type,
|
||||||
|
status: options.status,
|
||||||
|
includeStopped: options.includeStoppedProcesses,
|
||||||
|
sessionId: query.sessionId,
|
||||||
|
featureId: query.featureId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = processRegistry.getProcessSummary();
|
||||||
|
|
||||||
|
const response: GetProcessesResponse = {
|
||||||
|
processes,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate process ID format
|
||||||
|
* Process IDs should be non-empty strings with reasonable length
|
||||||
|
*/
|
||||||
|
function isValidProcessId(id: unknown): id is string {
|
||||||
|
return typeof id === 'string' && id.length > 0 && id.length <= 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler for GET /api/debug/processes/:id
|
||||||
|
* Returns a specific process by ID
|
||||||
|
*/
|
||||||
|
export function createGetProcessHandler(processRegistry: ProcessRegistryService) {
|
||||||
|
return (req: Request, res: Response) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Validate process ID format
|
||||||
|
if (!isValidProcessId(id)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Invalid process ID format',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const process = processRegistry.getProcess(id);
|
||||||
|
|
||||||
|
if (!process) {
|
||||||
|
res.status(404).json({
|
||||||
|
error: 'Process not found',
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(process);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler for GET /api/debug/processes/summary
|
||||||
|
* Returns summary statistics
|
||||||
|
*/
|
||||||
|
export function createGetSummaryHandler(processRegistry: ProcessRegistryService) {
|
||||||
|
return (_req: Request, res: Response) => {
|
||||||
|
const summary = processRegistry.getProcessSummary();
|
||||||
|
res.json(summary);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler for GET /api/debug/agents
|
||||||
|
* Returns all agent processes with their resource metrics
|
||||||
|
*/
|
||||||
|
export function createGetAgentsHandler(processRegistry: ProcessRegistryService) {
|
||||||
|
return (_req: Request, res: Response) => {
|
||||||
|
const agents = processRegistry.getAgentProcessesWithMetrics();
|
||||||
|
const summary = processRegistry.getAgentResourceSummary();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
agents,
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler for GET /api/debug/agents/:id/metrics
|
||||||
|
* Returns detailed resource metrics for a specific agent
|
||||||
|
*/
|
||||||
|
export function createGetAgentMetricsHandler(processRegistry: ProcessRegistryService) {
|
||||||
|
return (req: Request, res: Response) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Validate process ID format
|
||||||
|
if (!isValidProcessId(id)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Invalid agent ID format',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = processRegistry.getAgentMetrics(id);
|
||||||
|
|
||||||
|
if (!metrics) {
|
||||||
|
res.status(404).json({
|
||||||
|
error: 'Agent metrics not found',
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(metrics);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler for GET /api/debug/agents/summary
|
||||||
|
* Returns summary of resource usage across all agents
|
||||||
|
*/
|
||||||
|
export function createGetAgentSummaryHandler(processRegistry: ProcessRegistryService) {
|
||||||
|
return (_req: Request, res: Response) => {
|
||||||
|
const summary = processRegistry.getAgentResourceSummary();
|
||||||
|
res.json(summary);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,8 +12,6 @@ import { resolveModelString } from '@automaker/model-resolver';
|
|||||||
import {
|
import {
|
||||||
CLAUDE_MODEL_MAP,
|
CLAUDE_MODEL_MAP,
|
||||||
isCursorModel,
|
isCursorModel,
|
||||||
isOpencodeModel,
|
|
||||||
stripProviderPrefix,
|
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
getThinkingTokenBudget,
|
getThinkingTokenBudget,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
@@ -92,30 +90,24 @@ async function extractTextFromStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute enhancement using a provider (Cursor, OpenCode, etc.)
|
* Execute enhancement using Cursor provider
|
||||||
*
|
*
|
||||||
* @param prompt - The enhancement prompt
|
* @param prompt - The enhancement prompt
|
||||||
* @param model - The model to use
|
* @param model - The Cursor model to use
|
||||||
* @returns The enhanced text
|
* @returns The enhanced text
|
||||||
*/
|
*/
|
||||||
async function executeWithProvider(prompt: string, model: string): Promise<string> {
|
async function executeWithCursor(prompt: string, model: string): Promise<string> {
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(model);
|
|
||||||
|
|
||||||
let responseText = '';
|
let responseText = '';
|
||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt,
|
prompt,
|
||||||
model: bareModel,
|
model,
|
||||||
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
||||||
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
||||||
})) {
|
})) {
|
||||||
if (msg.type === 'error') {
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
// Throw error with the message from the provider
|
|
||||||
const errorMessage = msg.error || 'Provider returned an error';
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
} else if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
for (const block of msg.message.content) {
|
||||||
if (block.type === 'text' && block.text) {
|
if (block.type === 'text' && block.text) {
|
||||||
responseText += block.text;
|
responseText += block.text;
|
||||||
@@ -193,7 +185,6 @@ export function createEnhanceHandler(
|
|||||||
technical: prompts.enhancement.technicalSystemPrompt,
|
technical: prompts.enhancement.technicalSystemPrompt,
|
||||||
simplify: prompts.enhancement.simplifySystemPrompt,
|
simplify: prompts.enhancement.simplifySystemPrompt,
|
||||||
acceptance: prompts.enhancement.acceptanceSystemPrompt,
|
acceptance: prompts.enhancement.acceptanceSystemPrompt,
|
||||||
'ux-reviewer': prompts.enhancement.uxReviewerSystemPrompt,
|
|
||||||
};
|
};
|
||||||
const systemPrompt = systemPromptMap[validMode];
|
const systemPrompt = systemPromptMap[validMode];
|
||||||
|
|
||||||
@@ -217,14 +208,7 @@ export function createEnhanceHandler(
|
|||||||
|
|
||||||
// Cursor doesn't have a separate system prompt concept, so combine them
|
// Cursor doesn't have a separate system prompt concept, so combine them
|
||||||
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
||||||
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
|
enhancedText = await executeWithCursor(combinedPrompt, resolvedModel);
|
||||||
} else if (isOpencodeModel(resolvedModel)) {
|
|
||||||
// Use OpenCode provider for OpenCode models (static and dynamic)
|
|
||||||
logger.info(`Using OpenCode provider for model: ${resolvedModel}`);
|
|
||||||
|
|
||||||
// OpenCode CLI handles the system prompt, so combine them
|
|
||||||
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
|
||||||
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
|
|
||||||
} else {
|
} else {
|
||||||
// Use Claude SDK for Claude models
|
// Use Claude SDK for Claude models
|
||||||
logger.info(`Using Claude provider for model: ${resolvedModel}`);
|
logger.info(`Using Claude provider for model: ${resolvedModel}`);
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { createListHandler } from './routes/list.js';
|
|||||||
import { createGetHandler } from './routes/get.js';
|
import { createGetHandler } from './routes/get.js';
|
||||||
import { createCreateHandler } from './routes/create.js';
|
import { createCreateHandler } from './routes/create.js';
|
||||||
import { createUpdateHandler } from './routes/update.js';
|
import { createUpdateHandler } from './routes/update.js';
|
||||||
import { createBulkUpdateHandler } from './routes/bulk-update.js';
|
|
||||||
import { createBulkDeleteHandler } from './routes/bulk-delete.js';
|
|
||||||
import { createDeleteHandler } from './routes/delete.js';
|
import { createDeleteHandler } from './routes/delete.js';
|
||||||
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
||||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||||
@@ -22,16 +20,6 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
|||||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
||||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
|
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
|
||||||
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
|
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
|
||||||
router.post(
|
|
||||||
'/bulk-update',
|
|
||||||
validatePathParams('projectPath'),
|
|
||||||
createBulkUpdateHandler(featureLoader)
|
|
||||||
);
|
|
||||||
router.post(
|
|
||||||
'/bulk-delete',
|
|
||||||
validatePathParams('projectPath'),
|
|
||||||
createBulkDeleteHandler(featureLoader)
|
|
||||||
);
|
|
||||||
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
||||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /bulk-delete endpoint - Delete multiple features at once
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
|
||||||
|
|
||||||
interface BulkDeleteRequest {
|
|
||||||
projectPath: string;
|
|
||||||
featureIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BulkDeleteResult {
|
|
||||||
featureId: string;
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBulkDeleteHandler(featureLoader: FeatureLoader) {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { projectPath, featureIds } = req.body as BulkDeleteRequest;
|
|
||||||
|
|
||||||
if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'projectPath and featureIds (non-empty array) are required',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
featureIds.map(async (featureId) => {
|
|
||||||
const success = await featureLoader.delete(projectPath, featureId);
|
|
||||||
if (success) {
|
|
||||||
return { featureId, success: true };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
featureId,
|
|
||||||
success: false,
|
|
||||||
error: 'Deletion failed. Check server logs for details.',
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0);
|
|
||||||
const failureCount = results.length - successCount;
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: failureCount === 0,
|
|
||||||
deletedCount: successCount,
|
|
||||||
failedCount: failureCount,
|
|
||||||
results,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Bulk delete features failed');
|
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /bulk-update endpoint - Update multiple features at once
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
|
||||||
import type { Feature } from '@automaker/types';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
|
||||||
|
|
||||||
interface BulkUpdateRequest {
|
|
||||||
projectPath: string;
|
|
||||||
featureIds: string[];
|
|
||||||
updates: Partial<Feature>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BulkUpdateResult {
|
|
||||||
featureId: string;
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBulkUpdateHandler(featureLoader: FeatureLoader) {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { projectPath, featureIds, updates } = req.body as BulkUpdateRequest;
|
|
||||||
|
|
||||||
if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'projectPath and featureIds (non-empty array) are required',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!updates || Object.keys(updates).length === 0) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'updates object with at least one field is required',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: BulkUpdateResult[] = [];
|
|
||||||
const updatedFeatures: Feature[] = [];
|
|
||||||
|
|
||||||
for (const featureId of featureIds) {
|
|
||||||
try {
|
|
||||||
const updated = await featureLoader.update(projectPath, featureId, updates);
|
|
||||||
results.push({ featureId, success: true });
|
|
||||||
updatedFeatures.push(updated);
|
|
||||||
} catch (error) {
|
|
||||||
results.push({
|
|
||||||
featureId,
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const successCount = results.filter((r) => r.success).length;
|
|
||||||
const failureCount = results.filter((r) => !r.success).length;
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: failureCount === 0,
|
|
||||||
updatedCount: successCount,
|
|
||||||
failedCount: failureCount,
|
|
||||||
results,
|
|
||||||
features: updatedFeatures,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Bulk update features failed');
|
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -10,20 +10,10 @@ import { getErrorMessage, logError } from '../common.js';
|
|||||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const {
|
const { projectPath, featureId, updates } = req.body as {
|
||||||
projectPath,
|
|
||||||
featureId,
|
|
||||||
updates,
|
|
||||||
descriptionHistorySource,
|
|
||||||
enhancementMode,
|
|
||||||
preEnhancementDescription,
|
|
||||||
} = req.body as {
|
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
updates: Partial<Feature>;
|
updates: Partial<Feature>;
|
||||||
descriptionHistorySource?: 'enhance' | 'edit';
|
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
|
|
||||||
preEnhancementDescription?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !featureId || !updates) {
|
if (!projectPath || !featureId || !updates) {
|
||||||
@@ -34,14 +24,7 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await featureLoader.update(
|
const updated = await featureLoader.update(projectPath, featureId, updates);
|
||||||
projectPath,
|
|
||||||
featureId,
|
|
||||||
updates,
|
|
||||||
descriptionHistorySource,
|
|
||||||
enhancementMode,
|
|
||||||
preEnhancementDescription
|
|
||||||
);
|
|
||||||
res.json({ success: true, feature: updated });
|
res.json({ success: true, feature: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Update feature failed');
|
logError(error, 'Update feature failed');
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type {
|
|||||||
LinkedPRInfo,
|
LinkedPRInfo,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { isCursorModel, DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
|
import { isCursorModel, DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
|
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
|
||||||
import { extractJson } from '../../../lib/json-extractor.js';
|
import { extractJson } from '../../../lib/json-extractor.js';
|
||||||
@@ -120,8 +120,6 @@ async function runValidation(
|
|||||||
logger.info(`Using Cursor provider for validation with model: ${model}`);
|
logger.info(`Using Cursor provider for validation with model: ${model}`);
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(model);
|
|
||||||
|
|
||||||
// For Cursor, include the system prompt and schema in the user prompt
|
// For Cursor, include the system prompt and schema in the user prompt
|
||||||
const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
|
const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
|
||||||
@@ -139,7 +137,7 @@ ${prompt}`;
|
|||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model: bareModel,
|
model,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
readOnly: true, // Issue validation only reads code, doesn't write
|
readOnly: true, // Issue validation only reads code, doesn't write
|
||||||
})) {
|
})) {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { createGetProjectHandler } from './routes/get-project.js';
|
|||||||
import { createUpdateProjectHandler } from './routes/update-project.js';
|
import { createUpdateProjectHandler } from './routes/update-project.js';
|
||||||
import { createMigrateHandler } from './routes/migrate.js';
|
import { createMigrateHandler } from './routes/migrate.js';
|
||||||
import { createStatusHandler } from './routes/status.js';
|
import { createStatusHandler } from './routes/status.js';
|
||||||
import { createDiscoverAgentsHandler } from './routes/discover-agents.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create settings router with all endpoints
|
* Create settings router with all endpoints
|
||||||
@@ -40,7 +39,6 @@ import { createDiscoverAgentsHandler } from './routes/discover-agents.js';
|
|||||||
* - POST /project - Get project settings (requires projectPath in body)
|
* - POST /project - Get project settings (requires projectPath in body)
|
||||||
* - PUT /project - Update project settings
|
* - PUT /project - Update project settings
|
||||||
* - POST /migrate - Migrate settings from localStorage
|
* - POST /migrate - Migrate settings from localStorage
|
||||||
* - POST /agents/discover - Discover filesystem agents from .claude/agents/ (read-only)
|
|
||||||
*
|
*
|
||||||
* @param settingsService - Instance of SettingsService for file I/O
|
* @param settingsService - Instance of SettingsService for file I/O
|
||||||
* @returns Express Router configured with all settings endpoints
|
* @returns Express Router configured with all settings endpoints
|
||||||
@@ -74,8 +72,5 @@ export function createSettingsRoutes(settingsService: SettingsService): Router {
|
|||||||
// Migration from localStorage
|
// Migration from localStorage
|
||||||
router.post('/migrate', createMigrateHandler(settingsService));
|
router.post('/migrate', createMigrateHandler(settingsService));
|
||||||
|
|
||||||
// Filesystem agents discovery (read-only)
|
|
||||||
router.post('/agents/discover', createDiscoverAgentsHandler());
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Discover Agents Route - Returns filesystem-based agents from .claude/agents/
|
|
||||||
*
|
|
||||||
* Scans both user-level (~/.claude/agents/) and project-level (.claude/agents/)
|
|
||||||
* directories for AGENT.md files and returns parsed agent definitions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { discoverFilesystemAgents } from '../../../lib/agent-discovery.js';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('DiscoverAgentsRoute');
|
|
||||||
|
|
||||||
interface DiscoverAgentsRequest {
|
|
||||||
projectPath?: string;
|
|
||||||
sources?: Array<'user' | 'project'>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create handler for discovering filesystem agents
|
|
||||||
*
|
|
||||||
* POST /api/settings/agents/discover
|
|
||||||
* Body: { projectPath?: string, sources?: ['user', 'project'] }
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* {
|
|
||||||
* success: true,
|
|
||||||
* agents: Array<{
|
|
||||||
* name: string,
|
|
||||||
* definition: AgentDefinition,
|
|
||||||
* source: 'user' | 'project',
|
|
||||||
* filePath: string
|
|
||||||
* }>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export function createDiscoverAgentsHandler() {
|
|
||||||
return async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { projectPath, sources = ['user', 'project'] } = req.body as DiscoverAgentsRequest;
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Discovering agents from sources: ${sources.join(', ')}${projectPath ? ` (project: ${projectPath})` : ''}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const agents = await discoverFilesystemAgents(projectPath, sources);
|
|
||||||
|
|
||||||
logger.info(`Discovered ${agents.length} filesystem agents`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
agents,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to discover agents:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to discover agents',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { SettingsService } from '../../../services/settings-service.js';
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
import type { GlobalSettings } from '../../../types/settings.js';
|
import type { GlobalSettings } from '../../../types/settings.js';
|
||||||
import { getErrorMessage, logError, logger } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create handler factory for PUT /api/settings/global
|
* Create handler factory for PUT /api/settings/global
|
||||||
@@ -32,18 +32,6 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal debug logging to help diagnose accidental wipes.
|
|
||||||
if ('projects' in updates || 'theme' in updates || 'localStorageMigrated' in updates) {
|
|
||||||
const projectsLen = Array.isArray((updates as any).projects)
|
|
||||||
? (updates as any).projects.length
|
|
||||||
: undefined;
|
|
||||||
logger.info(
|
|
||||||
`Update global settings request: projects=${projectsLen ?? 'n/a'}, theme=${
|
|
||||||
(updates as any).theme ?? 'n/a'
|
|
||||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = await settingsService.updateGlobalSettings(updates);
|
const settings = await settingsService.updateGlobalSettings(updates);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -6,24 +6,9 @@ import { exec } from 'child_process';
|
|||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform';
|
import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform';
|
||||||
import { getApiKey } from './common.js';
|
import { getApiKey } from './common.js';
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
const DISCONNECTED_MARKER_FILE = '.claude-disconnected';
|
|
||||||
|
|
||||||
function isDisconnectedFromApp(): boolean {
|
|
||||||
try {
|
|
||||||
// Check if we're in a project directory
|
|
||||||
const projectRoot = process.cwd();
|
|
||||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
|
||||||
return fs.existsSync(markerPath);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getClaudeStatus() {
|
export async function getClaudeStatus() {
|
||||||
let installed = false;
|
let installed = false;
|
||||||
let version = '';
|
let version = '';
|
||||||
@@ -75,30 +60,6 @@ export async function getClaudeStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has manually disconnected from the app
|
|
||||||
if (isDisconnectedFromApp()) {
|
|
||||||
return {
|
|
||||||
status: installed ? 'installed' : 'not_installed',
|
|
||||||
installed,
|
|
||||||
method,
|
|
||||||
version,
|
|
||||||
path: cliPath,
|
|
||||||
auth: {
|
|
||||||
authenticated: false,
|
|
||||||
method: 'none',
|
|
||||||
hasCredentialsFile: false,
|
|
||||||
hasToken: false,
|
|
||||||
hasStoredOAuthToken: false,
|
|
||||||
hasStoredApiKey: false,
|
|
||||||
hasEnvApiKey: false,
|
|
||||||
oauthTokenValid: false,
|
|
||||||
apiKeyValid: false,
|
|
||||||
hasCliAuth: false,
|
|
||||||
hasRecentActivity: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check authentication - detect all possible auth methods
|
// Check authentication - detect all possible auth methods
|
||||||
// Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth
|
// Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth
|
||||||
// apiKeys.anthropic stores direct API keys for pay-per-use
|
// apiKeys.anthropic stores direct API keys for pay-per-use
|
||||||
|
|||||||
@@ -11,25 +11,8 @@ import { createDeleteApiKeyHandler } from './routes/delete-api-key.js';
|
|||||||
import { createApiKeysHandler } from './routes/api-keys.js';
|
import { createApiKeysHandler } from './routes/api-keys.js';
|
||||||
import { createPlatformHandler } from './routes/platform.js';
|
import { createPlatformHandler } from './routes/platform.js';
|
||||||
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
|
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
|
||||||
import { createVerifyCodexAuthHandler } from './routes/verify-codex-auth.js';
|
|
||||||
import { createGhStatusHandler } from './routes/gh-status.js';
|
import { createGhStatusHandler } from './routes/gh-status.js';
|
||||||
import { createCursorStatusHandler } from './routes/cursor-status.js';
|
import { createCursorStatusHandler } from './routes/cursor-status.js';
|
||||||
import { createCodexStatusHandler } from './routes/codex-status.js';
|
|
||||||
import { createInstallCodexHandler } from './routes/install-codex.js';
|
|
||||||
import { createAuthCodexHandler } from './routes/auth-codex.js';
|
|
||||||
import { createAuthCursorHandler } from './routes/auth-cursor.js';
|
|
||||||
import { createDeauthClaudeHandler } from './routes/deauth-claude.js';
|
|
||||||
import { createDeauthCodexHandler } from './routes/deauth-codex.js';
|
|
||||||
import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
|
|
||||||
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
|
|
||||||
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
|
||||||
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
|
|
||||||
import {
|
|
||||||
createGetOpencodeModelsHandler,
|
|
||||||
createRefreshOpencodeModelsHandler,
|
|
||||||
createGetOpencodeProvidersHandler,
|
|
||||||
createClearOpencodeCacheHandler,
|
|
||||||
} from './routes/opencode-models.js';
|
|
||||||
import {
|
import {
|
||||||
createGetCursorConfigHandler,
|
createGetCursorConfigHandler,
|
||||||
createSetCursorDefaultModelHandler,
|
createSetCursorDefaultModelHandler,
|
||||||
@@ -47,36 +30,15 @@ export function createSetupRoutes(): Router {
|
|||||||
router.get('/claude-status', createClaudeStatusHandler());
|
router.get('/claude-status', createClaudeStatusHandler());
|
||||||
router.post('/install-claude', createInstallClaudeHandler());
|
router.post('/install-claude', createInstallClaudeHandler());
|
||||||
router.post('/auth-claude', createAuthClaudeHandler());
|
router.post('/auth-claude', createAuthClaudeHandler());
|
||||||
router.post('/deauth-claude', createDeauthClaudeHandler());
|
|
||||||
router.post('/store-api-key', createStoreApiKeyHandler());
|
router.post('/store-api-key', createStoreApiKeyHandler());
|
||||||
router.post('/delete-api-key', createDeleteApiKeyHandler());
|
router.post('/delete-api-key', createDeleteApiKeyHandler());
|
||||||
router.get('/api-keys', createApiKeysHandler());
|
router.get('/api-keys', createApiKeysHandler());
|
||||||
router.get('/platform', createPlatformHandler());
|
router.get('/platform', createPlatformHandler());
|
||||||
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
|
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
|
||||||
router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
|
|
||||||
router.get('/gh-status', createGhStatusHandler());
|
router.get('/gh-status', createGhStatusHandler());
|
||||||
|
|
||||||
// Cursor CLI routes
|
// Cursor CLI routes
|
||||||
router.get('/cursor-status', createCursorStatusHandler());
|
router.get('/cursor-status', createCursorStatusHandler());
|
||||||
router.post('/auth-cursor', createAuthCursorHandler());
|
|
||||||
router.post('/deauth-cursor', createDeauthCursorHandler());
|
|
||||||
|
|
||||||
// Codex CLI routes
|
|
||||||
router.get('/codex-status', createCodexStatusHandler());
|
|
||||||
router.post('/install-codex', createInstallCodexHandler());
|
|
||||||
router.post('/auth-codex', createAuthCodexHandler());
|
|
||||||
router.post('/deauth-codex', createDeauthCodexHandler());
|
|
||||||
|
|
||||||
// OpenCode CLI routes
|
|
||||||
router.get('/opencode-status', createOpencodeStatusHandler());
|
|
||||||
router.post('/auth-opencode', createAuthOpencodeHandler());
|
|
||||||
router.post('/deauth-opencode', createDeauthOpencodeHandler());
|
|
||||||
|
|
||||||
// OpenCode Dynamic Model Discovery routes
|
|
||||||
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
|
||||||
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
|
|
||||||
router.get('/opencode/providers', createGetOpencodeProvidersHandler());
|
|
||||||
router.post('/opencode/cache/clear', createClearOpencodeCacheHandler());
|
|
||||||
router.get('/cursor-config', createGetCursorConfigHandler());
|
router.get('/cursor-config', createGetCursorConfigHandler());
|
||||||
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
|
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
|
||||||
router.post('/cursor-config/models', createSetCursorModelsHandler());
|
router.post('/cursor-config/models', createSetCursorModelsHandler());
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export function createApiKeysHandler() {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY,
|
hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY,
|
||||||
hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Get API keys failed');
|
logError(error, 'Get API keys failed');
|
||||||
|
|||||||
@@ -4,54 +4,19 @@
|
|||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
export function createAuthClaudeHandler() {
|
export function createAuthClaudeHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Remove the disconnected marker file to reconnect the app to the CLI
|
res.json({
|
||||||
const markerPath = path.join(process.cwd(), '.automaker', '.claude-disconnected');
|
success: true,
|
||||||
if (fs.existsSync(markerPath)) {
|
requiresManualAuth: true,
|
||||||
fs.unlinkSync(markerPath);
|
command: 'claude login',
|
||||||
}
|
message: "Please run 'claude login' in your terminal to authenticate",
|
||||||
|
});
|
||||||
// Check if CLI is already authenticated by checking auth indicators
|
|
||||||
const { getClaudeAuthIndicators } = await import('@automaker/platform');
|
|
||||||
const indicators = await getClaudeAuthIndicators();
|
|
||||||
const isAlreadyAuthenticated =
|
|
||||||
indicators.hasStatsCacheWithActivity ||
|
|
||||||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
|
|
||||||
indicators.hasCredentialsFile;
|
|
||||||
|
|
||||||
if (isAlreadyAuthenticated) {
|
|
||||||
// CLI is already authenticated, just reconnect
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Claude CLI is now linked with the app',
|
|
||||||
wasAlreadyAuthenticated: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// CLI needs authentication - but we can't run claude login here
|
|
||||||
// because it requires browser OAuth. Just reconnect and let the user authenticate if needed.
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message:
|
|
||||||
'Claude CLI is now linked with the app. If prompted, please authenticate with "claude login" in your terminal.',
|
|
||||||
requiresManualAuth: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Auth Claude failed');
|
logError(error, 'Auth Claude failed');
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
message: 'Failed to link Claude CLI with the app',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /auth-codex endpoint - Authenticate Codex CLI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { logError, getErrorMessage } from '../common.js';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
export function createAuthCodexHandler() {
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Remove the disconnected marker file to reconnect the app to the CLI
|
|
||||||
const markerPath = path.join(process.cwd(), '.automaker', '.codex-disconnected');
|
|
||||||
if (fs.existsSync(markerPath)) {
|
|
||||||
fs.unlinkSync(markerPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the same detection logic as the Codex provider
|
|
||||||
const { getCodexAuthIndicators } = await import('@automaker/platform');
|
|
||||||
const indicators = await getCodexAuthIndicators();
|
|
||||||
|
|
||||||
const isAlreadyAuthenticated =
|
|
||||||
indicators.hasApiKey || indicators.hasAuthFile || indicators.hasOAuthToken;
|
|
||||||
|
|
||||||
if (isAlreadyAuthenticated) {
|
|
||||||
// Already has authentication, just reconnect
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Codex CLI is now linked with the app',
|
|
||||||
wasAlreadyAuthenticated: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message:
|
|
||||||
'Codex CLI is now linked with the app. If prompted, please authenticate with "codex login" in your terminal.',
|
|
||||||
requiresManualAuth: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Auth Codex failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
message: 'Failed to link Codex CLI with the app',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /auth-cursor endpoint - Authenticate Cursor CLI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { logError, getErrorMessage } from '../common.js';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
export function createAuthCursorHandler() {
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Remove the disconnected marker file to reconnect the app to the CLI
|
|
||||||
const markerPath = path.join(process.cwd(), '.automaker', '.cursor-disconnected');
|
|
||||||
if (fs.existsSync(markerPath)) {
|
|
||||||
fs.unlinkSync(markerPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if Cursor is already authenticated using the same logic as CursorProvider
|
|
||||||
const isAlreadyAuthenticated = (): boolean => {
|
|
||||||
// Check for API key in environment
|
|
||||||
if (process.env.CURSOR_API_KEY) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for credentials files
|
|
||||||
const credentialPaths = [
|
|
||||||
path.join(os.homedir(), '.cursor', 'credentials.json'),
|
|
||||||
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const credPath of credentialPaths) {
|
|
||||||
if (fs.existsSync(credPath)) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(credPath, 'utf8');
|
|
||||||
const creds = JSON.parse(content);
|
|
||||||
if (creds.accessToken || creds.token) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Invalid credentials file, continue checking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isAlreadyAuthenticated()) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Cursor CLI is now linked with the app',
|
|
||||||
wasAlreadyAuthenticated: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message:
|
|
||||||
'Cursor CLI is now linked with the app. If prompted, please authenticate with "cursor auth" in your terminal.',
|
|
||||||
requiresManualAuth: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Auth Cursor failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
message: 'Failed to link Cursor CLI with the app',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /auth-opencode endpoint - Authenticate OpenCode CLI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { logError, getErrorMessage } from '../common.js';
|
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
export function createAuthOpencodeHandler() {
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Remove the disconnected marker file to reconnect the app to the CLI
|
|
||||||
const markerPath = path.join(process.cwd(), '.automaker', '.opencode-disconnected');
|
|
||||||
if (fs.existsSync(markerPath)) {
|
|
||||||
fs.unlinkSync(markerPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if OpenCode is already authenticated
|
|
||||||
// For OpenCode, check if there's an auth token or API key
|
|
||||||
const hasApiKey = !!process.env.OPENCODE_API_KEY;
|
|
||||||
|
|
||||||
if (hasApiKey) {
|
|
||||||
// Already has authentication, just reconnect
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'OpenCode CLI is now linked with the app',
|
|
||||||
wasAlreadyAuthenticated: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message:
|
|
||||||
'OpenCode CLI is now linked with the app. If prompted, please authenticate with OpenCode.',
|
|
||||||
requiresManualAuth: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Auth OpenCode failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
message: 'Failed to link OpenCode CLI with the app',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* GET /codex-status endpoint - Get Codex CLI installation and auth status
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { CodexProvider } from '../../../providers/codex-provider.js';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const DISCONNECTED_MARKER_FILE = '.codex-disconnected';
|
|
||||||
|
|
||||||
function isCodexDisconnectedFromApp(): boolean {
|
|
||||||
try {
|
|
||||||
const projectRoot = process.cwd();
|
|
||||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
|
||||||
return fs.existsSync(markerPath);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates handler for GET /api/setup/codex-status
|
|
||||||
* Returns Codex CLI installation and authentication status
|
|
||||||
*/
|
|
||||||
export function createCodexStatusHandler() {
|
|
||||||
const installCommand = 'npm install -g @openai/codex';
|
|
||||||
const loginCommand = 'codex login';
|
|
||||||
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Check if user has manually disconnected from the app
|
|
||||||
if (isCodexDisconnectedFromApp()) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
installed: true,
|
|
||||||
version: null,
|
|
||||||
path: null,
|
|
||||||
auth: {
|
|
||||||
authenticated: false,
|
|
||||||
method: 'none',
|
|
||||||
hasApiKey: false,
|
|
||||||
},
|
|
||||||
installCommand,
|
|
||||||
loginCommand,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = new CodexProvider();
|
|
||||||
const status = await provider.detectInstallation();
|
|
||||||
|
|
||||||
// Derive auth method from authenticated status and API key presence
|
|
||||||
let authMethod = 'none';
|
|
||||||
if (status.authenticated) {
|
|
||||||
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
installed: status.installed,
|
|
||||||
version: status.version || null,
|
|
||||||
path: status.path || null,
|
|
||||||
auth: {
|
|
||||||
authenticated: status.authenticated || false,
|
|
||||||
method: authMethod,
|
|
||||||
hasApiKey: status.hasApiKey || false,
|
|
||||||
},
|
|
||||||
installCommand,
|
|
||||||
loginCommand,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Get Codex status failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -5,20 +5,6 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { CursorProvider } from '../../../providers/cursor-provider.js';
|
import { CursorProvider } from '../../../providers/cursor-provider.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const DISCONNECTED_MARKER_FILE = '.cursor-disconnected';
|
|
||||||
|
|
||||||
function isCursorDisconnectedFromApp(): boolean {
|
|
||||||
try {
|
|
||||||
const projectRoot = process.cwd();
|
|
||||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
|
||||||
return fs.existsSync(markerPath);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates handler for GET /api/setup/cursor-status
|
* Creates handler for GET /api/setup/cursor-status
|
||||||
@@ -30,30 +16,6 @@ export function createCursorStatusHandler() {
|
|||||||
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Check if user has manually disconnected from the app
|
|
||||||
if (isCursorDisconnectedFromApp()) {
|
|
||||||
const provider = new CursorProvider();
|
|
||||||
const [installed, version] = await Promise.all([
|
|
||||||
provider.isInstalled(),
|
|
||||||
provider.getVersion(),
|
|
||||||
]);
|
|
||||||
const cliPath = installed ? provider.getCliPath() : null;
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
installed,
|
|
||||||
version: version || null,
|
|
||||||
path: cliPath,
|
|
||||||
auth: {
|
|
||||||
authenticated: false,
|
|
||||||
method: 'none',
|
|
||||||
},
|
|
||||||
installCommand,
|
|
||||||
loginCommand,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = new CursorProvider();
|
const provider = new CursorProvider();
|
||||||
|
|
||||||
const [installed, version, auth] = await Promise.all([
|
const [installed, version, auth] = await Promise.all([
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /deauth-claude endpoint - Sign out from Claude CLI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
export function createDeauthClaudeHandler() {
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Create a marker file to indicate the CLI is disconnected from the app
|
|
||||||
const automakerDir = path.join(process.cwd(), '.automaker');
|
|
||||||
const markerPath = path.join(automakerDir, '.claude-disconnected');
|
|
||||||
|
|
||||||
// Ensure .automaker directory exists
|
|
||||||
if (!fs.existsSync(automakerDir)) {
|
|
||||||
fs.mkdirSync(automakerDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the marker file with timestamp
|
|
||||||
fs.writeFileSync(
|
|
||||||
markerPath,
|
|
||||||
JSON.stringify({
|
|
||||||
disconnectedAt: new Date().toISOString(),
|
|
||||||
message: 'Claude CLI is disconnected from the app',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Claude CLI is now disconnected from the app',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Deauth Claude failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
message: 'Failed to disconnect Claude CLI from the app',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /deauth-codex endpoint - Sign out from Codex CLI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { logError, getErrorMessage } from '../common.js';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
export function createDeauthCodexHandler() {
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Create a marker file to indicate the CLI is disconnected from the app
|
|
||||||
const automakerDir = path.join(process.cwd(), '.automaker');
|
|
||||||
const markerPath = path.join(automakerDir, '.codex-disconnected');
|
|
||||||
|
|
||||||
// Ensure .automaker directory exists
|
|
||||||
if (!fs.existsSync(automakerDir)) {
|
|
||||||
fs.mkdirSync(automakerDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the marker file with timestamp
|
|
||||||
fs.writeFileSync(
|
|
||||||
markerPath,
|
|
||||||
JSON.stringify({
|
|
||||||
disconnectedAt: new Date().toISOString(),
|
|
||||||
message: 'Codex CLI is disconnected from the app',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Codex CLI is now disconnected from the app',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Deauth Codex failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
message: 'Failed to disconnect Codex CLI from the app',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /deauth-cursor endpoint - Sign out from Cursor CLI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { logError, getErrorMessage } from '../common.js';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
export function createDeauthCursorHandler() {
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Create a marker file to indicate the CLI is disconnected from the app
|
|
||||||
const automakerDir = path.join(process.cwd(), '.automaker');
|
|
||||||
const markerPath = path.join(automakerDir, '.cursor-disconnected');
|
|
||||||
|
|
||||||
// Ensure .automaker directory exists
|
|
||||||
if (!fs.existsSync(automakerDir)) {
|
|
||||||
fs.mkdirSync(automakerDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the marker file with timestamp
|
|
||||||
fs.writeFileSync(
|
|
||||||
markerPath,
|
|
||||||
JSON.stringify({
|
|
||||||
disconnectedAt: new Date().toISOString(),
|
|
||||||
message: 'Cursor CLI is disconnected from the app',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Cursor CLI is now disconnected from the app',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Deauth Cursor failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
message: 'Failed to disconnect Cursor CLI from the app',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { Request, Response } from 'express';
|
|
||||||
import { logError, getErrorMessage } from '../common.js';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
export function createDeauthOpencodeHandler() {
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Create a marker file to indicate the CLI is disconnected from the app
|
|
||||||
const automakerDir = path.join(process.cwd(), '.automaker');
|
|
||||||
const markerPath = path.join(automakerDir, '.opencode-disconnected');
|
|
||||||
|
|
||||||
// Ensure .automaker directory exists
|
|
||||||
if (!fs.existsSync(automakerDir)) {
|
|
||||||
fs.mkdirSync(automakerDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the marker file with timestamp
|
|
||||||
fs.writeFileSync(
|
|
||||||
markerPath,
|
|
||||||
JSON.stringify({
|
|
||||||
disconnectedAt: new Date().toISOString(),
|
|
||||||
message: 'OpenCode CLI is disconnected from the app',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'OpenCode CLI is now disconnected from the app',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Deauth OpenCode failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
message: 'Failed to disconnect OpenCode CLI from the app',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -46,14 +46,13 @@ export function createDeleteApiKeyHandler() {
|
|||||||
// Map provider to env key name
|
// Map provider to env key name
|
||||||
const envKeyMap: Record<string, string> = {
|
const envKeyMap: Record<string, string> = {
|
||||||
anthropic: 'ANTHROPIC_API_KEY',
|
anthropic: 'ANTHROPIC_API_KEY',
|
||||||
openai: 'OPENAI_API_KEY',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const envKey = envKeyMap[provider];
|
const envKey = envKeyMap[provider];
|
||||||
if (!envKey) {
|
if (!envKey) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Unknown provider: ${provider}. Only anthropic and openai are supported.`,
|
error: `Unknown provider: ${provider}. Only anthropic is supported.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /install-codex endpoint - Install Codex CLI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { logError, getErrorMessage } from '../common.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates handler for POST /api/setup/install-codex
|
|
||||||
* Installs Codex CLI (currently returns instructions for manual install)
|
|
||||||
*/
|
|
||||||
export function createInstallCodexHandler() {
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// For now, return manual installation instructions
|
|
||||||
// In the future, this could potentially trigger npm global install
|
|
||||||
const installCommand = 'npm install -g @openai/codex';
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: `Please install Codex CLI manually by running: ${installCommand}`,
|
|
||||||
requiresManualInstall: true,
|
|
||||||
installCommand,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Install Codex failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
/**
|
|
||||||
* OpenCode Dynamic Models API Routes
|
|
||||||
*
|
|
||||||
* Provides endpoints for:
|
|
||||||
* - GET /api/setup/opencode/models - Get available models (cached or refreshed)
|
|
||||||
* - POST /api/setup/opencode/models/refresh - Force refresh models from CLI
|
|
||||||
* - GET /api/setup/opencode/providers - Get authenticated providers
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import {
|
|
||||||
OpencodeProvider,
|
|
||||||
type OpenCodeProviderInfo,
|
|
||||||
} from '../../../providers/opencode-provider.js';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
|
||||||
import type { ModelDefinition } from '@automaker/types';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('OpenCodeModelsRoute');
|
|
||||||
|
|
||||||
// Singleton provider instance for caching
|
|
||||||
let providerInstance: OpencodeProvider | null = null;
|
|
||||||
|
|
||||||
function getProvider(): OpencodeProvider {
|
|
||||||
if (!providerInstance) {
|
|
||||||
providerInstance = new OpencodeProvider();
|
|
||||||
}
|
|
||||||
return providerInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response type for models endpoint
|
|
||||||
*/
|
|
||||||
interface ModelsResponse {
|
|
||||||
success: boolean;
|
|
||||||
models?: ModelDefinition[];
|
|
||||||
count?: number;
|
|
||||||
cached?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response type for providers endpoint
|
|
||||||
*/
|
|
||||||
interface ProvidersResponse {
|
|
||||||
success: boolean;
|
|
||||||
providers?: OpenCodeProviderInfo[];
|
|
||||||
authenticated?: OpenCodeProviderInfo[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates handler for GET /api/setup/opencode/models
|
|
||||||
*
|
|
||||||
* Returns currently available models (from cache if available).
|
|
||||||
* Query params:
|
|
||||||
* - refresh=true: Force refresh from CLI before returning
|
|
||||||
*
|
|
||||||
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
|
|
||||||
*/
|
|
||||||
export function createGetOpencodeModelsHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const provider = getProvider();
|
|
||||||
const forceRefresh = req.query.refresh === 'true';
|
|
||||||
|
|
||||||
let models: ModelDefinition[];
|
|
||||||
let cached = true;
|
|
||||||
|
|
||||||
if (forceRefresh) {
|
|
||||||
models = await provider.refreshModels();
|
|
||||||
cached = false;
|
|
||||||
} else {
|
|
||||||
// Check if we have cached models
|
|
||||||
const cachedModels = provider.getAvailableModels();
|
|
||||||
|
|
||||||
// If cache only has default models (provider.hasCachedModels() would be false),
|
|
||||||
// trigger a refresh to get dynamic models
|
|
||||||
if (!provider.hasCachedModels()) {
|
|
||||||
models = await provider.refreshModels();
|
|
||||||
cached = false;
|
|
||||||
} else {
|
|
||||||
models = cachedModels;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: ModelsResponse = {
|
|
||||||
success: true,
|
|
||||||
models,
|
|
||||||
count: models.length,
|
|
||||||
cached,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Get OpenCode models failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
} as ModelsResponse);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates handler for POST /api/setup/opencode/models/refresh
|
|
||||||
*
|
|
||||||
* Forces a refresh of models from the OpenCode CLI.
|
|
||||||
*/
|
|
||||||
export function createRefreshOpencodeModelsHandler() {
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const provider = getProvider();
|
|
||||||
const models = await provider.refreshModels();
|
|
||||||
|
|
||||||
const response: ModelsResponse = {
|
|
||||||
success: true,
|
|
||||||
models,
|
|
||||||
count: models.length,
|
|
||||||
cached: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Refresh OpenCode models failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
} as ModelsResponse);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates handler for GET /api/setup/opencode/providers
|
|
||||||
*
|
|
||||||
* Returns authenticated providers from OpenCode CLI.
|
|
||||||
* This calls `opencode auth list` to get provider status.
|
|
||||||
*/
|
|
||||||
export function createGetOpencodeProvidersHandler() {
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const provider = getProvider();
|
|
||||||
const providers = await provider.fetchAuthenticatedProviders();
|
|
||||||
|
|
||||||
// Filter to only authenticated providers
|
|
||||||
const authenticated = providers.filter((p) => p.authenticated);
|
|
||||||
|
|
||||||
const response: ProvidersResponse = {
|
|
||||||
success: true,
|
|
||||||
providers,
|
|
||||||
authenticated,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Get OpenCode providers failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
} as ProvidersResponse);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates handler for POST /api/setup/opencode/cache/clear
|
|
||||||
*
|
|
||||||
* Clears the model cache, forcing a fresh fetch on next access.
|
|
||||||
*/
|
|
||||||
export function createClearOpencodeCacheHandler() {
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const provider = getProvider();
|
|
||||||
provider.clearModelCache();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'OpenCode model cache cleared',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Clear OpenCode cache failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* GET /opencode-status endpoint - Get OpenCode CLI installation and auth status
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { OpencodeProvider } from '../../../providers/opencode-provider.js';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates handler for GET /api/setup/opencode-status
|
|
||||||
* Returns OpenCode CLI installation and authentication status
|
|
||||||
*/
|
|
||||||
export function createOpencodeStatusHandler() {
|
|
||||||
const installCommand = 'curl -fsSL https://opencode.ai/install | bash';
|
|
||||||
const loginCommand = 'opencode auth login';
|
|
||||||
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const provider = new OpencodeProvider();
|
|
||||||
const status = await provider.detectInstallation();
|
|
||||||
|
|
||||||
// Derive auth method from authenticated status and API key presence
|
|
||||||
let authMethod = 'none';
|
|
||||||
if (status.authenticated) {
|
|
||||||
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
installed: status.installed,
|
|
||||||
version: status.version || null,
|
|
||||||
path: status.path || null,
|
|
||||||
auth: {
|
|
||||||
authenticated: status.authenticated || false,
|
|
||||||
method: authMethod,
|
|
||||||
hasApiKey: status.hasApiKey || false,
|
|
||||||
hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.OPENAI_API_KEY,
|
|
||||||
hasOAuthToken: status.hasOAuthToken || false,
|
|
||||||
},
|
|
||||||
recommendation: status.installed
|
|
||||||
? undefined
|
|
||||||
: 'Install OpenCode CLI to use multi-provider AI models.',
|
|
||||||
installCommand,
|
|
||||||
loginCommand,
|
|
||||||
installCommands: {
|
|
||||||
macos: installCommand,
|
|
||||||
linux: installCommand,
|
|
||||||
npm: 'npm install -g opencode-ai',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Get OpenCode status failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -7,16 +7,8 @@ import type { Request, Response } from 'express';
|
|||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { getApiKey } from '../common.js';
|
import { getApiKey } from '../common.js';
|
||||||
import {
|
|
||||||
createSecureAuthEnv,
|
|
||||||
AuthSessionManager,
|
|
||||||
AuthRateLimiter,
|
|
||||||
validateApiKey,
|
|
||||||
createTempEnvOverride,
|
|
||||||
} from '../../../lib/auth-utils.js';
|
|
||||||
|
|
||||||
const logger = createLogger('Setup');
|
const logger = createLogger('Setup');
|
||||||
const rateLimiter = new AuthRateLimiter();
|
|
||||||
|
|
||||||
// Known error patterns that indicate auth failure
|
// Known error patterns that indicate auth failure
|
||||||
const AUTH_ERROR_PATTERNS = [
|
const AUTH_ERROR_PATTERNS = [
|
||||||
@@ -85,19 +77,6 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Rate limiting to prevent abuse
|
|
||||||
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
|
|
||||||
if (!rateLimiter.canAttempt(clientIp)) {
|
|
||||||
const resetTime = rateLimiter.getResetTime(clientIp);
|
|
||||||
res.status(429).json({
|
|
||||||
success: false,
|
|
||||||
authenticated: false,
|
|
||||||
error: 'Too many authentication attempts. Please try again later.',
|
|
||||||
resetTime,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}`
|
`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}`
|
||||||
);
|
);
|
||||||
@@ -110,48 +89,37 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
let receivedAnyContent = false;
|
let receivedAnyContent = false;
|
||||||
|
|
||||||
// Create secure auth session
|
// Save original env values
|
||||||
const sessionId = `claude-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For API key verification, validate the key first
|
// Configure environment based on auth method
|
||||||
if (authMethod === 'api_key' && apiKey) {
|
if (authMethod === 'cli') {
|
||||||
const validation = validateApiKey(apiKey, 'anthropic');
|
// For CLI verification, remove any API key so it uses CLI credentials only
|
||||||
if (!validation.isValid) {
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
res.json({
|
logger.info('[Setup] Cleared API key environment for CLI verification');
|
||||||
success: true,
|
} else if (authMethod === 'api_key') {
|
||||||
authenticated: false,
|
// For API key verification, use provided key, stored key, or env var (in order of priority)
|
||||||
error: validation.error,
|
if (apiKey) {
|
||||||
});
|
// Use the provided API key (allows testing unsaved keys)
|
||||||
return;
|
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||||
|
logger.info('[Setup] Using provided API key for verification');
|
||||||
|
} else {
|
||||||
|
const storedApiKey = getApiKey('anthropic');
|
||||||
|
if (storedApiKey) {
|
||||||
|
process.env.ANTHROPIC_API_KEY = storedApiKey;
|
||||||
|
logger.info('[Setup] Using stored API key for verification');
|
||||||
|
} else if (!process.env.ANTHROPIC_API_KEY) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
authenticated: false,
|
||||||
|
error: 'No API key configured. Please enter an API key first.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create secure environment without modifying process.env
|
|
||||||
const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'anthropic');
|
|
||||||
|
|
||||||
// For API key verification without provided key, use stored key or env var
|
|
||||||
if (authMethod === 'api_key' && !apiKey) {
|
|
||||||
const storedApiKey = getApiKey('anthropic');
|
|
||||||
if (storedApiKey) {
|
|
||||||
authEnv.ANTHROPIC_API_KEY = storedApiKey;
|
|
||||||
logger.info('[Setup] Using stored API key for verification');
|
|
||||||
} else if (!authEnv.ANTHROPIC_API_KEY) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
authenticated: false,
|
|
||||||
error: 'No API key configured. Please enter an API key first.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the secure environment in session manager
|
|
||||||
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic');
|
|
||||||
|
|
||||||
// Create temporary environment override for SDK call
|
|
||||||
const cleanupEnv = createTempEnvOverride(authEnv);
|
|
||||||
|
|
||||||
// Run a minimal query to verify authentication
|
// Run a minimal query to verify authentication
|
||||||
const stream = query({
|
const stream = query({
|
||||||
prompt: "Reply with only the word 'ok'",
|
prompt: "Reply with only the word 'ok'",
|
||||||
@@ -310,8 +278,13 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
// Clean up the auth session
|
// Restore original environment
|
||||||
AuthSessionManager.destroySession(sessionId);
|
if (originalAnthropicKey !== undefined) {
|
||||||
|
process.env.ANTHROPIC_API_KEY = originalAnthropicKey;
|
||||||
|
} else if (authMethod === 'cli') {
|
||||||
|
// If we cleared it and there was no original, keep it cleared
|
||||||
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('[Setup] Verification result:', {
|
logger.info('[Setup] Verification result:', {
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /verify-codex-auth endpoint - Verify Codex authentication
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import { CODEX_MODEL_MAP } from '@automaker/types';
|
|
||||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
|
||||||
import { getApiKey } from '../common.js';
|
|
||||||
import { getCodexAuthIndicators } from '@automaker/platform';
|
|
||||||
import {
|
|
||||||
createSecureAuthEnv,
|
|
||||||
AuthSessionManager,
|
|
||||||
AuthRateLimiter,
|
|
||||||
validateApiKey,
|
|
||||||
createTempEnvOverride,
|
|
||||||
} from '../../../lib/auth-utils.js';
|
|
||||||
|
|
||||||
const logger = createLogger('Setup');
|
|
||||||
const rateLimiter = new AuthRateLimiter();
|
|
||||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
|
||||||
const AUTH_PROMPT = "Reply with only the word 'ok'";
|
|
||||||
const AUTH_TIMEOUT_MS = 30000;
|
|
||||||
const ERROR_BILLING_MESSAGE =
|
|
||||||
'Credit balance is too low. Please add credits to your OpenAI account.';
|
|
||||||
const ERROR_RATE_LIMIT_MESSAGE =
|
|
||||||
'Rate limit reached. Please wait a while before trying again or upgrade your plan.';
|
|
||||||
const ERROR_CLI_AUTH_REQUIRED =
|
|
||||||
"CLI authentication failed. Please run 'codex login' to authenticate.";
|
|
||||||
const ERROR_API_KEY_REQUIRED = 'No API key configured. Please enter an API key first.';
|
|
||||||
const AUTH_ERROR_PATTERNS = [
|
|
||||||
'authentication',
|
|
||||||
'unauthorized',
|
|
||||||
'invalid_api_key',
|
|
||||||
'invalid api key',
|
|
||||||
'api key is invalid',
|
|
||||||
'not authenticated',
|
|
||||||
'login',
|
|
||||||
'auth(',
|
|
||||||
'token refresh',
|
|
||||||
'tokenrefresh',
|
|
||||||
'failed to parse server response',
|
|
||||||
'transport channel closed',
|
|
||||||
];
|
|
||||||
const BILLING_ERROR_PATTERNS = [
|
|
||||||
'credit balance is too low',
|
|
||||||
'credit balance too low',
|
|
||||||
'insufficient credits',
|
|
||||||
'insufficient balance',
|
|
||||||
'no credits',
|
|
||||||
'out of credits',
|
|
||||||
'billing',
|
|
||||||
'payment required',
|
|
||||||
'add credits',
|
|
||||||
];
|
|
||||||
const RATE_LIMIT_PATTERNS = [
|
|
||||||
'limit reached',
|
|
||||||
'rate limit',
|
|
||||||
'rate_limit',
|
|
||||||
'too many requests',
|
|
||||||
'resets',
|
|
||||||
'429',
|
|
||||||
];
|
|
||||||
|
|
||||||
function containsAuthError(text: string): boolean {
|
|
||||||
const lowerText = text.toLowerCase();
|
|
||||||
return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBillingError(text: string): boolean {
|
|
||||||
const lowerText = text.toLowerCase();
|
|
||||||
return BILLING_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRateLimitError(text: string): boolean {
|
|
||||||
if (isBillingError(text)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const lowerText = text.toLowerCase();
|
|
||||||
return RATE_LIMIT_PATTERNS.some((pattern) => lowerText.includes(pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createVerifyCodexAuthHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
const { authMethod, apiKey } = req.body as {
|
|
||||||
authMethod?: 'cli' | 'api_key';
|
|
||||||
apiKey?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create session ID for cleanup
|
|
||||||
const sessionId = `codex-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
|
|
||||||
if (!rateLimiter.canAttempt(clientIp)) {
|
|
||||||
const resetTime = rateLimiter.getResetTime(clientIp);
|
|
||||||
res.status(429).json({
|
|
||||||
success: false,
|
|
||||||
authenticated: false,
|
|
||||||
error: 'Too many authentication attempts. Please try again later.',
|
|
||||||
resetTime,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const abortController = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => abortController.abort(), AUTH_TIMEOUT_MS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create secure environment without modifying process.env
|
|
||||||
const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'openai');
|
|
||||||
|
|
||||||
// For API key auth, validate and use the provided key or stored key
|
|
||||||
if (authMethod === 'api_key') {
|
|
||||||
if (apiKey) {
|
|
||||||
// Use the provided API key
|
|
||||||
const validation = validateApiKey(apiKey, 'openai');
|
|
||||||
if (!validation.isValid) {
|
|
||||||
res.json({ success: true, authenticated: false, error: validation.error });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey;
|
|
||||||
} else {
|
|
||||||
// Try stored key
|
|
||||||
const storedApiKey = getApiKey('openai');
|
|
||||||
if (storedApiKey) {
|
|
||||||
const validation = validateApiKey(storedApiKey, 'openai');
|
|
||||||
if (!validation.isValid) {
|
|
||||||
res.json({ success: true, authenticated: false, error: validation.error });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey;
|
|
||||||
} else if (!authEnv[OPENAI_API_KEY_ENV]) {
|
|
||||||
res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create session and temporary environment override
|
|
||||||
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', undefined, 'openai');
|
|
||||||
const cleanupEnv = createTempEnvOverride(authEnv);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (authMethod === 'cli') {
|
|
||||||
const authIndicators = await getCodexAuthIndicators();
|
|
||||||
if (!authIndicators.hasOAuthToken && !authIndicators.hasApiKey) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
authenticated: false,
|
|
||||||
error: ERROR_CLI_AUTH_REQUIRED,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use Codex provider explicitly (not ProviderFactory.getProviderForModel)
|
|
||||||
// because Cursor also supports GPT models and has higher priority
|
|
||||||
const provider = ProviderFactory.getProviderByName('codex');
|
|
||||||
if (!provider) {
|
|
||||||
throw new Error('Codex provider not available');
|
|
||||||
}
|
|
||||||
const stream = provider.executeQuery({
|
|
||||||
prompt: AUTH_PROMPT,
|
|
||||||
model: CODEX_MODEL_MAP.gpt52Codex,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
maxTurns: 1,
|
|
||||||
allowedTools: [],
|
|
||||||
abortController,
|
|
||||||
});
|
|
||||||
|
|
||||||
let receivedAnyContent = false;
|
|
||||||
let errorMessage = '';
|
|
||||||
|
|
||||||
for await (const msg of stream) {
|
|
||||||
if (msg.type === 'error' && msg.error) {
|
|
||||||
if (isBillingError(msg.error)) {
|
|
||||||
errorMessage = ERROR_BILLING_MESSAGE;
|
|
||||||
} else if (isRateLimitError(msg.error)) {
|
|
||||||
errorMessage = ERROR_RATE_LIMIT_MESSAGE;
|
|
||||||
} else {
|
|
||||||
errorMessage = msg.error;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
receivedAnyContent = true;
|
|
||||||
if (isBillingError(block.text)) {
|
|
||||||
errorMessage = ERROR_BILLING_MESSAGE;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (isRateLimitError(block.text)) {
|
|
||||||
errorMessage = ERROR_RATE_LIMIT_MESSAGE;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (containsAuthError(block.text)) {
|
|
||||||
errorMessage = block.text;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === 'result' && msg.result) {
|
|
||||||
receivedAnyContent = true;
|
|
||||||
if (isBillingError(msg.result)) {
|
|
||||||
errorMessage = ERROR_BILLING_MESSAGE;
|
|
||||||
} else if (isRateLimitError(msg.result)) {
|
|
||||||
errorMessage = ERROR_RATE_LIMIT_MESSAGE;
|
|
||||||
} else if (containsAuthError(msg.result)) {
|
|
||||||
errorMessage = msg.result;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage) {
|
|
||||||
// Rate limit and billing errors mean auth succeeded but usage is limited
|
|
||||||
const isUsageLimitError =
|
|
||||||
errorMessage === ERROR_BILLING_MESSAGE || errorMessage === ERROR_RATE_LIMIT_MESSAGE;
|
|
||||||
|
|
||||||
const response: {
|
|
||||||
success: boolean;
|
|
||||||
authenticated: boolean;
|
|
||||||
error: string;
|
|
||||||
details?: string;
|
|
||||||
} = {
|
|
||||||
success: true,
|
|
||||||
authenticated: isUsageLimitError ? true : false,
|
|
||||||
error: isUsageLimitError
|
|
||||||
? errorMessage
|
|
||||||
: authMethod === 'cli'
|
|
||||||
? ERROR_CLI_AUTH_REQUIRED
|
|
||||||
: 'API key is invalid or has been revoked.',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Include detailed error for auth failures so users can debug
|
|
||||||
if (!isUsageLimitError && errorMessage !== response.error) {
|
|
||||||
response.details = errorMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!receivedAnyContent) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
authenticated: false,
|
|
||||||
error: 'No response received from Codex. Please check your authentication.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true, authenticated: true });
|
|
||||||
} finally {
|
|
||||||
// Clean up environment override
|
|
||||||
cleanupEnv();
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const errMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error('[Setup] Codex auth verification error:', errMessage);
|
|
||||||
const normalizedError = isBillingError(errMessage)
|
|
||||||
? ERROR_BILLING_MESSAGE
|
|
||||||
: isRateLimitError(errMessage)
|
|
||||||
? ERROR_RATE_LIMIT_MESSAGE
|
|
||||||
: errMessage;
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
authenticated: false,
|
|
||||||
error: normalizedError,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
// Clean up session
|
|
||||||
AuthSessionManager.destroySession(sessionId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -8,12 +8,7 @@
|
|||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import {
|
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
||||||
DEFAULT_PHASE_MODELS,
|
|
||||||
isCursorModel,
|
|
||||||
stripProviderPrefix,
|
|
||||||
type ThinkingLevel,
|
|
||||||
} from '@automaker/types';
|
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
|
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
|
||||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||||
@@ -212,8 +207,6 @@ The response will be automatically formatted as structured JSON.`;
|
|||||||
logger.info('[Suggestions] Using Cursor provider');
|
logger.info('[Suggestions] Using Cursor provider');
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(model);
|
|
||||||
|
|
||||||
// For Cursor, include the JSON schema in the prompt with clear instructions
|
// For Cursor, include the JSON schema in the prompt with clear instructions
|
||||||
const cursorPrompt = `${prompt}
|
const cursorPrompt = `${prompt}
|
||||||
@@ -229,7 +222,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
|||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model: bareModel,
|
model,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxTurns: 250,
|
maxTurns: 250,
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
|
|||||||
@@ -3,51 +3,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { spawnProcess } from '@automaker/platform';
|
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
import path from 'path';
|
||||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||||
|
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||||
|
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
export const execAsync = promisify(exec);
|
export const execAsync = promisify(exec);
|
||||||
|
const featureLoader = new FeatureLoader();
|
||||||
// ============================================================================
|
|
||||||
// Secure Command Execution
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute git command with array arguments to prevent command injection.
|
|
||||||
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
|
|
||||||
*
|
|
||||||
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
|
|
||||||
* @param cwd - Working directory to execute the command in
|
|
||||||
* @returns Promise resolving to stdout output
|
|
||||||
* @throws Error with stderr message if command fails
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Safe: no injection possible
|
|
||||||
* await execGitCommand(['branch', '-D', branchName], projectPath);
|
|
||||||
*
|
|
||||||
* // Instead of unsafe:
|
|
||||||
* // await execAsync(`git branch -D ${branchName}`, { cwd });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export async function execGitCommand(args: string[], cwd: string): Promise<string> {
|
|
||||||
const result = await spawnProcess({
|
|
||||||
command: 'git',
|
|
||||||
args,
|
|
||||||
cwd,
|
|
||||||
});
|
|
||||||
|
|
||||||
// spawnProcess returns { stdout, stderr, exitCode }
|
|
||||||
if (result.exitCode === 0) {
|
|
||||||
return result.stdout;
|
|
||||||
} else {
|
|
||||||
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Constants
|
// Constants
|
||||||
@@ -135,6 +99,18 @@ export function normalizePath(p: string): string {
|
|||||||
return p.replace(/\\/g, '/');
|
return p.replace(/\\/g, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a path is a git repo
|
||||||
|
*/
|
||||||
|
export async function isGitRepo(repoPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a git repository has at least one commit (i.e., HEAD exists)
|
* Check if a git repository has at least one commit (i.e., HEAD exists)
|
||||||
* Returns false for freshly initialized repos with no commits
|
* Returns false for freshly initialized repos with no commits
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
|
||||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||||
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
|
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
|
||||||
import { createInfoHandler } from './routes/info.js';
|
import { createInfoHandler } from './routes/info.js';
|
||||||
@@ -25,22 +24,14 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js';
|
|||||||
import {
|
import {
|
||||||
createOpenInEditorHandler,
|
createOpenInEditorHandler,
|
||||||
createGetDefaultEditorHandler,
|
createGetDefaultEditorHandler,
|
||||||
createGetAvailableEditorsHandler,
|
|
||||||
createRefreshEditorsHandler,
|
|
||||||
} from './routes/open-in-editor.js';
|
} from './routes/open-in-editor.js';
|
||||||
import { createInitGitHandler } from './routes/init-git.js';
|
import { createInitGitHandler } from './routes/init-git.js';
|
||||||
import { createMigrateHandler } from './routes/migrate.js';
|
import { createMigrateHandler } from './routes/migrate.js';
|
||||||
import { createStartDevHandler } from './routes/start-dev.js';
|
import { createStartDevHandler } from './routes/start-dev.js';
|
||||||
import { createStopDevHandler } from './routes/stop-dev.js';
|
import { createStopDevHandler } from './routes/stop-dev.js';
|
||||||
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
||||||
import {
|
|
||||||
createGetInitScriptHandler,
|
|
||||||
createPutInitScriptHandler,
|
|
||||||
createDeleteInitScriptHandler,
|
|
||||||
createRunInitScriptHandler,
|
|
||||||
} from './routes/init-script.js';
|
|
||||||
|
|
||||||
export function createWorktreeRoutes(events: EventEmitter): Router {
|
export function createWorktreeRoutes(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
||||||
@@ -54,7 +45,7 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
|
|||||||
requireValidProject,
|
requireValidProject,
|
||||||
createMergeHandler()
|
createMergeHandler()
|
||||||
);
|
);
|
||||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
|
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
|
||||||
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
||||||
router.post('/create-pr', createCreatePRHandler());
|
router.post('/create-pr', createCreatePRHandler());
|
||||||
router.post('/pr-info', createPRInfoHandler());
|
router.post('/pr-info', createPRInfoHandler());
|
||||||
@@ -86,8 +77,6 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
|
|||||||
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
||||||
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||||
router.get('/default-editor', createGetDefaultEditorHandler());
|
router.get('/default-editor', createGetDefaultEditorHandler());
|
||||||
router.get('/available-editors', createGetAvailableEditorsHandler());
|
|
||||||
router.post('/refresh-editors', createRefreshEditorsHandler());
|
|
||||||
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
||||||
router.post('/migrate', createMigrateHandler());
|
router.post('/migrate', createMigrateHandler());
|
||||||
router.post(
|
router.post(
|
||||||
@@ -98,15 +87,5 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
|
|||||||
router.post('/stop-dev', createStopDevHandler());
|
router.post('/stop-dev', createStopDevHandler());
|
||||||
router.post('/list-dev-servers', createListDevServersHandler());
|
router.post('/list-dev-servers', createListDevServersHandler());
|
||||||
|
|
||||||
// Init script routes
|
|
||||||
router.get('/init-script', createGetInitScriptHandler());
|
|
||||||
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
|
|
||||||
router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler());
|
|
||||||
router.post(
|
|
||||||
'/run-init-script',
|
|
||||||
validatePathParams('projectPath', 'worktreePath'),
|
|
||||||
createRunInitScriptHandler(events)
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo, hasCommits } from './common.js';
|
||||||
import { hasCommits } from './common.js';
|
|
||||||
|
|
||||||
interface ValidationOptions {
|
interface ValidationOptions {
|
||||||
/** Check if the path is a git repository (default: true) */
|
/** Check if the path is a git repository (default: true) */
|
||||||
|
|||||||
@@ -12,19 +12,15 @@ import { exec } from 'child_process';
|
|||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../../../lib/events.js';
|
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
|
||||||
import {
|
import {
|
||||||
|
isGitRepo,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
logError,
|
logError,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
ensureInitialCommit,
|
ensureInitialCommit,
|
||||||
isValidBranchName,
|
|
||||||
execGitCommand,
|
|
||||||
} from '../common.js';
|
} from '../common.js';
|
||||||
import { trackBranch } from './branch-tracking.js';
|
import { trackBranch } from './branch-tracking.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { runInitScript } from '../../../services/init-script-service.js';
|
|
||||||
|
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
@@ -81,7 +77,7 @@ async function findExistingWorktreeForBranch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCreateHandler(events: EventEmitter) {
|
export function createCreateHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, branchName, baseBranch } = req.body as {
|
const { projectPath, branchName, baseBranch } = req.body as {
|
||||||
@@ -98,26 +94,6 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate branch name to prevent command injection
|
|
||||||
if (!isValidBranchName(branchName)) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate base branch if provided
|
|
||||||
if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
'Invalid base branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await isGitRepo(projectPath))) {
|
if (!(await isGitRepo(projectPath))) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -167,28 +143,30 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
// Create worktrees directory if it doesn't exist
|
// Create worktrees directory if it doesn't exist
|
||||||
await secureFs.mkdir(worktreesDir, { recursive: true });
|
await secureFs.mkdir(worktreesDir, { recursive: true });
|
||||||
|
|
||||||
// Check if branch exists (using array arguments to prevent injection)
|
// Check if branch exists
|
||||||
let branchExists = false;
|
let branchExists = false;
|
||||||
try {
|
try {
|
||||||
await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
|
await execAsync(`git rev-parse --verify ${branchName}`, {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
branchExists = true;
|
branchExists = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Branch doesn't exist
|
// Branch doesn't exist
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create worktree (using array arguments to prevent injection)
|
// Create worktree
|
||||||
|
let createCmd: string;
|
||||||
if (branchExists) {
|
if (branchExists) {
|
||||||
// Use existing branch
|
// Use existing branch
|
||||||
await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath);
|
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
|
||||||
} else {
|
} else {
|
||||||
// Create new branch from base or HEAD
|
// Create new branch from base or HEAD
|
||||||
const base = baseBranch || 'HEAD';
|
const base = baseBranch || 'HEAD';
|
||||||
await execGitCommand(
|
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
|
||||||
['worktree', 'add', '-b', branchName, worktreePath, base],
|
|
||||||
projectPath
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await execAsync(createCmd, { cwd: projectPath });
|
||||||
|
|
||||||
// Note: We intentionally do NOT symlink .automaker to worktrees
|
// Note: We intentionally do NOT symlink .automaker to worktrees
|
||||||
// Features and config are always accessed from the main project path
|
// Features and config are always accessed from the main project path
|
||||||
// This avoids symlink loop issues when activating worktrees
|
// This avoids symlink loop issues when activating worktrees
|
||||||
@@ -199,8 +177,6 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
// Resolve to absolute path for cross-platform compatibility
|
// Resolve to absolute path for cross-platform compatibility
|
||||||
// normalizePath converts to forward slashes for API consistency
|
// normalizePath converts to forward slashes for API consistency
|
||||||
const absoluteWorktreePath = path.resolve(worktreePath);
|
const absoluteWorktreePath = path.resolve(worktreePath);
|
||||||
|
|
||||||
// Respond immediately (non-blocking)
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
worktree: {
|
worktree: {
|
||||||
@@ -209,17 +185,6 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
isNew: !branchExists,
|
isNew: !branchExists,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger init script asynchronously after response
|
|
||||||
// runInitScript internally checks if script exists and hasn't already run
|
|
||||||
runInitScript({
|
|
||||||
projectPath,
|
|
||||||
worktreePath: absoluteWorktreePath,
|
|
||||||
branch: branchName,
|
|
||||||
emitter: events,
|
|
||||||
}).catch((err) => {
|
|
||||||
logger.error(`Init script failed for ${branchName}:`, err);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Create worktree failed');
|
logError(error, 'Create worktree failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import type { Request, Response } from 'express';
|
|||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const logger = createLogger('Worktree');
|
|
||||||
|
|
||||||
export function createDeleteHandler() {
|
export function createDeleteHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -48,28 +46,22 @@ export function createDeleteHandler() {
|
|||||||
// Could not get branch name
|
// Could not get branch name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the worktree (using array arguments to prevent injection)
|
// Remove the worktree
|
||||||
try {
|
try {
|
||||||
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Try with prune if remove fails
|
// Try with prune if remove fails
|
||||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
await execAsync('git worktree prune', { cwd: projectPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally delete the branch
|
// Optionally delete the branch
|
||||||
let branchDeleted = false;
|
|
||||||
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
|
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
|
||||||
// Validate branch name to prevent command injection
|
try {
|
||||||
if (!isValidBranchName(branchName)) {
|
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
||||||
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
} catch {
|
||||||
} else {
|
// Branch deletion failed, not critical
|
||||||
try {
|
|
||||||
await execGitCommand(['branch', '-D', branchName], projectPath);
|
|
||||||
branchDeleted = true;
|
|
||||||
} catch {
|
|
||||||
// Branch deletion failed, not critical
|
|
||||||
logger.warn(`Failed to delete branch: ${branchName}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +69,7 @@ export function createDeleteHandler() {
|
|||||||
success: true,
|
success: true,
|
||||||
deleted: {
|
deleted: {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
branch: branchDeleted ? branchName : null,
|
branch: deleteBranch ? branchName : null,
|
||||||
branchDeleted,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ import { getGitRepositoryDiffs } from '../../common.js';
|
|||||||
export function createDiffsHandler() {
|
export function createDiffsHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, featureId, useWorktrees } = req.body as {
|
const { projectPath, featureId } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
useWorktrees?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !featureId) {
|
if (!projectPath || !featureId) {
|
||||||
@@ -25,19 +24,6 @@ export function createDiffsHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If worktrees aren't enabled, don't probe .worktrees at all.
|
|
||||||
// This avoids noisy logs that make it look like features are "running in worktrees".
|
|
||||||
if (useWorktrees === false) {
|
|
||||||
const result = await getGitRepositoryDiffs(projectPath);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
diff: result.diff,
|
|
||||||
files: result.files,
|
|
||||||
hasChanges: result.hasChanges,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Git worktrees are stored in project directory
|
// Git worktrees are stored in project directory
|
||||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||||
|
|
||||||
@@ -55,11 +41,7 @@ export function createDiffsHandler() {
|
|||||||
});
|
});
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
// Worktree doesn't exist - fallback to main project path
|
// Worktree doesn't exist - fallback to main project path
|
||||||
const code = (innerError as NodeJS.ErrnoException | undefined)?.code;
|
logError(innerError, 'Worktree access failed, falling back to main project');
|
||||||
// ENOENT is expected when a feature has no worktree; don't log as an error.
|
|
||||||
if (code && code !== 'ENOENT') {
|
|
||||||
logError(innerError, 'Worktree access failed, falling back to main project');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getGitRepositoryDiffs(projectPath);
|
const result = await getGitRepositoryDiffs(projectPath);
|
||||||
|
|||||||
@@ -15,11 +15,10 @@ const execAsync = promisify(exec);
|
|||||||
export function createFileDiffHandler() {
|
export function createFileDiffHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, featureId, filePath, useWorktrees } = req.body as {
|
const { projectPath, featureId, filePath } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
useWorktrees?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !featureId || !filePath) {
|
if (!projectPath || !featureId || !filePath) {
|
||||||
@@ -30,12 +29,6 @@ export function createFileDiffHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If worktrees aren't enabled, don't probe .worktrees at all.
|
|
||||||
if (useWorktrees === false) {
|
|
||||||
res.json({ success: true, diff: '', filePath });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Git worktrees are stored in project directory
|
// Git worktrees are stored in project directory
|
||||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||||
|
|
||||||
@@ -64,11 +57,7 @@ export function createFileDiffHandler() {
|
|||||||
|
|
||||||
res.json({ success: true, diff, filePath });
|
res.json({ success: true, diff, filePath });
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
const code = (innerError as NodeJS.ErrnoException | undefined)?.code;
|
logError(innerError, 'Worktree file diff failed');
|
||||||
// ENOENT is expected when a feature has no worktree; don't log as an error.
|
|
||||||
if (code && code !== 'ENOENT') {
|
|
||||||
logError(innerError, 'Worktree file diff failed');
|
|
||||||
}
|
|
||||||
res.json({ success: true, diff: '', filePath });
|
res.json({ success: true, diff: '', filePath });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
/**
|
|
||||||
* Init Script routes - Read/write/run the worktree-init.sh file
|
|
||||||
*
|
|
||||||
* POST /init-script - Read the init script content
|
|
||||||
* PUT /init-script - Write content to the init script file
|
|
||||||
* DELETE /init-script - Delete the init script file
|
|
||||||
* POST /run-init-script - Run the init script for a worktree
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import path from 'path';
|
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
|
||||||
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import type { EventEmitter } from '../../../lib/events.js';
|
|
||||||
import { forceRunInitScript } from '../../../services/init-script-service.js';
|
|
||||||
|
|
||||||
const logger = createLogger('InitScript');
|
|
||||||
|
|
||||||
/** Fixed path for init script within .automaker directory */
|
|
||||||
const INIT_SCRIPT_FILENAME = 'worktree-init.sh';
|
|
||||||
|
|
||||||
/** Maximum allowed size for init scripts (1MB) */
|
|
||||||
const MAX_SCRIPT_SIZE_BYTES = 1024 * 1024;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the full path to the init script for a project
|
|
||||||
*/
|
|
||||||
function getInitScriptPath(projectPath: string): string {
|
|
||||||
return path.join(projectPath, '.automaker', INIT_SCRIPT_FILENAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /init-script - Read the init script content
|
|
||||||
*/
|
|
||||||
export function createGetInitScriptHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const rawProjectPath = req.query.projectPath;
|
|
||||||
|
|
||||||
// Validate projectPath is a non-empty string (not an array or undefined)
|
|
||||||
if (!rawProjectPath || typeof rawProjectPath !== 'string') {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'projectPath query parameter is required',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectPath = rawProjectPath.trim();
|
|
||||||
if (!projectPath) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'projectPath cannot be empty',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scriptPath = getInitScriptPath(projectPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = await secureFs.readFile(scriptPath, 'utf-8');
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
exists: true,
|
|
||||||
content: content as string,
|
|
||||||
path: scriptPath,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// File doesn't exist
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
exists: false,
|
|
||||||
content: '',
|
|
||||||
path: scriptPath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Read init script failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /init-script - Write content to the init script file
|
|
||||||
*/
|
|
||||||
export function createPutInitScriptHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { projectPath, content } = req.body as {
|
|
||||||
projectPath: string;
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!projectPath) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'projectPath is required',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof content !== 'string') {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'content must be a string',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate script size to prevent disk exhaustion
|
|
||||||
const sizeBytes = Buffer.byteLength(content, 'utf-8');
|
|
||||||
if (sizeBytes > MAX_SCRIPT_SIZE_BYTES) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: `Script size (${Math.round(sizeBytes / 1024)}KB) exceeds maximum allowed size (${Math.round(MAX_SCRIPT_SIZE_BYTES / 1024)}KB)`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log warning if potentially dangerous patterns are detected (non-blocking)
|
|
||||||
const dangerousPatterns = [
|
|
||||||
/rm\s+-rf\s+\/(?!\s*\$)/i, // rm -rf / (not followed by variable)
|
|
||||||
/curl\s+.*\|\s*(?:bash|sh)/i, // curl | bash
|
|
||||||
/wget\s+.*\|\s*(?:bash|sh)/i, // wget | sh
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of dangerousPatterns) {
|
|
||||||
if (pattern.test(content)) {
|
|
||||||
logger.warn(
|
|
||||||
`Init script contains potentially dangerous pattern: ${pattern.source}. User responsibility to verify script safety.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scriptPath = getInitScriptPath(projectPath);
|
|
||||||
const automakerDir = path.dirname(scriptPath);
|
|
||||||
|
|
||||||
// Ensure .automaker directory exists
|
|
||||||
await secureFs.mkdir(automakerDir, { recursive: true });
|
|
||||||
|
|
||||||
// Write the script content
|
|
||||||
await secureFs.writeFile(scriptPath, content, 'utf-8');
|
|
||||||
|
|
||||||
logger.info(`Wrote init script to ${scriptPath}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
path: scriptPath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Write init script failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /init-script - Delete the init script file
|
|
||||||
*/
|
|
||||||
export function createDeleteInitScriptHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { projectPath } = req.body as { projectPath: string };
|
|
||||||
|
|
||||||
if (!projectPath) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'projectPath is required',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scriptPath = getInitScriptPath(projectPath);
|
|
||||||
|
|
||||||
await secureFs.rm(scriptPath, { force: true });
|
|
||||||
logger.info(`Deleted init script at ${scriptPath}`);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Delete init script failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /run-init-script - Run (or re-run) the init script for a worktree
|
|
||||||
*/
|
|
||||||
export function createRunInitScriptHandler(events: EventEmitter) {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { projectPath, worktreePath, branch } = req.body as {
|
|
||||||
projectPath: string;
|
|
||||||
worktreePath: string;
|
|
||||||
branch: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!projectPath) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'projectPath is required',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!worktreePath) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'worktreePath is required',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!branch) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'branch is required',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate branch name to prevent injection via environment variables
|
|
||||||
if (!isValidBranchName(branch)) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scriptPath = getInitScriptPath(projectPath);
|
|
||||||
|
|
||||||
// Check if script exists
|
|
||||||
try {
|
|
||||||
await secureFs.access(scriptPath);
|
|
||||||
} catch {
|
|
||||||
res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'No init script found. Create one in Settings > Worktrees.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Running init script for branch "${branch}" (forced)`);
|
|
||||||
|
|
||||||
// Run the script asynchronously (non-blocking)
|
|
||||||
forceRunInitScript({
|
|
||||||
projectPath,
|
|
||||||
worktreePath,
|
|
||||||
branch,
|
|
||||||
emitter: events,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return immediately - progress will be streamed via WebSocket events
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Init script started',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Run init script failed');
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -2,23 +2,18 @@
|
|||||||
* POST /list endpoint - List all git worktrees
|
* POST /list endpoint - List all git worktrees
|
||||||
*
|
*
|
||||||
* Returns actual git worktrees from `git worktree list`.
|
* Returns actual git worktrees from `git worktree list`.
|
||||||
* Also scans .worktrees/ directory to discover worktrees that may have been
|
|
||||||
* created externally or whose git state was corrupted.
|
|
||||||
* Does NOT include tracked branches - only real worktrees with separate directories.
|
* Does NOT include tracked branches - only real worktrees with separate directories.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
||||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const logger = createLogger('Worktree');
|
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -40,87 +35,6 @@ async function getCurrentBranch(cwd: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan the .worktrees directory to discover worktrees that may exist on disk
|
|
||||||
* but are not registered with git (e.g., created externally or corrupted state).
|
|
||||||
*/
|
|
||||||
async function scanWorktreesDirectory(
|
|
||||||
projectPath: string,
|
|
||||||
knownWorktreePaths: Set<string>
|
|
||||||
): Promise<Array<{ path: string; branch: string }>> {
|
|
||||||
const discovered: Array<{ path: string; branch: string }> = [];
|
|
||||||
const worktreesDir = path.join(projectPath, '.worktrees');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if .worktrees directory exists
|
|
||||||
await secureFs.access(worktreesDir);
|
|
||||||
} catch {
|
|
||||||
// .worktrees directory doesn't exist
|
|
||||||
return discovered;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const entries = await secureFs.readdir(worktreesDir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isDirectory()) continue;
|
|
||||||
|
|
||||||
const worktreePath = path.join(worktreesDir, entry.name);
|
|
||||||
const normalizedPath = normalizePath(worktreePath);
|
|
||||||
|
|
||||||
// Skip if already known from git worktree list
|
|
||||||
if (knownWorktreePaths.has(normalizedPath)) continue;
|
|
||||||
|
|
||||||
// Check if this is a valid git repository
|
|
||||||
const gitPath = path.join(worktreePath, '.git');
|
|
||||||
try {
|
|
||||||
const gitStat = await secureFs.stat(gitPath);
|
|
||||||
|
|
||||||
// Git worktrees have a .git FILE (not directory) that points to the parent repo
|
|
||||||
// Regular repos have a .git DIRECTORY
|
|
||||||
if (gitStat.isFile() || gitStat.isDirectory()) {
|
|
||||||
// Try to get the branch name
|
|
||||||
const branch = await getCurrentBranch(worktreePath);
|
|
||||||
if (branch) {
|
|
||||||
logger.info(
|
|
||||||
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${branch})`
|
|
||||||
);
|
|
||||||
discovered.push({
|
|
||||||
path: normalizedPath,
|
|
||||||
branch,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Try to get branch from HEAD if branch --show-current fails (detached HEAD)
|
|
||||||
try {
|
|
||||||
const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
const headBranch = headRef.trim();
|
|
||||||
if (headBranch && headBranch !== 'HEAD') {
|
|
||||||
logger.info(
|
|
||||||
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
|
|
||||||
);
|
|
||||||
discovered.push({
|
|
||||||
path: normalizedPath,
|
|
||||||
branch: headBranch,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Can't determine branch, skip this directory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not a git repo, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Failed to scan .worktrees directory: ${getErrorMessage(error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return discovered;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createListHandler() {
|
export function createListHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -202,22 +116,6 @@ export function createListHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan .worktrees directory to discover worktrees that exist on disk
|
|
||||||
// but are not registered with git (e.g., created externally)
|
|
||||||
const knownPaths = new Set(worktrees.map((w) => w.path));
|
|
||||||
const discoveredWorktrees = await scanWorktreesDirectory(projectPath, knownPaths);
|
|
||||||
|
|
||||||
// Add discovered worktrees to the list
|
|
||||||
for (const discovered of discoveredWorktrees) {
|
|
||||||
worktrees.push({
|
|
||||||
path: discovered.path,
|
|
||||||
branch: discovered.branch,
|
|
||||||
isMain: false,
|
|
||||||
isCurrent: discovered.branch === currentBranch,
|
|
||||||
hasWorktree: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read all worktree metadata to get PR info
|
// Read all worktree metadata to get PR info
|
||||||
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,78 @@
|
|||||||
/**
|
/**
|
||||||
* POST /open-in-editor endpoint - Open a worktree directory in the default code editor
|
* POST /open-in-editor endpoint - Open a worktree directory in the default code editor
|
||||||
* GET /default-editor endpoint - Get the name of the default code editor
|
* GET /default-editor endpoint - Get the name of the default code editor
|
||||||
* POST /refresh-editors endpoint - Clear editor cache and re-detect available editors
|
|
||||||
*
|
|
||||||
* This module uses @automaker/platform for cross-platform editor detection and launching.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { isAbsolute } from 'path';
|
import { exec } from 'child_process';
|
||||||
import {
|
import { promisify } from 'util';
|
||||||
clearEditorCache,
|
|
||||||
detectAllEditors,
|
|
||||||
detectDefaultEditor,
|
|
||||||
openInEditor,
|
|
||||||
openInFileManager,
|
|
||||||
} from '@automaker/platform';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
const logger = createLogger('open-in-editor');
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
export function createGetAvailableEditorsHandler() {
|
// Editor detection with caching
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
interface EditorInfo {
|
||||||
try {
|
name: string;
|
||||||
const editors = await detectAllEditors();
|
command: string;
|
||||||
res.json({
|
}
|
||||||
success: true,
|
|
||||||
result: {
|
let cachedEditor: EditorInfo | null = null;
|
||||||
editors,
|
|
||||||
},
|
/**
|
||||||
});
|
* Detect which code editor is available on the system
|
||||||
} catch (error) {
|
*/
|
||||||
logError(error, 'Get available editors failed');
|
async function detectDefaultEditor(): Promise<EditorInfo> {
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
// Return cached result if available
|
||||||
}
|
if (cachedEditor) {
|
||||||
};
|
return cachedEditor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Cursor first (if user has Cursor, they probably prefer it)
|
||||||
|
try {
|
||||||
|
await execAsync('which cursor || where cursor');
|
||||||
|
cachedEditor = { name: 'Cursor', command: 'cursor' };
|
||||||
|
return cachedEditor;
|
||||||
|
} catch {
|
||||||
|
// Cursor not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try VS Code
|
||||||
|
try {
|
||||||
|
await execAsync('which code || where code');
|
||||||
|
cachedEditor = { name: 'VS Code', command: 'code' };
|
||||||
|
return cachedEditor;
|
||||||
|
} catch {
|
||||||
|
// VS Code not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Zed
|
||||||
|
try {
|
||||||
|
await execAsync('which zed || where zed');
|
||||||
|
cachedEditor = { name: 'Zed', command: 'zed' };
|
||||||
|
return cachedEditor;
|
||||||
|
} catch {
|
||||||
|
// Zed not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Sublime Text
|
||||||
|
try {
|
||||||
|
await execAsync('which subl || where subl');
|
||||||
|
cachedEditor = { name: 'Sublime Text', command: 'subl' };
|
||||||
|
return cachedEditor;
|
||||||
|
} catch {
|
||||||
|
// Sublime not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to file manager
|
||||||
|
const platform = process.platform;
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
cachedEditor = { name: 'Finder', command: 'open' };
|
||||||
|
} else if (platform === 'win32') {
|
||||||
|
cachedEditor = { name: 'Explorer', command: 'explorer' };
|
||||||
|
} else {
|
||||||
|
cachedEditor = { name: 'File Manager', command: 'xdg-open' };
|
||||||
|
}
|
||||||
|
return cachedEditor;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGetDefaultEditorHandler() {
|
export function createGetDefaultEditorHandler() {
|
||||||
@@ -55,41 +93,11 @@ export function createGetDefaultEditorHandler() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler to refresh the editor cache and re-detect available editors
|
|
||||||
* Useful when the user has installed/uninstalled editors
|
|
||||||
*/
|
|
||||||
export function createRefreshEditorsHandler() {
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Clear the cache
|
|
||||||
clearEditorCache();
|
|
||||||
|
|
||||||
// Re-detect editors (this will repopulate the cache)
|
|
||||||
const editors = await detectAllEditors();
|
|
||||||
|
|
||||||
logger.info(`Editor cache refreshed, found ${editors.length} editors`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
editors,
|
|
||||||
message: `Found ${editors.length} available editors`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Refresh editors failed');
|
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOpenInEditorHandler() {
|
export function createOpenInEditorHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, editorCommand } = req.body as {
|
const { worktreePath } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
editorCommand?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -100,44 +108,42 @@ export function createOpenInEditorHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security: Validate that worktreePath is an absolute path
|
const editor = await detectDefaultEditor();
|
||||||
if (!isAbsolute(worktreePath)) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'worktreePath must be an absolute path',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the platform utility to open in editor
|
await execAsync(`${editor.command} "${worktreePath}"`);
|
||||||
const result = await openInEditor(worktreePath, editorCommand);
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
message: `Opened ${worktreePath} in ${result.editorName}`,
|
message: `Opened ${worktreePath} in ${editor.name}`,
|
||||||
editorName: result.editorName,
|
editorName: editor.name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (editorError) {
|
} catch (editorError) {
|
||||||
// If the specified editor fails, try opening in default file manager as fallback
|
// If the detected editor fails, try opening in default file manager as fallback
|
||||||
logger.warn(
|
const platform = process.platform;
|
||||||
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
|
let openCommand: string;
|
||||||
);
|
let fallbackName: string;
|
||||||
|
|
||||||
try {
|
if (platform === 'darwin') {
|
||||||
const result = await openInFileManager(worktreePath);
|
openCommand = `open "${worktreePath}"`;
|
||||||
res.json({
|
fallbackName = 'Finder';
|
||||||
success: true,
|
} else if (platform === 'win32') {
|
||||||
result: {
|
openCommand = `explorer "${worktreePath}"`;
|
||||||
message: `Opened ${worktreePath} in ${result.editorName}`,
|
fallbackName = 'Explorer';
|
||||||
editorName: result.editorName,
|
} else {
|
||||||
},
|
openCommand = `xdg-open "${worktreePath}"`;
|
||||||
});
|
fallbackName = 'File Manager';
|
||||||
} catch (fallbackError) {
|
|
||||||
// Both editor and file manager failed
|
|
||||||
throw fallbackError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await execAsync(openCommand);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
message: `Opened ${worktreePath} in ${fallbackName}`,
|
||||||
|
editorName: fallbackName,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Open in editor failed');
|
logError(error, 'Open in editor failed');
|
||||||
|
|||||||
@@ -6,16 +6,13 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as secureFs from '../lib/secure-fs.js';
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../lib/events.js';
|
import type { EventEmitter } from '../lib/events.js';
|
||||||
import type { ExecuteOptions, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
import type { ExecuteOptions, ThinkingLevel } from '@automaker/types';
|
||||||
import { stripProviderPrefix } from '@automaker/types';
|
|
||||||
import {
|
import {
|
||||||
readImageAsBase64,
|
readImageAsBase64,
|
||||||
buildPromptWithImages,
|
buildPromptWithImages,
|
||||||
isAbortError,
|
isAbortError,
|
||||||
loadContextFiles,
|
loadContextFiles,
|
||||||
createLogger,
|
createLogger,
|
||||||
classifyError,
|
|
||||||
getUserFriendlyErrorMessage,
|
|
||||||
} from '@automaker/utils';
|
} from '@automaker/utils';
|
||||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||||
@@ -23,12 +20,10 @@ import { PathNotAllowedError } from '@automaker/platform';
|
|||||||
import type { SettingsService } from './settings-service.js';
|
import type { SettingsService } from './settings-service.js';
|
||||||
import {
|
import {
|
||||||
getAutoLoadClaudeMdSetting,
|
getAutoLoadClaudeMdSetting,
|
||||||
|
getEnableSandboxModeSetting,
|
||||||
filterClaudeMdFromContext,
|
filterClaudeMdFromContext,
|
||||||
getMCPServersFromSettings,
|
getMCPServersFromSettings,
|
||||||
getPromptCustomization,
|
getPromptCustomization,
|
||||||
getSkillsConfiguration,
|
|
||||||
getSubagentsConfiguration,
|
|
||||||
getCustomSubagents,
|
|
||||||
} from '../lib/settings-helpers.js';
|
} from '../lib/settings-helpers.js';
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
@@ -60,7 +55,6 @@ interface Session {
|
|||||||
workingDirectory: string;
|
workingDirectory: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingLevel?: ThinkingLevel; // Thinking level for Claude models
|
thinkingLevel?: ThinkingLevel; // Thinking level for Claude models
|
||||||
reasoningEffort?: ReasoningEffort; // Reasoning effort for Codex models
|
|
||||||
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
|
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
|
||||||
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
|
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
|
||||||
}
|
}
|
||||||
@@ -150,7 +144,6 @@ export class AgentService {
|
|||||||
imagePaths,
|
imagePaths,
|
||||||
model,
|
model,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
reasoningEffort,
|
|
||||||
}: {
|
}: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -158,7 +151,6 @@ export class AgentService {
|
|||||||
imagePaths?: string[];
|
imagePaths?: string[];
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
reasoningEffort?: ReasoningEffort;
|
|
||||||
}) {
|
}) {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -171,7 +163,7 @@ export class AgentService {
|
|||||||
throw new Error('Agent is already processing a message');
|
throw new Error('Agent is already processing a message');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update session model, thinking level, and reasoning effort if provided
|
// Update session model and thinking level if provided
|
||||||
if (model) {
|
if (model) {
|
||||||
session.model = model;
|
session.model = model;
|
||||||
await this.updateSession(sessionId, { model });
|
await this.updateSession(sessionId, { model });
|
||||||
@@ -179,21 +171,6 @@ export class AgentService {
|
|||||||
if (thinkingLevel !== undefined) {
|
if (thinkingLevel !== undefined) {
|
||||||
session.thinkingLevel = thinkingLevel;
|
session.thinkingLevel = thinkingLevel;
|
||||||
}
|
}
|
||||||
if (reasoningEffort !== undefined) {
|
|
||||||
session.reasoningEffort = reasoningEffort;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate vision support before processing images
|
|
||||||
const effectiveModel = model || session.model;
|
|
||||||
if (imagePaths && imagePaths.length > 0 && effectiveModel) {
|
|
||||||
const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel);
|
|
||||||
if (!supportsVision) {
|
|
||||||
throw new Error(
|
|
||||||
`This model (${effectiveModel}) does not support image input. ` +
|
|
||||||
`Please switch to a model that supports vision, or remove the images and try again.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read images and convert to base64
|
// Read images and convert to base64
|
||||||
const images: Message['images'] = [];
|
const images: Message['images'] = [];
|
||||||
@@ -255,34 +232,19 @@ export class AgentService {
|
|||||||
'[AgentService]'
|
'[AgentService]'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Load enableSandboxMode setting (global setting only)
|
||||||
|
const enableSandboxMode = await getEnableSandboxModeSetting(
|
||||||
|
this.settingsService,
|
||||||
|
'[AgentService]'
|
||||||
|
);
|
||||||
|
|
||||||
// Load MCP servers from settings (global setting only)
|
// Load MCP servers from settings (global setting only)
|
||||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
||||||
|
|
||||||
// Get Skills configuration from settings
|
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
||||||
const skillsConfig = this.settingsService
|
|
||||||
? await getSkillsConfiguration(this.settingsService)
|
|
||||||
: { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false };
|
|
||||||
|
|
||||||
// Get Subagents configuration from settings
|
|
||||||
const subagentsConfig = this.settingsService
|
|
||||||
? await getSubagentsConfiguration(this.settingsService)
|
|
||||||
: { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false };
|
|
||||||
|
|
||||||
// Get custom subagents from settings (merge global + project-level) only if enabled
|
|
||||||
const customSubagents =
|
|
||||||
this.settingsService && subagentsConfig.enabled
|
|
||||||
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
|
|
||||||
// Use the user's message as task context for smart memory selection
|
|
||||||
const contextResult = await loadContextFiles({
|
const contextResult = await loadContextFiles({
|
||||||
projectPath: effectiveWorkDir,
|
projectPath: effectiveWorkDir,
|
||||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||||
taskContext: {
|
|
||||||
title: message.substring(0, 200), // Use first 200 chars as title
|
|
||||||
description: message,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
||||||
@@ -296,9 +258,8 @@ export class AgentService {
|
|||||||
: baseSystemPrompt;
|
: baseSystemPrompt;
|
||||||
|
|
||||||
// Build SDK options using centralized configuration
|
// Build SDK options using centralized configuration
|
||||||
// Use thinking level and reasoning effort from request, or fall back to session's stored values
|
// Use thinking level from request, or fall back to session's stored thinking level
|
||||||
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
|
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
|
||||||
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort;
|
|
||||||
const sdkOptions = createChatOptions({
|
const sdkOptions = createChatOptions({
|
||||||
cwd: effectiveWorkDir,
|
cwd: effectiveWorkDir,
|
||||||
model: model,
|
model: model,
|
||||||
@@ -306,6 +267,7 @@ export class AgentService {
|
|||||||
systemPrompt: combinedSystemPrompt,
|
systemPrompt: combinedSystemPrompt,
|
||||||
abortController: session.abortController!,
|
abortController: session.abortController!,
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
|
enableSandboxMode,
|
||||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||||
});
|
});
|
||||||
@@ -313,71 +275,25 @@ export class AgentService {
|
|||||||
// Extract model, maxTurns, and allowedTools from SDK options
|
// Extract model, maxTurns, and allowedTools from SDK options
|
||||||
const effectiveModel = sdkOptions.model!;
|
const effectiveModel = sdkOptions.model!;
|
||||||
const maxTurns = sdkOptions.maxTurns;
|
const maxTurns = sdkOptions.maxTurns;
|
||||||
let allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
||||||
|
|
||||||
// Build merged settingSources array using Set for automatic deduplication
|
// Get provider for this model
|
||||||
const sdkSettingSources = (sdkOptions.settingSources ?? []).filter(
|
|
||||||
(source): source is 'user' | 'project' => source === 'user' || source === 'project'
|
|
||||||
);
|
|
||||||
const skillSettingSources = skillsConfig.enabled ? skillsConfig.sources : [];
|
|
||||||
const settingSources = [...new Set([...sdkSettingSources, ...skillSettingSources])];
|
|
||||||
|
|
||||||
// Enhance allowedTools with Skills and Subagents tools
|
|
||||||
// These tools are not in the provider's default set - they're added dynamically based on settings
|
|
||||||
const needsSkillTool = skillsConfig.shouldIncludeInTools;
|
|
||||||
const needsTaskTool =
|
|
||||||
subagentsConfig.shouldIncludeInTools &&
|
|
||||||
customSubagents &&
|
|
||||||
Object.keys(customSubagents).length > 0;
|
|
||||||
|
|
||||||
// Base tools that match the provider's default set
|
|
||||||
const baseTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
|
||||||
|
|
||||||
if (allowedTools) {
|
|
||||||
allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options
|
|
||||||
// Add Skill tool if skills are enabled
|
|
||||||
if (needsSkillTool && !allowedTools.includes('Skill')) {
|
|
||||||
allowedTools.push('Skill');
|
|
||||||
}
|
|
||||||
// Add Task tool if custom subagents are configured
|
|
||||||
if (needsTaskTool && !allowedTools.includes('Task')) {
|
|
||||||
allowedTools.push('Task');
|
|
||||||
}
|
|
||||||
} else if (needsSkillTool || needsTaskTool) {
|
|
||||||
// If no allowedTools specified but we need to add Skill/Task tools,
|
|
||||||
// build the full list including base tools
|
|
||||||
allowedTools = [...baseTools];
|
|
||||||
if (needsSkillTool) {
|
|
||||||
allowedTools.push('Skill');
|
|
||||||
}
|
|
||||||
if (needsTaskTool) {
|
|
||||||
allowedTools.push('Task');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get provider for this model (with prefix)
|
|
||||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||||
|
|
||||||
// Strip provider prefix - providers should receive bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(effectiveModel);
|
|
||||||
|
|
||||||
// Build options for provider
|
// Build options for provider
|
||||||
const options: ExecuteOptions = {
|
const options: ExecuteOptions = {
|
||||||
prompt: '', // Will be set below based on images
|
prompt: '', // Will be set below based on images
|
||||||
model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1")
|
model: effectiveModel,
|
||||||
originalModel: effectiveModel, // Original with prefix for logging (e.g., "codex-gpt-5.1-codex-max")
|
|
||||||
cwd: effectiveWorkDir,
|
cwd: effectiveWorkDir,
|
||||||
systemPrompt: sdkOptions.systemPrompt,
|
systemPrompt: sdkOptions.systemPrompt,
|
||||||
maxTurns: maxTurns,
|
maxTurns: maxTurns,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
abortController: session.abortController!,
|
abortController: session.abortController!,
|
||||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||||
settingSources: settingSources.length > 0 ? settingSources : undefined,
|
settingSources: sdkOptions.settingSources,
|
||||||
|
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||||
agents: customSubagents, // Pass custom subagents for task delegation
|
|
||||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
|
||||||
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build prompt content with images
|
// Build prompt content with images
|
||||||
@@ -458,53 +374,6 @@ export class AgentService {
|
|||||||
content: responseText,
|
content: responseText,
|
||||||
toolUses,
|
toolUses,
|
||||||
});
|
});
|
||||||
} else if (msg.type === 'error') {
|
|
||||||
// Some providers (like Codex CLI/SaaS or Cursor CLI) surface failures as
|
|
||||||
// streamed error messages instead of throwing. Handle these here so the
|
|
||||||
// Agent Runner UX matches the Claude/Cursor behavior without changing
|
|
||||||
// their provider implementations.
|
|
||||||
const rawErrorText =
|
|
||||||
(typeof msg.error === 'string' && msg.error.trim()) ||
|
|
||||||
'Unexpected error from provider during agent execution.';
|
|
||||||
|
|
||||||
const errorInfo = classifyError(new Error(rawErrorText));
|
|
||||||
|
|
||||||
// Keep the provider-supplied text intact (Codex already includes helpful tips),
|
|
||||||
// only add a small rate-limit hint when we can detect it.
|
|
||||||
const enhancedText = errorInfo.isRateLimit
|
|
||||||
? `${rawErrorText}\n\nTip: It looks like you hit a rate limit. Try waiting a bit or reducing concurrent Agent Runner / Auto Mode tasks.`
|
|
||||||
: rawErrorText;
|
|
||||||
|
|
||||||
this.logger.error('Provider error during agent execution:', {
|
|
||||||
type: errorInfo.type,
|
|
||||||
message: errorInfo.message,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark session as no longer running so the UI and queue stay in sync
|
|
||||||
session.isRunning = false;
|
|
||||||
session.abortController = null;
|
|
||||||
|
|
||||||
const errorMessage: Message = {
|
|
||||||
id: this.generateId(),
|
|
||||||
role: 'assistant',
|
|
||||||
content: `Error: ${enhancedText}`,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
session.messages.push(errorMessage);
|
|
||||||
await this.saveSession(sessionId, session.messages);
|
|
||||||
|
|
||||||
this.emitAgentEvent(sessionId, {
|
|
||||||
type: 'error',
|
|
||||||
error: enhancedText,
|
|
||||||
message: errorMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Don't continue streaming after an error message
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,30 +14,24 @@ import type {
|
|||||||
ExecuteOptions,
|
ExecuteOptions,
|
||||||
Feature,
|
Feature,
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
|
PipelineConfig,
|
||||||
PipelineStep,
|
PipelineStep,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
PlanningMode,
|
PlanningMode,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
buildPromptWithImages,
|
buildPromptWithImages,
|
||||||
|
isAbortError,
|
||||||
classifyError,
|
classifyError,
|
||||||
loadContextFiles,
|
loadContextFiles,
|
||||||
appendLearning,
|
|
||||||
recordMemoryUsage,
|
|
||||||
createLogger,
|
createLogger,
|
||||||
} from '@automaker/utils';
|
} from '@automaker/utils';
|
||||||
|
|
||||||
const logger = createLogger('AutoMode');
|
const logger = createLogger('AutoMode');
|
||||||
import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver';
|
import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver';
|
||||||
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
|
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
|
||||||
import {
|
import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform';
|
||||||
getFeatureDir,
|
|
||||||
getAutomakerDir,
|
|
||||||
getFeaturesDir,
|
|
||||||
getExecutionStatePath,
|
|
||||||
ensureAutomakerDir,
|
|
||||||
} from '@automaker/platform';
|
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -53,6 +47,7 @@ import type { SettingsService } from './settings-service.js';
|
|||||||
import { pipelineService, PipelineService } from './pipeline-service.js';
|
import { pipelineService, PipelineService } from './pipeline-service.js';
|
||||||
import {
|
import {
|
||||||
getAutoLoadClaudeMdSetting,
|
getAutoLoadClaudeMdSetting,
|
||||||
|
getEnableSandboxModeSetting,
|
||||||
filterClaudeMdFromContext,
|
filterClaudeMdFromContext,
|
||||||
getMCPServersFromSettings,
|
getMCPServersFromSettings,
|
||||||
getPromptCustomization,
|
getPromptCustomization,
|
||||||
@@ -207,29 +202,6 @@ interface AutoModeConfig {
|
|||||||
projectPath: string;
|
projectPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Execution state for recovery after server restart
|
|
||||||
* Tracks which features were running and auto-loop configuration
|
|
||||||
*/
|
|
||||||
interface ExecutionState {
|
|
||||||
version: 1;
|
|
||||||
autoLoopWasRunning: boolean;
|
|
||||||
maxConcurrency: number;
|
|
||||||
projectPath: string;
|
|
||||||
runningFeatureIds: string[];
|
|
||||||
savedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default empty execution state
|
|
||||||
const DEFAULT_EXECUTION_STATE: ExecutionState = {
|
|
||||||
version: 1,
|
|
||||||
autoLoopWasRunning: false,
|
|
||||||
maxConcurrency: 3,
|
|
||||||
projectPath: '',
|
|
||||||
runningFeatureIds: [],
|
|
||||||
savedAt: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Constants for consecutive failure tracking
|
// Constants for consecutive failure tracking
|
||||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
||||||
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
||||||
@@ -351,11 +323,6 @@ export class AutoModeService {
|
|||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save execution state for recovery after restart
|
|
||||||
await this.saveExecutionState(projectPath);
|
|
||||||
|
|
||||||
// Note: Memory folder initialization is now handled by loadContextFiles
|
|
||||||
|
|
||||||
// Run the loop in the background
|
// Run the loop in the background
|
||||||
this.runAutoLoop().catch((error) => {
|
this.runAutoLoop().catch((error) => {
|
||||||
logger.error('Loop error:', error);
|
logger.error('Loop error:', error);
|
||||||
@@ -422,23 +389,17 @@ export class AutoModeService {
|
|||||||
*/
|
*/
|
||||||
async stopAutoLoop(): Promise<number> {
|
async stopAutoLoop(): Promise<number> {
|
||||||
const wasRunning = this.autoLoopRunning;
|
const wasRunning = this.autoLoopRunning;
|
||||||
const projectPath = this.config?.projectPath;
|
|
||||||
this.autoLoopRunning = false;
|
this.autoLoopRunning = false;
|
||||||
if (this.autoLoopAbortController) {
|
if (this.autoLoopAbortController) {
|
||||||
this.autoLoopAbortController.abort();
|
this.autoLoopAbortController.abort();
|
||||||
this.autoLoopAbortController = null;
|
this.autoLoopAbortController = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear execution state when auto-loop is explicitly stopped
|
|
||||||
if (projectPath) {
|
|
||||||
await this.clearExecutionState(projectPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit stop event immediately when user explicitly stops
|
// Emit stop event immediately when user explicitly stops
|
||||||
if (wasRunning) {
|
if (wasRunning) {
|
||||||
this.emitAutoModeEvent('auto_mode_stopped', {
|
this.emitAutoModeEvent('auto_mode_stopped', {
|
||||||
message: 'Auto mode stopped',
|
message: 'Auto mode stopped',
|
||||||
projectPath,
|
projectPath: this.config?.projectPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,11 +440,6 @@ export class AutoModeService {
|
|||||||
};
|
};
|
||||||
this.runningFeatures.set(featureId, tempRunningFeature);
|
this.runningFeatures.set(featureId, tempRunningFeature);
|
||||||
|
|
||||||
// Save execution state when feature starts
|
|
||||||
if (isAutoMode) {
|
|
||||||
await this.saveExecutionState(projectPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate that project path is allowed using centralized validation
|
// Validate that project path is allowed using centralized validation
|
||||||
validateWorkingDirectory(projectPath);
|
validateWorkingDirectory(projectPath);
|
||||||
@@ -558,21 +514,15 @@ export class AutoModeService {
|
|||||||
|
|
||||||
// Build the prompt - use continuation prompt if provided (for recovery after plan approval)
|
// Build the prompt - use continuation prompt if provided (for recovery after plan approval)
|
||||||
let prompt: string;
|
let prompt: string;
|
||||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
|
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
|
||||||
// Context loader uses task context to select relevant memory files
|
|
||||||
const contextResult = await loadContextFiles({
|
const contextResult = await loadContextFiles({
|
||||||
projectPath,
|
projectPath,
|
||||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||||
taskContext: {
|
|
||||||
title: feature.title ?? '',
|
|
||||||
description: feature.description ?? '',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
||||||
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
|
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
|
||||||
// Note: contextResult.formattedPrompt now includes both context AND memory
|
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||||
const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
|
||||||
|
|
||||||
if (options?.continuationPrompt) {
|
if (options?.continuationPrompt) {
|
||||||
// Continuation prompt is used when recovering from a plan approval
|
// Continuation prompt is used when recovering from a plan approval
|
||||||
@@ -625,7 +575,7 @@ export class AutoModeService {
|
|||||||
projectPath,
|
projectPath,
|
||||||
planningMode: feature.planningMode,
|
planningMode: feature.planningMode,
|
||||||
requirePlanApproval: feature.requirePlanApproval,
|
requirePlanApproval: feature.requirePlanApproval,
|
||||||
systemPrompt: combinedSystemPrompt || undefined,
|
systemPrompt: contextFilesPrompt || undefined,
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
thinkingLevel: feature.thinkingLevel,
|
thinkingLevel: feature.thinkingLevel,
|
||||||
}
|
}
|
||||||
@@ -657,36 +607,6 @@ export class AutoModeService {
|
|||||||
// Record success to reset consecutive failure tracking
|
// Record success to reset consecutive failure tracking
|
||||||
this.recordSuccess();
|
this.recordSuccess();
|
||||||
|
|
||||||
// Record learnings and memory usage after successful feature completion
|
|
||||||
try {
|
|
||||||
const featureDir = getFeatureDir(projectPath, featureId);
|
|
||||||
const outputPath = path.join(featureDir, 'agent-output.md');
|
|
||||||
let agentOutput = '';
|
|
||||||
try {
|
|
||||||
const outputContent = await secureFs.readFile(outputPath, 'utf-8');
|
|
||||||
agentOutput =
|
|
||||||
typeof outputContent === 'string' ? outputContent : outputContent.toString();
|
|
||||||
} catch {
|
|
||||||
// Agent output might not exist yet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record memory usage if we loaded any memory files
|
|
||||||
if (contextResult.memoryFiles.length > 0 && agentOutput) {
|
|
||||||
await recordMemoryUsage(
|
|
||||||
projectPath,
|
|
||||||
contextResult.memoryFiles,
|
|
||||||
agentOutput,
|
|
||||||
true, // success
|
|
||||||
secureFs as Parameters<typeof recordMemoryUsage>[4]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract and record learnings from the agent output
|
|
||||||
await this.recordLearningsFromFeature(projectPath, feature, agentOutput);
|
|
||||||
} catch (learningError) {
|
|
||||||
console.warn('[AutoMode] Failed to record learnings:', learningError);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
featureId,
|
featureId,
|
||||||
passes: true,
|
passes: true,
|
||||||
@@ -738,11 +658,6 @@ export class AutoModeService {
|
|||||||
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||||
);
|
);
|
||||||
this.runningFeatures.delete(featureId);
|
this.runningFeatures.delete(featureId);
|
||||||
|
|
||||||
// Update execution state after feature completes
|
|
||||||
if (this.autoLoopRunning && projectPath) {
|
|
||||||
await this.saveExecutionState(projectPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,14 +675,10 @@ export class AutoModeService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
|
logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
|
||||||
|
|
||||||
// Load context files once with feature context for smart memory selection
|
// Load context files once
|
||||||
const contextResult = await loadContextFiles({
|
const contextResult = await loadContextFiles({
|
||||||
projectPath,
|
projectPath,
|
||||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||||
taskContext: {
|
|
||||||
title: feature.title ?? '',
|
|
||||||
description: feature.description ?? '',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||||
|
|
||||||
@@ -1000,10 +911,6 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
|||||||
const contextResult = await loadContextFiles({
|
const contextResult = await loadContextFiles({
|
||||||
projectPath,
|
projectPath,
|
||||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||||
taskContext: {
|
|
||||||
title: feature?.title ?? prompt.substring(0, 200),
|
|
||||||
description: feature?.description ?? prompt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
||||||
@@ -1407,6 +1314,7 @@ Format your response as a structured markdown document.`;
|
|||||||
allowedTools: sdkOptions.allowedTools as string[],
|
allowedTools: sdkOptions.allowedTools as string[],
|
||||||
abortController,
|
abortController,
|
||||||
settingSources: sdkOptions.settingSources,
|
settingSources: sdkOptions.settingSources,
|
||||||
|
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||||
thinkingLevel: analysisThinkingLevel, // Pass thinking level
|
thinkingLevel: analysisThinkingLevel, // Pass thinking level
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1876,13 +1784,9 @@ Format your response as a structured markdown document.`;
|
|||||||
// Apply dependency-aware ordering
|
// Apply dependency-aware ordering
|
||||||
const { orderedFeatures } = resolveDependencies(pendingFeatures);
|
const { orderedFeatures } = resolveDependencies(pendingFeatures);
|
||||||
|
|
||||||
// Get skipVerificationInAutoMode setting
|
|
||||||
const settings = await this.settingsService?.getGlobalSettings();
|
|
||||||
const skipVerification = settings?.skipVerificationInAutoMode ?? false;
|
|
||||||
|
|
||||||
// Filter to only features with satisfied dependencies
|
// Filter to only features with satisfied dependencies
|
||||||
const readyFeatures = orderedFeatures.filter((feature: Feature) =>
|
const readyFeatures = orderedFeatures.filter((feature: Feature) =>
|
||||||
areDependenciesSatisfied(feature, allFeatures, { skipVerification })
|
areDependenciesSatisfied(feature, allFeatures)
|
||||||
);
|
);
|
||||||
|
|
||||||
return readyFeatures;
|
return readyFeatures;
|
||||||
@@ -2085,18 +1989,6 @@ This helps parse your summary correctly in the output logs.`;
|
|||||||
const planningMode = options?.planningMode || 'skip';
|
const planningMode = options?.planningMode || 'skip';
|
||||||
const previousContent = options?.previousContent;
|
const previousContent = options?.previousContent;
|
||||||
|
|
||||||
// Validate vision support before processing images
|
|
||||||
const effectiveModel = model || 'claude-sonnet-4-20250514';
|
|
||||||
if (imagePaths && imagePaths.length > 0) {
|
|
||||||
const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel);
|
|
||||||
if (!supportsVision) {
|
|
||||||
throw new Error(
|
|
||||||
`This model (${effectiveModel}) does not support image input. ` +
|
|
||||||
`Please switch to a model that supports vision (like Claude models), or remove the images and try again.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this planning mode can generate a spec/plan that needs approval
|
// Check if this planning mode can generate a spec/plan that needs approval
|
||||||
// - spec and full always generate specs
|
// - spec and full always generate specs
|
||||||
// - lite only generates approval-ready content when requirePlanApproval is true
|
// - lite only generates approval-ready content when requirePlanApproval is true
|
||||||
@@ -2170,6 +2062,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
? options.autoLoadClaudeMd
|
? options.autoLoadClaudeMd
|
||||||
: await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]');
|
: await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]');
|
||||||
|
|
||||||
|
// Load enableSandboxMode setting (global setting only)
|
||||||
|
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
|
||||||
|
|
||||||
// Load MCP servers from settings (global setting only)
|
// Load MCP servers from settings (global setting only)
|
||||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
|
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
|
||||||
|
|
||||||
@@ -2181,6 +2076,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
model: model,
|
model: model,
|
||||||
abortController,
|
abortController,
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
|
enableSandboxMode,
|
||||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||||
thinkingLevel: options?.thinkingLevel,
|
thinkingLevel: options?.thinkingLevel,
|
||||||
});
|
});
|
||||||
@@ -2197,12 +2093,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
// Get provider for this model
|
// Get provider for this model
|
||||||
const provider = ProviderFactory.getProviderForModel(finalModel);
|
const provider = ProviderFactory.getProviderForModel(finalModel);
|
||||||
|
|
||||||
// Strip provider prefix - providers should receive bare model IDs
|
logger.info(`Using provider "${provider.getName()}" for model "${finalModel}"`);
|
||||||
const bareModel = stripProviderPrefix(finalModel);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Using provider "${provider.getName()}" for model "${finalModel}" (bare: ${bareModel})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build prompt content with images using utility
|
// Build prompt content with images using utility
|
||||||
const { content: promptContent } = await buildPromptWithImages(
|
const { content: promptContent } = await buildPromptWithImages(
|
||||||
@@ -2221,13 +2112,14 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
|
|
||||||
const executeOptions: ExecuteOptions = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: promptContent,
|
prompt: promptContent,
|
||||||
model: bareModel,
|
model: finalModel,
|
||||||
maxTurns: maxTurns,
|
maxTurns: maxTurns,
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
abortController,
|
abortController,
|
||||||
systemPrompt: sdkOptions.systemPrompt,
|
systemPrompt: sdkOptions.systemPrompt,
|
||||||
settingSources: sdkOptions.settingSources,
|
settingSources: sdkOptions.settingSources,
|
||||||
|
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||||
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
|
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
|
||||||
};
|
};
|
||||||
@@ -2310,23 +2202,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
}, WRITE_DEBOUNCE_MS);
|
}, WRITE_DEBOUNCE_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Heartbeat logging so "silent" model calls are visible.
|
|
||||||
// Some runs can take a while before the first streamed message arrives.
|
|
||||||
const streamStartTime = Date.now();
|
|
||||||
let receivedAnyStreamMessage = false;
|
|
||||||
const STREAM_HEARTBEAT_MS = 15_000;
|
|
||||||
const streamHeartbeat = setInterval(() => {
|
|
||||||
if (receivedAnyStreamMessage) return;
|
|
||||||
const elapsedSeconds = Math.round((Date.now() - streamStartTime) / 1000);
|
|
||||||
logger.info(
|
|
||||||
`Waiting for first model response for feature ${featureId} (${elapsedSeconds}s elapsed)...`
|
|
||||||
);
|
|
||||||
}, STREAM_HEARTBEAT_MS);
|
|
||||||
|
|
||||||
// Wrap stream processing in try/finally to ensure timeout cleanup on any error/abort
|
// Wrap stream processing in try/finally to ensure timeout cleanup on any error/abort
|
||||||
try {
|
try {
|
||||||
streamLoop: for await (const msg of stream) {
|
streamLoop: for await (const msg of stream) {
|
||||||
receivedAnyStreamMessage = true;
|
|
||||||
// Log raw stream event for debugging
|
// Log raw stream event for debugging
|
||||||
appendRawEvent(msg);
|
appendRawEvent(msg);
|
||||||
|
|
||||||
@@ -2526,7 +2404,7 @@ After generating the revised spec, output:
|
|||||||
// Make revision call
|
// Make revision call
|
||||||
const revisionStream = provider.executeQuery({
|
const revisionStream = provider.executeQuery({
|
||||||
prompt: revisionPrompt,
|
prompt: revisionPrompt,
|
||||||
model: bareModel,
|
model: finalModel,
|
||||||
maxTurns: maxTurns || 100,
|
maxTurns: maxTurns || 100,
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
@@ -2664,7 +2542,7 @@ After generating the revised spec, output:
|
|||||||
// Execute task with dedicated agent
|
// Execute task with dedicated agent
|
||||||
const taskStream = provider.executeQuery({
|
const taskStream = provider.executeQuery({
|
||||||
prompt: taskPrompt,
|
prompt: taskPrompt,
|
||||||
model: bareModel,
|
model: finalModel,
|
||||||
maxTurns: Math.min(maxTurns || 100, 50), // Limit turns per task
|
maxTurns: Math.min(maxTurns || 100, 50), // Limit turns per task
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
@@ -2752,7 +2630,7 @@ Implement all the changes described in the plan above.`;
|
|||||||
|
|
||||||
const continuationStream = provider.executeQuery({
|
const continuationStream = provider.executeQuery({
|
||||||
prompt: continuationPrompt,
|
prompt: continuationPrompt,
|
||||||
model: bareModel,
|
model: finalModel,
|
||||||
maxTurns: maxTurns,
|
maxTurns: maxTurns,
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
@@ -2843,7 +2721,6 @@ Implement all the changes described in the plan above.`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearInterval(streamHeartbeat);
|
|
||||||
// ALWAYS clear pending timeouts to prevent memory leaks
|
// ALWAYS clear pending timeouts to prevent memory leaks
|
||||||
// This runs on success, error, or abort
|
// This runs on success, error, or abort
|
||||||
if (writeTimeout) {
|
if (writeTimeout) {
|
||||||
@@ -2997,350 +2874,4 @@ Begin implementing task ${task.id} now.`;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Execution State Persistence - For recovery after server restart
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save execution state to disk for recovery after server restart
|
|
||||||
*/
|
|
||||||
private async saveExecutionState(projectPath: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await ensureAutomakerDir(projectPath);
|
|
||||||
const statePath = getExecutionStatePath(projectPath);
|
|
||||||
const state: ExecutionState = {
|
|
||||||
version: 1,
|
|
||||||
autoLoopWasRunning: this.autoLoopRunning,
|
|
||||||
maxConcurrency: this.config?.maxConcurrency ?? 3,
|
|
||||||
projectPath,
|
|
||||||
runningFeatureIds: Array.from(this.runningFeatures.keys()),
|
|
||||||
savedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
|
||||||
logger.info(`Saved execution state: ${state.runningFeatureIds.length} running features`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to save execution state:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load execution state from disk
|
|
||||||
*/
|
|
||||||
private async loadExecutionState(projectPath: string): Promise<ExecutionState> {
|
|
||||||
try {
|
|
||||||
const statePath = getExecutionStatePath(projectPath);
|
|
||||||
const content = (await secureFs.readFile(statePath, 'utf-8')) as string;
|
|
||||||
const state = JSON.parse(content) as ExecutionState;
|
|
||||||
return state;
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
logger.error('Failed to load execution state:', error);
|
|
||||||
}
|
|
||||||
return DEFAULT_EXECUTION_STATE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear execution state (called on successful shutdown or when auto-loop stops)
|
|
||||||
*/
|
|
||||||
private async clearExecutionState(projectPath: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const statePath = getExecutionStatePath(projectPath);
|
|
||||||
await secureFs.unlink(statePath);
|
|
||||||
logger.info('Cleared execution state');
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
logger.error('Failed to clear execution state:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check for and resume interrupted features after server restart
|
|
||||||
* This should be called during server initialization
|
|
||||||
*/
|
|
||||||
async resumeInterruptedFeatures(projectPath: string): Promise<void> {
|
|
||||||
logger.info('Checking for interrupted features to resume...');
|
|
||||||
|
|
||||||
// Load all features and find those that were interrupted
|
|
||||||
const featuresDir = getFeaturesDir(projectPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
|
||||||
const interruptedFeatures: Feature[] = [];
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
|
||||||
try {
|
|
||||||
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
|
|
||||||
const feature = JSON.parse(data) as Feature;
|
|
||||||
|
|
||||||
// Check if feature was interrupted (in_progress or pipeline_*)
|
|
||||||
if (
|
|
||||||
feature.status === 'in_progress' ||
|
|
||||||
(feature.status && feature.status.startsWith('pipeline_'))
|
|
||||||
) {
|
|
||||||
// Verify it has existing context (agent-output.md)
|
|
||||||
const featureDir = getFeatureDir(projectPath, feature.id);
|
|
||||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
|
||||||
try {
|
|
||||||
await secureFs.access(contextPath);
|
|
||||||
interruptedFeatures.push(feature);
|
|
||||||
logger.info(
|
|
||||||
`Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}`
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// No context file, skip this feature - it will be restarted fresh
|
|
||||||
logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip invalid features
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interruptedFeatures.length === 0) {
|
|
||||||
logger.info('No interrupted features found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Found ${interruptedFeatures.length} interrupted feature(s) to resume`);
|
|
||||||
|
|
||||||
// Emit event to notify UI
|
|
||||||
this.emitAutoModeEvent('auto_mode_resuming_features', {
|
|
||||||
message: `Resuming ${interruptedFeatures.length} interrupted feature(s) after server restart`,
|
|
||||||
projectPath,
|
|
||||||
featureIds: interruptedFeatures.map((f) => f.id),
|
|
||||||
features: interruptedFeatures.map((f) => ({
|
|
||||||
id: f.id,
|
|
||||||
title: f.title,
|
|
||||||
status: f.status,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resume each interrupted feature
|
|
||||||
for (const feature of interruptedFeatures) {
|
|
||||||
try {
|
|
||||||
logger.info(`Resuming feature: ${feature.id} (${feature.title})`);
|
|
||||||
// Use resumeFeature which will detect the existing context and continue
|
|
||||||
await this.resumeFeature(projectPath, feature.id, true);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to resume feature ${feature.id}:`, error);
|
|
||||||
// Continue with other features
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
logger.info('No features directory found, nothing to resume');
|
|
||||||
} else {
|
|
||||||
logger.error('Error checking for interrupted features:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract and record learnings from a completed feature
|
|
||||||
* Uses a quick Claude call to identify important decisions and patterns
|
|
||||||
*/
|
|
||||||
private async recordLearningsFromFeature(
|
|
||||||
projectPath: string,
|
|
||||||
feature: Feature,
|
|
||||||
agentOutput: string
|
|
||||||
): Promise<void> {
|
|
||||||
if (!agentOutput || agentOutput.length < 100) {
|
|
||||||
// Not enough output to extract learnings from
|
|
||||||
console.log(
|
|
||||||
`[AutoMode] Skipping learning extraction - output too short (${agentOutput?.length || 0} chars)`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[AutoMode] Extracting learnings from feature "${feature.title}" (${agentOutput.length} chars)`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Limit output to avoid token limits
|
|
||||||
const truncatedOutput = agentOutput.length > 10000 ? agentOutput.slice(-10000) : agentOutput;
|
|
||||||
|
|
||||||
const userPrompt = `You are an Architecture Decision Record (ADR) extractor. Analyze this implementation and return ONLY JSON with learnings. No explanations.
|
|
||||||
|
|
||||||
Feature: "${feature.title}"
|
|
||||||
|
|
||||||
Implementation log:
|
|
||||||
${truncatedOutput}
|
|
||||||
|
|
||||||
Extract MEANINGFUL learnings - not obvious things. For each, capture:
|
|
||||||
- DECISIONS: Why this approach vs alternatives? What would break if changed?
|
|
||||||
- GOTCHAS: What was unexpected? What's the root cause? How to avoid?
|
|
||||||
- PATTERNS: Why this pattern? What problem does it solve? Trade-offs?
|
|
||||||
|
|
||||||
JSON format ONLY (no markdown, no text):
|
|
||||||
{"learnings": [{
|
|
||||||
"category": "architecture|api|ui|database|auth|testing|performance|security|gotchas",
|
|
||||||
"type": "decision|gotcha|pattern",
|
|
||||||
"content": "What was done/learned",
|
|
||||||
"context": "Problem being solved or situation faced",
|
|
||||||
"why": "Reasoning - why this approach",
|
|
||||||
"rejected": "Alternative considered and why rejected",
|
|
||||||
"tradeoffs": "What became easier/harder",
|
|
||||||
"breaking": "What breaks if this is changed/removed"
|
|
||||||
}]}
|
|
||||||
|
|
||||||
IMPORTANT: Only include NON-OBVIOUS learnings with real reasoning. Skip trivial patterns.
|
|
||||||
If nothing notable: {"learnings": []}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Import query dynamically to avoid circular dependencies
|
|
||||||
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
|
||||||
|
|
||||||
// Get model from phase settings
|
|
||||||
const settings = await this.settingsService?.getGlobalSettings();
|
|
||||||
const phaseModelEntry =
|
|
||||||
settings?.phaseModels?.memoryExtractionModel || DEFAULT_PHASE_MODELS.memoryExtractionModel;
|
|
||||||
const { model } = resolvePhaseModel(phaseModelEntry);
|
|
||||||
|
|
||||||
const stream = query({
|
|
||||||
prompt: userPrompt,
|
|
||||||
options: {
|
|
||||||
model,
|
|
||||||
maxTurns: 1,
|
|
||||||
allowedTools: [],
|
|
||||||
permissionMode: 'acceptEdits',
|
|
||||||
systemPrompt:
|
|
||||||
'You are a JSON extraction assistant. You MUST respond with ONLY valid JSON, no explanations, no markdown, no other text. Extract learnings from the provided implementation context and return them as JSON.',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract text from stream
|
|
||||||
let responseText = '';
|
|
||||||
for await (const msg of stream) {
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
|
||||||
responseText = msg.result || responseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`);
|
|
||||||
console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`);
|
|
||||||
|
|
||||||
// Parse the response - handle JSON in markdown code blocks or raw
|
|
||||||
let jsonStr: string | null = null;
|
|
||||||
|
|
||||||
// First try to find JSON in markdown code blocks
|
|
||||||
const codeBlockMatch = responseText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
|
|
||||||
if (codeBlockMatch) {
|
|
||||||
console.log('[AutoMode] Found JSON in code block');
|
|
||||||
jsonStr = codeBlockMatch[1];
|
|
||||||
} else {
|
|
||||||
// Fall back to finding balanced braces containing "learnings"
|
|
||||||
// Use a more precise approach: find the opening brace before "learnings"
|
|
||||||
const learningsIndex = responseText.indexOf('"learnings"');
|
|
||||||
if (learningsIndex !== -1) {
|
|
||||||
// Find the opening brace before "learnings"
|
|
||||||
let braceStart = responseText.lastIndexOf('{', learningsIndex);
|
|
||||||
if (braceStart !== -1) {
|
|
||||||
// Find matching closing brace
|
|
||||||
let braceCount = 0;
|
|
||||||
let braceEnd = -1;
|
|
||||||
for (let i = braceStart; i < responseText.length; i++) {
|
|
||||||
if (responseText[i] === '{') braceCount++;
|
|
||||||
if (responseText[i] === '}') braceCount--;
|
|
||||||
if (braceCount === 0) {
|
|
||||||
braceEnd = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (braceEnd !== -1) {
|
|
||||||
jsonStr = responseText.substring(braceStart, braceEnd + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!jsonStr) {
|
|
||||||
console.log('[AutoMode] Could not extract JSON from response');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[AutoMode] Extracted JSON: ${jsonStr.substring(0, 200)}`);
|
|
||||||
|
|
||||||
let parsed: { learnings?: unknown[] };
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(jsonStr);
|
|
||||||
} catch {
|
|
||||||
console.warn('[AutoMode] Failed to parse learnings JSON:', jsonStr.substring(0, 200));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed.learnings || !Array.isArray(parsed.learnings)) {
|
|
||||||
console.log('[AutoMode] No learnings array in parsed response');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[AutoMode] Found ${parsed.learnings.length} potential learnings`);
|
|
||||||
|
|
||||||
// Valid learning types
|
|
||||||
const validTypes = new Set(['decision', 'learning', 'pattern', 'gotcha']);
|
|
||||||
|
|
||||||
// Record each learning
|
|
||||||
for (const item of parsed.learnings) {
|
|
||||||
// Validate required fields with proper type narrowing
|
|
||||||
if (!item || typeof item !== 'object') continue;
|
|
||||||
|
|
||||||
const learning = item as Record<string, unknown>;
|
|
||||||
if (
|
|
||||||
!learning.category ||
|
|
||||||
typeof learning.category !== 'string' ||
|
|
||||||
!learning.content ||
|
|
||||||
typeof learning.content !== 'string' ||
|
|
||||||
!learning.content.trim()
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate and normalize type
|
|
||||||
const typeStr = typeof learning.type === 'string' ? learning.type : 'learning';
|
|
||||||
const learningType = validTypes.has(typeStr)
|
|
||||||
? (typeStr as 'decision' | 'learning' | 'pattern' | 'gotcha')
|
|
||||||
: 'learning';
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[AutoMode] Appending learning: category=${learning.category}, type=${learningType}`
|
|
||||||
);
|
|
||||||
await appendLearning(
|
|
||||||
projectPath,
|
|
||||||
{
|
|
||||||
category: learning.category,
|
|
||||||
type: learningType,
|
|
||||||
content: learning.content.trim(),
|
|
||||||
context: typeof learning.context === 'string' ? learning.context : undefined,
|
|
||||||
why: typeof learning.why === 'string' ? learning.why : undefined,
|
|
||||||
rejected: typeof learning.rejected === 'string' ? learning.rejected : undefined,
|
|
||||||
tradeoffs: typeof learning.tradeoffs === 'string' ? learning.tradeoffs : undefined,
|
|
||||||
breaking: typeof learning.breaking === 'string' ? learning.breaking : undefined,
|
|
||||||
},
|
|
||||||
secureFs as Parameters<typeof appendLearning>[2]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const validLearnings = parsed.learnings.filter(
|
|
||||||
(l) => l && typeof l === 'object' && (l as Record<string, unknown>).content
|
|
||||||
);
|
|
||||||
if (validLearnings.length > 0) {
|
|
||||||
console.log(
|
|
||||||
`[AutoMode] Recorded ${parsed.learnings.length} learning(s) from feature ${feature.id}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[AutoMode] Failed to extract learnings from feature ${feature.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { spawn } from 'child_process';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as pty from 'node-pty';
|
import * as pty from 'node-pty';
|
||||||
import { ClaudeUsage } from '../routes/claude/types.js';
|
import { ClaudeUsage } from '../routes/claude/types.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude Usage Service
|
* Claude Usage Service
|
||||||
@@ -15,8 +14,6 @@ import { createLogger } from '@automaker/utils';
|
|||||||
* - macOS: Uses 'expect' command for PTY
|
* - macOS: Uses 'expect' command for PTY
|
||||||
* - Windows/Linux: Uses node-pty for PTY
|
* - Windows/Linux: Uses node-pty for PTY
|
||||||
*/
|
*/
|
||||||
const logger = createLogger('ClaudeUsage');
|
|
||||||
|
|
||||||
export class ClaudeUsageService {
|
export class ClaudeUsageService {
|
||||||
private claudeBinary = 'claude';
|
private claudeBinary = 'claude';
|
||||||
private timeout = 30000; // 30 second timeout
|
private timeout = 30000; // 30 second timeout
|
||||||
@@ -167,40 +164,21 @@ export class ClaudeUsageService {
|
|||||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
||||||
|
|
||||||
let ptyProcess: any = null;
|
const ptyProcess = pty.spawn(shell, args, {
|
||||||
|
name: 'xterm-256color',
|
||||||
try {
|
cols: 120,
|
||||||
ptyProcess = pty.spawn(shell, args, {
|
rows: 30,
|
||||||
name: 'xterm-256color',
|
cwd: workingDirectory,
|
||||||
cols: 120,
|
env: {
|
||||||
rows: 30,
|
...process.env,
|
||||||
cwd: workingDirectory,
|
TERM: 'xterm-256color',
|
||||||
env: {
|
} as Record<string, string>,
|
||||||
...process.env,
|
});
|
||||||
TERM: 'xterm-256color',
|
|
||||||
} as Record<string, string>,
|
|
||||||
});
|
|
||||||
} catch (spawnError) {
|
|
||||||
// pty.spawn() can throw synchronously if the native module fails to load
|
|
||||||
// or if PTY is not available in the current environment (e.g., containers without /dev/pts)
|
|
||||||
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
|
||||||
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
|
||||||
|
|
||||||
// Return a user-friendly error instead of crashing
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
if (ptyProcess && !ptyProcess.killed) {
|
ptyProcess.kill();
|
||||||
ptyProcess.kill();
|
|
||||||
}
|
|
||||||
// Don't fail if we have data - return it instead
|
// Don't fail if we have data - return it instead
|
||||||
if (output.includes('Current session')) {
|
if (output.includes('Current session')) {
|
||||||
resolve(output);
|
resolve(output);
|
||||||
@@ -210,7 +188,7 @@ export class ClaudeUsageService {
|
|||||||
}
|
}
|
||||||
}, this.timeout);
|
}, this.timeout);
|
||||||
|
|
||||||
ptyProcess.onData((data: string) => {
|
ptyProcess.onData((data) => {
|
||||||
output += data;
|
output += data;
|
||||||
|
|
||||||
// Check if we've seen the usage data (look for "Current session")
|
// Check if we've seen the usage data (look for "Current session")
|
||||||
@@ -218,12 +196,12 @@ export class ClaudeUsageService {
|
|||||||
hasSeenUsageData = true;
|
hasSeenUsageData = true;
|
||||||
// Wait for full output, then send escape to exit
|
// Wait for full output, then send escape to exit
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
if (!settled) {
|
||||||
ptyProcess.write('\x1b'); // Send escape key
|
ptyProcess.write('\x1b'); // Send escape key
|
||||||
|
|
||||||
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
if (!settled) {
|
||||||
ptyProcess.kill('SIGTERM');
|
ptyProcess.kill('SIGTERM');
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -234,14 +212,14 @@ export class ClaudeUsageService {
|
|||||||
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
||||||
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
if (!settled) {
|
||||||
ptyProcess.write('\x1b'); // Send escape key
|
ptyProcess.write('\x1b'); // Send escape key
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
|
ptyProcess.onExit(({ exitCode }) => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
import { spawn, type ChildProcess } from 'child_process';
|
|
||||||
import readline from 'readline';
|
|
||||||
import { findCodexCliPath } from '@automaker/platform';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import type {
|
|
||||||
AppServerModelResponse,
|
|
||||||
AppServerAccountResponse,
|
|
||||||
AppServerRateLimitsResponse,
|
|
||||||
JsonRpcRequest,
|
|
||||||
} from '@automaker/types';
|
|
||||||
|
|
||||||
const logger = createLogger('CodexAppServer');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CodexAppServerService
|
|
||||||
*
|
|
||||||
* Centralized service for communicating with Codex CLI's app-server via JSON-RPC protocol.
|
|
||||||
* Handles process spawning, JSON-RPC messaging, and cleanup.
|
|
||||||
*
|
|
||||||
* Connection strategy: Spawn on-demand (new process for each method call)
|
|
||||||
*/
|
|
||||||
export class CodexAppServerService {
|
|
||||||
private cachedCliPath: string | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Codex CLI is available on the system
|
|
||||||
*/
|
|
||||||
async isAvailable(): Promise<boolean> {
|
|
||||||
this.cachedCliPath = await findCodexCliPath();
|
|
||||||
return Boolean(this.cachedCliPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch available models from app-server
|
|
||||||
*/
|
|
||||||
async getModels(): Promise<AppServerModelResponse | null> {
|
|
||||||
const result = await this.executeJsonRpc<AppServerModelResponse>((sendRequest) => {
|
|
||||||
return sendRequest('model/list', {});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
logger.info(`[getModels] ✓ Fetched ${result.data.length} models`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch account information from app-server
|
|
||||||
*/
|
|
||||||
async getAccount(): Promise<AppServerAccountResponse | null> {
|
|
||||||
return this.executeJsonRpc<AppServerAccountResponse>((sendRequest) => {
|
|
||||||
return sendRequest('account/read', { refreshToken: false });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch rate limits from app-server
|
|
||||||
*/
|
|
||||||
async getRateLimits(): Promise<AppServerRateLimitsResponse | null> {
|
|
||||||
return this.executeJsonRpc<AppServerRateLimitsResponse>((sendRequest) => {
|
|
||||||
return sendRequest('account/rateLimits/read', {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute JSON-RPC requests via Codex app-server
|
|
||||||
*
|
|
||||||
* This method:
|
|
||||||
* 1. Spawns a new `codex app-server` process
|
|
||||||
* 2. Handles JSON-RPC initialization handshake
|
|
||||||
* 3. Executes user-provided requests
|
|
||||||
* 4. Cleans up the process
|
|
||||||
*
|
|
||||||
* @param requestFn - Function that receives sendRequest helper and returns a promise
|
|
||||||
* @returns Result of the JSON-RPC request or null on failure
|
|
||||||
*/
|
|
||||||
private async executeJsonRpc<T>(
|
|
||||||
requestFn: (sendRequest: <R>(method: string, params?: unknown) => Promise<R>) => Promise<T>
|
|
||||||
): Promise<T | null> {
|
|
||||||
let childProcess: ChildProcess | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
|
||||||
|
|
||||||
if (!cliPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// On Windows, .cmd files must be run through shell
|
|
||||||
const needsShell = process.platform === 'win32' && cliPath.toLowerCase().endsWith('.cmd');
|
|
||||||
|
|
||||||
childProcess = spawn(cliPath, ['app-server'], {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TERM: 'dumb',
|
|
||||||
},
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
shell: needsShell,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!childProcess.stdin || !childProcess.stdout) {
|
|
||||||
throw new Error('Failed to create stdio pipes');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup readline for reading JSONL responses
|
|
||||||
const rl = readline.createInterface({
|
|
||||||
input: childProcess.stdout,
|
|
||||||
crlfDelay: Infinity,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Message ID counter for JSON-RPC
|
|
||||||
let messageId = 0;
|
|
||||||
const pendingRequests = new Map<
|
|
||||||
number,
|
|
||||||
{
|
|
||||||
resolve: (value: unknown) => void;
|
|
||||||
reject: (error: Error) => void;
|
|
||||||
timeout: NodeJS.Timeout;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
// Process incoming messages
|
|
||||||
rl.on('line', (line) => {
|
|
||||||
if (!line.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(line);
|
|
||||||
|
|
||||||
// Handle response to our request
|
|
||||||
if ('id' in message && message.id !== undefined) {
|
|
||||||
const pending = pendingRequests.get(message.id);
|
|
||||||
if (pending) {
|
|
||||||
clearTimeout(pending.timeout);
|
|
||||||
pendingRequests.delete(message.id);
|
|
||||||
if (message.error) {
|
|
||||||
pending.reject(new Error(message.error.message || 'Unknown error'));
|
|
||||||
} else {
|
|
||||||
pending.resolve(message.result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ignore notifications (no id field)
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors for non-JSON lines
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper to send JSON-RPC request and wait for response
|
|
||||||
const sendRequest = <R>(method: string, params?: unknown): Promise<R> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const id = ++messageId;
|
|
||||||
const request: JsonRpcRequest = {
|
|
||||||
method,
|
|
||||||
id,
|
|
||||||
params: params ?? {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set timeout for request (10 seconds)
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
pendingRequests.delete(id);
|
|
||||||
reject(new Error(`Request timeout: ${method}`));
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
pendingRequests.set(id, {
|
|
||||||
resolve: resolve as (value: unknown) => void,
|
|
||||||
reject,
|
|
||||||
timeout,
|
|
||||||
});
|
|
||||||
|
|
||||||
childProcess!.stdin!.write(JSON.stringify(request) + '\n');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to send notification (no response expected)
|
|
||||||
const sendNotification = (method: string, params?: unknown): void => {
|
|
||||||
const notification = params ? { method, params } : { method };
|
|
||||||
childProcess!.stdin!.write(JSON.stringify(notification) + '\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. Initialize the app-server
|
|
||||||
await sendRequest('initialize', {
|
|
||||||
clientInfo: {
|
|
||||||
name: 'automaker',
|
|
||||||
title: 'AutoMaker',
|
|
||||||
version: '1.0.0',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Send initialized notification
|
|
||||||
sendNotification('initialized');
|
|
||||||
|
|
||||||
// 3. Execute user-provided requests
|
|
||||||
const result = await requestFn(sendRequest);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
rl.close();
|
|
||||||
childProcess.kill('SIGTERM');
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[executeJsonRpc] Failed:', error);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
// Ensure process is killed
|
|
||||||
if (childProcess && !childProcess.killed) {
|
|
||||||
childProcess.kill('SIGTERM');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import path from 'path';
|
|
||||||
import { secureFs } from '@automaker/platform';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import type { AppServerModel } from '@automaker/types';
|
|
||||||
import type { CodexAppServerService } from './codex-app-server-service.js';
|
|
||||||
|
|
||||||
const logger = createLogger('CodexModelCache');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Codex model with UI-compatible format
|
|
||||||
*/
|
|
||||||
export interface CodexModel {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
hasThinking: boolean;
|
|
||||||
supportsVision: boolean;
|
|
||||||
tier: 'premium' | 'standard' | 'basic';
|
|
||||||
isDefault: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache structure stored on disk
|
|
||||||
*/
|
|
||||||
interface CodexModelCache {
|
|
||||||
models: CodexModel[];
|
|
||||||
cachedAt: number;
|
|
||||||
ttl: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CodexModelCacheService
|
|
||||||
*
|
|
||||||
* Caches Codex models fetched from app-server with TTL-based invalidation and disk persistence.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - 1-hour TTL (configurable)
|
|
||||||
* - Atomic file writes (temp file + rename)
|
|
||||||
* - Thread-safe (deduplicates concurrent refresh requests)
|
|
||||||
* - Auto-bootstrap on service creation
|
|
||||||
* - Graceful fallback (returns empty array on errors)
|
|
||||||
*/
|
|
||||||
export class CodexModelCacheService {
|
|
||||||
private cacheFilePath: string;
|
|
||||||
private ttl: number;
|
|
||||||
private appServerService: CodexAppServerService;
|
|
||||||
private inFlightRefresh: Promise<CodexModel[]> | null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
dataDir: string,
|
|
||||||
appServerService: CodexAppServerService,
|
|
||||||
ttl: number = 3600000 // 1 hour default
|
|
||||||
) {
|
|
||||||
this.cacheFilePath = path.join(dataDir, 'codex-models-cache.json');
|
|
||||||
this.ttl = ttl;
|
|
||||||
this.appServerService = appServerService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get models from cache or fetch if stale
|
|
||||||
*
|
|
||||||
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
|
||||||
* @returns Array of Codex models (empty array if unavailable)
|
|
||||||
*/
|
|
||||||
async getModels(forceRefresh = false): Promise<CodexModel[]> {
|
|
||||||
// If force refresh, skip cache
|
|
||||||
if (forceRefresh) {
|
|
||||||
return this.refreshModels();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to load from cache
|
|
||||||
const cached = await this.loadFromCache();
|
|
||||||
if (cached) {
|
|
||||||
const age = Date.now() - cached.cachedAt;
|
|
||||||
const isStale = age > cached.ttl;
|
|
||||||
|
|
||||||
if (!isStale) {
|
|
||||||
logger.info(
|
|
||||||
`[getModels] ✓ Using cached models (${cached.models.length} models, age: ${Math.round(age / 60000)}min)`
|
|
||||||
);
|
|
||||||
return cached.models;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache is stale or missing, refresh
|
|
||||||
return this.refreshModels();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get models with cache metadata
|
|
||||||
*
|
|
||||||
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
|
||||||
* @returns Object containing models and cache timestamp
|
|
||||||
*/
|
|
||||||
async getModelsWithMetadata(
|
|
||||||
forceRefresh = false
|
|
||||||
): Promise<{ models: CodexModel[]; cachedAt: number }> {
|
|
||||||
const models = await this.getModels(forceRefresh);
|
|
||||||
|
|
||||||
// Try to get the actual cache timestamp
|
|
||||||
const cached = await this.loadFromCache();
|
|
||||||
const cachedAt = cached?.cachedAt ?? Date.now();
|
|
||||||
|
|
||||||
return { models, cachedAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh models from app-server and update cache
|
|
||||||
*
|
|
||||||
* Thread-safe: Deduplicates concurrent refresh requests
|
|
||||||
*/
|
|
||||||
async refreshModels(): Promise<CodexModel[]> {
|
|
||||||
// Deduplicate concurrent refresh requests
|
|
||||||
if (this.inFlightRefresh) {
|
|
||||||
return this.inFlightRefresh;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new refresh
|
|
||||||
this.inFlightRefresh = this.doRefresh();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const models = await this.inFlightRefresh;
|
|
||||||
return models;
|
|
||||||
} finally {
|
|
||||||
this.inFlightRefresh = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the cache file
|
|
||||||
*/
|
|
||||||
async clearCache(): Promise<void> {
|
|
||||||
logger.info('[clearCache] Clearing cache...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await secureFs.unlink(this.cacheFilePath);
|
|
||||||
logger.info('[clearCache] Cache cleared');
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
logger.error('[clearCache] Failed to clear cache:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal method to perform the actual refresh
|
|
||||||
*/
|
|
||||||
private async doRefresh(): Promise<CodexModel[]> {
|
|
||||||
try {
|
|
||||||
// Check if app-server is available
|
|
||||||
const isAvailable = await this.appServerService.isAvailable();
|
|
||||||
if (!isAvailable) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch models from app-server
|
|
||||||
const response = await this.appServerService.getModels();
|
|
||||||
if (!response || !response.data) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform models to UI format
|
|
||||||
const models = response.data.map((model) => this.transformModel(model));
|
|
||||||
|
|
||||||
// Save to cache
|
|
||||||
await this.saveToCache(models);
|
|
||||||
|
|
||||||
logger.info(`[refreshModels] ✓ Fetched fresh models (${models.length} models)`);
|
|
||||||
|
|
||||||
return models;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[doRefresh] Refresh failed:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform app-server model to UI-compatible format
|
|
||||||
*/
|
|
||||||
private transformModel(appServerModel: AppServerModel): CodexModel {
|
|
||||||
return {
|
|
||||||
id: `codex-${appServerModel.id}`, // Add 'codex-' prefix for compatibility
|
|
||||||
label: appServerModel.displayName,
|
|
||||||
description: appServerModel.description,
|
|
||||||
hasThinking: appServerModel.supportedReasoningEfforts.length > 0,
|
|
||||||
supportsVision: true, // All Codex models support vision
|
|
||||||
tier: this.inferTier(appServerModel.id),
|
|
||||||
isDefault: appServerModel.isDefault,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Infer tier from model ID
|
|
||||||
*/
|
|
||||||
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
|
|
||||||
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) {
|
|
||||||
return 'premium';
|
|
||||||
}
|
|
||||||
if (modelId.includes('mini')) {
|
|
||||||
return 'basic';
|
|
||||||
}
|
|
||||||
return 'standard';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load cache from disk
|
|
||||||
*/
|
|
||||||
private async loadFromCache(): Promise<CodexModelCache | null> {
|
|
||||||
try {
|
|
||||||
const content = await secureFs.readFile(this.cacheFilePath, 'utf-8');
|
|
||||||
const cache = JSON.parse(content.toString()) as CodexModelCache;
|
|
||||||
|
|
||||||
// Validate cache structure
|
|
||||||
if (!Array.isArray(cache.models) || typeof cache.cachedAt !== 'number') {
|
|
||||||
logger.warn('[loadFromCache] Invalid cache structure, ignoring');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cache;
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
logger.warn('[loadFromCache] Failed to read cache:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save cache to disk (atomic write)
|
|
||||||
*/
|
|
||||||
private async saveToCache(models: CodexModel[]): Promise<void> {
|
|
||||||
const cache: CodexModelCache = {
|
|
||||||
models,
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
ttl: this.ttl,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tempPath = `${this.cacheFilePath}.tmp.${Date.now()}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Write to temp file
|
|
||||||
const content = JSON.stringify(cache, null, 2);
|
|
||||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
|
||||||
|
|
||||||
// Atomic rename
|
|
||||||
await secureFs.rename(tempPath, this.cacheFilePath);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[saveToCache] Failed to save cache:', error);
|
|
||||||
|
|
||||||
// Clean up temp file
|
|
||||||
try {
|
|
||||||
await secureFs.unlink(tempPath);
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
import {
|
|
||||||
findCodexCliPath,
|
|
||||||
getCodexAuthPath,
|
|
||||||
systemPathExists,
|
|
||||||
systemPathReadFile,
|
|
||||||
} from '@automaker/platform';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import type { CodexAppServerService } from './codex-app-server-service.js';
|
|
||||||
|
|
||||||
const logger = createLogger('CodexUsage');
|
|
||||||
|
|
||||||
export interface CodexRateLimitWindow {
|
|
||||||
limit: number;
|
|
||||||
used: number;
|
|
||||||
remaining: number;
|
|
||||||
usedPercent: number;
|
|
||||||
windowDurationMins: number;
|
|
||||||
resetsAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown';
|
|
||||||
|
|
||||||
export interface CodexUsageData {
|
|
||||||
rateLimits: {
|
|
||||||
primary?: CodexRateLimitWindow;
|
|
||||||
secondary?: CodexRateLimitWindow;
|
|
||||||
planType?: CodexPlanType;
|
|
||||||
} | null;
|
|
||||||
lastUpdated: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Codex Usage Service
|
|
||||||
*
|
|
||||||
* Fetches usage data from Codex CLI using the app-server JSON-RPC API.
|
|
||||||
* Falls back to auth file parsing if app-server is unavailable.
|
|
||||||
*/
|
|
||||||
export class CodexUsageService {
|
|
||||||
private cachedCliPath: string | null = null;
|
|
||||||
private appServerService: CodexAppServerService | null = null;
|
|
||||||
private accountPlanTypeArray: CodexPlanType[] = [
|
|
||||||
'free',
|
|
||||||
'plus',
|
|
||||||
'pro',
|
|
||||||
'team',
|
|
||||||
'enterprise',
|
|
||||||
'edu',
|
|
||||||
];
|
|
||||||
|
|
||||||
constructor(appServerService?: CodexAppServerService) {
|
|
||||||
this.appServerService = appServerService || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Codex CLI is available on the system
|
|
||||||
*/
|
|
||||||
async isAvailable(): Promise<boolean> {
|
|
||||||
this.cachedCliPath = await findCodexCliPath();
|
|
||||||
return Boolean(this.cachedCliPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to fetch usage data
|
|
||||||
*
|
|
||||||
* Priority order:
|
|
||||||
* 1. Codex app-server JSON-RPC API (most reliable, provides real-time data)
|
|
||||||
* 2. Auth file JWT parsing (fallback for plan type)
|
|
||||||
*/
|
|
||||||
async fetchUsageData(): Promise<CodexUsageData> {
|
|
||||||
logger.info('[fetchUsageData] Starting...');
|
|
||||||
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
|
||||||
|
|
||||||
if (!cliPath) {
|
|
||||||
logger.error('[fetchUsageData] Codex CLI not found');
|
|
||||||
throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`);
|
|
||||||
|
|
||||||
// Try to get usage from Codex app-server (most reliable method)
|
|
||||||
const appServerUsage = await this.fetchFromAppServer();
|
|
||||||
if (appServerUsage) {
|
|
||||||
logger.info('[fetchUsageData] ✓ Fetched usage from app-server');
|
|
||||||
return appServerUsage;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('[fetchUsageData] App-server failed, trying auth file fallback...');
|
|
||||||
|
|
||||||
// Fallback: try to parse usage from auth file
|
|
||||||
const authUsage = await this.fetchFromAuthFile();
|
|
||||||
if (authUsage) {
|
|
||||||
logger.info('[fetchUsageData] ✓ Fetched usage from auth file');
|
|
||||||
return authUsage;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('[fetchUsageData] All methods failed, returning unknown');
|
|
||||||
|
|
||||||
// If all else fails, return unknown
|
|
||||||
return {
|
|
||||||
rateLimits: {
|
|
||||||
planType: 'unknown',
|
|
||||||
},
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch usage data from Codex app-server using JSON-RPC API
|
|
||||||
* This is the most reliable method as it gets real-time data from OpenAI
|
|
||||||
*/
|
|
||||||
private async fetchFromAppServer(): Promise<CodexUsageData | null> {
|
|
||||||
try {
|
|
||||||
// Use CodexAppServerService if available
|
|
||||||
if (!this.appServerService) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch account and rate limits in parallel
|
|
||||||
const [accountResult, rateLimitsResult] = await Promise.all([
|
|
||||||
this.appServerService.getAccount(),
|
|
||||||
this.appServerService.getRateLimits(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!accountResult) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build response
|
|
||||||
// Prefer planType from rateLimits (more accurate/current) over account (can be stale)
|
|
||||||
let planType: CodexPlanType = 'unknown';
|
|
||||||
|
|
||||||
// First try rate limits planType (most accurate)
|
|
||||||
const rateLimitsPlanType = rateLimitsResult?.rateLimits?.planType;
|
|
||||||
if (rateLimitsPlanType) {
|
|
||||||
const normalizedType = rateLimitsPlanType.toLowerCase() as CodexPlanType;
|
|
||||||
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
|
||||||
planType = normalizedType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to account planType if rate limits didn't have it
|
|
||||||
if (planType === 'unknown' && accountResult.account?.planType) {
|
|
||||||
const normalizedType = accountResult.account.planType.toLowerCase() as CodexPlanType;
|
|
||||||
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
|
||||||
planType = normalizedType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: CodexUsageData = {
|
|
||||||
rateLimits: {
|
|
||||||
planType,
|
|
||||||
},
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add rate limit info if available
|
|
||||||
if (rateLimitsResult?.rateLimits?.primary) {
|
|
||||||
const primary = rateLimitsResult.rateLimits.primary;
|
|
||||||
result.rateLimits!.primary = {
|
|
||||||
limit: -1, // Not provided by API
|
|
||||||
used: -1, // Not provided by API
|
|
||||||
remaining: -1, // Not provided by API
|
|
||||||
usedPercent: primary.usedPercent,
|
|
||||||
windowDurationMins: primary.windowDurationMins,
|
|
||||||
resetsAt: primary.resetsAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add secondary rate limit if available
|
|
||||||
if (rateLimitsResult?.rateLimits?.secondary) {
|
|
||||||
const secondary = rateLimitsResult.rateLimits.secondary;
|
|
||||||
result.rateLimits!.secondary = {
|
|
||||||
limit: -1, // Not provided by API
|
|
||||||
used: -1, // Not provided by API
|
|
||||||
remaining: -1, // Not provided by API
|
|
||||||
usedPercent: secondary.usedPercent,
|
|
||||||
windowDurationMins: secondary.windowDurationMins,
|
|
||||||
resetsAt: secondary.resetsAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[fetchFromAppServer] ✓ Plan: ${planType}, Primary: ${result.rateLimits?.primary?.usedPercent || 'N/A'}%, Secondary: ${result.rateLimits?.secondary?.usedPercent || 'N/A'}%`
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[fetchFromAppServer] Failed:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract plan type from auth file JWT token
|
|
||||||
* Returns the actual plan type or 'unknown' if not available
|
|
||||||
*/
|
|
||||||
private async getPlanTypeFromAuthFile(): Promise<CodexPlanType> {
|
|
||||||
try {
|
|
||||||
const authFilePath = getCodexAuthPath();
|
|
||||||
logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`);
|
|
||||||
const exists = systemPathExists(authFilePath);
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
logger.warn('[getPlanTypeFromAuthFile] Auth file does not exist');
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
const authContent = await systemPathReadFile(authFilePath);
|
|
||||||
const authData = JSON.parse(authContent);
|
|
||||||
|
|
||||||
if (!authData.tokens?.id_token) {
|
|
||||||
logger.info('[getPlanTypeFromAuthFile] No id_token in auth file');
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
const claims = this.parseJwt(authData.tokens.id_token);
|
|
||||||
if (!claims) {
|
|
||||||
logger.info('[getPlanTypeFromAuthFile] Failed to parse JWT');
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('[getPlanTypeFromAuthFile] JWT claims keys:', Object.keys(claims));
|
|
||||||
|
|
||||||
// Extract plan type from nested OpenAI auth object with type validation
|
|
||||||
const openaiAuthClaim = claims['https://api.openai.com/auth'];
|
|
||||||
logger.info(
|
|
||||||
'[getPlanTypeFromAuthFile] OpenAI auth claim:',
|
|
||||||
JSON.stringify(openaiAuthClaim, null, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
let accountType: string | undefined;
|
|
||||||
let isSubscriptionExpired = false;
|
|
||||||
|
|
||||||
if (
|
|
||||||
openaiAuthClaim &&
|
|
||||||
typeof openaiAuthClaim === 'object' &&
|
|
||||||
!Array.isArray(openaiAuthClaim)
|
|
||||||
) {
|
|
||||||
const openaiAuth = openaiAuthClaim as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (typeof openaiAuth.chatgpt_plan_type === 'string') {
|
|
||||||
accountType = openaiAuth.chatgpt_plan_type;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if subscription has expired
|
|
||||||
if (typeof openaiAuth.chatgpt_subscription_active_until === 'string') {
|
|
||||||
const expiryDate = new Date(openaiAuth.chatgpt_subscription_active_until);
|
|
||||||
if (!isNaN(expiryDate.getTime())) {
|
|
||||||
isSubscriptionExpired = expiryDate < new Date();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: try top-level claim names
|
|
||||||
const possibleClaimNames = [
|
|
||||||
'https://chatgpt.com/account_type',
|
|
||||||
'account_type',
|
|
||||||
'plan',
|
|
||||||
'plan_type',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const claimName of possibleClaimNames) {
|
|
||||||
const claimValue = claims[claimName];
|
|
||||||
if (claimValue && typeof claimValue === 'string') {
|
|
||||||
accountType = claimValue;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If subscription is expired, treat as free plan
|
|
||||||
if (isSubscriptionExpired && accountType && accountType !== 'free') {
|
|
||||||
logger.info(`Subscription expired, using "free" instead of "${accountType}"`);
|
|
||||||
accountType = 'free';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accountType) {
|
|
||||||
const normalizedType = accountType.toLowerCase() as CodexPlanType;
|
|
||||||
logger.info(
|
|
||||||
`[getPlanTypeFromAuthFile] Account type: "${accountType}", normalized: "${normalizedType}"`
|
|
||||||
);
|
|
||||||
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
|
||||||
logger.info(`[getPlanTypeFromAuthFile] Returning plan type: ${normalizedType}`);
|
|
||||||
return normalizedType;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.info('[getPlanTypeFromAuthFile] No account type found in claims');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[getPlanTypeFromAuthFile] Failed to get plan type from auth file:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('[getPlanTypeFromAuthFile] Returning unknown');
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to extract usage info from the Codex auth file
|
|
||||||
* Reuses getPlanTypeFromAuthFile to avoid code duplication
|
|
||||||
*/
|
|
||||||
private async fetchFromAuthFile(): Promise<CodexUsageData | null> {
|
|
||||||
logger.info('[fetchFromAuthFile] Starting...');
|
|
||||||
try {
|
|
||||||
const planType = await this.getPlanTypeFromAuthFile();
|
|
||||||
logger.info(`[fetchFromAuthFile] Got plan type: ${planType}`);
|
|
||||||
|
|
||||||
if (planType === 'unknown') {
|
|
||||||
logger.info('[fetchFromAuthFile] Plan type unknown, returning null');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: CodexUsageData = {
|
|
||||||
rateLimits: {
|
|
||||||
planType,
|
|
||||||
},
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info('[fetchFromAuthFile] Returning result:', JSON.stringify(result, null, 2));
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[fetchFromAuthFile] Failed to parse auth file:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse JWT token to extract claims
|
|
||||||
*/
|
|
||||||
private parseJwt(token: string): Record<string, unknown> | null {
|
|
||||||
try {
|
|
||||||
const parts = token.split('.');
|
|
||||||
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const base64Url = parts[1];
|
|
||||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
|
|
||||||
// Use Buffer for Node.js environment
|
|
||||||
const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
|
|
||||||
|
|
||||||
return JSON.parse(jsonPayload);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { Feature, DescriptionHistoryEntry } from '@automaker/types';
|
import type { Feature } from '@automaker/types';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import * as secureFs from '../lib/secure-fs.js';
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
import {
|
import {
|
||||||
@@ -274,16 +274,6 @@ export class FeatureLoader {
|
|||||||
featureData.imagePaths
|
featureData.imagePaths
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize description history with the initial description
|
|
||||||
const initialHistory: DescriptionHistoryEntry[] = [];
|
|
||||||
if (featureData.description && featureData.description.trim()) {
|
|
||||||
initialHistory.push({
|
|
||||||
description: featureData.description,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
source: 'initial',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure feature has required fields
|
// Ensure feature has required fields
|
||||||
const feature: Feature = {
|
const feature: Feature = {
|
||||||
category: featureData.category || 'Uncategorized',
|
category: featureData.category || 'Uncategorized',
|
||||||
@@ -291,7 +281,6 @@ export class FeatureLoader {
|
|||||||
...featureData,
|
...featureData,
|
||||||
id: featureId,
|
id: featureId,
|
||||||
imagePaths: migratedImagePaths,
|
imagePaths: migratedImagePaths,
|
||||||
descriptionHistory: initialHistory,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write feature.json
|
// Write feature.json
|
||||||
@@ -303,20 +292,11 @@ export class FeatureLoader {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a feature (partial updates supported)
|
* Update a feature (partial updates supported)
|
||||||
* @param projectPath - Path to the project
|
|
||||||
* @param featureId - ID of the feature to update
|
|
||||||
* @param updates - Partial feature updates
|
|
||||||
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
|
|
||||||
* @param enhancementMode - Enhancement mode if source is 'enhance'
|
|
||||||
* @param preEnhancementDescription - Description before enhancement (for restoring original)
|
|
||||||
*/
|
*/
|
||||||
async update(
|
async update(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
updates: Partial<Feature>,
|
updates: Partial<Feature>
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
|
||||||
preEnhancementDescription?: string
|
|
||||||
): Promise<Feature> {
|
): Promise<Feature> {
|
||||||
const feature = await this.get(projectPath, featureId);
|
const feature = await this.get(projectPath, featureId);
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
@@ -333,50 +313,11 @@ export class FeatureLoader {
|
|||||||
updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths);
|
updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track description history if description changed
|
|
||||||
let updatedHistory = feature.descriptionHistory || [];
|
|
||||||
if (
|
|
||||||
updates.description !== undefined &&
|
|
||||||
updates.description !== feature.description &&
|
|
||||||
updates.description.trim()
|
|
||||||
) {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
// If this is an enhancement and we have the pre-enhancement description,
|
|
||||||
// add the original text to history first (so user can restore to it)
|
|
||||||
if (
|
|
||||||
descriptionHistorySource === 'enhance' &&
|
|
||||||
preEnhancementDescription &&
|
|
||||||
preEnhancementDescription.trim()
|
|
||||||
) {
|
|
||||||
// Check if this pre-enhancement text is different from the last history entry
|
|
||||||
const lastEntry = updatedHistory[updatedHistory.length - 1];
|
|
||||||
if (!lastEntry || lastEntry.description !== preEnhancementDescription) {
|
|
||||||
const preEnhanceEntry: DescriptionHistoryEntry = {
|
|
||||||
description: preEnhancementDescription,
|
|
||||||
timestamp,
|
|
||||||
source: updatedHistory.length === 0 ? 'initial' : 'edit',
|
|
||||||
};
|
|
||||||
updatedHistory = [...updatedHistory, preEnhanceEntry];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the new/enhanced description to history
|
|
||||||
const historyEntry: DescriptionHistoryEntry = {
|
|
||||||
description: updates.description,
|
|
||||||
timestamp,
|
|
||||||
source: descriptionHistorySource || 'edit',
|
|
||||||
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
|
|
||||||
};
|
|
||||||
updatedHistory = [...updatedHistory, historyEntry];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge updates
|
// Merge updates
|
||||||
const updatedFeature: Feature = {
|
const updatedFeature: Feature = {
|
||||||
...feature,
|
...feature,
|
||||||
...updates,
|
...updates,
|
||||||
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
|
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
|
||||||
descriptionHistory: updatedHistory,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write back to file
|
// Write back to file
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import type { SettingsService } from './settings-service.js';
|
|||||||
import type { FeatureLoader } from './feature-loader.js';
|
import type { FeatureLoader } from './feature-loader.js';
|
||||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import { stripProviderPrefix } from '@automaker/types';
|
|
||||||
|
|
||||||
const logger = createLogger('IdeationService');
|
const logger = createLogger('IdeationService');
|
||||||
|
|
||||||
@@ -202,7 +201,7 @@ export class IdeationService {
|
|||||||
existingWorkContext
|
existingWorkContext
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resolve model alias to canonical identifier (with prefix)
|
// Resolve model alias to canonical identifier
|
||||||
const modelId = resolveModelString(options?.model ?? 'sonnet');
|
const modelId = resolveModelString(options?.model ?? 'sonnet');
|
||||||
|
|
||||||
// Create SDK options
|
// Create SDK options
|
||||||
@@ -215,13 +214,9 @@ export class IdeationService {
|
|||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(modelId);
|
const provider = ProviderFactory.getProviderForModel(modelId);
|
||||||
|
|
||||||
// Strip provider prefix - providers need bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(modelId);
|
|
||||||
|
|
||||||
const executeOptions: ExecuteOptions = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: message,
|
prompt: message,
|
||||||
model: bareModel,
|
model: modelId,
|
||||||
originalModel: modelId,
|
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
systemPrompt: sdkOptions.systemPrompt,
|
systemPrompt: sdkOptions.systemPrompt,
|
||||||
maxTurns: 1, // Single turn for ideation
|
maxTurns: 1, // Single turn for ideation
|
||||||
@@ -653,7 +648,7 @@ export class IdeationService {
|
|||||||
existingWorkContext
|
existingWorkContext
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resolve model alias to canonical identifier (with prefix)
|
// Resolve model alias to canonical identifier
|
||||||
const modelId = resolveModelString('sonnet');
|
const modelId = resolveModelString('sonnet');
|
||||||
|
|
||||||
// Create SDK options
|
// Create SDK options
|
||||||
@@ -666,13 +661,9 @@ export class IdeationService {
|
|||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(modelId);
|
const provider = ProviderFactory.getProviderForModel(modelId);
|
||||||
|
|
||||||
// Strip provider prefix - providers need bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(modelId);
|
|
||||||
|
|
||||||
const executeOptions: ExecuteOptions = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: prompt.prompt,
|
prompt: prompt.prompt,
|
||||||
model: bareModel,
|
model: modelId,
|
||||||
originalModel: modelId,
|
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
systemPrompt: sdkOptions.systemPrompt,
|
systemPrompt: sdkOptions.systemPrompt,
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user