From e0d01c16def757b779e0a7a8dd648452ee739710 Mon Sep 17 00:00:00 2001 From: Mehdi Shahdoost Date: Thu, 4 Jun 2026 11:56:58 +0200 Subject: [PATCH 01/17] proposal: add devin desktop support --- .../add-devin-desktop-support/.openspec.yaml | 2 + .../add-devin-desktop-support/proposal.md | 33 +++++++ .../specs/cli-init/spec.md | 34 +++++++ .../specs/cli-update/spec.md | 19 ++++ .../add-devin-desktop-support/tasks.md | 92 +++++++++++++++++++ 5 files changed, 180 insertions(+) create mode 100644 openspec/changes/add-devin-desktop-support/.openspec.yaml create mode 100644 openspec/changes/add-devin-desktop-support/proposal.md create mode 100644 openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md create mode 100644 openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md create mode 100644 openspec/changes/add-devin-desktop-support/tasks.md 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..139a77cb80 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/proposal.md @@ -0,0 +1,33 @@ +## Why + +- Windsurf has been rebranded to **Devin Desktop**, the new flagship AI coding assistant from Cognition. Users who previously used Windsurf are now transitioning to Devin Desktop. +- Devin Desktop uses the same workflow system as Windsurf (Cascade workflows stored in `.devin/workflows/`), making it a natural migration path for existing OpenSpec users. +- OpenSpec currently supports Windsurf but not Devin Desktop. Adding Devin Desktop support ensures users can continue using OpenSpec with their new tool without manual migration. +- The adapter pattern is already established and proven with Windsurf; extending it to Devin Desktop is straightforward and maintains consistency across the tool ecosystem. + +## What Changes + +- Add **Devin Desktop** (`devin`) to the CLI tool picker (`openspec init`) so users can select it during setup. +- Create a new **Devin adapter** (`src/core/command-generation/adapters/devin.ts`) that generates commands in `.devin/workflows/opsx-.md` with the same frontmatter structure as Windsurf. +- Register the Devin adapter in the command adapter registry (`src/core/command-generation/registry.ts`) and export it from the adapters index. +- Update `docs/supported-tools.md` to include Devin Desktop in the tool reference table. +- Ensure `openspec update` refreshes existing Devin workflows in-place, mirroring current behavior for other editors. +- Extend unit tests for init/update to cover Devin Desktop generation and updates. +- Update CLI prompts and documentation to advertise Devin Desktop support. + +## Impact + +- **Specs:** `cli-init`, `cli-update`, `command-generation` +- **Code:** + - `src/core/command-generation/adapters/devin.ts` (new adapter) + - `src/core/command-generation/registry.ts` (register adapter) + - `src/core/command-generation/adapters/index.ts` (export adapter) + - CLI tool selection logic +- **Docs:** `docs/supported-tools.md` +- **Tests:** init/update integration coverage for Devin Desktop workflows + +## Notes + +- This is a **migration enabler** for existing Windsurf users transitioning to Devin Desktop. +- Windsurf support can remain in place for backward compatibility with users still on Windsurf. +- The implementation closely mirrors the existing Windsurf adapter, reducing complexity and risk. 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..3bc56d2940 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md @@ -0,0 +1,34 @@ +## MODIFIED Requirements + +### Requirement: AI Tool Configuration +The command SHALL configure AI coding assistants with OpenSpec instructions using a marker system. + +#### Scenario: Prompting for AI tool selection +- **WHEN** run interactively +- **THEN** prompt the user with "Which AI tools do you use?" using a multi-select menu +- **AND** list every available tool with a checkbox: + - Claude Code (creates or refreshes CLAUDE.md and slash commands) + - Cursor (creates or refreshes `.cursor/commands/*` slash commands) + - OpenCode (creates or refreshes `.opencode/command/openspec-*.md` slash commands) + - Devin Desktop (creates or refreshes `.devin/workflows/opsx-*.md` workflows) + - Windsurf (creates or refreshes `.windsurf/workflows/opsx-*.md` workflows) + - AGENTS.md standard (creates or refreshes AGENTS.md with OpenSpec markers) +- **AND** show "(already configured)" beside tools whose managed files exist so users understand selections will refresh content +- **AND** treat disabled tools as "coming soon" and keep them unselectable +- **AND** allow confirming with Enter after selecting one or more tools + +### Requirement: Slash Command Configuration +The init command SHALL generate slash command files for supported editors using shared templates. + +#### Scenario: Generating workflows for Devin Desktop +- **WHEN** the user selects Devin Desktop during initialization +- **THEN** create `.devin/workflows/opsx-propose.md`, `.devin/workflows/opsx-apply.md`, and `.devin/workflows/opsx-archive.md` +- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools +- **AND** each template includes instructions for the relevant OpenSpec workflow stage +- **AND** use the same frontmatter structure as Windsurf (name, description, category, tags) + +#### Scenario: Generating workflows for Windsurf +- **WHEN** the user selects Windsurf during initialization +- **THEN** create `.windsurf/workflows/opsx-propose.md`, `.windsurf/workflows/opsx-apply.md`, and `.windsurf/workflows/opsx-archive.md` +- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools +- **AND** each template includes instructions for the relevant OpenSpec workflow stage 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..83fb4686f5 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md @@ -0,0 +1,19 @@ +## MODIFIED Requirements + +### Requirement: Slash Command Updates +The update command SHALL refresh existing slash command files for configured tools without creating new ones. + +#### Scenario: Updating workflows for Devin Desktop +- **WHEN** `.devin/workflows/` contains `opsx-propose.md`, `opsx-apply.md`, and `opsx-archive.md` +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** preserve the frontmatter structure (name, description, category, tags) + +#### Scenario: Updating workflows for Windsurf +- **WHEN** `.windsurf/workflows/` contains `opsx-propose.md`, `opsx-apply.md`, and `opsx-archive.md` +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Missing workflow file +- **WHEN** a tool lacks a workflow file +- **THEN** do not create a new file during update 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..72b954e188 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/tasks.md @@ -0,0 +1,92 @@ +# Implementation Tasks + +## 1. Create Devin Desktop Adapter + +### 1.1 Create adapter file +- Create `src/core/command-generation/adapters/devin.ts` +- Base implementation on the existing Windsurf adapter (`src/core/command-generation/adapters/windsurf.ts`) +- Use `.devin/workflows/` as the target directory +- Use `opsx-.md` as the filename pattern +- Include frontmatter with: name, description, category, tags + +### 1.2 Implement adapter interface +- Export `devinAdapter` object implementing `ToolCommandAdapter` +- Set `toolId` to `'devin'` +- Implement `getFilePath()` to return `.devin/workflows/opsx-.md` +- Implement `formatFile()` to generate YAML frontmatter + body content + +## 2. Register Adapter + +### 2.1 Update registry +- Edit `src/core/command-generation/registry.ts` +- Import the new `devinAdapter` +- Register it in the static initializer: `CommandAdapterRegistry.register(devinAdapter)` + +### 2.2 Export adapter +- Edit `src/core/command-generation/adapters/index.ts` +- Add export: `export { devinAdapter } from './devin.js'` +- Update main index if needed: `src/core/command-generation/index.ts` + +## 3. Update CLI Tool Selection + +### 3.1 Add Devin Desktop to tool picker +- Locate CLI initialization code that prompts for tool selection +- Add "Devin Desktop" option to the multi-select menu +- Ensure it appears alongside Windsurf and other tools +- Map selection to `devin` tool ID + +## 4. Update Documentation + +### 4.1 Update supported tools reference +- Edit `docs/supported-tools.md` +- Add Devin Desktop row to the tool directory reference table +- Include: + - Tool name and ID: `Devin Desktop (devin)` + - Skills path: `.devin/skills/openspec-*/SKILL.md` + - Command path: `.devin/workflows/opsx-.md` +- Add `devin` to the available tool IDs list in the "Non-Interactive Setup" section + +### 4.2 Update README if needed +- Check if README mentions tool count or lists specific tools +- Update any references to reflect Devin Desktop support + +## 5. Add Tests + +### 5.1 Test adapter functionality +- Create or update tests for the Devin adapter +- Test `getFilePath()` returns correct path +- Test `formatFile()` generates valid YAML frontmatter +- Test cross-platform path handling (Windows, macOS, Linux) + +### 5.2 Test CLI integration +- Test `openspec init --tools devin` generates `.devin/workflows/` files +- Test `openspec update` refreshes existing Devin workflows +- Test that Devin Desktop appears in interactive tool selection +- Verify files are created with correct structure and content + +### 5.3 Test backward compatibility +- Ensure Windsurf adapter still works +- Verify both Devin and Windsurf can be selected together +- Test that existing Windsurf installations are not affected + +## 6. Verify and Polish + +### 6.1 Manual testing +- Run `openspec init` and select Devin Desktop +- Verify `.devin/workflows/` directory is created +- Check that workflow files have correct frontmatter and content +- Run `openspec update` and verify files are refreshed +- Test on Windows, macOS, and Linux if possible + +### 6.2 Code review checklist +- Adapter follows existing patterns (Windsurf, Cursor, Claude) +- No hardcoded paths (use `path.join()`) +- YAML escaping handles special characters +- Error handling is consistent with other adapters +- Comments are clear and helpful + +### 6.3 Documentation review +- Supported tools table is accurate and complete +- Tool IDs are consistent across docs +- Examples show Devin Desktop usage +- Links and references are correct From a0a3fb6e670980ed586e9db8f41d25fe8d40d542 Mon Sep 17 00:00:00 2001 From: Mehdi Shahdoost Date: Thu, 4 Jun 2026 12:08:31 +0200 Subject: [PATCH 02/17] feat(adapters): add devin desktop command adapter - Create new Devin Desktop adapter for .devin/workflows/opsx-.md - Register adapter in CommandAdapterRegistry - Export adapter from adapters index - Update docs/supported-tools.md with Devin Desktop entry - Add 'devin' to available tool IDs list Devin Desktop uses the same Cascade workflow system as Windsurf, making it a natural migration path for existing users. --- docs/supported-tools.md | 3 +- src/core/command-generation/adapters/devin.ts | 57 +++++++++++++++++++ src/core/command-generation/adapters/index.ts | 1 + src/core/command-generation/registry.ts | 2 + 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/core/command-generation/adapters/devin.ts diff --git a/docs/supported-tools.md b/docs/supported-tools.md index b2ee30fb42..667f150496 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -30,6 +30,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-.md` | | CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/.md` | | Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | `$CODEX_HOME/prompts/opsx-.md`\* | +| Devin Desktop (`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` | @@ -75,7 +76,7 @@ openspec init --tools none openspec init --profile core ``` -**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `vibe`, `windsurf` +**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `vibe`, `windsurf` ## Workflow-Dependent Installation diff --git a/src/core/command-generation/adapters/devin.ts b/src/core/command-generation/adapters/devin.ts new file mode 100644 index 0000000000..a16d18a086 --- /dev/null +++ b/src/core/command-generation/adapters/devin.ts @@ -0,0 +1,57 @@ +/** + * Devin Desktop Command Adapter + * + * Formats commands for Devin Desktop following its frontmatter specification. + * Devin Desktop uses the same Cascade workflow system as Windsurf. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Escapes a string value for safe YAML output. + * Quotes the string if it contains special YAML characters. + */ +function escapeYamlValue(value: string): string { + // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace) + const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); + if (needsQuoting) { + // Use double quotes and escape internal double quotes and backslashes + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + return `"${escaped}"`; + } + return value; +} + +/** + * Formats a tags array as a YAML array with proper escaping. + */ +function formatTagsArray(tags: string[]): string { + const escapedTags = tags.map((tag) => escapeYamlValue(tag)); + return `[${escapedTags.join(', ')}]`; +} + +/** + * Devin Desktop adapter for command generation. + * File path: .devin/workflows/opsx-.md + * Frontmatter: name, description, category, tags + */ +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 00fc75d5d6..97015d3db3 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -11,6 +11,7 @@ export { bobAdapter } from './bob.js'; export { claudeAdapter } from './claude.js'; export { clineAdapter } from './cline.js'; export { codexAdapter } from './codex.js'; +export { devinAdapter } from './devin.js'; export { codebuddyAdapter } from './codebuddy.js'; export { continueAdapter } from './continue.js'; export { costrictAdapter } from './costrict.js'; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index 3b726d707d..69a12f0fb1 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -13,6 +13,7 @@ import { bobAdapter } from './adapters/bob.js'; import { claudeAdapter } from './adapters/claude.js'; import { clineAdapter } from './adapters/cline.js'; import { codexAdapter } from './adapters/codex.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'; @@ -48,6 +49,7 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(claudeAdapter); CommandAdapterRegistry.register(clineAdapter); CommandAdapterRegistry.register(codexAdapter); + CommandAdapterRegistry.register(devinAdapter); CommandAdapterRegistry.register(codebuddyAdapter); CommandAdapterRegistry.register(continueAdapter); CommandAdapterRegistry.register(costrictAdapter); From 514699e569d2e98b76340e091d16119dc2fbe3a4 Mon Sep 17 00:00:00 2001 From: Mehdi Shahdoost Date: Thu, 4 Jun 2026 12:35:53 +0200 Subject: [PATCH 03/17] fix(config): add devin desktop to AI_TOOLS Add Devin Desktop entry to AI_TOOLS configuration so that: - getToolsWithSkillsDir() includes 'devin' as a valid tool ID - getWorkspaceSkillToolIds() returns 'devin' in the list - parseWorkspaceSkillToolsValue() accepts 'devin' as valid input - openspec init --tools devin works correctly This fixes validation failures where 'devin' was documented in docs/supported-tools.md but not recognized by validation functions that derive valid IDs from AI_TOOLS. --- src/core/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/config.ts b/src/core/config.ts index 3be428b26d..29182655cd 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -26,6 +26,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' }, { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' }, { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' }, + { name: 'Devin Desktop', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin' }, { 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' }, From fd974e18192cae9af2eee8a079ece985d624596c Mon Sep 17 00:00:00 2001 From: Mehdi Shahdoost Date: Thu, 4 Jun 2026 12:38:15 +0200 Subject: [PATCH 04/17] fix(devin-adapter): escape implicit YAML scalars in frontmatter Update escapeYamlValue to detect and quote implicit YAML scalars that would be coerced by parsers: - Booleans: true, false, yes, no, on, off - Null variants: null, ~ - Numbers: integers, floats, exponentials, hex (0x), octal (0o) - Edge cases: standalone dash (-) and dot (.) This ensures values like 'true', '123', 'null' remain strings in YAML frontmatter instead of being interpreted as booleans, numbers, or nulls. Preserves existing escaping logic for special characters and newlines. --- src/core/command-generation/adapters/devin.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/core/command-generation/adapters/devin.ts b/src/core/command-generation/adapters/devin.ts index a16d18a086..33ceba6d65 100644 --- a/src/core/command-generation/adapters/devin.ts +++ b/src/core/command-generation/adapters/devin.ts @@ -10,12 +10,18 @@ import type { CommandContent, ToolCommandAdapter } from '../types.js'; /** * Escapes a string value for safe YAML output. - * Quotes the string if it contains special YAML characters. + * Quotes the string if it contains special YAML characters or would be + * interpreted as an implicit YAML scalar (boolean, null, number, etc). */ function escapeYamlValue(value: string): string { - // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace) - const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); - if (needsQuoting) { + // Check if value needs quoting due to special YAML characters or whitespace + const hasSpecialChars = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); + + // Check if value would be interpreted as an implicit YAML scalar + // Matches: booleans (true/false/yes/no/on/off), null variants, numbers, hex/octal + const isImplicitScalar = /^(true|false|yes|no|on|off|null|~|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|0x[0-9a-fA-F]+|0o[0-7]+|-|\.?)$/.test(value); + + if (hasSpecialChars || isImplicitScalar) { // Use double quotes and escape internal double quotes and backslashes const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); return `"${escaped}"`; From 9a8befa2025066c7860bf0ccb1bfdc696dc75672 Mon Sep 17 00:00:00 2001 From: Mehdi Shahdoost Date: Sat, 6 Jun 2026 18:31:41 +0200 Subject: [PATCH 05/17] test(devin-adapter): add comprehensive tests for Devin Desktop adapter Add test coverage for the Devin Desktop adapter including: - Command reference transformation from colon to hyphen syntax - YAML frontmatter escaping for special characters and implicit scalars - File path generation for workflows - Integration with available tools detection - Init and update command workflows --- src/core/command-generation/adapters/devin.ts | 8 ++- test/core/available-tools.test.ts | 13 ++++ test/core/command-generation/adapters.test.ts | 71 +++++++++++++++++++ test/core/command-generation/registry.test.ts | 12 +++- test/core/init.test.ts | 15 ++++ test/core/update.test.ts | 27 +++++++ 6 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/core/command-generation/adapters/devin.ts b/src/core/command-generation/adapters/devin.ts index 33ceba6d65..17b86600e2 100644 --- a/src/core/command-generation/adapters/devin.ts +++ b/src/core/command-generation/adapters/devin.ts @@ -6,6 +6,7 @@ */ import path from 'path'; +import { transformToHyphenCommands } from '../../../utils/command-references.js'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; /** @@ -41,6 +42,8 @@ function formatTagsArray(tags: string[]): string { * Devin Desktop adapter for command generation. * File path: .devin/workflows/opsx-.md * Frontmatter: name, description, category, tags + * + * Devin Desktop uses slash-hyphen syntax (/opsx-apply) instead of colon syntax (/opsx:apply). */ export const devinAdapter: ToolCommandAdapter = { toolId: 'devin', @@ -50,6 +53,9 @@ export const devinAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { + // Transform command references from colon to hyphen syntax + const transformedBody = transformToHyphenCommands(content.body); + return `--- name: ${escapeYamlValue(content.name)} description: ${escapeYamlValue(content.description)} @@ -57,7 +63,7 @@ category: ${escapeYamlValue(content.category)} tags: ${formatTagsArray(content.tags)} --- -${content.body} +${transformedBody} `; }, }; diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index 50d7580702..b23121a427 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -46,6 +46,19 @@ describe('available-tools', () => { 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'); + expect(devinTool?.skillsDir).toBe('.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 b91dc024fb..09684f9100 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -13,6 +13,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'; @@ -126,6 +127,76 @@ describe('command-generation/adapters', () => { }); }); + describe('devinAdapter', () => { + it('should have correct toolId', () => { + expect(devinAdapter.toolId).toBe('devin'); + }); + + it('should generate correct file path', () => { + const filePath = devinAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.devin', 'workflows', 'opsx-explore.md')); + }); + + it('should generate correct file paths for different commands', () => { + expect(devinAdapter.getFilePath('new')).toBe(path.join('.devin', 'workflows', 'opsx-new.md')); + expect(devinAdapter.getFilePath('bulk-archive')).toBe(path.join('.devin', 'workflows', 'opsx-bulk-archive.md')); + }); + + it('should format file with YAML frontmatter', () => { + const output = devinAdapter.formatFile(sampleContent); + + expect(output).toContain('---\n'); + expect(output).toContain('name: OpenSpec Explore'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('category: Workflow'); + expect(output).toContain('tags: [workflow, explore, experimental]'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + + it('should transform colon command references to hyphen format', () => { + const contentWithRefs: CommandContent = { + ...sampleContent, + body: 'Run /opsx:apply to implement. Then use /opsx:verify.', + }; + const output = devinAdapter.formatFile(contentWithRefs); + expect(output).toContain('/opsx-apply'); + expect(output).toContain('/opsx-verify'); + expect(output).not.toContain('/opsx:apply'); + expect(output).not.toContain('/opsx:verify'); + }); + + it('should escape YAML special characters in frontmatter', () => { + const contentWithSpecialChars: CommandContent = { + ...sampleContent, + name: 'Test: Command', + description: 'Fix "auth" feature', + }; + const output = devinAdapter.formatFile(contentWithSpecialChars); + expect(output).toContain('name: "Test: Command"'); + expect(output).toContain('description: "Fix \\"auth\\" feature"'); + }); + + it('should escape implicit YAML scalars in frontmatter', () => { + const contentWithImplicitScalar: CommandContent = { + ...sampleContent, + name: 'true', + description: 'null', + category: 'on', + }; + const output = devinAdapter.formatFile(contentWithImplicitScalar); + expect(output).toContain('name: "true"'); + expect(output).toContain('description: "null"'); + expect(output).toContain('category: "on"'); + }); + + it('should handle empty tags', () => { + const contentNoTags: CommandContent = { ...sampleContent, tags: [] }; + const output = devinAdapter.formatFile(contentNoTags); + expect(output).toContain('tags: []'); + }); + }); + describe('amazonQAdapter', () => { it('should have correct toolId', () => { expect(amazonQAdapter.toolId).toBe('amazon-q'); diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index 14165ff51b..6a665a005d 100644 --- a/test/core/command-generation/registry.test.ts +++ b/test/core/command-generation/registry.test.ts @@ -21,6 +21,12 @@ describe('command-generation/registry', () => { expect(adapter?.toolId).toBe('windsurf'); }); + 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"', () => { const adapter = CommandAdapterRegistry.get('junie'); expect(adapter).toBeDefined(); @@ -45,13 +51,14 @@ describe('command-generation/registry', () => { expect(adapters.length).toBeGreaterThanOrEqual(3); // At least Claude, Cursor, Windsurf }); - it('should include Claude, Cursor, and Windsurf adapters', () => { + it('should include Claude, Cursor, Windsurf, 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'); }); }); @@ -60,6 +67,7 @@ describe('command-generation/registry', () => { 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('junie')).toBe(true); }); @@ -74,10 +82,12 @@ describe('command-generation/registry', () => { 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 6a436eaed1..1f3b8af579 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -457,6 +457,21 @@ describe('InitCommand', () => { expect(await fileExists(cmdFile)).toBe(true); }); + it('should generate Devin Desktop workflows', async () => { + const initCommand = new InitCommand({ tools: 'devin', force: true }); + await initCommand.execute(testDir); + + const cmdFile = path.join(testDir, '.devin', 'workflows', 'opsx-explore.md'); + expect(await fileExists(cmdFile)).toBe(true); + + const content = await fs.readFile(cmdFile, 'utf-8'); + expect(content).toContain('---'); + expect(content).toContain('name:'); + expect(content).toContain('description:'); + // Verify command references are transformed to hyphen syntax + 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/update.test.ts b/test/core/update.test.ts index ea7f66a7ed..4e450b8c3e 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -251,6 +251,33 @@ Old instructions content } }); + it('should update Devin Desktop workflows with hyphen command references', 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-explore'), { + recursive: true, + }); + await fs.writeFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + 'old content' + ); + + await updateCommand.execute(testDir); + + // Verify workflows were created + const workflowsDir = path.join(testDir, '.devin', 'workflows'); + const exploreWorkflow = path.join(workflowsDir, 'opsx-explore.md'); + const exists = await FileSystemUtils.fileExists(exploreWorkflow); + expect(exists).toBe(true); + + const content = await fs.readFile(exploreWorkflow, 'utf-8'); + expect(content).toContain('---'); + expect(content).toContain('name:'); + expect(content).toContain('description:'); + // Verify command references are transformed to hyphen syntax + expect(content).not.toContain('/opsx:'); + }); + }); describe('multi-tool support', () => { From cbb8301dae942077c6ee73cd17b9c82b5da3bd9b Mon Sep 17 00:00:00 2001 From: Mehdi Shahdoost Date: Sat, 6 Jun 2026 18:47:47 +0200 Subject: [PATCH 06/17] Add cross-platform testcase. --- test/core/command-generation/adapters.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 09684f9100..e963f739c3 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -762,6 +762,11 @@ describe('command-generation/adapters', () => { 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', () => { // Verify all adapters produce valid paths const adapters = [ From e310b41cf222c590f7501eeb60588666337782a8 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 09:42:52 -0500 Subject: [PATCH 07/17] fix(devin): refresh deltas against canonical specs and point skills at skills Addresses the two release blockers on this PR. Archive: the change's MODIFIED blocks were written against an older canonical `cli-init`, so `openspec archive add-devin-desktop-support` aborted rather than merging. The deltas are regenerated from the current canonical specs (cli-init `Skill Generation` + `Slash Command Generation`, cli-update `Slash Command Updates`, and a new `ai-tool-paths` delta for the `.devin` skillsDir), each restating every existing scenario so archive is purely additive. Invocation syntax: only Devin Desktop reads `.devin/workflows/`, so a `/opsx-*` workflow reference is dead text on Devin Local, which supports skills only. Devin now takes the skill-reference transformer, so skill bodies and the getting-started hint say `/openspec-*`. Workflow bodies keep hyphen references, applied by devinAdapter itself. The adapter also drops its private copy of escapeYamlValue / formatTagsArray in favor of the shared helpers main centralized in #1447, which quote unconditionally and escape control characters. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/ai-tool-paths/spec.md | 46 ++++++++ .../specs/cli-init/spec.md | 95 ++++++++++----- .../specs/cli-update/spec.md | 111 ++++++++++++++++-- src/core/command-generation/adapters/devin.ts | 42 ++----- src/utils/command-references.ts | 8 +- test/core/command-generation/adapters.test.ts | 8 +- 6 files changed, 233 insertions(+), 77 deletions(-) create mode 100644 openspec/changes/add-devin-desktop-support/specs/ai-tool-paths/spec.md 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..235d6fc6e2 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/specs/ai-tool-paths/spec.md @@ -0,0 +1,46 @@ +# ai-tool-paths Delta Specification + +## 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 + +- **WHEN** looking up the `windsurf` tool +- **THEN** `skillsDir` SHALL be `.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` + +#### 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 index 3bc56d2940..ff66f66292 100644 --- a/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md +++ b/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md @@ -1,34 +1,71 @@ +# cli-init Delta Specification + ## MODIFIED Requirements -### Requirement: AI Tool Configuration -The command SHALL configure AI coding assistants with OpenSpec instructions using a marker system. - -#### Scenario: Prompting for AI tool selection -- **WHEN** run interactively -- **THEN** prompt the user with "Which AI tools do you use?" using a multi-select menu -- **AND** list every available tool with a checkbox: - - Claude Code (creates or refreshes CLAUDE.md and slash commands) - - Cursor (creates or refreshes `.cursor/commands/*` slash commands) - - OpenCode (creates or refreshes `.opencode/command/openspec-*.md` slash commands) - - Devin Desktop (creates or refreshes `.devin/workflows/opsx-*.md` workflows) - - Windsurf (creates or refreshes `.windsurf/workflows/opsx-*.md` workflows) - - AGENTS.md standard (creates or refreshes AGENTS.md with OpenSpec markers) -- **AND** show "(already configured)" beside tools whose managed files exist so users understand selections will refresh content -- **AND** treat disabled tools as "coming soon" and keep them unselectable -- **AND** allow confirming with Enter after selecting one or more tools - -### Requirement: Slash Command Configuration -The init command SHALL generate slash command files for supported editors using shared templates. +### 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** only Devin Desktop reads `.devin/workflows/`, while Devin Local supports skills but not workflows +- **WHEN** generating skills for the `devin` tool +- **THEN** rewrite `/opsx:` references in the skill body to the matching `/openspec-` invocation, which works on both Devin surfaces +- **AND** the getting-started hint SHALL name `/openspec-propose` rather than a workflow + +### 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 `.devin/workflows/opsx-propose.md`, `.devin/workflows/opsx-apply.md`, and `.devin/workflows/opsx-archive.md` -- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools -- **AND** each template includes instructions for the relevant OpenSpec workflow stage -- **AND** use the same frontmatter structure as Windsurf (name, description, category, tags) - -#### Scenario: Generating workflows for Windsurf -- **WHEN** the user selects Windsurf during initialization -- **THEN** create `.windsurf/workflows/opsx-propose.md`, `.windsurf/workflows/opsx-apply.md`, and `.windsurf/workflows/opsx-archive.md` -- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools -- **AND** each template includes instructions for the relevant OpenSpec workflow stage +- **THEN** create one workflow file per profile workflow at `.devin/workflows/opsx-.md` +- **AND** include Windsurf-style 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 index 83fb4686f5..b70a08adc2 100644 --- a/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md +++ b/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md @@ -1,19 +1,114 @@ +# 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. -#### Scenario: Updating workflows for Devin Desktop -- **WHEN** `.devin/workflows/` contains `opsx-propose.md`, `opsx-apply.md`, and `opsx-archive.md` +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 Windsurf 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`) for OpenCode compatibility +- **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** `.windsurf/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** preserve the frontmatter structure (name, description, category, tags) +- **AND** skip creating missing files (the update command only refreshes what already exists) + +#### Scenario: Updating workflows for Devin Desktop +- **WHEN** `.devin/workflows/` contains OpenSpec-managed `opsx-*.md` workflow files (for example `opsx-propose.md`, `opsx-apply.md`, and `opsx-archive.md`) +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** preserve the frontmatter structure (`name`, `description`, `category`, `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, which work on Devin Local as well as Devin Desktop +- **AND** skip creating missing files (the update command only refreshes what already exists) -#### Scenario: Updating workflows for Windsurf -- **WHEN** `.windsurf/workflows/` contains `opsx-propose.md`, `opsx-apply.md`, and `opsx-archive.md` +#### 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 workflow file -- **WHEN** a tool lacks a workflow file +#### 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/src/core/command-generation/adapters/devin.ts b/src/core/command-generation/adapters/devin.ts index 17b86600e2..3c9fff3291 100644 --- a/src/core/command-generation/adapters/devin.ts +++ b/src/core/command-generation/adapters/devin.ts @@ -2,48 +2,23 @@ * Devin Desktop Command Adapter * * Formats commands for Devin Desktop following its frontmatter specification. - * Devin Desktop uses the same Cascade workflow system as Windsurf. + * Devin Desktop reads Cascade-style workflows from `.devin/workflows/`, the + * same shape Windsurf uses. */ import path from 'path'; import { transformToHyphenCommands } from '../../../utils/command-references.js'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; - -/** - * Escapes a string value for safe YAML output. - * Quotes the string if it contains special YAML characters or would be - * interpreted as an implicit YAML scalar (boolean, null, number, etc). - */ -function escapeYamlValue(value: string): string { - // Check if value needs quoting due to special YAML characters or whitespace - const hasSpecialChars = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); - - // Check if value would be interpreted as an implicit YAML scalar - // Matches: booleans (true/false/yes/no/on/off), null variants, numbers, hex/octal - const isImplicitScalar = /^(true|false|yes|no|on|off|null|~|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|0x[0-9a-fA-F]+|0o[0-7]+|-|\.?)$/.test(value); - - if (hasSpecialChars || isImplicitScalar) { - // Use double quotes and escape internal double quotes and backslashes - const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); - return `"${escaped}"`; - } - return value; -} - -/** - * Formats a tags array as a YAML array with proper escaping. - */ -function formatTagsArray(tags: string[]): string { - const escapedTags = tags.map((tag) => escapeYamlValue(tag)); - return `[${escapedTags.join(', ')}]`; -} +import { escapeYamlValue, formatTagsArray } from '../yaml.js'; /** * Devin Desktop adapter for command generation. * File path: .devin/workflows/opsx-.md * Frontmatter: name, description, category, tags - * - * Devin Desktop uses slash-hyphen syntax (/opsx-apply) instead of colon syntax (/opsx:apply). + * + * Devin discovers `.devin/workflows/opsx-apply.md` as `/opsx-apply`, so the + * body's `/opsx:*` references are rewritten to the hyphen form the tool + * actually registers. */ export const devinAdapter: ToolCommandAdapter = { toolId: 'devin', @@ -53,9 +28,8 @@ export const devinAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - // Transform command references from colon to hyphen syntax const transformedBody = transformToHyphenCommands(content.body); - + return `--- name: ${escapeYamlValue(content.name)} description: ${escapeYamlValue(content.description)} diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index 987f7f8634..8e1c8c7372 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -103,7 +103,11 @@ export function getSkillReferenceTransformer(toolId: string): (text: string) => * commands — either because delivery is skills-only (for every tool) or * because the tool has no command surface at all (capability 'none', e.g. * Kimi Code or Mistral Vibe) — so those skills never point at commands - * that were not generated. When commands are generated, tools where the + * that were not generated. Devin is the same case for a different reason: + * 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. Devin's workflow bodies still get hyphen references, applied by + * devinAdapter itself. When commands are generated, tools where the * command filename doubles as the command name (bob, oh-my-pi, opencode, * pi, qwen) use hyphen-based command references. All other cases keep the default * `/opsx:*` references; notably skills-invocable tools (codex) are @@ -120,7 +124,7 @@ export function getTransformerForTool( delivery: 'both' | 'skills' | 'commands', capability: CommandSurfaceCapability ): ((text: string) => string) | undefined { - if (delivery === 'skills' || capability === 'none') { + if (delivery === 'skills' || capability === 'none' || toolId === 'devin') { return getSkillReferenceTransformer(toolId); } if ( diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index aafcc7e581..5f010dff81 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -156,10 +156,10 @@ describe('command-generation/adapters', () => { const output = devinAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('name: OpenSpec Explore'); - expect(output).toContain('description: Enter explore mode for thinking'); - expect(output).toContain('category: Workflow'); - expect(output).toContain('tags: [workflow, explore, experimental]'); + expect(output).toContain('name: "OpenSpec Explore"'); + expect(output).toContain('description: "Enter explore mode for thinking"'); + expect(output).toContain('category: "Workflow"'); + expect(output).toContain('tags: ["workflow", "explore", "experimental"]'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); }); From 872e34c60b2ad40c6d9869928430b19229b46bc0 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 09:54:02 -0500 Subject: [PATCH 08/17] fix(devin): correct commands-only hint, fill doc gaps, cover both surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up from adversarial review of the previous commit. The devin special case in getTransformerForTool was unconditional, so under commands-only delivery — where `.devin/skills/` is deleted — the getting-started hint named `/openspec-propose`, a skill that is not on disk. Devin now takes the skill transformer only when skills are generated, and the hyphen form otherwise. The cli-init delta records the fallback, and a unit test pins all three delivery modes. Docs: `devin` was missing from the `--tools` list in docs/cli.md (which mirrors the list supported-tools.md already had) and from the command-syntax tables in docs/commands.md and docs/how-commands-work.md. The supported-tools row gains a footnote citing Cognition's docs for the `.windsurf/` -> `.devin/` move and the Devin Local workflow gap. Tests: init and update now assert both surfaces — workflows carry `/opsx-*`, skills carry `/openspec-*`, neither carries `/opsx:` — and update checks the seeded skill was actually refreshed. Adds the negative detection case. Drops three devin-only YAML assertions that duplicated, less rigorously, the registry-derived escaping matrix that now enrolls devin automatically. Also reverts an unrelated zcode export and lingma reorder that a merge resolution had pulled into adapters/index.ts. zcodeAdapter is registered but missing from that barrel on main; that is a pre-existing gap and belongs in its own change. Co-Authored-By: Claude Opus 5 (1M context) --- docs/cli.md | 2 +- docs/commands.md | 1 + docs/how-commands-work.md | 1 + docs/supported-tools.md | 4 +- .../add-devin-desktop-support/proposal.md | 25 ++--- .../specs/cli-init/spec.md | 5 +- .../specs/cli-update/spec.md | 2 +- .../add-devin-desktop-support/tasks.md | 102 ++++-------------- src/core/command-generation/adapters/index.ts | 3 +- src/utils/command-references.ts | 20 ++-- test/core/available-tools.test.ts | 7 ++ test/core/command-generation/adapters.test.ts | 31 +----- test/core/init.test.ts | 27 +++-- test/core/update.test.ts | 36 ++++--- test/utils/command-references.test.ts | 11 ++ 15 files changed, 120 insertions(+), 157 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index c2172d900f..5ae5e20e29 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`):** `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`, `windsurf`, `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 836a39a6b2..8717385ccd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -669,6 +669,7 @@ Different AI tools use slightly different command syntax. Use the format that ma | Claude Code | `/opsx:propose`, `/opsx:apply` | | Cursor | `/opsx-propose`, `/opsx-apply` | | Windsurf | `/opsx-propose`, `/opsx-apply` | +| Devin Desktop | `/opsx-propose`, `/opsx-apply` (Devin Local has no workflows — use `/openspec-propose`, `/openspec-apply-change`) | | Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | | CodeArts | Skill-based invocations such as `/openspec-propose`, `/openspec-apply-change` (no generated `opsx-*` command files) | | Codex | Skill-based invocations from `.codex/skills/openspec-*` (no generated `opsx-*` prompt files) | diff --git a/docs/how-commands-work.md b/docs/how-commands-work.md index 577d6b6d01..8589372a53 100644 --- a/docs/how-commands-work.md +++ b/docs/how-commands-work.md @@ -78,6 +78,7 @@ The intent is identical everywhere. The punctuation differs. Use the form that m | Claude Code | `/opsx:propose`, `/opsx:apply` | | Cursor | `/opsx-propose`, `/opsx-apply` | | Windsurf | `/opsx-propose`, `/opsx-apply` | +| Devin Desktop | `/opsx-propose`, `/opsx-apply` (Devin Local: skill-style, e.g. `/openspec-propose`) | | GitHub Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | | CodeArts | skill-style, e.g. `/openspec-propose` | | Codex | skill-style via `.codex/skills/openspec-*` | diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 1de2a68b6b..ba28bbb5ae 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -34,7 +34,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | 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 (`devin`) | `.devin/skills/openspec-*/SKILL.md` | `.devin/workflows/opsx-.md` | +| Devin Desktop (`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` | @@ -65,6 +65,8 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch \*\*\* 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`. +\*\*\*\* Devin Desktop is the current name for Windsurf, and `.devin/` is its [preferred replacement](https://docs.devin.ai/desktop/devin-desktop-faq) for `.windsurf/`. 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 — so OpenSpec keeps Devin's skill bodies and its getting-started hint on `/openspec-*` skill invocations, which work on both agents. + ## Non-Interactive Setup For CI/CD or scripted setup, use `--tools` (and optionally `--profile`): diff --git a/openspec/changes/add-devin-desktop-support/proposal.md b/openspec/changes/add-devin-desktop-support/proposal.md index 139a77cb80..29836bc190 100644 --- a/openspec/changes/add-devin-desktop-support/proposal.md +++ b/openspec/changes/add-devin-desktop-support/proposal.md @@ -1,33 +1,34 @@ ## Why -- Windsurf has been rebranded to **Devin Desktop**, the new flagship AI coding assistant from Cognition. Users who previously used Windsurf are now transitioning to Devin Desktop. -- Devin Desktop uses the same workflow system as Windsurf (Cascade workflows stored in `.devin/workflows/`), making it a natural migration path for existing OpenSpec users. -- OpenSpec currently supports Windsurf but not Devin Desktop. Adding Devin Desktop support ensures users can continue using OpenSpec with their new tool without manual migration. +- 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/` is the legacy read-only fallback. That applies to `rules/`, `workflows/`, `skills/`, and `plans/`. OpenSpec writes only `.windsurf/`, so every new 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. Devin therefore needs both surfaces, with skill bodies that stay usable on the agent that has no workflows. - The adapter pattern is already established and proven with Windsurf; extending it to Devin Desktop is straightforward and maintains consistency across the tool ecosystem. ## What Changes - Add **Devin Desktop** (`devin`) to the CLI tool picker (`openspec init`) so users can select it during setup. -- Create a new **Devin adapter** (`src/core/command-generation/adapters/devin.ts`) that generates commands in `.devin/workflows/opsx-.md` with the same frontmatter structure as Windsurf. +- Create a new **Devin adapter** (`src/core/command-generation/adapters/devin.ts`) that writes workflows to `.devin/workflows/opsx-.md` with the same frontmatter structure as Windsurf, rewriting `/opsx:` references to the `/opsx-` form a workflow filename registers. - Register the Devin adapter in the command adapter registry (`src/core/command-generation/registry.ts`) and export it from the adapters index. -- Update `docs/supported-tools.md` to include Devin Desktop in the tool reference table. +- 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 and command-syntax tables in `docs/` to include Devin Desktop. - Ensure `openspec update` refreshes existing Devin workflows in-place, mirroring current behavior for other editors. - Extend unit tests for init/update to cover Devin Desktop generation and updates. -- Update CLI prompts and documentation to advertise Devin Desktop support. ## Impact -- **Specs:** `cli-init`, `cli-update`, `command-generation` -- **Code:** +- **Specs:** `ai-tool-paths`, `cli-init`, `cli-update` +- **Code:** - `src/core/command-generation/adapters/devin.ts` (new adapter) - `src/core/command-generation/registry.ts` (register adapter) - `src/core/command-generation/adapters/index.ts` (export adapter) - - CLI tool selection logic -- **Docs:** `docs/supported-tools.md` -- **Tests:** init/update integration coverage for Devin Desktop workflows + - `src/core/config.ts` (`AI_TOOLS` entry, `skillsDir: '.devin'`) + - `src/utils/command-references.ts` (Devin's skill-reference transformer) +- **Docs:** `docs/supported-tools.md`, `docs/cli.md`, `docs/commands.md`, `docs/how-commands-work.md` +- **Tests:** adapter, registry, tool detection, and init/update coverage for both Devin surfaces ## Notes - This is a **migration enabler** for existing Windsurf users transitioning to Devin Desktop. -- Windsurf support can remain in place for backward compatibility with users still on Windsurf. +- Windsurf support stays in place. `.windsurf/` remains a valid read fallback for Devin, and OpenSpec keeps writing it for users who have not moved. - The implementation closely mirrors the existing Windsurf adapter, reducing complexity and risk. 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 index ff66f66292..8aed785f54 100644 --- a/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md +++ b/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md @@ -24,10 +24,11 @@ The command SHALL generate Agent Skills for selected AI tools. #### Scenario: Devin skills reference skills rather than workflows -- **GIVEN** only Devin Desktop reads `.devin/workflows/`, while Devin Local supports skills but not 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 works on both Devin surfaces +- **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 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 index b70a08adc2..31ccb592d5 100644 --- a/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md +++ b/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md @@ -74,7 +74,7 @@ The update command SHALL refresh existing slash command files for configured too - **THEN** refresh each file using shared templates wrapped in OpenSpec markers - **AND** preserve the frontmatter structure (`name`, `description`, `category`, `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, which work on Devin Local as well as Devin Desktop +- **AND** refresh `.devin/skills/openspec-*/SKILL.md` with `/openspec-*` skill references, the one invocation both Devin agents accept - **AND** skip creating missing files (the update command only refreshes what already exists) #### Scenario: Updating slash commands for Kilo Code diff --git a/openspec/changes/add-devin-desktop-support/tasks.md b/openspec/changes/add-devin-desktop-support/tasks.md index 72b954e188..d156913f66 100644 --- a/openspec/changes/add-devin-desktop-support/tasks.md +++ b/openspec/changes/add-devin-desktop-support/tasks.md @@ -1,92 +1,32 @@ # Implementation Tasks -## 1. Create Devin Desktop Adapter +## 1. Adapter -### 1.1 Create adapter file -- Create `src/core/command-generation/adapters/devin.ts` -- Base implementation on the existing Windsurf adapter (`src/core/command-generation/adapters/windsurf.ts`) -- Use `.devin/workflows/` as the target directory -- Use `opsx-.md` as the filename pattern -- Include frontmatter with: name, description, category, tags +- [x] 1.1 Add `src/core/command-generation/adapters/devin.ts`, modeled on the Windsurf adapter: `.devin/workflows/opsx-.md`, frontmatter `name`/`description`/`category`/`tags` via the shared helpers in `command-generation/yaml.ts`. +- [x] 1.2 Rewrite `/opsx:` body references to `/opsx-` with `transformToHyphenCommands` — Devin registers a workflow under its filename. +- [x] 1.3 Register in `registry.ts` and re-export from `adapters/index.ts`. -### 1.2 Implement adapter interface -- Export `devinAdapter` object implementing `ToolCommandAdapter` -- Set `toolId` to `'devin'` -- Implement `getFilePath()` to return `.devin/workflows/opsx-.md` -- Implement `formatFile()` to generate YAML frontmatter + body content +## 2. Tool wiring -## 2. Register Adapter +- [x] 2.1 Add the `devin` row to `AI_TOOLS` in `src/core/config.ts` with `skillsDir: '.devin'`. Detection, the init picker, `--tools` validation, update, and profile sync all derive from this row. +- [x] 2.2 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 back to the hyphen form. -### 2.1 Update registry -- Edit `src/core/command-generation/registry.ts` -- Import the new `devinAdapter` -- Register it in the static initializer: `CommandAdapterRegistry.register(devinAdapter)` +## 3. Documentation -### 2.2 Export adapter -- Edit `src/core/command-generation/adapters/index.ts` -- Add export: `export { devinAdapter } from './devin.js'` -- Update main index if needed: `src/core/command-generation/index.ts` +- [x] 3.1 Add the Devin row to the tool table in `docs/supported-tools.md`, plus a footnote covering the `.windsurf/` → `.devin/` move and the Devin Local workflow gap. +- [x] 3.2 Add `devin` to the `--tools` ID lists in `docs/supported-tools.md` and `docs/cli.md` (both mirror `AI_TOOLS`). +- [x] 3.3 Add a Devin row to the command-syntax tables in `docs/commands.md` and `docs/how-commands-work.md`. -## 3. Update CLI Tool Selection +## 4. Tests -### 3.1 Add Devin Desktop to tool picker -- Locate CLI initialization code that prompts for tool selection -- Add "Devin Desktop" option to the multi-select menu -- Ensure it appears alongside Windsurf and other tools -- Map selection to `devin` tool ID +- [x] 4.1 Adapter: tool id, `getFilePath`, frontmatter, and hyphen rewriting. YAML escaping is covered by the registry-derived parity matrix, which enrolls Devin automatically. +- [x] 4.2 Registry and `available-tools` detection from `.devin/`, including the negative case. +- [x] 4.3 `init`: both surfaces — `.devin/workflows/opsx-*.md` carry `/opsx-*`, `.devin/skills/openspec-*/SKILL.md` carry `/openspec-*`, and neither carries `/opsx:`. +- [x] 4.4 `update`: workflows and skills are both refreshed, with stale content gone. +- [x] 4.5 `getTransformerForTool` returns the skill transformer for Devin under `both`/`skills` delivery and the hyphen transformer under `commands`. -## 4. Update Documentation +## 5. Verification -### 4.1 Update supported tools reference -- Edit `docs/supported-tools.md` -- Add Devin Desktop row to the tool directory reference table -- Include: - - Tool name and ID: `Devin Desktop (devin)` - - Skills path: `.devin/skills/openspec-*/SKILL.md` - - Command path: `.devin/workflows/opsx-.md` -- Add `devin` to the available tool IDs list in the "Non-Interactive Setup" section - -### 4.2 Update README if needed -- Check if README mentions tool count or lists specific tools -- Update any references to reflect Devin Desktop support - -## 5. Add Tests - -### 5.1 Test adapter functionality -- Create or update tests for the Devin adapter -- Test `getFilePath()` returns correct path -- Test `formatFile()` generates valid YAML frontmatter -- Test cross-platform path handling (Windows, macOS, Linux) - -### 5.2 Test CLI integration -- Test `openspec init --tools devin` generates `.devin/workflows/` files -- Test `openspec update` refreshes existing Devin workflows -- Test that Devin Desktop appears in interactive tool selection -- Verify files are created with correct structure and content - -### 5.3 Test backward compatibility -- Ensure Windsurf adapter still works -- Verify both Devin and Windsurf can be selected together -- Test that existing Windsurf installations are not affected - -## 6. Verify and Polish - -### 6.1 Manual testing -- Run `openspec init` and select Devin Desktop -- Verify `.devin/workflows/` directory is created -- Check that workflow files have correct frontmatter and content -- Run `openspec update` and verify files are refreshed -- Test on Windows, macOS, and Linux if possible - -### 6.2 Code review checklist -- Adapter follows existing patterns (Windsurf, Cursor, Claude) -- No hardcoded paths (use `path.join()`) -- YAML escaping handles special characters -- Error handling is consistent with other adapters -- Comments are clear and helpful - -### 6.3 Documentation review -- Supported tools table is accurate and complete -- Tool IDs are consistent across docs -- Examples show Devin Desktop usage -- Links and references are correct +- [x] 5.1 `openspec validate add-devin-desktop-support --strict`. +- [x] 5.2 `openspec archive add-devin-desktop-support --yes` merges cleanly and additively (run on a scratch copy, then reverted). +- [x] 5.3 Manual `openspec init --tools devin --force` and `openspec update --force` in a scratch repo, under `both`, `skills`, and `commands` delivery. diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 2185f30e36..10e039b17f 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -23,13 +23,12 @@ export { iflowAdapter } from './iflow.js'; export { junieAdapter } from './junie.js'; export { kilocodeAdapter } from './kilocode.js'; export { kiroAdapter } from './kiro.js'; -export { lingmaAdapter } from './lingma.js'; export { ohMyPiAdapter } from './oh-my-pi.js'; export { opencodeAdapter } from './opencode.js'; export { piAdapter } from './pi.js'; export { qoderAdapter } from './qoder.js'; +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'; -export { zcodeAdapter } from './zcode.js'; diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index 8e1c8c7372..ca072f35ad 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -103,11 +103,14 @@ export function getSkillReferenceTransformer(toolId: string): (text: string) => * commands — either because delivery is skills-only (for every tool) or * because the tool has no command surface at all (capability 'none', e.g. * Kimi Code or Mistral Vibe) — so those skills never point at commands - * that were not generated. Devin is the same case for a different reason: - * 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. Devin's workflow bodies still get hyphen references, applied by - * devinAdapter itself. When commands are generated, tools where the + * that were not generated. Devin joins them whenever skills are on disk, + * for a different reason: 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. Under commands-only delivery there + * are no Devin skills to point at, so it falls through to the hyphen form + * its workflow filenames register. Devin's workflow bodies always get + * hyphen references, applied by devinAdapter itself. + * When commands are generated, tools where the * command filename doubles as the command name (bob, oh-my-pi, opencode, * pi, qwen) use hyphen-based command references. All other cases keep the default * `/opsx:*` references; notably skills-invocable tools (codex) are @@ -124,9 +127,14 @@ export function getTransformerForTool( delivery: 'both' | 'skills' | 'commands', capability: CommandSurfaceCapability ): ((text: string) => string) | undefined { - if (delivery === 'skills' || capability === 'none' || toolId === 'devin') { + if (delivery === 'skills' || capability === 'none') { return getSkillReferenceTransformer(toolId); } + if (toolId === 'devin') { + return delivery === 'commands' + ? transformToHyphenCommands + : getSkillReferenceTransformer(toolId); + } if ( toolId === 'bob' || toolId === 'oh-my-pi' || diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index b4f7415422..f594a77892 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -57,6 +57,13 @@ describe('available-tools', () => { expect(devinTool?.skillsDir).toBe('.devin'); }); + it('should not detect Devin Desktop when .devin does not exist', async () => { + await fs.mkdir(path.join(testDir, '.windsurf'), { 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 5f010dff81..8ddb6699be 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -147,11 +147,6 @@ describe('command-generation/adapters', () => { expect(filePath).toBe(path.join('.devin', 'workflows', 'opsx-explore.md')); }); - it('should generate correct file paths for different commands', () => { - expect(devinAdapter.getFilePath('new')).toBe(path.join('.devin', 'workflows', 'opsx-new.md')); - expect(devinAdapter.getFilePath('bulk-archive')).toBe(path.join('.devin', 'workflows', 'opsx-bulk-archive.md')); - }); - it('should format file with YAML frontmatter', () => { const output = devinAdapter.formatFile(sampleContent); @@ -176,29 +171,9 @@ describe('command-generation/adapters', () => { expect(output).not.toContain('/opsx:verify'); }); - it('should escape YAML special characters in frontmatter', () => { - const contentWithSpecialChars: CommandContent = { - ...sampleContent, - name: 'Test: Command', - description: 'Fix "auth" feature', - }; - const output = devinAdapter.formatFile(contentWithSpecialChars); - expect(output).toContain('name: "Test: Command"'); - expect(output).toContain('description: "Fix \\"auth\\" feature"'); - }); - - it('should escape implicit YAML scalars in frontmatter', () => { - const contentWithImplicitScalar: CommandContent = { - ...sampleContent, - name: 'true', - description: 'null', - category: 'on', - }; - const output = devinAdapter.formatFile(contentWithImplicitScalar); - expect(output).toContain('name: "true"'); - expect(output).toContain('description: "null"'); - expect(output).toContain('category: "on"'); - }); + // 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: [] }; diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 5c9009beb2..3275f822f1 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -607,19 +607,34 @@ describe('InitCommand', () => { expect(await fileExists(cmdFile)).toBe(true); }); - it('should generate Devin Desktop workflows', async () => { + 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-explore.md'); + 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).toContain('---'); - expect(content).toContain('name:'); - expect(content).toContain('description:'); - // Verify command references are transformed to hyphen syntax + 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 () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 795e8bcf94..972d74f8ea 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -387,31 +387,33 @@ Old instructions content } }); - it('should update Devin Desktop workflows with hyphen command references', async () => { + 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-explore'), { + await fs.mkdir(path.join(skillsDir, 'openspec-apply-change'), { recursive: true, }); - await fs.writeFile( - path.join(skillsDir, 'openspec-explore', 'SKILL.md'), - 'old content' - ); + const skillFile = path.join(skillsDir, 'openspec-apply-change', 'SKILL.md'); + await fs.writeFile(skillFile, 'old content'); await updateCommand.execute(testDir); - // Verify workflows were created - const workflowsDir = path.join(testDir, '.devin', 'workflows'); - const exploreWorkflow = path.join(workflowsDir, 'opsx-explore.md'); - const exists = await FileSystemUtils.fileExists(exploreWorkflow); - expect(exists).toBe(true); + // 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 content = await fs.readFile(exploreWorkflow, 'utf-8'); - expect(content).toContain('---'); - expect(content).toContain('name:'); - expect(content).toContain('description:'); - // Verify command references are transformed to hyphen syntax - expect(content).not.toContain('/opsx:'); + 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-'); }); }); diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index 8a9d7dced1..7c62a6f08d 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -217,6 +217,17 @@ 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 devinAdapter. + expect(getTransformerForTool('devin', 'both', 'adapter-backed')).toBe(transformToSkillReferences); + expect(getTransformerForTool('devin', 'skills', 'adapter-backed')).toBe(transformToSkillReferences); + // Under commands-only delivery no Devin skills exist to point at, so the + // hint falls back to the workflow name Devin registers. + expect(getTransformerForTool('devin', 'commands', 'adapter-backed')).toBe(transformToHyphenCommands); + }); + it('selects no transformer for adapter-backed and skills-invocable tools when commands are generated', () => { expect(getTransformerForTool('claude', 'both', 'adapter-backed')).toBeUndefined(); expect(getTransformerForTool('claude', 'commands', 'adapter-backed')).toBeUndefined(); From 44258ce0ced8f58542a01f072f16732bc9d3d5ba Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 10:07:00 -0500 Subject: [PATCH 09/17] fix(devin): name the right command in the profile migration notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile-migration notice printed by both `init` and `update` hardcoded `/opsx:propose` for every adapter-backed tool. Devin registers no such command on any surface — its workflows answer to `/opsx-propose` and its skills to `/openspec-propose` — so an upgrading Devin user was told to run something that does not exist: Migrated: custom profile with 6 workflows New in this version: /opsx:propose. The reference now goes through getTransformerForTool, the same call init.ts already makes for the getting-started hint. Devin prints `/openspec-propose`; opencode and the other filename-invoked tools are corrected to `/opsx-propose` as a side effect; claude is unchanged. Also corrects two inherited false claims in the cli-update delta — Devin workflows carry no OpenSpec markers, and update writes every profile workflow rather than only refreshing files that already exist, which the PR's own test demonstrates. Qualifies the supported-tools footnote for commands-only delivery, and strips trailing whitespace. Co-Authored-By: Claude Opus 5 (1M context) --- docs/supported-tools.md | 2 +- .../specs/cli-update/spec.md | 7 +++--- src/core/migration.ts | 23 ++++++++++++------- test/core/available-tools.test.ts | 2 +- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/docs/supported-tools.md b/docs/supported-tools.md index ba28bbb5ae..6f9e534278 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -65,7 +65,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch \*\*\* 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`. -\*\*\*\* Devin Desktop is the current name for Windsurf, and `.devin/` is its [preferred replacement](https://docs.devin.ai/desktop/devin-desktop-faq) for `.windsurf/`. 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 — so OpenSpec keeps Devin's skill bodies and its getting-started hint on `/openspec-*` skill invocations, which work on both agents. +\*\*\*\* Devin Desktop is the current name for Windsurf, and `.devin/` is its [preferred replacement](https://docs.devin.ai/desktop/devin-desktop-faq) for `.windsurf/`. 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 — 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 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 index 31ccb592d5..ed2e98012c 100644 --- a/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md +++ b/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md @@ -70,12 +70,11 @@ The update command SHALL refresh existing slash command files for configured too - **AND** skip creating missing files (the update command only refreshes what already exists) #### Scenario: Updating workflows for Devin Desktop -- **WHEN** `.devin/workflows/` contains OpenSpec-managed `opsx-*.md` workflow files (for example `opsx-propose.md`, `opsx-apply.md`, and `opsx-archive.md`) -- **THEN** refresh each file using shared templates wrapped in OpenSpec markers -- **AND** preserve the frontmatter structure (`name`, `description`, `category`, `tags`) +- **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 -- **AND** skip creating missing files (the update command only refreshes what already exists) #### Scenario: Updating slash commands for Kilo Code - **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` diff --git a/src/core/migration.ts b/src/core/migration.ts index 9334caeb41..dc0b097af1 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -11,7 +11,7 @@ import { CommandAdapterRegistry } from './command-generation/index.js'; import { resolveCommandSurfaceCapability, shouldGenerateCommandsForTool } from './command-surface.js'; import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; import { ALL_WORKFLOWS } from './profiles.js'; -import { getSkillReferenceTransformer } from '../utils/command-references.js'; +import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js'; import path from 'path'; import * as fs from 'fs'; @@ -210,17 +210,24 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`); // Each detected tool resolves to a propose reference for its surface: - // the shared /opsx:propose command form when commands will exist for it - // under the effective delivery, its documented skill invocation - // otherwise (skills-invocable codex has no slash surface and always - // gets the syntax-neutral form). When the tools disagree — including - // command tools mixed with skill-only tools — stay syntax-neutral - // rather than advertise a form that is wrong for one of them. + // the command form when commands will exist for it under the effective + // delivery, run through the tool's transformer so tools that do not + // answer to the colon form (Devin's `/opsx-propose`) are named + // correctly; its documented skill invocation otherwise (skills-invocable + // codex has no slash surface and always gets the syntax-neutral form). + // When the tools disagree — including command tools mixed with + // skill-only tools — stay syntax-neutral rather than advertise a form + // that is wrong for one of them. const effectiveDelivery: Delivery = config.delivery ?? 'both'; const proposeReferences = new Set( tools.map((tool) => { if (shouldGenerateCommandsForTool(tool.value, effectiveDelivery)) { - return '/opsx:propose'; + const transformer = getTransformerForTool( + tool.value, + effectiveDelivery, + resolveCommandSurfaceCapability(tool.value) + ); + return transformer ? transformer('/opsx:propose') : '/opsx:propose'; } if (resolveCommandSurfaceCapability(tool.value) === 'skills-invocable') { return 'the openspec-propose skill'; diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index f594a77892..1b616fe977 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -50,7 +50,7 @@ describe('available-tools', () => { 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'); From 343ae0af61b508a4317fc952da745dfedbba3cf2 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 12:45:28 -0500 Subject: [PATCH 10/17] fix(devin): keep the cli-update delta in step with the canonical spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delta restates the whole 'Slash Command Updates' requirement, and its copy of the OpenCode scenario predated #1471 — archiving it would have quietly reverted the spec to calling the hyphen rewrite an OpenCode special case, the hand-maintained framing #1471 removed. Archive on a scratch copy is now purely additive. Also point tasks.md at the generator rather than the deleted transformToHyphenCommands, and enroll devin in the pure-formatter tripwire — it is the one adapter whose private body transform was just removed, so it is the likeliest to have it re-added. Co-Authored-By: Claude Opus 5 (1M context) --- .../add-devin-desktop-support/specs/cli-update/spec.md | 2 +- openspec/changes/add-devin-desktop-support/tasks.md | 4 ++-- test/core/command-generation/invocation.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 index ed2e98012c..313dcdafa7 100644 --- a/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md +++ b/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md @@ -54,7 +54,7 @@ The update command SHALL refresh existing slash command files for configured too #### 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`) for OpenCode compatibility +- **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 diff --git a/openspec/changes/add-devin-desktop-support/tasks.md b/openspec/changes/add-devin-desktop-support/tasks.md index d156913f66..4467f4bfbf 100644 --- a/openspec/changes/add-devin-desktop-support/tasks.md +++ b/openspec/changes/add-devin-desktop-support/tasks.md @@ -3,7 +3,7 @@ ## 1. Adapter - [x] 1.1 Add `src/core/command-generation/adapters/devin.ts`, modeled on the Windsurf adapter: `.devin/workflows/opsx-.md`, frontmatter `name`/`description`/`category`/`tags` via the shared helpers in `command-generation/yaml.ts`. -- [x] 1.2 Rewrite `/opsx:` body references to `/opsx-` with `transformToHyphenCommands` — Devin registers a workflow under its filename. +- [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 Register in `registry.ts` and re-export from `adapters/index.ts`. ## 2. Tool wiring @@ -19,7 +19,7 @@ ## 4. Tests -- [x] 4.1 Adapter: tool id, `getFilePath`, frontmatter, and hyphen rewriting. YAML escaping is covered by the registry-derived parity matrix, which enrolls Devin automatically. +- [x] 4.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] 4.2 Registry and `available-tools` detection from `.devin/`, including the negative case. - [x] 4.3 `init`: both surfaces — `.devin/workflows/opsx-*.md` carry `/opsx-*`, `.devin/skills/openspec-*/SKILL.md` carry `/openspec-*`, and neither carries `/opsx:`. - [x] 4.4 `update`: workflows and skills are both refreshed, with stale content gone. diff --git a/test/core/command-generation/invocation.test.ts b/test/core/command-generation/invocation.test.ts index 9c5a8a33c9..b1f3d7988a 100644 --- a/test/core/command-generation/invocation.test.ts +++ b/test/core/command-generation/invocation.test.ts @@ -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'); } From 9005be2b5b131d16f9d1e39af6e5fe5a6e9e74c1 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 13:39:06 -0500 Subject: [PATCH 11/17] feat(adapters): follow the Windsurf rename to Devin Desktop, with migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windsurf was rebranded to Devin Desktop on 2026-06-02 and its config directory moved: `.devin/` is the preferred read+write location, `.windsurf/` a legacy read-only fallback. Devin Local does not read `.windsurf/` at all, so an existing Windsurf user's OpenSpec files are invisible to it. Carrying `devin` as a second tool id alongside `windsurf` would list one product twice and leave upgraders with two parallel installs — `openspec update` even told them to create the second one ("Detected new tool: Devin Desktop"). This follows the rename instead, as the repo already did for Kimi CLI -> Kimi Code: - `windsurf` is retired as a tool id; `devin` takes its place, with `detectionPaths: ['.devin', '.windsurf']` so pre-rebrand projects are still recognized. The Windsurf adapter is replaced, not duplicated. - `TOOL_ID_ALIASES` keeps `--tools windsurf` resolving, so existing setup scripts and CI keep working; they now configure `.devin/`. - OpenSpec-managed skills (`openspec-*`) and command files (`opsx-*`) under `.windsurf/` move to `.devin/`. The kimi migration handled skills only; command files now move too, deriving the legacy path from the adapter's own getFilePath rather than hard-coding a layout. - The move is offered, not taken: nothing on disk distinguishes a user who took the rebrand from one still on a pre-rebrand Windsurf build that reads only `.windsurf/`. `openspec update` explains the rename and asks; --force and non-interactive runs migrate; declining leaves every file untouched and says what that costs. Files the user wrote are never moved. Also gives Devin its own row in the authoritative invocation table — the catch-all row claimed `/opsx-` for both agents, which is wrong for Devin Local. Co-Authored-By: Claude Opus 5 (1M context) --- docs/cli.md | 2 +- docs/commands.md | 4 +- docs/faq.md | 2 +- docs/how-commands-work.md | 8 +- docs/migration-guide.md | 2 +- docs/opsx.md | 2 +- docs/supported-tools.md | 10 +- .../add-devin-desktop-support/proposal.md | 36 ++- .../specs/ai-tool-paths/spec.md | 67 ++++- .../specs/cli-init/spec.md | 2 +- .../specs/cli-update/spec.md | 4 +- .../specs/command-generation/spec.md | 45 +++ .../add-devin-desktop-support/tasks.md | 48 ++-- src/core/command-generation/adapters/index.ts | 1 - .../command-generation/adapters/windsurf.ts | 35 --- src/core/command-generation/index.ts | 2 +- src/core/command-generation/registry.ts | 2 - src/core/config.ts | 20 +- src/core/init.ts | 19 +- src/core/legacy-cleanup.ts | 5 +- src/core/migration.ts | 257 ++++++++++++++---- src/core/update.ts | 54 +++- test/commands/artifact-workflow.test.ts | 8 +- test/core/available-tools.test.ts | 17 +- test/core/command-generation/adapters.test.ts | 29 -- .../command-generation/invocation.test.ts | 2 +- test/core/command-generation/registry.test.ts | 15 +- test/core/init.test.ts | 18 +- test/core/legacy-cleanup.test.ts | 4 +- test/core/shared/tool-detection.test.ts | 2 +- test/core/update.test.ts | 76 ++++-- website/app/(home)/page.tsx | 4 +- 32 files changed, 573 insertions(+), 229 deletions(-) create mode 100644 openspec/changes/add-devin-desktop-support/specs/command-generation/spec.md delete mode 100644 src/core/command-generation/adapters/windsurf.ts diff --git a/docs/cli.md b/docs/cli.md index 679fe81f15..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`, `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`, `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 7b8c6873af..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,7 +672,7 @@ Different AI tools use slightly different command syntax. Use the format that ma | Your tool's command file | Syntax example | Example tools | |--------------------------|----------------|---------------| | `.../commands/opsx/.*` | `/opsx:propose`, `/opsx:apply` | Claude Code, Gemini CLI, Crush | -| `.../opsx-.*` | `/opsx-propose`, `/opsx-apply` | Cursor, Windsurf, Devin Desktop, 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 | 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 b03d4bc4a0..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,7 +76,7 @@ The intent is identical everywhere. The spelling follows the file your tool load | Your tool's command file | How you type it | Example tools | |--------------------------|-----------------|---------------| | `.../commands/opsx/.*` | `/opsx:propose` | Claude Code, Gemini CLI, Crush | -| `.../opsx-.*` | `/opsx-propose` | Cursor, GitHub Copilot (IDE), Windsurf, Devin Desktop, 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 | 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 bfcb845a0d..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,7 +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 (`devin`) | `.devin/skills/openspec-*/SKILL.md` | `.devin/workflows/opsx-.md`\*\*\*\* | +| 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` | @@ -96,14 +97,13 @@ 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`. -\*\*\*\* Devin Desktop is the current name for Windsurf, and `.devin/` is its [preferred replacement](https://docs.devin.ai/desktop/devin-desktop-faq) for `.windsurf/`. 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 — 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-*`. +\*\*\*\* 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 @@ -123,7 +123,7 @@ openspec init --tools none openspec init --profile core ``` -**Available tool IDs (`--tools`):** `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`, `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/proposal.md b/openspec/changes/add-devin-desktop-support/proposal.md index 29836bc190..e94310af23 100644 --- a/openspec/changes/add-devin-desktop-support/proposal.md +++ b/openspec/changes/add-devin-desktop-support/proposal.md @@ -1,34 +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/` is the legacy read-only fallback. That applies to `rules/`, `workflows/`, `skills/`, and `plans/`. OpenSpec writes only `.windsurf/`, so every new 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. Devin therefore needs both surfaces, with skill bodies that stay usable on the agent that has no workflows. -- The adapter pattern is already established and proven with Windsurf; extending it to Devin Desktop is straightforward and maintains consistency across the tool ecosystem. +- 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 -- Add **Devin Desktop** (`devin`) to the CLI tool picker (`openspec init`) so users can select it during setup. -- Create a new **Devin adapter** (`src/core/command-generation/adapters/devin.ts`) that writes workflows to `.devin/workflows/opsx-.md` with the same frontmatter structure as Windsurf, rewriting `/opsx:` references to the `/opsx-` form a workflow filename registers. -- Register the Devin adapter in the command adapter registry (`src/core/command-generation/registry.ts`) and export it from the adapters index. +- **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 and command-syntax tables in `docs/` to include Devin Desktop. -- Ensure `openspec update` refreshes existing Devin workflows in-place, mirroring current behavior for other editors. -- Extend unit tests for init/update to cover Devin Desktop generation and updates. +- 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` +- **Specs:** `ai-tool-paths`, `cli-init`, `cli-update`, `command-generation` - **Code:** - - `src/core/command-generation/adapters/devin.ts` (new adapter) - - `src/core/command-generation/registry.ts` (register adapter) - - `src/core/command-generation/adapters/index.ts` (export adapter) - - `src/core/config.ts` (`AI_TOOLS` entry, `skillsDir: '.devin'`) + - `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:** `docs/supported-tools.md`, `docs/cli.md`, `docs/commands.md`, `docs/how-commands-work.md` -- **Tests:** adapter, registry, tool detection, and init/update coverage for both Devin surfaces +- **Docs:** `supported-tools.md`, `cli.md`, `commands.md`, `how-commands-work.md`, `faq.md`, `migration-guide.md`, `opsx.md`, website home page ## Notes -- This is a **migration enabler** for existing Windsurf users transitioning to Devin Desktop. -- Windsurf support stays in place. `.windsurf/` remains a valid read fallback for Devin, and OpenSpec keeps writing it for users who have not moved. -- The implementation closely mirrors the existing Windsurf adapter, reducing complexity and risk. +- **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 index 235d6fc6e2..b6d3c9bcd7 100644 --- 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 @@ -1,5 +1,61 @@ # 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** keep the copy under the current root and delete the former one, rather than merging or overwriting + +#### 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 OpenSpec-managed skill directories (`openspec-*`) and command files (`opsx-*`) +- **AND** delete the former directory only when the move leaves it empty + +#### 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 @@ -18,8 +74,10 @@ The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent #### Scenario: Windsurf paths defined +- **GIVEN** Windsurf was rebranded to Devin Desktop and `windsurf` is retired as a tool id - **WHEN** looking up the `windsurf` tool -- **THEN** `skillsDir` SHALL be `.windsurf` +- **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 @@ -39,6 +97,13 @@ The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent - **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 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 index 8aed785f54..2b23f76af0 100644 --- a/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md +++ b/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md @@ -68,5 +68,5 @@ The command SHALL generate opsx slash commands only for selected tools that have - **WHEN** the user selects Devin Desktop during initialization - **THEN** create one workflow file per profile workflow at `.devin/workflows/opsx-.md` -- **AND** include Windsurf-style frontmatter with `name`, `description`, `category`, and `tags` +- **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 index 313dcdafa7..d8227b28e2 100644 --- a/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md +++ b/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md @@ -9,7 +9,7 @@ The update command SHALL refresh existing slash command files for configured too #### 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 Windsurf and other IDEs +- **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` @@ -64,7 +64,7 @@ The update command SHALL refresh existing slash command files for configured too - **AND** `openspec update` SHALL NOT refresh files that remain only under `.opencode/command/` #### Scenario: Updating slash commands for Windsurf -- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **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) 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..3e3b44800f --- /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** 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 index 4467f4bfbf..c80e2e3280 100644 --- a/openspec/changes/add-devin-desktop-support/tasks.md +++ b/openspec/changes/add-devin-desktop-support/tasks.md @@ -2,31 +2,43 @@ ## 1. Adapter -- [x] 1.1 Add `src/core/command-generation/adapters/devin.ts`, modeled on the Windsurf adapter: `.devin/workflows/opsx-.md`, frontmatter `name`/`description`/`category`/`tags` via the shared helpers in `command-generation/yaml.ts`. +- [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 Register in `registry.ts` and re-export from `adapters/index.ts`. +- [x] 1.3 Delete `adapters/windsurf.ts` and its registry/barrel entries; register `devinAdapter` in their place. ## 2. Tool wiring -- [x] 2.1 Add the `devin` row to `AI_TOOLS` in `src/core/config.ts` with `skillsDir: '.devin'`. Detection, the init picker, `--tools` validation, update, and profile sync all derive from this row. -- [x] 2.2 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 back to the hyphen form. +- [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. Documentation +## 3. Migration -- [x] 3.1 Add the Devin row to the tool table in `docs/supported-tools.md`, plus a footnote covering the `.windsurf/` → `.devin/` move and the Devin Local workflow gap. -- [x] 3.2 Add `devin` to the `--tools` ID lists in `docs/supported-tools.md` and `docs/cli.md` (both mirror `AI_TOOLS`). -- [x] 3.3 Add a Devin row to the command-syntax tables in `docs/commands.md` and `docs/how-commands-work.md`. +- [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. Tests +## 4. Documentation -- [x] 4.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] 4.2 Registry and `available-tools` detection from `.devin/`, including the negative case. -- [x] 4.3 `init`: both surfaces — `.devin/workflows/opsx-*.md` carry `/opsx-*`, `.devin/skills/openspec-*/SKILL.md` carry `/openspec-*`, and neither carries `/opsx:`. -- [x] 4.4 `update`: workflows and skills are both refreshed, with stale content gone. -- [x] 4.5 `getTransformerForTool` returns the skill transformer for Devin under `both`/`skills` delivery and the hyphen transformer under `commands`. +- [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. Verification +## 5. Tests -- [x] 5.1 `openspec validate add-devin-desktop-support --strict`. -- [x] 5.2 `openspec archive add-devin-desktop-support --yes` merges cleanly and additively (run on a scratch copy, then reverted). -- [x] 5.3 Manual `openspec init --tools devin --force` and `openspec update --force` in a scratch repo, under `both`, `skills`, and `commands` delivery. +- [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/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 10e039b17f..358bc82767 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -31,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 545f3420e1..14e5481814 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -33,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'; /** @@ -71,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 b1820d5e71..fd18c3f82e 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -28,7 +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', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin' }, + { 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' }, @@ -53,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..d4998d82b9 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, 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,16 @@ 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) + )) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + } + // Create directory structure and config await this.createDirectoryStructure(openspecPath, extendMode); @@ -544,7 +555,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; + /** Whether this move needs the user's consent first */ + needsConsent: boolean; +} + +/** + * 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); } /** - * 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. + * 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 migrateLegacySkillDirs(projectPath: string): LegacySkillsMigration[] { - const migrations: LegacySkillsMigration[] = []; +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 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 skillDirs = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply); + const commandFiles = 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)); + } + + if (skillDirs > 0 || commandFiles > 0) { + migrations.push({ + toolId: tool.value, + from: legacy.root, + to: tool.skillsDir, + skillDirs, + commandFiles, + needsConsent: legacy.needsConsent, + }); } } } @@ -89,6 +149,111 @@ export function migrateLegacySkillDirs(projectPath: string): LegacySkillsMigrati return migrations; } +function migrateSkillDirs( + projectPath: string, + currentRoot: string, + legacyRoot: string, + apply: boolean +): number { + const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills'); + if (!fs.existsSync(legacySkillsDir)) return 0; + const currentSkillsDir = path.join(projectPath, currentRoot, 'skills'); + let moved = 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; + if (!apply) { + moved++; + 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); + } + moved++; + } catch { + // Leave the legacy directory in place if it cannot be moved + } + } + + return moved; +} + +function migrateCommandFiles( + projectPath: string, + tool: AIToolOption, + legacyRoot: string, + apply: boolean +): number { + const adapter = CommandAdapterRegistry.get(tool.value); + if (!adapter || !tool.skillsDir) return 0; + let moved = 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; + if (!apply) { + moved++; + continue; + } + + try { + const destination = path.join(projectPath, currentPath); + if (fs.existsSync(destination)) { + 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; +} + +/** + * 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 '); +} + +/** + * 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.`; +} + 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..3b62731df6 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -51,7 +51,10 @@ import { import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, - migrateLegacySkillDirs, + findLegacyToolMigrations, + migrateLegacyToolDirs, + describeLegacyMigration, + legacyMigrationNotice, } from './migration.js'; import { resolveCommandSurfaceCapability, @@ -122,9 +125,10 @@ 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)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); } + await this.offerConsentedLegacyMigrations(resolvedProjectPath); // Use detected tool directories to preserve existing opsx skills/commands. const detectedTools = getAvailableTools(resolvedProjectPath); @@ -629,6 +633,50 @@ 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. + */ + private async offerConsentedLegacyMigrations(projectPath: string): Promise { + const pending = findLegacyToolMigrations(projectPath).filter((m) => m.needsConsent); + if (pending.length === 0) return; + + for (const migration of pending) { + console.log(chalk.yellow(legacyMigrationNotice(migration))); + + if (!this.force && isInteractive()) { + const { confirm } = await import('@inquirer/prompts'); + const shouldMigrate = await confirm({ + message: `Move ${describeLegacyMigration(migration)} from ${migration.from}/ to ${migration.to}/?`, + default: true, + }); + if (!shouldMigrate) { + // Say what declining costs. OpenSpec writes the current root now, so + // the files keep working where they are but stop being refreshed. + console.log( + chalk.dim( + `Left in place. OpenSpec writes ${migration.to}/ now, so ${migration.from}/ ` + + `will not be refreshed until you move it. You will be asked again next run.` + ) + ); + console.log(); + continue; + } + } + + for (const applied of migrateLegacyToolDirs(projectPath, [migration.toolId])) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(applied)}: ${applied.from} → ${applied.to}`)); + } + console.log(); + } + } + /** * Detect and handle legacy OpenSpec artifacts. * Unlike init, update warns but continues if legacy files found in non-interactive mode. 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 1b616fe977..ae20603613 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -40,7 +40,8 @@ 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); }); @@ -53,13 +54,23 @@ describe('available-tools', () => { const devinTool = tools.find((t) => t.value === 'devin'); expect(devinTool).toBeDefined(); - expect(devinTool?.name).toBe('Devin Desktop'); + expect(devinTool?.name).toBe('Devin Desktop (formerly Windsurf)'); expect(devinTool?.skillsDir).toBe('.devin'); }); - it('should not detect Devin Desktop when .devin does not exist', async () => { + 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'); }); diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 021d79e48e..43dbea5d4e 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -27,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, @@ -115,29 +114,6 @@ describe('command-generation/adapters', () => { }); }); - describe('windsurfAdapter', () => { - it('should have correct toolId', () => { - expect(windsurfAdapter.toolId).toBe('windsurf'); - }); - - it('should generate correct file path', () => { - const filePath = windsurfAdapter.getFilePath('explore'); - expect(filePath).toBe(path.join('.windsurf', 'workflows', 'opsx-explore.md')); - }); - - it('should format file similar to Claude format', () => { - const output = windsurfAdapter.formatFile(sampleContent); - - expect(output).toContain('---\n'); - expect(output).toContain('name: "OpenSpec Explore"'); - expect(output).toContain('description: "Enter explore mode for thinking"'); - expect(output).toContain('category: "Workflow"'); - expect(output).toContain('tags: ["workflow", "explore", "experimental"]'); - expect(output).toContain('---\n\n'); - expect(output).toContain('This is the command body.'); - }); - }); - describe('devinAdapter', () => { it('should have correct toolId', () => { expect(devinAdapter.toolId).toBe('devin'); @@ -970,11 +946,6 @@ 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']); diff --git a/test/core/command-generation/invocation.test.ts b/test/core/command-generation/invocation.test.ts index b1f3d7988a..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', 'devin', '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'); diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index 6c0984b532..07fb8bf774 100644 --- a/test/core/command-generation/registry.test.ts +++ b/test/core/command-generation/registry.test.ts @@ -16,10 +16,10 @@ 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"', () => { @@ -66,16 +66,15 @@ 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, Windsurf, and Devin 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'); }); @@ -92,7 +91,7 @@ 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); @@ -113,12 +112,10 @@ 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'); }); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 00bb259ed1..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,11 +605,11 @@ 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); }); 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 6d4a5ba9e9..008ef030da 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(); }); @@ -616,32 +616,64 @@ 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); - // Check Windsurf command format - const windsurfCmd = path.join( - testDir, - '.windsurf', - 'workflows', - 'opsx-explore.md' + // 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' ); - const exists = await FileSystemUtils.fileExists(windsurfCmd); - expect(exists).toBe(true); + 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('---'); - const content = await fs.readFile(windsurfCmd, 'utf-8'); - expect(content).toContain('---'); - expect(content).toContain('name:'); + // 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 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/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.

