mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge remote-tracking branch 'origin/v0.13.0rc' into feature/claude-code-max-glm-api-keys
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
607
libs/platform/src/terminal.ts
Normal file
607
libs/platform/src/terminal.ts
Normal 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, "'\\''")}'`;
|
||||
}
|
||||
39
libs/spec-parser/package.json
Normal file
39
libs/spec-parser/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
26
libs/spec-parser/src/index.ts
Normal file
26
libs/spec-parser/src/index.ts
Normal 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';
|
||||
88
libs/spec-parser/src/spec-to-xml.ts
Normal file
88
libs/spec-parser/src/spec-to-xml.ts
Normal 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;
|
||||
}
|
||||
143
libs/spec-parser/src/validate.ts
Normal file
143
libs/spec-parser/src/validate.ts
Normal 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;
|
||||
}
|
||||
232
libs/spec-parser/src/xml-to-spec.ts
Normal file
232
libs/spec-parser/src/xml-to-spec.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
79
libs/spec-parser/src/xml-utils.ts
Normal file
79
libs/spec-parser/src/xml-utils.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescape XML entities back to regular characters.
|
||||
*/
|
||||
export function unescapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/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;
|
||||
}
|
||||
9
libs/spec-parser/tsconfig.json
Normal file
9
libs/spec-parser/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
218
libs/types/src/model-migration.ts
Normal file
218
libs/types/src/model-migration.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
libs/types/src/terminal.ts
Normal file
15
libs/types/src/terminal.ts
Normal 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;
|
||||
}
|
||||
32
libs/types/src/worktree.ts
Normal file
32
libs/types/src/worktree.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user