feat(tool-profiles): replace Windsurf with Devin Desktop

This commit is contained in:
psmyrdek
2026-08-20 10:10:45 +02:00
parent 503a9cb179
commit 8a6a32aca1
18 changed files with 302 additions and 55 deletions
+4
View File
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **Windsurf is now Devin Desktop.** The tool selector uses `devin-desktop`,
writes new artifacts under `.devin/`, and uses root `AGENTS.md` for course
rules. The former `windsurf` ID and `.windsurf/` markers remain supported for
config compatibility, detection, and artifact migration.
- **`10x bench-kit` is generally available.** The `TENX_CLI_EXPERIMENTAL`
opt-in is gone: `init` and `update` are registered unconditionally, appear
in `--help`, and are documented in the README. The `experimental_locked`
+6 -3
View File
@@ -68,7 +68,7 @@ Once installed, just tell your agent to **set up 10x-cli** and it will pick up t
| Flag | Description |
|------|-------------|
| `--tool <tool>` | AI coding tool: `claude-code`, `cursor`, `copilot`, `codex`, `windsurf`, `gemini`, `generic` |
| `--tool <tool>` | AI coding tool: `claude-code`, `cursor`, `copilot`, `codex`, `devin-desktop`, `gemini`, `generic` |
| `--print` | Output artifact content to stdout instead of writing files |
| `--type <type>` | Filter by artifact type: `skills`, `prompts`, `rules`, `configs` |
| `--name <name>` | Filter by artifact name (requires `--type`) |
@@ -197,7 +197,7 @@ scoring live in the template and the instance.
| Flag | Description |
|------|-------------|
| `--template-version <tag>` | Template tag to install (default: latest) |
| `--tool <id>` | Agent tool for skill placement (`claude-code`, `cursor`, `copilot`, `codex`, `windsurf`, `gemini`, `generic`) |
| `--tool <id>` | Agent tool for skill placement (`claude-code`, `cursor`, `copilot`, `codex`, `devin-desktop`, `gemini`, `generic`) |
| `--yes` | Run non-interactively, accepting defaults |
```bash
@@ -239,11 +239,14 @@ On first run, the CLI prompts you to choose your AI coding tool. Artifacts are w
| Cursor | `.cursor/` | `.cursor/rules/10x-course.mdc` |
| GitHub Copilot | `.github/` | `.github/copilot-instructions.md` |
| Codex CLI | `.agents/` | `AGENTS.md` |
| Windsurf | `.windsurf/` | `.windsurfrules` |
| Devin Desktop | `.devin/` | `AGENTS.md` |
| Gemini CLI | `.gemini/` | `GEMINI.md` |
| Generic | `.ai/` | `AGENTS.md` |
Override anytime with `--tool <name>`. Your choice is saved in `~/.config/10x-cli/config.json`.
The former `windsurf` ID remains accepted as an alias and is upgraded to
`devin-desktop`; existing `.windsurf/` artifacts can be migrated by the normal
tool-switch prompt.
## Development
+5 -1
View File
@@ -62,7 +62,7 @@ The CLI writes artifacts to the correct directory for your AI coding tool:
| Cursor | `.cursor/skills/` | `.cursor/rules/10x-course.mdc` | `.cursor/config-templates/` |
| GitHub Copilot | `.github/skills/` | `.github/copilot-instructions.md` | `.github/config-templates/` |
| Codex CLI | `.agents/skills/` | `AGENTS.md` | `.agents/config-templates/` |
| Windsurf | `.windsurf/skills/` | `.windsurfrules` | `.windsurf/config-templates/` |
| Devin Desktop | `.devin/skills/` | `AGENTS.md` | `.devin/config-templates/` |
| Generic | `.ai/skills/` | `AGENTS.md` | `.ai/config-templates/` |
The CLI auto-detects your tool from project markers on first run. Override anytime with `--tool`:
@@ -71,6 +71,10 @@ The CLI auto-detects your tool from project markers on first run. Override anyti
10x get m1l1 --tool cursor
```
`windsurf` remains a backward-compatible alias for `devin-desktop`. New files
use Devin Desktop's `.devin/` workspace convention; legacy `.windsurf/` markers
are still detected so existing 10x artifacts can be migrated.
## CI testing
The CLI is tested on both Ubuntu and Windows in CI:
+1
View File
@@ -81,6 +81,7 @@ The primary daily command. Fetches a lesson bundle from the API and writes skill
| Cursor | `.cursor/skills/<name>/SKILL.md` | `.cursor/prompts/<name>.md` | `.cursor/rules/10x-course.mdc` | `.cursor/config-templates/<name>` |
| GitHub Copilot | `.github/skills/<name>/SKILL.md` | `.github/prompts/<name>.md` | `.github/copilot-instructions.md` | `.github/config-templates/<name>` |
| Codex CLI | `.agents/skills/<name>/SKILL.md` | `.agents/prompts/<name>.md` | `AGENTS.md` | `.agents/config-templates/<name>` |
| Devin Desktop | `.devin/skills/<name>/SKILL.md` | `.devin/prompts/<name>.md` | `AGENTS.md` | `.devin/config-templates/<name>` |
| Generic | `.ai/skills/<name>/SKILL.md` | `.ai/prompts/<name>.md` | `AGENTS.md` | `.ai/config-templates/<name>` |
**Re-applying a lesson** overwrites skills and prompts if content changed, updates the rules sentinel block, but never overwrites config templates (they may contain user edits).
+10 -4
View File
@@ -31,7 +31,12 @@ import {
verbose,
} from "../lib/output";
import { type DetectionSignal, detectTools } from "../lib/tool-detect";
import { DEFAULT_TOOL, PROFILES } from "../lib/tool-profile";
import {
canonicalToolId,
DEFAULT_TOOL,
getToolProfile,
PROFILES,
} from "../lib/tool-profile";
export const TEMPLATE_REPO_URL = "https://github.com/przeprogramowani/10x-bench-kit";
@@ -464,7 +469,8 @@ async function resolveInstanceTool(
existingInstance: boolean,
): Promise<{ id: string; explicit: boolean }> {
if (options.tool !== undefined) {
if (!PROFILES[options.tool]) {
const canonicalId = canonicalToolId(options.tool);
if (!getToolProfile(options.tool)) {
outputError(
ctx,
"unknown_tool",
@@ -473,7 +479,7 @@ async function resolveInstanceTool(
`Supported: ${Object.keys(PROFILES).join(", ")}.`,
);
}
return { id: options.tool, explicit: true };
return { id: canonicalId, explicit: true };
}
const signals = deps.detectToolSignals(process.cwd());
const top = signals[0];
@@ -496,7 +502,7 @@ async function resolveInstanceTool(
/** Skill root (relative, posix — the contract is cross-platform JSON). */
export function skillRootFor(toolId: string): string {
const profile = PROFILES[toolId] ?? PROFILES[DEFAULT_TOOL]!;
const profile = getToolProfile(toolId) ?? PROFILES[DEFAULT_TOOL]!;
return `${profile.manifestDir}/skills`;
}
+2 -3
View File
@@ -6,7 +6,7 @@ import { apiBaseUrl, fetchHealth } from "../lib/api-content";
import { isExpired, isNearExpiry } from "../lib/auth-guard";
import { configDir, readAuth, readToolConfig } from "../lib/config";
import { formatReleaseAt } from "../lib/format";
import { PROFILES, DEFAULT_TOOL } from "../lib/tool-profile";
import { getToolProfile, PROFILES, DEFAULT_TOOL } from "../lib/tool-profile";
import { compareSemver, fetchLatestVersion, upgradeCommand } from "../lib/update-check";
import {
type GlobalFlags,
@@ -246,7 +246,7 @@ async function checkCliVersion(): Promise<CheckResult> {
function checkToolDirectory(): CheckResult {
const cwd = process.cwd();
const toolId = readToolConfig()?.tool ?? DEFAULT_TOOL;
const profile = PROFILES[toolId] ?? PROFILES[DEFAULT_TOOL]!;
const profile = getToolProfile(toolId) ?? PROFILES[DEFAULT_TOOL]!;
const dirName = profile.manifestDir;
const toolDir = join(cwd, dirName);
@@ -280,4 +280,3 @@ function checkToolDirectory(): CheckResult {
};
}
}
+5 -5
View File
@@ -17,7 +17,7 @@ import {
} from "../lib/output";
import { readToolConfig, updateToolConfig } from "../lib/config";
import { resolveToolProfile } from "../lib/tool-prompt";
import type { ToolProfile } from "../lib/tool-profile";
import { contentToolId, type ToolProfile } from "../lib/tool-profile";
import { applyBundle, detectOrphanedArtifacts, type WriteResult } from "../lib/writer";
const ARTIFACT_TYPES = ["skills", "prompts", "rules", "configs"] as const;
@@ -64,7 +64,7 @@ export function registerGetCommand(cli: CAC): void {
.option("--course <course>", "Override the course slug (default: 10xdevs3)")
.option(
"--tool <tool>",
"AI coding tool (claude-code, cursor, copilot, codex, windsurf, gemini, generic)",
"AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, generic)",
)
.option("--print", "Print artifact content to stdout instead of writing to files")
.option("--type <type>", "Artifact type filter: skills, prompts, rules, configs")
@@ -161,7 +161,7 @@ export async function runGet(
verbose(ctx, `fetching lesson ${course}/${parsed.lessonId}`);
const result = await fetchLesson(course, parsed.lessonId, auth.access_token, {
lang,
tool: profile.toolId,
tool: contentToolId(profile),
});
if (!result.ok) {
@@ -311,7 +311,7 @@ async function runPrintMode(
lessonId,
options.type,
options.name,
profile.toolId,
contentToolId(profile),
token,
{ lang },
);
@@ -335,7 +335,7 @@ async function runPrintMode(
} else {
// Fetch full bundle, filter by type, concatenate
verbose(ctx, `fetching lesson ${course}/${lessonId} (filtering by ${options.type})`);
const result = await fetchLesson(course, lessonId, token, { lang, tool: profile.toolId });
const result = await fetchLesson(course, lessonId, token, { lang, tool: contentToolId(profile) });
if (!result.ok) {
handleLessonError(ctx, result.status, result.code, result.error, result.payload);
+3 -3
View File
@@ -34,7 +34,7 @@ import {
} from "../lib/output";
import { readToolConfig } from "../lib/config";
import { resolveToolProfile } from "../lib/tool-prompt";
import type { ToolProfile } from "../lib/tool-profile";
import { contentToolId, type ToolProfile } from "../lib/tool-profile";
import {
applyBundle,
type ArtifactAction,
@@ -99,7 +99,7 @@ export function registerSyncCommand(cli: CAC): void {
.option("--course <course>", "Override the course slug (default: 10xdevs3)")
.option(
"--tool <tool>",
"AI coding tool (claude-code, cursor, copilot, codex, windsurf, gemini, generic)",
"AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, generic)",
)
.option("--lang <lang>", "Content language: en (default) or pl")
.option(
@@ -267,7 +267,7 @@ async function syncLesson(
verbose(ctx, `${lesson.lessonId}: fetching`);
const result = await fetchLesson(opts.course, lesson.lessonId, opts.token, {
lang: opts.lang,
tool: opts.profile.toolId,
tool: contentToolId(opts.profile),
signal: opts.signal,
});
+34 -7
View File
@@ -92,17 +92,36 @@ export function detectTools(projectRoot: string): DetectionSignal[] {
signals.push({ profileId: "codex", confidence: "medium", reason: "AGENTS.md" });
}
// Windsurf
if (hit(".windsurf/" + MANIFEST_FILENAME)) {
// Devin Desktop (formerly Windsurf). Prefer new markers, but detect legacy
// projects so the tool-switch flow can offer to migrate their artifacts.
if (hit(".devin/" + MANIFEST_FILENAME)) {
signals.push({
profileId: "windsurf",
profileId: "devin-desktop",
confidence: "strong",
reason: ".windsurf/.10x-cli-manifest.json",
reason: ".devin/.10x-cli-manifest.json",
});
} else if (hit(".devin/rules")) {
signals.push({ profileId: "devin-desktop", confidence: "strong", reason: ".devin/rules/" });
} else if (hit(".devin")) {
signals.push({ profileId: "devin-desktop", confidence: "medium", reason: ".devin/ directory" });
} else if (hit(".windsurf/" + MANIFEST_FILENAME)) {
signals.push({
profileId: "devin-desktop",
confidence: "strong",
reason: ".windsurf/.10x-cli-manifest.json (legacy)",
});
} else if (hit(".windsurfrules")) {
signals.push({ profileId: "windsurf", confidence: "strong", reason: ".windsurfrules" });
signals.push({
profileId: "devin-desktop",
confidence: "strong",
reason: ".windsurfrules (legacy)",
});
} else if (hit(".windsurf")) {
signals.push({ profileId: "windsurf", confidence: "medium", reason: ".windsurf/ directory" });
signals.push({
profileId: "devin-desktop",
confidence: "medium",
reason: ".windsurf/ directory (legacy)",
});
}
// Gemini CLI
@@ -135,7 +154,15 @@ export function detectTools(projectRoot: string): DetectionSignal[] {
}
const CONFIDENCE_ORDER: Record<Confidence, number> = { strong: 3, medium: 2, weak: 1 };
const PROFILE_ORDER = ["claude-code", "cursor", "copilot", "codex", "windsurf", "gemini", "generic"];
const PROFILE_ORDER = [
"claude-code",
"cursor",
"copilot",
"codex",
"devin-desktop",
"gemini",
"generic",
];
function rankSignals(signals: DetectionSignal[]): DetectionSignal[] {
return [...signals].sort((a, b) => {
+50 -9
View File
@@ -12,6 +12,8 @@ export const SENTINEL_END = "<!-- END @przeprogramowani/10x-cli -->" as const;
export interface ToolProfile {
toolId: string;
/** Delivery API transform ID when it differs from the user-facing profile ID. */
contentToolId?: string;
displayName: string;
skillPath: (name: string) => string;
skillDir: (name: string) => string;
@@ -72,15 +74,18 @@ export const PROFILES: Record<string, ToolProfile> = {
sentinelBegin: SENTINEL_BEGIN,
sentinelEnd: SENTINEL_END,
},
windsurf: {
toolId: "windsurf",
displayName: "Windsurf",
skillPath: (n) => `.windsurf/skills/${n}/SKILL.md`,
skillDir: (n) => `.windsurf/skills/${n}`,
promptPath: (n) => `.windsurf/prompts/${n}.md`,
configPath: (n) => `.windsurf/config-templates/${n}`,
rulesFile: ".windsurfrules",
manifestDir: ".windsurf",
"devin-desktop": {
toolId: "devin-desktop",
// The delivery API predates the product rename and still uses this transform ID.
contentToolId: "windsurf",
displayName: "Devin Desktop",
skillPath: (n) => `.devin/skills/${n}/SKILL.md`,
skillDir: (n) => `.devin/skills/${n}`,
promptPath: (n) => `.devin/prompts/${n}.md`,
configPath: (n) => `.devin/config-templates/${n}`,
// Supported by both Cascade and Devin Local; root AGENTS.md is always on.
rulesFile: "AGENTS.md",
manifestDir: ".devin",
sentinelBegin: SENTINEL_BEGIN,
sentinelEnd: SENTINEL_END,
},
@@ -110,4 +115,40 @@ export const PROFILES: Record<string, ToolProfile> = {
},
};
/** Previous product ID accepted by flags/config files after the rename. */
export const TOOL_ALIASES: Readonly<Record<string, string>> = {
windsurf: "devin-desktop",
};
/**
* Filesystem layouts that are no longer selectable but may contain manifests
* written by an older 10x-cli. The orphan flow can migrate these safely.
*/
export const LEGACY_PROFILES: Readonly<Record<string, ToolProfile>> = {
windsurf: {
toolId: "windsurf",
displayName: "Windsurf",
skillPath: (n) => `.windsurf/skills/${n}/SKILL.md`,
skillDir: (n) => `.windsurf/skills/${n}`,
promptPath: (n) => `.windsurf/prompts/${n}.md`,
configPath: (n) => `.windsurf/config-templates/${n}`,
rulesFile: ".windsurfrules",
manifestDir: ".windsurf",
sentinelBegin: SENTINEL_BEGIN,
sentinelEnd: SENTINEL_END,
},
};
export function canonicalToolId(toolId: string): string {
return TOOL_ALIASES[toolId] ?? toolId;
}
export function getToolProfile(toolId: string): ToolProfile | undefined {
return PROFILES[canonicalToolId(toolId)];
}
export function contentToolId(profile: ToolProfile): string {
return profile.contentToolId ?? profile.toolId;
}
export const DEFAULT_TOOL = "claude-code";
+20 -6
View File
@@ -16,7 +16,13 @@
import * as p from "@clack/prompts";
import { readToolConfig, saveToolConfig } from "./config";
import { detectTools, topDetectedProfile } from "./tool-detect";
import { PROFILES, DEFAULT_TOOL, type ToolProfile } from "./tool-profile";
import {
canonicalToolId,
getToolProfile,
PROFILES,
DEFAULT_TOOL,
type ToolProfile,
} from "./tool-profile";
import {
deleteArtifacts,
migrateArtifacts,
@@ -39,15 +45,16 @@ async function resolveProfileOnly(
): Promise<ToolProfile> {
// 1. Explicit --tool flag
if (flagOverride) {
const profile = PROFILES[flagOverride];
const canonicalId = canonicalToolId(flagOverride);
const profile = getToolProfile(flagOverride);
if (!profile) {
throw new Error(
`Unknown tool '${flagOverride}'. Supported: ${Object.keys(PROFILES).join(", ")}`,
);
}
const existing = readToolConfig();
if (existing?.tool !== flagOverride) {
saveToolConfig({ ...(existing ?? {}), tool: flagOverride });
if (existing?.tool !== canonicalId) {
saveToolConfig({ ...(existing ?? {}), tool: canonicalId });
if (process.stdout.isTTY) {
process.stderr.write(`Default tool set to ${profile.displayName}.\n`);
}
@@ -57,8 +64,15 @@ async function resolveProfileOnly(
// 2. Saved config
const config = readToolConfig();
if (config?.tool && PROFILES[config.tool]) {
return PROFILES[config.tool]!;
if (config?.tool) {
const profile = getToolProfile(config.tool);
if (profile) {
const canonicalId = canonicalToolId(config.tool);
if (canonicalId !== config.tool) {
saveToolConfig({ ...config, tool: canonicalId });
}
return profile;
}
}
// 3. Interactive prompt (TTY only), pre-filled by auto-detection
+2 -2
View File
@@ -36,7 +36,7 @@ import {
writeManifest,
} from "./manifest";
import { applyRulesBlockWithMarkers, removeRulesBlockWithMarkers } from "./sentinel-migration";
import { PROFILES, DEFAULT_TOOL, type ToolProfile } from "./tool-profile";
import { LEGACY_PROFILES, PROFILES, DEFAULT_TOOL, type ToolProfile } from "./tool-profile";
import pkgJson from "../../package.json";
const CLI_VERSION = pkgJson.version;
@@ -833,7 +833,7 @@ export function findOrphanedManifests(
currentProfile: ToolProfile,
): OrphanInfo[] {
const out: OrphanInfo[] = [];
for (const profile of Object.values(PROFILES)) {
for (const profile of [...Object.values(PROFILES), ...Object.values(LEGACY_PROFILES)]) {
if (profile.toolId === currentProfile.toolId) continue;
const manifestPath = join(projectRoot, profile.manifestDir, MANIFEST_FILENAME);
if (!existsSync(manifestPath)) continue;
+10
View File
@@ -222,6 +222,16 @@ describe("10x bench-kit init", () => {
});
expect(parseEnvelope(result.stdout).data.skillRoot).toBe(".agents/skills");
const legacyAliasTarget = join(tempDir("bench-kit-target-"), "legacy-alias");
await captureStreams(() =>
runBenchKitInit(JSON_CTX, legacyAliasTarget, { tool: "windsurf" }, deps),
);
expect(bootstrapCalls[1]!.request.tool).toEqual({
id: "devin-desktop",
skillRoot: ".devin/skills",
explicit: true,
});
const rejected = await captureStreams(() =>
runBenchKitInit(JSON_CTX, target, { tool: "vim" }, deps),
);
+2 -2
View File
@@ -304,7 +304,7 @@ describe("10x doctor — tool-profile-aware directory check", () => {
expect(toolDir?.message).toContain(".cursor");
});
it("fails when configured tool directory is missing", async () => {
it("legacy windsurf config checks the current .devin directory", async () => {
writeValidAuth();
healthyApi();
saveToolConfig({ tool: "windsurf" });
@@ -315,7 +315,7 @@ describe("10x doctor — tool-profile-aware directory check", () => {
const report = parseDoctor(stdout);
const toolDir = report.checks.find((c) => c.name === "tool-dir");
expect(toolDir?.status).toBe("fail");
expect(toolDir?.message).toContain(".windsurf/");
expect(toolDir?.message).toContain(".devin/");
});
});
+22
View File
@@ -472,6 +472,28 @@ describe("10x get — --tool persistence", () => {
expect(capturedTool).toBe("cursor");
});
it("uses the Devin Desktop profile while retaining the Windsurf API transform", async () => {
writeValidAuth();
let capturedTool: string | undefined;
apiContentMockState.fetchLessonImpl = (_course, _lessonId, _token, options) => {
capturedTool = options?.tool;
return lessonOk(makeBundle());
};
const { exitCode } = await runGet([
"get",
"m1l1",
"--tool",
"devin-desktop",
"--json",
]);
expect(exitCode ?? 0).toBe(0);
expect(capturedTool).toBe("windsurf");
expect(readToolConfig()?.tool).toBe("devin-desktop");
expect(existsSync(join(projectRoot, ".devin/skills/code-review/SKILL.md"))).toBe(true);
});
it("shows feedback on stderr when tool changes (TTY mode)", async () => {
writeValidAuth();
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
+32 -8
View File
@@ -128,27 +128,51 @@ describe("detectTools", () => {
expect(signals[0]!.confidence).toBe("weak");
});
it(".windsurfrules → windsurf (strong)", () => {
touchFile(".windsurfrules", "# rules\n");
it(".devin/rules/devin-desktop (strong)", () => {
touchDir(".devin/rules");
const signals = detectTools(tmp);
expect(signals).toHaveLength(1);
expect(signals[0]!.profileId).toBe("windsurf");
expect(signals[0]!.profileId).toBe("devin-desktop");
expect(signals[0]!.confidence).toBe("strong");
});
it(".windsurf/ directory only → windsurf (medium)", () => {
touchDir(".windsurf");
it(".devin/ directory only → devin-desktop (medium)", () => {
touchDir(".devin");
const signals = detectTools(tmp);
expect(signals).toHaveLength(1);
expect(signals[0]!.profileId).toBe("windsurf");
expect(signals[0]!.profileId).toBe("devin-desktop");
expect(signals[0]!.confidence).toBe("medium");
});
it(".windsurf manifest → windsurf (strong)", () => {
it(".devin manifest → devin-desktop (strong)", () => {
writeManifestAt(".devin");
const signals = detectTools(tmp);
expect(signals).toHaveLength(1);
expect(signals[0]!.profileId).toBe("devin-desktop");
expect(signals[0]!.confidence).toBe("strong");
});
it("legacy .windsurfrules → devin-desktop (strong)", () => {
touchFile(".windsurfrules", "# rules\n");
const signals = detectTools(tmp);
expect(signals).toHaveLength(1);
expect(signals[0]!.profileId).toBe("devin-desktop");
expect(signals[0]!.confidence).toBe("strong");
});
it("legacy .windsurf/ directory only → devin-desktop (medium)", () => {
touchDir(".windsurf");
const signals = detectTools(tmp);
expect(signals).toHaveLength(1);
expect(signals[0]!.profileId).toBe("devin-desktop");
expect(signals[0]!.confidence).toBe("medium");
});
it("legacy .windsurf manifest → devin-desktop (strong)", () => {
writeManifestAt(".windsurf");
const signals = detectTools(tmp);
expect(signals).toHaveLength(1);
expect(signals[0]!.profileId).toBe("windsurf");
expect(signals[0]!.profileId).toBe("devin-desktop");
expect(signals[0]!.confidence).toBe("strong");
});
});
+55 -2
View File
@@ -7,7 +7,13 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PROFILES, DEFAULT_TOOL, SENTINEL_BEGIN, SENTINEL_END } from "../src/lib/tool-profile";
import {
DEFAULT_TOOL,
LEGACY_PROFILES,
PROFILES,
SENTINEL_BEGIN,
SENTINEL_END,
} from "../src/lib/tool-profile";
import { readToolConfig, saveToolConfig, toolConfigPath } from "../src/lib/config";
import { resolveToolProfile } from "../src/lib/tool-prompt";
import { isSafeName } from "../src/lib/writer";
@@ -60,6 +66,16 @@ describe("tool profiles — path generation", () => {
expect(p.manifestDir).toBe(".agents");
});
it("devin-desktop profile produces current .devin/ paths", () => {
const p = PROFILES["devin-desktop"]!;
expect(p.displayName).toBe("Devin Desktop");
expect(p.skillPath("code-review")).toBe(".devin/skills/code-review/SKILL.md");
expect(p.promptPath("plan")).toBe(".devin/prompts/plan.md");
expect(p.configPath("settings.json")).toBe(".devin/config-templates/settings.json");
expect(p.rulesFile).toBe("AGENTS.md");
expect(p.manifestDir).toBe(".devin");
});
it("generic profile produces .ai/ paths", () => {
const p = PROFILES["generic"]!;
expect(p.skillPath("code-review")).toBe(".ai/skills/code-review/SKILL.md");
@@ -172,6 +188,25 @@ describe("resolveToolProfile", () => {
expect(profile.toolId).toBe("cursor");
});
it("legacy windsurf config resolves to Devin Desktop and is canonicalized", async () => {
saveToolConfig({ tool: "windsurf" });
process.stdout.isTTY = false;
const profile = await resolveToolProfile();
expect(profile.toolId).toBe("devin-desktop");
expect(readToolConfig()?.tool).toBe("devin-desktop");
});
it("legacy --tool windsurf flag remains an alias for Devin Desktop", async () => {
process.stdout.isTTY = false;
const profile = await resolveToolProfile("windsurf");
expect(profile.toolId).toBe("devin-desktop");
expect(readToolConfig()?.tool).toBe("devin-desktop");
});
it("defaults to claude-code in non-interactive mode with no config", async () => {
process.stdout.isTTY = false;
const profile = await resolveToolProfile();
@@ -270,7 +305,7 @@ describe("resolveToolProfile — tool-switch migration", () => {
});
function seedOrphanManifest(toolId: string, skills: string[] = []): void {
const profile = PROFILES[toolId]!;
const profile = PROFILES[toolId] ?? LEGACY_PROFILES[toolId]!;
const skillsRecord = Object.fromEntries(
skills.map((s) => [s, { files: ["SKILL.md"] }]),
);
@@ -321,6 +356,24 @@ describe("resolveToolProfile — tool-switch migration", () => {
);
});
it("migrates legacy Windsurf artifacts into the Devin Desktop layout", async () => {
process.stdout.isTTY = true;
saveToolConfig({ tool: "windsurf" });
seedOrphanManifest("windsurf", ["code-review"]);
clackMockState.selectImpl = (opts: SelectOpts) => {
if (opts.message.includes("What should we do")) return "migrate";
return opts.initialValue;
};
const profile = await resolveToolProfile(undefined, projectRoot);
expect(profile.toolId).toBe("devin-desktop");
expect(readToolConfig()?.tool).toBe("devin-desktop");
expect(existsSync(join(projectRoot, ".devin/skills/code-review/SKILL.md"))).toBe(true);
expect(existsSync(join(projectRoot, ".windsurf/skills/code-review/SKILL.md"))).toBe(false);
expect(existsSync(join(projectRoot, ".windsurf", MANIFEST_FILENAME))).toBe(false);
});
it("does NOT prompt for an orphan that is already acknowledged", async () => {
process.stdout.isTTY = true;
saveToolConfig({ tool: "cursor", acknowledgedOrphans: ["claude-code"] });
+39
View File
@@ -135,6 +135,23 @@ describe("writer with codex profile", () => {
});
});
// ---------------------------------------------------------------------------
// Devin Desktop profile
// ---------------------------------------------------------------------------
describe("writer with Devin Desktop profile", () => {
const devinProfile = PROFILES["devin-desktop"]!;
it("writes artifacts under .devin/ and always-on rules to AGENTS.md", async () => {
await applyBundle(makeBundle(), tmp, { profile: devinProfile });
expect(existsSync(join(tmp, ".devin/skills/code-review/SKILL.md"))).toBe(true);
expect(existsSync(join(tmp, ".devin/prompts/plan.md"))).toBe(true);
expect(existsSync(join(tmp, "AGENTS.md"))).toBe(true);
expect(existsSync(join(tmp, ".devin/config-templates/settings.json"))).toBe(true);
expect(readManifest(join(tmp, ".devin"))?.tool).toBe("devin-desktop");
});
});
// ---------------------------------------------------------------------------
// Generic profile
// ---------------------------------------------------------------------------
@@ -239,6 +256,28 @@ describe("detectOrphanedArtifacts", () => {
expect(warning).toContain(".claude/");
});
it("recognizes a legacy .windsurf manifest when Devin Desktop is current", () => {
const legacyManifest = join(tmp, ".windsurf", MANIFEST_FILENAME);
mkdirSync(join(tmp, ".windsurf"), { recursive: true });
writeFileSync(
legacyManifest,
JSON.stringify({
package: "@przeprogramowani/10x-cli",
version: "1.19.0",
manifestVersion: 2,
lastApplied: new Date().toISOString(),
lessonId: "m1l1",
course: "10xdevs3",
tool: "windsurf",
files: { skills: {}, prompts: [], configs: [] },
}),
);
const warning = detectOrphanedArtifacts(tmp, PROFILES["devin-desktop"]!);
expect(warning).toContain(".windsurf/");
expect(warning).toContain(".devin/");
});
it("does not warn about the current tool's own manifest", async () => {
// Simulate existing cursor install, then check as cursor
const cursorManifest = join(tmp, ".cursor", MANIFEST_FILENAME);