Compare commits

...
Author SHA1 Message Date
Clay GoodandClaude Opus 5 1fe56159c4 fix(tools): correct the Grok vendor-compat claim and close test gaps
Hardening pass on #1851.

The docs and the adapter comment claimed Grok's flat command scan skips
every vendor-compat directory, so a command could not register twice.
That holds for `.claude/`, which nests commands under `opsx/`, and for
`.agents/`, which no adapter writes commands into — but Cursor writes
flat `opsx-<id>.md`, which the scan does find, and each of those roots
can also hold an identical `openspec-*` skill. The copies differ only in
the frontmatter each tool needs, so the outcome was never wrong; the
stated reason was. Both places now describe the real shape.

Also:

- Adds the Grok row to `docs-lab/reference/supported-tools.md`, the
  source the published docs site renders. Without it the live page
  would not list the tool.
- Orders the `--tools` id list in docs/supported-tools.md to match
  AI_TOOLS, so it is now identical to the copy in docs/cli.md.
- Warns against naming your own Grok command `opsx-<workflow>.md`:
  Grok's flat scan means OpenSpec shares that directory with the user,
  and it owns those names.
- Drops the upstream source citations from the user-facing footnote,
  which is the wrong altitude for it; they remain in the adapter header.
- Asserts `available` on the Grok tool entry. Mutation testing found
  that flipping it to false removed Grok from the workset tool picker
  with a fully green suite, because the only test reading the field
  derives its expectation from AI_TOOLS itself.
- Guards the command-stem loop against an empty command set, and adds
  grok to the flat-invocation tool list in command-references tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:32:26 -05:00
Clay GoodandClaude Opus 5 347c9ee178 docs(tools): cite the Grok command source and the skills-only form
Review feedback on #1851.

Documents the `/openspec-propose` spelling that skills-only delivery
produces for Grok, which the entry previously left to the general
invocation table, and pins that behavior with an assertion that no
command files are written in that mode.

Also cites `.grok/commands/` to the shipping CLI's own symbols rather
than asserting it. The published docs cover `skills/` only, so an
automated review read the directory as unsupported; `skill_config_dirs()`
returns `[".grok", ".agents", ".claude", ".cursor"]` and each is passed
to `find_command_paths`, which scans `commands/` flat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:03:47 -05:00
8d7ac40b3a feat(tools): add Grok Build CLI support
Register xAI's `grok` CLI as a supported tool with both skills and
commands, so `openspec init --tools grok` writes
`.grok/skills/openspec-*/SKILL.md` and `.grok/commands/opsx-<id>.md`.

Grok has no separate command subsystem: the loader that discovers
skills also scans `<vendor-dir>/commands/` and registers each Markdown
file there as a slash command. That scan does not recurse, so commands
are written flat — a nested `commands/opsx/<id>.md` would be skipped
rather than namespaced, leaving Grok users with no commands at all.
The filename therefore names the command, giving the `/opsx-<id>` form.

The adapter emits only `description`. Grok prefers a frontmatter `name`
over the file stem and normalizes it to `[a-z0-9-]`, while
`CommandContent.name` carries a display name ("OPSX: New"), so emitting
it would hand Grok a name OpenSpec does not control.

Also adds the missing `rovodev` id to the tool-ID list in
docs/supported-tools.md, on the same line this change edits: that list
and the one in docs/cli.md are maintained separately and had drifted.

Closes #1506

