feat: 添加对 OpenCode 的支持,更新 CLI 工具配置和会话发现逻辑

This commit is contained in:
catlog22
2026-01-08 10:47:07 +08:00
parent d2d6cce5f4
commit 55fa170b4e
7 changed files with 223 additions and 104 deletions

View File

@@ -20,7 +20,7 @@ export interface CliConfig {
tools: Record<string, CliToolConfig>;
}
export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude';
export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode';
// ========== Constants ==========
@@ -28,7 +28,16 @@ export const PREDEFINED_MODELS: Record<CliToolName, string[]> = {
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
qwen: ['coder-model', 'vision-model', 'qwen2.5-coder-32b'],
codex: ['gpt-5.2', 'gpt-4.1', 'o4-mini', 'o3'],
claude: ['sonnet', 'opus', 'haiku', 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101']
claude: ['sonnet', 'opus', 'haiku', 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'],
opencode: [
'anthropic/claude-sonnet-4-20250514',
'anthropic/claude-opus-4-20250514',
'anthropic/claude-haiku',
'openai/gpt-4.1',
'openai/o3',
'google/gemini-2.5-pro',
'google/gemini-2.5-flash'
]
};
export const DEFAULT_CONFIG: CliConfig = {
@@ -53,6 +62,11 @@ export const DEFAULT_CONFIG: CliConfig = {
enabled: true,
primaryModel: 'sonnet',
secondaryModel: 'haiku'
},
opencode: {
enabled: true,
primaryModel: 'anthropic/claude-sonnet-4-20250514',
secondaryModel: 'anthropic/claude-haiku'
}
}
};
@@ -69,7 +83,7 @@ function ensureConfigDirForProject(baseDir: string): void {
}
function isValidToolName(tool: string): tool is CliToolName {
return ['gemini', 'qwen', 'codex', 'claude'].includes(tool);
return ['gemini', 'qwen', 'codex', 'claude', 'opencode'].includes(tool);
}
function validateConfig(config: unknown): config is CliConfig {
@@ -80,7 +94,7 @@ function validateConfig(config: unknown): config is CliConfig {
if (!c.tools || typeof c.tools !== 'object') return false;
const tools = c.tools as Record<string, unknown>;
for (const toolName of ['gemini', 'qwen', 'codex', 'claude']) {
for (const toolName of ['gemini', 'qwen', 'codex', 'claude', 'opencode']) {
const tool = tools[toolName];
if (!tool || typeof tool !== 'object') return false;

View File

@@ -96,7 +96,7 @@ import {
// Define Zod schema for validation
const ParamsSchema = z.object({
tool: z.enum(['gemini', 'qwen', 'codex']),
tool: z.enum(['gemini', 'qwen', 'codex', 'opencode']),
prompt: z.string().min(1, 'Prompt is required'),
mode: z.enum(['analysis', 'write', 'auto']).default('analysis'),
format: z.enum(['plain', 'yaml', 'json']).default('plain'), // Multi-turn prompt concatenation format
@@ -808,7 +808,7 @@ export {
* Get status of all CLI tools
*/
export async function getCliToolsStatus(): Promise<Record<string, ToolAvailability>> {
const tools = ['gemini', 'qwen', 'codex', 'claude'];
const tools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'];
const results: Record<string, ToolAvailability> = {};
await Promise.all(tools.map(async (tool) => {
@@ -823,7 +823,8 @@ const CLI_TOOL_PACKAGES: Record<string, string> = {
gemini: '@google/gemini-cli',
qwen: '@qwen-code/qwen-code',
codex: '@openai/codex',
claude: '@anthropic-ai/claude-code'
claude: '@anthropic-ai/claude-code',
opencode: 'opencode' // https://opencode.ai - installed via npm/pnpm/bun/brew
};
// Disabled tools storage (in-memory fallback, main storage is in cli-config.json)

View File

@@ -290,6 +290,32 @@ export function buildCommand(params: {
}
break;
case 'opencode':
// OpenCode: opencode run "prompt" for non-interactive mode
// https://opencode.ai/docs/cli/
args.push('run');
// Native resume: opencode run --continue or --session <id>
if (nativeResume?.enabled) {
if (nativeResume.isLatest) {
args.push('--continue');
} else if (nativeResume.sessionId) {
args.push('--session', nativeResume.sessionId);
}
}
// Model: --model <provider/model> (e.g., anthropic/claude-sonnet-4-20250514)
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;
break;
default:
errorLog('BUILD_CMD', `Unknown CLI tool: ${tool}`);
throw new Error(`Unknown CLI tool: ${tool}`);

View File

@@ -679,12 +679,146 @@ 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/
*/
class OpenCodeSessionDiscoverer extends SessionDiscoverer {
tool = 'opencode';
// Primary: XDG config path, fallback to .opencode in home
basePath = join(
process.env.OPENCODE_CONFIG_DIR ||
process.env.XDG_CONFIG_HOME ||
join(getHomePath(), '.config'),
'opencode'
);
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 [];
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);
for (const file of sessionFiles) {
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
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', '');
}
sessions.push({
sessionId,
tool: this.tool,
filePath: file.path,
createdAt: file.stat.birthtime,
updatedAt: file.stat.mtime
});
} catch {
// Skip invalid files
}
}
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
return limit ? sessions.slice(0, limit) : sessions;
} catch {
return [];
}
}
findSessionById(sessionId: string): NativeSession | null {
const sessions = this.getSessions();
return sessions.find(s => s.sessionId === sessionId) || null;
}
/**
* Extract first user message from OpenCode session file
* Format may vary - try common patterns
*/
extractFirstUserMessage(filePath: string): string | null {
try {
const content = readFileSync(filePath, 'utf8');
// 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;
}
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;
}
}
return null;
} catch {
return null;
}
}
}
// Singleton discoverers
const discoverers: Record<string, SessionDiscoverer> = {
gemini: new GeminiSessionDiscoverer(),
qwen: new QwenSessionDiscoverer(),
codex: new CodexSessionDiscoverer(),
claude: new ClaudeSessionDiscoverer()
claude: new ClaudeSessionDiscoverer(),
opencode: new OpenCodeSessionDiscoverer()
};
/**
@@ -781,6 +915,13 @@ export function getNativeResumeArgs(
}
return ['resume', sessionId];
case 'opencode':
// opencode run --continue (latest) or --session <uuid>
if (sessionId === 'latest') {
return ['--continue'];
}
return ['--session', sessionId];
default:
return [];
}