mirror of
https://github.com/przeprogramowani/10x-cli.git
synced 2026-09-19 03:30:01 +08:00
da67b5f7bb
Register a `kiro` profile that writes artifacts under `.kiro/` (skills, prompts, config templates) and co-owns the root `AGENTS.md` for course rules alongside codex, devin-desktop and generic. Content is delivered in the generic variant because the delivery API exposes no `kiro` transform yet. Auto-detection reports Kiro from `.kiro/.10x-cli-manifest.json`, `.kiro/steering/`, `.kiro/specs/`, `.kiro/hooks/`, `.kiro/settings/` or a bare `.kiro/` directory. Every branch is `strong`: the directory name is Kiro-specific with no plausible false positive, so a Kiro workspace never loses the `AGENTS.md` confidence tie to codex and `PROFILE_ORDER` needs no reshuffle. The profile takes its place in the resolution order ahead of generic. Extend `--tool` help text for `get` and `sync`, document the new paths, the `.kiro/config-templates/` staging caveat and the exact limits of `AGENTS.md` co-ownership (byte-identical rules only, so `kiro` pairs with generic but reports `incompatible_shared_owner` next to codex or devin-desktop) in the platform support reference and README, and cover the profile in the detection, profile, tool-switch, writer and install-contract test suites — including the divergent-content case that must fail closed. The bundled `skills/10x-cli-guide/SKILL.md` is deliberately left unchanged: `helpers install` is create-only and exits 1 on a byte difference, so a documentation-only edit there would break every existing install.
570 lines
21 KiB
TypeScript
570 lines
21 KiB
TypeScript
/**
|
|
* Tool profile tests — verify each profile produces correct paths and
|
|
* that resolveToolProfile() respects the priority chain.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, relative } from "node:path";
|
|
import {
|
|
DEFAULT_TOOL,
|
|
LEGACY_PROFILES,
|
|
PROFILES,
|
|
SENTINEL_BEGIN,
|
|
SENTINEL_END,
|
|
contentToolId,
|
|
} from "../src/lib/tool-profile";
|
|
import { readToolConfig, saveToolConfig, toolConfigPath } from "../src/lib/config";
|
|
import { prepareToolForWrite, resolveToolProfile } from "../src/lib/tool-prompt";
|
|
import { isSafeName } from "../src/lib/writer";
|
|
import {
|
|
CLI_PACKAGE_NAME,
|
|
MANIFEST_FILENAME,
|
|
type CliManifest,
|
|
} from "../src/lib/manifest";
|
|
import { clackMockState, resetClackMock, type SelectOpts } from "./helpers/clack-mock";
|
|
import { redirectConfigDir, restoreConfigDir } from "./helpers/config-isolation";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Profile path tests — one case per tool profile
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("tool profiles — path generation", () => {
|
|
it("claude-code profile produces .claude/ paths", () => {
|
|
const p = PROFILES["claude-code"]!;
|
|
expect(p.skillPath("code-review")).toBe(".claude/skills/code-review/SKILL.md");
|
|
expect(p.promptPath("plan")).toBe(".claude/prompts/plan.md");
|
|
expect(p.configPath("settings.json")).toBe(".claude/config-templates/settings.json");
|
|
expect(p.rulesFile).toBe("CLAUDE.md");
|
|
expect(p.manifestDir).toBe(".claude");
|
|
});
|
|
|
|
it("cursor profile produces .cursor/ paths", () => {
|
|
const p = PROFILES["cursor"]!;
|
|
expect(p.skillPath("code-review")).toBe(".cursor/skills/code-review/SKILL.md");
|
|
expect(p.promptPath("plan")).toBe(".cursor/prompts/plan.md");
|
|
expect(p.configPath("settings.json")).toBe(".cursor/config-templates/settings.json");
|
|
expect(p.rulesFile).toBe(".cursor/rules/10x-course.mdc");
|
|
expect(p.manifestDir).toBe(".cursor");
|
|
});
|
|
|
|
it("copilot profile produces .github/ paths", () => {
|
|
const p = PROFILES["copilot"]!;
|
|
expect(p.skillPath("code-review")).toBe(".github/skills/code-review/SKILL.md");
|
|
expect(p.promptPath("plan")).toBe(".github/prompts/plan.md");
|
|
expect(p.configPath("settings.json")).toBe(".github/config-templates/settings.json");
|
|
expect(p.rulesFile).toBe(".github/copilot-instructions.md");
|
|
expect(p.manifestDir).toBe(".github");
|
|
});
|
|
|
|
it("codex profile produces .agents/ paths", () => {
|
|
const p = PROFILES["codex"]!;
|
|
expect(p.skillPath("code-review")).toBe(".agents/skills/code-review/SKILL.md");
|
|
expect(p.promptPath("plan")).toBe(".agents/prompts/plan.md");
|
|
expect(p.configPath("settings.json")).toBe(".agents/config-templates/settings.json");
|
|
expect(p.rulesFile).toBe("AGENTS.md");
|
|
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("kiro profile produces .kiro/ paths and requests the generic content variant", () => {
|
|
const p = PROFILES["kiro"]!;
|
|
expect(p.displayName).toBe("Kiro");
|
|
expect(p.skillPath("code-review")).toBe(".kiro/skills/code-review/SKILL.md");
|
|
expect(p.promptPath("plan")).toBe(".kiro/prompts/plan.md");
|
|
expect(p.configPath("settings.json")).toBe(".kiro/config-templates/settings.json");
|
|
expect(p.rulesFile).toBe("AGENTS.md");
|
|
expect(p.manifestDir).toBe(".kiro");
|
|
expect(contentToolId(p)).toBe("generic");
|
|
});
|
|
|
|
it("generic profile produces .ai/ paths", () => {
|
|
const p = PROFILES["generic"]!;
|
|
expect(p.skillPath("code-review")).toBe(".ai/skills/code-review/SKILL.md");
|
|
expect(p.promptPath("plan")).toBe(".ai/prompts/plan.md");
|
|
expect(p.configPath("settings.json")).toBe(".ai/config-templates/settings.json");
|
|
expect(p.rulesFile).toBe("AGENTS.md");
|
|
expect(p.manifestDir).toBe(".ai");
|
|
});
|
|
|
|
it("DEFAULT_TOOL is claude-code", () => {
|
|
expect(DEFAULT_TOOL).toBe("claude-code");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Profile coherence invariants — guards against silent drift when profiles
|
|
// are added or refactored.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("profile coherence", () => {
|
|
const profiles = Object.values(PROFILES);
|
|
|
|
it("every skill/prompt/config path is rooted under the profile's manifestDir", () => {
|
|
for (const p of profiles) {
|
|
const prefix = p.manifestDir + "/";
|
|
expect(p.skillPath("sample").startsWith(prefix)).toBe(true);
|
|
expect(p.promptPath("sample").startsWith(prefix)).toBe(true);
|
|
expect(p.configPath("sample").startsWith(prefix)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("leaf segments of generated paths pass isSafeName for safe inputs", () => {
|
|
for (const p of profiles) {
|
|
const skillLeaf = p.skillPath("foo").split("/").pop();
|
|
const promptLeaf = p.promptPath("foo").split("/").pop();
|
|
const configLeaf = p.configPath("foo").split("/").pop();
|
|
expect(skillLeaf).toBeDefined();
|
|
expect(promptLeaf).toBeDefined();
|
|
expect(configLeaf).toBeDefined();
|
|
// skill leaf is SKILL.md, prompt is foo.md, config is foo — all safe by construction
|
|
expect(isSafeName("foo")).toBe(true);
|
|
expect(skillLeaf!.length).toBeGreaterThan(0);
|
|
expect(promptLeaf!.length).toBeGreaterThan(0);
|
|
expect(configLeaf!.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("every profile's rulesFile is non-empty and has no parent-dir segments", () => {
|
|
for (const p of profiles) {
|
|
expect(p.rulesFile.length).toBeGreaterThan(0);
|
|
expect(p.rulesFile.split("/").includes("..")).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("every profile shares the same canonical sentinel markers", () => {
|
|
for (const p of profiles) {
|
|
expect(p.sentinelBegin).toBe(SENTINEL_BEGIN);
|
|
expect(p.sentinelEnd).toBe(SENTINEL_END);
|
|
}
|
|
});
|
|
|
|
it("no two profiles share the same manifestDir", () => {
|
|
const dirs = profiles.map((p) => p.manifestDir);
|
|
expect(new Set(dirs).size).toBe(dirs.length);
|
|
});
|
|
|
|
it("no two profiles share the same toolId", () => {
|
|
const ids = profiles.map((p) => p.toolId);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
});
|
|
|
|
it("every profile has a non-empty displayName", () => {
|
|
for (const p of profiles) {
|
|
expect(p.displayName.trim().length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveToolProfile — priority chain
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("resolveToolProfile", () => {
|
|
let tmp: string;
|
|
let priorIsTTY: boolean | undefined;
|
|
|
|
beforeEach(() => {
|
|
tmp = mkdtempSync(join(tmpdir(), "10x-cli-tool-"));
|
|
redirectConfigDir(tmp);
|
|
priorIsTTY = process.stdout.isTTY;
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreConfigDir();
|
|
if (priorIsTTY === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY;
|
|
else process.stdout.isTTY = priorIsTTY;
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
it("--tool flag takes highest priority over config", async () => {
|
|
saveToolConfig({ tool: "cursor" });
|
|
const profile = await resolveToolProfile("copilot");
|
|
expect(profile.toolId).toBe("copilot");
|
|
});
|
|
|
|
it("config file is used when no flag is given", async () => {
|
|
saveToolConfig({ tool: "cursor" });
|
|
process.stdout.isTTY = false; // non-interactive
|
|
const profile = await resolveToolProfile();
|
|
expect(profile.toolId).toBe("cursor");
|
|
});
|
|
|
|
it("legacy windsurf config resolves read-only and is canonicalized only for a validated write", async () => {
|
|
saveToolConfig({ tool: "windsurf" });
|
|
process.stdout.isTTY = false;
|
|
|
|
const before = readFileSync(toolConfigPath());
|
|
const profile = await resolveToolProfile(undefined, tmp);
|
|
|
|
expect(profile.toolId).toBe("devin-desktop");
|
|
expect(readFileSync(toolConfigPath())).toEqual(before);
|
|
expect(readToolConfig()?.tool).toBe("windsurf");
|
|
expect(existsSync(join(tmp, ".10x-cli.json"))).toBe(false);
|
|
|
|
await prepareToolForWrite(tmp, profile, "10xdevs3");
|
|
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", tmp);
|
|
|
|
expect(profile.toolId).toBe("devin-desktop");
|
|
expect(readToolConfig()).toBeNull();
|
|
expect(existsSync(join(tmp, ".10x-cli.json"))).toBe(false);
|
|
|
|
await prepareToolForWrite(tmp, profile, "10xdevs3");
|
|
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();
|
|
expect(profile.toolId).toBe("claude-code");
|
|
});
|
|
|
|
it("unknown tool name throws an error", async () => {
|
|
await expect(resolveToolProfile("vim")).rejects.toThrow(/Unknown tool 'vim'/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveToolProfile — auto-detection integration (TTY path)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("resolveToolProfile — auto-detection", () => {
|
|
let tmp: string;
|
|
let projectRoot: string;
|
|
let priorIsTTY: boolean | undefined;
|
|
|
|
beforeEach(() => {
|
|
tmp = mkdtempSync(join(tmpdir(), "10x-cli-detect-int-"));
|
|
projectRoot = join(tmp, "project");
|
|
mkdirSync(projectRoot, { recursive: true });
|
|
redirectConfigDir(tmp);
|
|
priorIsTTY = process.stdout.isTTY;
|
|
resetClackMock();
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreConfigDir();
|
|
if (priorIsTTY === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY;
|
|
else process.stdout.isTTY = priorIsTTY;
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
resetClackMock();
|
|
});
|
|
|
|
it("TTY + detection match: prompt initialValue is the detected tool", async () => {
|
|
process.stdout.isTTY = true;
|
|
mkdirSync(join(projectRoot, ".cursor", "rules"), { recursive: true });
|
|
|
|
const profile = await resolveToolProfile(undefined, projectRoot);
|
|
|
|
expect(clackMockState.lastSelect).not.toBeNull();
|
|
expect(clackMockState.lastSelect!.initialValue).toBe("cursor");
|
|
expect(clackMockState.noteMessages.some((m) => /Cursor/.test(m))).toBe(true);
|
|
expect(profile.toolId).toBe("cursor");
|
|
});
|
|
|
|
it("TTY + no detection match: prompt initialValue is claude-code, no note printed", async () => {
|
|
process.stdout.isTTY = true;
|
|
|
|
const profile = await resolveToolProfile(undefined, projectRoot);
|
|
|
|
expect(clackMockState.lastSelect).not.toBeNull();
|
|
expect(clackMockState.lastSelect!.initialValue).toBe(DEFAULT_TOOL);
|
|
expect(clackMockState.noteMessages).toEqual([]);
|
|
expect(profile.toolId).toBe(DEFAULT_TOOL);
|
|
});
|
|
|
|
it("non-TTY: detection is not consulted, select is never called, returns default", async () => {
|
|
process.stdout.isTTY = false;
|
|
mkdirSync(join(projectRoot, ".cursor", "rules"), { recursive: true });
|
|
|
|
const profile = await resolveToolProfile(undefined, projectRoot);
|
|
|
|
expect(clackMockState.lastSelect).toBeNull();
|
|
expect(profile.toolId).toBe(DEFAULT_TOOL);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveToolProfile — tool-switch migration integration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("prepareToolForWrite — tool-switch migration", () => {
|
|
let tmp: string;
|
|
let projectRoot: string;
|
|
let priorIsTTY: boolean | undefined;
|
|
|
|
beforeEach(() => {
|
|
tmp = mkdtempSync(join(tmpdir(), "10x-cli-switch-int-"));
|
|
projectRoot = join(tmp, "project");
|
|
mkdirSync(projectRoot, { recursive: true });
|
|
redirectConfigDir(tmp);
|
|
priorIsTTY = process.stdout.isTTY;
|
|
resetClackMock();
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreConfigDir();
|
|
if (priorIsTTY === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY;
|
|
else process.stdout.isTTY = priorIsTTY;
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
resetClackMock();
|
|
});
|
|
|
|
function seedOrphanManifest(toolId: string, skills: string[] = []): void {
|
|
const profile = PROFILES[toolId] ?? LEGACY_PROFILES[toolId]!;
|
|
const skillsRecord = Object.fromEntries(
|
|
skills.map((s) => [s, { files: ["SKILL.md"] }]),
|
|
);
|
|
const manifest: CliManifest = {
|
|
package: CLI_PACKAGE_NAME,
|
|
version: "0.5.0",
|
|
manifestVersion: 2,
|
|
lastApplied: "2026-04-18T00:00:00Z",
|
|
lessonId: "m1l1",
|
|
course: "10xdevs3",
|
|
tool: toolId,
|
|
files: { skills: skillsRecord, prompts: [], configs: [] },
|
|
};
|
|
const manifestPath = join(projectRoot, profile.manifestDir, MANIFEST_FILENAME);
|
|
mkdirSync(join(projectRoot, profile.manifestDir), { recursive: true });
|
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
for (const s of skills) {
|
|
const p = join(projectRoot, profile.skillPath(s));
|
|
mkdirSync(join(p, ".."), { recursive: true });
|
|
writeFileSync(p, `# ${s}\n`);
|
|
}
|
|
}
|
|
|
|
function projectSnapshot(): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
function visit(dir: string): void {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name);
|
|
const key = relative(projectRoot, path);
|
|
if (entry.isDirectory()) {
|
|
result[`${key}/`] = "directory";
|
|
visit(path);
|
|
} else {
|
|
result[key] = readFileSync(path).toString("base64");
|
|
}
|
|
}
|
|
}
|
|
visit(projectRoot);
|
|
return result;
|
|
}
|
|
|
|
async function resolveThenPrepare() {
|
|
const projectBefore = projectSnapshot();
|
|
const configBefore = readFileSync(toolConfigPath());
|
|
const profile = await resolveToolProfile(undefined, projectRoot);
|
|
expect(projectSnapshot()).toEqual(projectBefore);
|
|
expect(readFileSync(toolConfigPath())).toEqual(configBefore);
|
|
expect(clackMockState.selectCalls.some((call) => call.message.includes("What should we do"))).toBe(false);
|
|
await prepareToolForWrite(projectRoot, profile, "10xdevs3");
|
|
return profile;
|
|
}
|
|
|
|
it("prompts for migrate/delete/keep when switching tools with a present, non-acknowledged orphan", async () => {
|
|
process.stdout.isTTY = true;
|
|
saveToolConfig({ tool: "cursor" });
|
|
seedOrphanManifest("claude-code", ["code-review"]);
|
|
// Pick "migrate"
|
|
clackMockState.selectImpl = (opts: SelectOpts) => {
|
|
if (opts.message.includes("What should we do")) return "migrate";
|
|
return opts.initialValue;
|
|
};
|
|
|
|
const profile = await resolveThenPrepare();
|
|
|
|
expect(profile.toolId).toBe("cursor");
|
|
const migrationPrompt = clackMockState.selectCalls.find((c) =>
|
|
c.message.includes("What should we do"),
|
|
);
|
|
expect(migrationPrompt).toBeDefined();
|
|
expect(migrationPrompt!.options.map((o) => o.value).sort()).toEqual(
|
|
["delete", "keep", "migrate"].sort(),
|
|
);
|
|
// Migration ran: file moved to cursor profile
|
|
expect(existsSync(join(projectRoot, PROFILES["cursor"]!.skillPath("code-review")))).toBe(true);
|
|
expect(existsSync(join(projectRoot, PROFILES["claude-code"]!.skillPath("code-review")))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
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 resolveThenPrepare();
|
|
|
|
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"] });
|
|
seedOrphanManifest("claude-code", ["code-review"]);
|
|
|
|
await resolveThenPrepare();
|
|
|
|
const migrationPrompt = clackMockState.selectCalls.find((c) =>
|
|
c.message.includes("What should we do"),
|
|
);
|
|
expect(migrationPrompt).toBeUndefined();
|
|
// Files left in place
|
|
expect(existsSync(join(projectRoot, PROFILES["claude-code"]!.skillPath("code-review")))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("non-TTY: migration prompt is skipped entirely", async () => {
|
|
process.stdout.isTTY = false;
|
|
saveToolConfig({ tool: "cursor" });
|
|
seedOrphanManifest("claude-code", ["code-review"]);
|
|
|
|
await resolveThenPrepare();
|
|
|
|
expect(clackMockState.selectCalls).toEqual([]);
|
|
// Files left in place — non-TTY keeps the legacy verbose warning in get.ts
|
|
expect(existsSync(join(projectRoot, PROFILES["claude-code"]!.skillPath("code-review")))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("choosing 'keep' persists acknowledgedOrphans in config.json", async () => {
|
|
process.stdout.isTTY = true;
|
|
saveToolConfig({ tool: "cursor" });
|
|
seedOrphanManifest("claude-code", ["code-review"]);
|
|
clackMockState.selectImpl = (opts: SelectOpts) => {
|
|
if (opts.message.includes("What should we do")) return "keep";
|
|
return opts.initialValue;
|
|
};
|
|
|
|
await resolveThenPrepare();
|
|
|
|
const cfg = readToolConfig();
|
|
expect(cfg?.acknowledgedOrphans).toEqual(["claude-code"]);
|
|
// Files preserved
|
|
expect(existsSync(join(projectRoot, PROFILES["claude-code"]!.skillPath("code-review")))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("choosing 'keep' preserves unknown fields in config.json", async () => {
|
|
process.stdout.isTTY = true;
|
|
// Seed config.json directly with a synthetic field that the current
|
|
// ToolConfig type doesn't know about (simulating a future CLI version
|
|
// or a hand-edited key).
|
|
const cfgPath = toolConfigPath();
|
|
mkdirSync(join(cfgPath, ".."), { recursive: true });
|
|
writeFileSync(
|
|
cfgPath,
|
|
`${JSON.stringify(
|
|
{
|
|
tool: "cursor",
|
|
lang: "pl",
|
|
lastSwitchedAt: "2026-04-19",
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
seedOrphanManifest("claude-code", ["code-review"]);
|
|
clackMockState.selectImpl = (opts: SelectOpts) => {
|
|
if (opts.message.includes("What should we do")) return "keep";
|
|
return opts.initialValue;
|
|
};
|
|
|
|
await resolveThenPrepare();
|
|
|
|
const raw = JSON.parse(readFileSync(cfgPath, "utf8")) as Record<string, unknown>;
|
|
expect(raw["tool"]).toBe("cursor");
|
|
expect(raw["lang"]).toBe("pl");
|
|
expect(raw["lastSwitchedAt"]).toBe("2026-04-19");
|
|
expect(raw["acknowledgedOrphans"]).toEqual(["claude-code"]);
|
|
});
|
|
|
|
it("cancel on the migration prompt does NOT persist acknowledgement", async () => {
|
|
process.stdout.isTTY = true;
|
|
saveToolConfig({ tool: "cursor" });
|
|
seedOrphanManifest("claude-code", ["code-review"]);
|
|
// clack encodes cancel via a Symbol; our mock treats any symbol as cancel.
|
|
const CANCEL_SYMBOL = Symbol("cancel");
|
|
clackMockState.selectImpl = (opts: SelectOpts) => {
|
|
if (opts.message.includes("What should we do")) return CANCEL_SYMBOL;
|
|
return opts.initialValue;
|
|
};
|
|
|
|
await resolveThenPrepare();
|
|
|
|
const cfg = readToolConfig();
|
|
expect(cfg?.acknowledgedOrphans).toBeUndefined();
|
|
// Files preserved
|
|
expect(existsSync(join(projectRoot, PROFILES["claude-code"]!.skillPath("code-review")))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tool config persistence
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("tool config persistence", () => {
|
|
let tmp: string;
|
|
|
|
beforeEach(() => {
|
|
tmp = mkdtempSync(join(tmpdir(), "10x-cli-cfg-"));
|
|
redirectConfigDir(tmp);
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreConfigDir();
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
it("readToolConfig returns null when no config exists", () => {
|
|
expect(readToolConfig()).toBeNull();
|
|
});
|
|
|
|
it("saveToolConfig creates config.json and readToolConfig reads it back", () => {
|
|
saveToolConfig({ tool: "cursor" });
|
|
const config = readToolConfig();
|
|
expect(config).not.toBeNull();
|
|
expect(config!.tool).toBe("cursor");
|
|
});
|
|
|
|
it("config.json is stored alongside auth.json in configDir", () => {
|
|
saveToolConfig({ tool: "codex" });
|
|
const cfgPath = toolConfigPath();
|
|
expect(cfgPath).toContain("10x-cli");
|
|
expect(cfgPath).toEndWith("config.json");
|
|
expect(existsSync(cfgPath)).toBe(true);
|
|
});
|
|
});
|