diff --git a/.claude/agents/code-developer.md b/.claude/agents/code-developer.md index 536769cf..a1273789 100644 --- a/.claude/agents/code-developer.md +++ b/.claude/agents/code-developer.md @@ -188,7 +188,7 @@ output → Variable name to store this step's result ``` // Read task-level execution config (Single Source of Truth) const executionMethod = task.meta?.execution_config?.method || 'agent'; -const cliTool = task.meta?.execution_config?.cli_tool || 'codex'; +const cliTool = task.meta?.execution_config?.cli_tool || getDefaultCliTool(); // See ~/.claude/cli-tools.json // Phase 1: Execute pre_analysis (always by Agent) const preAnalysisResults = {}; @@ -240,6 +240,13 @@ ELSE (executionMethod === 'agent'): **CLI Handoff Functions**: ```javascript +// Get default CLI tool from cli-tools.json +function getDefaultCliTool() { + // Read ~/.claude/cli-tools.json and return first enabled tool + // Fallback order: gemini → qwen → codex (first enabled in config) + return firstEnabledTool || 'gemini'; // System default fallback +} + // Build CLI prompt from pre-analysis results and task function buildCliHandoffPrompt(preAnalysisResults, task) { const contextSection = Object.entries(preAnalysisResults) @@ -308,7 +315,7 @@ function buildCliCommand(task, cliTool, cliPrompt) { | Field | Values | Description | |-------|--------|-------------| | `method` | `agent` / `cli` / `hybrid` | Execution mode (default: agent) | -| `cli_tool` | `codex` / `gemini` / `qwen` | CLI tool preference (default: codex) | +| `cli_tool` | See `~/.claude/cli-tools.json` | CLI tool preference (first enabled tool as default) | | `enable_resume` | `true` / `false` | Enable CLI session resume | **CLI Execution Reference** (from task.cli_execution): @@ -498,7 +505,8 @@ Before completing any task, verify: - Use `run_in_background=false` for all Bash/CLI calls - agent cannot receive task hook callbacks - Set timeout ≥60 minutes for CLI commands (hooks don't propagate to subagents): ```javascript - Bash(command="ccw cli -p '...' --tool codex --mode write", timeout=3600000) // 60 min + Bash(command="ccw cli -p '...' --tool --mode write", timeout=3600000) // 60 min + // : First enabled tool from ~/.claude/cli-tools.json (e.g., gemini, qwen, codex) ``` **ALWAYS:** diff --git a/.claude/commands/workflow/execute.md b/.claude/commands/workflow/execute.md index ce890fe8..94b22ad2 100644 --- a/.claude/commands/workflow/execute.md +++ b/.claude/commands/workflow/execute.md @@ -477,7 +477,7 @@ Task(subagent_type="{meta.agent}", - TODO List: {session.todo_list_path} - Summaries: {session.summaries_dir} - **Execution**: Read task JSON → Parse flow_control → Execute implementation_approach → Update TODO_LIST.md → Generate summary", + **Execution**: Read task JSON → Execute pre_analysis → Check execution_config.method → (CLI: handoff to CLI tool | Agent: direct implementation) → Update TODO_LIST.md → Generate summary", description="Implement: {task.id}") ``` @@ -486,9 +486,11 @@ Task(subagent_type="{meta.agent}", - `[FLOW_CONTROL]`: Triggers flow_control.pre_analysis execution **Why Path-Based**: Agent (code-developer.md) autonomously: -- Reads and parses task JSON (requirements, acceptance, flow_control) -- Loads tech stack guidelines based on detected language -- Executes pre_analysis steps and implementation_approach +- Reads and parses task JSON (requirements, acceptance, flow_control, execution_config) +- Executes pre_analysis steps (Phase 1: context gathering) +- Checks execution_config.method (Phase 2: determine mode) +- CLI mode: Builds handoff prompt and executes via ccw cli with resume strategy +- Agent mode: Directly implements using modification_points and logic_flow - Generates structured summary with integration points Embedding task content in prompt creates duplication and conflicts with agent's parsing logic. diff --git a/.claude/commands/workflow/replan.md b/.claude/commands/workflow/replan.md index 5d77311b..947204e8 100644 --- a/.claude/commands/workflow/replan.md +++ b/.claude/commands/workflow/replan.md @@ -115,14 +115,26 @@ const taskId = taskIdMatch?.[1] 4. **Parse Execution Intent** (from requirements text): ```javascript - // Extract execution method change from requirements - const execPatterns = { - cli_codex: /使用\s*(codex|Codex)\s*执行|改用\s*(codex|Codex)/i, - cli_gemini: /使用\s*(gemini|Gemini)\s*执行|改用\s*(gemini|Gemini)/i, - cli_qwen: /使用\s*(qwen|Qwen)\s*执行|改用\s*(qwen|Qwen)/i, - agent: /改为\s*Agent\s*执行|使用\s*Agent\s*执行/i + // Dynamic tool detection from cli-tools.json + // Read enabled tools: ["gemini", "qwen", "codex", ...] + const enabledTools = loadEnabledToolsFromConfig(); // See ~/.claude/cli-tools.json + + // Build dynamic patterns from enabled tools + function buildExecPatterns(tools) { + const patterns = { + agent: /改为\s*Agent\s*执行|使用\s*Agent\s*执行/i + }; + tools.forEach(tool => { + // Pattern: "使用 {tool} 执行" or "改用 {tool}" + patterns[`cli_${tool}`] = new RegExp( + `使用\\s*(${tool})\\s*执行|改用\\s*(${tool})`, 'i' + ); + }); + return patterns; } + const execPatterns = buildExecPatterns(enabledTools); + let executionIntent = null for (const [key, pattern] of Object.entries(execPatterns)) { if (pattern.test(requirements)) { diff --git a/ccw/src/config/litellm-provider-models.ts b/ccw/src/config/litellm-provider-models.ts new file mode 100644 index 00000000..4bcdf240 --- /dev/null +++ b/ccw/src/config/litellm-provider-models.ts @@ -0,0 +1,222 @@ +/** + * Provider Model Presets + * + * Predefined model information for each supported LLM provider. + * Used for UI dropdowns and validation. + */ + +import type { ProviderType } from '../types/litellm-api-config.js'; + +/** + * Model information metadata + */ +export interface ModelInfo { + /** Model identifier (used in API calls) */ + id: string; + + /** Human-readable display name */ + name: string; + + /** Context window size in tokens */ + contextWindow: number; + + /** Whether this model supports prompt caching */ + supportsCaching: boolean; +} + +/** + * Embedding model information metadata + */ +export interface EmbeddingModelInfo { + /** Model identifier (used in API calls) */ + id: string; + + /** Human-readable display name */ + name: string; + + /** Embedding dimensions */ + dimensions: number; + + /** Maximum input tokens */ + maxTokens: number; + + /** Provider identifier */ + provider: string; +} + + +/** + * Predefined models for each API format + * Used for UI selection and validation + * Note: Most providers use OpenAI-compatible format + */ +export const PROVIDER_MODELS: Record = { + // OpenAI-compatible format (used by OpenAI, DeepSeek, Ollama, etc.) + openai: [ + { + id: 'gpt-4o', + name: 'GPT-4o', + contextWindow: 128000, + supportsCaching: true + }, + { + id: 'gpt-4o-mini', + name: 'GPT-4o Mini', + contextWindow: 128000, + supportsCaching: true + }, + { + id: 'o1', + name: 'O1', + contextWindow: 200000, + supportsCaching: true + }, + { + id: 'deepseek-chat', + name: 'DeepSeek Chat', + contextWindow: 64000, + supportsCaching: false + }, + { + id: 'deepseek-coder', + name: 'DeepSeek Coder', + contextWindow: 64000, + supportsCaching: false + }, + { + id: 'llama3.2', + name: 'Llama 3.2', + contextWindow: 128000, + supportsCaching: false + }, + { + id: 'qwen2.5-coder', + name: 'Qwen 2.5 Coder', + contextWindow: 32000, + supportsCaching: false + } + ], + + // Anthropic format + anthropic: [ + { + id: 'claude-sonnet-4-20250514', + name: 'Claude Sonnet 4', + contextWindow: 200000, + supportsCaching: true + }, + { + id: 'claude-3-5-sonnet-20241022', + name: 'Claude 3.5 Sonnet', + contextWindow: 200000, + supportsCaching: true + }, + { + id: 'claude-3-5-haiku-20241022', + name: 'Claude 3.5 Haiku', + contextWindow: 200000, + supportsCaching: true + }, + { + id: 'claude-3-opus-20240229', + name: 'Claude 3 Opus', + contextWindow: 200000, + supportsCaching: false + } + ], + + // Custom format + custom: [ + { + id: 'custom-model', + name: 'Custom Model', + contextWindow: 128000, + supportsCaching: false + } + ] +}; + +/** + * Get models for a specific provider + * @param providerType - Provider type to get models for + * @returns Array of model information + */ +export function getModelsForProvider(providerType: ProviderType): ModelInfo[] { + return PROVIDER_MODELS[providerType] || []; +} + +/** + * Predefined embedding models for each API format + * Used for UI selection and validation + */ +export const EMBEDDING_MODELS: Record = { + // OpenAI embedding models + openai: [ + { + id: 'text-embedding-3-small', + name: 'Text Embedding 3 Small', + dimensions: 1536, + maxTokens: 8191, + provider: 'openai' + }, + { + id: 'text-embedding-3-large', + name: 'Text Embedding 3 Large', + dimensions: 3072, + maxTokens: 8191, + provider: 'openai' + }, + { + id: 'text-embedding-ada-002', + name: 'Ada 002', + dimensions: 1536, + maxTokens: 8191, + provider: 'openai' + } + ], + + // Anthropic doesn't have embedding models + anthropic: [], + + // Custom embedding models + custom: [ + { + id: 'custom-embedding', + name: 'Custom Embedding', + dimensions: 1536, + maxTokens: 8192, + provider: 'custom' + } + ] +}; + +/** + * Get embedding models for a specific provider + * @param providerType - Provider type to get embedding models for + * @returns Array of embedding model information + */ +export function getEmbeddingModelsForProvider(providerType: ProviderType): EmbeddingModelInfo[] { + return EMBEDDING_MODELS[providerType] || []; +} + + +/** + * Get model information by ID within a provider + * @param providerType - Provider type + * @param modelId - Model identifier + * @returns Model information or undefined if not found + */ +export function getModelInfo(providerType: ProviderType, modelId: string): ModelInfo | undefined { + const models = PROVIDER_MODELS[providerType] || []; + return models.find(m => m.id === modelId); +} + +/** + * Validate if a model ID is supported by a provider + * @param providerType - Provider type + * @param modelId - Model identifier to validate + * @returns true if model is valid for provider + */ +export function isValidModel(providerType: ProviderType, modelId: string): boolean { + return getModelInfo(providerType, modelId) !== undefined; +} diff --git a/ccw/src/config/provider-models.ts b/ccw/src/config/provider-models.ts index 4bcdf240..ca20c6fa 100644 --- a/ccw/src/config/provider-models.ts +++ b/ccw/src/config/provider-models.ts @@ -1,222 +1,123 @@ /** - * Provider Model Presets + * CLI Tool Model Reference Library * - * Predefined model information for each supported LLM provider. - * Used for UI dropdowns and validation. + * System reference for available models per CLI tool provider. + * This is a read-only reference, NOT user configuration. + * User configuration is managed via tools.{tool}.primaryModel/secondaryModel in cli-tools.json */ -import type { ProviderType } from '../types/litellm-api-config.js'; - -/** - * Model information metadata - */ -export interface ModelInfo { - /** Model identifier (used in API calls) */ +export interface ProviderModelInfo { id: string; - - /** Human-readable display name */ name: string; + capabilities?: string[]; + contextWindow?: number; + deprecated?: boolean; +} - /** Context window size in tokens */ - contextWindow: number; - - /** Whether this model supports prompt caching */ - supportsCaching: boolean; +export interface ProviderInfo { + name: string; + models: ProviderModelInfo[]; } /** - * Embedding model information metadata + * System reference for CLI tool models + * Maps provider names to their available models */ -export interface EmbeddingModelInfo { - /** Model identifier (used in API calls) */ - id: string; - - /** Human-readable display name */ - name: string; - - /** Embedding dimensions */ - dimensions: number; - - /** Maximum input tokens */ - maxTokens: number; - - /** Provider identifier */ - provider: string; -} - - -/** - * Predefined models for each API format - * Used for UI selection and validation - * Note: Most providers use OpenAI-compatible format - */ -export const PROVIDER_MODELS: Record = { - // OpenAI-compatible format (used by OpenAI, DeepSeek, Ollama, etc.) - openai: [ - { - id: 'gpt-4o', - name: 'GPT-4o', - contextWindow: 128000, - supportsCaching: true - }, - { - id: 'gpt-4o-mini', - name: 'GPT-4o Mini', - contextWindow: 128000, - supportsCaching: true - }, - { - id: 'o1', - name: 'O1', - contextWindow: 200000, - supportsCaching: true - }, - { - id: 'deepseek-chat', - name: 'DeepSeek Chat', - contextWindow: 64000, - supportsCaching: false - }, - { - id: 'deepseek-coder', - name: 'DeepSeek Coder', - contextWindow: 64000, - supportsCaching: false - }, - { - id: 'llama3.2', - name: 'Llama 3.2', - contextWindow: 128000, - supportsCaching: false - }, - { - id: 'qwen2.5-coder', - name: 'Qwen 2.5 Coder', - contextWindow: 32000, - supportsCaching: false - } - ], - - // Anthropic format - anthropic: [ - { - id: 'claude-sonnet-4-20250514', - name: 'Claude Sonnet 4', - contextWindow: 200000, - supportsCaching: true - }, - { - id: 'claude-3-5-sonnet-20241022', - name: 'Claude 3.5 Sonnet', - contextWindow: 200000, - supportsCaching: true - }, - { - id: 'claude-3-5-haiku-20241022', - name: 'Claude 3.5 Haiku', - contextWindow: 200000, - supportsCaching: true - }, - { - id: 'claude-3-opus-20240229', - name: 'Claude 3 Opus', - contextWindow: 200000, - supportsCaching: false - } - ], - - // Custom format - custom: [ - { - id: 'custom-model', - name: 'Custom Model', - contextWindow: 128000, - supportsCaching: false - } - ] -}; +export const PROVIDER_MODELS: Record = { + google: { + name: 'Google AI', + models: [ + { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', capabilities: ['text', 'vision', 'code'], contextWindow: 1000000 }, + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', capabilities: ['text', 'code'], contextWindow: 1000000 }, + { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', capabilities: ['text'], contextWindow: 1000000 }, + { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', capabilities: ['text', 'vision'], contextWindow: 2000000 }, + { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', capabilities: ['text'], contextWindow: 1000000 } + ] + }, + qwen: { + name: 'Qwen', + models: [ + { id: 'coder-model', name: 'Qwen Coder', capabilities: ['code'] }, + { id: 'vision-model', name: 'Qwen Vision', capabilities: ['vision'] }, + { id: 'qwen2.5-coder-32b', name: 'Qwen 2.5 Coder 32B', capabilities: ['code'] } + ] + }, + openai: { + name: 'OpenAI', + models: [ + { id: 'gpt-5.2', name: 'GPT-5.2', capabilities: ['text', 'code'] }, + { id: 'gpt-4.1', name: 'GPT-4.1', capabilities: ['text', 'code'] }, + { id: 'o4-mini', name: 'O4 Mini', capabilities: ['text'] }, + { id: 'o3', name: 'O3', capabilities: ['text'] } + ] + }, + anthropic: { + name: 'Anthropic', + models: [ + { id: 'sonnet', name: 'Claude Sonnet', capabilities: ['text', 'code'] }, + { id: 'opus', name: 'Claude Opus', capabilities: ['text', 'code', 'vision'] }, + { id: 'haiku', name: 'Claude Haiku', capabilities: ['text'] }, + { id: 'claude-sonnet-4-5-20250929', name: 'Claude 4.5 Sonnet (2025-09-29)', capabilities: ['text', 'code'] }, + { id: 'claude-opus-4-5-20251101', name: 'Claude 4.5 Opus (2025-11-01)', capabilities: ['text', 'code', 'vision'] } + ] + }, + litellm: { + name: 'LiteLLM Aggregator', + models: [ + { id: 'opencode/glm-4.7-free', name: 'GLM-4.7 Free', capabilities: ['text'] }, + { id: 'opencode/gpt-5-nano', name: 'GPT-5 Nano', capabilities: ['text'] }, + { id: 'opencode/grok-code', name: 'Grok Code', capabilities: ['code'] }, + { id: 'opencode/minimax-m2.1-free', name: 'MiniMax M2.1 Free', capabilities: ['text'] }, + { id: 'anthropic/claude-sonnet-4-20250514', name: 'Claude Sonnet 4 (via LiteLLM)', capabilities: ['text'] }, + { id: 'anthropic/claude-opus-4-20250514', name: 'Claude Opus 4 (via LiteLLM)', capabilities: ['text'] }, + { id: 'openai/gpt-4.1', name: 'GPT-4.1 (via LiteLLM)', capabilities: ['text'] }, + { id: 'openai/o3', name: 'O3 (via LiteLLM)', capabilities: ['text'] }, + { id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro (via LiteLLM)', capabilities: ['text'] }, + { id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash (via LiteLLM)', capabilities: ['text'] } + ] + } +} as const; /** * Get models for a specific provider - * @param providerType - Provider type to get models for + * @param provider - Provider name (e.g., 'google', 'qwen', 'openai', 'anthropic', 'litellm') * @returns Array of model information */ -export function getModelsForProvider(providerType: ProviderType): ModelInfo[] { - return PROVIDER_MODELS[providerType] || []; +export function getProviderModels(provider: string): ProviderModelInfo[] { + return PROVIDER_MODELS[provider]?.models || []; } /** - * Predefined embedding models for each API format - * Used for UI selection and validation + * Get all provider names + * @returns Array of provider names */ -export const EMBEDDING_MODELS: Record = { - // OpenAI embedding models - openai: [ - { - id: 'text-embedding-3-small', - name: 'Text Embedding 3 Small', - dimensions: 1536, - maxTokens: 8191, - provider: 'openai' - }, - { - id: 'text-embedding-3-large', - name: 'Text Embedding 3 Large', - dimensions: 3072, - maxTokens: 8191, - provider: 'openai' - }, - { - id: 'text-embedding-ada-002', - name: 'Ada 002', - dimensions: 1536, - maxTokens: 8191, - provider: 'openai' - } - ], - - // Anthropic doesn't have embedding models - anthropic: [], - - // Custom embedding models - custom: [ - { - id: 'custom-embedding', - name: 'Custom Embedding', - dimensions: 1536, - maxTokens: 8192, - provider: 'custom' - } - ] -}; - -/** - * Get embedding models for a specific provider - * @param providerType - Provider type to get embedding models for - * @returns Array of embedding model information - */ -export function getEmbeddingModelsForProvider(providerType: ProviderType): EmbeddingModelInfo[] { - return EMBEDDING_MODELS[providerType] || []; +export function getAllProviders(): string[] { + return Object.keys(PROVIDER_MODELS); } - /** - * Get model information by ID within a provider - * @param providerType - Provider type - * @param modelId - Model identifier + * Find model information across all providers + * @param modelId - Model identifier to search for * @returns Model information or undefined if not found */ -export function getModelInfo(providerType: ProviderType, modelId: string): ModelInfo | undefined { - const models = PROVIDER_MODELS[providerType] || []; - return models.find(m => m.id === modelId); +export function findModelInfo(modelId: string): ProviderModelInfo | undefined { + for (const provider of Object.values(PROVIDER_MODELS)) { + const model = provider.models.find(m => m.id === modelId); + if (model) return model; + } + return undefined; } /** - * Validate if a model ID is supported by a provider - * @param providerType - Provider type - * @param modelId - Model identifier to validate - * @returns true if model is valid for provider + * Get provider name for a model ID + * @param modelId - Model identifier + * @returns Provider name or undefined if not found */ -export function isValidModel(providerType: ProviderType, modelId: string): boolean { - return getModelInfo(providerType, modelId) !== undefined; +export function getProviderForModel(modelId: string): string | undefined { + for (const [providerId, provider] of Object.entries(PROVIDER_MODELS)) { + if (provider.models.some(m => m.id === modelId)) { + return providerId; + } + } + return undefined; } diff --git a/ccw/src/config/storage-paths.ts b/ccw/src/config/storage-paths.ts index 47a74f54..30bfadb7 100644 --- a/ccw/src/config/storage-paths.ts +++ b/ccw/src/config/storage-paths.ts @@ -8,7 +8,7 @@ import { homedir } from 'os'; import { join, resolve, dirname, relative, sep } from 'path'; import { createHash } from 'crypto'; -import { existsSync, mkdirSync, renameSync, rmSync, readdirSync } from 'fs'; +import { existsSync, mkdirSync, renameSync, rmSync, readdirSync, cpSync } from 'fs'; import { readdir } from 'fs/promises'; // Environment variable override for custom storage location @@ -211,14 +211,29 @@ function migrateToHierarchical(legacyDir: string, targetDir: string): void { const target = join(targetDir, subDir); if (existsSync(source)) { - // Use atomic rename (same filesystem) + // Try atomic rename first (fastest, same filesystem) try { renameSync(source, target); console.log(` ✓ 迁移 ${subDir}`); } catch (error: any) { - // If rename fails (cross-filesystem), fallback to copy-delete - // For now, we'll just throw the error - throw new Error(`无法迁移 ${subDir}: ${error.message}`); + // If rename fails (EPERM, cross-filesystem, etc.), fallback to copy-delete + if (error.code === 'EPERM' || error.code === 'EXDEV' || error.code === 'EBUSY') { + try { + console.log(` ⚠️ rename 失败,使用 copy-delete 方式迁移 ${subDir}...`); + cpSync(source, target, { recursive: true, force: true }); + // Verify copy succeeded before deleting source + if (existsSync(target)) { + rmSync(source, { recursive: true, force: true }); + console.log(` ✓ 迁移 ${subDir} (copy-delete)`); + } else { + throw new Error('复制失败:目标目录不存在'); + } + } catch (copyError: any) { + throw new Error(`无法迁移 ${subDir}: ${copyError.message}`); + } + } else { + throw new Error(`无法迁移 ${subDir}: ${error.message}`); + } } } } diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts index 2c81b7cd..52e3e40e 100644 --- a/ccw/src/core/routes/cli-routes.ts +++ b/ccw/src/core/routes/cli-routes.ts @@ -29,8 +29,7 @@ import { loadCliConfig, getToolConfig, updateToolConfig, - getFullConfigResponse, - PREDEFINED_MODELS + getFullConfigResponse } from '../../tools/cli-config-manager.js'; import { loadClaudeCliTools, diff --git a/ccw/src/core/routes/provider-routes.ts b/ccw/src/core/routes/provider-routes.ts new file mode 100644 index 00000000..5725d12d --- /dev/null +++ b/ccw/src/core/routes/provider-routes.ts @@ -0,0 +1,78 @@ +/** + * Provider Reference Routes Module + * Handles read-only provider model reference API endpoints + */ + +import type { RouteContext } from './types.js'; +import { + PROVIDER_MODELS, + getAllProviders, + getProviderModels +} from '../../config/provider-models.js'; + +/** + * Handle Provider Reference routes + * @returns true if route was handled, false otherwise + */ +export async function handleProviderRoutes(ctx: RouteContext): Promise { + const { pathname, req, res } = ctx; + + // ========== GET ALL PROVIDERS ========== + // GET /api/providers + if (pathname === '/api/providers' && req.method === 'GET') { + try { + const providers = getAllProviders().map(id => ({ + id, + name: PROVIDER_MODELS[id].name, + modelCount: PROVIDER_MODELS[id].models.length + })); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, providers })); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: (err as Error).message + })); + } + return true; + } + + // ========== GET MODELS FOR PROVIDER ========== + // GET /api/providers/:provider/models + const providerMatch = pathname.match(/^\/api\/providers\/([^\/]+)\/models$/); + if (providerMatch && req.method === 'GET') { + const provider = decodeURIComponent(providerMatch[1]); + + try { + const models = getProviderModels(provider); + + if (models.length === 0) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: `Provider not found: ${provider}` + })); + return true; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + provider, + providerName: PROVIDER_MODELS[provider].name, + models + })); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: (err as Error).message + })); + } + return true; + } + + return false; +} diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index 5b5be4d9..f401c979 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -8,6 +8,7 @@ import { resolvePath, getRecentPaths, normalizePathForDisplay } from '../utils/p import { handleStatusRoutes } from './routes/status-routes.js'; import { handleCliRoutes, cleanupStaleExecutions } from './routes/cli-routes.js'; import { handleCliSettingsRoutes } from './routes/cli-settings-routes.js'; +import { handleProviderRoutes } from './routes/provider-routes.js'; import { handleMemoryRoutes } from './routes/memory-routes.js'; import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js'; import { handleMcpRoutes } from './routes/mcp-routes.js'; @@ -518,6 +519,11 @@ export async function startServer(options: ServerOptions = {}): Promise; // PREDEFINED_MODELS tools: Record; // All tools: builtin, cli-wrapper, api-endpoint apiEndpoints?: ClaudeApiEndpoint[]; // @deprecated Use tools with type: 'api-endpoint' instead customEndpoints?: ClaudeCustomEndpoint[]; // @deprecated Use tools with type: 'cli-wrapper' or 'api-endpoint' instead @@ -100,6 +99,8 @@ export interface ClaudeCliSettingsConfig { recursiveQuery: boolean; cache: ClaudeCacheSettings; codeIndexMcp: 'codexlens' | 'ace' | 'none'; + defaultModel?: string; + autoSyncEnabled?: boolean; } // Legacy combined config (for backward compatibility) @@ -120,29 +121,8 @@ export interface ClaudeCliCombinedConfig extends ClaudeCliToolsConfig { // ========== Default Config ========== -// Predefined models for each tool -const PREDEFINED_MODELS: Record = { - gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'], - qwen: ['coder-model', 'vision-model', 'qwen2.5-coder-32b'], - codex: ['gpt-5.2', 'gpt-4.1', 'o4-mini', 'o3'], - claude: ['sonnet', 'opus', 'haiku', 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'], - opencode: [ - 'opencode/glm-4.7-free', - 'opencode/gpt-5-nano', - 'opencode/grok-code', - 'opencode/minimax-m2.1-free', - 'anthropic/claude-sonnet-4-20250514', - 'anthropic/claude-opus-4-20250514', - 'openai/gpt-4.1', - 'openai/o3', - 'google/gemini-2.5-pro', - 'google/gemini-2.5-flash' - ] -}; - const DEFAULT_TOOLS_CONFIG: ClaudeCliToolsConfig = { - version: '3.2.0', - models: { ...PREDEFINED_MODELS }, + version: '3.3.0', tools: { gemini: { enabled: true, @@ -260,6 +240,28 @@ function resolveSettingsPath(projectDir: string): { path: string; source: 'proje // ========== Main Functions ========== +/** + * Create a timestamped backup of the config file + * @param filePath - Path to the config file to backup + * @returns Path to the backup file + */ +function backupConfigFile(filePath: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0] + '-' + + new Date().toISOString().replace(/[:.]/g, '-').split('T')[1].substring(0, 8); + const backupPath = `${filePath}.${timestamp}.bak`; + + try { + if (fs.existsSync(filePath)) { + fs.copyFileSync(filePath, backupPath); + debugLog(`[claude-cli-tools] Created backup: ${backupPath}`); + } + return backupPath; + } catch (err) { + console.warn('[claude-cli-tools] Failed to create backup:', err); + return ''; + } +} + /** * Ensure tool has required fields (for backward compatibility) */ @@ -274,18 +276,31 @@ function ensureToolTags(tool: Partial): ClaudeCliTool { } /** - * Migrate config from older versions to v3.2.0 + * Migrate config from older versions to v3.3.0 * v3.2.0: All endpoints (cli-wrapper, api-endpoint) are in tools with type field + * v3.3.0: Remove models field (moved to system reference) */ -function migrateConfig(config: any, projectDir: string): ClaudeCliToolsConfig { +function migrateConfig(config: any, projectDir: string, configPath?: string): ClaudeCliToolsConfig { const version = parseFloat(config.version || '1.0'); + let needsMigration = false; - // Already v3.2+, no migration needed - if (version >= 3.2) { + // Check if models field exists (v3.3.0 migration) + if (config.models) { + needsMigration = true; + debugLog('[claude-cli-tools] Detected models field, will remove (moved to system reference)'); + } + + // Already v3.3+, no migration needed + if (version >= 3.3 && !needsMigration) { return config as ClaudeCliToolsConfig; } - debugLog(`[claude-cli-tools] Migrating config from v${config.version || '1.0'} to v3.2.0`); + // Create backup before migration if config path is provided + if (configPath && (version < 3.3 || needsMigration)) { + backupConfigFile(configPath); + } + + debugLog(`[claude-cli-tools] Migrating config from v${config.version || '1.0'} to v3.3.0`); // Try to load legacy cli-config.json for model data let legacyCliConfig: any = null; @@ -372,9 +387,13 @@ function migrateConfig(config: any, projectDir: string): ClaudeCliToolsConfig { } } + // Remove models field if it exists (v3.3.0 migration) + if (config.models) { + debugLog('[claude-cli-tools] Removed models field (moved to system reference)'); + } + return { - version: '3.2.0', - models: { ...PREDEFINED_MODELS }, + version: '3.3.0', tools: migratedTools, $schema: config.$schema }; @@ -485,9 +504,8 @@ export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & { const content = fs.readFileSync(resolved.path, 'utf-8'); const parsed = JSON.parse(content) as Partial; - // Migrate older versions to v3.2.0 - const migrated = migrateConfig(parsed, projectDir); - const needsSave = migrated.version !== parsed.version; + // Migrate older versions to v3.3.0 (pass config path for backup) + const migrated = migrateConfig(parsed, projectDir, resolved.path); // Load user-configured tools only (defaults NOT merged) const mergedTools: Record = {}; @@ -501,14 +519,15 @@ export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & { const config: ClaudeCliToolsConfig & { _source?: string } = { version: migrated.version || DEFAULT_TOOLS_CONFIG.version, - models: migrated.models || DEFAULT_TOOLS_CONFIG.models, tools: mergedTools, $schema: migrated.$schema, _source: resolved.source }; - // Save migrated config if version changed - if (needsSave) { + // Save migrated config if version changed or models field exists + const needsVersionUpdate = migrated.version !== (parsed as any).version; + const hasModelsField = (parsed as any).models !== undefined; + if (needsVersionUpdate || hasModelsField) { try { saveClaudeCliTools(projectDir, config); debugLog(`[claude-cli-tools] Saved migrated config to: ${resolved.path}`); @@ -674,6 +693,161 @@ export function getDefaultTool(projectDir: string): string { } } +// ========== Settings Persistence Functions ========== + +/** + * Update prompt format setting + * @param projectDir - Project directory path + * @param format - Prompt format: 'plain' | 'yaml' | 'json' + * @returns Updated settings config + */ +export function setPromptFormat( + projectDir: string, + format: 'plain' | 'yaml' | 'json' +): ClaudeCliSettingsConfig { + const settings = loadClaudeCliSettings(projectDir); + settings.promptFormat = format; + saveClaudeCliSettings(projectDir, settings); + return settings; +} + +/** + * Get prompt format setting + * @param projectDir - Project directory path + * @returns Current prompt format or 'plain' as fallback + */ +export function getPromptFormat(projectDir: string): 'plain' | 'yaml' | 'json' { + try { + const settings = loadClaudeCliSettings(projectDir); + return settings.promptFormat || 'plain'; + } catch { + return 'plain'; + } +} + +/** + * Update default model setting + * @param projectDir - Project directory path + * @param model - Default model name + * @returns Updated settings config + */ +export function setDefaultModel( + projectDir: string, + model: string +): ClaudeCliSettingsConfig { + const settings = loadClaudeCliSettings(projectDir); + settings.defaultModel = model; + saveClaudeCliSettings(projectDir, settings); + return settings; +} + +/** + * Get default model setting + * @param projectDir - Project directory path + * @returns Current default model or undefined if not set + */ +export function getDefaultModel(projectDir: string): string | undefined { + try { + const settings = loadClaudeCliSettings(projectDir); + return settings.defaultModel; + } catch { + return undefined; + } +} + +/** + * Update auto-sync enabled setting + * @param projectDir - Project directory path + * @param enabled - Whether auto-sync is enabled + * @returns Updated settings config + */ +export function setAutoSyncEnabled( + projectDir: string, + enabled: boolean +): ClaudeCliSettingsConfig { + const settings = loadClaudeCliSettings(projectDir); + settings.autoSyncEnabled = enabled; + saveClaudeCliSettings(projectDir, settings); + return settings; +} + +/** + * Get auto-sync enabled setting + * @param projectDir - Project directory path + * @returns Current auto-sync status or undefined if not set + */ +export function getAutoSyncEnabled(projectDir: string): boolean | undefined { + try { + const settings = loadClaudeCliSettings(projectDir); + return settings.autoSyncEnabled; + } catch { + return undefined; + } +} + +/** + * Update smart context enabled setting + * @param projectDir - Project directory path + * @param enabled - Whether smart context is enabled + * @returns Updated settings config + */ +export function setSmartContextEnabled( + projectDir: string, + enabled: boolean +): ClaudeCliSettingsConfig { + const settings = loadClaudeCliSettings(projectDir); + settings.smartContext = { + ...settings.smartContext, + enabled + }; + saveClaudeCliSettings(projectDir, settings); + return settings; +} + +/** + * Get smart context enabled setting + * @param projectDir - Project directory path + * @returns Current smart context status or false as fallback + */ +export function getSmartContextEnabled(projectDir: string): boolean { + try { + const settings = loadClaudeCliSettings(projectDir); + return settings.smartContext?.enabled ?? false; + } catch { + return false; + } +} + +/** + * Update native resume setting + * @param projectDir - Project directory path + * @param enabled - Whether native resume is enabled + * @returns Updated settings config + */ +export function setNativeResume( + projectDir: string, + enabled: boolean +): ClaudeCliSettingsConfig { + const settings = loadClaudeCliSettings(projectDir); + settings.nativeResume = enabled; + saveClaudeCliSettings(projectDir, settings); + return settings; +} + +/** + * Get native resume setting + * @param projectDir - Project directory path + * @returns Current native resume status or true as fallback + */ +export function getNativeResume(projectDir: string): boolean { + try { + const settings = loadClaudeCliSettings(projectDir); + return settings.nativeResume ?? true; + } catch { + return true; + } +} + /** * Add API endpoint as a tool with type: 'api-endpoint' * Usage: --tool or --tool custom --model @@ -879,21 +1053,8 @@ export function getContextToolsPath(provider: 'codexlens' | 'ace' | 'none'): str } // ========== Model Configuration Functions ========== - -/** - * Get predefined models for a specific tool - */ -export function getPredefinedModels(tool: string): string[] { - const toolName = tool as CliToolName; - return PREDEFINED_MODELS[toolName] ? [...PREDEFINED_MODELS[toolName]] : []; -} - -/** - * Get all predefined models - */ -export function getAllPredefinedModels(): Record { - return { ...PREDEFINED_MODELS }; -} +// NOTE: Model reference data has been moved to system reference (src/config/provider-models.ts) +// User configuration only manages primaryModel/secondaryModel per tool via tools.{tool} /** * Get tool configuration (compatible with cli-config-manager interface) @@ -995,16 +1156,15 @@ export function isToolEnabled(projectDir: string, tool: string): boolean { } /** - * Get full config response for API (includes predefined models) + * Get full config response for API + * Note: Provider model reference has been moved to system reference (see provider-routes.ts) */ export function getFullConfigResponse(projectDir: string): { config: ClaudeCliToolsConfig; - predefinedModels: Record; } { const config = loadClaudeCliTools(projectDir); return { - config, - predefinedModels: { ...PREDEFINED_MODELS } + config }; } diff --git a/ccw/src/tools/cli-config-manager.ts b/ccw/src/tools/cli-config-manager.ts index 1826f70a..78f577c6 100644 --- a/ccw/src/tools/cli-config-manager.ts +++ b/ccw/src/tools/cli-config-manager.ts @@ -11,8 +11,6 @@ import { saveClaudeCliTools, getToolConfig as getToolConfigFromClaude, updateToolConfig as updateToolConfigFromClaude, - getPredefinedModels as getPredefinedModelsFromClaude, - getAllPredefinedModels, getPrimaryModel as getPrimaryModelFromClaude, getSecondaryModel as getSecondaryModelFromClaude, isToolEnabled as isToolEnabledFromClaude, @@ -39,27 +37,6 @@ export interface CliConfig { export type { CliToolName }; -// ========== Re-exported Constants ========== - -/** - * @deprecated Use getPredefinedModels() or getAllPredefinedModels() instead - */ -export const PREDEFINED_MODELS = getAllPredefinedModels(); - -/** - * @deprecated Default config is now managed in claude-cli-tools.ts - */ -export const DEFAULT_CONFIG: CliConfig = { - version: 1, - tools: { - gemini: { enabled: true, primaryModel: 'gemini-2.5-pro', secondaryModel: 'gemini-2.5-flash' }, - qwen: { enabled: true, primaryModel: 'coder-model', secondaryModel: 'coder-model' }, - codex: { enabled: true, primaryModel: 'gpt-5.2', secondaryModel: 'gpt-5.2' }, - claude: { enabled: true, primaryModel: 'sonnet', secondaryModel: 'haiku' }, - opencode: { enabled: true, primaryModel: 'opencode/glm-4.7-free', secondaryModel: 'opencode/glm-4.7-free' } - } -}; - // ========== Re-exported Functions ========== /** @@ -162,19 +139,12 @@ export function getSecondaryModel(baseDir: string, tool: string): string { return getSecondaryModelFromClaude(baseDir, tool); } -/** - * Get all predefined models for a tool - */ -export function getPredefinedModels(tool: string): string[] { - return getPredefinedModelsFromClaude(tool); -} - /** * Get full config response for API + * Note: Provider model reference has been moved to system reference (see provider-routes.ts) */ export function getFullConfigResponse(baseDir: string): { config: CliConfig; - predefinedModels: Record; } { const response = getFullConfigResponseFromClaude(baseDir); @@ -194,7 +164,6 @@ export function getFullConfigResponse(baseDir: string): { config: { version: parseFloat(response.config.version) || 1, tools - }, - predefinedModels: response.predefinedModels + } }; } diff --git a/ccw/tests/integration/provider-routes.test.ts b/ccw/tests/integration/provider-routes.test.ts new file mode 100644 index 00000000..68e1622d --- /dev/null +++ b/ccw/tests/integration/provider-routes.test.ts @@ -0,0 +1,205 @@ +/** + * Integration tests for provider routes. + * + * Notes: + * - Targets runtime implementation shipped in `ccw/dist`. + * - Exercises real HTTP request/response flow via a minimal test server. + */ + +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert'; +import http from 'node:http'; + +const providerRoutesUrl = new URL('../../dist/core/routes/provider-routes.js', import.meta.url); +providerRoutesUrl.searchParams.set('t', String(Date.now())); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let mod: any; + +before(async () => { + mod = await import(providerRoutesUrl.href); +}); + +describe('Provider Routes Integration', () => { + let server: http.Server; + const PORT = 19998; + + function startServer() { + server = http.createServer((req, res) => { + const routeContext = { + pathname: new URL(req.url!, `http://localhost:${PORT}`).pathname, + url: new URL(req.url!, `http://localhost:${PORT}`), + req, + res, + initialPath: process.cwd(), + handlePostRequest: () => {}, + broadcastToClients: () => {}, + extractSessionIdFromPath: () => null, + server + }; + + mod.handleProviderRoutes(routeContext).then((handled: boolean) => { + if (!handled) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not Found' })); + } + }).catch((err: Error) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + }); + }); + + return new Promise((resolve) => { + server.listen(PORT, () => resolve()); + }); + } + + function stopServer() { + return new Promise((resolve) => { + server.close(() => resolve()); + }); + } + + describe('GET /api/providers', () => { + it('should return list of all providers', async () => { + await startServer(); + + const response = await fetch(`http://localhost:${PORT}/api/providers`); + const data: any = await response.json(); + + assert.strictEqual(response.status, 200); + assert.strictEqual(data.success, true); + assert(Array.isArray(data.providers)); + assert(data.providers.length > 0); + assert(data.providers.some((p: any) => p.id === 'google')); + assert(data.providers.some((p: any) => p.id === 'qwen')); + assert(data.providers.some((p: any) => p.id === 'openai')); + assert(data.providers.some((p: any) => p.id === 'anthropic')); + + await stopServer(); + }); + + it('should include provider name and model count', async () => { + await startServer(); + + const response = await fetch(`http://localhost:${PORT}/api/providers`); + const data: any = await response.json(); + + const googleProvider = data.providers.find((p: any) => p.id === 'google'); + assert(googleProvider); + assert.strictEqual(googleProvider.name, 'Google AI'); + assert(typeof googleProvider.modelCount === 'number'); + assert(googleProvider.modelCount > 0); + + await stopServer(); + }); + }); + + describe('GET /api/providers/:provider/models', () => { + it('should return models for google provider', async () => { + await startServer(); + + const response = await fetch(`http://localhost:${PORT}/api/providers/google/models`); + const data: any = await response.json(); + + assert.strictEqual(response.status, 200); + assert.strictEqual(data.success, true); + assert.strictEqual(data.provider, 'google'); + assert.strictEqual(data.providerName, 'Google AI'); + assert(Array.isArray(data.models)); + assert(data.models.some((m: any) => m.id === 'gemini-2.5-pro')); + assert(data.models.some((m: any) => m.id === 'gemini-2.5-flash')); + + await stopServer(); + }); + + it('should return models with capabilities and context window', async () => { + await startServer(); + + const response = await fetch(`http://localhost:${PORT}/api/providers/google/models`); + const data: any = await response.json(); + + const geminiPro = data.models.find((m: any) => m.id === 'gemini-2.5-pro'); + assert(geminiPro); + assert.strictEqual(geminiPro.name, 'Gemini 2.5 Pro'); + assert(Array.isArray(geminiPro.capabilities)); + assert(geminiPro.capabilities.includes('text')); + assert(geminiPro.capabilities.includes('vision')); + assert(geminiPro.capabilities.includes('code')); + assert(typeof geminiPro.contextWindow === 'number'); + + await stopServer(); + }); + + it('should return models for qwen provider', async () => { + await startServer(); + + const response = await fetch(`http://localhost:${PORT}/api/providers/qwen/models`); + const data: any = await response.json(); + + assert.strictEqual(response.status, 200); + assert.strictEqual(data.success, true); + assert.strictEqual(data.provider, 'qwen'); + assert(Array.isArray(data.models)); + assert(data.models.some((m: any) => m.id === 'coder-model')); + + await stopServer(); + }); + + it('should return models for openai provider', async () => { + await startServer(); + + const response = await fetch(`http://localhost:${PORT}/api/providers/openai/models`); + const data: any = await response.json(); + + assert.strictEqual(response.status, 200); + assert.strictEqual(data.success, true); + assert.strictEqual(data.provider, 'openai'); + assert(Array.isArray(data.models)); + assert(data.models.some((m: any) => m.id === 'gpt-5.2')); + + await stopServer(); + }); + + it('should return models for anthropic provider', async () => { + await startServer(); + + const response = await fetch(`http://localhost:${PORT}/api/providers/anthropic/models`); + const data: any = await response.json(); + + assert.strictEqual(response.status, 200); + assert.strictEqual(data.success, true); + assert.strictEqual(data.provider, 'anthropic'); + assert(Array.isArray(data.models)); + assert(data.models.some((m: any) => m.id === 'sonnet')); + assert(data.models.some((m: any) => m.id === 'opus')); + + await stopServer(); + }); + + it('should return 404 for unknown provider', async () => { + await startServer(); + + const response = await fetch(`http://localhost:${PORT}/api/providers/unknown/models`); + const data: any = await response.json(); + + assert.strictEqual(response.status, 404); + assert.strictEqual(data.success, false); + assert(data.error.includes('Provider not found')); + + await stopServer(); + }); + + it('should handle URL encoding in provider name', async () => { + await startServer(); + + const response = await fetch(`http://localhost:${PORT}/api/providers/${encodeURIComponent('google')}/models`); + const data: any = await response.json(); + + assert.strictEqual(response.status, 200); + assert.strictEqual(data.success, true); + + await stopServer(); + }); + }); +}); diff --git a/ccw/tests/settings-persistence.test.ts b/ccw/tests/settings-persistence.test.ts new file mode 100644 index 00000000..6de10cd4 --- /dev/null +++ b/ccw/tests/settings-persistence.test.ts @@ -0,0 +1,375 @@ +/** + * Unit tests for Settings Persistence Functions + * + * Tests the new setter/getter functions for ClaudeCliSettingsConfig: + * - setPromptFormat / getPromptFormat + * - setDefaultModel / getDefaultModel + * - setAutoSyncEnabled / getAutoSyncEnabled + * - setSmartContextEnabled / getSmartContextEnabled + * - setNativeResume / getNativeResume + */ + +import { after, afterEach, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Set up isolated test environment +const TEST_CCW_HOME = mkdtempSync(join(tmpdir(), 'ccw-settings-test-')); +process.env.CCW_DATA_DIR = TEST_CCW_HOME; + +const claudeCliToolsPath = new URL('../dist/tools/claude-cli-tools.js', import.meta.url).href; + +describe('Settings Persistence Functions', async () => { + let claudeCliTools: any; + const testProjectDir = join(TEST_CCW_HOME, 'test-project'); + + before(async () => { + claudeCliTools = await import(claudeCliToolsPath); + }); + + after(() => { + rmSync(TEST_CCW_HOME, { recursive: true, force: true }); + }); + + afterEach(() => { + // Clean up settings file after each test + const settingsPath = join(TEST_CCW_HOME, '.claude', 'cli-settings.json'); + if (existsSync(settingsPath)) { + rmSync(settingsPath); + } + }); + + describe('setPromptFormat / getPromptFormat', () => { + it('should set and get prompt format', () => { + const result = claudeCliTools.setPromptFormat(testProjectDir, 'yaml'); + assert.equal(result.promptFormat, 'yaml'); + + const retrieved = claudeCliTools.getPromptFormat(testProjectDir); + assert.equal(retrieved, 'yaml'); + }); + + it('should persist prompt format to file', () => { + claudeCliTools.setPromptFormat(testProjectDir, 'json'); + + const settingsPath = join(TEST_CCW_HOME, '.claude', 'cli-settings.json'); + assert.ok(existsSync(settingsPath), 'Settings file should exist'); + + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + assert.equal(content.promptFormat, 'json'); + }); + + it('should update existing prompt format', () => { + claudeCliTools.setPromptFormat(testProjectDir, 'plain'); + claudeCliTools.setPromptFormat(testProjectDir, 'yaml'); + + const retrieved = claudeCliTools.getPromptFormat(testProjectDir); + assert.equal(retrieved, 'yaml'); + }); + + it('should return default when file does not exist', () => { + const retrieved = claudeCliTools.getPromptFormat(testProjectDir); + assert.equal(retrieved, 'plain'); + }); + + it('should accept all valid format values', () => { + const formats: Array<'plain' | 'yaml' | 'json'> = ['plain', 'yaml', 'json']; + + for (const format of formats) { + claudeCliTools.setPromptFormat(testProjectDir, format); + const retrieved = claudeCliTools.getPromptFormat(testProjectDir); + assert.equal(retrieved, format, `Format ${format} should be set correctly`); + } + }); + }); + + describe('setDefaultModel / getDefaultModel', () => { + it('should set and get default model', () => { + const result = claudeCliTools.setDefaultModel(testProjectDir, 'gemini-2.5-pro'); + assert.equal(result.defaultModel, 'gemini-2.5-pro'); + + const retrieved = claudeCliTools.getDefaultModel(testProjectDir); + assert.equal(retrieved, 'gemini-2.5-pro'); + }); + + it('should persist default model to file', () => { + claudeCliTools.setDefaultModel(testProjectDir, 'claude-opus-4'); + + const settingsPath = join(TEST_CCW_HOME, '.claude', 'cli-settings.json'); + assert.ok(existsSync(settingsPath), 'Settings file should exist'); + + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + assert.equal(content.defaultModel, 'claude-opus-4'); + }); + + it('should update existing default model', () => { + claudeCliTools.setDefaultModel(testProjectDir, 'gpt-4.1'); + claudeCliTools.setDefaultModel(testProjectDir, 'gpt-5.2'); + + const retrieved = claudeCliTools.getDefaultModel(testProjectDir); + assert.equal(retrieved, 'gpt-5.2'); + }); + + it('should return undefined when not set', () => { + const retrieved = claudeCliTools.getDefaultModel(testProjectDir); + assert.equal(retrieved, undefined); + }); + + it('should handle arbitrary model names', () => { + const models = ['custom-model-1', 'test-model', 'my-fine-tuned-model']; + + for (const model of models) { + claudeCliTools.setDefaultModel(testProjectDir, model); + const retrieved = claudeCliTools.getDefaultModel(testProjectDir); + assert.equal(retrieved, model, `Model ${model} should be set correctly`); + } + }); + }); + + describe('setAutoSyncEnabled / getAutoSyncEnabled', () => { + it('should set and get auto-sync enabled status', () => { + const result = claudeCliTools.setAutoSyncEnabled(testProjectDir, true); + assert.equal(result.autoSyncEnabled, true); + + const retrieved = claudeCliTools.getAutoSyncEnabled(testProjectDir); + assert.equal(retrieved, true); + }); + + it('should persist auto-sync status to file', () => { + claudeCliTools.setAutoSyncEnabled(testProjectDir, false); + + const settingsPath = join(TEST_CCW_HOME, '.claude', 'cli-settings.json'); + assert.ok(existsSync(settingsPath), 'Settings file should exist'); + + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + assert.equal(content.autoSyncEnabled, false); + }); + + it('should update existing auto-sync status', () => { + claudeCliTools.setAutoSyncEnabled(testProjectDir, true); + claudeCliTools.setAutoSyncEnabled(testProjectDir, false); + + const retrieved = claudeCliTools.getAutoSyncEnabled(testProjectDir); + assert.equal(retrieved, false); + }); + + it('should return undefined when not set', () => { + const retrieved = claudeCliTools.getAutoSyncEnabled(testProjectDir); + assert.equal(retrieved, undefined); + }); + + it('should toggle between true and false', () => { + claudeCliTools.setAutoSyncEnabled(testProjectDir, true); + let retrieved = claudeCliTools.getAutoSyncEnabled(testProjectDir); + assert.equal(retrieved, true); + + claudeCliTools.setAutoSyncEnabled(testProjectDir, false); + retrieved = claudeCliTools.getAutoSyncEnabled(testProjectDir); + assert.equal(retrieved, false); + + claudeCliTools.setAutoSyncEnabled(testProjectDir, true); + retrieved = claudeCliTools.getAutoSyncEnabled(testProjectDir); + assert.equal(retrieved, true); + }); + }); + + describe('setSmartContextEnabled / getSmartContextEnabled', () => { + it('should set and get smart context enabled status', () => { + const result = claudeCliTools.setSmartContextEnabled(testProjectDir, true); + assert.equal(result.smartContext.enabled, true); + + const retrieved = claudeCliTools.getSmartContextEnabled(testProjectDir); + assert.equal(retrieved, true); + }); + + it('should persist smart context status to file', () => { + claudeCliTools.setSmartContextEnabled(testProjectDir, true); + + const settingsPath = join(TEST_CCW_HOME, '.claude', 'cli-settings.json'); + assert.ok(existsSync(settingsPath), 'Settings file should exist'); + + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + assert.equal(content.smartContext.enabled, true); + }); + + it('should preserve other smartContext properties', () => { + // First, load settings to check default maxFiles + const settings = claudeCliTools.loadClaudeCliSettings(testProjectDir); + const defaultMaxFiles = settings.smartContext.maxFiles; + + claudeCliTools.setSmartContextEnabled(testProjectDir, true); + + const settingsPath = join(TEST_CCW_HOME, '.claude', 'cli-settings.json'); + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + + assert.equal(content.smartContext.enabled, true); + assert.equal(content.smartContext.maxFiles, defaultMaxFiles, 'maxFiles should be preserved'); + }); + + it('should return false when not set', () => { + const retrieved = claudeCliTools.getSmartContextEnabled(testProjectDir); + assert.equal(retrieved, false); + }); + }); + + describe('setNativeResume / getNativeResume', () => { + it('should set and get native resume status', () => { + const result = claudeCliTools.setNativeResume(testProjectDir, false); + assert.equal(result.nativeResume, false); + + const retrieved = claudeCliTools.getNativeResume(testProjectDir); + assert.equal(retrieved, false); + }); + + it('should persist native resume status to file', () => { + claudeCliTools.setNativeResume(testProjectDir, false); + + const settingsPath = join(TEST_CCW_HOME, '.claude', 'cli-settings.json'); + assert.ok(existsSync(settingsPath), 'Settings file should exist'); + + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + assert.equal(content.nativeResume, false); + }); + + it('should return true when not set (default)', () => { + const retrieved = claudeCliTools.getNativeResume(testProjectDir); + assert.equal(retrieved, true); + }); + }); + + describe('Multiple settings updates', () => { + it('should handle multiple settings updates in sequence', () => { + claudeCliTools.setPromptFormat(testProjectDir, 'yaml'); + claudeCliTools.setDefaultModel(testProjectDir, 'gemini-2.5-pro'); + claudeCliTools.setAutoSyncEnabled(testProjectDir, true); + claudeCliTools.setSmartContextEnabled(testProjectDir, true); + claudeCliTools.setNativeResume(testProjectDir, false); + + assert.equal(claudeCliTools.getPromptFormat(testProjectDir), 'yaml'); + assert.equal(claudeCliTools.getDefaultModel(testProjectDir), 'gemini-2.5-pro'); + assert.equal(claudeCliTools.getAutoSyncEnabled(testProjectDir), true); + assert.equal(claudeCliTools.getSmartContextEnabled(testProjectDir), true); + assert.equal(claudeCliTools.getNativeResume(testProjectDir), false); + }); + + it('should preserve existing settings when updating one', () => { + claudeCliTools.setPromptFormat(testProjectDir, 'json'); + claudeCliTools.setDefaultModel(testProjectDir, 'test-model'); + + // Update only auto-sync + claudeCliTools.setAutoSyncEnabled(testProjectDir, true); + + // Verify previous settings are preserved + assert.equal(claudeCliTools.getPromptFormat(testProjectDir), 'json'); + assert.equal(claudeCliTools.getDefaultModel(testProjectDir), 'test-model'); + assert.equal(claudeCliTools.getAutoSyncEnabled(testProjectDir), true); + }); + }); + + describe('Performance', () => { + it('should complete setter operations in under 10ms', () => { + const operations = [ + () => claudeCliTools.setPromptFormat(testProjectDir, 'yaml'), + () => claudeCliTools.setDefaultModel(testProjectDir, 'test-model'), + () => claudeCliTools.setAutoSyncEnabled(testProjectDir, true), + () => claudeCliTools.setSmartContextEnabled(testProjectDir, true), + () => claudeCliTools.setNativeResume(testProjectDir, false), + ]; + + for (const operation of operations) { + const start = Date.now(); + operation(); + const duration = Date.now() - start; + assert.ok(duration < 10, `Operation should complete in under 10ms (took ${duration}ms)`); + } + }); + + it('should complete getter operations in under 10ms', () => { + // Set up some data first + claudeCliTools.setPromptFormat(testProjectDir, 'yaml'); + claudeCliTools.setDefaultModel(testProjectDir, 'test-model'); + claudeCliTools.setAutoSyncEnabled(testProjectDir, true); + + const operations = [ + () => claudeCliTools.getPromptFormat(testProjectDir), + () => claudeCliTools.getDefaultModel(testProjectDir), + () => claudeCliTools.getAutoSyncEnabled(testProjectDir), + () => claudeCliTools.getSmartContextEnabled(testProjectDir), + () => claudeCliTools.getNativeResume(testProjectDir), + ]; + + for (const operation of operations) { + const start = Date.now(); + operation(); + const duration = Date.now() - start; + assert.ok(duration < 10, `Operation should complete in under 10ms (took ${duration}ms)`); + } + }); + }); + + describe('File corruption handling', () => { + it('should handle invalid JSON gracefully', () => { + const settingsPath = join(TEST_CCW_HOME, '.claude', 'cli-settings.json'); + const claudeDir = join(TEST_CCW_HOME, '.claude'); + + // Create .claude directory if it doesn't exist + if (!existsSync(claudeDir)) { + mkdtempSync(claudeDir); + } + + // Write invalid JSON + writeFileSync(settingsPath, '{invalid json}', 'utf-8'); + + // Getters should return defaults + assert.equal(claudeCliTools.getPromptFormat(testProjectDir), 'plain'); + assert.equal(claudeCliTools.getDefaultModel(testProjectDir), undefined); + assert.equal(claudeCliTools.getAutoSyncEnabled(testProjectDir), undefined); + }); + + it('should recover from corrupted file by overwriting', () => { + const settingsPath = join(TEST_CCW_HOME, '.claude', 'cli-settings.json'); + const claudeDir = join(TEST_CCW_HOME, '.claude'); + + // Create .claude directory if it doesn't exist + if (!existsSync(claudeDir)) { + mkdtempSync(claudeDir); + } + + // Write invalid JSON + writeFileSync(settingsPath, '{invalid json}', 'utf-8'); + + // Setter should fix the file + claudeCliTools.setPromptFormat(testProjectDir, 'yaml'); + + // Should now read correctly + assert.equal(claudeCliTools.getPromptFormat(testProjectDir), 'yaml'); + + // File should be valid JSON + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + assert.equal(content.promptFormat, 'yaml'); + }); + }); + + describe('Edge cases', () => { + it('should handle empty string for defaultModel', () => { + claudeCliTools.setDefaultModel(testProjectDir, ''); + const retrieved = claudeCliTools.getDefaultModel(testProjectDir); + assert.equal(retrieved, ''); + }); + + it('should handle very long model names', () => { + const longModelName = 'a'.repeat(1000); + claudeCliTools.setDefaultModel(testProjectDir, longModelName); + const retrieved = claudeCliTools.getDefaultModel(testProjectDir); + assert.equal(retrieved, longModelName); + }); + + it('should handle special characters in model names', () => { + const specialName = 'model-@#$%^&*()_+{}[]|:;<>?,./~`'; + claudeCliTools.setDefaultModel(testProjectDir, specialName); + const retrieved = claudeCliTools.getDefaultModel(testProjectDir); + assert.equal(retrieved, specialName); + }); + }); +});