mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 配置',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
258
ccw/tests/cli-env-file.test.ts
Normal file
258
ccw/tests/cli-env-file.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user