mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +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:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user