diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..7cddcaef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,108 @@ +name: Feature Request +description: Suggest a new feature or enhancement for Automaker +title: '[Feature]: ' +labels: ['enhancement'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to suggest a feature! Please fill out the form below to help us understand your request. + + - type: dropdown + id: feature-area + attributes: + label: Feature Area + description: Which area of Automaker does this feature relate to? + options: + - UI/UX (User Interface) + - Agent/AI + - Kanban Board + - Git/Worktree Management + - Project Management + - Settings/Configuration + - Documentation + - Performance + - Other + default: 0 + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: Priority + description: How important is this feature to your workflow? + options: + - Nice to have + - Would improve my workflow + - Critical for my use case + default: 0 + validations: + required: true + + - type: textarea + id: problem-statement + attributes: + label: Problem Statement + description: Is your feature request related to a problem? Please describe the problem you're trying to solve. + placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when... + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like to see implemented. + placeholder: A clear and concise description of what you want to happen. + validations: + required: true + + - type: textarea + id: alternatives-considered + attributes: + label: Alternatives Considered + description: Describe any alternative solutions or workarounds you've considered. + placeholder: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: false + + - type: textarea + id: use-cases + attributes: + label: Use Cases + description: Describe specific scenarios where this feature would be useful. + placeholder: | + 1. When working on... + 2. As a user who needs to... + 3. In situations where... + validations: + required: false + + - type: textarea + id: mockups + attributes: + label: Mockups/Screenshots + description: If applicable, add mockups, wireframes, or screenshots to help illustrate your feature request. + placeholder: Drag and drop images here or paste image URLs + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context, references, or examples about the feature request here. + placeholder: Any additional information that might be helpful... + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: Checklist + options: + - label: I have searched existing issues to ensure this feature hasn't been requested already + required: true + - label: I have provided a clear description of the problem and proposed solution + required: true diff --git a/.gitignore b/.gitignore index be8843e0..7d6c7b0e 100644 --- a/.gitignore +++ b/.gitignore @@ -90,9 +90,8 @@ pnpm-lock.yaml 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/ +.codex/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 276c2fa0..f61fd35b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -31,7 +31,12 @@ fi # Ensure common system paths are in PATH (for systems without nvm) # This helps find node/npm installed via Homebrew, system packages, etc. -export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin" +if [ -n "$WINDIR" ]; then + export PATH="$PATH:/c/Program Files/nodejs:/c/Program Files (x86)/nodejs" + export PATH="$PATH:$APPDATA/npm:$LOCALAPPDATA/Programs/nodejs" +else + export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin" +fi # Run lint-staged - works with or without nvm # Prefer npx, fallback to npm exec, both work with system-installed Node.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3fed705..61ad83f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,7 @@ For complete details on contribution terms and rights assignment, please review - [Development Setup](#development-setup) - [Project Structure](#project-structure) - [Pull Request Process](#pull-request-process) + - [Branching Strategy (RC Branches)](#branching-strategy-rc-branches) - [Branch Naming Convention](#branch-naming-convention) - [Commit Message Format](#commit-message-format) - [Submitting a Pull Request](#submitting-a-pull-request) @@ -186,6 +187,59 @@ automaker/ This section covers everything you need to know about contributing changes through pull requests, from creating your branch to getting your code merged. +### Branching Strategy (RC Branches) + +Automaker uses **Release Candidate (RC) branches** for all development work. Understanding this workflow is essential before contributing. + +**How it works:** + +1. **All development happens on RC branches** - We maintain version-specific RC branches (e.g., `v0.10.0rc`, `v0.11.0rc`) where all active development occurs +2. **RC branches are eventually merged to main** - Once an RC branch is stable and ready for release, it gets merged into `main` +3. **Main branch is for releases only** - The `main` branch contains only released, stable code + +**Before creating a PR:** + +1. **Check for the latest RC branch** - Before starting work, check the repository for the current RC branch: + + ```bash + git fetch upstream + git branch -r | grep rc + ``` + +2. **Base your work on the RC branch** - Create your feature branch from the latest RC branch, not from `main`: + + ```bash + # Find the latest RC branch (e.g., v0.11.0rc) + git checkout upstream/v0.11.0rc + git checkout -b feature/your-feature-name + ``` + +3. **Target the RC branch in your PR** - When opening your pull request, set the base branch to the current RC branch, not `main` + +**Example workflow:** + +```bash +# 1. Fetch latest changes +git fetch upstream + +# 2. Check for RC branches +git branch -r | grep rc +# Output: upstream/v0.11.0rc + +# 3. Create your branch from the RC +git checkout -b feature/add-dark-mode upstream/v0.11.0rc + +# 4. Make your changes and commit +git commit -m "feat: Add dark mode support" + +# 5. Push to your fork +git push origin feature/add-dark-mode + +# 6. Open PR targeting the RC branch (v0.11.0rc), NOT main +``` + +**Important:** PRs opened directly against `main` will be asked to retarget to the current RC branch. + ### Branch Naming Convention We use a consistent branch naming pattern to keep our repository organized: @@ -275,14 +329,14 @@ Follow these steps to submit your contribution: #### 1. Prepare Your Changes -Ensure you've synced with the latest upstream changes: +Ensure you've synced with the latest upstream changes from the RC branch: ```bash # Fetch latest changes from upstream git fetch upstream -# Rebase your branch on main (if needed) -git rebase upstream/main +# Rebase your branch on the current RC branch (if needed) +git rebase upstream/v0.11.0rc # Use the current RC branch name ``` #### 2. Run Pre-submission Checks @@ -314,18 +368,19 @@ git push origin feature/your-feature-name 1. Go to your fork on GitHub 2. Click "Compare & pull request" for your branch -3. Ensure the base repository is `AutoMaker-Org/automaker` and base branch is `main` +3. **Important:** Set the base repository to `AutoMaker-Org/automaker` and the base branch to the **current RC branch** (e.g., `v0.11.0rc`), not `main` 4. Fill out the PR template completely #### PR Requirements Checklist Your PR should include: +- [ ] **Targets the current RC branch** (not `main`) - see [Branching Strategy](#branching-strategy-rc-branches) - [ ] **Clear title** describing the change (use conventional commit format) - [ ] **Description** explaining what changed and why - [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456` - [ ] **All CI checks passing** (format, lint, build, tests) -- [ ] **No merge conflicts** with main branch +- [ ] **No merge conflicts** with the RC branch - [ ] **Tests included** for new functionality - [ ] **Documentation updated** if adding/changing public APIs diff --git a/DEVELOPMENT_WORKFLOW.md b/DEVELOPMENT_WORKFLOW.md new file mode 100644 index 00000000..0ce198ce --- /dev/null +++ b/DEVELOPMENT_WORKFLOW.md @@ -0,0 +1,253 @@ +# Development Workflow + +This document defines the standard workflow for keeping a branch in sync with the upstream +release candidate (RC) and for shipping feature work. It is paired with `check-sync.sh`. + +## Quick Decision Rule + +1. Ask the user to select a workflow: + - **Sync Workflow** → you are maintaining the current RC branch with fixes/improvements + and will push the same fixes to both origin and upstream RC when you have local + commits to publish. + - **PR Workflow** → you are starting new feature work on a new branch; upstream updates + happen via PR only. +2. After the user selects, run: + ```bash + ./check-sync.sh + ``` +3. Use the status output to confirm alignment. If it reports **diverged**, default to + merging `upstream/` into the current branch and preserving local commits. + For Sync Workflow, when the working tree is clean and you are behind upstream RC, + proceed with the fetch + merge without asking for additional confirmation. + +## Target RC Resolution + +The target RC is resolved dynamically so the workflow stays current as the RC changes. + +Resolution order: + +1. Latest `upstream/v*rc` branch (auto-detected) +2. `upstream/HEAD` (fallback) +3. If neither is available, you must pass `--rc ` + +Override for a single run: + +```bash +./check-sync.sh --rc +``` + +## Pre-Flight Checklist + +1. Confirm a clean working tree: + ```bash + git status + ``` +2. Confirm the current branch: + ```bash + git branch --show-current + ``` +3. Ensure remotes exist (origin + upstream): + ```bash + git remote -v + ``` + +## Sync Workflow (Upstream Sync) + +Use this flow when you are updating the current branch with fixes or improvements and +intend to keep origin and upstream RC in lockstep. + +1. **Check sync status** + ```bash + ./check-sync.sh + ``` +2. **Update from upstream RC before editing (no pulls)** + - **Behind upstream RC** → fetch and merge RC into your branch: + ```bash + git fetch upstream + git merge upstream/ --no-edit + ``` + When the working tree is clean and the user selected Sync Workflow, proceed without + an extra confirmation prompt. + - **Diverged** → stop and resolve manually. +3. **Resolve conflicts if needed** + - Handle conflicts intelligently: preserve upstream behavior and your local intent. +4. **Make changes and commit (if you are delivering fixes)** + ```bash + git add -A + git commit -m "type: description" + ``` +5. **Build to verify** + ```bash + npm run build:packages + npm run build + ``` +6. **Push after a successful merge to keep remotes aligned** + - If you only merged upstream RC changes, push **origin only** to sync your fork: + ```bash + git push origin + ``` + - If you have local fixes to publish, push **origin + upstream**: + ```bash + git push origin + git push upstream : + ``` + - Always ask the user which push to perform. + - Origin (origin-only sync): + ```bash + git push origin + ``` + - Upstream RC (publish the same fixes when you have local commits): + ```bash + git push upstream : + ``` +7. **Re-check sync** + ```bash + ./check-sync.sh + ``` + +## PR Workflow (Feature Work) + +Use this flow only for new feature work on a new branch. Do not push to upstream RC. + +1. **Create or switch to a feature branch** + ```bash + git checkout -b + ``` +2. **Make changes and commit** + ```bash + git add -A + git commit -m "type: description" + ``` +3. **Merge upstream RC before shipping** + ```bash + git merge upstream/ --no-edit + ``` +4. **Build and/or test** + ```bash + npm run build:packages + npm run build + ``` +5. **Push to origin** + ```bash + git push -u origin + ``` +6. **Create or update the PR** + - Use `gh pr create` or the GitHub UI. +7. **Review and follow-up** + +- Apply feedback, commit changes, and push again. +- Re-run `./check-sync.sh` if additional upstream sync is needed. + +## Conflict Resolution Checklist + +1. Identify which changes are from upstream vs. local. +2. Preserve both behaviors where possible; avoid dropping either side. +3. Prefer minimal, safe integrations over refactors. +4. Re-run build commands after resolving conflicts. +5. Re-run `./check-sync.sh` to confirm status. + +## Build/Test Matrix + +- **Sync Workflow**: `npm run build:packages` and `npm run build`. +- **PR Workflow**: `npm run build:packages` and `npm run build` (plus relevant tests). + +## Post-Sync Verification + +1. `git status` should be clean. +2. `./check-sync.sh` should show expected alignment. +3. Verify recent commits with: + ```bash + git log --oneline -5 + ``` + +## check-sync.sh Usage + +- Uses dynamic Target RC resolution (see above). +- Override target RC: + ```bash + ./check-sync.sh --rc + ``` +- Optional preview limit: + ```bash + ./check-sync.sh --preview 10 + ``` +- The script prints sync status for both origin and upstream and previews recent commits + when you are behind. + +## Stop Conditions + +Stop and ask for guidance if any of the following are true: + +- The working tree is dirty and you are about to merge or push. +- `./check-sync.sh` reports **diverged** during PR Workflow, or a merge cannot be completed. +- The script cannot resolve a target RC and requests `--rc`. +- A build fails after sync or conflict resolution. + +## AI Agent Guardrails + +- Always run `./check-sync.sh` before merges or pushes. +- Always ask for explicit user approval before any push command. +- Do not ask for additional confirmation before a Sync Workflow fetch + merge when the + working tree is clean and the user has already selected the Sync Workflow. +- Choose Sync vs PR workflow based on intent (RC maintenance vs new feature work), not + on the script's workflow hint. +- Only use force push when the user explicitly requests a history rewrite. +- Ask for explicit approval before dependency installs, branch deletion, or destructive operations. +- When resolving merge conflicts, preserve both upstream changes and local intent where possible. +- Do not create or switch to new branches unless the user explicitly requests it. + +## AI Agent Decision Guidance + +Agents should provide concrete, task-specific suggestions instead of repeatedly asking +open-ended questions. Use the user's stated goal and the `./check-sync.sh` status to +propose a default path plus one or two alternatives, and only ask for confirmation when +an action requires explicit approval. + +Default behavior: + +- If the intent is RC maintenance, recommend the Sync Workflow and proceed with + safe preparation steps (status checks, previews). If the branch is behind upstream RC, + fetch and merge without additional confirmation when the working tree is clean, then + push to origin to keep the fork aligned. Push upstream only when there are local fixes + to publish. +- If the intent is new feature work, recommend the PR Workflow and proceed with safe + preparation steps (status checks, identifying scope). Ask for approval before merges, + pushes, or dependency installs. +- If `./check-sync.sh` reports **diverged** during Sync Workflow, merge + `upstream/` into the current branch and preserve local commits. +- If `./check-sync.sh` reports **diverged** during PR Workflow, stop and ask for guidance + with a short explanation of the divergence and the minimal options to resolve it. + If the user's intent is RC maintenance, prefer the Sync Workflow regardless of the + script hint. When the intent is new feature work, use the PR Workflow and avoid upstream + RC pushes. + +Suggestion format (keep it short): + +- **Recommended**: one sentence with the default path and why it fits the task. +- **Alternatives**: one or two options with the tradeoff or prerequisite. +- **Approval points**: mention any upcoming actions that need explicit approval (exclude sync + workflow pushes and merges). + +## Failure Modes and How to Avoid Them + +Sync Workflow: + +- Wrong RC target: verify the auto-detected RC in `./check-sync.sh` output before merging. +- Diverged from upstream RC: stop and resolve manually before any merge or push. +- Dirty working tree: commit or stash before syncing to avoid accidental merges. +- Missing remotes: ensure both `origin` and `upstream` are configured before syncing. +- Build breaks after sync: run `npm run build:packages` and `npm run build` before pushing. + +PR Workflow: + +- Branch not synced to current RC: re-run `./check-sync.sh` and merge RC before shipping. +- Pushing the wrong branch: confirm `git branch --show-current` before pushing. +- Unreviewed changes: always commit and push to origin before opening or updating a PR. +- Skipped tests/builds: run the build commands before declaring the PR ready. + +## Notes + +- Avoid merging with uncommitted changes; commit or stash first. +- Prefer merge over rebase for PR branches; rebases rewrite history and often require a force push, + which should only be done with an explicit user request. +- Use clear, conventional commit messages and split unrelated changes into separate commits. diff --git a/Dockerfile b/Dockerfile index c32b1764..e0afeb74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,9 +59,22 @@ FROM node:22-slim AS server ARG GIT_COMMIT_SHA=unknown LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}" +# Build arguments for user ID matching (allows matching host user for mounted volumes) +# Override at build time: docker build --build-arg UID=$(id -u) --build-arg GID=$(id -g) ... +ARG UID=1001 +ARG GID=1001 + # Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch) +# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu) RUN apt-get update && apt-get install -y --no-install-recommends \ git curl bash gosu ca-certificates openssh-client \ + # Playwright/Chromium dependencies + libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \ + libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \ + libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \ + libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \ + libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \ + xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \ && GH_VERSION="2.63.2" \ && ARCH=$(uname -m) \ && case "$ARCH" in \ @@ -79,8 +92,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN npm install -g @anthropic-ai/claude-code # Create non-root user with home directory BEFORE installing Cursor CLI -RUN groupadd -g 1001 automaker && \ - useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \ +# Uses UID/GID build args to match host user for mounted volume permissions +# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group) +RUN groupadd -o -g ${GID} automaker && \ + useradd -o -u ${UID} -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 && \ @@ -95,6 +110,12 @@ RUN curl https://cursor.com/install -fsS | bash && \ ls -la /home/automaker/.local/bin/ && \ echo "=== PATH is: $PATH ===" && \ (which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)" + +# Install OpenCode CLI (for multi-provider AI model access) +RUN curl -fsSL https://opencode.ai/install | bash && \ + echo "=== Checking OpenCode CLI installation ===" && \ + ls -la /home/automaker/.local/bin/ && \ + (which opencode && opencode --version) || echo "opencode installed (may need auth setup)" USER root # Add PATH to profile so it's available in all interactive shells (for login shells) diff --git a/Dockerfile.dev b/Dockerfile.dev index 87ac6bf6..60e445f2 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -8,9 +8,17 @@ FROM node:22-slim # Install build dependencies for native modules (node-pty) and runtime tools +# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu) RUN apt-get update && apt-get install -y --no-install-recommends \ python3 make g++ \ git curl bash gosu ca-certificates openssh-client \ + # Playwright/Chromium dependencies + libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \ + libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \ + libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \ + libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \ + libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \ + xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \ && GH_VERSION="2.63.2" \ && ARCH=$(uname -m) \ && case "$ARCH" in \ @@ -27,9 +35,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # 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 && \ +# Build arguments for user ID matching (allows matching host user for mounted volumes) +# Override at build time: docker-compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g) +ARG UID=1001 +ARG GID=1001 + +# Create non-root user with configurable UID/GID +# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group) +RUN groupadd -o -g ${GID} automaker && \ + useradd -o -u ${UID} -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 && \ diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f763c08d..609be945 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -67,6 +67,7 @@ import { createPipelineRoutes } from './routes/pipeline/index.js'; import { pipelineService } from './services/pipeline-service.js'; import { createIdeationRoutes } from './routes/ideation/index.js'; import { IdeationService } from './services/ideation-service.js'; +import { getDevServerService } from './services/dev-server-service.js'; // Load environment variables dotenv.config(); @@ -176,6 +177,10 @@ const codexUsageService = new CodexUsageService(codexAppServerService); const mcpTestService = new MCPTestService(settingsService); const ideationService = new IdeationService(events, settingsService, featureLoader); +// Initialize DevServerService with event emitter for real-time log streaming +const devServerService = getDevServerService(); +devServerService.setEventEmitter(events); + // Initialize services (async () => { await agentService.initialize(); @@ -217,7 +222,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService)); app.use('/api/features', createFeaturesRoutes(featureLoader)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); -app.use('/api/worktree', createWorktreeRoutes(events)); +app.use('/api/worktree', createWorktreeRoutes(events, settingsService)); app.use('/api/git', createGitRoutes()); app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService)); app.use('/api/models', createModelsRoutes()); diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index ff3d6067..cc1df2f5 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -129,10 +129,30 @@ export const TOOL_PRESETS = { specGeneration: ['Read', 'Glob', 'Grep'] as const, /** Full tool access for feature implementation */ - fullAccess: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch', 'TodoWrite'] as const, + fullAccess: [ + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'Bash', + 'WebSearch', + 'WebFetch', + 'TodoWrite', + ] as const, /** Tools for chat/interactive mode */ - chat: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch', 'TodoWrite'] as const, + chat: [ + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'Bash', + 'WebSearch', + 'WebFetch', + 'TodoWrite', + ] as const, } as const; /** diff --git a/apps/server/src/middleware/validate-paths.ts b/apps/server/src/middleware/validate-paths.ts index 51b8ccb1..1f7f3876 100644 --- a/apps/server/src/middleware/validate-paths.ts +++ b/apps/server/src/middleware/validate-paths.ts @@ -8,12 +8,28 @@ import type { Request, Response, NextFunction } from 'express'; import { validatePath, PathNotAllowedError } from '@automaker/platform'; /** - * Creates a middleware that validates specified path parameters in req.body + * Helper to get parameter value from request (checks body first, then query) + */ +function getParamValue(req: Request, paramName: string): unknown { + // Check body first (for POST/PUT/PATCH requests) + if (req.body && req.body[paramName] !== undefined) { + return req.body[paramName]; + } + // Fall back to query params (for GET requests) + if (req.query && req.query[paramName] !== undefined) { + return req.query[paramName]; + } + return undefined; +} + +/** + * Creates a middleware that validates specified path parameters in req.body or req.query * @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath') * @example * router.post('/create', validatePathParams('projectPath'), handler); * router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler); * router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler); + * router.get('/logs', validatePathParams('worktreePath'), handler); // Works with query params too * * Special syntax: * - 'paramName?' - Optional parameter (only validated if present) @@ -26,8 +42,8 @@ export function validatePathParams(...paramNames: string[]) { // Handle optional parameters (paramName?) if (paramName.endsWith('?')) { const actualName = paramName.slice(0, -1); - const value = req.body[actualName]; - if (value) { + const value = getParamValue(req, actualName); + if (value && typeof value === 'string') { validatePath(value); } continue; @@ -36,18 +52,20 @@ export function validatePathParams(...paramNames: string[]) { // Handle array parameters (paramName[]) if (paramName.endsWith('[]')) { const actualName = paramName.slice(0, -2); - const values = req.body[actualName]; + const values = getParamValue(req, actualName); if (Array.isArray(values) && values.length > 0) { for (const value of values) { - validatePath(value); + if (typeof value === 'string') { + validatePath(value); + } } } continue; } // Handle regular parameters - const value = req.body[paramName]; - if (value) { + const value = getParamValue(req, paramName); + if (value && typeof value === 'string') { validatePath(value); } } diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index ba86bfad..f8a31d81 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -22,6 +22,8 @@ import type { // Only these vars are passed - nothing else from process.env leaks through. const ALLOWED_ENV_VARS = [ 'ANTHROPIC_API_KEY', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', 'PATH', 'HOME', 'SHELL', @@ -99,6 +101,8 @@ export class ClaudeProvider extends BaseProvider { ...(maxThinkingTokens && { maxThinkingTokens }), // Subagents configuration for specialized task delegation ...(options.agents && { agents: options.agents }), + // Pass through outputFormat for structured JSON outputs + ...(options.outputFormat && { outputFormat: options.outputFormat }), }; // Build prompt payload diff --git a/apps/server/src/providers/cli-provider.ts b/apps/server/src/providers/cli-provider.ts index 7e0599f9..8683f841 100644 --- a/apps/server/src/providers/cli-provider.ts +++ b/apps/server/src/providers/cli-provider.ts @@ -26,22 +26,22 @@ * ``` */ -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { BaseProvider } from './base-provider.js'; -import type { ProviderConfig, ExecuteOptions, ProviderMessage } from './types.js'; import { - spawnJSONLProcess, - type SubprocessOptions, - isWslAvailable, - findCliInWsl, createWslCommand, + findCliInWsl, + isWslAvailable, + spawnJSONLProcess, windowsToWslPath, + type SubprocessOptions, type WslCliResult, } from '@automaker/platform'; import { createLogger, isAbortError } from '@automaker/utils'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { BaseProvider } from './base-provider.js'; +import type { ExecuteOptions, ProviderConfig, ProviderMessage } from './types.js'; /** * Spawn strategy for CLI tools on Windows @@ -522,8 +522,13 @@ export abstract class CliProvider extends BaseProvider { throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`); } - const cliArgs = this.buildCliArgs(options); - const subprocessOptions = this.buildSubprocessOptions(options, cliArgs); + // Many CLI-based providers do not support a separate "system" message. + // If a systemPrompt is provided, embed it into the prompt so downstream models + // still receive critical formatting/schema instructions (e.g., JSON-only outputs). + const effectiveOptions = this.embedSystemPromptIntoPrompt(options); + + const cliArgs = this.buildCliArgs(effectiveOptions); + const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs); try { for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) { @@ -555,4 +560,52 @@ export abstract class CliProvider extends BaseProvider { throw error; } } + + /** + * Embed system prompt text into the user prompt for CLI providers. + * + * Most CLI providers we integrate with only accept a single prompt via stdin/args. + * When upstream code supplies `options.systemPrompt`, we prepend it to the prompt + * content and clear `systemPrompt` to avoid any accidental double-injection by + * subclasses. + */ + protected embedSystemPromptIntoPrompt(options: ExecuteOptions): ExecuteOptions { + if (!options.systemPrompt) { + return options; + } + + // Only string system prompts can be reliably embedded for CLI providers. + // Presets are provider-specific (e.g., Claude SDK) and cannot be represented + // universally. If a preset is provided, we only embed its optional `append`. + const systemText = + typeof options.systemPrompt === 'string' + ? options.systemPrompt + : options.systemPrompt.append + ? options.systemPrompt.append + : ''; + + if (!systemText) { + return { ...options, systemPrompt: undefined }; + } + + // Preserve original prompt structure. + if (typeof options.prompt === 'string') { + return { + ...options, + prompt: `${systemText}\n\n---\n\n${options.prompt}`, + systemPrompt: undefined, + }; + } + + if (Array.isArray(options.prompt)) { + return { + ...options, + prompt: [{ type: 'text', text: systemText }, ...options.prompt], + systemPrompt: undefined, + }; + } + + // Should be unreachable due to ExecuteOptions typing, but keep safe. + return { ...options, systemPrompt: undefined }; + } } diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 2e3962a0..e0f38ee9 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -45,6 +45,7 @@ import { getCodexTodoToolName, } from './codex-tool-mapping.js'; import { SettingsService } from '../services/settings-service.js'; +import { createTempEnvOverride } from '../lib/auth-utils.js'; import { checkSandboxCompatibility } from '../lib/sdk-options.js'; import { CODEX_MODELS } from './codex-models.js'; @@ -142,6 +143,7 @@ type CodexExecutionMode = typeof CODEX_EXECUTION_MODE_CLI | typeof CODEX_EXECUTI type CodexExecutionPlan = { mode: CodexExecutionMode; cliPath: string | null; + openAiApiKey?: string | null; }; const ALLOWED_ENV_VARS = [ @@ -166,6 +168,22 @@ function buildEnv(): Record { return env; } +async function resolveOpenAiApiKey(): Promise { + const envKey = process.env[OPENAI_API_KEY_ENV]; + if (envKey) { + return envKey; + } + + try { + const settingsService = new SettingsService(getCodexSettingsDir()); + const credentials = await settingsService.getCredentials(); + const storedKey = credentials.apiKeys.openai?.trim(); + return storedKey ? storedKey : null; + } catch { + return null; + } +} + function hasMcpServersConfigured(options: ExecuteOptions): boolean { return Boolean(options.mcpServers && Object.keys(options.mcpServers).length > 0); } @@ -181,18 +199,21 @@ function isSdkEligible(options: ExecuteOptions): boolean { async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise { const cliPath = await findCodexCliPath(); const authIndicators = await getCodexAuthIndicators(); - const hasApiKey = Boolean(process.env[OPENAI_API_KEY_ENV]); + const openAiApiKey = await resolveOpenAiApiKey(); + const hasApiKey = Boolean(openAiApiKey); const cliAuthenticated = authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey; const sdkEligible = isSdkEligible(options); const cliAvailable = Boolean(cliPath); + if (hasApiKey) { + return { + mode: CODEX_EXECUTION_MODE_SDK, + cliPath, + openAiApiKey, + }; + } + if (sdkEligible) { - if (hasApiKey) { - return { - mode: CODEX_EXECUTION_MODE_SDK, - cliPath, - }; - } if (!cliAvailable) { throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED); } @@ -209,6 +230,7 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise { const cliPath = await findCodexCliPath(); - const hasApiKey = !!process.env[OPENAI_API_KEY_ENV]; + const hasApiKey = Boolean(await resolveOpenAiApiKey()); const authIndicators = await getCodexAuthIndicators(); const installed = !!cliPath; @@ -1013,7 +1047,7 @@ export class CodexProvider extends BaseProvider { */ async checkAuth(): Promise { const cliPath = await findCodexCliPath(); - const hasApiKey = !!process.env[OPENAI_API_KEY_ENV]; + const hasApiKey = Boolean(await resolveOpenAiApiKey()); const authIndicators = await getCodexAuthIndicators(); // Check for API key in environment diff --git a/apps/server/src/providers/index.ts b/apps/server/src/providers/index.ts index a1da283b..b53695f6 100644 --- a/apps/server/src/providers/index.ts +++ b/apps/server/src/providers/index.ts @@ -30,3 +30,11 @@ export { OpencodeProvider } from './opencode-provider.js'; // Provider factory export { ProviderFactory } from './provider-factory.js'; + +// Simple query service - unified interface for basic AI queries +export { simpleQuery, streamingQuery } from './simple-query-service.js'; +export type { + SimpleQueryOptions, + SimpleQueryResult, + StreamingQueryOptions, +} from './simple-query-service.js'; diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index a5b3bae2..6babb978 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -730,7 +730,7 @@ export class OpencodeProvider extends CliProvider { if (this.detectedStrategy === 'npx') { // NPX strategy: execute npx with opencode-ai package - command = 'npx'; + command = process.platform === 'win32' ? 'npx.cmd' : 'npx'; args = ['opencode-ai@latest', 'models']; opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); } else if (this.useWsl && this.wslCliPath) { @@ -751,6 +751,8 @@ export class OpencodeProvider extends CliProvider { encoding: 'utf-8', timeout: 30000, windowsHide: true, + // Use shell on Windows for .cmd files + shell: process.platform === 'win32' && command.endsWith('.cmd'), }); opencodeLogger.debug( @@ -963,7 +965,7 @@ export class OpencodeProvider extends CliProvider { if (this.detectedStrategy === 'npx') { // NPX strategy - command = 'npx'; + command = process.platform === 'win32' ? 'npx.cmd' : 'npx'; args = ['opencode-ai@latest', 'auth', 'list']; opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); } else if (this.useWsl && this.wslCliPath) { @@ -984,6 +986,8 @@ export class OpencodeProvider extends CliProvider { encoding: 'utf-8', timeout: 15000, windowsHide: true, + // Use shell on Windows for .cmd files + shell: process.platform === 'win32' && command.endsWith('.cmd'), }); opencodeLogger.debug( diff --git a/apps/server/src/providers/simple-query-service.ts b/apps/server/src/providers/simple-query-service.ts new file mode 100644 index 00000000..5882b96f --- /dev/null +++ b/apps/server/src/providers/simple-query-service.ts @@ -0,0 +1,254 @@ +/** + * Simple Query Service - Simplified interface for basic AI queries + * + * Use this for routes that need simple text responses without + * complex event handling. This service abstracts away the provider + * selection and streaming details, providing a clean interface + * for common query patterns. + * + * Benefits: + * - No direct SDK imports needed in route files + * - Consistent provider routing based on model + * - Automatic text extraction from streaming responses + * - Structured output support for JSON schema responses + * - Eliminates duplicate extractTextFromStream() functions + */ + +import { ProviderFactory } from './provider-factory.js'; +import type { + ProviderMessage, + ContentBlock, + ThinkingLevel, + ReasoningEffort, +} from '@automaker/types'; +import { stripProviderPrefix } from '@automaker/types'; + +/** + * Options for simple query execution + */ +export interface SimpleQueryOptions { + /** The prompt to send to the AI (can be text or multi-part content) */ + prompt: string | Array<{ type: string; text?: string; source?: object }>; + /** Model to use (with or without provider prefix) */ + model?: string; + /** Working directory for the query */ + cwd: string; + /** System prompt (combined with user prompt for some providers) */ + systemPrompt?: string; + /** Maximum turns for agentic operations (default: 1) */ + maxTurns?: number; + /** Tools to allow (default: [] for simple queries) */ + allowedTools?: string[]; + /** Abort controller for cancellation */ + abortController?: AbortController; + /** Structured output format for JSON responses */ + outputFormat?: { + type: 'json_schema'; + schema: Record; + }; + /** Thinking level for Claude models */ + thinkingLevel?: ThinkingLevel; + /** Reasoning effort for Codex/OpenAI models */ + reasoningEffort?: ReasoningEffort; + /** If true, runs in read-only mode (no file writes) */ + readOnly?: boolean; + /** Setting sources for CLAUDE.md loading */ + settingSources?: Array<'user' | 'project' | 'local'>; +} + +/** + * Result from a simple query + */ +export interface SimpleQueryResult { + /** The accumulated text response */ + text: string; + /** Structured output if outputFormat was specified and provider supports it */ + structured_output?: Record; +} + +/** + * Options for streaming query execution + */ +export interface StreamingQueryOptions extends SimpleQueryOptions { + /** Callback for each text chunk received */ + onText?: (text: string) => void; + /** Callback for tool use events */ + onToolUse?: (tool: string, input: unknown) => void; + /** Callback for thinking blocks (if available) */ + onThinking?: (thinking: string) => void; +} + +/** + * Default model to use when none specified + */ +const DEFAULT_MODEL = 'claude-sonnet-4-20250514'; + +/** + * Execute a simple query and return the text result + * + * Use this for simple, non-streaming queries where you just need + * the final text response. For more complex use cases with progress + * callbacks, use streamingQuery() instead. + * + * @example + * ```typescript + * const result = await simpleQuery({ + * prompt: 'Generate a title for: user authentication', + * cwd: process.cwd(), + * systemPrompt: 'You are a title generator...', + * maxTurns: 1, + * allowedTools: [], + * }); + * console.log(result.text); // "Add user authentication" + * ``` + */ +export async function simpleQuery(options: SimpleQueryOptions): Promise { + const model = options.model || DEFAULT_MODEL; + const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); + + let responseText = ''; + let structuredOutput: Record | undefined; + + // Build provider options + const providerOptions = { + prompt: options.prompt, + model: bareModel, + originalModel: model, + cwd: options.cwd, + systemPrompt: options.systemPrompt, + maxTurns: options.maxTurns ?? 1, + allowedTools: options.allowedTools ?? [], + abortController: options.abortController, + outputFormat: options.outputFormat, + thinkingLevel: options.thinkingLevel, + reasoningEffort: options.reasoningEffort, + readOnly: options.readOnly, + settingSources: options.settingSources, + }; + + for await (const msg of provider.executeQuery(providerOptions)) { + // Handle error messages + if (msg.type === 'error') { + const errorMessage = msg.error || 'Provider returned an error'; + throw new Error(errorMessage); + } + + // Extract text from assistant messages + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } + + // Handle result messages + if (msg.type === 'result') { + if (msg.subtype === 'success') { + // Use result text if longer than accumulated text + if (msg.result && msg.result.length > responseText.length) { + responseText = msg.result; + } + // Capture structured output if present + if (msg.structured_output) { + structuredOutput = msg.structured_output; + } + } else if (msg.subtype === 'error_max_turns') { + // Max turns reached - return what we have + break; + } else if (msg.subtype === 'error_max_structured_output_retries') { + throw new Error('Could not produce valid structured output after retries'); + } + } + } + + return { text: responseText, structured_output: structuredOutput }; +} + +/** + * Execute a streaming query with event callbacks + * + * Use this for queries where you need real-time progress updates, + * such as when displaying streaming output to a user. + * + * @example + * ```typescript + * const result = await streamingQuery({ + * prompt: 'Analyze this project and suggest improvements', + * cwd: '/path/to/project', + * maxTurns: 250, + * allowedTools: ['Read', 'Glob', 'Grep'], + * onText: (text) => emitProgress(text), + * onToolUse: (tool, input) => emitToolUse(tool, input), + * }); + * ``` + */ +export async function streamingQuery(options: StreamingQueryOptions): Promise { + const model = options.model || DEFAULT_MODEL; + const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); + + let responseText = ''; + let structuredOutput: Record | undefined; + + // Build provider options + const providerOptions = { + prompt: options.prompt, + model: bareModel, + originalModel: model, + cwd: options.cwd, + systemPrompt: options.systemPrompt, + maxTurns: options.maxTurns ?? 250, + allowedTools: options.allowedTools ?? ['Read', 'Glob', 'Grep'], + abortController: options.abortController, + outputFormat: options.outputFormat, + thinkingLevel: options.thinkingLevel, + reasoningEffort: options.reasoningEffort, + readOnly: options.readOnly, + settingSources: options.settingSources, + }; + + for await (const msg of provider.executeQuery(providerOptions)) { + // Handle error messages + if (msg.type === 'error') { + const errorMessage = msg.error || 'Provider returned an error'; + throw new Error(errorMessage); + } + + // Extract content from assistant messages + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + options.onText?.(block.text); + } else if (block.type === 'tool_use' && block.name) { + options.onToolUse?.(block.name, block.input); + } else if (block.type === 'thinking' && block.thinking) { + options.onThinking?.(block.thinking); + } + } + } + + // Handle result messages + if (msg.type === 'result') { + if (msg.subtype === 'success') { + // Use result text if longer than accumulated text + if (msg.result && msg.result.length > responseText.length) { + responseText = msg.result; + } + // Capture structured output if present + if (msg.structured_output) { + structuredOutput = msg.structured_output; + } + } else if (msg.subtype === 'error_max_turns') { + // Max turns reached - return what we have + break; + } else if (msg.subtype === 'error_max_structured_output_retries') { + throw new Error('Could not produce valid structured output after retries'); + } + } + } + + return { text: responseText, structured_output: structuredOutput }; +} diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index d89e5eff..55bf792b 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -5,15 +5,12 @@ * (defaults to Sonnet for balanced speed and quality). */ -import { query } from '@anthropic-ai/claude-agent-sdk'; import * as secureFs from '../../lib/secure-fs.js'; import type { EventEmitter } from '../../lib/events.js'; import { createLogger } from '@automaker/utils'; -import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; -import { createFeatureGenerationOptions } from '../../lib/sdk-options.js'; -import { ProviderFactory } from '../../providers/provider-factory.js'; -import { logAuthStatus } from './common.js'; +import { streamingQuery } from '../../providers/simple-query-service.js'; import { parseAndCreateFeatures } from './parse-and-create-features.js'; import { getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; @@ -115,121 +112,30 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge logger.info('Using model:', model); - let responseText = ''; - let messageCount = 0; + // Use streamingQuery with event callbacks + const result = await streamingQuery({ + prompt, + model, + cwd: projectPath, + maxTurns: 250, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + thinkingLevel, + readOnly: true, // Feature generation only reads code, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + onText: (text) => { + logger.debug(`Feature text block received (${text.length} chars)`); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: text, + projectPath: projectPath, + }); + }, + }); - // Route to appropriate provider based on model type - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info('[FeatureGeneration] Using Cursor provider'); + const responseText = result.text; - 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 - const cursorPrompt = `${prompt} - -CRITICAL INSTRUCTIONS: -1. DO NOT write any files. Return the JSON in your response only. -2. Respond with ONLY a JSON object - no explanations, no markdown, just raw JSON. -3. Your entire response should be valid JSON starting with { and ending with }. No text before or after.`; - - for await (const msg of provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd: projectPath, - maxTurns: 250, - allowedTools: ['Read', 'Glob', 'Grep'], - abortController, - readOnly: true, // Feature generation only reads code, doesn't write - })) { - messageCount++; - - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - logger.debug(`Feature text block received (${block.text.length} chars)`); - events.emit('spec-regeneration:event', { - type: 'spec_regeneration_progress', - content: block.text, - projectPath: projectPath, - }); - } - } - } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { - // Use result if it's a final accumulated message - if (msg.result.length > responseText.length) { - responseText = msg.result; - } - } - } - } else { - // Use Claude SDK for Claude models - logger.info('[FeatureGeneration] Using Claude SDK'); - - const options = createFeatureGenerationOptions({ - cwd: projectPath, - abortController, - autoLoadClaudeMd, - model, - thinkingLevel, // Pass thinking level for extended thinking - }); - - logger.debug('SDK Options:', JSON.stringify(options, null, 2)); - logger.info('Calling Claude Agent SDK query() for features...'); - - logAuthStatus('Right before SDK query() for features'); - - let stream; - try { - stream = query({ prompt, options }); - logger.debug('query() returned stream successfully'); - } catch (queryError) { - logger.error('❌ query() threw an exception:'); - logger.error('Error:', queryError); - throw queryError; - } - - logger.debug('Starting to iterate over feature stream...'); - - try { - for await (const msg of stream) { - messageCount++; - logger.debug( - `Feature stream message #${messageCount}:`, - JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2) - ); - - if (msg.type === 'assistant' && msg.message.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - responseText += block.text; - logger.debug(`Feature text block received (${block.text.length} chars)`); - events.emit('spec-regeneration:event', { - type: 'spec_regeneration_progress', - content: block.text, - projectPath: projectPath, - }); - } - } - } else if (msg.type === 'result' && (msg as any).subtype === 'success') { - logger.debug('Received success result for features'); - responseText = (msg as any).result || responseText; - } else if ((msg as { type: string }).type === 'error') { - logger.error('❌ Received error message from feature stream:'); - logger.error('Error message:', JSON.stringify(msg, null, 2)); - } - } - } catch (streamError) { - logger.error('❌ Error while iterating feature stream:'); - logger.error('Stream error:', streamError); - throw streamError; - } - } - - logger.info(`Feature stream complete. Total messages: ${messageCount}`); + logger.info(`Feature stream complete.`); logger.info(`Feature response length: ${responseText.length} chars`); logger.info('========== FULL RESPONSE TEXT =========='); logger.info(responseText); diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index fe293da2..4af42b48 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -5,8 +5,6 @@ * (defaults to Opus for high-quality specification generation). */ -import { query } from '@anthropic-ai/claude-agent-sdk'; -import path from 'path'; import * as secureFs from '../../lib/secure-fs.js'; import type { EventEmitter } from '../../lib/events.js'; import { @@ -16,12 +14,10 @@ import { type SpecOutput, } from '../../lib/app-spec-format.js'; import { createLogger } from '@automaker/utils'; -import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; -import { createSpecGenerationOptions } from '../../lib/sdk-options.js'; import { extractJson } from '../../lib/json-extractor.js'; -import { ProviderFactory } from '../../providers/provider-factory.js'; -import { logAuthStatus } from './common.js'; +import { streamingQuery } from '../../providers/simple-query-service.js'; import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; @@ -109,21 +105,15 @@ ${getStructuredSpecPromptInstruction()}`; logger.info('Using model:', model); let responseText = ''; - let messageCount = 0; let structuredOutput: SpecOutput | null = null; - // Route to appropriate provider based on model type - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info('[SpecGeneration] Using Cursor provider'); + // Determine if we should use structured output (Claude supports it, Cursor doesn't) + const useStructuredOutput = !isCursorModel(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 - // to return JSON in the response (not write to a file) - const cursorPrompt = `${prompt} + // Build the final prompt - for Cursor, include JSON schema instructions + let finalPrompt = prompt; + if (!useStructuredOutput) { + finalPrompt = `${prompt} CRITICAL INSTRUCTIONS: 1. DO NOT write any files. DO NOT create any files like "project_specification.json". @@ -133,153 +123,57 @@ CRITICAL INSTRUCTIONS: ${JSON.stringify(specOutputSchema, null, 2)} Your entire response should be valid JSON starting with { and ending with }. No text before or after.`; - - for await (const msg of provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd: projectPath, - maxTurns: 250, - allowedTools: ['Read', 'Glob', 'Grep'], - abortController, - readOnly: true, // Spec generation only reads code, we write the spec ourselves - })) { - messageCount++; - - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - logger.info( - `Text block received (${block.text.length} chars), total now: ${responseText.length} chars` - ); - events.emit('spec-regeneration:event', { - type: 'spec_regeneration_progress', - content: block.text, - projectPath: projectPath, - }); - } else if (block.type === 'tool_use') { - logger.info('Tool use:', block.name); - events.emit('spec-regeneration:event', { - type: 'spec_tool', - tool: block.name, - input: block.input, - }); - } - } - } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { - // Use result if it's a final accumulated message - if (msg.result.length > responseText.length) { - responseText = msg.result; - } - } - } - - // Parse JSON from the response text using shared utility - if (responseText) { - structuredOutput = extractJson(responseText, { logger }); - } - } else { - // Use Claude SDK for Claude models - logger.info('[SpecGeneration] Using Claude SDK'); - - const options = createSpecGenerationOptions({ - cwd: projectPath, - abortController, - autoLoadClaudeMd, - model, - thinkingLevel, // Pass thinking level for extended thinking - outputFormat: { - type: 'json_schema', - schema: specOutputSchema, - }, - }); - - logger.debug('SDK Options:', JSON.stringify(options, null, 2)); - logger.info('Calling Claude Agent SDK query()...'); - - // Log auth status right before the SDK call - logAuthStatus('Right before SDK query()'); - - let stream; - try { - stream = query({ prompt, options }); - logger.debug('query() returned stream successfully'); - } catch (queryError) { - logger.error('❌ query() threw an exception:'); - logger.error('Error:', queryError); - throw queryError; - } - - logger.info('Starting to iterate over stream...'); - - try { - for await (const msg of stream) { - messageCount++; - logger.info( - `Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}` - ); - - if (msg.type === 'assistant') { - const msgAny = msg as any; - if (msgAny.message?.content) { - for (const block of msgAny.message.content) { - if (block.type === 'text') { - responseText += block.text; - logger.info( - `Text block received (${block.text.length} chars), total now: ${responseText.length} chars` - ); - events.emit('spec-regeneration:event', { - type: 'spec_regeneration_progress', - content: block.text, - projectPath: projectPath, - }); - } else if (block.type === 'tool_use') { - logger.info('Tool use:', block.name); - events.emit('spec-regeneration:event', { - type: 'spec_tool', - tool: block.name, - input: block.input, - }); - } - } - } - } else if (msg.type === 'result' && (msg as any).subtype === 'success') { - logger.info('Received success result'); - // Check for structured output - this is the reliable way to get spec data - const resultMsg = msg as any; - if (resultMsg.structured_output) { - structuredOutput = resultMsg.structured_output as SpecOutput; - logger.info('✅ Received structured output'); - logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2)); - } else { - logger.warn('⚠️ No structured output in result, will fall back to text parsing'); - } - } else if (msg.type === 'result') { - // Handle error result types - const subtype = (msg as any).subtype; - logger.info(`Result message: subtype=${subtype}`); - if (subtype === 'error_max_turns') { - logger.error('❌ Hit max turns limit!'); - } else if (subtype === 'error_max_structured_output_retries') { - logger.error('❌ Failed to produce valid structured output after retries'); - throw new Error('Could not produce valid spec output'); - } - } else if ((msg as { type: string }).type === 'error') { - logger.error('❌ Received error message from stream:'); - logger.error('Error message:', JSON.stringify(msg, null, 2)); - } else if (msg.type === 'user') { - // Log user messages (tool results) - logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`); - } - } - } catch (streamError) { - logger.error('❌ Error while iterating stream:'); - logger.error('Stream error:', streamError); - throw streamError; - } } - logger.info(`Stream iteration complete. Total messages: ${messageCount}`); + // Use streamingQuery with event callbacks + const result = await streamingQuery({ + prompt: finalPrompt, + model, + cwd: projectPath, + maxTurns: 250, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + thinkingLevel, + readOnly: true, // Spec generation only reads code, we write the spec ourselves + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + outputFormat: useStructuredOutput + ? { + type: 'json_schema', + schema: specOutputSchema, + } + : undefined, + onText: (text) => { + responseText += text; + logger.info( + `Text block received (${text.length} chars), total now: ${responseText.length} chars` + ); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: text, + projectPath: projectPath, + }); + }, + onToolUse: (tool, input) => { + logger.info('Tool use:', tool); + events.emit('spec-regeneration:event', { + type: 'spec_tool', + tool, + input, + }); + }, + }); + + // Get structured output if available + if (result.structured_output) { + structuredOutput = result.structured_output as unknown as SpecOutput; + logger.info('✅ Received structured output'); + logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2)); + } else if (!useStructuredOutput && responseText) { + // For non-Claude providers, parse JSON from response text + structuredOutput = extractJson(responseText, { logger }); + } + + logger.info(`Stream iteration complete.`); logger.info(`Response text length: ${responseText.length} chars`); // Determine XML content to save diff --git a/apps/server/src/routes/claude/index.ts b/apps/server/src/routes/claude/index.ts index 20816bbc..ec35ca1b 100644 --- a/apps/server/src/routes/claude/index.ts +++ b/apps/server/src/routes/claude/index.ts @@ -34,6 +34,13 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router { error: 'Authentication required', message: "Please run 'claude login' to authenticate", }); + } else if (message.includes('TRUST_PROMPT_PENDING')) { + // Trust prompt appeared but couldn't be auto-approved + res.status(200).json({ + error: 'Trust prompt pending', + message: + 'Claude CLI needs folder permission. Please run "claude" in your terminal and approve access.', + }); } else if (message.includes('timed out')) { res.status(200).json({ error: 'Command timed out', diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts index 1e312ff3..ca8c2b4a 100644 --- a/apps/server/src/routes/context/routes/describe-file.ts +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -11,13 +11,11 @@ */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; -import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS } from '@automaker/types'; import { PathNotAllowedError } from '@automaker/platform'; import { resolvePhaseModel } from '@automaker/model-resolver'; -import { createCustomOptions } from '../../../lib/sdk-options.js'; -import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { simpleQuery } from '../../../providers/simple-query-service.js'; import * as secureFs from '../../../lib/secure-fs.js'; import * as path from 'path'; import type { SettingsService } from '../../../services/settings-service.js'; @@ -49,31 +47,6 @@ interface DescribeFileErrorResponse { error: string; } -/** - * Extract text content from Claude SDK response messages - */ -async function extractTextFromStream( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - stream: AsyncIterable -): Promise { - let responseText = ''; - - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message?.content) { - const blocks = msg.message.content as Array<{ type: string; text?: string }>; - for (const block of blocks) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } else if (msg.type === 'result' && msg.subtype === 'success') { - responseText = msg.result || responseText; - } - } - - return responseText; -} - /** * Create the describe-file request handler * @@ -159,16 +132,14 @@ export function createDescribeFileHandler( // Build prompt with file content passed as structured data // The file content is included directly, not via tool invocation - const instructionText = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project"). + const prompt = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project"). Respond with ONLY the description text, no additional formatting, preamble, or explanation. -File: ${fileName}${truncated ? ' (truncated)' : ''}`; +File: ${fileName}${truncated ? ' (truncated)' : ''} - const promptContent = [ - { type: 'text' as const, text: instructionText }, - { type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` }, - ]; +--- FILE CONTENT --- +${contentToAnalyze}`; // Use the file's directory as the working directory const cwd = path.dirname(resolvedPath); @@ -190,67 +161,19 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`; logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`); - let description: string; + // Use simpleQuery - provider abstraction handles routing to correct provider + const result = await simpleQuery({ + prompt, + model, + cwd, + maxTurns: 1, + allowedTools: [], + thinkingLevel, + readOnly: true, // File description only reads, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + }); - // Route to appropriate provider based on model type - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info(`Using Cursor provider for model: ${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) - const cursorPrompt = `${instructionText}\n\n--- FILE CONTENT ---\n${contentToAnalyze}`; - - let responseText = ''; - for await (const msg of provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd, - maxTurns: 1, - allowedTools: [], - readOnly: true, // File description only reads, doesn't write - })) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } - } - description = responseText; - } else { - // Use Claude SDK for Claude models - logger.info(`Using Claude SDK for model: ${model}`); - - // Use centralized SDK options with proper cwd validation - // No tools needed since we're passing file content directly - const sdkOptions = createCustomOptions({ - cwd, - model, - maxTurns: 1, - allowedTools: [], - autoLoadClaudeMd, - thinkingLevel, // Pass thinking level for extended thinking - }); - - const promptGenerator = (async function* () { - yield { - type: 'user' as const, - session_id: '', - message: { role: 'user' as const, content: promptContent }, - parent_tool_use_id: null, - }; - })(); - - const stream = query({ prompt: promptGenerator, options: sdkOptions }); - - // Extract the description from the response - description = await extractTextFromStream(stream); - } + const description = result.text; if (!description || description.trim().length === 0) { logger.warn('Received empty response from Claude'); diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index 0ff8e143..e5e50963 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -12,12 +12,10 @@ */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger, readImageAsBase64 } from '@automaker/utils'; -import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; -import { createCustomOptions } from '../../../lib/sdk-options.js'; -import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { simpleQuery } from '../../../providers/simple-query-service.js'; import * as secureFs from '../../../lib/secure-fs.js'; import * as path from 'path'; import type { SettingsService } from '../../../services/settings-service.js'; @@ -178,57 +176,10 @@ function mapDescribeImageError(rawMessage: string | undefined): { return baseResponse; } -/** - * Extract text content from Claude SDK response messages and log high-signal stream events. - */ -async function extractTextFromStream( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - stream: AsyncIterable, - requestId: string -): Promise { - let responseText = ''; - let messageCount = 0; - - logger.info(`[${requestId}] [Stream] Begin reading SDK stream...`); - - for await (const msg of stream) { - messageCount++; - const msgType = msg?.type; - const msgSubtype = msg?.subtype; - - // Keep this concise but informative. Full error object is logged in catch blocks. - logger.info( - `[${requestId}] [Stream] #${messageCount} type=${String(msgType)} subtype=${String(msgSubtype ?? '')}` - ); - - if (msgType === 'assistant' && msg.message?.content) { - const blocks = msg.message.content as Array<{ type: string; text?: string }>; - logger.info(`[${requestId}] [Stream] assistant blocks=${blocks.length}`); - for (const block of blocks) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } - - if (msgType === 'result' && msgSubtype === 'success') { - if (typeof msg.result === 'string' && msg.result.length > 0) { - responseText = msg.result; - } - } - } - - logger.info( - `[${requestId}] [Stream] End of stream. messages=${messageCount} textLength=${responseText.length}` - ); - - return responseText; -} - /** * Create the describe-image request handler * - * Uses Claude SDK query with multi-part content blocks to include the image (base64), + * Uses the provider abstraction with multi-part content blocks to include the image (base64), * matching the agent runner behavior. * * @param settingsService - Optional settings service for loading autoLoadClaudeMd setting @@ -309,27 +260,6 @@ export function createDescribeImageHandler( `[${requestId}] image meta filename=${imageData.filename} mime=${imageData.mimeType} base64Len=${base64Length} estBytes=${estimatedBytes}` ); - // Build multi-part prompt with image block (no Read tool required) - const instructionText = - `Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` + - `Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` + - `"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` + - `Respond with ONLY the description text, no additional formatting, preamble, or explanation.`; - - const promptContent = [ - { type: 'text' as const, text: instructionText }, - { - type: 'image' as const, - source: { - type: 'base64' as const, - media_type: imageData.mimeType, - data: imageData.base64, - }, - }, - ]; - - logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`); - const cwd = path.dirname(actualPath); logger.info(`[${requestId}] Using cwd=${cwd}`); @@ -348,85 +278,59 @@ export function createDescribeImageHandler( logger.info(`[${requestId}] Using model: ${model}`); - let description: string; + // Build the instruction text + const instructionText = + `Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` + + `Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` + + `"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` + + `Respond with ONLY the description text, no additional formatting, preamble, or explanation.`; + + // Build prompt based on provider capability + // Some providers (like Cursor) may not support image content blocks + let prompt: string | Array<{ type: string; text?: string; source?: object }>; - // Route to appropriate provider based on model type if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - // Note: Cursor may have limited support for image content blocks - logger.info(`[${requestId}] Using Cursor provider for model: ${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 - // Note: Cursor CLI may not support base64 image blocks directly, - // so we include the image path as context - const cursorPrompt = `${instructionText}\n\nImage file: ${actualPath}\nMIME type: ${imageData.mimeType}`; - - let responseText = ''; - const queryStart = Date.now(); - for await (const msg of provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd, - maxTurns: 1, - allowedTools: ['Read'], // Allow Read tool so Cursor can read the image if needed - readOnly: true, // Image description only reads, doesn't write - })) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } - } - logger.info(`[${requestId}] Cursor query completed in ${Date.now() - queryStart}ms`); - description = responseText; + // Cursor may not support base64 image blocks directly + // Use text prompt with image path reference + logger.info(`[${requestId}] Using text prompt for Cursor model`); + prompt = `${instructionText}\n\nImage file: ${actualPath}\nMIME type: ${imageData.mimeType}`; } else { - // Use Claude SDK for Claude models (supports image content blocks) - logger.info(`[${requestId}] Using Claude SDK for model: ${model}`); - - // Use the same centralized option builder used across the server (validates cwd) - const sdkOptions = createCustomOptions({ - cwd, - model, - maxTurns: 1, - allowedTools: [], - autoLoadClaudeMd, - thinkingLevel, // Pass thinking level for extended thinking - }); - - logger.info( - `[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify( - sdkOptions.allowedTools - )}` - ); - - const promptGenerator = (async function* () { - yield { - type: 'user' as const, - session_id: '', - message: { role: 'user' as const, content: promptContent }, - parent_tool_use_id: null, - }; - })(); - - logger.info(`[${requestId}] Calling query()...`); - const queryStart = Date.now(); - const stream = query({ prompt: promptGenerator, options: sdkOptions }); - logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`); - - // Extract the description from the response - const extractStart = Date.now(); - description = await extractTextFromStream(stream, requestId); - logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`); + // Claude and other vision-capable models support multi-part prompts with images + logger.info(`[${requestId}] Using multi-part prompt with image block`); + prompt = [ + { type: 'text', text: instructionText }, + { + type: 'image', + source: { + type: 'base64', + media_type: imageData.mimeType, + data: imageData.base64, + }, + }, + ]; } + logger.info(`[${requestId}] Calling simpleQuery...`); + const queryStart = Date.now(); + + // Use simpleQuery - provider abstraction handles routing + const result = await simpleQuery({ + prompt, + model, + cwd, + maxTurns: 1, + allowedTools: isCursorModel(model) ? ['Read'] : [], // Allow Read for Cursor to read image if needed + thinkingLevel, + readOnly: true, // Image description only reads, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + }); + + logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`); + + const description = result.text; + if (!description || description.trim().length === 0) { - logger.warn(`[${requestId}] Received empty response from Claude`); + logger.warn(`[${requestId}] Received empty response from AI`); const response: DescribeImageErrorResponse = { success: false, error: 'Failed to generate description - empty response', diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 73043284..5861b418 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -1,23 +1,16 @@ /** * POST /enhance-prompt endpoint - Enhance user input text * - * Uses Claude AI or Cursor to enhance text based on the specified enhancement mode. - * Supports modes: improve, technical, simplify, acceptance + * Uses the provider abstraction to enhance text based on the specified + * enhancement mode. Works with any configured provider (Claude, Cursor, etc.). + * Supports modes: improve, technical, simplify, acceptance, ux-reviewer */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; import { resolveModelString } from '@automaker/model-resolver'; -import { - CLAUDE_MODEL_MAP, - isCursorModel, - isOpencodeModel, - stripProviderPrefix, - ThinkingLevel, - getThinkingTokenBudget, -} from '@automaker/types'; -import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types'; +import { simpleQuery } from '../../../providers/simple-query-service.js'; import type { SettingsService } from '../../../services/settings-service.js'; import { getPromptCustomization } from '../../../lib/settings-helpers.js'; import { @@ -38,7 +31,7 @@ interface EnhanceRequestBody { enhancementMode: string; /** Optional model override */ model?: string; - /** Optional thinking level for Claude models (ignored for Cursor models) */ + /** Optional thinking level for Claude models */ thinkingLevel?: ThinkingLevel; } @@ -58,80 +51,6 @@ interface EnhanceErrorResponse { error: string; } -/** - * Extract text content from Claude SDK response messages - * - * @param stream - The async iterable from the query function - * @returns The extracted text content - */ -async function extractTextFromStream( - stream: AsyncIterable<{ - type: string; - subtype?: string; - result?: string; - message?: { - content?: Array<{ type: string; text?: string }>; - }; - }> -): Promise { - 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; - } - } - - return responseText; -} - -/** - * Execute enhancement using a provider (Cursor, OpenCode, etc.) - * - * @param prompt - The enhancement prompt - * @param model - The model to use - * @returns The enhanced text - */ -async function executeWithProvider(prompt: string, model: string): Promise { - const provider = ProviderFactory.getProviderForModel(model); - // Strip provider prefix - providers expect bare model IDs - const bareModel = stripProviderPrefix(model); - - let responseText = ''; - - for await (const msg of provider.executeQuery({ - prompt, - model: bareModel, - cwd: process.cwd(), // Enhancement doesn't need a specific working directory - readOnly: true, // Prompt enhancement only generates text, doesn't write files - })) { - 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) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { - // Use result if it's a final accumulated message - if (msg.result.length > responseText.length) { - responseText = msg.result; - } - } - } - - return responseText; -} - /** * Create the enhance request handler * @@ -200,7 +119,6 @@ export function createEnhanceHandler( logger.debug(`Using ${validMode} system prompt (length: ${systemPrompt.length} chars)`); // Build the user prompt with few-shot examples - // This helps the model understand this is text transformation, not a coding task const userPrompt = buildUserPrompt(validMode, trimmedText, true); // Resolve the model - use the passed model, default to sonnet for quality @@ -208,47 +126,20 @@ export function createEnhanceHandler( logger.debug(`Using model: ${resolvedModel}`); - let enhancedText: string; + // Use simpleQuery - provider abstraction handles routing to correct provider + // The system prompt is combined with user prompt since some providers + // don't have a separate system prompt concept + const result = await simpleQuery({ + prompt: `${systemPrompt}\n\n${userPrompt}`, + model: resolvedModel, + cwd: process.cwd(), // Enhancement doesn't need a specific working directory + maxTurns: 1, + allowedTools: [], + thinkingLevel, + readOnly: true, // Prompt enhancement only generates text, doesn't write files + }); - // Route to appropriate provider based on model - if (isCursorModel(resolvedModel)) { - // Use Cursor provider for Cursor models - logger.info(`Using Cursor provider for model: ${resolvedModel}`); - - // Cursor doesn't have a separate system prompt concept, so combine them - const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`; - 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 { - // Use Claude SDK for Claude models - logger.info(`Using Claude provider for model: ${resolvedModel}`); - - // Convert thinkingLevel to maxThinkingTokens for SDK - const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel); - const queryOptions: Parameters[0]['options'] = { - model: resolvedModel, - systemPrompt, - maxTurns: 1, - allowedTools: [], - permissionMode: 'acceptEdits', - }; - if (maxThinkingTokens) { - queryOptions.maxThinkingTokens = maxThinkingTokens; - } - - const stream = query({ - prompt: userPrompt, - options: queryOptions, - }); - - enhancedText = await extractTextFromStream(stream); - } + const enhancedText = result.text; if (!enhancedText || enhancedText.trim().length === 0) { logger.warn('Received empty response from AI'); diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index 2602de03..a838e5aa 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -1,13 +1,14 @@ /** * POST /features/generate-title endpoint - Generate a concise title from description * - * Uses Claude Haiku to generate a short, descriptive title from feature description. + * Uses the provider abstraction to generate a short, descriptive title + * from a feature description. Works with any configured provider (Claude, Cursor, etc.). */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; +import { simpleQuery } from '../../../providers/simple-query-service.js'; const logger = createLogger('GenerateTitle'); @@ -34,33 +35,6 @@ Rules: - No quotes, periods, or extra formatting - Capture the essence of the feature in a scannable way`; -async function extractTextFromStream( - stream: AsyncIterable<{ - type: string; - subtype?: string; - result?: string; - message?: { - content?: Array<{ type: string; text?: string }>; - }; - }> -): Promise { - 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; - } - } - - return responseText; -} - export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { @@ -89,21 +63,19 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; - const stream = query({ - prompt: userPrompt, - options: { - model: CLAUDE_MODEL_MAP.haiku, - systemPrompt: SYSTEM_PROMPT, - maxTurns: 1, - allowedTools: [], - permissionMode: 'default', - }, + // Use simpleQuery - provider abstraction handles all the streaming/extraction + const result = await simpleQuery({ + prompt: `${SYSTEM_PROMPT}\n\n${userPrompt}`, + model: CLAUDE_MODEL_MAP.haiku, + cwd: process.cwd(), + maxTurns: 1, + allowedTools: [], }); - const title = await extractTextFromStream(stream); + const title = result.text; if (!title || title.trim().length === 0) { - logger.warn('Received empty response from Claude'); + logger.warn('Received empty response from AI'); const response: GenerateTitleErrorResponse = { success: false, error: 'Failed to generate title - empty response', diff --git a/apps/server/src/routes/github/routes/check-github-remote.ts b/apps/server/src/routes/github/routes/check-github-remote.ts index 34a07198..5efdb172 100644 --- a/apps/server/src/routes/github/routes/check-github-remote.ts +++ b/apps/server/src/routes/github/routes/check-github-remote.ts @@ -5,6 +5,43 @@ import type { Request, Response } from 'express'; import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; +const GIT_REMOTE_ORIGIN_COMMAND = 'git remote get-url origin'; +const GH_REPO_VIEW_COMMAND = 'gh repo view --json name,owner'; +const GITHUB_REPO_URL_PREFIX = 'https://github.com/'; +const GITHUB_HTTPS_REMOTE_REGEX = /https:\/\/github\.com\/([^/]+)\/([^/.]+)/; +const GITHUB_SSH_REMOTE_REGEX = /git@github\.com:([^/]+)\/([^/.]+)/; + +interface GhRepoViewResponse { + name?: string; + owner?: { + login?: string; + }; +} + +async function resolveRepoFromGh(projectPath: string): Promise<{ + owner: string; + repo: string; +} | null> { + try { + const { stdout } = await execAsync(GH_REPO_VIEW_COMMAND, { + cwd: projectPath, + env: execEnv, + }); + + const data = JSON.parse(stdout) as GhRepoViewResponse; + const owner = typeof data.owner?.login === 'string' ? data.owner.login : null; + const repo = typeof data.name === 'string' ? data.name : null; + + if (!owner || !repo) { + return null; + } + + return { owner, repo }; + } catch { + return null; + } +} + export interface GitHubRemoteStatus { hasGitHubRemote: boolean; remoteUrl: string | null; @@ -21,19 +58,38 @@ export async function checkGitHubRemote(projectPath: string): Promise; @@ -45,6 +50,7 @@ interface GraphQLResponse { /** Timeout for GitHub API requests in milliseconds */ const GITHUB_API_TIMEOUT_MS = 30000; +const COMMENTS_PAGE_SIZE = 50; /** * Validate cursor format (GraphQL cursors are typically base64 strings) @@ -54,7 +60,7 @@ function isValidCursor(cursor: string): boolean { } /** - * Fetch comments for a specific issue using GitHub GraphQL API + * Fetch comments for a specific issue or pull request using GitHub GraphQL API */ async function fetchIssueComments( projectPath: string, @@ -70,24 +76,52 @@ async function fetchIssueComments( // Use GraphQL variables instead of string interpolation for safety const query = ` - query GetIssueComments($owner: String!, $repo: String!, $issueNumber: Int!, $cursor: String) { + query GetIssueComments( + $owner: String! + $repo: String! + $issueNumber: Int! + $cursor: String + $pageSize: Int! + ) { repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - comments(first: 50, after: $cursor) { - totalCount - pageInfo { - hasNextPage - endCursor - } - nodes { - id - author { - login - avatarUrl + issueOrPullRequest(number: $issueNumber) { + __typename + ... on Issue { + comments(first: $pageSize, after: $cursor) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + author { + login + avatarUrl + } + body + createdAt + updatedAt + } + } + } + ... on PullRequest { + comments(first: $pageSize, after: $cursor) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + author { + login + avatarUrl + } + body + createdAt + updatedAt } - body - createdAt - updatedAt } } } @@ -99,6 +133,7 @@ async function fetchIssueComments( repo, issueNumber, cursor: cursor || null, + pageSize: COMMENTS_PAGE_SIZE, }; const requestBody = JSON.stringify({ query, variables }); @@ -140,10 +175,10 @@ async function fetchIssueComments( throw new Error(response.errors[0].message); } - const commentsData = response.data?.repository?.issue?.comments; + const commentsData = response.data?.repository?.issueOrPullRequest?.comments; if (!commentsData) { - throw new Error('Issue not found or no comments data available'); + throw new Error('Issue or pull request not found or no comments data available'); } const comments: GitHubComment[] = commentsData.nodes.map((node) => ({ diff --git a/apps/server/src/routes/github/routes/list-issues.ts b/apps/server/src/routes/github/routes/list-issues.ts index 9c0f8933..96c3c202 100644 --- a/apps/server/src/routes/github/routes/list-issues.ts +++ b/apps/server/src/routes/github/routes/list-issues.ts @@ -9,6 +9,17 @@ import { checkGitHubRemote } from './check-github-remote.js'; import { createLogger } from '@automaker/utils'; const logger = createLogger('ListIssues'); +const OPEN_ISSUES_LIMIT = 100; +const CLOSED_ISSUES_LIMIT = 50; +const ISSUE_LIST_FIELDS = 'number,title,state,author,createdAt,labels,url,body,assignees'; +const ISSUE_STATE_OPEN = 'open'; +const ISSUE_STATE_CLOSED = 'closed'; +const GH_ISSUE_LIST_COMMAND = 'gh issue list'; +const GH_STATE_FLAG = '--state'; +const GH_JSON_FLAG = '--json'; +const GH_LIMIT_FLAG = '--limit'; +const LINKED_PRS_BATCH_SIZE = 20; +const LINKED_PRS_TIMELINE_ITEMS = 10; export interface GitHubLabel { name: string; @@ -69,34 +80,68 @@ async function fetchLinkedPRs( // Build GraphQL query for batch fetching linked PRs // We fetch up to 20 issues at a time to avoid query limits - const batchSize = 20; - for (let i = 0; i < issueNumbers.length; i += batchSize) { - const batch = issueNumbers.slice(i, i + batchSize); + for (let i = 0; i < issueNumbers.length; i += LINKED_PRS_BATCH_SIZE) { + const batch = issueNumbers.slice(i, i + LINKED_PRS_BATCH_SIZE); const issueQueries = batch .map( (num, idx) => ` - issue${idx}: issue(number: ${num}) { - number - timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) { - nodes { - ... on CrossReferencedEvent { - source { - ... on PullRequest { - number - title - state - url + issue${idx}: issueOrPullRequest(number: ${num}) { + ... on Issue { + number + timelineItems( + first: ${LINKED_PRS_TIMELINE_ITEMS} + itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT] + ) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + state + url + } + } + } + ... on ConnectedEvent { + subject { + ... on PullRequest { + number + title + state + url + } } } } - ... on ConnectedEvent { - subject { - ... on PullRequest { - number - title - state - url + } + } + ... on PullRequest { + number + timelineItems( + first: ${LINKED_PRS_TIMELINE_ITEMS} + itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT] + ) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + state + url + } + } + } + ... on ConnectedEvent { + subject { + ... on PullRequest { + number + title + state + url + } } } } @@ -213,16 +258,35 @@ export function createListIssuesHandler() { } // Fetch open and closed issues in parallel (now including assignees) + const repoQualifier = + remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : ''; + const repoFlag = repoQualifier ? `-R ${repoQualifier}` : ''; const [openResult, closedResult] = await Promise.all([ execAsync( - 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100', + [ + GH_ISSUE_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${ISSUE_STATE_OPEN}`, + `${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${OPEN_ISSUES_LIMIT}`, + ] + .filter(Boolean) + .join(' '), { cwd: projectPath, env: execEnv, } ), execAsync( - 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50', + [ + GH_ISSUE_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${ISSUE_STATE_CLOSED}`, + `${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${CLOSED_ISSUES_LIMIT}`, + ] + .filter(Boolean) + .join(' '), { cwd: projectPath, env: execEnv, diff --git a/apps/server/src/routes/github/routes/list-prs.ts b/apps/server/src/routes/github/routes/list-prs.ts index 87f42a38..b273fc0a 100644 --- a/apps/server/src/routes/github/routes/list-prs.ts +++ b/apps/server/src/routes/github/routes/list-prs.ts @@ -6,6 +6,17 @@ import type { Request, Response } from 'express'; import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; import { checkGitHubRemote } from './check-github-remote.js'; +const OPEN_PRS_LIMIT = 100; +const MERGED_PRS_LIMIT = 50; +const PR_LIST_FIELDS = + 'number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body'; +const PR_STATE_OPEN = 'open'; +const PR_STATE_MERGED = 'merged'; +const GH_PR_LIST_COMMAND = 'gh pr list'; +const GH_STATE_FLAG = '--state'; +const GH_JSON_FLAG = '--json'; +const GH_LIMIT_FLAG = '--limit'; + export interface GitHubLabel { name: string; color: string; @@ -57,16 +68,36 @@ export function createListPRsHandler() { return; } + const repoQualifier = + remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : ''; + const repoFlag = repoQualifier ? `-R ${repoQualifier}` : ''; + const [openResult, mergedResult] = await Promise.all([ execAsync( - 'gh pr list --state open --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 100', + [ + GH_PR_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${PR_STATE_OPEN}`, + `${GH_JSON_FLAG} ${PR_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${OPEN_PRS_LIMIT}`, + ] + .filter(Boolean) + .join(' '), { cwd: projectPath, env: execEnv, } ), execAsync( - 'gh pr list --state merged --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 50', + [ + GH_PR_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${PR_STATE_MERGED}`, + `${GH_JSON_FLAG} ${PR_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${MERGED_PRS_LIMIT}`, + ] + .filter(Boolean) + .join(' '), { cwd: projectPath, env: execEnv, diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index 237036ae..14de437b 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -1,29 +1,33 @@ /** - * POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK or Cursor (async) + * POST /validate-issue endpoint - Validate a GitHub issue using provider abstraction (async) * * Scans the codebase to determine if an issue is valid, invalid, or needs clarification. * Runs asynchronously and emits events for progress and completion. - * Supports both Claude models and Cursor models. + * Supports Claude, Codex, Cursor, and OpenCode models. */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import type { EventEmitter } from '../../../lib/events.js'; import type { IssueValidationResult, IssueValidationEvent, - ModelAlias, - CursorModelId, + ModelId, GitHubComment, LinkedPRInfo, ThinkingLevel, + ReasoningEffort, +} from '@automaker/types'; +import { + DEFAULT_PHASE_MODELS, + isClaudeModel, + isCodexModel, + isCursorModel, + isOpencodeModel, } from '@automaker/types'; -import { isCursorModel, DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; -import { createSuggestionsOptions } from '../../../lib/sdk-options.js'; import { extractJson } from '../../../lib/json-extractor.js'; import { writeValidation } from '../../../lib/validation-storage.js'; -import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { streamingQuery } from '../../../providers/simple-query-service.js'; import { issueValidationSchema, ISSUE_VALIDATION_SYSTEM_PROMPT, @@ -41,9 +45,6 @@ import { import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; -/** Valid Claude model values for validation */ -const VALID_CLAUDE_MODELS: readonly ModelAlias[] = ['opus', 'sonnet', 'haiku'] as const; - /** * Request body for issue validation */ @@ -53,10 +54,12 @@ interface ValidateIssueRequestBody { issueTitle: string; issueBody: string; issueLabels?: string[]; - /** Model to use for validation (opus, sonnet, haiku, or cursor model IDs) */ - model?: ModelAlias | CursorModelId; - /** Thinking level for Claude models (ignored for Cursor models) */ + /** Model to use for validation (Claude alias or provider model ID) */ + model?: ModelId; + /** Thinking level for Claude models (ignored for non-Claude models) */ thinkingLevel?: ThinkingLevel; + /** Reasoning effort for Codex models (ignored for non-Codex models) */ + reasoningEffort?: ReasoningEffort; /** Comments to include in validation analysis */ comments?: GitHubComment[]; /** Linked pull requests for this issue */ @@ -68,7 +71,7 @@ interface ValidateIssueRequestBody { * * Emits events for start, progress, complete, and error. * Stores result on completion. - * Supports both Claude models (with structured output) and Cursor models (with JSON parsing). + * Supports Claude/Codex models (structured output) and Cursor/OpenCode models (JSON parsing). */ async function runValidation( projectPath: string, @@ -76,13 +79,14 @@ async function runValidation( issueTitle: string, issueBody: string, issueLabels: string[] | undefined, - model: ModelAlias | CursorModelId, + model: ModelId, events: EventEmitter, abortController: AbortController, settingsService?: SettingsService, comments?: ValidationComment[], linkedPRs?: ValidationLinkedPR[], - thinkingLevel?: ThinkingLevel + thinkingLevel?: ThinkingLevel, + reasoningEffort?: ReasoningEffort ): Promise { // Emit start event const startEvent: IssueValidationEvent = { @@ -102,7 +106,7 @@ async function runValidation( try { // Build the prompt (include comments and linked PRs if provided) - const prompt = buildValidationPrompt( + const basePrompt = buildValidationPrompt( issueNumber, issueTitle, issueBody, @@ -111,20 +115,15 @@ async function runValidation( linkedPRs ); - let validationResult: IssueValidationResult | null = null; let responseText = ''; - // Route to appropriate provider based on model - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info(`Using Cursor provider for validation with model: ${model}`); + // Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't) + const useStructuredOutput = isClaudeModel(model) || isCodexModel(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 - const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT} + // Build the final prompt - for Cursor, include system prompt and JSON schema instructions + let finalPrompt = basePrompt; + if (!useStructuredOutput) { + finalPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT} CRITICAL INSTRUCTIONS: 1. DO NOT write any files. Return the JSON in your response only. @@ -135,121 +134,78 @@ ${JSON.stringify(issueValidationSchema, null, 2)} Your entire response should be valid JSON starting with { and ending with }. No text before or after. -${prompt}`; +${basePrompt}`; + } - for await (const msg of provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd: projectPath, - readOnly: true, // Issue validation only reads code, doesn't write - })) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; + // Load autoLoadClaudeMd setting + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + settingsService, + '[ValidateIssue]' + ); - // Emit progress event - const progressEvent: IssueValidationEvent = { - type: 'issue_validation_progress', - issueNumber, - content: block.text, - projectPath, - }; - events.emit('issue-validation:event', progressEvent); - } - } - } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { - // Use result if it's a final accumulated message - if (msg.result.length > responseText.length) { - responseText = msg.result; - } - } - } - - // Parse JSON from the response text using shared utility - if (responseText) { - validationResult = extractJson(responseText, { logger }); - } - } else { - // Use Claude SDK for Claude models - logger.info(`Using Claude provider for validation with model: ${model}`); - - // Load autoLoadClaudeMd setting - const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( - projectPath, - settingsService, - '[ValidateIssue]' - ); - - // Use thinkingLevel from request if provided, otherwise fall back to settings - let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel; + // Use request overrides if provided, otherwise fall back to settings + let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel; + let effectiveReasoningEffort: ReasoningEffort | undefined = reasoningEffort; + if (!effectiveThinkingLevel || !effectiveReasoningEffort) { + const settings = await settingsService?.getGlobalSettings(); + const phaseModelEntry = + settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel; + const resolved = resolvePhaseModel(phaseModelEntry); if (!effectiveThinkingLevel) { - const settings = await settingsService?.getGlobalSettings(); - const phaseModelEntry = - settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel; - const resolved = resolvePhaseModel(phaseModelEntry); effectiveThinkingLevel = resolved.thinkingLevel; } - - // Create SDK options with structured output and abort controller - const options = createSuggestionsOptions({ - cwd: projectPath, - model: model as ModelAlias, - systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT, - abortController, - autoLoadClaudeMd, - thinkingLevel: effectiveThinkingLevel, - outputFormat: { - type: 'json_schema', - schema: issueValidationSchema as Record, - }, - }); - - // Execute the query - const stream = query({ prompt, options }); - - for await (const msg of stream) { - // Collect assistant text for debugging and emit progress - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - responseText += block.text; - - // Emit progress event - const progressEvent: IssueValidationEvent = { - type: 'issue_validation_progress', - issueNumber, - content: block.text, - projectPath, - }; - events.emit('issue-validation:event', progressEvent); - } - } - } - - // Extract structured output on success - if (msg.type === 'result' && msg.subtype === 'success') { - const resultMsg = msg as { structured_output?: IssueValidationResult }; - if (resultMsg.structured_output) { - validationResult = resultMsg.structured_output; - logger.debug('Received structured output:', validationResult); - } - } - - // Handle errors - if (msg.type === 'result') { - const resultMsg = msg as { subtype?: string }; - if (resultMsg.subtype === 'error_max_structured_output_retries') { - logger.error('Failed to produce valid structured output after retries'); - throw new Error('Could not produce valid validation output'); - } - } + if (!effectiveReasoningEffort && typeof phaseModelEntry !== 'string') { + effectiveReasoningEffort = phaseModelEntry.reasoningEffort; } } + logger.info(`Using model: ${model}`); + + // Use streamingQuery with event callbacks + const result = await streamingQuery({ + prompt: finalPrompt, + model: model as string, + cwd: projectPath, + systemPrompt: useStructuredOutput ? ISSUE_VALIDATION_SYSTEM_PROMPT : undefined, + abortController, + thinkingLevel: effectiveThinkingLevel, + reasoningEffort: effectiveReasoningEffort, + readOnly: true, // Issue validation only reads code, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + outputFormat: useStructuredOutput + ? { + type: 'json_schema', + schema: issueValidationSchema as Record, + } + : undefined, + onText: (text) => { + responseText += text; + // Emit progress event + const progressEvent: IssueValidationEvent = { + type: 'issue_validation_progress', + issueNumber, + content: text, + projectPath, + }; + events.emit('issue-validation:event', progressEvent); + }, + }); + // Clear timeout clearTimeout(timeoutId); + // Get validation result from structured output or parse from text + let validationResult: IssueValidationResult | null = null; + + if (result.structured_output) { + validationResult = result.structured_output as unknown as IssueValidationResult; + logger.debug('Received structured output:', validationResult); + } else if (responseText) { + // Parse JSON from response text + validationResult = extractJson(responseText, { logger }); + } + // Require validation result if (!validationResult) { logger.error('No validation result received from AI provider'); @@ -299,7 +255,7 @@ ${prompt}`; /** * Creates the handler for validating GitHub issues against the codebase. * - * Uses Claude SDK with: + * Uses the provider abstraction with: * - Read-only tools (Read, Glob, Grep) for codebase analysis * - JSON schema structured output for reliable parsing * - System prompt guiding the validation process @@ -319,6 +275,7 @@ export function createValidateIssueHandler( issueLabels, model = 'opus', thinkingLevel, + reasoningEffort, comments: rawComments, linkedPRs: rawLinkedPRs, } = req.body as ValidateIssueRequestBody; @@ -366,14 +323,17 @@ export function createValidateIssueHandler( return; } - // Validate model parameter at runtime - accept Claude models or Cursor models - const isValidClaudeModel = VALID_CLAUDE_MODELS.includes(model as ModelAlias); - const isValidCursorModel = isCursorModel(model); + // Validate model parameter at runtime - accept any supported provider model + const isValidModel = + isClaudeModel(model) || + isCursorModel(model) || + isCodexModel(model) || + isOpencodeModel(model); - if (!isValidClaudeModel && !isValidCursorModel) { + if (!isValidModel) { res.status(400).json({ success: false, - error: `Invalid model. Must be one of: ${VALID_CLAUDE_MODELS.join(', ')}, or a Cursor model ID`, + error: 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias).', }); return; } @@ -404,7 +364,8 @@ export function createValidateIssueHandler( settingsService, validationComments, validationLinkedPRs, - thinkingLevel + thinkingLevel, + reasoningEffort ) .catch(() => { // Error is already handled inside runValidation (event emitted) diff --git a/apps/server/src/routes/settings/routes/get-credentials.ts b/apps/server/src/routes/settings/routes/get-credentials.ts index be15b04b..140ccce4 100644 --- a/apps/server/src/routes/settings/routes/get-credentials.ts +++ b/apps/server/src/routes/settings/routes/get-credentials.ts @@ -5,7 +5,7 @@ * Each provider shows: `{ configured: boolean, masked: string }` * Masked shows first 4 and last 4 characters for verification. * - * Response: `{ "success": true, "credentials": { anthropic } }` + * Response: `{ "success": true, "credentials": { anthropic, google, openai } }` */ import type { Request, Response } from 'express'; diff --git a/apps/server/src/routes/settings/routes/update-credentials.ts b/apps/server/src/routes/settings/routes/update-credentials.ts index c08b2445..2b415830 100644 --- a/apps/server/src/routes/settings/routes/update-credentials.ts +++ b/apps/server/src/routes/settings/routes/update-credentials.ts @@ -1,7 +1,7 @@ /** * PUT /api/settings/credentials - Update API credentials * - * Updates API keys for Anthropic. Partial updates supported. + * Updates API keys for supported providers. Partial updates supported. * Returns masked credentials for verification without exposing full keys. * * Request body: `Partial` (usually just apiKeys) diff --git a/apps/server/src/routes/setup/routes/api-keys.ts b/apps/server/src/routes/setup/routes/api-keys.ts index 047b6455..ec870e7b 100644 --- a/apps/server/src/routes/setup/routes/api-keys.ts +++ b/apps/server/src/routes/setup/routes/api-keys.ts @@ -11,6 +11,7 @@ export function createApiKeysHandler() { res.json({ success: true, hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY, + hasGoogleKey: !!getApiKey('google'), hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY, }); } catch (error) { diff --git a/apps/server/src/routes/setup/routes/store-api-key.ts b/apps/server/src/routes/setup/routes/store-api-key.ts index e77a697e..eae2e430 100644 --- a/apps/server/src/routes/setup/routes/store-api-key.ts +++ b/apps/server/src/routes/setup/routes/store-api-key.ts @@ -21,22 +21,25 @@ export function createStoreApiKeyHandler() { return; } - setApiKey(provider, apiKey); - - // Also set as environment variable and persist to .env - if (provider === 'anthropic' || provider === 'anthropic_oauth_token') { - // Both API key and OAuth token use ANTHROPIC_API_KEY - process.env.ANTHROPIC_API_KEY = apiKey; - await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey); - logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY'); - } else { + const providerEnvMap: Record = { + anthropic: 'ANTHROPIC_API_KEY', + anthropic_oauth_token: 'ANTHROPIC_API_KEY', + openai: 'OPENAI_API_KEY', + }; + const envKey = providerEnvMap[provider]; + if (!envKey) { res.status(400).json({ success: false, - error: `Unsupported provider: ${provider}. Only anthropic is supported.`, + error: `Unsupported provider: ${provider}. Only anthropic and openai are supported.`, }); return; } + setApiKey(provider, apiKey); + process.env[envKey] = apiKey; + await persistApiKeyToEnv(envKey, apiKey); + logger.info(`[Setup] Stored API key as ${envKey}`); + res.json({ success: true }); } catch (error) { logError(error, 'Store API key failed'); diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index 2cf7925f..0766c41b 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -5,19 +5,12 @@ * (AI Suggestions in the UI). Supports both Claude and Cursor models. */ -import { query } from '@anthropic-ai/claude-agent-sdk'; import type { EventEmitter } from '../../lib/events.js'; import { createLogger } from '@automaker/utils'; -import { - DEFAULT_PHASE_MODELS, - isCursorModel, - stripProviderPrefix, - type ThinkingLevel, -} from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; -import { createSuggestionsOptions } from '../../lib/sdk-options.js'; import { extractJsonWithArray } from '../../lib/json-extractor.js'; -import { ProviderFactory } from '../../providers/provider-factory.js'; +import { streamingQuery } from '../../providers/simple-query-service.js'; import { FeatureLoader } from '../../services/feature-loader.js'; import { getAppSpecPath } from '@automaker/platform'; import * as secureFs from '../../lib/secure-fs.js'; @@ -204,19 +197,14 @@ The response will be automatically formatted as structured JSON.`; logger.info('[Suggestions] Using model:', model); let responseText = ''; - let structuredOutput: { suggestions: Array> } | null = null; - // Route to appropriate provider based on model type - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info('[Suggestions] Using Cursor provider'); + // Determine if we should use structured output (Claude supports it, Cursor doesn't) + const useStructuredOutput = !isCursorModel(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 - const cursorPrompt = `${prompt} + // Build the final prompt - for Cursor, include JSON schema instructions + let finalPrompt = prompt; + if (!useStructuredOutput) { + finalPrompt = `${prompt} CRITICAL INSTRUCTIONS: 1. DO NOT write any files. Return the JSON in your response only. @@ -226,104 +214,60 @@ CRITICAL INSTRUCTIONS: ${JSON.stringify(suggestionsSchema, null, 2)} Your entire response should be valid JSON starting with { and ending with }. No text before or after.`; - - for await (const msg of provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd: projectPath, - maxTurns: 250, - allowedTools: ['Read', 'Glob', 'Grep'], - abortController, - readOnly: true, // Suggestions only reads code, doesn't write - })) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - events.emit('suggestions:event', { - type: 'suggestions_progress', - content: block.text, - }); - } else if (block.type === 'tool_use') { - events.emit('suggestions:event', { - type: 'suggestions_tool', - tool: block.name, - input: block.input, - }); - } - } - } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { - // Use result if it's a final accumulated message (from Cursor provider) - logger.info('[Suggestions] Received result from Cursor, length:', msg.result.length); - logger.info('[Suggestions] Previous responseText length:', responseText.length); - if (msg.result.length > responseText.length) { - logger.info('[Suggestions] Using Cursor result (longer than accumulated text)'); - responseText = msg.result; - } else { - logger.info('[Suggestions] Keeping accumulated text (longer than Cursor result)'); - } - } - } - } else { - // Use Claude SDK for Claude models - logger.info('[Suggestions] Using Claude SDK'); - - const options = createSuggestionsOptions({ - cwd: projectPath, - abortController, - autoLoadClaudeMd, - model, // Pass the model from settings - thinkingLevel, // Pass thinking level for extended thinking - outputFormat: { - type: 'json_schema', - schema: suggestionsSchema, - }, - }); - - const stream = query({ prompt, options }); - - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - responseText += block.text; - events.emit('suggestions:event', { - type: 'suggestions_progress', - content: block.text, - }); - } else if (block.type === 'tool_use') { - events.emit('suggestions:event', { - type: 'suggestions_tool', - tool: block.name, - input: block.input, - }); - } - } - } else if (msg.type === 'result' && msg.subtype === 'success') { - // Check for structured output - const resultMsg = msg as any; - if (resultMsg.structured_output) { - structuredOutput = resultMsg.structured_output as { - suggestions: Array>; - }; - logger.debug('Received structured output:', structuredOutput); - } - } else if (msg.type === 'result') { - const resultMsg = msg as any; - if (resultMsg.subtype === 'error_max_structured_output_retries') { - logger.error('Failed to produce valid structured output after retries'); - throw new Error('Could not produce valid suggestions output'); - } else if (resultMsg.subtype === 'error_max_turns') { - logger.error('Hit max turns limit before completing suggestions generation'); - logger.warn(`Response text length: ${responseText.length} chars`); - // Still try to parse what we have - } - } - } } + // Use streamingQuery with event callbacks + const result = await streamingQuery({ + prompt: finalPrompt, + model, + cwd: projectPath, + maxTurns: 250, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + thinkingLevel, + readOnly: true, // Suggestions only reads code, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + outputFormat: useStructuredOutput + ? { + type: 'json_schema', + schema: suggestionsSchema, + } + : undefined, + onText: (text) => { + responseText += text; + events.emit('suggestions:event', { + type: 'suggestions_progress', + content: text, + }); + }, + onToolUse: (tool, input) => { + events.emit('suggestions:event', { + type: 'suggestions_tool', + tool, + input, + }); + }, + }); + // Use structured output if available, otherwise fall back to parsing text try { + let structuredOutput: { suggestions: Array> } | null = null; + + if (result.structured_output) { + structuredOutput = result.structured_output as { + suggestions: Array>; + }; + logger.debug('Received structured output:', structuredOutput); + } else if (responseText) { + // Fallback: try to parse from text using shared extraction utility + logger.warn('No structured output received, attempting to parse from text'); + structuredOutput = extractJsonWithArray<{ suggestions: Array> }>( + responseText, + 'suggestions', + { logger } + ); + } + if (structuredOutput && structuredOutput.suggestions) { // Use structured output directly events.emit('suggestions:event', { @@ -334,24 +278,7 @@ Your entire response should be valid JSON starting with { and ending with }. No })), }); } else { - // Fallback: try to parse from text using shared extraction utility - logger.warn('No structured output received, attempting to parse from text'); - const parsed = extractJsonWithArray<{ suggestions: Array> }>( - responseText, - 'suggestions', - { logger } - ); - if (parsed && parsed.suggestions) { - events.emit('suggestions:event', { - type: 'suggestions_complete', - suggestions: parsed.suggestions.map((s: Record, i: number) => ({ - ...s, - id: s.id || `suggestion-${Date.now()}-${i}`, - })), - }); - } else { - throw new Error('No valid JSON found in response'); - } + throw new Error('No valid JSON found in response'); } } catch (error) { // Log the parsing error for debugging diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index a00e0bfe..4b54ae9e 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -17,6 +17,7 @@ import { createDeleteHandler } from './routes/delete.js'; import { createCreatePRHandler } from './routes/create-pr.js'; import { createPRInfoHandler } from './routes/pr-info.js'; import { createCommitHandler } from './routes/commit.js'; +import { createGenerateCommitMessageHandler } from './routes/generate-commit-message.js'; import { createPushHandler } from './routes/push.js'; import { createPullHandler } from './routes/pull.js'; import { createCheckoutBranchHandler } from './routes/checkout-branch.js'; @@ -33,14 +34,19 @@ import { createMigrateHandler } from './routes/migrate.js'; import { createStartDevHandler } from './routes/start-dev.js'; import { createStopDevHandler } from './routes/stop-dev.js'; import { createListDevServersHandler } from './routes/list-dev-servers.js'; +import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js'; import { createGetInitScriptHandler, createPutInitScriptHandler, createDeleteInitScriptHandler, createRunInitScriptHandler, } from './routes/init-script.js'; +import type { SettingsService } from '../../services/settings-service.js'; -export function createWorktreeRoutes(events: EventEmitter): Router { +export function createWorktreeRoutes( + events: EventEmitter, + settingsService?: SettingsService +): Router { const router = Router(); router.post('/info', validatePathParams('projectPath'), createInfoHandler()); @@ -64,6 +70,12 @@ export function createWorktreeRoutes(events: EventEmitter): Router { requireGitRepoOnly, createCommitHandler() ); + router.post( + '/generate-commit-message', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createGenerateCommitMessageHandler(settingsService) + ); router.post( '/push', validatePathParams('worktreePath'), @@ -97,6 +109,11 @@ export function createWorktreeRoutes(events: EventEmitter): Router { ); router.post('/stop-dev', createStopDevHandler()); router.post('/list-dev-servers', createListDevServersHandler()); + router.get( + '/dev-server-logs', + validatePathParams('worktreePath'), + createGetDevServerLogsHandler() + ); // Init script routes router.get('/init-script', createGetInitScriptHandler()); diff --git a/apps/server/src/routes/worktree/routes/create-pr.ts b/apps/server/src/routes/worktree/routes/create-pr.ts index ec7ba4dd..1bde9448 100644 --- a/apps/server/src/routes/worktree/routes/create-pr.ts +++ b/apps/server/src/routes/worktree/routes/create-pr.ts @@ -70,9 +70,8 @@ export function createCreatePRHandler() { logger.debug(`Changed files:\n${status}`); } - // If there are changes, commit them + // If there are changes, commit them before creating the PR let commitHash: string | null = null; - let commitError: string | null = null; if (hasChanges) { const message = commitMessage || `Changes from ${branchName}`; logger.debug(`Committing changes with message: ${message}`); @@ -98,14 +97,13 @@ export function createCreatePRHandler() { logger.info(`Commit successful: ${commitHash}`); } catch (commitErr: unknown) { const err = commitErr as { stderr?: string; message?: string }; - commitError = err.stderr || err.message || 'Commit failed'; + const commitError = err.stderr || err.message || 'Commit failed'; logger.error(`Commit failed: ${commitError}`); // Return error immediately - don't proceed with push/PR if commit fails res.status(500).json({ success: false, error: `Failed to commit changes: ${commitError}`, - commitError, }); return; } @@ -381,9 +379,8 @@ export function createCreatePRHandler() { success: true, result: { branch: branchName, - committed: hasChanges && !commitError, + committed: hasChanges, commitHash, - commitError: commitError || undefined, pushed: true, prUrl, prNumber, diff --git a/apps/server/src/routes/worktree/routes/dev-server-logs.ts b/apps/server/src/routes/worktree/routes/dev-server-logs.ts new file mode 100644 index 00000000..66dfed92 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/dev-server-logs.ts @@ -0,0 +1,52 @@ +/** + * GET /dev-server-logs endpoint - Get buffered logs for a worktree's dev server + * + * Returns the scrollback buffer containing historical log output for a running + * dev server. Used by clients to populate the log panel on initial connection + * before subscribing to real-time updates via WebSocket. + */ + +import type { Request, Response } from 'express'; +import { getDevServerService } from '../../../services/dev-server-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createGetDevServerLogsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.query as { + worktreePath?: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath query parameter is required', + }); + return; + } + + const devServerService = getDevServerService(); + const result = devServerService.getServerLogs(worktreePath); + + if (result.success && result.result) { + res.json({ + success: true, + result: { + worktreePath: result.result.worktreePath, + port: result.result.port, + logs: result.result.logs, + startedAt: result.result.startedAt, + }, + }); + } else { + res.status(404).json({ + success: false, + error: result.error || 'Failed to get dev server logs', + }); + } + } catch (error) { + logError(error, 'Get dev server logs failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts new file mode 100644 index 00000000..a450659f --- /dev/null +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -0,0 +1,275 @@ +/** + * POST /worktree/generate-commit-message endpoint - Generate an AI commit message from git diff + * + * Uses the configured model (via phaseModels.commitMessageModel) to generate a concise, + * conventional commit message from git changes. Defaults to Claude Haiku for speed. + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger } from '@automaker/utils'; +import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { mergeCommitMessagePrompts } from '@automaker/prompts'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('GenerateCommitMessage'); +const execAsync = promisify(exec); + +/** Timeout for AI provider calls in milliseconds (30 seconds) */ +const AI_TIMEOUT_MS = 30_000; + +/** + * Wraps an async generator with a timeout. + * If the generator takes longer than the timeout, it throws an error. + */ +async function* withTimeout( + generator: AsyncIterable, + timeoutMs: number +): AsyncGenerator { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs); + }); + + const iterator = generator[Symbol.asyncIterator](); + let done = false; + + while (!done) { + const result = await Promise.race([iterator.next(), timeoutPromise]); + if (result.done) { + done = true; + } else { + yield result.value; + } + } +} + +/** + * Get the effective system prompt for commit message generation. + * Uses custom prompt from settings if enabled, otherwise falls back to default. + */ +async function getSystemPrompt(settingsService?: SettingsService): Promise { + const settings = await settingsService?.getGlobalSettings(); + const prompts = mergeCommitMessagePrompts(settings?.promptCustomization?.commitMessage); + return prompts.systemPrompt; +} + +interface GenerateCommitMessageRequestBody { + worktreePath: string; +} + +interface GenerateCommitMessageSuccessResponse { + success: true; + message: string; +} + +interface GenerateCommitMessageErrorResponse { + success: false; + error: string; +} + +async function extractTextFromStream( + stream: AsyncIterable<{ + type: string; + subtype?: string; + result?: string; + message?: { + content?: Array<{ type: string; text?: string }>; + }; + }> +): Promise { + 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; + } + } + + return responseText; +} + +export function createGenerateCommitMessageHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as GenerateCommitMessageRequestBody; + + if (!worktreePath || typeof worktreePath !== 'string') { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath is required and must be a string', + }; + res.status(400).json(response); + return; + } + + // Validate that the directory exists + if (!existsSync(worktreePath)) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath does not exist', + }; + res.status(400).json(response); + return; + } + + // Validate that it's a git repository (check for .git folder or file for worktrees) + const gitPath = join(worktreePath, '.git'); + if (!existsSync(gitPath)) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath is not a git repository', + }; + res.status(400).json(response); + return; + } + + logger.info(`Generating commit message for worktree: ${worktreePath}`); + + // Get git diff of staged and unstaged changes + let diff = ''; + try { + // First try to get staged changes + const { stdout: stagedDiff } = await execAsync('git diff --cached', { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, // 5MB buffer + }); + + // If no staged changes, get unstaged changes + if (!stagedDiff.trim()) { + const { stdout: unstagedDiff } = await execAsync('git diff', { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, // 5MB buffer + }); + diff = unstagedDiff; + } else { + diff = stagedDiff; + } + } catch (error) { + logger.error('Failed to get git diff:', error); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'Failed to get git changes', + }; + res.status(500).json(response); + return; + } + + if (!diff.trim()) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'No changes to commit', + }; + res.status(400).json(response); + return; + } + + // Truncate diff if too long (keep first 10000 characters to avoid token limits) + const truncatedDiff = + diff.length > 10000 ? diff.substring(0, 10000) + '\n\n[... diff truncated ...]' : diff; + + const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; + + // Get model from phase settings + const settings = await settingsService?.getGlobalSettings(); + const phaseModelEntry = + settings?.phaseModels?.commitMessageModel || DEFAULT_PHASE_MODELS.commitMessageModel; + const { model } = resolvePhaseModel(phaseModelEntry); + + logger.info(`Using model for commit message: ${model}`); + + // Get the effective system prompt (custom or default) + const systemPrompt = await getSystemPrompt(settingsService); + + let message: string; + + // Route to appropriate provider based on model type + if (isCursorModel(model)) { + // Use Cursor provider for Cursor models + logger.info(`Using Cursor provider for model: ${model}`); + + const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); + + const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`; + + let responseText = ''; + const cursorStream = provider.executeQuery({ + prompt: cursorPrompt, + model: bareModel, + cwd: worktreePath, + maxTurns: 1, + allowedTools: [], + readOnly: true, + }); + + // Wrap with timeout to prevent indefinite hangs + for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } + } + + message = responseText.trim(); + } else { + // Use Claude SDK for Claude models + const stream = query({ + prompt: userPrompt, + options: { + model, + systemPrompt, + maxTurns: 1, + allowedTools: [], + permissionMode: 'default', + }, + }); + + // Wrap with timeout to prevent indefinite hangs + message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS)); + } + + if (!message || message.trim().length === 0) { + logger.warn('Received empty response from model'); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'Failed to generate commit message - empty response', + }; + res.status(500).json(response); + return; + } + + logger.info(`Generated commit message: ${message.trim().substring(0, 100)}...`); + + const response: GenerateCommitMessageSuccessResponse = { + success: true, + message: message.trim(), + }; + res.json(response); + } catch (error) { + logError(error, 'Generate commit message failed'); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: getErrorMessage(error), + }; + res.status(500).json(response); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index dc7d7d6c..c6db10fc 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -1,5 +1,5 @@ /** - * POST /list-branches endpoint - List all local branches + * POST /list-branches endpoint - List all local branches and optionally remote branches * * Note: Git repository validation (isGitRepo, hasCommits) is handled by * the requireValidWorktree middleware in index.ts @@ -21,8 +21,9 @@ interface BranchInfo { export function createListBranchesHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath } = req.body as { + const { worktreePath, includeRemote = false } = req.body as { worktreePath: string; + includeRemote?: boolean; }; if (!worktreePath) { @@ -60,6 +61,55 @@ export function createListBranchesHandler() { }; }); + // Fetch remote branches if requested + if (includeRemote) { + try { + // Fetch latest remote refs (silently, don't fail if offline) + try { + await execAsync('git fetch --all --quiet', { + cwd: worktreePath, + timeout: 10000, // 10 second timeout + }); + } catch { + // Ignore fetch errors - we'll use cached remote refs + } + + // List remote branches + const { stdout: remoteBranchesOutput } = await execAsync( + 'git branch -r --format="%(refname:short)"', + { cwd: worktreePath } + ); + + const localBranchNames = new Set(branches.map((b) => b.name)); + + remoteBranchesOutput + .trim() + .split('\n') + .filter((b) => b.trim()) + .forEach((name) => { + // Remove any surrounding quotes + const cleanName = name.trim().replace(/^['"]|['"]$/g, ''); + // Skip HEAD pointers like "origin/HEAD" + if (cleanName.includes('/HEAD')) return; + + // Only add remote branches if a branch with the exact same name isn't already + // in the list. This avoids duplicates if a local branch is named like a remote one. + // Note: We intentionally include remote branches even when a local branch with the + // same base name exists (e.g., show "origin/main" even if local "main" exists), + // since users need to select remote branches as PR base targets. + if (!localBranchNames.has(cleanName)) { + branches.push({ + name: cleanName, // Keep full name like "origin/main" + isCurrent: false, + isRemote: true, + }); + } + }); + } catch { + // Ignore errors fetching remote branches - return local branches only + } + } + // Get ahead/behind count for current branch let aheadCount = 0; let behindCount = 0; diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index bc70a341..a7c12f98 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -13,7 +13,7 @@ import { promisify } from 'util'; import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; import { isGitRepo } from '@automaker/git-utils'; -import { getErrorMessage, logError, normalizePath } from '../common.js'; +import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js'; import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js'; import { createLogger } from '@automaker/utils'; @@ -121,6 +121,52 @@ async function scanWorktreesDirectory( return discovered; } +/** + * Fetch open PRs from GitHub and create a map of branch name to PR info. + * This allows detecting PRs that were created outside the app. + */ +async function fetchGitHubPRs(projectPath: string): Promise> { + const prMap = new Map(); + + try { + // Check if gh CLI is available + const ghAvailable = await isGhCliAvailable(); + if (!ghAvailable) { + return prMap; + } + + // Fetch open PRs from GitHub + const { stdout } = await execAsync( + 'gh pr list --state open --json number,title,url,state,headRefName,createdAt --limit 1000', + { cwd: projectPath, env: execEnv, timeout: 15000 } + ); + + const prs = JSON.parse(stdout || '[]') as Array<{ + number: number; + title: string; + url: string; + state: string; + headRefName: string; + createdAt: string; + }>; + + for (const pr of prs) { + prMap.set(pr.headRefName, { + number: pr.number, + url: pr.url, + title: pr.title, + state: pr.state, + createdAt: pr.createdAt, + }); + } + } catch (error) { + // Silently fail - PR detection is optional + logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`); + } + + return prMap; +} + export function createListHandler() { return async (req: Request, res: Response): Promise => { try { @@ -241,11 +287,23 @@ export function createListHandler() { } } - // Add PR info from metadata for each worktree + // Add PR info from metadata or GitHub for each worktree + // Only fetch GitHub PRs if includeDetails is requested (performance optimization) + const githubPRs = includeDetails + ? await fetchGitHubPRs(projectPath) + : new Map(); + for (const worktree of worktrees) { const metadata = allMetadata.get(worktree.branch); if (metadata?.pr) { + // Use stored metadata (more complete info) worktree.pr = metadata.pr; + } else if (includeDetails) { + // Fall back to GitHub PR detection only when includeDetails is requested + const githubPR = githubPRs.get(worktree.branch); + if (githubPR) { + worktree.pr = githubPR; + } } } diff --git a/apps/server/src/routes/worktree/routes/merge.ts b/apps/server/src/routes/worktree/routes/merge.ts index ab4e0c17..69f120b8 100644 --- a/apps/server/src/routes/worktree/routes/merge.ts +++ b/apps/server/src/routes/worktree/routes/merge.ts @@ -8,7 +8,6 @@ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; -import path from 'path'; import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); @@ -16,28 +15,31 @@ const execAsync = promisify(exec); export function createMergeHandler() { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId, options } = req.body as { + const { projectPath, branchName, worktreePath, options } = req.body as { projectPath: string; - featureId: string; + branchName: string; + worktreePath: string; options?: { squash?: boolean; message?: string }; }; - if (!projectPath || !featureId) { + if (!projectPath || !branchName || !worktreePath) { res.status(400).json({ success: false, - error: 'projectPath and featureId required', + error: 'projectPath, branchName, and worktreePath are required', }); return; } - const branchName = `feature/${featureId}`; - // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, '.worktrees', featureId); - - // Get current branch - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: projectPath, - }); + // Validate branch exists + try { + await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath }); + } catch { + res.status(400).json({ + success: false, + error: `Branch "${branchName}" does not exist`, + }); + return; + } // Merge the feature branch const mergeCmd = options?.squash diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index b830a297..29caa4e1 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -10,15 +10,18 @@ */ import { ProviderFactory } from '../providers/provider-factory.js'; +import { simpleQuery } from '../providers/simple-query-service.js'; import type { ExecuteOptions, Feature, ModelProvider, PipelineStep, + FeatureStatusWithPipeline, + PipelineConfig, ThinkingLevel, PlanningMode, } from '@automaker/types'; -import { DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, isClaudeModel, stripProviderPrefix } from '@automaker/types'; import { buildPromptWithImages, classifyError, @@ -83,6 +86,26 @@ interface PlanSpec { tasks?: ParsedTask[]; } +/** + * Information about pipeline status when resuming a feature. + * Used to determine how to handle features stuck in pipeline execution. + * + * @property {boolean} isPipeline - Whether the feature is in a pipeline step + * @property {string | null} stepId - ID of the current pipeline step (e.g., 'step_123') + * @property {number} stepIndex - Index of the step in the sorted pipeline steps (-1 if not found) + * @property {number} totalSteps - Total number of steps in the pipeline + * @property {PipelineStep | null} step - The pipeline step configuration, or null if step not found + * @property {PipelineConfig | null} config - The full pipeline configuration, or null if no pipeline + */ +interface PipelineStatusInfo { + isPipeline: boolean; + stepId: string | null; + stepIndex: number; + totalSteps: number; + step: PipelineStep | null; + config: PipelineConfig | null; +} + /** * Parse tasks from generated spec content * Looks for the ```tasks code block and extracts task lines @@ -917,6 +940,25 @@ Complete the pipeline step instructions above. Review the previous work and appl throw new Error('already running'); } + // Load feature to check status + const feature = await this.loadFeature(projectPath, featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + + // Check if feature is stuck in a pipeline step + const pipelineInfo = await this.detectPipelineStatus( + projectPath, + featureId, + (feature.status || '') as FeatureStatusWithPipeline + ); + + if (pipelineInfo.isPipeline) { + // Feature stuck in pipeline - use pipeline resume + return this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo); + } + + // Normal resume flow for non-pipeline features // Check if context exists in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, 'agent-output.md'); @@ -936,11 +978,252 @@ Complete the pipeline step instructions above. Review the previous work and appl } // No context, start fresh - executeFeature will handle adding to runningFeatures - // Remove the temporary entry we added - this.runningFeatures.delete(featureId); return this.executeFeature(projectPath, featureId, useWorktrees, false); } + /** + * Resume a feature that crashed during pipeline execution. + * Handles multiple edge cases to ensure robust recovery: + * - No context file: Restart entire pipeline from beginning + * - Step deleted from config: Complete feature without remaining pipeline steps + * - Valid step exists: Resume from the crashed step and continue + * + * @param {string} projectPath - Absolute path to the project directory + * @param {Feature} feature - The feature object (already loaded to avoid redundant reads) + * @param {boolean} useWorktrees - Whether to use git worktrees for isolation + * @param {PipelineStatusInfo} pipelineInfo - Information about the pipeline status from detectPipelineStatus() + * @returns {Promise} Resolves when resume operation completes or throws on error + * @throws {Error} If pipeline config is null but stepIndex is valid (should never happen) + * @private + */ + private async resumePipelineFeature( + projectPath: string, + feature: Feature, + useWorktrees: boolean, + pipelineInfo: PipelineStatusInfo + ): Promise { + const featureId = feature.id; + console.log( + `[AutoMode] Resuming feature ${featureId} from pipeline step ${pipelineInfo.stepId}` + ); + + // Check for context file + const featureDir = getFeatureDir(projectPath, featureId); + const contextPath = path.join(featureDir, 'agent-output.md'); + + let hasContext = false; + try { + await secureFs.access(contextPath); + hasContext = true; + } catch { + // No context + } + + // Edge Case 1: No context file - restart entire pipeline from beginning + if (!hasContext) { + console.warn( + `[AutoMode] No context found for pipeline feature ${featureId}, restarting from beginning` + ); + + // Reset status to in_progress and start fresh + await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); + + return this.executeFeature(projectPath, featureId, useWorktrees, false); + } + + // Edge Case 2: Step no longer exists in pipeline config + if (pipelineInfo.stepIndex === -1) { + console.warn( + `[AutoMode] Step ${pipelineInfo.stepId} no longer exists in pipeline, completing feature without pipeline` + ); + + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + + await this.updateFeatureStatus(projectPath, featureId, finalStatus); + + this.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + passes: true, + message: + 'Pipeline step no longer exists - feature completed without remaining pipeline steps', + projectPath, + }); + + return; + } + + // Normal case: Valid pipeline step exists, has context + // Resume from the stuck step (re-execute the step that crashed) + if (!pipelineInfo.config) { + throw new Error('Pipeline config is null but stepIndex is valid - this should not happen'); + } + + return this.resumeFromPipelineStep( + projectPath, + feature, + useWorktrees, + pipelineInfo.stepIndex, + pipelineInfo.config + ); + } + + /** + * Resume pipeline execution from a specific step index. + * Re-executes the step that crashed (to handle partial completion), + * then continues executing all remaining pipeline steps in order. + * + * This method handles the complete pipeline resume workflow: + * - Validates feature and step index + * - Locates or creates git worktree if needed + * - Executes remaining steps starting from the crashed step + * - Updates feature status to verified/waiting_approval when complete + * - Emits progress events throughout execution + * + * @param {string} projectPath - Absolute path to the project directory + * @param {Feature} feature - The feature object (already loaded to avoid redundant reads) + * @param {boolean} useWorktrees - Whether to use git worktrees for isolation + * @param {number} startFromStepIndex - Zero-based index of the step to resume from + * @param {PipelineConfig} pipelineConfig - Pipeline config passed from detectPipelineStatus to avoid re-reading + * @returns {Promise} Resolves when pipeline execution completes successfully + * @throws {Error} If feature not found, step index invalid, or pipeline execution fails + * @private + */ + private async resumeFromPipelineStep( + projectPath: string, + feature: Feature, + useWorktrees: boolean, + startFromStepIndex: number, + pipelineConfig: PipelineConfig + ): Promise { + const featureId = feature.id; + + const sortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order); + + // Validate step index + if (startFromStepIndex < 0 || startFromStepIndex >= sortedSteps.length) { + throw new Error(`Invalid step index: ${startFromStepIndex}`); + } + + // Get steps to execute (from startFromStepIndex onwards) + const stepsToExecute = sortedSteps.slice(startFromStepIndex); + + console.log( + `[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}` + ); + + // Add to running features immediately + const abortController = new AbortController(); + this.runningFeatures.set(featureId, { + featureId, + projectPath, + worktreePath: null, // Will be set below + branchName: feature.branchName ?? null, + abortController, + isAutoMode: false, + startTime: Date.now(), + }); + + try { + // Validate project path + validateWorkingDirectory(projectPath); + + // Derive workDir from feature.branchName + let worktreePath: string | null = null; + const branchName = feature.branchName; + + if (useWorktrees && branchName) { + worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); + if (worktreePath) { + console.log(`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`); + } else { + console.warn( + `[AutoMode] Worktree for branch "${branchName}" not found, using project path` + ); + } + } + + const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); + validateWorkingDirectory(workDir); + + // Update running feature with worktree info + const runningFeature = this.runningFeatures.get(featureId); + if (runningFeature) { + runningFeature.worktreePath = worktreePath; + runningFeature.branchName = branchName ?? null; + } + + // Emit resume event + this.emitAutoModeEvent('auto_mode_feature_start', { + featureId, + projectPath, + feature: { + id: featureId, + title: feature.title || 'Resuming Pipeline', + description: feature.description, + }, + }); + + this.emitAutoModeEvent('auto_mode_progress', { + featureId, + content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`, + projectPath, + }); + + // Load autoLoadClaudeMd setting + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + this.settingsService, + '[AutoMode]' + ); + + // Execute remaining pipeline steps (starting from crashed step) + await this.executePipelineSteps( + projectPath, + featureId, + feature, + stepsToExecute, + workDir, + abortController, + autoLoadClaudeMd + ); + + // Determine final status + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + await this.updateFeatureStatus(projectPath, featureId, finalStatus); + + console.log('[AutoMode] Pipeline resume completed successfully'); + + this.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + passes: true, + message: 'Pipeline resumed and completed successfully', + projectPath, + }); + } catch (error) { + const errorInfo = classifyError(error); + + if (errorInfo.isAbort) { + this.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + passes: false, + message: 'Pipeline resume stopped by user', + projectPath, + }); + } else { + console.error(`[AutoMode] Pipeline resume failed for feature ${featureId}:`, error); + await this.updateFeatureStatus(projectPath, featureId, 'backlog'); + this.emitAutoModeEvent('auto_mode_error', { + featureId, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath, + }); + } + } finally { + this.runningFeatures.delete(featureId); + } + } + /** * Follow up on a feature with additional instructions */ @@ -2885,6 +3168,111 @@ Review the previous work and continue the implementation. If the feature appears }); } + /** + * Detect if a feature is stuck in a pipeline step and extract step information. + * Parses the feature status to determine if it's a pipeline status (e.g., 'pipeline_step_xyz'), + * loads the pipeline configuration, and validates that the step still exists. + * + * This method handles several scenarios: + * - Non-pipeline status: Returns default PipelineStatusInfo with isPipeline=false + * - Invalid pipeline status format: Returns isPipeline=true but null step info + * - Step deleted from config: Returns stepIndex=-1 to signal missing step + * - Valid pipeline step: Returns full step information and config + * + * @param {string} projectPath - Absolute path to the project directory + * @param {string} featureId - Unique identifier of the feature + * @param {FeatureStatusWithPipeline} currentStatus - Current feature status (may include pipeline step info) + * @returns {Promise} Information about the pipeline status and step + * @private + */ + private async detectPipelineStatus( + projectPath: string, + featureId: string, + currentStatus: FeatureStatusWithPipeline + ): Promise { + // Check if status is pipeline format using PipelineService + const isPipeline = pipelineService.isPipelineStatus(currentStatus); + + if (!isPipeline) { + return { + isPipeline: false, + stepId: null, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + } + + // Extract step ID using PipelineService + const stepId = pipelineService.getStepIdFromStatus(currentStatus); + + if (!stepId) { + console.warn( + `[AutoMode] Feature ${featureId} has invalid pipeline status format: ${currentStatus}` + ); + return { + isPipeline: true, + stepId: null, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + } + + // Load pipeline config + const config = await pipelineService.getPipelineConfig(projectPath); + + if (!config || config.steps.length === 0) { + // Pipeline config doesn't exist or empty - feature stuck with invalid pipeline status + console.warn( + `[AutoMode] Feature ${featureId} has pipeline status but no pipeline config exists` + ); + return { + isPipeline: true, + stepId, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + } + + // Find the step directly from config (already loaded, avoid redundant file read) + const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order); + const stepIndex = sortedSteps.findIndex((s) => s.id === stepId); + const step = stepIndex === -1 ? null : sortedSteps[stepIndex]; + + if (!step) { + // Step not found in current config - step was deleted/changed + console.warn( + `[AutoMode] Feature ${featureId} stuck in step ${stepId} which no longer exists in pipeline config` + ); + return { + isPipeline: true, + stepId, + stepIndex: -1, + totalSteps: sortedSteps.length, + step: null, + config, + }; + } + + console.log( + `[AutoMode] Detected pipeline status for feature ${featureId}: step ${stepIndex + 1}/${sortedSteps.length} (${step.name})` + ); + + return { + isPipeline: true, + stepId, + stepIndex, + totalSteps: sortedSteps.length, + step, + config, + }; + } + /** * Build a focused prompt for executing a single task. * Each task gets minimal context to keep the agent focused. @@ -3193,41 +3581,43 @@ IMPORTANT: Only include NON-OBVIOUS learnings with real reasoning. Skip trivial 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 hasClaudeKey = Boolean(process.env.ANTHROPIC_API_KEY); + let resolvedModel = model; - 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; + if (isClaudeModel(model) && !hasClaudeKey) { + const fallbackModel = feature.model + ? resolveModelString(feature.model, DEFAULT_MODELS.claude) + : null; + if (fallbackModel && !isClaudeModel(fallbackModel)) { + console.log( + `[AutoMode] Claude not configured for memory extraction; using feature model "${fallbackModel}".` + ); + resolvedModel = fallbackModel; + } else { + console.log( + '[AutoMode] Claude not configured for memory extraction; skipping learning extraction.' + ); + return; } } + const result = await simpleQuery({ + prompt: userPrompt, + model: resolvedModel, + cwd: projectPath, + maxTurns: 1, + allowedTools: [], + 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.', + }); + + const responseText = result.text; + console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`); console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`); diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 64ace35d..64dceb6a 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -49,13 +49,11 @@ export class ClaudeUsageService { /** * Execute the claude /usage command and return the output - * Uses platform-specific PTY implementation + * Uses node-pty on all platforms for consistency */ private executeClaudeUsageCommand(): Promise { - if (this.isWindows || this.isLinux) { - return this.executeClaudeUsageCommandPty(); - } - return this.executeClaudeUsageCommandMac(); + // Use node-pty on all platforms - it's more reliable than expect on macOS + return this.executeClaudeUsageCommandPty(); } /** @@ -67,24 +65,36 @@ export class ClaudeUsageService { let stderr = ''; let settled = false; - // Use a simple working directory (home or tmp) - const workingDirectory = process.env.HOME || '/tmp'; + // Use current working directory - likely already trusted by Claude CLI + const workingDirectory = process.cwd(); // Use 'expect' with an inline script to run claude /usage with a PTY - // Wait for "Current session" header, then wait for full output before exiting + // Running from cwd which should already be trusted const expectScript = ` - set timeout 20 + set timeout 30 spawn claude /usage + + # Wait for usage data or handle trust prompt if needed expect { - "Current session" { - sleep 2 - send "\\x1b" + -re "Ready to code|permission to work|Do you want to work" { + # Trust prompt appeared - send Enter to approve + sleep 1 + send "\\r" + exp_continue } - "Esc to cancel" { + "Current session" { + # Usage data appeared - wait for full output, then exit sleep 3 send "\\x1b" } - timeout {} + "% left" { + # Usage percentage appeared + sleep 3 + send "\\x1b" + } + timeout { + send "\\x1b" + } eof {} } expect eof @@ -158,14 +168,18 @@ export class ClaudeUsageService { let output = ''; let settled = false; let hasSeenUsageData = false; + let hasSeenTrustPrompt = false; - const workingDirectory = this.isWindows - ? process.env.USERPROFILE || os.homedir() || 'C:\\' - : process.env.HOME || os.homedir() || '/tmp'; + // Use current working directory (project dir) - most likely already trusted by Claude CLI + const workingDirectory = process.cwd(); // Use platform-appropriate shell and command const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; - const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage']; + // Use --add-dir to whitelist the current directory and bypass the trust prompt + // We don't pass /usage here, we'll type it into the REPL + const args = this.isWindows + ? ['/c', 'claude', '--add-dir', workingDirectory] + : ['-c', `claude --add-dir "${workingDirectory}"`]; let ptyProcess: any = null; @@ -181,8 +195,6 @@ export class ClaudeUsageService { } as Record, }); } 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); @@ -204,17 +216,60 @@ export class ClaudeUsageService { // Don't fail if we have data - return it instead if (output.includes('Current session')) { resolve(output); + } else if (hasSeenTrustPrompt) { + // Trust prompt was shown but we couldn't auto-approve it + reject( + new Error( + 'TRUST_PROMPT_PENDING: Claude CLI is waiting for folder permission. Please run "claude" in your terminal and approve access to continue.' + ) + ); } else { - reject(new Error('Command timed out')); + reject( + new Error( + 'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.' + ) + ); } } - }, this.timeout); + }, 45000); // 45 second timeout + + let hasSentCommand = false; + let hasApprovedTrust = false; ptyProcess.onData((data: string) => { output += data; - // Check if we've seen the usage data (look for "Current session") - if (!hasSeenUsageData && output.includes('Current session')) { + // Strip ANSI codes for easier matching + // eslint-disable-next-line no-control-regex + const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); + + // Check for specific authentication/permission errors + if ( + cleanOutput.includes('OAuth token does not meet scope requirement') || + cleanOutput.includes('permission_error') || + cleanOutput.includes('token_expired') || + cleanOutput.includes('authentication_error') + ) { + if (!settled) { + settled = true; + if (ptyProcess && !ptyProcess.killed) { + ptyProcess.kill(); + } + reject( + new Error( + "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." + ) + ); + } + return; + } + + // Check if we've seen the usage data (look for "Current session" or the TUI Usage header) + if ( + !hasSeenUsageData && + (cleanOutput.includes('Current session') || + (cleanOutput.includes('Usage') && cleanOutput.includes('% left'))) + ) { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { @@ -228,16 +283,62 @@ export class ClaudeUsageService { } }, 2000); } - }, 2000); + }, 3000); + } + + // Handle Trust Dialog - multiple variants: + // - "Do you want to work in this folder?" + // - "Ready to code here?" / "I'll need permission to work with your files" + // Since we are running in cwd (project dir), it is safe to approve. + if ( + !hasApprovedTrust && + (cleanOutput.includes('Do you want to work in this folder?') || + cleanOutput.includes('Ready to code here') || + cleanOutput.includes('permission to work with your files')) + ) { + hasApprovedTrust = true; + hasSeenTrustPrompt = true; + // Wait a tiny bit to ensure prompt is ready, then send Enter + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + ptyProcess.write('\r'); + } + }, 1000); + } + + // Detect REPL prompt and send /usage command + if ( + !hasSentCommand && + (cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts')) + ) { + hasSentCommand = true; + // Wait for REPL to fully settle + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + // Send command with carriage return + ptyProcess.write('/usage\r'); + + // Send another enter after 1 second to confirm selection if autocomplete menu appeared + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + ptyProcess.write('\r'); + } + }, 1200); + } + }, 1500); } // Fallback: if we see "Esc to cancel" but haven't seen usage data yet - if (!hasSeenUsageData && output.includes('Esc to cancel')) { + if ( + !hasSeenUsageData && + cleanOutput.includes('Esc to cancel') && + !cleanOutput.includes('Do you want to work in this folder?') + ) { setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key } - }, 3000); + }, 5000); } }); @@ -246,8 +347,11 @@ export class ClaudeUsageService { if (settled) return; settled = true; - // Check for authentication errors in output - if (output.includes('token_expired') || output.includes('authentication_error')) { + if ( + output.includes('token_expired') || + output.includes('authentication_error') || + output.includes('permission_error') + ) { reject(new Error("Authentication required - please run 'claude login'")); return; } diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index cac27e92..5187c0c8 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -12,24 +12,123 @@ import * as secureFs from '../lib/secure-fs.js'; import path from 'path'; import net from 'net'; import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; const logger = createLogger('DevServerService'); +// Maximum scrollback buffer size (characters) - matches TerminalService pattern +const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server + +// Throttle output to prevent overwhelming WebSocket under heavy load +const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback +const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency + export interface DevServerInfo { worktreePath: string; port: number; url: string; process: ChildProcess | null; startedAt: Date; + // Scrollback buffer for log history (replay on reconnect) + scrollbackBuffer: string; + // Pending output to be flushed to subscribers + outputBuffer: string; + // Throttle timer for batching output + flushTimeout: NodeJS.Timeout | null; + // Flag to indicate server is stopping (prevents output after stop) + stopping: boolean; } // Port allocation starts at 3001 to avoid conflicts with common dev ports const BASE_PORT = 3001; const MAX_PORT = 3099; // Safety limit +// Common livereload ports that may need cleanup when stopping dev servers +const LIVERELOAD_PORTS = [35729, 35730, 35731] as const; + class DevServerService { private runningServers: Map = new Map(); private allocatedPorts: Set = new Set(); + private emitter: EventEmitter | null = null; + + /** + * Set the event emitter for streaming log events + * Called during service initialization with the global event emitter + */ + setEventEmitter(emitter: EventEmitter): void { + this.emitter = emitter; + } + + /** + * Append data to scrollback buffer with size limit enforcement + * Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE + */ + private appendToScrollback(server: DevServerInfo, data: string): void { + server.scrollbackBuffer += data; + if (server.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) { + server.scrollbackBuffer = server.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); + } + } + + /** + * Flush buffered output to WebSocket subscribers + * Sends batched output to prevent overwhelming clients under heavy load + */ + private flushOutput(server: DevServerInfo): void { + // Skip flush if server is stopping or buffer is empty + if (server.stopping || server.outputBuffer.length === 0) { + server.flushTimeout = null; + return; + } + + let dataToSend = server.outputBuffer; + if (dataToSend.length > OUTPUT_BATCH_SIZE) { + // Send in batches if buffer is large + dataToSend = server.outputBuffer.slice(0, OUTPUT_BATCH_SIZE); + server.outputBuffer = server.outputBuffer.slice(OUTPUT_BATCH_SIZE); + // Schedule another flush for remaining data + server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS); + } else { + server.outputBuffer = ''; + server.flushTimeout = null; + } + + // Emit output event for WebSocket streaming + if (this.emitter) { + this.emitter.emit('dev-server:output', { + worktreePath: server.worktreePath, + content: dataToSend, + timestamp: new Date().toISOString(), + }); + } + } + + /** + * Handle incoming stdout/stderr data from dev server process + * Buffers data for scrollback replay and schedules throttled emission + */ + private handleProcessOutput(server: DevServerInfo, data: Buffer): void { + // Skip output if server is stopping + if (server.stopping) { + return; + } + + const content = data.toString(); + + // Append to scrollback buffer for replay on reconnect + this.appendToScrollback(server, content); + + // Buffer output for throttled live delivery + server.outputBuffer += content; + + // Schedule flush if not already scheduled + if (!server.flushTimeout) { + server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS); + } + + // Also log for debugging (existing behavior) + logger.debug(`[Port${server.port}] ${content.trim()}`); + } /** * Check if a port is available (not in use by system or by us) @@ -244,10 +343,9 @@ class DevServerService { // Reserve the port (port was already force-killed in findAvailablePort) this.allocatedPorts.add(port); - // Also kill common related ports (livereload uses 35729 by default) + // Also kill common related ports (livereload, etc.) // Some dev servers use fixed ports for HMR/livereload regardless of main port - const commonRelatedPorts = [35729, 35730, 35731]; - for (const relatedPort of commonRelatedPorts) { + for (const relatedPort of LIVERELOAD_PORTS) { this.killProcessOnPort(relatedPort); } @@ -259,9 +357,14 @@ class DevServerService { logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`); // Spawn the dev process with PORT environment variable + // FORCE_COLOR enables colored output even when not running in a TTY const env = { ...process.env, PORT: String(port), + FORCE_COLOR: '1', + // Some tools use these additional env vars for color detection + COLORTERM: 'truecolor', + TERM: 'xterm-256color', }; const devProcess = spawn(devCommand.cmd, devCommand.args, { @@ -274,32 +377,66 @@ class DevServerService { // Track if process failed early using object to work around TypeScript narrowing const status = { error: null as string | null, exited: false }; - // Log output for debugging + // Create server info early so we can reference it in handlers + // We'll add it to runningServers after verifying the process started successfully + const serverInfo: DevServerInfo = { + worktreePath, + port, + url: `http://localhost:${port}`, + process: devProcess, + startedAt: new Date(), + scrollbackBuffer: '', + outputBuffer: '', + flushTimeout: null, + stopping: false, + }; + + // Capture stdout with buffer management and event emission if (devProcess.stdout) { devProcess.stdout.on('data', (data: Buffer) => { - logger.debug(`[Port${port}] ${data.toString().trim()}`); + this.handleProcessOutput(serverInfo, data); }); } + // Capture stderr with buffer management and event emission if (devProcess.stderr) { devProcess.stderr.on('data', (data: Buffer) => { - const msg = data.toString().trim(); - logger.debug(`[Port${port}] ${msg}`); + this.handleProcessOutput(serverInfo, data); }); } + // Helper to clean up resources and emit stop event + const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => { + if (serverInfo.flushTimeout) { + clearTimeout(serverInfo.flushTimeout); + serverInfo.flushTimeout = null; + } + + // Emit stopped event (only if not already stopping - prevents duplicate events) + if (this.emitter && !serverInfo.stopping) { + this.emitter.emit('dev-server:stopped', { + worktreePath, + port, + exitCode, + error: errorMessage, + timestamp: new Date().toISOString(), + }); + } + + this.allocatedPorts.delete(port); + this.runningServers.delete(worktreePath); + }; + devProcess.on('error', (error) => { logger.error(`Process error:`, error); status.error = error.message; - this.allocatedPorts.delete(port); - this.runningServers.delete(worktreePath); + cleanupAndEmitStop(null, error.message); }); devProcess.on('exit', (code) => { logger.info(`Process for ${worktreePath} exited with code ${code}`); status.exited = true; - this.allocatedPorts.delete(port); - this.runningServers.delete(worktreePath); + cleanupAndEmitStop(code); }); // Wait a moment to see if the process fails immediately @@ -319,16 +456,19 @@ class DevServerService { }; } - const serverInfo: DevServerInfo = { - worktreePath, - port, - url: `http://localhost:${port}`, - process: devProcess, - startedAt: new Date(), - }; - + // Server started successfully - add to running servers map this.runningServers.set(worktreePath, serverInfo); + // Emit started event for WebSocket subscribers + if (this.emitter) { + this.emitter.emit('dev-server:started', { + worktreePath, + port, + url: serverInfo.url, + timestamp: new Date().toISOString(), + }); + } + return { success: true, result: { @@ -365,6 +505,28 @@ class DevServerService { logger.info(`Stopping dev server for ${worktreePath}`); + // Mark as stopping to prevent further output events + server.stopping = true; + + // Clean up flush timeout to prevent memory leaks + if (server.flushTimeout) { + clearTimeout(server.flushTimeout); + server.flushTimeout = null; + } + + // Clear any pending output buffer + server.outputBuffer = ''; + + // Emit stopped event immediately so UI updates right away + if (this.emitter) { + this.emitter.emit('dev-server:stopped', { + worktreePath, + port: server.port, + exitCode: null, // Will be populated by exit handler if process exits normally + timestamp: new Date().toISOString(), + }); + } + // Kill the process if (server.process && !server.process.killed) { server.process.kill('SIGTERM'); @@ -422,6 +584,41 @@ class DevServerService { return this.runningServers.get(worktreePath); } + /** + * Get buffered logs for a worktree's dev server + * Returns the scrollback buffer containing historical log output + * Used by the API to serve logs to clients on initial connection + */ + getServerLogs(worktreePath: string): { + success: boolean; + result?: { + worktreePath: string; + port: number; + logs: string; + startedAt: string; + }; + error?: string; + } { + const server = this.runningServers.get(worktreePath); + + if (!server) { + return { + success: false, + error: `No dev server running for worktree: ${worktreePath}`, + }; + } + + return { + success: true, + result: { + worktreePath: server.worktreePath, + port: server.port, + logs: server.scrollbackBuffer, + startedAt: server.startedAt.toISOString(), + }, + }; + } + /** * Get all allocated ports */ diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index f1dfd45c..5f57ad83 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -431,6 +431,8 @@ export class SettingsService { */ async getMaskedCredentials(): Promise<{ anthropic: { configured: boolean; masked: string }; + google: { configured: boolean; masked: string }; + openai: { configured: boolean; masked: string }; }> { const credentials = await this.getCredentials(); @@ -444,6 +446,14 @@ export class SettingsService { configured: !!credentials.apiKeys.anthropic, masked: maskKey(credentials.apiKeys.anthropic), }, + google: { + configured: !!credentials.apiKeys.google, + masked: maskKey(credentials.apiKeys.google), + }, + openai: { + configured: !!credentials.apiKeys.openai, + masked: maskKey(credentials.apiKeys.openai), + }, }; } diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index f107c4f4..b3d2df79 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -12,6 +12,8 @@ describe('claude-provider.ts', () => { vi.clearAllMocks(); provider = new ClaudeProvider(); delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; }); describe('getName', () => { @@ -267,6 +269,93 @@ describe('claude-provider.ts', () => { }); }); + describe('environment variable passthrough', () => { + afterEach(() => { + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; + }); + + it('should pass ANTHROPIC_BASE_URL to SDK env', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://custom.example.com/v1'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_BASE_URL: 'https://custom.example.com/v1', + }), + }), + }); + }); + + it('should pass ANTHROPIC_AUTH_TOKEN to SDK env', async () => { + process.env.ANTHROPIC_AUTH_TOKEN = 'custom-auth-token'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_AUTH_TOKEN: 'custom-auth-token', + }), + }), + }); + }); + + it('should pass both custom endpoint vars together', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://gateway.example.com'; + process.env.ANTHROPIC_AUTH_TOKEN = 'gateway-token'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_BASE_URL: 'https://gateway.example.com', + ANTHROPIC_AUTH_TOKEN: 'gateway-token', + }), + }), + }); + }); + }); + describe('getAvailableModels', () => { it('should return 4 Claude models', () => { const models = provider.getAvailableModels(); diff --git a/apps/server/tests/unit/providers/codex-provider.test.ts b/apps/server/tests/unit/providers/codex-provider.test.ts index ada1aae1..6ca69d86 100644 --- a/apps/server/tests/unit/providers/codex-provider.test.ts +++ b/apps/server/tests/unit/providers/codex-provider.test.ts @@ -257,7 +257,7 @@ describe('codex-provider.ts', () => { expect(results[1].result).toBe('Hello from SDK'); }); - it('uses the CLI when tools are requested even if an API key is present', async () => { + it('uses the SDK when API key is present, even for tool requests (to avoid OAuth issues)', async () => { process.env[OPENAI_API_KEY_ENV] = 'sk-test'; vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); @@ -270,8 +270,8 @@ describe('codex-provider.ts', () => { }) ); - expect(codexRunMock).not.toHaveBeenCalled(); - expect(spawnJSONLProcess).toHaveBeenCalled(); + expect(codexRunMock).toHaveBeenCalled(); + expect(spawnJSONLProcess).not.toHaveBeenCalled(); }); it('falls back to CLI when no tools are requested and no API key is available', async () => { diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index d16802f6..4b3f3c94 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -551,7 +551,7 @@ Resets in 2h expect(result.sessionPercentage).toBe(35); expect(pty.spawn).toHaveBeenCalledWith( 'cmd.exe', - ['/c', 'claude', '/usage'], + ['/c', 'claude', '--add-dir', 'C:\\Users\\testuser'], expect.any(Object) ); }); @@ -582,8 +582,8 @@ Resets in 2h // Simulate seeing usage data dataCallback!(mockOutput); - // Advance time to trigger escape key sending - vi.advanceTimersByTime(2100); + // Advance time to trigger escape key sending (impl uses 3000ms delay) + vi.advanceTimersByTime(3100); expect(mockPty.write).toHaveBeenCalledWith('\x1b'); @@ -614,9 +614,10 @@ Resets in 2h const promise = windowsService.fetchUsageData(); dataCallback!('authentication_error'); - exitCallback!({ exitCode: 1 }); - await expect(promise).rejects.toThrow('Authentication required'); + await expect(promise).rejects.toThrow( + "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." + ); }); it('should handle timeout with no data on Windows', async () => { @@ -628,14 +629,18 @@ Resets in 2h onExit: vi.fn(), write: vi.fn(), kill: vi.fn(), + killed: false, }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); const promise = windowsService.fetchUsageData(); - vi.advanceTimersByTime(31000); + // Advance time past timeout (45 seconds) + vi.advanceTimersByTime(46000); - await expect(promise).rejects.toThrow('Command timed out'); + await expect(promise).rejects.toThrow( + 'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.' + ); expect(mockPty.kill).toHaveBeenCalled(); vi.useRealTimers(); @@ -654,6 +659,7 @@ Resets in 2h onExit: vi.fn(), write: vi.fn(), kill: vi.fn(), + killed: false, }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); @@ -662,8 +668,8 @@ Resets in 2h // Simulate receiving usage data dataCallback!('Current session\n65% left\nResets in 2h'); - // Advance time past timeout (30 seconds) - vi.advanceTimersByTime(31000); + // Advance time past timeout (45 seconds) + vi.advanceTimersByTime(46000); // Should resolve with data instead of rejecting const result = await promise; @@ -686,6 +692,7 @@ Resets in 2h onExit: vi.fn(), write: vi.fn(), kill: vi.fn(), + killed: false, }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); @@ -694,8 +701,8 @@ Resets in 2h // Simulate seeing usage data dataCallback!('Current session\n65% left'); - // Advance 2s to trigger ESC - vi.advanceTimersByTime(2100); + // Advance 3s to trigger ESC (impl uses 3000ms delay) + vi.advanceTimersByTime(3100); expect(mockPty.write).toHaveBeenCalledWith('\x1b'); // Advance another 2s to trigger SIGTERM fallback diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index d7bc54d4..6db837e3 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -70,6 +70,8 @@ const eslintConfig = defineConfig([ AbortSignal: 'readonly', Audio: 'readonly', ScrollBehavior: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', // Timers setTimeout: 'readonly', setInterval: 'readonly', diff --git a/apps/ui/package.json b/apps/ui/package.json index 61bd5ae8..384dc581 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -56,6 +56,7 @@ "@radix-ui/react-label": "2.1.8", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.4", diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 31a71e85..c27cd5e7 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -5,6 +5,7 @@ import { router } from './utils/router'; import { SplashScreen } from './components/splash-screen'; import { useSettingsSync } from './hooks/use-settings-sync'; import { useCursorStatusInit } from './hooks/use-cursor-status-init'; +import { useProviderAuthInit } from './hooks/use-provider-auth-init'; import './styles/global.css'; import './styles/theme-imports'; @@ -24,8 +25,11 @@ export default function App() { useEffect(() => { if (import.meta.env.DEV) { const clearPerfEntries = () => { - performance.clearMarks(); - performance.clearMeasures(); + // Check if window.performance is available before calling its methods + if (window.performance) { + window.performance.clearMarks(); + window.performance.clearMeasures(); + } }; const interval = setInterval(clearPerfEntries, 5000); return () => clearInterval(interval); @@ -45,6 +49,9 @@ export default function App() { // Initialize Cursor CLI status at startup useCursorStatusInit(); + // Initialize Provider auth status at startup (for Claude/Codex usage display) + useProviderAuthInit(); + const handleSplashComplete = useCallback(() => { sessionStorage.setItem('automaker-splash-shown', 'true'); setShowSplash(false); diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index 227f16e1..d51e316c 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -11,6 +11,7 @@ import { useSetupStore } from '@/store/setup-store'; const ERROR_CODES = { API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE', AUTH_ERROR: 'AUTH_ERROR', + TRUST_PROMPT: 'TRUST_PROMPT', UNKNOWN: 'UNKNOWN', } as const; @@ -55,8 +56,12 @@ export function ClaudeUsagePopover() { } const data = await api.claude.getUsage(); if ('error' in data) { + // Detect trust prompt error + const isTrustPrompt = + data.error === 'Trust prompt pending' || + (data.message && data.message.includes('folder permission')); setError({ - code: ERROR_CODES.AUTH_ERROR, + code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR, message: data.message || data.error, }); return; @@ -257,6 +262,11 @@ export function ClaudeUsagePopover() {

{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( 'Ensure the Electron bridge is running or restart the app' + ) : error.code === ERROR_CODES.TRUST_PROMPT ? ( + <> + Run claude in your + terminal and approve access to continue + ) : ( <> Make sure Claude CLI is installed and authenticated via{' '} diff --git a/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx b/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx new file mode 100644 index 00000000..31e39367 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx @@ -0,0 +1,187 @@ +import { useState, useRef } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Upload, X, ImageIcon } from 'lucide-react'; +import { useAppStore } from '@/store/app-store'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import type { Project } from '@/lib/electron'; +import { IconPicker } from './icon-picker'; + +interface EditProjectDialogProps { + project: Project; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) { + const { setProjectName, setProjectIcon, setProjectCustomIcon } = useAppStore(); + const [name, setName] = useState(project.name); + const [icon, setIcon] = useState((project as any).icon || null); + const [customIconPath, setCustomIconPath] = useState( + (project as any).customIconPath || null + ); + const [isUploadingIcon, setIsUploadingIcon] = useState(false); + const fileInputRef = useRef(null); + + const handleSave = () => { + if (name.trim() !== project.name) { + setProjectName(project.id, name.trim()); + } + if (icon !== (project as any).icon) { + setProjectIcon(project.id, icon); + } + if (customIconPath !== (project as any).customIconPath) { + setProjectCustomIcon(project.id, customIconPath); + } + onOpenChange(false); + }; + + const handleCustomIconUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Validate file type + const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + if (!validTypes.includes(file.type)) { + return; + } + + // Validate file size (max 2MB for icons) + if (file.size > 2 * 1024 * 1024) { + return; + } + + setIsUploadingIcon(true); + try { + // Convert to base64 + const reader = new FileReader(); + reader.onload = async () => { + const base64Data = reader.result as string; + const result = await getHttpApiClient().saveImageToTemp( + base64Data, + `project-icon-${file.name}`, + file.type, + project.path + ); + if (result.success && result.path) { + setCustomIconPath(result.path); + // Clear the Lucide icon when custom icon is set + setIcon(null); + } + setIsUploadingIcon(false); + }; + reader.readAsDataURL(file); + } catch { + setIsUploadingIcon(false); + } + }; + + const handleRemoveCustomIcon = () => { + setCustomIconPath(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + return ( +

+ + + Edit Project + + +
+ {/* Project Name */} +
+ + setName(e.target.value)} + placeholder="Enter project name" + /> +
+ + {/* Icon Picker */} +
+ +

+ Choose a preset icon or upload a custom image +

+ + {/* Custom Icon Upload */} +
+
+ {customIconPath ? ( +
+ Custom project icon + +
+ ) : ( +
+ +
+ )} +
+ + +

+ PNG, JPG, GIF or WebP. Max 2MB. +

+
+
+
+ + {/* Preset Icon Picker - only show if no custom icon */} + {!customIconPath && } +
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx new file mode 100644 index 00000000..31ce1d3d --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx @@ -0,0 +1,520 @@ +import { useState } from 'react'; +import { X, Search } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +interface IconPickerProps { + selectedIcon: string | null; + onSelectIcon: (icon: string | null) => void; +} + +// Comprehensive list of project-related icons from Lucide +// Organized by category for easier browsing +const POPULAR_ICONS = [ + // Folders & Files + 'Folder', + 'FolderOpen', + 'FolderCode', + 'FolderGit', + 'FolderKanban', + 'FolderTree', + 'FolderInput', + 'FolderOutput', + 'FolderPlus', + 'File', + 'FileCode', + 'FileText', + 'FileJson', + 'FileImage', + 'FileVideo', + 'FileAudio', + 'FileSpreadsheet', + 'Files', + 'Archive', + + // Code & Development + 'Code', + 'Code2', + 'Braces', + 'Brackets', + 'Terminal', + 'TerminalSquare', + 'Command', + 'GitBranch', + 'GitCommit', + 'GitMerge', + 'GitPullRequest', + 'GitCompare', + 'GitFork', + 'GitHub', + 'Gitlab', + 'Bitbucket', + 'Vscode', + + // Packages & Containers + 'Package', + 'PackageSearch', + 'PackageCheck', + 'PackageX', + 'Box', + 'Boxes', + 'Container', + + // UI & Design + 'Layout', + 'LayoutGrid', + 'LayoutList', + 'LayoutDashboard', + 'LayoutTemplate', + 'Layers', + 'Layers2', + 'Layers3', + 'Blocks', + 'Component', + 'Palette', + 'Paintbrush', + 'Brush', + 'PenTool', + 'Ruler', + 'Grid', + 'Grid3x3', + 'Square', + 'RectangleHorizontal', + 'RectangleVertical', + 'Circle', + + // Tools & Settings + 'Cog', + 'Settings', + 'Settings2', + 'Wrench', + 'Hammer', + 'Screwdriver', + 'WrenchIcon', + 'Tool', + 'ScrewdriverWrench', + 'Sliders', + 'SlidersHorizontal', + 'Filter', + 'FilterX', + + // Technology & Infrastructure + 'Server', + 'ServerCrash', + 'ServerCog', + 'Database', + 'DatabaseBackup', + 'CloudUpload', + 'CloudDownload', + 'CloudOff', + 'Globe', + 'Globe2', + 'Network', + 'Wifi', + 'WifiOff', + 'Router', + 'Cpu', + 'MemoryStick', + 'HardDrive', + 'HardDriveIcon', + 'CircuitBoard', + 'Microchip', + 'Monitor', + 'MonitorSpeaker', + 'Laptop', + 'Smartphone', + 'Tablet', + 'Mouse', + 'Keyboard', + 'Headphones', + 'Printer', + 'Scanner', + + // Workflow & Process + 'Workflow', + 'Zap', + 'Rocket', + 'Flame', + 'Lightning', + 'Bolt', + 'Target', + 'Flag', + 'FlagTriangleRight', + 'CheckCircle', + 'CheckCircle2', + 'XCircle', + 'AlertCircle', + 'Info', + 'HelpCircle', + 'Clock', + 'Timer', + 'Stopwatch', + 'Calendar', + 'CalendarDays', + 'CalendarCheck', + 'CalendarClock', + + // Security & Access + 'Shield', + 'ShieldCheck', + 'ShieldAlert', + 'ShieldOff', + 'Lock', + 'Unlock', + 'Key', + 'KeyRound', + 'Eye', + 'EyeOff', + 'User', + 'Users', + 'UserCheck', + 'UserX', + 'UserPlus', + 'UserCog', + + // Business & Finance + 'Briefcase', + 'Building', + 'Building2', + 'Store', + 'ShoppingCart', + 'ShoppingBag', + 'CreditCard', + 'Wallet', + 'DollarSign', + 'Euro', + 'PoundSterling', + 'Yen', + 'Coins', + 'Receipt', + 'ChartBar', + 'ChartLine', + 'ChartPie', + 'TrendingUp', + 'TrendingDown', + 'Activity', + 'BarChart', + 'LineChart', + 'PieChart', + + // Communication & Media + 'MessageSquare', + 'MessageCircle', + 'Mail', + 'MailOpen', + 'Send', + 'Inbox', + 'Phone', + 'PhoneCall', + 'Video', + 'VideoOff', + 'Camera', + 'CameraOff', + 'Image', + 'ImageIcon', + 'Film', + 'Music', + 'Mic', + 'MicOff', + 'Volume', + 'Volume2', + 'VolumeX', + 'Radio', + 'Podcast', + + // Social & Community + 'Heart', + 'HeartHandshake', + 'Star', + 'StarOff', + 'ThumbsUp', + 'ThumbsDown', + 'Share', + 'Share2', + 'Link', + 'Link2', + 'ExternalLink', + 'AtSign', + 'Hash', + 'Hashtag', + 'Tag', + 'Tags', + + // Navigation & Location + 'Compass', + 'Map', + 'MapPin', + 'Navigation', + 'Navigation2', + 'Route', + 'Plane', + 'Car', + 'Bike', + 'Ship', + 'Train', + 'Bus', + + // Science & Education + 'FlaskConical', + 'FlaskRound', + 'Beaker', + 'TestTube', + 'TestTube2', + 'Microscope', + 'Atom', + 'Brain', + 'GraduationCap', + 'Book', + 'BookOpen', + 'BookMarked', + 'Library', + 'School', + 'University', + + // Food & Health + 'Coffee', + 'Utensils', + 'UtensilsCrossed', + 'Apple', + 'Cherry', + 'Cookie', + 'Cake', + 'Pizza', + 'Beer', + 'Wine', + 'HeartPulse', + 'Dumbbell', + 'Running', + + // Nature & Weather + 'Tree', + 'TreePine', + 'Leaf', + 'Flower', + 'Flower2', + 'Sun', + 'Moon', + 'CloudRain', + 'CloudSnow', + 'CloudLightning', + 'Droplet', + 'Wind', + 'Snowflake', + 'Umbrella', + + // Objects & Symbols + 'Puzzle', + 'PuzzleIcon', + 'Gamepad', + 'Gamepad2', + 'Dice', + 'Dice1', + 'Dice6', + 'Gem', + 'Crown', + 'Trophy', + 'Medal', + 'Award', + 'Gift', + 'GiftIcon', + 'Bell', + 'BellOff', + 'BellRing', + 'Home', + 'House', + 'DoorOpen', + 'DoorClosed', + 'Window', + 'Lightbulb', + 'LightbulbOff', + 'Candle', + 'Flashlight', + 'FlashlightOff', + 'Battery', + 'BatteryFull', + 'BatteryLow', + 'BatteryCharging', + 'Plug', + 'PlugZap', + 'Power', + 'PowerOff', + + // Arrows & Directions + 'ArrowRight', + 'ArrowLeft', + 'ArrowUp', + 'ArrowDown', + 'ArrowUpRight', + 'ArrowDownRight', + 'ArrowDownLeft', + 'ArrowUpLeft', + 'ChevronRight', + 'ChevronLeft', + 'ChevronUp', + 'ChevronDown', + 'Move', + 'MoveUp', + 'MoveDown', + 'MoveLeft', + 'MoveRight', + 'RotateCw', + 'RotateCcw', + 'RefreshCw', + 'RefreshCcw', + + // Shapes & Symbols + 'Diamond', + 'Pentagon', + 'Cross', + 'Plus', + 'Minus', + 'X', + 'Check', + 'Divide', + 'Equal', + 'Infinity', + 'Percent', + + // Miscellaneous + 'Bot', + 'Wand', + 'Wand2', + 'Magic', + 'Stars', + 'Comet', + 'Satellite', + 'SatelliteDish', + 'Radar', + 'RadarIcon', + 'Scan', + 'ScanLine', + 'QrCode', + 'Barcode', + 'ScanSearch', + 'Search', + 'SearchX', + 'ZoomIn', + 'ZoomOut', + 'Maximize', + 'Minimize', + 'Maximize2', + 'Minimize2', + 'Expand', + 'Shrink', + 'Copy', + 'CopyCheck', + 'Clipboard', + 'ClipboardCheck', + 'ClipboardCopy', + 'ClipboardList', + 'ClipboardPaste', + 'Scissors', + 'Cut', + 'FileEdit', + 'Pen', + 'Pencil', + 'Eraser', + 'Trash', + 'Trash2', + 'Delete', + 'ArchiveRestore', + 'Download', + 'Upload', + 'Save', + 'SaveAll', + 'FilePlus', + 'FileMinus', + 'FileX', + 'FileCheck', + 'FileQuestion', + 'FileWarning', + 'FileSearch', + 'FolderSearch', + 'FolderX', + 'FolderCheck', + 'FolderMinus', + 'FolderSync', + 'FolderUp', + 'FolderDown', +]; + +export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) { + const [search, setSearch] = useState(''); + + const filteredIcons = POPULAR_ICONS.filter((icon) => + icon.toLowerCase().includes(search.toLowerCase()) + ); + + const getIconComponent = (iconName: string) => { + return (LucideIcons as Record>)[iconName]; + }; + + return ( +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search icons..." + className="pl-9" + /> +
+ + {/* Selected Icon Display */} + {selectedIcon && ( +
+
+ {(() => { + const IconComponent = getIconComponent(selectedIcon); + return IconComponent ? : null; + })()} + {selectedIcon} +
+ +
+ )} + + {/* Icons Grid */} + +
+ {filteredIcons.map((iconName) => { + const IconComponent = getIconComponent(iconName); + if (!IconComponent) return null; + + const isSelected = selectedIcon === iconName; + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/apps/ui/src/components/layout/project-switcher/components/index.ts b/apps/ui/src/components/layout/project-switcher/components/index.ts new file mode 100644 index 00000000..86073ca1 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/index.ts @@ -0,0 +1,4 @@ +export { ProjectSwitcherItem } from './project-switcher-item'; +export { ProjectContextMenu } from './project-context-menu'; +export { EditProjectDialog } from './edit-project-dialog'; +export { IconPicker } from './icon-picker'; diff --git a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx new file mode 100644 index 00000000..39a7b652 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -0,0 +1,333 @@ +import { useEffect, useRef, useState, memo } from 'react'; +import type { LucideIcon } from 'lucide-react'; +import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { type ThemeMode, useAppStore } from '@/store/app-store'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import type { Project } from '@/lib/electron'; +import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants'; +import { useThemePreview } from '@/components/layout/sidebar/hooks'; + +// Constants for z-index values +const Z_INDEX = { + CONTEXT_MENU: 100, + THEME_SUBMENU: 101, +} as const; + +// Theme option type - using ThemeMode for type safety +interface ThemeOption { + value: ThemeMode; + label: string; + icon: LucideIcon; + color: string; +} + +// Reusable theme button component to avoid duplication (DRY principle) +interface ThemeButtonProps { + option: ThemeOption; + isSelected: boolean; + onPointerEnter: () => void; + onPointerLeave: (e: React.PointerEvent) => void; + onClick: () => void; +} + +const ThemeButton = memo(function ThemeButton({ + option, + isSelected, + onPointerEnter, + onPointerLeave, + onClick, +}: ThemeButtonProps) { + const Icon = option.icon; + return ( + + ); +}); + +// Reusable theme column component +interface ThemeColumnProps { + title: string; + icon: LucideIcon; + themes: ThemeOption[]; + selectedTheme: ThemeMode | null; + onPreviewEnter: (value: ThemeMode) => void; + onPreviewLeave: (e: React.PointerEvent) => void; + onSelect: (value: ThemeMode) => void; +} + +const ThemeColumn = memo(function ThemeColumn({ + title, + icon: Icon, + themes, + selectedTheme, + onPreviewEnter, + onPreviewLeave, + onSelect, +}: ThemeColumnProps) { + return ( +
+
+ + {title} +
+
+ {themes.map((option) => ( + onPreviewEnter(option.value)} + onPointerLeave={onPreviewLeave} + onClick={() => onSelect(option.value)} + /> + ))} +
+
+ ); +}); + +interface ProjectContextMenuProps { + project: Project; + position: { x: number; y: number }; + onClose: () => void; + onEdit: (project: Project) => void; +} + +export function ProjectContextMenu({ + project, + position, + onClose, + onEdit, +}: ProjectContextMenuProps) { + const menuRef = useRef(null); + const { + moveProjectToTrash, + theme: globalTheme, + setTheme, + setProjectTheme, + setPreviewTheme, + } = useAppStore(); + const [showRemoveDialog, setShowRemoveDialog] = useState(false); + const [showThemeSubmenu, setShowThemeSubmenu] = useState(false); + const themeSubmenuRef = useRef(null); + + const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setPreviewTheme(null); + onClose(); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setPreviewTheme(null); + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [onClose, setPreviewTheme]); + + const handleEdit = () => { + onEdit(project); + }; + + const handleRemove = () => { + setShowRemoveDialog(true); + }; + + const handleThemeSelect = (value: ThemeMode | '') => { + setPreviewTheme(null); + if (value !== '') { + setTheme(value); + } else { + setTheme(globalTheme); + } + setProjectTheme(project.id, value === '' ? null : value); + setShowThemeSubmenu(false); + }; + + const handleConfirmRemove = () => { + moveProjectToTrash(project.id); + onClose(); + }; + + return ( + <> +
+
+ + + {/* Theme Submenu Trigger */} +
setShowThemeSubmenu(true)} + onMouseLeave={() => { + setShowThemeSubmenu(false); + setPreviewTheme(null); + }} + > + + + {/* Theme Submenu */} + {showThemeSubmenu && ( +
+
+ {/* Use Global Option */} + + +
+ + {/* Two Column Layout - Using reusable ThemeColumn component */} +
+ + +
+
+
+ )} +
+ + +
+
+ + + + ); +} diff --git a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx new file mode 100644 index 00000000..b4434f8b --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx @@ -0,0 +1,116 @@ +import { Folder, LucideIcon } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; +import type { Project } from '@/lib/electron'; + +interface ProjectSwitcherItemProps { + project: Project; + isActive: boolean; + hotkeyIndex?: number; // 0-9 for hotkeys 1-9, 0 + onClick: () => void; + onContextMenu: (event: React.MouseEvent) => void; +} + +export function ProjectSwitcherItem({ + project, + isActive, + hotkeyIndex, + onClick, + onContextMenu, +}: ProjectSwitcherItemProps) { + // Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0" + const hotkeyLabel = + hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9 + ? hotkeyIndex === 9 + ? '0' + : String(hotkeyIndex + 1) + : undefined; + // Get the icon component from lucide-react + const getIconComponent = (): LucideIcon => { + if (project.icon && project.icon in LucideIcons) { + return (LucideIcons as Record)[project.icon]; + } + return Folder; + }; + + const IconComponent = getIconComponent(); + const hasCustomIcon = !!project.customIconPath; + + return ( + + ); +} diff --git a/apps/ui/src/components/layout/project-switcher/index.ts b/apps/ui/src/components/layout/project-switcher/index.ts new file mode 100644 index 00000000..f540a4f6 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/index.ts @@ -0,0 +1 @@ +export { ProjectSwitcher } from './project-switcher'; diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx new file mode 100644 index 00000000..442413fd --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -0,0 +1,486 @@ +import { useState, useCallback, useEffect } from 'react'; +import { Plus, Bug, FolderOpen } from 'lucide-react'; +import { useNavigate } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { useAppStore, type ThemeMode } from '@/store/app-store'; +import { useOSDetection } from '@/hooks/use-os-detection'; +import { ProjectSwitcherItem } from './components/project-switcher-item'; +import { ProjectContextMenu } from './components/project-context-menu'; +import { EditProjectDialog } from './components/edit-project-dialog'; +import { NewProjectModal } from '@/components/dialogs/new-project-modal'; +import { OnboardingDialog } from '@/components/layout/sidebar/dialogs'; +import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks'; +import type { Project } from '@/lib/electron'; +import { getElectronAPI } from '@/lib/electron'; +import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; +import { toast } from 'sonner'; +import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; + +function getOSAbbreviation(os: string): string { + switch (os) { + case 'mac': + return 'M'; + case 'windows': + return 'W'; + case 'linux': + return 'L'; + default: + return '?'; + } +} + +export function ProjectSwitcher() { + const navigate = useNavigate(); + const { + projects, + currentProject, + setCurrentProject, + trashedProjects, + upsertAndSetCurrentProject, + specCreatingForProject, + setSpecCreatingForProject, + } = useAppStore(); + const [contextMenuProject, setContextMenuProject] = useState(null); + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>( + null + ); + const [editDialogProject, setEditDialogProject] = useState(null); + + // Setup dialog state for opening existing projects + const [showSetupDialog, setShowSetupDialog] = useState(false); + const [setupProjectPath, setSetupProjectPath] = useState(null); + const [projectOverview, setProjectOverview] = useState(''); + const [generateFeatures, setGenerateFeatures] = useState(true); + const [analyzeProject, setAnalyzeProject] = useState(true); + const [featureCount, setFeatureCount] = useState(5); + + // Derive isCreatingSpec from store state + const isCreatingSpec = specCreatingForProject !== null; + + // Version info + const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; + const { os } = useOSDetection(); + const appMode = import.meta.env.VITE_APP_MODE || '?'; + const versionSuffix = `${getOSAbbreviation(os)}${appMode}`; + + // Get global theme for project creation + const { globalTheme } = useProjectTheme(); + + // Project creation state and handlers + const { + showNewProjectModal, + setShowNewProjectModal, + isCreatingProject, + showOnboardingDialog, + setShowOnboardingDialog, + newProjectName, + handleCreateBlankProject, + handleCreateFromTemplate, + handleCreateFromCustomUrl, + } = useProjectCreation({ + trashedProjects, + currentProject, + globalTheme, + upsertAndSetCurrentProject, + }); + + const handleContextMenu = (project: Project, event: React.MouseEvent) => { + event.preventDefault(); + setContextMenuProject(project); + setContextMenuPosition({ x: event.clientX, y: event.clientY }); + }; + + const handleCloseContextMenu = () => { + setContextMenuProject(null); + setContextMenuPosition(null); + }; + + const handleEditProject = (project: Project) => { + setEditDialogProject(project); + handleCloseContextMenu(); + }; + + const handleProjectClick = useCallback( + (project: Project) => { + setCurrentProject(project); + // Navigate to board view when switching projects + navigate({ to: '/board' }); + }, + [setCurrentProject, navigate] + ); + + const handleNewProject = () => { + // Open the new project modal + setShowNewProjectModal(true); + }; + + const handleOnboardingSkip = () => { + setShowOnboardingDialog(false); + navigate({ to: '/board' }); + }; + + const handleBugReportClick = useCallback(() => { + const api = getElectronAPI(); + api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + }, []); + + /** + * Opens the system folder selection dialog and initializes the selected project. + */ + const handleOpenFolder = useCallback(async () => { + const api = getElectronAPI(); + const result = await api.openDirectory(); + + if (!result.canceled && result.filePaths[0]) { + const path = result.filePaths[0]; + // Extract folder name from path (works on both Windows and Mac/Linux) + const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; + + try { + // Check if this is a brand new project (no .automaker directory) + const hadAutomakerDir = await hasAutomakerDir(path); + + // Initialize the .automaker directory structure + const initResult = await initializeProject(path); + + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', + }); + return; + } + + // Upsert project and set as current (handles both create and update cases) + // Theme preservation is handled by the store action + const trashedProject = trashedProjects.find((p) => p.path === path); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + upsertAndSetCurrentProject(path, name, effectiveTheme); + + // Check if app_spec.txt exists + const specExists = await hasAppSpec(path); + + if (!hadAutomakerDir && !specExists) { + // This is a brand new project - show setup dialog + setSetupProjectPath(path); + setShowSetupDialog(true); + toast.success('Project opened', { + description: `Opened ${name}. Let's set up your app specification!`, + }); + } else if (initResult.createdFiles && initResult.createdFiles.length > 0) { + toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', { + description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`, + }); + } else { + toast.success('Project opened', { + description: `Opened ${name}`, + }); + } + + // Navigate to board view + navigate({ to: '/board' }); + } catch (error) { + console.error('Failed to open project:', error); + toast.error('Failed to open project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + }, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme, navigate]); + + // Handler for creating initial spec from the setup dialog + const handleCreateInitialSpec = useCallback(async () => { + if (!setupProjectPath) return; + + setSpecCreatingForProject(setupProjectPath); + setShowSetupDialog(false); + + try { + const api = getElectronAPI(); + await api.generateAppSpec({ + projectPath: setupProjectPath, + projectOverview, + generateFeatures, + analyzeProject, + featureCount, + }); + } catch (error) { + console.error('Failed to generate spec:', error); + toast.error('Failed to generate spec', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + setSpecCreatingForProject(null); + } + }, [ + setupProjectPath, + projectOverview, + generateFeatures, + analyzeProject, + featureCount, + setSpecCreatingForProject, + ]); + + const handleSkipSetup = useCallback(() => { + setShowSetupDialog(false); + setSetupProjectPath(null); + }, []); + + // Keyboard shortcuts for project switching (1-9, 0) + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Ignore if user is typing in an input, textarea, or contenteditable + const target = event.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + // Ignore if modifier keys are pressed (except for standalone number keys) + if (event.ctrlKey || event.metaKey || event.altKey) { + return; + } + + // Map key to project index: "1" -> 0, "2" -> 1, ..., "9" -> 8, "0" -> 9 + const key = event.key; + let projectIndex: number | null = null; + + if (key >= '1' && key <= '9') { + projectIndex = parseInt(key, 10) - 1; // "1" -> 0, "9" -> 8 + } else if (key === '0') { + projectIndex = 9; // "0" -> 9 + } + + if (projectIndex !== null && projectIndex < projects.length) { + const targetProject = projects[projectIndex]; + if (targetProject && targetProject.id !== currentProject?.id) { + handleProjectClick(targetProject); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [projects, currentProject, handleProjectClick]); + + return ( + <> + + + {/* Context Menu */} + {contextMenuProject && contextMenuPosition && ( + + )} + + {/* Edit Project Dialog */} + {editDialogProject && ( + !open && setEditDialogProject(null)} + /> + )} + + {/* New Project Modal */} + + + {/* Onboarding Dialog */} + + + {/* Setup Dialog for Open Project */} + + + ); +} diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index a8c70cb6..92e804ce 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -18,7 +18,6 @@ import { CollapseToggleButton, SidebarHeader, SidebarNavigation, - ProjectSelectorWithOptions, SidebarFooter, } from './sidebar/components'; import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; @@ -64,9 +63,6 @@ export function Sidebar() { // Get customizable keyboard shortcuts const shortcuts = useKeyboardShortcutsConfig(); - // State for project picker (needed for keyboard shortcuts) - const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false); - // State for delete project confirmation dialog const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); @@ -240,7 +236,6 @@ export function Sidebar() { navigate, toggleSidebar, handleOpenFolder, - setIsProjectPickerOpen, cyclePrevProject, cycleNextProject, unviewedValidationsCount, @@ -258,26 +253,25 @@ export function Sidebar() { return ( <> - {/* Mobile overlay backdrop */} + {/* Mobile backdrop overlay */} {sidebarOpen && (