Merge main into feat/cursor-cli-integration

Carefully merged latest changes from main branch into the Cursor CLI integration
branch. This merge brings in important improvements and fixes while preserving
all Cursor-related functionality.

Key changes from main:
- Sandbox mode security improvements and cloud storage compatibility
- Version-based settings migrations (v2 schema)
- Port configuration centralization
- System paths utilities for CLI detection
- Enhanced error handling in HttpApiClient
- Windows MCP process cleanup fixes
- New validation and build commands
- GitHub issue templates and release process improvements

Resolved conflicts in:
- apps/server/src/routes/context/routes/describe-image.ts
  (Combined Cursor provider routing with secure-fs imports)
- apps/server/src/services/auto-mode-service.ts
  (Merged failure tracking with raw output logging)
- apps/server/tests/unit/services/terminal-service.test.ts
  (Updated to async tests with systemPathExists mocking)
- libs/platform/src/index.ts
  (Combined WSL utilities with system-paths exports)
- libs/types/src/settings.ts
  (Merged DEFAULT_PHASE_MODELS with SETTINGS_VERSION constants)

All Cursor CLI integration features remain intact including:
- CursorProvider and CliProvider base class
- Phase-based model configuration
- Provider registry and factory patterns
- WSL support for Windows
- Model override UI components
- Cursor-specific settings and configurations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2026-01-01 18:03:48 +01:00
100 changed files with 4782 additions and 3239 deletions

View File

