Compare commits

..

1 Commits

Author SHA1 Message Date
Shirone
5b620011ad feat: add CodeRabbit integration for AI-powered code reviews
This commit introduces the CodeRabbit service and its associated routes, enabling users to trigger, manage, and check the status of code reviews through a new API. Key features include:

- New routes for triggering code reviews, checking status, and stopping reviews.
- Integration with the CodeRabbit CLI for authentication and status checks.
- UI components for displaying code review results and settings management.
- Unit tests for the new code review functionality to ensure reliability.

This enhancement aims to streamline the code review process and leverage AI capabilities for improved code quality.
2026-01-24 21:10:33 +01:00
493 changed files with 14919 additions and 49905 deletions

View File

@@ -4,9 +4,6 @@ on:
release:
types: [published]
permissions:
contents: write
jobs:
build:
strategy:
@@ -65,10 +62,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: macos-builds
path: |
apps/ui/release/*.dmg
apps/ui/release/*.zip
if-no-files-found: error
path: apps/ui/release/*.{dmg,zip}
retention-days: 30
- name: Upload Windows artifacts
@@ -77,7 +71,6 @@ jobs:
with:
name: windows-builds
path: apps/ui/release/*.exe
if-no-files-found: error
retention-days: 30
- name: Upload Linux artifacts
@@ -85,11 +78,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: linux-builds
path: |
apps/ui/release/*.AppImage
apps/ui/release/*.deb
apps/ui/release/*.rpm
if-no-files-found: error
path: apps/ui/release/*.{AppImage,deb,rpm}
retention-days: 30
upload:
@@ -119,13 +108,9 @@ jobs:
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
fail_on_unmatched_files: true
files: |
artifacts/macos-builds/*.dmg
artifacts/macos-builds/*.zip
artifacts/windows-builds/*.exe
artifacts/linux-builds/*.AppImage
artifacts/linux-builds/*.deb
artifacts/linux-builds/*.rpm
artifacts/macos-builds/*.{dmg,zip,blockmap}
artifacts/windows-builds/*.{exe,blockmap}
artifacts/linux-builds/*.{AppImage,deb,rpm,blockmap}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -172,5 +172,4 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
- `DATA_DIR` - Data storage directory (default: ./data)
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (disabled when NODE_ENV=production)
- `VITE_HOSTNAME` - Hostname for frontend API URLs (default: localhost)

View File

@@ -25,11 +25,9 @@ COPY libs/types/package*.json ./libs/types/
COPY libs/utils/package*.json ./libs/utils/
COPY libs/prompts/package*.json ./libs/prompts/
COPY libs/platform/package*.json ./libs/platform/
COPY libs/spec-parser/package*.json ./libs/spec-parser/
COPY libs/model-resolver/package*.json ./libs/model-resolver/
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
COPY libs/git-utils/package*.json ./libs/git-utils/
COPY libs/spec-parser/package*.json ./libs/spec-parser/
# Copy scripts (needed by npm workspace)
COPY scripts ./scripts

View File

@@ -389,7 +389,6 @@ npm run lint
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
- `AUTOMAKER_SKIP_SANDBOX_WARNING` - Skip sandbox warning dialog (useful for dev/CI)
- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (ignored when NODE_ENV=production)
### Authentication Setup

17
TODO.md Normal file
View File

@@ -0,0 +1,17 @@
# Bugs
- Setting the default model does not seem like it works.
# UX
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff
- Simplify the create feature modal. It should just be one page. I don't need nessa tabs and all these nested buttons. It's too complex.
- added to do's list checkbox directly into the card so as it's going through if there's any to do items we can see those update live
- When the feature is done, I want to see a summary of the LLM. That's the first thing I should see when I double click the card.
- I went away to mass edit all my features. For example, when I created a new project, it added auto testing on every single feature card. Now I have to manually go through one by one and change those. Have a way to mass edit those, the configuration of all them.
- Double check and debug if there's memory leaks. It seems like the memory of automaker grows like 3 gigabytes. It's 5gb right now and I'm running three different cursor cli features implementing at the same time.
- Typing in the text area of the plan mode was super laggy.
- When I have a bunch of features running at the same time, it seems like I cannot edit the features in the backlog. Like they don't persist their file changes and I think this is because of the secure FS file has an internal queue to prevent hitting that file open write limit. We may have to reconsider refactoring away from file system and do Postgres or SQLite or something.
- modals are not scrollable if height of the screen is small enough
- and the Agent Runner add an archival button for the new sessions.
- investigate a potential issue with the feature cards not refreshing. I see a lock icon on the feature card But it doesn't go away until I open the card and edit it and I turn the testing mode off. I think there's like a refresh sync issue.

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/server",
"version": "0.13.0",
"version": "0.12.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
@@ -32,7 +32,6 @@
"@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0",
"@github/copilot-sdk": "^0.1.16",
"@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.77.0",
"cookie-parser": "1.4.7",
@@ -41,8 +40,7 @@
"express": "5.2.1",
"morgan": "1.10.1",
"node-pty": "1.1.0-beta41",
"ws": "8.18.3",
"yaml": "2.7.0"
"ws": "8.18.3"
},
"devDependencies": {
"@types/cookie": "0.6.0",

View File

@@ -16,7 +16,7 @@ import { createServer } from 'http';
import dotenv from 'dotenv';
import { createEventEmitter, type EventEmitter } from './lib/events.js';
import { initAllowedPaths, getClaudeAuthIndicators } from '@automaker/platform';
import { initAllowedPaths } from '@automaker/platform';
import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
const logger = createLogger('Server');
@@ -43,6 +43,7 @@ import { createEnhancePromptRoutes } from './routes/enhance-prompt/index.js';
import { createWorktreeRoutes } from './routes/worktree/index.js';
import { createGitRoutes } from './routes/git/index.js';
import { createSetupRoutes } from './routes/setup/index.js';
import { createSuggestionsRoutes } from './routes/suggestions/index.js';
import { createModelsRoutes } from './routes/models/index.js';
import { createRunningAgentsRoutes } from './routes/running-agents/index.js';
import { createWorkspaceRoutes } from './routes/workspace/index.js';
@@ -82,8 +83,8 @@ import { createNotificationsRoutes } from './routes/notifications/index.js';
import { getNotificationService } from './services/notification-service.js';
import { createEventHistoryRoutes } from './routes/event-history/index.js';
import { getEventHistoryService } from './services/event-history-service.js';
import { getTestRunnerService } from './services/test-runner-service.js';
import { createProjectsRoutes } from './routes/projects/index.js';
import { createCodeReviewRoutes } from './routes/code-review/index.js';
import { CodeReviewService } from './services/code-review-service.js';
// Load environment variables
dotenv.config();
@@ -92,9 +93,6 @@ const PORT = parseInt(process.env.PORT || '3008', 10);
const HOST = process.env.HOST || '0.0.0.0';
const HOSTNAME = process.env.HOSTNAME || 'localhost';
const DATA_DIR = process.env.DATA_DIR || './data';
logger.info('[SERVER_STARTUP] process.env.DATA_DIR:', process.env.DATA_DIR);
logger.info('[SERVER_STARTUP] Resolved DATA_DIR:', DATA_DIR);
logger.info('[SERVER_STARTUP] process.cwd():', process.cwd());
const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
// Runtime-configurable request logging flag (can be changed via settings)
@@ -114,66 +112,25 @@ export function isRequestLoggingEnabled(): boolean {
return requestLoggingEnabled;
}
// Width for log box content (excluding borders)
const BOX_CONTENT_WIDTH = 67;
// Check for Claude authentication (async - runs in background)
// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
(async () => {
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
if (hasAnthropicKey) {
logger.info('✓ ANTHROPIC_API_KEY detected');
return;
}
// Check for Claude Code CLI authentication
try {
const indicators = await getClaudeAuthIndicators();
const hasCliAuth =
indicators.hasStatsCacheWithActivity ||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
(indicators.hasCredentialsFile &&
(indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
if (hasCliAuth) {
logger.info('✓ Claude Code CLI authentication detected');
return;
}
} catch (error) {
// Ignore errors checking CLI auth - will fall through to warning
logger.warn('Error checking for Claude Code CLI authentication:', error);
}
// No authentication found - show warning
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
const w3 = '1. Install Claude Code CLI and authenticate with subscription'.padEnd(
BOX_CONTENT_WIDTH
);
const w4 = '2. Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
const w5 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
const w6 = '3. Use the setup wizard in Settings to configure authentication.'.padEnd(
BOX_CONTENT_WIDTH
);
// Check for required environment variables
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
if (!hasAnthropicKey) {
logger.warn(`
╔═════════════════════════════════════════════════════════════════════╗
${wHeader}
╠═════════════════════════════════════════════════════════════════════╣
${w1}
${w2}
${w3}
${w4}
${w5}
${w6}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
╔═══════════════════════════════════════════════════════════════════════
⚠️ WARNING: No Claude authentication configured
║ ║
The Claude Agent SDK requires authentication to function.
Set your Anthropic API key:
export ANTHROPIC_API_KEY="sk-ant-..."
Or use the setup wizard in Settings to configure authentication.
╚═══════════════════════════════════════════════════════════════════════╝
`);
})();
} else {
logger.info('✓ ANTHROPIC_API_KEY detected (API key auth)');
}
// Initialize security
initAllowedPaths();
@@ -220,25 +177,14 @@ app.use(
return;
}
// For local development, allow all localhost/loopback origins (any port)
try {
const url = new URL(origin);
const hostname = url.hostname;
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('172.')
) {
callback(null, origin);
return;
}
} catch (err) {
// Ignore URL parsing errors
// For local development, allow localhost origins
if (
origin.startsWith('http://localhost:') ||
origin.startsWith('http://127.0.0.1:') ||
origin.startsWith('http://[::1]:')
) {
callback(null, origin);
return;
}
// Reject other origins by default for security
@@ -265,6 +211,7 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
const codexUsageService = new CodexUsageService(codexAppServerService);
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
const codeReviewService = new CodeReviewService(events, settingsService);
// Initialize DevServerService with event emitter for real-time log streaming
const devServerService = getDevServerService();
@@ -277,32 +224,11 @@ notificationService.setEventEmitter(events);
// Initialize Event History Service
const eventHistoryService = getEventHistoryService();
// Initialize Test Runner Service with event emitter for real-time test output streaming
const testRunnerService = getTestRunnerService();
testRunnerService.setEventEmitter(events);
// Initialize Event Hook Service for custom event triggers (with history storage)
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
eventHookService.initialize(events, settingsService, eventHistoryService);
// Initialize services
(async () => {
// Migrate settings from legacy Electron userData location if needed
// This handles users upgrading from versions that stored settings in ~/.config/Automaker (Linux),
// ~/Library/Application Support/Automaker (macOS), or %APPDATA%\Automaker (Windows)
// to the new shared ./data directory
try {
const migrationResult = await settingsService.migrateFromLegacyElectronPath();
if (migrationResult.migrated) {
logger.info(`Settings migrated from legacy location: ${migrationResult.legacyPath}`);
logger.info(`Migrated files: ${migrationResult.migratedFiles.join(', ')}`);
}
if (migrationResult.errors.length > 0) {
logger.warn('Migration errors:', migrationResult.errors);
}
} catch (err) {
logger.warn('Failed to check for legacy settings migration:', err);
}
// Apply logging settings from saved settings
try {
const settings = await settingsService.getGlobalSettings();
@@ -354,14 +280,12 @@ app.get('/api/health/detailed', createDetailedHandler());
app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService));
app.use(
'/api/features',
createFeaturesRoutes(featureLoader, settingsService, events, autoModeService)
);
app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
app.use('/api/git', createGitRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
@@ -379,10 +303,7 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
app.use('/api/notifications', createNotificationsRoutes(notificationService));
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
app.use(
'/api/projects',
createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService)
);
app.use('/api/code-review', createCodeReviewRoutes(codeReviewService));
// Create HTTP server
const server = createServer(app);
@@ -701,74 +622,40 @@ const startServer = (port: number, host: string) => {
? 'enabled (password protected)'
: 'enabled'
: 'disabled';
// Build URLs for display
const listenAddr = `${host}:${port}`;
const httpUrl = `http://${HOSTNAME}:${port}`;
const wsEventsUrl = `ws://${HOSTNAME}:${port}/api/events`;
const wsTerminalUrl = `ws://${HOSTNAME}:${port}/api/terminal/ws`;
const healthUrl = `http://${HOSTNAME}:${port}/api/health`;
const sHeader = '🚀 Automaker Backend Server'.padEnd(BOX_CONTENT_WIDTH);
const s1 = `Listening: ${listenAddr}`.padEnd(BOX_CONTENT_WIDTH);
const s2 = `HTTP API: ${httpUrl}`.padEnd(BOX_CONTENT_WIDTH);
const s3 = `WebSocket: ${wsEventsUrl}`.padEnd(BOX_CONTENT_WIDTH);
const s4 = `Terminal WS: ${wsTerminalUrl}`.padEnd(BOX_CONTENT_WIDTH);
const s5 = `Health: ${healthUrl}`.padEnd(BOX_CONTENT_WIDTH);
const s6 = `Terminal: ${terminalStatus}`.padEnd(BOX_CONTENT_WIDTH);
const portStr = port.toString().padEnd(4);
logger.info(`
╔═════════════════════════════════════════════════════════════════════
${sHeader}
╠═════════════════════════════════════════════════════════════════════
${s1}
${s2}
${s3}
${s4}
${s5}
${s6}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
╔═══════════════════════════════════════════════════════╗
Automaker Backend Server
╠═══════════════════════════════════════════════════════╣
Listening: ${host}:${port}${' '.repeat(Math.max(0, 34 - host.length - port.toString().length))}
HTTP API: http://${HOSTNAME}:${portStr}
WebSocket: ws://${HOSTNAME}:${portStr}/api/events
Terminal: ws://${HOSTNAME}:${portStr}/api/terminal/ws
Health: http://${HOSTNAME}:${portStr}/api/health
Terminal: ${terminalStatus.padEnd(37)}
╚═══════════════════════════════════════════════════════╝
`);
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
const portStr = port.toString();
const nextPortStr = (port + 1).toString();
const killCmd = `lsof -ti:${portStr} | xargs kill -9`;
const altCmd = `PORT=${nextPortStr} npm run dev:server`;
const eHeader = `❌ ERROR: Port ${portStr} is already in use`.padEnd(BOX_CONTENT_WIDTH);
const e1 = 'Another process is using this port.'.padEnd(BOX_CONTENT_WIDTH);
const e2 = 'To fix this, try one of:'.padEnd(BOX_CONTENT_WIDTH);
const e3 = '1. Kill the process using the port:'.padEnd(BOX_CONTENT_WIDTH);
const e4 = ` ${killCmd}`.padEnd(BOX_CONTENT_WIDTH);
const e5 = '2. Use a different port:'.padEnd(BOX_CONTENT_WIDTH);
const e6 = ` ${altCmd}`.padEnd(BOX_CONTENT_WIDTH);
const e7 = '3. Use the init.sh script which handles this:'.padEnd(BOX_CONTENT_WIDTH);
const e8 = ' ./init.sh'.padEnd(BOX_CONTENT_WIDTH);
logger.error(`
╔═════════════════════════════════════════════════════════════════════
${eHeader}
╠═════════════════════════════════════════════════════════════════════
${e1}
${e2}
${e3}
${e4}
${e5}
${e6}
${e7}
${e8}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
╔═══════════════════════════════════════════════════════╗
❌ ERROR: Port ${port} is already in use
╠═══════════════════════════════════════════════════════╣
Another process is using this port.
To fix this, try one of:
1. Kill the process using the port:
lsof -ti:${port} | xargs kill -9
2. Use a different port:
PORT=${port + 1} npm run dev:server
3. Use the init.sh script which handles this:
./init.sh
╚═══════════════════════════════════════════════════════╝
`);
process.exit(1);
} else {
@@ -800,36 +687,21 @@ process.on('uncaughtException', (error: Error) => {
process.exit(1);
});
// Graceful shutdown timeout (30 seconds)
const SHUTDOWN_TIMEOUT_MS = 30000;
// Graceful shutdown helper
const gracefulShutdown = async (signal: string) => {
logger.info(`${signal} received, shutting down...`);
// Set up a force-exit timeout to prevent hanging
const forceExitTimeout = setTimeout(() => {
logger.error(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
// Mark all running features as interrupted before shutdown
// This ensures they can be resumed when the server restarts
// Note: markAllRunningFeaturesInterrupted handles errors internally and never rejects
await autoModeService.markAllRunningFeaturesInterrupted(`${signal} signal received`);
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down...');
terminalService.cleanup();
server.close(() => {
clearTimeout(forceExitTimeout);
logger.info('Server closed');
process.exit(0);
});
};
process.on('SIGTERM', () => {
gracefulShutdown('SIGTERM');
});
process.on('SIGINT', () => {
gracefulShutdown('SIGINT');
logger.info('SIGINT received, shutting down...');
terminalService.cleanup();
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
});

View File

@@ -23,13 +23,6 @@ const SESSION_COOKIE_NAME = 'automaker_session';
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens
/**
* Check if an environment variable is set to 'true'
*/
function isEnvTrue(envVar: string | undefined): boolean {
return envVar === 'true';
}
// Session store - persisted to file for survival across server restarts
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
@@ -137,47 +130,21 @@ function ensureApiKey(): string {
// API key - always generated/loaded on startup for CSRF protection
const API_KEY = ensureApiKey();
// Width for log box content (excluding borders)
const BOX_CONTENT_WIDTH = 67;
// Print API key to console for web mode users (unless suppressed for production logging)
if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) {
const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN);
const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled';
// Build box lines with exact padding
const header = '🔐 API Key for Web Mode Authentication'.padEnd(BOX_CONTENT_WIDTH);
const line1 = "When accessing via browser, you'll be prompted to enter this key:".padEnd(
BOX_CONTENT_WIDTH
);
const line2 = API_KEY.padEnd(BOX_CONTENT_WIDTH);
const line3 = 'In Electron mode, authentication is handled automatically.'.padEnd(
BOX_CONTENT_WIDTH
);
const line4 = `Auto-login (AUTOMAKER_AUTO_LOGIN): ${autoLoginStatus}`.padEnd(BOX_CONTENT_WIDTH);
const tipHeader = '💡 Tips'.padEnd(BOX_CONTENT_WIDTH);
const line5 = 'Set AUTOMAKER_API_KEY env var to use a fixed key'.padEnd(BOX_CONTENT_WIDTH);
const line6 = 'Set AUTOMAKER_AUTO_LOGIN=true to skip the login prompt'.padEnd(BOX_CONTENT_WIDTH);
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
logger.info(`
╔═════════════════════════════════════════════════════════════════════╗
${header}
╠═════════════════════════════════════════════════════════════════════╣
║ ║
${line1}
║ ║
${line2}
║ ║
${line3}
║ ║
${line4}
║ ║
╠═════════════════════════════════════════════════════════════════════╣
${tipHeader}
╠═════════════════════════════════════════════════════════════════════╣
${line5}
${line6}
╚═════════════════════════════════════════════════════════════════════╝
╔═══════════════════════════════════════════════════════════════════════
🔐 API Key for Web Mode Authentication
╠═══════════════════════════════════════════════════════════════════════
When accessing via browser, you'll be prompted to enter this key:
${API_KEY}
In Electron mode, authentication is handled automatically.
💡 Tip: Set AUTOMAKER_API_KEY env var to use a fixed key for dev
╚═══════════════════════════════════════════════════════════════════════╝
`);
} else {
logger.info('API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
@@ -353,15 +320,6 @@ function checkAuthentication(
return { authenticated: false, errorType: 'invalid_api_key' };
}
// Check for session token in query parameter (web mode - needed for image loads)
const queryToken = query.token;
if (queryToken) {
if (validateSession(queryToken)) {
return { authenticated: true };
}
return { authenticated: false, errorType: 'invalid_session' };
}
// Check for session cookie (web mode)
const sessionToken = cookies[SESSION_COOKIE_NAME];
if (sessionToken && validateSession(sessionToken)) {
@@ -377,17 +335,10 @@ function checkAuthentication(
* Accepts either:
* 1. X-API-Key header (for Electron mode)
* 2. X-Session-Token header (for web mode with explicit token)
* 3. apiKey query parameter (fallback for Electron, cases where headers can't be set)
* 4. token query parameter (fallback for web mode, needed for image loads via CSS/img tags)
* 5. Session cookie (for web mode)
* 3. apiKey query parameter (fallback for cases where headers can't be set)
* 4. Session cookie (for web mode)
*/
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// Allow disabling auth for local/trusted networks
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) {
next();
return;
}
const result = checkAuthentication(
req.headers as Record<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
@@ -433,10 +384,9 @@ export function isAuthEnabled(): boolean {
* Get authentication status for health endpoint
*/
export function getAuthStatus(): { enabled: boolean; method: string } {
const disabled = isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH);
return {
enabled: !disabled,
method: disabled ? 'disabled' : 'api_key_or_session',
enabled: true,
method: 'api_key_or_session',
};
}
@@ -444,7 +394,6 @@ export function getAuthStatus(): { enabled: boolean; method: string } {
* Check if a request is authenticated (for status endpoint)
*/
export function isRequestAuthenticated(req: Request): boolean {
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
const result = checkAuthentication(
req.headers as Record<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
@@ -462,6 +411,5 @@ export function checkRawAuthentication(
query: Record<string, string | undefined>,
cookies: Record<string, string | undefined>
): boolean {
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
return checkAuthentication(headers, query, cookies).authenticated;
}

View File

@@ -40,6 +40,7 @@ export interface UnifiedCliDetection {
claude?: CliDetectionResult;
codex?: CliDetectionResult;
cursor?: CliDetectionResult;
coderabbit?: CliDetectionResult;
}
/**
@@ -76,6 +77,16 @@ const CLI_CONFIGS = {
win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex',
},
},
coderabbit: {
name: 'CodeRabbit CLI',
commands: ['coderabbit', 'cr'],
versionArgs: ['--version'],
installCommands: {
darwin: 'npm install -g coderabbit',
linux: 'npm install -g coderabbit',
win32: 'npm install -g coderabbit',
},
},
} as const;
/**
@@ -230,6 +241,8 @@ export async function checkCliAuth(
return await checkCodexAuth(command);
case 'cursor':
return await checkCursorAuth(command);
case 'coderabbit':
return await checkCodeRabbitAuth(command);
default:
return 'none';
}
@@ -355,6 +368,64 @@ async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'no
return 'none';
}
/**
* Check CodeRabbit CLI authentication
*
* Expected output when authenticated:
* ```
* CodeRabbit CLI Status
* ✅ Authentication: Logged in
* User Information:
* 👤 Name: ...
* ```
*/
async function checkCodeRabbitAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
// Check for environment variable
if (process.env.CODERABBIT_API_KEY) {
return 'api_key';
}
// Try running auth status command
return new Promise((resolve) => {
const child = spawn(command, ['auth', 'status'], {
stdio: 'pipe',
timeout: 10000, // Increased timeout for slower systems
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const output = stdout + stderr;
// Check for positive authentication indicators in output
const isAuthenticated =
code === 0 &&
(output.includes('Logged in') || output.includes('logged in')) &&
!output.toLowerCase().includes('not logged in') &&
!output.toLowerCase().includes('not authenticated');
if (isAuthenticated) {
resolve('cli');
} else {
resolve('none');
}
});
child.on('error', () => {
resolve('none');
});
});
}
/**
* Get installation instructions for a provider
*/

View File

@@ -5,17 +5,7 @@
import type { SettingsService } from '../services/settings-service.js';
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
import { createLogger } from '@automaker/utils';
import type {
MCPServerConfig,
McpServerConfig,
PromptCustomization,
ClaudeApiProfile,
ClaudeCompatibleProvider,
PhaseModelKey,
PhaseModelEntry,
Credentials,
} from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types';
import {
mergeAutoModePrompts,
mergeAgentPrompts,
@@ -355,376 +345,3 @@ export async function getCustomSubagents(
return Object.keys(merged).length > 0 ? merged : undefined;
}
/** Result from getActiveClaudeApiProfile */
export interface ActiveClaudeApiProfileResult {
/** The active profile, or undefined if using direct Anthropic API */
profile: ClaudeApiProfile | undefined;
/** Credentials for resolving 'credentials' apiKeySource */
credentials: import('@automaker/types').Credentials | undefined;
}
/**
* Get the active Claude API profile and credentials from settings.
* Checks project settings first for per-project overrides, then falls back to global settings.
* Returns both the profile and credentials for resolving 'credentials' apiKeySource.
*
* @deprecated Use getProviderById and getPhaseModelWithOverrides instead for the new provider system.
* This function is kept for backward compatibility during migration.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @param projectPath - Optional project path for per-project override
* @returns Promise resolving to object with profile and credentials
*/
export async function getActiveClaudeApiProfile(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]',
projectPath?: string
): Promise<ActiveClaudeApiProfileResult> {
if (!settingsService) {
return { profile: undefined, credentials: undefined };
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const profiles = globalSettings.claudeApiProfiles || [];
// Check for project-level override first
let activeProfileId: string | null | undefined;
let isProjectOverride = false;
if (projectPath) {
const projectSettings = await settingsService.getProjectSettings(projectPath);
// undefined = use global, null = explicit no profile, string = specific profile
if (projectSettings.activeClaudeApiProfileId !== undefined) {
activeProfileId = projectSettings.activeClaudeApiProfileId;
isProjectOverride = true;
}
}
// Fall back to global if project doesn't specify
if (activeProfileId === undefined && !isProjectOverride) {
activeProfileId = globalSettings.activeClaudeApiProfileId;
}
// No active profile selected - use direct Anthropic API
if (!activeProfileId) {
if (isProjectOverride && activeProfileId === null) {
logger.info(`${logPrefix} Project explicitly using Direct Anthropic API`);
}
return { profile: undefined, credentials };
}
// Find the active profile by ID
const activeProfile = profiles.find((p) => p.id === activeProfileId);
if (activeProfile) {
const overrideSuffix = isProjectOverride ? ' (project override)' : '';
logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}${overrideSuffix}`);
return { profile: activeProfile, credentials };
} else {
logger.warn(
`${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API`
);
return { profile: undefined, credentials };
}
} catch (error) {
logger.error(`${logPrefix} Failed to load Claude API profile:`, error);
return { profile: undefined, credentials: undefined };
}
}
// ============================================================================
// New Provider System Helpers
// ============================================================================
/** Result from getProviderById */
export interface ProviderByIdResult {
/** The provider, or undefined if not found */
provider: ClaudeCompatibleProvider | undefined;
/** Credentials for resolving 'credentials' apiKeySource */
credentials: Credentials | undefined;
}
/**
* Get a ClaudeCompatibleProvider by its ID.
* Returns the provider configuration and credentials for API key resolution.
*
* @param providerId - The provider ID to look up
* @param settingsService - Settings service instance
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to object with provider and credentials
*/
export async function getProviderById(
providerId: string,
settingsService: SettingsService,
logPrefix = '[SettingsHelper]'
): Promise<ProviderByIdResult> {
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const providers = globalSettings.claudeCompatibleProviders || [];
const provider = providers.find((p) => p.id === providerId);
if (provider) {
if (provider.enabled === false) {
logger.warn(`${logPrefix} Provider "${provider.name}" (${providerId}) is disabled`);
} else {
logger.debug(`${logPrefix} Found provider: ${provider.name}`);
}
return { provider, credentials };
} else {
logger.warn(`${logPrefix} Provider not found: ${providerId}`);
return { provider: undefined, credentials };
}
} catch (error) {
logger.error(`${logPrefix} Failed to load provider by ID:`, error);
return { provider: undefined, credentials: undefined };
}
}
/** Result from getPhaseModelWithOverrides */
export interface PhaseModelWithOverridesResult {
/** The resolved phase model entry */
phaseModel: PhaseModelEntry;
/** Whether a project override was applied */
isProjectOverride: boolean;
/** The provider if providerId is set and found */
provider: ClaudeCompatibleProvider | undefined;
/** Credentials for API key resolution */
credentials: Credentials | undefined;
}
/**
* Get the phase model configuration for a specific phase, applying project overrides if available.
* Also resolves the provider if the phase model has a providerId.
*
* @param phase - The phase key (e.g., 'enhancementModel', 'specGenerationModel')
* @param settingsService - Optional settings service instance (returns defaults if undefined)
* @param projectPath - Optional project path for checking overrides
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to phase model with provider info
*/
export async function getPhaseModelWithOverrides(
phase: PhaseModelKey,
settingsService?: SettingsService | null,
projectPath?: string,
logPrefix = '[SettingsHelper]'
): Promise<PhaseModelWithOverridesResult> {
// Handle undefined settingsService gracefully
if (!settingsService) {
logger.info(`${logPrefix} SettingsService not available, using default for ${phase}`);
return {
phaseModel: DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' },
isProjectOverride: false,
provider: undefined,
credentials: undefined,
};
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const globalPhaseModels = globalSettings.phaseModels || {};
// Start with global phase model
let phaseModel = globalPhaseModels[phase];
let isProjectOverride = false;
// Check for project override
if (projectPath) {
const projectSettings = await settingsService.getProjectSettings(projectPath);
const projectOverrides = projectSettings.phaseModelOverrides || {};
if (projectOverrides[phase]) {
phaseModel = projectOverrides[phase];
isProjectOverride = true;
logger.debug(`${logPrefix} Using project override for ${phase}`);
}
}
// If no phase model found, use per-phase default
if (!phaseModel) {
phaseModel = DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' };
logger.debug(`${logPrefix} No ${phase} configured, using default: ${phaseModel.model}`);
}
// Resolve provider if providerId is set
let provider: ClaudeCompatibleProvider | undefined;
if (phaseModel.providerId) {
const providers = globalSettings.claudeCompatibleProviders || [];
provider = providers.find((p) => p.id === phaseModel.providerId);
if (provider) {
if (provider.enabled === false) {
logger.warn(
`${logPrefix} Provider "${provider.name}" for ${phase} is disabled, falling back to direct API`
);
provider = undefined;
} else {
logger.debug(`${logPrefix} Using provider "${provider.name}" for ${phase}`);
}
} else {
logger.warn(
`${logPrefix} Provider ${phaseModel.providerId} not found for ${phase}, falling back to direct API`
);
}
}
return {
phaseModel,
isProjectOverride,
provider,
credentials,
};
} catch (error) {
logger.error(`${logPrefix} Failed to get phase model with overrides:`, error);
// Return a safe default
return {
phaseModel: { model: 'sonnet' },
isProjectOverride: false,
provider: undefined,
credentials: undefined,
};
}
}
/** Result from getProviderByModelId */
export interface ProviderByModelIdResult {
/** The provider that contains this model, or undefined if not found */
provider: ClaudeCompatibleProvider | undefined;
/** The model configuration if found */
modelConfig: import('@automaker/types').ProviderModel | undefined;
/** Credentials for API key resolution */
credentials: Credentials | undefined;
/** The resolved Claude model ID to use for API calls (from mapsToClaudeModel) */
resolvedModel: string | undefined;
}
/**
* Find a ClaudeCompatibleProvider by one of its model IDs.
* Searches through all enabled providers to find one that contains the specified model.
* This is useful when you have a model string from the UI but need the provider config.
*
* Also resolves the `mapsToClaudeModel` field to get the actual Claude model ID to use
* when calling the API (e.g., "GLM-4.5-Air" -> "claude-haiku-4-5").
*
* @param modelId - The model ID to search for (e.g., "GLM-4.7", "MiniMax-M2.1")
* @param settingsService - Settings service instance
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to object with provider, model config, credentials, and resolved model
*/
export async function getProviderByModelId(
modelId: string,
settingsService: SettingsService,
logPrefix = '[SettingsHelper]'
): Promise<ProviderByModelIdResult> {
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const providers = globalSettings.claudeCompatibleProviders || [];
// Search through all enabled providers for this model
for (const provider of providers) {
// Skip disabled providers
if (provider.enabled === false) {
continue;
}
// Check if this provider has the model
const modelConfig = provider.models?.find(
(m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
);
if (modelConfig) {
logger.info(`${logPrefix} Found model "${modelId}" in provider "${provider.name}"`);
// Resolve the mapped Claude model if specified
let resolvedModel: string | undefined;
if (modelConfig.mapsToClaudeModel) {
// Import resolveModelString to convert alias to full model ID
const { resolveModelString } = await import('@automaker/model-resolver');
resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
logger.info(
`${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
);
}
return { provider, modelConfig, credentials, resolvedModel };
}
}
// Model not found in any provider
logger.debug(`${logPrefix} Model "${modelId}" not found in any provider`);
return {
provider: undefined,
modelConfig: undefined,
credentials: undefined,
resolvedModel: undefined,
};
} catch (error) {
logger.error(`${logPrefix} Failed to find provider by model ID:`, error);
return {
provider: undefined,
modelConfig: undefined,
credentials: undefined,
resolvedModel: undefined,
};
}
}
/**
* Get all enabled provider models for use in model dropdowns.
* Returns models from all enabled ClaudeCompatibleProviders.
*
* @param settingsService - Settings service instance
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to array of provider models with their provider info
*/
export async function getAllProviderModels(
settingsService: SettingsService,
logPrefix = '[SettingsHelper]'
): Promise<
Array<{
providerId: string;
providerName: string;
model: import('@automaker/types').ProviderModel;
}>
> {
try {
const globalSettings = await settingsService.getGlobalSettings();
const providers = globalSettings.claudeCompatibleProviders || [];
const allModels: Array<{
providerId: string;
providerName: string;
model: import('@automaker/types').ProviderModel;
}> = [];
for (const provider of providers) {
// Skip disabled providers
if (provider.enabled === false) {
continue;
}
for (const model of provider.models || []) {
allModels.push({
providerId: provider.id,
providerName: provider.name,
model,
});
}
}
logger.debug(
`${logPrefix} Found ${allModels.length} models from ${providers.length} providers`
);
return allModels;
} catch (error) {
logger.error(`${logPrefix} Failed to get all provider models:`, error);
return [];
}
}

View File

@@ -10,21 +10,7 @@ import { BaseProvider } from './base-provider.js';
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
const logger = createLogger('ClaudeProvider');
import {
getThinkingTokenBudget,
validateBareModelId,
type ClaudeApiProfile,
type ClaudeCompatibleProvider,
type Credentials,
} from '@automaker/types';
/**
* ProviderConfig - Union type for provider configuration
*
* Accepts either the legacy ClaudeApiProfile or new ClaudeCompatibleProvider.
* Both share the same connection settings structure.
*/
type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
import type {
ExecuteOptions,
ProviderMessage,
@@ -35,19 +21,9 @@ import type {
// Explicit allowlist of environment variables to pass to the SDK.
// Only these vars are passed - nothing else from process.env leaks through.
const ALLOWED_ENV_VARS = [
// Authentication
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
// Endpoint configuration
'ANTHROPIC_BASE_URL',
'API_TIMEOUT_MS',
// Model mappings
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
// Traffic control
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
// System vars (always from process.env)
'ANTHROPIC_AUTH_TOKEN',
'PATH',
'HOME',
'SHELL',
@@ -57,132 +33,16 @@ const ALLOWED_ENV_VARS = [
'LC_ALL',
];
// System vars are always passed from process.env regardless of profile
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
/**
* Check if the config is a ClaudeCompatibleProvider (new system)
* by checking for the 'models' array property
* Build environment for the SDK with only explicitly allowed variables
*/
function isClaudeCompatibleProvider(config: ProviderConfig): config is ClaudeCompatibleProvider {
return 'models' in config && Array.isArray(config.models);
}
/**
* Build environment for the SDK with only explicitly allowed variables.
* When a provider/profile is provided, uses its configuration (clean switch - don't inherit from process.env).
* When no provider is provided, uses direct Anthropic API settings from process.env.
*
* Supports both:
* - ClaudeCompatibleProvider (new system with models[] array)
* - ClaudeApiProfile (legacy system with modelMappings)
*
* @param providerConfig - Optional provider configuration for alternative endpoint
* @param credentials - Optional credentials object for resolving 'credentials' apiKeySource
*/
function buildEnv(
providerConfig?: ProviderConfig,
credentials?: Credentials
): Record<string, string | undefined> {
function buildEnv(): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {};
if (providerConfig) {
// Use provider configuration (clean switch - don't inherit non-system vars from process.env)
logger.debug('[buildEnv] Using provider configuration:', {
name: providerConfig.name,
baseUrl: providerConfig.baseUrl,
apiKeySource: providerConfig.apiKeySource ?? 'inline',
isNewProvider: isClaudeCompatibleProvider(providerConfig),
});
// Resolve API key based on source strategy
let apiKey: string | undefined;
const source = providerConfig.apiKeySource ?? 'inline'; // Default to inline for backwards compat
switch (source) {
case 'inline':
apiKey = providerConfig.apiKey;
break;
case 'env':
apiKey = process.env.ANTHROPIC_API_KEY;
break;
case 'credentials':
apiKey = credentials?.apiKeys?.anthropic;
break;
}
// Warn if no API key found
if (!apiKey) {
logger.warn(`No API key found for provider "${providerConfig.name}" with source "${source}"`);
}
// Authentication
if (providerConfig.useAuthToken) {
env['ANTHROPIC_AUTH_TOKEN'] = apiKey;
} else {
env['ANTHROPIC_API_KEY'] = apiKey;
}
// Endpoint configuration
env['ANTHROPIC_BASE_URL'] = providerConfig.baseUrl;
logger.debug(`[buildEnv] Set ANTHROPIC_BASE_URL to: ${providerConfig.baseUrl}`);
if (providerConfig.timeoutMs) {
env['API_TIMEOUT_MS'] = String(providerConfig.timeoutMs);
}
// Model mappings - only for legacy ClaudeApiProfile
// For ClaudeCompatibleProvider, the model is passed directly (no mapping needed)
if (!isClaudeCompatibleProvider(providerConfig) && providerConfig.modelMappings) {
if (providerConfig.modelMappings.haiku) {
env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = providerConfig.modelMappings.haiku;
}
if (providerConfig.modelMappings.sonnet) {
env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = providerConfig.modelMappings.sonnet;
}
if (providerConfig.modelMappings.opus) {
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = providerConfig.modelMappings.opus;
}
}
// Traffic control
if (providerConfig.disableNonessentialTraffic) {
env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1';
}
} else {
// Use direct Anthropic API - pass through credentials or environment variables
// This supports:
// 1. API Key mode: ANTHROPIC_API_KEY from credentials (UI settings) or env
// 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically)
// 3. Custom endpoints via ANTHROPIC_BASE_URL env var (backward compatibility)
//
// Priority: credentials file (UI settings) -> environment variable
// Note: Only auth and endpoint vars are passed. Model mappings and traffic
// control are NOT passed (those require a profile for explicit configuration).
if (credentials?.apiKeys?.anthropic) {
env['ANTHROPIC_API_KEY'] = credentials.apiKeys.anthropic;
} else if (process.env.ANTHROPIC_API_KEY) {
env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY;
}
// If using Claude Max plan via CLI auth, the SDK handles auth automatically
// when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here
// unless it was explicitly set in process.env (rare edge case).
if (process.env.ANTHROPIC_AUTH_TOKEN) {
env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN;
}
// Pass through ANTHROPIC_BASE_URL if set in environment (backward compatibility)
if (process.env.ANTHROPIC_BASE_URL) {
env['ANTHROPIC_BASE_URL'] = process.env.ANTHROPIC_BASE_URL;
}
}
// Always add system vars from process.env
for (const key of SYSTEM_ENV_VARS) {
for (const key of ALLOWED_ENV_VARS) {
if (process.env[key]) {
env[key] = process.env[key];
}
}
return env;
}
@@ -210,15 +70,8 @@ export class ClaudeProvider extends BaseProvider {
conversationHistory,
sdkSessionId,
thinkingLevel,
claudeApiProfile,
claudeCompatibleProvider,
credentials,
} = options;
// Determine which provider config to use
// claudeCompatibleProvider takes precedence over claudeApiProfile
const providerConfig = claudeCompatibleProvider || claudeApiProfile;
// Convert thinking level to token budget
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
@@ -229,9 +82,7 @@ export class ClaudeProvider extends BaseProvider {
maxTurns,
cwd,
// Pass only explicitly allowed environment variables to SDK
// When a provider is active, uses provider settings (clean switch)
// When no provider, uses direct Anthropic API (from process.env or CLI OAuth)
env: buildEnv(providerConfig, credentials),
env: buildEnv(),
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
...(allowedTools && { allowedTools }),
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
@@ -276,18 +127,6 @@ export class ClaudeProvider extends BaseProvider {
promptPayload = prompt;
}
// Log the environment being passed to the SDK for debugging
const envForSdk = sdkOptions.env as Record<string, string | undefined>;
logger.debug('[ClaudeProvider] SDK Configuration:', {
model: sdkOptions.model,
baseUrl: envForSdk?.['ANTHROPIC_BASE_URL'] || '(default Anthropic API)',
hasApiKey: !!envForSdk?.['ANTHROPIC_API_KEY'],
hasAuthToken: !!envForSdk?.['ANTHROPIC_AUTH_TOKEN'],
providerName: providerConfig?.name || '(direct Anthropic)',
maxTurns: sdkOptions.maxTurns,
maxThinkingTokens: sdkOptions.maxThinkingTokens,
});
// Execute via Claude Agent SDK
try {
const stream = query({ prompt: promptPayload, options: sdkOptions });

View File

@@ -98,14 +98,9 @@ const TEXT_ENCODING = 'utf-8';
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
* for this duration, the process is killed. For reasoning models with high
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
*
* For feature generation (which can generate 50+ features), we use a much longer
* base timeout (5 minutes) since Codex models are slower at generating large JSON responses.
*
* @see calculateReasoningTimeout from @automaker/types
*/
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
const CONTEXT_WINDOW_256K = 256000;
const MAX_OUTPUT_32K = 32000;
const MAX_OUTPUT_16K = 16000;
@@ -832,14 +827,7 @@ export class CodexProvider extends BaseProvider {
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
// for the model to generate reasoning tokens before producing output.
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
//
// For feature generation with 'xhigh', use the extended 5-minute base timeout
// since generating 50+ features takes significantly longer than normal operations.
const baseTimeout =
options.reasoningEffort === 'xhigh'
? CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS
: CODEX_CLI_TIMEOUT_MS;
const timeout = calculateReasoningTimeout(options.reasoningEffort, baseTimeout);
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
const stream = spawnJSONLProcess({
command: commandPath,

View File

@@ -1,942 +0,0 @@
/**
* Copilot Provider - Executes queries using the GitHub Copilot SDK
*
* Uses the official @github/copilot-sdk for:
* - Session management and streaming responses
* - GitHub OAuth authentication (via gh CLI)
* - Tool call handling and permission management
* - Runtime model discovery
*
* Based on https://github.com/github/copilot-sdk
*/
import { execSync } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
import type {
ProviderConfig,
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
} from './types.js';
// Note: validateBareModelId is not used because Copilot's bare model IDs
// legitimately contain prefixes like claude-, gemini-, gpt-
import {
COPILOT_MODEL_MAP,
type CopilotAuthStatus,
type CopilotRuntimeModel,
} from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils';
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
import {
normalizeTodos,
normalizeFilePathInput,
normalizeCommandInput,
normalizePatternInput,
} from './tool-normalization.js';
// Create logger for this module
const logger = createLogger('CopilotProvider');
// Default bare model (without copilot- prefix) for SDK calls
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
// =============================================================================
// SDK Event Types (from @github/copilot-sdk)
// =============================================================================
/**
* SDK session event data types
*/
interface SdkEvent {
type: string;
data?: unknown;
}
interface SdkMessageEvent extends SdkEvent {
type: 'assistant.message';
data: {
content: string;
};
}
// Note: SdkMessageDeltaEvent is not used - we skip delta events to reduce noise
// The final assistant.message event contains the complete content
interface SdkToolExecutionStartEvent extends SdkEvent {
type: 'tool.execution_start';
data: {
toolName: string;
toolCallId: string;
input?: Record<string, unknown>;
};
}
interface SdkToolExecutionEndEvent extends SdkEvent {
type: 'tool.execution_end';
data: {
toolName: string;
toolCallId: string;
result?: string;
error?: string;
};
}
interface SdkSessionIdleEvent extends SdkEvent {
type: 'session.idle';
}
interface SdkSessionErrorEvent extends SdkEvent {
type: 'session.error';
data: {
message: string;
code?: string;
};
}
// =============================================================================
// Error Codes
// =============================================================================
export enum CopilotErrorCode {
NOT_INSTALLED = 'COPILOT_NOT_INSTALLED',
NOT_AUTHENTICATED = 'COPILOT_NOT_AUTHENTICATED',
RATE_LIMITED = 'COPILOT_RATE_LIMITED',
MODEL_UNAVAILABLE = 'COPILOT_MODEL_UNAVAILABLE',
NETWORK_ERROR = 'COPILOT_NETWORK_ERROR',
PROCESS_CRASHED = 'COPILOT_PROCESS_CRASHED',
TIMEOUT = 'COPILOT_TIMEOUT',
CLI_ERROR = 'COPILOT_CLI_ERROR',
SDK_ERROR = 'COPILOT_SDK_ERROR',
UNKNOWN = 'COPILOT_UNKNOWN_ERROR',
}
export interface CopilotError extends Error {
code: CopilotErrorCode;
recoverable: boolean;
suggestion?: string;
}
// =============================================================================
// Tool Name Normalization
// =============================================================================
/**
* Copilot SDK tool name to standard tool name mapping
*
* Maps Copilot CLI tool names to our standard tool names for consistent UI display.
* Tool names are case-insensitive (normalized to lowercase before lookup).
*/
const COPILOT_TOOL_NAME_MAP: Record<string, string> = {
// File operations
read_file: 'Read',
read: 'Read',
view: 'Read', // Copilot uses 'view' for reading files
read_many_files: 'Read',
write_file: 'Write',
write: 'Write',
create_file: 'Write',
edit_file: 'Edit',
edit: 'Edit',
replace: 'Edit',
patch: 'Edit',
// Shell operations
run_shell: 'Bash',
run_shell_command: 'Bash',
shell: 'Bash',
bash: 'Bash',
execute: 'Bash',
terminal: 'Bash',
// Search operations
search: 'Grep',
grep: 'Grep',
search_file_content: 'Grep',
find_files: 'Glob',
glob: 'Glob',
list_dir: 'Ls',
list_directory: 'Ls',
ls: 'Ls',
// Web operations
web_fetch: 'WebFetch',
fetch: 'WebFetch',
web_search: 'WebSearch',
search_web: 'WebSearch',
google_web_search: 'WebSearch',
// Todo operations
todo_write: 'TodoWrite',
write_todos: 'TodoWrite',
update_todos: 'TodoWrite',
// Planning/intent operations (Copilot-specific)
report_intent: 'ReportIntent', // Keep as-is, it's a planning tool
think: 'Think',
plan: 'Plan',
};
/**
* Normalize Copilot tool names to standard tool names
*/
function normalizeCopilotToolName(copilotToolName: string): string {
const lowerName = copilotToolName.toLowerCase();
return COPILOT_TOOL_NAME_MAP[lowerName] || copilotToolName;
}
/**
* Normalize Copilot tool input parameters to standard format
*
* Maps Copilot's parameter names to our standard parameter names.
* Uses shared utilities from tool-normalization.ts for common normalizations.
*/
function normalizeCopilotToolInput(
toolName: string,
input: Record<string, unknown>
): Record<string, unknown> {
const normalizedName = normalizeCopilotToolName(toolName);
// Normalize todo_write / write_todos: ensure proper format
if (normalizedName === 'TodoWrite' && Array.isArray(input.todos)) {
return { todos: normalizeTodos(input.todos) };
}
// Normalize file path parameters for Read/Write/Edit tools
if (normalizedName === 'Read' || normalizedName === 'Write' || normalizedName === 'Edit') {
return normalizeFilePathInput(input);
}
// Normalize shell command parameters for Bash tool
if (normalizedName === 'Bash') {
return normalizeCommandInput(input);
}
// Normalize search parameters for Grep tool
if (normalizedName === 'Grep') {
return normalizePatternInput(input);
}
return input;
}
/**
* CopilotProvider - Integrates GitHub Copilot SDK as an AI provider
*
* Features:
* - GitHub OAuth authentication
* - SDK-based session management
* - Runtime model discovery
* - Tool call normalization
* - Per-execution working directory support
*/
export class CopilotProvider extends CliProvider {
private runtimeModels: CopilotRuntimeModel[] | null = null;
constructor(config: ProviderConfig = {}) {
super(config);
// Trigger CLI detection on construction
this.ensureCliDetected();
}
// ==========================================================================
// CliProvider Abstract Method Implementations
// ==========================================================================
getName(): string {
return 'copilot';
}
getCliName(): string {
return 'copilot';
}
getSpawnConfig(): CliSpawnConfig {
return {
windowsStrategy: 'npx', // Copilot CLI can be run via npx
npxPackage: '@github/copilot', // Official GitHub Copilot CLI package
commonPaths: {
linux: [
path.join(os.homedir(), '.local/bin/copilot'),
'/usr/local/bin/copilot',
path.join(os.homedir(), '.npm-global/bin/copilot'),
],
darwin: [
path.join(os.homedir(), '.local/bin/copilot'),
'/usr/local/bin/copilot',
'/opt/homebrew/bin/copilot',
path.join(os.homedir(), '.npm-global/bin/copilot'),
],
win32: [
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'copilot.cmd'),
path.join(os.homedir(), '.npm-global', 'copilot.cmd'),
],
},
};
}
/**
* Extract prompt text from ExecuteOptions
*
* Note: CopilotProvider does not yet support vision/image inputs.
* If non-text content is provided, an error is thrown.
*/
private extractPromptText(options: ExecuteOptions): string {
if (typeof options.prompt === 'string') {
return options.prompt;
} else if (Array.isArray(options.prompt)) {
// Check for non-text content (images, etc.) which we don't support yet
const hasNonText = options.prompt.some((p) => p.type !== 'text');
if (hasNonText) {
throw new Error(
'CopilotProvider does not yet support non-text prompt parts (e.g., images). ' +
'Please use text-only prompts or switch to a provider that supports vision.'
);
}
return options.prompt
.filter((p) => p.type === 'text' && p.text)
.map((p) => p.text)
.join('\n');
} else {
throw new Error('Invalid prompt format');
}
}
/**
* Not used with SDK approach - kept for interface compatibility
*/
buildCliArgs(_options: ExecuteOptions): string[] {
return [];
}
/**
* Convert SDK event to AutoMaker ProviderMessage format
*/
normalizeEvent(event: unknown): ProviderMessage | null {
const sdkEvent = event as SdkEvent;
switch (sdkEvent.type) {
case 'assistant.message': {
const messageEvent = sdkEvent as SdkMessageEvent;
return {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: messageEvent.data.content }],
},
};
}
case 'assistant.message_delta': {
// Skip delta events - they create too much noise
// The final assistant.message event has the complete content
return null;
}
case 'tool.execution_start': {
const toolEvent = sdkEvent as SdkToolExecutionStartEvent;
const normalizedName = normalizeCopilotToolName(toolEvent.data.toolName);
const normalizedInput = toolEvent.data.input
? normalizeCopilotToolInput(toolEvent.data.toolName, toolEvent.data.input)
: {};
return {
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: normalizedName,
tool_use_id: toolEvent.data.toolCallId,
input: normalizedInput,
},
],
},
};
}
case 'tool.execution_end': {
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent;
const isError = !!toolResultEvent.data.error;
const content = isError
? `[ERROR] ${toolResultEvent.data.error}`
: toolResultEvent.data.result || '';
return {
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_result',
tool_use_id: toolResultEvent.data.toolCallId,
content,
},
],
},
};
}
case 'session.idle': {
logger.debug('Copilot session idle');
return {
type: 'result',
subtype: 'success',
};
}
case 'session.error': {
const errorEvent = sdkEvent as SdkSessionErrorEvent;
return {
type: 'error',
error: errorEvent.data.message || 'Unknown error',
};
}
default:
logger.debug(`Unknown Copilot SDK event type: ${sdkEvent.type}`);
return null;
}
}
// ==========================================================================
// CliProvider Overrides
// ==========================================================================
/**
* Override error mapping for Copilot-specific error codes
*/
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
const lower = stderr.toLowerCase();
if (
lower.includes('not authenticated') ||
lower.includes('please log in') ||
lower.includes('unauthorized') ||
lower.includes('login required') ||
lower.includes('authentication required') ||
lower.includes('github login')
) {
return {
code: CopilotErrorCode.NOT_AUTHENTICATED,
message: 'GitHub Copilot is not authenticated',
recoverable: true,
suggestion: 'Run "gh auth login" or "copilot auth login" to authenticate with GitHub',
};
}
if (
lower.includes('rate limit') ||
lower.includes('too many requests') ||
lower.includes('429') ||
lower.includes('quota exceeded')
) {
return {
code: CopilotErrorCode.RATE_LIMITED,
message: 'Copilot API rate limit exceeded',
recoverable: true,
suggestion: 'Wait a few minutes and try again',
};
}
if (
lower.includes('model not available') ||
lower.includes('invalid model') ||
lower.includes('unknown model') ||
lower.includes('model not found') ||
(lower.includes('not found') && lower.includes('404'))
) {
return {
code: CopilotErrorCode.MODEL_UNAVAILABLE,
message: 'Requested model is not available',
recoverable: true,
suggestion: `Try using "${DEFAULT_BARE_MODEL}" or select a different model`,
};
}
if (
lower.includes('network') ||
lower.includes('connection') ||
lower.includes('econnrefused') ||
lower.includes('timeout')
) {
return {
code: CopilotErrorCode.NETWORK_ERROR,
message: 'Network connection error',
recoverable: true,
suggestion: 'Check your internet connection and try again',
};
}
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
return {
code: CopilotErrorCode.PROCESS_CRASHED,
message: 'Copilot CLI process was terminated',
recoverable: true,
suggestion: 'The process may have run out of memory. Try a simpler task.',
};
}
return {
code: CopilotErrorCode.UNKNOWN,
message: stderr || `Copilot CLI exited with code ${exitCode}`,
recoverable: false,
};
}
/**
* Override install instructions for Copilot-specific guidance
*/
protected getInstallInstructions(): string {
return 'Install with: npm install -g @github/copilot (or visit https://github.com/github/copilot)';
}
/**
* Execute a prompt using Copilot SDK with real-time streaming
*
* Creates a new CopilotClient for each execution with the correct working directory.
* Streams tool execution events in real-time for UI display.
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Note: We don't use validateBareModelId here because Copilot's model IDs
// legitimately contain prefixes like claude-, gemini-, gpt- which are the
// actual model names from the Copilot CLI. We only need to ensure the
// copilot- prefix has been stripped by the ProviderFactory.
if (options.model?.startsWith('copilot-')) {
throw new Error(
`[CopilotProvider] Model ID should not have 'copilot-' prefix. Got: '${options.model}'. ` +
`The ProviderFactory should strip this prefix before passing to the provider.`
);
}
if (!this.cliPath) {
throw this.createError(
CopilotErrorCode.NOT_INSTALLED,
'Copilot CLI is not installed',
true,
this.getInstallInstructions()
);
}
const promptText = this.extractPromptText(options);
const bareModel = options.model || DEFAULT_BARE_MODEL;
const workingDirectory = options.cwd || process.cwd();
logger.debug(
`CopilotProvider.executeQuery called with model: "${bareModel}", cwd: "${workingDirectory}"`
);
logger.debug(`Prompt length: ${promptText.length} characters`);
// Create a client for this execution with the correct working directory
const client = new CopilotClient({
logLevel: 'warning',
autoRestart: false,
cwd: workingDirectory,
});
// Use an async queue to bridge callback-based SDK events to async generator
const eventQueue: SdkEvent[] = [];
let resolveWaiting: (() => void) | null = null;
let sessionComplete = false;
let sessionError: Error | null = null;
const pushEvent = (event: SdkEvent) => {
eventQueue.push(event);
if (resolveWaiting) {
resolveWaiting();
resolveWaiting = null;
}
};
const waitForEvent = (): Promise<void> => {
if (eventQueue.length > 0 || sessionComplete) {
return Promise.resolve();
}
return new Promise((resolve) => {
resolveWaiting = resolve;
});
};
try {
await client.start();
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
// Create session with streaming enabled for real-time events
const session = await client.createSession({
model: bareModel,
streaming: true,
// AUTONOMOUS MODE: Auto-approve all permission requests.
// AutoMaker is designed for fully autonomous AI agent operation.
// Security boundary is provided by Docker containerization (see CLAUDE.md).
// User is warned about this at app startup.
onPermissionRequest: async (
request: PermissionRequest
): Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user' }> => {
logger.debug(`Permission request: ${request.kind}`);
return { kind: 'approved' };
},
});
const sessionId = session.sessionId;
logger.debug(`Session created: ${sessionId}`);
// Set up event handler to push events to queue
session.on((event: SdkEvent) => {
logger.debug(`SDK event: ${event.type}`);
if (event.type === 'session.idle') {
sessionComplete = true;
pushEvent(event);
} else if (event.type === 'session.error') {
const errorEvent = event as SdkSessionErrorEvent;
sessionError = new Error(errorEvent.data.message);
sessionComplete = true;
pushEvent(event);
} else {
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.)
pushEvent(event);
}
});
// Send the prompt (non-blocking)
await session.send({ prompt: promptText });
// Process events as they arrive
while (!sessionComplete || eventQueue.length > 0) {
await waitForEvent();
// Check for errors first (before processing events to avoid race condition)
if (sessionError) {
await session.destroy();
await client.stop();
throw sessionError;
}
// Process all queued events
while (eventQueue.length > 0) {
const event = eventQueue.shift()!;
const normalized = this.normalizeEvent(event);
if (normalized) {
// Add session_id if not present
if (!normalized.session_id) {
normalized.session_id = sessionId;
}
yield normalized;
}
}
}
// Cleanup
await session.destroy();
await client.stop();
logger.debug('CopilotClient stopped successfully');
} catch (error) {
// Ensure client is stopped on error
try {
await client.stop();
} catch (cleanupError) {
// Log but don't throw cleanup errors - the original error is more important
logger.debug(`Failed to stop client during cleanup: ${cleanupError}`);
}
if (isAbortError(error)) {
logger.debug('Query aborted');
return;
}
// Map errors to CopilotError
if (error instanceof Error) {
logger.error(`Copilot SDK error: ${error.message}`);
const errorInfo = this.mapError(error.message, null);
throw this.createError(
errorInfo.code as CopilotErrorCode,
errorInfo.message,
errorInfo.recoverable,
errorInfo.suggestion
);
}
throw error;
}
}
// ==========================================================================
// Copilot-Specific Methods
// ==========================================================================
/**
* Create a CopilotError with details
*/
private createError(
code: CopilotErrorCode,
message: string,
recoverable: boolean = false,
suggestion?: string
): CopilotError {
const error = new Error(message) as CopilotError;
error.code = code;
error.recoverable = recoverable;
error.suggestion = suggestion;
error.name = 'CopilotError';
return error;
}
/**
* Get Copilot CLI version
*/
async getVersion(): Promise<string | null> {
this.ensureCliDetected();
if (!this.cliPath) return null;
try {
const result = execSync(`"${this.cliPath}" --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
}).trim();
return result;
} catch {
return null;
}
}
/**
* Check authentication status
*
* Uses GitHub CLI (gh) to check Copilot authentication status.
* The Copilot CLI relies on gh auth for authentication.
*/
async checkAuth(): Promise<CopilotAuthStatus> {
this.ensureCliDetected();
if (!this.cliPath) {
logger.debug('checkAuth: CLI not found');
return { authenticated: false, method: 'none' };
}
logger.debug('checkAuth: Starting credential check');
// Try to check GitHub CLI authentication status first
// The Copilot CLI uses gh auth for authentication
try {
const ghStatus = execSync('gh auth status --hostname github.com', {
encoding: 'utf8',
timeout: 10000,
stdio: 'pipe',
});
logger.debug(`checkAuth: gh auth status output: ${ghStatus.substring(0, 200)}`);
// Parse gh auth status output
const loggedInMatch = ghStatus.match(/Logged in to github\.com account (\S+)/);
if (loggedInMatch) {
return {
authenticated: true,
method: 'oauth',
login: loggedInMatch[1],
host: 'github.com',
};
}
// Check for token auth
if (ghStatus.includes('Logged in') || ghStatus.includes('Token:')) {
return {
authenticated: true,
method: 'oauth',
host: 'github.com',
};
}
} catch (ghError) {
logger.debug(`checkAuth: gh auth status failed: ${ghError}`);
}
// Try Copilot-specific auth check if gh is not available
try {
const result = execSync(`"${this.cliPath}" auth status`, {
encoding: 'utf8',
timeout: 10000,
stdio: 'pipe',
});
logger.debug(`checkAuth: copilot auth status output: ${result.substring(0, 200)}`);
if (result.includes('authenticated') || result.includes('logged in')) {
return {
authenticated: true,
method: 'cli',
};
}
} catch (copilotError) {
logger.debug(`checkAuth: copilot auth status failed: ${copilotError}`);
}
// Check for GITHUB_TOKEN environment variable
if (process.env.GITHUB_TOKEN) {
logger.debug('checkAuth: Found GITHUB_TOKEN environment variable');
return {
authenticated: true,
method: 'oauth',
statusMessage: 'Using GITHUB_TOKEN environment variable',
};
}
// Check for gh config file
const ghConfigPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
try {
await fs.access(ghConfigPath);
const content = await fs.readFile(ghConfigPath, 'utf8');
if (content.includes('github.com') && content.includes('oauth_token')) {
logger.debug('checkAuth: Found gh config with oauth_token');
return {
authenticated: true,
method: 'oauth',
host: 'github.com',
};
}
} catch {
logger.debug('checkAuth: No gh config found');
}
// No credentials found
logger.debug('checkAuth: No valid credentials found');
return {
authenticated: false,
method: 'none',
error:
'No authentication configured. Run "gh auth login" or install GitHub Copilot extension.',
};
}
/**
* Fetch available models from the CLI at runtime
*/
async fetchRuntimeModels(): Promise<CopilotRuntimeModel[]> {
this.ensureCliDetected();
if (!this.cliPath) {
return [];
}
try {
// Try to list models using the CLI
const result = execSync(`"${this.cliPath}" models list --format json`, {
encoding: 'utf8',
timeout: 15000,
stdio: 'pipe',
});
const models = JSON.parse(result) as CopilotRuntimeModel[];
this.runtimeModels = models;
logger.debug(`Fetched ${models.length} runtime models from Copilot CLI`);
return models;
} catch (error) {
// Clear cache on failure to avoid returning stale data
this.runtimeModels = null;
logger.debug(`Failed to fetch runtime models: ${error}`);
return [];
}
}
/**
* Detect installation status (required by BaseProvider)
*/
async detectInstallation(): Promise<InstallationStatus> {
const installed = await this.isInstalled();
const version = installed ? await this.getVersion() : undefined;
const auth = await this.checkAuth();
return {
installed,
version: version || undefined,
path: this.cliPath || undefined,
method: 'cli',
authenticated: auth.authenticated,
};
}
/**
* Get the detected CLI path (public accessor for status endpoints)
*/
getCliPath(): string | null {
this.ensureCliDetected();
return this.cliPath;
}
/**
* Get available Copilot models
*
* Returns both static model definitions and runtime-discovered models
*/
getAvailableModels(): ModelDefinition[] {
// Start with static model definitions - explicitly typed to allow runtime models
const staticModels: ModelDefinition[] = Object.entries(COPILOT_MODEL_MAP).map(
([id, config]) => ({
id, // Full model ID with copilot- prefix
name: config.label,
modelString: id.replace('copilot-', ''), // Bare model for CLI
provider: 'copilot',
description: config.description,
supportsTools: config.supportsTools,
supportsVision: config.supportsVision,
contextWindow: config.contextWindow,
})
);
// Add runtime models if available (discovered via CLI)
if (this.runtimeModels) {
for (const runtimeModel of this.runtimeModels) {
// Skip if already in static list
const staticId = `copilot-${runtimeModel.id}`;
if (staticModels.some((m) => m.id === staticId)) {
continue;
}
staticModels.push({
id: staticId,
name: runtimeModel.name || runtimeModel.id,
modelString: runtimeModel.id,
provider: 'copilot',
description: `Dynamic model: ${runtimeModel.name || runtimeModel.id}`,
supportsTools: true,
supportsVision: runtimeModel.capabilities?.supportsVision ?? false,
contextWindow: runtimeModel.capabilities?.maxInputTokens,
});
}
}
return staticModels;
}
/**
* Check if a feature is supported
*
* Note: Vision is NOT currently supported - the SDK doesn't handle image inputs yet.
* This may change in future versions of the Copilot SDK.
*/
supportsFeature(feature: string): boolean {
const supported = ['tools', 'text', 'streaming'];
return supported.includes(feature);
}
/**
* Check if runtime models have been cached
*/
hasCachedModels(): boolean {
return this.runtimeModels !== null && this.runtimeModels.length > 0;
}
/**
* Clear the runtime model cache
*/
clearModelCache(): void {
this.runtimeModels = null;
logger.debug('Cleared Copilot model cache');
}
/**
* Refresh models from CLI and return all available models
*/
async refreshModels(): Promise<ModelDefinition[]> {
logger.debug('Refreshing Copilot models from CLI');
await this.fetchRuntimeModels();
return this.getAvailableModels();
}
}

View File

@@ -44,7 +44,7 @@ export class CursorConfigManager {
// Return default config with all available models
return {
defaultModel: 'cursor-auto',
defaultModel: 'auto',
models: getAllCursorModelIds(),
};
}
@@ -77,7 +77,7 @@ export class CursorConfigManager {
* Get the default model
*/
getDefaultModel(): CursorModelId {
return this.config.defaultModel || 'cursor-auto';
return this.config.defaultModel || 'auto';
}
/**
@@ -93,7 +93,7 @@ export class CursorConfigManager {
* Get enabled models
*/
getEnabledModels(): CursorModelId[] {
return this.config.models || ['cursor-auto'];
return this.config.models || ['auto'];
}
/**
@@ -174,7 +174,7 @@ export class CursorConfigManager {
*/
reset(): void {
this.config = {
defaultModel: 'cursor-auto',
defaultModel: 'auto',
models: getAllCursorModelIds(),
};
this.saveConfig();

View File

@@ -337,11 +337,10 @@ export class CursorProvider extends CliProvider {
'--stream-partial-output' // Real-time streaming
);
// In read-only mode, use --mode ask for Q&A style (no tools)
// Otherwise, add --force to allow file edits
if (options.readOnly) {
cliArgs.push('--mode', 'ask');
} else {
// Only add --force if NOT in read-only mode
// Without --force, Cursor CLI suggests changes but doesn't apply them
// With --force, Cursor CLI can actually edit files
if (!options.readOnly) {
cliArgs.push('--force');
}
@@ -673,13 +672,10 @@ export class CursorProvider extends CliProvider {
);
}
// Embed system prompt into user prompt (Cursor CLI doesn't support separate system messages)
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
// Extract prompt text to pass via stdin (avoids shell escaping issues)
const promptText = this.extractPromptText(effectiveOptions);
const promptText = this.extractPromptText(options);
const cliArgs = this.buildCliArgs(effectiveOptions);
const cliArgs = this.buildCliArgs(options);
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
// Pass prompt via stdin to avoid shell interpretation of special characters

View File

@@ -1,810 +0,0 @@
/**
* Gemini Provider - Executes queries using the Gemini CLI
*
* Extends CliProvider with Gemini-specific:
* - Event normalization for Gemini's JSONL streaming format
* - Google account and API key authentication support
* - Thinking level configuration
*
* Based on https://github.com/google-gemini/gemini-cli
*/
import { execSync } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
import type {
ProviderConfig,
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
ContentBlock,
} from './types.js';
import { validateBareModelId } from '@automaker/types';
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils';
import { spawnJSONLProcess } from '@automaker/platform';
import { normalizeTodos } from './tool-normalization.js';
// Create logger for this module
const logger = createLogger('GeminiProvider');
// =============================================================================
// Gemini Stream Event Types
// =============================================================================
/**
* Base event structure from Gemini CLI --output-format stream-json
*
* Actual CLI output format:
* {"type":"init","timestamp":"...","session_id":"...","model":"..."}
* {"type":"message","timestamp":"...","role":"user","content":"..."}
* {"type":"message","timestamp":"...","role":"assistant","content":"...","delta":true}
* {"type":"tool_use","timestamp":"...","tool_name":"...","tool_id":"...","parameters":{...}}
* {"type":"tool_result","timestamp":"...","tool_id":"...","status":"success","output":"..."}
* {"type":"result","timestamp":"...","status":"success","stats":{...}}
*/
interface GeminiStreamEvent {
type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'error';
timestamp?: string;
session_id?: string;
}
interface GeminiInitEvent extends GeminiStreamEvent {
type: 'init';
session_id: string;
model: string;
}
interface GeminiMessageEvent extends GeminiStreamEvent {
type: 'message';
role: 'user' | 'assistant';
content: string;
delta?: boolean;
session_id?: string;
}
interface GeminiToolUseEvent extends GeminiStreamEvent {
type: 'tool_use';
tool_id: string;
tool_name: string;
parameters: Record<string, unknown>;
session_id?: string;
}
interface GeminiToolResultEvent extends GeminiStreamEvent {
type: 'tool_result';
tool_id: string;
status: 'success' | 'error';
output: string;
session_id?: string;
}
interface GeminiResultEvent extends GeminiStreamEvent {
type: 'result';
status: 'success' | 'error';
stats?: {
total_tokens?: number;
input_tokens?: number;
output_tokens?: number;
cached?: number;
input?: number;
duration_ms?: number;
tool_calls?: number;
};
error?: string;
session_id?: string;
}
// =============================================================================
// Error Codes
// =============================================================================
export enum GeminiErrorCode {
NOT_INSTALLED = 'GEMINI_NOT_INSTALLED',
NOT_AUTHENTICATED = 'GEMINI_NOT_AUTHENTICATED',
RATE_LIMITED = 'GEMINI_RATE_LIMITED',
MODEL_UNAVAILABLE = 'GEMINI_MODEL_UNAVAILABLE',
NETWORK_ERROR = 'GEMINI_NETWORK_ERROR',
PROCESS_CRASHED = 'GEMINI_PROCESS_CRASHED',
TIMEOUT = 'GEMINI_TIMEOUT',
UNKNOWN = 'GEMINI_UNKNOWN_ERROR',
}
export interface GeminiError extends Error {
code: GeminiErrorCode;
recoverable: boolean;
suggestion?: string;
}
// =============================================================================
// Tool Name Normalization
// =============================================================================
/**
* Gemini CLI tool name to standard tool name mapping
* This allows the UI to properly categorize and display Gemini tool calls
*/
const GEMINI_TOOL_NAME_MAP: Record<string, string> = {
write_todos: 'TodoWrite',
read_file: 'Read',
read_many_files: 'Read',
replace: 'Edit',
write_file: 'Write',
run_shell_command: 'Bash',
search_file_content: 'Grep',
glob: 'Glob',
list_directory: 'Ls',
web_fetch: 'WebFetch',
google_web_search: 'WebSearch',
};
/**
* Normalize Gemini tool names to standard tool names
*/
function normalizeGeminiToolName(geminiToolName: string): string {
return GEMINI_TOOL_NAME_MAP[geminiToolName] || geminiToolName;
}
/**
* Normalize Gemini tool input parameters to standard format
*
* Uses shared normalizeTodos utility for consistent todo normalization.
*
* Gemini `write_todos` format:
* {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
*
* Claude `TodoWrite` format:
* {"todos": [{"content": "Task text", "status": "pending|in_progress|completed", "activeForm": "..."}]}
*/
function normalizeGeminiToolInput(
toolName: string,
input: Record<string, unknown>
): Record<string, unknown> {
// Normalize write_todos using shared utility
if (toolName === 'write_todos' && Array.isArray(input.todos)) {
return { todos: normalizeTodos(input.todos) };
}
return input;
}
/**
* GeminiProvider - Integrates Gemini CLI as an AI provider
*
* Features:
* - Google account OAuth login support
* - API key authentication (GEMINI_API_KEY)
* - Vertex AI support
* - Thinking level configuration
* - Streaming JSON output
*/
export class GeminiProvider extends CliProvider {
constructor(config: ProviderConfig = {}) {
super(config);
// Trigger CLI detection on construction
this.ensureCliDetected();
}
// ==========================================================================
// CliProvider Abstract Method Implementations
// ==========================================================================
getName(): string {
return 'gemini';
}
getCliName(): string {
return 'gemini';
}
getSpawnConfig(): CliSpawnConfig {
return {
windowsStrategy: 'npx', // Gemini CLI can be run via npx
npxPackage: '@google/gemini-cli', // Official Google Gemini CLI package
commonPaths: {
linux: [
path.join(os.homedir(), '.local/bin/gemini'),
'/usr/local/bin/gemini',
path.join(os.homedir(), '.npm-global/bin/gemini'),
],
darwin: [
path.join(os.homedir(), '.local/bin/gemini'),
'/usr/local/bin/gemini',
'/opt/homebrew/bin/gemini',
path.join(os.homedir(), '.npm-global/bin/gemini'),
],
win32: [
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'),
path.join(os.homedir(), '.npm-global', 'gemini.cmd'),
],
},
};
}
/**
* Extract prompt text from ExecuteOptions
*/
private extractPromptText(options: ExecuteOptions): string {
if (typeof options.prompt === 'string') {
return options.prompt;
} else if (Array.isArray(options.prompt)) {
return options.prompt
.filter((p) => p.type === 'text' && p.text)
.map((p) => p.text)
.join('\n');
} else {
throw new Error('Invalid prompt format');
}
}
buildCliArgs(options: ExecuteOptions): string[] {
// Model comes in stripped of provider prefix (e.g., '2.5-flash' from 'gemini-2.5-flash')
// We need to add 'gemini-' back since it's part of the actual CLI model name
const bareModel = options.model || '2.5-flash';
const cliArgs: string[] = [];
// Streaming JSON output format for real-time updates
cliArgs.push('--output-format', 'stream-json');
// Model selection - Gemini CLI expects full model names like "gemini-2.5-flash"
// Unlike Cursor CLI where 'cursor-' is just a routing prefix, for Gemini CLI
// the 'gemini-' is part of the actual model name Google expects
if (bareModel && bareModel !== 'auto') {
// Add gemini- prefix if not already present (handles edge cases)
const cliModel = bareModel.startsWith('gemini-') ? bareModel : `gemini-${bareModel}`;
cliArgs.push('--model', cliModel);
}
// Disable sandbox mode for faster execution (sandbox adds overhead)
cliArgs.push('--sandbox', 'false');
// YOLO mode for automatic approval (required for non-interactive use)
// Use explicit approval-mode for clearer semantics
cliArgs.push('--approval-mode', 'yolo');
// Explicitly include the working directory in allowed workspace directories
// This ensures Gemini CLI allows file operations in the project directory,
// even if it has a different workspace cached from a previous session
if (options.cwd) {
cliArgs.push('--include-directories', options.cwd);
}
// Note: Gemini CLI doesn't have a --thinking-level flag.
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
// The model handles thinking internally based on the task complexity.
// The prompt will be passed as the last positional argument
// We'll append it in executeQuery after extracting the text
return cliArgs;
}
/**
* Convert Gemini event to AutoMaker ProviderMessage format
*/
normalizeEvent(event: unknown): ProviderMessage | null {
const geminiEvent = event as GeminiStreamEvent;
switch (geminiEvent.type) {
case 'init': {
// Init event - capture session but don't yield a message
const initEvent = geminiEvent as GeminiInitEvent;
logger.debug(
`Gemini init event: session=${initEvent.session_id}, model=${initEvent.model}`
);
return null;
}
case 'message': {
const messageEvent = geminiEvent as GeminiMessageEvent;
// Skip user messages - already handled by caller
if (messageEvent.role === 'user') {
return null;
}
// Handle assistant messages
if (messageEvent.role === 'assistant') {
return {
type: 'assistant',
session_id: messageEvent.session_id,
message: {
role: 'assistant',
content: [{ type: 'text', text: messageEvent.content }],
},
};
}
return null;
}
case 'tool_use': {
const toolEvent = geminiEvent as GeminiToolUseEvent;
const normalizedName = normalizeGeminiToolName(toolEvent.tool_name);
const normalizedInput = normalizeGeminiToolInput(
toolEvent.tool_name,
toolEvent.parameters as Record<string, unknown>
);
return {
type: 'assistant',
session_id: toolEvent.session_id,
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: normalizedName,
tool_use_id: toolEvent.tool_id,
input: normalizedInput,
},
],
},
};
}
case 'tool_result': {
const toolResultEvent = geminiEvent as GeminiToolResultEvent;
// If tool result is an error, prefix with error indicator
const content =
toolResultEvent.status === 'error'
? `[ERROR] ${toolResultEvent.output}`
: toolResultEvent.output;
return {
type: 'assistant',
session_id: toolResultEvent.session_id,
message: {
role: 'assistant',
content: [
{
type: 'tool_result',
tool_use_id: toolResultEvent.tool_id,
content,
},
],
},
};
}
case 'result': {
const resultEvent = geminiEvent as GeminiResultEvent;
if (resultEvent.status === 'error') {
return {
type: 'error',
session_id: resultEvent.session_id,
error: resultEvent.error || 'Unknown error',
};
}
// Success result - include stats for logging
logger.debug(
`Gemini result: status=${resultEvent.status}, tokens=${resultEvent.stats?.total_tokens}`
);
return {
type: 'result',
subtype: 'success',
session_id: resultEvent.session_id,
};
}
case 'error': {
const errorEvent = geminiEvent as GeminiResultEvent;
return {
type: 'error',
session_id: errorEvent.session_id,
error: errorEvent.error || 'Unknown error',
};
}
default:
logger.debug(`Unknown Gemini event type: ${geminiEvent.type}`);
return null;
}
}
// ==========================================================================
// CliProvider Overrides
// ==========================================================================
/**
* Override error mapping for Gemini-specific error codes
*/
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
const lower = stderr.toLowerCase();
if (
lower.includes('not authenticated') ||
lower.includes('please log in') ||
lower.includes('unauthorized') ||
lower.includes('login required') ||
lower.includes('error authenticating') ||
lower.includes('loadcodeassist') ||
(lower.includes('econnrefused') && lower.includes('8888'))
) {
return {
code: GeminiErrorCode.NOT_AUTHENTICATED,
message: 'Gemini CLI is not authenticated',
recoverable: true,
suggestion:
'Run "gemini" interactively to log in, or set GEMINI_API_KEY environment variable',
};
}
if (
lower.includes('rate limit') ||
lower.includes('too many requests') ||
lower.includes('429') ||
lower.includes('quota exceeded')
) {
return {
code: GeminiErrorCode.RATE_LIMITED,
message: 'Gemini API rate limit exceeded',
recoverable: true,
suggestion: 'Wait a few minutes and try again. Free tier: 60 req/min, 1000 req/day',
};
}
if (
lower.includes('model not available') ||
lower.includes('invalid model') ||
lower.includes('unknown model') ||
lower.includes('modelnotfounderror') ||
lower.includes('model not found') ||
(lower.includes('not found') && lower.includes('404'))
) {
return {
code: GeminiErrorCode.MODEL_UNAVAILABLE,
message: 'Requested model is not available',
recoverable: true,
suggestion: 'Try using "gemini-2.5-flash" or select a different model',
};
}
if (
lower.includes('network') ||
lower.includes('connection') ||
lower.includes('econnrefused') ||
lower.includes('timeout')
) {
return {
code: GeminiErrorCode.NETWORK_ERROR,
message: 'Network connection error',
recoverable: true,
suggestion: 'Check your internet connection and try again',
};
}
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
return {
code: GeminiErrorCode.PROCESS_CRASHED,
message: 'Gemini CLI process was terminated',
recoverable: true,
suggestion: 'The process may have run out of memory. Try a simpler task.',
};
}
return {
code: GeminiErrorCode.UNKNOWN,
message: stderr || `Gemini CLI exited with code ${exitCode}`,
recoverable: false,
};
}
/**
* Override install instructions for Gemini-specific guidance
*/
protected getInstallInstructions(): string {
return 'Install with: npm install -g @google/gemini-cli (or visit https://github.com/google-gemini/gemini-cli)';
}
/**
* Execute a prompt using Gemini CLI with streaming
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Validate that model doesn't have a provider prefix
validateBareModelId(options.model, 'GeminiProvider');
if (!this.cliPath) {
throw this.createError(
GeminiErrorCode.NOT_INSTALLED,
'Gemini CLI is not installed',
true,
this.getInstallInstructions()
);
}
// Extract prompt text to pass as positional argument
const promptText = this.extractPromptText(options);
// Build CLI args and append the prompt as the last positional argument
const cliArgs = this.buildCliArgs(options);
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
let sessionId: string | undefined;
logger.debug(`GeminiProvider.executeQuery called with model: "${options.model}"`);
try {
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
const event = rawEvent as GeminiStreamEvent;
// Capture session ID from init event
if (event.type === 'init') {
const initEvent = event as GeminiInitEvent;
sessionId = initEvent.session_id;
logger.debug(`Session started: ${sessionId}, model: ${initEvent.model}`);
}
// Normalize and yield the event
const normalized = this.normalizeEvent(event);
if (normalized) {
if (!normalized.session_id && sessionId) {
normalized.session_id = sessionId;
}
yield normalized;
}
}
} catch (error) {
if (isAbortError(error)) {
logger.debug('Query aborted');
return;
}
// Map CLI errors to GeminiError
if (error instanceof Error && 'stderr' in error) {
const errorInfo = this.mapError(
(error as { stderr?: string }).stderr || error.message,
(error as { exitCode?: number | null }).exitCode ?? null
);
throw this.createError(
errorInfo.code as GeminiErrorCode,
errorInfo.message,
errorInfo.recoverable,
errorInfo.suggestion
);
}
throw error;
}
}
// ==========================================================================
// Gemini-Specific Methods
// ==========================================================================
/**
* Create a GeminiError with details
*/
private createError(
code: GeminiErrorCode,
message: string,
recoverable: boolean = false,
suggestion?: string
): GeminiError {
const error = new Error(message) as GeminiError;
error.code = code;
error.recoverable = recoverable;
error.suggestion = suggestion;
error.name = 'GeminiError';
return error;
}
/**
* Get Gemini CLI version
*/
async getVersion(): Promise<string | null> {
this.ensureCliDetected();
if (!this.cliPath) return null;
try {
const result = execSync(`"${this.cliPath}" --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
}).trim();
return result;
} catch {
return null;
}
}
/**
* Check authentication status
*
* Uses a fast credential check approach:
* 1. Check for GEMINI_API_KEY environment variable
* 2. Check for Google Cloud credentials
* 3. Check for Gemini settings file with stored credentials
* 4. Quick CLI auth test with --help (fast, doesn't make API calls)
*/
async checkAuth(): Promise<GeminiAuthStatus> {
this.ensureCliDetected();
if (!this.cliPath) {
logger.debug('checkAuth: CLI not found');
return { authenticated: false, method: 'none' };
}
logger.debug('checkAuth: Starting credential check');
// Determine the likely auth method based on environment
const hasApiKey = !!process.env.GEMINI_API_KEY;
const hasEnvApiKey = hasApiKey;
const hasVertexAi = !!(
process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GOOGLE_CLOUD_PROJECT
);
logger.debug(`checkAuth: hasApiKey=${hasApiKey}, hasVertexAi=${hasVertexAi}`);
// Check for Gemini credentials file (~/.gemini/settings.json)
const geminiConfigDir = path.join(os.homedir(), '.gemini');
const settingsPath = path.join(geminiConfigDir, 'settings.json');
let hasCredentialsFile = false;
let authType: string | null = null;
try {
await fs.access(settingsPath);
logger.debug(`checkAuth: Found settings file at ${settingsPath}`);
try {
const content = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(content);
// Auth config is at security.auth.selectedType (e.g., "oauth-personal", "oauth-adc", "api-key")
const selectedType = settings?.security?.auth?.selectedType;
if (selectedType) {
hasCredentialsFile = true;
authType = selectedType;
logger.debug(`checkAuth: Settings file has auth config, selectedType=${selectedType}`);
} else {
logger.debug(`checkAuth: Settings file found but no auth type configured`);
}
} catch (e) {
logger.debug(`checkAuth: Failed to parse settings file: ${e}`);
}
} catch {
logger.debug('checkAuth: No settings file found');
}
// If we have an API key, we're authenticated
if (hasApiKey) {
logger.debug('checkAuth: Using API key authentication');
return {
authenticated: true,
method: 'api_key',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
// If we have Vertex AI credentials, we're authenticated
if (hasVertexAi) {
logger.debug('checkAuth: Using Vertex AI authentication');
return {
authenticated: true,
method: 'vertex_ai',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
// Check if settings file indicates configured authentication
if (hasCredentialsFile && authType) {
// OAuth types: "oauth-personal", "oauth-adc"
// API key type: "api-key"
// Code assist: "code-assist" (requires IDE integration)
if (authType.startsWith('oauth')) {
logger.debug(`checkAuth: OAuth authentication configured (${authType})`);
return {
authenticated: true,
method: 'google_login',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
if (authType === 'api-key') {
logger.debug('checkAuth: API key authentication configured in settings');
return {
authenticated: true,
method: 'api_key',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
if (authType === 'code-assist' || authType === 'codeassist') {
logger.debug('checkAuth: Code Assist auth configured but requires local server');
return {
authenticated: false,
method: 'google_login',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
error:
'Code Assist authentication requires IDE integration. Please use "gemini" CLI to log in with a different method, or set GEMINI_API_KEY.',
};
}
// Unknown auth type but something is configured
logger.debug(`checkAuth: Unknown auth type configured: ${authType}`);
return {
authenticated: true,
method: 'google_login',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
// No credentials found
logger.debug('checkAuth: No valid credentials found');
return {
authenticated: false,
method: 'none',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
error:
'No authentication configured. Run "gemini" interactively to log in, or set GEMINI_API_KEY.',
};
}
/**
* Detect installation status (required by BaseProvider)
*/
async detectInstallation(): Promise<InstallationStatus> {
const installed = await this.isInstalled();
const version = installed ? await this.getVersion() : undefined;
const auth = await this.checkAuth();
return {
installed,
version: version || undefined,
path: this.cliPath || undefined,
method: 'cli',
hasApiKey: !!process.env.GEMINI_API_KEY,
authenticated: auth.authenticated,
};
}
/**
* Get the detected CLI path (public accessor for status endpoints)
*/
getCliPath(): string | null {
this.ensureCliDetected();
return this.cliPath;
}
/**
* Get available Gemini models
*/
getAvailableModels(): ModelDefinition[] {
return Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({
id, // Full model ID with gemini- prefix (e.g., 'gemini-2.5-flash')
name: config.label,
modelString: id, // Same as id - CLI uses the full model name
provider: 'gemini',
description: config.description,
supportsTools: true,
supportsVision: config.supportsVision,
contextWindow: config.contextWindow,
}));
}
/**
* Check if a feature is supported
*/
supportsFeature(feature: string): boolean {
const supported = ['tools', 'text', 'streaming', 'vision', 'thinking'];
return supported.includes(feature);
}
}

View File

@@ -16,16 +16,6 @@ export type {
ProviderMessage,
InstallationStatus,
ModelDefinition,
AgentDefinition,
ReasoningEffort,
SystemPromptPreset,
ConversationMessage,
ContentBlock,
ValidationResult,
McpServerConfig,
McpStdioServerConfig,
McpSSEServerConfig,
McpHttpServerConfig,
} from './types.js';
// Claude provider
@@ -38,12 +28,6 @@ export { CursorConfigManager } from './cursor-config-manager.js';
// OpenCode provider
export { OpencodeProvider } from './opencode-provider.js';
// Gemini provider
export { GeminiProvider, GeminiErrorCode } from './gemini-provider.js';
// Copilot provider (GitHub Copilot SDK)
export { CopilotProvider, CopilotErrorCode } from './copilot-provider.js';
// Provider factory
export { ProviderFactory } from './provider-factory.js';

View File

@@ -25,6 +25,7 @@ import type {
InstallationStatus,
ContentBlock,
} from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
@@ -327,18 +328,10 @@ export class OpencodeProvider extends CliProvider {
args.push('--format', 'json');
// Handle model selection
// Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx)
// OpenCode CLI expects provider/model format (e.g., 'opencode/big-model')
// Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5'
if (options.model) {
// Strip opencode- prefix if present, then ensure slash format
const model = options.model.startsWith('opencode-')
? options.model.slice('opencode-'.length)
: options.model;
// If model has slash, it's already provider/model format; otherwise prepend opencode/
const cliModel = model.includes('/') ? model : `opencode/${model}`;
args.push('--model', cliModel);
const model = stripProviderPrefix(options.model);
args.push('--model', model);
}
// Note: OpenCode reads from stdin automatically when input is piped
@@ -1042,7 +1035,7 @@ export class OpencodeProvider extends CliProvider {
'lm studio': 'lmstudio',
lmstudio: 'lmstudio',
opencode: 'opencode',
'z.ai coding plan': 'zai-coding-plan',
'z.ai coding plan': 'z-ai',
'z.ai': 'z-ai',
};

View File

@@ -7,14 +7,7 @@
import { BaseProvider } from './base-provider.js';
import type { InstallationStatus, ModelDefinition } from './types.js';
import {
isCursorModel,
isCodexModel,
isOpencodeModel,
isGeminiModel,
isCopilotModel,
type ModelProvider,
} from '@automaker/types';
import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types';
import * as fs from 'fs';
import * as path from 'path';
@@ -23,8 +16,6 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
codex: '.codex-disconnected',
cursor: '.cursor-disconnected',
opencode: '.opencode-disconnected',
gemini: '.gemini-disconnected',
copilot: '.copilot-disconnected',
};
/**
@@ -248,8 +239,8 @@ export class ProviderFactory {
model.modelString === modelId ||
model.id.endsWith(`-${modelId}`) ||
model.modelString.endsWith(`-${modelId}`) ||
model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') ||
model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '')
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
) {
return model.supportsVision ?? true;
}
@@ -276,8 +267,6 @@ import { ClaudeProvider } from './claude-provider.js';
import { CursorProvider } from './cursor-provider.js';
import { CodexProvider } from './codex-provider.js';
import { OpencodeProvider } from './opencode-provider.js';
import { GeminiProvider } from './gemini-provider.js';
import { CopilotProvider } from './copilot-provider.js';
// Register Claude provider
registerProvider('claude', {
@@ -312,19 +301,3 @@ registerProvider('opencode', {
canHandleModel: (model: string) => isOpencodeModel(model),
priority: 3, // Between codex (5) and claude (0)
});
// Register Gemini provider
registerProvider('gemini', {
factory: () => new GeminiProvider(),
aliases: ['google'],
canHandleModel: (model: string) => isGeminiModel(model),
priority: 4, // Between opencode (3) and codex (5)
});
// Register Copilot provider (GitHub Copilot SDK)
registerProvider('copilot', {
factory: () => new CopilotProvider(),
aliases: ['github-copilot', 'github'],
canHandleModel: (model: string) => isCopilotModel(model),
priority: 6, // High priority - check before Codex since both can handle GPT models
});

View File

@@ -20,9 +20,6 @@ import type {
ContentBlock,
ThinkingLevel,
ReasoningEffort,
ClaudeApiProfile,
ClaudeCompatibleProvider,
Credentials,
} from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
@@ -57,18 +54,6 @@ export interface SimpleQueryOptions {
readOnly?: boolean;
/** Setting sources for CLAUDE.md loading */
settingSources?: Array<'user' | 'project' | 'local'>;
/**
* Active Claude API profile for alternative endpoint configuration
* @deprecated Use claudeCompatibleProvider instead
*/
claudeApiProfile?: ClaudeApiProfile;
/**
* Claude-compatible provider for alternative endpoint configuration.
* Takes precedence over claudeApiProfile if both are set.
*/
claudeCompatibleProvider?: ClaudeCompatibleProvider;
/** Credentials for resolving 'credentials' apiKeySource in Claude API profiles/providers */
credentials?: Credentials;
}
/**
@@ -140,9 +125,6 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQu
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration
claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence)
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {
@@ -225,9 +207,6 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration
claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence)
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {

View File

@@ -1,112 +0,0 @@
/**
* Shared tool normalization utilities for AI providers
*
* These utilities help normalize tool inputs from various AI providers
* to the standard format expected by the application.
*/
/**
* Valid todo status values in the standard format
*/
type TodoStatus = 'pending' | 'in_progress' | 'completed';
/**
* Set of valid status values for validation
*/
const VALID_STATUSES = new Set<TodoStatus>(['pending', 'in_progress', 'completed']);
/**
* Todo item from various AI providers (Gemini, Copilot, etc.)
*/
interface ProviderTodo {
description?: string;
content?: string;
status?: string;
}
/**
* Standard todo format used by the application
*/
interface NormalizedTodo {
content: string;
status: TodoStatus;
activeForm: string;
}
/**
* Normalize a provider status value to a valid TodoStatus
*/
function normalizeStatus(status: string | undefined): TodoStatus {
if (!status) return 'pending';
if (status === 'cancelled' || status === 'canceled') return 'completed';
if (VALID_STATUSES.has(status as TodoStatus)) return status as TodoStatus;
return 'pending';
}
/**
* Normalize todos array from provider format to standard format
*
* Handles different formats from providers:
* - Gemini: { description, status } with 'cancelled' as possible status
* - Copilot: { content/description, status } with 'cancelled' as possible status
*
* Output format (Claude/Standard):
* - { content, status, activeForm } where status is 'pending'|'in_progress'|'completed'
*/
export function normalizeTodos(todos: ProviderTodo[] | null | undefined): NormalizedTodo[] {
if (!todos) return [];
return todos.map((todo) => ({
content: todo.content || todo.description || '',
status: normalizeStatus(todo.status),
// Use content/description as activeForm since providers may not have it
activeForm: todo.content || todo.description || '',
}));
}
/**
* Normalize file path parameters from various provider formats
*
* Different providers use different parameter names for file paths:
* - path, file, filename, filePath -> file_path
*/
export function normalizeFilePathInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...input };
if (!normalized.file_path) {
if (input.path) normalized.file_path = input.path;
else if (input.file) normalized.file_path = input.file;
else if (input.filename) normalized.file_path = input.filename;
else if (input.filePath) normalized.file_path = input.filePath;
}
return normalized;
}
/**
* Normalize shell command parameters from various provider formats
*
* Different providers use different parameter names for commands:
* - cmd, script -> command
*/
export function normalizeCommandInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...input };
if (!normalized.command) {
if (input.cmd) normalized.command = input.cmd;
else if (input.script) normalized.command = input.script;
}
return normalized;
}
/**
* Normalize search pattern parameters from various provider formats
*
* Different providers use different parameter names for search patterns:
* - query, search, regex -> pattern
*/
export function normalizePatternInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...input };
if (!normalized.pattern) {
if (input.query) normalized.pattern = input.query;
else if (input.search) normalized.pattern = input.search;
else if (input.regex) normalized.pattern = input.regex;
}
return normalized;
}

View File

@@ -19,7 +19,4 @@ export type {
InstallationStatus,
ValidationResult,
ModelDefinition,
AgentDefinition,
ReasoningEffort,
SystemPromptPreset,
} from '@automaker/types';

View File

@@ -8,82 +8,19 @@
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput, isCodexModel } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getPhaseModelWithOverrides,
} from '../../lib/settings-helpers.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('SpecRegeneration');
const DEFAULT_MAX_FEATURES = 50;
/**
* Timeout for Codex models when generating features (5 minutes).
* Codex models are slower and need more time to generate 50+ features.
*/
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
/**
* Type for extracted features JSON response
*/
interface FeaturesExtractionResult {
features: Array<{
id: string;
category?: string;
title: string;
description: string;
priority?: number;
complexity?: 'simple' | 'moderate' | 'complex';
dependencies?: string[];
}>;
}
/**
* JSON schema for features output format (Claude/Codex structured output)
*/
const featuresOutputSchema = {
type: 'object',
properties: {
features: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Unique feature identifier (kebab-case)' },
category: { type: 'string', description: 'Feature category' },
title: { type: 'string', description: 'Short, descriptive title' },
description: { type: 'string', description: 'Detailed feature description' },
priority: {
type: 'number',
description: 'Priority level: 1 (highest) to 5 (lowest)',
},
complexity: {
type: 'string',
enum: ['simple', 'moderate', 'complex'],
description: 'Implementation complexity',
},
dependencies: {
type: 'array',
items: { type: 'string' },
description: 'IDs of features this depends on',
},
},
required: ['id', 'title', 'description'],
},
},
},
required: ['features'],
} as const;
export async function generateFeaturesFromSpec(
projectPath: string,
events: EventEmitter,
@@ -178,97 +115,25 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
'[FeatureGeneration]'
);
// Get model from phase settings with provider info
const {
phaseModel: phaseModelEntry,
provider,
credentials,
} = settingsService
? await getPhaseModelWithOverrides(
'featureGenerationModel',
settingsService,
projectPath,
'[FeatureGeneration]'
)
: {
phaseModel: DEFAULT_PHASE_MODELS.featureGenerationModel,
provider: undefined,
credentials: undefined,
};
const { model, thinkingLevel, reasoningEffort } = resolvePhaseModel(phaseModelEntry);
// Get model from phase settings
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.featureGenerationModel || DEFAULT_PHASE_MODELS.featureGenerationModel;
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
// Codex models need extended timeout for generating many features.
// Use 'xhigh' reasoning effort to get 5-minute timeout (300s base * 1.0x = 300s).
// The Codex provider has a special 5-minute base timeout for feature generation.
const isCodex = isCodexModel(model);
const effectiveReasoningEffort = isCodex ? 'xhigh' : reasoningEffort;
if (isCodex) {
logger.info('Codex model detected - using extended timeout (5 minutes for feature generation)');
}
if (effectiveReasoningEffort) {
logger.info('Reasoning effort:', effectiveReasoningEffort);
}
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Build the final prompt - for non-Claude/Codex models, include explicit JSON instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
2. After analyzing the spec, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
3. The JSON must have this exact structure:
{
"features": [
{
"id": "unique-feature-id",
"category": "Category Name",
"title": "Short Feature Title",
"description": "Detailed description of the feature",
"priority": 1,
"complexity": "simple|moderate|complex",
"dependencies": ["other-feature-id"]
}
]
}
4. Feature IDs must be unique, lowercase, kebab-case (e.g., "user-authentication", "data-export")
5. Priority ranges from 1 (highest) to 5 (lowest)
6. Complexity must be one of: "simple", "moderate", "complex"
7. Dependencies is an array of feature IDs that must be completed first (can be empty)
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
}
logger.info('Using model:', model);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
prompt,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models
readOnly: true, // Feature generation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: featuresOutputSchema,
}
: undefined,
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {
@@ -279,51 +144,15 @@ Your entire response should be valid JSON starting with { and ending with }. No
},
});
// Get response content - prefer structured output if available
let contentForParsing: string;
const responseText = result.text;
if (result.structured_output) {
// Use structured output from Claude/Codex models
logger.info('✅ Received structured output from model');
contentForParsing = JSON.stringify(result.structured_output);
logger.debug('Structured output:', contentForParsing);
} else {
// Use text response (for non-Claude/Codex models or fallback)
// Pre-extract JSON to handle conversational text that may surround the JSON response
// This follows the same pattern used in generate-spec.ts and validate-issue.ts
const rawText = result.text;
logger.info(`Feature stream complete.`);
logger.info(`Feature response length: ${rawText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(rawText);
logger.info('========== END RESPONSE TEXT ==========');
logger.info(`Feature stream complete.`);
logger.info(`Feature response length: ${responseText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(responseText);
logger.info('========== END RESPONSE TEXT ==========');
// Pre-extract JSON from response - handles conversational text around the JSON
const extracted = extractJsonWithArray<FeaturesExtractionResult>(rawText, 'features', {
logger,
});
if (extracted) {
contentForParsing = JSON.stringify(extracted);
logger.info('✅ Pre-extracted JSON from text response');
} else {
// If pre-extraction fails, we know the next step will also fail.
// Throw an error here to avoid redundant parsing and make the failure point clearer.
logger.error(
'❌ Could not extract features JSON from model response. Full response text was:\n' +
rawText
);
const errorMessage =
'Failed to parse features from model response: No valid JSON with a "features" array found.';
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: errorMessage,
projectPath: projectPath,
});
throw new Error(errorMessage);
}
}
await parseAndCreateFeatures(projectPath, contentForParsing, events);
await parseAndCreateFeatures(projectPath, responseText, events);
logger.debug('========== generateFeaturesFromSpec() completed ==========');
}

