feat: Add environment file support for CLI tools

- Introduced a new input group for environment file configuration in the dashboard CSS.
- Updated hook manager to queue CLAUDE.md updates with configurable threshold and timeout.
- Enhanced CLI manager view to include environment file input for built-in tools (gemini, qwen).
- Implemented environment file loading mechanism in cli-executor-core, allowing custom environment variables.
- Added unit tests for environment file parsing and loading functionalities.
- Updated memory update queue to support dynamic configuration of threshold and timeout settings.
This commit is contained in:
catlog22
2026-01-13 21:31:46 +08:00
parent d5f57d29ed
commit 275d2cb0af
8 changed files with 639 additions and 105 deletions

View File

@@ -304,6 +304,51 @@
margin-top: 0;
}
/* Environment File Input Group */
.env-file-input-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.env-file-input-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.env-file-input-row .tool-config-input {
flex: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
margin-top: 0;
}
.env-file-input-row .btn-sm {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
white-space: nowrap;
}
.env-file-hint {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin: 0;
padding: 0;
}
.env-file-hint i {
flex-shrink: 0;
opacity: 0.7;
}
.btn-ghost.text-destructive:hover {
background: hsl(var(--destructive) / 0.1);
}

View File

@@ -49,43 +49,17 @@ const HOOK_TEMPLATES = {
description: 'Auto-update code index when files are written or edited',
category: 'indexing'
},
'memory-update-related': {
'memory-update-queue': {
event: 'Stop',
matcher: '',
command: 'bash',
args: ['-c', 'ccw tool exec memory_queue "{\\"action\\":\\"add\\",\\"path\\":\\"$CLAUDE_PROJECT_DIR\\",\\"tool\\":\\"gemini\\",\\"strategy\\":\\"single-layer\\"}"'],
description: 'Queue CLAUDE.md update for changed modules when session ends',
args: ['-c', 'ccw tool exec memory_queue "{\\"action\\":\\"add\\",\\"path\\":\\"$CLAUDE_PROJECT_DIR\\"}"'],
description: 'Queue CLAUDE.md update when session ends (batched by threshold/timeout)',
category: 'memory',
configurable: true,
config: {
tool: { type: 'select', options: ['gemini', 'qwen', 'codex'], default: 'gemini', label: 'CLI Tool' },
strategy: { type: 'select', options: ['single-layer', 'multi-layer'], default: 'single-layer', label: 'Strategy' }
}
},
'memory-update-periodic': {
event: 'PostToolUse',
matcher: 'Write|Edit',
command: 'bash',
args: ['-c', 'ccw tool exec memory_queue "{\\"action\\":\\"add\\",\\"path\\":\\"$CLAUDE_PROJECT_DIR\\",\\"tool\\":\\"gemini\\",\\"strategy\\":\\"single-layer\\"}"'],
description: 'Queue CLAUDE.md update on file changes (batched with threshold/timeout)',
category: 'memory',
configurable: true,
config: {
tool: { type: 'select', options: ['gemini', 'qwen', 'codex'], default: 'gemini', label: 'CLI Tool' },
strategy: { type: 'select', options: ['single-layer', 'multi-layer'], default: 'single-layer', label: 'Strategy' }
}
},
'memory-update-count-based': {
event: 'PostToolUse',
matcher: 'Write|Edit',
command: 'bash',
args: ['-c', 'ccw tool exec memory_queue "{\\"action\\":\\"add\\",\\"path\\":\\"$CLAUDE_PROJECT_DIR\\",\\"tool\\":\\"gemini\\",\\"strategy\\":\\"single-layer\\"}"'],
description: 'Queue CLAUDE.md update on file changes (auto-flush at 5 paths or 5min timeout)',
category: 'memory',
configurable: true,
config: {
tool: { type: 'select', options: ['gemini', 'qwen', 'codex'], default: 'gemini', label: 'CLI Tool' },
strategy: { type: 'select', options: ['single-layer', 'multi-layer'], default: 'single-layer', label: 'Strategy' }
threshold: { type: 'number', default: 5, min: 1, max: 20, label: 'Threshold (paths)', step: 1 },
timeout: { type: 'number', default: 300, min: 60, max: 1800, label: 'Timeout (seconds)', step: 60 }
}
},
// SKILL Context Loader templates
@@ -210,33 +184,19 @@ const HOOK_TEMPLATES = {
const WIZARD_TEMPLATES = {
'memory-update': {
name: 'Memory Update Hook',
description: 'Automatically update CLAUDE.md documentation based on code changes',
description: 'Queue-based CLAUDE.md updates with configurable threshold and timeout',
icon: 'brain',
options: [
{
id: 'on-stop',
name: 'On Session End',
description: 'Update documentation when Claude session ends',
templateId: 'memory-update-related'
},
{
id: 'periodic',
name: 'Periodic Update',
description: 'Update documentation at regular intervals during session',
templateId: 'memory-update-periodic'
},
{
id: 'count-based',
name: 'Count-Based Update',
description: 'Update documentation when file changes reach threshold',
templateId: 'memory-update-count-based'
id: 'queue',
name: 'Queue-Based Update',
description: 'Batch updates when threshold reached or timeout expires',
templateId: 'memory-update-queue'
}
],
configFields: [
{ key: 'tool', type: 'select', label: 'CLI Tool', options: ['gemini', 'qwen', 'codex'], default: 'gemini', description: 'Tool for documentation generation' },
{ key: 'interval', type: 'number', label: 'Interval (seconds)', default: 300, min: 60, max: 3600, step: 60, showFor: ['periodic'], description: 'Time between updates' },
{ key: 'threshold', type: 'number', label: 'File Count Threshold', default: 10, min: 3, max: 50, step: 1, showFor: ['count-based'], description: 'Number of file changes to trigger update' },
{ key: 'strategy', type: 'select', label: 'Update Strategy', options: ['related', 'single-layer'], default: 'related', description: 'Related: changed modules, Single-layer: current directory' }
{ key: 'threshold', type: 'number', label: 'Threshold (paths)', default: 5, min: 1, max: 20, step: 1, description: 'Number of paths to trigger batch update' },
{ key: 'timeout', type: 'number', label: 'Timeout (seconds)', default: 300, min: 60, max: 1800, step: 60, description: 'Auto-flush queue after this time' }
]
},
'skill-context': {
@@ -730,9 +690,7 @@ function renderWizardModalContent() {
// Helper to get translated option names
const getOptionName = (optId) => {
if (wizardId === 'memory-update') {
if (optId === 'on-stop') return t('hook.wizard.onSessionEnd');
if (optId === 'periodic') return t('hook.wizard.periodicUpdate');
if (optId === 'count-based') return t('hook.wizard.countBasedUpdate');
if (optId === 'queue') return t('hook.wizard.queueBasedUpdate') || 'Queue-Based Update';
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTracker');
@@ -748,9 +706,7 @@ function renderWizardModalContent() {
const getOptionDesc = (optId) => {
if (wizardId === 'memory-update') {
if (optId === 'on-stop') return t('hook.wizard.onSessionEndDesc');
if (optId === 'periodic') return t('hook.wizard.periodicUpdateDesc');
if (optId === 'count-based') return t('hook.wizard.countBasedUpdateDesc');
if (optId === 'queue') return t('hook.wizard.queueBasedUpdateDesc') || 'Batch updates when threshold reached or timeout expires';
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTrackerDesc');
@@ -767,20 +723,16 @@ function renderWizardModalContent() {
// Helper to get translated field labels
const getFieldLabel = (fieldKey) => {
const labels = {
'tool': t('hook.wizard.cliTool'),
'interval': t('hook.wizard.intervalSeconds'),
'threshold': t('hook.wizard.fileCountThreshold'),
'strategy': t('hook.wizard.updateStrategy')
'threshold': t('hook.wizard.thresholdPaths') || 'Threshold (paths)',
'timeout': t('hook.wizard.timeoutSeconds') || 'Timeout (seconds)'
};
return labels[fieldKey] || wizard.configFields.find(f => f.key === fieldKey)?.label || fieldKey;
};
const getFieldDesc = (fieldKey) => {
const descs = {
'tool': t('hook.wizard.toolForDocGen'),
'interval': t('hook.wizard.timeBetweenUpdates'),
'threshold': t('hook.wizard.fileCountThresholdDesc'),
'strategy': t('hook.wizard.relatedStrategy')
'threshold': t('hook.wizard.thresholdPathsDesc') || 'Number of paths to trigger batch update',
'timeout': t('hook.wizard.timeoutSecondsDesc') || 'Auto-flush queue after this time'
};
return descs[fieldKey] || wizard.configFields.find(f => f.key === fieldKey)?.description || '';
};
@@ -1154,16 +1106,9 @@ function generateWizardCommand() {
}
// Handle memory-update wizard (default)
// Now uses memory_queue for batched updates
const tool = wizardConfig.tool || 'gemini';
const strategy = wizardConfig.strategy || 'single-layer';
// Build the ccw tool command using memory_queue
// Use double quotes to allow bash $CLAUDE_PROJECT_DIR expansion
const params = `"{\\"action\\":\\"add\\",\\"path\\":\\"$CLAUDE_PROJECT_DIR\\",\\"tool\\":\\"${tool}\\",\\"strategy\\":\\"${strategy}\\"}"`;
// All trigger types now use the same queue-based command
// The queue handles batching (threshold: 5 paths, timeout: 5 min)
// Now uses memory_queue for batched updates with configurable threshold/timeout
// The command adds to queue, configuration is applied separately via submitHookWizard
const params = `"{\\"action\\":\\"add\\",\\"path\\":\\"$CLAUDE_PROJECT_DIR\\"}"`;
return `ccw tool exec memory_queue ${params}`;
}
@@ -1259,6 +1204,26 @@ async function submitHookWizard() {
}
await saveHook(scope, baseTemplate.event, hookData);
// For memory-update wizard, also configure queue settings
if (wizard.id === 'memory-update') {
const threshold = wizardConfig.threshold || 5;
const timeout = wizardConfig.timeout || 300;
try {
const configParams = JSON.stringify({ action: 'configure', threshold, timeout });
const response = await fetch('/api/tools/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool: 'memory_queue', params: configParams })
});
if (response.ok) {
showRefreshToast(`Queue configured: threshold=${threshold}, timeout=${timeout}s`, 'success');
}
} catch (e) {
console.warn('Failed to configure memory queue:', e);
}
}
closeHookWizardModal();
}

View File

@@ -261,6 +261,13 @@ const i18n = {
'cli.wrapper': 'Wrapper',
'cli.customClaudeSettings': 'Custom Claude CLI settings',
'cli.updateFailed': 'Failed to update',
// CLI Tool Config - Environment File
'cli.envFile': 'Environment File',
'cli.envFileOptional': '(optional)',
'cli.envFilePlaceholder': 'Path to .env file (e.g., ~/.gemini-env or C:/Users/xxx/.env)',
'cli.envFileHint': 'Load environment variables (e.g., API keys) before CLI execution. Supports ~ for home directory.',
'cli.envFileBrowse': 'Browse',
// CodexLens Configuration
'codexlens.config': 'CodexLens Configuration',
@@ -2421,6 +2428,13 @@ const i18n = {
'cli.wrapper': '封装',
'cli.customClaudeSettings': '自定义 Claude CLI 配置',
'cli.updateFailed': '更新失败',
// CLI 工具配置 - 环境文件
'cli.envFile': '环境文件',
'cli.envFileOptional': '(可选)',
'cli.envFilePlaceholder': '.env 文件路径(如 ~/.gemini-env 或 C:/Users/xxx/.env',
'cli.envFileHint': '在 CLI 执行前加载环境变量(如 API 密钥)。支持 ~ 表示用户目录。',
'cli.envFileBrowse': '浏览',
// CodexLens 配置
'codexlens.config': 'CodexLens 配置',

View File

@@ -523,6 +523,27 @@ function buildToolConfigModalContent(tool, config, models, status) {
'</div>' +
'</div>' +
// Environment File Section (only for builtin tools: gemini, qwen)
(tool === 'gemini' || tool === 'qwen' ? (
'<div class="tool-config-section">' +
'<h4><i data-lucide="file-key" class="w-3.5 h-3.5"></i> ' + t('cli.envFile') + ' <span class="text-muted">' + t('cli.envFileOptional') + '</span></h4>' +
'<div class="env-file-input-group">' +
'<div class="env-file-input-row">' +
'<input type="text" id="envFileInput" class="tool-config-input" ' +
'placeholder="' + t('cli.envFilePlaceholder') + '" ' +
'value="' + (config.envFile ? escapeHtml(config.envFile) : '') + '" />' +
'<button type="button" class="btn-sm btn-outline" id="envFileBrowseBtn">' +
'<i data-lucide="folder-open" class="w-3.5 h-3.5"></i> ' + t('cli.envFileBrowse') +
'</button>' +
'</div>' +
'<p class="env-file-hint">' +
'<i data-lucide="info" class="w-3 h-3"></i> ' +
t('cli.envFileHint') +
'</p>' +
'</div>' +
'</div>'
) : '') +
// Footer
'<div class="tool-config-footer">' +
'<button class="btn btn-outline" onclick="closeModal()">' + t('common.cancel') + '</button>' +
@@ -701,12 +722,23 @@ function initToolConfigModalEvents(tool, currentConfig, models) {
return;
}
// Get envFile value (only for gemini/qwen)
var envFileInput = document.getElementById('envFileInput');
var envFile = envFileInput ? envFileInput.value.trim() : '';
try {
await updateCliToolConfig(tool, {
var updateData = {
primaryModel: primaryModel,
secondaryModel: secondaryModel,
tags: currentTags
});
};
// Only include envFile for gemini/qwen tools
if (tool === 'gemini' || tool === 'qwen') {
updateData.envFile = envFile || null;
}
await updateCliToolConfig(tool, updateData);
// Reload config to reflect changes
await loadCliToolConfig();
showRefreshToast('Configuration saved', 'success');
@@ -719,6 +751,44 @@ function initToolConfigModalEvents(tool, currentConfig, models) {
};
}
// Environment file browse button (only for gemini/qwen)
var envFileBrowseBtn = document.getElementById('envFileBrowseBtn');
if (envFileBrowseBtn) {
envFileBrowseBtn.onclick = async function() {
try {
// Use file dialog API if available
var response = await fetch('/api/dialog/open-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: t('cli.envFile'),
filters: [
{ name: 'Environment Files', extensions: ['env'] },
{ name: 'All Files', extensions: ['*'] }
],
defaultPath: ''
})
});
if (response.ok) {
var data = await response.json();
if (data.filePath) {
var envFileInput = document.getElementById('envFileInput');
if (envFileInput) {
envFileInput.value = data.filePath;
}
}
} else {
// Fallback: prompt user to enter path manually
showRefreshToast('File dialog not available. Please enter path manually.', 'info');
}
} catch (err) {
console.error('Failed to open file dialog:', err);
showRefreshToast('File dialog not available. Please enter path manually.', 'info');
}
};
}
// Initialize lucide icons in modal
if (window.lucide) lucide.createIcons();
}