diff --git a/.changeset/devin-desktop-rename.md b/.changeset/devin-desktop-rename.md new file mode 100644 index 0000000000..f51bcf124b --- /dev/null +++ b/.changeset/devin-desktop-rename.md @@ -0,0 +1,9 @@ +--- +'@fission-ai/openspec': patch +--- + +**Windsurf is now Devin Desktop.** Windsurf was rebranded on June 2, 2026 and its config directory moved: `.devin/` is the preferred read + write location, `.windsurf/` a legacy read-only fallback that the Devin Local agent does not read at all. OpenSpec follows the rename rather than carrying two ids for one product — the tool id is `devin`, writing `.devin/workflows/opsx-.md` and `.devin/skills/openspec-*/SKILL.md`, and it is detected from either directory. + +- `--tools windsurf` still resolves, so existing setup scripts keep working; it now configures `.devin/`. +- If your OpenSpec files are still in `.windsurf/`, `openspec update` explains the rebrand and offers to move them. `--force` and non-interactive runs take the move; declining leaves every file exactly where it is. Only the files OpenSpec generates move — each skill's `SKILL.md` and commands named `opsx-*`. A hand-written Cascade workflow, a reference file you keep beside a `SKILL.md`, a command file you edited, and `.devin/rules/` all stay exactly where they are. +- Devin skills and the getting-started hint reference `/openspec-*` skills rather than `/opsx-*` workflows, because only Devin Desktop reads workflows; the `/openspec-*` form works on both agents. Workflow bodies still use `/opsx-`, the name Devin registers for a workflow file. diff --git a/docs/cli.md b/docs/cli.md index 9eeec8e29d..04cb514d2d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -107,7 +107,7 @@ openspec init [path] [options] 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`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`, `zcode` +**Supported tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zcode` > This list mirrors `AI_TOOLS` in `src/core/config.ts`. See [Supported Tools](supported-tools.md) for each tool's skill and command paths. diff --git a/docs/commands.md b/docs/commands.md index 6a484dc8d7..c6fa8841a9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,6 +1,6 @@ # Commands -This is the reference for OpenSpec's slash commands. These commands are invoked in your AI coding assistant's chat interface (e.g., Claude Code, Cursor, Windsurf). +This is the reference for OpenSpec's slash commands. These commands are invoked in your AI coding assistant's chat interface (e.g., Claude Code, Cursor, Devin Desktop). For workflow patterns and when to use each command, see [Workflows](workflows.md). For CLI commands, see [CLI](cli.md). @@ -672,11 +672,16 @@ Different AI tools use slightly different command syntax. Use the format that ma | Your tool's command file | Syntax example | Example tools | |--------------------------|----------------|---------------| | `.../commands/opsx/.*` | `/opsx:propose`, `/opsx:apply` | Claude Code, Gemini CLI, Crush | -| `.../opsx-.*` | `/opsx-propose`, `/opsx-apply` | Cursor, Windsurf, Copilot (IDE), Trae, Oh My Pi | +| `.../opsx-.*` | `/opsx-propose`, `/opsx-apply` | Cursor, Devin Desktop, Copilot (IDE), Trae, Oh My Pi | | none — skills only | `/openspec-propose`, `/openspec-apply-change` | CodeArts, ForgeCode, Hermes, Mistral Vibe | | none — Kimi Code | `/skill:openspec-propose` | Kimi Code | | none — Codex CLI | `$openspec-propose` | Codex | +> **Devin Desktop vs Devin Local:** the `.devin/workflows/opsx-*.md` files give +> Devin Desktop `/opsx-propose`. Devin Local has no workflows — use the skills +> OpenSpec writes to `.devin/skills/`, e.g. `/openspec-propose`, which work on +> both agents. + The intent is the same across tools, but how commands are surfaced can differ by integration. [How To Invoke](supported-tools.md#how-to-invoke) lists every supported tool; this table shows only examples of each shape. > **Note:** GitHub Copilot commands (`.github/prompts/*.prompt.md`) are only available in IDE extensions (VS Code, JetBrains, Visual Studio). GitHub Copilot CLI does not currently support custom prompt files — see [Supported Tools](supported-tools.md) for details and workarounds. diff --git a/docs/faq.md b/docs/faq.md index d5081d97f7..9afd9afcf7 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -22,7 +22,7 @@ Existing codebases are the main event. OpenSpec is brownfield-first: you do not ### Is it tied to one AI tool? -No. OpenSpec works with 30+ assistants, including Claude Code, Cursor, Windsurf, GitHub Copilot, Gemini CLI, Codex, and more. The full list and per-tool details are in [Supported Tools](supported-tools.md). +No. OpenSpec works with 30+ assistants, including Claude Code, Cursor, Devin Desktop, GitHub Copilot, Gemini CLI, Codex, and more. The full list and per-tool details are in [Supported Tools](supported-tools.md). ## Running commands diff --git a/docs/how-commands-work.md b/docs/how-commands-work.md index 69efb7aceb..887dfacb5e 100644 --- a/docs/how-commands-work.md +++ b/docs/how-commands-work.md @@ -21,7 +21,7 @@ openspec list # see active changes openspec view # open the interactive dashboard ``` -**The slash commands (chat half).** Short commands like `/opsx:propose` and `/opsx:apply` that you type into your AI assistant. These tell the AI to follow the OpenSpec workflow: draft a proposal, write specs, build from the task list, archive when done. You type these into Claude Code, Cursor, Windsurf, Copilot, or whichever assistant you use. +**The slash commands (chat half).** Short commands like `/opsx:propose` and `/opsx:apply` that you type into your AI assistant. These tell the AI to follow the OpenSpec workflow: draft a proposal, write specs, build from the task list, archive when done. You type these into Claude Code, Cursor, Devin Desktop, Copilot, or whichever assistant you use. ```text /opsx:propose add-dark-mode (typed in your AI chat) @@ -51,7 +51,7 @@ You don't enter a special OpenSpec mode. You just open your AI coding assistant So the real instructions are: -1. Open your AI coding assistant (Claude Code, Cursor, Windsurf, and so on) in your project. +1. Open your AI coding assistant (Claude Code, Cursor, Devin Desktop, and so on) in your project. 2. Type `/opsx:propose` in its chat, the same place you type any other request. 3. Watch the autocomplete: if OpenSpec is installed, you'll see `/opsx:propose`, `/opsx:apply`, and friends appear as you type the slash. @@ -65,7 +65,7 @@ It's worth understanding, because it explains why OpenSpec works with 30+ differ The CLI is the **engine**. It knows the rules: what a change folder looks like, which artifacts depend on which, how to merge a delta spec into your source of truth. It's the same everywhere. -The slash commands are the **steering wheel**, and every AI tool has a slightly different one. Claude Code calls them commands. Cursor and Windsurf have their own formats. Some tools call them skills. When you run `openspec init`, OpenSpec generates the right kind of file for each tool you selected, so the same `/opsx:propose` intent works no matter which assistant you prefer. +The slash commands are the **steering wheel**, and every AI tool has a slightly different one. Claude Code calls them commands. Cursor and Devin Desktop have their own formats. Some tools call them skills. When you run `openspec init`, OpenSpec generates the right kind of file for each tool you selected, so the same `/opsx:propose` intent works no matter which assistant you prefer. The strength of this design: you learn the workflow once and carry it across tools. The tradeoff: the exact syntax of a command can differ slightly between tools, which is the next section. @@ -76,12 +76,19 @@ 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/.*` | `/opsx:propose` | Claude Code, Gemini CLI, Crush | -| `.../opsx-.*` | `/opsx-propose` | Cursor, GitHub Copilot (IDE), Windsurf, Trae, Oh My Pi | +| `.../opsx-.*` | `/opsx-propose` | Cursor, GitHub Copilot (IDE), Devin Desktop, Trae, Oh My Pi | | `.amazonq/prompts/opsx-.md` | `@opsx-propose` | Amazon Q Developer | | none — skills only | `/openspec-propose` | CodeArts, ForgeCode, Hermes, Mistral Vibe | | none — Kimi Code | `/skill:openspec-propose` | Kimi Code | | none — Codex CLI | `$openspec-propose` | Codex | +Devin is the one tool that spans two rows. Devin Desktop reads +`.devin/workflows/`, so `/opsx-propose` works there; [Devin Local does +not](https://docs.devin.ai/desktop/devin-local), so on that agent use the +`/openspec-propose` skill instead. The skills OpenSpec writes to +`.devin/skills/` work on both, which is why they reference each other by skill +name. + Every tool is listed in [How To Invoke](supported-tools.md#how-to-invoke) — that table is the authoritative one. Two rows are not slash commands at all: Amazon Q loads its files into a prompt library invoked with `@`, and the last three rows diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 477aa5c7af..57afdbb4bb 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -43,7 +43,7 @@ Only OpenSpec-managed files that are being replaced: - Claude Code: `.claude/commands/openspec/` - Cursor: `.cursor/commands/openspec-*.md` -- Windsurf: `.windsurf/workflows/openspec-*.md` +- Devin Desktop, formerly Windsurf: `.windsurf/workflows/openspec-*.md` - Cline: `.clinerules/workflows/openspec-*.md` - Roo: `.roo/commands/openspec-*.md` - GitHub Copilot: `.github/prompts/openspec-*.prompt.md` (IDE extensions only; not supported in Copilot CLI) diff --git a/docs/opsx.md b/docs/opsx.md index 57cb77bf74..123eb68fc9 100644 --- a/docs/opsx.md +++ b/docs/opsx.md @@ -419,7 +419,7 @@ Examples in this section use the expanded command set (`new`, `continue`, etc.); │ ▼ │ │ Skill Files (.claude/skills/openspec-*/SKILL.md) │ │ │ -│ • Cross-editor compatible (Claude Code, Cursor, Windsurf) │ +│ • Cross-editor compatible (Claude Code, Cursor, Devin) │ │ • Skills query CLI for structured data │ │ • Fully customizable via schema files │ │ │ diff --git a/docs/supported-tools.md b/docs/supported-tools.md index d024d6bc40..71d0f1d947 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -30,7 +30,8 @@ way it loads the file OpenSpec wrote. Find your tool's command path in the | Command file OpenSpec writes | You type | Tools | |------------------------------|----------|-------| | `.../commands/opsx/.*` — an `opsx/` folder namespaces it | `/opsx:` | Claude Code, CodeBuddy, Crush, Gemini CLI, Lingma, Qoder, ZCode | -| `.../opsx-.*` — the filename is the command | `/opsx-` | Every other tool with generated command files, except Amazon Q | +| `.../opsx-.*` — the filename is the command | `/opsx-` | Every other tool with generated command files, except Amazon Q and Devin | +| `.devin/workflows/opsx-.md` — read by only one of Devin's two agents | `/opsx-` on Devin Desktop, `/openspec-` on Devin Local | Devin Desktop\*\*\*\* | | `.amazonq/prompts/opsx-.md` — a prompt, not a command | `@opsx-` | Amazon Q Developer | | none — skills only | `/openspec-` | CodeArts, ForgeCode, Hermes, Mistral Vibe | | none — Kimi Code | `/skill:openspec-` | Kimi Code | @@ -72,6 +73,7 @@ to read the hint. | CodeArts (`codeartsagent`) | `.codeartsdoer/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/.md` | | Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | Not generated (skills-only; use `.codex/skills/openspec-*`) | +| Devin Desktop, formerly Windsurf (`devin`) | `.devin/skills/openspec-*/SKILL.md` | `.devin/workflows/opsx-.md`\*\*\*\* | | ForgeCode (`forgecode`) | `.forge/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | Continue (`continue`) | `.continue/skills/openspec-*/SKILL.md` | `.continue/prompts/opsx-.prompt` | | CoStrict (`costrict`) | `.cospec/skills/openspec-*/SKILL.md` | `.cospec/openspec/commands/opsx-.md` | @@ -95,13 +97,14 @@ to read the hint. | Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-.md` | | [Zoo Code](https://github.com/Zoo-Code-Org/Zoo-Code) (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-.md` | | Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | `.trae/commands/opsx-.md` | -| Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-.md` | | ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/opsx/.md` | \*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. \*\*\* Hermes loads skills from `~/.hermes/skills/` by default. To use project-local OpenSpec skills, add the project `.hermes/skills/` directory to `skills.external_dirs` in `~/.hermes/config.yaml`; Hermes then exposes skills with user-facing slash invocations such as `/openspec-propose`. +\*\*\*\* 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-*`. + ## Non-Interactive Setup For CI/CD or scripted setup, use `--tools` (and optionally `--profile`): @@ -120,7 +123,7 @@ openspec init --tools none openspec init --profile core ``` -**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`, `zcode` +**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zcode` ## Workflow-Dependent Installation diff --git a/openspec/changes/add-devin-desktop-support/.openspec.yaml b/openspec/changes/add-devin-desktop-support/.openspec.yaml new file mode 100644 index 0000000000..f617bd1867 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-04 diff --git a/openspec/changes/add-devin-desktop-support/proposal.md b/openspec/changes/add-devin-desktop-support/proposal.md new file mode 100644 index 0000000000..e94310af23 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/proposal.md @@ -0,0 +1,32 @@ +## Why + +- Windsurf has been [rebranded to **Devin Desktop**](https://docs.devin.ai/desktop/devin-desktop-faq) as of June 2, 2026. Same IDE, same editor, new brand. +- The rebrand moved the config directory: `.devin/` is now the preferred read + write location and `.windsurf/` the legacy read-only fallback, for `rules/`, `workflows/`, `skills/`, and `plans/`. OpenSpec writes only `.windsurf/`, so every Devin install lands in the deprecated path. +- Devin ships two agents. Devin Desktop (Cascade) reads workflows; the [Devin Local agent does not](https://docs.devin.ai/desktop/devin-local) — its docs say to migrate workflows to skills, and it does not read `.windsurf/` at all. An existing Windsurf user's OpenSpec files are therefore invisible to Devin Local entirely. +- Adding `devin` as a *second* tool id alongside `windsurf` would list one product twice in the picker and leave existing users with two parallel installs. This follows the rename instead, matching what OpenSpec already did for Kimi CLI → Kimi Code. + +## What Changes + +- **Rename the tool, don't duplicate it.** `windsurf` is retired as a tool id; `devin` (Devin Desktop) takes its place with `skillsDir: '.devin'` and `detectionPaths: ['.devin', '.windsurf']`. The Windsurf adapter is replaced by a Devin adapter writing `.devin/workflows/opsx-.md`. +- **Keep `--tools windsurf` working.** A `TOOL_ID_ALIASES` map resolves retired ids, so existing setup scripts and CI keep running; they now configure `.devin/`. +- **Migrate existing installs, with consent.** OpenSpec-managed skills (`openspec-*`) and command files (`opsx-*`) under `.windsurf/` move to `.devin/`. `openspec update` explains the rebrand and asks first; `--force` and non-interactive runs take the move. Selecting the tool during `openspec init` is itself consent. Files the user wrote are never touched. +- Route Devin's **skill** bodies and the getting-started hint through the skill-reference transformer so they say `/openspec-*`, the one invocation both Devin agents accept. +- Update the tool reference, invocation, and command-syntax tables in `docs/`, plus the website tool list. + +## Impact + +- **Specs:** `ai-tool-paths`, `cli-init`, `cli-update`, `command-generation` +- **Code:** + - `src/core/command-generation/adapters/devin.ts` (new; `windsurf.ts` deleted) + - `src/core/command-generation/registry.ts`, `adapters/index.ts`, `index.ts` + - `src/core/config.ts` (`AI_TOOLS` row, `TOOL_ID_ALIASES`, `resolveToolIdAlias`) + - `src/core/migration.ts` (`LEGACY_TOOL_ROOTS`, consent-aware migration of skills *and* command files) + - `src/core/init.ts`, `src/core/update.ts` (alias resolution, migration prompt) + - `src/core/legacy-cleanup.ts` (pre-opsx `.windsurf/` files now key to `devin`) + - `src/utils/command-references.ts` (Devin's skill-reference transformer) +- **Docs:** `supported-tools.md`, `cli.md`, `commands.md`, `how-commands-work.md`, `faq.md`, `migration-guide.md`, `opsx.md`, website home page + +## Notes + +- **Who could be affected:** a user still on a pre-rebrand Windsurf build reads only `.windsurf/`. That is why the move is offered rather than taken — declining leaves every file where it is. Declining does mean `.windsurf/` stops being refreshed, which the prompt says plainly. +- The `.devin/` directory also covers `rules/` and `plans/`. OpenSpec writes neither, so they are out of scope and untouched. diff --git a/openspec/changes/add-devin-desktop-support/specs/ai-tool-paths/spec.md b/openspec/changes/add-devin-desktop-support/specs/ai-tool-paths/spec.md new file mode 100644 index 0000000000..9829f48533 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/specs/ai-tool-paths/spec.md @@ -0,0 +1,137 @@ +# ai-tool-paths Delta Specification + +## ADDED Requirements + +### Requirement: Migrating OpenSpec content out of a renamed tool's former directory + +When a tool's directory is renamed, OpenSpec-managed content left in the former +location SHALL be moved to the current one. Content the user wrote SHALL never +be moved or deleted. + +Some renames are safe to apply silently and some are not, so each former root +declares whether leaving it needs the user's consent. Kimi CLI is gone, so +`.kimi` can be vacated without asking. Windsurf's `.windsurf` cannot: a +pre-rebrand Windsurf build reads only that directory, and nothing on disk +distinguishes that user from one who took the rebrand. + +#### Scenario: Moving a former directory that needs no consent + +- **WHEN** `openspec init` or `openspec update` runs and OpenSpec-managed content is found under a former root marked as needing no consent, such as `.kimi` +- **THEN** move it to the tool's current directory without prompting +- **AND** report what moved + +#### Scenario: Offering a move that needs consent + +- **GIVEN** OpenSpec skills or command files under `.windsurf/` +- **WHEN** `openspec update` runs interactively without `--force` +- **THEN** explain that Windsurf is now Devin Desktop, that `.devin/` is the current directory, and that Devin Local does not read `.windsurf/` at all +- **AND** ask before moving anything +- **AND** on decline, leave every file untouched and state that `.windsurf/` will no longer be refreshed until it is moved + +#### Scenario: Unattended runs take the move + +- **WHEN** `openspec update` runs with `--force`, or non-interactively +- **THEN** perform the move without prompting, reporting what moved + +#### Scenario: Selecting a renamed tool is consent + +- **WHEN** `openspec init` configures a tool that has OpenSpec content under a former root +- **THEN** move that content as part of setup, rather than leaving the user with two installs of one tool + +#### Scenario: Both directories already hold OpenSpec content + +- **GIVEN** the same OpenSpec-managed skill or command exists under both the former and the current root +- **WHEN** the move runs +- **THEN** the copy under the current root SHALL win, rather than being merged or overwritten +- **AND** only the file OpenSpec generated SHALL be removed from the former root — for a skill directory that is `SKILL.md` alone, never the directory and whatever else it holds +- **AND** one rule SHALL govern skills and command files alike: the former copy SHALL be removed only when it is byte-identical to the surviving one +- **AND** a former copy that differs SHALL be left where it is, since the difference may be a customization +- **AND** files left behind for that reason SHALL be reported, so the user knows two copies now exist + +#### Scenario: Every former file differs, so nothing is movable + +- **GIVEN** every OpenSpec-managed file under the former root differs from its counterpart under the current one +- **WHEN** the move runs +- **THEN** report the files left in place, rather than staying silent because nothing moved +- **AND** NOT offer to move anything, since there is nothing movable to consent to +- **AND** NOT report a migration that did not happen + +#### Scenario: One root is a symbolic link to the other + +- **GIVEN** the former and current roots resolve to the same directory, as when a user symlinks one at the other to straddle the rename +- **WHEN** the move runs +- **THEN** recognize that source and destination are the same file and change nothing, rather than deleting the only copy + +#### Scenario: User files survive the move + +- **GIVEN** a former root also holds files the user wrote, such as a hand-written workflow beside the generated ones +- **WHEN** the move runs +- **THEN** move only the files OpenSpec generates — each skill's `SKILL.md` and command files named `opsx-*` +- **AND** delete the former directory only when the move leaves it empty + +#### Scenario: A user file beside a generated skill is not carried into a directory OpenSpec prunes + +- **GIVEN** a former skill directory holds `SKILL.md` alongside a file the user wrote +- **AND** OpenSpec removes whole skill directories it owns, as under commands-only delivery or for a workflow outside the active profile +- **WHEN** the move runs +- **THEN** move `SKILL.md` alone and leave the user's file under the former root +- **AND** never move the enclosing directory, which would hand that file to a later removal + +#### Scenario: The move is idempotent + +- **WHEN** `openspec update` runs again after a completed move +- **THEN** find nothing to migrate and report nothing + +## MODIFIED Requirements + +### Requirement: Path configuration for supported tools + +The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent Skills specification. + +#### Scenario: Claude Code paths defined + +- **WHEN** looking up the `claude` tool +- **THEN** `skillsDir` SHALL be `.claude` + +#### Scenario: Cursor paths defined + +- **WHEN** looking up the `cursor` tool +- **THEN** `skillsDir` SHALL be `.cursor` + +#### Scenario: Windsurf paths defined + +- **GIVEN** RETIRED — Windsurf was rebranded to Devin Desktop and `windsurf` is no longer a tool id +- **WHEN** looking up the `windsurf` tool +- **THEN** no `AI_TOOLS` entry SHALL exist for it +- **AND** the id SHALL resolve to `devin`, whose `skillsDir` is `.devin` and whose `detectionPaths` still include the legacy `.windsurf` + +#### Scenario: Kimi Code paths defined + +- **WHEN** looking up the `kimi` tool +- **THEN** `skillsDir` SHALL be `.kimi-code` +- **AND** OpenSpec-managed skills remaining under the legacy `.kimi/skills` directory SHALL be migrated to `.kimi-code/skills` during init and update, preserving user files + +#### Scenario: Hermes Agent paths defined + +- **WHEN** looking up the `hermes` tool +- **THEN** `skillsDir` SHALL be `.hermes` +- **AND** `setupNote` SHALL explain that project `.hermes/skills` must be added to `skills.external_dirs` in `~/.hermes/config.yaml` +- **AND** `openspec init` and `openspec update` SHALL display the note whenever `hermes` is configured + +#### Scenario: Devin Desktop paths defined + +- **WHEN** looking up the `devin` tool +- **THEN** `skillsDir` SHALL be `.devin` +- **AND** workflow files SHALL be written to `.devin/workflows/opsx-.md` +- **AND** `detectionPaths` SHALL include both `.devin` and the legacy `.windsurf`, so a project set up before the rebrand is still recognized + +#### Scenario: Retired tool ids resolve on the command line + +- **WHEN** a retired brand is named on the command line, such as `--tools windsurf` +- **THEN** it SHALL resolve to the current tool id `devin` rather than erroring as unknown +- **AND** generation SHALL write the current directory `.devin/`, not the retired one + +#### Scenario: Tools without skillsDir + +- **WHEN** a tool has no `skillsDir` defined +- **THEN** skill generation SHALL error with message indicating the tool is not supported diff --git a/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md b/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md new file mode 100644 index 0000000000..2b23f76af0 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md @@ -0,0 +1,72 @@ +# cli-init Delta Specification + +## MODIFIED Requirements + +### Requirement: Skill Generation + +The command SHALL generate Agent Skills for selected AI tools. + +#### Scenario: Generating skills for a tool + +- **WHEN** a tool is selected during initialization +- **THEN** create 9 skill directories under `./skills/`: + - `openspec-explore/SKILL.md` + - `openspec-new-change/SKILL.md` + - `openspec-continue-change/SKILL.md` + - `openspec-apply-change/SKILL.md` + - `openspec-ff-change/SKILL.md` + - `openspec-verify-change/SKILL.md` + - `openspec-sync-specs/SKILL.md` + - `openspec-archive-change/SKILL.md` + - `openspec-bulk-archive-change/SKILL.md` +- **AND** each SKILL.md SHALL contain YAML frontmatter with name and description +- **AND** each SKILL.md SHALL contain the skill instructions + +#### Scenario: Devin skills reference skills rather than workflows + +- **GIVEN** the Devin Local agent does not support workflows and its documentation directs users to skills instead +- **WHEN** generating skills for the `devin` tool +- **THEN** rewrite `/opsx:` references in the skill body to the matching `/openspec-` invocation, which both Devin agents accept +- **AND** the getting-started hint SHALL name `/openspec-propose` rather than a workflow +- **AND** under commands-only delivery, where no Devin skills are written, both the workflow bodies and the hint SHALL fall back to `/opsx-` + +### Requirement: Slash Command Generation + +The command SHALL generate opsx slash commands only for selected tools that have a registered command adapter, while keeping adapterless tools valid for skill generation. + +#### Scenario: Generating slash commands for a tool with a registered adapter + +- **WHEN** a tool with a registered command adapter is selected during initialization +- **THEN** create 9 slash command files using the tool's command adapter: + - `/opsx:explore` + - `/opsx:new` + - `/opsx:continue` + - `/opsx:apply` + - `/opsx:ff` + - `/opsx:verify` + - `/opsx:sync` + - `/opsx:archive` + - `/opsx:bulk-archive` +- **AND** use tool-specific path conventions (e.g., `.claude/commands/opsx/` for Claude) +- **AND** include tool-specific frontmatter format + +#### Scenario: Selected tool has no command adapter + +- **GIVEN** a selected tool has `skillsDir` configured but no registered command adapter +- **WHEN** initialization includes command generation +- **THEN** skill generation for that tool SHALL still remain valid +- **AND** command-file generation SHALL be skipped for that tool +- **AND** the command output SHALL include `Commands skipped for: (no adapter)` + +#### Scenario: Kimi Code skips command-file generation + +- **WHEN** the user selects Kimi Code during initialization +- **THEN** OpenSpec SHALL treat it as a supported tool with `skillsDir: '.kimi-code'` +- **AND** command-file generation SHALL be skipped because no Kimi adapter is registered + +#### Scenario: Generating workflows for Devin Desktop + +- **WHEN** the user selects Devin Desktop during initialization +- **THEN** create one workflow file per profile workflow at `.devin/workflows/opsx-.md` +- **AND** include frontmatter with `name`, `description`, `category`, and `tags` +- **AND** rewrite `/opsx:` references in the body to `/opsx-`, the name Devin registers for a workflow file diff --git a/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md b/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md new file mode 100644 index 0000000000..d8227b28e2 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md @@ -0,0 +1,113 @@ +# cli-update Delta Specification + +## MODIFIED Requirements + +### Requirement: Slash Command Updates + +The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments. + +#### Scenario: Updating slash commands for Antigravity +- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter +- **AND** skip creating any missing workflow files during update, mirroring the behavior for Devin Desktop and other IDEs + +#### Scenario: Updating slash commands for Claude Code +- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for CodeBuddy Code +- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` +- **THEN** refresh each file using the shared CodeBuddy templates that include YAML frontmatter for the `description` and `argument-hint` fields +- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`) +- **AND** preserve any user customizations outside the OpenSpec managed markers + +#### Scenario: Updating slash commands for Cline +- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** include Cline-specific Markdown heading frontmatter +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Continue +- **WHEN** `.continue/prompts/` contains `openspec-proposal.prompt`, `openspec-apply.prompt`, and `openspec-archive.prompt` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Crush +- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md` +- **THEN** refresh each file using shared templates +- **AND** include Crush-specific frontmatter with OpenSpec category and tags +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Cursor +- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Factory Droid +- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields +- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid +- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched +- **AND** skip creating missing files during update + +#### Scenario: Updating slash commands for OpenCode +- **WHEN** `.opencode/commands/` contains OpenSpec-managed `opsx-*.md` command files for the configured profile (for example `opsx-propose.md`, `opsx-apply.md`, and `opsx-archive.md`) +- **THEN** refresh each file using shared templates +- **AND** transform command references to hyphen form (for example `/opsx-propose`), as for every tool whose command files are named `opsx-` +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments + +#### Scenario: Legacy OpenCode command path cleanup +- **WHEN** a project still has command files under the legacy singular path `.opencode/command/` (for example `opsx-*.md` or `openspec-*.md`) +- **THEN** `openspec init` or legacy cleanup SHALL remove those files and generate replacements under `.opencode/commands/` +- **AND** `openspec update` SHALL NOT refresh files that remain only under `.opencode/command/` + +#### Scenario: Updating slash commands for Windsurf +- **WHEN** the legacy Windsurf location `.windsurf/workflows/`, now Devin's, contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** skip creating missing files (the update command only refreshes what already exists) + +#### Scenario: Updating workflows for Devin Desktop +- **WHEN** Devin Desktop is a configured tool (its `.devin/` directory exists) +- **THEN** write `.devin/workflows/opsx-.md` for each workflow in the active profile, from shared templates +- **AND** emit frontmatter with `name`, `description`, `category`, and `tags` +- **AND** transform command references to hyphen form (for example `/opsx-propose`), the name Devin registers for a workflow file +- **AND** refresh `.devin/skills/openspec-*/SKILL.md` with `/openspec-*` skill references, the one invocation both Devin agents accept + +#### Scenario: Updating slash commands for Kilo Code +- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** skip creating missing files (the update command only refreshes what already exists) + +#### Scenario: Updating slash commands for Codex +- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **WHEN** a user runs `openspec update` +- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance) +- **AND** preserve any unmanaged content outside the OpenSpec marker block +- **AND** skip creation when a Codex prompt file is missing + +#### Scenario: Updating slash commands for GitHub Copilot +- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md` +- **THEN** refresh each file using shared templates while preserving the YAML frontmatter +- **AND** update only the OpenSpec-managed block between markers +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Gemini CLI +- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml` +- **THEN** refresh the body of each file using the shared proposal/apply/archive templates +- **AND** replace only the content between `` and `` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact +- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed + +#### Scenario: Updating slash commands for iFlow CLI +- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields +- **AND** update only the OpenSpec-managed block between markers +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Missing slash command file +- **WHEN** a tool lacks a slash command file +- **THEN** do not create a new file during update diff --git a/openspec/changes/add-devin-desktop-support/specs/command-generation/spec.md b/openspec/changes/add-devin-desktop-support/specs/command-generation/spec.md new file mode 100644 index 0000000000..07ba141aba --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/specs/command-generation/spec.md @@ -0,0 +1,45 @@ +# command-generation Delta Specification + +## MODIFIED Requirements + +### Requirement: ToolCommandAdapter interface + +The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting. + +#### Scenario: Adapter interface structure + +- **WHEN** implementing a tool adapter +- **THEN** `ToolCommandAdapter` SHALL require: + - `toolId`: string identifier matching `AIToolOption.value` + - `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex) + - `formatFile(content: CommandContent)`: returns complete file content with frontmatter + +#### Scenario: Claude adapter formatting + +- **WHEN** formatting a command for Claude Code +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.claude/commands/opsx/.md` + +#### Scenario: Cursor adapter formatting + +- **WHEN** formatting a command for Cursor +- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-`, `id`, `category`, `description` fields +- **AND** file path SHALL follow pattern `.cursor/commands/opsx-.md` + +#### Scenario: Windsurf adapter formatting + +- **GIVEN** RETIRED — Windsurf was rebranded to Devin Desktop and its config directory moved +- **WHEN** looking for a Windsurf adapter +- **THEN** none SHALL be registered — it is replaced by the Devin adapter below, not kept alongside a second adapter for the same product + +#### Scenario: Devin Desktop adapter formatting + +- **WHEN** formatting a command for Devin Desktop +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.devin/workflows/opsx-.md` + +#### Scenario: Trae adapter formatting + +- **WHEN** formatting a command for Trae +- **THEN** the adapter SHALL output YAML frontmatter with `name` and `description` fields +- **AND** file path SHALL follow pattern `.trae/commands/opsx-.md` diff --git a/openspec/changes/add-devin-desktop-support/tasks.md b/openspec/changes/add-devin-desktop-support/tasks.md new file mode 100644 index 0000000000..c80e2e3280 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/tasks.md @@ -0,0 +1,44 @@ +# Implementation Tasks + +## 1. Adapter + +- [x] 1.1 Add `src/core/command-generation/adapters/devin.ts`: `.devin/workflows/opsx-.md`, frontmatter `name`/`description`/`category`/`tags` via the shared helpers in `command-generation/yaml.ts`. +- [x] 1.2 Keep the adapter a pure formatter: the `opsx-` filename prefix makes Devin a flat invocation, so the generator rewrites `/opsx:` body references to `/opsx-` — the name Devin registers for a workflow file. +- [x] 1.3 Delete `adapters/windsurf.ts` and its registry/barrel entries; register `devinAdapter` in their place. + +## 2. Tool wiring + +- [x] 2.1 Replace the `windsurf` row in `AI_TOOLS` with `devin` (`skillsDir: '.devin'`, `detectionPaths: ['.devin', '.windsurf']`). Detection, the init picker, `--tools` validation, update, and profile sync all derive from this row. +- [x] 2.2 Add `TOOL_ID_ALIASES` / `resolveToolIdAlias` in `src/core/config.ts` and apply it when parsing `--tools`, so `--tools windsurf` still resolves. +- [x] 2.3 Re-key the pre-opsx `.windsurf/workflows/openspec-*.md` entry in `LEGACY_SLASH_COMMAND_PATHS` to `devin` — that map's keys are tool ids. +- [x] 2.4 In `getTransformerForTool`, give `devin` the skill-reference transformer whenever skills are generated, so skill bodies and the getting-started hint say `/openspec-*` — the Devin Local agent has no workflows. Under commands-only delivery, fall through to the invocation rewrite. + +## 3. Migration + +- [x] 3.1 Replace `LEGACY_SKILLS_DIRS` with `LEGACY_TOOL_ROOTS`, each root carrying whether leaving it needs consent (`.kimi` no, `.windsurf` yes). +- [x] 3.2 Extend the move to command files, deriving the legacy path from the adapter's own `getFilePath` so no layout is hard-coded. Skip absolute paths. +- [x] 3.3 Split find from apply (`findLegacyToolMigrations` / `migrateLegacyToolDirs`) so a consent-gated move can be described before it happens. +- [x] 3.4 `openspec update`: explain the rebrand, prompt interactively, migrate under `--force` or non-interactively, and say plainly what declining costs. +- [x] 3.5 `openspec init`: treat selecting the tool as consent and migrate for the selected tools only. + +## 4. Documentation + +- [x] 4.1 `docs/supported-tools.md`: give Devin its own row in the authoritative "How To Invoke" table — the catch-all row would otherwise claim `/opsx-` for both agents. Replace the Windsurf directory row and rewrite the footnote to cover the rename, the alias, and the migration. +- [x] 4.2 Drop `windsurf` from the `--tools` ID lists in `docs/cli.md` and `docs/supported-tools.md`, noting it is still accepted as an alias. +- [x] 4.3 Update the command-syntax tables in `docs/commands.md` and `docs/how-commands-work.md`, plus prose mentions in `faq.md`, `migration-guide.md`, `opsx.md`, and the website tool list. + +## 5. Tests + +- [x] 5.1 Adapter: tool id, `getFilePath`, and frontmatter. Hyphen rewriting is asserted end to end in the `generateCommand` flat-tool loop, and YAML escaping by the registry-derived parity matrix — both enroll Devin automatically. +- [x] 5.2 Detection: `.devin` and legacy `.windsurf` both resolve to `devin`; neither present means not detected. +- [x] 5.3 Alias: `--tools windsurf` writes `.devin/` and leaves no `.windsurf/`. +- [x] 5.4 Migration: skills and workflows move, user-authored files in `.windsurf/` survive, and a second run migrates nothing. +- [x] 5.5 `init`/`update`: both surfaces — `.devin/workflows/opsx-*.md` carry `/opsx-*`, `.devin/skills/openspec-*/SKILL.md` carry `/openspec-*`, and neither carries `/opsx:`. +- [x] 5.6 `getTransformerForTool` returns the skill transformer for Devin under `both`/`skills` delivery and the hyphen form under `commands`. + +## 6. Verification + +- [x] 6.1 `openspec validate add-devin-desktop-support --strict`. +- [x] 6.2 `openspec archive add-devin-desktop-support --yes` merges cleanly and additively (run on a scratch copy, then reverted). +- [x] 6.3 Full suite green. +- [x] 6.4 Manual journeys in scratch repos: legacy `.windsurf` install upgraded; both directories populated; IDE-written `.devin/rules/` preserved; `--tools windsurf` alias. diff --git a/src/cli/index.ts b/src/cli/index.ts index b2c2c19c3f..902c46d0a1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,7 +5,7 @@ import ora from 'ora'; import path from 'path'; import { fileURLToPath } from 'url'; import { promises as fs } from 'fs'; -import { AI_TOOLS } from '../core/config.js'; +import { AI_TOOLS, TOOL_ID_ALIASES } from '../core/config.js'; import { UpdateCommand } from '../core/update.js'; import { getAvailableCliUpdate, @@ -147,7 +147,10 @@ program.hook('postAction', async () => { }); const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value); -const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}`; +const toolAliasNote = Object.entries(TOOL_ID_ALIASES) + .map(([retired, current]) => `${retired} (now ${current})`) + .join(', '); +const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}. Also accepted: ${toolAliasNote}`; program .command('init [path]') diff --git a/src/core/command-generation/adapters/devin.ts b/src/core/command-generation/adapters/devin.ts new file mode 100644 index 0000000000..8a912c6854 --- /dev/null +++ b/src/core/command-generation/adapters/devin.ts @@ -0,0 +1,40 @@ +/** + * Devin Desktop Command Adapter + * + * Formats commands for Devin Desktop following its frontmatter specification. + * Devin Desktop reads Cascade-style workflows from `.devin/workflows/`, the + * same shape Windsurf uses. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue, formatTagsArray } from '../yaml.js'; + +/** + * Devin Desktop adapter for command generation. + * File path: .devin/workflows/opsx-.md + * Frontmatter: name, description, category, tags + * + * The `opsx-` filename prefix makes this a flat invocation, so the generator + * rewrites the body's `/opsx:*` references to the `/opsx-*` form Devin + * registers — see invocation.ts. + */ +export const devinAdapter: ToolCommandAdapter = { + toolId: 'devin', + + getFilePath(commandId: string): string { + return path.join('.devin', 'workflows', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} +tags: ${formatTagsArray(content.tags)} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index ad0c8fb867..358bc82767 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -15,6 +15,7 @@ export { continueAdapter } from './continue.js'; export { costrictAdapter } from './costrict.js'; export { crushAdapter } from './crush.js'; export { cursorAdapter } from './cursor.js'; +export { devinAdapter } from './devin.js'; export { factoryAdapter } from './factory.js'; export { geminiAdapter } from './gemini.js'; export { githubCopilotAdapter } from './github-copilot.js'; @@ -30,4 +31,3 @@ export { lingmaAdapter } from './lingma.js'; export { qwenAdapter } from './qwen.js'; export { roocodeAdapter } from './roocode.js'; export { traeAdapter } from './trae.js'; -export { windsurfAdapter } from './windsurf.js'; diff --git a/src/core/command-generation/adapters/windsurf.ts b/src/core/command-generation/adapters/windsurf.ts deleted file mode 100644 index 2497e2a21f..0000000000 --- a/src/core/command-generation/adapters/windsurf.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Windsurf Command Adapter - * - * Formats commands for Windsurf following its frontmatter specification. - * Windsurf uses a similar format to Claude but may have different conventions. - */ - -import path from 'path'; -import type { CommandContent, ToolCommandAdapter } from '../types.js'; -import { escapeYamlValue, formatTagsArray } from '../yaml.js'; - -/** - * Windsurf adapter for command generation. - * File path: .windsurf/workflows/opsx-.md - * Frontmatter: name, description, category, tags - */ -export const windsurfAdapter: ToolCommandAdapter = { - toolId: 'windsurf', - - getFilePath(commandId: string): string { - return path.join('.windsurf', 'workflows', `opsx-${commandId}.md`); - }, - - formatFile(content: CommandContent): string { - return `--- -name: ${escapeYamlValue(content.name)} -description: ${escapeYamlValue(content.description)} -category: ${escapeYamlValue(content.category)} -tags: ${formatTagsArray(content.tags)} ---- - -${content.body} -`; - }, -}; diff --git a/src/core/command-generation/index.ts b/src/core/command-generation/index.ts index a067f33b20..47c05cd1bb 100644 --- a/src/core/command-generation/index.ts +++ b/src/core/command-generation/index.ts @@ -30,4 +30,4 @@ export { CommandAdapterRegistry } from './registry.js'; export { generateCommand, generateCommands } from './generator.js'; // Adapters (for direct access if needed) -export { claudeAdapter, cursorAdapter, windsurfAdapter } from './adapters/index.js'; +export { claudeAdapter, cursorAdapter, devinAdapter } from './adapters/index.js'; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index 7fe470c22a..14e5481814 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -12,6 +12,7 @@ import { auggieAdapter } from './adapters/auggie.js'; import { bobAdapter } from './adapters/bob.js'; import { claudeAdapter } from './adapters/claude.js'; import { clineAdapter } from './adapters/cline.js'; +import { devinAdapter } from './adapters/devin.js'; import { codebuddyAdapter } from './adapters/codebuddy.js'; import { continueAdapter } from './adapters/continue.js'; import { costrictAdapter } from './adapters/costrict.js'; @@ -32,7 +33,6 @@ import { lingmaAdapter } from './adapters/lingma.js'; import { qwenAdapter } from './adapters/qwen.js'; import { roocodeAdapter } from './adapters/roocode.js'; import { traeAdapter } from './adapters/trae.js'; -import { windsurfAdapter } from './adapters/windsurf.js'; import { zcodeAdapter } from './adapters/zcode.js'; /** @@ -49,6 +49,7 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(bobAdapter); CommandAdapterRegistry.register(claudeAdapter); CommandAdapterRegistry.register(clineAdapter); + CommandAdapterRegistry.register(devinAdapter); CommandAdapterRegistry.register(codebuddyAdapter); CommandAdapterRegistry.register(continueAdapter); CommandAdapterRegistry.register(costrictAdapter); @@ -69,7 +70,6 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(qwenAdapter); CommandAdapterRegistry.register(roocodeAdapter); CommandAdapterRegistry.register(traeAdapter); - CommandAdapterRegistry.register(windsurfAdapter); CommandAdapterRegistry.register(zcodeAdapter); } diff --git a/src/core/config.ts b/src/core/config.ts index f6236c6a4c..fd18c3f82e 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -28,6 +28,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' }, { name: 'CodeArts', value: 'codeartsagent', available: true, successLabel: 'CodeArts', skillsDir: '.codeartsdoer' }, { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' }, + { name: 'Devin Desktop (formerly Windsurf)', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin', detectionPaths: ['.devin', '.windsurf'] }, { name: 'ForgeCode', value: 'forgecode', available: true, successLabel: 'ForgeCode', skillsDir: '.forge' }, { name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' }, { name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue' }, @@ -52,7 +53,23 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' }, { name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo' }, { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' }, - { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf' }, { name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' }, { name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' } ]; + +/** + * Retired tool ids that still resolve, so a rebrand does not break scripted + * `--tools` invocations. Windsurf was rebranded to Devin Desktop on + * 2026-06-02 and its config directory moved from `.windsurf/` to `.devin/`; + * `--tools windsurf` therefore configures `devin`. + */ +export const TOOL_ID_ALIASES: Record = { + windsurf: 'devin', +}; + +/** + * Resolves a tool id through TOOL_ID_ALIASES, leaving current ids untouched. + */ +export function resolveToolIdAlias(toolId: string): string { + return TOOL_ID_ALIASES[toolId] ?? toolId; +} diff --git a/src/core/init.ts b/src/core/init.ts index a846fc56cb..b2064b183d 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -18,6 +18,7 @@ import { AI_TOOLS, OPENSPEC_DIR_NAME, AIToolOption, + resolveToolIdAlias, } from './config.js'; import { PALETTE } from './styles/palette.js'; import { isInteractive } from '../utils/interactive.js'; @@ -50,7 +51,7 @@ import { import { getGlobalConfig, type Delivery, type Profile } from './global-config.js'; import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; -import { migrateIfNeeded, migrateLegacySkillDirs, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js'; +import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js'; import { resolveCommandSurfaceCapability, resolveCommandInvocation, @@ -169,7 +170,7 @@ export class InitCommand { // Migrate OpenSpec-managed skills left in renamed tool directories // (e.g. .kimi -> .kimi-code) before detection so they stay recognized. - migrateLegacySkillDirs(projectPath); + migrateLegacyToolDirs(projectPath); // Detect available tools in the project (task 7.1) const detectedTools = getAvailableTools(projectPath); @@ -201,6 +202,20 @@ export class InitCommand { // Validate selected tools const validatedTools = this.validateTools(selectedToolIds, toolStates); + // Selecting a renamed tool is consent to leave its former directory: + // init is about to write the current one, and leaving OpenSpec content + // behind would give the user two installs of the same tool. + for (const migration of migrateLegacyToolDirs( + projectPath, + validatedTools.map((tool) => tool.value) + )) { + if (hasMovableContent(migration)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + } + const kept = keptInPlaceNotice(migration); + if (kept) console.log(chalk.dim(kept)); + } + // Create directory structure and config await this.createDirectoryStructure(openspecPath, extendMode); @@ -544,7 +559,9 @@ export class InitCommand { ); } - const normalizedTokens = tokens.map((token) => token.toLowerCase()); + // Retired ids resolve to their current tool, so a rebrand does not break + // an existing `--tools windsurf` in someone's setup script. + const normalizedTokens = tokens.map((token) => resolveToolIdAlias(token.toLowerCase())); if (normalizedTokens.some((token) => token === 'all' || token === 'none')) { throw new Error('Cannot combine reserved values "all" or "none" with specific tool IDs.'); diff --git a/src/core/legacy-cleanup.ts b/src/core/legacy-cleanup.ts index 978fc441a2..e312023e98 100644 --- a/src/core/legacy-cleanup.ts +++ b/src/core/legacy-cleanup.ts @@ -43,7 +43,10 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record = { - // Kimi CLI became Kimi Code and moved from .kimi to .kimi-code - kimi: ['.kimi'], +export const LEGACY_TOOL_ROOTS: Record = { + // Kimi CLI became Kimi Code and moved from .kimi to .kimi-code. + kimi: [{ root: '.kimi', needsConsent: false }], + // Windsurf was rebranded to Devin Desktop on 2026-06-02 and its config + // directory moved to .devin/. Devin Desktop reads .windsurf/ only as a + // fallback and Devin Local does not read it at all, so moving is the right + // default — but a pre-rebrand Windsurf build reads ONLY .windsurf/, and + // nothing on disk tells that user apart, so the move is offered, not taken. + devin: [{ root: '.windsurf', needsConsent: true }], }; -export interface LegacySkillsMigration { +export interface LegacyToolMigration { toolId: string; - /** Legacy tool root, e.g. '.kimi' */ + /** Legacy tool root, e.g. '.windsurf' */ from: string; - /** Current tool root, e.g. '.kimi-code' */ + /** Current tool root, e.g. '.devin' */ to: string; - /** Number of skill directories moved or removed */ - movedSkillDirs: number; + /** Skill directories that moved, or would move */ + skillDirs: number; + /** Command files that moved, or would move */ + commandFiles: number; + /** + * OpenSpec-managed files left under the legacy root because the copy there + * differs from the one that survives — the user edited it, so it is reported + * rather than dropped. + */ + keptInPlace: number; + /** Whether this move needs the user's consent first */ + needsConsent: boolean; +} + +/** + * Classifies one OpenSpec-managed file. `move` is the fast path (nothing at + * the destination yet); `drop` means the destination already holds the same + * bytes, so the legacy copy is redundant; `keep` means the two differ, which + * only happens when the user edited one, and an edit is not ours to discard. + */ +type FileDisposition = 'move' | 'drop' | 'keep' | 'skip'; + +function classifyManagedFile(source: string, destination: string): FileDisposition { + if (isSamePath(source, destination)) return 'skip'; + if (!fs.existsSync(destination)) return 'move'; + try { + return fs.readFileSync(source, 'utf-8') === fs.readFileSync(destination, 'utf-8') + ? 'drop' + : 'keep'; + } catch { + return 'keep'; + } } /** - * Moves OpenSpec-managed skill directories (openspec-*) from a tool's legacy - * skillsDir to its current one. When the destination already exists the legacy - * copy is removed instead. Legacy directories are deleted only when left empty, - * so user files under the old location are preserved. + * Rewrites a generated command path from the tool's current root to a legacy + * one, so `.devin/workflows/opsx-apply.md` locates its `.windsurf/` twin + * without the migration hard-coding either layout. + * + * Returns undefined for adapters whose paths are absolute (global-scoped + * command files) or do not start at the tool root — neither can be relocated + * by swapping a leading segment. + */ +function legacyCommandPath( + commandPath: string, + currentRoot: string, + legacyRoot: string +): string | undefined { + if (path.isAbsolute(commandPath)) return undefined; + const segments = commandPath.split(/[\\/]/); + if (segments[0] !== currentRoot) return undefined; + segments[0] = legacyRoot; + return path.join(...segments); +} + +/** + * Reports the OpenSpec content sitting under each tool's legacy root, without + * moving anything. Callers use this to ask before a move that needs consent. + */ +export function findLegacyToolMigrations(projectPath: string): LegacyToolMigration[] { + return collectLegacyToolMigrations(projectPath, false); +} + +/** + * Moves OpenSpec-managed skill directories (openspec-*) and command files + * (opsx-*) from a tool's legacy root to its current one. When the destination + * already exists the legacy copy is removed instead. Legacy directories are + * deleted only when left empty, so user files under the old location — a + * hand-written Cascade workflow next to the generated ones — are preserved. + * + * @param projectPath - Project root + * @param toolIds - Restrict the move to these tools; omit to move every tool + * whose legacy root needs no consent */ -export function migrateLegacySkillDirs(projectPath: string): LegacySkillsMigration[] { - const migrations: LegacySkillsMigration[] = []; +export function migrateLegacyToolDirs( + projectPath: string, + toolIds?: string[] +): LegacyToolMigration[] { + return collectLegacyToolMigrations(projectPath, true, toolIds); +} + +function collectLegacyToolMigrations( + projectPath: string, + apply: boolean, + toolIds?: string[] +): LegacyToolMigration[] { + const migrations: LegacyToolMigration[] = []; for (const tool of AI_TOOLS) { if (!tool.skillsDir) continue; + if (toolIds && !toolIds.includes(tool.value)) continue; - for (const legacyRoot of LEGACY_SKILLS_DIRS[tool.value] ?? []) { - if (legacyRoot === tool.skillsDir) continue; - const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills'); - if (!fs.existsSync(legacySkillsDir)) continue; - const currentSkillsDir = path.join(projectPath, tool.skillsDir, 'skills'); - let movedSkillDirs = 0; - - for (const workflowId of ALL_WORKFLOWS) { - const dirName = WORKFLOW_TO_SKILL_DIR[workflowId]; - const source = path.join(legacySkillsDir, dirName); - if (!fs.existsSync(path.join(source, 'SKILL.md'))) continue; - - try { - const destination = path.join(currentSkillsDir, dirName); - if (fs.existsSync(destination)) { - fs.rmSync(source, { recursive: true, force: true }); - } else { - fs.mkdirSync(currentSkillsDir, { recursive: true }); - fs.renameSync(source, destination); - } - movedSkillDirs++; - } catch { - // Leave the legacy directory in place if it cannot be moved - } - } + for (const legacy of LEGACY_TOOL_ROOTS[tool.value] ?? []) { + if (legacy.root === tool.skillsDir) continue; + // Without an explicit tool list, only moves that need no consent run. + if (apply && !toolIds && legacy.needsConsent) continue; + if (!fs.existsSync(path.join(projectPath, legacy.root))) continue; - removeDirIfEmpty(legacySkillsDir); - removeDirIfEmpty(path.join(projectPath, legacyRoot)); + const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply); + const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply); - if (movedSkillDirs > 0) { - migrations.push({ toolId: tool.value, from: legacyRoot, to: tool.skillsDir, movedSkillDirs }); + if (apply) { + removeDirIfEmpty(path.join(projectPath, legacy.root, 'skills')); + removeDirIfEmpty(path.join(projectPath, legacy.root, 'workflows')); + removeDirIfEmpty(path.join(projectPath, legacy.root)); + } + + // Kept-only results are retained deliberately. When every legacy file + // differs from its counterpart nothing is movable, and dropping the + // record here would leave the user with two divergent copies and no + // word of it. + if (skills.moved > 0 || commands.moved > 0 || skills.kept > 0 || commands.kept > 0) { + migrations.push({ + toolId: tool.value, + from: legacy.root, + to: tool.skillsDir, + skillDirs: skills.moved, + commandFiles: commands.moved, + keptInPlace: skills.kept + commands.kept, + needsConsent: legacy.needsConsent, + }); } } } @@ -89,6 +180,178 @@ export function migrateLegacySkillDirs(projectPath: string): LegacySkillsMigrati return migrations; } +function migrateSkillDirs( + projectPath: string, + currentRoot: string, + legacyRoot: string, + apply: boolean +): { moved: number; kept: number } { + const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills'); + if (!fs.existsSync(legacySkillsDir)) return { moved: 0, kept: 0 }; + const currentSkillsDir = path.join(projectPath, currentRoot, 'skills'); + let moved = 0; + let kept = 0; + + for (const workflowId of ALL_WORKFLOWS) { + const dirName = WORKFLOW_TO_SKILL_DIR[workflowId]; + const source = path.join(legacySkillsDir, dirName); + const sourceSkill = path.join(source, 'SKILL.md'); + if (!fs.existsSync(sourceSkill)) continue; + + const destination = path.join(currentSkillsDir, dirName); + const destinationSkill = path.join(destination, 'SKILL.md'); + const disposition = classifyManagedFile(sourceSkill, destinationSkill); + if (disposition === 'skip') continue; + if (disposition === 'keep') { + kept++; + continue; + } + if (!apply) { + moved++; + continue; + } + + try { + // Move the generated file, never the directory around it. A skill + // directory can also hold files the user wrote, and this destination is + // one OpenSpec deletes on its own — commands-only delivery and a + // deselected workflow both remove the whole skill directory. Carrying a + // user's file across would be handing it to that later removal. + if (disposition === 'drop') { + fs.rmSync(sourceSkill, { force: true }); + } else { + fs.mkdirSync(destination, { recursive: true }); + fs.renameSync(sourceSkill, destinationSkill); + } + // Anything the user left beside it stays under the legacy root. + removeDirIfEmpty(source); + moved++; + } catch { + // Leave the legacy directory in place if it cannot be moved + } + } + + return { moved, kept }; +} + +function migrateCommandFiles( + projectPath: string, + tool: AIToolOption, + legacyRoot: string, + apply: boolean +): { moved: number; kept: number } { + const adapter = CommandAdapterRegistry.get(tool.value); + if (!adapter || !tool.skillsDir) return { moved: 0, kept: 0 }; + let moved = 0; + let kept = 0; + + for (const commandId of COMMAND_IDS) { + const currentPath = adapter.getFilePath(commandId); + const legacyPath = legacyCommandPath(currentPath, tool.skillsDir, legacyRoot); + if (!legacyPath) continue; + + const source = path.join(projectPath, legacyPath); + if (!fs.existsSync(source)) continue; + + const destination = path.join(projectPath, currentPath); + const disposition = classifyManagedFile(source, destination); + if (disposition === 'skip') continue; + if (disposition === 'keep') { + kept++; + continue; + } + if (!apply) { + moved++; + continue; + } + + try { + if (disposition === 'drop') { + fs.rmSync(source, { force: true }); + } else { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.renameSync(source, destination); + } + moved++; + } catch { + // Leave the legacy file in place if it cannot be moved + } + } + + return { moved, kept }; +} + +/** + * Summarizes what a migration moved, e.g. "6 skills and 6 commands". + */ +export function describeLegacyMigration(migration: LegacyToolMigration): string { + const parts: string[] = []; + if (migration.skillDirs > 0) { + parts.push(`${migration.skillDirs} skill${migration.skillDirs === 1 ? '' : 's'}`); + } + if (migration.commandFiles > 0) { + parts.push(`${migration.commandFiles} command${migration.commandFiles === 1 ? '' : 's'}`); + } + return parts.join(' and '); +} + +/** + * Names OpenSpec-managed files the move deliberately left behind, so a user + * who customized one knows there are now two copies to reconcile. + */ +export function keptInPlaceNotice(migration: LegacyToolMigration): string | undefined { + if (migration.keptInPlace === 0) return undefined; + const n = migration.keptInPlace; + // Deliberately does not claim the difference came from an edit: an older + // OpenSpec version's output differs too. Either way nothing was overwritten, + // and the user is the one who decides which copy to keep. + return ( + `Left ${n} file${n === 1 ? '' : 's'} in ${migration.from}/ that ` + + `differ${n === 1 ? 's' : ''} from the copy in ${migration.to}/. Nothing was ` + + `overwritten — compare the two and delete the ${migration.from}/ copy once ` + + `you have kept anything you customized.` + ); +} + +/** + * Whether a migration has anything to move, as opposed to only files left in + * place. Callers use this to avoid offering a move of nothing. + */ +export function hasMovableContent(migration: LegacyToolMigration): boolean { + return migration.skillDirs > 0 || migration.commandFiles > 0; +} + +/** + * Explains why a consent-gated move is being offered, in the user's terms. + * Keyed by tool so the reason is specific rather than a generic "files moved". + */ +export function legacyMigrationNotice(migration: LegacyToolMigration): string { + if (migration.toolId === 'devin') { + return ( + `Windsurf is now Devin Desktop, and its config directory moved from ` + + `${migration.from}/ to ${migration.to}/. Devin Desktop reads ${migration.from}/ ` + + `only as a fallback, and Devin Local does not read it at all.` + ); + } + return `${migration.from}/ is the former location for this tool; ${migration.to}/ is current.`; +} + +/** + * Whether two paths are the same file on disk once symlinks are resolved. + * + * Symlinking one tool root at the other is a realistic way to straddle a + * rebrand (`ln -s .devin .windsurf` to keep an older build working). Without + * this check the "destination already exists, drop the legacy copy" branch + * deletes the destination itself, taking the only copy with it. + */ +function isSamePath(a: string, b: string): boolean { + try { + return fs.realpathSync(a) === fs.realpathSync(b); + } catch { + return false; + } +} + function removeDirIfEmpty(dirPath: string): void { try { if (fs.readdirSync(dirPath).length === 0) { diff --git a/src/core/update.ts b/src/core/update.ts index 72e528e9fe..9d48f621ec 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -51,7 +51,13 @@ import { import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, - migrateLegacySkillDirs, + findLegacyToolMigrations, + migrateLegacyToolDirs, + describeLegacyMigration, + legacyMigrationNotice, + keptInPlaceNotice, + hasMovableContent, + type LegacyToolMigration, } from './migration.js'; import { resolveCommandSurfaceCapability, @@ -122,9 +128,13 @@ export class UpdateCommand { // (e.g. .kimi -> .kimi-code) so they stay detected and get refreshed, // then perform the one-time profile migration if needed before any // legacy upgrade generation. - for (const migration of migrateLegacySkillDirs(resolvedProjectPath)) { - console.log(chalk.dim(`Migrated ${migration.movedSkillDirs} skill director${migration.movedSkillDirs === 1 ? 'y' : 'ies'}: ${migration.from}/skills → ${migration.to}/skills`)); + for (const migration of migrateLegacyToolDirs(resolvedProjectPath)) { + if (hasMovableContent(migration)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + } + this.reportKeptInPlace(migration); } + const declinedMigrations = await this.offerConsentedLegacyMigrations(resolvedProjectPath); // Use detected tool directories to preserve existing opsx skills/commands. const detectedTools = getAvailableTools(resolvedProjectPath); @@ -158,6 +168,22 @@ export class UpdateCommand { if (deferredGlobalCleanup) { await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup); } + if (declinedMigrations.length > 0) { + // Not an unconfigured project — a configured one the user chose to + // leave in its former directory. Saying "run init" would be wrong. + for (const migration of declinedMigrations) { + console.log( + chalk.yellow( + `Nothing to update: this project's OpenSpec files are still in ${migration.from}/, ` + + `which OpenSpec no longer writes.` + ) + ); + console.log( + chalk.dim(`Re-run "openspec update" and accept the move to ${migration.to}/ to resume updates.`) + ); + } + return; + } console.log(chalk.yellow('No configured tools found.')); console.log(chalk.dim('Run "openspec init" to set up tools.')); return; @@ -629,6 +655,82 @@ export class UpdateCommand { return removed; } + /** + * Offers to move OpenSpec content out of a renamed tool's former directory + * when the old location might still be the live one — today, Windsurf's + * `.windsurf/` after the Devin Desktop rebrand. + * + * Interactive runs are asked, because nothing on disk distinguishes a user + * who took the rebrand from one still on a pre-rebrand Windsurf build that + * reads only `.windsurf/`. `--force` and non-interactive runs migrate, which + * is what an unattended upgrade wants. + */ + /** Surfaces files the move left behind rather than overwriting. */ + private reportKeptInPlace(migration: LegacyToolMigration): void { + const notice = keptInPlaceNotice(migration); + if (notice) console.log(chalk.dim(notice)); + } + + private async offerConsentedLegacyMigrations( + projectPath: string + ): Promise { + const pending = findLegacyToolMigrations(projectPath).filter((m) => m.needsConsent); + const declined: LegacyToolMigration[] = []; + if (pending.length === 0) return declined; + + for (const migration of pending) { + // Nothing movable: every legacy file differs from its counterpart, so + // there is no move to offer. Still say so — silence would leave two + // divergent copies the user never hears about. + if (!hasMovableContent(migration)) { + this.reportKeptInPlace(migration); + console.log(); + continue; + } + + console.log(chalk.yellow(legacyMigrationNotice(migration))); + + if (!this.force && isInteractive()) { + const { confirm } = await import('@inquirer/prompts'); + let shouldMigrate: boolean; + try { + shouldMigrate = await confirm({ + message: `Move ${describeLegacyMigration(migration)} from ${migration.from}/ to ${migration.to}/?`, + default: true, + }); + } catch { + // Closed stdin is not consent, and it must not abort the update. + shouldMigrate = false; + } + if (!shouldMigrate) { + // Say what declining costs. OpenSpec writes the current root now, so + // the files keep working where they are, but OpenSpec stops managing + // them — it no longer looks in the former directory. + console.log( + chalk.dim( + `Left in place. OpenSpec writes ${migration.to}/ now and will not manage ` + + `${migration.from}/, so those files stay as they are until you move them. ` + + `You will be asked again next run.` + ) + ); + console.log(); + declined.push(migration); + continue; + } + } + + for (const applied of migrateLegacyToolDirs(projectPath, [migration.toolId])) { + if (hasMovableContent(applied)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(applied)}: ${applied.from} → ${applied.to}`)); + } + this.reportKeptInPlace(applied); + } + console.log(); + } + + return declined; + } + /** * Detect and handle legacy OpenSpec artifacts. * Unlike init, update warns but continues if legacy files found in non-interactive mode. diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index af437b03f4..cdcce79187 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -140,6 +140,13 @@ export function getSkillReferenceTransformer(toolId: string): (text: string) => * the list drifted and left 16 tools advertising commands their palettes never * registered (#727, #1307). * + * Devin is the one tool that takes skill references even though its commands + * are generated: only Devin Desktop reads `.devin/workflows/`, so a workflow + * reference is dead text for anyone on Devin Local, while the `/openspec-*` + * skills work on both agents. Under commands-only delivery there are no Devin + * skills to point at, so it falls through to the invocation rewrite below and + * gets the `/opsx-` form its workflow filenames register. + * * @param toolId - The AI tool identifier (e.g. 'claude', 'opencode', 'pi') * @param delivery - The configured delivery mode * @param capability - The tool's command surface capability @@ -159,6 +166,9 @@ export function getTransformerForTool( if (delivery === 'skills' || capability !== 'adapter-backed') { return getSkillReferenceTransformer(toolId); } + if (toolId === 'devin' && delivery === 'both') { + return getSkillReferenceTransformer(toolId); + } if (invocation !== undefined && needsInvocationRewrite(invocation)) { return (text: string) => transformCommandInvocations(text, invocation); } diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index a8af2e6d78..3927718137 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -1173,17 +1173,17 @@ operations: expect(content).toContain('name: "/opsx-explore"'); }); - it('creates skills for Windsurf tool', async () => { + it('creates skills for the retired windsurf id, under Devin Desktop', async () => { const result = await runCLI(['experimental', '--tool', 'windsurf'], { cwd: tempDir, }); expect(result.exitCode).toBe(0); const output = normalizePaths(getOutput(result)); - expect(output).toContain('Windsurf'); - expect(output).toContain('.windsurf/'); + expect(output).toContain('Devin Desktop'); + expect(output).toContain('.devin/'); // Verify skill files were created - const skillFile = path.join(tempDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md'); + const skillFile = path.join(tempDir, '.devin', 'skills', 'openspec-explore', 'SKILL.md'); const stat = await fs.stat(skillFile); expect(stat.isFile()).toBe(true); }); diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index ef13effe30..ae20603613 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -40,10 +40,41 @@ describe('available-tools', () => { const toolValues = tools.map((t) => t.value); expect(toolValues).toContain('claude'); expect(toolValues).toContain('cursor'); - expect(toolValues).toContain('windsurf'); + // Windsurf was rebranded to Devin Desktop, so .windsurf detects as devin + expect(toolValues).toContain('devin'); expect(tools).toHaveLength(3); }); + it('should detect Devin Desktop when .devin directory exists', async () => { + await fs.mkdir(path.join(testDir, '.devin'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('devin'); + + const devinTool = tools.find((t) => t.value === 'devin'); + expect(devinTool).toBeDefined(); + expect(devinTool?.name).toBe('Devin Desktop (formerly Windsurf)'); + expect(devinTool?.skillsDir).toBe('.devin'); + }); + + it('should detect Devin Desktop from the legacy .windsurf directory', async () => { + // The rebrand moved the config dir; a project set up before it still has + // only .windsurf/, and that user must still be recognized. + await fs.mkdir(path.join(testDir, '.windsurf'), { recursive: true }); + + const tools = getAvailableTools(testDir); + expect(tools.map((t) => t.value)).toContain('devin'); + expect(tools.find((t) => t.value === 'devin')?.skillsDir).toBe('.devin'); + }); + + it('should not detect Devin Desktop when neither .devin nor .windsurf exists', async () => { + await fs.mkdir(path.join(testDir, '.cursor'), { recursive: true }); + + const tools = getAvailableTools(testDir); + expect(tools.map((t) => t.value)).not.toContain('devin'); + }); + it('should ignore files that are not directories', async () => { // Create a file named .claude instead of a directory await fs.writeFile(path.join(testDir, '.claude'), 'not a directory'); diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 03c5466aa3..43dbea5d4e 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -11,6 +11,7 @@ import { continueAdapter } from '../../../src/core/command-generation/adapters/c import { costrictAdapter } from '../../../src/core/command-generation/adapters/costrict.js'; import { crushAdapter } from '../../../src/core/command-generation/adapters/crush.js'; import { cursorAdapter } from '../../../src/core/command-generation/adapters/cursor.js'; +import { devinAdapter } from '../../../src/core/command-generation/adapters/devin.js'; 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'; @@ -26,7 +27,6 @@ import { qoderAdapter } from '../../../src/core/command-generation/adapters/qode import { qwenAdapter } from '../../../src/core/command-generation/adapters/qwen.js'; import { roocodeAdapter } from '../../../src/core/command-generation/adapters/roocode.js'; import { traeAdapter } from '../../../src/core/command-generation/adapters/trae.js'; -import { windsurfAdapter } from '../../../src/core/command-generation/adapters/windsurf.js'; import { zcodeAdapter } from '../../../src/core/command-generation/adapters/zcode.js'; import type { CommandContent, @@ -114,18 +114,18 @@ describe('command-generation/adapters', () => { }); }); - describe('windsurfAdapter', () => { + describe('devinAdapter', () => { it('should have correct toolId', () => { - expect(windsurfAdapter.toolId).toBe('windsurf'); + expect(devinAdapter.toolId).toBe('devin'); }); it('should generate correct file path', () => { - const filePath = windsurfAdapter.getFilePath('explore'); - expect(filePath).toBe(path.join('.windsurf', 'workflows', 'opsx-explore.md')); + const filePath = devinAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.devin', 'workflows', 'opsx-explore.md')); }); - it('should format file similar to Claude format', () => { - const output = windsurfAdapter.formatFile(sampleContent); + it('should format file with YAML frontmatter', () => { + const output = devinAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); expect(output).toContain('name: "OpenSpec Explore"'); @@ -135,6 +135,20 @@ describe('command-generation/adapters', () => { expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); }); + + // The body's `/opsx:*` references are rewritten to the `/opsx-*` form + // Devin registers by the generator, not here — adapters are pure + // formatters. Covered for devin in invocation.test.ts. + + // Frontmatter escaping comes from the shared yaml.ts helpers and is + // covered for every registered adapter by the round-trip matrix in + // "YAML frontmatter escaping across adapters" below. + + it('should handle empty tags', () => { + const contentNoTags: CommandContent = { ...sampleContent, tags: [] }; + const output = devinAdapter.formatFile(contentNoTags); + expect(output).toContain('tags: []'); + }); }); describe('amazonQAdapter', () => { @@ -932,9 +946,9 @@ describe('command-generation/adapters', () => { expect(filePath.split(path.sep)).toEqual(['.cursor', 'commands', 'opsx-test.md']); }); - it('Windsurf adapter uses path.join for paths', () => { - const filePath = windsurfAdapter.getFilePath('test'); - expect(filePath.split(path.sep)).toEqual(['.windsurf', 'workflows', 'opsx-test.md']); + it('Devin adapter uses path.join for paths', () => { + const filePath = devinAdapter.getFilePath('test'); + expect(filePath.split(path.sep)).toEqual(['.devin', 'workflows', 'opsx-test.md']); }); it('All adapters use path.join for paths', () => { diff --git a/test/core/command-generation/invocation.test.ts b/test/core/command-generation/invocation.test.ts index c4a74ff7e9..fbbf0d963f 100644 --- a/test/core/command-generation/invocation.test.ts +++ b/test/core/command-generation/invocation.test.ts @@ -133,7 +133,7 @@ describe('command-generation/invocation', () => { describe('generateCommand', () => { it('rewrites command references to the names a flat tool registers', () => { - for (const toolId of ['cursor', 'github-copilot', 'windsurf', 'opencode', 'qwen']) { + for (const toolId of ['cursor', 'github-copilot', 'devin', 'opencode', 'qwen']) { const adapter = CommandAdapterRegistry.get(toolId)!; const { fileContent } = generateCommand(sampleContent, adapter); expect(fileContent, toolId).toContain('/opsx-archive'); @@ -173,7 +173,7 @@ describe('command-generation/invocation', () => { // generateCommand owns the rewrite; an adapter that re-added its own // body transform would break this contract even though the output of // generateCommand happens to be identical (the rewrite is idempotent). - for (const toolId of ['bob', 'oh-my-pi', 'opencode', 'pi', 'qwen', 'cursor']) { + for (const toolId of ['bob', 'oh-my-pi', 'opencode', 'pi', 'qwen', 'cursor', 'devin']) { const adapter = CommandAdapterRegistry.get(toolId)!; expect(adapter.formatFile(sampleContent), toolId).toContain('/opsx:archive'); } diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index ce41d96f6e..07fb8bf774 100644 --- a/test/core/command-generation/registry.test.ts +++ b/test/core/command-generation/registry.test.ts @@ -16,10 +16,16 @@ describe('command-generation/registry', () => { expect(adapter?.toolId).toBe('cursor'); }); - it('should return Windsurf adapter for "windsurf"', () => { - const adapter = CommandAdapterRegistry.get('windsurf'); + it('should return the Devin adapter for "devin", the id Windsurf became', () => { + const adapter = CommandAdapterRegistry.get('devin'); expect(adapter).toBeDefined(); - expect(adapter?.toolId).toBe('windsurf'); + expect(adapter?.toolId).toBe('devin'); + }); + + it('should return Devin adapter for "devin"', () => { + const adapter = CommandAdapterRegistry.get('devin'); + expect(adapter).toBeDefined(); + expect(adapter?.toolId).toBe('devin'); }); it('should return Junie adapter for "junie"', () => { @@ -60,16 +66,16 @@ describe('command-generation/registry', () => { it('should return array of all registered adapters', () => { const adapters = CommandAdapterRegistry.getAll(); expect(Array.isArray(adapters)).toBe(true); - expect(adapters.length).toBeGreaterThanOrEqual(3); // At least Claude, Cursor, Windsurf + expect(adapters.length).toBeGreaterThanOrEqual(3); // At least Claude, Cursor, Devin }); - it('should include Claude, Cursor, and Windsurf adapters', () => { + it('should include Claude, Cursor, and Devin adapters', () => { const adapters = CommandAdapterRegistry.getAll(); const toolIds = adapters.map((a) => a.toolId); expect(toolIds).toContain('claude'); expect(toolIds).toContain('cursor'); - expect(toolIds).toContain('windsurf'); + expect(toolIds).toContain('devin'); expect(toolIds).not.toContain('codex'); }); @@ -85,7 +91,8 @@ describe('command-generation/registry', () => { it('should return true for registered tools', () => { expect(CommandAdapterRegistry.has('claude')).toBe(true); expect(CommandAdapterRegistry.has('cursor')).toBe(true); - expect(CommandAdapterRegistry.has('windsurf')).toBe(true); + expect(CommandAdapterRegistry.has('devin')).toBe(true); + expect(CommandAdapterRegistry.has('devin')).toBe(true); expect(CommandAdapterRegistry.has('junie')).toBe(true); expect(CommandAdapterRegistry.has('zcode')).toBe(true); expect(CommandAdapterRegistry.has('codex')).toBe(false); @@ -105,11 +112,11 @@ describe('command-generation/registry', () => { it('registered adapters should have working getFilePath', () => { const claudeAdapter = CommandAdapterRegistry.get('claude'); const cursorAdapter = CommandAdapterRegistry.get('cursor'); - const windsurfAdapter = CommandAdapterRegistry.get('windsurf'); + const devinAdapter = CommandAdapterRegistry.get('devin'); expect(claudeAdapter?.getFilePath('test')).toContain('.claude'); expect(cursorAdapter?.getFilePath('test')).toContain('.cursor'); - expect(windsurfAdapter?.getFilePath('test')).toContain('.windsurf'); + expect(devinAdapter?.getFilePath('test')).toContain('.devin'); }); it('registered adapters should have working formatFile', () => { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index dd691ff425..1ba3c1144a 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -160,13 +160,19 @@ describe('InitCommand', () => { expect(await fileExists(skillFile)).toBe(true); }); - it('should create skills in Windsurf skills directory', async () => { + it('should route the retired windsurf id to Devin Desktop', async () => { + // Windsurf was rebranded to Devin Desktop; `--tools windsurf` still + // resolves so an existing setup script keeps working, but it configures + // the current tool and writes the current directory. const initCommand = new InitCommand({ tools: 'windsurf', force: true }); await initCommand.execute(testDir); - const skillFile = path.join(testDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md'); + const skillFile = path.join(testDir, '.devin', 'skills', 'openspec-explore', 'SKILL.md'); expect(await fileExists(skillFile)).toBe(true); + expect( + await fileExists(path.join(testDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md')) + ).toBe(false); }); it('should generate ZCode skills and commands under .zcode without creating .agents', async () => { @@ -361,12 +367,12 @@ describe('InitCommand', () => { const claudeSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); const codeArtsSkill = path.join(testDir, '.codeartsdoer', 'skills', 'openspec-explore', 'SKILL.md'); const cursorSkill = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md'); - const windsurfSkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md'); + const devinSkill = path.join(testDir, '.devin', 'skills', 'openspec-explore', 'SKILL.md'); expect(await fileExists(claudeSkill)).toBe(true); expect(await fileExists(codeArtsSkill)).toBe(true); expect(await fileExists(cursorSkill)).toBe(true); - expect(await fileExists(windsurfSkill)).toBe(true); + expect(await fileExists(devinSkill)).toBe(true); }); it('should skip tool configuration with --tools none option', async () => { @@ -599,14 +605,44 @@ describe('InitCommand', () => { expect(content).toContain('prompt ='); }); - it('should generate Windsurf commands', async () => { + it('should generate Devin workflows for the retired windsurf id', async () => { const initCommand = new InitCommand({ tools: 'windsurf', force: true }); await initCommand.execute(testDir); - const cmdFile = path.join(testDir, '.windsurf', 'workflows', 'opsx-explore.md'); + const cmdFile = path.join(testDir, '.devin', 'workflows', 'opsx-explore.md'); expect(await fileExists(cmdFile)).toBe(true); }); + it('should generate Devin Desktop workflows that reference the hyphen form Devin registers', async () => { + const initCommand = new InitCommand({ tools: 'devin', force: true }); + await initCommand.execute(testDir); + + const cmdFile = path.join(testDir, '.devin', 'workflows', 'opsx-apply.md'); + expect(await fileExists(cmdFile)).toBe(true); + + const content = await fs.readFile(cmdFile, 'utf-8'); + expect(content).toMatch(/^---\nname: "/); + expect(content).toContain('category: "Workflow"'); + // Devin discovers `.devin/workflows/opsx-apply.md` as `/opsx-apply`. + expect(content).toContain('/opsx-'); + expect(content).not.toContain('/opsx:'); + }); + + it('should generate Devin Desktop skills that reference skills, not workflows', async () => { + const initCommand = new InitCommand({ tools: 'devin', force: true }); + await initCommand.execute(testDir); + + // The Devin Local agent has no workflows, so skill bodies must point at + // `/openspec-*` skills, which both Devin agents accept. + const skillFile = path.join(testDir, '.devin', 'skills', 'openspec-apply-change', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + const content = await fs.readFile(skillFile, 'utf-8'); + expect(content).toContain('/openspec-apply-change'); + expect(content).not.toContain('/opsx:'); + expect(content).not.toContain('/opsx-'); + }); + it('should generate Continue prompt files', async () => { const initCommand = new InitCommand({ tools: 'continue', force: true }); await initCommand.execute(testDir); diff --git a/test/core/legacy-cleanup.test.ts b/test/core/legacy-cleanup.test.ts index 9ef0197ac6..c178054802 100644 --- a/test/core/legacy-cleanup.test.ts +++ b/test/core/legacy-cleanup.test.ts @@ -1109,7 +1109,7 @@ ${OPENSPEC_MARKERS.end}`); pattern: '.cursor/commands/openspec-*.md', }); - expect(LEGACY_SLASH_COMMAND_PATHS['windsurf']).toEqual({ + expect(LEGACY_SLASH_COMMAND_PATHS['devin']).toEqual({ type: 'files', pattern: '.windsurf/workflows/openspec-*.md', }); @@ -1220,7 +1220,7 @@ ${OPENSPEC_MARKERS.end}`); expect(tools).toContain('claude'); expect(tools).toContain('qoder'); expect(tools).toContain('cursor'); - expect(tools).toContain('windsurf'); + expect(tools).toContain('devin'); expect(tools).toHaveLength(4); }); diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index 0d5e74febd..5b86abf0dd 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -50,7 +50,7 @@ describe('tool-detection', () => { expect(tools).toContain('claude'); expect(tools).toContain('codeartsagent'); expect(tools).toContain('cursor'); - expect(tools).toContain('windsurf'); + expect(tools).toContain('devin'); expect(tools.length).toBeGreaterThan(0); }); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index b7e3dc11d8..c6f6a6a3ea 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -222,7 +222,7 @@ Old instructions content expect(await fs.readFile(path.join(testDir, '.kimi', 'config.toml'), 'utf-8')).toBe('user config'); const logCalls = consoleSpy.mock.calls.flat().map(String); - expect(logCalls.some((entry) => entry.includes('.kimi/skills') && entry.includes('.kimi-code/skills'))).toBe(true); + expect(logCalls.some((entry) => entry.includes('.kimi → .kimi-code'))).toBe(true); consoleSpy.mockRestore(); }); @@ -492,6 +492,35 @@ Old instructions content } }); + it('should refresh both Devin Desktop surfaces with the right invocation syntax', async () => { + // Set up Devin Desktop directory with a skill to indicate it's configured + const skillsDir = path.join(testDir, '.devin', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-apply-change'), { + recursive: true, + }); + const skillFile = path.join(skillsDir, 'openspec-apply-change', 'SKILL.md'); + await fs.writeFile(skillFile, 'old content'); + + await updateCommand.execute(testDir); + + // Workflows are invoked by filename, so their bodies use `/opsx-*`. + const workflow = path.join(testDir, '.devin', 'workflows', 'opsx-apply.md'); + expect(await FileSystemUtils.fileExists(workflow)).toBe(true); + + const workflowContent = await fs.readFile(workflow, 'utf-8'); + expect(workflowContent).toMatch(/^---\nname: "/); + expect(workflowContent).toContain('/opsx-'); + expect(workflowContent).not.toContain('/opsx:'); + + // Skills are refreshed too, and point at skills — the Devin Local agent + // has no workflows to point at. + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).not.toContain('old content'); + expect(skillContent).toContain('/openspec-apply-change'); + expect(skillContent).not.toContain('/opsx:'); + expect(skillContent).not.toContain('/opsx-'); + }); + it('should update command files when tool is configured via commands-only delivery without skills', async () => { setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); @@ -587,32 +616,230 @@ Old instructions content expect(content).toContain('description:'); }); - it('should update Windsurf tool with correct command format', async () => { - // Set up Windsurf - const windsurfSkillsDir = path.join(testDir, '.windsurf', 'skills'); - await fs.mkdir(path.join(windsurfSkillsDir, 'openspec-explore'), { - recursive: true, - }); - await fs.writeFile( - path.join(windsurfSkillsDir, 'openspec-explore', 'SKILL.md'), - 'old' + it('should migrate a legacy .windsurf install to .devin, preserving user files', async () => { + // A project set up before the Devin Desktop rebrand: OpenSpec skills and + // workflows under .windsurf/, alongside files the user wrote themselves. + const legacySkillDir = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkillDir, { recursive: true }); + await fs.writeFile(path.join(legacySkillDir, 'SKILL.md'), 'old skill content'); + + const legacyWorkflows = path.join(testDir, '.windsurf', 'workflows'); + await fs.mkdir(legacyWorkflows, { recursive: true }); + await fs.writeFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'old workflow content'); + + // User-owned content that must survive untouched + const userSkillDir = path.join(testDir, '.windsurf', 'skills', 'my-custom-skill'); + await fs.mkdir(userSkillDir, { recursive: true }); + await fs.writeFile(path.join(userSkillDir, 'SKILL.md'), 'user skill'); + await fs.writeFile(path.join(legacyWorkflows, 'my-workflow.md'), 'user workflow'); + + // Tests run non-interactively, so the consent-gated move is taken. + await updateCommand.execute(testDir); + + // Both surfaces now live under .devin and were refreshed + const migratedSkill = await fs.readFile( + path.join(testDir, '.devin', 'skills', 'openspec-explore', 'SKILL.md'), + 'utf-8' + ); + expect(migratedSkill).not.toContain('old skill content'); + const migratedWorkflow = await fs.readFile( + path.join(testDir, '.devin', 'workflows', 'opsx-explore.md'), + 'utf-8' ); + expect(migratedWorkflow).not.toContain('old workflow content'); + expect(migratedWorkflow).toContain('---'); + // The OpenSpec-managed originals are gone; the user's files are not + await expect(fs.access(legacySkillDir)).rejects.toThrow(); + await expect( + fs.access(path.join(legacyWorkflows, 'opsx-explore.md')) + ).rejects.toThrow(); + expect(await fs.readFile(path.join(userSkillDir, 'SKILL.md'), 'utf-8')).toBe('user skill'); + expect( + await fs.readFile(path.join(legacyWorkflows, 'my-workflow.md'), 'utf-8') + ).toBe('user workflow'); + }); + + it('should not delete the install when the legacy root is a symlink to the current one', async () => { + // Symlinking the two roots is a realistic way to straddle the rebrand. + // Source and destination are then the same file, so a naive + // "destination exists, drop the legacy copy" would delete the original. await updateCommand.execute(testDir); + const devinSkill = path.join(testDir, '.devin', 'skills', 'openspec-explore'); + await fs.mkdir(devinSkill, { recursive: true }); + await fs.writeFile(path.join(devinSkill, 'SKILL.md'), 'real content'); + await fs.symlink('.devin', path.join(testDir, '.windsurf')); - // Check Windsurf command format - const windsurfCmd = path.join( - testDir, - '.windsurf', - 'workflows', - 'opsx-explore.md' + await updateCommand.execute(testDir); + + // The real file is still there, through either path + expect(await FileSystemUtils.fileExists(path.join(devinSkill, 'SKILL.md'))).toBe(true); + }); + + it('should keep user files that live inside an OpenSpec-managed skill directory', async () => { + // Both roots holding the same skill is the normal state after a rebrand. + // A reference the user wrote beside SKILL.md is theirs and never moves. + const devinSkill = path.join(testDir, '.devin', 'skills', 'openspec-explore'); + await fs.mkdir(devinSkill, { recursive: true }); + await fs.writeFile(path.join(devinSkill, 'SKILL.md'), 'current'); + + const legacySkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkill, { recursive: true }); + await fs.writeFile(path.join(legacySkill, 'SKILL.md'), 'current'); + await fs.writeFile(path.join(legacySkill, 'reference.md'), 'my notes'); + + await updateCommand.execute(testDir); + + // Byte-identical to the survivor, so the redundant copy goes + await expect(fs.access(path.join(legacySkill, 'SKILL.md'))).rejects.toThrow(); + expect(await fs.readFile(path.join(legacySkill, 'reference.md'), 'utf-8')).toBe('my notes'); + }); + + it('should report divergent files even when nothing is movable', async () => { + // Every legacy file differs from its counterpart, so there is no move to + // make. Staying silent would leave two divergent copies the user never + // hears about, so the result is reported rather than dropped. + const devinSkill = path.join(testDir, '.devin', 'skills', 'openspec-explore'); + await fs.mkdir(devinSkill, { recursive: true }); + await fs.writeFile(path.join(devinSkill, 'SKILL.md'), 'current'); + const devinWorkflows = path.join(testDir, '.devin', 'workflows'); + await fs.mkdir(devinWorkflows, { recursive: true }); + await fs.writeFile(path.join(devinWorkflows, 'opsx-explore.md'), 'current'); + + const legacySkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkill, { recursive: true }); + await fs.writeFile(path.join(legacySkill, 'SKILL.md'), 'mine'); + const legacyWorkflows = path.join(testDir, '.windsurf', 'workflows'); + await fs.mkdir(legacyWorkflows, { recursive: true }); + await fs.writeFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'mine'); + + const consoleSpy = vi.spyOn(console, 'log'); + await updateCommand.execute(testDir); + const logCalls = consoleSpy.mock.calls.flat().map(String); + consoleSpy.mockRestore(); + + // The divergence is surfaced... + expect(logCalls.some((entry) => entry.includes('Left 2 files in .windsurf/'))).toBe(true); + // ...without claiming a migration that did not happen. Matched on the + // directory arrow rather than the word "Migrated", which also begins the + // unrelated profile-migration line ("Migrated: custom profile with N + // workflows") that fires only under some config states. + expect(logCalls.some((entry) => entry.includes('.windsurf → .devin'))).toBe(false); + expect(logCalls.some((entry) => entry.includes('Migrated 0'))).toBe(false); + // ...and nothing was touched + expect(await fs.readFile(path.join(legacySkill, 'SKILL.md'), 'utf-8')).toBe('mine'); + expect(await fs.readFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'utf-8')).toBe('mine'); + }); + + it('should keep a legacy SKILL.md the user edited, matching how command files are treated', async () => { + // Skills and commands must follow one rule. An earlier draft compared + // content for commands and not for skills, so the same situation + // destroyed a user's edited skill while preserving their edited command. + const devinSkill = path.join(testDir, '.devin', 'skills', 'openspec-explore'); + await fs.mkdir(devinSkill, { recursive: true }); + await fs.writeFile(path.join(devinSkill, 'SKILL.md'), 'current'); + const devinWorkflows = path.join(testDir, '.devin', 'workflows'); + await fs.mkdir(devinWorkflows, { recursive: true }); + await fs.writeFile(path.join(devinWorkflows, 'opsx-explore.md'), 'current'); + + const legacySkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkill, { recursive: true }); + await fs.writeFile(path.join(legacySkill, 'SKILL.md'), 'my edited skill'); + const legacyWorkflows = path.join(testDir, '.windsurf', 'workflows'); + await fs.mkdir(legacyWorkflows, { recursive: true }); + await fs.writeFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'my edited command'); + + await updateCommand.execute(testDir); + + expect(await fs.readFile(path.join(legacySkill, 'SKILL.md'), 'utf-8')).toBe( + 'my edited skill' ); - const exists = await FileSystemUtils.fileExists(windsurfCmd); - expect(exists).toBe(true); + expect(await fs.readFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'utf-8')).toBe( + 'my edited command' + ); + }); - const content = await fs.readFile(windsurfCmd, 'utf-8'); - expect(content).toContain('---'); - expect(content).toContain('name:'); + it('should not carry a user file into a skill directory that commands-only delivery deletes', async () => { + // Only SKILL.md may cross. The destination is a directory OpenSpec owns + // and removes on its own under commands-only delivery, so moving the + // whole legacy directory would hand the user's file to that removal. + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + const legacySkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkill, { recursive: true }); + await fs.writeFile(path.join(legacySkill, 'SKILL.md'), 'stale'); + await fs.writeFile(path.join(legacySkill, 'reference.md'), 'my notes'); + + await updateCommand.execute(testDir); + + expect(await fs.readFile(path.join(legacySkill, 'reference.md'), 'utf-8')).toBe('my notes'); + await expect(fs.access(path.join(legacySkill, 'SKILL.md'))).rejects.toThrow(); + }); + + it('should not carry a user file into a skill directory a deselected workflow deletes', async () => { + // openspec-new-change is outside the core profile, so the skill + // directory it would land in is one OpenSpec prunes. + const legacySkill = path.join(testDir, '.windsurf', 'skills', 'openspec-new-change'); + await fs.mkdir(legacySkill, { recursive: true }); + await fs.writeFile(path.join(legacySkill, 'SKILL.md'), 'stale'); + await fs.writeFile(path.join(legacySkill, 'reference.md'), 'my notes'); + + await updateCommand.execute(testDir); + + expect(await fs.readFile(path.join(legacySkill, 'reference.md'), 'utf-8')).toBe('my notes'); + }); + + it('should still fully vacate a legacy skill directory that holds only SKILL.md', async () => { + // The safety rule must not leave empty scaffolding behind in the + // ordinary case, where there is nothing of the user's to preserve. + const legacySkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkill, { recursive: true }); + await fs.writeFile(path.join(legacySkill, 'SKILL.md'), 'stale'); + + await updateCommand.execute(testDir); + + expect( + await FileSystemUtils.fileExists( + path.join(testDir, '.devin', 'skills', 'openspec-explore', 'SKILL.md') + ) + ).toBe(true); + await expect(fs.access(path.join(testDir, '.windsurf'))).rejects.toThrow(); + }); + + it('should keep a legacy command file the user edited, and drop an identical one', async () => { + const devinWorkflows = path.join(testDir, '.devin', 'workflows'); + await fs.mkdir(devinWorkflows, { recursive: true }); + await fs.writeFile(path.join(devinWorkflows, 'opsx-explore.md'), 'generated'); + await fs.writeFile(path.join(devinWorkflows, 'opsx-apply.md'), 'generated'); + + const legacyWorkflows = path.join(testDir, '.windsurf', 'workflows'); + await fs.mkdir(legacyWorkflows, { recursive: true }); + // Edited by the user — deleting it would throw the edit away + await fs.writeFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'my edits'); + // Byte-identical — nothing is lost by dropping it + await fs.writeFile(path.join(legacyWorkflows, 'opsx-apply.md'), 'generated'); + + await updateCommand.execute(testDir); + + expect(await fs.readFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'utf-8')).toBe( + 'my edits' + ); + await expect(fs.access(path.join(legacyWorkflows, 'opsx-apply.md'))).rejects.toThrow(); + }); + + it('should leave a migrated project alone on the next run', async () => { + // The move must be idempotent: once .windsurf/ holds nothing of ours, + // a second update has nothing to migrate and nothing to announce. + const legacySkillDir = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkillDir, { recursive: true }); + await fs.writeFile(path.join(legacySkillDir, 'SKILL.md'), 'old'); + + await updateCommand.execute(testDir); + + const consoleSpy = vi.spyOn(console, 'log'); + await updateCommand.execute(testDir); + const logCalls = consoleSpy.mock.calls.flat().map(String); + expect(logCalls.some((entry) => entry.includes('.windsurf → .devin'))).toBe(false); + consoleSpy.mockRestore(); }); }); diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index 5fb8ebcc60..4ed4fc4f32 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -264,6 +264,23 @@ describe('getTransformerForTool', () => { } }); + it('selects skill references for devin whenever skills are generated', () => { + // The Devin Local agent has no workflows, so Devin skill bodies and the + // getting-started hint must name `/openspec-*` skills, which both Devin + // agents accept. Workflow bodies get the hyphen form from the generator, + // like every other flat-invocation tool. + expect(getTransformerForTool('devin', 'both', 'adapter-backed', FLAT_SLASH)).toBe( + transformToSkillReferences + ); + expect(getTransformerForTool('devin', 'skills', 'adapter-backed', FLAT_SLASH)).toBe( + transformToSkillReferences + ); + // Under commands-only delivery no Devin skills exist to point at, so the + // hint falls back to the workflow name Devin registers. + const commandsOnly = getTransformerForTool('devin', 'commands', 'adapter-backed', FLAT_SLASH); + expect(commandsOnly?.('/opsx:propose')).toBe('/opsx-propose'); + }); + it("selects Amazon Q's @-prefixed prompt form when commands are generated", () => { // Amazon Q loads .amazonq/prompts/opsx-.md into its prompt library, // which is invoked with @ — it registers no slash command at all. diff --git a/website/app/(home)/page.tsx b/website/app/(home)/page.tsx index b5bd1928f2..60433e7ec4 100644 --- a/website/app/(home)/page.tsx +++ b/website/app/(home)/page.tsx @@ -418,7 +418,7 @@ const TOOLS = [ 'Claude Code', 'Cursor', 'Codex', - 'Windsurf', + 'Devin Desktop', 'Gemini CLI', 'GitHub Copilot', 'Cline', @@ -615,7 +615,7 @@ function FinalCta() { Ship your first change in five minutes

- Works with 30+ AI assistants — Claude Code, Cursor, Codex, Windsurf, + Works with 30+ AI assistants — Claude Code, Cursor, Codex, Devin Desktop, Gemini CLI, and more.