mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: Refactor CLI tool configuration management and introduce skill context loader
- Updated `claude-cli-tools.ts` to support new model configurations and migration from older versions. - Added `getPredefinedModels` and `getAllPredefinedModels` functions for better model management. - Deprecated `cli-config-manager.ts` in favor of `claude-cli-tools.ts`, maintaining backward compatibility. - Introduced `skill-context-loader.ts` to handle skill context loading based on user prompts and keywords. - Enhanced tool configuration functions to include secondary models and improved migration logic. - Updated index file to register the new skill context loader tool.
This commit is contained in:
@@ -13,13 +13,13 @@ import * as os from 'os';
|
||||
|
||||
export interface ClaudeCliTool {
|
||||
enabled: boolean;
|
||||
isBuiltin: boolean;
|
||||
command: string;
|
||||
description: string;
|
||||
primaryModel?: string;
|
||||
secondaryModel?: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode';
|
||||
|
||||
export interface ClaudeCustomEndpoint {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -37,6 +37,7 @@ export interface ClaudeCacheSettings {
|
||||
export interface ClaudeCliToolsConfig {
|
||||
$schema?: string;
|
||||
version: string;
|
||||
models?: Record<string, string[]>; // PREDEFINED_MODELS
|
||||
tools: Record<string, ClaudeCliTool>;
|
||||
customEndpoints: ClaudeCustomEndpoint[];
|
||||
}
|
||||
@@ -75,43 +76,58 @@ export interface ClaudeCliCombinedConfig extends ClaudeCliToolsConfig {
|
||||
|
||||
// ========== 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 = {
|
||||
version: '2.0.0',
|
||||
version: '3.0.0',
|
||||
models: { ...PREDEFINED_MODELS },
|
||||
tools: {
|
||||
gemini: {
|
||||
enabled: true,
|
||||
isBuiltin: true,
|
||||
command: 'gemini',
|
||||
description: 'Google AI for code analysis',
|
||||
primaryModel: 'gemini-2.5-pro',
|
||||
secondaryModel: 'gemini-2.5-flash',
|
||||
tags: []
|
||||
},
|
||||
qwen: {
|
||||
enabled: true,
|
||||
isBuiltin: true,
|
||||
command: 'qwen',
|
||||
description: 'Alibaba AI assistant',
|
||||
primaryModel: 'coder-model',
|
||||
secondaryModel: 'coder-model',
|
||||
tags: []
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
isBuiltin: true,
|
||||
command: 'codex',
|
||||
description: 'OpenAI code generation',
|
||||
primaryModel: 'gpt-5.2',
|
||||
secondaryModel: 'gpt-5.2',
|
||||
tags: []
|
||||
},
|
||||
claude: {
|
||||
enabled: true,
|
||||
isBuiltin: true,
|
||||
command: 'claude',
|
||||
description: 'Anthropic AI assistant',
|
||||
primaryModel: 'sonnet',
|
||||
secondaryModel: 'haiku',
|
||||
tags: []
|
||||
},
|
||||
opencode: {
|
||||
enabled: true,
|
||||
isBuiltin: true,
|
||||
command: 'opencode',
|
||||
description: 'OpenCode AI assistant',
|
||||
primaryModel: 'opencode/glm-4.7-free',
|
||||
secondaryModel: 'opencode/glm-4.7-free',
|
||||
tags: []
|
||||
}
|
||||
},
|
||||
@@ -203,19 +219,80 @@ function ensureClaudeDir(projectDir: string): void {
|
||||
// ========== Main Functions ==========
|
||||
|
||||
/**
|
||||
* Ensure tool has tags field (for backward compatibility)
|
||||
* Ensure tool has required fields (for backward compatibility)
|
||||
*/
|
||||
function ensureToolTags(tool: Partial<ClaudeCliTool>): ClaudeCliTool {
|
||||
return {
|
||||
enabled: tool.enabled ?? true,
|
||||
isBuiltin: tool.isBuiltin ?? false,
|
||||
command: tool.command ?? '',
|
||||
description: tool.description ?? '',
|
||||
primaryModel: tool.primaryModel,
|
||||
secondaryModel: tool.secondaryModel,
|
||||
tags: tool.tags ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate config from older versions to v3.0.0
|
||||
*/
|
||||
function migrateConfig(config: any, projectDir: string): ClaudeCliToolsConfig {
|
||||
const version = parseFloat(config.version || '1.0');
|
||||
|
||||
// Already v3.x, no migration needed
|
||||
if (version >= 3.0) {
|
||||
return config as ClaudeCliToolsConfig;
|
||||
}
|
||||
|
||||
console.log(`[claude-cli-tools] Migrating config from v${config.version || '1.0'} to v3.0.0`);
|
||||
|
||||
// Try to load legacy cli-config.json for model data
|
||||
let legacyCliConfig: any = null;
|
||||
try {
|
||||
const { StoragePaths } = require('../config/storage-paths.js');
|
||||
const legacyPath = StoragePaths.project(projectDir).cliConfig;
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync(legacyPath)) {
|
||||
legacyCliConfig = JSON.parse(fs.readFileSync(legacyPath, 'utf-8'));
|
||||
console.log(`[claude-cli-tools] Found legacy cli-config.json, merging model data`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors loading legacy config
|
||||
}
|
||||
|
||||
const migratedTools: Record<string, ClaudeCliTool> = {};
|
||||
|
||||
for (const [key, tool] of Object.entries(config.tools || {})) {
|
||||
const t = tool as any;
|
||||
const legacyTool = legacyCliConfig?.tools?.[key];
|
||||
|
||||
migratedTools[key] = {
|
||||
enabled: t.enabled ?? legacyTool?.enabled ?? true,
|
||||
primaryModel: t.primaryModel ?? legacyTool?.primaryModel ?? DEFAULT_TOOLS_CONFIG.tools[key]?.primaryModel,
|
||||
secondaryModel: t.secondaryModel ?? legacyTool?.secondaryModel ?? DEFAULT_TOOLS_CONFIG.tools[key]?.secondaryModel,
|
||||
tags: t.tags ?? legacyTool?.tags ?? []
|
||||
};
|
||||
}
|
||||
|
||||
// Add any missing default tools
|
||||
for (const [key, defaultTool] of Object.entries(DEFAULT_TOOLS_CONFIG.tools)) {
|
||||
if (!migratedTools[key]) {
|
||||
const legacyTool = legacyCliConfig?.tools?.[key];
|
||||
migratedTools[key] = {
|
||||
enabled: legacyTool?.enabled ?? defaultTool.enabled,
|
||||
primaryModel: legacyTool?.primaryModel ?? defaultTool.primaryModel,
|
||||
secondaryModel: legacyTool?.secondaryModel ?? defaultTool.secondaryModel,
|
||||
tags: legacyTool?.tags ?? defaultTool.tags
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: '3.0.0',
|
||||
models: { ...PREDEFINED_MODELS },
|
||||
tools: migratedTools,
|
||||
customEndpoints: config.customEndpoints || [],
|
||||
$schema: config.$schema
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure CLI tools configuration file exists
|
||||
* Creates default config if missing (auto-rebuild feature)
|
||||
@@ -270,6 +347,8 @@ export function ensureClaudeCliTools(projectDir: string, createInProject: boolea
|
||||
* 1. Project: {projectDir}/.claude/cli-tools.json
|
||||
* 2. Global: ~/.claude/cli-tools.json
|
||||
* 3. Default config
|
||||
*
|
||||
* Automatically migrates older config versions to v3.0.0
|
||||
*/
|
||||
export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & { _source?: string } {
|
||||
const resolved = resolveConfigPath(projectDir);
|
||||
@@ -282,26 +361,41 @@ export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & {
|
||||
const content = fs.readFileSync(resolved.path, 'utf-8');
|
||||
const parsed = JSON.parse(content) as Partial<ClaudeCliCombinedConfig>;
|
||||
|
||||
// Merge tools with defaults and ensure tags exist
|
||||
// Migrate older versions to v3.0.0
|
||||
const migrated = migrateConfig(parsed, projectDir);
|
||||
const needsSave = migrated.version !== parsed.version;
|
||||
|
||||
// Merge tools with defaults and ensure required fields exist
|
||||
const mergedTools: Record<string, ClaudeCliTool> = {};
|
||||
for (const [key, tool] of Object.entries({ ...DEFAULT_TOOLS_CONFIG.tools, ...(parsed.tools || {}) })) {
|
||||
for (const [key, tool] of Object.entries({ ...DEFAULT_TOOLS_CONFIG.tools, ...(migrated.tools || {}) })) {
|
||||
mergedTools[key] = ensureToolTags(tool);
|
||||
}
|
||||
|
||||
// Ensure customEndpoints have tags
|
||||
const mergedEndpoints = (parsed.customEndpoints || []).map(ep => ({
|
||||
const mergedEndpoints = (migrated.customEndpoints || []).map(ep => ({
|
||||
...ep,
|
||||
tags: ep.tags ?? []
|
||||
}));
|
||||
|
||||
const config: ClaudeCliToolsConfig & { _source?: string } = {
|
||||
version: parsed.version || DEFAULT_TOOLS_CONFIG.version,
|
||||
version: migrated.version || DEFAULT_TOOLS_CONFIG.version,
|
||||
models: migrated.models || DEFAULT_TOOLS_CONFIG.models,
|
||||
tools: mergedTools,
|
||||
customEndpoints: mergedEndpoints,
|
||||
$schema: parsed.$schema,
|
||||
$schema: migrated.$schema,
|
||||
_source: resolved.source
|
||||
};
|
||||
|
||||
// Save migrated config if version changed
|
||||
if (needsSave) {
|
||||
try {
|
||||
saveClaudeCliTools(projectDir, config);
|
||||
console.log(`[claude-cli-tools] Saved migrated config to: ${resolved.path}`);
|
||||
} catch (err) {
|
||||
console.warn('[claude-cli-tools] Failed to save migrated config:', err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[claude-cli-tools] Loaded tools config from ${resolved.source}: ${resolved.path}`);
|
||||
return config;
|
||||
} catch (err) {
|
||||
@@ -578,3 +672,122 @@ export function getContextToolsPath(provider: 'codexlens' | 'ace' | 'none'): str
|
||||
return 'context-tools.md';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 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<string, string[]> {
|
||||
return { ...PREDEFINED_MODELS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool configuration (compatible with cli-config-manager interface)
|
||||
*/
|
||||
export function getToolConfig(projectDir: string, tool: string): {
|
||||
enabled: boolean;
|
||||
primaryModel: string;
|
||||
secondaryModel: string;
|
||||
tags?: string[];
|
||||
} {
|
||||
const config = loadClaudeCliTools(projectDir);
|
||||
const toolConfig = config.tools[tool];
|
||||
|
||||
if (!toolConfig) {
|
||||
const defaultTool = DEFAULT_TOOLS_CONFIG.tools[tool];
|
||||
return {
|
||||
enabled: defaultTool?.enabled ?? true,
|
||||
primaryModel: defaultTool?.primaryModel ?? '',
|
||||
secondaryModel: defaultTool?.secondaryModel ?? '',
|
||||
tags: defaultTool?.tags ?? []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: toolConfig.enabled,
|
||||
primaryModel: toolConfig.primaryModel ?? '',
|
||||
secondaryModel: toolConfig.secondaryModel ?? '',
|
||||
tags: toolConfig.tags
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tool configuration
|
||||
*/
|
||||
export function updateToolConfig(
|
||||
projectDir: string,
|
||||
tool: string,
|
||||
updates: Partial<{
|
||||
enabled: boolean;
|
||||
primaryModel: string;
|
||||
secondaryModel: string;
|
||||
tags: string[];
|
||||
}>
|
||||
): ClaudeCliToolsConfig {
|
||||
const config = loadClaudeCliTools(projectDir);
|
||||
|
||||
if (config.tools[tool]) {
|
||||
if (updates.enabled !== undefined) {
|
||||
config.tools[tool].enabled = updates.enabled;
|
||||
}
|
||||
if (updates.primaryModel !== undefined) {
|
||||
config.tools[tool].primaryModel = updates.primaryModel;
|
||||
}
|
||||
if (updates.secondaryModel !== undefined) {
|
||||
config.tools[tool].secondaryModel = updates.secondaryModel;
|
||||
}
|
||||
if (updates.tags !== undefined) {
|
||||
config.tools[tool].tags = updates.tags;
|
||||
}
|
||||
saveClaudeCliTools(projectDir, config);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get primary model for a tool
|
||||
*/
|
||||
export function getPrimaryModel(projectDir: string, tool: string): string {
|
||||
const toolConfig = getToolConfig(projectDir, tool);
|
||||
return toolConfig.primaryModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secondary model for a tool
|
||||
*/
|
||||
export function getSecondaryModel(projectDir: string, tool: string): string {
|
||||
const toolConfig = getToolConfig(projectDir, tool);
|
||||
return toolConfig.secondaryModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is enabled
|
||||
*/
|
||||
export function isToolEnabled(projectDir: string, tool: string): boolean {
|
||||
const toolConfig = getToolConfig(projectDir, tool);
|
||||
return toolConfig.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full config response for API (includes predefined models)
|
||||
*/
|
||||
export function getFullConfigResponse(projectDir: string): {
|
||||
config: ClaudeCliToolsConfig;
|
||||
predefinedModels: Record<string, string[]>;
|
||||
} {
|
||||
const config = loadClaudeCliTools(projectDir);
|
||||
return {
|
||||
config,
|
||||
predefinedModels: { ...PREDEFINED_MODELS }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
/**
|
||||
* CLI Configuration Manager
|
||||
* Handles loading, saving, and managing CLI tool configurations
|
||||
* Stores config in centralized storage (~/.ccw/projects/{id}/config/)
|
||||
* CLI Configuration Manager (Deprecated - Redirects to claude-cli-tools.ts)
|
||||
*
|
||||
* This module is maintained for backward compatibility.
|
||||
* All configuration is now managed by claude-cli-tools.ts using cli-tools.json
|
||||
*
|
||||
* @deprecated Use claude-cli-tools.ts directly
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
|
||||
import { loadClaudeCliTools, saveClaudeCliTools } from './claude-cli-tools.js';
|
||||
import {
|
||||
loadClaudeCliTools,
|
||||
saveClaudeCliTools,
|
||||
getToolConfig as getToolConfigFromClaude,
|
||||
updateToolConfig as updateToolConfigFromClaude,
|
||||
getPredefinedModels as getPredefinedModelsFromClaude,
|
||||
getAllPredefinedModels,
|
||||
getPrimaryModel as getPrimaryModelFromClaude,
|
||||
getSecondaryModel as getSecondaryModelFromClaude,
|
||||
isToolEnabled as isToolEnabledFromClaude,
|
||||
getFullConfigResponse as getFullConfigResponseFromClaude,
|
||||
type ClaudeCliTool,
|
||||
type ClaudeCliToolsConfig,
|
||||
type CliToolName
|
||||
} from './claude-cli-tools.js';
|
||||
|
||||
// ========== Types ==========
|
||||
// ========== Re-exported Types ==========
|
||||
|
||||
export interface CliToolConfig {
|
||||
enabled: boolean;
|
||||
primaryModel: string; // For CLI endpoint calls (ccw cli -p)
|
||||
secondaryModel: string; // For internal calls (llm_enhancer, generate_module_docs)
|
||||
tags?: string[]; // User-defined tags/labels for the tool
|
||||
primaryModel: string;
|
||||
secondaryModel: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface CliConfig {
|
||||
@@ -22,234 +36,94 @@ export interface CliConfig {
|
||||
tools: Record<string, CliToolConfig>;
|
||||
}
|
||||
|
||||
export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode';
|
||||
export type { CliToolName };
|
||||
|
||||
// ========== Constants ==========
|
||||
// ========== Re-exported Constants ==========
|
||||
|
||||
export 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'
|
||||
]
|
||||
};
|
||||
/**
|
||||
* @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', // Free model as default
|
||||
secondaryModel: 'opencode/glm-4.7-free'
|
||||
}
|
||||
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' }
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
// ========== Re-exported Functions ==========
|
||||
|
||||
function getConfigPath(baseDir: string): string {
|
||||
return StoragePaths.project(baseDir).cliConfig;
|
||||
}
|
||||
/**
|
||||
* Load CLI configuration
|
||||
* @deprecated Use loadClaudeCliTools() instead
|
||||
*/
|
||||
export function loadCliConfig(baseDir: string): CliConfig {
|
||||
const config = loadClaudeCliTools(baseDir);
|
||||
|
||||
function ensureConfigDirForProject(baseDir: string): void {
|
||||
const configDir = StoragePaths.project(baseDir).config;
|
||||
ensureStorageDir(configDir);
|
||||
}
|
||||
|
||||
function isValidToolName(tool: string): tool is CliToolName {
|
||||
return ['gemini', 'qwen', 'codex', 'claude', 'opencode'].includes(tool);
|
||||
}
|
||||
|
||||
function validateConfig(config: unknown): config is CliConfig {
|
||||
if (!config || typeof config !== 'object') return false;
|
||||
const c = config as Record<string, unknown>;
|
||||
|
||||
if (typeof c.version !== 'number') return false;
|
||||
if (!c.tools || typeof c.tools !== 'object') return false;
|
||||
|
||||
const tools = c.tools as Record<string, unknown>;
|
||||
for (const toolName of ['gemini', 'qwen', 'codex', 'claude', 'opencode']) {
|
||||
const tool = tools[toolName];
|
||||
if (!tool || typeof tool !== 'object') return false;
|
||||
|
||||
const t = tool as Record<string, unknown>;
|
||||
if (typeof t.enabled !== 'boolean') return false;
|
||||
if (typeof t.primaryModel !== 'string') return false;
|
||||
if (typeof t.secondaryModel !== 'string') return false;
|
||||
// Convert to legacy format
|
||||
const tools: Record<string, CliToolConfig> = {};
|
||||
for (const [key, tool] of Object.entries(config.tools)) {
|
||||
tools[key] = {
|
||||
enabled: tool.enabled,
|
||||
primaryModel: tool.primaryModel ?? '',
|
||||
secondaryModel: tool.secondaryModel ?? '',
|
||||
tags: tool.tags
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
return {
|
||||
version: parseFloat(config.version) || 1,
|
||||
tools
|
||||
};
|
||||
}
|
||||
|
||||
function mergeWithDefaults(config: Partial<CliConfig>): CliConfig {
|
||||
const result: CliConfig = {
|
||||
version: config.version ?? DEFAULT_CONFIG.version,
|
||||
tools: { ...DEFAULT_CONFIG.tools }
|
||||
};
|
||||
/**
|
||||
* Save CLI configuration
|
||||
* @deprecated Use saveClaudeCliTools() instead
|
||||
*/
|
||||
export function saveCliConfig(baseDir: string, config: CliConfig): void {
|
||||
const currentConfig = loadClaudeCliTools(baseDir);
|
||||
|
||||
if (config.tools) {
|
||||
for (const toolName of Object.keys(config.tools)) {
|
||||
if (isValidToolName(toolName) && config.tools[toolName]) {
|
||||
result.tools[toolName] = {
|
||||
...DEFAULT_CONFIG.tools[toolName],
|
||||
...config.tools[toolName]
|
||||
};
|
||||
// Update tools from legacy format
|
||||
for (const [key, tool] of Object.entries(config.tools)) {
|
||||
if (currentConfig.tools[key]) {
|
||||
currentConfig.tools[key].enabled = tool.enabled;
|
||||
currentConfig.tools[key].primaryModel = tool.primaryModel;
|
||||
currentConfig.tools[key].secondaryModel = tool.secondaryModel;
|
||||
if (tool.tags) {
|
||||
currentConfig.tools[key].tags = tool.tags;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ========== Main Functions ==========
|
||||
|
||||
/**
|
||||
* Load CLI configuration from .workflow/cli-config.json
|
||||
* Returns default config if file doesn't exist or is invalid
|
||||
*/
|
||||
export function loadCliConfig(baseDir: string): CliConfig {
|
||||
const configPath = getConfigPath(baseDir);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
if (validateConfig(parsed)) {
|
||||
return mergeWithDefaults(parsed);
|
||||
}
|
||||
|
||||
// Invalid config, return defaults
|
||||
console.warn('[cli-config] Invalid config file, using defaults');
|
||||
return { ...DEFAULT_CONFIG };
|
||||
} catch (err) {
|
||||
console.error('[cli-config] Error loading config:', err);
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save CLI configuration to .workflow/cli-config.json
|
||||
*/
|
||||
export function saveCliConfig(baseDir: string, config: CliConfig): void {
|
||||
ensureConfigDirForProject(baseDir);
|
||||
const configPath = getConfigPath(baseDir);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (err) {
|
||||
console.error('[cli-config] Error saving config:', err);
|
||||
throw new Error(`Failed to save CLI config: ${err}`);
|
||||
}
|
||||
saveClaudeCliTools(baseDir, currentConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration for a specific tool
|
||||
*/
|
||||
export function getToolConfig(baseDir: string, tool: string): CliToolConfig {
|
||||
if (!isValidToolName(tool)) {
|
||||
throw new Error(`Invalid tool name: ${tool}`);
|
||||
}
|
||||
|
||||
const config = loadCliConfig(baseDir);
|
||||
return config.tools[tool] || DEFAULT_CONFIG.tools[tool];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize tags array
|
||||
* @param tags - Raw tags array from user input
|
||||
* @returns Sanitized tags array
|
||||
*/
|
||||
function validateTags(tags: string[] | undefined): string[] | undefined {
|
||||
if (!tags || !Array.isArray(tags)) return undefined;
|
||||
|
||||
const MAX_TAGS = 10;
|
||||
const MAX_TAG_LENGTH = 30;
|
||||
|
||||
return tags
|
||||
.filter(tag => typeof tag === 'string')
|
||||
.map(tag => tag.trim())
|
||||
.filter(tag => tag.length > 0 && tag.length <= MAX_TAG_LENGTH)
|
||||
.slice(0, MAX_TAGS);
|
||||
return getToolConfigFromClaude(baseDir, tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration for a specific tool
|
||||
* Returns the updated tool config
|
||||
*/
|
||||
export function updateToolConfig(
|
||||
baseDir: string,
|
||||
tool: string,
|
||||
updates: Partial<CliToolConfig>
|
||||
): CliToolConfig {
|
||||
if (!isValidToolName(tool)) {
|
||||
throw new Error(`Invalid tool name: ${tool}`);
|
||||
}
|
||||
|
||||
const config = loadCliConfig(baseDir);
|
||||
const currentToolConfig = config.tools[tool] || DEFAULT_CONFIG.tools[tool];
|
||||
|
||||
// Apply updates
|
||||
const updatedToolConfig: CliToolConfig = {
|
||||
enabled: updates.enabled !== undefined ? updates.enabled : currentToolConfig.enabled,
|
||||
primaryModel: updates.primaryModel || currentToolConfig.primaryModel,
|
||||
secondaryModel: updates.secondaryModel || currentToolConfig.secondaryModel,
|
||||
tags: updates.tags !== undefined ? validateTags(updates.tags) : currentToolConfig.tags
|
||||
};
|
||||
|
||||
// Save updated config
|
||||
config.tools[tool] = updatedToolConfig;
|
||||
saveCliConfig(baseDir, config);
|
||||
|
||||
// Also sync tags to cli-tools.json
|
||||
if (updates.tags !== undefined) {
|
||||
try {
|
||||
const claudeCliTools = loadClaudeCliTools(baseDir);
|
||||
if (claudeCliTools.tools[tool]) {
|
||||
claudeCliTools.tools[tool].tags = updatedToolConfig.tags || [];
|
||||
saveClaudeCliTools(baseDir, claudeCliTools);
|
||||
}
|
||||
} catch (err) {
|
||||
// Log warning instead of ignoring errors syncing to cli-tools.json
|
||||
console.warn(`[cli-config] Failed to sync tags to cli-tools.json for tool '${tool}'.`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedToolConfig;
|
||||
updateToolConfigFromClaude(baseDir, tool, updates);
|
||||
return getToolConfig(baseDir, tool);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,73 +144,55 @@ export function disableTool(baseDir: string, tool: string): CliToolConfig {
|
||||
* Check if a tool is enabled
|
||||
*/
|
||||
export function isToolEnabled(baseDir: string, tool: string): boolean {
|
||||
try {
|
||||
const config = getToolConfig(baseDir, tool);
|
||||
return config.enabled;
|
||||
} catch {
|
||||
return true; // Default to enabled if error
|
||||
}
|
||||
return isToolEnabledFromClaude(baseDir, tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get primary model for a tool
|
||||
*/
|
||||
export function getPrimaryModel(baseDir: string, tool: string): string {
|
||||
try {
|
||||
const config = getToolConfig(baseDir, tool);
|
||||
return config.primaryModel;
|
||||
} catch {
|
||||
return isValidToolName(tool) ? DEFAULT_CONFIG.tools[tool].primaryModel : 'gemini-2.5-pro';
|
||||
}
|
||||
return getPrimaryModelFromClaude(baseDir, tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secondary model for a tool (used for internal calls)
|
||||
* Get secondary model for a tool
|
||||
*/
|
||||
export function getSecondaryModel(baseDir: string, tool: string): string {
|
||||
try {
|
||||
const config = getToolConfig(baseDir, tool);
|
||||
return config.secondaryModel;
|
||||
} catch {
|
||||
return isValidToolName(tool) ? DEFAULT_CONFIG.tools[tool].secondaryModel : 'gemini-2.5-flash';
|
||||
}
|
||||
return getSecondaryModelFromClaude(baseDir, tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all predefined models for a tool
|
||||
*/
|
||||
export function getPredefinedModels(tool: string): string[] {
|
||||
if (!isValidToolName(tool)) {
|
||||
return [];
|
||||
}
|
||||
return [...PREDEFINED_MODELS[tool]];
|
||||
return getPredefinedModelsFromClaude(tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full config response for API (includes predefined models and tags from cli-tools.json)
|
||||
* Get full config response for API
|
||||
*/
|
||||
export function getFullConfigResponse(baseDir: string): {
|
||||
config: CliConfig;
|
||||
predefinedModels: Record<string, string[]>;
|
||||
} {
|
||||
const config = loadCliConfig(baseDir);
|
||||
const response = getFullConfigResponseFromClaude(baseDir);
|
||||
|
||||
// Merge tags from cli-tools.json
|
||||
try {
|
||||
const claudeCliTools = loadClaudeCliTools(baseDir);
|
||||
for (const [toolName, toolConfig] of Object.entries(config.tools)) {
|
||||
const claudeTool = claudeCliTools.tools[toolName];
|
||||
if (claudeTool && claudeTool.tags) {
|
||||
toolConfig.tags = claudeTool.tags;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Log warning instead of ignoring errors loading cli-tools.json
|
||||
console.warn('[cli-config] Could not merge tags from cli-tools.json.', err);
|
||||
// Convert to legacy format
|
||||
const tools: Record<string, CliToolConfig> = {};
|
||||
for (const [key, tool] of Object.entries(response.config.tools)) {
|
||||
tools[key] = {
|
||||
enabled: tool.enabled,
|
||||
primaryModel: tool.primaryModel ?? '',
|
||||
secondaryModel: tool.secondaryModel ?? '',
|
||||
tags: tool.tags
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
predefinedModels: { ...PREDEFINED_MODELS }
|
||||
config: {
|
||||
version: parseFloat(response.config.version) || 1,
|
||||
tools
|
||||
},
|
||||
predefinedModels: response.predefinedModels
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { executeInitWithProgress } from './smart-search.js';
|
||||
import * as readFileMod from './read-file.js';
|
||||
import * as coreMemoryMod from './core-memory.js';
|
||||
import * as contextCacheMod from './context-cache.js';
|
||||
import * as skillContextLoaderMod from './skill-context-loader.js';
|
||||
import type { ProgressInfo } from './codex-lens.js';
|
||||
|
||||
// Import legacy JS tools
|
||||
@@ -359,6 +360,7 @@ registerTool(toLegacyTool(smartSearchMod));
|
||||
registerTool(toLegacyTool(readFileMod));
|
||||
registerTool(toLegacyTool(coreMemoryMod));
|
||||
registerTool(toLegacyTool(contextCacheMod));
|
||||
registerTool(toLegacyTool(skillContextLoaderMod));
|
||||
|
||||
// Register legacy JS tools
|
||||
registerTool(uiGeneratePreviewTool);
|
||||
|
||||
213
ccw/src/tools/skill-context-loader.ts
Normal file
213
ccw/src/tools/skill-context-loader.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Skill Context Loader Tool
|
||||
* Loads SKILL context based on keyword matching in user prompt
|
||||
* Used by UserPromptSubmit hooks to inject skill context
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||
import { readFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
// Input schema for keyword mode config
|
||||
const SkillConfigSchema = z.object({
|
||||
skill: z.string(),
|
||||
keywords: z.array(z.string())
|
||||
});
|
||||
|
||||
// Main params schema
|
||||
const ParamsSchema = z.object({
|
||||
// Auto mode flag
|
||||
mode: z.literal('auto').optional(),
|
||||
// User prompt to match against
|
||||
prompt: z.string(),
|
||||
// Keyword mode configs (only for keyword mode)
|
||||
configs: z.array(SkillConfigSchema).optional()
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof ParamsSchema>;
|
||||
|
||||
/**
|
||||
* Get all available skill names from project and user directories
|
||||
*/
|
||||
function getAvailableSkills(): Array<{ name: string; folderName: string; location: 'project' | 'user' }> {
|
||||
const skills: Array<{ name: string; folderName: string; location: 'project' | 'user' }> = [];
|
||||
|
||||
// Project skills
|
||||
const projectSkillsDir = join(process.cwd(), '.claude', 'skills');
|
||||
if (existsSync(projectSkillsDir)) {
|
||||
try {
|
||||
const entries = readdirSync(projectSkillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = join(projectSkillsDir, entry.name, 'SKILL.md');
|
||||
if (existsSync(skillMdPath)) {
|
||||
const name = parseSkillName(skillMdPath) || entry.name;
|
||||
skills.push({ name, folderName: entry.name, location: 'project' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// User skills
|
||||
const userSkillsDir = join(homedir(), '.claude', 'skills');
|
||||
if (existsSync(userSkillsDir)) {
|
||||
try {
|
||||
const entries = readdirSync(userSkillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = join(userSkillsDir, entry.name, 'SKILL.md');
|
||||
if (existsSync(skillMdPath)) {
|
||||
const name = parseSkillName(skillMdPath) || entry.name;
|
||||
// Skip if already added from project (project takes priority)
|
||||
if (!skills.some(s => s.folderName === entry.name)) {
|
||||
skills.push({ name, folderName: entry.name, location: 'user' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse skill name from SKILL.md frontmatter
|
||||
*/
|
||||
function parseSkillName(skillMdPath: string): string | null {
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, 'utf8');
|
||||
if (content.startsWith('---')) {
|
||||
const endIndex = content.indexOf('---', 3);
|
||||
if (endIndex > 0) {
|
||||
const frontmatter = content.substring(3, endIndex);
|
||||
const nameMatch = frontmatter.match(/^name:\s*["']?([^"'\n]+)["']?/m);
|
||||
if (nameMatch) {
|
||||
return nameMatch[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match prompt against keywords (case-insensitive)
|
||||
*/
|
||||
function matchKeywords(prompt: string, keywords: string[]): string | null {
|
||||
const lowerPrompt = prompt.toLowerCase();
|
||||
for (const keyword of keywords) {
|
||||
if (keyword && lowerPrompt.includes(keyword.toLowerCase())) {
|
||||
return keyword;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format skill invocation instruction for hook output
|
||||
* Returns a prompt to invoke the skill, not the full content
|
||||
*/
|
||||
function formatSkillInvocation(skillName: string, matchedKeyword?: string): string {
|
||||
return `Use /${skillName} skill to handle this request.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool schema definition
|
||||
*/
|
||||
export const schema: ToolSchema = {
|
||||
name: 'skill_context_loader',
|
||||
description: 'Match keywords in user prompt and return skill invocation instruction. Returns "Use /skill-name skill" when keywords match.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['auto'],
|
||||
description: 'Auto mode: detect skill name in prompt automatically'
|
||||
},
|
||||
prompt: {
|
||||
type: 'string',
|
||||
description: 'User prompt to match against keywords'
|
||||
},
|
||||
configs: {
|
||||
type: 'array',
|
||||
description: 'Keyword mode: array of skill configs with keywords',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
skill: { type: 'string', description: 'Skill folder name to load' },
|
||||
keywords: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Keywords to match in prompt'
|
||||
}
|
||||
},
|
||||
required: ['skill', 'keywords']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['prompt']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool handler
|
||||
*/
|
||||
export async function handler(params: Record<string, unknown>): Promise<ToolResult<string>> {
|
||||
try {
|
||||
const parsed = ParamsSchema.parse(params);
|
||||
const { mode, prompt, configs } = parsed;
|
||||
|
||||
// Auto mode: detect skill name in prompt
|
||||
if (mode === 'auto') {
|
||||
const skills = getAvailableSkills();
|
||||
const lowerPrompt = prompt.toLowerCase();
|
||||
|
||||
for (const skill of skills) {
|
||||
// Check if prompt contains skill name or folder name
|
||||
if (lowerPrompt.includes(skill.name.toLowerCase()) ||
|
||||
lowerPrompt.includes(skill.folderName.toLowerCase())) {
|
||||
return {
|
||||
success: true,
|
||||
result: formatSkillInvocation(skill.folderName, skill.name)
|
||||
};
|
||||
}
|
||||
}
|
||||
// No match - return empty (silent)
|
||||
return { success: true, result: '' };
|
||||
}
|
||||
|
||||
// Keyword mode: match against configured keywords
|
||||
if (configs && configs.length > 0) {
|
||||
for (const config of configs) {
|
||||
const matchedKeyword = matchKeywords(prompt, config.keywords);
|
||||
if (matchedKeyword) {
|
||||
return {
|
||||
success: true,
|
||||
result: formatSkillInvocation(config.skill, matchedKeyword)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No match - return empty (silent)
|
||||
return { success: true, result: '' };
|
||||
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
error: `skill_context_loader error: ${message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user