diff --git a/.claude/cli-tools.json b/.claude/cli-tools.json index 2f2dbd16..8ad669d2 100644 --- a/.claude/cli-tools.json +++ b/.claude/cli-tools.json @@ -1,42 +1,53 @@ { - "$schema": "./cli-tools.schema.json", - "version": "2.0.0", + "version": "3.0.0", + "models": { + "gemini": ["gemini-2.5-pro", "gemini-2.5-flash" ], + "qwen": ["coder-model", "vision-model" ], + "codex": ["gpt-5.2"], + "claude": ["sonnet", "opus", "haiku"], + "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" + ] + }, "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", - "tags": [] + "secondaryModel": "opencode/glm-4.7-free", + "tags": ["分析"] } }, "customEndpoints": [ @@ -46,5 +57,6 @@ "enabled": true, "tags": [] } - ] + ], + "$schema": "./cli-tools.schema.json" } diff --git a/ccw/src/commands/tool.ts b/ccw/src/commands/tool.ts index 0007ce30..5cd475c9 100644 --- a/ccw/src/commands/tool.ts +++ b/ccw/src/commands/tool.ts @@ -159,8 +159,23 @@ async function execAction(toolName: string | undefined, jsonParams: string | und // Execute tool const result = await executeTool(toolName, params); - // Always output JSON - console.log(JSON.stringify(result, null, 2)); + // Output raw result value for hooks, or JSON on error + if (result.success && result.result !== undefined) { + // For string results, output directly (useful for hooks) + if (typeof result.result === 'string') { + if (result.result) { + console.log(result.result); + } + // Empty string = silent (no output) + } else { + // For object results, output JSON + console.log(JSON.stringify(result.result, null, 2)); + } + } else if (!result.success) { + // Error case - output full JSON for debugging + console.error(JSON.stringify(result, null, 2)); + process.exit(1); + } } /** diff --git a/ccw/src/templates/dashboard-css/21-cli-toolmgmt.css b/ccw/src/templates/dashboard-css/21-cli-toolmgmt.css index 6511d5c8..8b3ab2d0 100644 --- a/ccw/src/templates/dashboard-css/21-cli-toolmgmt.css +++ b/ccw/src/templates/dashboard-css/21-cli-toolmgmt.css @@ -308,3 +308,311 @@ background: hsl(var(--destructive) / 0.1); } +/* ======================================== + * CLI Manager Split Layout (Claude Config) + * ======================================== */ + +.cli-manager-split { + display: flex; + gap: var(--spacing-md, 1rem); + height: 100%; + min-height: 400px; +} + +.cli-manager-sidebar { + width: 280px; + flex-shrink: 0; + overflow-y: auto; + background: hsl(var(--card)); + border-radius: 0.5rem; + border: 1px solid hsl(var(--border)); + padding: 1rem; +} + +.cli-manager-sidebar h3 { + font-size: 0.875rem; + font-weight: 600; + margin: 0 0 0.75rem 0; + display: flex; + align-items: center; + gap: 0.5rem; + color: hsl(var(--foreground)); +} + +.cli-manager-main { + flex: 1; + overflow-y: auto; + padding: 1.25rem; + background: hsl(var(--card)); + border-radius: 0.5rem; + border: 1px solid hsl(var(--border)); +} + +/* Tool List Items */ +.cli-tool-list-item { + padding: 0.625rem 0.75rem; + cursor: pointer; + border-radius: 0.375rem; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.25rem; +} + +.cli-tool-list-item:hover { + background: hsl(var(--accent)); +} + +.cli-tool-list-item.selected { + background: hsl(var(--primary) / 0.1); + border-left: 3px solid hsl(var(--primary)); + padding-left: calc(0.75rem - 3px); +} + +.cli-tool-list-item .tool-name { + font-weight: 500; + font-size: 0.8125rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.cli-tool-list-item .tool-status { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.cli-tool-list-item .tool-status.installed { + background: hsl(var(--success)); +} + +.cli-tool-list-item .tool-status.not-installed { + background: hsl(var(--muted-foreground) / 0.4); +} + +/* Config Mode Toggle */ +.config-mode-toggle { + display: flex; + gap: 0.5rem; + margin-bottom: 1.25rem; + background: hsl(var(--muted) / 0.5); + padding: 0.25rem; + border-radius: 0.5rem; +} + +.config-mode-btn { + flex: 1; + padding: 0.5rem 0.75rem; + border: none; + border-radius: 0.375rem; + background: transparent; + color: hsl(var(--muted-foreground)); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; +} + +.config-mode-btn:hover { + color: hsl(var(--foreground)); +} + +.config-mode-btn.active { + background: hsl(var(--background)); + color: hsl(var(--primary)); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* Tool Detail Header */ +.tool-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.tool-detail-header h3 { + font-size: 1rem; + font-weight: 600; + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Claude Settings Form */ +.claude-config-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.claude-config-form .form-group { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.claude-config-form .form-group label { + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.claude-config-form .form-control { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + font-family: inherit; + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.claude-config-form .form-control:focus { + outline: none; + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2); +} + +.claude-config-form .form-control::placeholder { + color: hsl(var(--muted-foreground) / 0.6); +} + +/* Model Config Section */ +.model-config-section { + background: hsl(var(--muted) / 0.3); + padding: 1rem; + border-radius: 0.5rem; + margin-top: 0.5rem; +} + +.model-config-section h4 { + font-size: 0.8125rem; + font-weight: 600; + margin: 0 0 0.75rem 0; + display: flex; + align-items: center; + gap: 0.375rem; +} + +.model-config-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; +} + +@media (max-width: 768px) { + .model-config-grid { + grid-template-columns: 1fr; + } +} + +/* Empty State */ +.cli-manager-main .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 200px; + color: hsl(var(--muted-foreground)); + font-size: 0.875rem; + gap: 0.5rem; +} + +.cli-manager-main .empty-state i { + opacity: 0.5; +} + +/* Endpoints List in Config Panel */ +.claude-endpoints-list { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid hsl(var(--border)); +} + +.claude-endpoints-list h4 { + font-size: 0.8125rem; + font-weight: 600; + margin: 0 0 0.75rem 0; + display: flex; + align-items: center; + gap: 0.375rem; +} + +.claude-endpoint-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 0.75rem; + background: hsl(var(--muted) / 0.3); + border-radius: 0.375rem; + margin-bottom: 0.5rem; +} + +.claude-endpoint-item:last-child { + margin-bottom: 0; +} + +.claude-endpoint-info { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.claude-endpoint-name { + font-weight: 500; + font-size: 0.8125rem; +} + +.claude-endpoint-meta { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.claude-endpoint-actions { + display: flex; + align-items: center; + gap: 0.375rem; +} + +/* Config Source Badge */ +.config-source-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.375rem; + font-size: 0.625rem; + font-weight: 500; + border-radius: 9999px; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.config-source-badge.provider { + background: hsl(var(--primary) / 0.15); + color: hsl(var(--primary)); +} + +.config-source-badge.direct { + background: hsl(var(--success) / 0.15); + color: hsl(var(--success)); +} + diff --git a/ccw/src/templates/dashboard-css/31-api-settings.css b/ccw/src/templates/dashboard-css/31-api-settings.css index 29dc1a4e..f159f01a 100644 --- a/ccw/src/templates/dashboard-css/31-api-settings.css +++ b/ccw/src/templates/dashboard-css/31-api-settings.css @@ -2329,4 +2329,132 @@ select.cli-input { width: 1rem; height: 1rem; flex-shrink: 0; +} + +/* =========================== + Model Pool Detail Info Grid + =========================== */ + +.provider-detail { + padding: 1.5rem; + overflow-y: auto; +} + +.provider-detail .provider-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.provider-detail .provider-detail-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.provider-detail .provider-detail-body { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.provider-detail .form-section { + padding: 1.25rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; +} + +.provider-detail .form-section h3 { + margin: 0 0 1rem 0; + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.provider-detail .form-section p { + margin: 0; + font-size: 0.875rem; + color: hsl(var(--foreground)); + line-height: 1.5; +} + +/* Info Grid - Used in Model Pool Detail */ +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.info-item label { + font-size: 0.75rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.info-item span { + font-size: 0.875rem; + color: hsl(var(--foreground)); +} + +/* Status badge inside info-item should not stretch */ +.info-item .status-badge { + display: inline-flex; + width: auto; +} + +/* Excluded Providers List */ +.excluded-providers-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.excluded-providers-list .tag { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; + background: hsl(var(--muted) / 0.5); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + color: hsl(var(--muted-foreground)); +} + +/* Provider Actions in Detail Header */ +.provider-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +@media (max-width: 640px) { + .info-grid { + grid-template-columns: 1fr; + } + + .provider-detail .provider-detail-header { + flex-direction: column; + gap: 1rem; + } + + .provider-actions { + width: 100%; + justify-content: flex-end; + } } \ No newline at end of file diff --git a/ccw/src/templates/dashboard-js/components/cli-status.js b/ccw/src/templates/dashboard-js/components/cli-status.js index f1f448c1..793306e9 100644 --- a/ccw/src/templates/dashboard-js/components/cli-status.js +++ b/ccw/src/templates/dashboard-js/components/cli-status.js @@ -10,6 +10,7 @@ let defaultCliTool = 'gemini'; let promptConcatFormat = localStorage.getItem('ccw-prompt-format') || 'plain'; // plain, yaml, json let cliToolsConfig = {}; // CLI tools enable/disable config let apiEndpoints = []; // API endpoints from LiteLLM config +let cliSettingsEndpoints = []; // CLI Settings endpoints (for Claude wrapper) // Smart Context settings let smartContextEnabled = localStorage.getItem('ccw-smart-context') === 'true'; @@ -43,10 +44,11 @@ async function loadAllStatuses() { semanticStatus = data.semantic || { available: false }; ccwInstallStatus = data.ccwInstall || { installed: true, workflowsInstalled: true, missingFiles: [], installPath: '' }; - // Load CLI tools config and API endpoints + // Load CLI tools config, API endpoints, and CLI Settings await Promise.all([ loadCliToolsConfig(), - loadApiEndpoints() + loadApiEndpoints(), + loadCliSettingsEndpoints() ]); // Update badges @@ -285,6 +287,22 @@ async function loadApiEndpoints() { } } +/** + * Load CLI Settings endpoints (Claude wrapper configurations) + */ +async function loadCliSettingsEndpoints() { + try { + const response = await fetch('/api/cli/settings'); + if (!response.ok) return []; + const data = await response.json(); + cliSettingsEndpoints = data.endpoints || []; + return cliSettingsEndpoints; + } catch (err) { + console.error('Failed to load CLI settings endpoints:', err); + return []; + } +} + // ========== Badge Update ========== function updateCliBadge() { const badge = document.getElementById('badgeCliTools'); @@ -355,6 +373,52 @@ function renderCliStatus() { const isEnabled = config.enabled !== false; const canSetDefault = isAvailable && isEnabled && !isDefault; + // Special handling for Claude: show CLI Settings info + const isClaude = tool === 'claude'; + const enabledCliSettings = isClaude ? cliSettingsEndpoints.filter(ep => ep.enabled) : []; + const hasCliSettings = enabledCliSettings.length > 0; + + // Build CLI Settings badge for Claude + let cliSettingsBadge = ''; + if (isClaude && hasCliSettings) { + cliSettingsBadge = `${enabledCliSettings.length} Endpoint${enabledCliSettings.length > 1 ? 's' : ''}`; + } + + // Build CLI Settings info for Claude + let cliSettingsInfo = ''; + if (isClaude) { + if (hasCliSettings) { + const epNames = enabledCliSettings.slice(0, 2).map(ep => ep.name).join(', '); + const moreCount = enabledCliSettings.length > 2 ? ` +${enabledCliSettings.length - 2}` : ''; + cliSettingsInfo = ` +
+
+ + CLI Wrapper Endpoints: +
+
${epNames}${moreCount}
+ + + Configure + +
+ `; + } else { + cliSettingsInfo = ` +
+
+ + No CLI wrapper configured +
+ + + Add Endpoint + +
+ `; + } + } + return `
@@ -362,6 +426,7 @@ function renderCliStatus() { ${tool.charAt(0).toUpperCase() + tool.slice(1)} ${isDefault ? 'Default' : ''} ${!isEnabled && isAvailable ? 'Disabled' : ''} + ${cliSettingsBadge}
${toolDescriptions[tool]} @@ -376,6 +441,7 @@ function renderCliStatus() { }
+ ${cliSettingsInfo}
${isAvailable ? (isEnabled ? `
' + '
' + - '' + - '' + escapeHtml(settings.model || 'sonnet') + '' + - '
' + - '
' + '' + '' + (endpoint.enabled ? t('common.enabled') : t('common.disabled')) + '' + '
' + + (endpoint.description ? '
' + escapeHtml(endpoint.description) + '
' : '') + '' + '' + '
' + @@ -3805,6 +3847,7 @@ function renderCliSettingsDetail(endpointId) { '
' + '' + '' + + modelConfigHtml + '
' + '

' + t('apiSettings.settingsFilePath') + '

' + '
' + @@ -3817,22 +3860,407 @@ function renderCliSettingsDetail(endpointId) { } /** - * Render CLI Settings empty state + * Render CLI Settings empty state or add form */ function renderCliSettingsEmptyState() { var container = document.getElementById('provider-detail-panel'); if (!container) return; + // If adding new settings, show the form + if (isAddingCliSettings) { + renderCliSettingsForm(null); + return; + } + container.innerHTML = '
' + '' + '

' + t('apiSettings.noCliSettingsSelected') + '

' + '

' + t('apiSettings.cliSettingsHint') + '

' + + '' + '
'; if (window.lucide) lucide.createIcons(); } +/** + * Start adding new CLI Settings (show form in panel) + */ +function startAddCliSettings() { + isAddingCliSettings = true; + selectedCliSettingsId = null; + editingCliSettingsId = null; + cliConfigMode = 'provider'; + renderCliSettingsForm(null); + renderCliSettingsList(); +} + +/** + * Cancel adding/editing CLI Settings + */ +function cancelCliSettingsForm() { + isAddingCliSettings = false; + editingCliSettingsId = null; + + // Re-render detail panel + if (selectedCliSettingsId) { + renderCliSettingsDetail(selectedCliSettingsId); + } else { + renderCliSettingsEmptyState(); + } + renderCliSettingsList(); +} + +/** + * Render CLI Settings form in detail panel (for add or edit) + */ +function renderCliSettingsForm(existingEndpoint) { + var container = document.getElementById('provider-detail-panel'); + if (!container) return; + + var isEdit = !!existingEndpoint; + var settings = existingEndpoint ? existingEndpoint.settings : { env: {}, model: '' }; + var env = settings.env || {}; + + // Determine initial config mode for editing + if (isEdit) { + // If settings has configMode, use it; otherwise detect based on providerId + cliConfigMode = settings.configMode || (settings.providerId ? 'provider' : 'direct'); + } + + // Build mode toggle + var modeToggleHtml = + '
' + + '' + + '' + + '
'; + + // Common fields + var commonFieldsHtml = + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
'; + + // Mode-specific form content container + var formContentHtml = '
'; + + // Enabled toggle + var enabledHtml = + '
' + + '' + + '
'; + + // Action buttons + var actionsHtml = + '
' + + '' + + '' + + '
'; + + container.innerHTML = + '
' + + '

' + (isEdit ? t('apiSettings.editCliSettings') : t('apiSettings.addCliSettings')) + '

' + + '
' + + '
' + + (isEdit ? '' : '') + + '' + + modeToggleHtml + + commonFieldsHtml + + formContentHtml + + enabledHtml + + actionsHtml + + '
'; + + if (window.lucide) lucide.createIcons(); + + // Render mode-specific content + renderCliConfigModeContent(existingEndpoint); +} + +/** + * Switch CLI config mode + */ +function switchCliConfigMode(mode) { + cliConfigMode = mode; + + // Update hidden input + var modeInput = document.getElementById('cli-config-mode'); + if (modeInput) modeInput.value = mode; + + // Update toggle buttons + document.querySelectorAll('.config-mode-btn').forEach(function(btn) { + btn.classList.toggle('active', btn.dataset.mode === mode); + }); + + // Re-render mode content while preserving form data + var existingEndpoint = null; + var idInput = document.getElementById('cli-settings-id'); + if (idInput && idInput.value && cliSettingsData && cliSettingsData.endpoints) { + existingEndpoint = cliSettingsData.endpoints.find(function(e) { return e.id === idInput.value; }); + } + + renderCliConfigModeContent(existingEndpoint); +} + +/** + * Render CLI config mode-specific content + */ +function renderCliConfigModeContent(existingEndpoint) { + var container = document.getElementById('cli-config-mode-content'); + if (!container) return; + + var settings = existingEndpoint ? existingEndpoint.settings : { env: {}, model: '' }; + var env = settings.env || {}; + + if (cliConfigMode === 'provider') { + renderProviderModeContent(container, settings); + } else { + renderDirectModeContent(container, env); + } + + if (window.lucide) lucide.createIcons(); +} + +/** + * Render Provider Binding mode content + */ +function renderProviderModeContent(container, settings) { + var providers = getAvailableAnthropicProviders(); + var hasProviders = providers.length > 0; + var selectedProviderId = settings.providerId || ''; + var providerOptionsHtml = buildCliProviderOptions(selectedProviderId); + var env = settings.env || {}; + + var noProvidersWarning = !hasProviders ? + '
' + + '' + + '' + (t('apiSettings.noAnthropicProviders') || 'No Anthropic providers configured. Please add a provider first.') + '' + + '
' : ''; + + container.innerHTML = noProvidersWarning + + '
' + + '' + + '' + + '
' + + // Model Config Section + '
' + + '

' + (t('apiSettings.modelConfig') || 'Model Configuration') + '

' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; +} + +/** + * Render Direct Configuration mode content + */ +function renderDirectModeContent(container, env) { + container.innerHTML = + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + // Model Config Section + '
' + + '

' + (t('apiSettings.modelConfig') || 'Model Configuration') + '

' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; +} + +/** + * Submit CLI Settings Form (handles both Provider and Direct modes) + */ +async function submitCliSettingsForm() { + // Get common fields + var name = document.getElementById('cli-settings-name').value.trim(); + var description = document.getElementById('cli-settings-description').value.trim(); + var enabled = document.getElementById('cli-settings-enabled').checked; + var idInput = document.getElementById('cli-settings-id'); + var id = idInput ? idInput.value : null; + var configMode = cliConfigMode; + + // Get model configuration fields + var anthropicModel = document.getElementById('cli-model-default').value.trim(); + var haikuModel = document.getElementById('cli-model-haiku').value.trim(); + var sonnetModel = document.getElementById('cli-model-sonnet').value.trim(); + var opusModel = document.getElementById('cli-model-opus').value.trim(); + + // Validate common fields + if (!name) { + showRefreshToast(t('apiSettings.nameRequired'), 'error'); + return; + } + + var data = { + name: name, + description: description, + enabled: enabled, + settings: { + env: { + DISABLE_AUTOUPDATER: '1' + }, + configMode: configMode, + includeCoAuthoredBy: false + } + }; + + // Mode-specific handling + if (configMode === 'provider') { + // Provider binding mode + var providerId = document.getElementById('cli-settings-provider').value; + + if (!providerId) { + showRefreshToast(t('apiSettings.providerRequired'), 'error'); + return; + } + + // Get provider credentials + var providers = getAvailableAnthropicProviders(); + var provider = providers.find(function(p) { return p.id === providerId; }); + + if (!provider) { + showRefreshToast(t('apiSettings.providerNotFound'), 'error'); + return; + } + + // Copy provider credentials to env + data.settings.env.ANTHROPIC_AUTH_TOKEN = provider.apiKey || ''; + if (provider.apiBase) { + data.settings.env.ANTHROPIC_BASE_URL = provider.apiBase; + } + data.settings.providerId = providerId; + + } else { + // Direct configuration mode + var authToken = document.getElementById('cli-auth-token').value.trim(); + var baseUrl = document.getElementById('cli-base-url').value.trim(); + + if (!authToken) { + showRefreshToast(t('apiSettings.authTokenRequired') || 'Auth token is required', 'error'); + return; + } + + data.settings.env.ANTHROPIC_AUTH_TOKEN = authToken; + if (baseUrl) { + data.settings.env.ANTHROPIC_BASE_URL = baseUrl; + } + } + + // Add model configuration + if (anthropicModel) { + data.settings.env.ANTHROPIC_MODEL = anthropicModel; + } + if (haikuModel) { + data.settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = haikuModel; + } + if (sonnetModel) { + data.settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = sonnetModel; + } + if (opusModel) { + data.settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = opusModel; + } + + // Set ID if editing + if (id) { + data.id = id; + } + + // Save endpoint + var result = await saveCliSettingsEndpoint(data); + if (result && result.success) { + // Reset form state + isAddingCliSettings = false; + editingCliSettingsId = null; + + // Select the newly created/updated endpoint + if (result.endpoint && result.endpoint.id) { + selectedCliSettingsId = result.endpoint.id; + } + + // Refresh view + await loadCliSettings(); + renderCliSettingsList(); + if (selectedCliSettingsId) { + renderCliSettingsDetail(selectedCliSettingsId); + } + } +} + +/** + * Edit CLI Settings in panel (new panel-based approach) + */ +function editCliSettingsInPanel(endpointId) { + var endpoint = null; + if (cliSettingsData && cliSettingsData.endpoints) { + endpoint = cliSettingsData.endpoints.find(function(e) { return e.id === endpointId; }); + } + if (endpoint) { + isAddingCliSettings = false; + editingCliSettingsId = endpointId; + + // Determine config mode from existing settings + var settings = endpoint.settings || {}; + cliConfigMode = settings.configMode || (settings.providerId ? 'provider' : 'direct'); + + renderCliSettingsForm(endpoint); + renderCliSettingsList(); + } +} + /** * Get available Anthropic providers */ @@ -3964,16 +4392,10 @@ function showAddCliSettingsModal(existingEndpoint) { } /** - * Edit CLI Settings + * Edit CLI Settings (uses panel-based form) */ function editCliSettings(endpointId) { - var endpoint = null; - if (cliSettingsData && cliSettingsData.endpoints) { - endpoint = cliSettingsData.endpoints.find(function(e) { return e.id === endpointId; }); - } - if (endpoint) { - showAddCliSettingsModal(endpoint); - } + editCliSettingsInPanel(endpointId); } /** @@ -4381,8 +4803,150 @@ async function submitModelPool(event) { * Edit model pool */ function editModelPool(poolId) { - // TODO: Implement edit modal - showRefreshToast('Edit functionality coming soon', 'info'); + var pool = modelPools.find(function(p) { return p.id === poolId; }); + if (!pool) { + showRefreshToast(t('common.error') + ': Pool not found', 'error'); + return; + } + + var modalHtml = '
' + + '
' + + '
' + + '

' + t('apiSettings.editModelPool') + '

' + + '' + + '
' + + '
' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '
' + + + '
' + + '' + + '
' + + + '
' + + '
' + + '' + + '
' + + '
'; + + document.body.insertAdjacentHTML('beforeend', modalHtml); + if (window.lucide) lucide.createIcons(); +} + +/** + * Close edit pool modal + */ +function closeEditPoolModal() { + var modal = document.getElementById('edit-pool-modal'); + if (modal) modal.remove(); +} + +/** + * Submit edit model pool form + */ +async function submitEditModelPool(event, poolId) { + event.preventDefault(); + + var pool = modelPools.find(function(p) { return p.id === poolId; }); + if (!pool) { + showRefreshToast(t('common.error') + ': Pool not found', 'error'); + return; + } + + var name = document.getElementById('edit-pool-name').value.trim(); + var strategy = document.getElementById('edit-pool-strategy').value; + var cooldown = parseInt(document.getElementById('edit-pool-cooldown').value || '60'); + var maxConcurrent = parseInt(document.getElementById('edit-pool-max-concurrent').value || '4'); + var description = document.getElementById('edit-pool-description').value.trim(); + var enabled = document.getElementById('edit-pool-enabled').checked; + var autoDiscover = document.getElementById('edit-pool-auto-discover').checked; + + var poolData = { + id: poolId, + modelType: pool.modelType, + enabled: enabled, + name: name || pool.targetModel, + targetModel: pool.targetModel, + strategy: strategy, + autoDiscover: autoDiscover, + defaultCooldown: cooldown, + defaultMaxConcurrentPerKey: maxConcurrent, + description: description || undefined, + excludedProviderIds: pool.excludedProviderIds || [] + }; + + try { + await initCsrfToken(); + var response = await csrfFetch('/api/litellm-api/model-pools/' + poolId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(poolData) + }); + + if (!response.ok) { + var err = await response.json(); + throw new Error(err.error || 'Failed to update pool'); + } + + showRefreshToast(t('apiSettings.poolUpdated'), 'success'); + closeEditPoolModal(); + + // Reload pools and refresh view + await loadModelPools(); + renderModelPoolsList(); + renderModelPoolDetail(poolId); + } catch (err) { + showRefreshToast(t('common.error') + ': ' + err.message, 'error'); + } } /** @@ -4426,6 +4990,8 @@ window.closeAddPoolModal = closeAddPoolModal; window.onPoolModelTypeChange = onPoolModelTypeChange; window.submitModelPool = submitModelPool; window.editModelPool = editModelPool; +window.closeEditPoolModal = closeEditPoolModal; +window.submitEditModelPool = submitEditModelPool; window.deleteModelPool = deleteModelPool; // Make CLI Settings functions globally accessible @@ -4441,6 +5007,12 @@ window.editCliSettings = editCliSettings; window.closeCliSettingsModal = closeCliSettingsModal; window.submitCliSettings = submitCliSettings; window.onCliProviderChange = onCliProviderChange; +// New panel-based CLI Settings functions +window.startAddCliSettings = startAddCliSettings; +window.cancelCliSettingsForm = cancelCliSettingsForm; +window.switchCliConfigMode = switchCliConfigMode; +window.submitCliSettingsForm = submitCliSettingsForm; +window.editCliSettingsInPanel = editCliSettingsInPanel; // ========== Utility Functions ========== diff --git a/ccw/src/templates/dashboard-js/views/codexlens-manager.js b/ccw/src/templates/dashboard-js/views/codexlens-manager.js index bbd333c0..e3f64b32 100644 --- a/ccw/src/templates/dashboard-js/views/codexlens-manager.js +++ b/ccw/src/templates/dashboard-js/views/codexlens-manager.js @@ -1,6 +1,75 @@ // CodexLens Manager - Configuration, Model Management, and Semantic Dependencies // Extracted from cli-manager.js for better maintainability +// ============================================================ +// CACHE MANAGEMENT +// ============================================================ + +// Cache TTL in milliseconds (30 seconds default) +const CODEXLENS_CACHE_TTL = 30000; + +// Cache storage for CodexLens data +const codexLensCache = { + workspaceStatus: { data: null, timestamp: 0 }, + config: { data: null, timestamp: 0 }, + status: { data: null, timestamp: 0 }, + env: { data: null, timestamp: 0 }, + models: { data: null, timestamp: 0 }, + rerankerModels: { data: null, timestamp: 0 }, + semanticStatus: { data: null, timestamp: 0 }, + gpuList: { data: null, timestamp: 0 }, + indexes: { data: null, timestamp: 0 } +}; + +/** + * Check if cache is valid (not expired) + * @param {string} key - Cache key + * @param {number} ttl - Optional custom TTL + * @returns {boolean} + */ +function isCacheValid(key, ttl = CODEXLENS_CACHE_TTL) { + const cache = codexLensCache[key]; + if (!cache || !cache.data) return false; + return (Date.now() - cache.timestamp) < ttl; +} + +/** + * Get cached data + * @param {string} key - Cache key + * @returns {*} Cached data or null + */ +function getCachedData(key) { + return codexLensCache[key]?.data || null; +} + +/** + * Set cache data + * @param {string} key - Cache key + * @param {*} data - Data to cache + */ +function setCacheData(key, data) { + if (codexLensCache[key]) { + codexLensCache[key].data = data; + codexLensCache[key].timestamp = Date.now(); + } +} + +/** + * Invalidate specific cache or all caches + * @param {string} key - Cache key (optional, if not provided clears all) + */ +function invalidateCache(key) { + if (key && codexLensCache[key]) { + codexLensCache[key].data = null; + codexLensCache[key].timestamp = 0; + } else if (!key) { + Object.keys(codexLensCache).forEach(function(k) { + codexLensCache[k].data = null; + codexLensCache[k].timestamp = 0; + }); + } +} + // ============================================================ // UTILITY FUNCTIONS // ============================================================ @@ -25,8 +94,9 @@ function escapeHtml(str) { /** * Refresh workspace index status (FTS and Vector coverage) * Updates both the detailed panel (if exists) and header badges + * @param {boolean} forceRefresh - Force refresh, bypass cache */ -async function refreshWorkspaceIndexStatus() { +async function refreshWorkspaceIndexStatus(forceRefresh) { var container = document.getElementById('workspaceIndexStatusContent'); var headerFtsEl = document.getElementById('headerFtsPercent'); var headerVectorEl = document.getElementById('headerVectorPercent'); @@ -34,6 +104,13 @@ async function refreshWorkspaceIndexStatus() { // If neither container nor header elements exist, nothing to update if (!container && !headerFtsEl) return; + // Check cache first (unless force refresh) + if (!forceRefresh && isCacheValid('workspaceStatus')) { + var cachedResult = getCachedData('workspaceStatus'); + updateWorkspaceStatusUI(cachedResult, container, headerFtsEl, headerVectorEl); + return; + } + // Show loading state in container if (container) { container.innerHTML = '
' + @@ -46,106 +123,10 @@ async function refreshWorkspaceIndexStatus() { var response = await fetch('/api/codexlens/workspace-status'); var result = await response.json(); - if (result.success) { - var ftsPercent = result.hasIndex ? (result.fts.percent || 0) : 0; - var vectorPercent = result.hasIndex ? (result.vector.percent || 0) : 0; + // Cache the result + setCacheData('workspaceStatus', result); - // Update header badges (always update if elements exist) - if (headerFtsEl) { - headerFtsEl.textContent = ftsPercent + '%'; - headerFtsEl.className = 'text-sm font-medium ' + - (ftsPercent >= 100 ? 'text-success' : (ftsPercent > 0 ? 'text-blue-500' : 'text-muted-foreground')); - } - if (headerVectorEl) { - headerVectorEl.textContent = vectorPercent.toFixed(1) + '%'; - headerVectorEl.className = 'text-sm font-medium ' + - (vectorPercent >= 100 ? 'text-success' : (vectorPercent >= 50 ? 'text-purple-500' : (vectorPercent > 0 ? 'text-purple-400' : 'text-muted-foreground'))); - } - - // Update detailed container (if exists) - if (container) { - var html = ''; - - if (!result.hasIndex) { - // No index for current workspace - html = '
' + - '
' + - ' ' + - (t('codexlens.noIndexFound') || 'No index found for current workspace') + - '
' + - '' + - '
'; - } else { - // FTS Status - var ftsColor = ftsPercent >= 100 ? 'bg-success' : (ftsPercent > 0 ? 'bg-blue-500' : 'bg-muted-foreground'); - var ftsTextColor = ftsPercent >= 100 ? 'text-success' : (ftsPercent > 0 ? 'text-blue-500' : 'text-muted-foreground'); - - html += '
' + - '
' + - '' + - ' ' + - '' + (t('codexlens.ftsIndex') || 'FTS Index') + '' + - '' + - '' + ftsPercent + '%' + - '
' + - '
' + - '
' + - '
' + - '
' + - (result.fts.indexedFiles || 0) + ' / ' + (result.fts.totalFiles || 0) + ' ' + (t('codexlens.filesIndexed') || 'files indexed') + - '
' + - '
'; - - // Vector Status - var vectorColor = vectorPercent >= 100 ? 'bg-success' : (vectorPercent >= 50 ? 'bg-purple-500' : (vectorPercent > 0 ? 'bg-purple-400' : 'bg-muted-foreground')); - var vectorTextColor = vectorPercent >= 100 ? 'text-success' : (vectorPercent >= 50 ? 'text-purple-500' : (vectorPercent > 0 ? 'text-purple-400' : 'text-muted-foreground')); - - html += '
' + - '
' + - '' + - ' ' + - '' + (t('codexlens.vectorIndex') || 'Vector Index') + '' + - '' + - '' + vectorPercent.toFixed(1) + '%' + - '
' + - '
' + - '
' + - '
' + - '
' + - (result.vector.filesWithEmbeddings || 0) + ' / ' + (result.vector.totalFiles || 0) + ' ' + (t('codexlens.filesWithEmbeddings') || 'files with embeddings') + - (result.vector.totalChunks > 0 ? ' (' + result.vector.totalChunks + ' chunks)' : '') + - '
' + - '
'; - - // Vector search availability indicator - if (vectorPercent >= 50) { - html += '
' + - '' + - '' + (t('codexlens.vectorSearchEnabled') || 'Vector search enabled') + '' + - '
'; - } else if (vectorPercent > 0) { - html += '
' + - '' + - '' + (t('codexlens.vectorSearchPartial') || 'Vector search requires ≥50% coverage') + '' + - '
'; - } - } - - container.innerHTML = html; - } - } else { - // Error from API - if (headerFtsEl) headerFtsEl.textContent = '--'; - if (headerVectorEl) headerVectorEl.textContent = '--'; - if (container) { - container.innerHTML = '
' + - ' ' + - (result.error || t('common.error') || 'Error loading status') + - '
'; - } - } + updateWorkspaceStatusUI(result, container, headerFtsEl, headerVectorEl); } catch (err) { console.error('[CodexLens] Failed to load workspace status:', err); if (headerFtsEl) headerFtsEl.textContent = '--'; @@ -161,24 +142,151 @@ async function refreshWorkspaceIndexStatus() { if (window.lucide) lucide.createIcons(); } +/** + * Update workspace status UI with result data + * @param {Object} result - API result + * @param {HTMLElement} container - Container element + * @param {HTMLElement} headerFtsEl - FTS header element + * @param {HTMLElement} headerVectorEl - Vector header element + */ +function updateWorkspaceStatusUI(result, container, headerFtsEl, headerVectorEl) { + if (result.success) { + var ftsPercent = result.hasIndex ? (result.fts.percent || 0) : 0; + var vectorPercent = result.hasIndex ? (result.vector.percent || 0) : 0; + + // Update header badges (always update if elements exist) + if (headerFtsEl) { + headerFtsEl.textContent = ftsPercent + '%'; + headerFtsEl.className = 'text-sm font-medium ' + + (ftsPercent >= 100 ? 'text-success' : (ftsPercent > 0 ? 'text-blue-500' : 'text-muted-foreground')); + } + if (headerVectorEl) { + headerVectorEl.textContent = vectorPercent.toFixed(1) + '%'; + headerVectorEl.className = 'text-sm font-medium ' + + (vectorPercent >= 100 ? 'text-success' : (vectorPercent >= 50 ? 'text-purple-500' : (vectorPercent > 0 ? 'text-purple-400' : 'text-muted-foreground'))); + } + + // Update detailed container (if exists) + if (container) { + var html = ''; + + if (!result.hasIndex) { + // No index for current workspace + html = '
' + + '
' + + ' ' + + (t('codexlens.noIndexFound') || 'No index found for current workspace') + + '
' + + '' + + '
'; + } else { + // FTS Status + var ftsColor = ftsPercent >= 100 ? 'bg-success' : (ftsPercent > 0 ? 'bg-blue-500' : 'bg-muted-foreground'); + var ftsTextColor = ftsPercent >= 100 ? 'text-success' : (ftsPercent > 0 ? 'text-blue-500' : 'text-muted-foreground'); + + html += '
' + + '
' + + '' + + ' ' + + '' + (t('codexlens.ftsIndex') || 'FTS Index') + '' + + '' + + '' + ftsPercent + '%' + + '
' + + '
' + + '
' + + '
' + + '
' + + (result.fts.indexedFiles || 0) + ' / ' + (result.fts.totalFiles || 0) + ' ' + (t('codexlens.filesIndexed') || 'files indexed') + + '
' + + '
'; + + // Vector Status + var vectorColor = vectorPercent >= 100 ? 'bg-success' : (vectorPercent >= 50 ? 'bg-purple-500' : (vectorPercent > 0 ? 'bg-purple-400' : 'bg-muted-foreground')); + var vectorTextColor = vectorPercent >= 100 ? 'text-success' : (vectorPercent >= 50 ? 'text-purple-500' : (vectorPercent > 0 ? 'text-purple-400' : 'text-muted-foreground')); + + html += '
' + + '
' + + '' + + ' ' + + '' + (t('codexlens.vectorIndex') || 'Vector Index') + '' + + '' + + '' + vectorPercent.toFixed(1) + '%' + + '
' + + '
' + + '
' + + '
' + + '
' + + (result.vector.filesWithEmbeddings || 0) + ' / ' + (result.vector.totalFiles || 0) + ' ' + (t('codexlens.filesWithEmbeddings') || 'files with embeddings') + + (result.vector.totalChunks > 0 ? ' (' + result.vector.totalChunks + ' chunks)' : '') + + '
' + + '
'; + + // Vector search availability indicator + if (vectorPercent >= 50) { + html += '
' + + '' + + '' + (t('codexlens.vectorSearchEnabled') || 'Vector search enabled') + '' + + '
'; + } else if (vectorPercent > 0) { + html += '
' + + '' + + '' + (t('codexlens.vectorSearchPartial') || 'Vector search requires ≥50% coverage') + '' + + '
'; + } + } + + container.innerHTML = html; + } + } else { + // Error from API + if (headerFtsEl) headerFtsEl.textContent = '--'; + if (headerVectorEl) headerVectorEl.textContent = '--'; + if (container) { + container.innerHTML = '
' + + ' ' + + (result.error || t('common.error') || 'Error loading status') + + '
'; + } + } + + if (window.lucide) lucide.createIcons(); +} + // ============================================================ // CODEXLENS CONFIGURATION MODAL // ============================================================ /** * Show CodexLens configuration modal + * @param {boolean} forceRefresh - Force refresh, bypass cache */ -async function showCodexLensConfigModal() { +async function showCodexLensConfigModal(forceRefresh) { try { - showRefreshToast(t('codexlens.loadingConfig'), 'info'); + // Check cache first for config and status + var config, status; + var usedCache = false; - // Fetch current config and status in parallel - const [configResponse, statusResponse] = await Promise.all([ - fetch('/api/codexlens/config'), - fetch('/api/codexlens/status') - ]); - const config = await configResponse.json(); - const status = await statusResponse.json(); + if (!forceRefresh && isCacheValid('config') && isCacheValid('status')) { + config = getCachedData('config'); + status = getCachedData('status'); + usedCache = true; + } else { + showRefreshToast(t('codexlens.loadingConfig'), 'info'); + + // Fetch current config and status in parallel + const [configResponse, statusResponse] = await Promise.all([ + fetch('/api/codexlens/config'), + fetch('/api/codexlens/status') + ]); + config = await configResponse.json(); + status = await statusResponse.json(); + + // Cache the results + setCacheData('config', config); + setCacheData('status', status); + } // Update window.cliToolsStatus to ensure isInstalled is correct if (!window.cliToolsStatus) { @@ -6642,3 +6750,13 @@ async function initIgnorePatternsCount() { } } window.initIgnorePatternsCount = initIgnorePatternsCount; + +// ============================================================ +// CACHE MANAGEMENT - Global Exports +// ============================================================ +window.invalidateCodexLensCache = invalidateCache; +window.refreshCodexLensData = async function(forceRefresh) { + invalidateCache(); + await refreshWorkspaceIndexStatus(true); + showRefreshToast(t('common.refreshed') || 'Refreshed', 'success'); +}; diff --git a/ccw/src/tools/claude-cli-tools.ts b/ccw/src/tools/claude-cli-tools.ts index bc49e150..7519aecb 100644 --- a/ccw/src/tools/claude-cli-tools.ts +++ b/ccw/src/tools/claude-cli-tools.ts @@ -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; // PREDEFINED_MODELS tools: Record; customEndpoints: ClaudeCustomEndpoint[]; } @@ -75,43 +76,58 @@ export interface ClaudeCliCombinedConfig extends ClaudeCliToolsConfig { // ========== Default Config ========== +// Predefined models for each tool +const PREDEFINED_MODELS: Record = { + gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'], + qwen: ['coder-model', 'vision-model', 'qwen2.5-coder-32b'], + codex: ['gpt-5.2', 'gpt-4.1', 'o4-mini', 'o3'], + claude: ['sonnet', 'opus', 'haiku', 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'], + opencode: [ + 'opencode/glm-4.7-free', + 'opencode/gpt-5-nano', + 'opencode/grok-code', + 'opencode/minimax-m2.1-free', + 'anthropic/claude-sonnet-4-20250514', + 'anthropic/claude-opus-4-20250514', + 'openai/gpt-4.1', + 'openai/o3', + 'google/gemini-2.5-pro', + 'google/gemini-2.5-flash' + ] +}; + const DEFAULT_TOOLS_CONFIG: ClaudeCliToolsConfig = { - version: '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 { 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 = {}; + + 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; - // 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 = {}; - 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 { + 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; +} { + const config = loadClaudeCliTools(projectDir); + return { + config, + predefinedModels: { ...PREDEFINED_MODELS } + }; +} diff --git a/ccw/src/tools/cli-config-manager.ts b/ccw/src/tools/cli-config-manager.ts index b94e3e74..781124ff 100644 --- a/ccw/src/tools/cli-config-manager.ts +++ b/ccw/src/tools/cli-config-manager.ts @@ -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; } -export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode'; +export type { CliToolName }; -// ========== Constants ========== +// ========== Re-exported Constants ========== -export const PREDEFINED_MODELS: Record = { - gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'], - qwen: ['coder-model', 'vision-model', 'qwen2.5-coder-32b'], - codex: ['gpt-5.2', 'gpt-4.1', 'o4-mini', 'o3'], - claude: ['sonnet', 'opus', 'haiku', 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'], - opencode: [ - 'opencode/glm-4.7-free', - 'opencode/gpt-5-nano', - 'opencode/grok-code', - 'opencode/minimax-m2.1-free', - 'anthropic/claude-sonnet-4-20250514', - 'anthropic/claude-opus-4-20250514', - 'openai/gpt-4.1', - 'openai/o3', - 'google/gemini-2.5-pro', - 'google/gemini-2.5-flash' - ] -}; +/** + * @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; - - if (typeof c.version !== 'number') return false; - if (!c.tools || typeof c.tools !== 'object') return false; - - const tools = c.tools as Record; - 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; - 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 = {}; + 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 { - 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 { - 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; } { - 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 = {}; + 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 }; } diff --git a/ccw/src/tools/index.ts b/ccw/src/tools/index.ts index 9381c567..c6bb1120 100644 --- a/ccw/src/tools/index.ts +++ b/ccw/src/tools/index.ts @@ -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); diff --git a/ccw/src/tools/skill-context-loader.ts b/ccw/src/tools/skill-context-loader.ts new file mode 100644 index 00000000..6038b9ac --- /dev/null +++ b/ccw/src/tools/skill-context-loader.ts @@ -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; + +/** + * 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): Promise> { + 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}` + }; + } +}