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

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