Refactor CLI Config Manager and Add Provider Model Routes

- Removed deprecated constants and functions from cli-config-manager.ts.
- Introduced new provider model presets in litellm-provider-models.ts for better organization and management of model information.
- Created provider-routes.ts to handle API endpoints for retrieving provider information and models.
- Added integration tests for provider routes to ensure correct functionality and response structure.
- Implemented unit tests for settings persistence functions, covering various scenarios and edge cases.
- Enhanced error handling and validation in the new routes and settings functions.
This commit is contained in:
catlog22
2026-01-25 17:27:58 +08:00
parent 7c16cc6427
commit 985085c624
13 changed files with 1252 additions and 300 deletions

View File

@@ -188,7 +188,7 @@ output → Variable name to store this step's result
``` ```
// Read task-level execution config (Single Source of Truth) // Read task-level execution config (Single Source of Truth)
const executionMethod = task.meta?.execution_config?.method || 'agent'; 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) // Phase 1: Execute pre_analysis (always by Agent)
const preAnalysisResults = {}; const preAnalysisResults = {};
@@ -240,6 +240,13 @@ ELSE (executionMethod === 'agent'):
**CLI Handoff Functions**: **CLI Handoff Functions**:
```javascript ```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 // Build CLI prompt from pre-analysis results and task
function buildCliHandoffPrompt(preAnalysisResults, task) { function buildCliHandoffPrompt(preAnalysisResults, task) {
const contextSection = Object.entries(preAnalysisResults) const contextSection = Object.entries(preAnalysisResults)
@@ -308,7 +315,7 @@ function buildCliCommand(task, cliTool, cliPrompt) {
| Field | Values | Description | | Field | Values | Description |
|-------|--------|-------------| |-------|--------|-------------|
| `method` | `agent` / `cli` / `hybrid` | Execution mode (default: agent) | | `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 | | `enable_resume` | `true` / `false` | Enable CLI session resume |
**CLI Execution Reference** (from task.cli_execution): **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 - 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): - Set timeout ≥60 minutes for CLI commands (hooks don't propagate to subagents):
```javascript ```javascript
Bash(command="ccw cli -p '...' --tool codex --mode write", timeout=3600000) // 60 min Bash(command="ccw cli -p '...' --tool <cli-tool> --mode write", timeout=3600000) // 60 min
// <cli-tool>: First enabled tool from ~/.claude/cli-tools.json (e.g., gemini, qwen, codex)
``` ```
**ALWAYS:** **ALWAYS:**

View File

@@ -477,7 +477,7 @@ Task(subagent_type="{meta.agent}",
- TODO List: {session.todo_list_path} - TODO List: {session.todo_list_path}
- Summaries: {session.summaries_dir} - 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}") description="Implement: {task.id}")
``` ```
@@ -486,9 +486,11 @@ Task(subagent_type="{meta.agent}",
- `[FLOW_CONTROL]`: Triggers flow_control.pre_analysis execution - `[FLOW_CONTROL]`: Triggers flow_control.pre_analysis execution
**Why Path-Based**: Agent (code-developer.md) autonomously: **Why Path-Based**: Agent (code-developer.md) autonomously:
- Reads and parses task JSON (requirements, acceptance, flow_control) - Reads and parses task JSON (requirements, acceptance, flow_control, execution_config)
- Loads tech stack guidelines based on detected language - Executes pre_analysis steps (Phase 1: context gathering)
- Executes pre_analysis steps and implementation_approach - 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 - Generates structured summary with integration points
Embedding task content in prompt creates duplication and conflicts with agent's parsing logic. Embedding task content in prompt creates duplication and conflicts with agent's parsing logic.

View File

@@ -115,14 +115,26 @@ const taskId = taskIdMatch?.[1]
4. **Parse Execution Intent** (from requirements text): 4. **Parse Execution Intent** (from requirements text):
```javascript ```javascript
// Extract execution method change from requirements // Dynamic tool detection from cli-tools.json
const execPatterns = { // Read enabled tools: ["gemini", "qwen", "codex", ...]
cli_codex: /使用\s*(codex|Codex)\s*执行|改用\s*(codex|Codex)/i, const enabledTools = loadEnabledToolsFromConfig(); // See ~/.claude/cli-tools.json
cli_gemini: /使用\s*(gemini|Gemini)\s*执行|改用\s*(gemini|Gemini)/i,
cli_qwen: /使用\s*(qwen|Qwen)\s*执行|改用\s*(qwen|Qwen)/i, // Build dynamic patterns from enabled tools
agent: /改为\s*Agent\s*执行|使用\s*Agent\s*执行/i 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 let executionIntent = null
for (const [key, pattern] of Object.entries(execPatterns)) { for (const [key, pattern] of Object.entries(execPatterns)) {
if (pattern.test(requirements)) { if (pattern.test(requirements)) {

View File

@@ -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<ProviderType, ModelInfo[]> = {
// 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<ProviderType, EmbeddingModelInfo[]> = {
// 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;
}

View File

@@ -1,222 +1,123 @@
/** /**
* Provider Model Presets * CLI Tool Model Reference Library
* *
* Predefined model information for each supported LLM provider. * System reference for available models per CLI tool provider.
* Used for UI dropdowns and validation. * 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'; export interface ProviderModelInfo {
/**
* Model information metadata
*/
export interface ModelInfo {
/** Model identifier (used in API calls) */
id: string; id: string;
/** Human-readable display name */
name: string; name: string;
capabilities?: string[];
contextWindow?: number;
deprecated?: boolean;
}
/** Context window size in tokens */ export interface ProviderInfo {
contextWindow: number; name: string;
models: ProviderModelInfo[];
/** Whether this model supports prompt caching */
supportsCaching: boolean;
} }
/** /**
* Embedding model information metadata * System reference for CLI tool models
* Maps provider names to their available models
*/ */
export interface EmbeddingModelInfo { export const PROVIDER_MODELS: Record<string, ProviderInfo> = {
/** Model identifier (used in API calls) */ google: {
id: string; name: 'Google AI',
models: [
/** Human-readable display name */ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', capabilities: ['text', 'vision', 'code'], contextWindow: 1000000 },
name: string; { 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 },
/** Embedding dimensions */ { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', capabilities: ['text', 'vision'], contextWindow: 2000000 },
dimensions: number; { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', capabilities: ['text'], contextWindow: 1000000 }
]
/** Maximum input tokens */ },
maxTokens: number; qwen: {
name: 'Qwen',
/** Provider identifier */ models: [
provider: string; { 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'] }
]
/** },
* Predefined models for each API format openai: {
* Used for UI selection and validation name: 'OpenAI',
* Note: Most providers use OpenAI-compatible format models: [
*/ { id: 'gpt-5.2', name: 'GPT-5.2', capabilities: ['text', 'code'] },
export const PROVIDER_MODELS: Record<ProviderType, ModelInfo[]> = { { id: 'gpt-4.1', name: 'GPT-4.1', capabilities: ['text', 'code'] },
// OpenAI-compatible format (used by OpenAI, DeepSeek, Ollama, etc.) { id: 'o4-mini', name: 'O4 Mini', capabilities: ['text'] },
openai: [ { id: 'o3', name: 'O3', capabilities: ['text'] }
{ ]
id: 'gpt-4o', },
name: 'GPT-4o', anthropic: {
contextWindow: 128000, name: 'Anthropic',
supportsCaching: true models: [
}, { id: 'sonnet', name: 'Claude Sonnet', capabilities: ['text', 'code'] },
{ { id: 'opus', name: 'Claude Opus', capabilities: ['text', 'code', 'vision'] },
id: 'gpt-4o-mini', { id: 'haiku', name: 'Claude Haiku', capabilities: ['text'] },
name: 'GPT-4o Mini', { id: 'claude-sonnet-4-5-20250929', name: 'Claude 4.5 Sonnet (2025-09-29)', capabilities: ['text', 'code'] },
contextWindow: 128000, { id: 'claude-opus-4-5-20251101', name: 'Claude 4.5 Opus (2025-11-01)', capabilities: ['text', 'code', 'vision'] }
supportsCaching: true ]
}, },
{ litellm: {
id: 'o1', name: 'LiteLLM Aggregator',
name: 'O1', models: [
contextWindow: 200000, { id: 'opencode/glm-4.7-free', name: 'GLM-4.7 Free', capabilities: ['text'] },
supportsCaching: true { 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: 'deepseek-chat', { id: 'anthropic/claude-sonnet-4-20250514', name: 'Claude Sonnet 4 (via LiteLLM)', capabilities: ['text'] },
name: 'DeepSeek Chat', { id: 'anthropic/claude-opus-4-20250514', name: 'Claude Opus 4 (via LiteLLM)', capabilities: ['text'] },
contextWindow: 64000, { id: 'openai/gpt-4.1', name: 'GPT-4.1 (via LiteLLM)', capabilities: ['text'] },
supportsCaching: false { 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'] }
id: 'deepseek-coder', ]
name: 'DeepSeek Coder', }
contextWindow: 64000, } as const;
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 * 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 * @returns Array of model information
*/ */
export function getModelsForProvider(providerType: ProviderType): ModelInfo[] { export function getProviderModels(provider: string): ProviderModelInfo[] {
return PROVIDER_MODELS[providerType] || []; return PROVIDER_MODELS[provider]?.models || [];
} }
/** /**
* Predefined embedding models for each API format * Get all provider names
* Used for UI selection and validation * @returns Array of provider names
*/ */
export const EMBEDDING_MODELS: Record<ProviderType, EmbeddingModelInfo[]> = { export function getAllProviders(): string[] {
// OpenAI embedding models return Object.keys(PROVIDER_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 * Find model information across all providers
* @param providerType - Provider type * @param modelId - Model identifier to search for
* @param modelId - Model identifier
* @returns Model information or undefined if not found * @returns Model information or undefined if not found
*/ */
export function getModelInfo(providerType: ProviderType, modelId: string): ModelInfo | undefined { export function findModelInfo(modelId: string): ProviderModelInfo | undefined {
const models = PROVIDER_MODELS[providerType] || []; for (const provider of Object.values(PROVIDER_MODELS)) {
return models.find(m => m.id === modelId); 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 * Get provider name for a model ID
* @param providerType - Provider type * @param modelId - Model identifier
* @param modelId - Model identifier to validate * @returns Provider name or undefined if not found
* @returns true if model is valid for provider
*/ */
export function isValidModel(providerType: ProviderType, modelId: string): boolean { export function getProviderForModel(modelId: string): string | undefined {
return getModelInfo(providerType, modelId) !== undefined; for (const [providerId, provider] of Object.entries(PROVIDER_MODELS)) {
if (provider.models.some(m => m.id === modelId)) {
return providerId;
}
}
return undefined;
} }

View File

@@ -8,7 +8,7 @@
import { homedir } from 'os'; import { homedir } from 'os';
import { join, resolve, dirname, relative, sep } from 'path'; import { join, resolve, dirname, relative, sep } from 'path';
import { createHash } from 'crypto'; 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'; import { readdir } from 'fs/promises';
// Environment variable override for custom storage location // Environment variable override for custom storage location
@@ -211,14 +211,29 @@ function migrateToHierarchical(legacyDir: string, targetDir: string): void {
const target = join(targetDir, subDir); const target = join(targetDir, subDir);
if (existsSync(source)) { if (existsSync(source)) {
// Use atomic rename (same filesystem) // Try atomic rename first (fastest, same filesystem)
try { try {
renameSync(source, target); renameSync(source, target);
console.log(` ✓ 迁移 ${subDir}`); console.log(` ✓ 迁移 ${subDir}`);
} catch (error: any) { } catch (error: any) {
// If rename fails (cross-filesystem), fallback to copy-delete // If rename fails (EPERM, cross-filesystem, etc.), fallback to copy-delete
// For now, we'll just throw the error if (error.code === 'EPERM' || error.code === 'EXDEV' || error.code === 'EBUSY') {
throw new Error(`无法迁移 ${subDir}: ${error.message}`); 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}`);
}
} }
} }
} }

