diff --git a/src/__tests__/main/ipc/handlers/agents.test.ts b/src/__tests__/main/ipc/handlers/agents.test.ts index 02c829cc1..56b603cf9 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 @@ -1075,7 +1079,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, @@ -1084,13 +1104,150 @@ 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'); - expect(result).toBeNull(); + expect(result).toEqual( + expect.arrayContaining(['init', 'review', 'undo', 'redo', 'share', 'help', 'models']) + ); 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 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', + 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/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..fb2d732c6 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,82 @@ 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: $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. + */ +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'); + + // 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: any) { + if (error?.code === 'ENOENT') { + logger.debug(`OpenCode commands directory not found: ${dir}`, LOG_CONTEXT); + } else { + throw error; + } + } + }; + + // Helper: read command names from an opencode.json config file + const addCommandsFromConfig = async (configPath: string) => { + let content: string; + try { + content = await fs.promises.readFile(configPath, 'utf-8'); + } catch (error: any) { + if (error?.code === 'ENOENT') { + logger.debug(`OpenCode config not found: ${configPath}`, LOG_CONTEXT); + 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); + } + } + }; + + // 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); + return commandList; +} + /** * Interface for agent configuration store data */ @@ -850,7 +928,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..ec3289031 100644 --- a/src/renderer/constants/app.ts +++ b/src/renderer/constants/app.ts @@ -89,16 +89,46 @@ 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 only when no specific agent was requested + if (!agentId) { + 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 +138,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..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; @@ -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) { @@ -264,7 +264,9 @@ export function useWizardHandlers(deps: UseWizardHandlersDeps): UseWizardHandler } }; - fetchCustomCommands(); + if (currentSession.toolType === 'claude-code') { + fetchCustomCommands(); + } discoverAgentCommands(); return () => {