' +
@@ -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 ? '' : '') +
+ '' +
+ 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 = '
' +
+ '
' +
+ '' +
+ '
' +
+ '' +
+ '
' +
+ '
';
+
+ 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}`
+ };
+ }
+}