View File

@@ -9,18 +9,14 @@ import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJson } from '../../lib/json-extractor.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';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getPhaseModelWithOverrides,
} from '../../lib/settings-helpers.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration');
@@ -96,37 +92,21 @@ ${prompts.appSpec.structuredSpecInstructions}`;
'[SpecRegeneration]'
);
// Get model from phase settings with provider info
const {
phaseModel: phaseModelEntry,
provider,
credentials,
} = settingsService
? await getPhaseModelWithOverrides(
'specGenerationModel',
settingsService,
projectPath,
'[SpecRegeneration]'
)
: {
phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel,
provider: undefined,
credentials: undefined,
};
// Get model from phase settings
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
logger.info('Using model:', model);
let responseText = '';
let structuredOutput: SpecOutput | null = null;
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
const useStructuredOutput = !isCursorModel(model);
// Build the final prompt - for non-Claude/Codex models, include JSON schema instructions
// Build the final prompt - for Cursor, include JSON schema instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}
@@ -152,8 +132,6 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel,
readOnly: true, // Spec generation only reads code, we write the spec ourselves
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',

View File

@@ -10,16 +10,12 @@
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { extractJson } from '../../lib/json-extractor.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPhaseModelWithOverrides,
} from '../../lib/settings-helpers.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
import {
extractImplementedFeatures,
@@ -35,28 +31,6 @@ import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecSync');
/**
* Type for extracted tech stack JSON response
*/
interface TechStackExtractionResult {
technologies: string[];
}
/**
* JSON schema for tech stack analysis output (Claude/Codex structured output)
*/
const techStackOutputSchema = {
type: 'object',
properties: {
technologies: {
type: 'array',
items: { type: 'string' },
description: 'List of technologies detected in the project',
},
},
required: ['technologies'],
} as const;
/**
* Result of a sync operation
*/
@@ -178,35 +152,13 @@ export async function syncSpec(
'[SpecSync]'
);
// Get model from phase settings with provider info
const {
phaseModel: phaseModelEntry,
provider,
credentials,
} = settingsService
? await getPhaseModelWithOverrides(
'specGenerationModel',
settingsService,
projectPath,
'[SpecSync]'
)
: {
phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel,
provider: undefined,
credentials: undefined,
};
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Use AI to analyze tech stack
let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
Current known technologies: ${currentTechStack.join(', ')}
@@ -222,16 +174,6 @@ Return ONLY this JSON format, no other text:
"technologies": ["Technology 1", "Technology 2", ...]
}`;
// Add explicit JSON instructions for non-Claude/Codex models
if (!useStructuredOutput) {
techAnalysisPrompt = `${techAnalysisPrompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
2. Your entire response should be valid JSON starting with { and ending with }.
3. No explanations, no markdown, no text before or after the JSON.`;
}
try {
const techResult = await streamingQuery({
prompt: techAnalysisPrompt,
@@ -243,69 +185,44 @@ CRITICAL INSTRUCTIONS:
thinkingLevel,
readOnly: true,
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: techStackOutputSchema,
}
: undefined,
onText: (text) => {
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
},
});
// Parse tech stack from response - prefer structured output if available
let parsedTechnologies: string[] | null = null;
// Parse tech stack from response
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
if (Array.isArray(parsed.technologies)) {
const newTechStack = parsed.technologies as string[];
if (techResult.structured_output) {
// Use structured output from Claude/Codex models
const structured = techResult.structured_output as unknown as TechStackExtractionResult;
if (Array.isArray(structured.technologies)) {
parsedTechnologies = structured.technologies;
logger.info('✅ Received structured output for tech analysis');
}
} else {
// Fall back to text parsing for non-Claude/Codex models
const extracted = extractJson<TechStackExtractionResult>(techResult.text, {
logger,
requiredKey: 'technologies',
requireArray: true,
});
if (extracted && Array.isArray(extracted.technologies)) {
parsedTechnologies = extracted.technologies;
logger.info('✅ Extracted tech stack from text response');
} else {
logger.warn('⚠️ Failed to extract tech stack JSON from response');
}
}
// Calculate differences
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
if (parsedTechnologies) {
const newTechStack = parsedTechnologies;
// Calculate differences
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
for (const tech of newTechStack) {
if (!currentSet.has(tech.toLowerCase())) {
result.techStackUpdates.added.push(tech);
for (const tech of newTechStack) {
if (!currentSet.has(tech.toLowerCase())) {
result.techStackUpdates.added.push(tech);
}
}
}
for (const tech of currentTechStack) {
if (!newSet.has(tech.toLowerCase())) {
result.techStackUpdates.removed.push(tech);
for (const tech of currentTechStack) {
if (!newSet.has(tech.toLowerCase())) {
result.techStackUpdates.removed.push(tech);
}
}
}
// Update spec with new tech stack if there are changes
if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) {
specContent = updateTechnologyStack(specContent, newTechStack);
logger.info(
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
);
// Update spec with new tech stack if there are changes
if (
result.techStackUpdates.added.length > 0 ||
result.techStackUpdates.removed.length > 0
) {
specContent = updateTechnologyStack(specContent, newTechStack);
logger.info(
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
);
}
}
}
} catch (error) {

View File

@@ -117,27 +117,9 @@ export function createAuthRoutes(): Router {
*
* Returns whether the current request is authenticated.
* Used by the UI to determine if login is needed.
*
* If AUTOMAKER_AUTO_LOGIN=true is set, automatically creates a session
* for unauthenticated requests (useful for development).
*/
router.get('/status', async (req, res) => {
let authenticated = isRequestAuthenticated(req);
// Auto-login for development: create session automatically if enabled
// Only works in non-production environments as a safeguard
if (
!authenticated &&
process.env.AUTOMAKER_AUTO_LOGIN === 'true' &&
process.env.NODE_ENV !== 'production'
) {
const sessionToken = await createSession();
const cookieOptions = getSessionCookieOptions();
const cookieName = getSessionCookieName();
res.cookie(cookieName, sessionToken, cookieOptions);
authenticated = true;
}
router.get('/status', (req, res) => {
const authenticated = isRequestAuthenticated(req);
res.json({
success: true,
authenticated,

View File

@@ -10,8 +10,6 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
import { createStopFeatureHandler } from './routes/stop-feature.js';
import { createStatusHandler } from './routes/status.js';
import { createRunFeatureHandler } from './routes/run-feature.js';
import { createStartHandler } from './routes/start.js';
import { createStopHandler } from './routes/stop.js';
import { createVerifyFeatureHandler } from './routes/verify-feature.js';
import { createResumeFeatureHandler } from './routes/resume-feature.js';
import { createContextExistsHandler } from './routes/context-exists.js';
@@ -24,10 +22,6 @@ import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
// Auto loop control routes
router.post('/start', validatePathParams('projectPath'), createStartHandler(autoModeService));
router.post('/stop', validatePathParams('projectPath'), createStopHandler(autoModeService));
router.post('/stop-feature', createStopFeatureHandler(autoModeService));
router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService));
router.post(

View File

@@ -26,24 +26,6 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
return;
}
// Check per-worktree capacity before starting
const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId);
if (!capacity.hasCapacity) {
const worktreeDesc = capacity.branchName
? `worktree "${capacity.branchName}"`
: 'main worktree';
res.status(429).json({
success: false,
error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
details: {
currentAgents: capacity.currentAgents,
maxAgents: capacity.maxAgents,
branchName: capacity.branchName,
},
});
return;
}
// Start execution in background
// executeFeature derives workDir from feature.branchName
autoModeService

View File

@@ -1,67 +0,0 @@
/**
* POST /start endpoint - Start auto mode loop for a project
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createStartHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, maxConcurrency } = req.body as {
projectPath: string;
branchName?: string | null;
maxConcurrency?: number;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
// Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
const worktreeDesc = normalizedBranchName
? `worktree ${normalizedBranchName}`
: 'main worktree';
// Check if already running
if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
res.json({
success: true,
message: `Auto mode is already running for ${worktreeDesc}`,
alreadyRunning: true,
branchName: normalizedBranchName,
});
return;
}
// Start the auto loop for this project/worktree
const resolvedMaxConcurrency = await autoModeService.startAutoLoopForProject(
projectPath,
normalizedBranchName,
maxConcurrency
);
logger.info(
`Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
);
res.json({
success: true,
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
branchName: normalizedBranchName,
});
} catch (error) {
logError(error, 'Start auto mode failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,8 +1,5 @@
/**
* POST /status endpoint - Get auto mode status
*
* If projectPath is provided, returns per-project status including autoloop state.
* If no projectPath, returns global status for backward compatibility.
*/
import type { Request, Response } from 'express';
@@ -12,41 +9,10 @@ import { getErrorMessage, logError } from '../common.js';
export function createStatusHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName } = req.body as {
projectPath?: string;
branchName?: string | null;
};
// If projectPath is provided, return per-project/worktree status
if (projectPath) {
// Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
const projectStatus = autoModeService.getStatusForProject(
projectPath,
normalizedBranchName
);
res.json({
success: true,
isRunning: projectStatus.runningCount > 0,
isAutoLoopRunning: projectStatus.isAutoLoopRunning,
runningFeatures: projectStatus.runningFeatures,
runningCount: projectStatus.runningCount,
maxConcurrency: projectStatus.maxConcurrency,
projectPath,
branchName: normalizedBranchName,
});
return;
}
// Fall back to global status for backward compatibility
const status = autoModeService.getStatus();
const activeProjects = autoModeService.getActiveAutoLoopProjects();
const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();
res.json({
success: true,
...status,
activeAutoLoopProjects: activeProjects,
activeAutoLoopWorktrees: activeWorktrees,
});
} catch (error) {
logError(error, 'Get status failed');

View File

@@ -1,66 +0,0 @@
/**
* POST /stop endpoint - Stop auto mode loop for a project
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createStopHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName } = req.body as {
projectPath: string;
branchName?: string | null;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
// Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
const worktreeDesc = normalizedBranchName
? `worktree ${normalizedBranchName}`
: 'main worktree';
// Check if running
if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
res.json({
success: true,
message: `Auto mode is not running for ${worktreeDesc}`,
wasRunning: false,
branchName: normalizedBranchName,
});
return;
}
// Stop the auto loop for this project/worktree
const runningCount = await autoModeService.stopAutoLoopForProject(
projectPath,
normalizedBranchName
);
logger.info(
`Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running`
);
res.json({
success: true,
message: 'Auto mode stopped',
runningFeaturesCount: runningCount,
branchName: normalizedBranchName,
});
} catch (error) {
logError(error, 'Stop auto mode failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -100,60 +100,11 @@ export function getAbortController(): AbortController | null {
return currentAbortController;
}
/**
* Map SDK/CLI errors to user-friendly messages
*/
export function mapBacklogPlanError(rawMessage: string): string {
// Claude Code spawn failures
if (
rawMessage.includes('Failed to spawn Claude Code process') ||
rawMessage.includes('spawn node ENOENT') ||
rawMessage.includes('Claude Code executable not found') ||
rawMessage.includes('Claude Code native binary not found')
) {
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
}
// Claude Code process crash
if (rawMessage.includes('Claude Code process exited')) {
return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.';
}
// Rate limiting
if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) {
return 'Rate limited. Please wait a moment and try again.';
}
// Network errors
if (
rawMessage.toLowerCase().includes('network') ||
rawMessage.toLowerCase().includes('econnrefused') ||
rawMessage.toLowerCase().includes('timeout')
) {
return 'Network error. Check your internet connection and try again.';
}
// Authentication errors
if (
rawMessage.toLowerCase().includes('not authenticated') ||
rawMessage.toLowerCase().includes('unauthorized') ||
rawMessage.includes('401')
) {
return 'Authentication failed. Please check your API key or run `claude login` to authenticate.';
}
// Return original message for unknown errors
return rawMessage;
}
export function getErrorMessage(error: unknown): string {
let rawMessage: string;
if (error instanceof Error) {
rawMessage = error.message;
} else {
rawMessage = String(error);
return error.message;
}
return mapBacklogPlanError(rawMessage);
return String(error);
}
export function logError(error: unknown, context: string): void {

View File

@@ -25,11 +25,7 @@ import {
saveBacklogPlan,
} from './common.js';
import type { SettingsService } from '../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getPhaseModelWithOverrides,
} from '../../lib/settings-helpers.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
const featureLoader = new FeatureLoader();
@@ -121,42 +117,18 @@ export async function generateBacklogPlan(
content: 'Generating plan with AI...',
});
// Get the model to use from settings or provided override with provider info
// Get the model to use from settings or provided override
let effectiveModel = model;
let thinkingLevel: ThinkingLevel | undefined;
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
let credentials: import('@automaker/types').Credentials | undefined;
if (effectiveModel) {
// Use explicit override - resolve model alias and get credentials
const resolved = resolvePhaseModel({ model: effectiveModel });
effectiveModel = resolved.model;
thinkingLevel = resolved.thinkingLevel;
credentials = await settingsService?.getCredentials();
} else if (settingsService) {
// Use settings-based model with provider info
const phaseResult = await getPhaseModelWithOverrides(
'backlogPlanningModel',
settingsService,
projectPath,
'[BacklogPlan]'
);
const resolved = resolvePhaseModel(phaseResult.phaseModel);
effectiveModel = resolved.model;
thinkingLevel = resolved.thinkingLevel;
claudeCompatibleProvider = phaseResult.provider;
credentials = phaseResult.credentials;
} else {
// Fallback to defaults
const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.backlogPlanningModel);
if (!effectiveModel) {
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.backlogPlanningModel || DEFAULT_PHASE_MODELS.backlogPlanningModel;
const resolved = resolvePhaseModel(phaseModelEntry);
effectiveModel = resolved.model;
thinkingLevel = resolved.thinkingLevel;
}
logger.info(
'[BacklogPlan] Using model:',
effectiveModel,
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
);
logger.info('[BacklogPlan] Using model:', effectiveModel);
const provider = ProviderFactory.getProviderForModel(effectiveModel);
// Strip provider prefix - providers expect bare model IDs
@@ -201,8 +173,6 @@ ${userPrompt}`;
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
readOnly: true, // Plan generation only generates text, doesn't write files
thinkingLevel, // Pass thinking level for extended thinking
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
let responseText = '';

View File

@@ -85,9 +85,8 @@ export function createApplyHandler() {
if (!change.feature) continue;
try {
// Create the new feature - use the AI-generated ID if provided
// Create the new feature
const newFeature = await featureLoader.create(projectPath, {
id: change.feature.id, // Use descriptive ID from AI if provided
title: change.feature.title,
description: change.feature.description || '',
category: change.feature.category || 'Uncategorized',

View File

@@ -53,12 +53,13 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
setRunningState(true, abortController);
// Start generation in background
// Note: generateBacklogPlan handles its own error event emission,
// so we only log here to avoid duplicate error toasts
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
.catch((error) => {
// Just log - error event already emitted by generateBacklogPlan
logError(error, 'Generate backlog plan failed (background)');
events.emit('backlog-plan:event', {
type: 'backlog_plan_error',
error: getErrorMessage(error),
});
})
.finally(() => {
setRunningState(false, null);

View File

@@ -0,0 +1,78 @@
/**
* Common utilities for code-review routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('CodeReview');
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);
/**
* Review state interface
*/
interface ReviewState {
isRunning: boolean;
abortController: AbortController | null;
projectPath: string | null;
}
/**
* Shared state for code review operations
* Using an object to avoid mutable `let` exports which can cause issues in ES modules
*/
const reviewState: ReviewState = {
isRunning: false,
abortController: null,
projectPath: null,
};
/**
* Check if a review is currently running
*/
export function isRunning(): boolean {
return reviewState.isRunning;
}
/**
* Get the current abort controller (for stopping reviews)
*/
export function getAbortController(): AbortController | null {
return reviewState.abortController;
}
/**
* Get the current project path being reviewed
*/
export function getCurrentProjectPath(): string | null {
return reviewState.projectPath;
}
/**
* Set the running state for code review operations
*/
export function setRunningState(
running: boolean,
controller: AbortController | null = null,
projectPath: string | null = null
): void {
reviewState.isRunning = running;
reviewState.abortController = controller;
reviewState.projectPath = projectPath;
}
/**
* Get the current review status
*/
export function getReviewStatus(): {
isRunning: boolean;
projectPath: string | null;
} {
return {
isRunning: reviewState.isRunning,
projectPath: reviewState.projectPath,
};
}

View File

@@ -0,0 +1,40 @@
/**
* Code Review routes - HTTP API for triggering and managing code reviews
*
* Provides endpoints for:
* - Triggering code reviews on projects
* - Checking review status
* - Stopping in-progress reviews
*
* Uses the CodeReviewService for actual review execution with AI providers.
*/
import { Router } from 'express';
import type { CodeReviewService } from '../../services/code-review-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createTriggerHandler } from './routes/trigger.js';
import { createStatusHandler } from './routes/status.js';
import { createStopHandler } from './routes/stop.js';
import { createProvidersHandler } from './routes/providers.js';
export function createCodeReviewRoutes(codeReviewService: CodeReviewService): Router {
const router = Router();
// POST /trigger - Start a new code review
router.post(
'/trigger',
validatePathParams('projectPath'),
createTriggerHandler(codeReviewService)
);
// GET /status - Get current review status
router.get('/status', createStatusHandler());
// POST /stop - Stop current review
router.post('/stop', createStopHandler());
// GET /providers - Get available providers and their status
router.get('/providers', createProvidersHandler(codeReviewService));
return router;
}

View File

@@ -0,0 +1,38 @@
/**
* GET /providers endpoint - Get available code review providers
*
* Returns the status of all available AI providers that can be used for code reviews.
*/
import type { Request, Response } from 'express';
import type { CodeReviewService } from '../../../services/code-review-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('CodeReview');
export function createProvidersHandler(codeReviewService: CodeReviewService) {
return async (req: Request, res: Response): Promise<void> => {
logger.debug('========== /providers endpoint called ==========');
try {
// Check if refresh is requested
const forceRefresh = req.query.refresh === 'true';
const providers = await codeReviewService.getProviderStatus(forceRefresh);
const bestProvider = await codeReviewService.getBestProvider();
res.json({
success: true,
providers,
recommended: bestProvider,
});
} catch (error) {
logError(error, 'Providers handler exception');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,32 @@
/**
* GET /status endpoint - Get current code review status
*
* Returns whether a code review is currently running and which project.
*/
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import { getReviewStatus, getErrorMessage, logError } from '../common.js';
const logger = createLogger('CodeReview');
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
logger.debug('========== /status endpoint called ==========');
try {
const status = getReviewStatus();
res.json({
success: true,
...status,
});
} catch (error) {
logError(error, 'Status handler exception');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,54 @@
/**
* POST /stop endpoint - Stop the current code review
*
* Aborts any running code review operation.
*/
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import {
isRunning,
getAbortController,
setRunningState,
getErrorMessage,
logError,
} from '../common.js';
const logger = createLogger('CodeReview');
export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => {
logger.info('========== /stop endpoint called ==========');
try {
if (!isRunning()) {
res.json({
success: true,
message: 'No code review is currently running',
});
return;
}
// Abort the current operation
const abortController = getAbortController();
if (abortController) {
abortController.abort();
logger.info('Code review aborted');
}
// Reset state
setRunningState(false, null, null);
res.json({
success: true,
message: 'Code review stopped',
});
} catch (error) {
logError(error, 'Stop handler exception');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,188 @@
/**
* POST /trigger endpoint - Trigger a code review
*
* Starts an asynchronous code review on the specified project.
* Progress updates are streamed via WebSocket events.
*/
import type { Request, Response } from 'express';
import type { CodeReviewService } from '../../../services/code-review-service.js';
import type { CodeReviewCategory, ThinkingLevel, ModelId } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import { isRunning, setRunningState, getErrorMessage, logError } from '../common.js';
const logger = createLogger('CodeReview');
/**
* Maximum number of files allowed per review request
*/
const MAX_FILES_PER_REQUEST = 100;
/**
* Maximum length for baseRef parameter
*/
const MAX_BASE_REF_LENGTH = 256;
/**
* Valid categories for code review
*/
const VALID_CATEGORIES: CodeReviewCategory[] = [
'tech_stack',
'security',
'code_quality',
'implementation',
'architecture',
'performance',
'testing',
'documentation',
];
/**
* Valid thinking levels
*/
const VALID_THINKING_LEVELS: ThinkingLevel[] = ['low', 'medium', 'high'];
interface TriggerRequestBody {
projectPath: string;
files?: string[];
baseRef?: string;
categories?: CodeReviewCategory[];
autoFix?: boolean;
model?: ModelId;
thinkingLevel?: ThinkingLevel;
}
/**
* Validate and sanitize the request body
*/
function validateRequestBody(body: TriggerRequestBody): { valid: boolean; error?: string } {
const { files, baseRef, categories, autoFix, thinkingLevel } = body;
// Validate files array
if (files !== undefined) {
if (!Array.isArray(files)) {
return { valid: false, error: 'files must be an array' };
}
if (files.length > MAX_FILES_PER_REQUEST) {
return { valid: false, error: `Maximum ${MAX_FILES_PER_REQUEST} files allowed per request` };
}
for (const file of files) {
if (typeof file !== 'string') {
return { valid: false, error: 'Each file must be a string' };
}
if (file.length > 500) {
return { valid: false, error: 'File path too long' };
}
}
}
// Validate baseRef
if (baseRef !== undefined) {
if (typeof baseRef !== 'string') {
return { valid: false, error: 'baseRef must be a string' };
}
if (baseRef.length > MAX_BASE_REF_LENGTH) {
return { valid: false, error: 'baseRef is too long' };
}
}
// Validate categories
if (categories !== undefined) {
if (!Array.isArray(categories)) {
return { valid: false, error: 'categories must be an array' };
}
for (const category of categories) {
if (!VALID_CATEGORIES.includes(category)) {
return { valid: false, error: `Invalid category: ${category}` };
}
}
}
// Validate autoFix
if (autoFix !== undefined && typeof autoFix !== 'boolean') {
return { valid: false, error: 'autoFix must be a boolean' };
}
// Validate thinkingLevel
if (thinkingLevel !== undefined) {
if (!VALID_THINKING_LEVELS.includes(thinkingLevel)) {
return { valid: false, error: `Invalid thinkingLevel: ${thinkingLevel}` };
}
}
return { valid: true };
}
export function createTriggerHandler(codeReviewService: CodeReviewService) {
return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /trigger endpoint called ==========');
try {
const body = req.body as TriggerRequestBody;
const { projectPath, files, baseRef, categories, autoFix, model, thinkingLevel } = body;
// Validate required parameters
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
// SECURITY: Validate all input parameters
const validation = validateRequestBody(body);
if (!validation.valid) {
res.status(400).json({
success: false,
error: validation.error,
});
return;
}
// Check if a review is already running
if (isRunning()) {
res.status(409).json({
success: false,
error: 'A code review is already in progress',
});
return;
}
// Set up abort controller for cancellation
const abortController = new AbortController();
setRunningState(true, abortController, projectPath);
// Start the review in the background
codeReviewService
.executeReview({
projectPath,
files,
baseRef,
categories,
autoFix,
model,
thinkingLevel,
abortController,
})
.catch((error) => {
logError(error, 'Code review failed');
})
.finally(() => {
setRunningState(false, null, null);
});
// Return immediate response
res.json({
success: true,
message: 'Code review started',
});
} catch (error) {
logError(error, 'Trigger handler exception');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -12,6 +12,7 @@
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { PathNotAllowedError } from '@automaker/platform';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js';
@@ -21,7 +22,6 @@ import type { SettingsService } from '../../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getPhaseModelWithOverrides,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeFile');
@@ -155,23 +155,15 @@ ${contentToAnalyze}`;
'[DescribeFile]'
);
// Get model from phase settings with provider info
const {
phaseModel: phaseModelEntry,
provider,
credentials,
} = await getPhaseModelWithOverrides(
'fileDescriptionModel',
settingsService,
cwd,
'[DescribeFile]'
);
// Get model from phase settings
const settings = await settingsService?.getGlobalSettings();
logger.info(`Raw phaseModels from settings:`, JSON.stringify(settings?.phaseModels, null, 2));
const phaseModelEntry =
settings?.phaseModels?.fileDescriptionModel || DEFAULT_PHASE_MODELS.fileDescriptionModel;
logger.info(`fileDescriptionModel entry:`, JSON.stringify(phaseModelEntry));
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info(
`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`,
provider ? `via provider: ${provider.name}` : 'direct API'
);
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
// Use simpleQuery - provider abstraction handles routing to correct provider
const result = await simpleQuery({
@@ -183,8 +175,6 @@ ${contentToAnalyze}`;
thinkingLevel,
readOnly: true, // File description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const description = result.text;

View File

@@ -13,7 +13,7 @@
import type { Request, Response } from 'express';
import { createLogger, readImageAsBase64 } from '@automaker/utils';
import { isCursorModel } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import * as secureFs from '../../../lib/secure-fs.js';
@@ -22,7 +22,6 @@ import type { SettingsService } from '../../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getPhaseModelWithOverrides,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeImage');
@@ -274,23 +273,13 @@ export function createDescribeImageHandler(
'[DescribeImage]'
);
// Get model from phase settings with provider info
const {
phaseModel: phaseModelEntry,
provider,
credentials,
} = await getPhaseModelWithOverrides(
'imageDescriptionModel',
settingsService,
cwd,
'[DescribeImage]'
);
// Get model from phase settings
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.imageDescriptionModel || DEFAULT_PHASE_MODELS.imageDescriptionModel;
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info(
`[${requestId}] Using model: ${model}`,
provider ? `via provider: ${provider.name}` : 'direct API'
);
logger.info(`[${requestId}] Using model: ${model}`);
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
@@ -336,8 +325,6 @@ export function createDescribeImageHandler(
thinkingLevel,
readOnly: true, // Image description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`);

View File

@@ -12,7 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver';
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, getProviderByModelId } from '../../../lib/settings-helpers.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
buildUserPrompt,
isValidEnhancementMode,
@@ -33,8 +33,6 @@ interface EnhanceRequestBody {
model?: string;
/** Optional thinking level for Claude models */
thinkingLevel?: ThinkingLevel;
/** Optional project path for per-project Claude API profile */
projectPath?: string;
}
/**
@@ -64,7 +62,7 @@ export function createEnhanceHandler(
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { originalText, enhancementMode, model, thinkingLevel, projectPath } =
const { originalText, enhancementMode, model, thinkingLevel } =
req.body as EnhanceRequestBody;
// Validate required fields
@@ -123,32 +121,8 @@ export function createEnhanceHandler(
// Build the user prompt with few-shot examples
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
// Check if the model is a provider model (like "GLM-4.5-Air")
// If so, get the provider config and resolved Claude model
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
let providerResolvedModel: string | undefined;
let credentials = await settingsService?.getCredentials();
if (model && settingsService) {
const providerResult = await getProviderByModelId(
model,
settingsService,
'[EnhancePrompt]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
providerResolvedModel = providerResult.resolvedModel;
credentials = providerResult.credentials;
logger.info(
`Using provider "${providerResult.provider.name}" for model "${model}"` +
(providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
);
}
}
// Resolve the model - use provider resolved model, passed model, or default to sonnet
const resolvedModel =
providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
// Resolve the model - use the passed model, default to sonnet for quality
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
logger.debug(`Using model: ${resolvedModel}`);
@@ -163,8 +137,6 @@ export function createEnhanceHandler(
allowedTools: [],
thinkingLevel,
readOnly: true, // Prompt enhancement only generates text, doesn't write files
credentials, // Pass credentials for resolving 'credentials' apiKeySource
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
});
const enhancedText = result.text;

View File

@@ -5,7 +5,6 @@
import { Router } from 'express';
import { FeatureLoader } from '../../services/feature-loader.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
@@ -17,22 +16,15 @@ import { createBulkDeleteHandler } from './routes/bulk-delete.js';
import { createDeleteHandler } from './routes/delete.js';
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
import { createGenerateTitleHandler } from './routes/generate-title.js';
import { createExportHandler } from './routes/export.js';
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
export function createFeaturesRoutes(
featureLoader: FeatureLoader,
settingsService?: SettingsService,
events?: EventEmitter,
autoModeService?: AutoModeService
events?: EventEmitter
): Router {
const router = Router();
router.post(
'/list',
validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService)
);
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post(
'/create',
@@ -54,13 +46,6 @@ export function createFeaturesRoutes(
router.post('/agent-output', createAgentOutputHandler(featureLoader));
router.post('/raw-output', createRawOutputHandler(featureLoader));
router.post('/generate-title', createGenerateTitleHandler(settingsService));
router.post('/export', validatePathParams('projectPath'), createExportHandler(featureLoader));
router.post('/import', validatePathParams('projectPath'), createImportHandler(featureLoader));
router.post(
'/check-conflicts',
validatePathParams('projectPath'),
createConflictCheckHandler(featureLoader)
);
return router;
}

View File

@@ -1,96 +0,0 @@
/**
* POST /export endpoint - Export features to JSON or YAML format
*/
import type { Request, Response } from 'express';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import {
getFeatureExportService,
type ExportFormat,
type BulkExportOptions,
} from '../../../services/feature-export-service.js';
import { getErrorMessage, logError } from '../common.js';
interface ExportRequest {
projectPath: string;
/** Feature IDs to export. If empty/undefined, exports all features */
featureIds?: string[];
/** Export format: 'json' or 'yaml' */
format?: ExportFormat;
/** Whether to include description history */
includeHistory?: boolean;
/** Whether to include plan spec */
includePlanSpec?: boolean;
/** Filter by category */
category?: string;
/** Filter by status */
status?: string;
/** Pretty print output */
prettyPrint?: boolean;
/** Optional metadata to include */
metadata?: {
projectName?: string;
projectPath?: string;
branch?: string;
[key: string]: unknown;
};
}
export function createExportHandler(featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
featureIds,
format = 'json',
includeHistory = true,
includePlanSpec = true,
category,
status,
prettyPrint = true,
metadata,
} = req.body as ExportRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// Validate format
if (format !== 'json' && format !== 'yaml') {
res.status(400).json({
success: false,
error: 'format must be "json" or "yaml"',
});
return;
}
const options: BulkExportOptions = {
format,
includeHistory,
includePlanSpec,
category,
status,
featureIds,
prettyPrint,
metadata,
};
const exportData = await exportService.exportFeatures(projectPath, options);
// Return the export data as a string in the response
res.json({
success: true,
data: exportData,
format,
contentType: format === 'json' ? 'application/json' : 'application/x-yaml',
filename: `features-export.${format === 'json' ? 'json' : 'yaml'}`,
});
} catch (error) {
logError(error, 'Export features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -16,7 +16,6 @@ const logger = createLogger('GenerateTitle');
interface GenerateTitleRequestBody {
description: string;
projectPath?: string;
}
interface GenerateTitleSuccessResponse {
@@ -34,7 +33,7 @@ export function createGenerateTitleHandler(
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { description, projectPath } = req.body as GenerateTitleRequestBody;
const { description } = req.body as GenerateTitleRequestBody;
if (!description || typeof description !== 'string') {
const response: GenerateTitleErrorResponse = {
@@ -61,9 +60,6 @@ export function createGenerateTitleHandler(
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
const systemPrompt = prompts.titleGeneration.systemPrompt;
// Get credentials for API calls (uses hardcoded haiku model, no phase setting)
const credentials = await settingsService?.getCredentials();
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
// Use simpleQuery - provider abstraction handles all the streaming/extraction
@@ -73,7 +69,6 @@ export function createGenerateTitleHandler(
cwd: process.cwd(),
maxTurns: 1,
allowedTools: [],
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const title = result.text;

View File

@@ -1,210 +0,0 @@
/**
* POST /import endpoint - Import features from JSON or YAML format
*/
import type { Request, Response } from 'express';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import type { FeatureImportResult, Feature, FeatureExport } from '@automaker/types';
import { getFeatureExportService } from '../../../services/feature-export-service.js';
import { getErrorMessage, logError } from '../common.js';
interface ImportRequest {
projectPath: string;
/** Raw JSON or YAML string containing feature data */
data: string;
/** Whether to overwrite existing features with same ID */
overwrite?: boolean;
/** Whether to preserve branch info from imported features */
preserveBranchInfo?: boolean;
/** Optional category to assign to all imported features */
targetCategory?: string;
}
interface ConflictCheckRequest {
projectPath: string;
/** Raw JSON or YAML string containing feature data */
data: string;
}
interface ConflictInfo {
featureId: string;
title?: string;
existingTitle?: string;
hasConflict: boolean;
}
export function createImportHandler(featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
data,
overwrite = false,
preserveBranchInfo = false,
targetCategory,
} = req.body as ImportRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!data) {
res.status(400).json({ success: false, error: 'data is required' });
return;
}
// Detect format and parse the data
const format = exportService.detectFormat(data);
if (!format) {
res.status(400).json({
success: false,
error: 'Invalid data format. Expected valid JSON or YAML.',
});
return;
}
const parsed = exportService.parseImportData(data);
if (!parsed) {
res.status(400).json({
success: false,
error: 'Failed to parse import data. Ensure it is valid JSON or YAML.',
});
return;
}
// Determine if this is a single feature or bulk import
const isBulkImport =
'features' in parsed && Array.isArray((parsed as { features: unknown }).features);
let results: FeatureImportResult[];
if (isBulkImport) {
// Bulk import
results = await exportService.importFeatures(projectPath, data, {
overwrite,
preserveBranchInfo,
targetCategory,
});
} else {
// Single feature import - we know it's not a bulk export at this point
// It must be either a Feature or FeatureExport
const singleData = parsed as Feature | FeatureExport;
const result = await exportService.importFeature(projectPath, {
data: singleData,
overwrite,
preserveBranchInfo,
targetCategory,
});
results = [result];
}
const successCount = results.filter((r) => r.success).length;
const failureCount = results.filter((r) => !r.success).length;
const allSuccessful = failureCount === 0;
res.json({
success: allSuccessful,
importedCount: successCount,
failedCount: failureCount,
results,
});
} catch (error) {
logError(error, 'Import features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Create handler for checking conflicts before import
*/
export function createConflictCheckHandler(featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, data } = req.body as ConflictCheckRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!data) {
res.status(400).json({ success: false, error: 'data is required' });
return;
}
// Parse the import data
const format = exportService.detectFormat(data);
if (!format) {
res.status(400).json({
success: false,
error: 'Invalid data format. Expected valid JSON or YAML.',
});
return;
}
const parsed = exportService.parseImportData(data);
if (!parsed) {
res.status(400).json({
success: false,
error: 'Failed to parse import data.',
});
return;
}
// Extract features from the data using type guards
let featuresToCheck: Array<{ id: string; title?: string }> = [];
if (exportService.isBulkExport(parsed)) {
// Bulk export format
featuresToCheck = parsed.features.map((f) => ({
id: f.feature.id,
title: f.feature.title,
}));
} else if (exportService.isFeatureExport(parsed)) {
// Single FeatureExport format
featuresToCheck = [
{
id: parsed.feature.id,
title: parsed.feature.title,
},
];
} else if (exportService.isRawFeature(parsed)) {
// Raw Feature format
featuresToCheck = [{ id: parsed.id, title: parsed.title }];
}
// Check each feature for conflicts in parallel
const conflicts: ConflictInfo[] = await Promise.all(
featuresToCheck.map(async (feature) => {
const existing = await featureLoader.get(projectPath, feature.id);
return {
featureId: feature.id,
title: feature.title,
existingTitle: existing?.title,
hasConflict: !!existing,
};
})
);
const hasConflicts = conflicts.some((c) => c.hasConflict);
res.json({
success: true,
hasConflicts,
conflicts,
totalFeatures: featuresToCheck.length,
conflictCount: conflicts.filter((c) => c.hasConflict).length,
});
} catch (error) {
logError(error, 'Conflict check failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,19 +1,12 @@
/**
* POST /list endpoint - List all features for a project
*
* Also performs orphan detection when a project is loaded to identify
* features whose branches no longer exist. This runs on every project load/switch.
*/
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('FeaturesListRoute');
export function createListHandler(featureLoader: FeatureLoader, autoModeService?: AutoModeService) {
export function createListHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
@@ -24,26 +17,6 @@ export function createListHandler(featureLoader: FeatureLoader, autoModeService?
}
const features = await featureLoader.getAll(projectPath);
// Run orphan detection in background when project is loaded
// This detects features whose branches no longer exist (e.g., after merge/delete)
// We don't await this to keep the list response fast
// Note: detectOrphanedFeatures handles errors internally and always resolves
if (autoModeService) {
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
);
for (const { feature, missingBranch } of orphanedFeatures) {
logger.info(
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
);
}
}
});
}
res.json({ success: true, features });
} catch (error) {
logError(error, 'List features failed');

View File

@@ -1,12 +1,5 @@
/**
* GET /image endpoint - Serve image files
*
* Requires authentication via auth middleware:
* - apiKey query parameter (Electron mode)
* - token query parameter (web mode)
* - session cookie (web mode)
* - X-API-Key header (Electron mode)
* - X-Session-Token header (web mode)
*/
import type { Request, Response } from 'express';

View File

@@ -23,7 +23,6 @@ import {
isCodexModel,
isCursorModel,
isOpencodeModel,
supportsStructuredOutput,
} from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJson } from '../../../lib/json-extractor.js';
@@ -35,11 +34,7 @@ import {
ValidationComment,
ValidationLinkedPR,
} from './validation-schema.js';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
getProviderByModelId,
} from '../../../lib/settings-helpers.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
trySetValidationRunning,
clearValidationStatus,
@@ -48,6 +43,7 @@ import {
logger,
} from './validation-common.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
/**
* Request body for issue validation
@@ -125,9 +121,8 @@ async function runValidation(
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
// Determine if we should use structured output based on model type
// Claude and Codex support it; Cursor, Gemini, OpenCode, Copilot don't
const useStructuredOutput = supportsStructuredOutput(model);
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
let finalPrompt = basePrompt;
@@ -169,33 +164,12 @@ ${basePrompt}`;
}
}
// Check if the model is a provider model (like "GLM-4.5-Air")
// If so, get the provider config and resolved Claude model
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
let providerResolvedModel: string | undefined;
let credentials = await settingsService?.getCredentials();
if (settingsService) {
const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]');
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
providerResolvedModel = providerResult.resolvedModel;
credentials = providerResult.credentials;
logger.info(
`Using provider "${providerResult.provider.name}" for model "${model}"` +
(providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
);
}
}
// Use provider resolved model if available, otherwise use original model
const effectiveModel = providerResolvedModel || (model as string);
logger.info(`Using model: ${effectiveModel}`);
logger.info(`Using model: ${model}`);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
model: effectiveModel,
model: model as string,
cwd: projectPath,
systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined,
abortController,
@@ -203,8 +177,6 @@ ${basePrompt}`;
reasoningEffort: effectiveReasoningEffort,
readOnly: true, // Issue validation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',

View File

@@ -4,21 +4,15 @@
import type { Request, Response } from 'express';
import type { IdeationService } from '../../../services/ideation-service.js';
import type { IdeationContextSources } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('ideation:suggestions-generate');
/**
* Creates an Express route handler for generating AI-powered ideation suggestions.
* Accepts a prompt, category, and optional context sources configuration,
* then returns structured suggestions that can be added to the board.
*/
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, promptId, category, count, contextSources } = req.body;
const { projectPath, promptId, category, count } = req.body;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
@@ -44,8 +38,7 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic
projectPath,
promptId,
category,
suggestionCount,
contextSources as IdeationContextSources | undefined
suggestionCount
);
res.json({

View File

@@ -1,12 +0,0 @@
/**
* Common utilities for projects routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('Projects');
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);

View File

@@ -1,27 +0,0 @@
/**
* Projects routes - HTTP API for multi-project overview and management
*/
import { Router } from 'express';
import type { FeatureLoader } from '../../services/feature-loader.js';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { NotificationService } from '../../services/notification-service.js';
import { createOverviewHandler } from './routes/overview.js';
export function createProjectsRoutes(
featureLoader: FeatureLoader,
autoModeService: AutoModeService,
settingsService: SettingsService,
notificationService: NotificationService
): Router {
const router = Router();
// GET /overview - Get aggregate status for all projects
router.get(
'/overview',
createOverviewHandler(featureLoader, autoModeService, settingsService, notificationService)
);
return router;
}

View File

@@ -1,317 +0,0 @@
/**
* GET /overview endpoint - Get aggregate status for all projects
*
* Returns a complete overview of all projects including:
* - Individual project status (features, auto-mode state)
* - Aggregate metrics across all projects
* - Recent activity feed (placeholder for future implementation)
*/
import type { Request, Response } from 'express';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import type { NotificationService } from '../../../services/notification-service.js';
import type {
ProjectStatus,
AggregateStatus,
MultiProjectOverview,
FeatureStatusCounts,
AggregateFeatureCounts,
AggregateProjectCounts,
ProjectHealthStatus,
Feature,
ProjectRef,
} from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
/**
* Compute feature status counts from a list of features
*/
function computeFeatureCounts(features: Feature[]): FeatureStatusCounts {
const counts: FeatureStatusCounts = {
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
};
for (const feature of features) {
switch (feature.status) {
case 'pending':
case 'ready':
counts.pending++;
break;
case 'running':
case 'generating_spec':
case 'in_progress':
counts.running++;
break;
case 'waiting_approval':
// waiting_approval means agent finished, needs human review - count as pending
counts.pending++;
break;
case 'completed':
counts.completed++;
break;
case 'failed':
counts.failed++;
break;
case 'verified':
counts.verified++;
break;
default:
// Unknown status, treat as pending
counts.pending++;
}
}
return counts;
}
/**
* Determine the overall health status of a project based on its feature statuses
*/
function computeHealthStatus(
featureCounts: FeatureStatusCounts,
isAutoModeRunning: boolean
): ProjectHealthStatus {
const totalFeatures =
featureCounts.pending +
featureCounts.running +
featureCounts.completed +
featureCounts.failed +
featureCounts.verified;
// If there are failed features, the project has errors
if (featureCounts.failed > 0) {
return 'error';
}
// If there are running features or auto mode is running with pending work
if (featureCounts.running > 0 || (isAutoModeRunning && featureCounts.pending > 0)) {
return 'active';
}
// Pending work but no active execution
if (featureCounts.pending > 0) {
return 'waiting';
}
// If all features are completed or verified
if (totalFeatures > 0 && featureCounts.pending === 0 && featureCounts.running === 0) {
return 'completed';
}
// Default to idle
return 'idle';
}
/**
* Get the most recent activity timestamp from features
*/
function getLastActivityAt(features: Feature[]): string | undefined {
if (features.length === 0) {
return undefined;
}
let latestTimestamp: number = 0;
for (const feature of features) {
// Check startedAt timestamp (the main timestamp available on Feature)
if (feature.startedAt) {
const timestamp = new Date(feature.startedAt).getTime();
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
latestTimestamp = timestamp;
}
}
// Also check planSpec timestamps if available
if (feature.planSpec?.generatedAt) {
const timestamp = new Date(feature.planSpec.generatedAt).getTime();
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
latestTimestamp = timestamp;
}
}
if (feature.planSpec?.approvedAt) {
const timestamp = new Date(feature.planSpec.approvedAt).getTime();
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
latestTimestamp = timestamp;
}
}
}
return latestTimestamp > 0 ? new Date(latestTimestamp).toISOString() : undefined;
}
export function createOverviewHandler(
featureLoader: FeatureLoader,
autoModeService: AutoModeService,
settingsService: SettingsService,
notificationService: NotificationService
) {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Get all projects from settings
const settings = await settingsService.getGlobalSettings();
const projectRefs: ProjectRef[] = settings.projects || [];
// Get all running agents once to count live running features per project
const allRunningAgents = await autoModeService.getRunningAgents();
// Collect project statuses in parallel
const projectStatusPromises = projectRefs.map(async (projectRef): Promise<ProjectStatus> => {
try {
// Load features for this project
const features = await featureLoader.getAll(projectRef.path);
const featureCounts = computeFeatureCounts(features);
const totalFeatures = features.length;
// Get auto-mode status for this project (main worktree, branchName = null)
const autoModeStatus = autoModeService.getStatusForProject(projectRef.path, null);
const isAutoModeRunning = autoModeStatus.isAutoLoopRunning;
// Count live running features for this project (across all branches)
// This ensures we only count features that are actually running in memory
const liveRunningCount = allRunningAgents.filter(
(agent) => agent.projectPath === projectRef.path
).length;
featureCounts.running = liveRunningCount;
// Get notification count for this project
let unreadNotificationCount = 0;
try {
const notifications = await notificationService.getNotifications(projectRef.path);
unreadNotificationCount = notifications.filter((n) => !n.read).length;
} catch {
// Ignore notification errors - project may not have any notifications yet
}
// Compute health status
const healthStatus = computeHealthStatus(featureCounts, isAutoModeRunning);
// Get last activity timestamp
const lastActivityAt = getLastActivityAt(features);
return {
projectId: projectRef.id,
projectName: projectRef.name,
projectPath: projectRef.path,
healthStatus,
featureCounts,
totalFeatures,
lastActivityAt,
isAutoModeRunning,
activeBranch: autoModeStatus.branchName ?? undefined,
unreadNotificationCount,
};
} catch (error) {
logError(error, `Failed to load project status: ${projectRef.name}`);
// Return a minimal status for projects that fail to load
return {
projectId: projectRef.id,
projectName: projectRef.name,
projectPath: projectRef.path,
healthStatus: 'error' as ProjectHealthStatus,
featureCounts: {
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
},
totalFeatures: 0,
isAutoModeRunning: false,
unreadNotificationCount: 0,
};
}
});
const projectStatuses = await Promise.all(projectStatusPromises);
// Compute aggregate metrics
const aggregateFeatureCounts: AggregateFeatureCounts = {
total: 0,
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
};
const aggregateProjectCounts: AggregateProjectCounts = {
total: projectStatuses.length,
active: 0,
idle: 0,
waiting: 0,
withErrors: 0,
allCompleted: 0,
};
let totalUnreadNotifications = 0;
let projectsWithAutoModeRunning = 0;
for (const status of projectStatuses) {
// Aggregate feature counts
aggregateFeatureCounts.total += status.totalFeatures;
aggregateFeatureCounts.pending += status.featureCounts.pending;
aggregateFeatureCounts.running += status.featureCounts.running;
aggregateFeatureCounts.completed += status.featureCounts.completed;
aggregateFeatureCounts.failed += status.featureCounts.failed;
aggregateFeatureCounts.verified += status.featureCounts.verified;
// Aggregate project counts by health status
switch (status.healthStatus) {
case 'active':
aggregateProjectCounts.active++;
break;
case 'idle':
aggregateProjectCounts.idle++;
break;
case 'waiting':
aggregateProjectCounts.waiting++;
break;
case 'error':
aggregateProjectCounts.withErrors++;
break;
case 'completed':
aggregateProjectCounts.allCompleted++;
break;
}
// Aggregate notifications
totalUnreadNotifications += status.unreadNotificationCount;
// Count projects with auto-mode running
if (status.isAutoModeRunning) {
projectsWithAutoModeRunning++;
}
}
const aggregateStatus: AggregateStatus = {
projectCounts: aggregateProjectCounts,
featureCounts: aggregateFeatureCounts,
totalUnreadNotifications,
projectsWithAutoModeRunning,
computedAt: new Date().toISOString(),
};
// Build the response (recentActivity is empty for now - can be populated later)
const overview: MultiProjectOverview = {
projects: projectStatuses,
aggregate: aggregateStatus,
recentActivity: [], // Placeholder for future activity feed implementation
generatedAt: new Date().toISOString(),
};
res.json({
success: true,
...overview,
});
} catch (error) {
logError(error, 'Get project overview failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -45,24 +45,18 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
}
// Minimal debug logging to help diagnose accidental wipes.
const projectsLen = Array.isArray((updates as any).projects)
? (updates as any).projects.length
: undefined;
const trashedLen = Array.isArray((updates as any).trashedProjects)
? (updates as any).trashedProjects.length
: undefined;
logger.info(
`[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${
(updates as any).theme ?? 'n/a'
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
);
if ('projects' in updates || 'theme' in updates || 'localStorageMigrated' in updates) {
const projectsLen = Array.isArray((updates as any).projects)
? (updates as any).projects.length
: undefined;
logger.info(
`Update global settings request: projects=${projectsLen ?? 'n/a'}, theme=${
(updates as any).theme ?? 'n/a'
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
);
}
logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
const settings = await settingsService.updateGlobalSettings(updates);
logger.info(
'[SERVER_SETTINGS_UPDATE] Update complete, projects count:',
settings.projects?.length ?? 0
);
// Apply server log level if it was updated
if ('serverLogLevel' in updates && updates.serverLogLevel) {

View File

@@ -52,8 +52,3 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise<vo
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);
/**
* Marker file used to indicate a provider has been explicitly disconnected by user
*/
export const COPILOT_DISCONNECTED_MARKER_FILE = '.copilot-disconnected';

View File

@@ -3,6 +3,7 @@
*/
import { Router } from 'express';
import { createStatusHandler } from './routes/status.js';
import { createClaudeStatusHandler } from './routes/claude-status.js';
import { createInstallClaudeHandler } from './routes/install-claude.js';
import { createAuthClaudeHandler } from './routes/auth-claude.js';
@@ -12,6 +13,10 @@ import { createApiKeysHandler } from './routes/api-keys.js';
import { createPlatformHandler } from './routes/platform.js';
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
import { createVerifyCodexAuthHandler } from './routes/verify-codex-auth.js';
import { createVerifyCodeRabbitAuthHandler } from './routes/verify-coderabbit-auth.js';
import { createCodeRabbitStatusHandler } from './routes/coderabbit-status.js';
import { createAuthCodeRabbitHandler } from './routes/auth-coderabbit.js';
import { createDeauthCodeRabbitHandler } from './routes/deauth-coderabbit.js';
import { createGhStatusHandler } from './routes/gh-status.js';
import { createCursorStatusHandler } from './routes/cursor-status.js';
import { createCodexStatusHandler } from './routes/codex-status.js';
@@ -24,17 +29,6 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
import { createGeminiStatusHandler } from './routes/gemini-status.js';
import { createAuthGeminiHandler } from './routes/auth-gemini.js';
import { createDeauthGeminiHandler } from './routes/deauth-gemini.js';
import { createCopilotStatusHandler } from './routes/copilot-status.js';
import { createAuthCopilotHandler } from './routes/auth-copilot.js';
import { createDeauthCopilotHandler } from './routes/deauth-copilot.js';
import {
createGetCopilotModelsHandler,
createRefreshCopilotModelsHandler,
createClearCopilotCacheHandler,
} from './routes/copilot-models.js';
import {
createGetOpencodeModelsHandler,
createRefreshOpencodeModelsHandler,
@@ -55,6 +49,9 @@ import {
export function createSetupRoutes(): Router {
const router = Router();
// Unified CLI status endpoint
router.get('/status', createStatusHandler());
router.get('/claude-status', createClaudeStatusHandler());
router.post('/install-claude', createInstallClaudeHandler());
router.post('/auth-claude', createAuthClaudeHandler());
@@ -65,6 +62,7 @@ export function createSetupRoutes(): Router {
router.get('/platform', createPlatformHandler());
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
router.post('/verify-coderabbit-auth', createVerifyCodeRabbitAuthHandler());
router.get('/gh-status', createGhStatusHandler());
// Cursor CLI routes
@@ -83,20 +81,10 @@ export function createSetupRoutes(): Router {
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());
// Gemini CLI routes
router.get('/gemini-status', createGeminiStatusHandler());
router.post('/auth-gemini', createAuthGeminiHandler());
router.post('/deauth-gemini', createDeauthGeminiHandler());
// Copilot CLI routes
router.get('/copilot-status', createCopilotStatusHandler());
router.post('/auth-copilot', createAuthCopilotHandler());
router.post('/deauth-copilot', createDeauthCopilotHandler());
// Copilot Dynamic Model Discovery routes
router.get('/copilot/models', createGetCopilotModelsHandler());
router.post('/copilot/models/refresh', createRefreshCopilotModelsHandler());
router.post('/copilot/cache/clear', createClearCopilotCacheHandler());
// CodeRabbit CLI routes
router.get('/coderabbit-status', createCodeRabbitStatusHandler());
router.post('/auth-coderabbit', createAuthCodeRabbitHandler());
router.post('/deauth-coderabbit', createDeauthCodeRabbitHandler());
// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());

View File

@@ -0,0 +1,80 @@
/**
* POST /auth-coderabbit endpoint - Authenticate CodeRabbit CLI via OAuth
*
* CodeRabbit CLI requires interactive authentication:
* 1. Run `cr auth login`
* 2. Browser opens with OAuth flow
* 3. After browser auth, CLI shows a token
* 4. User must press Enter to confirm
*
* Since step 4 requires interactive input, we can't fully automate this.
* Instead, we provide the command for the user to run manually.
*/
import type { Request, Response } from 'express';
import { execSync } from 'child_process';
import { logError, getErrorMessage } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
/**
* Find the CodeRabbit CLI command (coderabbit or cr)
*/
function findCodeRabbitCommand(): string | null {
const commands = ['coderabbit', 'cr'];
for (const command of commands) {
try {
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
const result = execSync(`${whichCommand} ${command}`, {
encoding: 'utf8',
timeout: 2000,
}).trim();
if (result) {
return result.split('\n')[0];
}
} catch {
// Command not found, try next
}
}
return null;
}
export function createAuthCodeRabbitHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Remove the disconnected marker file to reconnect the app to the CLI
const markerPath = path.join(process.cwd(), '.automaker', '.coderabbit-disconnected');
if (fs.existsSync(markerPath)) {
fs.unlinkSync(markerPath);
}
// Find CodeRabbit CLI
const cliPath = findCodeRabbitCommand();
if (!cliPath) {
res.status(400).json({
success: false,
error: 'CodeRabbit CLI is not installed. Please install it first.',
});
return;
}
// CodeRabbit CLI requires interactive input (pressing Enter after OAuth)
// We can't automate this, so we return the command for the user to run
const command = cliPath.includes('coderabbit') ? 'coderabbit auth login' : 'cr auth login';
res.json({
success: true,
requiresManualAuth: true,
command,
message: `Please run "${command}" in your terminal to authenticate. After completing OAuth in your browser, press Enter in the terminal to confirm.`,
});
} catch (error) {
logError(error, 'Auth CodeRabbit failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to initiate CodeRabbit authentication',
});
}
};
}

View File

@@ -1,30 +0,0 @@
/**
* POST /auth-copilot endpoint - Connect Copilot CLI to the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { connectCopilot } from '../../../services/copilot-connection-service.js';
/**
* Creates handler for POST /api/setup/auth-copilot
* Removes the disconnection marker to allow Copilot CLI to be used
*/
export function createAuthCopilotHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
await connectCopilot();
res.json({
success: true,
message: 'Copilot CLI connected to app',
});
} catch (error) {
logError(error, 'Auth Copilot failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,42 +0,0 @@
/**
* POST /auth-gemini endpoint - Connect Gemini CLI to the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
/**
* Creates handler for POST /api/setup/auth-gemini
* Removes the disconnection marker to allow Gemini CLI to be used
*/
export function createAuthGeminiHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const projectRoot = process.cwd();
const automakerDir = path.join(projectRoot, '.automaker');
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
// Remove the disconnection marker if it exists
try {
await fs.unlink(markerPath);
} catch {
// File doesn't exist, nothing to remove
}
res.json({
success: true,
message: 'Gemini CLI connected to app',
});
} catch (error) {
logError(error, 'Auth Gemini failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,240 @@
/**
* GET /coderabbit-status endpoint - Get CodeRabbit CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { spawn, execSync } from 'child_process';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.coderabbit-disconnected';
function isCodeRabbitDisconnectedFromApp(): boolean {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
return fs.existsSync(markerPath);
} catch {
return false;
}
}
/**
* Find the CodeRabbit CLI command (coderabbit or cr)
*/
function findCodeRabbitCommand(): string | null {
const commands = ['coderabbit', 'cr'];
for (const command of commands) {
try {
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
const result = execSync(`${whichCommand} ${command}`, {
encoding: 'utf8',
timeout: 2000,
}).trim();
if (result) {
return result.split('\n')[0];
}
} catch {
// Command not found, try next
}
}
return null;
}
/**
* Get CodeRabbit CLI version
*/
async function getCodeRabbitVersion(command: string): Promise<string | null> {
return new Promise((resolve) => {
const child = spawn(command, ['--version'], {
stdio: 'pipe',
timeout: 5000,
});
let stdout = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.on('close', (code) => {
if (code === 0 && stdout) {
resolve(stdout.trim());
} else {
resolve(null);
}
});
child.on('error', () => {
resolve(null);
});
});
}
interface CodeRabbitAuthInfo {
authenticated: boolean;
method: 'oauth' | 'none';
username?: string;
email?: string;
organization?: string;
}
/**
* Check CodeRabbit CLI authentication status
* Parses output like:
* ```
* CodeRabbit CLI Status
* ✅ Authentication: Logged in
* User Information:
* 👤 Name: Kacper
* 📧 Email: kacperlachowiczwp.pl@wp.pl
* 🔧 Username: Shironex
* Organization Information:
* 🏢 Name: Anime-World-SPZOO
* ```
*/
async function getCodeRabbitAuthStatus(command: string): Promise<CodeRabbitAuthInfo> {
return new Promise((resolve) => {
const child = spawn(command, ['auth', 'status'], {
stdio: 'pipe',
timeout: 10000,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const output = stdout + stderr;
// Check for "Logged in" in Authentication line
const isAuthenticated =
code === 0 &&
(output.includes('Logged in') || output.includes('logged in')) &&
!output.toLowerCase().includes('not logged in');
if (isAuthenticated) {
// Parse the structured output format
// Username: look for "Username: <value>" line
const usernameMatch = output.match(/Username:\s*(\S+)/i);
// Email: look for "Email: <value>" line
const emailMatch = output.match(/Email:\s*(\S+@\S+)/i);
// Organization: look for "Name: <value>" under Organization Information
// The org name appears after "Organization Information:" section
const orgSection = output.split(/Organization Information:/i)[1];
const orgMatch = orgSection?.match(/Name:\s*(.+?)(?:\n|$)/i);
resolve({
authenticated: true,
method: 'oauth',
username: usernameMatch?.[1]?.trim(),
email: emailMatch?.[1]?.trim(),
organization: orgMatch?.[1]?.trim(),
});
} else {
resolve({
authenticated: false,
method: 'none',
});
}
});
child.on('error', () => {
resolve({
authenticated: false,
method: 'none',
});
});
});
}
/**
* Creates handler for GET /api/setup/coderabbit-status
* Returns CodeRabbit CLI installation and authentication status
*/
export function createCodeRabbitStatusHandler() {
const installCommand = 'npm install -g coderabbit';
const loginCommand = 'coderabbit auth login';
return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (isCodeRabbitDisconnectedFromApp()) {
res.json({
success: true,
installed: true,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
},
recommendation: 'CodeRabbit CLI is disconnected. Click Sign In to reconnect.',
installCommand,
loginCommand,
});
return;
}
// Find CodeRabbit CLI
const cliPath = findCodeRabbitCommand();
if (!cliPath) {
res.json({
success: true,
installed: false,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
},
recommendation: 'Install CodeRabbit CLI to enable AI-powered code reviews.',
installCommand,
loginCommand,
installCommands: {
macos: 'curl -fsSL https://coderabbit.ai/install | bash',
npm: installCommand,
},
});
return;
}
// Get version
const version = await getCodeRabbitVersion(cliPath);
// Get auth status
const authStatus = await getCodeRabbitAuthStatus(cliPath);
res.json({
success: true,
installed: true,
version,
path: cliPath,
auth: authStatus,
recommendation: authStatus.authenticated
? undefined
: 'Sign in to CodeRabbit to enable AI-powered code reviews.',
installCommand,
loginCommand,
installCommands: {
macos: 'curl -fsSL https://coderabbit.ai/install | bash',
npm: installCommand,
},
});
} catch (error) {
logError(error, 'Get CodeRabbit status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,139 +0,0 @@
/**
* Copilot Dynamic Models API Routes
*
* Provides endpoints for:
* - GET /api/setup/copilot/models - Get available models (cached or refreshed)
* - POST /api/setup/copilot/models/refresh - Force refresh models from CLI
*/
import type { Request, Response } from 'express';
import { CopilotProvider } from '../../../providers/copilot-provider.js';
import { getErrorMessage, logError } from '../common.js';
import type { ModelDefinition } from '@automaker/types';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CopilotModelsRoute');
// Singleton provider instance for caching
let providerInstance: CopilotProvider | null = null;
function getProvider(): CopilotProvider {
if (!providerInstance) {
providerInstance = new CopilotProvider();
}
return providerInstance;
}
/**
* Response type for models endpoint
*/
interface ModelsResponse {
success: boolean;
models?: ModelDefinition[];
count?: number;
cached?: boolean;
error?: string;
}
/**
* Creates handler for GET /api/setup/copilot/models
*
* Returns currently available models (from cache if available).
* Query params:
* - refresh=true: Force refresh from CLI before returning
*
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
*/
export function createGetCopilotModelsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const forceRefresh = req.query.refresh === 'true';
let models: ModelDefinition[];
let cached = true;
if (forceRefresh) {
models = await provider.refreshModels();
cached = false;
} else {
// Check if we have cached models
if (!provider.hasCachedModels()) {
models = await provider.refreshModels();
cached = false;
} else {
models = provider.getAvailableModels();
}
}
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached,
};
res.json(response);
} catch (error) {
logError(error, 'Get Copilot models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for POST /api/setup/copilot/models/refresh
*
* Forces a refresh of models from the Copilot CLI.
*/
export function createRefreshCopilotModelsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const models = await provider.refreshModels();
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached: false,
};
res.json(response);
} catch (error) {
logError(error, 'Refresh Copilot models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for POST /api/setup/copilot/cache/clear
*
* Clears the model cache, forcing a fresh fetch on next access.
*/
export function createClearCopilotCacheHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
provider.clearModelCache();
res.json({
success: true,
message: 'Copilot model cache cleared',
});
} catch (error) {
logError(error, 'Clear Copilot cache failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,78 +0,0 @@
/**
* GET /copilot-status endpoint - Get Copilot CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { CopilotProvider } from '../../../providers/copilot-provider.js';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.copilot-disconnected';
async function isCopilotDisconnectedFromApp(): Promise<boolean> {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
await fs.access(markerPath);
return true;
} catch {
return false;
}
}
/**
* Creates handler for GET /api/setup/copilot-status
* Returns Copilot CLI installation and authentication status
*/
export function createCopilotStatusHandler() {
const installCommand = 'npm install -g @github/copilot';
const loginCommand = 'gh auth login';
return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (await isCopilotDisconnectedFromApp()) {
res.json({
success: true,
installed: true,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
},
installCommand,
loginCommand,
});
return;
}
const provider = new CopilotProvider();
const status = await provider.detectInstallation();
const auth = await provider.checkAuth();
res.json({
success: true,
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: auth.authenticated,
method: auth.method,
login: auth.login,
host: auth.host,
error: auth.error,
},
installCommand,
loginCommand,
});
} catch (error) {
logError(error, 'Get Copilot status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,113 @@
/**
* POST /deauth-coderabbit endpoint - Sign out from CodeRabbit CLI
*/
import type { Request, Response } from 'express';
import { spawn, execSync } from 'child_process';
import { logError, getErrorMessage } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
/**
* Find the CodeRabbit CLI command (coderabbit or cr)
*/
function findCodeRabbitCommand(): string | null {
const commands = ['coderabbit', 'cr'];
for (const command of commands) {
try {
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
const result = execSync(`${whichCommand} ${command}`, {
encoding: 'utf8',
timeout: 2000,
}).trim();
if (result) {
return result.split('\n')[0];
}
} catch {
// Command not found, try next
}
}
return null;
}
export function createDeauthCodeRabbitHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Find CodeRabbit CLI
const cliPath = findCodeRabbitCommand();
if (cliPath) {
// Try to run the CLI logout command
const logoutResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
const child = spawn(cliPath, ['auth', 'logout'], {
stdio: 'pipe',
timeout: 10000,
});
let stderr = '';
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve({ success: true });
} else {
resolve({ success: false, error: stderr || 'Logout command failed' });
}
});
child.on('error', (err) => {
resolve({ success: false, error: err.message });
});
});
if (!logoutResult.success) {
// CLI logout failed, create marker file as fallback
const automakerDir = path.join(process.cwd(), '.automaker');
const markerPath = path.join(automakerDir, '.coderabbit-disconnected');
if (!fs.existsSync(automakerDir)) {
fs.mkdirSync(automakerDir, { recursive: true });
}
fs.writeFileSync(
markerPath,
JSON.stringify({
disconnectedAt: new Date().toISOString(),
message: 'CodeRabbit CLI is disconnected from the app',
})
);
}
} else {
// CLI not installed, just create marker file
const automakerDir = path.join(process.cwd(), '.automaker');
const markerPath = path.join(automakerDir, '.coderabbit-disconnected');
if (!fs.existsSync(automakerDir)) {
fs.mkdirSync(automakerDir, { recursive: true });
}
fs.writeFileSync(
markerPath,
JSON.stringify({
disconnectedAt: new Date().toISOString(),
message: 'CodeRabbit CLI is disconnected from the app',
})
);
}
res.json({
success: true,
message: 'Successfully signed out from CodeRabbit CLI',
});
} catch (error) {
logError(error, 'Deauth CodeRabbit failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to sign out from CodeRabbit CLI',
});
}
};
}