Co-Authored-By: mndz <0x6d6e647a@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:49:47 -05:00
15 changed files with 295 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": minor
---
Add Grok Build, xAI's `grok` CLI, as a supported tool. `openspec init --tools grok` installs skills to `.grok/skills/openspec-*/SKILL.md` and commands to `.grok/commands/opsx-<id>.md`, invoked as `/opsx-propose`.
+1
View File
@@ -32,6 +32,7 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).
| ForgeCode | `forgecode` | `.forge/skills/` | `/openspec-apply-change` | none | none |
| Gemini CLI | `gemini` | `.gemini/skills/` | `/openspec-apply-change` | `.gemini/commands/opsx/` | `/opsx:apply` |
| GitHub Copilot | `github-copilot` | `.github/skills/` | `/openspec-apply-change` | `.github/prompts/` | `/opsx-apply` |
| Grok Build | `grok` | `.grok/skills/` | `/openspec-apply-change` | `.grok/commands/` | `/opsx-apply` |
| Hermes Agent | `hermes` | `.hermes/skills/` | `/openspec-apply-change` | none | none |
| iFlow | `iflow` | `.iflow/skills/` | `/openspec-apply-change` | `.iflow/commands/` | `/opsx-apply` |
| Junie | `junie` | `.junie/skills/` | `/openspec-apply-change` | `.junie/commands/` | `/opsx-apply` |
+1 -1
View File
@@ -114,7 +114,7 @@ field so OpenSpec never overwrites project-specific guidance.
The welcome animation is also skipped when the `OPENSPEC_NO_ANIMATION` environment variable is set (any value, including empty), when `NO_COLOR` is set to a non-empty value, or when the OS reduced-motion preference is enabled (macOS Reduce Motion, GNOME animations disabled).
**Supported 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`, `codeassistant`, `qoder`, `qwen`, `rovodev`, `roocode`, `trae`, `zed`, `zcode`, `agents`
**Supported 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`, `grok`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `codeassistant`, `qoder`, `qwen`, `rovodev`, `roocode`, `trae`, `zed`, `zcode`, `agents`
> This list mirrors `AI_TOOLS` in `src/core/config.ts`. See [Supported Tools](supported-tools.md) for each tool's skill and command paths.
+1 -1
View File
@@ -672,7 +672,7 @@ Different AI tools use slightly different command syntax. Use the format that ma
| Your tool's command file | Syntax example | Example tools |
|--------------------------|----------------|---------------|
| `.../commands/opsx/<id>.*` | `/opsx:propose`, `/opsx:apply` | Claude Code, Gemini CLI, Crush |
| `.../opsx-<id>.*` | `/opsx-propose`, `/opsx-apply` | Cursor, Devin Desktop, Copilot (IDE), Trae, Oh My Pi |
| `.../opsx-<id>.*` | `/opsx-propose`, `/opsx-apply` | Cursor, Devin Desktop, Copilot (IDE), Grok Build, Trae, Oh My Pi |
| none — skills only | `/openspec-propose`, `/openspec-apply-change` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, Zed Agent, shared `.agents` |
| none — Kimi Code | `/skill:openspec-propose` | Kimi Code |
| none — Codex CLI | `$openspec-propose` | Codex |
+1 -1
View File
@@ -76,7 +76,7 @@ The intent is identical everywhere. The spelling follows the file your tool load
| Your tool's command file | How you type it | Example tools |
|--------------------------|-----------------|---------------|
| `.../commands/opsx/<id>.*` | `/opsx:propose` | Claude Code, Gemini CLI, Crush |
| `.../opsx-<id>.*` | `/opsx-propose` | Cursor, GitHub Copilot (IDE), Devin Desktop, Trae, Oh My Pi |
| `.../opsx-<id>.*` | `/opsx-propose` | Cursor, GitHub Copilot (IDE), Devin Desktop, Grok Build, Trae, Oh My Pi |
| `.amazonq/prompts/opsx-<id>.md` | `@opsx-propose` | Amazon Q Developer |
| none — skills only | `/openspec-propose` | CodeArts, ForgeCode, Hermes, Mistral Vibe, Zed Agent, shared `.agents` |
| none — Kimi Code | `/skill:openspec-propose` | Kimi Code |
+8 -1
View File
@@ -83,6 +83,7 @@ to read the hint.
| Factory Droid (`factory`) | `.factory/skills/openspec-*/SKILL.md` | `.factory/commands/opsx-<id>.md` |
| Gemini CLI (`gemini`) | `.gemini/skills/openspec-*/SKILL.md` | `.gemini/commands/opsx/<id>.toml` |
| GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-<id>.prompt.md`\*\* |
| [Grok Build](https://docs.x.ai/build/overview) (`grok`) | `.grok/skills/openspec-*/SKILL.md` | `.grok/commands/opsx-<id>.md`\*\*\*\*\* |
| Hermes Agent (`hermes`) | `.hermes/skills/openspec-*/SKILL.md`\*\*\* | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
| iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-<id>.md` |
| Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-<id>.md` |
@@ -111,6 +112,12 @@ 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-*`.
\*\*\*\*\* Grok Build is xAI's `grok` CLI. It has no separate command subsystem: the loader that discovers `.grok/skills/<name>/SKILL.md` also scans `.grok/commands/` and registers each Markdown file there as a slash command. That scan is flat — a nested `commands/opsx/<id>.md` is skipped rather than namespaced — so OpenSpec writes `.grok/commands/opsx-<id>.md`, and the filename is the command: `/opsx-propose`. Because that directory is shared with your own command files, avoid naming one of yours `opsx-<workflow>.md`: OpenSpec owns those names and will overwrite or remove them. Under skills-only delivery no command files are written, and the skills are invoked by name instead, as `/openspec-propose`.
Grok also scans `.agents/`, `.claude/`, and `.cursor/` for skills and commands, so a project configured for Grok alongside one of those tools can offer Grok the same workflow from two directories. The copies differ only in the frontmatter each tool needs, so either one runs the same workflow.
[Skills](https://docs.x.ai/build/features/skills-plugins-marketplaces) are documented upstream. The `commands/` directory is read by the shipping CLI but is not yet in the published docs, so a pinned test guards its shape.
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.
@@ -219,7 +226,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`, `codeassistant`, `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`, `grok`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `codeassistant`, `qoder`, `qwen`, `rovodev`, `roocode`, `trae`, `zed`, `zcode`, `agents`
## Workflow-Dependent Installation
@@ -0,0 +1,60 @@
/**
* Grok Command Adapter
*
* Formats commands for xAI's Grok Build CLI (`grok`).
*
* Grok has no separate "commands" subsystem: its skill loader also scans
* `<vendor-dir>/commands/` and turns each Markdown file there into a slash
* command. Two properties of that loader shape this adapter:
*
* - The scan is flat. `find_command_paths` reads `commands/` with a single
* `read_dir` and keeps only files, so a nested `commands/opsx/<id>.md`
* is silently ignored rather than namespaced. Commands must be written
* directly in `commands/`, which makes the filename the command name —
* the `flat` invocation style, `/opsx-<id>`.
* - The name comes from frontmatter `name` when present and from the file
* stem otherwise, and either is normalized to `[a-z0-9-]`. `CommandContent`
* carries a display name ("OPSX: New"), not a command id, so emitting it
* would hand Grok a name to normalize instead of the stem OpenSpec
* controls. This adapter emits `description` only and lets the stem name
* the command, which keeps the generated name identical to the one
* advertised in skills, docs, and the getting-started hint.
*
* Grok also scans `.agents/`, `.claude/`, and `.cursor/` for skills and
* commands. Nothing writes `.agents/commands/`, and Claude Code nests its
* commands under `opsx/`, which the flat scan skips — but Cursor writes them
* flat as `opsx-<id>.md`, and every one of those roots can hold an identical
* `skills/openspec-<name>/SKILL.md`. So a project configured for Grok alongside
* Cursor or Claude Code can present Grok with the same name from two roots.
* The copies differ only in the frontmatter each tool needs, so whichever one
* Grok resolves to runs the same workflow. OpenSpec writes `.grok/`
* unconditionally rather than trying to predict that resolution: the user may
* drop the other tool at any time, and a missing `.grok/` tree would then
* leave Grok with nothing.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue } from '../yaml.js';
/**
* Grok adapter for command generation.
* File path: .grok/commands/opsx-<id>.md
* Frontmatter: description
*/
export const grokAdapter: ToolCommandAdapter = {
toolId: 'grok',
getFilePath(commandId: string): string {
return path.join('.grok', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
description: ${escapeYamlValue(content.description)}
---
${content.body}
`;
},
};
@@ -20,6 +20,7 @@ export { devinAdapter } from './devin.js';
export { factoryAdapter } from './factory.js';
export { geminiAdapter } from './gemini.js';
export { githubCopilotAdapter } from './github-copilot.js';
export { grokAdapter } from './grok.js';
export { iflowAdapter } from './iflow.js';
export { junieAdapter } from './junie.js';
export { kilocodeAdapter } from './kilocode.js';
+2
View File
@@ -22,6 +22,7 @@ import { cursorAdapter } from './adapters/cursor.js';
import { factoryAdapter } from './adapters/factory.js';
import { geminiAdapter } from './adapters/gemini.js';
import { githubCopilotAdapter } from './adapters/github-copilot.js';
import { grokAdapter } from './adapters/grok.js';
import { iflowAdapter } from './adapters/iflow.js';
import { junieAdapter } from './adapters/junie.js';
import { kilocodeAdapter } from './adapters/kilocode.js';
@@ -61,6 +62,7 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(factoryAdapter);
CommandAdapterRegistry.register(geminiAdapter);
CommandAdapterRegistry.register(githubCopilotAdapter);
CommandAdapterRegistry.register(grokAdapter);
CommandAdapterRegistry.register(iflowAdapter);
CommandAdapterRegistry.register(junieAdapter);
CommandAdapterRegistry.register(kilocodeAdapter);
+1
View File
@@ -62,6 +62,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'], requiresIdeRestart: true },
{ name: 'Grok Build', value: 'grok', available: true, successLabel: 'Grok Build', skillsDir: '.grok' },
{ name: 'Hermes Agent', value: 'hermes', available: true, successLabel: 'Hermes Agent', skillsDir: '.hermes', detectionPaths: ['.hermes', 'HERMES.md', '.hermes.md'], setupNote: "Hermes only loads skills from ~/.hermes/skills by default. Add this project's .hermes/skills directory to skills.external_dirs in ~/.hermes/config.yaml so Hermes picks up the generated OpenSpec skills." },
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie', requiresIdeRestart: true },
+19
View File
@@ -533,6 +533,25 @@ describe('available-tools', () => {
expect(ohMyPiTool?.skillsDir).toBe('.omp');
});
it('should detect Grok Build when .grok directory exists', async () => {
await fs.mkdir(path.join(testDir, '.grok'), { recursive: true });
const tools = getAvailableTools(testDir);
const toolValues = tools.map((t) => t.value);
expect(toolValues).toContain('grok');
const grokTool = tools.find((t) => t.value === 'grok');
expect(grokTool?.name).toBe('Grok Build');
expect(grokTool?.skillsDir).toBe('.grok');
// `available` gates the workset tool picker. The cli-e2e `--tools` list
// derives its expectation from AI_TOOLS itself, so it cancels out when
// this flag flips and nothing else would notice Grok disappearing.
expect(grokTool?.available).toBe(true);
// Grok is a CLI: it picks up new command and skill files without an
// editor restart, so no restart hint should be offered (#1067).
expect(grokTool?.requiresIdeRestart).toBeUndefined();
});
it('should detect SourceCraft Code Assistant when .codeassistant directory exists', async () => {
await fs.mkdir(path.join(testDir, '.codeassistant'), { recursive: true });
+81 -1
View File
@@ -16,6 +16,7 @@ import { devinAdapter } from '../../../src/core/command-generation/adapters/devi
import { factoryAdapter } from '../../../src/core/command-generation/adapters/factory.js';
import { geminiAdapter } from '../../../src/core/command-generation/adapters/gemini.js';
import { githubCopilotAdapter } from '../../../src/core/command-generation/adapters/github-copilot.js';
import { grokAdapter } from '../../../src/core/command-generation/adapters/grok.js';
import { iflowAdapter } from '../../../src/core/command-generation/adapters/iflow.js';
import { junieAdapter } from '../../../src/core/command-generation/adapters/junie.js';
import { kilocodeAdapter } from '../../../src/core/command-generation/adapters/kilocode.js';
@@ -1136,7 +1137,7 @@ describe('command-generation/adapters', () => {
amazonQAdapter, antigravityAdapter, auggieAdapter, bobAdapter, clineAdapter,
codebuddyAdapter, continueAdapter, costrictAdapter,
crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter,
iflowAdapter, kilocodeAdapter, kiroAdapter, lingmaAdapter, ohMyPiAdapter,
grokAdapter, iflowAdapter, kilocodeAdapter, kiroAdapter, lingmaAdapter, ohMyPiAdapter,
opencodeAdapter, piAdapter, qoderAdapter, qwenAdapter, roocodeAdapter,
traeAdapter, zcodeAdapter
];
@@ -1148,6 +1149,85 @@ describe('command-generation/adapters', () => {
});
});
describe('grokAdapter', () => {
it('should have correct toolId', () => {
expect(grokAdapter.toolId).toBe('grok');
});
it('should generate correct file path', () => {
const filePath = grokAdapter.getFilePath('explore');
expect(filePath).toBe(path.join('.grok', 'commands', 'opsx-explore.md'));
});
it('should generate correct file path for different command IDs', () => {
expect(grokAdapter.getFilePath('new')).toBe(path.join('.grok', 'commands', 'opsx-new.md'));
expect(grokAdapter.getFilePath('bulk-archive')).toBe(
path.join('.grok', 'commands', 'opsx-bulk-archive.md')
);
});
// Grok's command loader reads `commands/` with a single non-recursive
// scan, so a file one directory deeper is skipped rather than namespaced.
// Nesting these under `opsx/` would leave Grok users with no commands at
// all, and nothing else in the suite would notice.
it('writes commands directly in commands/, which Grok scans without recursion', () => {
for (const commandId of ['explore', 'propose', 'bulk-archive']) {
const segments = grokAdapter.getFilePath(commandId).split(path.sep);
expect(segments).toEqual(['.grok', 'commands', `opsx-${commandId}.md`]);
}
});
// The file stem is what Grok registers, so it has to survive Grok's own
// name rules, which its loader applies to commands and skills alike:
// lowercase [a-z0-9-], no leading or trailing hyphen, no `--`, and at most
// 64 characters. Grok normalizes a stem that breaks the character rules,
// so the risk is a silently renamed command rather than a rejected one.
it('names every command with a stem Grok accepts verbatim', () => {
const contents = getCommandContents();
expect(contents.length).toBeGreaterThan(0);
for (const { id } of contents) {
const stem = path.basename(grokAdapter.getFilePath(id), '.md');
expect(stem).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
expect(stem.length).toBeLessThanOrEqual(64);
}
});
it('should format file with correct YAML frontmatter', () => {
const output = grokAdapter.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`);
});
// `CommandContent.name` is a display name ("OPSX: New"). Grok normalizes a
// frontmatter `name` to [a-z0-9-] and prefers it over the stem, so
// emitting one would hand Grok a name OpenSpec does not control while
// docs, skills, and the getting-started hint all advertise the stem.
it('omits frontmatter name so the file stem names the command', () => {
const output = grokAdapter.formatFile({ ...sampleContent, name: 'OPSX: Explore' });
const frontmatter = output.match(/^---\n([\s\S]*?)\n---/);
expect(Object.keys(parseYaml(frontmatter![1]) as object)).toEqual(['description']);
expect(frontmatter![1]).not.toContain('OPSX: Explore');
});
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('grok');
expect(adapter).toBe(grokAdapter);
const generated = generateCommand(content, adapter!);
expect(generated.path).toBe(path.join('.grok', 'commands', 'opsx-explore.md'));
expect(generated.fileContent).toContain(
'Use /opsx-propose, /opsx-update, and /opsx-bulk-archive. Keep /opsx:unknown.'
);
});
});
describe('codeassistantAdapter', () => {
it('should have correct toolId', () => {
expect(codeassistantAdapter.toolId).toBe('codeassistant');
+41
View File
@@ -1540,6 +1540,47 @@ describe('InitCommand', () => {
expect(content).not.toContain('/opsx-');
});
it('should generate Grok skills and flat commands', async () => {
const initCommand = new InitCommand({ tools: 'grok', force: true });
await initCommand.execute(testDir);
const skillFile = path.join(testDir, '.grok', 'skills', 'openspec-propose', 'SKILL.md');
expect(await fileExists(skillFile)).toBe(true);
// Grok scans `.grok/commands/` without recursion, so the command file
// must sit directly in it — a nested `opsx/` directory is not
// namespaced, it is skipped, and the user gets no commands at all.
const cmdFile = path.join(testDir, '.grok', 'commands', 'opsx-propose.md');
expect(await fileExists(cmdFile)).toBe(true);
expect(await fileExists(path.join(testDir, '.grok', 'commands', 'opsx'))).toBe(false);
const content = await fs.readFile(cmdFile, 'utf-8');
expect(content).toMatch(/^---\ndescription: "/);
// The filename names the command, so bodies must advertise the hyphen form.
expect(content).toContain('/opsx-');
expect(content).not.toContain('/opsx:');
});
it('should advertise the hyphen form in Grok skills and the getting-started hint', async () => {
const initCommand = new InitCommand({ tools: 'grok', force: true });
await initCommand.execute(testDir);
const skillContent = await fs.readFile(
path.join(testDir, '.grok', 'skills', 'openspec-propose', 'SKILL.md'),
'utf-8'
);
expect(skillContent).toContain('/opsx-');
expect(skillContent).not.toContain('/opsx:');
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls
.flat()
.map(String);
const startHint = logCalls.find((entry) => entry.includes('Start your first change'));
expect(startHint).toBeTruthy();
expect(startHint).toContain('/opsx-propose');
expect(startHint).not.toContain('/opsx:propose');
});
it('should generate Continue prompt files', async () => {
const initCommand = new InitCommand({ tools: 'continue', force: true });
await initCommand.execute(testDir);
+72
View File
@@ -1261,6 +1261,42 @@ metadata:
expect(skillContent).not.toContain('/opsx-');
});
it.each(['both', 'commands'] as const)(
'should discover and refresh Grok Build commands with delivery=%s',
async (delivery) => {
setMockConfig({ featureFlags: {}, profile: 'core', delivery });
const commandsDir = path.join(testDir, '.grok', 'commands');
await fs.mkdir(commandsDir, { recursive: true });
await fs.writeFile(path.join(commandsDir, 'opsx-apply.md'), 'old command content');
const skillFile = path.join(testDir, '.grok', '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);
// Flat, never nested: Grok's command scan does not recurse.
expect(await FileSystemUtils.fileExists(path.join(commandsDir, 'opsx'))).toBe(false);
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.each(['both', 'commands'] as const)(
'should discover and refresh SourceCraft Code Assistant commands with delivery=%s',
async (delivery) => {
@@ -3563,6 +3599,42 @@ More user content after markers.
expect(updateSkillContent).toContain('/openspec-');
});
it.each(['skills', 'commands'] as const)(
'should switch Grok Build to delivery=%s without deleting custom files',
async (delivery) => {
await new InitCommand({ tools: 'grok', force: true }).execute(testDir);
const toolDir = path.join(testDir, '.grok');
const customCommand = path.join(toolDir, 'commands', 'my-command.md');
const customSkill = path.join(toolDir, 'skills', 'custom-review', 'SKILL.md');
const userConfig = path.join(toolDir, 'config.toml');
await fs.mkdir(path.dirname(customSkill), { recursive: true });
await fs.writeFile(customCommand, 'custom command');
await fs.writeFile(customSkill, 'custom skill');
await fs.writeFile(userConfig, '[permission]\n');
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') {
// No command files are written, so skill bodies must invoke skills by
// name. Grok registers a user-invocable skill as `/<skill-name>`.
const skillContent = await fs.readFile(skillFile, 'utf-8');
expect(skillContent).toContain('/openspec-archive-change');
expect(skillContent).not.toContain('/opsx:');
expect(skillContent).not.toContain('/opsx-');
expect(await FileSystemUtils.fileExists(path.join(toolDir, 'commands', 'opsx-propose.md'))).toBe(false);
}
// Grok's own project config and the user's hand-written command and
// skill are never OpenSpec's to touch.
expect(await fs.readFile(customCommand, 'utf-8')).toBe('custom command');
expect(await fs.readFile(customSkill, 'utf-8')).toBe('custom skill');
expect(await fs.readFile(userConfig, 'utf-8')).toBe('[permission]\n');
}
);
it.each(['skills', 'commands'] as const)(
'should switch SourceCraft Code Assistant to delivery=%s without deleting custom files',
async (delivery) => {
+1 -1
View File
@@ -272,7 +272,7 @@ describe('getTransformerForTool', () => {
it('selects hyphen commands for every flat-invocation tool when commands are generated', () => {
// These tools invoke commands by filename (/opsx-<id>), so skills must
// reference the hyphen form their command files actually answer to.
for (const toolId of ['bob', 'cursor', 'github-copilot', 'oh-my-pi', 'opencode', 'pi', 'qwen'] as const) {
for (const toolId of ['bob', 'cursor', 'github-copilot', 'grok', 'oh-my-pi', 'opencode', 'pi', 'qwen'] as const) {
for (const delivery of ['both', 'commands'] as const) {
const transformer = getTransformerForTool(toolId, delivery, 'adapter-backed', FLAT_SLASH);
expect(transformer?.('/opsx:apply'), `${toolId} ${delivery}`).toBe('/opsx-apply');