@@ -4,7 +4,7 @@
import { createLogger } from '@automaker/utils';
import path from 'path';
import fs from 'fs/promises';
import { secureFs } from '@automaker/platform';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('Setup');
@@ -35,36 +35,13 @@ export function getAllApiKeys(): Record<string, string> {
/**
* Helper to persist API keys to .env file
* Uses centralized secureFs.writeEnvKey for path validation
*/
export async function persistApiKeyToEnv(key: string, value: string): Promise<void> {
const envPath = path.join(process.cwd(), '.env');
try {
let envContent = '';
try {
envContent = await fs.readFile(envPath, 'utf-8');
} catch {
// .env file doesn't exist, we'll create it
}
// Parse existing env content
const lines = envContent.split('\n');
const keyRegex = new RegExp(`^${key}=`);
let found = false;
const newLines = lines.map((line) => {
if (keyRegex.test(line)) {
found = true;
return `${key}=${value}`;
}
return line;
});
if (!found) {
// Add the key at the end
newLines.push(`${key}=${value}`);
}
await fs.writeFile(envPath, newLines.join('\n'));
await secureFs.writeEnvKey(envPath, key, value);
logger.info(`[Setup] Persisted ${key} to .env file`);
} catch (error) {
logger.error(`[Setup] Failed to persist ${key} to .env:`, error);

View File

@@ -4,9 +4,7 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform';
import { getApiKey } from './common.js';
const execAsync = promisify(exec);
@@ -37,42 +35,25 @@ export async function getClaudeStatus() {
// Version command might not be available
}
} catch {
// Not in PATH, try common locations based on platform
const commonPaths = isWindows
? (() => {
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return [
// Windows-specific paths
path.join(os.homedir(), '.local', 'bin', 'claude.exe'),
path.join(appData, 'npm', 'claude.cmd'),
path.join(appData, 'npm', 'claude'),
path.join(appData, '.npm-global', 'bin', 'claude.cmd'),
path.join(appData, '.npm-global', 'bin', 'claude'),
];
})()
: [
// Unix (Linux/macOS) paths
path.join(os.homedir(), '.local', 'bin', 'claude'),
path.join(os.homedir(), '.claude', 'local', 'claude'),
'/usr/local/bin/claude',
path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
];
// Not in PATH, try common locations from centralized system paths
const commonPaths = getClaudeCliPaths();
for (const p of commonPaths) {
try {
await fs.access(p);
cliPath = p;
installed = true;
method = 'local';
if (await systemPathAccess(p)) {
cliPath = p;
installed = true;
method = 'local';
// Get version from this path
try {
const { stdout: versionOut } = await execAsync(`"${p}" --version`);
version = versionOut.trim();
} catch {
// Version command might not be available
// Get version from this path
try {
const { stdout: versionOut } = await execAsync(`"${p}" --version`);
version = versionOut.trim();
} catch {
// Version command might not be available
}
break;
}
break;
} catch {
// Not found at this path
}
@@ -82,7 +63,7 @@ export async function getClaudeStatus() {
// Check authentication - detect all possible auth methods
// Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth
// apiKeys.anthropic stores direct API keys for pay-per-use
let auth = {
const auth = {
authenticated: false,
method: 'none' as string,
hasCredentialsFile: false,
@@ -97,76 +78,36 @@ export async function getClaudeStatus() {
hasRecentActivity: false,
};
const claudeDir = path.join(os.homedir(), '.claude');
// Use centralized system paths to check Claude authentication indicators
const indicators = await getClaudeAuthIndicators();
// Check for recent Claude CLI activity - indicates working authentication
// The stats-cache.json file is only populated when the CLI is working properly
const statsCachePath = path.join(claudeDir, 'stats-cache.json');
try {
const statsContent = await fs.readFile(statsCachePath, 'utf-8');
const stats = JSON.parse(statsContent);
// Check for recent activity (indicates working authentication)
if (indicators.hasStatsCacheWithActivity) {
auth.hasRecentActivity = true;
auth.hasCliAuth = true;
auth.authenticated = true;
auth.method = 'cli_authenticated';
}
// Check if there's any activity (which means the CLI is authenticated and working)
if (stats.dailyActivity && stats.dailyActivity.length > 0) {
auth.hasRecentActivity = true;
auth.hasCliAuth = true;
// Check for settings + sessions (indicates CLI is set up)
if (!auth.hasCliAuth && indicators.hasSettingsFile && indicators.hasProjectsSessions) {
auth.hasCliAuth = true;
auth.authenticated = true;
auth.method = 'cli_authenticated';
}
// Check credentials file
if (indicators.hasCredentialsFile && indicators.credentials) {
auth.hasCredentialsFile = true;
if (indicators.credentials.hasOAuthToken) {
auth.hasStoredOAuthToken = true;
auth.oauthTokenValid = true;
auth.authenticated = true;
auth.method = 'cli_authenticated';
}
} catch {
// Stats file doesn't exist or is invalid
}
// Check for settings.json - indicates CLI has been set up
const settingsPath = path.join(claudeDir, 'settings.json');
try {
await fs.access(settingsPath);
// If settings exist but no activity, CLI might be set up but not authenticated
if (!auth.hasCliAuth) {
// Try to check for other indicators of auth
const sessionsDir = path.join(claudeDir, 'projects');
try {
const sessions = await fs.readdir(sessionsDir);
if (sessions.length > 0) {
auth.hasCliAuth = true;
auth.authenticated = true;
auth.method = 'cli_authenticated';
}
} catch {
// Sessions directory doesn't exist
}
}
} catch {
// Settings file doesn't exist
}
// Check for credentials file (OAuth tokens from claude login)
// Note: Claude CLI may use ".credentials.json" (hidden) or "credentials.json" depending on version/platform
const credentialsPaths = [
path.join(claudeDir, '.credentials.json'),
path.join(claudeDir, 'credentials.json'),
];
for (const credentialsPath of credentialsPaths) {
try {
const credentialsContent = await fs.readFile(credentialsPath, 'utf-8');
const credentials = JSON.parse(credentialsContent);
auth.hasCredentialsFile = true;
// Check what type of token is in credentials
if (credentials.oauth_token || credentials.access_token) {
auth.hasStoredOAuthToken = true;
auth.oauthTokenValid = true;
auth.authenticated = true;
auth.method = 'oauth_token'; // Stored OAuth token from credentials file
} else if (credentials.api_key) {
auth.apiKeyValid = true;
auth.authenticated = true;
auth.method = 'api_key'; // Stored API key in credentials file
}
break; // Found and processed credentials file
} catch {
// No credentials file at this path or invalid format
auth.method = 'oauth_token';
} else if (indicators.credentials.hasApiKey) {
auth.apiKeyValid = true;
auth.authenticated = true;
auth.method = 'api_key';
}
}
@@ -174,21 +115,21 @@ export async function getClaudeStatus() {
if (auth.hasEnvApiKey) {
auth.authenticated = true;
auth.apiKeyValid = true;
auth.method = 'api_key_env'; // API key from ANTHROPIC_API_KEY env var
auth.method = 'api_key_env';
}
// In-memory stored OAuth token (from setup wizard - subscription auth)
if (!auth.authenticated && getApiKey('anthropic_oauth_token')) {
auth.authenticated = true;
auth.oauthTokenValid = true;
auth.method = 'oauth_token'; // Stored OAuth token from setup wizard
auth.method = 'oauth_token';
}
// In-memory stored API key (from settings UI - pay-per-use)
if (!auth.authenticated && getApiKey('anthropic')) {
auth.authenticated = true;
auth.apiKeyValid = true;
auth.method = 'api_key'; // Manually stored API key
auth.method = 'api_key';
}
return {

View File

@@ -5,40 +5,22 @@
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import path from 'path';
import fs from 'fs/promises';
import { secureFs } from '@automaker/platform';
const logger = createLogger('Setup');
// In-memory storage reference (imported from common.ts pattern)
// We need to modify common.ts to export a deleteApiKey function
import { setApiKey } from '../common.js';
/**
* Remove an API key from the .env file
* Uses centralized secureFs.removeEnvKey for path validation
*/
async function removeApiKeyFromEnv(key: string): Promise<void> {
const envPath = path.join(process.cwd(), '.env');
try {
let envContent = '';
try {
envContent = await fs.readFile(envPath, 'utf-8');
} catch {
// .env file doesn't exist, nothing to delete
return;
}
// Parse existing env content and remove the key
const lines = envContent.split('\n');
const keyRegex = new RegExp(`^${key}=`);
const newLines = lines.filter((line) => !keyRegex.test(line));
// Remove empty lines at the end
while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') {
newLines.pop();
}
await fs.writeFile(envPath, newLines.join('\n') + (newLines.length > 0 ? '\n' : ''));
await secureFs.removeEnvKey(envPath, key);
logger.info(`[Setup] Removed ${key} from .env file`);
} catch (error) {
logger.error(`[Setup] Failed to remove ${key} from .env:`, error);

View File

@@ -5,27 +5,14 @@
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { getGitHubCliPaths, getExtendedPath, systemPathAccess } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations
const extendedPath = [
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
]
.filter(Boolean)
.join(':');
const execEnv = {
...process.env,
PATH: extendedPath,
PATH: getExtendedPath(),
};
export interface GhStatus {
@@ -55,25 +42,16 @@ async function getGhStatus(): Promise<GhStatus> {
status.path = stdout.trim().split(/\r?\n/)[0];
status.installed = true;
} catch {
// gh not in PATH, try common locations
const commonPaths = isWindows
? [
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'),
path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'),
]
: [
'/opt/homebrew/bin/gh',
'/usr/local/bin/gh',
path.join(os.homedir(), '.local', 'bin', 'gh'),
'/home/linuxbrew/.linuxbrew/bin/gh',
];
// gh not in PATH, try common locations from centralized system paths
const commonPaths = getGitHubCliPaths();
for (const p of commonPaths) {
try {
await fs.access(p);
status.path = p;
status.installed = true;
break;
if (await systemPathAccess(p)) {
status.path = p;
status.installed = true;
break;
}
} catch {
// Not found at this path
}