From 781c7f9447b4eeb6fdc69fa745ff46f6168f3edf Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 29 Sep 2026 20:24:15 +0000 Subject: [PATCH] feat(warp): add project skills support (#1738) * feat(warp): add project skills support * docs(warp): align integration contract and references * docs(warp): remove changes to frozen legacy docs --------- Co-authored-by: Claude Opus 5 --- .changeset/tidy-warps-listen.md | 5 + docs-lab/reference/supported-tools.md | 8 ++ openspec/specs/ai-tool-paths/spec.md | 13 ++ src/core/command-surface.ts | 2 +- src/core/config.ts | 1 + src/utils/command-references.ts | 2 +- test/core/warp.test.ts | 178 ++++++++++++++++++++++++++ 7 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 .changeset/tidy-warps-listen.md create mode 100644 test/core/warp.test.ts diff --git a/.changeset/tidy-warps-listen.md b/.changeset/tidy-warps-listen.md new file mode 100644 index 00000000..b1aea150 --- /dev/null +++ b/.changeset/tidy-warps-listen.md @@ -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. diff --git a/docs-lab/reference/supported-tools.md b/docs-lab/reference/supported-tools.md index 02e7e06e..8cdd2a63 100644 --- a/docs-lab/reference/supported-tools.md +++ b/docs-lab/reference/supported-tools.md @@ -54,6 +54,7 @@ The id goes to `openspec init --tools ` to skip the picker ([CLI](cli.md)). | Qwen Code | `qwen` | `.qwen/skills/` | `/openspec-apply-change` | `.qwen/commands/` | `/opsx-apply` | | Trae | `trae` | `.trae/skills/` | `/openspec-apply-change` | `.trae/commands/` | `/opsx-apply` | | [Veai](https://veai.ru/docs/veai/download) | `veai` | `.veai/skills/` | `/openspec-apply-change` | none | none | +| Warp | `warp` | `.warp/skills/` | `/openspec-apply-change` | none | none | | ZCode | `zcode` | `.zcode/skills/` | `/openspec-apply-change` | `.zcode/commands/opsx/` | `/opsx:apply` | | Zoo Code | `roocode` | `.roo/skills/` | `/openspec-apply-change` | `.roo/commands/` | `/opsx-apply` | | Other / Universal | `agents` | `.agents/skills/` | `/openspec-apply-change` | none | none | @@ -163,6 +164,13 @@ init prints this reminder after install. - **Safe across projects**: a commands-only delivery leaves the global skills in place, so one project's setting cannot remove skills another project uses. +### Warp + +- **Skills always**: skills go to `.warp/skills/` even when delivery is `commands`, + because Warp has no command files and invokes skills directly. +- **What OpenSpec claims**: only `.warp/skills/`. Warp settings and `WARP.md` are + not created or edited. + ### Other / Universal (shared `.agents` skills) - **When it fits**: any tool that reads the shared `.agents/skills/` folder, diff --git a/openspec/specs/ai-tool-paths/spec.md b/openspec/specs/ai-tool-paths/spec.md index b3f84b20..8c5ad245 100644 --- a/openspec/specs/ai-tool-paths/spec.md +++ b/openspec/specs/ai-tool-paths/spec.md @@ -63,6 +63,19 @@ The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent - **AND** auto-detection SHALL require `.dsh` to be a directory - **AND** OpenSpec SHALL write dsh skills under `/.dsh/skills/` using platform-native path joining +#### Scenario: Warp paths and detection defined + +- **WHEN** looking up the `warp` tool +- **THEN** `skillsDir` SHALL be `.warp` +- **AND** `detectionPaths` SHALL include `.warp` and `WARP.md` + +#### Scenario: Warp invokes skills without command files + +- **WHEN** generating workflows for the `warp` tool with delivery set to `commands` +- **THEN** skills SHALL remain installed in `.warp/skills/` +- **AND** no command adapter or command files SHALL be required +- **AND** each skill SHALL be directly invocable by its `/openspec-*` name + #### Scenario: Tools without skillsDir - **WHEN** a tool has no `skillsDir` defined diff --git a/src/core/command-surface.ts b/src/core/command-surface.ts index 4162e925..25b5ad2c 100644 --- a/src/core/command-surface.ts +++ b/src/core/command-surface.ts @@ -19,7 +19,7 @@ export function resolveCommandSurfaceCapability(toolId: string): CommandSurfaceC return 'adapter-backed'; } - if (toolId === 'codex') { + if (toolId === 'codex' || toolId === 'warp') { return 'skills-invocable'; } diff --git a/src/core/config.ts b/src/core/config.ts index 0933aa9e..77023a90 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -88,6 +88,7 @@ export const AI_TOOLS: AIToolOption[] = [ { 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: 'Veai', value: 'veai', available: true, successLabel: 'Veai', skillsDir: '.veai' }, + { 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. diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index f161678b..a68aee0c 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -171,7 +171,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 diff --git a/test/core/warp.test.ts b/test/core/warp.test.ts new file mode 100644 index 00000000..459a7797 --- /dev/null +++ b/test/core/warp.test.ts @@ -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(), + 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'); + }); +});