View File

@@ -1,30 +0,0 @@
/**
* POST /deauth-copilot endpoint - Disconnect Copilot CLI from the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { disconnectCopilot } from '../../../services/copilot-connection-service.js';
/**
* Creates handler for POST /api/setup/deauth-copilot
* Creates a marker file to disconnect Copilot CLI from the app
*/
export function createDeauthCopilotHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
await disconnectCopilot();
res.json({
success: true,
message: 'Copilot CLI disconnected from app',
});
} catch (error) {
logError(error, 'Deauth Copilot failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,42 +0,0 @@
/**
* POST /deauth-gemini endpoint - Disconnect Gemini CLI from the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
/**
* Creates handler for POST /api/setup/deauth-gemini
* Creates a marker file to disconnect Gemini CLI from the app
*/
export function createDeauthGeminiHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const projectRoot = process.cwd();
const automakerDir = path.join(projectRoot, '.automaker');
// Ensure .automaker directory exists
await fs.mkdir(automakerDir, { recursive: true });
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
// Create the disconnection marker
await fs.writeFile(markerPath, 'Gemini CLI disconnected from app');
res.json({
success: true,
message: 'Gemini CLI disconnected from app',
});
} catch (error) {
logError(error, 'Deauth Gemini failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,79 +0,0 @@
/**
* GET /gemini-status endpoint - Get Gemini CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { GeminiProvider } from '../../../providers/gemini-provider.js';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
async function isGeminiDisconnectedFromApp(): Promise<boolean> {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
await fs.access(markerPath);
return true;
} catch {
return false;
}
}
/**
* Creates handler for GET /api/setup/gemini-status
* Returns Gemini CLI installation and authentication status
*/
export function createGeminiStatusHandler() {
const installCommand = 'npm install -g @google/gemini-cli';
const loginCommand = 'gemini';
return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (await isGeminiDisconnectedFromApp()) {
res.json({
success: true,
installed: true,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
hasApiKey: false,
},
installCommand,
loginCommand,
});
return;
}
const provider = new GeminiProvider();
const status = await provider.detectInstallation();
const auth = await provider.checkAuth();
res.json({
success: true,
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: auth.authenticated,
method: auth.method,
hasApiKey: auth.hasApiKey || false,
hasEnvApiKey: auth.hasEnvApiKey || false,
error: auth.error,
},
installCommand,
loginCommand,
});
} catch (error) {
logError(error, 'Get Gemini status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,249 @@
/**
* GET /status endpoint - Get unified CLI availability status
*
* Returns the installation and authentication status of all supported CLIs
* in a single response. This is useful for quickly determining which
* providers are available without making multiple API calls.
*/
import type { Request, Response } from 'express';
import { getClaudeStatus } from '../get-claude-status.js';
import { getErrorMessage, logError } from '../common.js';
import { CursorProvider } from '../../../providers/cursor-provider.js';
import { CodexProvider } from '../../../providers/codex-provider.js';
import { OpencodeProvider } from '../../../providers/opencode-provider.js';
import * as fs from 'fs';
import * as path from 'path';
/**
* Check if a CLI has been manually disconnected from the app
*/
function isCliDisconnected(cliName: string): boolean {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', `.${cliName}-disconnected`);
return fs.existsSync(markerPath);
} catch {
return false;
}
}
/**
* CLI status response for a single provider
*/
interface CliStatusResponse {
installed: boolean;
version: string | null;
path: string | null;
auth: {
authenticated: boolean;
method: string;
};
disconnected: boolean;
}
/**
* Unified status response for all CLIs
*/
interface UnifiedStatusResponse {
success: boolean;
timestamp: string;
clis: {
claude: CliStatusResponse | null;
cursor: CliStatusResponse | null;
codex: CliStatusResponse | null;
opencode: CliStatusResponse | null;
};
availableProviders: string[];
hasAnyAuthenticated: boolean;
}
/**
* Get detailed Claude CLI status
*/
async function getClaudeCliStatus(): Promise<CliStatusResponse> {
const disconnected = isCliDisconnected('claude');
try {
const status = await getClaudeStatus();
return {
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: disconnected ? false : status.auth.authenticated,
method: disconnected ? 'none' : status.auth.method,
},
disconnected,
};
} catch {
return {
installed: false,
version: null,
path: null,
auth: { authenticated: false, method: 'none' },
disconnected,
};
}
}
/**
* Get detailed Cursor CLI status
*/
async function getCursorCliStatus(): Promise<CliStatusResponse> {
const disconnected = isCliDisconnected('cursor');
try {
const provider = new CursorProvider();
const [installed, version, auth] = await Promise.all([
provider.isInstalled(),
provider.getVersion(),
provider.checkAuth(),
]);
const cliPath = installed ? provider.getCliPath() : null;
return {
installed,
version: version || null,
path: cliPath,
auth: {
authenticated: disconnected ? false : auth.authenticated,
method: disconnected ? 'none' : auth.method,
},
disconnected,
};
} catch {
return {
installed: false,
version: null,
path: null,
auth: { authenticated: false, method: 'none' },
disconnected,
};
}
}
/**
* Get detailed Codex CLI status
*/
async function getCodexCliStatus(): Promise<CliStatusResponse> {
const disconnected = isCliDisconnected('codex');
try {
const provider = new CodexProvider();
const status = await provider.detectInstallation();
let authMethod = 'none';
if (!disconnected && status.authenticated) {
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
}
return {
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: disconnected ? false : status.authenticated || false,
method: authMethod,
},
disconnected,
};
} catch {
return {
installed: false,
version: null,
path: null,
auth: { authenticated: false, method: 'none' },
disconnected,
};
}
}
/**
* Get detailed OpenCode CLI status
*/
async function getOpencodeCliStatus(): Promise<CliStatusResponse> {
try {
const provider = new OpencodeProvider();
const status = await provider.detectInstallation();
let authMethod = 'none';
if (status.authenticated) {
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
}
return {
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: status.authenticated || false,
method: authMethod,
},
disconnected: false, // OpenCode doesn't have disconnect feature
};
} catch {
return {
installed: false,
version: null,
path: null,
auth: { authenticated: false, method: 'none' },
disconnected: false,
};
}
}
/**
* Creates handler for GET /api/setup/status
* Returns unified CLI availability status for all providers
*/
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Fetch all CLI statuses in parallel for performance
const [claude, cursor, codex, opencode] = await Promise.all([
getClaudeCliStatus(),
getCursorCliStatus(),
getCodexCliStatus(),
getOpencodeCliStatus(),
]);
// Determine which providers are available (installed and authenticated)
const availableProviders: string[] = [];
if (claude.installed && claude.auth.authenticated) {
availableProviders.push('claude');
}
if (cursor.installed && cursor.auth.authenticated) {
availableProviders.push('cursor');
}
if (codex.installed && codex.auth.authenticated) {
availableProviders.push('codex');
}
if (opencode.installed && opencode.auth.authenticated) {
availableProviders.push('opencode');
}
const response: UnifiedStatusResponse = {
success: true,
timestamp: new Date().toISOString(),
clis: {
claude,
cursor,
codex,
opencode,
},
availableProviders,
hasAnyAuthenticated: availableProviders.length > 0,
};
res.json(response);
} catch (error) {
logError(error, 'Get unified CLI status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,163 @@
/**
* POST /verify-coderabbit-auth endpoint - Verify CodeRabbit authentication
* Validates API key format and optionally tests the connection
*/
import type { Request, Response } from 'express';
import { spawn } from 'child_process';
import { createLogger } from '@automaker/utils';
import { AuthRateLimiter, validateApiKey } from '../../../lib/auth-utils.js';
const logger = createLogger('Setup');
const rateLimiter = new AuthRateLimiter();
/**
* Test CodeRabbit CLI authentication by running a simple command
*/
async function testCodeRabbitCli(
apiKey?: string
): Promise<{ authenticated: boolean; error?: string }> {
return new Promise((resolve) => {
// Set up environment with API key if provided
const env = { ...process.env };
if (apiKey) {
env.CODERABBIT_API_KEY = apiKey;
}
// Try to run coderabbit auth status to verify auth
const child = spawn('coderabbit', ['auth', 'status'], {
stdio: ['pipe', 'pipe', 'pipe'],
env,
timeout: 10000,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
// Check output for authentication status
const output = stdout.toLowerCase() + stderr.toLowerCase();
if (
output.includes('authenticated') ||
output.includes('logged in') ||
output.includes('valid')
) {
resolve({ authenticated: true });
} else if (output.includes('not authenticated') || output.includes('not logged in')) {
resolve({ authenticated: false, error: 'CodeRabbit CLI is not authenticated.' });
} else {
// Command succeeded, assume authenticated
resolve({ authenticated: true });
}
} else {
// Command failed
const errorMsg = stderr || stdout || 'CodeRabbit CLI authentication check failed.';
resolve({ authenticated: false, error: errorMsg.trim() });
}
});
child.on('error', (err) => {
// CodeRabbit CLI not installed or other error
resolve({ authenticated: false, error: `CodeRabbit CLI error: ${err.message}` });
});
});
}
/**
* Validate CodeRabbit API key format
* CodeRabbit API keys typically start with 'cr-'
*/
function validateCodeRabbitKey(apiKey: string): { isValid: boolean; error?: string } {
if (!apiKey || apiKey.trim().length === 0) {
return { isValid: false, error: 'API key cannot be empty.' };
}
// CodeRabbit API keys typically start with 'cr-'
if (!apiKey.startsWith('cr-')) {
return {
isValid: false,
error: 'Invalid CodeRabbit API key format. Keys should start with "cr-".',
};
}
if (apiKey.length < 10) {
return { isValid: false, error: 'API key is too short.' };
}
return { isValid: true };
}
export function createVerifyCodeRabbitAuthHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { authMethod, apiKey } = req.body as {
authMethod?: 'cli' | 'api_key';
apiKey?: string;
};
// Rate limiting to prevent abuse
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
if (!rateLimiter.canAttempt(clientIp)) {
const resetTime = rateLimiter.getResetTime(clientIp);
res.status(429).json({
success: false,
authenticated: false,
error: 'Too many authentication attempts. Please try again later.',
resetTime,
});
return;
}
logger.info(
`[Setup] Verifying CodeRabbit authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}`
);
// For API key verification
if (authMethod === 'api_key' && apiKey) {
// Validate key format
const validation = validateCodeRabbitKey(apiKey);
if (!validation.isValid) {
res.json({
success: true,
authenticated: false,
error: validation.error,
});
return;
}
// Test the CLI with the provided API key
const result = await testCodeRabbitCli(apiKey);
res.json({
success: true,
authenticated: result.authenticated,
error: result.error,
});
return;
}
// For CLI auth or auto detection
const result = await testCodeRabbitCli();
res.json({
success: true,
authenticated: result.authenticated,
error: result.error,
});
} catch (error) {
logger.error('[Setup] Verify CodeRabbit auth endpoint error:', error);
res.status(500).json({
success: false,
authenticated: false,
error: error instanceof Error ? error.message : 'Verification failed',
});
}
};
}

