mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Compare commits
212 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3f5d05349 | ||
|
|
0c4b833b07 | ||
|
|
fbab1d323f | ||
|
|
f50520c93f | ||
|
|
cebf57ffd3 | ||
|
|
6020219fda | ||
|
|
8094941385 | ||
|
|
9ce3cfee7d | ||
|
|
6184440441 | ||
|
|
0cff4cf510 | ||
|
|
b152f119c5 | ||
|
|
9f936c6968 | ||
|
|
b8531cf7e8 | ||
|
|
edcc4e789b | ||
|
|
20cc401238 | ||
|
|
70204a2d36 | ||
|
|
e38325c27f | ||
|
|
5e4b422315 | ||
|
|
6c5206daf4 | ||
|
|
ed65f70315 | ||
|
|
f41a42010c | ||
|
|
a0669d4262 | ||
|
|
a4a792c6b1 | ||
|
|
6842e4c7f7 | ||
|
|
6638c35945 | ||
|
|
53f5c2b2bb | ||
|
|
6e13cdd516 | ||
|
|
a48c67d271 | ||
|
|
43fc3de2e1 | ||
|
|
80081b60bf | ||
|
|
cbca9b68e6 | ||
|
|
b9b3695497 | ||
|
|
1b9acb1395 | ||
|
|
01cf81a105 | ||
|
|
6d267ce0fa | ||
|
|
8b0b565282 | ||
|
|
a046d1232e | ||
|
|
d724e782dd | ||
|
|
a266d85ecd | ||
|
|
a4a111fad0 | ||
|
|
2a98de85a8 | ||
|
|
fb3a8499f3 | ||
|
|
33dd9ae347 | ||
|
|
ac87594b5d | ||
|
|
32656a9662 | ||
|
|
785a4d2c3b | ||
|
|
41a6c7f712 | ||
|
|
7e5d915b60 | ||
|
|
8321c06e16 | ||
|
|
f60c18d31a | ||
|
|
e171b6a049 | ||
|
|
6e4b611662 | ||
|
|
7522e58fee | ||
|
|
317c21ffc0 | ||
|
|
9c5fe44617 | ||
|
|
7f79d9692c | ||
|
|
2d4ffc7514 | ||
|
|
5f3db1f25e | ||
|
|
7115460804 | ||
|
|
0db8808b2a | ||
|
|
cf3ed1dd8f | ||
|
|
da682e3993 | ||
|
|
4a59e901e6 | ||
|
|
8ed2fa07a0 | ||
|
|
385e7f5c1e | ||
|
|
861fff1aae | ||
|
|
09527b3b67 | ||
|
|
d98ff16c8f | ||
|
|
e902e8ea4c | ||
|
|
aeb5bd829f | ||
|
|
a92457b871 | ||
|
|
c24e6207d0 | ||
|
|
6c412cd367 | ||
|
|
89a960629a | ||
|
|
05d96a7d6e | ||
|
|
41144ff1fa | ||
|
|
360cddcb91 | ||
|
|
427832e72e | ||
|
|
27c60658f7 | ||
|
|
fa8ae149d3 | ||
|
|
0c19beb11c | ||
|
|
e34e4a59e9 | ||
|
|
7cc092cd59 | ||
|
|
51cd7156d2 | ||
|
|
1dc843d2d0 | ||
|
|
4040bef4b8 | ||
|
|
e64a850f57 | ||
|
|
555523df38 | ||
|
|
dd882139f3 | ||
|
|
a67b8c6109 | ||
|
|
134208dab6 | ||
|
|
887343d232 | ||
|
|
299b838400 | ||
|
|
c5d0a8be7d | ||
|
|
fe433a84c9 | ||
|
|
543aa7a27b | ||
|
|
36ddf0513b | ||
|
|
c99883e634 | ||
|
|
604f98b08f | ||
|
|
c5009a0333 | ||
|
|
99b05d35a2 | ||
|
|
a3ecc6fe02 | ||
|
|
fc20dd5ad4 | ||
|
|
3f2707404c | ||
|
|
fdd3a28be7 | ||
|
|
eb94e4de72 | ||
|
|
21d275c984 | ||
|
|
cadb19d7ed | ||
|
|
a01241fabe | ||
|
|
89a0877bcc | ||
|
|
950a97d72b | ||
|
|
639c1de2e8 | ||
|
|
254e4f630c | ||
|
|
5d0fb08651 | ||
|
|
7ea64b32f3 | ||
|
|
93807c22c1 | ||
|
|
b2cf17b53b | ||
|
|
7e768b6290 | ||
|
|
7bdf5e4261 | ||
|
|
f6fed612df | ||
|
|
3b3e282df7 | ||
|
|
7f69f652fb | ||
|
|
1452232409 | ||
|
|
4f0f56a7ba | ||
|
|
a695d0db7b | ||
|
|
87c3d766c9 | ||
|
|
89248001e4 | ||
|
|
41b4869068 | ||
|
|
be88a07329 | ||
|
|
f6738ff26c | ||
|
|
ae22f781d8 | ||
|
|
e649c4ced5 | ||
|
|
50da1b401c | ||
|
|
33d02d1df8 | ||
|
|
f3041190fa | ||
|
|
55c2530d5a | ||
|
|
5fbc7dd13e | ||
|
|
b2e5ff1460 | ||
|
|
0f9232ea33 | ||
|
|
a815be6a20 | ||
|
|
7b4667eba9 | ||
|
|
08ccf2632a | ||
|
|
7583598a05 | ||
|
|
7e68691e92 | ||
|
|
4b2034b834 | ||
|
|
4dcf54146c | ||
|
|
8a9715adef | ||
|
|
d253d494ba | ||
|
|
d1f7794afa | ||
|
|
4d80a93710 | ||
|
|
d70faf3b28 | ||
|
|
271749a5a4 | ||
|
|
d1bd131cab | ||
|
|
96fe90ca65 | ||
|
|
fd5f7b873a | ||
|
|
959467de90 | ||
|
|
69434fe356 | ||
|
|
dc264bd164 | ||
|
|
8992f667c7 | ||
|
|
eb627ef323 | ||
|
|
d8cdb0bf7a | ||
|
|
8c68c24716 | ||
|
|
9b302583c4 | ||
|
|
47c2d795e0 | ||
|
|
d608d8c2d4 | ||
|
|
f737b1f30a | ||
|
|
8b36fce7d7 | ||
|
|
30a2a1c921 | ||
|
|
763f9832c3 | ||
|
|
11b1bbc143 | ||
|
|
7176d3e513 | ||
|
|
9c3ba34b51 | ||
|
|
ff3af937da | ||
|
|
b9fcb916a6 | ||
|
|
cfa1f114fd | ||
|
|
761929ea8e | ||
|
|
4d36e66deb | ||
|
|
e58e389658 | ||
|
|
821827f850 | ||
|
|
9d8464cceb | ||
|
|
48a4fa5c6c | ||
|
|
70c04b5a3f | ||
|
|
24ea10e818 | ||
|
|
927451013c | ||
|
|
0d206fe75f | ||
|
|
11accac5ae | ||
|
|
2250367ddc | ||
|
|
fe305bbc81 | ||
|
|
92195340c6 | ||
|
|
1316ead8c8 | ||
|
|
03b33106e0 | ||
|
|
251f0fd88e | ||
|
|
96f154d440 | ||
|
|
27c6d5a3bb | ||
|
|
a57dcc170d | ||
|
|
5c601ff200 | ||
|
|
4d4025ca06 | ||
|
|
77f253c7bd | ||
|
|
fe13d47b24 | ||
|
|
33acf502ed | ||
|
|
66557b2093 | ||
|
|
c713cef484 | ||
|
|
5f7cbd3435 | ||
|
|
a4290b5863 | ||
|
|
f9b0a38642 | ||
|
|
46636cf385 | ||
|
|
e24a6894a5 | ||
|
|
cf9289e21a | ||
|
|
fe7bc954ba | ||
|
|
236989bf6e | ||
|
|
1117afc37a | ||
|
|
06c02de1cb |
3
.claude/.gitignore
vendored
3
.claude/.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
hans/
|
hans/
|
||||||
|
skills/
|
||||||
117
.github/workflows/e2e-tests.yml
vendored
117
.github/workflows/e2e-tests.yml
vendored
@@ -31,24 +31,99 @@ 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: npm run start --workspace=apps/server &
|
run: |
|
||||||
|
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
|
|
||||||
if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then
|
# Check if server process is running
|
||||||
|
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
|
||||||
@@ -59,6 +134,20 @@ 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
|
||||||
@@ -68,10 +157,22 @@ jobs:
|
|||||||
path: apps/ui/playwright-report/
|
path: apps/ui/playwright-report/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results (screenshots, traces, videos)
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: failure()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
path: apps/ui/test-results/
|
path: |
|
||||||
|
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=moderate
|
run: npm audit --audit-level=critical
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -73,6 +73,9 @@ blob-report/
|
|||||||
!.env.example
|
!.env.example
|
||||||
!.env.local.example
|
!.env.local.example
|
||||||
|
|
||||||
|
# Codex config (contains API keys)
|
||||||
|
.codex/config.toml
|
||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
@@ -84,4 +87,12 @@ 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/
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -55,9 +55,13 @@ RUN npm run build:packages && npm run build --workspace=apps/server
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM node:22-slim AS server
|
FROM node:22-slim AS server
|
||||||
|
|
||||||
|
# Build argument for tracking which commit this image was built from
|
||||||
|
ARG GIT_COMMIT_SHA=unknown
|
||||||
|
LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
|
||||||
|
|
||||||
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
|
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git curl bash gosu ca-certificates \
|
git curl bash gosu ca-certificates openssh-client \
|
||||||
&& GH_VERSION="2.63.2" \
|
&& GH_VERSION="2.63.2" \
|
||||||
&& ARCH=$(uname -m) \
|
&& ARCH=$(uname -m) \
|
||||||
&& case "$ARCH" in \
|
&& case "$ARCH" in \
|
||||||
@@ -184,6 +188,10 @@ 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
|
||||||
|
|
||||||
|
|||||||
80
Dockerfile.dev
Normal file
80
Dockerfile.dev
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# 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"]
|
||||||
19
README.md
19
README.md
@@ -117,24 +117,16 @@ cd automaker
|
|||||||
# 2. Install dependencies
|
# 2. Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly)
|
# 3. Build shared packages (can be skipped - npm run dev does it automatically)
|
||||||
npm run build:packages
|
npm run build:packages
|
||||||
|
|
||||||
# 4. Start Automaker (production mode)
|
# 4. Start Automaker
|
||||||
npm run start
|
npm run dev
|
||||||
# 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
|
||||||
@@ -150,7 +142,7 @@ export ANTHROPIC_API_KEY="sk-ant-..."
|
|||||||
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
|
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
|
||||||
```
|
```
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
## How to Run
|
## How to Run
|
||||||
|
|
||||||
@@ -194,9 +186,6 @@ 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
|
||||||
|
|||||||
@@ -8,6 +8,20 @@
|
|||||||
# 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.8.0",
|
"version": "0.10.0",
|
||||||
"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,7 +32,8 @@
|
|||||||
"@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.1",
|
"@modelcontextprotocol/sdk": "1.25.2",
|
||||||
|
"@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,6 +53,10 @@ 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';
|
||||||
@@ -166,6 +170,9 @@ 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);
|
||||||
|
|
||||||
@@ -173,6 +180,11 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
|
|||||||
(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
|
||||||
@@ -188,9 +200,10 @@ 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 and auth are unauthenticated
|
// Mount API routes - health, auth, and setup 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);
|
||||||
@@ -204,9 +217,8 @@ 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());
|
app.use('/api/worktree', createWorktreeRoutes(events));
|
||||||
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));
|
||||||
@@ -216,6 +228,7 @@ 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));
|
||||||
@@ -584,6 +597,26 @@ 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...');
|
||||||
|
|||||||
257
apps/server/src/lib/agent-discovery.ts
Normal file
257
apps/server/src/lib/agent-discovery.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
263
apps/server/src/lib/auth-utils.ts
Normal file
263
apps/server/src/lib/auth-utils.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* 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: 'strict', // Only sent for same-site requests (CSRF protection)
|
sameSite: 'lax', // Sent for same-site requests and top-level navigations, but not cross-origin fetch/XHR
|
||||||
maxAge: SESSION_MAX_AGE_MS,
|
maxAge: SESSION_MAX_AGE_MS,
|
||||||
path: '/',
|
path: '/',
|
||||||
};
|
};
|
||||||
|
|||||||
447
apps/server/src/lib/cli-detection.ts
Normal file
447
apps/server/src/lib/cli-detection.ts
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
68
apps/server/src/lib/codex-auth.ts
Normal file
68
apps/server/src/lib/codex-auth.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* 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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
414
apps/server/src/lib/error-handler.ts
Normal file
414
apps/server/src/lib/error-handler.ts
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
173
apps/server/src/lib/permission-enforcer.ts
Normal file
173
apps/server/src/lib/permission-enforcer.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* 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,7 +16,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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';
|
||||||
@@ -31,6 +30,68 @@ 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.
|
||||||
@@ -57,139 +118,6 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -272,55 +200,31 @@ 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: 'acceptEdits',
|
permissionMode: 'bypassPermissions',
|
||||||
|
allowDangerouslySkipPermissions: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP permission options result
|
* MCP options result
|
||||||
*/
|
*/
|
||||||
interface McpPermissionOptions {
|
interface McpOptions {
|
||||||
/** 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 permission settings to spread into final options
|
* @returns Object with MCP server settings to spread into final options
|
||||||
*/
|
*/
|
||||||
function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
|
function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
|
||||||
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 } : {},
|
||||||
};
|
};
|
||||||
@@ -422,18 +326,9 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -554,7 +449,6 @@ 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 {
|
||||||
@@ -573,24 +467,12 @@ 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,
|
||||||
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
allowedTools: [...TOOL_PRESETS.chat],
|
||||||
...(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 }),
|
||||||
@@ -605,7 +487,6 @@ 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 {
|
||||||
@@ -621,24 +502,12 @@ 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,
|
||||||
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
allowedTools: [...TOOL_PRESETS.fullAccess],
|
||||||
...(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 }),
|
||||||
@@ -656,7 +525,6 @@ 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
|
||||||
@@ -671,22 +539,17 @@ 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 use preset based on MCP settings
|
// For custom options: use explicit allowedTools if provided, otherwise default to readOnly
|
||||||
const effectiveAllowedTools = config.allowedTools
|
const effectiveAllowedTools = config.allowedTools
|
||||||
? [...config.allowedTools]
|
? [...config.allowedTools]
|
||||||
: mcpOptions.shouldRestrictTools
|
: [...TOOL_PRESETS.readOnly];
|
||||||
? [...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,
|
||||||
...(effectiveAllowedTools && { allowedTools: 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,34 +55,6 @@ 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.
|
||||||
@@ -269,3 +241,83 @@ 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,6 +21,12 @@ 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 } from '@automaker/types';
|
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
|
||||||
import type {
|
import type {
|
||||||
ExecuteOptions,
|
ExecuteOptions,
|
||||||
ProviderMessage,
|
ProviderMessage,
|
||||||
@@ -53,6 +53,10 @@ 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,
|
||||||
@@ -70,14 +74,6 @@ 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,
|
||||||
@@ -85,10 +81,9 @@ 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(),
|
||||||
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
|
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
||||||
...(allowedTools && shouldRestrictTools && { allowedTools }),
|
...(allowedTools && { allowedTools }),
|
||||||
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
|
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||||
// AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations
|
|
||||||
permissionMode: 'bypassPermissions',
|
permissionMode: 'bypassPermissions',
|
||||||
allowDangerouslySkipPermissions: true,
|
allowDangerouslySkipPermissions: true,
|
||||||
abortController,
|
abortController,
|
||||||
@@ -98,12 +93,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
|
||||||
|
|||||||
85
apps/server/src/providers/codex-config-manager.ts
Normal file
85
apps/server/src/providers/codex-config-manager.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
apps/server/src/providers/codex-models.ts
Normal file
111
apps/server/src/providers/codex-models.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
1082
apps/server/src/providers/codex-provider.ts
Normal file
1082
apps/server/src/providers/codex-provider.ts
Normal file
File diff suppressed because it is too large
Load Diff
173
apps/server/src/providers/codex-sdk-client.ts
Normal file
173
apps/server/src/providers/codex-sdk-client.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
436
apps/server/src/providers/codex-tool-mapping.ts
Normal file
436
apps/server/src/providers/codex-tool-mapping.ts
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
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,7 +28,9 @@ import type {
|
|||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
ContentBlock,
|
ContentBlock,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { stripProviderPrefix } from '@automaker/types';
|
import { validateBareModelId } 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,
|
||||||
@@ -315,18 +317,25 @@ export class CursorProvider extends CliProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildCliArgs(options: ExecuteOptions): string[] {
|
buildCliArgs(options: ExecuteOptions): string[] {
|
||||||
// Extract model (strip 'cursor-' prefix if present)
|
// Model is already bare (no prefix) - validated by executeQuery
|
||||||
const model = stripProviderPrefix(options.model || 'auto');
|
const model = 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
|
||||||
@@ -472,7 +481,9 @@ export class CursorProvider extends CliProvider {
|
|||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Override CLI detection to add Cursor-specific versions directory check
|
* Override CLI detection to add Cursor-specific checks:
|
||||||
|
* 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)
|
||||||
@@ -507,6 +518,39 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +649,10 @@ 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,
|
||||||
@@ -642,6 +690,9 @@ 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' ||
|
||||||
@@ -838,9 +889,16 @@ 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 {
|
||||||
@@ -857,8 +915,13 @@ export class CursorProvider extends CliProvider {
|
|||||||
return { authenticated: false, method: 'none' };
|
return { authenticated: false, method: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for API key in environment
|
// Check for API key in environment with validation
|
||||||
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,5 +25,8 @@ 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';
|
||||||
|
|||||||
1194
apps/server/src/providers/opencode-provider.ts
Normal file
1194
apps/server/src/providers/opencode-provider.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,27 @@
|
|||||||
|
|
||||||
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, type ModelProvider } from '@automaker/types';
|
import { isCursorModel, isCodexModel, isOpencodeModel, 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
|
||||||
@@ -75,10 +95,26 @@ 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(modelId: string): BaseProvider {
|
static getProviderForModel(
|
||||||
const providerName = this.getProviderNameForModel(modelId);
|
modelId: string,
|
||||||
|
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) {
|
||||||
@@ -93,6 +129,35 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -156,6 +221,41 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -165,6 +265,8 @@ 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', {
|
||||||
@@ -184,3 +286,18 @@ 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,26 +6,57 @@ import { createLogger } from '@automaker/utils';
|
|||||||
|
|
||||||
const logger = createLogger('SpecRegeneration');
|
const logger = createLogger('SpecRegeneration');
|
||||||
|
|
||||||
// Shared state for tracking generation status - private
|
// Shared state for tracking generation status - scoped by project path
|
||||||
let isRunning = false;
|
const runningProjects = new Map<string, boolean>();
|
||||||
let currentAbortController: AbortController | null = null;
|
const abortControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current running state
|
* Get the running state for a specific project
|
||||||
*/
|
*/
|
||||||
export function getSpecRegenerationStatus(): {
|
export function getSpecRegenerationStatus(projectPath?: string): {
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
currentAbortController: AbortController | null;
|
currentAbortController: AbortController | null;
|
||||||
|
projectPath?: string;
|
||||||
} {
|
} {
|
||||||
return { isRunning, currentAbortController };
|
if (projectPath) {
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the running state and abort controller
|
* Get the project path that is currently running (if any)
|
||||||
*/
|
*/
|
||||||
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
|
export function getRunningProjectPath(): string | null {
|
||||||
isRunning = running;
|
for (const [path, running] of runningProjects.entries()) {
|
||||||
currentAbortController = controller;
|
if (running) return path;
|
||||||
|
}
|
||||||
|
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 } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } 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,6 +124,8 @@ 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}
|
||||||
@@ -135,7 +137,7 @@ CRITICAL INSTRUCTIONS:
|
|||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model,
|
model: bareModel,
|
||||||
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 } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } 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,6 +118,8 @@ ${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)
|
||||||
@@ -134,7 +136,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,
|
model: bareModel,
|
||||||
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();
|
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
logger.warn('Generation already running, rejecting request');
|
logger.warn('Generation already running for project:', projectPath);
|
||||||
res.json({ success: false, error: 'Spec generation already running' });
|
res.json({ success: false, error: 'Spec generation already running for this project' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuthStatus('Before starting generation');
|
logAuthStatus('Before starting generation');
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setRunningState(true, abortController);
|
setRunningState(projectPath, 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(false, null);
|
setRunningState(projectPath, 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();
|
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
logger.warn('Generation already running, rejecting request');
|
logger.warn('Generation already running for project:', projectPath);
|
||||||
res.json({ success: false, error: 'Generation already running' });
|
res.json({ success: false, error: 'Generation already running for this project' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuthStatus('Before starting feature generation');
|
logAuthStatus('Before starting feature generation');
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setRunningState(true, abortController);
|
setRunningState(projectPath, 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(false, null);
|
setRunningState(projectPath, 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();
|
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
logger.warn('Generation already running, rejecting request');
|
logger.warn('Generation already running for project:', projectPath);
|
||||||
res.json({ success: false, error: 'Spec generation already running' });
|
res.json({ success: false, error: 'Spec generation already running for this project' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuthStatus('Before starting generation');
|
logAuthStatus('Before starting generation');
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setRunningState(true, abortController);
|
setRunningState(projectPath, 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(false, null);
|
setRunningState(projectPath, false, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Returning success response (generation running in background)');
|
logger.info('Returning success response (generation running in background)');
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ 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 { isRunning } = getSpecRegenerationStatus();
|
const projectPath = req.query.projectPath as string | undefined;
|
||||||
res.json({ success: true, isRunning });
|
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||||
|
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,13 +6,16 @@ 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 { currentAbortController } = getSpecRegenerationStatus();
|
const { projectPath } = req.body as { projectPath?: string };
|
||||||
|
const { currentAbortController } = getSpecRegenerationStatus(projectPath);
|
||||||
if (currentAbortController) {
|
if (currentAbortController) {
|
||||||
currentAbortController.abort();
|
currentAbortController.abort();
|
||||||
}
|
}
|
||||||
setRunningState(false, null);
|
if (projectPath) {
|
||||||
|
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,12 +229,13 @@ export function createAuthRoutes(): Router {
|
|||||||
await invalidateSession(sessionToken);
|
await invalidateSession(sessionToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the cookie
|
// Clear the cookie by setting it to empty with immediate expiration
|
||||||
res.clearCookie(cookieName, {
|
// Using res.cookie() with maxAge: 0 is more reliable than clearCookie()
|
||||||
httpOnly: true,
|
// in cross-origin development environments
|
||||||
secure: process.env.NODE_ENV === 'production',
|
res.cookie(cookieName, '', {
|
||||||
sameSite: 'strict',
|
...getSessionCookieOptions(),
|
||||||
path: '/',
|
maxAge: 0,
|
||||||
|
expires: new Date(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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();
|
||||||
@@ -63,6 +64,11 @@ 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,7 +31,9 @@ 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
|
||||||
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? true)
|
// Default to false to match run-feature/resume-feature behavior.
|
||||||
|
// 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);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 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,7 +7,12 @@
|
|||||||
|
|
||||||
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 { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
import {
|
||||||
|
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';
|
||||||
@@ -120,6 +125,8 @@ 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(
|
||||||
@@ -151,7 +158,7 @@ ${userPrompt}`;
|
|||||||
// Execute the query
|
// Execute the query
|
||||||
const stream = provider.executeQuery({
|
const stream = provider.executeQuery({
|
||||||
prompt: finalPrompt,
|
prompt: finalPrompt,
|
||||||
model: effectiveModel,
|
model: bareModel,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
systemPrompt: finalSystemPrompt,
|
systemPrompt: finalSystemPrompt,
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
|
|||||||
@@ -12,11 +12,22 @@ 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 { projectPath, plan } = req.body as {
|
const {
|
||||||
|
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;
|
||||||
@@ -82,6 +93,7 @@ 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,7 +13,10 @@ 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) {
|
||||||
res.status(503).json({
|
// IMPORTANT: This endpoint is behind Automaker session auth already.
|
||||||
|
// 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",
|
||||||
});
|
});
|
||||||
@@ -26,12 +29,13 @@ 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')) {
|
||||||
res.status(401).json({
|
// Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
|
||||||
|
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(504).json({
|
res.status(200).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',
|
||||||
});
|
});
|
||||||
|
|||||||
90
apps/server/src/routes/codex/index.ts
Normal file
90
apps/server/src/routes/codex/index.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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 } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } 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,6 +198,8 @@ 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}`;
|
||||||
@@ -205,7 +207,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,
|
model: bareModel,
|
||||||
cwd,
|
cwd,
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
@@ -232,7 +234,6 @@ 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 } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } 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,6 +357,8 @@ 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,
|
||||||
@@ -367,7 +369,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,
|
model: bareModel,
|
||||||
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
|
||||||
@@ -394,14 +396,13 @@ 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* () {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ 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';
|
||||||
@@ -90,24 +92,30 @@ async function extractTextFromStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute enhancement using Cursor provider
|
* Execute enhancement using a provider (Cursor, OpenCode, etc.)
|
||||||
*
|
*
|
||||||
* @param prompt - The enhancement prompt
|
* @param prompt - The enhancement prompt
|
||||||
* @param model - The Cursor model to use
|
* @param model - The model to use
|
||||||
* @returns The enhanced text
|
* @returns The enhanced text
|
||||||
*/
|
*/
|
||||||
async function executeWithCursor(prompt: string, model: string): Promise<string> {
|
async function executeWithProvider(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,
|
model: bareModel,
|
||||||
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 === 'assistant' && msg.message?.content) {
|
if (msg.type === 'error') {
|
||||||
|
// 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;
|
||||||
@@ -185,6 +193,7 @@ 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];
|
||||||
|
|
||||||
@@ -208,7 +217,14 @@ 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 executeWithCursor(combinedPrompt, resolvedModel);
|
enhancedText = await executeWithProvider(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,6 +9,8 @@ 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';
|
||||||
@@ -20,6 +22,16 @@ 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));
|
||||||
|
|||||||
61
apps/server/src/routes/features/routes/bulk-delete.ts
Normal file
61
apps/server/src/routes/features/routes/bulk-delete.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
75
apps/server/src/routes/features/routes/bulk-update.ts
Normal file
75
apps/server/src/routes/features/routes/bulk-update.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 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,10 +10,20 @@ 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 { projectPath, featureId, updates } = req.body as {
|
const {
|
||||||
|
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) {
|
||||||
@@ -24,7 +34,14 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await featureLoader.update(projectPath, featureId, updates);
|
const updated = await featureLoader.update(
|
||||||
|
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 } from '@automaker/types';
|
import { isCursorModel, DEFAULT_PHASE_MODELS, stripProviderPrefix } 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,6 +120,8 @@ 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}
|
||||||
@@ -137,7 +139,7 @@ ${prompt}`;
|
|||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model,
|
model: bareModel,
|
||||||
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,6 +23,7 @@ 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
|
||||||
@@ -39,6 +40,7 @@ import { createStatusHandler } from './routes/status.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
|
||||||
@@ -72,5 +74,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
61
apps/server/src/routes/settings/routes/discover-agents.ts
Normal file
61
apps/server/src/routes/settings/routes/discover-agents.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 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 } from '../common.js';
|
import { getErrorMessage, logError, logger } from '../common.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create handler factory for PUT /api/settings/global
|
* Create handler factory for PUT /api/settings/global
|
||||||
@@ -32,6 +32,18 @@ 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,9 +6,24 @@ 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 = '';
|
||||||
@@ -60,6 +75,30 @@ 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,8 +11,25 @@ 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,
|
||||||
@@ -30,15 +47,36 @@ 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,6 +11,7 @@ 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,19 +4,54 @@
|
|||||||
|
|
||||||
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 {
|
||||||
res.json({
|
// Remove the disconnected marker file to reconnect the app to the CLI
|
||||||
success: true,
|
const markerPath = path.join(process.cwd(), '.automaker', '.claude-disconnected');
|
||||||
requiresManualAuth: true,
|
if (fs.existsSync(markerPath)) {
|
||||||
command: 'claude login',
|
fs.unlinkSync(markerPath);
|
||||||
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({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
message: 'Failed to link Claude CLI with the app',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
50
apps/server/src/routes/setup/routes/auth-codex.ts
Normal file
50
apps/server/src/routes/setup/routes/auth-codex.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
73
apps/server/src/routes/setup/routes/auth-cursor.ts
Normal file
73
apps/server/src/routes/setup/routes/auth-cursor.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
51
apps/server/src/routes/setup/routes/auth-opencode.ts
Normal file
51
apps/server/src/routes/setup/routes/auth-opencode.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
81
apps/server/src/routes/setup/routes/codex-status.ts
Normal file
81
apps/server/src/routes/setup/routes/codex-status.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 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,6 +5,20 @@
|
|||||||
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
|
||||||
@@ -16,6 +30,30 @@ 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([
|
||||||
|
|||||||
44
apps/server/src/routes/setup/routes/deauth-claude.ts
Normal file
44
apps/server/src/routes/setup/routes/deauth-claude.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
44
apps/server/src/routes/setup/routes/deauth-codex.ts
Normal file
44
apps/server/src/routes/setup/routes/deauth-codex.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
44
apps/server/src/routes/setup/routes/deauth-cursor.ts
Normal file
44
apps/server/src/routes/setup/routes/deauth-cursor.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
40
apps/server/src/routes/setup/routes/deauth-opencode.ts
Normal file
40
apps/server/src/routes/setup/routes/deauth-opencode.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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,13 +46,14 @@ 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 is supported.`,
|
error: `Unknown provider: ${provider}. Only anthropic and openai are supported.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
33
apps/server/src/routes/setup/routes/install-codex.ts
Normal file
33
apps/server/src/routes/setup/routes/install-codex.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
189
apps/server/src/routes/setup/routes/opencode-models.ts
Normal file
189
apps/server/src/routes/setup/routes/opencode-models.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
59
apps/server/src/routes/setup/routes/opencode-status.ts
Normal file
59
apps/server/src/routes/setup/routes/opencode-status.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 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,8 +7,16 @@ 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 = [
|
||||||
@@ -77,6 +85,19 @@ 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)' : ''}`
|
||||||
);
|
);
|
||||||
@@ -89,37 +110,48 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
let receivedAnyContent = false;
|
let receivedAnyContent = false;
|
||||||
|
|
||||||
// Save original env values
|
// Create secure auth session
|
||||||
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
|
const sessionId = `claude-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Configure environment based on auth method
|
// For API key verification, validate the key first
|
||||||
if (authMethod === 'cli') {
|
if (authMethod === 'api_key' && apiKey) {
|
||||||
// For CLI verification, remove any API key so it uses CLI credentials only
|
const validation = validateApiKey(apiKey, 'anthropic');
|
||||||
delete process.env.ANTHROPIC_API_KEY;
|
if (!validation.isValid) {
|
||||||
logger.info('[Setup] Cleared API key environment for CLI verification');
|
res.json({
|
||||||
} else if (authMethod === 'api_key') {
|
success: true,
|
||||||
// For API key verification, use provided key, stored key, or env var (in order of priority)
|
authenticated: false,
|
||||||
if (apiKey) {
|
error: validation.error,
|
||||||
// Use the provided API key (allows testing unsaved keys)
|
});
|
||||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
return;
|
||||||
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'",
|
||||||
@@ -278,13 +310,8 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
// Restore original environment
|
// Clean up the auth session
|
||||||
if (originalAnthropicKey !== undefined) {
|
AuthSessionManager.destroySession(sessionId);
|
||||||
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:', {
|
||||||
|
|||||||
282
apps/server/src/routes/setup/routes/verify-codex-auth.ts
Normal file
282
apps/server/src/routes/setup/routes/verify-codex-auth.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* 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,7 +8,12 @@
|
|||||||
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 { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
import {
|
||||||
|
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';
|
||||||
@@ -207,6 +212,8 @@ 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}
|
||||||
@@ -222,7 +229,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,
|
model: bareModel,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxTurns: 250,
|
maxTurns: 250,
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
|
|||||||
@@ -3,15 +3,51 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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
|
||||||
@@ -99,18 +135,6 @@ 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,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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';
|
||||||
@@ -24,14 +25,22 @@ 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(): Router {
|
export function createWorktreeRoutes(events: EventEmitter): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
||||||
@@ -45,7 +54,7 @@ export function createWorktreeRoutes(): Router {
|
|||||||
requireValidProject,
|
requireValidProject,
|
||||||
createMergeHandler()
|
createMergeHandler()
|
||||||
);
|
);
|
||||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
|
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
|
||||||
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());
|
||||||
@@ -77,6 +86,8 @@ export function createWorktreeRoutes(): 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(
|
||||||
@@ -87,5 +98,15 @@ export function createWorktreeRoutes(): 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,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { isGitRepo, hasCommits } from './common.js';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
|
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) */
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function createCheckoutBranchHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current branch for reference
|
// Get current branch for reference
|
||||||
const { stdout: currentBranchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
const currentBranch = currentBranchOutput.trim();
|
const currentBranch = currentBranchOutput.trim();
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function createCommitHandler() {
|
|||||||
const commitHash = hashOutput.trim().substring(0, 8);
|
const commitHash = hashOutput.trim().substring(0, 8);
|
||||||
|
|
||||||
// Get branch name
|
// Get branch name
|
||||||
const { stdout: branchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
const branchName = branchOutput.trim();
|
const branchName = branchOutput.trim();
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function createCreatePRHandler() {
|
|||||||
const effectiveProjectPath = projectPath || worktreePath;
|
const effectiveProjectPath = projectPath || worktreePath;
|
||||||
|
|
||||||
// Get current branch name
|
// Get current branch name
|
||||||
const { stdout: branchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ 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');
|
||||||
|
|
||||||
@@ -77,7 +81,7 @@ async function findExistingWorktreeForBranch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCreateHandler() {
|
export function createCreateHandler(events: EventEmitter) {
|
||||||
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 {
|
||||||
@@ -94,6 +98,26 @@ export function createCreateHandler() {
|
|||||||
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,
|
||||||
@@ -143,30 +167,28 @@ export function createCreateHandler() {
|
|||||||
// 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
|
// Check if branch exists (using array arguments to prevent injection)
|
||||||
let branchExists = false;
|
let branchExists = false;
|
||||||
try {
|
try {
|
||||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
|
||||||
cwd: projectPath,
|
|
||||||
});
|
|
||||||
branchExists = true;
|
branchExists = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Branch doesn't exist
|
// Branch doesn't exist
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create worktree
|
// Create worktree (using array arguments to prevent injection)
|
||||||
let createCmd: string;
|
|
||||||
if (branchExists) {
|
if (branchExists) {
|
||||||
// Use existing branch
|
// Use existing branch
|
||||||
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
|
await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath);
|
||||||
} else {
|
} else {
|
||||||
// Create new branch from base or HEAD
|
// Create new branch from base or HEAD
|
||||||
const base = baseBranch || 'HEAD';
|
const base = baseBranch || 'HEAD';
|
||||||
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
|
await execGitCommand(
|
||||||
|
['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
|
||||||
@@ -177,6 +199,8 @@ export function createCreateHandler() {
|
|||||||
// 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: {
|
||||||
@@ -185,6 +209,17 @@ export function createCreateHandler() {
|
|||||||
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,9 +6,11 @@ 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 } from '../common.js';
|
import { getErrorMessage, logError, isValidBranchName, execGitCommand } 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> => {
|
||||||
@@ -38,7 +40,7 @@ export function createDeleteHandler() {
|
|||||||
// Get branch name before removing worktree
|
// Get branch name before removing worktree
|
||||||
let branchName: string | null = null;
|
let branchName: string | null = null;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync('git symbolic-ref --short HEAD', {
|
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
branchName = stdout.trim();
|
branchName = stdout.trim();
|
||||||
@@ -46,22 +48,28 @@ export function createDeleteHandler() {
|
|||||||
// Could not get branch name
|
// Could not get branch name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the worktree
|
// Remove the worktree (using array arguments to prevent injection)
|
||||||
try {
|
try {
|
||||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
||||||
cwd: projectPath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Try with prune if remove fails
|
// Try with prune if remove fails
|
||||||
await execAsync('git worktree prune', { cwd: projectPath });
|
await execGitCommand(['worktree', 'prune'], 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') {
|
||||||
try {
|
// Validate branch name to prevent command injection
|
||||||
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
if (!isValidBranchName(branchName)) {
|
||||||
} catch {
|
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
||||||
// Branch deletion failed, not critical
|
} else {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['branch', '-D', branchName], projectPath);
|
||||||
|
branchDeleted = true;
|
||||||
|
} catch {
|
||||||
|
// Branch deletion failed, not critical
|
||||||
|
logger.warn(`Failed to delete branch: ${branchName}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +77,8 @@ export function createDeleteHandler() {
|
|||||||
success: true,
|
success: true,
|
||||||
deleted: {
|
deleted: {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
branch: deleteBranch ? branchName : null,
|
branch: branchDeleted ? branchName : null,
|
||||||
|
branchDeleted,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ 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 } = req.body as {
|
const { projectPath, featureId, useWorktrees } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
|
useWorktrees?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !featureId) {
|
if (!projectPath || !featureId) {
|
||||||
@@ -24,6 +25,19 @@ 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);
|
||||||
|
|
||||||
@@ -41,7 +55,11 @@ 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
|
||||||
logError(innerError, 'Worktree access failed, falling back to main project');
|
const code = (innerError as NodeJS.ErrnoException | undefined)?.code;
|
||||||
|
// 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,10 +15,11 @@ 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 } = req.body as {
|
const { projectPath, featureId, filePath, useWorktrees } = 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) {
|
||||||
@@ -29,6 +30,12 @@ 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);
|
||||||
|
|
||||||
@@ -57,7 +64,11 @@ export function createFileDiffHandler() {
|
|||||||
|
|
||||||
res.json({ success: true, diff, filePath });
|
res.json({ success: true, diff, filePath });
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
logError(innerError, 'Worktree file diff failed');
|
const code = (innerError as NodeJS.ErrnoException | undefined)?.code;
|
||||||
|
// 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) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function createInfoHandler() {
|
|||||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||||
try {
|
try {
|
||||||
await secureFs.access(worktreePath);
|
await secureFs.access(worktreePath);
|
||||||
const { stdout } = await execAsync('git symbolic-ref --short HEAD', {
|
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
280
apps/server/src/routes/worktree/routes/init-script.ts
Normal file
280
apps/server/src/routes/worktree/routes/init-script.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ export function createListBranchesHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current branch
|
// Get current branch
|
||||||
const { stdout: currentBranchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
const currentBranch = currentBranchOutput.trim();
|
const currentBranch = currentBranchOutput.trim();
|
||||||
|
|||||||
@@ -2,18 +2,23 @@
|
|||||||
* 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;
|
||||||
@@ -35,6 +40,87 @@ 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 {
|
||||||
@@ -116,6 +202,22 @@ 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);
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function createMergeHandler() {
|
|||||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||||
|
|
||||||
// Get current branch
|
// Get current branch
|
||||||
const { stdout: currentBranch } = await execAsync('git symbolic-ref --short HEAD', {
|
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,78 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* 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 { exec } from 'child_process';
|
import { isAbsolute } from 'path';
|
||||||
import { promisify } from 'util';
|
import {
|
||||||
|
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 execAsync = promisify(exec);
|
const logger = createLogger('open-in-editor');
|
||||||
|
|
||||||
// Editor detection with caching
|
export function createGetAvailableEditorsHandler() {
|
||||||
interface EditorInfo {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
name: string;
|
try {
|
||||||
command: string;
|
const editors = await detectAllEditors();
|
||||||
}
|
res.json({
|
||||||
|
success: true,
|
||||||
let cachedEditor: EditorInfo | null = null;
|
result: {
|
||||||
|
editors,
|
||||||
/**
|
},
|
||||||
* Detect which code editor is available on the system
|
});
|
||||||
*/
|
} catch (error) {
|
||||||
async function detectDefaultEditor(): Promise<EditorInfo> {
|
logError(error, 'Get available editors failed');
|
||||||
// Return cached result if available
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
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() {
|
||||||
@@ -93,11 +55,41 @@ 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 } = req.body as {
|
const { worktreePath, editorCommand } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
editorCommand?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -108,42 +100,44 @@ export function createOpenInEditorHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = await detectDefaultEditor();
|
// Security: Validate that worktreePath is an absolute path
|
||||||
|
if (!isAbsolute(worktreePath)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath must be an absolute path',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await execAsync(`${editor.command} "${worktreePath}"`);
|
// Use the platform utility to open in editor
|
||||||
|
const result = await openInEditor(worktreePath, editorCommand);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
message: `Opened ${worktreePath} in ${editor.name}`,
|
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||||
editorName: editor.name,
|
editorName: result.editorName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (editorError) {
|
} catch (editorError) {
|
||||||
// If the detected editor fails, try opening in default file manager as fallback
|
// If the specified editor fails, try opening in default file manager as fallback
|
||||||
const platform = process.platform;
|
logger.warn(
|
||||||
let openCommand: string;
|
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
|
||||||
let fallbackName: string;
|
);
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
try {
|
||||||
openCommand = `open "${worktreePath}"`;
|
const result = await openInFileManager(worktreePath);
|
||||||
fallbackName = 'Finder';
|
res.json({
|
||||||
} else if (platform === 'win32') {
|
success: true,
|
||||||
openCommand = `explorer "${worktreePath}"`;
|
result: {
|
||||||
fallbackName = 'Explorer';
|
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||||
} else {
|
editorName: result.editorName,
|
||||||
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');
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function createPullHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current branch name
|
// Get current branch name
|
||||||
const { stdout: branchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
const branchName = branchOutput.trim();
|
const branchName = branchOutput.trim();
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function createPushHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get branch name
|
// Get branch name
|
||||||
const { stdout: branchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
const branchName = branchOutput.trim();
|
const branchName = branchOutput.trim();
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function createSwitchBranchHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current branch
|
// Get current branch
|
||||||
const { stdout: currentBranchOutput } = await execAsync('git symbolic-ref --short HEAD', {
|
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
const previousBranch = currentBranchOutput.trim();
|
const previousBranch = currentBranchOutput.trim();
|
||||||
|
|||||||
@@ -6,13 +6,16 @@
|
|||||||
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 } from '@automaker/types';
|
import type { ExecuteOptions, ThinkingLevel, ReasoningEffort } 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';
|
||||||
@@ -20,10 +23,12 @@ 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 {
|
||||||
@@ -55,6 +60,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -144,6 +150,7 @@ export class AgentService {
|
|||||||
imagePaths,
|
imagePaths,
|
||||||
model,
|
model,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
|
reasoningEffort,
|
||||||
}: {
|
}: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -151,6 +158,7 @@ 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) {
|
||||||
@@ -163,7 +171,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 and thinking level if provided
|
// Update session model, thinking level, and reasoning effort if provided
|
||||||
if (model) {
|
if (model) {
|
||||||
session.model = model;
|
session.model = model;
|
||||||
await this.updateSession(sessionId, { model });
|
await this.updateSession(sessionId, { model });
|
||||||
@@ -171,6 +179,21 @@ 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'] = [];
|
||||||
@@ -232,19 +255,34 @@ 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]');
|
||||||
|
|
||||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
// Get Skills configuration from settings
|
||||||
|
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
|
||||||
@@ -258,8 +296,9 @@ export class AgentService {
|
|||||||
: baseSystemPrompt;
|
: baseSystemPrompt;
|
||||||
|
|
||||||
// Build SDK options using centralized configuration
|
// Build SDK options using centralized configuration
|
||||||
// Use thinking level from request, or fall back to session's stored thinking level
|
// Use thinking level and reasoning effort from request, or fall back to session's stored values
|
||||||
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,
|
||||||
@@ -267,7 +306,6 @@ 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,
|
||||||
});
|
});
|
||||||
@@ -275,25 +313,71 @@ 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;
|
||||||
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
let allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
||||||
|
|
||||||
// Get provider for this model
|
// Build merged settingSources array using Set for automatic deduplication
|
||||||
|
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: effectiveModel,
|
model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1")
|
||||||
|
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: sdkOptions.settingSources,
|
settingSources: settingSources.length > 0 ? settingSources : undefined,
|
||||||
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
|
||||||
@@ -374,6 +458,53 @@ 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,24 +14,30 @@ 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 } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, stripProviderPrefix } 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 { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform';
|
import {
|
||||||
|
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';
|
||||||
@@ -47,7 +53,6 @@ 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,
|
||||||
@@ -202,6 +207,29 @@ 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
|
||||||
@@ -323,6 +351,11 @@ 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);
|
||||||
@@ -389,17 +422,23 @@ 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: this.config?.projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +479,11 @@ 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);
|
||||||
@@ -514,15 +558,21 @@ 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.) - passed as system prompt
|
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
|
||||||
|
// 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
|
||||||
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
// Note: contextResult.formattedPrompt now includes both context AND memory
|
||||||
|
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
|
||||||
@@ -575,7 +625,7 @@ export class AutoModeService {
|
|||||||
projectPath,
|
projectPath,
|
||||||
planningMode: feature.planningMode,
|
planningMode: feature.planningMode,
|
||||||
requirePlanApproval: feature.requirePlanApproval,
|
requirePlanApproval: feature.requirePlanApproval,
|
||||||
systemPrompt: contextFilesPrompt || undefined,
|
systemPrompt: combinedSystemPrompt || undefined,
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
thinkingLevel: feature.thinkingLevel,
|
thinkingLevel: feature.thinkingLevel,
|
||||||
}
|
}
|
||||||
@@ -607,6 +657,36 @@ 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,
|
||||||
@@ -658,6 +738,11 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,10 +760,14 @@ 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
|
// Load context files once with feature context for smart memory selection
|
||||||
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);
|
||||||
|
|
||||||
@@ -911,6 +1000,10 @@ 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
|
||||||
@@ -1314,7 +1407,6 @@ 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
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1784,9 +1876,13 @@ 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)
|
areDependenciesSatisfied(feature, allFeatures, { skipVerification })
|
||||||
);
|
);
|
||||||
|
|
||||||
return readyFeatures;
|
return readyFeatures;
|
||||||
@@ -1989,6 +2085,18 @@ 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
|
||||||
@@ -2062,9 +2170,6 @@ 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]');
|
||||||
|
|
||||||
@@ -2076,7 +2181,6 @@ 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,
|
||||||
});
|
});
|
||||||
@@ -2093,7 +2197,12 @@ 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);
|
||||||
|
|
||||||
logger.info(`Using provider "${provider.getName()}" for model "${finalModel}"`);
|
// Strip provider prefix - providers should receive bare model IDs
|
||||||
|
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(
|
||||||
@@ -2112,14 +2221,13 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
|
|
||||||
const executeOptions: ExecuteOptions = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: promptContent,
|
prompt: promptContent,
|
||||||
model: finalModel,
|
model: bareModel,
|
||||||
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
|
||||||
};
|
};
|
||||||
@@ -2202,9 +2310,23 @@ 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);
|
||||||
|
|
||||||
@@ -2404,7 +2526,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: finalModel,
|
model: bareModel,
|
||||||
maxTurns: maxTurns || 100,
|
maxTurns: maxTurns || 100,
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
@@ -2542,7 +2664,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: finalModel,
|
model: bareModel,
|
||||||
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,
|
||||||
@@ -2630,7 +2752,7 @@ Implement all the changes described in the plan above.`;
|
|||||||
|
|
||||||
const continuationStream = provider.executeQuery({
|
const continuationStream = provider.executeQuery({
|
||||||
prompt: continuationPrompt,
|
prompt: continuationPrompt,
|
||||||
model: finalModel,
|
model: bareModel,
|
||||||
maxTurns: maxTurns,
|
maxTurns: maxTurns,
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
@@ -2721,6 +2843,7 @@ 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) {
|
||||||
@@ -2874,4 +2997,350 @@ 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,6 +2,7 @@ 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
|
||||||
@@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js';
|
|||||||
* - 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
|
||||||
@@ -164,21 +167,40 @@ 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'];
|
||||||
|
|
||||||
const ptyProcess = pty.spawn(shell, args, {
|
let ptyProcess: any = null;
|
||||||
name: 'xterm-256color',
|
|
||||||
cols: 120,
|
try {
|
||||||
rows: 30,
|
ptyProcess = pty.spawn(shell, args, {
|
||||||
cwd: workingDirectory,
|
name: 'xterm-256color',
|
||||||
env: {
|
cols: 120,
|
||||||
...process.env,
|
rows: 30,
|
||||||
TERM: 'xterm-256color',
|
cwd: workingDirectory,
|
||||||
} as Record<string, string>,
|
env: {
|
||||||
});
|
...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;
|
||||||
ptyProcess.kill();
|
if (ptyProcess && !ptyProcess.killed) {
|
||||||
|
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);
|
||||||
@@ -188,7 +210,7 @@ export class ClaudeUsageService {
|
|||||||
}
|
}
|
||||||
}, this.timeout);
|
}, this.timeout);
|
||||||
|
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data: string) => {
|
||||||
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")
|
||||||
@@ -196,12 +218,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) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
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) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.kill('SIGTERM');
|
ptyProcess.kill('SIGTERM');
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -212,14 +234,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) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.write('\x1b'); // Send escape key
|
ptyProcess.write('\x1b'); // Send escape key
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ptyProcess.onExit(({ exitCode }) => {
|
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
|||||||
212
apps/server/src/services/codex-app-server-service.ts
Normal file
212
apps/server/src/services/codex-app-server-service.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user