From 835d91675081643cb3d0f26b856672ccfc165e95 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 10 Aug 2026 08:31:14 -0500 Subject: [PATCH 1/3] feat(tools): add Command Code command adapter for /opsx-* commands Command Code documents custom slash commands under `.commandcode/commands/`, where the command name is the markdown filename without its `.md` extension (see https://commandcode.ai/docs/reference/slash-commands). That is the same flat naming Cursor and OpenCode use, so a standard flat adapter writing `.commandcode/commands/opsx-.md` registers `/opsx-`. Registering the adapter flips Command Code from `none` to `adapter-backed`, so with the default `both` delivery `openspec init` now generates OpenSpec commands alongside the skills it already installs under `.commandcode/skills/`. Builds on #1613, which registered Command Code as a skills-only tool. Co-Authored-By: Claude Opus 4.8 --- .changeset/command-code-command-adapter.md | 7 ++++ docs/supported-tools.md | 4 +-- .../adapters/command-code.ts | 34 +++++++++++++++++++ src/core/command-generation/adapters/index.ts | 1 + src/core/command-generation/registry.ts | 2 ++ test/core/command-generation/adapters.test.ts | 32 +++++++++++++++++ test/core/init.test.ts | 21 ++++-------- 7 files changed, 84 insertions(+), 17 deletions(-) create mode 100644 .changeset/command-code-command-adapter.md create mode 100644 src/core/command-generation/adapters/command-code.ts diff --git a/.changeset/command-code-command-adapter.md b/.changeset/command-code-command-adapter.md new file mode 100644 index 0000000000..1e6118cb20 --- /dev/null +++ b/.changeset/command-code-command-adapter.md @@ -0,0 +1,7 @@ +--- +"@fission-ai/openspec": minor +--- + +### New Features + +- **Command Code command adapter** — Command Code is now a first-class, adapter-backed tool. `openspec init` generates OpenSpec commands under `.commandcode/commands/opsx-.md` (invoked as `/opsx-`) alongside the skills under `.commandcode/skills/`, matching Command Code's documented custom-slash-command surface. diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 5d8fe04ebe..9ede9d3f29 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -33,7 +33,7 @@ way it loads the file OpenSpec wrote. Find your tool's command path in the | `.../opsx-.*` — the filename is the command | `/opsx-` | Every other tool with generated command files, except Amazon Q and Devin | | `.devin/workflows/opsx-.md` — read by only one of Devin's two agents | `/opsx-` on Devin Desktop, `/openspec-` on Devin Local | Devin Desktop\*\*\*\* | | `.amazonq/prompts/opsx-.md` — a prompt, not a command | `@opsx-` | Amazon Q Developer | -| none — skills only | `/openspec-` | Command Code, CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, shared `.agents` | +| none — skills only | `/openspec-` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, shared `.agents` | | none — Kimi Code | `/skill:openspec-` | Kimi Code | | none — Codex CLI | `$openspec-` | Codex ([`/openspec-` is not recognized](https://github.com/openai/codex/issues/11817)) | @@ -70,7 +70,7 @@ to read the hint. | IBM Bob Shell (`bob`) | `.bob/skills/openspec-*/SKILL.md` | `.bob/commands/opsx-.md` | | Claude Code (`claude`) | `.claude/skills/openspec-*/SKILL.md` | `.claude/commands/opsx/.md` | | Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-.md` | -| Command Code (`command-code`) | `.commandcode/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | +| Command Code (`command-code`) | `.commandcode/skills/openspec-*/SKILL.md` | `.commandcode/commands/opsx-.md` | | CodeArts (`codeartsagent`) | `.codeartsdoer/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/.md` | | Codex (`codex`) | `.agents/skills/openspec-*/SKILL.md` | Not generated (skills-only; use `$openspec-*`) | diff --git a/src/core/command-generation/adapters/command-code.ts b/src/core/command-generation/adapters/command-code.ts new file mode 100644 index 0000000000..0f18f4c997 --- /dev/null +++ b/src/core/command-generation/adapters/command-code.ts @@ -0,0 +1,34 @@ +/** + * Command Code Command Adapter + * + * Command Code reads custom slash commands from `.commandcode/commands/`. The + * command name is the markdown filename without its `.md` extension, so + * `opsx-.md` registers `/opsx-` — the same flat naming Cursor and + * OpenCode use. See https://commandcode.ai/docs/reference/slash-commands. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; + +/** + * Command Code adapter for command generation. + * File path: .commandcode/commands/opsx-.md + * Frontmatter: description + */ +export const commandCodeAdapter: ToolCommandAdapter = { + toolId: 'command-code', + + getFilePath(commandId: string): string { + return path.join('.commandcode', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +description: ${escapeYamlValue(content.description)} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 43c2e36e65..ea7333b7ec 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -10,6 +10,7 @@ export { auggieAdapter } from './auggie.js'; export { bobAdapter } from './bob.js'; export { claudeAdapter } from './claude.js'; export { clineAdapter } from './cline.js'; +export { commandCodeAdapter } from './command-code.js'; export { codebuddyAdapter } from './codebuddy.js'; export { continueAdapter } from './continue.js'; export { costrictAdapter } from './costrict.js'; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index 14e5481814..6c4f7fb818 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -12,6 +12,7 @@ import { auggieAdapter } from './adapters/auggie.js'; import { bobAdapter } from './adapters/bob.js'; import { claudeAdapter } from './adapters/claude.js'; import { clineAdapter } from './adapters/cline.js'; +import { commandCodeAdapter } from './adapters/command-code.js'; import { devinAdapter } from './adapters/devin.js'; import { codebuddyAdapter } from './adapters/codebuddy.js'; import { continueAdapter } from './adapters/continue.js'; @@ -49,6 +50,7 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(bobAdapter); CommandAdapterRegistry.register(claudeAdapter); CommandAdapterRegistry.register(clineAdapter); + CommandAdapterRegistry.register(commandCodeAdapter); CommandAdapterRegistry.register(devinAdapter); CommandAdapterRegistry.register(codebuddyAdapter); CommandAdapterRegistry.register(continueAdapter); diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index f4d946e565..4245ca2ea3 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -6,6 +6,7 @@ import { auggieAdapter } from '../../../src/core/command-generation/adapters/aug import { bobAdapter } from '../../../src/core/command-generation/adapters/bob.js'; import { claudeAdapter } from '../../../src/core/command-generation/adapters/claude.js'; import { clineAdapter } from '../../../src/core/command-generation/adapters/cline.js'; +import { commandCodeAdapter } from '../../../src/core/command-generation/adapters/command-code.js'; import { codebuddyAdapter } from '../../../src/core/command-generation/adapters/codebuddy.js'; import { continueAdapter } from '../../../src/core/command-generation/adapters/continue.js'; import { costrictAdapter } from '../../../src/core/command-generation/adapters/costrict.js'; @@ -115,6 +116,37 @@ describe('command-generation/adapters', () => { }); }); + describe('commandCodeAdapter', () => { + it('should have correct toolId', () => { + expect(commandCodeAdapter.toolId).toBe('command-code'); + }); + + it('should generate correct file path with opsx- prefix', () => { + const filePath = commandCodeAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.commandcode', 'commands', 'opsx-explore.md')); + }); + + it('should format file with description frontmatter', () => { + const output = commandCodeAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('description: "Enter explore mode for thinking"'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + + it('is generated by generateCommand with hyphen command references', () => { + const contentWithCommands: CommandContent = { + ...sampleContent, + body: 'Use /opsx:new to start, then /opsx:apply to implement.', + }; + const output = generateCommand(contentWithCommands, commandCodeAdapter).fileContent; + expect(output).toContain('/opsx-new'); + expect(output).toContain('/opsx-apply'); + expect(output).not.toContain('/opsx:new'); + expect(output).not.toContain('/opsx:apply'); + }); + }); + describe('devinAdapter', () => { it('should have correct toolId', () => { expect(devinAdapter.toolId).toBe('devin'); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 591122fea0..e1dc554db0 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -503,7 +503,7 @@ describe('InitCommand', () => { ).toBe(true); }); - it('should support Command Code as an adapterless skills-only tool', async () => { + it('should support Command Code with both skills and generated commands', async () => { saveGlobalConfig({ featureFlags: {}, profile: 'core', @@ -513,23 +513,14 @@ describe('InitCommand', () => { const initCommand = new InitCommand({ tools: 'command-code', force: true }); await initCommand.execute(testDir); + // Skills install under .commandcode/skills (Command Code's native skill surface) const skillFile = path.join(testDir, '.commandcode', 'skills', 'openspec-explore', 'SKILL.md'); expect(await fileExists(skillFile)).toBe(true); - // Command Code documents /openspec-* skill invocations (no /skill: prefix) - const skill = await fs.readFile(skillFile, 'utf-8'); - expect(skill).toContain('/openspec-'); - expect(skill).not.toContain('/skill:'); - - const commandsDir = path.join(testDir, '.commandcode', 'commands'); - expect(await directoryExists(commandsDir)).toBe(false); - - const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); - expect( - logCalls.some( - (entry) => entry.includes('Commands skipped for: command-code') && entry.includes('(no adapter)'), - ), - ).toBe(true); + // Adapter-backed: Command Code reads custom slash commands from + // .commandcode/commands/opsx-.md, invoked as /opsx-. + const commandFile = path.join(testDir, '.commandcode', 'commands', 'opsx-explore.md'); + expect(await fileExists(commandFile)).toBe(true); }); it('should support CodeArts as an adapterless skills-only tool', async () => { From be86f0a54d01c3a7fa1e51a5555feccd268c8529 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 10 Aug 2026 08:59:24 -0500 Subject: [PATCH 2/3] fix(tools): preserve Command Code command arguments --- .../adapters/command-code.ts | 26 +++++++---- test/core/command-generation/adapters.test.ts | 45 ++++++++++++++++--- test/core/command-generation/registry.test.ts | 2 +- test/core/init.test.ts | 3 ++ 4 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/core/command-generation/adapters/command-code.ts b/src/core/command-generation/adapters/command-code.ts index 0f18f4c997..5dbebb4cc4 100644 --- a/src/core/command-generation/adapters/command-code.ts +++ b/src/core/command-generation/adapters/command-code.ts @@ -9,12 +9,27 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; -import { escapeYamlValue } from '../yaml.js'; + +const COMMAND_CODE_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m; + +function injectCommandCodeArgs(body: string): string { + if (/^\*\*Provided arguments\*\*:\s*(?:\$(?:ARGUMENTS|@)|\$\{(?:ARGUMENTS|@)\})\s*$/m.test(body)) { + return body; + } + + return body.replace( + COMMAND_CODE_INPUT_HEADING, + (heading) => `${heading}\n**Provided arguments**: $ARGUMENTS` + ); +} /** * Command Code adapter for command generation. * File path: .commandcode/commands/opsx-.md - * Frontmatter: description + * Format: plain Markdown with $ARGUMENTS injected after the input contract + * + * Command Code executes the full trimmed file body and substitutes invocation + * arguments only where the body includes one of its argument placeholders. */ export const commandCodeAdapter: ToolCommandAdapter = { toolId: 'command-code', @@ -24,11 +39,6 @@ export const commandCodeAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - return `--- -description: ${escapeYamlValue(content.description)} ---- - -${content.body} -`; + return `${injectCommandCodeArgs(content.body)}\n`; }, }; diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 4245ca2ea3..b4dd55459b 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -35,6 +35,7 @@ import type { } from '../../../src/core/command-generation/types.js'; import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; import { generateCommand } from '../../../src/core/command-generation/generator.js'; +import { getCommandContents } from '../../../src/core/shared/skill-generation.js'; import { parse as parseYaml } from 'yaml'; import { parse as parseToml } from 'smol-toml'; @@ -126,12 +127,44 @@ describe('command-generation/adapters', () => { expect(filePath).toBe(path.join('.commandcode', 'commands', 'opsx-explore.md')); }); - it('should format file with description frontmatter', () => { + it('should format the documented plain Markdown command body', () => { const output = commandCodeAdapter.formatFile(sampleContent); - expect(output).toContain('---\n'); - expect(output).toContain('description: "Enter explore mode for thinking"'); - expect(output).toContain('---\n\n'); - expect(output).toContain('This is the command body.'); + expect(output).toBe(`${sampleContent.body}\n`); + expect(output).not.toContain('description:'); + }); + + it('should pass invocation arguments into the OpenSpec input contract', () => { + const output = commandCodeAdapter.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.each(['$ARGUMENTS', '$@', '${ARGUMENTS}', '${@}'])( + 'should not duplicate an existing %s placeholder', + (placeholder) => { + const output = commandCodeAdapter.formatFile({ + ...sampleContent, + body: `**Input**: A change name.\n**Provided arguments**: ${placeholder}`, + }); + expect(output.match(/\*\*Provided arguments\*\*:/g)).toHaveLength(1); + } + ); + + it('should preserve invocation arguments for every workflow that accepts them', () => { + const commandsWithoutArguments = getCommandContents() + .filter((content) => { + const output = generateCommand(content, commandCodeAdapter).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', () => { @@ -1055,7 +1088,7 @@ describe('command-generation/adapters', () => { // Derived from the registry, not hand-listed: a newly registered adapter // must be covered by default. Adding one that emits no YAML frontmatter is // then a deliberate act of adding it here. - const NON_YAML_ADAPTERS = ['cline', 'kilocode', 'roocode', 'gemini']; + const NON_YAML_ADAPTERS = ['cline', 'command-code', 'kilocode', 'roocode', 'gemini']; const yamlAdapters = CommandAdapterRegistry.getAll().filter( (adapter) => !NON_YAML_ADAPTERS.includes(adapter.toolId) ); diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index 07fb8bf774..b5d150a23a 100644 --- a/test/core/command-generation/registry.test.ts +++ b/test/core/command-generation/registry.test.ts @@ -130,7 +130,7 @@ describe('command-generation/registry', () => { }; // Tools that don't use YAML frontmatter (markdown headers or TOML or plain) - const noYamlFrontmatter = ['cline', 'kilocode', 'roocode', 'gemini']; + const noYamlFrontmatter = ['cline', 'command-code', 'kilocode', 'roocode', 'gemini']; const adapters = CommandAdapterRegistry.getAll(); for (const adapter of adapters) { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index e1dc554db0..00b3cd2397 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -521,6 +521,9 @@ describe('InitCommand', () => { // .commandcode/commands/opsx-.md, invoked as /opsx-. const commandFile = path.join(testDir, '.commandcode', 'commands', 'opsx-explore.md'); expect(await fileExists(commandFile)).toBe(true); + const commandContent = await fs.readFile(commandFile, 'utf-8'); + expect(commandContent).toContain('**Provided arguments**: $ARGUMENTS'); + expect(commandContent).not.toMatch(/^---\n/); }); it('should support CodeArts as an adapterless skills-only tool', async () => { From cbb3f55962e6f03939980534ac2c6b963794c6a1 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 11 Aug 2026 15:38:52 -0500 Subject: [PATCH 3/3] test(command-code): cover commands-only delivery and openspec update Addresses review: prove the Command Code adapter survives both the commands-only init path and the update path, not just default delivery. - init: delivery=commands generates .commandcode/commands/opsx-explore.md and installs no skills. - update: a detected .commandcode install regenerates the flat opsx-.md command (plain Markdown, $ARGUMENTS injected). Co-Authored-By: Claude Opus 4.8 --- test/core/init.test.ts | 20 ++++++++++++++++++++ test/core/update.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 00b3cd2397..d660648fe0 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -526,6 +526,26 @@ describe('InitCommand', () => { expect(commandContent).not.toMatch(/^---\n/); }); + it('should generate Command Code commands and skip skills under delivery=commands', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const initCommand = new InitCommand({ tools: 'command-code', force: true }); + await initCommand.execute(testDir); + + // commands-only delivery: the adapter still writes commands... + const commandFile = path.join(testDir, '.commandcode', 'commands', 'opsx-explore.md'); + expect(await fileExists(commandFile)).toBe(true); + const commandContent = await fs.readFile(commandFile, 'utf-8'); + expect(commandContent).toContain('**Provided arguments**: $ARGUMENTS'); + + // ...but no skills are installed + expect(await directoryExists(path.join(testDir, '.commandcode', 'skills'))).toBe(false); + }); + it('should support CodeArts as an adapterless skills-only tool', async () => { saveGlobalConfig({ featureFlags: {}, diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 3a40b7e97c..5dfc716502 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1200,6 +1200,34 @@ metadata: expect(content).toContain('description:'); }); + it('should update Command Code tool and regenerate its flat command', async () => { + // A configured Command Code install is detected by its skills dir + const commandCodeSkillsDir = path.join(testDir, '.commandcode', 'skills'); + await fs.mkdir(path.join(commandCodeSkillsDir, 'openspec-explore'), { + recursive: true, + }); + await fs.writeFile( + path.join(commandCodeSkillsDir, 'openspec-explore', 'SKILL.md'), + 'old' + ); + + await updateCommand.execute(testDir); + + // Adapter-backed: update regenerates .commandcode/commands/opsx-.md + const commandCodeCmd = path.join( + testDir, + '.commandcode', + 'commands', + 'opsx-explore.md' + ); + expect(await FileSystemUtils.fileExists(commandCodeCmd)).toBe(true); + + // Plain Markdown (no frontmatter) with the argument placeholder injected + const content = await fs.readFile(commandCodeCmd, 'utf-8'); + expect(content).not.toMatch(/^---\n/); + 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.