View File

@@ -0,0 +1,34 @@
/**
* Common utilities and state for suggestions routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('Suggestions');
// Shared state for tracking generation status - private
let isRunning = false;
let currentAbortController: AbortController | null = null;
/**
* Get the current running state
*/
export function getSuggestionsStatus(): {
isRunning: boolean;
currentAbortController: AbortController | null;
} {
return { isRunning, currentAbortController };
}
/**
* Set the running state and abort controller
*/
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
isRunning = running;
currentAbortController = controller;
}
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);

View File

@@ -0,0 +1,296 @@
/**
* Business logic for generating suggestions
*
* Model is configurable via phaseModels.suggestionsModel in settings
* (AI Suggestions in the UI). Supports both Claude and Cursor models.
*/
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJsonWithArray } from '../../lib/json-extractor.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';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
const logger = createLogger('Suggestions');
/**
* Extract implemented features from app_spec.txt XML content
*
* Note: This uses regex-based parsing which is sufficient for our controlled
* XML structure. If more complex XML parsing is needed in the future, consider
* using a library like 'fast-xml-parser' or 'xml2js'.
*/
function extractImplementedFeatures(specContent: string): string[] {
const features: string[] = [];
// Match <implemented_features>...</implemented_features> section
const implementedMatch = specContent.match(
/<implemented_features>([\s\S]*?)<\/implemented_features>/
);
if (implementedMatch) {
const implementedSection = implementedMatch[1];
// Extract feature names from <name>...</name> tags using matchAll
const nameRegex = /<name>(.*?)<\/name>/g;
const matches = implementedSection.matchAll(nameRegex);
for (const match of matches) {
features.push(match[1].trim());
}
}
return features;
}
/**
* Load existing context (app spec and backlog features) to avoid duplicates
*/
async function loadExistingContext(projectPath: string): Promise<string> {
let context = '';
// 1. Read app_spec.txt for implemented features
try {
const appSpecPath = getAppSpecPath(projectPath);
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
if (specContent && specContent.trim().length > 0) {
const implementedFeatures = extractImplementedFeatures(specContent);
if (implementedFeatures.length > 0) {
context += '\n\n=== ALREADY IMPLEMENTED FEATURES ===\n';
context += 'These features are already implemented in the codebase:\n';
context += implementedFeatures.map((feature) => `- ${feature}`).join('\n') + '\n';
}
}
} catch (error) {
// app_spec.txt doesn't exist or can't be read - that's okay
logger.debug('No app_spec.txt found or error reading it:', error);
}
// 2. Load existing features from backlog
try {
const featureLoader = new FeatureLoader();
const features = await featureLoader.getAll(projectPath);
if (features.length > 0) {
context += '\n\n=== EXISTING FEATURES IN BACKLOG ===\n';
context += 'These features are already planned or in progress:\n';
context +=
features
.map((feature) => {
const status = feature.status || 'pending';
const title = feature.title || feature.description?.substring(0, 50) || 'Untitled';
return `- ${title} (${status})`;
})
.join('\n') + '\n';
}
} catch (error) {
// Features directory doesn't exist or can't be read - that's okay
logger.debug('No features found or error loading them:', error);
}
return context;
}
/**
* JSON Schema for suggestions output
*/
const suggestionsSchema = {
type: 'object',
properties: {
suggestions: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
category: { type: 'string' },
description: { type: 'string' },
priority: {
type: 'number',
minimum: 1,
maximum: 3,
},
reasoning: { type: 'string' },
},
required: ['category', 'description', 'priority', 'reasoning'],
},
},
},
required: ['suggestions'],
additionalProperties: false,
};
export async function generateSuggestions(
projectPath: string,
suggestionType: string,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService,
modelOverride?: string,
thinkingLevelOverride?: ThinkingLevel
): Promise<void> {
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[Suggestions]');
// Map suggestion types to their prompts
const typePrompts: Record<string, string> = {
features: prompts.suggestions.featuresPrompt,
refactoring: prompts.suggestions.refactoringPrompt,
security: prompts.suggestions.securityPrompt,
performance: prompts.suggestions.performancePrompt,
};
// Load existing context to avoid duplicates
const existingContext = await loadExistingContext(projectPath);
const prompt = `${typePrompts[suggestionType] || typePrompts.features}
${existingContext}
${existingContext ? '\nIMPORTANT: Do NOT suggest features that are already implemented or already in the backlog above. Focus on NEW ideas that complement what already exists.\n' : ''}
${prompts.suggestions.baseTemplate}`;
// Don't send initial message - let the agent output speak for itself
// The first agent message will be captured as an info entry
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[Suggestions]'
);
// Get model from phase settings (AI Suggestions = suggestionsModel)
// Use override if provided, otherwise fall back to settings
const settings = await settingsService?.getGlobalSettings();
let model: string;
let thinkingLevel: ThinkingLevel | undefined;
if (modelOverride) {
// Use explicit override - resolve the model string
const resolved = resolvePhaseModel({
model: modelOverride,
thinkingLevel: thinkingLevelOverride,
});
model = resolved.model;
thinkingLevel = resolved.thinkingLevel;
} else {
// Use settings-based model
const phaseModelEntry =
settings?.phaseModels?.suggestionsModel || DEFAULT_PHASE_MODELS.suggestionsModel;
const resolved = resolvePhaseModel(phaseModelEntry);
model = resolved.model;
thinkingLevel = resolved.thinkingLevel;
}
logger.info('[Suggestions] Using model:', model);
let responseText = '';
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
const useStructuredOutput = !isCursorModel(model);
// 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.
2. After analyzing the project, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
3. The JSON must match this exact schema:
${JSON.stringify(suggestionsSchema, null, 2)}
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
}
// 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<Record<string, unknown>> } | null = null;
if (result.structured_output) {
structuredOutput = result.structured_output as {
suggestions: Array<Record<string, unknown>>;
};
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<Record<string, unknown>> }>(
responseText,
'suggestions',
{ logger }
);
}
if (structuredOutput && structuredOutput.suggestions) {
// Use structured output directly
events.emit('suggestions:event', {
type: 'suggestions_complete',
suggestions: structuredOutput.suggestions.map((s: Record<string, unknown>, i: number) => ({
...s,
id: s.id || `suggestion-${Date.now()}-${i}`,
})),
});
} else {
throw new Error('No valid JSON found in response');
}
} catch (error) {
// Log the parsing error for debugging
logger.error('Failed to parse suggestions JSON from AI response:', error);
// Return generic suggestions if parsing fails
events.emit('suggestions:event', {
type: 'suggestions_complete',
suggestions: [
{
id: `suggestion-${Date.now()}-0`,
category: 'Analysis',
description: 'Review the AI analysis output for insights',
priority: 1,
reasoning: 'The AI provided analysis but suggestions need manual review',
},
],
});
}
}

