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
5 changes: 5 additions & 0 deletions .changeset/tidy-warps-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": minor
---

Add Warp support through project-local skills. Select `warp` during init to install OpenSpec workflows in `.warp/skills`, invoke them with `/openspec-*`, and refresh them with `openspec update`. Skills remain available in every delivery mode.
27 changes: 25 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>` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, Zed Agent, shared `.agents` |
| none — skills only | `/openspec-<skill>` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, Warp, Zed Agent, 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 @@ -100,6 +100,7 @@ to read the hint.
| [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. |
| [Zoo Code](https://github.com/Zoo-Code-Org/Zoo-Code) (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-<id>.md` |
| Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | `.trae/commands/opsx-<id>.md` |
| [Warp](https://docs.warp.dev/agents/capabilities/skills/) (`warp`) | `.warp/skills/openspec-*/SKILL.md` | Not generated (skills-only; use `/openspec-*`) |
| [Zed Agent](https://zed.dev/docs/ai/skills) (`zed`) | `.agents/skills/openspec-*/SKILL.md` | Not generated (skills-only; use `/openspec-*` or `@openspec-*`) |
| ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/opsx/<id>.md` |
| Shared `.agents` skills (`agents`) | `.agents/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
Expand All @@ -116,6 +117,28 @@ repo-local `.minimax` or `.mavis` directories. Commands-only delivery leaves
existing global MiniMax Code skills untouched so one project's delivery setting
cannot remove skills used by another project.

### Warp

In your terminal, from the project root:

```bash
openspec init --tools warp
```

OpenSpec installs your selected workflows in `.warp/skills/`. Skills are installed
even when delivery is `commands`, because Warp invokes them directly.
No command files, Warp settings, or `WARP.md` rules are created or edited.

In Warp's agent chat:

```text
/openspec-propose add-dark-mode
```

Run `openspec update` from the project to refresh installed skills. For a shared
skill tree used by several assistants, choose the [`agents` target](#when-to-pick-the-shared-agents-target)
instead. Warp reads that directory too.

### GitHub Copilot cloud coding agent

GitHub's [Copilot coding agent](https://docs.github.com/en/copilot/using-github-copilot/coding-agent) runs on GitHub in a GitHub Actions environment — separate from Copilot in your editor. OpenSpec can set it up to use the OpenSpec CLI by generating two files:
Expand Down Expand Up @@ -214,7 +237,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`, `trae`, `warp`, `zed`, `zcode`, `agents`

## Workflow-Dependent Installation

Expand Down
2 changes: 1 addition & 1 deletion src/core/command-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function resolveCommandSurfaceCapability(toolId: string): CommandSurfaceC
return 'adapter-backed';
}

if (toolId === 'codex') {
if (toolId === 'codex' || toolId === 'warp') {
return 'skills-invocable';
}

Expand Down
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
{ name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo', requiresIdeRestart: true },
{ name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae', requiresIdeRestart: true },
{ name: 'Warp', value: 'warp', available: true, successLabel: 'Warp', skillsDir: '.warp', detectionPaths: ['.warp', 'WARP.md'] },
{ name: 'Zed Agent', value: 'zed', available: true, successLabel: 'Zed Agent', skillsDir: '.agents', detectionPaths: ['.zed', '.agents/skills'] },
{ name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' },
// Vendor-neutral target for assistants that read the shared `.agents` root.
Expand Down
2 changes: 1 addition & 1 deletion src/utils/command-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export function getSkillReferenceTransformer(toolId: string): (text: string) =>
* commands — because delivery is skills-only, because the tool has no command
* surface at all (capability 'none', e.g. Kimi Code or Mistral Vibe), or
* because the tool invokes skills directly and OpenSpec generates no command
* files for it (capability 'skills-invocable', i.e. Codex) — so those skills
* files for it (capability 'skills-invocable', e.g. Codex or Warp) — so those skills
* never point at commands that were not generated.
*
* When commands are generated, the spelling follows the tool's invocation: a
Expand Down
178 changes: 178 additions & 0 deletions test/core/warp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { parse } from 'yaml';
import { InitCommand } from '../../src/core/init.js';
import { UpdateCommand } from '../../src/core/update.js';
import { AI_TOOLS } from '../../src/core/config.js';
import { getAvailableTools } from '../../src/core/available-tools.js';
import { resolveCommandSurfaceCapability } from '../../src/core/command-surface.js';
import { CommandAdapterRegistry } from '../../src/core/command-generation/registry.js';
import type { GlobalConfig } from '../../src/core/global-config.js';
import { LEGACY_GLOBAL_SLASH_COMMAND_PATHS } from '../../src/core/legacy-cleanup.js';
import { ALL_WORKFLOWS, CORE_WORKFLOWS } from '../../src/core/profiles.js';
import { getSkillTemplates } from '../../src/core/shared/skill-generation.js';
import { getConfiguredTools, getToolSkillStatus } from '../../src/core/shared/tool-detection.js';

const mockState = { config: {} as GlobalConfig };

vi.mock('../../src/core/global-config.js', async (importOriginal) => ({
...await importOriginal<typeof import('../../src/core/global-config.js')>(),
getGlobalConfig: () => ({ ...mockState.config }),
saveGlobalConfig: vi.fn(),
}));

describe('Warp integration', () => {
let tempDir: string;
let projectPath: string;

const skillPath = (name: string) => path.join(projectPath, '.warp', 'skills', name, 'SKILL.md');
const installedSkills = async () =>
(await fs.readdir(path.join(projectPath, '.warp', 'skills'))).sort();

async function writeProjectFile(relativePath: string, content: string) {
const filePath = path.join(projectPath, relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content);
}

beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-warp-'));
projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath);
mockState.config = { profile: 'core', delivery: 'both' };
vi.stubEnv('XDG_CONFIG_HOME', path.join(tempDir, 'config'));
vi.stubEnv('XDG_DATA_HOME', path.join(tempDir, 'data'));
vi.stubEnv('USERPROFILE', path.join(tempDir, 'home'));
vi.spyOn(os, 'homedir').mockReturnValue(path.join(tempDir, 'home'));
vi.spyOn(LEGACY_GLOBAL_SLASH_COMMAND_PATHS.codex, 'resolvePromptDir')
.mockReturnValue(path.join(tempDir, 'codex-prompts'));
vi.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(async () => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
await fs.rm(tempDir, { recursive: true, force: true });
});

it('registers Warp as a skill-invocable tool without a command adapter', () => {
expect(AI_TOOLS.find((tool) => tool.value === 'warp')).toMatchObject({
name: 'Warp', skillsDir: '.warp', available: true,
});
expect(resolveCommandSurfaceCapability('warp')).toBe('skills-invocable');
expect(CommandAdapterRegistry.get('warp')).toBeUndefined();
});

it.each(['both', 'skills', 'commands'] as const)(
'initializes core skills with %s delivery and advertises invocable skill names',
async (delivery) => {
mockState.config.delivery = delivery;
await new InitCommand({ tools: 'warp', force: true }).execute(projectPath);

expect(await installedSkills()).toEqual(
getSkillTemplates(CORE_WORKFLOWS).map(({ dirName }) => dirName).sort()
);
for (const { dirName } of getSkillTemplates(CORE_WORKFLOWS)) {
const content = await fs.readFile(skillPath(dirName), 'utf8');
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
expect(frontmatter).not.toBeNull();
expect(parse(frontmatter![1])).toMatchObject({ name: dirName, description: expect.any(String) });
expect(content).not.toMatch(/\/opsx[:\-]|\$openspec-/);
}
expect(await fs.readFile(skillPath('openspec-propose'), 'utf8')).toContain('/openspec-apply-change');
expect(await fs.readdir(path.join(projectPath, '.warp'))).toEqual(['skills']);
expect((await fs.readdir(projectPath)).sort()).toEqual(['.warp', 'openspec']);
expect(getConfiguredTools(projectPath)).toEqual(['warp']);
expect(getToolSkillStatus(projectPath, 'warp').configured).toBe(true);
const output = vi.mocked(console.log).mock.calls.flat().join('\n');
expect(output).toContain('/openspec-propose');
expect(output).not.toContain('/opsx:');
}
);

it('installs the full workflow set and rewrites onboarding references', async () => {
mockState.config = { profile: 'custom', workflows: [...ALL_WORKFLOWS], delivery: 'both' };
await new InitCommand({ tools: 'warp', force: true }).execute(projectPath);

expect(await installedSkills()).toEqual(getSkillTemplates().map(({ dirName }) => dirName).sort());
const onboarding = await fs.readFile(skillPath('openspec-onboard'), 'utf8');
expect(onboarding).toContain('/openspec-new-change');
expect(onboarding).toContain('/openspec-apply-change');
expect(onboarding).not.toMatch(/\/opsx[:\-]|\$openspec-/);
});

it('refreshes and prunes skills for a custom commands-only profile without touching other files', async () => {
mockState.config = { profile: 'custom', workflows: [...ALL_WORKFLOWS], delivery: 'both' };
const preserved = {
'WARP.md': '# My Warp instructions\n',
'AGENTS.md': '# Shared instructions\n',
'.warp/settings.json': '{"theme":"dark"}\n',
'.warp/skills/my-skill/SKILL.md': '# My skill\n',
'.claude/settings.json': '{"permissions":{}}\n',
'.agents/skills/my-skill/SKILL.md': '# Shared custom skill\n',
};
for (const [filePath, content] of Object.entries(preserved)) {
await writeProjectFile(filePath, content);
}
await new InitCommand({ tools: 'warp', force: true }).execute(projectPath);
for (const [filePath, content] of Object.entries(preserved)) {
expect(await fs.readFile(path.join(projectPath, filePath), 'utf8')).toBe(content);
}
await fs.writeFile(skillPath('openspec-apply-change'), '# stale content\n');
mockState.config = { profile: 'custom', workflows: ['apply'], delivery: 'commands' };

await new UpdateCommand({ force: true }).execute(projectPath);

expect(await installedSkills()).toEqual(['my-skill', 'openspec-apply-change']);
expect(await fs.readFile(skillPath('openspec-apply-change'), 'utf8')).toContain('name: openspec-apply-change');
expect((await fs.readdir(path.join(projectPath, '.warp'))).sort()).toEqual(['settings.json', 'skills']);
for (const [filePath, content] of Object.entries(preserved)) {
expect(await fs.readFile(path.join(projectPath, filePath), 'utf8')).toBe(content);
}
expect(await fs.readdir(path.join(projectPath, '.claude'))).toEqual(['settings.json']);
expect(await fs.readdir(path.join(projectPath, '.agents', 'skills'))).toEqual(['my-skill']);
expect(getConfiguredTools(projectPath)).toContain('warp');
});

it('refreshes outdated skills without force and stays current after commands-only reinitialization', async () => {
await new InitCommand({ tools: 'warp', force: true }).execute(projectPath);
const original = await fs.readFile(skillPath('openspec-explore'), 'utf8');
const outdated = original.replace(/generatedBy: "[^"]+"/, 'generatedBy: "0.0.1"');
expect(outdated).not.toBe(original);
await fs.writeFile(skillPath('openspec-explore'), outdated);

await new UpdateCommand().execute(projectPath);
expect(await fs.readFile(skillPath('openspec-explore'), 'utf8')).toBe(original);

mockState.config.delivery = 'commands';
await new InitCommand({ tools: 'warp', force: true }).execute(projectPath);
expect(await installedSkills()).toEqual(
getSkillTemplates(CORE_WORKFLOWS).map(({ dirName }) => dirName).sort()
);
vi.mocked(console.log).mockClear();

await new UpdateCommand().execute(projectPath);

expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain('up to date');
expect(await fs.readFile(skillPath('openspec-explore'), 'utf8')).toBe(original);
});

it.each(['.warp', 'WARP.md'])('detects %s as Warp without treating it as configured', async (signal) => {
if (signal === '.warp') await fs.mkdir(path.join(projectPath, signal));
else await writeProjectFile(signal, '# Warp rules\n');

expect(getAvailableTools(projectPath).map((tool) => tool.value)).toContain('warp');
expect(getConfiguredTools(projectPath)).not.toContain('warp');
});

it('does not infer Warp from shared or other tool instructions', async () => {
await writeProjectFile('AGENTS.md', '# Shared rules\n');
await writeProjectFile('.agents/skills/openspec-propose/SKILL.md', '# Use /openspec-propose\n');
await writeProjectFile('.claude/settings.json', '{}\n');

expect(getAvailableTools(projectPath).map((tool) => tool.value)).not.toContain('warp');
expect(getConfiguredTools(projectPath)).not.toContain('warp');
});
});
Loading