feat: Refactor CLI tool configuration management and introduce skill context loader

- Updated `claude-cli-tools.ts` to support new model configurations and migration from older versions.
- Added `getPredefinedModels` and `getAllPredefinedModels` functions for better model management.
- Deprecated `cli-config-manager.ts` in favor of `claude-cli-tools.ts`, maintaining backward compatibility.
- Introduced `skill-context-loader.ts` to handle skill context loading based on user prompts and keywords.
- Enhanced tool configuration functions to include secondary models and improved migration logic.
- Updated index file to register the new skill context loader tool.
This commit is contained in:
catlog22
2026-01-11 13:56:20 +08:00
parent 2c11392848
commit 16083130f8
11 changed files with 1959 additions and 420 deletions

View File

@@ -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"
}

View File

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

View File

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

View File

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

View File

@@ -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 = `<span class="cli-tool-badge cli-settings-badge" title="${enabledCliSettings.length} endpoint(s) configured">${enabledCliSettings.length} Endpoint${enabledCliSettings.length > 1 ? 's' : ''}</span>`;
}
// 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 = `
<div class="cli-settings-info mt-2 p-2 rounded bg-muted/50 text-xs">
<div class="flex items-center gap-1 text-muted-foreground mb-1">
<i data-lucide="settings-2" class="w-3 h-3"></i>
<span>CLI Wrapper Endpoints:</span>
</div>
<div class="text-foreground font-medium">${epNames}${moreCount}</div>
<a href="#" onclick="navigateToApiSettings('cli-settings'); return false;" class="text-primary hover:underline mt-1 inline-flex items-center gap-1">
<i data-lucide="external-link" class="w-3 h-3"></i>
Configure
</a>
</div>
`;
} else {
cliSettingsInfo = `
<div class="cli-settings-info mt-2 p-2 rounded bg-muted/30 text-xs">
<div class="flex items-center gap-1 text-muted-foreground">
<i data-lucide="info" class="w-3 h-3"></i>
<span>No CLI wrapper configured</span>
</div>
<a href="#" onclick="navigateToApiSettings('cli-settings'); return false;" class="text-primary hover:underline mt-1 inline-flex items-center gap-1">
<i data-lucide="plus" class="w-3 h-3"></i>
Add Endpoint
</a>
</div>
`;
}
}
return `
<div class="cli-tool-card tool-${tool} ${isAvailable ? 'available' : 'unavailable'} ${!isEnabled ? 'disabled' : ''}">
<div class="cli-tool-header">
@@ -362,6 +426,7 @@ function renderCliStatus() {
<span class="cli-tool-name">${tool.charAt(0).toUpperCase() + tool.slice(1)}</span>
${isDefault ? '<span class="cli-tool-badge">Default</span>' : ''}
${!isEnabled && isAvailable ? '<span class="cli-tool-badge-disabled">Disabled</span>' : ''}
${cliSettingsBadge}
</div>
<div class="cli-tool-desc text-xs text-muted-foreground mt-1">
${toolDescriptions[tool]}
@@ -376,6 +441,7 @@ function renderCliStatus() {
}
</div>
</div>
${cliSettingsInfo}
<div class="cli-tool-actions mt-3 flex gap-2">
${isAvailable ? (isEnabled
? `<button class="btn-sm btn-outline-warning flex items-center gap-1" onclick="toggleCliTool('${tool}', false)">
@@ -1323,3 +1389,39 @@ async function startSemanticInstall() {
}
}
// ========== Navigation ==========
/**
* Navigate to API Settings page with optional section
* @param {string} section - Target section: 'cli-settings', 'providers', 'endpoints'
*/
function navigateToApiSettings(section) {
// Try to switch to API Settings view
if (typeof switchView === 'function') {
switchView('api-settings');
} else if (window.switchView) {
window.switchView('api-settings');
}
// After view switch, select the target section
setTimeout(() => {
if (section === 'cli-settings') {
// Click CLI Settings tab if exists
const cliSettingsTab = document.querySelector('[data-section="cli-settings"]');
if (cliSettingsTab) {
cliSettingsTab.click();
} else {
// Fallback: try to find and click by text content
const tabs = document.querySelectorAll('.api-settings-sidebar .sidebar-item, .api-settings-tabs .tab-btn');
tabs.forEach(tab => {
if (tab.textContent.includes('CLI') || tab.textContent.includes('Wrapper')) {
tab.click();
}
});
}
}
}, 100);
}
// Export navigation function
window.navigateToApiSettings = navigateToApiSettings;

View File

@@ -27,6 +27,9 @@ let poolDiscoveredProviders = {};
// CLI Settings state
let cliSettingsData = null;
let selectedCliSettingsId = null;
let cliConfigMode = 'provider'; // 'provider' | 'direct'
let isAddingCliSettings = false;
let editingCliSettingsId = null;
// Cache for ccw-litellm status (frontend cache with TTL)
let ccwLitellmStatusCache = null;
@@ -3711,14 +3714,21 @@ function renderCliSettingsList() {
var html = '';
endpoints.forEach(function(endpoint) {
var isSelected = endpoint.id === selectedCliSettingsId;
html += '<div class="provider-item' + (isSelected ? ' selected' : '') + '" onclick="selectCliSettings(\'' + endpoint.id + '\')">' +
var isEditing = endpoint.id === editingCliSettingsId;
var settings = endpoint.settings || {};
var configMode = settings.configMode || (settings.providerId ? 'provider' : 'direct');
var configIcon = configMode === 'provider' ? 'link' : 'key';
html += '<div class="provider-item' + (isSelected || isEditing ? ' selected' : '') + '" onclick="selectCliSettings(\'' + endpoint.id + '\')">' +
'<div class="provider-item-content">' +
'<div class="provider-icon">' +
'<i data-lucide="settings"></i>' +
'<i data-lucide="' + configIcon + '"></i>' +
'</div>' +
'<div class="provider-info">' +
'<div class="provider-name">' + escapeHtml(endpoint.name) + '</div>' +
'<div class="provider-type">' + (endpoint.settings.model || 'sonnet') + '</div>' +
'<div class="provider-type" style="font-size: 0.75rem; color: var(--text-muted);">' +
(configMode === 'provider' ? 'Provider' : 'Direct') +
'</div>' +
'</div>' +
'</div>' +
'<div class="provider-status' + (endpoint.enabled ? ' enabled' : ' disabled') + '">' +
@@ -3759,10 +3769,45 @@ function renderCliSettingsDetail(endpointId) {
var settings = endpoint.settings || {};
var env = settings.env || {};
var configMode = settings.configMode || (settings.providerId ? 'provider' : 'direct');
var configModeLabel = configMode === 'provider' ? (t('apiSettings.providerBinding') || 'Provider Binding') : (t('apiSettings.directConfig') || 'Direct Configuration');
// Build model config display
var modelConfigHtml = '';
if (env.ANTHROPIC_MODEL || env.ANTHROPIC_DEFAULT_HAIKU_MODEL || env.ANTHROPIC_DEFAULT_SONNET_MODEL || env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
modelConfigHtml =
'<div class="detail-section">' +
'<h3>' + (t('apiSettings.modelConfig') || 'Model Configuration') + '</h3>' +
'<div class="detail-grid">' +
'<div class="detail-item">' +
'<label>ANTHROPIC_MODEL</label>' +
'<span class="mono">' + escapeHtml(env.ANTHROPIC_MODEL || '-') + '</span>' +
'</div>' +
'<div class="detail-item">' +
'<label>ANTHROPIC_DEFAULT_HAIKU_MODEL</label>' +
'<span class="mono">' + escapeHtml(env.ANTHROPIC_DEFAULT_HAIKU_MODEL || '-') + '</span>' +
'</div>' +
'<div class="detail-item">' +
'<label>ANTHROPIC_DEFAULT_SONNET_MODEL</label>' +
'<span class="mono">' + escapeHtml(env.ANTHROPIC_DEFAULT_SONNET_MODEL || '-') + '</span>' +
'</div>' +
'<div class="detail-item">' +
'<label>ANTHROPIC_DEFAULT_OPUS_MODEL</label>' +
'<span class="mono">' + escapeHtml(env.ANTHROPIC_DEFAULT_OPUS_MODEL || '-') + '</span>' +
'</div>' +
'</div>' +
'</div>';
}
container.innerHTML =
'<div class="provider-detail-header">' +
'<div>' +
'<h2>' + escapeHtml(endpoint.name) + '</h2>' +
'<span class="config-source-badge ' + configMode + '" style="margin-top: 0.5rem; display: inline-block;">' +
(configMode === 'provider' ? '<i data-lucide="link" style="width: 12px; height: 12px;"></i> ' : '<i data-lucide="key" style="width: 12px; height: 12px;"></i> ') +
configModeLabel +
'</span>' +
'</div>' +
'<div class="provider-detail-actions">' +
'<button class="btn btn-ghost" onclick="editCliSettings(\'' + endpoint.id + '\')" title="' + t('common.edit') + '">' +
'<i data-lucide="edit-2"></i>' +
@@ -3781,15 +3826,12 @@ function renderCliSettingsDetail(endpointId) {
'<span class="mono">' + escapeHtml(endpoint.id) + '</span>' +
'</div>' +
'<div class="detail-item">' +
'<label>' + t('apiSettings.model') + '</label>' +
'<span>' + escapeHtml(settings.model || 'sonnet') + '</span>' +
'</div>' +
'<div class="detail-item">' +
'<label>' + t('apiSettings.status') + '</label>' +
'<span class="status-badge ' + (endpoint.enabled ? 'enabled' : 'disabled') + '">' +
(endpoint.enabled ? t('common.enabled') : t('common.disabled')) +
'</span>' +
'</div>' +
(endpoint.description ? '<div class="detail-item detail-item-full"><label>' + t('apiSettings.description') + '</label><span>' + escapeHtml(endpoint.description) + '</span></div>' : '') +
'</div>' +
'</div>' +
'<div class="detail-section">' +
@@ -3805,6 +3847,7 @@ function renderCliSettingsDetail(endpointId) {
'</div>' +
'</div>' +
'</div>' +
modelConfigHtml +
'<div class="detail-section">' +
'<h3>' + t('apiSettings.settingsFilePath') + '</h3>' +
'<div class="code-block">' +
@@ -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 =
'<div class="provider-empty-state">' +
'<i data-lucide="settings" class="empty-icon"></i>' +
'<h3>' + t('apiSettings.noCliSettingsSelected') + '</h3>' +
'<p>' + t('apiSettings.cliSettingsHint') + '</p>' +
'<button class="btn btn-primary" style="margin-top: 1rem;" onclick="startAddCliSettings()">' +
'<i data-lucide="plus"></i> ' + t('apiSettings.addCliSettings') +
'</button>' +
'</div>';
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 =
'<div class="config-mode-toggle">' +
'<button type="button" class="config-mode-btn' + (cliConfigMode === 'provider' ? ' active' : '') + '" data-mode="provider" onclick="switchCliConfigMode(\'provider\')">' +
'<i data-lucide="link"></i> ' + (t('apiSettings.providerBinding') || 'Provider Binding') +
'</button>' +
'<button type="button" class="config-mode-btn' + (cliConfigMode === 'direct' ? ' active' : '') + '" data-mode="direct" onclick="switchCliConfigMode(\'direct\')">' +
'<i data-lucide="key"></i> ' + (t('apiSettings.directConfig') || 'Direct Configuration') +
'</button>' +
'</div>';
// Common fields
var commonFieldsHtml =
'<div class="form-group">' +
'<label for="cli-settings-name">' + t('apiSettings.endpointName') + ' *</label>' +
'<input type="text" id="cli-settings-name" class="form-control" value="' + escapeHtml(existingEndpoint ? existingEndpoint.name : '') + '" placeholder="My Claude Endpoint" required />' +
'</div>' +
'<div class="form-group">' +
'<label for="cli-settings-description">' + t('apiSettings.description') + '</label>' +
'<input type="text" id="cli-settings-description" class="form-control" value="' + escapeHtml(existingEndpoint ? (existingEndpoint.description || '') : '') + '" placeholder="Optional description" />' +
'</div>';
// Mode-specific form content container
var formContentHtml = '<div id="cli-config-mode-content"></div>';
// Enabled toggle
var enabledHtml =
'<div class="form-group" style="margin-top: 1rem;">' +
'<label class="checkbox-label">' +
'<input type="checkbox" id="cli-settings-enabled"' + (existingEndpoint ? (existingEndpoint.enabled ? ' checked' : '') : ' checked') + ' />' +
' ' + t('common.enabled') +
'</label>' +
'</div>';
// Action buttons
var actionsHtml =
'<div class="form-actions" style="margin-top: 1.5rem; display: flex; gap: 0.75rem; justify-content: flex-end;">' +
'<button type="button" class="btn btn-secondary" onclick="cancelCliSettingsForm()">' + t('common.cancel') + '</button>' +
'<button type="button" class="btn btn-primary" onclick="submitCliSettingsForm()">' +
'<i data-lucide="save"></i> ' + (isEdit ? t('common.save') : t('common.create')) +
'</button>' +
'</div>';
container.innerHTML =
'<div class="tool-detail-header">' +
'<h3><i data-lucide="settings"></i> ' + (isEdit ? t('apiSettings.editCliSettings') : t('apiSettings.addCliSettings')) + '</h3>' +
'</div>' +
'<div class="claude-config-form">' +
(isEdit ? '<input type="hidden" id="cli-settings-id" value="' + existingEndpoint.id + '">' : '') +
'<input type="hidden" id="cli-config-mode" value="' + cliConfigMode + '">' +
modeToggleHtml +
commonFieldsHtml +
formContentHtml +
enabledHtml +
actionsHtml +
'</div>';
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 ?
'<div class="info-message" style="margin-bottom: 1rem; padding: 0.75rem; background: hsl(var(--warning) / 0.1); border-radius: 0.375rem; display: flex; align-items: center; gap: 0.5rem;">' +
'<i data-lucide="alert-circle" style="width: 16px; height: 16px; color: hsl(var(--warning));"></i>' +
'<span style="font-size: 0.8125rem;">' + (t('apiSettings.noAnthropicProviders') || 'No Anthropic providers configured. Please add a provider first.') + '</span>' +
'</div>' : '';
container.innerHTML = noProvidersWarning +
'<div class="form-group">' +
'<label for="cli-settings-provider">' + t('apiSettings.provider') + ' *</label>' +
'<select id="cli-settings-provider" class="form-control" onchange="onCliProviderChange()"' + (!hasProviders ? ' disabled' : '') + '>' +
providerOptionsHtml +
'</select>' +
'</div>' +
// Model Config Section
'<div class="model-config-section">' +
'<h4><i data-lucide="cpu"></i> ' + (t('apiSettings.modelConfig') || 'Model Configuration') + '</h4>' +
'<div class="model-config-grid">' +
'<div class="form-group">' +
'<label for="cli-model-default">ANTHROPIC_MODEL</label>' +
'<input type="text" id="cli-model-default" class="form-control" placeholder="claude-3-5-sonnet-20241022" value="' + escapeHtml(env.ANTHROPIC_MODEL || '') + '" />' +
'</div>' +
'<div class="form-group">' +
'<label for="cli-model-haiku">ANTHROPIC_DEFAULT_HAIKU_MODEL</label>' +
'<input type="text" id="cli-model-haiku" class="form-control" placeholder="claude-3-haiku-20240307" value="' + escapeHtml(env.ANTHROPIC_DEFAULT_HAIKU_MODEL || '') + '" />' +
'</div>' +
'<div class="form-group">' +
'<label for="cli-model-sonnet">ANTHROPIC_DEFAULT_SONNET_MODEL</label>' +
'<input type="text" id="cli-model-sonnet" class="form-control" placeholder="claude-3-5-sonnet-20241022" value="' + escapeHtml(env.ANTHROPIC_DEFAULT_SONNET_MODEL || '') + '" />' +
'</div>' +
'<div class="form-group">' +
'<label for="cli-model-opus">ANTHROPIC_DEFAULT_OPUS_MODEL</label>' +
'<input type="text" id="cli-model-opus" class="form-control" placeholder="claude-3-opus-20240229" value="' + escapeHtml(env.ANTHROPIC_DEFAULT_OPUS_MODEL || '') + '" />' +
'</div>' +
'</div>' +
'</div>';
}
/**
* Render Direct Configuration mode content
*/
function renderDirectModeContent(container, env) {
container.innerHTML =
'<div class="form-group">' +
'<label for="cli-auth-token">ANTHROPIC_AUTH_TOKEN *</label>' +
'<input type="password" id="cli-auth-token" class="form-control" placeholder="sk-ant-..." value="' + escapeHtml(env.ANTHROPIC_AUTH_TOKEN || '') + '" />' +
'</div>' +
'<div class="form-group">' +
'<label for="cli-base-url">ANTHROPIC_BASE_URL</label>' +
'<input type="text" id="cli-base-url" class="form-control" placeholder="https://api.anthropic.com (optional)" value="' + escapeHtml(env.ANTHROPIC_BASE_URL || '') + '" />' +
'</div>' +
// Model Config Section
'<div class="model-config-section">' +
'<h4><i data-lucide="cpu"></i> ' + (t('apiSettings.modelConfig') || 'Model Configuration') + '</h4>' +
'<div class="model-config-grid">' +
'<div class="form-group">' +
'<label for="cli-model-default">ANTHROPIC_MODEL</label>' +
'<input type="text" id="cli-model-default" class="form-control" placeholder="claude-3-5-sonnet-20241022" value="' + escapeHtml(env.ANTHROPIC_MODEL || '') + '" />' +
'</div>' +
'<div class="form-group">' +
'<label for="cli-model-haiku">ANTHROPIC_DEFAULT_HAIKU_MODEL</label>' +
'<input type="text" id="cli-model-haiku" class="form-control" placeholder="claude-3-haiku-20240307" value="' + escapeHtml(env.ANTHROPIC_DEFAULT_HAIKU_MODEL || '') + '" />' +
'</div>' +
'<div class="form-group">' +
'<label for="cli-model-sonnet">ANTHROPIC_DEFAULT_SONNET_MODEL</label>' +
'<input type="text" id="cli-model-sonnet" class="form-control" placeholder="claude-3-5-sonnet-20241022" value="' + escapeHtml(env.ANTHROPIC_DEFAULT_SONNET_MODEL || '') + '" />' +
'</div>' +
'<div class="form-group">' +
'<label for="cli-model-opus">ANTHROPIC_DEFAULT_OPUS_MODEL</label>' +
'<input type="text" id="cli-model-opus" class="form-control" placeholder="claude-3-opus-20240229" value="' + escapeHtml(env.ANTHROPIC_DEFAULT_OPUS_MODEL || '') + '" />' +
'</div>' +
'</div>' +
'</div>';
}
/**
* 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 = '<div class="generic-modal-overlay active" id="edit-pool-modal">' +
'<div class="generic-modal" style="max-width: 600px;">' +
'<div class="generic-modal-header">' +
'<h3 class="generic-modal-title">' + t('apiSettings.editModelPool') + '</h3>' +
'<button class="generic-modal-close" onclick="closeEditPoolModal()">&times;</button>' +
'</div>' +
'<div class="generic-modal-body">' +
'<form id="edit-pool-form" class="api-settings-form" onsubmit="submitEditModelPool(event, \'' + poolId + '\')">' +
'<div class="form-group">' +
'<label>' + t('apiSettings.modelType') + '</label>' +
'<input type="text" class="cli-input" value="' + (pool.modelType === 'embedding' ? 'Embedding' : pool.modelType === 'llm' ? 'LLM' : 'Reranker') + '" disabled />' +
'</div>' +
'<div class="form-group">' +
'<label>' + t('apiSettings.poolName') + '</label>' +
'<input type="text" id="edit-pool-name" class="cli-input" value="' + escapeHtml(pool.name || '') + '" placeholder="e.g., Primary Embedding Pool" />' +
'</div>' +
'<div class="form-group">' +
'<label>' + t('apiSettings.targetModel') + '</label>' +
'<input type="text" class="cli-input" value="' + escapeHtml(pool.targetModel) + '" disabled />' +
'</div>' +
'<div class="form-group">' +
'<label>' + t('apiSettings.strategy') + ' *</label>' +
'<select id="edit-pool-strategy" class="cli-input" required>' +
'<option value="round_robin"' + (pool.strategy === 'round_robin' ? ' selected' : '') + '>Round Robin</option>' +
'<option value="latency_aware"' + (pool.strategy === 'latency_aware' ? ' selected' : '') + '>Latency Aware</option>' +
'<option value="weighted_random"' + (pool.strategy === 'weighted_random' ? ' selected' : '') + '>Weighted Random</option>' +
'</select>' +
'</div>' +
'<div class="form-group">' +
'<label>' + t('apiSettings.cooldown') + ' (seconds)</label>' +
'<input type="number" id="edit-pool-cooldown" class="cli-input" value="' + (pool.defaultCooldown || 60) + '" min="0" />' +
'</div>' +
'<div class="form-group">' +
'<label>' + t('apiSettings.maxConcurrent') + '</label>' +
'<input type="number" id="edit-pool-max-concurrent" class="cli-input" value="' + (pool.defaultMaxConcurrentPerKey || 4) + '" min="1" />' +
'</div>' +
'<div class="form-group">' +
'<label>' + t('apiSettings.description') + '</label>' +
'<textarea id="edit-pool-description" class="cli-input" rows="2" placeholder="Optional description">' + escapeHtml(pool.description || '') + '</textarea>' +
'</div>' +
'<div class="form-group">' +
'<label class="checkbox-label">' +
'<input type="checkbox" id="edit-pool-enabled"' + (pool.enabled ? ' checked' : '') + ' /> ' + t('apiSettings.enablePool') +
'</label>' +
'</div>' +
'<div class="form-group">' +
'<label class="checkbox-label">' +
'<input type="checkbox" id="edit-pool-auto-discover"' + (pool.autoDiscover !== false ? ' checked' : '') + ' /> ' + t('apiSettings.autoDiscoverProviders') +
'</label>' +
'</div>' +
'</form>' +
'</div>' +
'<div class="generic-modal-footer">' +
'<button class="btn btn-secondary" onclick="closeEditPoolModal()">' + t('common.cancel') + '</button>' +
'<button class="btn btn-primary" onclick="document.getElementById(\'edit-pool-form\').requestSubmit()">' + t('common.save') + '</button>' +
'</div>' +
'</div>' +
'</div>';
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 ==========

View File

@@ -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 = '<div class="text-xs text-muted-foreground text-center py-2">' +
@@ -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 = '<div class="text-center py-3">' +
'<div class="text-sm text-muted-foreground mb-2">' +
'<i data-lucide="alert-circle" class="w-4 h-4 inline mr-1"></i> ' +
(t('codexlens.noIndexFound') || 'No index found for current workspace') +
'</div>' +
'<button onclick="runFtsFullIndex()" class="text-xs text-primary hover:underline">' +
(t('codexlens.createIndex') || 'Create Index') +
'</button>' +
'</div>';
} 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 += '<div class="space-y-1">' +
'<div class="flex items-center justify-between text-xs">' +
'<span class="flex items-center gap-1.5">' +
'<i data-lucide="file-text" class="w-3.5 h-3.5 text-blue-500"></i> ' +
'<span class="font-medium">' + (t('codexlens.ftsIndex') || 'FTS Index') + '</span>' +
'</span>' +
'<span class="' + ftsTextColor + ' font-medium">' + ftsPercent + '%</span>' +
'</div>' +
'<div class="h-1.5 bg-muted rounded-full overflow-hidden">' +
'<div class="h-full ' + ftsColor + ' transition-all duration-300" style="width: ' + ftsPercent + '%"></div>' +
'</div>' +
'<div class="text-xs text-muted-foreground">' +
(result.fts.indexedFiles || 0) + ' / ' + (result.fts.totalFiles || 0) + ' ' + (t('codexlens.filesIndexed') || 'files indexed') +
'</div>' +
'</div>';
// 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 += '<div class="space-y-1 mt-3">' +
'<div class="flex items-center justify-between text-xs">' +
'<span class="flex items-center gap-1.5">' +
'<i data-lucide="brain" class="w-3.5 h-3.5 text-purple-500"></i> ' +
'<span class="font-medium">' + (t('codexlens.vectorIndex') || 'Vector Index') + '</span>' +
'</span>' +
'<span class="' + vectorTextColor + ' font-medium">' + vectorPercent.toFixed(1) + '%</span>' +
'</div>' +
'<div class="h-1.5 bg-muted rounded-full overflow-hidden">' +
'<div class="h-full ' + vectorColor + ' transition-all duration-300" style="width: ' + vectorPercent + '%"></div>' +
'</div>' +
'<div class="text-xs text-muted-foreground">' +
(result.vector.filesWithEmbeddings || 0) + ' / ' + (result.vector.totalFiles || 0) + ' ' + (t('codexlens.filesWithEmbeddings') || 'files with embeddings') +
(result.vector.totalChunks > 0 ? ' (' + result.vector.totalChunks + ' chunks)' : '') +
'</div>' +
'</div>';
// Vector search availability indicator
if (vectorPercent >= 50) {
html += '<div class="flex items-center gap-1.5 mt-2 pt-2 border-t border-border">' +
'<i data-lucide="check-circle-2" class="w-3.5 h-3.5 text-success"></i>' +
'<span class="text-xs text-success">' + (t('codexlens.vectorSearchEnabled') || 'Vector search enabled') + '</span>' +
'</div>';
} else if (vectorPercent > 0) {
html += '<div class="flex items-center gap-1.5 mt-2 pt-2 border-t border-border">' +
'<i data-lucide="alert-triangle" class="w-3.5 h-3.5 text-warning"></i>' +
'<span class="text-xs text-warning">' + (t('codexlens.vectorSearchPartial') || 'Vector search requires ≥50% coverage') + '</span>' +
'</div>';
}
}
container.innerHTML = html;
}
} else {
// Error from API
if (headerFtsEl) headerFtsEl.textContent = '--';
if (headerVectorEl) headerVectorEl.textContent = '--';
if (container) {
container.innerHTML = '<div class="text-xs text-destructive text-center py-2">' +
'<i data-lucide="alert-circle" class="w-4 h-4 inline mr-1"></i> ' +
(result.error || t('common.error') || 'Error loading status') +
'</div>';
}
}
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 = '<div class="text-center py-3">' +
'<div class="text-sm text-muted-foreground mb-2">' +
'<i data-lucide="alert-circle" class="w-4 h-4 inline mr-1"></i> ' +
(t('codexlens.noIndexFound') || 'No index found for current workspace') +
'</div>' +
'<button onclick="runFtsFullIndex()" class="text-xs text-primary hover:underline">' +
(t('codexlens.createIndex') || 'Create Index') +
'</button>' +
'</div>';
} 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 += '<div class="space-y-1">' +
'<div class="flex items-center justify-between text-xs">' +
'<span class="flex items-center gap-1.5">' +
'<i data-lucide="file-text" class="w-3.5 h-3.5 text-blue-500"></i> ' +
'<span class="font-medium">' + (t('codexlens.ftsIndex') || 'FTS Index') + '</span>' +
'</span>' +
'<span class="' + ftsTextColor + ' font-medium">' + ftsPercent + '%</span>' +
'</div>' +
'<div class="h-1.5 bg-muted rounded-full overflow-hidden">' +
'<div class="h-full ' + ftsColor + ' transition-all duration-300" style="width: ' + ftsPercent + '%"></div>' +
'</div>' +
'<div class="text-xs text-muted-foreground">' +
(result.fts.indexedFiles || 0) + ' / ' + (result.fts.totalFiles || 0) + ' ' + (t('codexlens.filesIndexed') || 'files indexed') +
'</div>' +
'</div>';
// 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 += '<div class="space-y-1 mt-3">' +
'<div class="flex items-center justify-between text-xs">' +
'<span class="flex items-center gap-1.5">' +
'<i data-lucide="brain" class="w-3.5 h-3.5 text-purple-500"></i> ' +
'<span class="font-medium">' + (t('codexlens.vectorIndex') || 'Vector Index') + '</span>' +
'</span>' +
'<span class="' + vectorTextColor + ' font-medium">' + vectorPercent.toFixed(1) + '%</span>' +
'</div>' +
'<div class="h-1.5 bg-muted rounded-full overflow-hidden">' +
'<div class="h-full ' + vectorColor + ' transition-all duration-300" style="width: ' + vectorPercent + '%"></div>' +
'</div>' +
'<div class="text-xs text-muted-foreground">' +
(result.vector.filesWithEmbeddings || 0) + ' / ' + (result.vector.totalFiles || 0) + ' ' + (t('codexlens.filesWithEmbeddings') || 'files with embeddings') +
(result.vector.totalChunks > 0 ? ' (' + result.vector.totalChunks + ' chunks)' : '') +
'</div>' +
'</div>';
// Vector search availability indicator
if (vectorPercent >= 50) {
html += '<div class="flex items-center gap-1.5 mt-2 pt-2 border-t border-border">' +
'<i data-lucide="check-circle-2" class="w-3.5 h-3.5 text-success"></i>' +
'<span class="text-xs text-success">' + (t('codexlens.vectorSearchEnabled') || 'Vector search enabled') + '</span>' +
'</div>';
} else if (vectorPercent > 0) {
html += '<div class="flex items-center gap-1.5 mt-2 pt-2 border-t border-border">' +
'<i data-lucide="alert-triangle" class="w-3.5 h-3.5 text-warning"></i>' +
'<span class="text-xs text-warning">' + (t('codexlens.vectorSearchPartial') || 'Vector search requires ≥50% coverage') + '</span>' +
'</div>';
}
}
container.innerHTML = html;
}
} else {
// Error from API
if (headerFtsEl) headerFtsEl.textContent = '--';
if (headerVectorEl) headerVectorEl.textContent = '--';
if (container) {
container.innerHTML = '<div class="text-xs text-destructive text-center py-2">' +
'<i data-lucide="alert-circle" class="w-4 h-4 inline mr-1"></i> ' +
(result.error || t('common.error') || 'Error loading status') +
'</div>';
}
}
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');
};

View File

@@ -13,13 +13,13 @@ import * as os from 'os';
export interface ClaudeCliTool {
enabled: boolean;
isBuiltin: boolean;
command: string;
description: string;
primaryModel?: string;
secondaryModel?: string;
tags: string[];
}
export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode';
export interface ClaudeCustomEndpoint {
id: string;
name: string;
@@ -37,6 +37,7 @@ export interface ClaudeCacheSettings {
export interface ClaudeCliToolsConfig {
$schema?: string;
version: string;
models?: Record<string, string[]>; // PREDEFINED_MODELS
tools: Record<string, ClaudeCliTool>;
customEndpoints: ClaudeCustomEndpoint[];
}
@@ -75,43 +76,58 @@ export interface ClaudeCliCombinedConfig extends ClaudeCliToolsConfig {
// ========== Default Config ==========
// Predefined models for each tool
const PREDEFINED_MODELS: Record<CliToolName, string[]> = {
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
qwen: ['coder-model', 'vision-model', 'qwen2.5-coder-32b'],
codex: ['gpt-5.2', 'gpt-4.1', 'o4-mini', 'o3'],
claude: ['sonnet', 'opus', 'haiku', 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'],
opencode: [
'opencode/glm-4.7-free',
'opencode/gpt-5-nano',
'opencode/grok-code',
'opencode/minimax-m2.1-free',
'anthropic/claude-sonnet-4-20250514',
'anthropic/claude-opus-4-20250514',
'openai/gpt-4.1',
'openai/o3',
'google/gemini-2.5-pro',
'google/gemini-2.5-flash'
]
};
const DEFAULT_TOOLS_CONFIG: ClaudeCliToolsConfig = {
version: '2.0.0',
version: '3.0.0',
models: { ...PREDEFINED_MODELS },
tools: {
gemini: {
enabled: true,
isBuiltin: true,
command: 'gemini',
description: 'Google AI for code analysis',
primaryModel: 'gemini-2.5-pro',
secondaryModel: 'gemini-2.5-flash',
tags: []
},
qwen: {
enabled: true,
isBuiltin: true,
command: 'qwen',
description: 'Alibaba AI assistant',
primaryModel: 'coder-model',
secondaryModel: 'coder-model',
tags: []
},
codex: {
enabled: true,
isBuiltin: true,
command: 'codex',
description: 'OpenAI code generation',
primaryModel: 'gpt-5.2',
secondaryModel: 'gpt-5.2',
tags: []
},
claude: {
enabled: true,
isBuiltin: true,
command: 'claude',
description: 'Anthropic AI assistant',
primaryModel: 'sonnet',
secondaryModel: 'haiku',
tags: []
},
opencode: {
enabled: true,
isBuiltin: true,
command: 'opencode',
description: 'OpenCode AI assistant',
primaryModel: 'opencode/glm-4.7-free',
secondaryModel: 'opencode/glm-4.7-free',
tags: []
}
},
@@ -203,19 +219,80 @@ function ensureClaudeDir(projectDir: string): void {
// ========== Main Functions ==========
/**
* Ensure tool has tags field (for backward compatibility)
* Ensure tool has required fields (for backward compatibility)
*/
function ensureToolTags(tool: Partial<ClaudeCliTool>): ClaudeCliTool {
return {
enabled: tool.enabled ?? true,
isBuiltin: tool.isBuiltin ?? false,
command: tool.command ?? '',
description: tool.description ?? '',
primaryModel: tool.primaryModel,
secondaryModel: tool.secondaryModel,
tags: tool.tags ?? []
};
}
/**
* Migrate config from older versions to v3.0.0
*/
function migrateConfig(config: any, projectDir: string): ClaudeCliToolsConfig {
const version = parseFloat(config.version || '1.0');
// Already v3.x, no migration needed
if (version >= 3.0) {
return config as ClaudeCliToolsConfig;
}
console.log(`[claude-cli-tools] Migrating config from v${config.version || '1.0'} to v3.0.0`);
// Try to load legacy cli-config.json for model data
let legacyCliConfig: any = null;
try {
const { StoragePaths } = require('../config/storage-paths.js');
const legacyPath = StoragePaths.project(projectDir).cliConfig;
const fs = require('fs');
if (fs.existsSync(legacyPath)) {
legacyCliConfig = JSON.parse(fs.readFileSync(legacyPath, 'utf-8'));
console.log(`[claude-cli-tools] Found legacy cli-config.json, merging model data`);
}
} catch {
// Ignore errors loading legacy config
}
const migratedTools: Record<string, ClaudeCliTool> = {};
for (const [key, tool] of Object.entries(config.tools || {})) {
const t = tool as any;
const legacyTool = legacyCliConfig?.tools?.[key];
migratedTools[key] = {
enabled: t.enabled ?? legacyTool?.enabled ?? true,
primaryModel: t.primaryModel ?? legacyTool?.primaryModel ?? DEFAULT_TOOLS_CONFIG.tools[key]?.primaryModel,
secondaryModel: t.secondaryModel ?? legacyTool?.secondaryModel ?? DEFAULT_TOOLS_CONFIG.tools[key]?.secondaryModel,
tags: t.tags ?? legacyTool?.tags ?? []
};
}
// Add any missing default tools
for (const [key, defaultTool] of Object.entries(DEFAULT_TOOLS_CONFIG.tools)) {
if (!migratedTools[key]) {
const legacyTool = legacyCliConfig?.tools?.[key];
migratedTools[key] = {
enabled: legacyTool?.enabled ?? defaultTool.enabled,
primaryModel: legacyTool?.primaryModel ?? defaultTool.primaryModel,
secondaryModel: legacyTool?.secondaryModel ?? defaultTool.secondaryModel,
tags: legacyTool?.tags ?? defaultTool.tags
};
}
}
return {
version: '3.0.0',
models: { ...PREDEFINED_MODELS },
tools: migratedTools,
customEndpoints: config.customEndpoints || [],
$schema: config.$schema
};
}
/**
* Ensure CLI tools configuration file exists
* Creates default config if missing (auto-rebuild feature)
@@ -270,6 +347,8 @@ export function ensureClaudeCliTools(projectDir: string, createInProject: boolea
* 1. Project: {projectDir}/.claude/cli-tools.json
* 2. Global: ~/.claude/cli-tools.json
* 3. Default config
*
* Automatically migrates older config versions to v3.0.0
*/
export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & { _source?: string } {
const resolved = resolveConfigPath(projectDir);
@@ -282,26 +361,41 @@ export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & {
const content = fs.readFileSync(resolved.path, 'utf-8');
const parsed = JSON.parse(content) as Partial<ClaudeCliCombinedConfig>;
// Merge tools with defaults and ensure tags exist
// Migrate older versions to v3.0.0
const migrated = migrateConfig(parsed, projectDir);
const needsSave = migrated.version !== parsed.version;
// Merge tools with defaults and ensure required fields exist
const mergedTools: Record<string, ClaudeCliTool> = {};
for (const [key, tool] of Object.entries({ ...DEFAULT_TOOLS_CONFIG.tools, ...(parsed.tools || {}) })) {
for (const [key, tool] of Object.entries({ ...DEFAULT_TOOLS_CONFIG.tools, ...(migrated.tools || {}) })) {
mergedTools[key] = ensureToolTags(tool);
}
// Ensure customEndpoints have tags
const mergedEndpoints = (parsed.customEndpoints || []).map(ep => ({
const mergedEndpoints = (migrated.customEndpoints || []).map(ep => ({
...ep,
tags: ep.tags ?? []
}));
const config: ClaudeCliToolsConfig & { _source?: string } = {
version: parsed.version || DEFAULT_TOOLS_CONFIG.version,
version: migrated.version || DEFAULT_TOOLS_CONFIG.version,
models: migrated.models || DEFAULT_TOOLS_CONFIG.models,
tools: mergedTools,
customEndpoints: mergedEndpoints,
$schema: parsed.$schema,
$schema: migrated.$schema,
_source: resolved.source
};
// Save migrated config if version changed
if (needsSave) {
try {
saveClaudeCliTools(projectDir, config);
console.log(`[claude-cli-tools] Saved migrated config to: ${resolved.path}`);
} catch (err) {
console.warn('[claude-cli-tools] Failed to save migrated config:', err);
}
}
console.log(`[claude-cli-tools] Loaded tools config from ${resolved.source}: ${resolved.path}`);
return config;
} catch (err) {
@@ -578,3 +672,122 @@ export function getContextToolsPath(provider: 'codexlens' | 'ace' | 'none'): str
return 'context-tools.md';
}
}
// ========== Model Configuration Functions ==========
/**
* Get predefined models for a specific tool
*/
export function getPredefinedModels(tool: string): string[] {
const toolName = tool as CliToolName;
return PREDEFINED_MODELS[toolName] ? [...PREDEFINED_MODELS[toolName]] : [];
}
/**
* Get all predefined models
*/
export function getAllPredefinedModels(): Record<string, string[]> {
return { ...PREDEFINED_MODELS };
}
/**
* Get tool configuration (compatible with cli-config-manager interface)
*/
export function getToolConfig(projectDir: string, tool: string): {
enabled: boolean;
primaryModel: string;
secondaryModel: string;
tags?: string[];
} {
const config = loadClaudeCliTools(projectDir);
const toolConfig = config.tools[tool];
if (!toolConfig) {
const defaultTool = DEFAULT_TOOLS_CONFIG.tools[tool];
return {
enabled: defaultTool?.enabled ?? true,
primaryModel: defaultTool?.primaryModel ?? '',
secondaryModel: defaultTool?.secondaryModel ?? '',
tags: defaultTool?.tags ?? []
};
}
return {
enabled: toolConfig.enabled,
primaryModel: toolConfig.primaryModel ?? '',
secondaryModel: toolConfig.secondaryModel ?? '',
tags: toolConfig.tags
};
}
/**
* Update tool configuration
*/
export function updateToolConfig(
projectDir: string,
tool: string,
updates: Partial<{
enabled: boolean;
primaryModel: string;
secondaryModel: string;
tags: string[];
}>
): ClaudeCliToolsConfig {
const config = loadClaudeCliTools(projectDir);
if (config.tools[tool]) {
if (updates.enabled !== undefined) {
config.tools[tool].enabled = updates.enabled;
}
if (updates.primaryModel !== undefined) {
config.tools[tool].primaryModel = updates.primaryModel;
}
if (updates.secondaryModel !== undefined) {
config.tools[tool].secondaryModel = updates.secondaryModel;
}
if (updates.tags !== undefined) {
config.tools[tool].tags = updates.tags;
}
saveClaudeCliTools(projectDir, config);
}
return config;
}
/**
* Get primary model for a tool
*/
export function getPrimaryModel(projectDir: string, tool: string): string {
const toolConfig = getToolConfig(projectDir, tool);
return toolConfig.primaryModel;
}
/**
* Get secondary model for a tool
*/
export function getSecondaryModel(projectDir: string, tool: string): string {
const toolConfig = getToolConfig(projectDir, tool);
return toolConfig.secondaryModel;
}
/**
* Check if a tool is enabled
*/
export function isToolEnabled(projectDir: string, tool: string): boolean {
const toolConfig = getToolConfig(projectDir, tool);
return toolConfig.enabled;
}
/**
* Get full config response for API (includes predefined models)
*/
export function getFullConfigResponse(projectDir: string): {
config: ClaudeCliToolsConfig;
predefinedModels: Record<string, string[]>;
} {
const config = loadClaudeCliTools(projectDir);
return {
config,
predefinedModels: { ...PREDEFINED_MODELS }
};
}

View File

@@ -1,20 +1,34 @@
/**
* CLI Configuration Manager
* Handles loading, saving, and managing CLI tool configurations
* Stores config in centralized storage (~/.ccw/projects/{id}/config/)
* CLI Configuration Manager (Deprecated - Redirects to claude-cli-tools.ts)
*
* This module is maintained for backward compatibility.
* All configuration is now managed by claude-cli-tools.ts using cli-tools.json
*
* @deprecated Use claude-cli-tools.ts directly
*/
import * as fs from 'fs';
import * as path from 'path';
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
import { loadClaudeCliTools, saveClaudeCliTools } from './claude-cli-tools.js';
import {
loadClaudeCliTools,
saveClaudeCliTools,
getToolConfig as getToolConfigFromClaude,
updateToolConfig as updateToolConfigFromClaude,
getPredefinedModels as getPredefinedModelsFromClaude,
getAllPredefinedModels,
getPrimaryModel as getPrimaryModelFromClaude,
getSecondaryModel as getSecondaryModelFromClaude,
isToolEnabled as isToolEnabledFromClaude,
getFullConfigResponse as getFullConfigResponseFromClaude,
type ClaudeCliTool,
type ClaudeCliToolsConfig,
type CliToolName
} from './claude-cli-tools.js';
// ========== Types ==========
// ========== Re-exported Types ==========
export interface CliToolConfig {
enabled: boolean;
primaryModel: string; // For CLI endpoint calls (ccw cli -p)
secondaryModel: string; // For internal calls (llm_enhancer, generate_module_docs)
tags?: string[]; // User-defined tags/labels for the tool
primaryModel: string;
secondaryModel: string;
tags?: string[];
}
export interface CliConfig {
@@ -22,234 +36,94 @@ export interface CliConfig {
tools: Record<string, CliToolConfig>;
}
export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode';
export type { CliToolName };
// ========== Constants ==========
// ========== Re-exported Constants ==========
export const PREDEFINED_MODELS: Record<CliToolName, string[]> = {
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
qwen: ['coder-model', 'vision-model', 'qwen2.5-coder-32b'],
codex: ['gpt-5.2', 'gpt-4.1', 'o4-mini', 'o3'],
claude: ['sonnet', 'opus', 'haiku', 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'],
opencode: [
'opencode/glm-4.7-free',
'opencode/gpt-5-nano',
'opencode/grok-code',
'opencode/minimax-m2.1-free',
'anthropic/claude-sonnet-4-20250514',
'anthropic/claude-opus-4-20250514',
'openai/gpt-4.1',
'openai/o3',
'google/gemini-2.5-pro',
'google/gemini-2.5-flash'
]
};
/**
* @deprecated Use getPredefinedModels() or getAllPredefinedModels() instead
*/
export const PREDEFINED_MODELS = getAllPredefinedModels();
/**
* @deprecated Default config is now managed in claude-cli-tools.ts
*/
export const DEFAULT_CONFIG: CliConfig = {
version: 1,
tools: {
gemini: {
enabled: true,
primaryModel: 'gemini-2.5-pro',
secondaryModel: 'gemini-2.5-flash'
},
qwen: {
enabled: true,
primaryModel: 'coder-model',
secondaryModel: 'coder-model'
},
codex: {
enabled: true,
primaryModel: 'gpt-5.2',
secondaryModel: 'gpt-5.2'
},
claude: {
enabled: true,
primaryModel: 'sonnet',
secondaryModel: 'haiku'
},
opencode: {
enabled: true,
primaryModel: 'opencode/glm-4.7-free', // Free model as default
secondaryModel: 'opencode/glm-4.7-free'
}
gemini: { enabled: true, primaryModel: 'gemini-2.5-pro', secondaryModel: 'gemini-2.5-flash' },
qwen: { enabled: true, primaryModel: 'coder-model', secondaryModel: 'coder-model' },
codex: { enabled: true, primaryModel: 'gpt-5.2', secondaryModel: 'gpt-5.2' },
claude: { enabled: true, primaryModel: 'sonnet', secondaryModel: 'haiku' },
opencode: { enabled: true, primaryModel: 'opencode/glm-4.7-free', secondaryModel: 'opencode/glm-4.7-free' }
}
};
// ========== Helper Functions ==========
// ========== Re-exported Functions ==========
function getConfigPath(baseDir: string): string {
return StoragePaths.project(baseDir).cliConfig;
}
/**
* Load CLI configuration
* @deprecated Use loadClaudeCliTools() instead
*/
export function loadCliConfig(baseDir: string): CliConfig {
const config = loadClaudeCliTools(baseDir);
function ensureConfigDirForProject(baseDir: string): void {
const configDir = StoragePaths.project(baseDir).config;
ensureStorageDir(configDir);
}
function isValidToolName(tool: string): tool is CliToolName {
return ['gemini', 'qwen', 'codex', 'claude', 'opencode'].includes(tool);
}
function validateConfig(config: unknown): config is CliConfig {
if (!config || typeof config !== 'object') return false;
const c = config as Record<string, unknown>;
if (typeof c.version !== 'number') return false;
if (!c.tools || typeof c.tools !== 'object') return false;
const tools = c.tools as Record<string, unknown>;
for (const toolName of ['gemini', 'qwen', 'codex', 'claude', 'opencode']) {
const tool = tools[toolName];
if (!tool || typeof tool !== 'object') return false;
const t = tool as Record<string, unknown>;
if (typeof t.enabled !== 'boolean') return false;
if (typeof t.primaryModel !== 'string') return false;
if (typeof t.secondaryModel !== 'string') return false;
// Convert to legacy format
const tools: Record<string, CliToolConfig> = {};
for (const [key, tool] of Object.entries(config.tools)) {
tools[key] = {
enabled: tool.enabled,
primaryModel: tool.primaryModel ?? '',
secondaryModel: tool.secondaryModel ?? '',
tags: tool.tags
};
}
return true;
return {
version: parseFloat(config.version) || 1,
tools
};
}
function mergeWithDefaults(config: Partial<CliConfig>): CliConfig {
const result: CliConfig = {
version: config.version ?? DEFAULT_CONFIG.version,
tools: { ...DEFAULT_CONFIG.tools }
};
/**
* Save CLI configuration
* @deprecated Use saveClaudeCliTools() instead
*/
export function saveCliConfig(baseDir: string, config: CliConfig): void {
const currentConfig = loadClaudeCliTools(baseDir);
if (config.tools) {
for (const toolName of Object.keys(config.tools)) {
if (isValidToolName(toolName) && config.tools[toolName]) {
result.tools[toolName] = {
...DEFAULT_CONFIG.tools[toolName],
...config.tools[toolName]
};
// Update tools from legacy format
for (const [key, tool] of Object.entries(config.tools)) {
if (currentConfig.tools[key]) {
currentConfig.tools[key].enabled = tool.enabled;
currentConfig.tools[key].primaryModel = tool.primaryModel;
currentConfig.tools[key].secondaryModel = tool.secondaryModel;
if (tool.tags) {
currentConfig.tools[key].tags = tool.tags;
}
}
}
return result;
}
// ========== Main Functions ==========
/**
* Load CLI configuration from .workflow/cli-config.json
* Returns default config if file doesn't exist or is invalid
*/
export function loadCliConfig(baseDir: string): CliConfig {
const configPath = getConfigPath(baseDir);
try {
if (!fs.existsSync(configPath)) {
return { ...DEFAULT_CONFIG };
}
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
if (validateConfig(parsed)) {
return mergeWithDefaults(parsed);
}
// Invalid config, return defaults
console.warn('[cli-config] Invalid config file, using defaults');
return { ...DEFAULT_CONFIG };
} catch (err) {
console.error('[cli-config] Error loading config:', err);
return { ...DEFAULT_CONFIG };
}
}
/**
* Save CLI configuration to .workflow/cli-config.json
*/
export function saveCliConfig(baseDir: string, config: CliConfig): void {
ensureConfigDirForProject(baseDir);
const configPath = getConfigPath(baseDir);
try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
} catch (err) {
console.error('[cli-config] Error saving config:', err);
throw new Error(`Failed to save CLI config: ${err}`);
}
saveClaudeCliTools(baseDir, currentConfig);
}
/**
* Get configuration for a specific tool
*/
export function getToolConfig(baseDir: string, tool: string): CliToolConfig {
if (!isValidToolName(tool)) {
throw new Error(`Invalid tool name: ${tool}`);
}
const config = loadCliConfig(baseDir);
return config.tools[tool] || DEFAULT_CONFIG.tools[tool];
}
/**
* Validate and sanitize tags array
* @param tags - Raw tags array from user input
* @returns Sanitized tags array
*/
function validateTags(tags: string[] | undefined): string[] | undefined {
if (!tags || !Array.isArray(tags)) return undefined;
const MAX_TAGS = 10;
const MAX_TAG_LENGTH = 30;
return tags
.filter(tag => typeof tag === 'string')
.map(tag => tag.trim())
.filter(tag => tag.length > 0 && tag.length <= MAX_TAG_LENGTH)
.slice(0, MAX_TAGS);
return getToolConfigFromClaude(baseDir, tool);
}
/**
* Update configuration for a specific tool
* Returns the updated tool config
*/
export function updateToolConfig(
baseDir: string,
tool: string,
updates: Partial<CliToolConfig>
): CliToolConfig {
if (!isValidToolName(tool)) {
throw new Error(`Invalid tool name: ${tool}`);
}
const config = loadCliConfig(baseDir);
const currentToolConfig = config.tools[tool] || DEFAULT_CONFIG.tools[tool];
// Apply updates
const updatedToolConfig: CliToolConfig = {
enabled: updates.enabled !== undefined ? updates.enabled : currentToolConfig.enabled,
primaryModel: updates.primaryModel || currentToolConfig.primaryModel,
secondaryModel: updates.secondaryModel || currentToolConfig.secondaryModel,
tags: updates.tags !== undefined ? validateTags(updates.tags) : currentToolConfig.tags
};
// Save updated config
config.tools[tool] = updatedToolConfig;
saveCliConfig(baseDir, config);
// Also sync tags to cli-tools.json
if (updates.tags !== undefined) {
try {
const claudeCliTools = loadClaudeCliTools(baseDir);
if (claudeCliTools.tools[tool]) {
claudeCliTools.tools[tool].tags = updatedToolConfig.tags || [];
saveClaudeCliTools(baseDir, claudeCliTools);
}
} catch (err) {
// Log warning instead of ignoring errors syncing to cli-tools.json
console.warn(`[cli-config] Failed to sync tags to cli-tools.json for tool '${tool}'.`, err);
}
}
return updatedToolConfig;
updateToolConfigFromClaude(baseDir, tool, updates);
return getToolConfig(baseDir, tool);
}
/**
@@ -270,73 +144,55 @@ export function disableTool(baseDir: string, tool: string): CliToolConfig {
* Check if a tool is enabled
*/
export function isToolEnabled(baseDir: string, tool: string): boolean {
try {
const config = getToolConfig(baseDir, tool);
return config.enabled;
} catch {
return true; // Default to enabled if error
}
return isToolEnabledFromClaude(baseDir, tool);
}
/**
* Get primary model for a tool
*/
export function getPrimaryModel(baseDir: string, tool: string): string {
try {
const config = getToolConfig(baseDir, tool);
return config.primaryModel;
} catch {
return isValidToolName(tool) ? DEFAULT_CONFIG.tools[tool].primaryModel : 'gemini-2.5-pro';
}
return getPrimaryModelFromClaude(baseDir, tool);
}
/**
* Get secondary model for a tool (used for internal calls)
* Get secondary model for a tool
*/
export function getSecondaryModel(baseDir: string, tool: string): string {
try {
const config = getToolConfig(baseDir, tool);
return config.secondaryModel;
} catch {
return isValidToolName(tool) ? DEFAULT_CONFIG.tools[tool].secondaryModel : 'gemini-2.5-flash';
}
return getSecondaryModelFromClaude(baseDir, tool);
}
/**
* Get all predefined models for a tool
*/
export function getPredefinedModels(tool: string): string[] {
if (!isValidToolName(tool)) {
return [];
}
return [...PREDEFINED_MODELS[tool]];
return getPredefinedModelsFromClaude(tool);
}
/**
* Get full config response for API (includes predefined models and tags from cli-tools.json)
* Get full config response for API
*/
export function getFullConfigResponse(baseDir: string): {
config: CliConfig;
predefinedModels: Record<string, string[]>;
} {
const config = loadCliConfig(baseDir);
const response = getFullConfigResponseFromClaude(baseDir);
// Merge tags from cli-tools.json
try {
const claudeCliTools = loadClaudeCliTools(baseDir);
for (const [toolName, toolConfig] of Object.entries(config.tools)) {
const claudeTool = claudeCliTools.tools[toolName];
if (claudeTool && claudeTool.tags) {
toolConfig.tags = claudeTool.tags;
}
}
} catch (err) {
// Log warning instead of ignoring errors loading cli-tools.json
console.warn('[cli-config] Could not merge tags from cli-tools.json.', err);
// Convert to legacy format
const tools: Record<string, CliToolConfig> = {};
for (const [key, tool] of Object.entries(response.config.tools)) {
tools[key] = {
enabled: tool.enabled,
primaryModel: tool.primaryModel ?? '',
secondaryModel: tool.secondaryModel ?? '',
tags: tool.tags
};
}
return {
config,
predefinedModels: { ...PREDEFINED_MODELS }
config: {
version: parseFloat(response.config.version) || 1,
tools
},
predefinedModels: response.predefinedModels
};
}

View File

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

View File

@@ -0,0 +1,213 @@
/**
* Skill Context Loader Tool
* Loads SKILL context based on keyword matching in user prompt
* Used by UserPromptSubmit hooks to inject skill context
*/
import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import { readFileSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
// Input schema for keyword mode config
const SkillConfigSchema = z.object({
skill: z.string(),
keywords: z.array(z.string())
});
// Main params schema
const ParamsSchema = z.object({
// Auto mode flag
mode: z.literal('auto').optional(),
// User prompt to match against
prompt: z.string(),
// Keyword mode configs (only for keyword mode)
configs: z.array(SkillConfigSchema).optional()
});
type Params = z.infer<typeof ParamsSchema>;
/**
* Get all available skill names from project and user directories
*/
function getAvailableSkills(): Array<{ name: string; folderName: string; location: 'project' | 'user' }> {
const skills: Array<{ name: string; folderName: string; location: 'project' | 'user' }> = [];
// Project skills
const projectSkillsDir = join(process.cwd(), '.claude', 'skills');
if (existsSync(projectSkillsDir)) {
try {
const entries = readdirSync(projectSkillsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMdPath = join(projectSkillsDir, entry.name, 'SKILL.md');
if (existsSync(skillMdPath)) {
const name = parseSkillName(skillMdPath) || entry.name;
skills.push({ name, folderName: entry.name, location: 'project' });
}
}
}
} catch {
// Ignore errors
}
}
// User skills
const userSkillsDir = join(homedir(), '.claude', 'skills');
if (existsSync(userSkillsDir)) {
try {
const entries = readdirSync(userSkillsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMdPath = join(userSkillsDir, entry.name, 'SKILL.md');
if (existsSync(skillMdPath)) {
const name = parseSkillName(skillMdPath) || entry.name;
// Skip if already added from project (project takes priority)
if (!skills.some(s => s.folderName === entry.name)) {
skills.push({ name, folderName: entry.name, location: 'user' });
}
}
}
}
} catch {
// Ignore errors
}
}
return skills;
}
/**
* Parse skill name from SKILL.md frontmatter
*/
function parseSkillName(skillMdPath: string): string | null {
try {
const content = readFileSync(skillMdPath, 'utf8');
if (content.startsWith('---')) {
const endIndex = content.indexOf('---', 3);
if (endIndex > 0) {
const frontmatter = content.substring(3, endIndex);
const nameMatch = frontmatter.match(/^name:\s*["']?([^"'\n]+)["']?/m);
if (nameMatch) {
return nameMatch[1].trim();
}
}
}
} catch {
// Ignore errors
}
return null;
}
/**
* Match prompt against keywords (case-insensitive)
*/
function matchKeywords(prompt: string, keywords: string[]): string | null {
const lowerPrompt = prompt.toLowerCase();
for (const keyword of keywords) {
if (keyword && lowerPrompt.includes(keyword.toLowerCase())) {
return keyword;
}
}
return null;
}
/**
* Format skill invocation instruction for hook output
* Returns a prompt to invoke the skill, not the full content
*/
function formatSkillInvocation(skillName: string, matchedKeyword?: string): string {
return `Use /${skillName} skill to handle this request.`;
}
/**
* Tool schema definition
*/
export const schema: ToolSchema = {
name: 'skill_context_loader',
description: 'Match keywords in user prompt and return skill invocation instruction. Returns "Use /skill-name skill" when keywords match.',
inputSchema: {
type: 'object',
properties: {
mode: {
type: 'string',
enum: ['auto'],
description: 'Auto mode: detect skill name in prompt automatically'
},
prompt: {
type: 'string',
description: 'User prompt to match against keywords'
},
configs: {
type: 'array',
description: 'Keyword mode: array of skill configs with keywords',
items: {
type: 'object',
properties: {
skill: { type: 'string', description: 'Skill folder name to load' },
keywords: {
type: 'array',
items: { type: 'string' },
description: 'Keywords to match in prompt'
}
},
required: ['skill', 'keywords']
}
}
},
required: ['prompt']
}
};
/**
* Tool handler
*/
export async function handler(params: Record<string, unknown>): Promise<ToolResult<string>> {
try {
const parsed = ParamsSchema.parse(params);
const { mode, prompt, configs } = parsed;
// Auto mode: detect skill name in prompt
if (mode === 'auto') {
const skills = getAvailableSkills();
const lowerPrompt = prompt.toLowerCase();
for (const skill of skills) {
// Check if prompt contains skill name or folder name
if (lowerPrompt.includes(skill.name.toLowerCase()) ||
lowerPrompt.includes(skill.folderName.toLowerCase())) {
return {
success: true,
result: formatSkillInvocation(skill.folderName, skill.name)
};
}
}
// No match - return empty (silent)
return { success: true, result: '' };
}
// Keyword mode: match against configured keywords
if (configs && configs.length > 0) {
for (const config of configs) {
const matchedKeyword = matchKeywords(prompt, config.keywords);
if (matchedKeyword) {
return {
success: true,
result: formatSkillInvocation(config.skill, matchedKeyword)
};
}
}
}
// No match - return empty (silent)
return { success: true, result: '' };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `skill_context_loader error: ${message}`
};
}
}