View File

@@ -0,0 +1,28 @@
/**
* Suggestions routes - HTTP API for AI-powered feature suggestions
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createGenerateHandler } from './routes/generate.js';
import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createSuggestionsRoutes(
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router();
router.post(
'/generate',
validatePathParams('projectPath'),
createGenerateHandler(events, settingsService)
);
router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler());
return router;
}

View File

@@ -0,0 +1,75 @@
/**
* POST /generate endpoint - Generate suggestions
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { createLogger } from '@automaker/utils';
import type { ThinkingLevel } from '@automaker/types';
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
import { generateSuggestions } from '../generate-suggestions.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('Suggestions');
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
suggestionType = 'features',
model,
thinkingLevel,
} = req.body as {
projectPath: string;
suggestionType?: string;
model?: string;
thinkingLevel?: ThinkingLevel;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
const { isRunning } = getSuggestionsStatus();
if (isRunning) {
res.json({
success: false,
error: 'Suggestions generation is already running',
});
return;
}
setRunningState(true);
const abortController = new AbortController();
setRunningState(true, abortController);
// Start generation in background
generateSuggestions(
projectPath,
suggestionType,
events,
abortController,
settingsService,
model,
thinkingLevel
)
.catch((error) => {
logError(error, 'Generate suggestions failed (background)');
events.emit('suggestions:event', {
type: 'suggestions_error',
error: getErrorMessage(error),
});
})
.finally(() => {
setRunningState(false, null);
});
res.json({ success: true });
} catch (error) {
logError(error, 'Generate suggestions failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,18 @@
/**
* GET /status endpoint - Get status
*/
import type { Request, Response } from 'express';
import { getSuggestionsStatus, getErrorMessage, logError } from '../common.js';
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const { isRunning } = getSuggestionsStatus();
res.json({ success: true, isRunning });
} catch (error) {
logError(error, 'Get status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,22 @@
/**
* POST /stop endpoint - Stop suggestions generation
*/
import type { Request, Response } from 'express';
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const { currentAbortController } = getSuggestionsStatus();
if (currentAbortController) {
currentAbortController.abort();
}
setRunningState(false, null);
res.json({ success: true });
} catch (error) {
logError(error, 'Stop suggestions failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -29,31 +29,18 @@ import {
createGetAvailableEditorsHandler,
createRefreshEditorsHandler,
} from './routes/open-in-editor.js';
import {
createOpenInTerminalHandler,
createGetAvailableTerminalsHandler,
createGetDefaultTerminalHandler,
createRefreshTerminalsHandler,
createOpenInExternalTerminalHandler,
} from './routes/open-in-terminal.js';
import { createInitGitHandler } from './routes/init-git.js';
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 { createStartTestsHandler } from './routes/start-tests.js';
import { createStopTestsHandler } from './routes/stop-tests.js';
import { createGetTestLogsHandler } from './routes/test-logs.js';
import {
createGetInitScriptHandler,
createPutInitScriptHandler,
createDeleteInitScriptHandler,
createRunInitScriptHandler,
} from './routes/init-script.js';
import { createDiscardChangesHandler } from './routes/discard-changes.js';
import { createListRemotesHandler } from './routes/list-remotes.js';
import { createAddRemoteHandler } from './routes/add-remote.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -110,31 +97,15 @@ export function createWorktreeRoutes(
);
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.post(
'/open-in-terminal',
validatePathParams('worktreePath'),
createOpenInTerminalHandler()
);
router.get('/default-editor', createGetDefaultEditorHandler());
router.get('/available-editors', createGetAvailableEditorsHandler());
router.post('/refresh-editors', createRefreshEditorsHandler());
// External terminal routes
router.get('/available-terminals', createGetAvailableTerminalsHandler());
router.get('/default-terminal', createGetDefaultTerminalHandler());
router.post('/refresh-terminals', createRefreshTerminalsHandler());
router.post(
'/open-in-external-terminal',
validatePathParams('worktreePath'),
createOpenInExternalTerminalHandler()
);
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
router.post('/migrate', createMigrateHandler());
router.post(
'/start-dev',
validatePathParams('projectPath', 'worktreePath'),
createStartDevHandler(settingsService)
createStartDevHandler()
);
router.post('/stop-dev', createStopDevHandler());
router.post('/list-dev-servers', createListDevServersHandler());
@@ -144,15 +115,6 @@ export function createWorktreeRoutes(
createGetDevServerLogsHandler()
);
// Test runner routes
router.post(
'/start-tests',
validatePathParams('worktreePath', 'projectPath?'),
createStartTestsHandler(settingsService)
);
router.post('/stop-tests', createStopTestsHandler());
router.get('/test-logs', validatePathParams('worktreePath?'), createGetTestLogsHandler());
// Init script routes
router.get('/init-script', createGetInitScriptHandler());
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
@@ -163,29 +125,5 @@ export function createWorktreeRoutes(
createRunInitScriptHandler(events)
);
// Discard changes route
router.post(
'/discard-changes',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createDiscardChangesHandler()
);
// List remotes route
router.post(
'/list-remotes',
validatePathParams('worktreePath'),
requireValidWorktree,
createListRemotesHandler()
);
// Add remote route
router.post(
'/add-remote',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createAddRemoteHandler()
);
return router;
}

