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
7 changes: 7 additions & 0 deletions .changeset/command-code-command-adapter.md
Original file line number Diff line number Diff line change
@@ -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-<id>.md` (invoked as `/opsx-<id>`) alongside the skills under `.commandcode/skills/`, matching Command Code's documented custom-slash-command surface.
4 changes: 2 additions & 2 deletions docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ way it loads the file OpenSpec wrote. Find your tool's command path in the
| `.../opsx-<id>.*` — the filename is the command | `/opsx-<id>` | Every other tool with generated command files, except Amazon Q and Devin |
| `.devin/workflows/opsx-<id>.md` — read by only one of Devin's two agents | `/opsx-<id>` on Devin Desktop, `/openspec-<skill>` on Devin Local | Devin Desktop\*\*\*\* |
| `.amazonq/prompts/opsx-<id>.md` — a prompt, not a command | `@opsx-<id>` | Amazon Q Developer |
| none — skills only | `/openspec-<skill>` | Command Code, CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, shared `.agents` |
| none — skills only | `/openspec-<skill>` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, shared `.agents` |
| none — Kimi Code | `/skill:openspec-<skill>` | Kimi Code |
| none — Codex CLI | `$openspec-<skill>` | Codex ([`/openspec-<skill>` is not recognized](https://github.com/openai/codex/issues/11817)) |

Expand Down Expand Up @@ -70,7 +70,7 @@ to read the hint.
| IBM Bob Shell (`bob`) | `.bob/skills/openspec-*/SKILL.md` | `.bob/commands/opsx-<id>.md` |
| Claude Code (`claude`) | `.claude/skills/openspec-*/SKILL.md` | `.claude/commands/opsx/<id>.md` |
| Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-<id>.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-<id>.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/<id>.md` |
| Codex (`codex`) | `.agents/skills/openspec-*/SKILL.md` | Not generated (skills-only; use `$openspec-*`) |
Expand Down
44 changes: 44 additions & 0 deletions src/core/command-generation/adapters/command-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* 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-<id>.md` registers `/opsx-<id>` — 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';

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;
Comment on lines +16 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Detect supported placeholders throughout the command body.

Line 16 only recognizes a placeholder when it is the complete value of a **Provided arguments** line. A body that already uses $ARGUMENTS, $@, ${ARGUMENTS}, or ${@} elsewhere receives a second injected $ARGUMENTS line. This duplicates the invocation text.

  • src/core/command-generation/adapters/command-code.ts#L16-L17: Detect the supported placeholders anywhere in body before injection.
  • test/core/command-generation/adapters.test.ts#L146-L155: Add cases with each supported placeholder outside **Provided arguments** and assert that injection does not add a second placeholder.
📍 Affects 2 files
  • src/core/command-generation/adapters/command-code.ts#L16-L17 (this comment)
  • test/core/command-generation/adapters.test.ts#L146-L155
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/command-generation/adapters/command-code.ts` around lines 16 - 17,
Update the placeholder detection in command-code generation to recognize
$ARGUMENTS, $@, ${ARGUMENTS}, and ${@} anywhere in body, not only as the
complete **Provided arguments** line, so injection never duplicates an existing
placeholder. In test/core/command-generation/adapters.test.ts lines 146-155, add
coverage for each placeholder appearing outside that section and assert no
second placeholder is injected.

}

return body.replace(
COMMAND_CODE_INPUT_HEADING,
(heading) => `${heading}\n**Provided arguments**: $ARGUMENTS`
);
}

/**
* Command Code adapter for command generation.
* File path: .commandcode/commands/opsx-<id>.md
* 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',

getFilePath(commandId: string): string {
return path.join('.commandcode', 'commands', `opsx-${commandId}.md`);
},

formatFile(content: CommandContent): string {
return `${injectCommandCodeArgs(content.body)}\n`;
},
};
1 change: 1 addition & 0 deletions src/core/command-generation/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions src/core/command-generation/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
67 changes: 66 additions & 1 deletion test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -34,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';

Expand Down Expand Up @@ -115,6 +117,69 @@ 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 the documented plain Markdown command body', () => {
const output = commandCodeAdapter.formatFile(sampleContent);
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', () => {
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');
Expand Down Expand Up @@ -1023,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)
);
Expand Down
2 changes: 1 addition & 1 deletion test/core/command-generation/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add an explicit Command Code registry assertion.

The noYamlFrontmatter list only affects adapters that getAll() already returns. If command-code is not registered, this test still passes. Assert that CommandAdapterRegistry.get('command-code'), has('command-code'), or getAll() includes the adapter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/command-generation/registry.test.ts` at line 133, Update the
registry test around the noYamlFrontmatter list to explicitly verify that
CommandAdapterRegistry registers the "command-code" adapter, using get, has, or
a getAll membership assertion. Keep the existing frontmatter behavior checks
unchanged.


const adapters = CommandAdapterRegistry.getAll();
for (const adapter of adapters) {
Expand Down
40 changes: 27 additions & 13 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -513,23 +513,37 @@ 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:');
// Adapter-backed: Command Code reads custom slash commands from
// .commandcode/commands/opsx-<id>.md, invoked as /opsx-<id>.
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/);
});

const commandsDir = path.join(testDir, '.commandcode', 'commands');
expect(await directoryExists(commandsDir)).toBe(false);
it('should generate Command Code commands and skip skills under delivery=commands', async () => {
saveGlobalConfig({
featureFlags: {},
profile: 'core',
delivery: 'commands',
});

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);
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 () => {
Expand Down
28 changes: 28 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>.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.
Expand Down
Loading