View File

@@ -29,8 +29,7 @@ import {
loadCliConfig, loadCliConfig,
getToolConfig, getToolConfig,
updateToolConfig, updateToolConfig,
getFullConfigResponse, getFullConfigResponse
PREDEFINED_MODELS
} from '../../tools/cli-config-manager.js'; } from '../../tools/cli-config-manager.js';
import { import {
loadClaudeCliTools, loadClaudeCliTools,

View File

@@ -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<boolean> {
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;
}

View File

@@ -8,6 +8,7 @@ import { resolvePath, getRecentPaths, normalizePathForDisplay } from '../utils/p
import { handleStatusRoutes } from './routes/status-routes.js'; import { handleStatusRoutes } from './routes/status-routes.js';
import { handleCliRoutes, cleanupStaleExecutions } from './routes/cli-routes.js'; import { handleCliRoutes, cleanupStaleExecutions } from './routes/cli-routes.js';
import { handleCliSettingsRoutes } from './routes/cli-settings-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 { handleMemoryRoutes } from './routes/memory-routes.js';
import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js'; import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js';
import { handleMcpRoutes } from './routes/mcp-routes.js'; import { handleMcpRoutes } from './routes/mcp-routes.js';
@@ -518,6 +519,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleCliRoutes(routeContext)) return; if (await handleCliRoutes(routeContext)) return;
} }
// Provider routes (/api/providers/*)
if (pathname.startsWith('/api/providers')) {
if (await handleProviderRoutes(routeContext)) return;
}
// Claude CLAUDE.md routes (/api/memory/claude/*) and Language routes (/api/language/*) // Claude CLAUDE.md routes (/api/memory/claude/*) and Language routes (/api/language/*)
if (pathname.startsWith('/api/memory/claude/') || pathname.startsWith('/api/language/')) { if (pathname.startsWith('/api/memory/claude/') || pathname.startsWith('/api/language/')) {
if (await handleClaudeRoutes(routeContext)) return; if (await handleClaudeRoutes(routeContext)) return;

View File

@@ -80,7 +80,6 @@ export interface ClaudeCacheSettings {
export interface ClaudeCliToolsConfig { export interface ClaudeCliToolsConfig {
$schema?: string; $schema?: string;
version: string; version: string;
models?: Record<string, string[]>; // PREDEFINED_MODELS
tools: Record<string, ClaudeCliTool>; // All tools: builtin, cli-wrapper, api-endpoint tools: Record<string, ClaudeCliTool>; // All tools: builtin, cli-wrapper, api-endpoint
apiEndpoints?: ClaudeApiEndpoint[]; // @deprecated Use tools with type: 'api-endpoint' instead apiEndpoints?: ClaudeApiEndpoint[]; // @deprecated Use tools with type: 'api-endpoint' instead
customEndpoints?: ClaudeCustomEndpoint[]; // @deprecated Use tools with type: 'cli-wrapper' or '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; recursiveQuery: boolean;
cache: ClaudeCacheSettings; cache: ClaudeCacheSettings;
codeIndexMcp: 'codexlens' | 'ace' | 'none'; codeIndexMcp: 'codexlens' | 'ace' | 'none';
defaultModel?: string;
autoSyncEnabled?: boolean;
} }
// Legacy combined config (for backward compatibility) // Legacy combined config (for backward compatibility)
@@ -120,29 +121,8 @@ export interface ClaudeCliCombinedConfig extends ClaudeCliToolsConfig {
// ========== Default Config ========== // ========== Default Config ==========
// Predefined models for each tool
const PREDEFINED_MODELS: Record<CliToolName, string[]> = {
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 = { const DEFAULT_TOOLS_CONFIG: ClaudeCliToolsConfig = {
version: '3.2.0', version: '3.3.0',
models: { ...PREDEFINED_MODELS },
tools: { tools: {
gemini: { gemini: {
enabled: true, enabled: true,
@@ -260,6 +240,28 @@ function resolveSettingsPath(projectDir: string): { path: string; source: 'proje
// ========== Main Functions ========== // ========== 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) * Ensure tool has required fields (for backward compatibility)
*/ */
@@ -274,18 +276,31 @@ function ensureToolTags(tool: Partial<ClaudeCliTool>): 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.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'); const version = parseFloat(config.version || '1.0');
let needsMigration = false;
// Already v3.2+, no migration needed // Check if models field exists (v3.3.0 migration)
if (version >= 3.2) { 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; 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 // Try to load legacy cli-config.json for model data
let legacyCliConfig: any = null; 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 { return {
version: '3.2.0', version: '3.3.0',
models: { ...PREDEFINED_MODELS },
tools: migratedTools, tools: migratedTools,
$schema: config.$schema $schema: config.$schema
}; };
@@ -485,9 +504,8 @@ export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & {
const content = fs.readFileSync(resolved.path, 'utf-8'); const content = fs.readFileSync(resolved.path, 'utf-8');
const parsed = JSON.parse(content) as Partial<ClaudeCliCombinedConfig>; const parsed = JSON.parse(content) as Partial<ClaudeCliCombinedConfig>;
// Migrate older versions to v3.2.0 // Migrate older versions to v3.3.0 (pass config path for backup)
const migrated = migrateConfig(parsed, projectDir); const migrated = migrateConfig(parsed, projectDir, resolved.path);
const needsSave = migrated.version !== parsed.version;
// Load user-configured tools only (defaults NOT merged) // Load user-configured tools only (defaults NOT merged)
const mergedTools: Record<string, ClaudeCliTool> = {}; const mergedTools: Record<string, ClaudeCliTool> = {};
@@ -501,14 +519,15 @@ export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & {
const config: ClaudeCliToolsConfig & { _source?: string } = { const config: ClaudeCliToolsConfig & { _source?: string } = {
version: migrated.version || DEFAULT_TOOLS_CONFIG.version, version: migrated.version || DEFAULT_TOOLS_CONFIG.version,
models: migrated.models || DEFAULT_TOOLS_CONFIG.models,
tools: mergedTools, tools: mergedTools,
$schema: migrated.$schema, $schema: migrated.$schema,
_source: resolved.source _source: resolved.source
}; };
// Save migrated config if version changed // Save migrated config if version changed or models field exists
if (needsSave) { const needsVersionUpdate = migrated.version !== (parsed as any).version;
const hasModelsField = (parsed as any).models !== undefined;
if (needsVersionUpdate || hasModelsField) {
try { try {
saveClaudeCliTools(projectDir, config); saveClaudeCliTools(projectDir, config);
debugLog(`[claude-cli-tools] Saved migrated config to: ${resolved.path}`); 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' * Add API endpoint as a tool with type: 'api-endpoint'
* Usage: --tool <name> or --tool custom --model <id> * Usage: --tool <name> or --tool custom --model <id>
@@ -879,21 +1053,8 @@ export function getContextToolsPath(provider: 'codexlens' | 'ace' | 'none'): str
} }
// ========== Model Configuration Functions ========== // ========== Model Configuration Functions ==========
// 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 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<string, string[]> {
return { ...PREDEFINED_MODELS };
}
/** /**
* Get tool configuration (compatible with cli-config-manager interface) * 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): { export function getFullConfigResponse(projectDir: string): {
config: ClaudeCliToolsConfig; config: ClaudeCliToolsConfig;
predefinedModels: Record<string, string[]>;
} { } {
const config = loadClaudeCliTools(projectDir); const config = loadClaudeCliTools(projectDir);
return { return {
config, config
predefinedModels: { ...PREDEFINED_MODELS }
}; };
} }

View File

@@ -11,8 +11,6 @@ import {
saveClaudeCliTools, saveClaudeCliTools,
getToolConfig as getToolConfigFromClaude, getToolConfig as getToolConfigFromClaude,
updateToolConfig as updateToolConfigFromClaude, updateToolConfig as updateToolConfigFromClaude,
getPredefinedModels as getPredefinedModelsFromClaude,
getAllPredefinedModels,
getPrimaryModel as getPrimaryModelFromClaude, getPrimaryModel as getPrimaryModelFromClaude,
getSecondaryModel as getSecondaryModelFromClaude, getSecondaryModel as getSecondaryModelFromClaude,
isToolEnabled as isToolEnabledFromClaude, isToolEnabled as isToolEnabledFromClaude,
@@ -39,27 +37,6 @@ export interface CliConfig {
export type { CliToolName }; 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 ========== // ========== Re-exported Functions ==========
/** /**
@@ -162,19 +139,12 @@ export function getSecondaryModel(baseDir: string, tool: string): string {
return getSecondaryModelFromClaude(baseDir, tool); 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 * 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): { export function getFullConfigResponse(baseDir: string): {
config: CliConfig; config: CliConfig;
predefinedModels: Record<string, string[]>;
} { } {
const response = getFullConfigResponseFromClaude(baseDir); const response = getFullConfigResponseFromClaude(baseDir);
@@ -194,7 +164,6 @@ export function getFullConfigResponse(baseDir: string): {
config: { config: {
version: parseFloat(response.config.version) || 1, version: parseFloat(response.config.version) || 1,
tools tools
}, }
predefinedModels: response.predefinedModels
}; };
} }

View File

@@ -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<void>((resolve) => {
server.listen(PORT, () => resolve());
});
}
function stopServer() {
return new Promise<void>((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();
});
});
});

View File

@@ -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);
});
});
});