View File

@@ -1,166 +0,0 @@
/**
* POST /add-remote endpoint - Add a new remote to a git repository
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from '../common.js';
const execFileAsync = promisify(execFile);
/** Maximum allowed length for remote names */
const MAX_REMOTE_NAME_LENGTH = 250;
/** Maximum allowed length for remote URLs */
const MAX_REMOTE_URL_LENGTH = 2048;
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30000;
/**
* Validate remote name - must be alphanumeric with dashes/underscores
* Git remote names have similar restrictions to branch names
*/
function isValidRemoteName(name: string): boolean {
// Remote names should be alphanumeric, may contain dashes, underscores, periods
// Cannot start with a dash or period, cannot be empty
if (!name || name.length === 0 || name.length > MAX_REMOTE_NAME_LENGTH) {
return false;
}
return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
}
/**
* Validate remote URL - basic validation for git remote URLs
* Supports HTTPS, SSH, and git:// protocols
*/
function isValidRemoteUrl(url: string): boolean {
if (!url || url.length === 0 || url.length > MAX_REMOTE_URL_LENGTH) {
return false;
}
// Support common git URL formats:
// - https://github.com/user/repo.git
// - git@github.com:user/repo.git
// - git://github.com/user/repo.git
// - ssh://git@github.com/user/repo.git
const httpsPattern = /^https?:\/\/.+/;
const sshPattern = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:.+/;
const gitProtocolPattern = /^git:\/\/.+/;
const sshProtocolPattern = /^ssh:\/\/.+/;
return (
httpsPattern.test(url) ||
sshPattern.test(url) ||
gitProtocolPattern.test(url) ||
sshProtocolPattern.test(url)
);
}
export function createAddRemoteHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remoteName, remoteUrl } = req.body as {
worktreePath: string;
remoteName: string;
remoteUrl: string;
};
// Validate required fields
const requiredFields = { worktreePath, remoteName, remoteUrl };
for (const [key, value] of Object.entries(requiredFields)) {
if (!value) {
res.status(400).json({ success: false, error: `${key} required` });
return;
}
}
// Validate remote name
if (!isValidRemoteName(remoteName)) {
res.status(400).json({
success: false,
error:
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
});
return;
}
// Validate remote URL
if (!isValidRemoteUrl(remoteUrl)) {
res.status(400).json({
success: false,
error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).',
});
return;
}
// Check if remote already exists
try {
const { stdout: existingRemotes } = await execFileAsync('git', ['remote'], {
cwd: worktreePath,
});
const remoteNames = existingRemotes
.trim()
.split('\n')
.filter((r) => r.trim());
if (remoteNames.includes(remoteName)) {
res.status(400).json({
success: false,
error: `Remote '${remoteName}' already exists`,
code: 'REMOTE_EXISTS',
});
return;
}
} catch (error) {
// If git remote fails, continue with adding the remote. Log for debugging.
logWorktreeError(
error,
'Checking for existing remotes failed, proceeding to add.',
worktreePath
);
}
// Add the remote using execFile with array arguments to prevent command injection
await execFileAsync('git', ['remote', 'add', remoteName, remoteUrl], {
cwd: worktreePath,
});
// Optionally fetch from the new remote to get its branches
let fetchSucceeded = false;
try {
await execFileAsync('git', ['fetch', remoteName, '--quiet'], {
cwd: worktreePath,
timeout: FETCH_TIMEOUT_MS,
});
fetchSucceeded = true;
} catch (fetchError) {
// Fetch failed (maybe offline or invalid URL), but remote was added successfully
logWorktreeError(
fetchError,
`Fetch from new remote '${remoteName}' failed (remote added successfully)`,
worktreePath
);
fetchSucceeded = false;
}
res.json({
success: true,
result: {
remoteName,
remoteUrl,
fetched: fetchSucceeded,
message: fetchSucceeded
? `Successfully added remote '${remoteName}' and fetched its branches`
: `Successfully added remote '${remoteName}' (fetch failed - you may need to fetch manually)`,
},
});
} catch (error) {
const worktreePath = req.body?.worktreePath;
logWorktreeError(error, 'Add remote failed', worktreePath);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -39,10 +39,7 @@ export function createDiffsHandler() {
}
// Git worktrees are stored in project directory
// Sanitize featureId the same way it's sanitized when creating worktrees
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
// Check if worktree exists

View File

@@ -1,112 +0,0 @@
/**
* POST /discard-changes endpoint - Discard all uncommitted changes in a worktree
*
* This performs a destructive operation that:
* 1. Resets staged changes (git reset HEAD)
* 2. Discards modified tracked files (git checkout .)
* 3. Removes untracked files and directories (git clean -fd)
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
export function createDiscardChangesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Check for uncommitted changes first
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
if (!status.trim()) {
res.json({
success: true,
result: {
discarded: false,
message: 'No changes to discard',
},
});
return;
}
// Count the files that will be affected
const lines = status.trim().split('\n').filter(Boolean);
const fileCount = lines.length;
// Get branch name before discarding
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const branchName = branchOutput.trim();
// Discard all changes:
// 1. Reset any staged changes
await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there's nothing staged
});
// 2. Discard changes in tracked files
await execAsync('git checkout .', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there are no tracked changes
});
// 3. Remove untracked files and directories
await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there are no untracked files
});
// Verify all changes were discarded
const { stdout: finalStatus } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
if (finalStatus.trim()) {
// Some changes couldn't be discarded (possibly ignored files or permission issues)
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
res.json({
success: true,
result: {
discarded: true,
filesDiscarded: fileCount - remainingCount,
filesRemaining: remainingCount,
branch: branchName,
message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`,
},
});
} else {
res.json({
success: true,
result: {
discarded: true,
filesDiscarded: fileCount,
filesRemaining: 0,
branch: branchName,
message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
},
});
}
} catch (error) {
logError(error, 'Discard changes failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -37,10 +37,7 @@ export function createFileDiffHandler() {
}
// Git worktrees are stored in project directory
// Sanitize featureId the same way it's sanitized when creating worktrees
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
await secureFs.access(worktreePath);

View File

@@ -10,14 +10,14 @@ 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 { isCursorModel, stripProviderPrefix } from '@automaker/types';
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';
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateCommitMessage');
const execAsync = promisify(exec);
@@ -74,6 +74,33 @@ interface GenerateCommitMessageErrorResponse {
error: string;
}
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
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<void> {
@@ -157,68 +184,67 @@ export function createGenerateCommitMessageHandler(
const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
// Get model from phase settings with provider info
const {
phaseModel: phaseModelEntry,
provider: claudeCompatibleProvider,
credentials,
} = await getPhaseModelWithOverrides(
'commitMessageModel',
settingsService,
worktreePath,
'[GenerateCommitMessage]'
);
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
// 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}`,
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
);
logger.info(`Using model for commit message: ${model}`);
// Get the effective system prompt (custom or default)
const systemPrompt = await getSystemPrompt(settingsService);
// Get provider for the model type
const aiProvider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
let message: string;
// For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation
const effectivePrompt = isCursorModel(model)
? `${systemPrompt}\n\n${userPrompt}`
: userPrompt;
const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt;
// 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}`);
logger.info(`Using ${aiProvider.getName()} provider for model: ${model}`);
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
let responseText = '';
const stream = aiProvider.executeQuery({
prompt: effectivePrompt,
model: bareModel,
cwd: worktreePath,
systemPrompt: effectiveSystemPrompt,
maxTurns: 1,
allowedTools: [],
readOnly: true,
thinkingLevel, // Pass thinking level for extended thinking support
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;
// Wrap with timeout to prevent indefinite hangs
for await (const msg of withTimeout(stream, 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;
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;
}
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if available (some providers return final text here)
responseText = msg.result;
}
}
const message = responseText.trim();
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');

View File

@@ -28,10 +28,7 @@ export function createInfoHandler() {
}
// Check if worktree exists (git worktrees are stored in project directory)
// Sanitize featureId the same way it's sanitized when creating worktrees
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
await secureFs.access(worktreePath);
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {

View File

@@ -110,22 +110,9 @@ export function createListBranchesHandler() {
}
}
// Check if any remotes are configured for this repository
let hasAnyRemotes = false;
try {
const { stdout: remotesOutput } = await execAsync('git remote', {
cwd: worktreePath,
});
hasAnyRemotes = remotesOutput.trim().length > 0;
} catch {
// If git remote fails, assume no remotes
hasAnyRemotes = false;
}
// Get ahead/behind count for current branch and check if remote branch exists
// Get ahead/behind count for current branch
let aheadCount = 0;
let behindCount = 0;
let hasRemoteBranch = false;
try {
// First check if there's a remote tracking branch
const { stdout: upstreamOutput } = await execAsync(
@@ -134,7 +121,6 @@ export function createListBranchesHandler() {
);
if (upstreamOutput.trim()) {
hasRemoteBranch = true;
const { stdout: aheadBehindOutput } = await execAsync(
`git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`,
{ cwd: worktreePath }
@@ -144,18 +130,7 @@ export function createListBranchesHandler() {
behindCount = behind || 0;
}
} catch {
// No upstream branch set - check if the branch exists on any remote
try {
// Check if there's a matching branch on origin (most common remote)
const { stdout: remoteBranchOutput } = await execAsync(
`git ls-remote --heads origin ${currentBranch}`,
{ cwd: worktreePath, timeout: 5000 }
);
hasRemoteBranch = remoteBranchOutput.trim().length > 0;
} catch {
// No remote branch found or origin doesn't exist
hasRemoteBranch = false;
}
// No upstream branch set, that's okay
}
res.json({
@@ -165,8 +140,6 @@ export function createListBranchesHandler() {
branches,
aheadCount,
behindCount,
hasRemoteBranch,
hasAnyRemotes,
},
});
} catch (error) {

View File

@@ -1,127 +0,0 @@
/**
* POST /list-remotes endpoint - List all remotes and their branches
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from '../common.js';
const execAsync = promisify(exec);
interface RemoteBranch {
name: string;
fullRef: string;
}
interface RemoteInfo {
name: string;
url: string;
branches: RemoteBranch[];
}
export function createListRemotesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Get list of remotes
const { stdout: remotesOutput } = await execAsync('git remote -v', {
cwd: worktreePath,
});
// Parse remotes (each remote appears twice - once for fetch, once for push)
const remotesSet = new Map<string, string>();
remotesOutput
.trim()
.split('\n')
.filter((line) => line.trim())
.forEach((line) => {
const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
if (match) {
remotesSet.set(match[1], match[2]);
}
});
// Fetch latest from all remotes (silently, don't fail if offline)
try {
await execAsync('git fetch --all --quiet', {
cwd: worktreePath,
timeout: 15000, // 15 second timeout
});
} catch {
// Ignore fetch errors - we'll use cached remote refs
}
// Get all remote branches
const { stdout: remoteBranchesOutput } = await execAsync(
'git branch -r --format="%(refname:short)"',
{ cwd: worktreePath }
);
// Group branches by remote
const remotesBranches = new Map<string, RemoteBranch[]>();
remotesSet.forEach((_, remoteName) => {
remotesBranches.set(remoteName, []);
});
remoteBranchesOutput
.trim()
.split('\n')
.filter((line) => line.trim())
.forEach((line) => {
const cleanLine = line.trim().replace(/^['"]|['"]$/g, '');
// Skip HEAD pointers like "origin/HEAD"
if (cleanLine.includes('/HEAD')) return;
// Parse remote name from branch ref (e.g., "origin/main" -> "origin")
const slashIndex = cleanLine.indexOf('/');
if (slashIndex === -1) return;
const remoteName = cleanLine.substring(0, slashIndex);
const branchName = cleanLine.substring(slashIndex + 1);
if (remotesBranches.has(remoteName)) {
remotesBranches.get(remoteName)!.push({
name: branchName,
fullRef: cleanLine,
});
}
});
// Build final result
const remotes: RemoteInfo[] = [];
remotesSet.forEach((url, name) => {
remotes.push({
name,
url,
branches: remotesBranches.get(name) || [],
});
});
res.json({
success: true,
result: {
remotes,
},
});
} catch (error) {
const worktreePath = req.body?.worktreePath;
logWorktreeError(error, 'List remotes failed', worktreePath);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -39,15 +39,8 @@ interface GitHubRemoteCacheEntry {
checkedAt: number;
}
interface GitHubPRCacheEntry {
prs: Map<string, WorktreePRInfo>;
fetchedAt: number;
}
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
const githubPRCache = new Map<string, GitHubPRCacheEntry>();
const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const GITHUB_PR_CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes - avoid hitting GitHub on every poll
interface WorktreeInfo {
path: string;
@@ -187,21 +180,9 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
* This also allows detecting PRs that were created outside the app.
*
* Uses cached GitHub remote status to avoid repeated warnings when the
* project doesn't have a GitHub remote configured. Results are cached
* briefly to avoid hammering GitHub on frequent worktree polls.
* project doesn't have a GitHub remote configured.
*/
async function fetchGitHubPRs(
projectPath: string,
forceRefresh = false
): Promise<Map<string, WorktreePRInfo>> {
const now = Date.now();
const cached = githubPRCache.get(projectPath);
// Return cached result if valid and not forcing refresh
if (!forceRefresh && cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) {
return cached.prs;
}
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
const prMap = new Map<string, WorktreePRInfo>();
try {
@@ -244,22 +225,8 @@ async function fetchGitHubPRs(
createdAt: pr.createdAt,
});
}
// Only update cache on successful fetch
githubPRCache.set(projectPath, {
prs: prMap,
fetchedAt: Date.now(),
});
} catch (error) {
// On fetch failure, return stale cached data if available to avoid
// repeated API calls during GitHub API flakiness or temporary outages
if (cached) {
logger.warn(`Failed to fetch GitHub PRs, returning stale cache: ${getErrorMessage(error)}`);
// Extend cache TTL to avoid repeated retries during outages
githubPRCache.set(projectPath, { prs: cached.prs, fetchedAt: Date.now() });
return cached.prs;
}
// No cache available, log warning and return empty map
// Silently fail - PR detection is optional
logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`);
}
@@ -397,17 +364,10 @@ export function createListHandler() {
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
const githubPRs = includeDetails
? await fetchGitHubPRs(projectPath, forceRefreshGitHub)
? await fetchGitHubPRs(projectPath)
: new Map<string, WorktreePRInfo>();
for (const worktree of worktrees) {
// Skip PR assignment for the main worktree - it's not meaningful to show
// PRs on the main branch tab, and can be confusing if someone created
// a PR from main to another branch
if (worktree.isMain) {
continue;
}
const metadata = allMetadata.get(worktree.branch);
const githubPR = githubPRs.get(worktree.branch);
@@ -427,8 +387,8 @@ export function createListHandler() {
);
});
}
} else if (metadata?.pr && metadata.pr.state === 'OPEN') {
// Fall back to stored metadata only if the PR is still OPEN
} else if (metadata?.pr) {
// Fall back to stored metadata (for PRs not in recent GitHub response)
worktree.pr = metadata.pr;
}
}

View File

@@ -1,7 +1,5 @@
/**
* POST /merge endpoint - Merge feature (merge worktree branch into a target branch)
*
* Allows merging a worktree branch into any target branch (defaults to 'main').
* POST /merge endpoint - Merge feature (merge worktree branch into main)
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidProject middleware in index.ts
@@ -10,21 +8,18 @@
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
const logger = createLogger('Worktree');
export function createMergeHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as {
const { projectPath, branchName, worktreePath, options } = req.body as {
projectPath: string;
branchName: string;
worktreePath: string;
targetBranch?: string; // Branch to merge into (defaults to 'main')
options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean };
options?: { squash?: boolean; message?: string };
};
if (!projectPath || !branchName || !worktreePath) {
@@ -35,10 +30,7 @@ export function createMergeHandler() {
return;
}
// Determine the target branch (default to 'main')
const mergeTo = targetBranch || 'main';
// Validate source branch exists
// Validate branch exists
try {
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
} catch {
@@ -49,44 +41,12 @@ export function createMergeHandler() {
return;
}
// Validate target branch exists
try {
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
} catch {
res.status(400).json({
success: false,
error: `Target branch "${mergeTo}" does not exist`,
});
return;
}
// Merge the feature branch into the target branch
// Merge the feature branch
const mergeCmd = options?.squash
? `git merge --squash ${branchName}`
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`;
try {
await execAsync(mergeCmd, { cwd: projectPath });
} catch (mergeError: unknown) {
// Check if this is a merge conflict
const err = mergeError as { stdout?: string; stderr?: string; message?: string };
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
const hasConflicts =
output.includes('CONFLICT') || output.includes('Automatic merge failed');
if (hasConflicts) {
// Return conflict-specific error message that frontend can detect
res.status(409).json({
success: false,
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
hasConflicts: true,
});
return;
}
// Re-throw non-conflict errors to be handled by outer catch
throw mergeError;
}
await execAsync(mergeCmd, { cwd: projectPath });
// If squash merge, need to commit
if (options?.squash) {
@@ -95,46 +55,17 @@ export function createMergeHandler() {
});
}
// Optionally delete the worktree and branch after merging
let worktreeDeleted = false;
let branchDeleted = false;
if (options?.deleteWorktreeAndBranch) {
// Remove the worktree
try {
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
worktreeDeleted = true;
} catch {
// Try with prune if remove fails
try {
await execGitCommand(['worktree', 'prune'], projectPath);
worktreeDeleted = true;
} catch {
logger.warn(`Failed to remove worktree: ${worktreePath}`);
}
}
// Delete the branch (but not main/master)
if (branchName !== 'main' && branchName !== 'master') {
if (!isValidBranchName(branchName)) {
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
} else {
try {
await execGitCommand(['branch', '-D', branchName], projectPath);
branchDeleted = true;
} catch {
logger.warn(`Failed to delete branch: ${branchName}`);
}
}
}
// Clean up worktree and branch
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: projectPath,
});
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
} catch {
// Cleanup errors are non-fatal
}
res.json({
success: true,
mergedBranch: branchName,
targetBranch: mergeTo,
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
});
res.json({ success: true, mergedBranch: branchName });
} catch (error) {
logError(error, 'Merge worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -1,181 +0,0 @@
/**
* Terminal endpoints for opening worktree directories in terminals
*
* POST /open-in-terminal - Open in system default terminal (integrated)
* GET /available-terminals - List all available external terminals
* GET /default-terminal - Get the default external terminal
* POST /refresh-terminals - Clear terminal cache and re-detect
* POST /open-in-external-terminal - Open a directory in an external terminal
*/
import type { Request, Response } from 'express';
import { isAbsolute } from 'path';
import {
openInTerminal,
clearTerminalCache,
detectAllTerminals,
detectDefaultTerminal,
openInExternalTerminal,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('open-in-terminal');
/**
* Handler to open in system default terminal (integrated terminal behavior)
*/
export function createOpenInTerminalHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath || typeof worktreePath !== 'string') {
res.status(400).json({
success: false,
error: 'worktreePath required and must be a string',
});
return;
}
// Security: Validate that worktreePath is an absolute path
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}
// Use the platform utility to open in terminal
const result = await openInTerminal(worktreePath);
res.json({
success: true,
result: {
message: `Opened terminal in ${worktreePath}`,
terminalName: result.terminalName,
},
});
} catch (error) {
logError(error, 'Open in terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to get all available external terminals
*/
export function createGetAvailableTerminalsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const terminals = await detectAllTerminals();
res.json({
success: true,
result: {
terminals,
},
});
} catch (error) {
logError(error, 'Get available terminals failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to get the default external terminal
*/
export function createGetDefaultTerminalHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const terminal = await detectDefaultTerminal();
res.json({
success: true,
result: terminal
? {
terminalId: terminal.id,
terminalName: terminal.name,
terminalCommand: terminal.command,
}
: null,
});
} catch (error) {
logError(error, 'Get default terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to refresh the terminal cache and re-detect available terminals
* Useful when the user has installed/uninstalled terminals
*/
export function createRefreshTerminalsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Clear the cache
clearTerminalCache();
// Re-detect terminals (this will repopulate the cache)
const terminals = await detectAllTerminals();
logger.info(`Terminal cache refreshed, found ${terminals.length} terminals`);
res.json({
success: true,
result: {
terminals,
message: `Found ${terminals.length} available external terminals`,
},
});
} catch (error) {
logError(error, 'Refresh terminals failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to open a directory in an external terminal
*/
export function createOpenInExternalTerminalHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, terminalId } = req.body as {
worktreePath: string;
terminalId?: string;
};
if (!worktreePath || typeof worktreePath !== 'string') {
res.status(400).json({
success: false,
error: 'worktreePath required and must be a string',
});
return;
}
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}
const result = await openInExternalTerminal(worktreePath, terminalId);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${result.terminalName}`,
terminalName: result.terminalName,
},
});
} catch (error) {
logError(error, 'Open in external terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -15,10 +15,9 @@ const execAsync = promisify(exec);
export function createPushHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, force, remote } = req.body as {
const { worktreePath, force } = req.body as {
worktreePath: string;
force?: boolean;
remote?: string;
};
if (!worktreePath) {
@@ -35,18 +34,15 @@ export function createPushHandler() {
});
const branchName = branchOutput.trim();
// Use specified remote or default to 'origin'
const targetRemote = remote || 'origin';
// Push the branch
const forceFlag = force ? '--force' : '';
try {
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
await execAsync(`git push -u origin ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
} catch {
// Try setting upstream
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
await execAsync(`git push --set-upstream origin ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
}
@@ -56,7 +52,7 @@ export function createPushHandler() {
result: {
branch: branchName,
pushed: true,
message: `Successfully pushed ${branchName} to ${targetRemote}`,
message: `Successfully pushed ${branchName} to origin`,
},
});
} catch (error) {

View File

@@ -1,22 +1,16 @@
/**
* POST /start-dev endpoint - Start a dev server for a worktree
*
* Spins up a development server in the worktree directory on a unique port,
* allowing preview of the worktree's changes without affecting the main dev server.
*
* If a custom devCommand is configured in project settings, it will be used.
* Otherwise, auto-detection based on package manager (npm/yarn/pnpm/bun run dev) is used.
* Spins up a development server (npm run dev) in the worktree directory
* on a unique port, allowing preview of the worktree's changes without
* affecting the main dev server.
*/
import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import { getDevServerService } from '../../../services/dev-server-service.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('start-dev');
export function createStartDevHandler(settingsService?: SettingsService) {
export function createStartDevHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath } = req.body as {
@@ -40,25 +34,8 @@ export function createStartDevHandler(settingsService?: SettingsService) {
return;
}
// Get custom dev command from project settings (if configured)
let customCommand: string | undefined;
if (settingsService) {
const projectSettings = await settingsService.getProjectSettings(projectPath);
const devCommand = projectSettings?.devCommand?.trim();
if (devCommand) {
customCommand = devCommand;
logger.debug(`Using custom dev command from project settings: ${customCommand}`);
} else {
logger.debug('No custom dev command configured, using auto-detection');
}
}
const devServerService = getDevServerService();
const result = await devServerService.startDevServer(
projectPath,
worktreePath,
customCommand
);
const result = await devServerService.startDevServer(projectPath, worktreePath);
if (result.success && result.result) {
res.json({

View File

@@ -1,92 +0,0 @@
/**
* POST /start-tests endpoint - Start tests for a worktree
*
* Runs the test command configured in project settings.
* If no testCommand is configured, returns an error.
*/
import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import { getTestRunnerService } from '../../../services/test-runner-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createStartTestsHandler(settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body;
// Validate request body
if (!body || typeof body !== 'object') {
res.status(400).json({
success: false,
error: 'Request body must be an object',
});
return;
}
const worktreePath = typeof body.worktreePath === 'string' ? body.worktreePath : undefined;
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : undefined;
const testFile = typeof body.testFile === 'string' ? body.testFile : undefined;
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required and must be a string',
});
return;
}
// Get project settings to find the test command
// Use projectPath if provided, otherwise use worktreePath
const settingsPath = projectPath || worktreePath;
if (!settingsService) {
res.status(500).json({
success: false,
error: 'Settings service not available',
});
return;
}
const projectSettings = await settingsService.getProjectSettings(settingsPath);
const testCommand = projectSettings?.testCommand;
if (!testCommand) {
res.status(400).json({
success: false,
error:
'No test command configured. Please configure a test command in Project Settings > Testing Configuration.',
});
return;
}
const testRunnerService = getTestRunnerService();
const result = await testRunnerService.startTests(worktreePath, {
command: testCommand,
testFile,
});
if (result.success && result.result) {
res.json({
success: true,
result: {
sessionId: result.result.sessionId,
worktreePath: result.result.worktreePath,
command: result.result.command,
status: result.result.status,
testFile: result.result.testFile,
message: result.result.message,
},
});
} else {
res.status(400).json({
success: false,
error: result.error || 'Failed to start tests',
});
}
} catch (error) {
logError(error, 'Start tests failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -28,10 +28,7 @@ export function createStatusHandler() {
}
// Git worktrees are stored in project directory
// Sanitize featureId the same way it's sanitized when creating worktrees
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
await secureFs.access(worktreePath);

View File

@@ -1,58 +0,0 @@
/**
* POST /stop-tests endpoint - Stop a running test session
*
* Stops the test runner process for a specific session,
* cancelling any ongoing tests and freeing up resources.
*/
import type { Request, Response } from 'express';
import { getTestRunnerService } from '../../../services/test-runner-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createStopTestsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body;
// Validate request body
if (!body || typeof body !== 'object') {
res.status(400).json({
success: false,
error: 'Request body must be an object',
});
return;
}
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined;
if (!sessionId) {
res.status(400).json({
success: false,
error: 'sessionId is required and must be a string',
});
return;
}
const testRunnerService = getTestRunnerService();
const result = await testRunnerService.stopTests(sessionId);
if (result.success && result.result) {
res.json({
success: true,
result: {
sessionId: result.result.sessionId,
message: result.result.message,
},
});
} else {
res.status(400).json({
success: false,
error: result.error || 'Failed to stop tests',
});
}
} catch (error) {
logError(error, 'Stop tests failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,160 +0,0 @@
/**
* GET /test-logs endpoint - Get buffered logs for a test runner session
*
* Returns the scrollback buffer containing historical log output for a test run.
* Used by clients to populate the log panel on initial connection
* before subscribing to real-time updates via WebSocket.
*
* Query parameters:
* - worktreePath: Path to the worktree (optional if sessionId provided)
* - sessionId: Specific test session ID (optional, uses active session if not provided)
*/
import type { Request, Response } from 'express';
import { getTestRunnerService } from '../../../services/test-runner-service.js';
import { getErrorMessage, logError } from '../common.js';
interface SessionInfo {
sessionId: string;
worktreePath?: string;
command?: string;
testFile?: string;
exitCode?: number | null;
}
interface OutputResult {
sessionId: string;
status: string;
output: string;
startedAt: string;
finishedAt?: string | null;
}
function buildLogsResponse(session: SessionInfo, output: OutputResult) {
return {
success: true,
result: {
sessionId: session.sessionId,
worktreePath: session.worktreePath,
command: session.command,
status: output.status,
testFile: session.testFile,
logs: output.output,
startedAt: output.startedAt,
finishedAt: output.finishedAt,
exitCode: session.exitCode ?? null,
},
};
}
export function createGetTestLogsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, sessionId } = req.query as {
worktreePath?: string;
sessionId?: string;
};
const testRunnerService = getTestRunnerService();
// If sessionId is provided, get logs for that specific session
if (sessionId) {
const result = testRunnerService.getSessionOutput(sessionId);
if (result.success && result.result) {
const session = testRunnerService.getSession(sessionId);
res.json(
buildLogsResponse(
{
sessionId: result.result.sessionId,
worktreePath: session?.worktreePath,
command: session?.command,
testFile: session?.testFile,
exitCode: session?.exitCode,
},
result.result
)
);
} else {
res.status(404).json({
success: false,
error: result.error || 'Failed to get test logs',
});
}
return;
}
// If worktreePath is provided, get logs for the active session
if (worktreePath) {
const activeSession = testRunnerService.getActiveSession(worktreePath);
if (activeSession) {
const result = testRunnerService.getSessionOutput(activeSession.id);
if (result.success && result.result) {
res.json(
buildLogsResponse(
{
sessionId: activeSession.id,
worktreePath: activeSession.worktreePath,
command: activeSession.command,
testFile: activeSession.testFile,
exitCode: activeSession.exitCode,
},
result.result
)
);
} else {
res.status(404).json({
success: false,
error: result.error || 'Failed to get test logs',
});
}
} else {
// No active session - check for most recent session for this worktree
const sessions = testRunnerService.listSessions(worktreePath);
if (sessions.result.sessions.length > 0) {
// Get the most recent session (list is not sorted, so find it)
const mostRecent = sessions.result.sessions.reduce((latest, current) => {
const latestTime = new Date(latest.startedAt).getTime();
const currentTime = new Date(current.startedAt).getTime();
return currentTime > latestTime ? current : latest;
});
const result = testRunnerService.getSessionOutput(mostRecent.sessionId);
if (result.success && result.result) {
res.json(
buildLogsResponse(
{
sessionId: mostRecent.sessionId,
worktreePath: mostRecent.worktreePath,
command: mostRecent.command,
testFile: mostRecent.testFile,
exitCode: mostRecent.exitCode,
},
result.result
)
);
return;
}
}
res.status(404).json({
success: false,
error: 'No test sessions found for this worktree',
});
}
return;
}
// Neither sessionId nor worktreePath provided
res.status(400).json({
success: false,
error: 'Either worktreePath or sessionId query parameter is required',
});
} catch (error) {
logError(error, 'Get test logs failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -29,7 +29,6 @@ import {
getSkillsConfiguration,
getSubagentsConfiguration,
getCustomSubagents,
getProviderByModelId,
} from '../lib/settings-helpers.js';
interface Message {
@@ -275,30 +274,6 @@ export class AgentService {
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
: undefined;
// Get credentials for API calls
const credentials = await this.settingsService?.getCredentials();
// Try to find a provider for the model (if it's a provider model like "GLM-4.7")
// This allows users to select provider models in the Agent Runner UI
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
let providerResolvedModel: string | undefined;
const requestedModel = model || session.model;
if (requestedModel && this.settingsService) {
const providerResult = await getProviderByModelId(
requestedModel,
this.settingsService,
'[AgentService]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
providerResolvedModel = providerResult.resolvedModel;
this.logger.info(
`[AgentService] Using provider "${providerResult.provider.name}" for model "${requestedModel}"` +
(providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
);
}
}
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
// Use the user's message as task context for smart memory selection
const contextResult = await loadContextFiles({
@@ -324,16 +299,10 @@ export class AgentService {
// Use thinking level and reasoning effort from request, or fall back to session's stored values
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort;
// When using a provider model, use the resolved Claude model (from mapsToClaudeModel)
// e.g., "GLM-4.5-Air" -> "claude-haiku-4-5"
const modelForSdk = providerResolvedModel || model;
const sessionModelForSdk = providerResolvedModel ? undefined : session.model;
const sdkOptions = createChatOptions({
cwd: effectiveWorkDir,
model: modelForSdk,
sessionModel: sessionModelForSdk,
model: model,
sessionModel: session.model,
systemPrompt: combinedSystemPrompt,
abortController: session.abortController!,
autoLoadClaudeMd,
@@ -409,8 +378,6 @@ export class AgentService {
agents: customSubagents, // Pass custom subagents for task delegation
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
credentials, // Pass credentials for resolving 'credentials' apiKeySource
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration (GLM, MiniMax, etc.)
};
// Build prompt content with images

File diff suppressed because it is too large Load Diff

View File

@@ -468,41 +468,10 @@ export class ClaudeUsageService {
/**
* Strip ANSI escape codes from text
* Handles CSI, OSC, and other common ANSI sequences
*/
private stripAnsiCodes(text: string): string {
// First strip ANSI sequences (colors, etc) and handle CR
// eslint-disable-next-line no-control-regex
let clean = text
// CSI sequences: ESC [ ... (letter or @)
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '')
// OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '')
// Other ESC sequences: ESC (letter)
.replace(/\x1B[A-Za-z]/g, '')
// Carriage returns: replace with newline to avoid concatenation
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');
// Handle backspaces (\x08) by applying them
// If we encounter a backspace, remove the character before it
while (clean.includes('\x08')) {
clean = clean.replace(/[^\x08]\x08/, '');
clean = clean.replace(/^\x08+/, '');
}
// Explicitly strip known "Synchronized Output" and "Window Title" garbage
// even if ESC is missing (seen in some environments)
clean = clean
.replace(/\[\?2026[hl]/g, '') // CSI ? 2026 h/l
.replace(/\]0;[^\x07]*\x07/g, '') // OSC 0; Title BEL
.replace(/\]0;.*?(\[\?|$)/g, ''); // OSC 0; Title ... (unterminated or hit next sequence)
// Strip remaining non-printable control characters (except newline \n)
// ASCII 0-8, 11-31, 127
clean = clean.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, '');
return clean;
return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
}
/**
@@ -581,7 +550,7 @@ export class ClaudeUsageService {
sectionLabel: string,
type: string
): { percentage: number; resetTime: string; resetText: string } {
let percentage: number | null = null;
let percentage = 0;
let resetTime = this.getDefaultResetTime(type);
let resetText = '';
@@ -595,7 +564,7 @@ export class ClaudeUsageService {
}
if (sectionIndex === -1) {
return { percentage: 0, resetTime, resetText };
return { percentage, resetTime, resetText };
}
// Look at the lines following the section header (within a window of 5 lines)
@@ -603,8 +572,7 @@ export class ClaudeUsageService {
for (const line of searchWindow) {
// Extract percentage - only take the first match (avoid picking up next section's data)
// Use null to track "not found" since 0% is a valid percentage (100% left = 0% used)
if (percentage === null) {
if (percentage === 0) {
const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i);
if (percentMatch) {
const value = parseInt(percentMatch[1], 10);
@@ -616,31 +584,18 @@ export class ClaudeUsageService {
// Extract reset time - only take the first match
if (!resetText && line.toLowerCase().includes('reset')) {
// Only extract the part starting from "Resets" (or "Reset") to avoid garbage prefixes
const match = line.match(/(Resets?.*)$/i);
// If regex fails despite 'includes', likely a complex string issues - verify match before using line
// Only fallback to line if it's reasonably short/clean, otherwise skip it to avoid showing garbage
if (match) {
resetText = match[1];
}
resetText = line;
}
}
// Parse the reset time if we found one
if (resetText) {
// Clean up resetText: remove percentage info if it was matched on the same line
// e.g. "46%used Resets5:59pm" -> " Resets5:59pm"
resetText = resetText.replace(/(\d{1,3})\s*%\s*(left|used|remaining)/i, '').trim();
// Ensure space after "Resets" if missing (e.g. "Resets5:59pm" -> "Resets 5:59pm")
resetText = resetText.replace(/(resets?)(\d)/i, '$1 $2');
resetTime = this.parseResetTime(resetText, type);
// Strip timezone like "(Asia/Dubai)" from the display text
resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim();
}
return { percentage: percentage ?? 0, resetTime, resetText };
return { percentage, resetTime, resetText };
}
/**
@@ -669,7 +624,7 @@ export class ClaudeUsageService {
}
// Try to parse simple time-only format: "Resets 11am" or "Resets 3pm"
const simpleTimeMatch = text.match(/resets\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
if (simpleTimeMatch) {
let hours = parseInt(simpleTimeMatch[1], 10);
const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0;
@@ -694,11 +649,8 @@ export class ClaudeUsageService {
}
// Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm"
// The regex explicitly matches only valid 3-letter month abbreviations to avoid
// matching words like "Resets" when there's no space separator.
// Optional "resets\s*" prefix handles cases with or without space after "Resets"
const dateMatch = text.match(
/(?:resets\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
/([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
);
if (dateMatch) {
const monthName = dateMatch[1];

File diff suppressed because it is too large Load Diff

View File

@@ -1,80 +0,0 @@
/**
* Copilot Connection Service
*
* Handles the connection and disconnection of Copilot CLI to the app.
* Uses a marker file to track the disconnected state.
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { createLogger } from '@automaker/utils';
import { COPILOT_DISCONNECTED_MARKER_FILE } from '../routes/setup/common.js';
const logger = createLogger('CopilotConnectionService');
/**
* Get the path to the disconnected marker file
*/
function getMarkerPath(projectRoot?: string): string {
const root = projectRoot || process.cwd();
const automakerDir = path.join(root, '.automaker');
return path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE);
}
/**
* Connect Copilot CLI to the app by removing the disconnected marker
*
* @param projectRoot - Optional project root directory (defaults to cwd)
* @returns Promise that resolves when the connection is established
*/
export async function connectCopilot(projectRoot?: string): Promise<void> {
const markerPath = getMarkerPath(projectRoot);
try {
await fs.unlink(markerPath);
logger.info('Copilot CLI connected to app (marker removed)');
} catch (error) {
// File doesn't exist - that's fine, Copilot is already connected
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('Failed to remove disconnected marker:', error);
throw error;
}
logger.debug('Copilot already connected (no marker file found)');
}
}
/**
* Disconnect Copilot CLI from the app by creating the disconnected marker
*
* @param projectRoot - Optional project root directory (defaults to cwd)
* @returns Promise that resolves when the disconnection is complete
*/
export async function disconnectCopilot(projectRoot?: string): Promise<void> {
const root = projectRoot || process.cwd();
const automakerDir = path.join(root, '.automaker');
const markerPath = path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE);
// Ensure .automaker directory exists
await fs.mkdir(automakerDir, { recursive: true });
// Create the disconnection marker
await fs.writeFile(markerPath, 'Copilot CLI disconnected from app');
logger.info('Copilot CLI disconnected from app (marker created)');
}
/**
* Check if Copilot CLI is connected (not disconnected)
*
* @param projectRoot - Optional project root directory (defaults to cwd)
* @returns Promise that resolves to true if connected, false if disconnected
*/
export async function isCopilotConnected(projectRoot?: string): Promise<boolean> {
const markerPath = getMarkerPath(projectRoot);
try {
await fs.access(markerPath);
return false; // Marker exists = disconnected
} catch {
return true; // Marker doesn't exist = connected
}
}

View File

@@ -273,56 +273,12 @@ class DevServerService {
}
}
/**
* Parse a custom command string into cmd and args
* Handles quoted strings with spaces (e.g., "my command" arg1 arg2)
*/
private parseCustomCommand(command: string): { cmd: string; args: string[] } {
const tokens: string[] = [];
let current = '';
let inQuote = false;
let quoteChar = '';
for (let i = 0; i < command.length; i++) {
const char = command[i];
if (inQuote) {
if (char === quoteChar) {
inQuote = false;
} else {
current += char;
}
} else if (char === '"' || char === "'") {
inQuote = true;
quoteChar = char;
} else if (char === ' ') {
if (current) {
tokens.push(current);
current = '';
}
} else {
current += char;
}
}
if (current) {
tokens.push(current);
}
const [cmd, ...args] = tokens;
return { cmd: cmd || '', args };
}
/**
* Start a dev server for a worktree
* @param projectPath - The project root path
* @param worktreePath - The worktree directory path
* @param customCommand - Optional custom command to run instead of auto-detected dev command
*/
async startDevServer(
projectPath: string,
worktreePath: string,
customCommand?: string
worktreePath: string
): Promise<{
success: boolean;
result?: {
@@ -355,41 +311,22 @@ class DevServerService {
};
}
// Determine the dev command to use
let devCommand: { cmd: string; args: string[] };
// Check for package.json
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Normalize custom command: trim whitespace and treat empty strings as undefined
const normalizedCustomCommand = customCommand?.trim();
if (normalizedCustomCommand) {
// Use the provided custom command
devCommand = this.parseCustomCommand(normalizedCustomCommand);
if (!devCommand.cmd) {
return {
success: false,
error: 'Invalid custom command: command cannot be empty',
};
}
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
} else {
// Check for package.json when auto-detecting
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Get dev command from package manager detection
const detectedCommand = await this.getDevCommand(worktreePath);
if (!detectedCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
}
devCommand = detectedCommand;
// Get dev command
const devCommand = await this.getDevCommand(worktreePath);
if (!devCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
}
// Find available port

Some files were not shown because too many files have changed in this diff Show More