From 8012bc39ee27e3f61720f0591eeb41a248140b94 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 13:56:39 -0500 Subject: [PATCH 12/17] fix(devin): stop the migration from deleting anything it does not own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial pass found two ways the move destroyed files. Symlinked roots wiped the install. `ln -s .devin .windsurf` is a realistic way to straddle the rebrand, and it makes source and destination the same file — so the "destination exists, drop the legacy copy" branch deleted the only copy. Twelve generated files, gone, and not regenerated: the wipe happens before tool detection, so update then reported no configured tools. Both roots are now realpath'd and a self-move is skipped. User content inside an OpenSpec-managed path was deleted. The same branch rm -rf'd the whole legacy skill directory, taking a hand-written reference.md beside SKILL.md with it, and deleted a legacy command file even when the user had edited it. Now only SKILL.md is removed from a skill directory, and a command file is removed only when byte-identical to the one that survives — an edit is left where it is. Also: declining the move stranded the user. `update` then printed "No configured tools found. Run openspec init", which is wrong — the project is configured, just in the directory OpenSpec no longer writes. It now says so and how to resume. A closed stdin during the prompt aborted the whole update; it is treated as a decline. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/ai-tool-paths/spec.md | 12 +++- .../specs/command-generation/spec.md | 2 +- src/cli/index.ts | 7 ++- src/core/migration.ts | 45 ++++++++++++--- src/core/update.ts | 51 +++++++++++++---- test/core/update.test.ts | 56 +++++++++++++++++++ 6 files changed, 151 insertions(+), 22 deletions(-) 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 index b6d3c9bcd7..b93527cc45 100644 --- 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 @@ -42,7 +42,15 @@ distinguishes that user from one who took the rebrand. - **GIVEN** the same OpenSpec-managed skill or command exists under both the former and the current root - **WHEN** the move runs -- **THEN** keep the copy under the current root and delete the former one, rather than merging or overwriting +- **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** a former command file SHALL be removed only when it is byte-identical to the surviving one; a file the user edited SHALL be left where it is + +#### 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 @@ -74,7 +82,7 @@ The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent #### Scenario: Windsurf paths defined -- **GIVEN** Windsurf was rebranded to Devin Desktop and `windsurf` is retired as a tool id +- **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` 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 index 3e3b44800f..07ba141aba 100644 --- a/openspec/changes/add-devin-desktop-support/specs/command-generation/spec.md +++ b/openspec/changes/add-devin-desktop-support/specs/command-generation/spec.md @@ -28,7 +28,7 @@ The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting #### Scenario: Windsurf adapter formatting -- **GIVEN** Windsurf was rebranded to Devin Desktop and its config directory moved +- **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 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/migration.ts b/src/core/migration.ts index 2240a73fa0..9632524b2f 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -171,12 +171,19 @@ function migrateSkillDirs( try { const destination = path.join(currentSkillsDir, dirName); - if (fs.existsSync(destination)) { - fs.rmSync(source, { recursive: true, force: true }); - } else { + if (!fs.existsSync(destination)) { fs.mkdirSync(currentSkillsDir, { recursive: true }); fs.renameSync(source, destination); + moved++; + continue; } + if (isSamePath(source, destination)) continue; + + // The destination wins, but only the file OpenSpec generated may be + // removed from the source: a skill directory can also hold references + // the user wrote next to SKILL.md, and those are not ours to delete. + fs.rmSync(path.join(source, 'SKILL.md'), { force: true }); + removeDirIfEmpty(source); moved++; } catch { // Leave the legacy directory in place if it cannot be moved @@ -210,13 +217,21 @@ function migrateCommandFiles( try { const destination = path.join(projectPath, currentPath); - if (fs.existsSync(destination)) { - fs.rmSync(source, { force: true }); - } else { + if (!fs.existsSync(destination)) { fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.renameSync(source, destination); + moved++; + continue; + } + if (isSamePath(source, destination)) continue; + + // Both roots have this command. Dropping the legacy copy is only safe + // when it is byte-identical to the one that survives — otherwise the + // user edited it, and an edit is not ours to throw away. + if (fs.readFileSync(source, 'utf-8') === fs.readFileSync(destination, 'utf-8')) { + fs.rmSync(source, { force: true }); + moved++; } - moved++; } catch { // Leave the legacy file in place if it cannot be moved } @@ -254,6 +269,22 @@ export function legacyMigrationNotice(migration: LegacyToolMigration): string { 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 3b62731df6..23bad15de7 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -55,6 +55,7 @@ import { migrateLegacyToolDirs, describeLegacyMigration, legacyMigrationNotice, + type LegacyToolMigration, } from './migration.js'; import { resolveCommandSurfaceCapability, @@ -128,7 +129,7 @@ export class UpdateCommand { for (const migration of migrateLegacyToolDirs(resolvedProjectPath)) { console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); } - await this.offerConsentedLegacyMigrations(resolvedProjectPath); + const declinedMigrations = await this.offerConsentedLegacyMigrations(resolvedProjectPath); // Use detected tool directories to preserve existing opsx skills/commands. const detectedTools = getAvailableTools(resolvedProjectPath); @@ -162,6 +163,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; @@ -643,29 +660,41 @@ export class UpdateCommand { * reads only `.windsurf/`. `--force` and non-interactive runs migrate, which * is what an unattended upgrade wants. */ - private async offerConsentedLegacyMigrations(projectPath: string): Promise { + private async offerConsentedLegacyMigrations( + projectPath: string + ): Promise { const pending = findLegacyToolMigrations(projectPath).filter((m) => m.needsConsent); - if (pending.length === 0) return; + const declined: LegacyToolMigration[] = []; + if (pending.length === 0) return declined; for (const migration of pending) { console.log(chalk.yellow(legacyMigrationNotice(migration))); if (!this.force && isInteractive()) { const { confirm } = await import('@inquirer/prompts'); - const shouldMigrate = await confirm({ - message: `Move ${describeLegacyMigration(migration)} from ${migration.from}/ to ${migration.to}/?`, - default: true, - }); + 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 stop being refreshed. + // 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, so ${migration.from}/ ` + - `will not be refreshed until you move it. You will be asked again next run.` + `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; } } @@ -675,6 +704,8 @@ export class UpdateCommand { } console.log(); } + + return declined; } /** diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 008ef030da..e84cfed5ba 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -660,6 +660,62 @@ Old instructions content ).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')); + + 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. + // The destination wins, but only the file OpenSpec generated may be + // removed from the loser — a reference the user wrote beside it is theirs. + 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'), 'stale'); + await fs.writeFile(path.join(legacySkill, 'reference.md'), 'my notes'); + + await updateCommand.execute(testDir); + + 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 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. From d94af8b8008e6d0be9f740f437d501563a6ac9b7 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 13:58:49 -0500 Subject: [PATCH 13/17] docs(devin): add a changeset for the Windsurf rename and migration Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/devin-desktop-rename.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/devin-desktop-rename.md diff --git a/.changeset/devin-desktop-rename.md b/.changeset/devin-desktop-rename.md new file mode 100644 index 0000000000..ddd86dbce0 --- /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 OpenSpec's own files move — skills named `openspec-*` and commands named `opsx-*` — so a hand-written Cascade workflow, a reference beside a `SKILL.md`, an edited command file, and `.devin/rules/` are all left alone. +- 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. From 2a9c0e93d4888f1c3c023f6e53bb473f0cc1f2fa Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 14:37:59 -0500 Subject: [PATCH 14/17] fix(devin): move only SKILL.md, never the skill directory around it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alfred caught a data-loss path the earlier fix missed. When the destination did not yet exist, migration renamed the whole legacy skill directory into `.devin/` — carrying any file the user kept beside `SKILL.md` with it. That destination is a directory OpenSpec owns and removes on its own: under commands-only delivery, or for a workflow outside the active profile. So the move handed the user's file to a later rm and it vanished. Reproduced on `d94af8b`: with `delivery: commands`, a `reference.md` beside a legacy `SKILL.md` was gone after `openspec update`. Only `SKILL.md` crosses now, in both branches; anything else stays under the legacy root, and the legacy directory is still removed when the move leaves it empty. Regression tests cover the commands-only and deselected-workflow cases and both fail against the previous code. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/devin-desktop-rename.md | 2 +- .../specs/ai-tool-paths/spec.md | 10 +++- src/core/migration.ts | 28 ++++++----- test/core/update.test.ts | 46 +++++++++++++++++++ 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/.changeset/devin-desktop-rename.md b/.changeset/devin-desktop-rename.md index ddd86dbce0..f51bcf124b 100644 --- a/.changeset/devin-desktop-rename.md +++ b/.changeset/devin-desktop-rename.md @@ -5,5 +5,5 @@ **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 OpenSpec's own files move — skills named `openspec-*` and commands named `opsx-*` — so a hand-written Cascade workflow, a reference beside a `SKILL.md`, an edited command file, and `.devin/rules/` are all left alone. +- 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/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 index b93527cc45..71113d7468 100644 --- 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 @@ -56,9 +56,17 @@ distinguishes that user from one who took the rebrand. - **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 OpenSpec-managed skill directories (`openspec-*`) and command files (`opsx-*`) +- **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 diff --git a/src/core/migration.ts b/src/core/migration.ts index 9632524b2f..53149e88e8 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -163,7 +163,8 @@ function migrateSkillDirs( 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; + const sourceSkill = path.join(source, 'SKILL.md'); + if (!fs.existsSync(sourceSkill)) continue; if (!apply) { moved++; continue; @@ -171,18 +172,21 @@ function migrateSkillDirs( try { const destination = path.join(currentSkillsDir, dirName); - if (!fs.existsSync(destination)) { - fs.mkdirSync(currentSkillsDir, { recursive: true }); - fs.renameSync(source, destination); - moved++; - continue; + const destinationSkill = path.join(destination, 'SKILL.md'); + if (isSamePath(sourceSkill, destinationSkill)) continue; + + // 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 (fs.existsSync(destinationSkill)) { + fs.rmSync(sourceSkill, { force: true }); + } else { + fs.mkdirSync(destination, { recursive: true }); + fs.renameSync(sourceSkill, destinationSkill); } - if (isSamePath(source, destination)) continue; - - // The destination wins, but only the file OpenSpec generated may be - // removed from the source: a skill directory can also hold references - // the user wrote next to SKILL.md, and those are not ours to delete. - fs.rmSync(path.join(source, 'SKILL.md'), { force: true }); + // Anything the user left beside it stays under the legacy root. removeDirIfEmpty(source); moved++; } catch { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index e84cfed5ba..f27809308b 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -695,6 +695,52 @@ Old instructions content expect(await fs.readFile(path.join(legacySkill, 'reference.md'), 'utf-8')).toBe('my notes'); }); + 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 }); From 90eaee18a293ef0c189bd0eca1ce63df3b27854c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 15:05:51 -0500 Subject: [PATCH 15/17] fix(devin): treat an edited skill the way an edited command is already treated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A final adversarial pass found the two paths disagreeing. When both roots held the same file with different content, the command path compared bytes and kept the user's version; the skill path deleted it with no comparison — so one `openspec update` destroyed an edited SKILL.md while preserving an edited opsx-*.md in the same project. Both now share one `classifyManagedFile` rule: move when the destination is empty, drop the legacy copy only when byte-identical, otherwise leave it. Anything left behind is reported, so a user who customized a file knows two copies exist rather than discovering it later. Note on the other finding from that pass: OpenSpec regenerating or pruning the files it owns is long-standing behavior, not something this PR introduces. Verified against main — an edited SKILL.md under a deselected workflow, and an edited selected skill and command, are all destroyed by `openspec update` on 9a937cb too. No regression, so left alone here. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/ai-tool-paths/spec.md | 4 +- src/core/init.ts | 4 +- src/core/migration.ts | 105 +++++++++++++----- src/core/update.ts | 9 ++ test/core/update.test.ts | 34 +++++- 5 files changed, 122 insertions(+), 34 deletions(-) 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 index 71113d7468..b7d5be4fbe 100644 --- 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 @@ -44,7 +44,9 @@ distinguishes that user from one who took the rebrand. - **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** a former command file SHALL be removed only when it is byte-identical to the surviving one; a file the user edited SHALL be left where it is +- **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, because only a user edit makes it differ +- **AND** files left behind for that reason SHALL be reported, so the user knows two copies now exist #### Scenario: One root is a symbolic link to the other diff --git a/src/core/init.ts b/src/core/init.ts index d4998d82b9..ae0b87d41b 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -51,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, migrateLegacyToolDirs, describeLegacyMigration, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js'; +import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js'; import { resolveCommandSurfaceCapability, resolveCommandInvocation, @@ -210,6 +210,8 @@ export class InitCommand { validatedTools.map((tool) => tool.value) )) { 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 diff --git a/src/core/migration.ts b/src/core/migration.ts index 53149e88e8..3d03db4231 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -56,10 +56,36 @@ export interface LegacyToolMigration { 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'; + } +} + /** * 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 @@ -124,8 +150,8 @@ function collectLegacyToolMigrations( if (apply && !toolIds && legacy.needsConsent) continue; if (!fs.existsSync(path.join(projectPath, legacy.root))) continue; - const skillDirs = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply); - const commandFiles = migrateCommandFiles(projectPath, tool, legacy.root, apply); + const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply); + const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply); if (apply) { removeDirIfEmpty(path.join(projectPath, legacy.root, 'skills')); @@ -133,13 +159,14 @@ function collectLegacyToolMigrations( removeDirIfEmpty(path.join(projectPath, legacy.root)); } - if (skillDirs > 0 || commandFiles > 0) { + if (skills.moved > 0 || commands.moved > 0) { migrations.push({ toolId: tool.value, from: legacy.root, to: tool.skillsDir, - skillDirs, - commandFiles, + skillDirs: skills.moved, + commandFiles: commands.moved, + keptInPlace: skills.kept + commands.kept, needsConsent: legacy.needsConsent, }); } @@ -154,33 +181,39 @@ function migrateSkillDirs( currentRoot: string, legacyRoot: string, apply: boolean -): number { +): { moved: number; kept: number } { const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills'); - if (!fs.existsSync(legacySkillsDir)) return 0; + 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 { - const destination = path.join(currentSkillsDir, dirName); - const destinationSkill = path.join(destination, 'SKILL.md'); - if (isSamePath(sourceSkill, destinationSkill)) continue; - // 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 (fs.existsSync(destinationSkill)) { + if (disposition === 'drop') { fs.rmSync(sourceSkill, { force: true }); } else { fs.mkdirSync(destination, { recursive: true }); @@ -194,7 +227,7 @@ function migrateSkillDirs( } } - return moved; + return { moved, kept }; } function migrateCommandFiles( @@ -202,10 +235,11 @@ function migrateCommandFiles( tool: AIToolOption, legacyRoot: string, apply: boolean -): number { +): { moved: number; kept: number } { const adapter = CommandAdapterRegistry.get(tool.value); - if (!adapter || !tool.skillsDir) return 0; + 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); @@ -214,34 +248,33 @@ function migrateCommandFiles( 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 { - const destination = path.join(projectPath, currentPath); - if (!fs.existsSync(destination)) { + if (disposition === 'drop') { + fs.rmSync(source, { force: true }); + } else { fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.renameSync(source, destination); - moved++; - continue; - } - if (isSamePath(source, destination)) continue; - - // Both roots have this command. Dropping the legacy copy is only safe - // when it is byte-identical to the one that survives — otherwise the - // user edited it, and an edit is not ours to throw away. - if (fs.readFileSync(source, 'utf-8') === fs.readFileSync(destination, 'utf-8')) { - fs.rmSync(source, { force: true }); - moved++; } + moved++; } catch { // Leave the legacy file in place if it cannot be moved } } - return moved; + return { moved, kept }; } /** @@ -258,6 +291,20 @@ export function describeLegacyMigration(migration: LegacyToolMigration): string 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; + return ( + `Left ${n} edited file${n === 1 ? '' : 's'} in ${migration.from}/: ` + + `${migration.to}/ already has ${n === 1 ? 'it' : 'them'} with different content, ` + + `so your version was kept rather than overwritten.` + ); +} + /** * 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". diff --git a/src/core/update.ts b/src/core/update.ts index 23bad15de7..0f6e3ccc06 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -55,6 +55,7 @@ import { migrateLegacyToolDirs, describeLegacyMigration, legacyMigrationNotice, + keptInPlaceNotice, type LegacyToolMigration, } from './migration.js'; import { @@ -128,6 +129,7 @@ export class UpdateCommand { // legacy upgrade generation. for (const migration of migrateLegacyToolDirs(resolvedProjectPath)) { console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + this.reportKeptInPlace(migration); } const declinedMigrations = await this.offerConsentedLegacyMigrations(resolvedProjectPath); @@ -660,6 +662,12 @@ export class UpdateCommand { * 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 { @@ -701,6 +709,7 @@ export class UpdateCommand { for (const applied of migrateLegacyToolDirs(projectPath, [migration.toolId])) { console.log(chalk.dim(`Migrated ${describeLegacyMigration(applied)}: ${applied.from} → ${applied.to}`)); + this.reportKeptInPlace(applied); } console.log(); } diff --git a/test/core/update.test.ts b/test/core/update.test.ts index f27809308b..d380ae5e9f 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -678,23 +678,51 @@ Old instructions content 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. - // The destination wins, but only the file OpenSpec generated may be - // removed from the loser — a reference the user wrote beside it is theirs. + // 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'), 'stale'); + 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 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' + ); + expect(await fs.readFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'utf-8')).toBe( + 'my edited command' + ); + }); + 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 From 37bff11c8ead83296c24906cd2bc21dfd1b88443 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 15:21:22 -0500 Subject: [PATCH 16/17] fix(devin): report divergent legacy files even when nothing is movable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collectLegacyToolMigrations only returned a result when something moved, so a project where EVERY legacy file differs from its counterpart produced no output at all — two divergent copies and not a word about them. That is the one case where the report matters most, since it is entirely made of files the migration deliberately refused to touch. Kept-only results are retained now. Callers gate on hasMovableContent(), so a kept-only result reports what was left without offering to move nothing and without claiming a migration that did not happen. Also reworded the notice. A legacy file can differ because the user edited it or simply because an older OpenSpec generated it, so it no longer asserts an edit — it states that nothing was overwritten and leaves the user to compare the two copies. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/ai-tool-paths/spec.md | 10 +++++- src/core/init.ts | 6 ++-- src/core/migration.ts | 24 +++++++++++--- src/core/update.ts | 18 ++++++++-- test/core/update.test.ts | 33 +++++++++++++++++++ 5 files changed, 82 insertions(+), 9 deletions(-) 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 index b7d5be4fbe..9829f48533 100644 --- 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 @@ -45,9 +45,17 @@ distinguishes that user from one who took the rebrand. - **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, because only a user edit makes it differ +- **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 diff --git a/src/core/init.ts b/src/core/init.ts index ae0b87d41b..b2064b183d 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -51,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, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js'; +import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js'; import { resolveCommandSurfaceCapability, resolveCommandInvocation, @@ -209,7 +209,9 @@ export class InitCommand { projectPath, validatedTools.map((tool) => tool.value) )) { - console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + 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)); } diff --git a/src/core/migration.ts b/src/core/migration.ts index 3d03db4231..186af7f0d4 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -159,7 +159,11 @@ function collectLegacyToolMigrations( removeDirIfEmpty(path.join(projectPath, legacy.root)); } - if (skills.moved > 0 || commands.moved > 0) { + // 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, @@ -298,13 +302,25 @@ export function describeLegacyMigration(migration: LegacyToolMigration): string 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} edited file${n === 1 ? '' : 's'} in ${migration.from}/: ` + - `${migration.to}/ already has ${n === 1 ? 'it' : 'them'} with different content, ` + - `so your version was kept rather than overwritten.` + `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". diff --git a/src/core/update.ts b/src/core/update.ts index 0f6e3ccc06..9d48f621ec 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -56,6 +56,7 @@ import { describeLegacyMigration, legacyMigrationNotice, keptInPlaceNotice, + hasMovableContent, type LegacyToolMigration, } from './migration.js'; import { @@ -128,7 +129,9 @@ export class UpdateCommand { // then perform the one-time profile migration if needed before any // legacy upgrade generation. for (const migration of migrateLegacyToolDirs(resolvedProjectPath)) { - console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + if (hasMovableContent(migration)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + } this.reportKeptInPlace(migration); } const declinedMigrations = await this.offerConsentedLegacyMigrations(resolvedProjectPath); @@ -676,6 +679,15 @@ export class UpdateCommand { 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()) { @@ -708,7 +720,9 @@ export class UpdateCommand { } for (const applied of migrateLegacyToolDirs(projectPath, [migration.toolId])) { - console.log(chalk.dim(`Migrated ${describeLegacyMigration(applied)}: ${applied.from} → ${applied.to}`)); + if (hasMovableContent(applied)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(applied)}: ${applied.from} → ${applied.to}`)); + } this.reportKeptInPlace(applied); } console.log(); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index d380ae5e9f..823f0dfff3 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -695,6 +695,39 @@ Old instructions content 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 + expect(logCalls.some((entry) => entry.includes('Migrated 0'))).toBe(false); + expect(logCalls.some((entry) => /Migrated\s*:/.test(entry))).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 From c164701f2aeee76d31b23f04af5f29839104b676 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 15:39:58 -0500 Subject: [PATCH 17/17] test(devin): stop matching the unrelated profile-migration line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kept-only regression asserted no line matched /Migrated\s*:/, which also matches OpenSpec's profile migration message, "Migrated: custom profile with N workflows". That line only prints when the global config has no profile yet — true on a fresh CI runner, false on a developer machine that has run OpenSpec before — so the test passed locally and failed on all three CI platforms. Now matched on the directory arrow, ".windsurf → .devin", which is specific to a migration report and unaffected by config state. Reproduced both ways with an empty XDG_CONFIG_HOME: the old assertion fails there, the new one passes, and the full suite is green under CI's XDG_CONFIG_HOME + VITEST_MAX_WORKERS=4. Co-Authored-By: Claude Opus 5 (1M context) --- test/core/update.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 823f0dfff3..c6f6a6a3ea 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -720,9 +720,12 @@ Old instructions content // 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 + // ...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); - expect(logCalls.some((entry) => /Migrated\s*:/.test(entry))).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');