Files
przeprogramowani__10x-cli/tests/get-command.test.ts
Claude 2033530ffa feat: prepare v4 course delivery and protected project sync
Capture the reviewed implementation and manual rehearsal for draft PR review. Master source prerequisites, full clean verification and coordinated Windows CI remain open; production rollout is separate.
2026-09-12 18:49:57 +02:00

694 lines
26 KiB
TypeScript

/**
* 10x get — command-level behavior.
*
* Strategy mirrors exit-codes.test.ts / json-envelope.test.ts:
* - mock api-content at the module level via the shared helper
* - write a valid auth file to a per-test XDG_CONFIG_HOME
* - drive the command via cli.parse(..., { run: false }) + runMatchedCommand()
*
* These tests never make real network calls. Phase 5 turned the writer into
* a real filesystem writer, so every test also chdirs into a per-test
* tempdir (`<tmp>/project`) before running the command — that isolates any
* `.claude/` or `CLAUDE.md` side effects from the repo the tests run from.
*/
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 cac from "cac";
import type { ApiResult } from "../src/lib/api-client";
import type { LessonBundle } from "../src/lib/api-content";
import { applyBundle } from "../src/lib/writer";
import { readManifest } from "../src/lib/manifest";
import { AUTH_FILE_VERSION, type AuthData, saveAuth, readToolConfig, saveToolConfig } from "../src/lib/config";
// IMPORTANT: import the shared mock BEFORE any dynamic import of the command.
import {
apiContentMockState,
resetApiContentMock,
} from "./helpers/api-content-mock";
import { redirectConfigDir, restoreConfigDir } from "./helpers/config-isolation";
interface CaptureResult {
stdout: string;
stderr: string;
exitCode?: number;
}
function captureStreams(fn: () => Promise<unknown>): Promise<CaptureResult> {
return new Promise((resolve) => {
const realExit = process.exit;
const realStdoutWrite = process.stdout.write.bind(process.stdout);
const realStderrWrite = process.stderr.write.bind(process.stderr);
let stdout = "";
let stderr = "";
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
return true;
}) as typeof process.stdout.write;
process.stderr.write = ((chunk: string | Uint8Array) => {
stderr += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
return true;
}) as typeof process.stderr.write;
process.exit = ((code?: number) => {
throw Object.assign(new Error("__exit__"), { __exitCode: code });
}) as typeof process.exit;
fn()
.then(() => resolve({ stdout, stderr }))
.catch((err: unknown) => {
if (err && typeof err === "object" && "__exitCode" in err) {
resolve({
stdout,
stderr,
exitCode: (err as { __exitCode: number }).__exitCode,
});
} else {
resolve({
stdout,
stderr: `${stderr}\n[uncaught: ${err instanceof Error ? err.message : String(err)}]`,
});
}
})
.finally(() => {
process.stdout.write = realStdoutWrite;
process.stderr.write = realStderrWrite;
process.exit = realExit;
});
});
}
async function runGet(argv: string[]): Promise<CaptureResult> {
return captureStreams(async () => {
const { registerGetCommand } = await import("../src/commands/get");
const cli = cac("10x");
cli.option("--json", "Output as JSON (auto-detected when piped)");
cli.option("--verbose", "Show detailed output on stderr");
registerGetCommand(cli);
// Mirror the command argv onto process.argv so argv-peeking resolution
// (e.g. resolveCourseRulesFlag) sees the real flags, matching production
// where process.argv IS the invocation. Restored after the run.
const realArgv = process.argv;
process.argv = ["bun", "10x", ...argv];
try {
cli.parse(["bun", "10x", ...argv], { run: false });
await cli.runMatchedCommand();
} finally {
process.argv = realArgv;
}
});
}
// ---------------------------------------------------------------------------
// Per-test setup
// ---------------------------------------------------------------------------
let tmp: string;
let projectRoot: string;
let priorIsTTY: boolean | undefined;
let priorCwd: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "10x-cli-get-"));
projectRoot = join(tmp, "project");
mkdirSync(projectRoot, { recursive: true });
redirectConfigDir(tmp);
priorIsTTY = process.stdout.isTTY;
process.stdout.isTTY = false; // force JSON mode
// `applyBundle` writes into `process.cwd()` now that Phase 5 has a real
// writer; chdir to a per-test project root so tests can't clobber the
// repo they're running from.
priorCwd = process.cwd();
process.chdir(projectRoot);
resetApiContentMock();
});
afterEach(() => {
process.chdir(priorCwd);
restoreConfigDir();
if (priorIsTTY === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY;
else process.stdout.isTTY = priorIsTTY;
resetApiContentMock();
rmSync(tmp, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// Fixture builders
// ---------------------------------------------------------------------------
function writeValidAuth(): void {
const data: AuthData = {
version: AUTH_FILE_VERSION,
email: "student@example.com",
access_token: "jwt-valid",
refresh_token: "rt-valid",
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1_000).toISOString(),
created_at: new Date().toISOString(),
};
saveAuth(data);
}
function makeBundle(overrides: Partial<LessonBundle> = {}): LessonBundle {
return {
lessonId: "m1l1",
module: 1,
lesson: 1,
title: "Intro to Claude Code",
summary: "First steps with AI pair programming",
skills: [
{ name: "code-review", files: [{ path: "SKILL.md", content: "skill md" }] },
],
prompts: [{ name: "plan", content: "prompt md" }],
rules: [{ name: "tdd", content: "rules md" }],
configs: [{ name: "settings.json", content: "{}" }],
...overrides,
};
}
function lessonOk(bundle: LessonBundle): ApiResult<LessonBundle> {
return { ok: true, status: 200, data: bundle, responseHeaders: new Headers(), rawBody: "" };
}
function lessonErr(
status: number,
code: string,
error: string,
payload?: Record<string, unknown>,
): ApiResult<LessonBundle> {
return { ok: false, status, code, error, payload };
}
interface OkEnvelope<T = unknown> {
status: "ok";
data: T;
}
interface ErrorEnvelope {
status: "error";
error: { code: string; message: string; hint?: string };
}
function parseOk<T = unknown>(stdout: string): T {
expect(stdout.endsWith("\n")).toBe(true);
const body = stdout.slice(0, -1);
const parsed = JSON.parse(body) as OkEnvelope<T>;
expect(parsed.status).toBe("ok");
return parsed.data;
}
function parseErr(stdout: string, expectedCode: string): ErrorEnvelope["error"] {
expect(stdout.endsWith("\n")).toBe(true);
const body = stdout.slice(0, -1);
const parsed = JSON.parse(body) as ErrorEnvelope;
expect(parsed.status).toBe("error");
expect(parsed.error.code).toBe(expectedCode);
return parsed.error;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("10x get — lesson ref validation", () => {
it("rejects uppercase M1L1 with exit code 2 (USAGE)", async () => {
writeValidAuth();
const { stdout, exitCode } = await runGet(["get", "M1L1", "--json"]);
expect(exitCode).toBe(2);
parseErr(stdout, "invalid_lesson_ref");
});
it("rejects out-of-range m6l1 with exit code 2 (USAGE)", async () => {
writeValidAuth();
const { stdout, exitCode } = await runGet(["get", "m6l1", "--json"]);
expect(exitCode).toBe(2);
parseErr(stdout, "invalid_lesson_ref");
});
});
describe("10x get — auth propagation", () => {
it("exits 3 AUTH_REQUIRED when no auth file exists", async () => {
const { stdout, exitCode } = await runGet(["get", "m1l1", "--json"]);
expect(exitCode).toBe(3);
parseErr(stdout, "auth_required");
});
});
describe("10x get — happy path", () => {
it("fetches lesson and prints planned artifacts (--json)", async () => {
writeValidAuth();
const bundle = makeBundle();
apiContentMockState.fetchLessonImpl = () => lessonOk(bundle);
const { stdout, exitCode } = await runGet(["get", "m1l1", "--json"]);
expect(exitCode ?? 0).toBe(0);
const data = parseOk<{
lessonId: string;
title: string;
writes: {
skills: {
name: string;
files: { path: string; absolutePath: string; action: string }[];
}[];
prompts: { name: string; path: string; action: string }[];
rules: { action: string };
configs: { name: string; path: string; action: string }[];
};
counts: { skills: number; prompts: number; rules: number; configs: number; removals: number };
dry_run: boolean;
}>(stdout);
expect(data.lessonId).toBe("m1l1");
expect(data.title).toBe("Intro to Claude Code");
expect(data.counts).toEqual({ skills: 1, prompts: 1, rules: 1, configs: 1, removals: 0 });
expect(data.writes.skills[0]!.name).toBe("code-review");
expect(data.writes.skills[0]!.files[0]!.absolutePath).toContain(
join(".claude", "skills", "code-review", "SKILL.md"),
);
expect(data.writes.prompts[0]!.path).toContain(join(".claude", "prompts", "plan.md"));
expect(data.writes.configs[0]!.path).toContain(join(".claude", "config-templates", "settings.json"));
expect(data.dry_run).toBe(false);
});
it("--dry-run returns planned writes with dry_run: true and no side effects", async () => {
writeValidAuth();
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
const { stdout, exitCode } = await runGet(["get", "m1l1", "--dry-run", "--json"]);
expect(exitCode ?? 0).toBe(0);
const data = parseOk<{ dry_run: boolean; writes: unknown }>(stdout);
expect(data.dry_run).toBe(true);
expect(data.writes).toBeDefined();
});
});
describe("10x get — error handling", () => {
it("module_locked (403) → exit 4 FORBIDDEN with human-friendly date", async () => {
writeValidAuth();
apiContentMockState.fetchLessonImpl = () =>
lessonErr(403, "module_locked", "module_locked", {
module: 2,
releaseAt: "2026-05-11T07:00:00Z",
});
const { stdout, exitCode } = await runGet(["get", "m2l1", "--json"]);
expect(exitCode).toBe(4);
const err = parseErr(stdout, "module_locked");
// Human sentence shape — NOT raw ISO, NOT the duplicated machine code.
expect(err.message).toContain("Module 2");
expect(err.message).toContain("May 11, 2026");
expect(err.message).not.toContain("2026-05-11T07:00:00Z");
// The raw code "module_locked" appears once (as the envelope code), not
// echoed inside the message itself.
expect(err.message.toLowerCase()).not.toContain("module_locked");
// The hint routes the user to a recovery action, not a restated date.
expect(err.hint).toContain("10x list");
});
it("module_locked without releaseAt → message states module is locked", async () => {
writeValidAuth();
apiContentMockState.fetchLessonImpl = () =>
lessonErr(403, "module_locked", "module_locked", { module: 3 });
const { stdout, exitCode } = await runGet(["get", "m3l1", "--json"]);
expect(exitCode).toBe(4);
const err = parseErr(stdout, "module_locked");
expect(err.message).toContain("Module 3");
expect(err.message).toContain("locked");
});
it("lesson_not_found (404) → exit 5 NOT_FOUND with suggest list hint", async () => {
writeValidAuth();
apiContentMockState.fetchLessonImpl = () =>
lessonErr(404, "not_found", "Lesson not found");
const { stdout, exitCode } = await runGet(["get", "m1l1", "--json"]);
expect(exitCode).toBe(5);
const err = parseErr(stdout, "lesson_not_found");
expect(err.hint).toContain("10x list");
});
it("network_error (status 0) → exit 1 ERROR", async () => {
writeValidAuth();
apiContentMockState.fetchLessonImpl = () =>
lessonErr(0, "network_error", "ECONNREFUSED");
const { stdout, exitCode } = await runGet(["get", "m1l1", "--json"]);
expect(exitCode).toBe(1);
parseErr(stdout, "network_error");
});
});
describe("10x get — --lang flag", () => {
it("rejects invalid --lang value with exit code 2 (USAGE)", async () => {
writeValidAuth();
const { stdout, exitCode } = await runGet(["get", "m1l1", "--lang", "de", "--json"]);
expect(exitCode).toBe(2);
parseErr(stdout, "invalid_lang");
});
it("passes ?lang=pl to fetchLesson when --lang pl is set", async () => {
writeValidAuth();
let capturedLang: string | undefined;
apiContentMockState.fetchLessonImpl = (_course, _lessonId, _token, options) => {
capturedLang = options?.lang;
return lessonOk(makeBundle());
};
const { exitCode } = await runGet(["get", "m1l1", "--lang", "pl", "--json"]);
expect(exitCode ?? 0).toBe(0);
expect(capturedLang).toBe("pl");
});
it("defaults to lang=en when no --lang flag", async () => {
writeValidAuth();
let capturedLang: string | undefined;
apiContentMockState.fetchLessonImpl = (_course, _lessonId, _token, options) => {
capturedLang = options?.lang;
return lessonOk(makeBundle());
};
const { exitCode } = await runGet(["get", "m1l1", "--json"]);
expect(exitCode ?? 0).toBe(0);
expect(capturedLang).toBe("en");
});
it("includes language metadata in JSON output", async () => {
writeValidAuth();
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
const { stdout, exitCode } = await runGet(["get", "m1l1", "--lang", "pl", "--json"]);
expect(exitCode ?? 0).toBe(0);
const data = parseOk<{ language: string; languageFallback: boolean }>(stdout);
expect(data.language).toBe("pl");
expect(data.languageFallback).toBe(false);
});
it("passes ?tool=cursor to fetchLesson when --tool cursor is set (install flow)", 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", "cursor", "--json"]);
expect(exitCode ?? 0).toBe(0);
expect(capturedTool).toBe("cursor");
});
it("passes ?tool=cursor to fetchLesson in print/filter flow (--print --type rules)", 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",
"cursor",
"--print",
"--type",
"rules",
"--json",
]);
expect(exitCode ?? 0).toBe(0);
expect(capturedTool).toBe("cursor");
});
it("defaults to tool=claude-code when no --tool flag", async () => {
writeValidAuth();
let capturedTool: string | undefined;
apiContentMockState.fetchLessonImpl = (_course, _lessonId, _token, options) => {
capturedTool = options?.tool;
return lessonOk(makeBundle());
};
const { exitCode } = await runGet(["get", "m1l1", "--json"]);
expect(exitCode ?? 0).toBe(0);
expect(capturedTool).toBe("claude-code");
});
it("shows fallback info in verbose output when X-Content-Fallback is true", async () => {
writeValidAuth();
apiContentMockState.fetchLessonImpl = () => {
const headers = new Headers();
headers.set("X-Content-Language", "en");
headers.set("X-Content-Fallback", "true");
return { ok: true, status: 200, data: makeBundle(), responseHeaders: headers } as ApiResult<LessonBundle>;
};
const { stderr, exitCode } = await runGet(["get", "m1l1", "--lang", "pl", "--json", "--verbose"]);
expect(exitCode ?? 0).toBe(0);
expect(stderr).toContain("PL not available");
expect(stderr).toContain("showing EN");
});
});
describe("10x get — --tool persistence", () => {
it("--tool cursor persists tool to config.json", async () => {
writeValidAuth();
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
const { exitCode } = await runGet(["get", "m1l1", "--tool", "cursor", "--json"]);
expect(exitCode ?? 0).toBe(0);
const config = readToolConfig();
expect(config?.tool).toBe("cursor");
});
it("subsequent call without --tool resolves to the persisted tool", async () => {
writeValidAuth();
saveToolConfig({ tool: "cursor" });
let capturedTool: string | undefined;
apiContentMockState.fetchLessonImpl = (_course, _lessonId, _token, options) => {
capturedTool = options?.tool;
return lessonOk(makeBundle());
};
const { exitCode } = await runGet(["get", "m1l1", "--json"]);
expect(exitCode ?? 0).toBe(0);
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());
process.stdout.isTTY = true;
const { stderr, exitCode } = await runGet(["get", "m1l1", "--tool", "cursor", "--json"]);
expect(exitCode ?? 0).toBe(0);
expect(stderr).toContain("Default tool set to Cursor.");
});
it("no feedback when --tool matches already-persisted tool", async () => {
writeValidAuth();
saveToolConfig({ tool: "cursor" });
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
process.stdout.isTTY = true;
const { stderr, exitCode } = await runGet(["get", "m1l1", "--tool", "cursor", "--json"]);
expect(exitCode ?? 0).toBe(0);
expect(stderr).not.toContain("Default tool set to");
});
it("preserves existing lang and acknowledgedOrphans when persisting tool", async () => {
writeValidAuth();
saveToolConfig({ tool: "claude-code", lang: "pl", acknowledgedOrphans: ["copilot"] });
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
const { exitCode } = await runGet(["get", "m1l1", "--tool", "cursor", "--json"]);
expect(exitCode ?? 0).toBe(0);
const config = readToolConfig();
expect(config?.tool).toBe("cursor");
expect(config?.lang).toBe("pl");
expect(config?.acknowledgedOrphans).toEqual(["copilot"]);
});
});
describe("10x get — course rules opt-out", () => {
const BEGIN = "<!-- BEGIN @przeprogramowani/10x-cli -->";
const END = "<!-- END @przeprogramowani/10x-cli -->";
it("resolveCourseRulesFlag returns false/true/undefined for the three argv shapes", async () => {
const { resolveCourseRulesFlag } = await import("../src/commands/get");
expect(resolveCourseRulesFlag(["bun", "10x", "get", "m1l1", "--no-course-rules"])).toBe(false);
expect(resolveCourseRulesFlag(["bun", "10x", "get", "m1l1", "--course-rules"])).toBe(true);
expect(resolveCourseRulesFlag(["bun", "10x", "get", "m1l1"])).toBeUndefined();
});
it("plain get with courseRules:false persisted writes no block and counts.rules:0", async () => {
writeValidAuth();
saveToolConfig({ tool: "claude-code", courseRules: false });
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
const { stdout, exitCode } = await runGet(["get", "m1l1", "--json"]);
expect(exitCode ?? 0).toBe(0);
const data = parseOk<{ counts: { rules: number }; writes: { rules: { action: string } } }>(stdout);
expect(data.counts.rules).toBe(0);
// No course block landed in CLAUDE.md.
const claudeMd = join(projectRoot, "CLAUDE.md");
expect(existsSync(claudeMd) && readFileSync(claudeMd, "utf8").includes(BEGIN)).toBe(false);
});
it("--type rules overrides a persisted opt-out and applies the block", async () => {
writeValidAuth();
saveToolConfig({ tool: "claude-code", courseRules: false });
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
const { stdout, exitCode } = await runGet(["get", "m1l1", "--type", "rules", "--json"]);
expect(exitCode ?? 0).toBe(0);
const data = parseOk<{ counts: { rules: number } }>(stdout);
expect(data.counts.rules).toBe(1);
const claudeMd = readFileSync(join(projectRoot, "CLAUDE.md"), "utf8");
expect(claudeMd).toContain(BEGIN);
});
it("--no-course-rules persists courseRules:false (not dry-run)", async () => {
writeValidAuth();
saveToolConfig({ tool: "claude-code", acknowledgedOrphans: ["copilot"] });
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
const { exitCode } = await runGet(["get", "m1l1", "--no-course-rules", "--json"]);
expect(exitCode ?? 0).toBe(0);
const config = readToolConfig();
expect(config?.courseRules).toBe(false);
// Merge-safe: untouched fields preserved.
expect(config?.tool).toBe("claude-code");
expect(config?.acknowledgedOrphans).toEqual(["copilot"]);
});
it("--no-course-rules under --dry-run does not write config", async () => {
writeValidAuth();
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
const { exitCode } = await runGet([
"get",
"m1l1",
"--no-course-rules",
"--dry-run",
"--json",
]);
expect(exitCode ?? 0).toBe(0);
// No config written under dry-run.
expect(readToolConfig()).toBeNull();
});
it("--no-course-rules strips an existing block and preserves surrounding content", async () => {
writeValidAuth();
saveToolConfig({ tool: "claude-code" });
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
// Establish the baseline through a successful delivery, then add user
// content outside its sentinel without changing the managed block.
await applyBundle(makeBundle(), projectRoot);
const claudeMd = join(projectRoot, "CLAUDE.md");
const installed = readFileSync(claudeMd, "utf8");
writeFileSync(claudeMd, `# My own rules\n\nKeep me.\n\n${installed}\r\nTrailer `);
const { stdout, exitCode } = await runGet(["get", "m1l1", "--no-course-rules", "--json"]);
expect(exitCode ?? 0).toBe(0);
const data = parseOk<{ counts: { rules: number }; writes: { rules: { action: string } } }>(stdout);
expect(data.writes.rules.action).toBe("removed");
expect(data.counts.rules).toBe(0);
const after = readFileSync(claudeMd, "utf8");
expect(after).toBe("# My own rules\n\nKeep me.\n\n\n\r\nTrailer ");
expect(readManifest(join(projectRoot, ".claude"))!.managedRules).toBeUndefined();
});
it("human output renders a [removed] rules line when a block is stripped", async () => {
writeValidAuth();
saveToolConfig({ tool: "claude-code" });
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
// A genuine delivered baseline permits removal without prompting.
await applyBundle(makeBundle(), projectRoot);
expect(readManifest(join(projectRoot, ".claude"))!.managedRules?.upstreamHash).toBeDefined();
process.stdout.isTTY = true;
const { stdout, stderr, exitCode } = await runGet(["get", "m1l1", "--no-course-rules"]);
expect(exitCode ?? 0).toBe(0);
expect(`${stdout}${stderr}`).toContain("[removed] rules");
});
it("--no-course-rules preserves an unknown block byte-for-byte and does not adopt its baseline", async () => {
writeValidAuth();
saveToolConfig({ tool: "claude-code" });
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
const path = join(projectRoot, "CLAUDE.md");
const existing = ` before\r\n${BEGIN}\nlocal course rules\n${END}\r\nvaluable trailer `;
writeFileSync(path, existing);
const { stdout, exitCode } = await runGet(["get", "m1l1", "--no-course-rules", "--json"]);
expect(exitCode ?? 0).toBe(0);
const data = parseOk<{ writes: { rules: { action: string; reason: string } } }>(stdout);
expect(data.writes.rules).toMatchObject({ action: "conflict_skipped", reason: "missing_baseline" });
expect(readFileSync(path, "utf8")).toBe(existing);
expect(readManifest(join(projectRoot, ".claude"))!.managedRules).toBeUndefined();
});
it("--no-course-rules preserves edited managed rules and their original upstream hash", async () => {
writeValidAuth();
saveToolConfig({ tool: "claude-code" });
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
await applyBundle(makeBundle(), projectRoot);
const baseline = readManifest(join(projectRoot, ".claude"))!.managedRules;
const path = join(projectRoot, "CLAUDE.md");
const edited = readFileSync(path, "utf8").replace("rules md", "my edited rules");
writeFileSync(path, edited);
const { stdout, exitCode } = await runGet(["get", "m1l1", "--no-course-rules", "--json"]);
expect(exitCode ?? 0).toBe(0);
const data = parseOk<{ writes: { rules: { action: string; reason: string } } }>(stdout);
expect(data.writes.rules).toMatchObject({ action: "conflict_skipped", reason: "locally_modified" });
expect(readFileSync(path, "utf8")).toBe(edited);
expect(readManifest(join(projectRoot, ".claude"))!.managedRules).toEqual(baseline);
});
it("--course-rules (positive form) parses through CAC without a USAGE exit", async () => {
writeValidAuth();
saveToolConfig({ tool: "claude-code", courseRules: false });
apiContentMockState.fetchLessonImpl = () => lessonOk(makeBundle());
// The positive form is the implicit counterpart of the registered
// `--no-course-rules`. Guard against a regression where CAC stops
// accepting it and exits 2 (USAGE).
const { stdout, exitCode } = await runGet(["get", "m1l1", "--course-rules", "--json"]);
expect(exitCode ?? 0).toBe(0);
const data = parseOk<{ counts: { rules: number } }>(stdout);
expect(data.counts.rules).toBe(1);
// Re-enabling also persists the positive choice.
expect(readToolConfig()?.courseRules).toBe(true);
});
});