Skip to content
Open
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: 6 additions & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ to read the hint.
| Oh My Pi (`oh-my-pi`) | `.omp/skills/openspec-*/SKILL.md` | `.omp/commands/opsx-<id>.md` |
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-<id>.md` |
| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-<id>.md` |
| SourceCraft Code Assistant for VS Code (`codeassistant`) | `.codeassistant/skills/openspec-*/SKILL.md` | `.codeassistant/commands/opsx-<id>.md` |
| Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/<id>.md` |
| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-<id>.md` |
| [Rovo Dev CLI](https://support.atlassian.com/rovo/docs/use-rovo-dev-cli/) (`rovodev`) | `.rovodev/skills/openspec-*/SKILL.md` | Not generated. Rovo has no slash-command surface — it matches skills automatically or by prompt (e.g. "use the openspec-propose skill"); `/skills` only manages them. Generated content references skills by name, never as `/openspec-*` commands. |
Expand All @@ -110,6 +111,10 @@ to read the hint.

\*\*\*\* Windsurf was [rebranded to Devin Desktop](https://docs.devin.ai/desktop/devin-desktop-faq) on June 2, 2026, and its config directory moved: `.devin/` is the preferred read + write location, `.windsurf/` a legacy read-only fallback. OpenSpec follows the rename — the tool id is `devin`, and `--tools windsurf` still resolves to it so existing setup scripts keep working. A project still holding OpenSpec files in `.windsurf/` is offered the move on the next `openspec update`; declining leaves them in place, and files you wrote yourself are never touched. Workflows are invoked by filename, so `.devin/workflows/opsx-apply.md` is `/opsx-apply`. The [Devin Local agent does not support workflows](https://docs.devin.ai/desktop/devin-local) — only skills, and it does not read `.windsurf/` at all — so whenever OpenSpec writes Devin skills it keeps their bodies, and the getting-started hint, on `/openspec-*` skill invocations, which work on both agents. Under commands-only delivery no skills are written and both fall back to `/opsx-*`.

SourceCraft Code Assistant support targets its VS Code extension. Its [custom commands](https://sourcecraft.dev/portal/docs/en/code-assistant/operations/agent/slash-commands) and [skills](https://sourcecraft.dev/portal/docs/ru/code-assistant/operations/agent/skills) are available only in VS Code. This integration does not configure SourceCraft web or JetBrains.

With skills-only delivery, ask Code Assistant to use the `openspec-propose` skill with your idea. Skills activate through request matching; OpenSpec does not generate `/openspec-*` commands for this tool.

MiniMax Code is a global skills-only integration. OpenSpec writes only its
`openspec-*` directories under `~/.minimax/skills/`; it does not create
repo-local `.minimax` or `.mavis` directories. Commands-only delivery leaves
Expand Down Expand Up @@ -214,7 +219,7 @@ openspec init --tools none
openspec init --profile core
```

**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zed`, `zcode`, `agents`
**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `codeassistant`, `trae`, `zed`, `zcode`, `agents`

## Workflow-Dependent Installation

Expand Down
33 changes: 33 additions & 0 deletions src/core/command-generation/adapters/codeassistant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* SourceCraft Code Assistant Command Adapter
*
* Formats commands for the SourceCraft Code Assistant VS Code extension.
*
* @see https://sourcecraft.dev/portal/docs/en/code-assistant/operations/agent/slash-commands
*/

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue } from '../yaml.js';

/**
* SourceCraft Code Assistant adapter for command generation.
* File path: .codeassistant/commands/opsx-<id>.md
* Format: YAML frontmatter with description
*/
export const codeassistantAdapter: ToolCommandAdapter = {
toolId: 'codeassistant',

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

formatFile(content: CommandContent): string {
return `---
description: ${escapeYamlValue(content.description)}
---

${content.body}
`;
},
};
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 @@ -27,6 +27,7 @@ export { kiroAdapter } from './kiro.js';
export { ohMyPiAdapter } from './oh-my-pi.js';
export { opencodeAdapter } from './opencode.js';
export { piAdapter } from './pi.js';
export { codeassistantAdapter } from './codeassistant.js';
export { qoderAdapter } from './qoder.js';
export { lingmaAdapter } from './lingma.js';
export { qwenAdapter } from './qwen.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 @@ -29,6 +29,7 @@ import { kiroAdapter } from './adapters/kiro.js';
import { ohMyPiAdapter } from './adapters/oh-my-pi.js';
import { opencodeAdapter } from './adapters/opencode.js';
import { piAdapter } from './adapters/pi.js';
import { codeassistantAdapter } from './adapters/codeassistant.js';
import { qoderAdapter } from './adapters/qoder.js';
import { lingmaAdapter } from './adapters/lingma.js';
import { qwenAdapter } from './adapters/qwen.js';
Expand Down Expand Up @@ -67,6 +68,7 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(ohMyPiAdapter);
CommandAdapterRegistry.register(opencodeAdapter);
CommandAdapterRegistry.register(piAdapter);
CommandAdapterRegistry.register(codeassistantAdapter);
CommandAdapterRegistry.register(qoderAdapter);
CommandAdapterRegistry.register(lingmaAdapter);
CommandAdapterRegistry.register(qwenAdapter);
Expand Down
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Oh My Pi', value: 'oh-my-pi', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },
{ name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },
{ name: 'SourceCraft Code Assistant', value: 'codeassistant', available: true, successLabel: 'SourceCraft Code Assistant', skillsDir: '.codeassistant' },
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder', requiresIdeRestart: true },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
{ name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
Expand Down
15 changes: 7 additions & 8 deletions src/utils/command-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,14 @@ const SKILL_INVOCATION_PREFIX: Record<string, string> = {
};

/**
* Tools that have no slash-command surface at all: skills are matched
* automatically or invoked by natural-language prompts, never by typing a
* `/<name>` command. Rovo Dev CLI is such a tool — `/skills` only manages
* skills, and any `/openspec-*` form would be a dead command (see
* docs/supported-tools.md). References for these tools are spelled as prose
* ("the openspec-propose skill") so generated content never tells the user to
* type a command their CLI does not register.
* Tools with no documented slash invocation for skills: use automatic
* matching or natural-language prompts instead. SourceCraft Code Assistant
* supports separate command files, but its skills use description matching.
* Rovo Dev's `/skills` only manages skills (see docs/supported-tools.md).
* Skill references for these tools are spelled as prose ("the openspec-propose
* skill") so skills-only delivery does not advertise unregistered commands.
*/
const NATURAL_LANGUAGE_SKILL_TOOLS = new Set<string>(['rovodev']);
const NATURAL_LANGUAGE_SKILL_TOOLS = new Set<string>(['rovodev', 'codeassistant']);

/**
* Whether a tool references skills by natural language rather than a slash
Expand Down
12 changes: 12 additions & 0 deletions test/core/available-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,5 +532,17 @@ describe('available-tools', () => {
expect(ohMyPiTool?.name).toBe('Oh My Pi');
expect(ohMyPiTool?.skillsDir).toBe('.omp');
});

it('should detect SourceCraft Code Assistant when .codeassistant directory exists', async () => {
await fs.mkdir(path.join(testDir, '.codeassistant'), { recursive: true });

const tools = getAvailableTools(testDir);
const toolValues = tools.map((t) => t.value);
expect(toolValues).toContain('codeassistant');

const codeassistantTool = tools.find((t) => t.value === 'codeassistant');
expect(codeassistantTool?.name).toBe('SourceCraft Code Assistant');
expect(codeassistantTool?.skillsDir).toBe('.codeassistant');
});
});
});
42 changes: 42 additions & 0 deletions test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { qoderAdapter } from '../../../src/core/command-generation/adapters/qode
import { qwenAdapter } from '../../../src/core/command-generation/adapters/qwen.js';
import { roocodeAdapter } from '../../../src/core/command-generation/adapters/roocode.js';
import { traeAdapter } from '../../../src/core/command-generation/adapters/trae.js';
import { codeassistantAdapter } from '../../../src/core/command-generation/adapters/codeassistant.js';
import { zcodeAdapter } from '../../../src/core/command-generation/adapters/zcode.js';
import type {
CommandContent,
Expand Down Expand Up @@ -1147,6 +1148,47 @@ describe('command-generation/adapters', () => {
});
});

describe('codeassistantAdapter', () => {
it('should have correct toolId', () => {
expect(codeassistantAdapter.toolId).toBe('codeassistant');
});

it('should generate correct file path', () => {
const filePath = codeassistantAdapter.getFilePath('explore');
expect(filePath).toBe(path.join('.codeassistant', 'commands', 'opsx-explore.md'));
});

it('should generate correct file path for different command IDs', () => {
expect(codeassistantAdapter.getFilePath('new')).toBe(path.join('.codeassistant', 'commands', 'opsx-new.md'));
expect(codeassistantAdapter.getFilePath('bulk-archive')).toBe(path.join('.codeassistant', 'commands', 'opsx-bulk-archive.md'));
});

it('should format file with correct YAML frontmatter', () => {
const output = codeassistantAdapter.formatFile(sampleContent);

const frontmatter = output.match(/^---\n([\s\S]*?)\n---\n\n/);
expect(frontmatter).not.toBeNull();
expect(parseYaml(frontmatter![1])).toEqual({ description: sampleContent.description });
expect(output.slice(frontmatter![0].length)).toBe(`${sampleContent.body}\n`);
});

it('generates registered commands with hyphenated workflow references', () => {
const content: CommandContent = {
...sampleContent,
body: 'Use /opsx:propose, /opsx:update, and /opsx:bulk-archive. Keep /opsx:unknown.',
};
const adapter = CommandAdapterRegistry.get('codeassistant');
expect(adapter).toBe(codeassistantAdapter);
const generated = generateCommand(content, adapter!);

expect(generated.path).toBe(path.join('.codeassistant', 'commands', 'opsx-explore.md'));
expect(generated.fileContent).toContain(
'Use /opsx-propose, /opsx-update, and /opsx-bulk-archive. Keep /opsx:unknown.'
);
expect(content.body).toContain('/opsx:propose');
});
});

describe('YAML frontmatter escaping across 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
Expand Down
38 changes: 38 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,44 @@ describe('InitCommand', () => {
expect(await directoryExists(path.join(testDir, '.agents'))).toBe(false);
});

it.each(['both', 'skills', 'commands'] as const)(
'should initialize SourceCraft Code Assistant with delivery=%s and working invocation hints',
async (delivery) => {
if (delivery !== 'both') {
saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery });
}

await new InitCommand({ tools: 'codeassistant', force: true }).execute(testDir);

const skillFile = path.join(testDir, '.codeassistant', 'skills', 'openspec-apply-change', 'SKILL.md');
const commandFile = path.join(testDir, '.codeassistant', 'commands', 'opsx-apply.md');
expect(await fileExists(skillFile)).toBe(delivery !== 'commands');
expect(await fileExists(commandFile)).toBe(delivery !== 'skills');

if (delivery !== 'commands') {
const skillContent = await fs.readFile(skillFile, 'utf-8');
expect(skillContent).toContain(delivery === 'skills' ? 'the openspec-archive-change skill' : '/opsx-archive');
expect(skillContent).not.toContain('/opsx:');
if (delivery === 'skills') {
expect(skillContent).not.toContain('/openspec-');
expect(skillContent).not.toContain('/opsx-');
}
}
if (delivery !== 'skills') {
const commandContent = await fs.readFile(commandFile, 'utf-8');
expect(commandContent).toMatch(/^---\ndescription: /);
expect(commandContent).toContain('/opsx-archive');
expect(commandContent).not.toContain('/opsx:');
}

const logCalls = vi.mocked(console.log).mock.calls.flat().map(String);
const startHint = logCalls.find((entry) => entry.includes('Start your first change'));
expect(startHint).toContain(delivery === 'skills'
? 'ask SourceCraft Code Assistant to use the openspec-propose skill with "your idea"'
: '/opsx-propose');
}
);

it('should support the shared agents target as an adapterless skills-only tool', async () => {
saveGlobalConfig({
featureFlags: {},
Expand Down
62 changes: 62 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,40 @@ metadata:
expect(skillContent).not.toContain('/opsx-');
});

it.each(['both', 'commands'] as const)(
'should discover and refresh SourceCraft Code Assistant commands with delivery=%s',
async (delivery) => {
setMockConfig({ featureFlags: {}, profile: 'core', delivery });
const commandsDir = path.join(testDir, '.codeassistant', 'commands');
await fs.mkdir(commandsDir, { recursive: true });
await fs.writeFile(path.join(commandsDir, 'opsx-apply.md'), 'old command content');
const skillFile = path.join(testDir, '.codeassistant', 'skills', 'openspec-apply-change', 'SKILL.md');
if (delivery === 'both') {
await fs.mkdir(path.dirname(skillFile), { recursive: true });
await fs.writeFile(skillFile, 'old skill content');
}

await updateCommand.execute(testDir);

const commandContent = await fs.readFile(path.join(commandsDir, 'opsx-apply.md'), 'utf-8');
expect(commandContent).toMatch(/^---\ndescription: /);
expect(commandContent).toContain('/opsx-archive');
expect(commandContent).not.toContain('/opsx:');
expect(await FileSystemUtils.fileExists(path.join(commandsDir, 'opsx-propose.md'))).toBe(true);

expect(await FileSystemUtils.fileExists(skillFile)).toBe(delivery === 'both');
if (delivery === 'both') {
const skillContent = await fs.readFile(skillFile, 'utf-8');
expect(skillContent).toContain('/opsx-archive');
expect(skillContent).not.toContain('/opsx:');
}

const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
expect(consoleSpy.mock.calls.flat().map(String).some((entry) => entry.includes('up to date'))).toBe(true);
}
);

it('should update command files when tool is configured via commands-only delivery without skills', async () => {
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' });
const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');
Expand Down Expand Up @@ -3366,6 +3400,34 @@ More user content after markers.
expect(updateSkillContent).toContain('/openspec-');
});

it.each(['skills', 'commands'] as const)(
'should switch SourceCraft Code Assistant to delivery=%s without deleting custom files',
async (delivery) => {
await new InitCommand({ tools: 'codeassistant', force: true }).execute(testDir);
const toolDir = path.join(testDir, '.codeassistant');
const customCommand = path.join(toolDir, 'commands', 'opsx-custom.md');
const customSkill = path.join(toolDir, 'skills', 'custom-review', 'SKILL.md');
await fs.mkdir(path.dirname(customSkill), { recursive: true });
await fs.writeFile(customCommand, 'custom command');
await fs.writeFile(customSkill, 'custom skill');

setMockConfig({ featureFlags: {}, profile: 'core', delivery });
await updateCommand.execute(testDir);

expect(await FileSystemUtils.fileExists(path.join(toolDir, 'commands', 'opsx-apply.md'))).toBe(delivery === 'commands');
const skillFile = path.join(toolDir, 'skills', 'openspec-apply-change', 'SKILL.md');
expect(await FileSystemUtils.fileExists(skillFile)).toBe(delivery === 'skills');
if (delivery === 'skills') {
const skillContent = await fs.readFile(skillFile, 'utf-8');
expect(skillContent).toContain('the openspec-archive-change skill');
expect(skillContent).not.toContain('/openspec-');
expect(skillContent).not.toContain('/opsx-');
}
expect(await fs.readFile(customCommand, 'utf-8')).toBe('custom command');
expect(await fs.readFile(customSkill, 'utf-8')).toBe('custom skill');
}
);

it('should respect commands-only delivery setting', async () => {
setMockConfig({
featureFlags: {},
Expand Down
4 changes: 2 additions & 2 deletions test/utils/command-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ describe('getSkillReferenceTransformer', () => {
expect(transformer('/opsx:unknown-command')).toBe('/opsx:unknown-command');
});

it('uses natural-language references for Rovo Dev, which has no slash surface', () => {
const transformer = getSkillReferenceTransformer('rovodev');
it.each(['rovodev', 'codeassistant'])('uses natural-language skill references for %s', (toolId) => {
const transformer = getSkillReferenceTransformer(toolId);
expect(transformer('/opsx:propose')).toBe('the openspec-propose skill');
expect(transformer('Run `/opsx:apply` then /opsx:archive')).toBe(
'Run `the openspec-apply-change skill` then the openspec-archive-change skill'
Expand Down