diff --git a/src/core/command-generation/adapters/opencode.ts b/src/core/command-generation/adapters/opencode.ts index 74f645022e..b3d96d63e5 100644 --- a/src/core/command-generation/adapters/opencode.ts +++ b/src/core/command-generation/adapters/opencode.ts @@ -8,10 +8,27 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; import { escapeYamlValue } from '../yaml.js'; +const OPENCODE_INPUT_BLOCK = /^\*\*Input\*\*:[^\r\n]*(?:\r?\n(?!\r?\n)[^\r\n]*)*/m; +const OPENCODE_NO_INPUT = /^\*\*Input\*\*:\s*None required\b/im; +const OPENCODE_ARGUMENT_PLACEHOLDER = /\$(?:ARGUMENTS\b|[1-9]\d*\b)/; + +function injectOpenCodeArgs(body: string): string { + if (OPENCODE_ARGUMENT_PLACEHOLDER.test(body) || OPENCODE_NO_INPUT.test(body)) { + return body; + } + + const eol = body.includes('\r\n') ? '\r\n' : '\n'; + return body.replace( + OPENCODE_INPUT_BLOCK, + (input) => `${input}${eol}**Provided arguments**: $ARGUMENTS` + ); +} + /** * OpenCode adapter for command generation. * File path: .opencode/commands/opsx-.md - * Frontmatter: description + * Frontmatter: description. $ARGUMENTS is injected after the complete input + * contract because OpenCode only passes arguments through explicit placeholders. */ export const opencodeAdapter: ToolCommandAdapter = { toolId: 'opencode', @@ -25,7 +42,7 @@ export const opencodeAdapter: ToolCommandAdapter = { description: ${escapeYamlValue(content.description)} --- -${content.body} +${injectOpenCodeArgs(content.body)} `; }, }; diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b4dd55459b..9036a239a4 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -607,6 +607,69 @@ describe('command-generation/adapters', () => { expect(output).toContain('This is the command body.'); }); + it('should pass invocation arguments into the OpenSpec input contract', () => { + const output = opencodeAdapter.formatFile({ + ...sampleContent, + body: '# OpenSpec command\n\n**Input**: A change name or description.\n\nRun the workflow.', + }); + expect(output).toContain( + '**Input**: A change name or description.\n**Provided arguments**: $ARGUMENTS' + ); + }); + + it('should not duplicate an existing $ARGUMENTS placeholder', () => { + const output = opencodeAdapter.formatFile({ + ...sampleContent, + body: '**Input**: A change name.\nExisting input: $ARGUMENTS', + }); + expect(output.match(/\$ARGUMENTS/g)).toHaveLength(1); + }); + + it('should not duplicate documented positional argument placeholders', () => { + const output = opencodeAdapter.formatFile({ + ...sampleContent, + body: '**Input**: Two values.\nFirst: $1\nSecond: $2', + }); + expect(output).not.toContain('**Provided arguments**: $ARGUMENTS'); + expect(output).toContain('First: $1\nSecond: $2'); + }); + + it('should keep multi-line input guidance together before provided arguments', () => { + const output = opencodeAdapter.formatFile({ + ...sampleContent, + body: '**Input**: A topic, such as:\n- an idea\n- a problem\n\n**Steps**\n1. Explore.', + }); + expect(output).toContain( + '**Input**: A topic, such as:\n- an idea\n- a problem\n**Provided arguments**: $ARGUMENTS' + ); + }); + + it('should preserve CRLF while keeping multi-line input guidance together', () => { + const output = opencodeAdapter.formatFile({ + ...sampleContent, + body: '**Input**: A topic, such as:\r\n- an idea\r\n- a problem\r\n\r\nRun it.', + }); + expect(output).toContain( + '**Input**: A topic, such as:\r\n- an idea\r\n- a problem\r\n**Provided arguments**: $ARGUMENTS\r\n\r\nRun it.' + ); + }); + + it('should not add invocation arguments to an explicitly input-free workflow', () => { + const output = opencodeAdapter.formatFile({ + ...sampleContent, + body: '**Input**: None required (prompts for selection)\n\nPrompt the user.', + }); + expect(output).not.toContain('$ARGUMENTS'); + }); + + it('should preserve exactly one argument placeholder for each workflow that accepts input', () => { + for (const content of getCommandContents()) { + const output = generateCommand(content, opencodeAdapter).fileContent; + const acceptsInput = /^\*\*Input\*\*:(?!\s*None required\b)/im.test(content.body); + expect(output.match(/\$ARGUMENTS/g) ?? [], content.id).toHaveLength(acceptsInput ? 1 : 0); + } + }); + it('is generated by generateCommand with hyphen command references', () => { const contentWithCommands: CommandContent = { ...sampleContent, diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 42a60a79f6..f5930bd858 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -1349,6 +1349,8 @@ describe('InitCommand - profile and detection features', () => { // New commands should be at the correct plural path const newCommandsDir = path.join(testDir, '.opencode', 'commands'); expect(await directoryExists(newCommandsDir)).toBe(true); + const proposeCommand = await fs.readFile(path.join(newCommandsDir, 'opsx-propose.md'), 'utf-8'); + expect(proposeCommand).toContain('**Provided arguments**: $ARGUMENTS'); }); it('should remove managed global Codex prompts in non-interactive mode', async () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index b541bdadc1..b385abe1f2 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1332,6 +1332,46 @@ metadata: expect(content).toContain('**Provided arguments**: $ARGUMENTS'); }); + it('should repair stale OpenCode commands-only installs once', async () => { + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + const commandsDir = path.join(testDir, '.opencode', 'commands'); + const coreCommandIds = [ + 'explore', + 'apply', + 'update', + 'sync', + 'archive', + 'propose', + ]; + await fs.mkdir(commandsDir, { recursive: true }); + for (const commandId of coreCommandIds) { + await fs.writeFile( + path.join(commandsDir, `opsx-${commandId}.md`), + 'old command without arguments' + ); + } + + await updateCommand.execute(testDir); + + for (const commandId of coreCommandIds) { + const content = await fs.readFile( + path.join(commandsDir, `opsx-${commandId}.md`), + 'utf-8' + ); + expect(content.match(/\$ARGUMENTS/g)).toHaveLength(1); + expect(content).toContain('**Provided arguments**: $ARGUMENTS'); + expect(content).not.toContain('old command without arguments'); + } + + const consoleSpy = vi.spyOn(console, 'log'); + await updateCommand.execute(testDir); + + const logCalls = consoleSpy.mock.calls.flat().map(String); + expect(logCalls.some((entry) => entry.includes('up to date'))).toBe(true); + expect(logCalls.some((entry) => entry.includes('Updating 1 tool(s)'))).toBe(false); + consoleSpy.mockRestore(); + }); + it('should migrate a legacy .windsurf install to .devin, preserving user files', async () => { // A project set up before the Devin Desktop rebrand: OpenSpec skills and // workflows under .windsurf/, alongside files the user wrote themselves.