Skip to content
This repository was archived by the owner on Jun 18, 2026. It is now read-only.

Commit e1eafed

Browse files
author
catlog22
committed
feat: 优化 CLI 工具配置管理,动态加载工具并简化配置路径
1 parent 9d7b770 commit e1eafed

6 files changed

Lines changed: 134 additions & 78 deletions

File tree

.claude/CLAUDE.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ Available CLI endpoints are dynamically defined by the config file:
1515
- Managed through the CCW Dashboard Status page
1616

1717

18-
1918
## Tool Execution
2019

2120
- **Context Requirements**: @~/.claude/workflows/context-tools.md

ccw/src/templates/dashboard-js/components/cli-status.js

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Displays CLI tool availability status and allows setting default tool
33

44
// ========== CLI State ==========
5-
let cliToolStatus = { gemini: {}, qwen: {}, codex: {}, claude: {} };
5+
let cliToolStatus = {}; // Dynamically populated from config
66
let codexLensStatus = { ready: false };
77
let semanticStatus = { available: false };
88
let ccwInstallStatus = { installed: true, workflowsInstalled: true, missingFiles: [], installPath: '' };
@@ -38,8 +38,8 @@ async function loadAllStatuses() {
3838
if (!response.ok) throw new Error('Failed to load status');
3939
const data = await response.json();
4040

41-
// Update all status data
42-
cliToolStatus = data.cli || { gemini: {}, qwen: {}, codex: {}, claude: {} };
41+
// Update all status data - merge with config tools to ensure all tools are tracked
42+
cliToolStatus = data.cli || {};
4343
codexLensStatus = data.codexLens || { ready: false };
4444
semanticStatus = data.semantic || { available: false };
4545
ccwInstallStatus = data.ccwInstall || { installed: true, workflowsInstalled: true, missingFiles: [], installPath: '' };
@@ -70,6 +70,7 @@ async function loadAllStatuses() {
7070
async function loadAllStatusesFallback() {
7171
console.warn('[CLI Status] Using fallback individual API calls');
7272
await Promise.all([
73+
loadCliToolsConfig(), // Ensure config is loaded (auto-creates if missing)
7374
loadCliToolStatus(),
7475
loadCodexLensStatus()
7576
]);
@@ -307,12 +308,49 @@ async function loadCliSettingsEndpoints() {
307308
function updateCliBadge() {
308309
const badge = document.getElementById('badgeCliTools');
309310
if (badge) {
310-
const available = Object.values(cliToolStatus).filter(t => t.available).length;
311-
const total = Object.keys(cliToolStatus).length;
312-
badge.textContent = `${available}/${total}`;
313-
badge.classList.toggle('text-success', available === total);
314-
badge.classList.toggle('text-warning', available > 0 && available < total);
315-
badge.classList.toggle('text-destructive', available === 0);
311+
// Merge tools from both status and config to get complete list
312+
const allTools = new Set([
313+
...Object.keys(cliToolStatus),
314+
...Object.keys(cliToolsConfig)
315+
]);
316+
317+
// Count available and enabled CLI tools
318+
let available = 0;
319+
allTools.forEach(tool => {
320+
const status = cliToolStatus[tool] || {};
321+
const config = cliToolsConfig[tool] || { enabled: true };
322+
if (status.available && config.enabled !== false) {
323+
available++;
324+
}
325+
});
326+
327+
// Also count CodexLens and Semantic Search
328+
let totalExtras = 0;
329+
let availableExtras = 0;
330+
331+
// CodexLens counts if ready
332+
if (codexLensStatus.ready) {
333+
totalExtras++;
334+
availableExtras++;
335+
} else if (codexLensStatus.ready === false) {
336+
// Only count as total if we have status info (not just initial state)
337+
totalExtras++;
338+
}
339+
340+
// Semantic Search counts if CodexLens is ready (it's a feature of CodexLens)
341+
if (codexLensStatus.ready) {
342+
totalExtras++;
343+
if (semanticStatus.available) {
344+
availableExtras++;
345+
}
346+
}
347+
348+
const total = allTools.size + totalExtras;
349+
const totalAvailable = available + availableExtras;
350+
badge.textContent = `${totalAvailable}/${total}`;
351+
badge.classList.toggle('text-success', totalAvailable === total && total > 0);
352+
badge.classList.toggle('text-warning', totalAvailable > 0 && totalAvailable < total);
353+
badge.classList.toggle('text-destructive', totalAvailable === 0);
316354
}
317355
}
318356

@@ -353,17 +391,33 @@ function renderCliStatus() {
353391
gemini: 'Google AI for code analysis',
354392
qwen: 'Alibaba AI assistant',
355393
codex: 'OpenAI code generation',
356-
claude: 'Anthropic AI assistant'
394+
claude: 'Anthropic AI assistant',
395+
opencode: 'OpenCode multi-model API'
357396
};
358397

359398
const toolIcons = {
360399
gemini: 'sparkle',
361400
qwen: 'bot',
362401
codex: 'code-2',
363-
claude: 'brain'
402+
claude: 'brain',
403+
opencode: 'globe' // Default icon for new tools
404+
};
405+
406+
// Helper to get description for any tool (with fallback)
407+
const getToolDescription = (tool) => {
408+
return toolDescriptions[tool] || `${tool.charAt(0).toUpperCase() + tool.slice(1)} CLI tool`;
409+
};
410+
411+
// Helper to get icon for any tool (with fallback)
412+
const getToolIcon = (tool) => {
413+
return toolIcons[tool] || 'terminal';
364414
};
365415

366-
const tools = ['gemini', 'qwen', 'codex', 'claude'];
416+
// Get tools dynamically from config, merging with status for complete list
417+
const tools = [...new Set([
418+
...Object.keys(cliToolsConfig),
419+
...Object.keys(cliToolStatus)
420+
])].filter(t => t && t !== '_configInfo'); // Filter out metadata keys
367421

368422
const toolsHtml = tools.map(tool => {
369423
const status = cliToolStatus[tool] || {};
@@ -429,7 +483,7 @@ function renderCliStatus() {
429483
${cliSettingsBadge}
430484
</div>
431485
<div class="cli-tool-desc text-xs text-muted-foreground mt-1">
432-
${toolDescriptions[tool]}
486+
${getToolDescription(tool)}
433487
</div>
434488
<div class="cli-tool-info mt-2 flex items-center justify-between">
435489
<div>
@@ -810,7 +864,8 @@ async function refreshAllCliStatus() {
810864
async function toggleCliTool(tool, enabled) {
811865
// If disabling the current default tool, switch to another available+enabled tool
812866
if (!enabled && defaultCliTool === tool) {
813-
const tools = ['gemini', 'qwen', 'codex', 'claude'];
867+
// Get tools dynamically from config
868+
const tools = Object.keys(cliToolsConfig).filter(t => t && t !== '_configInfo');
814869
const newDefault = tools.find(t => {
815870
if (t === tool) return false;
816871
const status = cliToolStatus[t] || {};

ccw/src/templates/dashboard-js/views/cli-manager.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,9 @@ async function renderCliManager() {
620620
if (searchInput) searchInput.parentElement.style.display = 'none';
621621

622622
// Load data (including CodexLens status for tools section)
623+
// loadCliToolsConfig() ensures cli-tools.json is auto-created if missing
623624
await Promise.all([
625+
loadCliToolsConfig(),
624626
loadCliToolStatus(),
625627
loadCodexLensStatus(),
626628
loadCcwInstallations(),

ccw/src/tools/claude-cli-tools.ts

Lines changed: 38 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
/**
22
* Claude CLI Tools Configuration Manager
3-
* Manages .claude/cli-tools.json (tools) and .claude/cli-settings.json (settings)
3+
* Manages cli-tools.json (tools) and cli-settings.json (settings)
44
*
5-
* Configuration Strategy:
6-
* - READ: Project → Global → Default (fallback chain)
7-
* - CREATE: Always in ~/.claude/ (global user-level config)
8-
* - SAVE: Based on source (project config saves to project, others to global)
5+
* Configuration Strategy (GLOBAL ONLY):
6+
* - READ: Global → Default (no project-level configs)
7+
* - CREATE/SAVE: Always in ~/.claude/ (global user-level config)
98
*
10-
* Read priority:
11-
* 1. Project workspace: {projectDir}/.claude/ (if exists)
12-
* 2. Global: ~/.claude/ (fallback)
9+
* Config location: ~/.claude/cli-tools.json
10+
* Settings location: ~/.claude/cli-settings.json
11+
*
12+
* Note: Project-level configs are NOT used - all config is user-level.
1313
*/
1414
import * as fs from 'fs';
1515
import * as path from 'path';
@@ -177,50 +177,35 @@ function getGlobalSettingsPath(): string {
177177
}
178178

179179
/**
180-
* Resolve config path with fallback:
181-
* 1. Project: {projectDir}/.claude/cli-tools.json
182-
* 2. Global: ~/.claude/cli-tools.json
183-
* Returns { path, source } where source is 'project' | 'global' | 'default'
180+
* Resolve config path - GLOBAL ONLY
181+
* Config is user-level, stored only in ~/.claude/cli-tools.json
182+
* Returns { path, source } where source is 'global' | 'default'
184183
*/
185184
function resolveConfigPath(projectDir: string): { path: string; source: 'project' | 'global' | 'default' } {
186-
const projectPath = getProjectConfigPath(projectDir);
187-
if (fs.existsSync(projectPath)) {
188-
return { path: projectPath, source: 'project' };
189-
}
190-
191185
const globalPath = getGlobalConfigPath();
192186
if (fs.existsSync(globalPath)) {
193187
return { path: globalPath, source: 'global' };
194188
}
195189

196-
return { path: projectPath, source: 'default' };
190+
// Return global path for default (will be created there)
191+
return { path: globalPath, source: 'default' };
197192
}
198193

199194
/**
200-
* Resolve settings path with fallback:
201-
* 1. Project: {projectDir}/.claude/cli-settings.json
202-
* 2. Global: ~/.claude/cli-settings.json
195+
* Resolve settings path - GLOBAL ONLY
196+
* Settings are user-level, stored only in ~/.claude/cli-settings.json
203197
*/
204198
function resolveSettingsPath(projectDir: string): { path: string; source: 'project' | 'global' | 'default' } {
205-
const projectPath = getProjectSettingsPath(projectDir);
206-
if (fs.existsSync(projectPath)) {
207-
return { path: projectPath, source: 'project' };
208-
}
209-
210199
const globalPath = getGlobalSettingsPath();
211200
if (fs.existsSync(globalPath)) {
212201
return { path: globalPath, source: 'global' };
213202
}
214203

215-
return { path: projectPath, source: 'default' };
204+
// Return global path for default (will be created there)
205+
return { path: globalPath, source: 'default' };
216206
}
217207

218-
function ensureClaudeDir(projectDir: string): void {
219-
const claudeDir = path.join(projectDir, '.claude');
220-
if (!fs.existsSync(claudeDir)) {
221-
fs.mkdirSync(claudeDir, { recursive: true });
222-
}
223-
}
208+
// NOTE: ensureClaudeDir removed - config should only be in ~/.claude/, not project directory
224209

225210
// ========== Main Functions ==========
226211

@@ -336,10 +321,8 @@ export function ensureClaudeCliTools(projectDir: string, createInProject: boolea
336321
}
337322

338323
/**
339-
* Load CLI tools configuration with fallback:
340-
* 1. Project: {projectDir}/.claude/cli-tools.json
341-
* 2. Global: ~/.claude/cli-tools.json
342-
* 3. Default config
324+
* Load CLI tools configuration from global ~/.claude/cli-tools.json
325+
* Falls back to default config if not found.
343326
*
344327
* Automatically migrates older config versions to v3.0.0
345328
*/
@@ -398,27 +381,18 @@ export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & {
398381
}
399382

400383
/**
401-
* Save CLI tools configuration
402-
* - If config was loaded from project, saves to project
403-
* - Otherwise saves to global ~/.claude/cli-tools.json
384+
* Save CLI tools configuration to global ~/.claude/cli-tools.json
385+
* Always saves to global directory (user-level config)
404386
*/
405387
export function saveClaudeCliTools(projectDir: string, config: ClaudeCliToolsConfig & { _source?: string }): void {
406388
const { _source, ...configToSave } = config;
407389

408-
// Determine save location based on source
409-
let configPath: string;
410-
if (_source === 'project') {
411-
// Config was loaded from project, save back to project
412-
ensureClaudeDir(projectDir);
413-
configPath = getProjectConfigPath(projectDir);
414-
} else {
415-
// Default: save to global directory
416-
const globalDir = path.join(os.homedir(), '.claude');
417-
if (!fs.existsSync(globalDir)) {
418-
fs.mkdirSync(globalDir, { recursive: true });
419-
}
420-
configPath = getGlobalConfigPath();
390+
// Always save to global directory
391+
const globalDir = path.join(os.homedir(), '.claude');
392+
if (!fs.existsSync(globalDir)) {
393+
fs.mkdirSync(globalDir, { recursive: true });
421394
}
395+
const configPath = getGlobalConfigPath();
422396

423397
try {
424398
fs.writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8');
@@ -430,10 +404,8 @@ export function saveClaudeCliTools(projectDir: string, config: ClaudeCliToolsCon
430404
}
431405

432406
/**
433-
* Load CLI settings configuration with fallback:
434-
* 1. Project: {projectDir}/.claude/cli-settings.json
435-
* 2. Global: ~/.claude/cli-settings.json
436-
* 3. Default settings
407+
* Load CLI settings configuration from global ~/.claude/cli-settings.json
408+
* Falls back to default settings if not found.
437409
*/
438410
export function loadClaudeCliSettings(projectDir: string): ClaudeCliSettingsConfig & { _source?: string } {
439411
const resolved = resolveSettingsPath(projectDir);
@@ -469,14 +441,19 @@ export function loadClaudeCliSettings(projectDir: string): ClaudeCliSettingsConf
469441
}
470442

471443
/**
472-
* Save CLI settings configuration to project .claude/cli-settings.json
444+
* Save CLI settings configuration to global ~/.claude/cli-settings.json
445+
* Always saves to global directory (user-level config)
473446
*/
474447
export function saveClaudeCliSettings(projectDir: string, config: ClaudeCliSettingsConfig & { _source?: string }): void {
475-
ensureClaudeDir(projectDir);
476-
const settingsPath = getProjectSettingsPath(projectDir);
477-
478448
const { _source, ...configToSave } = config;
479449

450+
// Always save to global directory
451+
const globalDir = path.join(os.homedir(), '.claude');
452+
if (!fs.existsSync(globalDir)) {
453+
fs.mkdirSync(globalDir, { recursive: true });
454+
}
455+
const settingsPath = getGlobalSettingsPath();
456+
480457
try {
481458
fs.writeFileSync(settingsPath, JSON.stringify(configToSave, null, 2), 'utf-8');
482459
console.log(`[claude-cli-tools] Saved settings to: ${settingsPath}`);

ccw/src/tools/cli-executor-core.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -859,9 +859,28 @@ export {
859859

860860
/**
861861
* Get status of all CLI tools
862+
* Dynamically reads tools from config file
862863
*/
863864
export async function getCliToolsStatus(): Promise<Record<string, ToolAvailability>> {
864-
const tools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'];
865+
// Default built-in tools
866+
const builtInTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'];
867+
868+
// Try to get tools from config
869+
let tools = builtInTools;
870+
try {
871+
// Dynamic import to avoid circular dependencies
872+
const { loadClaudeCliTools } = await import('./claude-cli-tools.js');
873+
const config = loadClaudeCliTools(configBaseDir);
874+
if (config.tools && typeof config.tools === 'object') {
875+
// Merge built-in tools with config tools to ensure all are checked
876+
const configTools = Object.keys(config.tools);
877+
tools = [...new Set([...builtInTools, ...configTools])];
878+
}
879+
} catch (e) {
880+
// Fallback to built-in tools if config load fails
881+
debugLog('cli-executor', `Using built-in tools (config load failed: ${(e as Error).message})`);
882+
}
883+
865884
const results: Record<string, ToolAvailability> = {};
866885

867886
await Promise.all(tools.map(async (tool) => {

ccw/src/utils/shell-escape.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@ const WINDOWS_METACHARS = /[&|<>()%!"]/g;
1010
export function escapeWindowsArg(arg: string): string {
1111
if (arg === '') return '""';
1212

13+
// Normalize newlines to spaces to prevent cmd.exe from
14+
// misinterpreting multiline arguments (breaks argument parsing)
15+
let sanitizedArg = arg.replace(/\r?\n/g, ' ');
16+
1317
// Escape caret first to avoid double-escaping when prefixing other metachars.
14-
let escaped = arg.replace(/\^/g, '^^');
18+
let escaped = sanitizedArg.replace(/\^/g, '^^');
1519

1620
// Escape cmd.exe metacharacters with caret.
1721
escaped = escaped.replace(WINDOWS_METACHARS, '^$&');

0 commit comments

Comments
 (0)