From 55fa170b4e7a9a38c0f3988c0f38a7fe165a810d Mon Sep 17 00:00:00 2001 From: catlog22 Date: Thu, 8 Jan 2026 10:47:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AF=B9=20OpenCode?= =?UTF-8?q?=20=E7=9A=84=E6=94=AF=E6=8C=81=EF=BC=8C=E6=9B=B4=E6=96=B0=20CLI?= =?UTF-8?q?=20=E5=B7=A5=E5=85=B7=E9=85=8D=E7=BD=AE=E5=92=8C=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=8F=91=E7=8E=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/coding/ts-naming.md | 88 ----------- .../dashboard-js/components/hook-manager.js | 10 +- .../dashboard-js/views/api-settings.js | 31 +++- ccw/src/tools/cli-config-manager.ts | 22 ++- ccw/src/tools/cli-executor-core.ts | 7 +- ccw/src/tools/cli-executor-utils.ts | 26 ++++ ccw/src/tools/native-session-discovery.ts | 143 +++++++++++++++++- 7 files changed, 223 insertions(+), 104 deletions(-) delete mode 100644 .claude/rules/coding/ts-naming.md diff --git a/.claude/rules/coding/ts-naming.md b/.claude/rules/coding/ts-naming.md deleted file mode 100644 index 6d1e7e43..00000000 --- a/.claude/rules/coding/ts-naming.md +++ /dev/null @@ -1,88 +0,0 @@ -# TypeScript Naming Conventions - -This rule enforces consistent naming conventions for TypeScript code to improve readability and maintain codebase consistency. - -## Guidelines - -1. **Variables and Functions** - Use camelCase for all variable names, function names, and function parameters -2. **Classes and Interfaces** - Use PascalCase for class names, interface names, type aliases, and enum names -3. **Constants** - Use UPPER_SNAKE_CASE for module-level constants and readonly static class members -4. **Private Members** - Use camelCase with no special prefix for private class members (rely on TypeScript's `private` keyword) -5. **File Names** - Use kebab-case for file names (e.g., `user-service.ts`, `auth-controller.ts`) - -## Examples - -### ✅ Correct - -```typescript -// Variables and functions -const userName = 'John'; -let itemCount = 0; -function calculateTotal(orderItems: Item[]): number { - return orderItems.reduce((sum, item) => sum + item.price, 0); -} - -// Classes and interfaces -class UserService { - private userRepository: UserRepository; - - constructor(userRepository: UserRepository) { - this.userRepository = userRepository; - } -} - -interface ApiResponse { - statusCode: number; - data: unknown; -} - -type UserId = string; - -enum OrderStatus { - Pending, - Confirmed, - Shipped -} - -// Constants -const MAX_RETRY_ATTEMPTS = 3; -const API_BASE_URL = 'https://api.example.com'; - -class Configuration { - static readonly DEFAULT_TIMEOUT = 5000; -} -``` - -### ❌ Incorrect - -```typescript -// Wrong: PascalCase for variables/functions -const UserName = 'John'; -function CalculateTotal(order_items: Item[]): number { } - -// Wrong: camelCase for classes/interfaces -class userService { } -interface apiResponse { } -type userId = string; - -// Wrong: camelCase for constants -const maxRetryAttempts = 3; -const apiBaseUrl = 'https://api.example.com'; - -// Wrong: snake_case usage -const user_name = 'John'; -function calculate_total(items: Item[]): number { } -class user_service { } - -// Wrong: Hungarian notation or underscore prefix for private -class Service { - private _userData: User; // Don't use underscore prefix - private m_count: number; // Don't use Hungarian notation -} -``` - -## Exceptions - -- Third-party library types and names should maintain their original casing for compatibility -- Database column names or API field names may use different conventions when mapped from external systems (use transformation layers to convert) -- Test files may use descriptive names like `UserService.test.ts` to match the tested class \ No newline at end of file diff --git a/ccw/src/templates/dashboard-js/components/hook-manager.js b/ccw/src/templates/dashboard-js/components/hook-manager.js index ceaba8c9..da8e55a7 100644 --- a/ccw/src/templates/dashboard-js/components/hook-manager.js +++ b/ccw/src/templates/dashboard-js/components/hook-manager.js @@ -21,7 +21,7 @@ const HOOK_TEMPLATES = { event: 'PostToolUse', matcher: '', command: 'bash', - args: ['-c', 'INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty"); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); echo "[$(date)] Tool: $TOOL, File: $FILE" >> ~/.claude/tool-usage.log'], + args: ['-c', 'mkdir -p "$HOME/.claude"; INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty" 2>/dev/null); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); echo "[$(date)] Tool: $TOOL, File: $FILE" >> "$HOME/.claude/tool-usage.log"'], description: 'Log all tool executions to a file', category: 'logging' }, @@ -66,7 +66,7 @@ const HOOK_TEMPLATES = { event: 'PostToolUse', matcher: 'Write|Edit', command: 'bash', - args: ['-c', 'INTERVAL=300; LAST_FILE=~/.claude/.last_memory_update; NOW=$(date +%s); LAST=0; [ -f "$LAST_FILE" ] && LAST=$(cat "$LAST_FILE"); if [ $((NOW - LAST)) -ge $INTERVAL ]; then echo $NOW > "$LAST_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'], + args: ['-c', 'INTERVAL=300; LAST_FILE="$HOME/.claude/.last_memory_update"; mkdir -p "$HOME/.claude"; NOW=$(date +%s); LAST=0; [ -f "$LAST_FILE" ] && LAST=$(cat "$LAST_FILE" 2>/dev/null || echo 0); if [ $((NOW - LAST)) -ge $INTERVAL ]; then echo $NOW > "$LAST_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'], description: 'Periodically update CLAUDE.md (default: 5 min interval)', category: 'memory', configurable: true, @@ -79,7 +79,7 @@ const HOOK_TEMPLATES = { event: 'PostToolUse', matcher: 'Write|Edit', command: 'bash', - args: ['-c', 'THRESHOLD=10; COUNT_FILE=~/.claude/.memory_update_count; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'], + args: ['-c', 'THRESHOLD=10; COUNT_FILE="$HOME/.claude/.memory_update_count"; mkdir -p "$HOME/.claude"; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'], description: 'Update CLAUDE.md when file changes reach threshold (default: 10 files)', category: 'memory', configurable: true, @@ -1163,9 +1163,9 @@ function generateWizardCommand() { const params = JSON.stringify({ strategy, tool }); if (triggerType === 'periodic') { - return `INTERVAL=${interval}; LAST_FILE=~/.claude/.last_memory_update; NOW=$(date +%s); LAST=0; [ -f "$LAST_FILE" ] && LAST=$(cat "$LAST_FILE"); if [ $((NOW - LAST)) -ge $INTERVAL ]; then echo $NOW > "$LAST_FILE"; ccw tool exec update_module_claude '${params}' & fi`; + return `INTERVAL=${interval}; LAST_FILE="$HOME/.claude/.last_memory_update"; mkdir -p "$HOME/.claude"; NOW=$(date +%s); LAST=0; [ -f "$LAST_FILE" ] && LAST=$(cat "$LAST_FILE" 2>/dev/null || echo 0); if [ $((NOW - LAST)) -ge $INTERVAL ]; then echo $NOW > "$LAST_FILE"; ccw tool exec update_module_claude '${params}' & fi`; } else if (triggerType === 'count-based') { - return `THRESHOLD=${threshold}; COUNT_FILE=~/.claude/.memory_update_count; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude '${params}' & fi`; + return `THRESHOLD=${threshold}; COUNT_FILE="$HOME/.claude/.memory_update_count"; mkdir -p "$HOME/.claude"; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude '${params}' & fi`; } else { return `ccw tool exec update_module_claude '${params}'`; } diff --git a/ccw/src/templates/dashboard-js/views/api-settings.js b/ccw/src/templates/dashboard-js/views/api-settings.js index 4f06afec..13ef6ddb 100644 --- a/ccw/src/templates/dashboard-js/views/api-settings.js +++ b/ccw/src/templates/dashboard-js/views/api-settings.js @@ -643,6 +643,24 @@ function updateProviderSpecificFields() { el.style.display = 'block'; }); } + + // Update API base URL placeholder based on provider type + var apiBaseInput = document.getElementById('provider-apibase'); + if (apiBaseInput) { + var defaultBase = getDefaultApiBase(providerType); + apiBaseInput.placeholder = defaultBase; + + // If the current value is empty or matches a known default, update to show the new default + var currentValue = apiBaseInput.value.trim(); + var knownDefaults = [ + 'https://api.openai.com/v1', + 'https://api.anthropic.com/v1', + 'https://api.example.com/v1' + ]; + if (!currentValue || knownDefaults.includes(currentValue)) { + apiBaseInput.value = ''; // Clear to use placeholder, user can override if needed + } + } } // ========== Endpoint Management ========== @@ -1449,8 +1467,14 @@ async function toggleProviderEnabled(providerId, enabled) { if (!response.ok) throw new Error('Failed to update provider'); // Update local data (for instant UI feedback) - var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); - if (provider) provider.enabled = enabled; + // Ensure apiSettingsData is loaded before accessing + if (!apiSettingsData || !apiSettingsData.providers) { + await loadApiSettings(true); + } + if (apiSettingsData && apiSettingsData.providers) { + var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); + if (provider) provider.enabled = enabled; + } renderProviderList(); showRefreshToast(t('apiSettings.providerUpdated'), 'success'); @@ -2129,7 +2153,8 @@ function saveModelSettings(event, providerId, modelId, modelType) { }) .then(function() { closeModelSettingsModal(); - return loadApiSettings(); + // Force refresh to get latest data after saving + return loadApiSettings(true); }) .then(function() { if (selectedProviderId === providerId) { diff --git a/ccw/src/tools/cli-config-manager.ts b/ccw/src/tools/cli-config-manager.ts index 62fc429e..7dbff753 100644 --- a/ccw/src/tools/cli-config-manager.ts +++ b/ccw/src/tools/cli-config-manager.ts @@ -20,7 +20,7 @@ export interface CliConfig { tools: Record; } -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 = { 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; - 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; diff --git a/ccw/src/tools/cli-executor-core.ts b/ccw/src/tools/cli-executor-core.ts index 23e694c3..48db5c16 100644 --- a/ccw/src/tools/cli-executor-core.ts +++ b/ccw/src/tools/cli-executor-core.ts @@ -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> { - const tools = ['gemini', 'qwen', 'codex', 'claude']; + const tools = ['gemini', 'qwen', 'codex', 'claude', 'opencode']; const results: Record = {}; await Promise.all(tools.map(async (tool) => { @@ -823,7 +823,8 @@ const CLI_TOOL_PACKAGES: Record = { 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) diff --git a/ccw/src/tools/cli-executor-utils.ts b/ccw/src/tools/cli-executor-utils.ts index 2c93f9a9..cca63cb7 100644 --- a/ccw/src/tools/cli-executor-utils.ts +++ b/ccw/src/tools/cli-executor-utils.ts @@ -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 + if (nativeResume?.enabled) { + if (nativeResume.isLatest) { + args.push('--continue'); + } else if (nativeResume.sessionId) { + args.push('--session', nativeResume.sessionId); + } + } + // Model: --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}`); diff --git a/ccw/src/tools/native-session-discovery.ts b/ccw/src/tools/native-session-discovery.ts index 799dcbe1..6701bdeb 100644 --- a/ccw/src/tools/native-session-discovery.ts +++ b/ccw/src/tools/native-session-discovery.ts @@ -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 = { 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 + if (sessionId === 'latest') { + return ['--continue']; + } + return ['--session', sessionId]; + default: return []; }