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

View File

@@ -34,6 +34,11 @@ export interface ClaudeCliTool {
* Used to lookup endpoint configuration in litellm-api-config.json
*/
id?: string;
/**
* Path to .env file for loading environment variables before CLI execution
* Supports both absolute paths and paths relative to home directory (e.g., ~/.my-env)
*/
envFile?: string;
}
export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode' | string;
@@ -808,6 +813,7 @@ export function getToolConfig(projectDir: string, tool: string): {
primaryModel: string;
secondaryModel: string;
tags?: string[];
envFile?: string;
} {
const config = loadClaudeCliTools(projectDir);
const toolConfig = config.tools[tool];
@@ -826,7 +832,8 @@ export function getToolConfig(projectDir: string, tool: string): {
enabled: toolConfig.enabled,
primaryModel: toolConfig.primaryModel ?? '',
secondaryModel: toolConfig.secondaryModel ?? '',
tags: toolConfig.tags
tags: toolConfig.tags,
envFile: toolConfig.envFile
};
}
@@ -841,6 +848,7 @@ export function updateToolConfig(
primaryModel: string;
secondaryModel: string;
tags: string[];
envFile: string | null;
}>
): ClaudeCliToolsConfig {
const config = loadClaudeCliTools(projectDir);
@@ -858,6 +866,14 @@ export function updateToolConfig(
if (updates.tags !== undefined) {
config.tools[tool].tags = updates.tags;
}
// Handle envFile: set to undefined if null/empty, otherwise set value
if (updates.envFile !== undefined) {
if (updates.envFile === null || updates.envFile === '') {
delete config.tools[tool].envFile;
} else {
config.tools[tool].envFile = updates.envFile;
}
}
saveClaudeCliTools(projectDir, config);
}

View File

@@ -6,6 +6,9 @@
import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import { spawn, ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { validatePath } from '../utils/path-resolver.js';
import { escapeWindowsArg } from '../utils/shell-escape.js';
import { buildCommand, checkToolAvailability, clearToolCache, debugLog, errorLog, type NativeResumeConfig, type ToolAvailability } from './cli-executor-utils.js';
@@ -82,7 +85,73 @@ import { findEndpointById } from '../config/litellm-api-config-manager.js';
// CLI Settings (CLI封装) integration
import { loadEndpointSettings, getSettingsFilePath, findEndpoint } from '../config/cli-settings-manager.js';
import { loadClaudeCliTools } from './claude-cli-tools.js';
import { loadClaudeCliTools, getToolConfig } from './claude-cli-tools.js';
/**
* Parse .env file content into key-value pairs
* Supports: KEY=value, KEY="value", KEY='value', comments (#), empty lines
*/
function parseEnvFile(content: string): Record<string, string> {
const env: Record<string, string> = {};
const lines = content.split(/\r?\n/);
for (const line of lines) {
// Skip empty lines and comments
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
// Find first = sign
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1).trim();
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (key) {
env[key] = value;
}
}
return env;
}
/**
* Load environment variables from .env file
* Supports ~ for home directory expansion
*/
function loadEnvFile(envFilePath: string): Record<string, string> {
try {
// Expand ~ to home directory
let resolvedPath = envFilePath;
if (resolvedPath.startsWith('~')) {
resolvedPath = path.join(os.homedir(), resolvedPath.slice(1));
}
// Resolve relative paths
if (!path.isAbsolute(resolvedPath)) {
resolvedPath = path.resolve(resolvedPath);
}
if (!fs.existsSync(resolvedPath)) {
debugLog('ENV_FILE', `Env file not found: ${resolvedPath}`);
return {};
}
const content = fs.readFileSync(resolvedPath, 'utf-8');
const envVars = parseEnvFile(content);
debugLog('ENV_FILE', `Loaded ${Object.keys(envVars).length} env vars from ${resolvedPath}`);
return envVars;
} catch (err) {
errorLog('ENV_FILE', `Failed to load env file: ${envFilePath}`, err as Error);
return {};
}
}
/**
* Execute Claude CLI with custom settings file (CLI封装)
@@ -746,6 +815,19 @@ async function executeCliTool(
const commandToSpawn = isWindows ? escapeWindowsArg(command) : command;
const argsToSpawn = isWindows ? args.map(escapeWindowsArg) : args;
// Load custom environment variables from envFile if configured (for gemini/qwen)
const toolConfig = getToolConfig(workingDir, tool);
let customEnv: Record<string, string> = {};
if (toolConfig.envFile) {
customEnv = loadEnvFile(toolConfig.envFile);
}
// Merge custom env with process.env (custom env takes precedence)
const spawnEnv = {
...process.env,
...customEnv
};
debugLog('SPAWN', `Spawning process`, {
command,
args,
@@ -754,13 +836,16 @@ async function executeCliTool(
useStdin,
platform: process.platform,
fullCommand: `${command} ${args.join(' ')}`,
hasCustomEnv: Object.keys(customEnv).length > 0,
customEnvKeys: Object.keys(customEnv),
...(isWindows ? { escapedCommand: commandToSpawn, escapedArgs: argsToSpawn, escapedFullCommand: `${commandToSpawn} ${argsToSpawn.join(' ')}` } : {})
});
const child = spawn(commandToSpawn, argsToSpawn, {
cwd: workingDir,
shell: isWindows, // Enable shell on Windows for .cmd files
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe']
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
env: spawnEnv
});
// Track current child process for cleanup on interruption
@@ -1533,6 +1618,9 @@ export type { PromptFormat, ConcatOptions } from './cli-prompt-builder.js';
// Export utility functions and tool definition for backward compatibility
export { executeCliTool, checkToolAvailability, clearToolCache };
// Export env file utilities for testing
export { parseEnvFile, loadEnvFile };
// Export prompt concatenation utilities
export { PromptConcatenator, createPromptConcatenator, buildPrompt, buildMultiTurnPrompt } from './cli-prompt-builder.js';

View File

@@ -13,11 +13,34 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { homedir } from 'os';
// Configuration constants
const QUEUE_THRESHOLD = 5;
const QUEUE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
// Default configuration
const DEFAULT_THRESHOLD = 5;
const DEFAULT_TIMEOUT_SECONDS = 300; // 5 minutes
const QUEUE_FILE_PATH = join(homedir(), '.claude', '.memory-queue.json');
/**
* Get queue configuration (from file or defaults)
* @returns {{ threshold: number, timeoutMs: number }}
*/
function getQueueConfig() {
try {
if (existsSync(QUEUE_FILE_PATH)) {
const content = readFileSync(QUEUE_FILE_PATH, 'utf8');
const data = JSON.parse(content);
return {
threshold: data.config?.threshold || DEFAULT_THRESHOLD,
timeoutMs: (data.config?.timeout || DEFAULT_TIMEOUT_SECONDS) * 1000
};
}
} catch (e) {
// Use defaults
}
return {
threshold: DEFAULT_THRESHOLD,
timeoutMs: DEFAULT_TIMEOUT_SECONDS * 1000
};
}
// In-memory timeout reference (for cross-call persistence, we track via file timestamp)
let scheduledTimeoutId = null;
@@ -33,7 +56,7 @@ function ensureDir(filePath) {
/**
* Load queue from file
* @returns {{ items: Array<{path: string, tool: string, strategy: string, addedAt: string}>, createdAt: string | null }}
* @returns {{ items: Array<{path: string, tool: string, strategy: string, addedAt: string}>, createdAt: string | null, config?: { threshold: number, timeout: number } }}
*/
function loadQueue() {
try {
@@ -42,13 +65,14 @@ function loadQueue() {
const data = JSON.parse(content);
return {
items: Array.isArray(data.items) ? data.items : [],
createdAt: data.createdAt || null
createdAt: data.createdAt || null,
config: data.config || null
};
}
} catch (e) {
console.error('[MemoryQueue] Failed to load queue:', e.message);
}
return { items: [], createdAt: null };
return { items: [], createdAt: null, config: null };
}
/**
@@ -83,6 +107,7 @@ function normalizePath(p) {
function addToQueue(path, options = {}) {
const { tool = 'gemini', strategy = 'single-layer' } = options;
const queue = loadQueue();
const config = getQueueConfig();
const normalizedPath = normalizePath(path);
const now = new Date().toISOString();
@@ -101,7 +126,7 @@ function addToQueue(path, options = {}) {
return {
queued: false,
queueSize: queue.items.length,
willFlush: queue.items.length >= QUEUE_THRESHOLD,
willFlush: queue.items.length >= config.threshold,
message: `Path already in queue (updated): ${path}`
};
}
@@ -121,7 +146,7 @@ function addToQueue(path, options = {}) {
saveQueue(queue);
const willFlush = queue.items.length >= QUEUE_THRESHOLD;
const willFlush = queue.items.length >= config.threshold;
// Schedule timeout if not already scheduled
scheduleTimeout();
@@ -131,8 +156,8 @@ function addToQueue(path, options = {}) {
queueSize: queue.items.length,
willFlush,
message: willFlush
? `Queue threshold reached (${queue.items.length}/${QUEUE_THRESHOLD}), will flush`
: `Added to queue (${queue.items.length}/${QUEUE_THRESHOLD})`
? `Queue threshold reached (${queue.items.length}/${config.threshold}), will flush`
: `Added to queue (${queue.items.length}/${config.threshold})`
};
}
@@ -142,24 +167,58 @@ function addToQueue(path, options = {}) {
*/
function getQueueStatus() {
const queue = loadQueue();
const config = getQueueConfig();
let timeUntilTimeout = null;
if (queue.createdAt && queue.items.length > 0) {
const createdTime = new Date(queue.createdAt).getTime();
const elapsed = Date.now() - createdTime;
timeUntilTimeout = Math.max(0, QUEUE_TIMEOUT_MS - elapsed);
timeUntilTimeout = Math.max(0, config.timeoutMs - elapsed);
}
return {
queueSize: queue.items.length,
threshold: QUEUE_THRESHOLD,
threshold: config.threshold,
items: queue.items,
timeoutMs: QUEUE_TIMEOUT_MS,
timeoutMs: config.timeoutMs,
timeoutSeconds: config.timeoutMs / 1000,
timeUntilTimeout,
createdAt: queue.createdAt
};
}
/**
* Configure queue settings
* @param {{ threshold?: number, timeout?: number }} settings
* @returns {{ success: boolean, config: { threshold: number, timeout: number } }}
*/
function configureQueue(settings) {
const queue = loadQueue();
const currentConfig = getQueueConfig();
const newConfig = {
threshold: settings.threshold || currentConfig.threshold,
timeout: settings.timeout || (currentConfig.timeoutMs / 1000)
};
// Validate
if (newConfig.threshold < 1 || newConfig.threshold > 20) {
throw new Error('Threshold must be between 1 and 20');
}
if (newConfig.timeout < 60 || newConfig.timeout > 1800) {
throw new Error('Timeout must be between 60 and 1800 seconds');
}
queue.config = newConfig;
saveQueue(queue);
return {
success: true,
config: newConfig,
message: `Queue configured: threshold=${newConfig.threshold}, timeout=${newConfig.timeout}s`
};
}
/**
* Flush queue - execute batch update
* @returns {Promise<{ success: boolean, processed: number, results: Array, errors: Array }>}
@@ -243,6 +302,7 @@ function scheduleTimeout() {
// We use file-based timeout tracking for persistence across process restarts
// The actual timeout check happens on next add/status call
const queue = loadQueue();
const config = getQueueConfig();
if (!queue.createdAt || queue.items.length === 0) {
return;
@@ -251,7 +311,7 @@ function scheduleTimeout() {
const createdTime = new Date(queue.createdAt).getTime();
const elapsed = Date.now() - createdTime;
if (elapsed >= QUEUE_TIMEOUT_MS) {
if (elapsed >= config.timeoutMs) {
// Timeout already exceeded, should flush
console.log('[MemoryQueue] Timeout exceeded, auto-flushing');
// Don't await here to avoid blocking
@@ -260,7 +320,7 @@ function scheduleTimeout() {
});
} else if (!scheduledTimeoutId) {
// Schedule in-memory timeout for current process
const remaining = QUEUE_TIMEOUT_MS - elapsed;
const remaining = config.timeoutMs - elapsed;
scheduledTimeoutId = setTimeout(() => {
scheduledTimeoutId = null;
const currentQueue = loadQueue();
@@ -295,6 +355,7 @@ function clearScheduledTimeout() {
*/
async function checkTimeout() {
const queue = loadQueue();
const config = getQueueConfig();
if (!queue.createdAt || queue.items.length === 0) {
return { expired: false, flushed: false };
@@ -303,7 +364,7 @@ async function checkTimeout() {
const createdTime = new Date(queue.createdAt).getTime();
const elapsed = Date.now() - createdTime;
if (elapsed >= QUEUE_TIMEOUT_MS) {
if (elapsed >= config.timeoutMs) {
console.log('[MemoryQueue] Timeout expired, triggering flush');
const result = await flushQueue();
return { expired: true, flushed: true, result };
@@ -318,7 +379,7 @@ async function checkTimeout() {
* @returns {Promise<unknown>}
*/
async function execute(params) {
const { action, path, tool = 'gemini', strategy = 'single-layer' } = params;
const { action, path, tool = 'gemini', strategy = 'single-layer', threshold, timeout } = params;
switch (action) {
case 'add':
@@ -359,8 +420,11 @@ async function execute(params) {
case 'flush':
return await flushQueue();
case 'configure':
return configureQueue({ threshold, timeout });
default:
throw new Error(`Unknown action: ${action}. Valid actions: add, status, flush`);
throw new Error(`Unknown action: ${action}. Valid actions: add, status, flush, configure`);
}
}
@@ -372,21 +436,34 @@ export const memoryQueueTool = {
description: `Memory update queue management. Batches CLAUDE.md updates for efficiency.
Actions:
- add: Add path to queue (auto-flushes at threshold ${QUEUE_THRESHOLD} or timeout ${QUEUE_TIMEOUT_MS / 1000}s)
- status: Get queue status
- flush: Immediately execute all queued updates`,
- add: Add path to queue (auto-flushes at configured threshold/timeout)
- status: Get queue status and configuration
- flush: Immediately execute all queued updates
- configure: Set threshold and timeout settings`,
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['add', 'status', 'flush'],
enum: ['add', 'status', 'flush', 'configure'],
description: 'Queue action to perform'
},
path: {
type: 'string',
description: 'Module directory path (required for add action)'
},
threshold: {
type: 'number',
description: 'Number of paths to trigger flush (1-20, for configure action)',
minimum: 1,
maximum: 20
},
timeout: {
type: 'number',
description: 'Timeout in seconds to trigger flush (60-1800, for configure action)',
minimum: 60,
maximum: 1800
},
tool: {
type: 'string',
enum: ['gemini', 'qwen', 'codex'],
@@ -412,10 +489,11 @@ export {
addToQueue,
getQueueStatus,
flushQueue,
configureQueue,
scheduleTimeout,
clearScheduledTimeout,
checkTimeout,
QUEUE_THRESHOLD,
QUEUE_TIMEOUT_MS,
DEFAULT_THRESHOLD,
DEFAULT_TIMEOUT_SECONDS,
QUEUE_FILE_PATH
};

View File

@@ -0,0 +1,258 @@
/**
* Unit tests for CLI env file loading mechanism
*
* Tests parseEnvFile and loadEnvFile functions without calling the actual CLI
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
// Set test CCW home before importing module
const TEST_CCW_HOME = mkdtempSync(join(tmpdir(), 'ccw-env-file-test-'));
process.env.CCW_DATA_DIR = TEST_CCW_HOME;
// Import from dist (built version)
const cliExecutorPath = new URL('../dist/tools/cli-executor-core.js', import.meta.url).href;
describe('Env File Loading Mechanism', async () => {
let parseEnvFile: (content: string) => Record<string, string>;
let loadEnvFile: (envFilePath: string) => Record<string, string>;
let testTempDir: string;
before(async () => {
const mod = await import(cliExecutorPath);
parseEnvFile = mod.parseEnvFile;
loadEnvFile = mod.loadEnvFile;
testTempDir = mkdtempSync(join(tmpdir(), 'env-test-'));
});
after(() => {
// Cleanup test directories
if (existsSync(testTempDir)) {
rmSync(testTempDir, { recursive: true, force: true });
}
if (existsSync(TEST_CCW_HOME)) {
rmSync(TEST_CCW_HOME, { recursive: true, force: true });
}
});
describe('parseEnvFile', () => {
it('should parse simple KEY=value pairs', () => {
const content = `API_KEY=abc123
SECRET=mysecret`;
const result = parseEnvFile(content);
assert.equal(result['API_KEY'], 'abc123');
assert.equal(result['SECRET'], 'mysecret');
});
it('should handle double-quoted values', () => {
const content = `API_KEY="value with spaces"
PATH="/usr/local/bin"`;
const result = parseEnvFile(content);
assert.equal(result['API_KEY'], 'value with spaces');
assert.equal(result['PATH'], '/usr/local/bin');
});
it('should handle single-quoted values', () => {
const content = `API_KEY='value with spaces'
NAME='John Doe'`;
const result = parseEnvFile(content);
assert.equal(result['API_KEY'], 'value with spaces');
assert.equal(result['NAME'], 'John Doe');
});
it('should skip comments', () => {
const content = `# This is a comment
API_KEY=value
# Another comment
SECRET=test`;
const result = parseEnvFile(content);
assert.equal(Object.keys(result).length, 2);
assert.equal(result['API_KEY'], 'value');
assert.equal(result['SECRET'], 'test');
});
it('should skip empty lines', () => {
const content = `
API_KEY=value
SECRET=test
`;
const result = parseEnvFile(content);
assert.equal(Object.keys(result).length, 2);
});
it('should handle values with equals signs', () => {
const content = `DATABASE_URL=postgresql://user:pass@host/db?sslmode=require`;
const result = parseEnvFile(content);
assert.equal(result['DATABASE_URL'], 'postgresql://user:pass@host/db?sslmode=require');
});
it('should handle Windows-style line endings (CRLF)', () => {
const content = `API_KEY=value\r\nSECRET=test\r\n`;
const result = parseEnvFile(content);
assert.equal(result['API_KEY'], 'value');
assert.equal(result['SECRET'], 'test');
});
it('should trim whitespace around keys and values', () => {
const content = ` API_KEY = value
SECRET = test `;
const result = parseEnvFile(content);
assert.equal(result['API_KEY'], 'value');
assert.equal(result['SECRET'], 'test');
});
it('should skip lines without equals sign', () => {
const content = `API_KEY=value
INVALID_LINE
SECRET=test`;
const result = parseEnvFile(content);
assert.equal(Object.keys(result).length, 2);
assert.equal(result['INVALID_LINE'], undefined);
});
it('should handle empty values', () => {
const content = `EMPTY_VALUE=
ANOTHER=test`;
const result = parseEnvFile(content);
assert.equal(result['EMPTY_VALUE'], '');
assert.equal(result['ANOTHER'], 'test');
});
it('should handle mixed format content', () => {
const content = `# Gemini API Configuration
GEMINI_API_KEY="sk-gemini-xxx"
# OpenAI compatible settings
OPENAI_API_BASE='https://api.example.com/v1'
OPENAI_API_KEY=abc123
# Feature flags
ENABLE_DEBUG=true`;
const result = parseEnvFile(content);
assert.equal(result['GEMINI_API_KEY'], 'sk-gemini-xxx');
assert.equal(result['OPENAI_API_BASE'], 'https://api.example.com/v1');
assert.equal(result['OPENAI_API_KEY'], 'abc123');
assert.equal(result['ENABLE_DEBUG'], 'true');
assert.equal(Object.keys(result).length, 4);
});
});
describe('loadEnvFile', () => {
it('should load env file from absolute path', () => {
const envPath = join(testTempDir, 'test.env');
writeFileSync(envPath, 'API_KEY=test_value\nSECRET=123');
const result = loadEnvFile(envPath);
assert.equal(result['API_KEY'], 'test_value');
assert.equal(result['SECRET'], '123');
});
it('should return empty object for non-existent file', () => {
const result = loadEnvFile('/non/existent/path/.env');
assert.deepEqual(result, {});
});
it('should expand ~ to home directory', () => {
// Create .env-test in home directory for testing
const homeEnvPath = join(homedir(), '.ccw-env-test');
writeFileSync(homeEnvPath, 'HOME_TEST_KEY=home_value');
try {
const result = loadEnvFile('~/.ccw-env-test');
assert.equal(result['HOME_TEST_KEY'], 'home_value');
} finally {
// Cleanup
if (existsSync(homeEnvPath)) {
rmSync(homeEnvPath);
}
}
});
it('should handle relative paths', () => {
const envPath = join(testTempDir, 'relative.env');
writeFileSync(envPath, 'RELATIVE_KEY=rel_value');
// Save and change cwd
const originalCwd = process.cwd();
try {
process.chdir(testTempDir);
const result = loadEnvFile('./relative.env');
assert.equal(result['RELATIVE_KEY'], 'rel_value');
} finally {
process.chdir(originalCwd);
}
});
it('should handle empty file', () => {
const envPath = join(testTempDir, 'empty.env');
writeFileSync(envPath, '');
const result = loadEnvFile(envPath);
assert.deepEqual(result, {});
});
it('should handle file with only comments', () => {
const envPath = join(testTempDir, 'comments.env');
writeFileSync(envPath, '# Just a comment\n# Another comment\n');
const result = loadEnvFile(envPath);
assert.deepEqual(result, {});
});
});
describe('Integration scenario: Gemini CLI env file', () => {
it('should correctly parse typical Gemini .env file', () => {
const geminiEnvContent = `# Gemini CLI Environment Configuration
# Created by CCW Dashboard
# Google AI API Key
GOOGLE_API_KEY="AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
# Optional: Custom API endpoint
# GOOGLE_API_BASE=https://generativelanguage.googleapis.com/v1beta
# Model configuration
GEMINI_MODEL=gemini-2.5-pro
# Rate limiting
GEMINI_RATE_LIMIT=60
`;
const envPath = join(testTempDir, '.gemini-env');
writeFileSync(envPath, geminiEnvContent);
const result = loadEnvFile(envPath);
assert.equal(result['GOOGLE_API_KEY'], 'AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX');
assert.equal(result['GEMINI_MODEL'], 'gemini-2.5-pro');
assert.equal(result['GEMINI_RATE_LIMIT'], '60');
assert.equal(result['GOOGLE_API_BASE'], undefined); // Commented out
});
it('should correctly parse typical Qwen .env file', () => {
const qwenEnvContent = `# Qwen CLI Environment Configuration
# DashScope API Key (Alibaba Cloud)
DASHSCOPE_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# OpenAI-compatible endpoint settings
OPENAI_API_BASE=https://dashscope.aliyuncs.com/compatible-mode/v1
OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# Model selection
QWEN_MODEL=qwen2.5-coder-32b
`;
const envPath = join(testTempDir, '.qwen-env');
writeFileSync(envPath, qwenEnvContent);
const result = loadEnvFile(envPath);
assert.equal(result['DASHSCOPE_API_KEY'], 'sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX');
assert.equal(result['OPENAI_API_BASE'], 'https://dashscope.aliyuncs.com/compatible-mode/v1');
assert.equal(result['QWEN_MODEL'], 'qwen2.5-coder-32b');
});
});
});