From 4000bfad0a41ad5a2b7d8e93e83ed3602c9b614c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 14 Aug 2026 18:02:13 -0500 Subject: [PATCH 1/5] fix(opencode): pass command arguments to workflows --- .../command-generation/adapters/opencode.ts | 18 +++++++++-- test/core/command-generation/adapters.test.ts | 31 +++++++++++++++++++ test/core/init.test.ts | 2 ++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/core/command-generation/adapters/opencode.ts b/src/core/command-generation/adapters/opencode.ts index 74f645022e..11bd1ac97c 100644 --- a/src/core/command-generation/adapters/opencode.ts +++ b/src/core/command-generation/adapters/opencode.ts @@ -8,10 +8,24 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; import { escapeYamlValue } from '../yaml.js'; +const OPENCODE_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m; + +function injectOpenCodeArgs(body: string): string { + if (body.includes('$ARGUMENTS')) { + return body; + } + + return body.replace( + OPENCODE_INPUT_HEADING, + (heading) => `${heading}\n**Provided arguments**: $ARGUMENTS` + ); +} + /** * OpenCode adapter for command generation. * File path: .opencode/commands/opsx-.md - * Frontmatter: description + * Frontmatter: description. $ARGUMENTS is injected after the input contract + * because OpenCode only passes command arguments through explicit placeholders. */ export const opencodeAdapter: ToolCommandAdapter = { toolId: 'opencode', @@ -25,7 +39,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..bee72d3de3 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -607,6 +607,37 @@ 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 preserve invocation arguments for every workflow that accepts them', () => { + const commandsWithoutArguments = getCommandContents() + .filter((content) => { + const output = generateCommand(content, opencodeAdapter).fileContent; + return !output.includes('**Provided arguments**: $ARGUMENTS'); + }) + .map((content) => content.id); + + // Onboarding is deliberately interactive and has no invocation input. + // This list is a tripwire for a new workflow that accidentally drops args. + expect(commandsWithoutArguments).toEqual(['onboard']); + }); + 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 () => { From 067db80f3e0dc750f94c21c2d9d215bc6c1bd299 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 14 Aug 2026 18:09:47 -0500 Subject: [PATCH 2/5] test(opencode): recognize existing argument placeholders --- test/core/command-generation/adapters.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index bee72d3de3..7b00b4432d 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -629,7 +629,7 @@ describe('command-generation/adapters', () => { const commandsWithoutArguments = getCommandContents() .filter((content) => { const output = generateCommand(content, opencodeAdapter).fileContent; - return !output.includes('**Provided arguments**: $ARGUMENTS'); + return !output.includes('$ARGUMENTS'); }) .map((content) => content.id); From 2924d3a8444bae6cc701a42b925f1a2f689d0f87 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 14 Aug 2026 18:47:56 -0500 Subject: [PATCH 3/5] test(opencode): harden argument generation --- .../command-generation/adapters/opencode.ts | 15 +++--- test/core/command-generation/adapters.test.ts | 52 +++++++++++++++---- test/core/update.test.ts | 20 +++++++ 3 files changed, 71 insertions(+), 16 deletions(-) diff --git a/src/core/command-generation/adapters/opencode.ts b/src/core/command-generation/adapters/opencode.ts index 11bd1ac97c..b3d96d63e5 100644 --- a/src/core/command-generation/adapters/opencode.ts +++ b/src/core/command-generation/adapters/opencode.ts @@ -8,24 +8,27 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; import { escapeYamlValue } from '../yaml.js'; -const OPENCODE_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m; +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 (body.includes('$ARGUMENTS')) { + 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_HEADING, - (heading) => `${heading}\n**Provided arguments**: $ARGUMENTS` + OPENCODE_INPUT_BLOCK, + (input) => `${input}${eol}**Provided arguments**: $ARGUMENTS` ); } /** * OpenCode adapter for command generation. * File path: .opencode/commands/opsx-.md - * Frontmatter: description. $ARGUMENTS is injected after the input contract - * because OpenCode only passes command arguments through explicit placeholders. + * Frontmatter: description. $ARGUMENTS is injected after the complete input + * contract because OpenCode only passes arguments through explicit placeholders. */ export const opencodeAdapter: ToolCommandAdapter = { toolId: 'opencode', diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 7b00b4432d..9036a239a4 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -625,17 +625,49 @@ describe('command-generation/adapters', () => { expect(output.match(/\$ARGUMENTS/g)).toHaveLength(1); }); - it('should preserve invocation arguments for every workflow that accepts them', () => { - const commandsWithoutArguments = getCommandContents() - .filter((content) => { - const output = generateCommand(content, opencodeAdapter).fileContent; - return !output.includes('$ARGUMENTS'); - }) - .map((content) => content.id); + 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'); + }); - // Onboarding is deliberately interactive and has no invocation input. - // This list is a tripwire for a new workflow that accidentally drops args. - expect(commandsWithoutArguments).toEqual(['onboard']); + 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', () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index b541bdadc1..d2fcf13462 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1332,6 +1332,26 @@ metadata: expect(content).toContain('**Provided arguments**: $ARGUMENTS'); }); + it('should update OpenCode commands with invocation arguments', async () => { + const opencodeSkillsDir = path.join(testDir, '.opencode', 'skills'); + await fs.mkdir(path.join(opencodeSkillsDir, 'openspec-explore'), { + recursive: true, + }); + await fs.writeFile( + path.join(opencodeSkillsDir, 'openspec-explore', 'SKILL.md'), + 'old' + ); + + const staleCommand = path.join(testDir, '.opencode', 'commands', 'opsx-propose.md'); + await fs.mkdir(path.dirname(staleCommand), { recursive: true }); + await fs.writeFile(staleCommand, 'old command without arguments'); + + await updateCommand.execute(testDir); + + const content = await fs.readFile(staleCommand, 'utf-8'); + expect(content).toContain('**Provided arguments**: $ARGUMENTS'); + }); + 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. From fcd6ced615524ce8c9b509f35a50c870d636fc59 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 14 Aug 2026 19:45:59 -0500 Subject: [PATCH 4/5] test(opencode): cover commands-only upgrades --- test/core/update.test.ts | 46 ++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/test/core/update.test.ts b/test/core/update.test.ts index d2fcf13462..fe2d3d5242 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1332,24 +1332,42 @@ metadata: expect(content).toContain('**Provided arguments**: $ARGUMENTS'); }); - it('should update OpenCode commands with invocation arguments', async () => { - const opencodeSkillsDir = path.join(testDir, '.opencode', 'skills'); - await fs.mkdir(path.join(opencodeSkillsDir, 'openspec-explore'), { - recursive: true, - }); - await fs.writeFile( - path.join(opencodeSkillsDir, 'openspec-explore', 'SKILL.md'), - 'old' - ); + 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' + ); + } - const staleCommand = path.join(testDir, '.opencode', 'commands', 'opsx-propose.md'); - await fs.mkdir(path.dirname(staleCommand), { recursive: true }); - await fs.writeFile(staleCommand, '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); + } + + const consoleSpy = vi.spyOn(console, 'log'); await updateCommand.execute(testDir); - const content = await fs.readFile(staleCommand, 'utf-8'); - expect(content).toContain('**Provided arguments**: $ARGUMENTS'); + 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 () => { From 362e35142583adde72eb40e2ce267c69c9c500a0 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 14 Aug 2026 19:52:32 -0500 Subject: [PATCH 5/5] test(opencode): verify repaired command content --- test/core/update.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/core/update.test.ts b/test/core/update.test.ts index fe2d3d5242..b385abe1f2 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1359,6 +1359,8 @@ metadata: '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');