mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fe56159c4 | ||
|
|
347c9ee178 | ||
|
|
8d7ac40b3a |
@@ -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`.
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user