mirror of
https://github.com/przeprogramowani/10x-cli.git
synced 2026-09-19 03:30:01 +08:00
feat(tool-profiles): replace Windsurf with Devin Desktop
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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/");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user