From 0ab51f129c0d918c53b802b54e2545b817081553 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Fri, 13 Mar 2026 10:46:52 +0100 Subject: [PATCH 1/5] fix: add OpenCode slash command discovery and execution support OpenCode slash commands (both built-in and custom) were silently ignored because the capability flag was false and discovery was hard-gated to Claude Code only. This adds disk-based discovery for OpenCode commands from .opencode/commands/*.md, ~/.config/opencode/commands/*.md, and opencode.json config files, and makes getSlashCommandDescription() agent-aware to provide correct descriptions for each agent's commands. Closes #552 Co-Authored-By: Claude Opus 4.6 --- src/main/agents/capabilities.ts | 2 +- src/main/ipc/handlers/agents.ts | 99 ++++++++++++++++++- src/renderer/constants/app.ts | 42 ++++++-- src/renderer/hooks/agent/useAgentListeners.ts | 9 +- .../hooks/wizard/useWizardHandlers.ts | 2 +- 5 files changed, 139 insertions(+), 15 deletions(-) diff --git a/src/main/agents/capabilities.ts b/src/main/agents/capabilities.ts index c5be26ad3..ca204f83d 100644 --- a/src/main/agents/capabilities.ts +++ b/src/main/agents/capabilities.ts @@ -302,7 +302,7 @@ export const AGENT_CAPABILITIES: Record = { supportsSessionId: true, // sessionID in JSON output (camelCase) - Verified supportsImageInput: true, // -f, --file flag documented - Documented supportsImageInputOnResume: true, // -f flag works with --session flag - Documented - supportsSlashCommands: false, // Not investigated + supportsSlashCommands: true, // Built-in + custom commands via .opencode/commands/ and opencode.json supportsSessionStorage: true, // ~/.local/share/opencode/storage/ (JSON files) - Verified supportsCostTracking: true, // part.cost in step_finish events - Verified supportsUsageStats: true, // part.tokens in step_finish events - Verified diff --git a/src/main/ipc/handlers/agents.ts b/src/main/ipc/handlers/agents.ts index babed9f31..7a62d87ab 100644 --- a/src/main/ipc/handlers/agents.ts +++ b/src/main/ipc/handlers/agents.ts @@ -1,6 +1,8 @@ import { ipcMain } from 'electron'; import Store from 'electron-store'; import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; import { AgentDetector, AGENT_DEFINITIONS, getAgentCapabilities } from '../../agents'; import { execFileNoThrow } from '../../utils/execFile'; import { logger } from '../../utils/logger'; @@ -27,6 +29,97 @@ const handlerOpts = ( operation, }); +// OpenCode built-in slash commands (always available) +const OPENCODE_BUILTIN_COMMANDS = ['init', 'review', 'undo', 'redo', 'share', 'help', 'models']; + +/** + * Discover OpenCode slash commands by reading from disk. + * + * OpenCode commands come from three sources: + * 1. Built-in commands (init, review, undo, redo, share, help, models) + * 2. Project-local custom commands: .opencode/commands/*.md + * 3. Global custom commands: ~/.config/opencode/commands/*.md + * 4. Config-based commands: opencode.json "command" property + * + * Unlike Claude Code (which emits commands via init event), OpenCode commands + * are statically defined on disk and can be discovered without spawning the agent. + */ +function discoverOpenCodeSlashCommands(cwd: string): string[] { + const commands = new Set(OPENCODE_BUILTIN_COMMANDS); + + // Project-local custom commands: .opencode/commands/*.md + const projectCommandsDir = path.join(cwd, '.opencode', 'commands'); + try { + if (fs.existsSync(projectCommandsDir)) { + const files = fs.readdirSync(projectCommandsDir); + for (const file of files) { + if (file.endsWith('.md')) { + commands.add(file.replace(/\.md$/, '')); + } + } + } + } catch (error) { + logger.debug(`Failed to read project OpenCode commands from ${projectCommandsDir}`, LOG_CONTEXT, { + error: String(error), + }); + } + + // Global custom commands: ~/.config/opencode/commands/*.md + const globalCommandsDir = path.join(os.homedir(), '.config', 'opencode', 'commands'); + try { + if (fs.existsSync(globalCommandsDir)) { + const files = fs.readdirSync(globalCommandsDir); + for (const file of files) { + if (file.endsWith('.md')) { + commands.add(file.replace(/\.md$/, '')); + } + } + } + } catch (error) { + logger.debug(`Failed to read global OpenCode commands from ${globalCommandsDir}`, LOG_CONTEXT, { + error: String(error), + }); + } + + // Config-based commands: opencode.json "command" property (project-level) + const projectConfigPath = path.join(cwd, 'opencode.json'); + try { + if (fs.existsSync(projectConfigPath)) { + const config = JSON.parse(fs.readFileSync(projectConfigPath, 'utf-8')); + if (config.command && typeof config.command === 'object') { + for (const name of Object.keys(config.command)) { + commands.add(name); + } + } + } + } catch (error) { + logger.debug(`Failed to read OpenCode config from ${projectConfigPath}`, LOG_CONTEXT, { + error: String(error), + }); + } + + // Config-based commands: global opencode.json + const globalConfigPath = path.join(os.homedir(), '.config', 'opencode', 'opencode.json'); + try { + if (fs.existsSync(globalConfigPath)) { + const config = JSON.parse(fs.readFileSync(globalConfigPath, 'utf-8')); + if (config.command && typeof config.command === 'object') { + for (const name of Object.keys(config.command)) { + commands.add(name); + } + } + } + } catch (error) { + logger.debug(`Failed to read global OpenCode config from ${globalConfigPath}`, LOG_CONTEXT, { + error: String(error), + }); + } + + const commandList = Array.from(commands); + logger.info(`Discovered ${commandList.length} OpenCode slash commands`, LOG_CONTEXT); + return commandList; +} + /** * Interface for agent configuration store data */ @@ -850,7 +943,11 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void { return null; } - // Only Claude Code supports slash command discovery via init message + // Agent-specific discovery paths + if (agentId === 'opencode') { + return discoverOpenCodeSlashCommands(cwd); + } + if (agentId !== 'claude-code') { logger.debug(`Agent ${agentId} does not support slash command discovery`, LOG_CONTEXT); return null; diff --git a/src/renderer/constants/app.ts b/src/renderer/constants/app.ts index eaa2172bc..f1eb65e51 100644 --- a/src/renderer/constants/app.ts +++ b/src/renderer/constants/app.ts @@ -89,16 +89,44 @@ export const CLAUDE_BUILTIN_COMMANDS: Record = { }; /** - * Get description for Claude Code slash commands - * Built-in commands have known descriptions, custom ones use a generic description + * Built-in OpenCode slash commands with their descriptions */ -export function getSlashCommandDescription(cmd: string): string { +export const OPENCODE_BUILTIN_COMMANDS: Record = { + init: 'Create or update AGENTS.md for the project', + review: 'Review changes (commit, branch, or PR)', + undo: 'Revert changes made by OpenCode', + redo: 'Restore previously undone changes', + share: 'Create a shareable link to the conversation', + help: 'List available commands', + models: 'Switch models interactively', +}; + +/** + * Agent-specific built-in command maps, keyed by agent ID + */ +const AGENT_BUILTIN_COMMANDS: Record> = { + 'claude-code': CLAUDE_BUILTIN_COMMANDS, + opencode: OPENCODE_BUILTIN_COMMANDS, +}; + +/** + * Get description for agent slash commands. + * Checks all known agent built-in command maps, then falls back to generic description. + */ +export function getSlashCommandDescription(cmd: string, agentId?: string): string { // Remove leading slash if present const cmdName = cmd.startsWith('/') ? cmd.slice(1) : cmd; - // Check for built-in command - if (CLAUDE_BUILTIN_COMMANDS[cmdName]) { - return CLAUDE_BUILTIN_COMMANDS[cmdName]; + // If a specific agent is provided, check that agent's commands first + if (agentId && AGENT_BUILTIN_COMMANDS[agentId]?.[cmdName]) { + return AGENT_BUILTIN_COMMANDS[agentId][cmdName]; + } + + // Check all agent command maps (for backwards compatibility when agentId is not provided) + for (const commands of Object.values(AGENT_BUILTIN_COMMANDS)) { + if (commands[cmdName]) { + return commands[cmdName]; + } } // For plugin commands (e.g., "plugin-name:command"), use the full name as description hint @@ -108,5 +136,5 @@ export function getSlashCommandDescription(cmd: string): string { } // Generic description for unknown commands - return 'Claude Code command'; + return 'Agent command'; } diff --git a/src/renderer/hooks/agent/useAgentListeners.ts b/src/renderer/hooks/agent/useAgentListeners.ts index 001c8453e..03c4d207d 100644 --- a/src/renderer/hooks/agent/useAgentListeners.ts +++ b/src/renderer/hooks/agent/useAgentListeners.ts @@ -980,14 +980,13 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { (sessionId: string, slashCommands: string[]) => { const actualSessionId = parseSessionId(sessionId).baseSessionId; - const commands = slashCommands.map((cmd) => ({ - command: cmd.startsWith('/') ? cmd : `/${cmd}`, - description: getSlashCommandDescription(cmd), - })); - setSessions((prev) => prev.map((s) => { if (s.id !== actualSessionId) return s; + const commands = slashCommands.map((cmd) => ({ + command: cmd.startsWith('/') ? cmd : `/${cmd}`, + description: getSlashCommandDescription(cmd, s.toolType), + })); return { ...s, agentCommands: commands }; }) ); diff --git a/src/renderer/hooks/wizard/useWizardHandlers.ts b/src/renderer/hooks/wizard/useWizardHandlers.ts index 4609110ac..880dbee24 100644 --- a/src/renderer/hooks/wizard/useWizardHandlers.ts +++ b/src/renderer/hooks/wizard/useWizardHandlers.ts @@ -242,7 +242,7 @@ export function useWizardHandlers(deps: UseWizardHandlersDeps): UseWizardHandler const agentCommandObjects = ((agentSlashCommands || []) as string[]).map((cmd) => ({ command: cmd.startsWith('/') ? cmd : `/${cmd}`, - description: getSlashCommandDescription(cmd), + description: getSlashCommandDescription(cmd, currentSession.toolType), })); if (agentCommandObjects.length > 0) { From 0a05d4d656531c6366e97fd5f0c99533396defbb Mon Sep 17 00:00:00 2001 From: chr1syy Date: Fri, 13 Mar 2026 11:35:36 +0100 Subject: [PATCH 2/5] test: update slash command discovery test for OpenCode support The test expected OpenCode to return null from discoverSlashCommands, but it now correctly returns built-in commands. Updated to test Codex (which genuinely has no slash commands) and added a dedicated test for OpenCode built-in command discovery. Co-Authored-By: Claude Opus 4.6 --- .../main/ipc/handlers/agents.test.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/__tests__/main/ipc/handlers/agents.test.ts b/src/__tests__/main/ipc/handlers/agents.test.ts index 02c829cc1..ed6cc0bf4 100644 --- a/src/__tests__/main/ipc/handlers/agents.test.ts +++ b/src/__tests__/main/ipc/handlers/agents.test.ts @@ -1075,7 +1075,23 @@ describe('agents IPC handlers', () => { expect(execFileNoThrow).toHaveBeenCalledWith('/custom/claude', expect.any(Array), '/test'); }); - it('should return null for non-Claude Code agents', async () => { + it('should return null for unsupported agents', async () => { + const mockAgent = { + id: 'codex', + available: true, + path: '/usr/bin/codex', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'codex', '/test'); + + expect(result).toBeNull(); + expect(execFileNoThrow).not.toHaveBeenCalled(); + }); + + it('should return built-in commands for opencode', async () => { const mockAgent = { id: 'opencode', available: true, @@ -1087,7 +1103,9 @@ describe('agents IPC handlers', () => { const handler = handlers.get('agents:discoverSlashCommands'); const result = await handler!({} as any, 'opencode', '/test'); - expect(result).toBeNull(); + expect(result).toEqual( + expect.arrayContaining(['init', 'review', 'undo', 'redo', 'share', 'help', 'models']) + ); expect(execFileNoThrow).not.toHaveBeenCalled(); }); From f52b59c7304fba8d5c869c0b81f7b55f7a5ec430 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Fri, 13 Mar 2026 16:56:41 +0100 Subject: [PATCH 3/5] fix: guard against array values in OpenCode command config parsing typeof [] === 'object' is true in JS, so an array value for the "command" property in opencode.json would produce bogus /0, /1 slash command entries. Add !Array.isArray() guard to both project-level and global config parsing. Co-Authored-By: Claude Opus 4.6 --- src/main/ipc/handlers/agents.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/handlers/agents.ts b/src/main/ipc/handlers/agents.ts index 7a62d87ab..e91eb7ba2 100644 --- a/src/main/ipc/handlers/agents.ts +++ b/src/main/ipc/handlers/agents.ts @@ -86,7 +86,7 @@ function discoverOpenCodeSlashCommands(cwd: string): string[] { try { if (fs.existsSync(projectConfigPath)) { const config = JSON.parse(fs.readFileSync(projectConfigPath, 'utf-8')); - if (config.command && typeof config.command === 'object') { + if (config.command && typeof config.command === 'object' && !Array.isArray(config.command)) { for (const name of Object.keys(config.command)) { commands.add(name); } @@ -103,7 +103,7 @@ function discoverOpenCodeSlashCommands(cwd: string): string[] { try { if (fs.existsSync(globalConfigPath)) { const config = JSON.parse(fs.readFileSync(globalConfigPath, 'utf-8')); - if (config.command && typeof config.command === 'object') { + if (config.command && typeof config.command === 'object' && !Array.isArray(config.command)) { for (const name of Object.keys(config.command)) { commands.add(name); } From c2714539e8a466db2ba245ddb167f9a75792fa08 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Fri, 13 Mar 2026 18:42:08 +0100 Subject: [PATCH 4/5] fix: address PR review feedback for OpenCode slash command discovery - Convert discoverOpenCodeSlashCommands to async using fs.promises - Respect XDG_CONFIG_HOME for global OpenCode config paths - Only suppress ENOENT errors; rethrow permission/parse errors to Sentry - Fix cross-agent description leak in getSlashCommandDescription - Wire OpenCode sessions into slash command discovery in useWizardHandlers - Add tests for disk/config discovery, array guard, and error propagation Co-Authored-By: Claude Opus 4.6 --- .../main/ipc/handlers/agents.test.ts | 112 ++++++++++++++++++ src/main/ipc/handlers/agents.ts | 86 +++++--------- src/renderer/constants/app.ts | 10 +- .../hooks/wizard/useWizardHandlers.ts | 6 +- 4 files changed, 154 insertions(+), 60 deletions(-) diff --git a/src/__tests__/main/ipc/handlers/agents.test.ts b/src/__tests__/main/ipc/handlers/agents.test.ts index ed6cc0bf4..442cc88d6 100644 --- a/src/__tests__/main/ipc/handlers/agents.test.ts +++ b/src/__tests__/main/ipc/handlers/agents.test.ts @@ -67,6 +67,10 @@ vi.mock('../../../../main/utils/execFile', () => ({ // Mock fs vi.mock('fs', () => ({ existsSync: vi.fn(), + promises: { + readdir: vi.fn(), + readFile: vi.fn(), + }, })); // Mock ssh-command-builder for remote model discovery tests @@ -1100,6 +1104,11 @@ describe('agents IPC handlers', () => { mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + // All disk reads return ENOENT (no custom commands) + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(fs.promises.readdir).mockRejectedValue(enoent); + vi.mocked(fs.promises.readFile).mockRejectedValue(enoent); + const handler = handlers.get('agents:discoverSlashCommands'); const result = await handler!({} as any, 'opencode', '/test'); @@ -1109,6 +1118,109 @@ describe('agents IPC handlers', () => { expect(execFileNoThrow).not.toHaveBeenCalled(); }); + it('should discover opencode commands from project .opencode/commands/*.md', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + // Project commands dir has custom .md files + vi.mocked(fs.promises.readdir).mockImplementation(async (dir) => { + if (String(dir).includes('/test/.opencode/commands')) { + return ['deploy.md', 'lint.md', 'README.txt'] as any; + } + throw enoent; + }); + vi.mocked(fs.promises.readFile).mockRejectedValue(enoent); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'opencode', '/test'); + + expect(result).toContain('deploy'); + expect(result).toContain('lint'); + // Non-.md files should be ignored + expect(result).not.toContain('README.txt'); + // Built-ins should still be present + expect(result).toContain('init'); + }); + + it('should discover opencode commands from opencode.json config', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(fs.promises.readdir).mockRejectedValue(enoent); + vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => { + if (String(filePath).includes('/test/opencode.json')) { + return JSON.stringify({ command: { 'my-cmd': { description: 'test' } } }); + } + throw enoent; + }); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'opencode', '/test'); + + expect(result).toContain('my-cmd'); + expect(result).toContain('init'); + }); + + it('should ignore array values in opencode.json command property', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(fs.promises.readdir).mockRejectedValue(enoent); + vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => { + if (String(filePath).includes('/test/opencode.json')) { + return JSON.stringify({ command: ['not', 'an', 'object'] }); + } + throw enoent; + }); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'opencode', '/test'); + + // Should only have built-in commands (array config ignored) + expect(result).toEqual( + expect.arrayContaining(['init', 'review', 'undo', 'redo', 'share', 'help', 'models']) + ); + expect(result).not.toContain('not'); + }); + + it('should rethrow non-ENOENT errors for opencode discovery', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + // Permission error (not ENOENT) + const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); + vi.mocked(fs.promises.readdir).mockRejectedValue(permError); + vi.mocked(fs.promises.readFile).mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + ); + + const handler = handlers.get('agents:discoverSlashCommands'); + await expect(handler!({} as any, 'opencode', '/test')).rejects.toThrow('EACCES'); + }); + it('should return null when agent is not available', async () => { mockAgentDetector.getAgent.mockResolvedValue({ id: 'claude-code', available: false }); diff --git a/src/main/ipc/handlers/agents.ts b/src/main/ipc/handlers/agents.ts index e91eb7ba2..c1bdf41d4 100644 --- a/src/main/ipc/handlers/agents.ts +++ b/src/main/ipc/handlers/agents.ts @@ -38,82 +38,60 @@ const OPENCODE_BUILTIN_COMMANDS = ['init', 'review', 'undo', 'redo', 'share', 'h * OpenCode commands come from three sources: * 1. Built-in commands (init, review, undo, redo, share, help, models) * 2. Project-local custom commands: .opencode/commands/*.md - * 3. Global custom commands: ~/.config/opencode/commands/*.md + * 3. Global custom commands: $XDG_CONFIG_HOME/opencode/commands/*.md * 4. Config-based commands: opencode.json "command" property * * Unlike Claude Code (which emits commands via init event), OpenCode commands * are statically defined on disk and can be discovered without spawning the agent. */ -function discoverOpenCodeSlashCommands(cwd: string): string[] { +async function discoverOpenCodeSlashCommands(cwd: string): Promise { const commands = new Set(OPENCODE_BUILTIN_COMMANDS); + const globalConfigBase = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); - // Project-local custom commands: .opencode/commands/*.md - const projectCommandsDir = path.join(cwd, '.opencode', 'commands'); - try { - if (fs.existsSync(projectCommandsDir)) { - const files = fs.readdirSync(projectCommandsDir); + // Helper: read .md filenames from a commands directory + const addCommandsFromDir = async (dir: string) => { + try { + const files = await fs.promises.readdir(dir); for (const file of files) { if (file.endsWith('.md')) { commands.add(file.replace(/\.md$/, '')); } } - } - } catch (error) { - logger.debug(`Failed to read project OpenCode commands from ${projectCommandsDir}`, LOG_CONTEXT, { - error: String(error), - }); - } - - // Global custom commands: ~/.config/opencode/commands/*.md - const globalCommandsDir = path.join(os.homedir(), '.config', 'opencode', 'commands'); - try { - if (fs.existsSync(globalCommandsDir)) { - const files = fs.readdirSync(globalCommandsDir); - for (const file of files) { - if (file.endsWith('.md')) { - commands.add(file.replace(/\.md$/, '')); - } + } catch (error: any) { + if (error?.code === 'ENOENT') { + logger.debug(`OpenCode commands directory not found: ${dir}`, LOG_CONTEXT); + } else { + throw error; } } - } catch (error) { - logger.debug(`Failed to read global OpenCode commands from ${globalCommandsDir}`, LOG_CONTEXT, { - error: String(error), - }); - } + }; - // Config-based commands: opencode.json "command" property (project-level) - const projectConfigPath = path.join(cwd, 'opencode.json'); - try { - if (fs.existsSync(projectConfigPath)) { - const config = JSON.parse(fs.readFileSync(projectConfigPath, 'utf-8')); + // Helper: read command names from an opencode.json config file + const addCommandsFromConfig = async (configPath: string) => { + try { + const content = await fs.promises.readFile(configPath, 'utf-8'); + const config = JSON.parse(content); if (config.command && typeof config.command === 'object' && !Array.isArray(config.command)) { for (const name of Object.keys(config.command)) { commands.add(name); } } - } - } catch (error) { - logger.debug(`Failed to read OpenCode config from ${projectConfigPath}`, LOG_CONTEXT, { - error: String(error), - }); - } - - // Config-based commands: global opencode.json - const globalConfigPath = path.join(os.homedir(), '.config', 'opencode', 'opencode.json'); - try { - if (fs.existsSync(globalConfigPath)) { - const config = JSON.parse(fs.readFileSync(globalConfigPath, 'utf-8')); - if (config.command && typeof config.command === 'object' && !Array.isArray(config.command)) { - for (const name of Object.keys(config.command)) { - commands.add(name); - } + } catch (error: any) { + if (error?.code === 'ENOENT') { + logger.debug(`OpenCode config not found: ${configPath}`, LOG_CONTEXT); + } else { + throw error; } } - } catch (error) { - logger.debug(`Failed to read global OpenCode config from ${globalConfigPath}`, LOG_CONTEXT, { - error: String(error), - }); - } + }; + + // Read all four sources concurrently + await Promise.all([ + addCommandsFromDir(path.join(cwd, '.opencode', 'commands')), + addCommandsFromDir(path.join(globalConfigBase, 'opencode', 'commands')), + addCommandsFromConfig(path.join(cwd, 'opencode.json')), + addCommandsFromConfig(path.join(globalConfigBase, 'opencode', 'opencode.json')), + ]); const commandList = Array.from(commands); logger.info(`Discovered ${commandList.length} OpenCode slash commands`, LOG_CONTEXT); diff --git a/src/renderer/constants/app.ts b/src/renderer/constants/app.ts index f1eb65e51..ec3289031 100644 --- a/src/renderer/constants/app.ts +++ b/src/renderer/constants/app.ts @@ -122,10 +122,12 @@ export function getSlashCommandDescription(cmd: string, agentId?: string): strin return AGENT_BUILTIN_COMMANDS[agentId][cmdName]; } - // Check all agent command maps (for backwards compatibility when agentId is not provided) - for (const commands of Object.values(AGENT_BUILTIN_COMMANDS)) { - if (commands[cmdName]) { - return commands[cmdName]; + // Check all agent command maps only when no specific agent was requested + if (!agentId) { + for (const commands of Object.values(AGENT_BUILTIN_COMMANDS)) { + if (commands[cmdName]) { + return commands[cmdName]; + } } } diff --git a/src/renderer/hooks/wizard/useWizardHandlers.ts b/src/renderer/hooks/wizard/useWizardHandlers.ts index 880dbee24..ba0846fbe 100644 --- a/src/renderer/hooks/wizard/useWizardHandlers.ts +++ b/src/renderer/hooks/wizard/useWizardHandlers.ts @@ -180,7 +180,7 @@ export function useWizardHandlers(deps: UseWizardHandlersDeps): UseWizardHandler .getState() .sessions.find((s) => s.id === activeSession?.id); if (!currentSession) return; - if (currentSession.toolType !== 'claude-code') return; + if (currentSession.toolType !== 'claude-code' && currentSession.toolType !== 'opencode') return; if (currentSession.agentCommands && currentSession.agentCommands.length > 0) return; const sessionId = currentSession.id; @@ -264,7 +264,9 @@ export function useWizardHandlers(deps: UseWizardHandlersDeps): UseWizardHandler } }; - fetchCustomCommands(); + if (currentSession.toolType === 'claude-code') { + fetchCustomCommands(); + } discoverAgentCommands(); return () => { From c83a23e5ea84eba35b06d21114334457b74242b7 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Fri, 13 Mar 2026 20:22:05 +0100 Subject: [PATCH 5/5] fix: handle malformed opencode.json gracefully in slash command discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SyntaxError from JSON.parse has no .code property, so it bypassed the ENOENT check and rejected the entire Promise.all — wiping out built-in commands. Split the try/catch so file-not-found and parse errors are handled independently. Co-Authored-By: Claude Opus 4.6 --- .../main/ipc/handlers/agents.test.ts | 27 +++++++++++++++++++ src/main/ipc/handlers/agents.ts | 25 ++++++++++------- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/__tests__/main/ipc/handlers/agents.test.ts b/src/__tests__/main/ipc/handlers/agents.test.ts index 442cc88d6..56b603cf9 100644 --- a/src/__tests__/main/ipc/handlers/agents.test.ts +++ b/src/__tests__/main/ipc/handlers/agents.test.ts @@ -1201,6 +1201,33 @@ describe('agents IPC handlers', () => { expect(result).not.toContain('not'); }); + it('should gracefully handle malformed opencode.json and still return built-in commands', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(fs.promises.readdir).mockRejectedValue(enoent); + vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => { + if (String(filePath).includes('/test/opencode.json')) { + return '{ invalid json, }'; + } + throw enoent; + }); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'opencode', '/test'); + + // Malformed JSON should be skipped gracefully — built-ins still present + expect(result).toEqual( + expect.arrayContaining(['init', 'review', 'undo', 'redo', 'share', 'help', 'models']) + ); + }); + it('should rethrow non-ENOENT errors for opencode discovery', async () => { const mockAgent = { id: 'opencode', diff --git a/src/main/ipc/handlers/agents.ts b/src/main/ipc/handlers/agents.ts index c1bdf41d4..fb2d732c6 100644 --- a/src/main/ipc/handlers/agents.ts +++ b/src/main/ipc/handlers/agents.ts @@ -68,19 +68,26 @@ async function discoverOpenCodeSlashCommands(cwd: string): Promise { // Helper: read command names from an opencode.json config file const addCommandsFromConfig = async (configPath: string) => { + let content: string; try { - const content = await fs.promises.readFile(configPath, 'utf-8'); - const config = JSON.parse(content); - if (config.command && typeof config.command === 'object' && !Array.isArray(config.command)) { - for (const name of Object.keys(config.command)) { - commands.add(name); - } - } + content = await fs.promises.readFile(configPath, 'utf-8'); } catch (error: any) { if (error?.code === 'ENOENT') { logger.debug(`OpenCode config not found: ${configPath}`, LOG_CONTEXT); - } else { - throw error; + return; + } + throw error; + } + let config: any; + try { + config = JSON.parse(content); + } catch { + logger.warn(`OpenCode config has invalid JSON, skipping: ${configPath}`, LOG_CONTEXT); + return; + } + if (config.command && typeof config.command === 'object' && !Array.isArray(config.command)) { + for (const name of Object.keys(config.command)) { + commands.add(name); } } };