mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
feat(cli-settings): Implement CLI settings management and routes
- Added CLI settings file manager to handle endpoint configurations. - Introduced API routes for creating, updating, deleting, and listing CLI settings. - Enhanced session discovery for OpenCode with improved storage structure. - Updated command building logic for OpenCode and Claude to support new settings. - Added validation and sanitization for endpoint IDs and settings. - Implemented functionality to toggle endpoint enabled status and retrieve executable settings paths.
This commit is contained in:
@@ -65,8 +65,8 @@ export const DEFAULT_CONFIG: CliConfig = {
|
||||
},
|
||||
opencode: {
|
||||
enabled: true,
|
||||
primaryModel: 'anthropic/claude-sonnet-4-20250514',
|
||||
secondaryModel: 'anthropic/claude-haiku'
|
||||
primaryModel: '', // Empty = use opencode's default config
|
||||
secondaryModel: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -308,7 +308,9 @@ async function executeCliTool(
|
||||
tool,
|
||||
resumeIds,
|
||||
customId,
|
||||
forcePromptConcat: noNative,
|
||||
// Force prompt-concat if noNative flag is set OR if tool doesn't support native resume
|
||||
// (e.g., codex resume requires TTY which spawn() doesn't provide)
|
||||
forcePromptConcat: noNative || !supportsNativeResume(tool),
|
||||
getNativeSessionId: (ccwId) => store.getNativeSessionId(ccwId),
|
||||
getConversation: (ccwId) => loadConversation(workingDir, ccwId),
|
||||
getConversationTool: (ccwId) => {
|
||||
|
||||
@@ -157,8 +157,10 @@ export function buildCommand(params: {
|
||||
dir?: string;
|
||||
include?: string;
|
||||
nativeResume?: NativeResumeConfig;
|
||||
/** Claude CLI settings file path (for --settings parameter) */
|
||||
settingsFile?: string;
|
||||
}): { command: string; args: string[]; useStdin: boolean } {
|
||||
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume } = params;
|
||||
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume, settingsFile } = params;
|
||||
|
||||
debugLog('BUILD_CMD', `Building command for tool: ${tool}`, {
|
||||
mode,
|
||||
@@ -238,7 +240,10 @@ export function buildCommand(params: {
|
||||
args.push('--add-dir', addDir);
|
||||
}
|
||||
}
|
||||
args.push('-');
|
||||
// codex resume uses positional prompt argument, not stdin
|
||||
// Format: codex resume <session-id> [prompt]
|
||||
useStdin = false;
|
||||
args.push(prompt);
|
||||
} else {
|
||||
args.push('exec');
|
||||
if (mode === 'write' || mode === 'auto') {
|
||||
@@ -262,6 +267,10 @@ export function buildCommand(params: {
|
||||
case 'claude':
|
||||
// Claude Code: claude -p "prompt" for non-interactive mode
|
||||
args.push('-p'); // Print mode (non-interactive)
|
||||
// Settings file: claude --settings <file-or-json>
|
||||
if (settingsFile) {
|
||||
args.push('--settings', settingsFile);
|
||||
}
|
||||
// Native resume: claude --resume <session-id> or --continue
|
||||
if (nativeResume?.enabled) {
|
||||
if (nativeResume.isLatest) {
|
||||
@@ -291,8 +300,10 @@ export function buildCommand(params: {
|
||||
break;
|
||||
|
||||
case 'opencode':
|
||||
// OpenCode: opencode run "prompt" for non-interactive mode
|
||||
// OpenCode: opencode run [message..] for non-interactive mode
|
||||
// https://opencode.ai/docs/cli/
|
||||
// Prompt is passed as positional arguments (NOT stdin)
|
||||
useStdin = false;
|
||||
args.push('run');
|
||||
// Native resume: opencode run --continue or --session <id>
|
||||
if (nativeResume?.enabled) {
|
||||
@@ -306,14 +317,11 @@ export function buildCommand(params: {
|
||||
if (model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
// Write mode: Use full-auto permission via environment or default permissive mode
|
||||
// OpenCode uses OPENCODE_PERMISSION env var for permission control
|
||||
// For now, we rely on project-level configuration
|
||||
// Output format for parsing
|
||||
args.push('--format', 'default');
|
||||
// Prompt is passed as positional argument after 'run'
|
||||
// Use stdin for prompt to avoid shell escaping issues
|
||||
useStdin = true;
|
||||
// Add prompt as positional argument at the end
|
||||
// OpenCode expects: opencode run [options] [message..]
|
||||
args.push(prompt);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -681,82 +681,124 @@ class ClaudeSessionDiscoverer extends SessionDiscoverer {
|
||||
|
||||
/**
|
||||
* OpenCode Session Discoverer
|
||||
* Path: ~/.config/opencode/sessions/ or ~/.opencode/sessions/ (fallback)
|
||||
* OpenCode stores sessions with UUID-based session IDs
|
||||
* https://opencode.ai/docs/cli/
|
||||
* Storage path: ~/.local/share/opencode/storage/ (all platforms)
|
||||
* Structure:
|
||||
* session/<project-hash>/<session-id>.json - Session metadata
|
||||
* message/<session-id>/<message-id>.json - Message content
|
||||
* part/<message-id>/<part-id>.json - Message parts
|
||||
* project/<project-hash>.json - Project metadata
|
||||
* https://opencode.ai/docs/config/
|
||||
*/
|
||||
class OpenCodeSessionDiscoverer extends SessionDiscoverer {
|
||||
tool = 'opencode';
|
||||
// Primary: XDG config path, fallback to .opencode in home
|
||||
// Storage base path: ~/.local/share/opencode/storage
|
||||
basePath = join(
|
||||
process.env.OPENCODE_CONFIG_DIR ||
|
||||
process.env.XDG_CONFIG_HOME ||
|
||||
join(getHomePath(), '.config'),
|
||||
'opencode'
|
||||
process.env.USERPROFILE || getHomePath(),
|
||||
'.local',
|
||||
'share',
|
||||
'opencode',
|
||||
'storage'
|
||||
);
|
||||
fallbackBasePath = join(getHomePath(), '.opencode');
|
||||
|
||||
private getSessionsDir(): string | null {
|
||||
// Check primary path first
|
||||
const primarySessionsDir = join(this.basePath, 'sessions');
|
||||
if (existsSync(primarySessionsDir)) {
|
||||
return primarySessionsDir;
|
||||
}
|
||||
// Fallback to ~/.opencode/sessions
|
||||
const fallbackSessionsDir = join(this.fallbackBasePath, 'sessions');
|
||||
if (existsSync(fallbackSessionsDir)) {
|
||||
return fallbackSessionsDir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
|
||||
const { limit, afterTimestamp } = options;
|
||||
const sessions: NativeSession[] = [];
|
||||
|
||||
const sessionsDir = this.getSessionsDir();
|
||||
if (!sessionsDir) return [];
|
||||
private getProjectHash(workingDir: string): string | null {
|
||||
// OpenCode uses SHA1 hash of the project directory path
|
||||
const sessionDir = join(this.basePath, 'session');
|
||||
if (!existsSync(sessionDir)) return null;
|
||||
|
||||
try {
|
||||
// OpenCode stores sessions as JSON/JSONL files with UUID names
|
||||
const sessionFiles = readdirSync(sessionsDir)
|
||||
.filter(f => f.endsWith('.json') || f.endsWith('.jsonl'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(sessionsDir, f),
|
||||
stat: statSync(join(sessionsDir, f))
|
||||
}))
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
||||
const projectHashes = readdirSync(sessionDir).filter(d => {
|
||||
const fullPath = join(sessionDir, d);
|
||||
return statSync(fullPath).isDirectory();
|
||||
});
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
|
||||
if (projectHashes.length === 0) return null;
|
||||
|
||||
try {
|
||||
// Try to extract session ID from filename (UUID pattern)
|
||||
const uuidMatch = file.name.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
||||
let sessionId: string;
|
||||
|
||||
if (uuidMatch) {
|
||||
sessionId = uuidMatch[1];
|
||||
} else {
|
||||
// Try reading first line for session metadata
|
||||
const firstLine = readFileSync(file.path, 'utf8').split('\n')[0];
|
||||
const meta = JSON.parse(firstLine);
|
||||
sessionId = meta.id || meta.session_id || basename(file.name, '.json').replace('.jsonl', '');
|
||||
// If workingDir provided, try to find matching project
|
||||
if (workingDir) {
|
||||
const normalizedWorkDir = resolve(workingDir);
|
||||
// Check project files for directory match
|
||||
const projectDir = join(this.basePath, 'project');
|
||||
if (existsSync(projectDir)) {
|
||||
for (const hash of projectHashes) {
|
||||
const projectFile = join(projectDir, `${hash}.json`);
|
||||
if (existsSync(projectFile)) {
|
||||
try {
|
||||
const projectData = JSON.parse(readFileSync(projectFile, 'utf8'));
|
||||
// Normalize path comparison for Windows
|
||||
const projectPath = projectData.directory?.replace(/\\/g, '/').toLowerCase();
|
||||
const targetPath = normalizedWorkDir.replace(/\\/g, '/').toLowerCase();
|
||||
if (projectPath === targetPath) {
|
||||
return hash;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid project files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
sessionId,
|
||||
tool: this.tool,
|
||||
filePath: file.path,
|
||||
createdAt: file.stat.birthtime,
|
||||
updatedAt: file.stat.mtime
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
|
||||
// Return first available project hash if no match
|
||||
return projectHashes[0];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
|
||||
const { workingDir, limit, afterTimestamp } = options;
|
||||
const sessions: NativeSession[] = [];
|
||||
|
||||
const sessionDir = join(this.basePath, 'session');
|
||||
if (!existsSync(sessionDir)) return [];
|
||||
|
||||
try {
|
||||
// Get all project directories or specific one
|
||||
let projectHashes: string[];
|
||||
if (workingDir) {
|
||||
const hash = this.getProjectHash(workingDir);
|
||||
projectHashes = hash ? [hash] : [];
|
||||
} else {
|
||||
projectHashes = readdirSync(sessionDir).filter(d => {
|
||||
const fullPath = join(sessionDir, d);
|
||||
return statSync(fullPath).isDirectory();
|
||||
});
|
||||
}
|
||||
|
||||
for (const projectHash of projectHashes) {
|
||||
const projectSessionDir = join(sessionDir, projectHash);
|
||||
if (!existsSync(projectSessionDir)) continue;
|
||||
|
||||
// Get all session files
|
||||
const sessionFiles = readdirSync(projectSessionDir)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(projectSessionDir, f),
|
||||
stat: statSync(join(projectSessionDir, f))
|
||||
}))
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
|
||||
|
||||
try {
|
||||
const sessionData = JSON.parse(readFileSync(file.path, 'utf8'));
|
||||
sessions.push({
|
||||
sessionId: sessionData.id || basename(file.name, '.json'),
|
||||
tool: this.tool,
|
||||
filePath: file.path,
|
||||
projectHash,
|
||||
createdAt: new Date(sessionData.time?.created || file.stat.birthtime),
|
||||
updatedAt: new Date(sessionData.time?.updated || file.stat.mtime)
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by updatedAt descending
|
||||
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||
return limit ? sessions.slice(0, limit) : sessions;
|
||||
} catch {
|
||||
@@ -770,42 +812,61 @@ class OpenCodeSessionDiscoverer extends SessionDiscoverer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first user message from OpenCode session file
|
||||
* Format may vary - try common patterns
|
||||
* Extract first user message from OpenCode session
|
||||
* Messages are stored in: message/<session-id>/<message-id>.json
|
||||
* Format: { id, sessionID, role, time }
|
||||
* Content is in parts: part/<message-id>/<part-id>.json
|
||||
*/
|
||||
extractFirstUserMessage(filePath: string): string | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
// filePath is the session JSON file
|
||||
const sessionData = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
const sessionId = sessionData.id;
|
||||
if (!sessionId) return null;
|
||||
|
||||
// Check if JSON or JSONL
|
||||
if (filePath.endsWith('.jsonl')) {
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Try common patterns for user message
|
||||
if (entry.role === 'user' && entry.content) {
|
||||
return entry.content;
|
||||
// Find messages for this session
|
||||
const messageDir = join(this.basePath, 'message', sessionId);
|
||||
if (!existsSync(messageDir)) return null;
|
||||
|
||||
// Get message files sorted by time
|
||||
const messageFiles = readdirSync(messageDir)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(messageDir, f)
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const msgFile of messageFiles) {
|
||||
try {
|
||||
const msgData = JSON.parse(readFileSync(msgFile.path, 'utf8'));
|
||||
if (msgData.role === 'user') {
|
||||
// Get content from parts
|
||||
const partDir = join(this.basePath, 'part', msgData.id);
|
||||
if (existsSync(partDir)) {
|
||||
const partFiles = readdirSync(partDir)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.sort();
|
||||
|
||||
for (const partFile of partFiles) {
|
||||
try {
|
||||
const partData = JSON.parse(readFileSync(join(partDir, partFile), 'utf8'));
|
||||
if (partData.type === 'text' && partData.text) {
|
||||
return partData.text;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid parts
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.type === 'user' && entry.message) {
|
||||
return typeof entry.message === 'string' ? entry.message : entry.message.content;
|
||||
}
|
||||
if (entry.type === 'user_message' && entry.content) {
|
||||
return entry.content;
|
||||
}
|
||||
} catch { /* skip invalid lines */ }
|
||||
}
|
||||
} else {
|
||||
// JSON format - look for messages array
|
||||
const data = JSON.parse(content);
|
||||
if (data.messages && Array.isArray(data.messages)) {
|
||||
const userMsg = data.messages.find((m: { role?: string; type?: string }) =>
|
||||
m.role === 'user' || m.type === 'user'
|
||||
);
|
||||
return userMsg?.content || null;
|
||||
// Fallback to title if available
|
||||
return msgData.summary?.title || sessionData.title || null;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid messages
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return sessionData.title || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -884,8 +945,14 @@ export function getNativeSessions(
|
||||
|
||||
/**
|
||||
* Check if a tool supports native resume
|
||||
* Note: codex is excluded because `codex resume` requires a TTY (terminal)
|
||||
* which doesn't work in spawn() context. Codex uses prompt-concat mode instead.
|
||||
*/
|
||||
export function supportsNativeResume(tool: string): boolean {
|
||||
// codex resume requires TTY - use prompt-concat mode instead
|
||||
if (tool === 'codex') {
|
||||
return false;
|
||||
}
|
||||
return tool in discoverers;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user