Files
Mehdi Shahdoost 1637856c42 feat(adapters): follow the Windsurf rename to Devin Desktop (#1167)
* proposal: add devin desktop support

* feat(adapters): add devin desktop command adapter

- Create new Devin Desktop adapter for .devin/workflows/opsx-<id>.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.

* 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.

* 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.

* 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

* Add cross-platform testcase.

* 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) <noreply@anthropic.com>

* fix(devin): correct commands-only hint, fill doc gaps, cover both surfaces

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) <noreply@anthropic.com>

* fix(devin): name the right command in the profile migration notice

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) <noreply@anthropic.com>

* fix(devin): keep the cli-update delta in step with the canonical spec

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) <noreply@anthropic.com>

* feat(adapters): follow the Windsurf rename to Devin Desktop, with migration

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-<id>` for both agents, which is wrong for Devin
Local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(devin): stop the migration from deleting anything it does not own

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) <noreply@anthropic.com>

* docs(devin): add a changeset for the Windsurf rename and migration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(devin): move only SKILL.md, never the skill directory around it

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) <noreply@anthropic.com>

* fix(devin): treat an edited skill the way an edited command is already treated

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) <noreply@anthropic.com>

* fix(devin): report divergent legacy files even when nothing is movable

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) <noreply@anthropic.com>

* test(devin): stop matching the unrelated profile-migration line

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) <noreply@anthropic.com>

---------

Co-authored-by: Clay Good <hi@claygood.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:02:23 +00:00

183 lines
8.2 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import path from 'path';
import {
formatCommandInvocation,
getInvocationForAdapter,
getInvocationStyleForPath,
needsInvocationRewrite,
} from '../../../src/core/command-generation/invocation.js';
import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js';
import { resolveCommandInvocation } from '../../../src/core/command-surface.js';
import { generateCommand } from '../../../src/core/command-generation/generator.js';
import type { CommandContent } from '../../../src/core/command-generation/types.js';
import { ALL_WORKFLOWS } from '../../../src/core/profiles.js';
/**
* Tools whose command files live in an `opsx/` directory, so the tool
* namespaces the command and registers `/opsx:<id>`. Every other registered
* adapter writes `opsx-<id>` as the filename and therefore registers
* `/opsx-<id>`.
*
* This list is a tripwire, not the source of truth: production classifies a
* tool from its own `getFilePath`. A new adapter that lands on the wrong side
* of the split fails here, which is the point.
*/
const NAMESPACED_TOOLS = ['claude', 'codebuddy', 'crush', 'gemini', 'lingma', 'qoder', 'zcode'];
/**
* Tools whose command name is wrapped in something other than a slash. The
* prefix cannot be read off the file path, so it is adapter metadata — and
* this list is the tripwire that a new one was declared deliberately. Amazon Q
* loads its `.amazonq/prompts/` files into its prompt library, invoked as
* `@opsx-<id>`.
*/
const NON_SLASH_PREFIXES: Record<string, string> = { 'amazon-q': '@' };
const expectedInvocation = (toolId: string) => ({
style: NAMESPACED_TOOLS.includes(toolId) ? ('namespaced' as const) : ('flat' as const),
prefix: NON_SLASH_PREFIXES[toolId] ?? '/',
});
const sampleContent: CommandContent = {
id: 'apply',
name: 'OpenSpec Apply',
description: 'Implement tasks',
category: 'Workflow',
tags: ['openspec'],
body: 'Run /opsx:archive when done. See /opsx:continue for the next artifact.',
};
describe('command-generation/invocation', () => {
describe('getInvocationStyleForPath', () => {
it('classifies an opsx- prefixed filename as flat', () => {
expect(getInvocationStyleForPath(path.join('.cursor', 'commands', 'opsx-apply.md'))).toBe('flat');
expect(getInvocationStyleForPath(path.join('.github', 'prompts', 'opsx-apply.prompt.md'))).toBe('flat');
});
it('classifies a file inside an opsx/ directory as namespaced', () => {
expect(getInvocationStyleForPath(path.join('.claude', 'commands', 'opsx', 'apply.md'))).toBe('namespaced');
expect(getInvocationStyleForPath(path.join('.gemini', 'commands', 'opsx', 'apply.toml'))).toBe('namespaced');
});
});
describe('every registered adapter', () => {
it('is classified by the command files it writes, not by a hand-kept list', () => {
for (const adapter of CommandAdapterRegistry.getAll()) {
expect(
getInvocationForAdapter(adapter),
`${adapter.toolId} writes ${adapter.getFilePath('apply')}`
).toEqual(expectedInvocation(adapter.toolId));
}
});
it('defaults to the slash prefix unless the adapter declares another', () => {
// The prefix is the one part that cannot be derived from the file path,
// so an adapter that quietly grew one should show up here.
for (const adapter of CommandAdapterRegistry.getAll()) {
expect(adapter.invocationPrefix, adapter.toolId).toBe(
NON_SLASH_PREFIXES[adapter.toolId]
);
}
});
it('classifies every command id as that adapter is expected to be classified', () => {
for (const adapter of CommandAdapterRegistry.getAll()) {
const expected = NAMESPACED_TOOLS.includes(adapter.toolId) ? 'namespaced' : 'flat';
for (const id of ALL_WORKFLOWS) {
expect(
getInvocationStyleForPath(adapter.getFilePath(id)),
`${adapter.toolId} ${id}`
).toBe(expected);
}
}
});
});
describe('resolveCommandInvocation', () => {
it('resolves the invocation for every registered tool', () => {
// Compared against the expected table, not against
// getInvocationForAdapter — asserting f(x) === f(x) can never fail.
for (const adapter of CommandAdapterRegistry.getAll()) {
expect(resolveCommandInvocation(adapter.toolId), adapter.toolId).toEqual(
expectedInvocation(adapter.toolId)
);
}
expect(resolveCommandInvocation('cursor')).toEqual({ style: 'flat', prefix: '/' });
expect(resolveCommandInvocation('claude')).toEqual({ style: 'namespaced', prefix: '/' });
expect(resolveCommandInvocation('amazon-q')).toEqual({ style: 'flat', prefix: '@' });
});
it('returns undefined for tools with no command adapter', () => {
// These tools receive skills only, so they have no command name to spell.
for (const toolId of ['codex', 'kimi', 'vibe', 'hermes', 'not-a-tool']) {
expect(resolveCommandInvocation(toolId), toolId).toBeUndefined();
}
});
});
describe('formatCommandInvocation', () => {
it('spells each shape the way the tool registers it', () => {
expect(formatCommandInvocation({ style: 'namespaced', prefix: '/' }, 'apply')).toBe('/opsx:apply');
expect(formatCommandInvocation({ style: 'flat', prefix: '/' }, 'apply')).toBe('/opsx-apply');
expect(formatCommandInvocation({ style: 'flat', prefix: '@' }, 'bulk-archive')).toBe(
'@opsx-bulk-archive'
);
});
it('rewrites only what differs from the canonical authored form', () => {
expect(needsInvocationRewrite({ style: 'namespaced', prefix: '/' })).toBe(false);
expect(needsInvocationRewrite({ style: 'flat', prefix: '/' })).toBe(true);
expect(needsInvocationRewrite({ style: 'namespaced', prefix: '@' })).toBe(true);
});
});
describe('generateCommand', () => {
it('rewrites command references to the names a flat tool registers', () => {
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');
expect(fileContent, toolId).toContain('/opsx-continue');
expect(fileContent, toolId).not.toContain('/opsx:');
}
});
it("writes Amazon Q's prompt-library form, not a slash command", () => {
// .amazonq/prompts/opsx-<id>.md is a prompt, invoked with @ — a body
// telling the user to type /opsx-archive names nothing Amazon Q registers.
const adapter = CommandAdapterRegistry.get('amazon-q')!;
const { fileContent } = generateCommand(sampleContent, adapter);
expect(fileContent).toContain('@opsx-archive');
expect(fileContent).toContain('@opsx-continue');
expect(fileContent).not.toContain('/opsx-');
expect(fileContent).not.toContain('/opsx:');
});
it('leaves command references alone for namespaced tools', () => {
for (const toolId of NAMESPACED_TOOLS) {
const adapter = CommandAdapterRegistry.get(toolId)!;
const { fileContent } = generateCommand(sampleContent, adapter);
expect(fileContent, toolId).toContain('/opsx:archive');
expect(fileContent, toolId).not.toContain('/opsx-archive');
}
});
it('rewrites nothing but the command references', () => {
const adapter = CommandAdapterRegistry.get('cursor')!;
const plain = { ...sampleContent, body: 'Plain body. See docs/opsx.md and openspec/changes/.' };
const { fileContent } = generateCommand(plain, adapter);
expect(fileContent).toContain('Plain body. See docs/opsx.md and openspec/changes/.');
});
it('leaves the adapters themselves as pure formatters', () => {
// 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', 'devin']) {
const adapter = CommandAdapterRegistry.get(toolId)!;
expect(adapter.formatFile(sampleContent), toolId).toContain('/opsx:archive');
}
});
});
});