Merge remote-tracking branch 'origin/v0.13.0rc' into feature/claude-code-max-glm-api-keys

This commit is contained in:
Stefan de Vogelaere
2026-01-19 14:42:15 +01:00
200 changed files with 6671 additions and 1311 deletions

View File

@@ -6,10 +6,16 @@
* - Passes through Cursor models unchanged (handled by CursorProvider)
* - Provides default models per provider
* - Handles multiple model sources with priority
*
* With canonical model IDs:
* - Cursor: cursor-auto, cursor-composer-1, cursor-gpt-5.2
* - OpenCode: opencode-big-pickle, opencode-grok-code
* - Claude: claude-haiku, claude-sonnet, claude-opus (also supports legacy aliases)
*/
import {
CLAUDE_MODEL_MAP,
CLAUDE_CANONICAL_MAP,
CURSOR_MODEL_MAP,
CODEX_MODEL_MAP,
DEFAULT_MODELS,
@@ -17,6 +23,7 @@ import {
isCursorModel,
isOpencodeModel,
stripProviderPrefix,
migrateModelId,
type PhaseModelEntry,
type ThinkingLevel,
} from '@automaker/types';
@@ -29,7 +36,11 @@ const OPENAI_O_SERIES_ALLOWED_MODELS = new Set<string>();
/**
* Resolve a model key/alias to a full model string
*
* @param modelKey - Model key (e.g., "opus", "cursor-composer-1", "claude-sonnet-4-20250514")
* Handles both canonical prefixed IDs and legacy aliases:
* - Canonical: cursor-auto, cursor-gpt-5.2, opencode-big-pickle, claude-sonnet
* - Legacy: auto, composer-1, sonnet, opus
*
* @param modelKey - Model key (e.g., "claude-opus", "cursor-composer-1", "sonnet")
* @param defaultModel - Fallback model if modelKey is undefined
* @returns Full model string
*/
@@ -47,74 +58,65 @@ export function resolveModelString(
return defaultModel;
}
// Cursor model with explicit prefix (e.g., "cursor-composer-1") - pass through unchanged
// CursorProvider will strip the prefix when calling the CLI
if (modelKey.startsWith(PROVIDER_PREFIXES.cursor)) {
const cursorModelId = stripProviderPrefix(modelKey);
// Verify it's a valid Cursor model
if (cursorModelId in CURSOR_MODEL_MAP) {
console.log(
`[ModelResolver] Using Cursor model: ${modelKey} (valid model ID: ${cursorModelId})`
);
return modelKey;
}
// Could be a cursor-prefixed model not in our map yet - still pass through
console.log(`[ModelResolver] Passing through cursor-prefixed model: ${modelKey}`);
return modelKey;
// First, migrate legacy IDs to canonical format
const canonicalKey = migrateModelId(modelKey);
if (canonicalKey !== modelKey) {
console.log(`[ModelResolver] Migrated legacy ID: "${modelKey}" -> "${canonicalKey}"`);
}
// Codex model with explicit prefix (e.g., "codex-gpt-5.1-codex-max") - pass through unchanged
if (modelKey.startsWith(PROVIDER_PREFIXES.codex)) {
console.log(`[ModelResolver] Using Codex model: ${modelKey}`);
return modelKey;
// Cursor model with explicit prefix (e.g., "cursor-auto", "cursor-composer-1")
// Pass through unchanged - provider will extract bare ID for CLI
if (canonicalKey.startsWith(PROVIDER_PREFIXES.cursor)) {
console.log(`[ModelResolver] Using Cursor model: ${canonicalKey}`);
return canonicalKey;
}
// OpenCode model (static or dynamic) - pass through unchanged
// This handles models like:
// - opencode-* (Automaker routing prefix)
// - opencode/* (free tier models)
// - amazon-bedrock/* (AWS Bedrock models)
// - provider/model-name (dynamic models like github-copilot/gpt-4o, google/gemini-2.5-pro)
if (isOpencodeModel(modelKey)) {
console.log(`[ModelResolver] Using OpenCode model: ${modelKey}`);
return modelKey;
// Codex model with explicit prefix (e.g., "codex-gpt-5.1-codex-max")
if (canonicalKey.startsWith(PROVIDER_PREFIXES.codex)) {
console.log(`[ModelResolver] Using Codex model: ${canonicalKey}`);
return canonicalKey;
}
// Full Claude model string - pass through unchanged
if (modelKey.includes('claude-')) {
console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`);
return modelKey;
// OpenCode model (static with opencode- prefix or dynamic with provider/model format)
if (isOpencodeModel(canonicalKey)) {
console.log(`[ModelResolver] Using OpenCode model: ${canonicalKey}`);
return canonicalKey;
}
// Look up Claude model alias
const resolved = CLAUDE_MODEL_MAP[modelKey];
if (resolved) {
console.log(`[ModelResolver] Resolved Claude model alias: "${modelKey}" -> "${resolved}"`);
// Claude canonical ID (claude-haiku, claude-sonnet, claude-opus)
// Map to full model string
if (canonicalKey in CLAUDE_CANONICAL_MAP) {
const resolved = CLAUDE_CANONICAL_MAP[canonicalKey as keyof typeof CLAUDE_CANONICAL_MAP];
console.log(`[ModelResolver] Resolved Claude canonical ID: "${canonicalKey}" -> "${resolved}"`);
return resolved;
}
// OpenAI/Codex models - check for codex- or gpt- prefix
if (
CODEX_MODEL_PREFIXES.some((prefix) => modelKey.startsWith(prefix)) ||
(OPENAI_O_SERIES_PATTERN.test(modelKey) && OPENAI_O_SERIES_ALLOWED_MODELS.has(modelKey))
) {
console.log(`[ModelResolver] Using OpenAI/Codex model: ${modelKey}`);
return modelKey;
// Full Claude model string (e.g., claude-sonnet-4-5-20250929) - pass through
if (canonicalKey.includes('claude-')) {
console.log(`[ModelResolver] Using full Claude model string: ${canonicalKey}`);
return canonicalKey;
}
// Check if it's a bare Cursor model ID (e.g., "composer-1", "auto", "gpt-4o")
// Note: This is checked AFTER Codex check to prioritize Codex for bare gpt-* models
if (modelKey in CURSOR_MODEL_MAP) {
// Return with cursor- prefix so provider routing works correctly
const prefixedModel = `${PROVIDER_PREFIXES.cursor}${modelKey}`;
console.log(
`[ModelResolver] Detected bare Cursor model ID: "${modelKey}" -> "${prefixedModel}"`
);
return prefixedModel;
// Legacy Claude model alias (sonnet, opus, haiku) - support for backward compatibility
const resolved = CLAUDE_MODEL_MAP[canonicalKey];
if (resolved) {
console.log(`[ModelResolver] Resolved Claude legacy alias: "${canonicalKey}" -> "${resolved}"`);
return resolved;
}
// OpenAI/Codex models - check for gpt- prefix
if (
CODEX_MODEL_PREFIXES.some((prefix) => canonicalKey.startsWith(prefix)) ||
(OPENAI_O_SERIES_PATTERN.test(canonicalKey) && OPENAI_O_SERIES_ALLOWED_MODELS.has(canonicalKey))
) {
console.log(`[ModelResolver] Using OpenAI/Codex model: ${canonicalKey}`);
return canonicalKey;
}
// Unknown model key - use default
console.warn(`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`);
console.warn(
`[ModelResolver] Unknown model key "${canonicalKey}", using default: "${defaultModel}"`
);
return defaultModel;
}

View File

@@ -78,8 +78,9 @@ describe('model-resolver', () => {
const result = resolveModelString('sonnet');
expect(result).toBe(CLAUDE_MODEL_MAP.sonnet);
// Legacy aliases are migrated to canonical IDs then resolved
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Resolved Claude model alias: "sonnet"')
expect.stringContaining('Migrated legacy ID: "sonnet" -> "claude-sonnet"')
);
});
@@ -88,7 +89,7 @@ describe('model-resolver', () => {
expect(result).toBe(CLAUDE_MODEL_MAP.opus);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Resolved Claude model alias: "opus"')
expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"')
);
});
@@ -101,8 +102,9 @@ describe('model-resolver', () => {
it('should log the resolution for aliases', () => {
resolveModelString('sonnet');
// Legacy aliases get migrated and resolved via canonical map
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Resolved Claude model alias')
expect.stringContaining('Resolved Claude canonical ID')
);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining(CLAUDE_MODEL_MAP.sonnet)
@@ -134,8 +136,9 @@ describe('model-resolver', () => {
const result = resolveModelString('composer-1');
expect(result).toBe('cursor-composer-1');
// Legacy bare IDs are migrated to canonical prefixed format
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Detected bare Cursor model ID')
expect.stringContaining('Migrated legacy ID: "composer-1" -> "cursor-composer-1"')
);
});
@@ -149,17 +152,18 @@ describe('model-resolver', () => {
const result = resolveModelString('cursor-unknown-future-model');
expect(result).toBe('cursor-unknown-future-model');
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Passing through cursor-prefixed model')
);
// Unknown cursor-prefixed models pass through as Cursor models
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Using Cursor model'));
});
it('should handle all known Cursor model IDs', () => {
// CURSOR_MODEL_MAP now uses prefixed keys (e.g., 'cursor-auto')
const cursorModelIds = Object.keys(CURSOR_MODEL_MAP);
for (const modelId of cursorModelIds) {
const result = resolveModelString(`cursor-${modelId}`);
expect(result).toBe(`cursor-${modelId}`);
// modelId is already prefixed (e.g., 'cursor-auto')
const result = resolveModelString(modelId);
expect(result).toBe(modelId);
}
});
});

View File

@@ -19,6 +19,15 @@ const execFileAsync = promisify(execFile);
const isWindows = process.platform === 'win32';
const isMac = process.platform === 'darwin';
/**
* Escape a string for safe use in shell commands
* Handles paths with spaces, special characters, etc.
*/
function escapeShellArg(arg: string): string {
// Escape single quotes by ending the quoted string, adding escaped quote, and starting new quoted string
return `'${arg.replace(/'/g, "'\\''")}'`;
}
// Cache with TTL for editor detection
let cachedEditors: EditorInfo[] | null = null;
let cacheTimestamp: number = 0;
@@ -341,3 +350,100 @@ export async function openInFileManager(targetPath: string): Promise<{ editorNam
await execFileAsync(fileManager.command, [targetPath]);
return { editorName: fileManager.name };
}
/**
* Open a terminal in the specified directory
*
* Handles cross-platform differences:
* - On macOS, uses Terminal.app via 'open -a Terminal' or AppleScript for directory
* - On Windows, uses Windows Terminal (wt) or falls back to cmd
* - On Linux, uses x-terminal-emulator or common terminal emulators
*
* @param targetPath - The directory path to open the terminal in
* @returns Promise that resolves with terminal info when launched, rejects on error
*/
export async function openInTerminal(targetPath: string): Promise<{ terminalName: string }> {
if (isMac) {
// Use AppleScript to open Terminal.app in the specified directory
const script = `
tell application "Terminal"
do script "cd ${escapeShellArg(targetPath)}"
activate
end tell
`;
await execFileAsync('osascript', ['-e', script]);
return { terminalName: 'Terminal' };
} else if (isWindows) {
// Try Windows Terminal first - check if it exists before trying to spawn
const hasWindowsTerminal = await commandExists('wt');
if (hasWindowsTerminal) {
return await new Promise((resolve, reject) => {
const child: ChildProcess = spawn('wt', ['-d', targetPath], {
shell: true,
stdio: 'ignore',
detached: true,
});
child.unref();
child.on('error', (err) => {
reject(err);
});
setTimeout(() => resolve({ terminalName: 'Windows Terminal' }), 100);
});
}
// Fall back to cmd
return await new Promise((resolve, reject) => {
const child: ChildProcess = spawn(
'cmd',
['/c', 'start', 'cmd', '/k', `cd /d "${targetPath}"`],
{
shell: true,
stdio: 'ignore',
detached: true,
}
);
child.unref();
child.on('error', (err) => {
reject(err);
});
setTimeout(() => resolve({ terminalName: 'Command Prompt' }), 100);
});
} else {
// Linux: Try common terminal emulators in order
const terminals = [
{
name: 'GNOME Terminal',
command: 'gnome-terminal',
args: ['--working-directory', targetPath],
},
{ name: 'Konsole', command: 'konsole', args: ['--workdir', targetPath] },
{
name: 'xfce4-terminal',
command: 'xfce4-terminal',
args: ['--working-directory', targetPath],
},
{
name: 'xterm',
command: 'xterm',
args: ['-e', 'sh', '-c', `cd ${escapeShellArg(targetPath)} && $SHELL`],
},
{
name: 'x-terminal-emulator',
command: 'x-terminal-emulator',
args: ['--working-directory', targetPath],
},
];
for (const terminal of terminals) {
if (await commandExists(terminal.command)) {
await execFileAsync(terminal.command, terminal.args);
return { terminalName: terminal.name };
}
}
throw new Error('No terminal emulator found');
}
}

View File

@@ -175,4 +175,14 @@ export {
findEditorByCommand,
openInEditor,
openInFileManager,
openInTerminal,
} from './editor.js';
// External terminal detection and launching
export {
clearTerminalCache,
detectAllTerminals,
detectDefaultTerminal,
findTerminalById,
openInExternalTerminal,
} from './terminal.js';

View File

@@ -0,0 +1,607 @@
/**
* Cross-platform terminal detection and launching utilities
*
* Handles:
* - Detecting available external terminals on the system
* - Cross-platform terminal launching
* - Caching of detected terminals for performance
*/
import { execFile, spawn, type ChildProcess } from 'child_process';
import { promisify } from 'util';
import { homedir } from 'os';
import { join } from 'path';
import { access } from 'fs/promises';
import type { TerminalInfo } from '@automaker/types';
const execFileAsync = promisify(execFile);
// Platform detection
const isWindows = process.platform === 'win32';
const isMac = process.platform === 'darwin';
const isLinux = process.platform === 'linux';
// Cache with TTL for terminal detection
let cachedTerminals: TerminalInfo[] | null = null;
let cacheTimestamp: number = 0;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Check if the terminal cache is still valid
*/
function isCacheValid(): boolean {
return cachedTerminals !== null && Date.now() - cacheTimestamp < CACHE_TTL_MS;
}
/**
* Clear the terminal detection cache
* Useful when terminals may have been installed/uninstalled
*/
export function clearTerminalCache(): void {
cachedTerminals = null;
cacheTimestamp = 0;
}
/**
* Check if a CLI command exists in PATH
* Uses platform-specific command lookup (where on Windows, which on Unix)
*/
async function commandExists(cmd: string): Promise<boolean> {
try {
const whichCmd = isWindows ? 'where' : 'which';
await execFileAsync(whichCmd, [cmd]);
return true;
} catch {
return false;
}
}
/**
* Check if a macOS app bundle exists and return the path if found
* Checks /Applications, /System/Applications (for built-in apps), and ~/Applications
*/
async function findMacApp(appName: string): Promise<string | null> {
if (!isMac) return null;
// Check /Applications first (third-party apps)
const appPath = join('/Applications', `${appName}.app`);
try {
await access(appPath);
return appPath;
} catch {
// Not in /Applications
}
// Check /System/Applications (built-in macOS apps like Terminal on Catalina+)
const systemAppPath = join('/System/Applications', `${appName}.app`);
try {
await access(systemAppPath);
return systemAppPath;
} catch {
// Not in /System/Applications
}
// Check ~/Applications (used by some installers)
const userAppPath = join(homedir(), 'Applications', `${appName}.app`);
try {
await access(userAppPath);
return userAppPath;
} catch {
return null;
}
}
/**
* Check if a Windows path exists
*/
async function windowsPathExists(path: string): Promise<boolean> {
if (!isWindows) return false;
try {
await access(path);
return true;
} catch {
return false;
}
}
/**
* Terminal definition with CLI command and platform-specific identifiers
*/
interface TerminalDefinition {
id: string;
name: string;
/** CLI command (cross-platform, checked via which/where) */
cliCommand?: string;
/** Alternative CLI commands to check */
cliAliases?: readonly string[];
/** macOS app bundle name */
macAppName?: string;
/** Windows executable paths to check */
windowsPaths?: readonly string[];
/** Linux binary paths to check */
linuxPaths?: readonly string[];
/** Platform restriction */
platform?: 'darwin' | 'win32' | 'linux';
}
/**
* List of supported terminals in priority order
*/
const SUPPORTED_TERMINALS: TerminalDefinition[] = [
// macOS terminals
{
id: 'iterm2',
name: 'iTerm2',
cliCommand: 'iterm2',
macAppName: 'iTerm',
platform: 'darwin',
},
{
id: 'warp',
name: 'Warp',
cliCommand: 'warp',
macAppName: 'Warp',
platform: 'darwin',
},
{
id: 'ghostty',
name: 'Ghostty',
cliCommand: 'ghostty',
macAppName: 'Ghostty',
},
{
id: 'rio',
name: 'Rio',
cliCommand: 'rio',
macAppName: 'Rio',
},
{
id: 'alacritty',
name: 'Alacritty',
cliCommand: 'alacritty',
macAppName: 'Alacritty',
},
{
id: 'wezterm',
name: 'WezTerm',
cliCommand: 'wezterm',
macAppName: 'WezTerm',
},
{
id: 'kitty',
name: 'Kitty',
cliCommand: 'kitty',
macAppName: 'kitty',
},
{
id: 'hyper',
name: 'Hyper',
cliCommand: 'hyper',
macAppName: 'Hyper',
},
{
id: 'tabby',
name: 'Tabby',
cliCommand: 'tabby',
macAppName: 'Tabby',
},
{
id: 'terminal-macos',
name: 'System Terminal',
macAppName: 'Utilities/Terminal',
platform: 'darwin',
},
// Windows terminals
{
id: 'windows-terminal',
name: 'Windows Terminal',
cliCommand: 'wt',
windowsPaths: [join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WindowsApps', 'wt.exe')],
platform: 'win32',
},
{
id: 'powershell',
name: 'PowerShell',
cliCommand: 'pwsh',
cliAliases: ['powershell'],
windowsPaths: [
join(
process.env.SYSTEMROOT || 'C:\\Windows',
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe'
),
],
platform: 'win32',
},
{
id: 'cmd',
name: 'Command Prompt',
cliCommand: 'cmd',
windowsPaths: [join(process.env.SYSTEMROOT || 'C:\\Windows', 'System32', 'cmd.exe')],
platform: 'win32',
},
{
id: 'git-bash',
name: 'Git Bash',
windowsPaths: [
join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Git', 'git-bash.exe'),
join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Git', 'git-bash.exe'),
],
platform: 'win32',
},
// Linux terminals
{
id: 'gnome-terminal',
name: 'GNOME Terminal',
cliCommand: 'gnome-terminal',
platform: 'linux',
},
{
id: 'konsole',
name: 'Konsole',
cliCommand: 'konsole',
platform: 'linux',
},
{
id: 'xfce4-terminal',
name: 'XFCE4 Terminal',
cliCommand: 'xfce4-terminal',
platform: 'linux',
},
{
id: 'tilix',
name: 'Tilix',
cliCommand: 'tilix',
platform: 'linux',
},
{
id: 'terminator',
name: 'Terminator',
cliCommand: 'terminator',
platform: 'linux',
},
{
id: 'foot',
name: 'Foot',
cliCommand: 'foot',
platform: 'linux',
},
{
id: 'xterm',
name: 'XTerm',
cliCommand: 'xterm',
platform: 'linux',
},
];
/**
* Try to find a terminal - checks CLI, macOS app bundle, or Windows paths
* Returns TerminalInfo if found, null otherwise
*/
async function findTerminal(definition: TerminalDefinition): Promise<TerminalInfo | null> {
// Skip if terminal is for a different platform
if (definition.platform) {
if (definition.platform === 'darwin' && !isMac) return null;
if (definition.platform === 'win32' && !isWindows) return null;
if (definition.platform === 'linux' && !isLinux) return null;
}
// Try CLI command first (works on all platforms)
const cliCandidates = [definition.cliCommand, ...(definition.cliAliases ?? [])].filter(
Boolean
) as string[];
for (const cliCommand of cliCandidates) {
if (await commandExists(cliCommand)) {
return {
id: definition.id,
name: definition.name,
command: cliCommand,
};
}
}
// Try macOS app bundle
if (isMac && definition.macAppName) {
const appPath = await findMacApp(definition.macAppName);
if (appPath) {
return {
id: definition.id,
name: definition.name,
command: `open -a "${appPath}"`,
};
}
}
// Try Windows paths
if (isWindows && definition.windowsPaths) {
for (const windowsPath of definition.windowsPaths) {
if (await windowsPathExists(windowsPath)) {
return {
id: definition.id,
name: definition.name,
command: windowsPath,
};
}
}
}
return null;
}
/**
* Detect all available external terminals on the system
* Results are cached for 5 minutes for performance
*/
export async function detectAllTerminals(): Promise<TerminalInfo[]> {
// Return cached result if still valid
if (isCacheValid() && cachedTerminals) {
return cachedTerminals;
}
// Check all terminals in parallel for better performance
const terminalChecks = SUPPORTED_TERMINALS.map((def) => findTerminal(def));
const results = await Promise.all(terminalChecks);
// Filter out null results (terminals not found)
const terminals = results.filter((t): t is TerminalInfo => t !== null);
// Update cache
cachedTerminals = terminals;
cacheTimestamp = Date.now();
return terminals;
}
/**
* Detect the default (first available) external terminal on the system
* Returns the highest priority terminal that is installed, or null if none found
*/
export async function detectDefaultTerminal(): Promise<TerminalInfo | null> {
const terminals = await detectAllTerminals();
return terminals[0] ?? null;
}
/**
* Find a specific terminal by ID
* Returns the terminal info if available, null otherwise
*/
export async function findTerminalById(id: string): Promise<TerminalInfo | null> {
const terminals = await detectAllTerminals();
return terminals.find((t) => t.id === id) ?? null;
}
/**
* Open a directory in the specified external terminal
*
* Handles cross-platform differences:
* - On macOS, uses 'open -a' for app bundles or direct command with --directory flag
* - On Windows, uses spawn with shell:true
* - On Linux, uses direct execution with working directory
*
* @param targetPath - The directory path to open
* @param terminalId - The terminal ID to use (optional, uses default if not specified)
* @returns Promise that resolves with terminal info when launched, rejects on error
*/
export async function openInExternalTerminal(
targetPath: string,
terminalId?: string
): Promise<{ terminalName: string }> {
// Determine which terminal to use
let terminal: TerminalInfo | null;
if (terminalId) {
terminal = await findTerminalById(terminalId);
if (!terminal) {
// Fall back to default if specified terminal not found
terminal = await detectDefaultTerminal();
}
} else {
terminal = await detectDefaultTerminal();
}
if (!terminal) {
throw new Error('No external terminal available');
}
// Execute the terminal
await executeTerminalCommand(terminal, targetPath);
return { terminalName: terminal.name };
}
/**
* Execute a terminal command to open at a specific path
* Handles platform-specific differences in command execution
*/
async function executeTerminalCommand(terminal: TerminalInfo, targetPath: string): Promise<void> {
const { id, command } = terminal;
// Handle 'open -a "AppPath"' style commands (macOS app bundles)
if (command.startsWith('open -a ')) {
const appPath = command.replace('open -a ', '').replace(/"/g, '');
// Different terminals have different ways to open at a directory
if (id === 'iterm2') {
// iTerm2: Use AppleScript to open a new window at the path
await execFileAsync('osascript', [
'-e',
`tell application "iTerm"
create window with default profile
tell current session of current window
write text "cd ${escapeShellArg(targetPath)}"
end tell
end tell`,
]);
} else if (id === 'terminal-macos') {
// macOS Terminal: Use AppleScript
await execFileAsync('osascript', [
'-e',
`tell application "Terminal"
do script "cd ${escapeShellArg(targetPath)}"
activate
end tell`,
]);
} else if (id === 'warp') {
// Warp: Open app and use AppleScript to cd
await execFileAsync('open', ['-a', appPath, targetPath]);
} else {
// Generic: Just open the app with the directory as argument
await execFileAsync('open', ['-a', appPath, targetPath]);
}
return;
}
// Handle different terminals based on their ID
switch (id) {
case 'iterm2':
// iTerm2 CLI mode
await execFileAsync('osascript', [
'-e',
`tell application "iTerm"
create window with default profile
tell current session of current window
write text "cd ${escapeShellArg(targetPath)}"
end tell
end tell`,
]);
break;
case 'ghostty':
// Ghostty: uses --working-directory=PATH format (single arg)
await spawnDetached(command, [`--working-directory=${targetPath}`]);
break;
case 'alacritty':
// Alacritty: uses --working-directory flag
await spawnDetached(command, ['--working-directory', targetPath]);
break;
case 'wezterm':
// WezTerm: uses start --cwd flag
await spawnDetached(command, ['start', '--cwd', targetPath]);
break;
case 'kitty':
// Kitty: uses --directory flag
await spawnDetached(command, ['--directory', targetPath]);
break;
case 'hyper':
// Hyper: open at directory by setting cwd
await spawnDetached(command, [targetPath]);
break;
case 'tabby':
// Tabby: open at directory
await spawnDetached(command, ['open', targetPath]);
break;
case 'rio':
// Rio: uses --working-dir flag
await spawnDetached(command, ['--working-dir', targetPath]);
break;
case 'windows-terminal':
// Windows Terminal: uses -d flag for directory
await spawnDetached(command, ['-d', targetPath], { shell: true });
break;
case 'powershell':
case 'cmd':
// PowerShell/CMD: Start in directory with /K to keep open
await spawnDetached('start', [command, '/K', `cd /d "${targetPath}"`], {
shell: true,
});
break;
case 'git-bash':
// Git Bash: uses --cd flag
await spawnDetached(command, ['--cd', targetPath], { shell: true });
break;
case 'gnome-terminal':
// GNOME Terminal: uses --working-directory flag
await spawnDetached(command, ['--working-directory', targetPath]);
break;
case 'konsole':
// Konsole: uses --workdir flag
await spawnDetached(command, ['--workdir', targetPath]);
break;
case 'xfce4-terminal':
// XFCE4 Terminal: uses --working-directory flag
await spawnDetached(command, ['--working-directory', targetPath]);
break;
case 'tilix':
// Tilix: uses --working-directory flag
await spawnDetached(command, ['--working-directory', targetPath]);
break;
case 'terminator':
// Terminator: uses --working-directory flag
await spawnDetached(command, ['--working-directory', targetPath]);
break;
case 'foot':
// Foot: uses --working-directory flag
await spawnDetached(command, ['--working-directory', targetPath]);
break;
case 'xterm':
// XTerm: uses -e to run a shell in the directory
await spawnDetached(command, [
'-e',
'sh',
'-c',
`cd ${escapeShellArg(targetPath)} && $SHELL`,
]);
break;
default:
// Generic fallback: try to run the command with the directory as argument
await spawnDetached(command, [targetPath]);
}
}
/**
* Spawn a detached process that won't block the parent
*/
function spawnDetached(
command: string,
args: string[],
options: { shell?: boolean } = {}
): Promise<void> {
return new Promise((resolve, reject) => {
const child: ChildProcess = spawn(command, args, {
shell: options.shell ?? false,
stdio: 'ignore',
detached: true,
});
// Unref to allow the parent process to exit independently
child.unref();
child.on('error', (err) => {
reject(err);
});
// Resolve after a small delay to catch immediate spawn errors
// Terminals run in background, so we don't wait for them to exit
setTimeout(() => resolve(), 100);
});
}
/**
* Escape a string for safe use in shell commands
*/
function escapeShellArg(arg: string): string {
// Escape single quotes by ending the quoted string, adding escaped quote, and starting new quoted string
return `'${arg.replace(/'/g, "'\\''")}'`;
}

View File

@@ -0,0 +1,39 @@
{
"name": "@automaker/spec-parser",
"version": "1.0.0",
"type": "module",
"description": "XML spec parser for AutoMaker - parses and generates app_spec.txt XML",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"automaker",
"spec-parser",
"xml"
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=22.0.0 <23.0.0"
},
"dependencies": {
"@automaker/types": "1.0.0",
"fast-xml-parser": "^5.3.3"
},
"devDependencies": {
"@types/node": "22.19.3",
"typescript": "5.9.3",
"vitest": "4.0.16"
}
}

View File

@@ -0,0 +1,26 @@
/**
* @automaker/spec-parser
*
* XML spec parser for AutoMaker - parses and generates app_spec.txt XML.
* This package provides utilities for:
* - Parsing XML spec content into SpecOutput objects
* - Converting SpecOutput objects back to XML
* - Validating spec data
*/
// Re-export types from @automaker/types for convenience
export type { SpecOutput } from '@automaker/types';
// XML utilities
export { escapeXml, unescapeXml, extractXmlSection, extractXmlElements } from './xml-utils.js';
// XML to Spec parsing
export { xmlToSpec } from './xml-to-spec.js';
export type { ParseResult } from './xml-to-spec.js';
// Spec to XML conversion
export { specToXml } from './spec-to-xml.js';
// Validation
export { validateSpec, isValidSpecXml } from './validate.js';
export type { ValidationResult } from './validate.js';

View File

@@ -0,0 +1,88 @@
/**
* SpecOutput to XML converter.
* Converts a structured SpecOutput object back to XML format.
*/
import type { SpecOutput } from '@automaker/types';
import { escapeXml } from './xml-utils.js';
/**
* Convert structured spec output to XML format.
*
* @param spec - The SpecOutput object to convert
* @returns XML string formatted for app_spec.txt
*/
export function specToXml(spec: SpecOutput): string {
const indent = ' ';
let xml = `<?xml version="1.0" encoding="UTF-8"?>
<project_specification>
${indent}<project_name>${escapeXml(spec.project_name)}</project_name>
${indent}<overview>
${indent}${indent}${escapeXml(spec.overview)}
${indent}</overview>
${indent}<technology_stack>
${spec.technology_stack.map((t) => `${indent}${indent}<technology>${escapeXml(t)}</technology>`).join('\n')}
${indent}</technology_stack>
${indent}<core_capabilities>
${spec.core_capabilities.map((c) => `${indent}${indent}<capability>${escapeXml(c)}</capability>`).join('\n')}
${indent}</core_capabilities>
${indent}<implemented_features>
${spec.implemented_features
.map(
(f) => `${indent}${indent}<feature>
${indent}${indent}${indent}<name>${escapeXml(f.name)}</name>
${indent}${indent}${indent}<description>${escapeXml(f.description)}</description>${
f.file_locations && f.file_locations.length > 0
? `\n${indent}${indent}${indent}<file_locations>
${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}<location>${escapeXml(loc)}</location>`).join('\n')}
${indent}${indent}${indent}</file_locations>`
: ''
}
${indent}${indent}</feature>`
)
.join('\n')}
${indent}</implemented_features>`;
// Optional sections
if (spec.additional_requirements && spec.additional_requirements.length > 0) {
xml += `
${indent}<additional_requirements>
${spec.additional_requirements.map((r) => `${indent}${indent}<requirement>${escapeXml(r)}</requirement>`).join('\n')}
${indent}</additional_requirements>`;
}
if (spec.development_guidelines && spec.development_guidelines.length > 0) {
xml += `
${indent}<development_guidelines>
${spec.development_guidelines.map((g) => `${indent}${indent}<guideline>${escapeXml(g)}</guideline>`).join('\n')}
${indent}</development_guidelines>`;
}
if (spec.implementation_roadmap && spec.implementation_roadmap.length > 0) {
xml += `
${indent}<implementation_roadmap>
${spec.implementation_roadmap
.map(
(r) => `${indent}${indent}<phase>
${indent}${indent}${indent}<name>${escapeXml(r.phase)}</name>
${indent}${indent}${indent}<status>${escapeXml(r.status)}</status>
${indent}${indent}${indent}<description>${escapeXml(r.description)}</description>
${indent}${indent}</phase>`
)
.join('\n')}
${indent}</implementation_roadmap>`;
}
xml += `
</project_specification>`;
return xml;
}

View File

@@ -0,0 +1,143 @@
/**
* Validation utilities for SpecOutput objects.
*/
import type { SpecOutput } from '@automaker/types';
/**
* Validation result containing errors if any.
*/
export interface ValidationResult {
valid: boolean;
errors: string[];
}
/**
* Validate a SpecOutput object for required fields and data integrity.
*
* @param spec - The SpecOutput object to validate
* @returns ValidationResult with errors if validation fails
*/
export function validateSpec(spec: SpecOutput | null | undefined): ValidationResult {
const errors: string[] = [];
if (!spec) {
return { valid: false, errors: ['Spec is null or undefined'] };
}
// Required string fields
if (!spec.project_name || typeof spec.project_name !== 'string') {
errors.push('project_name is required and must be a string');
} else if (spec.project_name.trim().length === 0) {
errors.push('project_name cannot be empty');
}
if (!spec.overview || typeof spec.overview !== 'string') {
errors.push('overview is required and must be a string');
} else if (spec.overview.trim().length === 0) {
errors.push('overview cannot be empty');
}
// Required array fields
if (!Array.isArray(spec.technology_stack)) {
errors.push('technology_stack is required and must be an array');
} else if (spec.technology_stack.length === 0) {
errors.push('technology_stack must have at least one item');
} else if (spec.technology_stack.some((t) => typeof t !== 'string' || t.trim() === '')) {
errors.push('technology_stack items must be non-empty strings');
}
if (!Array.isArray(spec.core_capabilities)) {
errors.push('core_capabilities is required and must be an array');
} else if (spec.core_capabilities.length === 0) {
errors.push('core_capabilities must have at least one item');
} else if (spec.core_capabilities.some((c) => typeof c !== 'string' || c.trim() === '')) {
errors.push('core_capabilities items must be non-empty strings');
}
// Implemented features
if (!Array.isArray(spec.implemented_features)) {
errors.push('implemented_features is required and must be an array');
} else {
spec.implemented_features.forEach((f, i) => {
if (!f.name || typeof f.name !== 'string' || f.name.trim() === '') {
errors.push(`implemented_features[${i}].name is required and must be a non-empty string`);
}
if (!f.description || typeof f.description !== 'string') {
errors.push(`implemented_features[${i}].description is required and must be a string`);
}
if (f.file_locations !== undefined) {
if (!Array.isArray(f.file_locations)) {
errors.push(`implemented_features[${i}].file_locations must be an array if provided`);
} else if (f.file_locations.some((loc) => typeof loc !== 'string' || loc.trim() === '')) {
errors.push(`implemented_features[${i}].file_locations items must be non-empty strings`);
}
}
});
}
// Optional array fields
if (spec.additional_requirements !== undefined) {
if (!Array.isArray(spec.additional_requirements)) {
errors.push('additional_requirements must be an array if provided');
} else if (spec.additional_requirements.some((r) => typeof r !== 'string' || r.trim() === '')) {
errors.push('additional_requirements items must be non-empty strings');
}
}
if (spec.development_guidelines !== undefined) {
if (!Array.isArray(spec.development_guidelines)) {
errors.push('development_guidelines must be an array if provided');
} else if (spec.development_guidelines.some((g) => typeof g !== 'string' || g.trim() === '')) {
errors.push('development_guidelines items must be non-empty strings');
}
}
// Implementation roadmap
if (spec.implementation_roadmap !== undefined) {
if (!Array.isArray(spec.implementation_roadmap)) {
errors.push('implementation_roadmap must be an array if provided');
} else {
const validStatuses = ['completed', 'in_progress', 'pending'];
spec.implementation_roadmap.forEach((r, i) => {
if (!r.phase || typeof r.phase !== 'string' || r.phase.trim() === '') {
errors.push(
`implementation_roadmap[${i}].phase is required and must be a non-empty string`
);
}
if (!r.status || !validStatuses.includes(r.status)) {
errors.push(
`implementation_roadmap[${i}].status must be one of: ${validStatuses.join(', ')}`
);
}
if (!r.description || typeof r.description !== 'string') {
errors.push(`implementation_roadmap[${i}].description is required and must be a string`);
}
});
}
}
return { valid: errors.length === 0, errors };
}
/**
* Check if XML content appears to be a valid spec XML (basic structure check).
* This is a quick check, not a full validation.
*
* @param xmlContent - The XML content to check
* @returns true if the content appears to be valid spec XML
*/
export function isValidSpecXml(xmlContent: string): boolean {
if (!xmlContent || typeof xmlContent !== 'string') {
return false;
}
// Check for essential elements
const hasRoot = xmlContent.includes('<project_specification>');
const hasProjectName = /<project_name>[\s\S]*?<\/project_name>/.test(xmlContent);
const hasOverview = /<overview>[\s\S]*?<\/overview>/.test(xmlContent);
const hasTechStack = /<technology_stack>[\s\S]*?<\/technology_stack>/.test(xmlContent);
const hasCapabilities = /<core_capabilities>[\s\S]*?<\/core_capabilities>/.test(xmlContent);
return hasRoot && hasProjectName && hasOverview && hasTechStack && hasCapabilities;
}

View File

@@ -0,0 +1,232 @@
/**
* XML to SpecOutput parser.
* Parses app_spec.txt XML content into a structured SpecOutput object.
* Uses fast-xml-parser for robust XML parsing.
*/
import { XMLParser } from 'fast-xml-parser';
import type { SpecOutput } from '@automaker/types';
/**
* Result of parsing XML content.
*/
export interface ParseResult {
success: boolean;
spec: SpecOutput | null;
errors: string[];
}
// Configure the XML parser
const parser = new XMLParser({
ignoreAttributes: true,
trimValues: true,
// Preserve arrays for elements that can have multiple values
isArray: (name) => {
return [
'technology',
'capability',
'feature',
'location',
'requirement',
'guideline',
'phase',
].includes(name);
},
});
/**
* Safely get a string value from parsed XML, handling various input types.
*/
function getString(value: unknown): string {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number') return String(value);
if (value === null || value === undefined) return '';
return '';
}
/**
* Safely get an array of strings from parsed XML.
*/
function getStringArray(value: unknown): string[] {
if (!value) return [];
if (Array.isArray(value)) {
return value.map((item) => getString(item)).filter((s) => s.length > 0);
}
const str = getString(value);
return str ? [str] : [];
}
/**
* Parse implemented features from the parsed XML object.
*/
function parseImplementedFeatures(featuresSection: unknown): SpecOutput['implemented_features'] {
const features: SpecOutput['implemented_features'] = [];
if (!featuresSection || typeof featuresSection !== 'object') {
return features;
}
const section = featuresSection as Record<string, unknown>;
const featureList = section.feature;
if (!featureList) return features;
const featureArray = Array.isArray(featureList) ? featureList : [featureList];
for (const feature of featureArray) {
if (typeof feature !== 'object' || feature === null) continue;
const f = feature as Record<string, unknown>;
const name = getString(f.name);
const description = getString(f.description);
if (!name) continue;
const locationsSection = f.file_locations as Record<string, unknown> | undefined;
const file_locations = locationsSection ? getStringArray(locationsSection.location) : undefined;
features.push({
name,
description,
...(file_locations && file_locations.length > 0 ? { file_locations } : {}),
});
}
return features;
}
/**
* Parse implementation roadmap phases from the parsed XML object.
*/
function parseImplementationRoadmap(roadmapSection: unknown): SpecOutput['implementation_roadmap'] {
if (!roadmapSection || typeof roadmapSection !== 'object') {
return undefined;
}
const section = roadmapSection as Record<string, unknown>;
const phaseList = section.phase;
if (!phaseList) return undefined;
const phaseArray = Array.isArray(phaseList) ? phaseList : [phaseList];
const roadmap: NonNullable<SpecOutput['implementation_roadmap']> = [];
for (const phase of phaseArray) {
if (typeof phase !== 'object' || phase === null) continue;
const p = phase as Record<string, unknown>;
const phaseName = getString(p.name);
const statusRaw = getString(p.status);
const description = getString(p.description);
if (!phaseName) continue;
const status = (
['completed', 'in_progress', 'pending'].includes(statusRaw) ? statusRaw : 'pending'
) as 'completed' | 'in_progress' | 'pending';
roadmap.push({ phase: phaseName, status, description });
}
return roadmap.length > 0 ? roadmap : undefined;
}
/**
* Parse XML content into a SpecOutput object.
*
* @param xmlContent - The raw XML content from app_spec.txt
* @returns ParseResult with the parsed spec or errors
*/
export function xmlToSpec(xmlContent: string): ParseResult {
const errors: string[] = [];
// Check for root element before parsing
if (!xmlContent.includes('<project_specification>')) {
return {
success: false,
spec: null,
errors: ['Missing <project_specification> root element'],
};
}
// Parse the XML
let parsed: Record<string, unknown>;
try {
parsed = parser.parse(xmlContent) as Record<string, unknown>;
} catch (e) {
return {
success: false,
spec: null,
errors: [`XML parsing error: ${e instanceof Error ? e.message : 'Unknown error'}`],
};
}
const root = parsed.project_specification as Record<string, unknown> | undefined;
if (!root) {
return {
success: false,
spec: null,
errors: ['Missing <project_specification> root element'],
};
}
// Extract required fields
const project_name = getString(root.project_name);
if (!project_name) {
errors.push('Missing or empty <project_name>');
}
const overview = getString(root.overview);
if (!overview) {
errors.push('Missing or empty <overview>');
}
// Extract technology stack
const techSection = root.technology_stack as Record<string, unknown> | undefined;
const technology_stack = techSection ? getStringArray(techSection.technology) : [];
if (technology_stack.length === 0) {
errors.push('Missing or empty <technology_stack>');
}
// Extract core capabilities
const capSection = root.core_capabilities as Record<string, unknown> | undefined;
const core_capabilities = capSection ? getStringArray(capSection.capability) : [];
if (core_capabilities.length === 0) {
errors.push('Missing or empty <core_capabilities>');
}
// Extract implemented features
const implemented_features = parseImplementedFeatures(root.implemented_features);
// Extract optional sections
const reqSection = root.additional_requirements as Record<string, unknown> | undefined;
const additional_requirements = reqSection ? getStringArray(reqSection.requirement) : undefined;
const guideSection = root.development_guidelines as Record<string, unknown> | undefined;
const development_guidelines = guideSection ? getStringArray(guideSection.guideline) : undefined;
const implementation_roadmap = parseImplementationRoadmap(root.implementation_roadmap);
// Build spec object
const spec: SpecOutput = {
project_name,
overview,
technology_stack,
core_capabilities,
implemented_features,
...(additional_requirements && additional_requirements.length > 0
? { additional_requirements }
: {}),
...(development_guidelines && development_guidelines.length > 0
? { development_guidelines }
: {}),
...(implementation_roadmap ? { implementation_roadmap } : {}),
};
return {
success: errors.length === 0,
spec,
errors,
};
}

View File

@@ -0,0 +1,79 @@
/**
* XML utility functions for escaping, unescaping, and extracting XML content.
* These are pure functions with no dependencies for maximum reusability.
*/
/**
* Escape special XML characters.
* Handles undefined/null values by converting them to empty strings.
*/
export function escapeXml(str: string | undefined | null): string {
if (str == null) {
return '';
}
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Unescape XML entities back to regular characters.
*/
export function unescapeXml(str: string): string {
return str
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
/**
* Escape special RegExp characters in a string.
*/
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Extract the content of a specific XML section.
*
* Note: This function only matches bare tags without attributes.
* Tags with attributes (e.g., `<tag id="1">`) are not supported.
*
* @param xmlContent - The full XML content
* @param tagName - The tag name to extract (e.g., 'implemented_features')
* @returns The content between the tags, or null if not found
*/
export function extractXmlSection(xmlContent: string, tagName: string): string | null {
const safeTag = escapeRegExp(tagName);
const regex = new RegExp(`<${safeTag}>([\\s\\S]*?)<\\/${safeTag}>`, 'i');
const match = xmlContent.match(regex);
return match ? match[1] : null;
}
/**
* Extract all values from repeated XML elements.
*
* Note: This function only matches bare tags without attributes.
* Tags with attributes (e.g., `<tag id="1">`) are not supported.
*
* @param xmlContent - The XML content to search
* @param tagName - The tag name to extract values from
* @returns Array of extracted values (unescaped and trimmed)
*/
export function extractXmlElements(xmlContent: string, tagName: string): string[] {
const values: string[] = [];
const safeTag = escapeRegExp(tagName);
const regex = new RegExp(`<${safeTag}>([\\s\\S]*?)<\\/${safeTag}>`, 'g');
const matches = xmlContent.matchAll(regex);
for (const match of matches) {
values.push(unescapeXml(match[1].trim()));
}
return values;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -2,18 +2,19 @@
* Cursor CLI Model IDs
* Reference: https://cursor.com/docs
*
* IMPORTANT: GPT models use 'cursor-' prefix to distinguish from Codex CLI models
* All Cursor model IDs use 'cursor-' prefix for consistent provider routing.
* This prevents naming collisions (e.g., cursor-gpt-5.2-codex vs codex-gpt-5.2-codex).
*/
export type CursorModelId =
| 'auto' // Auto-select best model
| 'composer-1' // Cursor Composer agent model
| 'sonnet-4.5' // Claude Sonnet 4.5
| 'sonnet-4.5-thinking' // Claude Sonnet 4.5 with extended thinking
| 'opus-4.5' // Claude Opus 4.5
| 'opus-4.5-thinking' // Claude Opus 4.5 with extended thinking
| 'opus-4.1' // Claude Opus 4.1
| 'gemini-3-pro' // Gemini 3 Pro
| 'gemini-3-flash' // Gemini 3 Flash
| 'cursor-auto' // Auto-select best model
| 'cursor-composer-1' // Cursor Composer agent model
| 'cursor-sonnet-4.5' // Claude Sonnet 4.5
| 'cursor-sonnet-4.5-thinking' // Claude Sonnet 4.5 with extended thinking
| 'cursor-opus-4.5' // Claude Opus 4.5
| 'cursor-opus-4.5-thinking' // Claude Opus 4.5 with extended thinking
| 'cursor-opus-4.1' // Claude Opus 4.1
| 'cursor-gemini-3-pro' // Gemini 3 Pro
| 'cursor-gemini-3-flash' // Gemini 3 Flash
| 'cursor-gpt-5.2' // GPT-5.2 via Cursor
| 'cursor-gpt-5.1' // GPT-5.1 via Cursor
| 'cursor-gpt-5.2-high' // GPT-5.2 High via Cursor
@@ -26,7 +27,22 @@ export type CursorModelId =
| 'cursor-gpt-5.2-codex-high' // GPT-5.2 Codex High via Cursor
| 'cursor-gpt-5.2-codex-max' // GPT-5.2 Codex Max via Cursor
| 'cursor-gpt-5.2-codex-max-high' // GPT-5.2 Codex Max High via Cursor
| 'grok'; // Grok
| 'cursor-grok'; // Grok
/**
* Legacy Cursor model IDs (without prefix) for migration support
*/
export type LegacyCursorModelId =
| 'auto'
| 'composer-1'
| 'sonnet-4.5'
| 'sonnet-4.5-thinking'
| 'opus-4.5'
| 'opus-4.5-thinking'
| 'opus-4.1'
| 'gemini-3-pro'
| 'gemini-3-flash'
| 'grok';
/**
* Cursor model metadata
@@ -42,66 +58,67 @@ export interface CursorModelConfig {
/**
* Complete model map for Cursor CLI
* All keys use 'cursor-' prefix for consistent provider routing.
*/
export const CURSOR_MODEL_MAP: Record<CursorModelId, CursorModelConfig> = {
auto: {
id: 'auto',
'cursor-auto': {
id: 'cursor-auto',
label: 'Auto (Recommended)',
description: 'Automatically selects the best model for each task',
hasThinking: false,
supportsVision: false, // Vision not yet supported by Cursor CLI
},
'composer-1': {
id: 'composer-1',
'cursor-composer-1': {
id: 'cursor-composer-1',
label: 'Composer 1',
description: 'Cursor Composer agent model optimized for multi-file edits',
hasThinking: false,
supportsVision: false,
},
'sonnet-4.5': {
id: 'sonnet-4.5',
'cursor-sonnet-4.5': {
id: 'cursor-sonnet-4.5',
label: 'Claude Sonnet 4.5',
description: 'Anthropic Claude Sonnet 4.5 via Cursor',
hasThinking: false,
supportsVision: false, // Model supports vision but Cursor CLI doesn't pass images
},
'sonnet-4.5-thinking': {
id: 'sonnet-4.5-thinking',
'cursor-sonnet-4.5-thinking': {
id: 'cursor-sonnet-4.5-thinking',
label: 'Claude Sonnet 4.5 (Thinking)',
description: 'Claude Sonnet 4.5 with extended thinking enabled',
hasThinking: true,
supportsVision: false,
},
'opus-4.5': {
id: 'opus-4.5',
'cursor-opus-4.5': {
id: 'cursor-opus-4.5',
label: 'Claude Opus 4.5',
description: 'Anthropic Claude Opus 4.5 via Cursor',
hasThinking: false,
supportsVision: false,
},
'opus-4.5-thinking': {
id: 'opus-4.5-thinking',
'cursor-opus-4.5-thinking': {
id: 'cursor-opus-4.5-thinking',
label: 'Claude Opus 4.5 (Thinking)',
description: 'Claude Opus 4.5 with extended thinking enabled',
hasThinking: true,
supportsVision: false,
},
'opus-4.1': {
id: 'opus-4.1',
'cursor-opus-4.1': {
id: 'cursor-opus-4.1',
label: 'Claude Opus 4.1',
description: 'Anthropic Claude Opus 4.1 via Cursor',
hasThinking: false,
supportsVision: false,
},
'gemini-3-pro': {
id: 'gemini-3-pro',
'cursor-gemini-3-pro': {
id: 'cursor-gemini-3-pro',
label: 'Gemini 3 Pro',
description: 'Google Gemini 3 Pro via Cursor',
hasThinking: false,
supportsVision: false,
},
'gemini-3-flash': {
id: 'gemini-3-flash',
'cursor-gemini-3-flash': {
id: 'cursor-gemini-3-flash',
label: 'Gemini 3 Flash',
description: 'Google Gemini 3 Flash (faster)',
hasThinking: false,
@@ -191,8 +208,8 @@ export const CURSOR_MODEL_MAP: Record<CursorModelId, CursorModelConfig> = {
hasThinking: false,
supportsVision: false,
},
grok: {
id: 'grok',
'cursor-grok': {
id: 'cursor-grok',
label: 'Grok',
description: 'xAI Grok via Cursor',
hasThinking: false,
@@ -200,6 +217,22 @@ export const CURSOR_MODEL_MAP: Record<CursorModelId, CursorModelConfig> = {
},
};
/**
* Map from legacy model IDs to canonical prefixed IDs
*/
export const LEGACY_CURSOR_MODEL_MAP: Record<LegacyCursorModelId, CursorModelId> = {
auto: 'cursor-auto',
'composer-1': 'cursor-composer-1',
'sonnet-4.5': 'cursor-sonnet-4.5',
'sonnet-4.5-thinking': 'cursor-sonnet-4.5-thinking',
'opus-4.5': 'cursor-opus-4.5',
'opus-4.5-thinking': 'cursor-opus-4.5-thinking',
'opus-4.1': 'cursor-opus-4.1',
'gemini-3-pro': 'cursor-gemini-3-pro',
'gemini-3-flash': 'cursor-gemini-3-flash',
grok: 'cursor-grok',
};
/**
* Helper: Check if model has thinking capability
*/
@@ -254,6 +287,7 @@ export interface GroupedModel {
/**
* Configuration for grouping Cursor models with variants
* All variant IDs use 'cursor-' prefix for consistent provider routing.
*/
export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
// GPT-5.2 group (compute levels)
@@ -346,14 +380,14 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
},
// Sonnet 4.5 group (thinking mode)
{
baseId: 'sonnet-4.5-group',
baseId: 'cursor-sonnet-4.5-group',
label: 'Claude Sonnet 4.5',
description: 'Anthropic Claude Sonnet 4.5 via Cursor',
variantType: 'thinking',
variants: [
{ id: 'sonnet-4.5', label: 'Standard', description: 'Fast responses' },
{ id: 'cursor-sonnet-4.5', label: 'Standard', description: 'Fast responses' },
{
id: 'sonnet-4.5-thinking',
id: 'cursor-sonnet-4.5-thinking',
label: 'Thinking',
description: 'Extended reasoning',
badge: 'Reasoning',
@@ -362,14 +396,14 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
},
// Opus 4.5 group (thinking mode)
{
baseId: 'opus-4.5-group',
baseId: 'cursor-opus-4.5-group',
label: 'Claude Opus 4.5',
description: 'Anthropic Claude Opus 4.5 via Cursor',
variantType: 'thinking',
variants: [
{ id: 'opus-4.5', label: 'Standard', description: 'Fast responses' },
{ id: 'cursor-opus-4.5', label: 'Standard', description: 'Fast responses' },
{
id: 'opus-4.5-thinking',
id: 'cursor-opus-4.5-thinking',
label: 'Thinking',
description: 'Extended reasoning',
badge: 'Reasoning',
@@ -380,14 +414,15 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
/**
* Cursor models that are not part of any group (standalone)
* All IDs use 'cursor-' prefix for consistent provider routing.
*/
export const STANDALONE_CURSOR_MODELS: CursorModelId[] = [
'auto',
'composer-1',
'opus-4.1',
'gemini-3-pro',
'gemini-3-flash',
'grok',
'cursor-auto',
'cursor-composer-1',
'cursor-opus-4.1',
'cursor-gemini-3-pro',
'cursor-gemini-3-flash',
'cursor-grok',
];
/**

View File

@@ -77,12 +77,15 @@ export type { ImageData, ImageContentBlock } from './image.js';
// Model types and constants
export {
CLAUDE_MODEL_MAP,
CLAUDE_CANONICAL_MAP,
LEGACY_CLAUDE_ALIAS_MAP,
CODEX_MODEL_MAP,
CODEX_MODEL_IDS,
REASONING_CAPABLE_MODELS,
supportsReasoningEffort,
getAllCodexModelIds,
DEFAULT_MODELS,
type ClaudeCanonicalId,
type ModelAlias,
type CodexModelId,
type AgentModel,
@@ -242,6 +245,18 @@ export {
validateBareModelId,
} from './provider-utils.js';
// Model migration utilities
export {
isLegacyCursorModelId,
isLegacyOpencodeModelId,
isLegacyClaudeAlias,
migrateModelId,
migrateCursorModelIds,
migrateOpencodeModelIds,
migratePhaseModelEntry,
getBareModelIdForCli,
} from './model-migration.js';
// Pipeline types
export type {
PipelineStep,
@@ -297,3 +312,10 @@ export type {
EventReplayHookResult,
} from './event-history.js';
export { EVENT_HISTORY_VERSION, DEFAULT_EVENT_HISTORY_INDEX } from './event-history.js';
// Worktree and PR types
export type { PRState, WorktreePRInfo } from './worktree.js';
export { PR_STATES, validatePRState } from './worktree.js';
// Terminal types
export type { TerminalInfo } from './terminal.js';

View File

@@ -0,0 +1,218 @@
/**
* Model ID Migration Utilities
*
* Provides functions to migrate legacy model IDs to the canonical prefixed format.
* This ensures backward compatibility when loading settings from older versions.
*/
import type { CursorModelId, LegacyCursorModelId } from './cursor-models.js';
import { LEGACY_CURSOR_MODEL_MAP, CURSOR_MODEL_MAP } from './cursor-models.js';
import type { OpencodeModelId, LegacyOpencodeModelId } from './opencode-models.js';
import { LEGACY_OPENCODE_MODEL_MAP, OPENCODE_MODEL_CONFIG_MAP } from './opencode-models.js';
import type { ClaudeCanonicalId } from './model.js';
import { LEGACY_CLAUDE_ALIAS_MAP, CLAUDE_CANONICAL_MAP, CLAUDE_MODEL_MAP } from './model.js';
import type { PhaseModelEntry } from './settings.js';
/**
* Check if a string is a legacy Cursor model ID (without prefix)
*/
export function isLegacyCursorModelId(id: string): id is LegacyCursorModelId {
return id in LEGACY_CURSOR_MODEL_MAP;
}
/**
* Check if a string is a legacy OpenCode model ID (with slash format)
*/
export function isLegacyOpencodeModelId(id: string): id is LegacyOpencodeModelId {
return id in LEGACY_OPENCODE_MODEL_MAP;
}
/**
* Check if a string is a legacy Claude alias (short name without prefix)
*/
export function isLegacyClaudeAlias(id: string): boolean {
return id in LEGACY_CLAUDE_ALIAS_MAP;
}
/**
* Migrate a single model ID to canonical format
*
* Handles:
* - Legacy Cursor IDs (e.g., 'auto' -> 'cursor-auto')
* - Legacy OpenCode IDs (e.g., 'opencode/big-pickle' -> 'opencode-big-pickle')
* - Legacy Claude aliases (e.g., 'sonnet' -> 'claude-sonnet')
* - Already-canonical IDs are passed through unchanged
*
* @param legacyId - The model ID to migrate
* @returns The canonical model ID
*/
export function migrateModelId(legacyId: string | undefined | null): string {
if (!legacyId) {
return legacyId as string;
}
// Already has cursor- prefix and is in the map - it's canonical
if (legacyId.startsWith('cursor-') && legacyId in CURSOR_MODEL_MAP) {
return legacyId;
}
// Legacy Cursor model ID (without prefix)
if (isLegacyCursorModelId(legacyId)) {
return LEGACY_CURSOR_MODEL_MAP[legacyId];
}
// Already has opencode- prefix - it's canonical
if (legacyId.startsWith('opencode-') && legacyId in OPENCODE_MODEL_CONFIG_MAP) {
return legacyId;
}
// Legacy OpenCode model ID (with slash format)
if (isLegacyOpencodeModelId(legacyId)) {
return LEGACY_OPENCODE_MODEL_MAP[legacyId];
}
// Already has claude- prefix and is in canonical map
if (legacyId.startsWith('claude-') && legacyId in CLAUDE_CANONICAL_MAP) {
return legacyId;
}
// Legacy Claude alias (short name)
if (isLegacyClaudeAlias(legacyId)) {
return LEGACY_CLAUDE_ALIAS_MAP[legacyId];
}
// Unknown or already canonical - pass through
return legacyId;
}
/**
* Migrate an array of Cursor model IDs to canonical format
*
* @param ids - Array of legacy or canonical Cursor model IDs
* @returns Array of canonical Cursor model IDs
*/
export function migrateCursorModelIds(ids: string[]): CursorModelId[] {
if (!ids || !Array.isArray(ids)) {
return [];
}
return ids.map((id) => {
// Already canonical
if (id.startsWith('cursor-') && id in CURSOR_MODEL_MAP) {
return id as CursorModelId;
}
// Legacy ID
if (isLegacyCursorModelId(id)) {
return LEGACY_CURSOR_MODEL_MAP[id];
}
// Unknown - assume it might be a valid cursor model with prefix
if (id.startsWith('cursor-')) {
return id as CursorModelId;
}
// Add prefix if not present
return `cursor-${id}` as CursorModelId;
});
}
/**
* Migrate an array of OpenCode model IDs to canonical format
*
* @param ids - Array of legacy or canonical OpenCode model IDs
* @returns Array of canonical OpenCode model IDs
*/
export function migrateOpencodeModelIds(ids: string[]): OpencodeModelId[] {
if (!ids || !Array.isArray(ids)) {
return [];
}
return ids.map((id) => {
// Already canonical (dash format)
if (id.startsWith('opencode-') && id in OPENCODE_MODEL_CONFIG_MAP) {
return id as OpencodeModelId;
}
// Legacy ID (slash format)
if (isLegacyOpencodeModelId(id)) {
return LEGACY_OPENCODE_MODEL_MAP[id];
}
// Convert slash to dash format for unknown models
if (id.startsWith('opencode/')) {
return id.replace('opencode/', 'opencode-') as OpencodeModelId;
}
// Add prefix if not present
if (!id.startsWith('opencode-')) {
return `opencode-${id}` as OpencodeModelId;
}
return id as OpencodeModelId;
});
}
/**
* Migrate a PhaseModelEntry to use canonical model IDs
*
* @param entry - The phase model entry to migrate
* @returns Migrated phase model entry with canonical model ID
*/
export function migratePhaseModelEntry(
entry: PhaseModelEntry | string | undefined | null
): PhaseModelEntry {
// Handle null/undefined
if (!entry) {
return { model: 'claude-sonnet' }; // Default
}
// Handle legacy string format
if (typeof entry === 'string') {
return { model: migrateModelId(entry) };
}
// Handle PhaseModelEntry object
return {
...entry,
model: migrateModelId(entry.model),
};
}
/**
* Get the bare model ID for CLI calls (strip provider prefix)
*
* When calling provider CLIs, we need to strip the provider prefix:
* - 'cursor-auto' -> 'auto' (for Cursor CLI)
* - 'cursor-composer-1' -> 'composer-1' (for Cursor CLI)
* - 'opencode-big-pickle' -> 'big-pickle' (for OpenCode CLI)
*
* Note: GPT models via Cursor keep the gpt- part: 'cursor-gpt-5.2' -> 'gpt-5.2'
*
* @param modelId - The canonical model ID with provider prefix
* @returns The bare model ID for CLI usage
*/
export function getBareModelIdForCli(modelId: string): string {
if (!modelId) return modelId;
// Cursor models
if (modelId.startsWith('cursor-')) {
const bareId = modelId.slice(7); // Remove 'cursor-'
// For GPT models, keep the gpt- prefix since that's what the CLI expects
// e.g., 'cursor-gpt-5.2' -> 'gpt-5.2'
return bareId;
}
// OpenCode models - strip prefix
if (modelId.startsWith('opencode-')) {
return modelId.slice(9); // Remove 'opencode-'
}
// Codex models - strip prefix
if (modelId.startsWith('codex-')) {
return modelId.slice(6); // Remove 'codex-'
}
// Claude and other models - pass through
return modelId;
}

View File

@@ -4,12 +4,42 @@
import type { CursorModelId } from './cursor-models.js';
import type { OpencodeModelId } from './opencode-models.js';
/**
* Canonical Claude model IDs with provider prefix
* Used for internal storage and consistent provider routing.
*/
export type ClaudeCanonicalId = 'claude-haiku' | 'claude-sonnet' | 'claude-opus';
/**
* Canonical Claude model map - maps prefixed IDs to full model strings
* Use these IDs for internal storage and routing.
*/
export const CLAUDE_CANONICAL_MAP: Record<ClaudeCanonicalId, string> = {
'claude-haiku': 'claude-haiku-4-5-20251001',
'claude-sonnet': 'claude-sonnet-4-5-20250929',
'claude-opus': 'claude-opus-4-5-20251101',
} as const;
/**
* Legacy Claude model aliases (short names) for backward compatibility
* These map to the same full model strings as the canonical map.
* @deprecated Use CLAUDE_CANONICAL_MAP for new code
*/
export const CLAUDE_MODEL_MAP: Record<string, string> = {
haiku: 'claude-haiku-4-5-20251001',
sonnet: 'claude-sonnet-4-5-20250929',
opus: 'claude-opus-4-5-20251101',
} as const;
/**
* Map from legacy aliases to canonical IDs
*/
export const LEGACY_CLAUDE_ALIAS_MAP: Record<string, ClaudeCanonicalId> = {
haiku: 'claude-haiku',
sonnet: 'claude-sonnet',
opus: 'claude-opus',
} as const;
/**
* Codex/OpenAI model identifiers
* Based on OpenAI Codex CLI official models
@@ -62,10 +92,11 @@ export function getAllCodexModelIds(): CodexModelId[] {
/**
* Default models per provider
* Uses canonical prefixed IDs for consistent routing.
*/
export const DEFAULT_MODELS = {
claude: 'claude-opus-4-5-20251101',
cursor: 'auto', // Cursor's recommended default
cursor: 'cursor-auto', // Cursor's recommended default (with prefix)
codex: CODEX_MODEL_MAP.gpt52Codex, // GPT-5.2-Codex is the most advanced agentic coding model
} as const;

View File

@@ -1,9 +1,22 @@
/**
* OpenCode Model IDs
* Models available via OpenCode CLI (opencode models command)
*
* All OpenCode model IDs use 'opencode-' prefix for consistent provider routing.
* This prevents naming collisions and ensures clear provider attribution.
*/
export type OpencodeModelId =
// OpenCode Free Tier Models
| 'opencode-big-pickle'
| 'opencode-glm-4.7-free'
| 'opencode-gpt-5-nano'
| 'opencode-grok-code'
| 'opencode-minimax-m2.1-free';
/**
* Legacy OpenCode model IDs (with slash format) for migration support
*/
export type LegacyOpencodeModelId =
| 'opencode/big-pickle'
| 'opencode/glm-4.7-free'
| 'opencode/gpt-5-nano'
@@ -20,16 +33,27 @@ export type OpencodeProvider = 'opencode';
*/
export const OPENCODE_MODEL_MAP: Record<string, OpencodeModelId> = {
// OpenCode free tier aliases
'big-pickle': 'opencode/big-pickle',
pickle: 'opencode/big-pickle',
'glm-free': 'opencode/glm-4.7-free',
'gpt-nano': 'opencode/gpt-5-nano',
nano: 'opencode/gpt-5-nano',
'grok-code': 'opencode/grok-code',
grok: 'opencode/grok-code',
minimax: 'opencode/minimax-m2.1-free',
'big-pickle': 'opencode-big-pickle',
pickle: 'opencode-big-pickle',
'glm-free': 'opencode-glm-4.7-free',
'gpt-nano': 'opencode-gpt-5-nano',
nano: 'opencode-gpt-5-nano',
'grok-code': 'opencode-grok-code',
grok: 'opencode-grok-code',
minimax: 'opencode-minimax-m2.1-free',
} as const;
/**
* Map from legacy slash-format model IDs to canonical prefixed IDs
*/
export const LEGACY_OPENCODE_MODEL_MAP: Record<LegacyOpencodeModelId, OpencodeModelId> = {
'opencode/big-pickle': 'opencode-big-pickle',
'opencode/glm-4.7-free': 'opencode-glm-4.7-free',
'opencode/gpt-5-nano': 'opencode-gpt-5-nano',
'opencode/grok-code': 'opencode-grok-code',
'opencode/minimax-m2.1-free': 'opencode-minimax-m2.1-free',
};
/**
* OpenCode model metadata
*/
@@ -44,11 +68,12 @@ export interface OpencodeModelConfig {
/**
* Complete list of OpenCode model configurations
* All IDs use 'opencode-' prefix for consistent provider routing.
*/
export const OPENCODE_MODELS: OpencodeModelConfig[] = [
// OpenCode Free Tier Models
{
id: 'opencode/big-pickle',
id: 'opencode-big-pickle',
label: 'Big Pickle',
description: 'OpenCode free tier model - great for general coding',
supportsVision: false,
@@ -56,7 +81,7 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
tier: 'free',
},
{
id: 'opencode/glm-4.7-free',
id: 'opencode-glm-4.7-free',
label: 'GLM 4.7 Free',
description: 'OpenCode free tier GLM model',
supportsVision: false,
@@ -64,7 +89,7 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
tier: 'free',
},
{
id: 'opencode/gpt-5-nano',
id: 'opencode-gpt-5-nano',
label: 'GPT-5 Nano',
description: 'OpenCode free tier nano model - fast and lightweight',
supportsVision: false,
@@ -72,7 +97,7 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
tier: 'free',
},
{
id: 'opencode/grok-code',
id: 'opencode-grok-code',
label: 'Grok Code',
description: 'OpenCode free tier Grok model for coding',
supportsVision: false,
@@ -80,7 +105,7 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
tier: 'free',
},
{
id: 'opencode/minimax-m2.1-free',
id: 'opencode-minimax-m2.1-free',
label: 'MiniMax M2.1 Free',
description: 'OpenCode free tier MiniMax model',
supportsVision: false,
@@ -104,7 +129,7 @@ export const OPENCODE_MODEL_CONFIG_MAP: Record<OpencodeModelId, OpencodeModelCon
/**
* Default OpenCode model - OpenCode free tier
*/
export const DEFAULT_OPENCODE_MODEL: OpencodeModelId = 'opencode/big-pickle';
export const DEFAULT_OPENCODE_MODEL: OpencodeModelId = 'opencode-big-pickle';
/**
* Helper: Get display name for model

View File

@@ -7,9 +7,9 @@
*/
import type { ModelProvider } from './settings.js';
import { CURSOR_MODEL_MAP } from './cursor-models.js';
import { CURSOR_MODEL_MAP, LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js';
import { OPENCODE_MODEL_CONFIG_MAP } from './opencode-models.js';
import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
/** Provider prefix constants */
export const PROVIDER_PREFIXES = {
@@ -21,20 +21,23 @@ export const PROVIDER_PREFIXES = {
/**
* Check if a model string represents a Cursor model
*
* @param model - Model string to check (e.g., "cursor-composer-1" or "composer-1")
* @returns true if the model is a Cursor model (excluding Codex-specific models)
* With canonical model IDs, Cursor models always have 'cursor-' prefix.
* Legacy IDs without prefix are handled by migration utilities.
*
* @param model - Model string to check (e.g., "cursor-auto", "cursor-composer-1")
* @returns true if the model is a Cursor model
*/
export function isCursorModel(model: string | undefined | null): boolean {
if (!model || typeof model !== 'string') return false;
// Check for explicit cursor- prefix
// Canonical format: all Cursor models have cursor- prefix
if (model.startsWith(PROVIDER_PREFIXES.cursor)) {
return true;
}
// Check if it's a bare Cursor model ID (excluding Codex-specific models)
// Codex-specific models should always route to Codex provider, not Cursor
if (model in CURSOR_MODEL_MAP) {
// Legacy support: check if it's a known legacy bare ID
// This handles transition period before migration
if (model in LEGACY_CURSOR_MODEL_MAP) {
return true;
}
@@ -90,12 +93,14 @@ export function isCodexModel(model: string | undefined | null): boolean {
/**
* Check if a model string represents an OpenCode model
*
* With canonical model IDs, static OpenCode models use 'opencode-' prefix.
* Dynamic models from OpenCode CLI still use provider/model format.
*
* OpenCode models can be identified by:
* - Explicit 'opencode-' prefix (for routing in Automaker)
* - 'opencode/' prefix (OpenCode free tier models)
* - 'opencode-' prefix (canonical format for static models)
* - 'opencode/' prefix (legacy format, will be migrated)
* - 'amazon-bedrock/' prefix (AWS Bedrock models via OpenCode)
* - Full model ID from OPENCODE_MODEL_CONFIG_MAP
* - Dynamic models from OpenCode CLI with provider/model format (e.g., "github-copilot/gpt-4o", "google/gemini-2.5-pro")
* - Dynamic models with provider/model format (e.g., "github-copilot/gpt-4o")
*
* @param model - Model string to check
* @returns true if the model is an OpenCode model
@@ -103,19 +108,18 @@ export function isCodexModel(model: string | undefined | null): boolean {
export function isOpencodeModel(model: string | undefined | null): boolean {
if (!model || typeof model !== 'string') return false;
// Check for explicit opencode- prefix (Automaker routing prefix)
// Canonical format: opencode- prefix for static models
if (model.startsWith(PROVIDER_PREFIXES.opencode)) {
return true;
}
// Check if it's a known OpenCode model ID
// Check if it's a known OpenCode model ID (handles both formats during transition)
if (model in OPENCODE_MODEL_CONFIG_MAP) {
return true;
}
// Check for OpenCode native model prefixes
// - opencode/ = OpenCode free tier models
// - amazon-bedrock/ = AWS Bedrock models
// Legacy format: opencode/ prefix (will be migrated to opencode-)
// Also supports amazon-bedrock/ for AWS Bedrock models
if (model.startsWith('opencode/') || model.startsWith('amazon-bedrock/')) {
return true;
}
@@ -228,32 +232,47 @@ export function getBareModelId(model: string): string {
/**
* Normalize a model string to its canonical form
* - For Cursor: adds cursor- prefix if missing
* - For Codex: can add codex- prefix (but bare gpt-* is also valid)
* - For Claude: returns as-is
*
* With the new canonical format:
* - Cursor models: always have cursor- prefix
* - OpenCode models: always have opencode- prefix (static) or provider/model format (dynamic)
* - Claude models: can use legacy aliases or claude- prefix
* - Codex models: always have codex- prefix
*
* @param model - Model string to normalize
* @returns Normalized model string
*/
export function normalizeModelString(model: string | undefined | null): string {
if (!model || typeof model !== 'string') return 'sonnet'; // Default
if (!model || typeof model !== 'string') return 'claude-sonnet'; // Default to canonical
// If it's a Cursor model without prefix, add the prefix
if (model in CURSOR_MODEL_MAP && !model.startsWith(PROVIDER_PREFIXES.cursor)) {
return `${PROVIDER_PREFIXES.cursor}${model}`;
// Already has a canonical prefix - return as-is
if (
model.startsWith(PROVIDER_PREFIXES.cursor) ||
model.startsWith(PROVIDER_PREFIXES.codex) ||
model.startsWith(PROVIDER_PREFIXES.opencode) ||
model.startsWith('claude-')
) {
return model;
}
// For Codex, bare gpt-* and o-series models are valid canonical forms
// Check if it's in the CODEX_MODEL_MAP
if (model in CODEX_MODEL_MAP) {
// If it already starts with gpt- or o, it's canonical
if (model.startsWith('gpt-') || /^o\d/.test(model)) {
return model;
}
// Otherwise, it might need a prefix (though this is unlikely)
if (!model.startsWith(PROVIDER_PREFIXES.codex)) {
return `${PROVIDER_PREFIXES.codex}${model}`;
}
// Check if it's a legacy Cursor model ID
if (model in LEGACY_CURSOR_MODEL_MAP) {
return LEGACY_CURSOR_MODEL_MAP[model as keyof typeof LEGACY_CURSOR_MODEL_MAP];
}
// Check if it's a legacy OpenCode model ID
if (model in LEGACY_OPENCODE_MODEL_MAP) {
return LEGACY_OPENCODE_MODEL_MAP[model as keyof typeof LEGACY_OPENCODE_MODEL_MAP];
}
// Legacy Claude aliases
if (model in CLAUDE_MODEL_MAP) {
return `claude-${model}`;
}
// For Codex, bare gpt-* and o-series models need codex- prefix
if (model.startsWith('gpt-') || /^o\d/.test(model)) {
return `${PROVIDER_PREFIXES.codex}${model}`;
}
return model;

View File

@@ -541,6 +541,10 @@ export interface GlobalSettings {
/** Terminal font family (undefined = use default Menlo/Monaco) */
terminalFontFamily?: string;
// Terminal Configuration
/** How to open terminals from "Open in Terminal" worktree action */
openTerminalMode?: 'newTab' | 'split';
// UI State Preferences
/** Whether sidebar is currently open */
sidebarOpen: boolean;
@@ -669,6 +673,10 @@ export interface GlobalSettings {
/** Default editor command for "Open In" action (null = auto-detect: Cursor > VS Code > first available) */
defaultEditorCommand: string | null;
// Terminal Configuration
/** Default external terminal ID for "Open In Terminal" action (null = integrated terminal) */
defaultTerminalId: string | null;
// Prompt Customization
/** Custom prompts for Auto Mode, Agent Runner, Backlog Planning, and Enhancements */
promptCustomization?: PromptCustomization;
@@ -859,34 +867,42 @@ export interface ProjectSettings {
* Value: agent configuration
*/
customSubagents?: Record<string, import('./provider.js').AgentDefinition>;
// Auto Mode Configuration (per-project)
/** Whether auto mode is enabled for this project (backend-controlled loop) */
automodeEnabled?: boolean;
/** Maximum concurrent agents for this project (overrides global maxConcurrency) */
maxConcurrentAgents?: number;
}
/**
* Default values and constants
*/
/** Default phase model configuration - sensible defaults for each task type */
/** Default phase model configuration - sensible defaults for each task type
* Uses canonical prefixed model IDs for consistent routing.
*/
export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
// Quick tasks - use fast models for speed and cost
enhancementModel: { model: 'sonnet' },
fileDescriptionModel: { model: 'haiku' },
imageDescriptionModel: { model: 'haiku' },
enhancementModel: { model: 'claude-sonnet' },
fileDescriptionModel: { model: 'claude-haiku' },
imageDescriptionModel: { model: 'claude-haiku' },
// Validation - use smart models for accuracy
validationModel: { model: 'sonnet' },
validationModel: { model: 'claude-sonnet' },
// Generation - use powerful models for quality
specGenerationModel: { model: 'opus' },
featureGenerationModel: { model: 'sonnet' },
backlogPlanningModel: { model: 'sonnet' },
projectAnalysisModel: { model: 'sonnet' },
suggestionsModel: { model: 'sonnet' },
specGenerationModel: { model: 'claude-opus' },
featureGenerationModel: { model: 'claude-sonnet' },
backlogPlanningModel: { model: 'claude-sonnet' },
projectAnalysisModel: { model: 'claude-sonnet' },
suggestionsModel: { model: 'claude-sonnet' },
// Memory - use fast model for learning extraction (cost-effective)
memoryExtractionModel: { model: 'haiku' },
memoryExtractionModel: { model: 'claude-haiku' },
// Commit messages - use fast model for speed
commitMessageModel: { model: 'haiku' },
commitMessageModel: { model: 'claude-haiku' },
};
/** Current version of the global settings schema */
@@ -936,18 +952,18 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
useWorktrees: true,
defaultPlanningMode: 'skip',
defaultRequirePlanApproval: false,
defaultFeatureModel: { model: 'opus' },
defaultFeatureModel: { model: 'claude-opus' }, // Use canonical ID
muteDoneSound: false,
serverLogLevel: 'info',
enableRequestLogging: true,
enableAiCommitMessages: true,
phaseModels: DEFAULT_PHASE_MODELS,
enhancementModel: 'sonnet',
validationModel: 'opus',
enabledCursorModels: getAllCursorModelIds(),
cursorDefaultModel: 'auto',
enabledOpencodeModels: getAllOpencodeModelIds(),
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
enhancementModel: 'sonnet', // Legacy alias still supported
validationModel: 'opus', // Legacy alias still supported
enabledCursorModels: getAllCursorModelIds(), // Returns prefixed IDs
cursorDefaultModel: 'cursor-auto', // Use canonical prefixed ID
enabledOpencodeModels: getAllOpencodeModelIds(), // Returns prefixed IDs
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Already prefixed
enabledDynamicModelIds: [],
disabledProviders: [],
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
@@ -971,6 +987,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
codexThreadId: undefined,
mcpServers: [],
defaultEditorCommand: null,
defaultTerminalId: null,
enableSkills: true,
skillsSources: ['user', 'project'],
enableSubagents: true,

View File

@@ -0,0 +1,15 @@
/**
* Terminal types for the "Open In Terminal" functionality
*/
/**
* Information about an available external terminal
*/
export interface TerminalInfo {
/** Unique identifier for the terminal (e.g., 'iterm2', 'warp') */
id: string;
/** Display name of the terminal (e.g., "iTerm2", "Warp") */
name: string;
/** CLI command or open command to launch the terminal */
command: string;
}

View File

@@ -0,0 +1,32 @@
/**
* Worktree and PR-related types
* Shared across server and UI components
*/
/** GitHub PR states as returned by the GitHub API (uppercase) */
export type PRState = 'OPEN' | 'MERGED' | 'CLOSED';
/** Valid PR states for validation */
export const PR_STATES: readonly PRState[] = ['OPEN', 'MERGED', 'CLOSED'] as const;
/**
* Validates a PR state value from external APIs (e.g., GitHub CLI).
* Returns the validated state if it matches a known PRState, otherwise returns 'OPEN' as default.
* This is safer than type assertions as it handles unexpected values from external APIs.
*
* @param state - The state string to validate (can be any string)
* @returns A valid PRState value
*/
export function validatePRState(state: string | undefined | null): PRState {
return PR_STATES.find((s) => s === state) ?? 'OPEN';
}
/** PR information stored in worktree metadata */
export interface WorktreePRInfo {
number: number;
url: string;
title: string;
/** PR state: OPEN, MERGED, or CLOSED */
state: PRState;
createdAt: string;
}