Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/core/command-generation/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>.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',
Expand All @@ -25,7 +42,7 @@ export const opencodeAdapter: ToolCommandAdapter = {
description: ${escapeYamlValue(content.description)}
---

${content.body}
${injectOpenCodeArgs(content.body)}
`;
},
};
63 changes: 63 additions & 0 deletions test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
40 changes: 40 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Comment thread
clay-good marked this conversation as resolved.

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.
Expand Down
Loading