@przeprogramowani/10x-cli 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -3183,7 +3183,7 @@ var cac = (name = "") => new CAC(name);
3183
3183
  // package.json
3184
3184
  var package_default = {
3185
3185
  name: "@przeprogramowani/10x-cli",
3186
- version: "1.20.0",
3186
+ version: "1.21.0",
3187
3187
  description: "Open-source CLI for 10xDevs course content",
3188
3188
  repository: {
3189
3189
  type: "git",
@@ -3350,6 +3350,9 @@ function updateToolConfig(patch) {
3350
3350
  saveToolConfig(merged);
3351
3351
  }
3352
3352
 
3353
+ // src/lib/auth-flow.ts
3354
+ import { hostname as osHostname, release as osRelease } from "node:os";
3355
+
3353
3356
  // src/lib/api-client.ts
3354
3357
  var DEFAULT_API_BASE = "https://10x-toolkit-api.przeprogramowani.workers.dev";
3355
3358
  var PROD_HOSTNAME = "10x-toolkit-api.przeprogramowani.workers.dev";
@@ -3382,6 +3385,10 @@ var DEFAULT_TIMEOUT_MS = 30000;
3382
3385
  var UNKNOWN_API_ERROR_MESSAGE = "An unknown error occurred. Please contact 10xDevs mentors and share the command that caused this. Sorry for the issue!";
3383
3386
  var ERROR_CODE_MESSAGES = {
3384
3387
  course_not_found: "Course not found.",
3388
+ course_unavailable: "This course has no available published content yet.",
3389
+ course_access_denied: "Your account does not have access to this course.",
3390
+ invalid_release: "The selected content release is invalid.",
3391
+ release_mismatch: "Content does not match the selected release. Run the command again.",
3385
3392
  lesson_not_found: "Lesson not found.",
3386
3393
  module_not_found: "Module not found.",
3387
3394
  artifact_not_found: "Artifact not found.",
@@ -3395,6 +3402,12 @@ var ERROR_CODE_MESSAGES = {
3395
3402
  membership_revoked: "Your course membership has been revoked.",
3396
3403
  rate_limited: "Too many requests. Please slow down and try again shortly.",
3397
3404
  email_send_failed: "Could not send the login email. Try again in a moment.",
3405
+ circle_login_disabled: "Circle login is currently unavailable. Run `10x auth --method email` instead.",
3406
+ dm_rejected: "Circle refused to deliver the login message. Check your Circle direct-message settings or run `10x auth --method email`.",
3407
+ access_denied: "This account is not allowed to sign in. Check your course access.",
3408
+ expired_or_used: "This Circle login link has expired or was already used. Run `10x auth --method circle` again.",
3409
+ slow_down: "Polling too fast. The CLI will wait longer between checks.",
3410
+ auth_cancelled: "Authentication was cancelled before it completed.",
3398
3411
  admin_access_required: "This action requires admin access.",
3399
3412
  circle_api_error: "The membership service is temporarily unavailable.",
3400
3413
  internal_error: "The server hit an unexpected error."
@@ -3467,7 +3480,7 @@ async function request(method, path, body, options = {}) {
3467
3480
  return {
3468
3481
  ok: false,
3469
3482
  status: response.status,
3470
- code: typeof payload?.code === "string" ? payload.code : `http_${response.status}`,
3483
+ code: typeof payload?.code === "string" ? payload.code : typeof payload?.error === "string" ? payload.error : `http_${response.status}`,
3471
3484
  error: errorMessage,
3472
3485
  payload
3473
3486
  };
@@ -3541,6 +3554,97 @@ async function pollVerifySession(sessionId, options = {}) {
3541
3554
  await sleep(intervalMs);
3542
3555
  }
3543
3556
  }
3557
+ var CLIENT_HOSTNAME_MAX = 128;
3558
+ var CLIENT_OS_MAX = 64;
3559
+ function describeCircleClient() {
3560
+ const client = {};
3561
+ try {
3562
+ const host = osHostname();
3563
+ if (host.length > 0)
3564
+ client.hostname = host.slice(0, CLIENT_HOSTNAME_MAX);
3565
+ } catch {}
3566
+ try {
3567
+ client.os = `${process.platform} ${osRelease()}`.slice(0, CLIENT_OS_MAX);
3568
+ } catch {}
3569
+ return client;
3570
+ }
3571
+ function circleStartRequest(email, client = {}, options = {}) {
3572
+ return apiPost("/auth/circle/start", { email, client }, { signal: options.signal });
3573
+ }
3574
+ async function checkCircleLogin(deviceCode, options = {}) {
3575
+ const result = await apiPost("/auth/circle/poll", { device_code: deviceCode }, { signal: options.signal });
3576
+ if (result.ok) {
3577
+ if (result.status === 200 && result.data && "token" in result.data) {
3578
+ return { kind: "verified", tokens: result.data };
3579
+ }
3580
+ return { kind: "pending" };
3581
+ }
3582
+ if (result.status === 0)
3583
+ return { kind: "pending" };
3584
+ if (result.status === 400 && result.code === "slow_down")
3585
+ return { kind: "slow_down" };
3586
+ if (result.status >= 500 || result.status === 429)
3587
+ return { kind: "pending" };
3588
+ const message = result.payload?.message ?? result.error;
3589
+ if (result.status === 410)
3590
+ return { kind: "expired", message };
3591
+ if (result.status === 403)
3592
+ return { kind: "denied", message };
3593
+ return {
3594
+ kind: "error",
3595
+ code: result.code,
3596
+ message: result.error,
3597
+ status: result.status
3598
+ };
3599
+ }
3600
+ var SLOW_DOWN_INCREMENT_MS = 5000;
3601
+ function abortableSleep(ms, signal) {
3602
+ return new Promise((resolve) => {
3603
+ if (signal?.aborted) {
3604
+ resolve();
3605
+ return;
3606
+ }
3607
+ const timer = setTimeout(() => {
3608
+ signal?.removeEventListener("abort", onAbort);
3609
+ resolve();
3610
+ }, ms);
3611
+ function onAbort() {
3612
+ clearTimeout(timer);
3613
+ resolve();
3614
+ }
3615
+ signal?.addEventListener("abort", onAbort, { once: true });
3616
+ });
3617
+ }
3618
+ async function pollCircleLogin(deviceCode, options = {}) {
3619
+ let intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
3620
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
3621
+ const now = options.now ?? Date.now;
3622
+ const sleep = options.sleep ?? ((ms) => abortableSleep(ms, options.signal));
3623
+ const startedAt = now();
3624
+ const deadline = startedAt + timeoutMs;
3625
+ while (true) {
3626
+ if (options.signal?.aborted)
3627
+ return { kind: "aborted" };
3628
+ const remaining = deadline - now();
3629
+ options.onTick?.(Math.max(0, remaining));
3630
+ const outcome = await checkCircleLogin(deviceCode, { signal: options.signal });
3631
+ if (options.signal?.aborted)
3632
+ return { kind: "aborted" };
3633
+ if (outcome.kind === "verified")
3634
+ return outcome;
3635
+ if (outcome.kind === "expired")
3636
+ return outcome;
3637
+ if (outcome.kind === "denied")
3638
+ return outcome;
3639
+ if (outcome.kind === "error")
3640
+ return outcome;
3641
+ if (outcome.kind === "slow_down")
3642
+ intervalMs += SLOW_DOWN_INCREMENT_MS;
3643
+ if (now() + intervalMs >= deadline)
3644
+ return { kind: "timeout" };
3645
+ await sleep(intervalMs);
3646
+ }
3647
+ }
3544
3648
 
3545
3649
  // src/lib/auth-guard.ts
3546
3650
  var import_proper_lockfile = __toESM(require_proper_lockfile(), 1);
@@ -3645,7 +3749,8 @@ async function requireAuth(ctx, options = {}) {
3645
3749
  outputError(ctx, "auth_required", "Not authenticated.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in.");
3646
3750
  }
3647
3751
  const initialNow = now();
3648
- if (!isExpired(auth, initialNow) && !isNearExpiry(auth, windowMs, initialNow)) {
3752
+ const forced = options.forceRefreshForToken !== undefined;
3753
+ if (!forced && !isExpired(auth, initialNow) && !isNearExpiry(auth, windowMs, initialNow)) {
3649
3754
  return auth;
3650
3755
  }
3651
3756
  const release = await acquireRefreshLock(ctx, lockTarget);
@@ -3656,7 +3761,7 @@ async function requireAuth(ctx, options = {}) {
3656
3761
  }
3657
3762
  const freshNow = now();
3658
3763
  const freshExpired = isExpired(fresh, freshNow);
3659
- if (!freshExpired && !isNearExpiry(fresh, windowMs, freshNow)) {
3764
+ if (!freshExpired && !isNearExpiry(fresh, windowMs, freshNow) && (!forced || fresh.access_token !== options.forceRefreshForToken)) {
3660
3765
  verbose(ctx, "another process already refreshed — using rotated token");
3661
3766
  return fresh;
3662
3767
  }
@@ -3669,11 +3774,15 @@ async function requireAuth(ctx, options = {}) {
3669
3774
  access_token: refreshed.data.token,
3670
3775
  refresh_token: refreshed.data.refresh_token,
3671
3776
  expires_at: refreshed.data.expires_at,
3672
- created_at: fresh.created_at
3777
+ created_at: fresh.created_at,
3778
+ ...fresh.method !== undefined ? { method: fresh.method } : {}
3673
3779
  };
3674
3780
  persist(next);
3675
3781
  return next;
3676
3782
  }
3783
+ if (forced) {
3784
+ outputError(ctx, refreshed.code, refreshed.error, refreshed.status === 403 ? ExitCodes.FORBIDDEN : refreshed.status === 401 ? ExitCodes.AUTH_REQUIRED : ExitCodes.ERROR);
3785
+ }
3677
3786
  if (!freshExpired) {
3678
3787
  verbose(ctx, `refresh failed (${refreshed.code}) — using existing token`);
3679
3788
  return fresh;
@@ -3690,169 +3799,597 @@ async function requireAuth(ctx, options = {}) {
3690
3799
  throw new Error("requireAuth: unreachable");
3691
3800
  }
3692
3801
 
3693
- // src/commands/auth.ts
3694
- function registerAuthCommand(cli) {
3695
- cli.command("auth", "Authenticate with 10xDevs via magic link").option("--email <email>", "Email address (skips interactive prompt)").option("--status", "Show current authentication state").option("--logout", "Delete locally stored credentials").action(async (options) => {
3696
- const ctx = resolveContext(options);
3697
- if (options.status) {
3698
- runStatus(ctx);
3699
- return;
3700
- }
3701
- if (options.logout) {
3702
- runLogout(ctx);
3703
- return;
3704
- }
3705
- await runLogin(ctx, options);
3706
- });
3802
+ // src/lib/signing.ts
3803
+ import { createHash, createPublicKey, verify } from "node:crypto";
3804
+
3805
+ class SignatureError extends Error {
3806
+ constructor(message) {
3807
+ super(message);
3808
+ this.name = "SignatureError";
3809
+ }
3707
3810
  }
3708
- function runStatus(ctx) {
3709
- const auth = readAuth();
3710
- if (!auth) {
3711
- outputError(ctx, "auth_required", "You're not signed in.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in.");
3811
+ var KEYSET = [
3812
+ {
3813
+ keyId: 1,
3814
+ publicKey: "MCowBQYDK2VwAyEANXEwq1y+9CYAJS6ze9m/c212fL4r1BauXwKdT6Zo5Ko="
3712
3815
  }
3713
- const expired = isExpired(auth);
3714
- const expiresAt = new Date(auth.expires_at);
3715
- if (expired) {
3716
- outputError(ctx, "auth_expired", `Your session for ${auth.email} has expired.`, ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
3816
+ ];
3817
+ var REQUIRE_SIGNATURES = true;
3818
+ function loadEffectiveKeyset() {
3819
+ const override = process.env["BUNDLE_PUBLIC_KEYSET"];
3820
+ if (!override)
3821
+ return KEYSET;
3822
+ const apiBase = process.env["API_BASE_URL"];
3823
+ if (!apiBase)
3824
+ return KEYSET;
3825
+ let hostname;
3826
+ try {
3827
+ hostname = new URL(apiBase).hostname;
3828
+ } catch {
3829
+ return KEYSET;
3717
3830
  }
3718
- output(ctx, `Signed in as ${auth.email} — session expires ${formatExpiry(expiresAt)}.`, {
3719
- email: auth.email,
3720
- expires_at: auth.expires_at,
3721
- is_valid: true
3722
- });
3723
- }
3724
- function formatExpiry(expiresAt) {
3725
- if (!Number.isFinite(expiresAt.getTime()))
3726
- return "at an unknown time";
3727
- const diffMs = expiresAt.getTime() - Date.now();
3728
- if (diffMs <= 0)
3729
- return "now";
3730
- const days = Math.floor(diffMs / (24 * 60 * 60 * 1000));
3731
- if (days >= 1)
3732
- return `in ${days} day${days === 1 ? "" : "s"}`;
3733
- const hours = Math.floor(diffMs / (60 * 60 * 1000));
3734
- if (hours >= 1)
3735
- return `in ${hours} hour${hours === 1 ? "" : "s"}`;
3736
- const mins = Math.max(1, Math.floor(diffMs / (60 * 1000)));
3737
- return `in ${mins} minute${mins === 1 ? "" : "s"}`;
3738
- }
3739
- function runLogout(ctx) {
3740
- const had = readAuth() !== null;
3741
- deleteAuth();
3742
- output(ctx, had ? "Logged out successfully." : "Already logged out.", {
3743
- logged_out: true,
3744
- had_credentials: had
3745
- });
3746
- }
3747
- async function runLogin(ctx, options) {
3748
- const email = await collectEmail(ctx, options);
3749
- if (!ctx.json)
3750
- we("10x auth");
3751
- const sp = ctx.json ? null : L2();
3752
- sp?.start("Requesting magic link…");
3753
- const login = await loginRequest(email);
3754
- if (!login.ok) {
3755
- sp?.stop("Magic link request failed.", 1);
3756
- handleLoginError(ctx, login.status, login.code, login.error);
3831
+ if (hostname !== "localhost" && hostname !== "127.0.0.1") {
3832
+ return KEYSET;
3757
3833
  }
3758
- const { session_id } = login.data;
3759
- sp?.stop(`Magic link sent to ${email}. Check your inbox.`);
3760
- const pollSpinner = ctx.json ? null : L2();
3761
- pollSpinner?.start("Waiting for magic link…");
3762
- const result = await pollVerifySession(session_id, {
3763
- onTick: (remainingMs) => {
3764
- if (pollSpinner) {
3765
- pollSpinner.message(`Waiting for magic link (${formatRemaining(remainingMs)} remaining)`);
3834
+ try {
3835
+ const parsed = JSON.parse(override);
3836
+ if (!Array.isArray(parsed) || parsed.length === 0)
3837
+ return KEYSET;
3838
+ for (const k3 of parsed) {
3839
+ if (typeof k3.keyId !== "number" || typeof k3.publicKey !== "string") {
3840
+ return KEYSET;
3766
3841
  }
3767
3842
  }
3768
- });
3769
- if (result.kind === "verified") {
3770
- pollSpinner?.stop("Authenticated.");
3771
- const auth = {
3772
- version: AUTH_FILE_VERSION,
3773
- email,
3774
- access_token: result.tokens.token,
3775
- refresh_token: result.tokens.refresh_token,
3776
- expires_at: result.tokens.expires_at,
3777
- created_at: new Date().toISOString()
3778
- };
3779
- saveAuth(auth);
3780
- if (!ctx.json)
3781
- fe(`Signed in as ${email}.`);
3782
- output(ctx, "", {
3783
- authenticated: true,
3784
- email,
3785
- expires_at: auth.expires_at
3786
- });
3787
- return;
3788
- }
3789
- if (result.kind === "expired") {
3790
- pollSpinner?.stop("Session expired.", 1);
3791
- outputError(ctx, "session_expired", result.message || "Magic-link session expired before it was clicked.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' again to request a fresh link.");
3792
- }
3793
- if (result.kind === "timeout") {
3794
- pollSpinner?.stop("Timed out waiting for magic link.", 1);
3795
- outputError(ctx, "auth_timeout", "Timed out waiting for the magic link to be clicked.", ExitCodes.ERROR, "Run '10x auth' to request a new link.");
3796
- }
3797
- if (result.kind === "aborted") {
3798
- pollSpinner?.stop("Cancelled.", 1);
3799
- outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
3843
+ return parsed;
3844
+ } catch {
3845
+ return KEYSET;
3800
3846
  }
3801
- pollSpinner?.stop("Authentication failed.", 1);
3802
- outputError(ctx, result.code || "auth_error", result.message || "Authentication failed.", ExitCodes.ERROR);
3803
3847
  }
3804
- async function collectEmail(ctx, options) {
3805
- if (options.email) {
3806
- const trimmed = options.email.trim();
3807
- if (!isLikelyEmail(trimmed)) {
3808
- outputError(ctx, "invalid_email", `'${options.email}' does not look like an email address.`, ExitCodes.USAGE, "Pass your 10xDevs email, for example '10x auth --email you@example.com'.");
3809
- }
3810
- return trimmed;
3811
- }
3812
- if (ctx.json) {
3813
- outputError(ctx, "missing_email", "Email is required in non-interactive mode.", ExitCodes.USAGE, "Pass --email, for example '10x auth --email you@example.com'.");
3814
- }
3815
- const answer = await ue({
3816
- message: "Enter your 10xDevs email",
3817
- placeholder: "you@example.com",
3818
- validate: (value) => {
3819
- if (!value || value.trim().length === 0)
3820
- return "Email is required.";
3821
- if (!isLikelyEmail(value.trim()))
3822
- return "That does not look like an email address.";
3823
- return;
3824
- }
3825
- });
3826
- if (BD(answer)) {
3827
- ve("Authentication cancelled.");
3828
- outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
3829
- }
3830
- return answer.trim();
3848
+ function sha256Hex(body) {
3849
+ return createHash("sha256").update(body).digest("hex");
3831
3850
  }
3832
- function isLikelyEmail(value) {
3833
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
3851
+ function canonicalSigningString(keyId, hashHex) {
3852
+ return `v1:${keyId}:${hashHex}`;
3834
3853
  }
3835
- function formatRemaining(ms) {
3836
- const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
3837
- const minutes = Math.floor(totalSeconds / 60);
3838
- const seconds = totalSeconds % 60;
3839
- return `${minutes}:${seconds.toString().padStart(2, "0")}`;
3854
+ function verifyBundleSignature(rawBody, signature, keyId, headerHash) {
3855
+ verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, loadEffectiveKeyset());
3840
3856
  }
3841
- function handleLoginError(ctx, status, code, error) {
3842
- verbose(ctx, `login failed: status=${status} code=${code}`);
3843
- if (status === 403) {
3844
- outputError(ctx, "no_access", "This email has no active 10xDevs course membership.", ExitCodes.FORBIDDEN, "Enroll at https://10xdevs.pl, then run '10x auth' again.");
3857
+ function verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, keyset) {
3858
+ const key = keyset.find((k3) => k3.keyId === keyId);
3859
+ if (!key) {
3860
+ throw new SignatureError(`Unknown signing key (key_id=${keyId}). Update your CLI: npm update -g @przeprogramowani/10x-cli`);
3845
3861
  }
3846
- if (status === 429) {
3847
- outputError(ctx, "rate_limited", "Too many magic-link requests for this email.", ExitCodes.ERROR, "Wait a few minutes, then run '10x auth' again.");
3862
+ const computedHash = sha256Hex(rawBody);
3863
+ if (computedHash !== headerHash) {
3864
+ throw new SignatureError(`Bundle content hash mismatch (header=${headerHash.slice(0, 12)}…, computed=${computedHash.slice(0, 12)}…). ` + "The bundle may have been tampered with in transit. Do NOT use the content.");
3848
3865
  }
3849
- if (status === 502) {
3850
- outputError(ctx, "email_delivery_failed", "Could not send the magic-link email.", ExitCodes.ERROR, "Try '10x auth' again in a few minutes. If it keeps failing, contact support.");
3866
+ let pubKey;
3867
+ try {
3868
+ pubKey = createPublicKey({
3869
+ key: Buffer.from(key.publicKey, "base64"),
3870
+ format: "der",
3871
+ type: "spki"
3872
+ });
3873
+ } catch {
3874
+ throw new SignatureError("Failed to load the signing public key. Your CLI binary may be corrupted — reinstall it.");
3851
3875
  }
3852
- if (status === 0) {
3853
- outputError(ctx, "network_error", "Could not reach the 10x-toolkit API.", ExitCodes.ERROR, "Check your internet connection and run '10x auth' again.");
3876
+ const canonical = canonicalSigningString(keyId, computedHash);
3877
+ const sig = Buffer.from(signature, "base64");
3878
+ const valid = verify(null, Buffer.from(canonical), pubKey, sig);
3879
+ if (!valid) {
3880
+ throw new SignatureError("Bundle signature verification failed — the bundle may have been tampered with. " + "Do NOT use the content. Report this to the course team.");
3854
3881
  }
3855
- outputError(ctx, code || "auth_error", "Authentication failed.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
3882
+ }
3883
+
3884
+ // src/lib/api-content.ts
3885
+ function validateCourseDiscovery(value) {
3886
+ if (!value || typeof value !== "object")
3887
+ return false;
3888
+ const data = value;
3889
+ if (!Array.isArray(data.courses) || data.defaultCourse !== null && typeof data.defaultCourse !== "string")
3890
+ return false;
3891
+ const identifiers = new Set;
3892
+ for (const course of data.courses) {
3893
+ if (!course || typeof course.id !== "string" || !course.id || typeof course.slug !== "string" || !course.slug || typeof course.title !== "string" || !Number.isInteger(course.edition) || course.edition < 1 || typeof course.available !== "boolean")
3894
+ return false;
3895
+ for (const identifier of new Set([course.id, course.slug])) {
3896
+ if (identifiers.has(identifier))
3897
+ return false;
3898
+ identifiers.add(identifier);
3899
+ }
3900
+ }
3901
+ const available = data.courses.filter((course) => course.available);
3902
+ if (data.defaultCourse === null)
3903
+ return available.length === 0;
3904
+ const selected = available.find((course) => course.id === data.defaultCourse || course.slug === data.defaultCourse);
3905
+ return !!selected && available.every((course) => course.edition <= selected.edition);
3906
+ }
3907
+ async function fetchCourses(token) {
3908
+ const result = await apiGet("/api/me/courses", { token });
3909
+ if (!result.ok) {
3910
+ if (result.status === 404)
3911
+ return { ...result, code: "discovery_unsupported", error: "The backend does not support course discovery. Update the backend before using this CLI." };
3912
+ return result;
3913
+ }
3914
+ if (!validateCourseDiscovery(result.data))
3915
+ return { ok: false, status: 0, code: "discovery_invalid", error: "Invalid course discovery response; no course was selected." };
3916
+ return result;
3917
+ }
3918
+ function catalogRelease(catalog) {
3919
+ if (catalog.course !== "10xdevs4")
3920
+ return;
3921
+ if (!/^r-[a-f0-9]{64}$/.test(catalog.releaseId ?? "") || !/^[a-f0-9]{64}$/.test(catalog.releaseManifestHash ?? ""))
3922
+ throw new Error("Invalid or missing v4 catalog release identity");
3923
+ return { course: catalog.course, releaseId: catalog.releaseId, releaseManifestHash: catalog.releaseManifestHash };
3924
+ }
3925
+ function releaseMismatch(course, data, selected) {
3926
+ if (course !== "10xdevs4" && course !== "10xdevs-4")
3927
+ return null;
3928
+ if (!selected || selected.course !== "10xdevs4" || !data || typeof data !== "object")
3929
+ return "V4 content requires the operation's selected catalog release.";
3930
+ const value = data;
3931
+ return value["course"] === selected.course && value["releaseId"] === selected.releaseId && value["releaseManifestHash"] === selected.releaseManifestHash ? null : "Signed content differs from the selected course/release/manifest.";
3932
+ }
3933
+ async function fetchCatalog(course, token, options = {}) {
3934
+ const result = await apiGet(`/api/catalog/${encodeURIComponent(course)}${options.release ? `?release=${encodeURIComponent(options.release.releaseId)}` : ""}`, { token, signal: options.signal });
3935
+ if (result.ok && (course === "10xdevs4" || course === "10xdevs-4")) {
3936
+ try {
3937
+ if (result.data.course !== "10xdevs4")
3938
+ throw new Error("Catalog course mismatch");
3939
+ catalogRelease(result.data);
3940
+ if (options.release) {
3941
+ const mismatch = releaseMismatch(course, result.data, options.release);
3942
+ if (mismatch)
3943
+ throw new Error(mismatch);
3944
+ }
3945
+ } catch (error) {
3946
+ return { ok: false, status: 0, code: "release_mismatch", error: error instanceof Error ? error.message : String(error) };
3947
+ }
3948
+ }
3949
+ return result;
3950
+ }
3951
+ async function fetchModuleDetail(course, module, token, options = {}) {
3952
+ const result = await apiGet(`/api/modules/${encodeURIComponent(course)}/${module}${options.release ? `?release=${encodeURIComponent(options.release.releaseId)}` : ""}`, { token, signal: options.signal });
3953
+ if (result.ok) {
3954
+ const mismatch = releaseMismatch(course, result.data, options.release);
3955
+ if (mismatch)
3956
+ return { ok: false, status: 0, code: "release_mismatch", error: mismatch };
3957
+ }
3958
+ return result;
3959
+ }
3960
+ async function fetchLesson(course, lessonId, token, options = {}) {
3961
+ const params = new URLSearchParams;
3962
+ if (options.lang)
3963
+ params.set("lang", options.lang);
3964
+ if (options.release)
3965
+ params.set("release", options.release.releaseId);
3966
+ if (options.tool)
3967
+ params.set("tool", options.tool);
3968
+ const qs = params.toString();
3969
+ const path = `/api/lessons/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}${qs ? `?${qs}` : ""}`;
3970
+ const result = await apiGet(path, { token, signal: options.signal });
3971
+ if (!result.ok)
3972
+ return result;
3973
+ const mismatch = releaseMismatch(course, result.data, options.release);
3974
+ if (mismatch)
3975
+ return { ok: false, status: 0, code: "release_mismatch", error: mismatch };
3976
+ const signature = result.responseHeaders.get("X-Bundle-Signature");
3977
+ const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
3978
+ const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
3979
+ if (signature && keyIdRaw && headerHash) {
3980
+ const keyId = Number(keyIdRaw);
3981
+ try {
3982
+ verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
3983
+ } catch (err) {
3984
+ const message = err instanceof Error ? err.message : String(err);
3985
+ return {
3986
+ ok: false,
3987
+ status: 0,
3988
+ code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
3989
+ error: message
3990
+ };
3991
+ }
3992
+ } else if (signature || keyIdRaw || headerHash) {
3993
+ return {
3994
+ ok: false,
3995
+ status: 0,
3996
+ code: "signature_error",
3997
+ error: "Bundle signing headers are incomplete (expected X-Bundle-Signature, X-Bundle-Key-Id, and X-Bundle-Content-Hash together). " + "The API may be misconfigured. Do NOT use the content. Report this to the course team."
3998
+ };
3999
+ } else if (REQUIRE_SIGNATURES) {
4000
+ return {
4001
+ ok: false,
4002
+ status: 0,
4003
+ code: "signature_missing",
4004
+ error: "Bundle is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
4005
+ };
4006
+ } else {
4007
+ process.stderr.write(`Warning: bundle is not signed. Signature verification skipped.
4008
+ `);
4009
+ }
4010
+ return result;
4011
+ }
4012
+ async function fetchArtifact(course, lessonId, type, name, tool, token, options = {}) {
4013
+ const params = new URLSearchParams({ tool });
4014
+ if (options.lang)
4015
+ params.set("lang", options.lang);
4016
+ if (options.release)
4017
+ params.set("release", options.release.releaseId);
4018
+ const path = `/api/artifacts/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?${params}`;
4019
+ const result = await apiGet(path, { token, signal: options.signal });
4020
+ if (!result.ok)
4021
+ return result;
4022
+ const mismatch = releaseMismatch(course, result.data, options.release);
4023
+ if (mismatch)
4024
+ return { ok: false, status: 0, code: "release_mismatch", error: mismatch };
4025
+ const signature = result.responseHeaders.get("X-Bundle-Signature");
4026
+ const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
4027
+ const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
4028
+ if (signature && keyIdRaw && headerHash) {
4029
+ const keyId = Number(keyIdRaw);
4030
+ try {
4031
+ verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
4032
+ } catch (err) {
4033
+ const message = err instanceof Error ? err.message : String(err);
4034
+ return {
4035
+ ok: false,
4036
+ status: 0,
4037
+ code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
4038
+ error: message
4039
+ };
4040
+ }
4041
+ } else if (signature || keyIdRaw || headerHash) {
4042
+ return {
4043
+ ok: false,
4044
+ status: 0,
4045
+ code: "signature_error",
4046
+ error: "Artifact signing headers are incomplete (expected X-Bundle-Signature, X-Bundle-Key-Id, and X-Bundle-Content-Hash together). " + "The API may be misconfigured. Do NOT use the content. Report this to the course team."
4047
+ };
4048
+ } else if (REQUIRE_SIGNATURES) {
4049
+ return {
4050
+ ok: false,
4051
+ status: 0,
4052
+ code: "signature_missing",
4053
+ error: "Artifact is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
4054
+ };
4055
+ } else {
4056
+ process.stderr.write(`Warning: artifact is not signed. Signature verification skipped.
4057
+ `);
4058
+ }
4059
+ return result;
4060
+ }
4061
+ async function fetchHealth(options = {}) {
4062
+ const timeoutMs = options.timeoutMs ?? 5000;
4063
+ const controller = new AbortController;
4064
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
4065
+ const started = Date.now();
4066
+ try {
4067
+ const result = await apiGet("/health", { signal: controller.signal });
4068
+ const latencyMs = Date.now() - started;
4069
+ return { ...result, latencyMs };
4070
+ } catch (err) {
4071
+ const latencyMs = Date.now() - started;
4072
+ const aborted = controller.signal.aborted;
4073
+ return {
4074
+ ok: false,
4075
+ status: 0,
4076
+ code: aborted ? "timeout" : "network_error",
4077
+ error: err instanceof Error ? err.message : String(err),
4078
+ latencyMs
4079
+ };
4080
+ } finally {
4081
+ clearTimeout(timer);
4082
+ }
4083
+ }
4084
+ function apiBaseUrl() {
4085
+ return resolveApiBase();
4086
+ }
4087
+
4088
+ // src/commands/auth.ts
4089
+ var AUTH_METHODS = ["email", "circle"];
4090
+ function isAuthMethod(value) {
4091
+ return AUTH_METHODS.includes(value);
4092
+ }
4093
+ function registerAuthCommand(cli) {
4094
+ cli.command("auth", "Authenticate with 10xDevs via magic link or Circle message").option("--email <email>", "Email address (skips interactive prompt)").option("--method <method>", "Login method: 'email' (magic link, default) or 'circle' (approval link sent as a Circle message)").option("--status", "Show current authentication state").option("--logout", "Delete locally stored credentials").action(async (options) => {
4095
+ const ctx = resolveContext(options);
4096
+ if (options.status) {
4097
+ await runStatus(ctx);
4098
+ return;
4099
+ }
4100
+ if (options.logout) {
4101
+ runLogout(ctx);
4102
+ return;
4103
+ }
4104
+ await runLogin(ctx, options);
4105
+ });
4106
+ }
4107
+ async function runStatus(ctx) {
4108
+ const auth = readAuth();
4109
+ if (!auth) {
4110
+ outputError(ctx, "auth_required", "You're not signed in.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in.");
4111
+ }
4112
+ const expired = isExpired(auth);
4113
+ const expiresAt = new Date(auth.expires_at);
4114
+ if (expired) {
4115
+ outputError(ctx, "auth_expired", `Your session for ${auth.email} has expired.`, ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
4116
+ }
4117
+ const access = await fetchCourses(auth.access_token);
4118
+ const accessMessage = access.ok ? `Available courses: ${access.data.courses.filter((course) => course.available).map((course) => course.slug).join(", ") || "none"}.` : `Course access could not be checked (${access.code}).`;
4119
+ const methodMessage = auth.method ? ` Signed in via ${describeMethod(auth.method)}.` : "";
4120
+ output(ctx, `Signed in as ${auth.email} — session expires ${formatExpiry(expiresAt)}. ${accessMessage}${methodMessage}`, {
4121
+ email: auth.email,
4122
+ expires_at: auth.expires_at,
4123
+ is_valid: true,
4124
+ ...auth.method ? { method: auth.method } : {},
4125
+ access_checked: access.ok,
4126
+ ...access.ok ? { courses: access.data.courses, defaultCourse: access.data.defaultCourse } : { access_error: { code: access.code, message: access.error } }
4127
+ });
4128
+ }
4129
+ function describeMethod(method) {
4130
+ return method === "circle" ? "Circle message" : "email magic link";
4131
+ }
4132
+ function formatExpiry(expiresAt) {
4133
+ if (!Number.isFinite(expiresAt.getTime()))
4134
+ return "at an unknown time";
4135
+ const diffMs = expiresAt.getTime() - Date.now();
4136
+ if (diffMs <= 0)
4137
+ return "now";
4138
+ const days = Math.floor(diffMs / (24 * 60 * 60 * 1000));
4139
+ if (days >= 1)
4140
+ return `in ${days} day${days === 1 ? "" : "s"}`;
4141
+ const hours = Math.floor(diffMs / (60 * 60 * 1000));
4142
+ if (hours >= 1)
4143
+ return `in ${hours} hour${hours === 1 ? "" : "s"}`;
4144
+ const mins = Math.max(1, Math.floor(diffMs / (60 * 1000)));
4145
+ return `in ${mins} minute${mins === 1 ? "" : "s"}`;
4146
+ }
4147
+ function runLogout(ctx) {
4148
+ const had = readAuth() !== null;
4149
+ deleteAuth();
4150
+ output(ctx, had ? "Logged out successfully." : "Already logged out.", {
4151
+ logged_out: true,
4152
+ had_credentials: had
4153
+ });
4154
+ }
4155
+ async function runLogin(ctx, options) {
4156
+ const method = await collectMethod(ctx, options);
4157
+ const email = await collectEmail(ctx, options);
4158
+ if (method === "circle") {
4159
+ await runCircleLogin(ctx, email);
4160
+ return;
4161
+ }
4162
+ if (!ctx.json)
4163
+ we("10x auth");
4164
+ const sp = ctx.json ? null : L2();
4165
+ sp?.start("Requesting magic link…");
4166
+ const login = await loginRequest(email);
4167
+ if (!login.ok) {
4168
+ sp?.stop("Magic link request failed.", 1);
4169
+ handleLoginError(ctx, login.status, login.code, login.error);
4170
+ }
4171
+ const { session_id } = login.data;
4172
+ sp?.stop(`Magic link sent to ${email}. Check your inbox.`);
4173
+ const pollSpinner = ctx.json ? null : L2();
4174
+ pollSpinner?.start("Waiting for magic link…");
4175
+ const result = await pollVerifySession(session_id, {
4176
+ onTick: (remainingMs) => {
4177
+ if (pollSpinner) {
4178
+ pollSpinner.message(`Waiting for magic link (${formatRemaining(remainingMs)} remaining)`);
4179
+ }
4180
+ }
4181
+ });
4182
+ if (result.kind === "verified") {
4183
+ pollSpinner?.stop("Authenticated.");
4184
+ const auth = {
4185
+ version: AUTH_FILE_VERSION,
4186
+ email,
4187
+ access_token: result.tokens.token,
4188
+ refresh_token: result.tokens.refresh_token,
4189
+ expires_at: result.tokens.expires_at,
4190
+ created_at: new Date().toISOString(),
4191
+ method: "email"
4192
+ };
4193
+ saveAuth(auth);
4194
+ if (!ctx.json)
4195
+ fe(`Signed in as ${email}.`);
4196
+ output(ctx, "", {
4197
+ authenticated: true,
4198
+ email,
4199
+ expires_at: auth.expires_at,
4200
+ method: "email"
4201
+ });
4202
+ return;
4203
+ }
4204
+ if (result.kind === "expired") {
4205
+ pollSpinner?.stop("Session expired.", 1);
4206
+ outputError(ctx, "session_expired", result.message || "Magic-link session expired before it was clicked.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' again to request a fresh link.");
4207
+ }
4208
+ if (result.kind === "timeout") {
4209
+ pollSpinner?.stop("Timed out waiting for magic link.", 1);
4210
+ outputError(ctx, "auth_timeout", "Timed out waiting for the magic link to be clicked.", ExitCodes.ERROR, "Run '10x auth' to request a new link.");
4211
+ }
4212
+ if (result.kind === "aborted") {
4213
+ pollSpinner?.stop("Cancelled.", 1);
4214
+ outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
4215
+ }
4216
+ pollSpinner?.stop("Authentication failed.", 1);
4217
+ outputError(ctx, result.code || "auth_error", result.message || "Authentication failed.", ExitCodes.ERROR);
4218
+ }
4219
+ var CIRCLE_SENT_MESSAGE = "A Circle message with an approval link is on its way; open it in Circle on any device.";
4220
+ var CIRCLE_UNKNOWN_MESSAGE = "Circle did not confirm delivery; the message may still arrive. Waiting for approval.";
4221
+ var CIRCLE_EXPIRY_HINT = "Run '10x auth --method email' to sign in with a magic link, or run '10x auth --method circle' to request a fresh Circle message.";
4222
+ async function runCircleLogin(ctx, email) {
4223
+ if (!ctx.json)
4224
+ we("10x auth");
4225
+ const sp = ctx.json ? null : L2();
4226
+ sp?.start("Requesting a Circle message…");
4227
+ const start = await circleStartRequest(email, describeCircleClient());
4228
+ if (!start.ok) {
4229
+ sp?.stop("Circle message request failed.", 1);
4230
+ handleCircleStartError(ctx, start.status, start.code, start.error, start.payload);
4231
+ }
4232
+ const { device_code, expires_in, interval, delivery } = start.data;
4233
+ sp?.stop(delivery === "unknown" ? CIRCLE_UNKNOWN_MESSAGE : CIRCLE_SENT_MESSAGE);
4234
+ verbose(ctx, `circle login started: delivery=${delivery} interval=${interval}s expires_in=${expires_in}s`);
4235
+ const pollSpinner = ctx.json ? null : L2();
4236
+ pollSpinner?.start("Waiting for approval in Circle…");
4237
+ const controller = new AbortController;
4238
+ const onSigint = () => controller.abort();
4239
+ process.on("SIGINT", onSigint);
4240
+ let result;
4241
+ try {
4242
+ result = await pollCircleLogin(device_code, {
4243
+ intervalMs: interval * 1000,
4244
+ timeoutMs: expires_in * 1000,
4245
+ signal: controller.signal,
4246
+ onTick: (remainingMs) => {
4247
+ if (pollSpinner) {
4248
+ pollSpinner.message(`Waiting for approval in Circle (${formatRemaining(remainingMs)} remaining)`);
4249
+ }
4250
+ }
4251
+ });
4252
+ } finally {
4253
+ process.removeListener("SIGINT", onSigint);
4254
+ }
4255
+ if (result.kind === "verified") {
4256
+ pollSpinner?.stop("Authenticated.");
4257
+ const auth = {
4258
+ version: AUTH_FILE_VERSION,
4259
+ email,
4260
+ access_token: result.tokens.token,
4261
+ refresh_token: result.tokens.refresh_token,
4262
+ expires_at: result.tokens.expires_at,
4263
+ created_at: new Date().toISOString(),
4264
+ method: "circle"
4265
+ };
4266
+ saveAuth(auth);
4267
+ if (!ctx.json)
4268
+ fe(`Signed in as ${email}.`);
4269
+ output(ctx, "", {
4270
+ authenticated: true,
4271
+ email,
4272
+ expires_at: auth.expires_at,
4273
+ method: "circle"
4274
+ });
4275
+ return;
4276
+ }
4277
+ if (result.kind === "expired") {
4278
+ pollSpinner?.stop("Circle login expired.", 1);
4279
+ outputError(ctx, "circle_login_expired", "The Circle login expired before the approval link was opened.", ExitCodes.ERROR, CIRCLE_EXPIRY_HINT);
4280
+ }
4281
+ if (result.kind === "denied") {
4282
+ pollSpinner?.stop("Access denied.", 1);
4283
+ outputError(ctx, "access_denied", "This email has no active 10xDevs course membership, so the login was denied.", ExitCodes.FORBIDDEN, "Enroll at https://10xdevs.pl, then run '10x auth --method circle' again.");
4284
+ }
4285
+ if (result.kind === "timeout") {
4286
+ pollSpinner?.stop("Timed out waiting for approval.", 1);
4287
+ outputError(ctx, "auth_timeout", "Timed out waiting for the Circle approval link to be opened.", ExitCodes.ERROR, CIRCLE_EXPIRY_HINT);
4288
+ }
4289
+ if (result.kind === "aborted") {
4290
+ pollSpinner?.stop("Cancelled.", 1);
4291
+ outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
4292
+ }
4293
+ pollSpinner?.stop("Authentication failed.", 1);
4294
+ outputError(ctx, result.code || "auth_error", result.message || "Authentication failed.", ExitCodes.ERROR);
4295
+ }
4296
+ function handleCircleStartError(ctx, status, code, error, payload) {
4297
+ verbose(ctx, `circle start failed: status=${status} code=${code}`);
4298
+ if (status === 403) {
4299
+ outputError(ctx, "no_access", "This email has no active 10xDevs course membership.", ExitCodes.FORBIDDEN, "Enroll at https://10xdevs.pl, then run '10x auth --method circle' again.");
4300
+ }
4301
+ if (status === 429) {
4302
+ const retryAfter = payload?.retry_after_s;
4303
+ const wait = typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter > 0 ? `Wait about ${Math.ceil(retryAfter)} seconds` : "Wait a few minutes";
4304
+ outputError(ctx, "rate_limited", "Too many Circle login requests right now.", ExitCodes.ERROR, `${wait}, then run '10x auth --method circle' again.`);
4305
+ }
4306
+ if (status === 502) {
4307
+ outputError(ctx, "dm_rejected", "Circle refused to deliver the login message.", ExitCodes.ERROR, "Check that direct messages are enabled in your Circle settings, or run '10x auth --method email'.");
4308
+ }
4309
+ if (status === 503) {
4310
+ outputError(ctx, "circle_login_disabled", "Circle login is currently unavailable.", ExitCodes.ERROR, "Run '10x auth --method email' to sign in with a magic link.");
4311
+ }
4312
+ if (status === 0) {
4313
+ outputError(ctx, "network_error", "Could not reach the 10x-toolkit API.", ExitCodes.ERROR, "Check your internet connection and run '10x auth --method circle' again.");
4314
+ }
4315
+ outputError(ctx, code || "auth_error", "Authentication failed.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
4316
+ }
4317
+ async function collectMethod(ctx, options) {
4318
+ if (options.method !== undefined) {
4319
+ const value = String(options.method).trim().toLowerCase();
4320
+ if (!isAuthMethod(value)) {
4321
+ outputError(ctx, "invalid_method", `'${options.method}' is not a login method.`, ExitCodes.USAGE, "Pass '--method email' for a magic link or '--method circle' for a Circle message.");
4322
+ }
4323
+ return value;
4324
+ }
4325
+ if (ctx.json)
4326
+ return "email";
4327
+ const answer = await de({
4328
+ message: "How do you want to sign in?",
4329
+ options: [
4330
+ { value: "email", label: "Email magic link", hint: "a link sent to your inbox" },
4331
+ { value: "circle", label: "Circle message", hint: "an approval link sent to you in Circle" }
4332
+ ],
4333
+ initialValue: "email"
4334
+ });
4335
+ if (BD(answer)) {
4336
+ ve("Authentication cancelled.");
4337
+ outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
4338
+ }
4339
+ return answer;
4340
+ }
4341
+ async function collectEmail(ctx, options) {
4342
+ if (options.email) {
4343
+ const trimmed = options.email.trim();
4344
+ if (!isLikelyEmail(trimmed)) {
4345
+ outputError(ctx, "invalid_email", `'${options.email}' does not look like an email address.`, ExitCodes.USAGE, "Pass your 10xDevs email, for example '10x auth --email you@example.com'.");
4346
+ }
4347
+ return trimmed;
4348
+ }
4349
+ if (ctx.json) {
4350
+ outputError(ctx, "missing_email", "Email is required in non-interactive mode.", ExitCodes.USAGE, "Pass --email, for example '10x auth --email you@example.com'.");
4351
+ }
4352
+ const answer = await ue({
4353
+ message: "Enter your 10xDevs email",
4354
+ placeholder: "you@example.com",
4355
+ validate: (value) => {
4356
+ if (!value || value.trim().length === 0)
4357
+ return "Email is required.";
4358
+ if (!isLikelyEmail(value.trim()))
4359
+ return "That does not look like an email address.";
4360
+ return;
4361
+ }
4362
+ });
4363
+ if (BD(answer)) {
4364
+ ve("Authentication cancelled.");
4365
+ outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
4366
+ }
4367
+ return answer.trim();
4368
+ }
4369
+ function isLikelyEmail(value) {
4370
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
4371
+ }
4372
+ function formatRemaining(ms) {
4373
+ const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
4374
+ const minutes = Math.floor(totalSeconds / 60);
4375
+ const seconds = totalSeconds % 60;
4376
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
4377
+ }
4378
+ function handleLoginError(ctx, status, code, error) {
4379
+ verbose(ctx, `login failed: status=${status} code=${code}`);
4380
+ if (status === 403) {
4381
+ outputError(ctx, "no_access", "This email has no active 10xDevs course membership.", ExitCodes.FORBIDDEN, "Enroll at https://10xdevs.pl, then run '10x auth' again.");
4382
+ }
4383
+ if (status === 429) {
4384
+ outputError(ctx, "rate_limited", "Too many magic-link requests for this email.", ExitCodes.ERROR, "Wait a few minutes, then run '10x auth' again.");
4385
+ }
4386
+ if (status === 502) {
4387
+ outputError(ctx, "email_delivery_failed", "Could not send the magic-link email.", ExitCodes.ERROR, "Try '10x auth' again in a few minutes. If it keeps failing, contact support.");
4388
+ }
4389
+ if (status === 0) {
4390
+ outputError(ctx, "network_error", "Could not reach the 10x-toolkit API.", ExitCodes.ERROR, "Check your internet connection and run '10x auth' again.");
4391
+ }
4392
+ outputError(ctx, code || "auth_error", "Authentication failed.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
3856
4393
  }
3857
4394
 
3858
4395
  // src/lib/bench-client.ts
@@ -4043,207 +4580,80 @@ function renderLeaderboard(ctx, data, limit) {
4043
4580
  totalAttempts: data.totalAttempts,
4044
4581
  totalModels: data.totalModels,
4045
4582
  models: ranked.map((e2, i) => ({
4046
- rank: i + 1,
4047
- model: e2.modelName,
4048
- modelBaseId: e2.modelBaseId,
4049
- score: e2.averageScore,
4050
- maxScore: e2.averageMaxScore,
4051
- percentage: e2.averagePercentage,
4052
- attempts: e2.attemptCount,
4053
- environment: e2.agentEnvironment,
4054
- averageCost: e2.averageCost
4055
- }))
4056
- });
4057
- return;
4058
- }
4059
- if (ranked.length === 0) {
4060
- output(ctx, "The leaderboard has no results yet.", undefined);
4061
- return;
4062
- }
4063
- const names = ranked.map((e2) => sanitize(e2.modelName));
4064
- const details = ranked.map((e2) => formatDetail(e2));
4065
- const nameWidth = Math.max(...names.map((n) => n.length));
4066
- const detailWidth = Math.max(...details.map((d2) => d2.length));
4067
- const rankWidth = String(ranked.length).length;
4068
- const lines = [];
4069
- lines.push(paint(STYLE.bold, `10xBench — Top ${ranked.length} of ${data.totalModels} models`) + paint(STYLE.dim, ` (${data.totalAttempts} attempts scored)`));
4070
- lines.push("");
4071
- for (let i = 0;i < ranked.length; i++) {
4072
- lines.push(formatRow(ranked[i], i + 1, names[i], details[i], rankWidth, nameWidth, detailWidth));
4073
- }
4074
- lines.push("");
4075
- lines.push(paint(STYLE.dim, `Data as of ${formatReleaseAt(data.generatedAt)} — ${resolveBenchBase()}`));
4076
- output(ctx, lines.join(`
4077
- `), undefined);
4078
- }
4079
- function formatDetail(entry) {
4080
- const score = `${entry.averageScore.toFixed(1)}/${entry.averageMaxScore.toFixed(0)}`;
4081
- const runs = `${entry.attemptCount} run${entry.attemptCount === 1 ? "" : "s"}`;
4082
- const cost = entry.averageCost === null || entry.averageCost === undefined ? "" : `, ~$${entry.averageCost.toFixed(2)}/run`;
4083
- return `(${score}, ${runs}${cost})`;
4084
- }
4085
- function formatRow(entry, rank, name, detail, rankWidth, nameWidth, detailWidth) {
4086
- const style = scoreStyle(entry.averagePercentage);
4087
- const rankCell = String(rank).padStart(rankWidth);
4088
- const nameCell = name.padEnd(nameWidth);
4089
- const pctCell = `${entry.averagePercentage.toFixed(1)}%`.padStart(6);
4090
- return [
4091
- ` ${paint(rank <= 3 ? STYLE.bold : STYLE.dim, rankCell)}`,
4092
- rank <= 3 ? paint(STYLE.bold, nameCell) : nameCell,
4093
- paint(style, scoreBar(entry.averagePercentage)),
4094
- paint(style, pctCell),
4095
- detail.padEnd(detailWidth),
4096
- paint(STYLE.dim, sanitize(entry.agentEnvironment))
4097
- ].join(" ");
4098
- }
4099
-
4100
- // src/commands/bench-kit.ts
4101
- import { spawn } from "node:child_process";
4102
- import { existsSync as existsSync4, mkdtempSync, readFileSync as readFileSync3, rmSync as rmSync3 } from "node:fs";
4103
- import { tmpdir } from "node:os";
4104
- import { basename, join as join4, resolve } from "node:path";
4105
-
4106
- // src/lib/tool-detect.ts
4107
- import { existsSync as existsSync3 } from "node:fs";
4108
- import { join as join3 } from "node:path";
4109
-
4110
- // src/lib/manifest.ts
4111
- import { createHash } from "node:crypto";
4112
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
4113
- import { dirname as dirname2, join as join2 } from "node:path";
4114
- var MANIFEST_FILENAME = ".10x-cli-manifest.json";
4115
- var CLI_PACKAGE_NAME = "@przeprogramowani/10x-cli";
4116
- var MANIFEST_VERSION = 3;
4117
- function contentHash(content) {
4118
- return createHash("sha256").update(content, "utf8").digest("hex");
4119
- }
4120
- function readManifest(dir) {
4121
- const manifestPath = join2(dir, MANIFEST_FILENAME);
4122
- if (!existsSync2(manifestPath))
4123
- return null;
4124
- let raw;
4125
- try {
4126
- raw = readFileSync2(manifestPath, "utf8");
4127
- } catch {
4128
- return null;
4129
- }
4130
- let parsed;
4131
- try {
4132
- parsed = JSON.parse(raw);
4133
- } catch {
4134
- return null;
4135
- }
4136
- if (!isManifest(parsed))
4137
- return null;
4138
- return parsed;
4139
- }
4140
- function writeManifest(dir, manifest) {
4141
- if (!existsSync2(dir)) {
4142
- mkdirSync3(dir, { recursive: true });
4143
- }
4144
- const manifestPath = join2(dir, MANIFEST_FILENAME);
4145
- mkdirSync3(dirname2(manifestPath), { recursive: true });
4146
- const tmp = `${manifestPath}.tmp`;
4147
- rmSync2(tmp, { force: true });
4148
- writeFileSync2(tmp, `${JSON.stringify(manifest, null, 2)}
4149
- `);
4150
- renameSync2(tmp, manifestPath);
4151
- }
4152
- function isManifest(value) {
4153
- if (typeof value !== "object" || value === null)
4154
- return false;
4155
- const v3 = value;
4156
- if (v3["package"] !== CLI_PACKAGE_NAME)
4157
- return false;
4158
- if (typeof v3["version"] !== "string")
4159
- return false;
4160
- const mv = v3["manifestVersion"];
4161
- if (mv !== 2 && mv !== 3)
4162
- return false;
4163
- if (typeof v3["lastApplied"] !== "string")
4164
- return false;
4165
- if (typeof v3["lessonId"] !== "string")
4166
- return false;
4167
- if (typeof v3["course"] !== "string")
4168
- return false;
4169
- const files = v3["files"];
4170
- if (typeof files !== "object" || files === null)
4171
- return false;
4172
- const f2 = files;
4173
- if (!(isSkillsRecord(f2["skills"]) && isStringArray(f2["prompts"]) && isStringArray(f2["configs"])))
4174
- return false;
4175
- const lessons = v3["lessons"];
4176
- if (lessons !== undefined && !isLessonsRecord(lessons))
4177
- return false;
4178
- return true;
4179
- }
4180
- function isSkillsRecord(value) {
4181
- if (typeof value !== "object" || value === null || Array.isArray(value))
4182
- return false;
4183
- for (const entry of Object.values(value)) {
4184
- if (typeof entry !== "object" || entry === null)
4185
- return false;
4186
- const e2 = entry;
4187
- if (!isStringArray(e2["files"]))
4188
- return false;
4583
+ rank: i + 1,
4584
+ model: e2.modelName,
4585
+ modelBaseId: e2.modelBaseId,
4586
+ score: e2.averageScore,
4587
+ maxScore: e2.averageMaxScore,
4588
+ percentage: e2.averagePercentage,
4589
+ attempts: e2.attemptCount,
4590
+ environment: e2.agentEnvironment,
4591
+ averageCost: e2.averageCost
4592
+ }))
4593
+ });
4594
+ return;
4189
4595
  }
4190
- return true;
4191
- }
4192
- function isStringArray(value) {
4193
- return Array.isArray(value) && value.every((item) => typeof item === "string");
4194
- }
4195
- function isLessonFilesEntry(value) {
4196
- if (typeof value !== "object" || value === null)
4197
- return false;
4198
- const e2 = value;
4199
- if (typeof e2["appliedAt"] !== "string")
4200
- return false;
4201
- if (typeof e2["skills"] !== "object" || e2["skills"] === null || Array.isArray(e2["skills"]))
4202
- return false;
4203
- for (const skill of Object.values(e2["skills"])) {
4204
- if (typeof skill !== "object" || skill === null)
4205
- return false;
4206
- if (!isStringArray(skill["files"]))
4207
- return false;
4596
+ if (ranked.length === 0) {
4597
+ output(ctx, "The leaderboard has no results yet.", undefined);
4598
+ return;
4208
4599
  }
4209
- if (e2["catalogContentHash"] !== undefined && typeof e2["catalogContentHash"] !== "string") {
4210
- return false;
4600
+ const names = ranked.map((e2) => sanitize(e2.modelName));
4601
+ const details = ranked.map((e2) => formatDetail(e2));
4602
+ const nameWidth = Math.max(...names.map((n) => n.length));
4603
+ const detailWidth = Math.max(...details.map((d2) => d2.length));
4604
+ const rankWidth = String(ranked.length).length;
4605
+ const lines = [];
4606
+ lines.push(paint(STYLE.bold, `10xBench — Top ${ranked.length} of ${data.totalModels} models`) + paint(STYLE.dim, ` (${data.totalAttempts} attempts scored)`));
4607
+ lines.push("");
4608
+ for (let i = 0;i < ranked.length; i++) {
4609
+ lines.push(formatRow(ranked[i], i + 1, names[i], details[i], rankWidth, nameWidth, detailWidth));
4211
4610
  }
4212
- return isStringArray(e2["prompts"]) && isStringArray(e2["configs"]);
4611
+ lines.push("");
4612
+ lines.push(paint(STYLE.dim, `Data as of ${formatReleaseAt(data.generatedAt)} — ${resolveBenchBase()}`));
4613
+ output(ctx, lines.join(`
4614
+ `), undefined);
4213
4615
  }
4214
- function isLessonsRecord(value) {
4215
- if (typeof value !== "object" || value === null || Array.isArray(value))
4216
- return false;
4217
- for (const entry of Object.values(value)) {
4218
- if (!isLessonFilesEntry(entry))
4219
- return false;
4220
- }
4221
- return true;
4616
+ function formatDetail(entry) {
4617
+ const score = `${entry.averageScore.toFixed(1)}/${entry.averageMaxScore.toFixed(0)}`;
4618
+ const runs = `${entry.attemptCount} run${entry.attemptCount === 1 ? "" : "s"}`;
4619
+ const cost = entry.averageCost === null || entry.averageCost === undefined ? "" : `, ~$${entry.averageCost.toFixed(2)}/run`;
4620
+ return `(${score}, ${runs}${cost})`;
4222
4621
  }
4223
- function buildUnionFiles(lessons) {
4224
- const skills = {};
4225
- const prompts = new Set;
4226
- const configs = new Set;
4227
- for (const entry of Object.values(lessons)) {
4228
- for (const [name, skill] of Object.entries(entry.skills)) {
4229
- if (!skills[name]) {
4230
- skills[name] = { files: [...skill.files] };
4231
- } else {
4232
- for (const f2 of skill.files) {
4233
- if (!skills[name].files.includes(f2)) {
4234
- skills[name].files.push(f2);
4235
- }
4236
- }
4237
- }
4238
- }
4239
- for (const p2 of entry.prompts)
4240
- prompts.add(p2);
4241
- for (const c2 of entry.configs)
4242
- configs.add(c2);
4243
- }
4244
- return { skills, prompts: [...prompts], configs: [...configs] };
4622
+ function formatRow(entry, rank, name, detail, rankWidth, nameWidth, detailWidth) {
4623
+ const style = scoreStyle(entry.averagePercentage);
4624
+ const rankCell = String(rank).padStart(rankWidth);
4625
+ const nameCell = name.padEnd(nameWidth);
4626
+ const pctCell = `${entry.averagePercentage.toFixed(1)}%`.padStart(6);
4627
+ return [
4628
+ ` ${paint(rank <= 3 ? STYLE.bold : STYLE.dim, rankCell)}`,
4629
+ rank <= 3 ? paint(STYLE.bold, nameCell) : nameCell,
4630
+ paint(style, scoreBar(entry.averagePercentage)),
4631
+ paint(style, pctCell),
4632
+ detail.padEnd(detailWidth),
4633
+ paint(STYLE.dim, sanitize(entry.agentEnvironment))
4634
+ ].join(" ");
4245
4635
  }
4246
4636
 
4637
+ // src/commands/bench-kit.ts
4638
+ import { spawn } from "node:child_process";
4639
+ import { existsSync as existsSync4, mkdtempSync, readFileSync as readFileSync4, rmSync as rmSync4 } from "node:fs";
4640
+ import { tmpdir } from "node:os";
4641
+ import { basename, join as join5, resolve as resolve2 } from "node:path";
4642
+
4643
+ // src/lib/tool-detect.ts
4644
+ import { existsSync as existsSync3 } from "node:fs";
4645
+ import { join as join4 } from "node:path";
4646
+
4647
+ // src/lib/manifest.ts
4648
+ import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
4649
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
4650
+ import { dirname as dirname2, join as join3 } from "node:path";
4651
+
4652
+ // src/lib/project-course.ts
4653
+ import { randomUUID } from "node:crypto";
4654
+ import { linkSync, lstatSync, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
4655
+ import { isAbsolute, join as join2, relative, resolve, sep } from "node:path";
4656
+
4247
4657
  // src/lib/tool-profile.ts
4248
4658
  var SENTINEL_BEGIN = "<!-- BEGIN @przeprogramowani/10x-cli -->";
4249
4659
  var SENTINEL_END = "<!-- END @przeprogramowani/10x-cli -->";
@@ -4350,21 +4760,329 @@ var LEGACY_PROFILES = {
4350
4760
  sentinelBegin: SENTINEL_BEGIN,
4351
4761
  sentinelEnd: SENTINEL_END
4352
4762
  }
4353
- };
4354
- function canonicalToolId(toolId) {
4355
- return TOOL_ALIASES[toolId] ?? toolId;
4763
+ };
4764
+ function canonicalToolId(toolId) {
4765
+ return TOOL_ALIASES[toolId] ?? toolId;
4766
+ }
4767
+ function getToolProfile(toolId) {
4768
+ return PROFILES[canonicalToolId(toolId)];
4769
+ }
4770
+ function contentToolId(profile) {
4771
+ return profile.contentToolId ?? profile.toolId;
4772
+ }
4773
+ var DEFAULT_TOOL = "claude-code";
4774
+
4775
+ // src/lib/project-course.ts
4776
+ var PROJECT_COURSE_FILENAME = ".10x-cli.json";
4777
+
4778
+ class CourseBindingError extends Error {
4779
+ code;
4780
+ constructor(code, message) {
4781
+ super(message);
4782
+ this.code = code;
4783
+ }
4784
+ }
4785
+ function normalizeProjectCourse(value) {
4786
+ if (value === "10xdevs3" || value === "10xdevs-3")
4787
+ return "10xdevs3";
4788
+ if (value === "10xdevs4" || value === "10xdevs-4")
4789
+ return "10xdevs4";
4790
+ return null;
4791
+ }
4792
+ function assertProjectPath(projectRoot, target) {
4793
+ const root = resolve(projectRoot);
4794
+ const rel = relative(root, resolve(target));
4795
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`))
4796
+ throw new CourseBindingError("course_binding_invalid", `Path escapes project: ${target}`);
4797
+ let current = root;
4798
+ const parts = rel ? rel.split(sep) : [];
4799
+ for (let i = 0;i <= parts.length; i++) {
4800
+ if (i > 0)
4801
+ current = join2(current, parts[i - 1]);
4802
+ let stat;
4803
+ try {
4804
+ stat = lstatSync(current);
4805
+ } catch (error) {
4806
+ if (error.code === "ENOENT")
4807
+ return;
4808
+ throw error;
4809
+ }
4810
+ if (stat.isSymbolicLink() || i < parts.length && !stat.isDirectory() || i === parts.length && !stat.isFile() && !stat.isDirectory()) {
4811
+ throw new CourseBindingError("course_binding_invalid", `Unsafe project path: ${current}`);
4812
+ }
4813
+ }
4814
+ }
4815
+ function assertProjectFilePath(projectRoot, target) {
4816
+ assertProjectPath(projectRoot, target);
4817
+ try {
4818
+ if (!lstatSync(target).isFile())
4819
+ throw new CourseBindingError("course_binding_invalid", `Expected a regular file at ${target}.`);
4820
+ } catch (error) {
4821
+ if (error.code !== "ENOENT")
4822
+ throw error;
4823
+ }
4824
+ }
4825
+ function readJsonIfPresent(projectRoot, target) {
4826
+ assertProjectPath(projectRoot, target);
4827
+ try {
4828
+ if (!lstatSync(target).isFile())
4829
+ throw new Error("Expected a regular file");
4830
+ return JSON.parse(readFileSync2(target, "utf8"));
4831
+ } catch (error) {
4832
+ if (error.code === "ENOENT")
4833
+ return;
4834
+ throw new CourseBindingError("course_binding_invalid", `Cannot read project state ${target}: ${error instanceof Error ? error.message : String(error)}`);
4835
+ }
4836
+ }
4837
+ function inspectProjectCourse(projectRoot) {
4838
+ const binding = readJsonIfPresent(projectRoot, join2(projectRoot, PROJECT_COURSE_FILENAME));
4839
+ let bound = null;
4840
+ if (binding !== undefined) {
4841
+ if (!binding || typeof binding !== "object" || Array.isArray(binding))
4842
+ throw new CourseBindingError("course_binding_invalid", "Invalid .10x-cli.json.");
4843
+ const value = binding;
4844
+ bound = normalizeProjectCourse(value.course);
4845
+ if (value.version !== 1 || !bound || value.course !== bound || Object.keys(value).some((key) => key !== "version" && key !== "course"))
4846
+ throw new CourseBindingError("course_binding_invalid", "Unsupported or corrupt .10x-cli.json; preserve it and repair the project state before writing.");
4847
+ }
4848
+ const courses = new Set;
4849
+ if (bound)
4850
+ courses.add(bound);
4851
+ for (const profile of [...Object.values(PROFILES), ...Object.values(LEGACY_PROFILES)]) {
4852
+ const path = join2(projectRoot, profile.manifestDir, MANIFEST_FILENAME);
4853
+ const value = readJsonIfPresent(projectRoot, path);
4854
+ if (value === undefined)
4855
+ continue;
4856
+ if (!isManifest(value))
4857
+ throw new CourseBindingError("course_binding_invalid", `Unsupported or corrupt manifest: ${path}. Preserve it; this CLI supports manifest versions 2 and 3.`);
4858
+ const course = normalizeProjectCourse(value.course);
4859
+ if (!course)
4860
+ throw new CourseBindingError("course_binding_invalid", `Unsupported course in ${path}.`);
4861
+ courses.add(course);
4862
+ }
4863
+ if (courses.size > 1)
4864
+ throw new CourseBindingError("course_binding_conflict", "Project binding and tool manifests disagree on the course. Resolve the conflict before writing.");
4865
+ return { course: courses.values().next().value ?? null, source: bound ? "binding" : courses.size ? "legacy" : "none" };
4866
+ }
4867
+ function assertProjectCourse(projectRoot, course) {
4868
+ const normalized = normalizeProjectCourse(course);
4869
+ if (!normalized || normalized !== course)
4870
+ throw new CourseBindingError("course_binding_invalid", `Unsupported normalized course: ${course}`);
4871
+ const state = inspectProjectCourse(projectRoot);
4872
+ if (state.course && state.course !== course)
4873
+ throw new CourseBindingError("course_mismatch", `This project uses ${state.course}. Start ${course} in a separate directory; get/sync cannot switch its edition.`);
4874
+ return state;
4875
+ }
4876
+ function establishProjectCourse(projectRoot, course) {
4877
+ const state = assertProjectCourse(projectRoot, course);
4878
+ if (state.source === "binding")
4879
+ return;
4880
+ const destination = join2(projectRoot, PROJECT_COURSE_FILENAME);
4881
+ const temporary = `${destination}.${randomUUID()}.tmp`;
4882
+ try {
4883
+ writeFileSync2(temporary, `${JSON.stringify({ version: 1, course }, null, 2)}
4884
+ `, { flag: "wx" });
4885
+ try {
4886
+ linkSync(temporary, destination);
4887
+ } catch (error) {
4888
+ if (error.code !== "EEXIST")
4889
+ throw error;
4890
+ assertProjectCourse(projectRoot, course);
4891
+ }
4892
+ } finally {
4893
+ rmSync2(temporary, { force: true });
4894
+ }
4895
+ }
4896
+
4897
+ // src/lib/manifest.ts
4898
+ var MANIFEST_FILENAME = ".10x-cli-manifest.json";
4899
+ var CLI_PACKAGE_NAME = "@przeprogramowani/10x-cli";
4900
+ var MANIFEST_VERSION = 3;
4901
+ function contentHash(content) {
4902
+ return createHash2("sha256").update(content).digest("hex");
4903
+ }
4904
+ function readManifest(dir) {
4905
+ const manifestPath = join3(dir, MANIFEST_FILENAME);
4906
+ assertProjectFilePath(dirname2(dir), manifestPath);
4907
+ if (!existsSync2(manifestPath))
4908
+ return null;
4909
+ let raw;
4910
+ try {
4911
+ raw = readFileSync3(manifestPath, "utf8");
4912
+ } catch {
4913
+ return null;
4914
+ }
4915
+ let parsed;
4916
+ try {
4917
+ parsed = JSON.parse(raw);
4918
+ } catch {
4919
+ return null;
4920
+ }
4921
+ if (!isManifest(parsed))
4922
+ return null;
4923
+ return parsed;
4924
+ }
4925
+ function writeManifest(dir, manifest) {
4926
+ assertProjectFilePath(dirname2(dir), join3(dir, MANIFEST_FILENAME));
4927
+ assertProjectFilePath(dirname2(dir), join3(dir, `${MANIFEST_FILENAME}.tmp`));
4928
+ if (!existsSync2(dir)) {
4929
+ mkdirSync3(dir, { recursive: true });
4930
+ }
4931
+ const manifestPath = join3(dir, MANIFEST_FILENAME);
4932
+ mkdirSync3(dirname2(manifestPath), { recursive: true });
4933
+ const tmp = `${manifestPath}.${randomUUID2()}.tmp`;
4934
+ assertProjectFilePath(dirname2(dir), tmp);
4935
+ try {
4936
+ writeFileSync3(tmp, `${JSON.stringify(manifest, null, 2)}
4937
+ `, { flag: "wx" });
4938
+ renameSync2(tmp, manifestPath);
4939
+ } finally {
4940
+ rmSync3(tmp, { force: true });
4941
+ }
4942
+ }
4943
+ function isManifest(value) {
4944
+ if (typeof value !== "object" || value === null)
4945
+ return false;
4946
+ const v3 = value;
4947
+ if (v3["package"] !== CLI_PACKAGE_NAME || "directSkills" in v3)
4948
+ return false;
4949
+ if (typeof v3["version"] !== "string")
4950
+ return false;
4951
+ const mv = v3["manifestVersion"];
4952
+ if (mv !== 2 && mv !== 3)
4953
+ return false;
4954
+ if (typeof v3["lastApplied"] !== "string")
4955
+ return false;
4956
+ if (typeof v3["lessonId"] !== "string")
4957
+ return false;
4958
+ if (typeof v3["course"] !== "string")
4959
+ return false;
4960
+ const files = v3["files"];
4961
+ if (typeof files !== "object" || files === null)
4962
+ return false;
4963
+ const f2 = files;
4964
+ if ("directSkills" in f2 || f2["promptHashes"] !== undefined && !isStringRecord(f2["promptHashes"]))
4965
+ return false;
4966
+ if (!(isSkillsRecord(f2["skills"]) && isStringArray(f2["prompts"]) && isStringArray(f2["configs"])))
4967
+ return false;
4968
+ if (f2["configHashes"] !== undefined && !isStringRecord(f2["configHashes"]))
4969
+ return false;
4970
+ const rules = v3["managedRules"];
4971
+ if (rules !== undefined) {
4972
+ if (!rules || typeof rules !== "object" || Array.isArray(rules))
4973
+ return false;
4974
+ const r2 = rules;
4975
+ if (!["path", "begin", "end"].every((k3) => typeof r2[k3] === "string") || r2.upstreamHash !== undefined && typeof r2.upstreamHash !== "string")
4976
+ return false;
4977
+ }
4978
+ const lessons = v3["lessons"];
4979
+ if (lessons !== undefined && !isLessonsRecord(lessons))
4980
+ return false;
4981
+ return true;
4982
+ }
4983
+ function isSkillsRecord(value) {
4984
+ if (typeof value !== "object" || value === null || Array.isArray(value))
4985
+ return false;
4986
+ for (const entry of Object.values(value)) {
4987
+ if (typeof entry !== "object" || entry === null)
4988
+ return false;
4989
+ const e2 = entry;
4990
+ if (!isStringArray(e2["files"]) || e2["contentHashes"] !== undefined && !isStringRecord(e2["contentHashes"]))
4991
+ return false;
4992
+ }
4993
+ return true;
4994
+ }
4995
+ function isStringRecord(value) {
4996
+ return !!value && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string");
4997
+ }
4998
+ function isStringArray(value) {
4999
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
5000
+ }
5001
+ function isLessonFilesEntry(value) {
5002
+ if (typeof value !== "object" || value === null)
5003
+ return false;
5004
+ const e2 = value;
5005
+ if (typeof e2["appliedAt"] !== "string")
5006
+ return false;
5007
+ if (typeof e2["skills"] !== "object" || e2["skills"] === null || Array.isArray(e2["skills"]))
5008
+ return false;
5009
+ for (const skill of Object.values(e2["skills"])) {
5010
+ if (typeof skill !== "object" || skill === null)
5011
+ return false;
5012
+ if (!isStringArray(skill["files"]))
5013
+ return false;
5014
+ }
5015
+ if (e2["catalogContentHash"] !== undefined && typeof e2["catalogContentHash"] !== "string") {
5016
+ return false;
5017
+ }
5018
+ for (const k3 of ["installedReleaseId", "installedManifestHash"])
5019
+ if (e2[k3] !== undefined && typeof e2[k3] !== "string")
5020
+ return false;
5021
+ if (e2.representation !== undefined) {
5022
+ const r2 = e2.representation;
5023
+ if (!r2 || typeof r2 !== "object" || typeof r2.lang !== "string" || typeof r2.tool !== "string" || typeof r2.courseRules !== "boolean" || r2.rules !== undefined && typeof r2.rules !== "boolean")
5024
+ return false;
5025
+ }
5026
+ return isStringArray(e2["prompts"]) && isStringArray(e2["configs"]);
5027
+ }
5028
+ function isLessonsRecord(value) {
5029
+ if (typeof value !== "object" || value === null || Array.isArray(value))
5030
+ return false;
5031
+ for (const entry of Object.values(value)) {
5032
+ if (!isLessonFilesEntry(entry))
5033
+ return false;
5034
+ }
5035
+ return true;
4356
5036
  }
4357
- function getToolProfile(toolId) {
4358
- return PROFILES[canonicalToolId(toolId)];
5037
+ function buildUnionFiles(lessons) {
5038
+ const skills = {};
5039
+ const prompts = new Set;
5040
+ const configs = new Set;
5041
+ for (const entry of Object.values(lessons)) {
5042
+ for (const [name, skill] of Object.entries(entry.skills)) {
5043
+ if (!skills[name]) {
5044
+ skills[name] = { files: [...skill.files] };
5045
+ } else {
5046
+ for (const f2 of skill.files) {
5047
+ if (!skills[name].files.includes(f2)) {
5048
+ skills[name].files.push(f2);
5049
+ }
5050
+ }
5051
+ }
5052
+ }
5053
+ for (const p2 of entry.prompts)
5054
+ prompts.add(p2);
5055
+ for (const c2 of entry.configs)
5056
+ configs.add(c2);
5057
+ }
5058
+ return { skills, prompts: [...prompts], configs: [...configs] };
4359
5059
  }
4360
- function contentToolId(profile) {
4361
- return profile.contentToolId ?? profile.toolId;
5060
+ function seedLessons(manifest) {
5061
+ return structuredClone(manifest.lessons ?? { [manifest.lessonId]: {
5062
+ appliedAt: manifest.lastApplied,
5063
+ skills: Object.fromEntries(Object.entries(manifest.files.skills).map(([name, entry]) => [name, { files: [...entry.files] }])),
5064
+ prompts: [...manifest.files.prompts],
5065
+ configs: [...manifest.files.configs]
5066
+ } });
5067
+ }
5068
+ function rebuildManifestFiles(manifest) {
5069
+ const union = buildUnionFiles(manifest.lessons ?? {});
5070
+ const previous = manifest.files;
5071
+ manifest.files = {
5072
+ skills: Object.fromEntries(Object.entries(union.skills).map(([name, entry]) => [name, {
5073
+ files: entry.files,
5074
+ contentHashes: Object.fromEntries(entry.files.flatMap((file) => previous.skills[name]?.contentHashes?.[file] ? [[file, previous.skills[name].contentHashes[file]]] : []))
5075
+ }])),
5076
+ prompts: union.prompts,
5077
+ configs: union.configs,
5078
+ promptHashes: Object.fromEntries(union.prompts.flatMap((name) => previous.promptHashes?.[name] ? [[name, previous.promptHashes[name]]] : [])),
5079
+ configHashes: Object.fromEntries(union.configs.flatMap((name) => previous.configHashes?.[name] ? [[name, previous.configHashes[name]]] : []))
5080
+ };
4362
5081
  }
4363
- var DEFAULT_TOOL = "claude-code";
4364
5082
 
4365
5083
  // src/lib/tool-detect.ts
4366
5084
  function detectTools(projectRoot) {
4367
- const hit = (rel) => existsSync3(join3(projectRoot, rel));
5085
+ const hit = (rel) => existsSync3(join4(projectRoot, rel));
4368
5086
  const signals = [];
4369
5087
  if (hit(".claude/" + MANIFEST_FILENAME)) {
4370
5088
  signals.push({
@@ -4498,7 +5216,7 @@ function topDetectedProfile(signals) {
4498
5216
 
4499
5217
  // src/commands/bench-kit.ts
4500
5218
  var TEMPLATE_REPO_URL = "https://github.com/przeprogramowani/10x-bench-kit";
4501
- var BOOTSTRAP_ENTRY = join4(".bench-kit", "bootstrap", "index.mjs");
5219
+ var BOOTSTRAP_ENTRY = join5(".bench-kit", "bootstrap", "index.mjs");
4502
5220
  var CONTRACT_VERSION = 1;
4503
5221
  function registerBenchKitCommand(cli) {
4504
5222
  cli.command("bench-kit <action> [dir]", "Manage a benchmark instance (actions: init, update)").option("--template-version <tag>", "Template tag to install (default: latest)").option("--tool <id>", `Agent tool for skill placement (${Object.keys(PROFILES).join(", ")})`).option("--yes", "Run non-interactively, accepting defaults").example("10x bench-kit init my-benchmark").example("10x bench-kit init my-benchmark --template-version v0.1.0").example("10x bench-kit update").action(async (action, dir, options) => {
@@ -4515,7 +5233,7 @@ function registerBenchKitCommand(cli) {
4515
5233
  });
4516
5234
  }
4517
5235
  async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
4518
- const targetDir = resolve(dirArg ?? ".");
5236
+ const targetDir = resolve2(dirArg ?? ".");
4519
5237
  const requestedRef = normalizeRef(ctx, options.templateVersion);
4520
5238
  await preflight(ctx, deps);
4521
5239
  const existingVersion = readInstanceVersion(targetDir);
@@ -4523,7 +5241,7 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
4523
5241
  outputError(ctx, "version_conflict", `This directory already holds a benchmark instance on template version ${existingVersion}.`, ExitCodes.USAGE, "Run '10x bench-kit update' to change the template version of an existing instance.");
4524
5242
  }
4525
5243
  const toolChoice = await resolveInstanceTool(ctx, options, deps, existingVersion !== null);
4526
- const scratch = mkdtempSync(join4(tmpdir(), "bench-kit-"));
5244
+ const scratch = mkdtempSync(join5(tmpdir(), "bench-kit-"));
4527
5245
  try {
4528
5246
  await cloneTemplateOrDie(ctx, deps, requestedRef, scratch, "init");
4529
5247
  const entry = requireBootstrap(ctx, scratch);
@@ -4560,14 +5278,14 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
4560
5278
  }
4561
5279
  let baseRepoClone = "skipped";
4562
5280
  if (res.baseRepoClone != null) {
4563
- const cloneDest = join4(targetDir, res.baseRepoClone.dest);
5281
+ const cloneDest = join5(targetDir, res.baseRepoClone.dest);
4564
5282
  verbose(ctx, `cloning base repo into ${res.baseRepoClone.dest}`);
4565
5283
  const cloned = await deps.cloneBaseRepo(res.baseRepoClone, cloneDest);
4566
5284
  if (cloned.ok) {
4567
5285
  baseRepoClone = "cloned";
4568
5286
  } else {
4569
5287
  baseRepoClone = "failed";
4570
- rmSync3(cloneDest, { recursive: true, force: true });
5288
+ rmSync4(cloneDest, { recursive: true, force: true });
4571
5289
  verbose(ctx, `base repo clone failed (${cloned.error.trim().split(`
4572
5290
  `).pop() ?? ""})`);
4573
5291
  }
@@ -4614,11 +5332,11 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
4614
5332
  warnings: res.warnings ?? []
4615
5333
  });
4616
5334
  } finally {
4617
- rmSync3(scratch, { recursive: true, force: true });
5335
+ rmSync4(scratch, { recursive: true, force: true });
4618
5336
  }
4619
5337
  }
4620
5338
  async function runBenchKitUpdate(ctx, dirArg, options, deps = defaultDeps) {
4621
- const targetDir = resolve(dirArg ?? ".");
5339
+ const targetDir = resolve2(dirArg ?? ".");
4622
5340
  const requestedRef = normalizeRef(ctx, options.templateVersion);
4623
5341
  await preflight(ctx, deps);
4624
5342
  const currentVersion = readInstanceVersion(targetDir);
@@ -4633,7 +5351,7 @@ async function runBenchKitUpdate(ctx, dirArg, options, deps = defaultDeps) {
4633
5351
  if (!status.ok) {
4634
5352
  verbose(ctx, "not a git repository — proceeding, but review the changes without git diff");
4635
5353
  }
4636
- const scratch = mkdtempSync(join4(tmpdir(), "bench-kit-"));
5354
+ const scratch = mkdtempSync(join5(tmpdir(), "bench-kit-"));
4637
5355
  try {
4638
5356
  await cloneTemplateOrDie(ctx, deps, requestedRef, scratch, "update");
4639
5357
  const entry = requireBootstrap(ctx, scratch);
@@ -4691,7 +5409,7 @@ async function runBenchKitUpdate(ctx, dirArg, options, deps = defaultDeps) {
4691
5409
  warnings: res.warnings ?? []
4692
5410
  });
4693
5411
  } finally {
4694
- rmSync3(scratch, { recursive: true, force: true });
5412
+ rmSync4(scratch, { recursive: true, force: true });
4695
5413
  }
4696
5414
  }
4697
5415
  function describeSync(counts) {
@@ -4754,17 +5472,17 @@ async function cloneTemplateOrDie(ctx, deps, ref, scratch, action) {
4754
5472
  }
4755
5473
  }
4756
5474
  function requireBootstrap(ctx, scratch) {
4757
- const entry = join4(scratch, BOOTSTRAP_ENTRY);
5475
+ const entry = join5(scratch, BOOTSTRAP_ENTRY);
4758
5476
  if (!existsSync4(entry)) {
4759
5477
  outputError(ctx, "template_incomplete", `The downloaded template has no ${BOOTSTRAP_ENTRY} — it predates the bootstrap contract.`, ExitCodes.ERROR, "This CLI needs template v0.10.0 or newer; drop --template-version or pass a newer tag.");
4760
5478
  }
4761
5479
  return entry;
4762
5480
  }
4763
5481
  function readInstanceVersion(dir) {
4764
- const versionFile = join4(dir, ".bench-kit", "VERSION");
5482
+ const versionFile = join5(dir, ".bench-kit", "VERSION");
4765
5483
  if (!existsSync4(versionFile))
4766
5484
  return null;
4767
- return readFileSync3(versionFile, "utf8").trim();
5485
+ return readFileSync4(versionFile, "utf8").trim();
4768
5486
  }
4769
5487
  function toHttpsUrl(url) {
4770
5488
  const scp = url.match(/^git@([^:/]+):(.+)$/);
@@ -4890,214 +5608,76 @@ var defaultDeps = {
4890
5608
 
4891
5609
  // src/commands/doctor.ts
4892
5610
  import { existsSync as existsSync5, accessSync, constants } from "node:fs";
4893
- import { join as join5 } from "node:path";
5611
+ import { join as join6 } from "node:path";
4894
5612
 
4895
- // src/lib/signing.ts
4896
- import { createHash as createHash2, createPublicKey, verify } from "node:crypto";
4897
-
4898
- class SignatureError extends Error {
4899
- constructor(message) {
5613
+ // src/lib/course-selection.ts
5614
+ class CourseSelectionError extends Error {
5615
+ code;
5616
+ constructor(code, message) {
4900
5617
  super(message);
4901
- this.name = "SignatureError";
4902
- }
4903
- }
4904
- var KEYSET = [
4905
- {
4906
- keyId: 1,
4907
- publicKey: "MCowBQYDK2VwAyEANXEwq1y+9CYAJS6ze9m/c212fL4r1BauXwKdT6Zo5Ko="
4908
- }
4909
- ];
4910
- var REQUIRE_SIGNATURES = true;
4911
- function loadEffectiveKeyset() {
4912
- const override = process.env["BUNDLE_PUBLIC_KEYSET"];
4913
- if (!override)
4914
- return KEYSET;
4915
- const apiBase = process.env["API_BASE_URL"];
4916
- if (!apiBase)
4917
- return KEYSET;
4918
- let hostname;
4919
- try {
4920
- hostname = new URL(apiBase).hostname;
4921
- } catch {
4922
- return KEYSET;
4923
- }
4924
- if (hostname !== "localhost" && hostname !== "127.0.0.1") {
4925
- return KEYSET;
4926
- }
4927
- try {
4928
- const parsed = JSON.parse(override);
4929
- if (!Array.isArray(parsed) || parsed.length === 0)
4930
- return KEYSET;
4931
- for (const k3 of parsed) {
4932
- if (typeof k3.keyId !== "number" || typeof k3.publicKey !== "string") {
4933
- return KEYSET;
4934
- }
4935
- }
4936
- return parsed;
4937
- } catch {
4938
- return KEYSET;
4939
- }
4940
- }
4941
- function sha256Hex(body) {
4942
- return createHash2("sha256").update(body).digest("hex");
4943
- }
4944
- function canonicalSigningString(keyId, hashHex) {
4945
- return `v1:${keyId}:${hashHex}`;
4946
- }
4947
- function verifyBundleSignature(rawBody, signature, keyId, headerHash) {
4948
- verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, loadEffectiveKeyset());
4949
- }
4950
- function verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, keyset) {
4951
- const key = keyset.find((k3) => k3.keyId === keyId);
4952
- if (!key) {
4953
- throw new SignatureError(`Unknown signing key (key_id=${keyId}). Update your CLI: npm update -g @przeprogramowani/10x-cli`);
4954
- }
4955
- const computedHash = sha256Hex(rawBody);
4956
- if (computedHash !== headerHash) {
4957
- throw new SignatureError(`Bundle content hash mismatch (header=${headerHash.slice(0, 12)}…, computed=${computedHash.slice(0, 12)}…). ` + "The bundle may have been tampered with in transit. Do NOT use the content.");
4958
- }
4959
- let pubKey;
5618
+ this.code = code;
5619
+ }
5620
+ }
5621
+ function selectCourse(discovery, state, explicit, writing = false) {
5622
+ if (!validateCourseDiscovery(discovery))
5623
+ throw new CourseSelectionError("discovery_invalid", "Invalid course discovery response.");
5624
+ const requested = explicit ?? state.course ?? discovery.defaultCourse;
5625
+ if (!requested)
5626
+ throw new CourseSelectionError(discovery.courses.length ? "course_unavailable" : "course_access_denied", discovery.courses.length ? "Your courses have no available published content yet." : "Your account has no active course access.");
5627
+ const selected = discovery.courses.find((course) => course.id === requested || course.slug === requested);
5628
+ const normalized = selected?.slug ?? normalizeProjectCourse(requested);
5629
+ if (writing && state.course && normalized && state.course !== normalized)
5630
+ throw new CourseBindingError("course_mismatch", `This project uses ${state.course}. Start ${normalized} in a separate directory; get/sync cannot switch its edition.`);
5631
+ if (!selected)
5632
+ throw new CourseSelectionError("course_access_denied", `Your account does not have access to ${requested}.`);
5633
+ if (!selected.available)
5634
+ throw new CourseSelectionError("course_unavailable", `${selected.slug} has no available published content yet.`);
5635
+ if (normalizeProjectCourse(selected.slug) !== selected.slug)
5636
+ throw new CourseSelectionError("course_unsupported", `This CLI does not support ${selected.slug}. Update the CLI before writing its content.`);
5637
+ return { course: selected.slug, courseId: selected.id, reason: explicit !== undefined ? "explicit" : state.source === "binding" ? "project_binding" : state.source === "legacy" ? "legacy_manifest" : "backend_recommendation" };
5638
+ }
5639
+ function tokenLacksCourse(token, selection) {
4960
5640
  try {
4961
- pubKey = createPublicKey({
4962
- key: Buffer.from(key.publicKey, "base64"),
4963
- format: "der",
4964
- type: "spki"
4965
- });
5641
+ const segment = token.split(".")[1];
5642
+ if (!segment)
5643
+ return false;
5644
+ const payload = JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
5645
+ return Array.isArray(payload.courses) && !payload.courses.includes(selection.courseId) && !payload.courses.includes(selection.course);
4966
5646
  } catch {
4967
- throw new SignatureError("Failed to load the signing public key. Your CLI binary may be corrupted — reinstall it.");
4968
- }
4969
- const canonical = canonicalSigningString(keyId, computedHash);
4970
- const sig = Buffer.from(signature, "base64");
4971
- const valid = verify(null, Buffer.from(canonical), pubKey, sig);
4972
- if (!valid) {
4973
- throw new SignatureError("Bundle signature verification failed — the bundle may have been tampered with. " + "Do NOT use the content. Report this to the course team.");
4974
- }
4975
- }
4976
-
4977
- // src/lib/api-content.ts
4978
- function fetchCatalog(course, token, options = {}) {
4979
- return apiGet(`/api/catalog/${encodeURIComponent(course)}`, { token, signal: options.signal });
4980
- }
4981
- function fetchModuleDetail(course, module, token, options = {}) {
4982
- return apiGet(`/api/modules/${encodeURIComponent(course)}/${module}`, { token, signal: options.signal });
4983
- }
4984
- async function fetchLesson(course, lessonId, token, options = {}) {
4985
- const params = new URLSearchParams;
4986
- if (options.lang)
4987
- params.set("lang", options.lang);
4988
- if (options.tool)
4989
- params.set("tool", options.tool);
4990
- const qs = params.toString();
4991
- const path = `/api/lessons/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}${qs ? `?${qs}` : ""}`;
4992
- const result = await apiGet(path, { token, signal: options.signal });
4993
- if (!result.ok)
4994
- return result;
4995
- const signature = result.responseHeaders.get("X-Bundle-Signature");
4996
- const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
4997
- const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
4998
- if (signature && keyIdRaw && headerHash) {
4999
- const keyId = Number(keyIdRaw);
5000
- try {
5001
- verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
5002
- } catch (err) {
5003
- const message = err instanceof Error ? err.message : String(err);
5004
- return {
5005
- ok: false,
5006
- status: 0,
5007
- code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
5008
- error: message
5009
- };
5010
- }
5011
- } else if (signature || keyIdRaw || headerHash) {
5012
- return {
5013
- ok: false,
5014
- status: 0,
5015
- code: "signature_error",
5016
- error: "Bundle signing headers are incomplete (expected X-Bundle-Signature, X-Bundle-Key-Id, and X-Bundle-Content-Hash together). " + "The API may be misconfigured. Do NOT use the content. Report this to the course team."
5017
- };
5018
- } else if (REQUIRE_SIGNATURES) {
5019
- return {
5020
- ok: false,
5021
- status: 0,
5022
- code: "signature_missing",
5023
- error: "Bundle is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
5024
- };
5025
- } else {
5026
- process.stderr.write(`Warning: bundle is not signed. Signature verification skipped.
5027
- `);
5647
+ return false;
5028
5648
  }
5029
- return result;
5030
5649
  }
5031
- async function fetchArtifact(course, lessonId, type, name, tool, token, options = {}) {
5032
- const params = new URLSearchParams({ tool });
5033
- if (options.lang)
5034
- params.set("lang", options.lang);
5035
- const path = `/api/artifacts/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?${params}`;
5036
- const result = await apiGet(path, { token, signal: options.signal });
5037
- if (!result.ok)
5038
- return result;
5039
- const signature = result.responseHeaders.get("X-Bundle-Signature");
5040
- const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
5041
- const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
5042
- if (signature && keyIdRaw && headerHash) {
5043
- const keyId = Number(keyIdRaw);
5044
- try {
5045
- verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
5046
- } catch (err) {
5047
- const message = err instanceof Error ? err.message : String(err);
5048
- return {
5049
- ok: false,
5050
- status: 0,
5051
- code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
5052
- error: message
5053
- };
5054
- }
5055
- } else if (signature || keyIdRaw || headerHash) {
5056
- return {
5057
- ok: false,
5058
- status: 0,
5059
- code: "signature_error",
5060
- error: "Artifact signing headers are incomplete (expected X-Bundle-Signature, X-Bundle-Key-Id, and X-Bundle-Content-Hash together). " + "The API may be misconfigured. Do NOT use the content. Report this to the course team."
5061
- };
5062
- } else if (REQUIRE_SIGNATURES) {
5063
- return {
5064
- ok: false,
5065
- status: 0,
5066
- code: "signature_missing",
5067
- error: "Artifact is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
5068
- };
5069
- } else {
5070
- process.stderr.write(`Warning: artifact is not signed. Signature verification skipped.
5071
- `);
5650
+ function reportCourseError(ctx, error) {
5651
+ if (error instanceof CourseBindingError || error instanceof CourseSelectionError) {
5652
+ outputError(ctx, error.code, error.message, error.code === "course_access_denied" ? ExitCodes.FORBIDDEN : error.code === "course_mismatch" ? ExitCodes.USAGE : ExitCodes.ERROR);
5072
5653
  }
5073
- return result;
5654
+ throw error;
5074
5655
  }
5075
- async function fetchHealth(options = {}) {
5076
- const timeoutMs = options.timeoutMs ?? 5000;
5077
- const controller = new AbortController;
5078
- const timer = setTimeout(() => controller.abort(), timeoutMs);
5079
- const started = Date.now();
5656
+ async function resolveCourseSelection(ctx, options, dependencies = {}) {
5657
+ const authenticate = dependencies.authenticate ?? requireAuth;
5658
+ const discover = dependencies.discover ?? fetchCourses;
5659
+ const root = options.projectRoot ?? process.cwd();
5080
5660
  try {
5081
- const result = await apiGet("/health", { signal: controller.signal });
5082
- const latencyMs = Date.now() - started;
5083
- return { ...result, latencyMs };
5084
- } catch (err) {
5085
- const latencyMs = Date.now() - started;
5086
- const aborted = controller.signal.aborted;
5087
- return {
5088
- ok: false,
5089
- status: 0,
5090
- code: aborted ? "timeout" : "network_error",
5091
- error: err instanceof Error ? err.message : String(err),
5092
- latencyMs
5093
- };
5094
- } finally {
5095
- clearTimeout(timer);
5661
+ const state = inspectProjectCourse(root);
5662
+ let auth = await authenticate(ctx);
5663
+ let discovery = await discover(auth.access_token);
5664
+ if (!discovery.ok)
5665
+ outputError(ctx, discovery.code, discovery.error, discovery.status === 401 ? ExitCodes.AUTH_REQUIRED : discovery.status === 403 ? ExitCodes.FORBIDDEN : ExitCodes.ERROR);
5666
+ const selected = selectCourse(discovery.data, state, options.explicit, options.writing);
5667
+ if (tokenLacksCourse(auth.access_token, selected)) {
5668
+ auth = await authenticate(ctx, { forceRefreshForToken: auth.access_token });
5669
+ discovery = await discover(auth.access_token);
5670
+ if (!discovery.ok)
5671
+ outputError(ctx, discovery.code, discovery.error, discovery.status === 401 ? ExitCodes.AUTH_REQUIRED : discovery.status === 403 ? ExitCodes.FORBIDDEN : ExitCodes.ERROR);
5672
+ selectCourse(discovery.data, state, selected.course, options.writing);
5673
+ if (tokenLacksCourse(auth.access_token, selected))
5674
+ throw new CourseSelectionError("course_access_denied", "The refreshed session still does not grant the selected course. Sign in again.");
5675
+ }
5676
+ return { ...selected, auth };
5677
+ } catch (error) {
5678
+ return reportCourseError(ctx, error);
5096
5679
  }
5097
5680
  }
5098
- function apiBaseUrl() {
5099
- return resolveApiBase();
5100
- }
5101
5681
 
5102
5682
  // src/lib/update-check.ts
5103
5683
  var NPM_REGISTRY_URL = "https://registry.npmjs.org/@przeprogramowani/10x-cli/latest";
@@ -5172,7 +5752,7 @@ function registerDoctorCommand(cli) {
5172
5752
  async function runDoctor(ctx) {
5173
5753
  verbose(ctx, "running doctor checks");
5174
5754
  const checks = [];
5175
- checks.push(checkAuth());
5755
+ checks.push(await checkAuth());
5176
5756
  checks.push(await checkApiConnectivity());
5177
5757
  checks.push(checkConfigDirectory());
5178
5758
  checks.push(await checkCliVersion());
@@ -5221,7 +5801,7 @@ async function runDoctor(ctx) {
5221
5801
  process.exit(EX_CONFIG);
5222
5802
  }
5223
5803
  }
5224
- function checkAuth() {
5804
+ async function checkAuth() {
5225
5805
  const auth = readAuth();
5226
5806
  if (!auth) {
5227
5807
  return {
@@ -5242,6 +5822,16 @@ function checkAuth() {
5242
5822
  details: { email: auth.email, expires_at: auth.expires_at }
5243
5823
  };
5244
5824
  }
5825
+ const access = await fetchCourses(auth.access_token);
5826
+ if (!access.ok)
5827
+ return { name: "auth", label: "Auth", status: "fail", message: `Session exists, but course access could not be verified: ${access.error}`, details: { email: auth.email, expires_at: auth.expires_at, code: access.code, access_checked: false } };
5828
+ let selected;
5829
+ try {
5830
+ selected = selectCourse(access.data, inspectProjectCourse(process.cwd()));
5831
+ } catch (error) {
5832
+ return { name: "auth", label: "Auth", status: "fail", message: error instanceof Error ? error.message : String(error), details: { access_checked: true, courses: access.data.courses, code: error && typeof error === "object" && "code" in error ? error.code : "course_error" } };
5833
+ }
5834
+ const accessDetails = { access_checked: true, courses: access.data.courses, course: selected.course, selectionReason: selected.reason };
5245
5835
  const nearExpiry = isNearExpiry(auth);
5246
5836
  const prettyDate = formatReleaseAt(auth.expires_at);
5247
5837
  if (nearExpiry) {
@@ -5251,15 +5841,15 @@ function checkAuth() {
5251
5841
  status: "warn",
5252
5842
  message: `Signed in as ${auth.email}, but the session expires ${prettyDate.toLowerCase()}.`,
5253
5843
  hint: "The next command will transparently refresh the token.",
5254
- details: { email: auth.email, expires_at: auth.expires_at }
5844
+ details: { email: auth.email, expires_at: auth.expires_at, ...accessDetails }
5255
5845
  };
5256
5846
  }
5257
5847
  return {
5258
5848
  name: "auth",
5259
5849
  label: "Auth",
5260
5850
  status: "pass",
5261
- message: `Signed in as ${auth.email} — session expires ${prettyDate.toLowerCase()}.`,
5262
- details: { email: auth.email, expires_at: auth.expires_at }
5851
+ message: `Signed in as ${auth.email} — ${selected.course} (${selected.reason}); session expires ${prettyDate.toLowerCase()}.`,
5852
+ details: { email: auth.email, expires_at: auth.expires_at, ...accessDetails }
5263
5853
  };
5264
5854
  }
5265
5855
  async function checkApiConnectivity() {
@@ -5360,7 +5950,7 @@ function checkToolDirectory() {
5360
5950
  const toolId = readToolConfig()?.tool ?? DEFAULT_TOOL;
5361
5951
  const profile = getToolProfile(toolId) ?? PROFILES[DEFAULT_TOOL];
5362
5952
  const dirName = profile.manifestDir;
5363
- const toolDir = join5(cwd, dirName);
5953
+ const toolDir = join6(cwd, dirName);
5364
5954
  if (!existsSync5(toolDir)) {
5365
5955
  return {
5366
5956
  name: "tool-dir",
@@ -5393,7 +5983,7 @@ function checkToolDirectory() {
5393
5983
  }
5394
5984
 
5395
5985
  // src/commands/get.ts
5396
- import { join as join8 } from "node:path";
5986
+ import { join as join10 } from "node:path";
5397
5987
 
5398
5988
  // src/lib/conflict-prompt.ts
5399
5989
  init_dist2();
@@ -5481,108 +6071,163 @@ function parseModuleRef(raw) {
5481
6071
  init_dist2();
5482
6072
 
5483
6073
  // src/lib/tool-switch.ts
5484
- import {
5485
- existsSync as existsSync7,
5486
- lstatSync,
5487
- mkdirSync as mkdirSync5,
5488
- readFileSync as readFileSync6,
5489
- renameSync as renameSync3,
5490
- rmdirSync as rmdirSync2,
5491
- rmSync as rmSync5,
5492
- statSync,
5493
- writeFileSync as writeFileSync4
5494
- } from "node:fs";
5495
- import { dirname as dirname4, join as join7 } from "node:path";
6074
+ import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync8, renameSync as renameSync3, rmSync as rmSync6, writeFileSync as writeFileSync5, statSync, chmodSync as chmodSync3, openSync, closeSync } from "node:fs";
6075
+ import { dirname as dirname5, join as join9 } from "node:path";
6076
+ import { randomUUID as randomUUID3 } from "node:crypto";
5496
6077
 
5497
- // src/lib/fs-utils.ts
5498
- import { readFileSync as readFileSync4 } from "node:fs";
5499
- function readFileOrNull(path) {
5500
- try {
5501
- return readFileSync4(path, "utf8");
5502
- } catch {
5503
- return null;
6078
+ // src/lib/managed-removal.ts
6079
+ import { existsSync as existsSync6, readFileSync as readFileSync5, rmSync as rmSync5, rmdirSync } from "node:fs";
6080
+ import { dirname as dirname3, relative as relative2, sep as sep2 } from "node:path";
6081
+ function planManagedRemoval(root, input) {
6082
+ assertProjectFilePath(root, input.path);
6083
+ const base = { name: input.name, path: input.path, storedHash: input.storedHash, pruneRoot: input.pruneRoot };
6084
+ if (!existsSync6(input.path))
6085
+ return { ...base, action: "unchanged", reason: "already_missing" };
6086
+ if (input.protected)
6087
+ return { ...base, action: "preserved_local", reason: "still_owned" };
6088
+ if (input.config)
6089
+ return { ...base, action: "preserved_local", reason: "config_template" };
6090
+ if (!input.storedHash)
6091
+ return { ...base, action: "preserved_local", reason: "missing_baseline" };
6092
+ if (contentHash(readFileSync5(input.path)) !== input.storedHash)
6093
+ return { ...base, action: "preserved_local", reason: "locally_modified" };
6094
+ return { ...base, action: "removed" };
6095
+ }
6096
+ function executeManagedRemoval(root, plan) {
6097
+ assertProjectFilePath(root, plan.path);
6098
+ if (plan.action !== "removed")
6099
+ return plan;
6100
+ if (!existsSync6(plan.path))
6101
+ return { ...plan, action: "unchanged", reason: "already_missing" };
6102
+ if (!plan.storedHash || contentHash(readFileSync5(plan.path)) !== plan.storedHash)
6103
+ return { ...plan, action: "preserved_local", reason: "locally_modified" };
6104
+ rmSync5(plan.path);
6105
+ if (plan.pruneRoot)
6106
+ pruneEmptyParents(root, plan.path, plan.pruneRoot);
6107
+ return plan;
6108
+ }
6109
+ function pruneEmptyParents(root, file, boundary) {
6110
+ let dir = dirname3(file);
6111
+ while (dir === boundary || relative2(boundary, dir) !== ".." && !relative2(boundary, dir).startsWith(`..${sep2}`)) {
6112
+ assertProjectPath(root, dir);
6113
+ try {
6114
+ rmdirSync(dir);
6115
+ } catch (error) {
6116
+ if (["ENOENT", "ENOTEMPTY", "EEXIST", "EACCES", "EPERM"].includes(error.code ?? ""))
6117
+ return;
6118
+ throw error;
6119
+ }
6120
+ if (dir === boundary)
6121
+ return;
6122
+ dir = dirname3(dir);
5504
6123
  }
5505
6124
  }
5506
6125
 
6126
+ // src/lib/managed-rules.ts
6127
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
6128
+ import { join as join7 } from "node:path";
6129
+
5507
6130
  // src/lib/sentinel-migration.ts
5508
6131
  var OLD_BEGIN = "<!-- BEGIN @przeprogramowani/10x-toolkit -->";
5509
6132
  var OLD_END = "<!-- END @przeprogramowani/10x-toolkit -->";
6133
+ function inspectRulesBlock(content, begin, end) {
6134
+ const starts = content.split(begin).length - 1;
6135
+ const ends = content.split(end).length - 1;
6136
+ if (!starts && !ends)
6137
+ return null;
6138
+ const start = content.indexOf(begin);
6139
+ const stop = content.indexOf(end);
6140
+ if (starts !== 1 || ends !== 1 || stop < start) {
6141
+ throw new Error("Rules markers need repair: orphan, duplicate, or out-of-order sentinel. Preserve the file and resolve its markers before continuing.");
6142
+ }
6143
+ const finish = stop + end.length;
6144
+ return { start, finish, text: content.slice(start, finish), body: content.slice(start + begin.length, stop).trim() };
6145
+ }
5510
6146
  function removeRulesBlockWithMarkers(existing, begin, end) {
5511
- const beginIdx = existing.indexOf(begin);
5512
- const endIdx = existing.indexOf(end);
5513
- if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
6147
+ const block = inspectRulesBlock(existing, begin, end);
6148
+ if (!block)
5514
6149
  return { content: existing, removed: false };
5515
- }
5516
- const beforeRaw = existing.slice(0, beginIdx);
5517
- const afterRaw = existing.slice(endIdx + end.length);
5518
- const before = beforeRaw.replace(/[\r\n]*$/, "");
5519
- const after = afterRaw.replace(/^[\r\n]*/, "");
5520
- const joiner = before.length > 0 && after.length > 0 ? `
5521
-
5522
- ` : "";
5523
- const combined = before + joiner + after;
5524
- const content = combined.length === 0 ? "" : combined.replace(/[\r\n]*$/, `
5525
- `);
5526
- return { content, removed: true };
6150
+ return { content: existing.slice(0, block.start) + existing.slice(block.finish), removed: true };
5527
6151
  }
5528
- function applyRulesBlockWithMarkers(existingContent, rulesBody, beginMarker, endMarker) {
5529
- for (const marker of [OLD_BEGIN, OLD_END, beginMarker, endMarker]) {
5530
- if (rulesBody.includes(marker)) {
5531
- throw new Error(`rules body contains a sentinel marker (${JSON.stringify(marker)}) — refusing to write to prevent rules file corruption`);
5532
- }
6152
+ function applyRulesBlockWithMarkers(existing, body, begin, end) {
6153
+ for (const marker of [OLD_BEGIN, OLD_END, begin, end]) {
6154
+ if (body.includes(marker))
6155
+ throw new Error(`rules body contains a sentinel marker (${JSON.stringify(marker)})`);
5533
6156
  }
5534
- const warnings = [];
5535
- let content = existingContent;
5536
- content = stripBlock(content, OLD_BEGIN, OLD_END, "@przeprogramowani/10x-toolkit", warnings);
5537
- content = stripBlock(content, beginMarker, endMarker, "@przeprogramowani/10x-cli", warnings);
5538
- content = content.replace(/\n{3,}/g, `
5539
-
5540
- `).trim();
5541
- const block = `${beginMarker}
6157
+ const current = inspectRulesBlock(existing, begin, end);
6158
+ const legacy = begin === OLD_BEGIN ? null : inspectRulesBlock(existing, OLD_BEGIN, OLD_END);
6159
+ const fresh = `${begin}
5542
6160
 
5543
- ${rulesBody.trim()}
5544
-
5545
- ${endMarker}`;
5546
- const result = content.length > 0 ? `${content}
5547
-
5548
- ${block}
5549
- ` : `${block}
5550
- `;
5551
- return { content: result, warnings };
5552
- }
5553
- function stripBlock(content, begin, end, label, warnings) {
5554
- const beginIdx = content.indexOf(begin);
5555
- const endIdx = content.indexOf(end);
5556
- if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
5557
- return content.slice(0, beginIdx) + content.slice(endIdx + end.length);
5558
- }
5559
- if (beginIdx !== -1 && endIdx === -1) {
5560
- warnings.push(`CLAUDE.md has an orphan ${label} BEGIN marker; truncating from the marker onward.`);
5561
- return content.slice(0, beginIdx);
5562
- }
5563
- if (beginIdx === -1 && endIdx !== -1) {
5564
- warnings.push(`CLAUDE.md has an orphan ${label} END marker; truncating content before the marker.`);
5565
- return content.slice(0, endIdx);
5566
- }
5567
- if (beginIdx !== -1 && endIdx !== -1 && endIdx < beginIdx) {
5568
- warnings.push(`CLAUDE.md has out-of-order ${label} markers; repairing by truncating to the first marker.`);
5569
- return content.slice(0, Math.min(beginIdx, endIdx));
5570
- }
5571
- return content;
6161
+ ${body.trim()}
6162
+
6163
+ ${end}`;
6164
+ const blocks = [current, legacy].filter((entry) => entry !== null).sort((a2, b2) => a2.start - b2.start);
6165
+ if (blocks.length > 1 && blocks[0].finish > blocks[1].start)
6166
+ throw new Error("Rules markers need repair: nested sentinel blocks.");
6167
+ if (!blocks.length)
6168
+ return { content: `${existing}${existing && !existing.endsWith(`
6169
+ `) ? `
6170
+ ` : ""}${fresh}
6171
+ `, warnings: [] };
6172
+ let content = existing;
6173
+ for (let i = blocks.length - 1;i >= 0; i--) {
6174
+ const block = blocks[i];
6175
+ content = content.slice(0, block.start) + (i === 0 ? fresh : "") + content.slice(block.finish);
6176
+ }
6177
+ return { content, warnings: [] };
6178
+ }
6179
+
6180
+ // src/lib/managed-rules.ts
6181
+ function otherRulesOwners(root, profile) {
6182
+ const owners = [];
6183
+ for (const candidate of [...Object.values(PROFILES), ...Object.values(LEGACY_PROFILES)]) {
6184
+ if (candidate.manifestDir === profile.manifestDir || candidate.rulesFile !== profile.rulesFile)
6185
+ continue;
6186
+ const manifest = readManifest(join7(root, candidate.manifestDir));
6187
+ if (!manifest)
6188
+ continue;
6189
+ if (manifest.managedRules || !manifest.lessons || Object.values(manifest.lessons).some((lesson) => lesson.representation?.courseRules !== false))
6190
+ owners.push({ profile: candidate, rules: manifest.managedRules });
6191
+ }
6192
+ return owners;
6193
+ }
6194
+ function planManagedRules(root, profile, body, enabled, baseline) {
6195
+ const path = join7(root, profile.rulesFile);
6196
+ assertProjectFilePath(root, path);
6197
+ const existing = existsSync7(path) ? readFileSync6(path, "utf8") : "";
6198
+ const block = inspectRulesBlock(existing, profile.sentinelBegin, profile.sentinelEnd);
6199
+ const legacy = inspectRulesBlock(existing, OLD_BEGIN, OLD_END);
6200
+ if (block && legacy && Math.max(block.start, legacy.start) < Math.min(block.finish, legacy.finish))
6201
+ throw new Error("Rules markers need repair: nested sentinel blocks.");
6202
+ if (body === undefined && enabled)
6203
+ return { action: "unchanged", content: existing, isConflict: false, next: baseline };
6204
+ const validBaseline = baseline?.path === profile.rulesFile && baseline.begin === profile.sentinelBegin && baseline.end === profile.sentinelEnd ? baseline : undefined;
6205
+ const owners = otherRulesOwners(root, profile);
6206
+ const desired = enabled ? applyRulesBlockWithMarkers(existing, body ?? "", profile.sentinelBegin, profile.sentinelEnd).content : removeRulesBlockWithMarkers(removeRulesBlockWithMarkers(existing, profile.sentinelBegin, profile.sentinelEnd).content, OLD_BEGIN, OLD_END).content;
6207
+ const desiredBlock = enabled ? inspectRulesBlock(desired, profile.sentinelBegin, profile.sentinelEnd) : null;
6208
+ const next = desiredBlock ? { path: profile.rulesFile, begin: profile.sentinelBegin, end: profile.sentinelEnd, upstreamHash: contentHash(desiredBlock.text) } : undefined;
6209
+ if (!enabled && owners.length)
6210
+ return { action: "unchanged", content: existing, isConflict: false, reason: "still_owned" };
6211
+ if (enabled && owners.some((owner) => !owner.rules?.upstreamHash || owner.rules.path !== profile.rulesFile || owner.rules.begin !== profile.sentinelBegin || owner.rules.end !== profile.sentinelEnd || owner.rules.upstreamHash !== next?.upstreamHash)) {
6212
+ return { action: "conflict_skipped", content: existing, isConflict: true, blocked: true, reason: "incompatible_shared_owner", next: baseline };
6213
+ }
6214
+ const unknownOrEdited = !!legacy || !!block && (!validBaseline?.upstreamHash || contentHash(block.text) !== validBaseline.upstreamHash);
6215
+ if (unknownOrEdited)
6216
+ return { action: "conflict_skipped", content: desired, isConflict: true, reason: validBaseline?.upstreamHash ? "locally_modified" : "missing_baseline", next };
6217
+ if (desired === existing)
6218
+ return { action: "unchanged", content: existing, isConflict: false, next: enabled ? next ?? baseline : undefined };
6219
+ return { action: !enabled ? "removed" : existing ? "updated" : "created", content: desired, isConflict: false, next };
5572
6220
  }
5573
6221
 
5574
6222
  // src/lib/writer.ts
5575
6223
  import {
5576
6224
  chmodSync as chmodSync2,
5577
- existsSync as existsSync6,
6225
+ existsSync as existsSync8,
5578
6226
  mkdirSync as mkdirSync4,
5579
- readFileSync as readFileSync5,
5580
- readdirSync,
5581
- rmdirSync,
5582
- rmSync as rmSync4,
5583
- writeFileSync as writeFileSync3
6227
+ readFileSync as readFileSync7,
6228
+ writeFileSync as writeFileSync4
5584
6229
  } from "node:fs";
5585
- import { dirname as dirname3, join as join6 } from "node:path";
6230
+ import { dirname as dirname4, join as join8 } from "node:path";
5586
6231
  var CLI_VERSION = package_default.version;
5587
6232
  var DEFAULT_COURSE = "10xdevs3";
5588
6233
  async function applyBundle(bundle, projectRoot, options = {}) {
@@ -5590,221 +6235,208 @@ async function applyBundle(bundle, projectRoot, options = {}) {
5590
6235
  const partial = options.partial === true;
5591
6236
  const course = options.course ?? DEFAULT_COURSE;
5592
6237
  const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
5593
- const onConflict = options.onConflict;
5594
6238
  const applyCourseRules = options.applyCourseRules !== false;
5595
- const manifestDir = join6(projectRoot, profile.manifestDir);
5596
- const prevManifest = readManifest(manifestDir);
5597
- const plan = planBundle(bundle, projectRoot, { profile, applyCourseRules });
5598
- const nextSkillHashes = {};
5599
- const nextPromptHashes = {};
5600
- const skills = [];
6239
+ const plan = planBundle(bundle, projectRoot, { course, profile, applyCourseRules });
6240
+ const manifestDir = join8(projectRoot, profile.manifestDir);
6241
+ const previous = readManifest(manifestDir);
6242
+ const ledger = previous ? structuredClone(previous) : {
6243
+ package: CLI_PACKAGE_NAME,
6244
+ version: CLI_VERSION,
6245
+ manifestVersion: MANIFEST_VERSION,
6246
+ lastApplied: new Date().toISOString(),
6247
+ lessonId: bundle.lessonId,
6248
+ course,
6249
+ tool: profile.toolId,
6250
+ files: { skills: {}, prompts: [], configs: [] },
6251
+ lessons: {}
6252
+ };
6253
+ ledger.lessons = previous ? seedLessons(previous) : {};
6254
+ ledger.manifestVersion = MANIFEST_VERSION;
6255
+ ledger.course = course;
6256
+ ledger.tool = profile.toolId;
6257
+ const lesson = ledger.lessons[bundle.lessonId] ?? { appliedAt: new Date().toISOString(), skills: {}, prompts: [], configs: [] };
6258
+ ledger.lessons[bundle.lessonId] = lesson;
6259
+ const oldRepresentation = lesson.representation;
6260
+ const oldDigest = oldRepresentation?.lang === (options.lang ?? "en") && oldRepresentation.tool === profile.toolId ? lesson.catalogContentHash : undefined;
6261
+ delete lesson.representation;
6262
+ delete lesson.catalogContentHash;
6263
+ delete lesson.installedReleaseId;
6264
+ delete lesson.installedManifestHash;
6265
+ const invalidateOtherOwners = (kind, name, file) => {
6266
+ for (const [id, owner] of Object.entries(ledger.lessons)) {
6267
+ if (id === bundle.lessonId)
6268
+ continue;
6269
+ if (kind === "rules" || (kind === "skills" ? owner.skills[name]?.files.includes(file) : owner.prompts.includes(name))) {
6270
+ const representation = owner.representation;
6271
+ if (!partial && isEarlierLesson(id, bundle.lessonId) && representation?.lang === (options.lang ?? "en") && representation.tool === profile.toolId && representation.courseRules === applyCourseRules)
6272
+ continue;
6273
+ delete owner.representation;
6274
+ delete owner.catalogContentHash;
6275
+ }
6276
+ }
6277
+ };
6278
+ const persist = () => {
6279
+ if (!dryRun) {
6280
+ rebuildManifestFiles(ledger);
6281
+ writeManifest(manifestDir, ledger);
6282
+ }
6283
+ };
6284
+ if (!dryRun) {
6285
+ assertProjectCourse(projectRoot, course);
6286
+ establishProjectCourse(projectRoot, course);
6287
+ }
6288
+ const result = { skills: [], prompts: [], configs: [], rules: { action: "unchanged" }, removals: { skills: [], prompts: [], configs: [] } };
6289
+ const deliver = async (file, content, info) => {
6290
+ let action = file.action;
6291
+ let userBackupPath;
6292
+ if (file.isConflict) {
6293
+ const resolution = dryRun ? "skip" : await options.onConflict?.(info) ?? "skip";
6294
+ if (resolution === "skip")
6295
+ return { action: "conflict_skipped", delivered: false };
6296
+ action = resolution === "save_user" ? "conflict_saved_user" : "conflict_overwritten";
6297
+ if (resolution === "save_user") {
6298
+ userBackupPath = buildUserBackupPath(file.path);
6299
+ assertProjectFilePath(projectRoot, userBackupPath);
6300
+ assertProjectFilePath(projectRoot, file.path);
6301
+ copyFileSync(file.path, userBackupPath);
6302
+ }
6303
+ }
6304
+ if (!dryRun && action !== "unchanged") {
6305
+ assertProjectFilePath(projectRoot, file.path);
6306
+ writeFileAt(file.path, content);
6307
+ }
6308
+ return { action, userBackupPath, delivered: true };
6309
+ };
5601
6310
  for (let si = 0;si < bundle.skills.length; si++) {
5602
6311
  const skill = bundle.skills[si];
5603
- const planSkill = plan.skills[si];
5604
- const prevEntry = prevManifest?.files.skills[skill.name];
5605
- const fileWrites = [];
5606
- const skillHashes = {};
6312
+ const writes = [];
6313
+ result.skills.push({ name: skill.name, files: writes });
5607
6314
  for (let fi = 0;fi < skill.files.length; fi++) {
5608
6315
  const file = skill.files[fi];
5609
- const planFile = planSkill.files[fi];
5610
- const target = planFile.path;
5611
- const storedHash = prevEntry?.contentHashes?.[file.path];
5612
- const { action, isConflict } = planFile;
5613
- let finalAction = action;
5614
- let userBackupPath;
5615
- if (isConflict) {
5616
- const resolution = onConflict ? await onConflict({
5617
- artifactType: "skill",
5618
- artifactName: `${skill.name}/${file.path}`,
5619
- filePath: target,
5620
- relativePath: file.path
5621
- }) : "skip";
5622
- if (resolution === "overwrite") {
5623
- finalAction = "conflict_overwritten";
5624
- if (!dryRun)
5625
- writeFileAt(target, file.content);
5626
- skillHashes[file.path] = contentHash(file.content);
5627
- } else if (resolution === "save_user") {
5628
- finalAction = "conflict_saved_user";
5629
- if (!dryRun) {
5630
- userBackupPath = buildUserBackupPath(target);
5631
- copyFileSync(target, userBackupPath);
5632
- writeFileAt(target, file.content);
5633
- }
5634
- skillHashes[file.path] = contentHash(file.content);
5635
- } else {
5636
- finalAction = "conflict_skipped";
5637
- if (storedHash)
5638
- skillHashes[file.path] = storedHash;
5639
- }
5640
- } else {
5641
- if (!dryRun && action !== "unchanged") {
5642
- writeFileAt(target, file.content);
6316
+ const planned = plan.skills[si].files[fi];
6317
+ const outcome = await deliver(planned, file.content, { artifactType: "skill", artifactName: `${skill.name}/${file.path}`, filePath: planned.path, relativePath: file.path });
6318
+ writes.push({ path: file.path, absolutePath: planned.path, action: outcome.action, userBackupPath: outcome.userBackupPath });
6319
+ if (outcome.delivered) {
6320
+ if (!dryRun && file.executable && outcome.action !== "unchanged") {
6321
+ assertProjectFilePath(projectRoot, planned.path);
6322
+ chmodSync2(planned.path, 493);
5643
6323
  }
5644
- if (!dryRun && file.executable === true && action !== "unchanged") {
5645
- chmodSync2(target, 493);
5646
- }
5647
- skillHashes[file.path] = contentHash(file.content);
6324
+ const entry = lesson.skills[skill.name] ??= { files: [] };
6325
+ if (!entry.files.includes(file.path))
6326
+ entry.files.push(file.path);
6327
+ const global2 = ledger.files.skills[skill.name] ??= { files: [] };
6328
+ if (global2.contentHashes?.[file.path] !== contentHash(file.content))
6329
+ invalidateOtherOwners("skills", skill.name, file.path);
6330
+ (global2.contentHashes ??= {})[file.path] = contentHash(file.content);
6331
+ persist();
5648
6332
  }
5649
- fileWrites.push({ path: file.path, absolutePath: target, action: finalAction, userBackupPath });
5650
6333
  }
5651
- nextSkillHashes[skill.name] = skillHashes;
5652
- skills.push({ name: skill.name, files: fileWrites });
5653
6334
  }
5654
- const prompts = [];
5655
6335
  for (let pi = 0;pi < bundle.prompts.length; pi++) {
5656
6336
  const prompt = bundle.prompts[pi];
5657
- const planPrompt = plan.prompts[pi];
5658
- const target = planPrompt.path;
5659
- const promptFilename = `${prompt.name}.md`;
5660
- const storedHash = prevManifest?.files.promptHashes?.[promptFilename];
5661
- const { action, isConflict } = planPrompt;
5662
- let finalAction = action;
5663
- let userBackupPath;
5664
- if (isConflict) {
5665
- const resolution = onConflict ? await onConflict({
5666
- artifactType: "prompt",
5667
- artifactName: prompt.name,
5668
- filePath: target,
5669
- relativePath: promptFilename
5670
- }) : "skip";
5671
- if (resolution === "overwrite") {
5672
- finalAction = "conflict_overwritten";
5673
- if (!dryRun)
5674
- writeFileAt(target, prompt.content);
5675
- nextPromptHashes[promptFilename] = contentHash(prompt.content);
5676
- } else if (resolution === "save_user") {
5677
- finalAction = "conflict_saved_user";
5678
- if (!dryRun) {
5679
- userBackupPath = buildUserBackupPath(target);
5680
- copyFileSync(target, userBackupPath);
5681
- writeFileAt(target, prompt.content);
5682
- }
5683
- nextPromptHashes[promptFilename] = contentHash(prompt.content);
5684
- } else {
5685
- finalAction = "conflict_skipped";
5686
- if (storedHash)
5687
- nextPromptHashes[promptFilename] = storedHash;
5688
- }
5689
- } else {
5690
- if (!dryRun && action !== "unchanged") {
5691
- writeFileAt(target, prompt.content);
6337
+ const file = plan.prompts[pi];
6338
+ const name = `${prompt.name}.md`;
6339
+ const outcome = await deliver(file, prompt.content, { artifactType: "prompt", artifactName: prompt.name, filePath: file.path, relativePath: name });
6340
+ result.prompts.push({ name: prompt.name, path: file.path, action: outcome.action, userBackupPath: outcome.userBackupPath });
6341
+ if (outcome.delivered) {
6342
+ if (!lesson.prompts.includes(name))
6343
+ lesson.prompts.push(name);
6344
+ if (ledger.files.promptHashes?.[name] !== contentHash(prompt.content))
6345
+ invalidateOtherOwners("prompts", name);
6346
+ (ledger.files.promptHashes ??= {})[name] = contentHash(prompt.content);
6347
+ persist();
6348
+ }
6349
+ }
6350
+ const rules = planManagedRules(projectRoot, profile, bundle.rules.length ? bundle.rules.map((r2) => r2.content.trim()).join(`
6351
+
6352
+ `) : undefined, applyCourseRules, previous?.managedRules);
6353
+ let rulesAction = rules.action;
6354
+ let rulesDelivered = !rules.isConflict;
6355
+ let rulesBackup;
6356
+ if (rules.isConflict && !rules.blocked && !dryRun) {
6357
+ const resolution = await options.onConflict?.({ artifactType: "rules", artifactName: "course-rules", filePath: join8(projectRoot, profile.rulesFile), relativePath: profile.rulesFile }) ?? "skip";
6358
+ if (resolution !== "skip") {
6359
+ rulesDelivered = true;
6360
+ rulesAction = resolution === "save_user" ? "conflict_saved_user" : "conflict_overwritten";
6361
+ if (resolution === "save_user") {
6362
+ rulesBackup = buildUserBackupPath(join8(projectRoot, profile.rulesFile));
6363
+ assertProjectFilePath(projectRoot, rulesBackup);
6364
+ assertProjectFilePath(projectRoot, join8(projectRoot, profile.rulesFile));
6365
+ copyFileSync(join8(projectRoot, profile.rulesFile), rulesBackup);
5692
6366
  }
5693
- nextPromptHashes[promptFilename] = contentHash(prompt.content);
5694
6367
  }
5695
- prompts.push({ name: prompt.name, path: target, action: finalAction, userBackupPath });
5696
- }
5697
- const rulesFilePath = join6(projectRoot, profile.rulesFile);
5698
- const existingRules = readFileOrEmpty(rulesFilePath);
5699
- const { action: rulesAction, content: newRulesContent } = planRules(existingRules, bundle, profile, applyCourseRules);
5700
- if (!dryRun && rulesAction !== "unchanged") {
5701
- writeFileAt(rulesFilePath, newRulesContent);
5702
6368
  }
5703
- const configs = bundle.configs.map((config, ci) => {
5704
- const planConfig = plan.configs[ci];
5705
- if (!dryRun && planConfig.action === "created") {
5706
- writeFileAt(planConfig.path, config.content);
5707
- }
5708
- return { name: config.name, path: planConfig.path, action: planConfig.action };
5709
- });
5710
- const removed = computeRemovals(prevManifest, bundle, profile, projectRoot);
5711
- const removalResult = {
5712
- skills: [],
5713
- prompts: [],
5714
- configs: []
5715
- };
5716
- for (const entry of removed.skillDirs) {
5717
- removalResult.skills.push({ name: entry.name, path: entry.path, action: "removed" });
5718
- if (!dryRun && !partial)
5719
- rmSync4(entry.path, { recursive: true, force: true });
5720
- }
5721
- for (const entry of removed.skillFiles) {
5722
- removalResult.skills.push({ name: entry.name, path: entry.path, action: "removed" });
5723
- if (!dryRun && !partial) {
5724
- rmSync4(entry.path, { force: true });
5725
- removeEmptyParentDirs(entry.path, entry.skillDirAbs);
5726
- }
5727
- }
5728
- for (const entry of removed.prompts) {
5729
- removalResult.prompts.push({ name: entry.name, path: entry.path, action: "removed" });
5730
- if (!dryRun && !partial)
5731
- rmSync4(entry.path, { force: true });
5732
- }
5733
- for (const entry of removed.configs) {
5734
- removalResult.configs.push({ name: entry.name, path: entry.path, action: "removed" });
5735
- if (!dryRun && !partial)
5736
- rmSync4(entry.path, { force: true });
5737
- }
5738
- if (!dryRun && !partial) {
5739
- const catalogContentHash = options.catalogContentHash ?? prevManifest?.lessons?.[bundle.lessonId]?.catalogContentHash;
5740
- const newLessonEntry = {
5741
- appliedAt: new Date().toISOString(),
5742
- skills: Object.fromEntries(bundle.skills.map((s) => [s.name, { files: s.files.map((f2) => f2.path) }])),
5743
- prompts: bundle.prompts.map((p2) => `${p2.name}.md`),
5744
- configs: bundle.configs.map((c2) => c2.name),
5745
- ...catalogContentHash !== undefined ? { catalogContentHash } : {}
5746
- };
5747
- let baseLessons = {};
5748
- if (prevManifest && !prevManifest.lessons) {
5749
- baseLessons[prevManifest.lessonId] = {
5750
- appliedAt: prevManifest.lastApplied,
5751
- skills: Object.fromEntries(Object.entries(prevManifest.files.skills).map(([name, entry]) => [
5752
- name,
5753
- { files: [...entry.files] }
5754
- ])),
5755
- prompts: [...prevManifest.files.prompts],
5756
- configs: [...prevManifest.files.configs]
5757
- };
5758
- } else if (prevManifest?.lessons) {
5759
- baseLessons = { ...prevManifest.lessons };
5760
- }
5761
- const lessons = {
5762
- ...baseLessons,
5763
- [bundle.lessonId]: newLessonEntry
5764
- };
5765
- const union = buildUnionFiles(lessons);
5766
- const unionSkills = {};
5767
- for (const [name, skill] of Object.entries(union.skills)) {
5768
- const prevHash = prevManifest?.files.skills[name]?.contentHashes;
5769
- const currentHash = nextSkillHashes[name];
5770
- unionSkills[name] = {
5771
- files: skill.files,
5772
- contentHashes: { ...prevHash, ...currentHash }
5773
- };
6369
+ if (rulesDelivered) {
6370
+ if (!dryRun && rulesAction !== "unchanged") {
6371
+ assertProjectFilePath(projectRoot, join8(projectRoot, profile.rulesFile));
6372
+ const path = join8(projectRoot, profile.rulesFile);
6373
+ if (!existsSync8(path) || readFileSync7(path, "utf8") !== rules.content)
6374
+ writeFileAt(path, rules.content);
6375
+ }
6376
+ if (ledger.managedRules?.upstreamHash !== rules.next?.upstreamHash)
6377
+ invalidateOtherOwners("rules");
6378
+ ledger.managedRules = rules.next;
6379
+ persist();
6380
+ }
6381
+ result.rules = { action: rulesAction, reason: rules.reason, userBackupPath: rulesBackup };
6382
+ for (let ci = 0;ci < bundle.configs.length; ci++) {
6383
+ const config = bundle.configs[ci];
6384
+ const planned = plan.configs[ci];
6385
+ if (planned.action === "created") {
6386
+ if (!dryRun) {
6387
+ assertProjectFilePath(projectRoot, planned.path);
6388
+ writeFileAt(planned.path, config.content);
6389
+ }
6390
+ if (!lesson.configs.includes(config.name))
6391
+ lesson.configs.push(config.name);
6392
+ (ledger.files.configHashes ??= {})[config.name] = contentHash(config.content);
6393
+ persist();
6394
+ }
6395
+ if (planned.action === "skipped" && previous?.files.configs.includes(config.name) && !lesson.configs.includes(config.name)) {
6396
+ lesson.configs.push(config.name);
6397
+ persist();
6398
+ }
6399
+ result.configs.push({ name: config.name, path: planned.path, action: planned.action });
6400
+ }
6401
+ if (!partial) {
6402
+ for (const kind of ["skills", "prompts", "configs"]) {
6403
+ for (const candidate of plan.removals[kind]) {
6404
+ const removed = dryRun ? candidate : executeManagedRemoval(projectRoot, candidate);
6405
+ result.removals[kind].push({ name: removed.name, path: removed.path, action: removed.action, reason: removed.reason });
6406
+ if (kind === "skills") {
6407
+ const slash = candidate.name.indexOf("/");
6408
+ const name = candidate.name.slice(0, slash);
6409
+ const file = candidate.name.slice(slash + 1);
6410
+ const entry = lesson.skills[name];
6411
+ if (entry) {
6412
+ entry.files = entry.files.filter((value) => value !== file);
6413
+ if (!entry.files.length)
6414
+ delete lesson.skills[name];
6415
+ }
6416
+ } else
6417
+ lesson[kind] = lesson[kind].filter((value) => value !== candidate.name);
6418
+ persist();
6419
+ }
5774
6420
  }
5775
- const unionPromptHashes = {
5776
- ...prevManifest?.files.promptHashes,
5777
- ...nextPromptHashes
5778
- };
5779
- const nextManifest = {
5780
- package: CLI_PACKAGE_NAME,
5781
- version: CLI_VERSION,
5782
- manifestVersion: MANIFEST_VERSION,
5783
- lastApplied: new Date().toISOString(),
5784
- lessonId: bundle.lessonId,
5785
- course,
5786
- tool: profile.toolId,
5787
- files: {
5788
- skills: unionSkills,
5789
- prompts: union.prompts,
5790
- configs: union.configs,
5791
- promptHashes: unionPromptHashes
5792
- },
5793
- lessons
5794
- };
5795
- writeManifest(manifestDir, nextManifest);
5796
6421
  }
5797
- return {
5798
- skills,
5799
- prompts,
5800
- rules: { action: rulesAction },
5801
- configs,
5802
- removals: removalResult
5803
- };
6422
+ ledger.lessonId = bundle.lessonId;
6423
+ ledger.lastApplied = lesson.appliedAt = new Date().toISOString();
6424
+ const conflicts = result.skills.some((s) => s.files.some((f2) => f2.action === "conflict_skipped")) || result.prompts.some((p2) => p2.action === "conflict_skipped") || rulesAction === "conflict_skipped";
6425
+ if (!partial && !conflicts) {
6426
+ lesson.catalogContentHash = options.catalogContentHash ?? oldDigest;
6427
+ lesson.representation = { lang: options.lang ?? "en", tool: profile.toolId, courseRules: applyCourseRules, rules: applyCourseRules && bundle.rules.length > 0 };
6428
+ lesson.installedReleaseId = bundle.releaseId;
6429
+ lesson.installedManifestHash = bundle.releaseManifestHash;
6430
+ }
6431
+ persist();
6432
+ return result;
5804
6433
  }
5805
6434
  function planBundle(bundle, projectRoot, options = {}) {
5806
6435
  const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
5807
6436
  const applyCourseRules = options.applyCourseRules !== false;
6437
+ validateBundlePayload(bundle);
6438
+ if (options.course && bundle.course !== undefined && bundle.course !== options.course)
6439
+ throw new Error("Bundle course differs from selected project course");
5808
6440
  for (const skill of bundle.skills) {
5809
6441
  assertSafeName(skill.name, "skill");
5810
6442
  for (const file of skill.files)
@@ -5814,13 +6446,15 @@ function planBundle(bundle, projectRoot, options = {}) {
5814
6446
  assertSafeName(prompt.name, "prompt");
5815
6447
  for (const config of bundle.configs)
5816
6448
  assertSafeName(config.name, "config");
5817
- const manifestDir = join6(projectRoot, profile.manifestDir);
6449
+ const manifestDir = join8(projectRoot, profile.manifestDir);
6450
+ assertProjectFilePath(projectRoot, join8(manifestDir, MANIFEST_FILENAME));
5818
6451
  const prevManifest = readManifest(manifestDir);
5819
6452
  const skills = bundle.skills.map((skill) => {
5820
- const skillDir = join6(projectRoot, profile.skillDir(skill.name));
6453
+ const skillDir = join8(projectRoot, profile.skillDir(skill.name));
5821
6454
  const prevEntry = prevManifest?.files.skills[skill.name];
5822
6455
  const files = skill.files.map((file) => {
5823
- const target = join6(skillDir, file.path);
6456
+ const target = join8(skillDir, file.path);
6457
+ assertProjectFilePath(projectRoot, target);
5824
6458
  const storedHash = prevEntry?.contentHashes?.[file.path];
5825
6459
  const { action, isConflict } = computeFileAction(target, file.content, storedHash);
5826
6460
  return {
@@ -5834,7 +6468,8 @@ function planBundle(bundle, projectRoot, options = {}) {
5834
6468
  return { name: skill.name, files };
5835
6469
  });
5836
6470
  const prompts = bundle.prompts.map((prompt) => {
5837
- const target = join6(projectRoot, profile.promptPath(prompt.name));
6471
+ const target = join8(projectRoot, profile.promptPath(prompt.name));
6472
+ assertProjectFilePath(projectRoot, target);
5838
6473
  const storedHash = prevManifest?.files.promptHashes?.[`${prompt.name}.md`];
5839
6474
  const { action, isConflict } = computeFileAction(target, prompt.content, storedHash);
5840
6475
  return {
@@ -5845,12 +6480,14 @@ function planBundle(bundle, projectRoot, options = {}) {
5845
6480
  upstreamChanged: computeUpstreamChanged(prompt.content, storedHash, action)
5846
6481
  };
5847
6482
  });
5848
- const rulesFilePath = join6(projectRoot, profile.rulesFile);
5849
- const existingRules = readFileOrEmpty(rulesFilePath);
5850
- const { action: rulesAction } = planRules(existingRules, bundle, profile, applyCourseRules);
6483
+ const rulesFilePath = join8(projectRoot, profile.rulesFile);
6484
+ const rulesPlan = planManagedRules(projectRoot, profile, bundle.rules.length ? bundle.rules.map((r2) => r2.content.trim()).join(`
6485
+
6486
+ `) : undefined, applyCourseRules, prevManifest?.managedRules);
5851
6487
  const configs = bundle.configs.map((config) => {
5852
- const target = join6(projectRoot, profile.configPath(config.name));
5853
- const action = existsSync6(target) ? "skipped" : "created";
6488
+ const target = join8(projectRoot, profile.configPath(config.name));
6489
+ assertProjectFilePath(projectRoot, target);
6490
+ const action = existsSync8(target) ? "skipped" : "created";
5854
6491
  return {
5855
6492
  name: config.name,
5856
6493
  path: target,
@@ -5860,58 +6497,56 @@ function planBundle(bundle, projectRoot, options = {}) {
5860
6497
  };
5861
6498
  });
5862
6499
  const removed = computeRemovals(prevManifest, bundle, profile, projectRoot);
5863
- const removals = {
5864
- skills: [
5865
- ...removed.skillDirs.map((e2) => ({ name: e2.name, path: e2.path })),
5866
- ...removed.skillFiles.map((e2) => ({ name: e2.name, path: e2.path }))
5867
- ],
5868
- prompts: removed.prompts.map((e2) => ({ name: e2.name, path: e2.path })),
5869
- configs: removed.configs.map((e2) => ({ name: e2.name, path: e2.path }))
5870
- };
6500
+ const targets = [
6501
+ join8(manifestDir, MANIFEST_FILENAME),
6502
+ join8(manifestDir, `${MANIFEST_FILENAME}.tmp`),
6503
+ rulesFilePath,
6504
+ buildUserBackupPath(rulesFilePath),
6505
+ ...skills.flatMap((skill) => skill.files.flatMap((file) => [file.path, buildUserBackupPath(file.path)])),
6506
+ ...prompts.flatMap((prompt) => [prompt.path, buildUserBackupPath(prompt.path)]),
6507
+ ...configs.map((config) => config.path)
6508
+ ];
6509
+ for (const target of targets)
6510
+ assertProjectFilePath(projectRoot, target);
6511
+ const removals = removed;
5871
6512
  return {
5872
6513
  skills,
5873
6514
  prompts,
5874
- rules: { action: rulesAction, upstreamChanged: rulesAction !== "unchanged" },
6515
+ rules: { action: rulesPlan.action, isConflict: rulesPlan.isConflict, reason: rulesPlan.reason, upstreamChanged: rulesPlan.action !== "unchanged" },
5875
6516
  configs,
5876
6517
  removals
5877
6518
  };
5878
6519
  }
6520
+ function isEarlierLesson(left, right) {
6521
+ const a2 = /^m(\d+)l(\d+)$/.exec(left);
6522
+ const b2 = /^m(\d+)l(\d+)$/.exec(right);
6523
+ if (!a2 || !b2)
6524
+ return false;
6525
+ return Number(a2[1]) < Number(b2[1]) || Number(a2[1]) === Number(b2[1]) && Number(a2[2]) < Number(b2[2]);
6526
+ }
6527
+ function validateBundlePayload(bundle) {
6528
+ if (!bundle || typeof bundle.lessonId !== "string" || !Array.isArray(bundle.skills) || !Array.isArray(bundle.prompts) || !Array.isArray(bundle.rules) || !Array.isArray(bundle.configs))
6529
+ throw new Error("Invalid lesson bundle payload");
6530
+ for (const skill of bundle.skills) {
6531
+ if (!skill || typeof skill.name !== "string" || !Array.isArray(skill.files))
6532
+ throw new Error("Invalid skill payload");
6533
+ for (const file of skill.files)
6534
+ if (!file || typeof file.path !== "string" || typeof file.content !== "string" || file.executable !== undefined && typeof file.executable !== "boolean")
6535
+ throw new Error("Invalid skill file payload");
6536
+ }
6537
+ for (const artifact of [...bundle.prompts, ...bundle.rules, ...bundle.configs])
6538
+ if (!artifact || typeof artifact.name !== "string" || typeof artifact.content !== "string")
6539
+ throw new Error("Invalid artifact payload");
6540
+ }
5879
6541
  function computeUpstreamChanged(newContent, storedHash, action) {
5880
6542
  if (storedHash === undefined)
5881
6543
  return action !== "unchanged";
5882
6544
  return contentHash(newContent) !== storedHash;
5883
6545
  }
5884
- function planRules(existingRules, bundle, profile, applyCourseRules) {
5885
- if (!applyCourseRules) {
5886
- const { content: stripped, removed } = removeRulesBlockWithMarkers(existingRules, profile.sentinelBegin, profile.sentinelEnd);
5887
- if (removed && stripped !== existingRules)
5888
- return { action: "removed", content: stripped };
5889
- return { action: "unchanged", content: existingRules };
5890
- }
5891
- if (bundle.rules.length === 0)
5892
- return { action: "unchanged", content: existingRules };
5893
- const rulesBody = bundle.rules.map((r2) => r2.content.trim()).join(`
5894
-
5895
- `);
5896
- const { content: newRules } = applyRulesBlockWithMarkers(existingRules, rulesBody, profile.sentinelBegin, profile.sentinelEnd);
5897
- let action;
5898
- if (newRules === existingRules)
5899
- action = "unchanged";
5900
- else if (existingRules.length === 0)
5901
- action = "created";
5902
- else
5903
- action = "updated";
5904
- return { action, content: newRules };
5905
- }
5906
6546
  function computeFileAction(filePath, newContent, storedHash) {
5907
- if (!existsSync6(filePath))
6547
+ if (!existsSync8(filePath))
5908
6548
  return { action: "created", isConflict: false };
5909
- let current;
5910
- try {
5911
- current = readFileSync5(filePath, "utf8");
5912
- } catch {
5913
- return { action: "updated", isConflict: false };
5914
- }
6549
+ const current = readFileSync7(filePath, "utf8");
5915
6550
  if (current === newContent)
5916
6551
  return { action: "unchanged", isConflict: false };
5917
6552
  if (storedHash !== undefined) {
@@ -5924,152 +6559,62 @@ function computeFileAction(filePath, newContent, storedHash) {
5924
6559
  }
5925
6560
  function buildUserBackupPath(filePath) {
5926
6561
  const lastDot = filePath.lastIndexOf(".");
5927
- if (lastDot === -1)
5928
- return `${filePath}.user`;
5929
- return `${filePath.slice(0, lastDot)}.user${filePath.slice(lastDot)}`;
6562
+ const stem = lastDot === -1 ? filePath : filePath.slice(0, lastDot);
6563
+ const extension = lastDot === -1 ? "" : filePath.slice(lastDot);
6564
+ let candidate = `${stem}.user${extension}`;
6565
+ let suffix = 1;
6566
+ while (existsSync8(candidate))
6567
+ candidate = `${stem}.user.${suffix++}${extension}`;
6568
+ return candidate;
5930
6569
  }
5931
6570
  function copyFileSync(src, dest) {
5932
- mkdirSync4(dirname3(dest), { recursive: true });
5933
- writeFileSync3(dest, readFileSync5(src));
5934
- }
5935
- function readFileOrEmpty(filePath) {
5936
- if (!existsSync6(filePath))
5937
- return "";
5938
- try {
5939
- return readFileSync5(filePath, "utf8");
5940
- } catch {
5941
- return "";
5942
- }
6571
+ mkdirSync4(dirname4(dest), { recursive: true });
6572
+ writeFileSync4(dest, readFileSync7(src));
5943
6573
  }
5944
6574
  function writeFileAt(filePath, content) {
5945
- mkdirSync4(dirname3(filePath), { recursive: true });
5946
- writeFileSync3(filePath, content);
5947
- }
5948
- function computeRemovals(prevManifest, bundle, profile, projectRoot) {
5949
- const empty = {
5950
- skillDirs: [],
5951
- skillFiles: [],
5952
- prompts: [],
5953
- configs: []
5954
- };
5955
- if (!prevManifest?.lessons)
5956
- return empty;
5957
- const prevLessonEntry = prevManifest.lessons[bundle.lessonId];
5958
- if (!prevLessonEntry)
5959
- return empty;
5960
- const protectedSkills = new Map;
5961
- const protectedPrompts = new Set;
5962
- const protectedConfigs = new Set;
5963
- for (const [lessonId, entry] of Object.entries(prevManifest.lessons)) {
5964
- if (lessonId === bundle.lessonId)
5965
- continue;
5966
- for (const [name, skill] of Object.entries(entry.skills)) {
5967
- if (!protectedSkills.has(name))
5968
- protectedSkills.set(name, new Set);
5969
- for (const f2 of skill.files)
5970
- protectedSkills.get(name).add(f2);
5971
- }
5972
- for (const p2 of entry.prompts)
5973
- protectedPrompts.add(p2);
5974
- for (const c2 of entry.configs)
5975
- protectedConfigs.add(c2);
5976
- }
5977
- const currentSkills = new Map(bundle.skills.map((s) => [s.name, new Set(s.files.map((f2) => f2.path))]));
5978
- const currentPrompts = new Set(bundle.prompts.map((p2) => `${p2.name}.md`));
5979
- const currentConfigs = new Set(bundle.configs.map((c2) => c2.name));
5980
- const removed = {
5981
- skillDirs: [],
5982
- skillFiles: [],
5983
- prompts: [],
5984
- configs: []
5985
- };
5986
- for (const [skillName, skill] of Object.entries(prevLessonEntry.skills)) {
5987
- if (!isSafeName(skillName))
5988
- continue;
5989
- const skillDirAbs = join6(projectRoot, profile.skillDir(skillName));
5990
- if (!currentSkills.has(skillName)) {
5991
- if (protectedSkills.has(skillName)) {
5992
- const prot = protectedSkills.get(skillName);
5993
- for (const relPath of skill.files) {
5994
- if (prot.has(relPath))
5995
- continue;
5996
- if (!isSafeSkillFilePath(relPath))
5997
- continue;
5998
- removed.skillFiles.push({
5999
- name: `${skillName}/${relPath}`,
6000
- path: join6(skillDirAbs, relPath),
6001
- skillDirAbs
6002
- });
6003
- }
6004
- } else {
6005
- removed.skillDirs.push({ name: skillName, path: skillDirAbs });
6006
- }
6007
- continue;
6008
- }
6009
- const currentFiles = currentSkills.get(skillName);
6010
- for (const relPath of skill.files) {
6011
- if (currentFiles.has(relPath))
6012
- continue;
6013
- if (protectedSkills.get(skillName)?.has(relPath))
6014
- continue;
6015
- if (!isSafeSkillFilePath(relPath))
6575
+ mkdirSync4(dirname4(filePath), { recursive: true });
6576
+ writeFileSync4(filePath, content);
6577
+ }
6578
+ function computeRemovals(previous, bundle, profile, root) {
6579
+ const out = { skills: [], prompts: [], configs: [] };
6580
+ if (!previous)
6581
+ return out;
6582
+ const lessons = seedLessons(previous);
6583
+ const before = lessons[bundle.lessonId];
6584
+ if (!before)
6585
+ return out;
6586
+ const others = Object.entries(lessons).filter(([id]) => id !== bundle.lessonId).map(([, entry]) => entry);
6587
+ for (const [name, skill] of Object.entries(before.skills)) {
6588
+ if (!isSafeName(name))
6589
+ throw new Error("Unsafe skill name in manifest");
6590
+ for (const file of skill.files) {
6591
+ if (!isSafeSkillFilePath(file))
6592
+ throw new Error("Unsafe skill path in manifest");
6593
+ if (bundle.skills.some((entry) => entry.name === name && entry.files.some((entry2) => entry2.path === file)))
6016
6594
  continue;
6017
- removed.skillFiles.push({
6018
- name: `${skillName}/${relPath}`,
6019
- path: join6(skillDirAbs, relPath),
6020
- skillDirAbs
6021
- });
6595
+ out.skills.push(planManagedRemoval(root, { name: `${name}/${file}`, path: join8(root, profile.skillDir(name), file), storedHash: previous.files.skills[name]?.contentHashes?.[file], protected: others.some((entry) => entry.skills[name]?.files.includes(file)), pruneRoot: join8(root, profile.skillDir(name)) }));
6022
6596
  }
6023
6597
  }
6024
- for (const promptFile of prevLessonEntry.prompts) {
6025
- if (currentPrompts.has(promptFile))
6026
- continue;
6027
- if (protectedPrompts.has(promptFile))
6028
- continue;
6029
- if (!isSafeName(promptFile))
6030
- continue;
6031
- const promptName = promptFile.replace(/\.md$/, "");
6032
- removed.prompts.push({
6033
- name: promptFile,
6034
- path: join6(projectRoot, profile.promptPath(promptName))
6035
- });
6036
- }
6037
- for (const configFile of prevLessonEntry.configs) {
6038
- if (currentConfigs.has(configFile))
6039
- continue;
6040
- if (protectedConfigs.has(configFile))
6041
- continue;
6042
- if (!isSafeName(configFile))
6043
- continue;
6044
- removed.configs.push({
6045
- name: configFile,
6046
- path: join6(projectRoot, profile.configPath(configFile))
6047
- });
6048
- }
6049
- return removed;
6050
- }
6051
- function removeEmptyParentDirs(filePath, skillDirAbs) {
6052
- let dir = dirname3(filePath);
6053
- while (dir.startsWith(skillDirAbs) && dir !== skillDirAbs) {
6054
- try {
6055
- if (readdirSync(dir).length > 0)
6056
- return;
6057
- rmdirSync(dir);
6058
- } catch {
6059
- return;
6598
+ for (const kind of ["prompts", "configs"])
6599
+ for (const name of before[kind]) {
6600
+ if (!isSafeName(name))
6601
+ throw new Error("Unsafe file name in manifest");
6602
+ if ((kind === "prompts" ? bundle.prompts.map((p2) => `${p2.name}.md`) : bundle.configs.map((c2) => c2.name)).includes(name))
6603
+ continue;
6604
+ const path = join8(root, kind === "prompts" ? profile.promptPath(name.replace(/\.md$/, "")) : profile.configPath(name));
6605
+ out[kind].push(planManagedRemoval(root, { name, path, storedHash: kind === "prompts" ? previous.files.promptHashes?.[name] : previous.files.configHashes?.[name], protected: others.some((entry) => entry[kind].includes(name)), config: kind === "configs" }));
6060
6606
  }
6061
- dir = dirname3(dir);
6062
- }
6607
+ return out;
6063
6608
  }
6064
6609
  function findOrphanedManifests(projectRoot, currentProfile) {
6065
6610
  const out = [];
6066
6611
  for (const profile of [...Object.values(PROFILES), ...Object.values(LEGACY_PROFILES)]) {
6067
6612
  if (profile.toolId === currentProfile.toolId)
6068
6613
  continue;
6069
- const manifestPath = join6(projectRoot, profile.manifestDir, MANIFEST_FILENAME);
6070
- if (!existsSync6(manifestPath))
6614
+ const manifestPath = join8(projectRoot, profile.manifestDir, MANIFEST_FILENAME);
6615
+ if (!existsSync8(manifestPath))
6071
6616
  continue;
6072
- const manifest = readManifest(join6(projectRoot, profile.manifestDir));
6617
+ const manifest = readManifest(join8(projectRoot, profile.manifestDir));
6073
6618
  if (!manifest)
6074
6619
  continue;
6075
6620
  out.push({ profile, manifestPath, manifest });
@@ -6134,231 +6679,315 @@ function isSafeName(name) {
6134
6679
  }
6135
6680
 
6136
6681
  // src/lib/tool-switch.ts
6137
- function migrateArtifacts(projectRoot, orphan, newProfile) {
6138
- const summary = {
6139
- action: "migrated",
6140
- oldToolId: orphan.profile.toolId,
6141
- movedOrRemoved: { skills: [], prompts: [], configs: [] },
6142
- sentinelStripped: false,
6143
- skipped: []
6144
- };
6145
- const oldProfile = orphan.profile;
6146
- for (const [skillName, entry] of Object.entries(orphan.manifest.files.skills)) {
6147
- if (!isSafeName(skillName)) {
6148
- summary.skipped.push({ path: skillName, reason: "unsafe name in manifest" });
6682
+ function entries(manifest) {
6683
+ return [
6684
+ ...Object.entries(manifest.files.skills).flatMap(([name, entry]) => entry.files.map((file) => ({ kind: "skills", name, file, hash: entry.contentHashes?.[file] }))),
6685
+ ...manifest.files.prompts.map((name) => ({ kind: "prompts", name, hash: manifest.files.promptHashes?.[name] })),
6686
+ ...manifest.files.configs.map((name) => ({ kind: "configs", name, hash: manifest.files.configHashes?.[name] }))
6687
+ ];
6688
+ }
6689
+ function entryPath(root, profile, entry) {
6690
+ if (!isSafeName(entry.name) || entry.file !== undefined && !isSafeSkillFilePath(entry.file))
6691
+ throw new Error("unsafe name or path in manifest");
6692
+ return join9(root, entry.kind === "skills" ? join9(profile.skillDir(entry.name), entry.file) : entry.kind === "prompts" ? profile.promptPath(entry.name.replace(/\.md$/, "")) : profile.configPath(entry.name));
6693
+ }
6694
+ function owns(lesson, entry) {
6695
+ return entry.kind === "skills" ? !!lesson.skills[entry.name]?.files.includes(entry.file) : lesson[entry.kind].includes(entry.name);
6696
+ }
6697
+ function retire(manifest, entry) {
6698
+ for (const lesson of Object.values(manifest.lessons)) {
6699
+ if (!owns(lesson, entry))
6149
6700
  continue;
6150
- }
6151
- const fromSkillDir = join7(projectRoot, oldProfile.skillDir(skillName));
6152
- const toSkillDir = join7(projectRoot, newProfile.skillDir(skillName));
6153
- let anyMoved = false;
6154
- for (const relPath of entry.files) {
6155
- if (!isSafeSkillFilePath(relPath)) {
6156
- summary.skipped.push({
6157
- path: `${skillName}/${relPath}`,
6158
- reason: "unsafe path in manifest"
6159
- });
6160
- continue;
6161
- }
6162
- const from = join7(fromSkillDir, relPath);
6163
- const to = join7(toSkillDir, relPath);
6164
- if (moveIfSafe(from, to, summary.skipped))
6165
- anyMoved = true;
6166
- }
6167
- tryRemoveEmptyDir(fromSkillDir);
6168
- if (anyMoved)
6169
- summary.movedOrRemoved.skills.push(skillName);
6170
- }
6171
- for (const promptFile of orphan.manifest.files.prompts) {
6172
- if (!isSafeName(promptFile)) {
6173
- summary.skipped.push({ path: promptFile, reason: "unsafe name in manifest" });
6701
+ if (entry.kind === "skills") {
6702
+ const skill = lesson.skills[entry.name];
6703
+ skill.files = skill.files.filter((file) => file !== entry.file);
6704
+ if (!skill.files.length)
6705
+ delete lesson.skills[entry.name];
6706
+ } else
6707
+ lesson[entry.kind] = lesson[entry.kind].filter((name) => name !== entry.name);
6708
+ delete lesson.representation;
6709
+ delete lesson.catalogContentHash;
6710
+ }
6711
+ rebuildManifestFiles(manifest);
6712
+ }
6713
+ function acquire(destination, source, entry) {
6714
+ for (const [id, prior] of Object.entries(source.lessons)) {
6715
+ if (!owns(prior, entry))
6174
6716
  continue;
6175
- }
6176
- const promptName = promptFile.replace(/\.md$/, "");
6177
- const from = join7(projectRoot, oldProfile.promptPath(promptName));
6178
- const to = join7(projectRoot, newProfile.promptPath(promptName));
6179
- if (moveIfSafe(from, to, summary.skipped)) {
6180
- summary.movedOrRemoved.prompts.push(promptFile);
6181
- }
6717
+ const lesson = destination.lessons[id] ??= { appliedAt: prior.appliedAt, skills: {}, prompts: [], configs: [] };
6718
+ delete lesson.representation;
6719
+ delete lesson.catalogContentHash;
6720
+ if (entry.kind === "skills") {
6721
+ const skill = lesson.skills[entry.name] ??= { files: [] };
6722
+ if (!skill.files.includes(entry.file))
6723
+ skill.files.push(entry.file);
6724
+ } else if (!lesson[entry.kind].includes(entry.name))
6725
+ lesson[entry.kind].push(entry.name);
6726
+ }
6727
+ if (entry.kind === "skills") {
6728
+ const skill = destination.files.skills[entry.name] ??= { files: [] };
6729
+ if (entry.hash)
6730
+ (skill.contentHashes ??= {})[entry.file] = entry.hash;
6731
+ } else if (entry.hash)
6732
+ (entry.kind === "prompts" ? destination.files.promptHashes ??= {} : destination.files.configHashes ??= {})[entry.name] = entry.hash;
6733
+ rebuildManifestFiles(destination);
6734
+ }
6735
+ function summaryFor(orphan, action) {
6736
+ return { action, oldToolId: orphan.profile.toolId, movedOrRemoved: { skills: [], prompts: [], configs: [] }, sentinelStripped: false, skipped: [] };
6737
+ }
6738
+ function record(summary, entry) {
6739
+ if (!summary.movedOrRemoved[entry.kind].includes(entry.name))
6740
+ summary.movedOrRemoved[entry.kind].push(entry.name);
6741
+ }
6742
+ function persistSource(root, profile, source) {
6743
+ rebuildManifestFiles(source);
6744
+ const dir = join9(root, profile.manifestDir);
6745
+ if (entries(source).length || source.managedRules) {
6746
+ writeManifest(dir, source);
6747
+ return;
6182
6748
  }
6183
- for (const configFile of orphan.manifest.files.configs) {
6184
- if (!isSafeName(configFile)) {
6185
- summary.skipped.push({ path: configFile, reason: "unsafe name in manifest" });
6186
- continue;
6187
- }
6188
- const from = join7(projectRoot, oldProfile.configPath(configFile));
6189
- const to = join7(projectRoot, newProfile.configPath(configFile));
6190
- if (moveIfSafe(from, to, summary.skipped)) {
6191
- summary.movedOrRemoved.configs.push(configFile);
6749
+ const path = join9(dir, MANIFEST_FILENAME);
6750
+ assertProjectFilePath(root, path);
6751
+ rmSync6(path, { force: true });
6752
+ pruneEmptyParents(root, path, dir);
6753
+ }
6754
+ function commitSourceRules(root, profile, source, update) {
6755
+ const rulesPath = join9(root, profile.rulesFile);
6756
+ const manifestPath = join9(root, profile.manifestDir, MANIFEST_FILENAME);
6757
+ assertProjectFilePath(root, rulesPath);
6758
+ assertProjectFilePath(root, manifestPath);
6759
+ const rulesBefore = existsSync9(rulesPath) ? readFileSync8(rulesPath) : undefined;
6760
+ const manifestBefore = existsSync9(manifestPath) ? readFileSync8(manifestPath) : undefined;
6761
+ const metadataBefore = source.managedRules;
6762
+ try {
6763
+ update();
6764
+ persistSource(root, profile, source);
6765
+ } catch (error) {
6766
+ source.managedRules = metadataBefore;
6767
+ for (const [path, bytes] of [[rulesPath, rulesBefore], [manifestPath, manifestBefore]]) {
6768
+ if (bytes === undefined)
6769
+ continue;
6770
+ assertProjectFilePath(root, path);
6771
+ if (!existsSync9(path) || !readFileSync8(path).equals(bytes)) {
6772
+ mkdirSync5(dirname5(path), { recursive: true });
6773
+ writeFileSync5(path, bytes);
6774
+ }
6192
6775
  }
6776
+ throw error;
6193
6777
  }
6194
- summary.sentinelStripped = stripSentinelFromRulesFile(projectRoot, oldProfile);
6195
- rmSync5(orphan.manifestPath, { force: true });
6196
- tryRemoveEmptyDir(join7(projectRoot, oldProfile.manifestDir));
6197
- return summary;
6198
6778
  }
6199
- function deleteArtifacts(projectRoot, orphan) {
6200
- const summary = {
6201
- action: "deleted",
6202
- oldToolId: orphan.profile.toolId,
6203
- movedOrRemoved: { skills: [], prompts: [], configs: [] },
6204
- sentinelStripped: false,
6205
- skipped: []
6206
- };
6207
- const oldProfile = orphan.profile;
6208
- for (const [skillName, entry] of Object.entries(orphan.manifest.files.skills)) {
6209
- if (!isSafeName(skillName)) {
6210
- summary.skipped.push({ path: skillName, reason: "unsafe name in manifest" });
6779
+ function copyForTransfer(root, from, to, bytes) {
6780
+ assertProjectFilePath(root, to);
6781
+ mkdirSync5(dirname5(to), { recursive: true });
6782
+ const temporary = `${to}.${randomUUID3()}.tmp`;
6783
+ assertProjectFilePath(root, temporary);
6784
+ let owned = false;
6785
+ try {
6786
+ const fd = openSync(temporary, "wx");
6787
+ owned = true;
6788
+ closeSync(fd);
6789
+ writeFileSync5(temporary, bytes);
6790
+ chmodSync3(temporary, statSync(from).mode & 511);
6791
+ renameSync3(temporary, to);
6792
+ } finally {
6793
+ if (owned) {
6794
+ assertProjectFilePath(root, temporary);
6795
+ rmSync6(temporary, { force: true });
6796
+ }
6797
+ }
6798
+ }
6799
+ function preflightProfilePaths(root, orphan, destination) {
6800
+ assertProjectFilePath(root, orphan.manifestPath);
6801
+ for (const profile of destination ? [orphan.profile, destination] : [orphan.profile]) {
6802
+ assertProjectPath(root, join9(root, profile.manifestDir));
6803
+ for (const file of [MANIFEST_FILENAME, `${MANIFEST_FILENAME}.tmp`])
6804
+ assertProjectFilePath(root, join9(root, profile.manifestDir, file));
6805
+ assertProjectFilePath(root, join9(root, profile.rulesFile));
6806
+ for (const entry of entries(orphan.manifest)) {
6807
+ const path = entryPath(root, profile, entry);
6808
+ assertProjectFilePath(root, path);
6809
+ assertProjectFilePath(root, `${path}.tmp`);
6810
+ }
6811
+ const rulesPath = join9(root, profile.rulesFile);
6812
+ if (existsSync9(rulesPath))
6813
+ inspectRulesBlock(readFileSync8(rulesPath, "utf8"), profile.sentinelBegin, profile.sentinelEnd);
6814
+ }
6815
+ }
6816
+ function begin(root, orphan, destination) {
6817
+ const course = normalizeProjectCourse(orphan.manifest.course);
6818
+ if (!course)
6819
+ throw new Error("Unsupported course in orphan manifest");
6820
+ assertProjectCourse(root, course);
6821
+ preflightProfilePaths(root, orphan, destination);
6822
+ establishProjectCourse(root, course);
6823
+ const source = structuredClone(orphan.manifest);
6824
+ source.course = course;
6825
+ source.lessons = seedLessons(source);
6826
+ const rulesPath = join9(root, orphan.profile.rulesFile);
6827
+ if (!source.managedRules && existsSync9(rulesPath) && inspectRulesBlock(readFileSync8(rulesPath, "utf8"), orphan.profile.sentinelBegin, orphan.profile.sentinelEnd)) {
6828
+ source.managedRules = { path: orphan.profile.rulesFile, begin: orphan.profile.sentinelBegin, end: orphan.profile.sentinelEnd };
6829
+ }
6830
+ return source;
6831
+ }
6832
+ function migrateArtifacts(root, orphan, profile) {
6833
+ const summary = summaryFor(orphan, "migrated");
6834
+ if (!existsSync9(orphan.manifestPath))
6835
+ return summary;
6836
+ const source = begin(root, orphan, profile);
6837
+ const destination = readManifest(join9(root, profile.manifestDir)) ?? { ...structuredClone(source), tool: profile.toolId, files: { skills: {}, prompts: [], configs: [] }, lessons: {}, managedRules: undefined };
6838
+ destination.lessons = seedLessons(destination);
6839
+ destination.course = source.course;
6840
+ destination.tool = profile.toolId;
6841
+ for (const entry of entries(source)) {
6842
+ const from = entryPath(root, orphan.profile, entry);
6843
+ const to = entryPath(root, profile, entry);
6844
+ if (!existsSync9(from)) {
6845
+ retire(source, entry);
6846
+ persistSource(root, orphan.profile, source);
6211
6847
  continue;
6212
6848
  }
6213
- const skillDir = join7(projectRoot, oldProfile.skillDir(skillName));
6214
- for (const relPath of entry.files) {
6215
- if (!isSafeSkillFilePath(relPath)) {
6216
- summary.skipped.push({
6217
- path: `${skillName}/${relPath}`,
6218
- reason: "unsafe path in manifest"
6219
- });
6220
- continue;
6221
- }
6222
- rmSync5(join7(skillDir, relPath), { force: true });
6223
- }
6224
- rmSync5(skillDir, { recursive: true, force: true });
6225
- summary.movedOrRemoved.skills.push(skillName);
6226
- }
6227
- for (const promptFile of orphan.manifest.files.prompts) {
6228
- if (!isSafeName(promptFile)) {
6229
- summary.skipped.push({ path: promptFile, reason: "unsafe name in manifest" });
6849
+ const bytes = readFileSync8(from);
6850
+ if (existsSync9(to) && !readFileSync8(to).equals(bytes)) {
6851
+ summary.skipped.push({ path: to, reason: "destination already exists with different content" });
6230
6852
  continue;
6231
6853
  }
6232
- const promptName = promptFile.replace(/\.md$/, "");
6233
- rmSync5(join7(projectRoot, oldProfile.promptPath(promptName)), { force: true });
6234
- summary.movedOrRemoved.prompts.push(promptFile);
6235
- }
6236
- for (const configFile of orphan.manifest.files.configs) {
6237
- if (!isSafeName(configFile)) {
6238
- summary.skipped.push({ path: configFile, reason: "unsafe name in manifest" });
6854
+ if (!existsSync9(to))
6855
+ copyForTransfer(root, from, to, bytes);
6856
+ acquire(destination, source, entry);
6857
+ writeManifest(join9(root, profile.manifestDir), destination);
6858
+ record(summary, entry);
6859
+ try {
6860
+ assertProjectFilePath(root, from);
6861
+ if (!readFileSync8(from).equals(bytes))
6862
+ throw new Error("source changed during transfer");
6863
+ rmSync6(from);
6864
+ } catch (error) {
6865
+ summary.skipped.push({ path: from, reason: `copied to destination but could not remove source: ${error instanceof Error ? error.message : String(error)}` });
6866
+ persistSource(root, orphan.profile, source);
6239
6867
  continue;
6240
6868
  }
6241
- rmSync5(join7(projectRoot, oldProfile.configPath(configFile)), { force: true });
6242
- summary.movedOrRemoved.configs.push(configFile);
6869
+ retire(source, entry);
6870
+ try {
6871
+ persistSource(root, orphan.profile, source);
6872
+ } catch (error) {
6873
+ assertProjectFilePath(root, from);
6874
+ if (!existsSync9(from)) {
6875
+ mkdirSync5(dirname5(from), { recursive: true });
6876
+ writeFileSync5(from, bytes);
6877
+ }
6878
+ throw error;
6879
+ }
6880
+ pruneEmptyParents(root, from, join9(root, orphan.profile.manifestDir));
6243
6881
  }
6244
- summary.sentinelStripped = stripSentinelFromRulesFile(projectRoot, oldProfile);
6245
- rmSync5(orphan.manifestPath, { force: true });
6246
- const manifestDirAbs = join7(projectRoot, oldProfile.manifestDir);
6247
- tryRemoveEmptyDir(join7(manifestDirAbs, "skills"));
6248
- tryRemoveEmptyDir(join7(manifestDirAbs, "prompts"));
6249
- tryRemoveEmptyDir(join7(manifestDirAbs, "config-templates"));
6250
- tryRemoveEmptyDir(manifestDirAbs);
6882
+ commitSourceRules(root, orphan.profile, source, () => {
6883
+ transferRules(root, orphan.profile, profile, source, destination, summary);
6884
+ });
6251
6885
  return summary;
6252
6886
  }
6253
- function moveIfSafe(from, to, skipped) {
6254
- if (!existsSync7(from)) {
6255
- return false;
6887
+ function transferRules(root, old, profile, source, destination, summary) {
6888
+ const metadata = source.managedRules;
6889
+ if (!metadata)
6890
+ return;
6891
+ const from = join9(root, old.rulesFile);
6892
+ if (!existsSync9(from)) {
6893
+ delete source.managedRules;
6894
+ return;
6256
6895
  }
6257
- if (lstatSync(from).isSymbolicLink()) {
6258
- skipped.push({ path: from, reason: "source is a symlink" });
6259
- return false;
6896
+ const existing = readFileSync8(from, "utf8");
6897
+ const block = inspectRulesBlock(existing, old.sentinelBegin, old.sentinelEnd);
6898
+ if (!block) {
6899
+ delete source.managedRules;
6900
+ return;
6260
6901
  }
6261
- if (existsSync7(to)) {
6262
- if (!filesAreIdentical(from, to)) {
6263
- skipped.push({
6264
- path: to,
6265
- reason: "destination already exists with different content"
6266
- });
6267
- return false;
6268
- }
6269
- rmSync5(from, { force: true });
6270
- return true;
6902
+ if (metadata.path !== old.rulesFile || metadata.begin !== old.sentinelBegin || metadata.end !== old.sentinelEnd || !metadata.upstreamHash || contentHash(block.text) !== metadata.upstreamHash) {
6903
+ summary.skipped.push({ path: from, reason: "managed rules require explicit resolution: missing baseline or local edits" });
6904
+ return;
6271
6905
  }
6272
- mkdirSync5(dirname4(to), { recursive: true });
6273
- try {
6274
- renameSync3(from, to);
6275
- } catch (err) {
6276
- const code = err?.code;
6277
- if (code !== "EXDEV")
6278
- throw err;
6279
- const bytes = readFileSync6(from);
6280
- const tmp = `${to}.tmp`;
6281
- writeFileSync4(tmp, bytes);
6282
- renameSync3(tmp, to);
6283
- try {
6284
- rmSync5(from, { force: true });
6285
- } catch {
6286
- skipped.push({
6287
- path: from,
6288
- reason: "copied to destination but could not remove source"
6289
- });
6290
- return true;
6906
+ if (old.rulesFile === profile.rulesFile) {
6907
+ if (destination.managedRules && destination.managedRules.upstreamHash !== metadata.upstreamHash) {
6908
+ summary.skipped.push({ path: from, reason: "incompatible shared rules ownership" });
6909
+ return;
6291
6910
  }
6911
+ destination.managedRules = { ...metadata };
6912
+ writeManifest(join9(root, profile.manifestDir), destination);
6913
+ if (!entries(source).length)
6914
+ delete source.managedRules;
6915
+ return;
6292
6916
  }
6293
- return true;
6294
- }
6295
- function stripSentinelFromRulesFile(projectRoot, oldProfile) {
6296
- const rulesPath = join7(projectRoot, oldProfile.rulesFile);
6297
- if (!existsSync7(rulesPath))
6298
- return false;
6299
- const existing = readFileOrNull(rulesPath);
6300
- if (existing === null)
6301
- return false;
6302
- const { content, removed } = removeRulesBlockWithMarkers(existing, oldProfile.sentinelBegin, oldProfile.sentinelEnd);
6303
- if (!removed)
6304
- return false;
6305
- if (content.trim().length === 0) {
6306
- rmSync5(rulesPath, { force: true });
6307
- } else {
6308
- writeFileSync4(rulesPath, content);
6917
+ const plan = planManagedRules(root, profile, block.body, true, destination.managedRules);
6918
+ if (plan.isConflict) {
6919
+ summary.skipped.push({ path: join9(root, profile.rulesFile), reason: plan.reason ?? "rules conflict" });
6920
+ return;
6309
6921
  }
6310
- return true;
6311
- }
6312
- function filesAreIdentical(a2, b2) {
6313
- try {
6314
- const aStat = statSync(a2);
6315
- const bStat = statSync(b2);
6316
- if (aStat.size !== bStat.size)
6317
- return false;
6318
- return readFileSync6(a2).equals(readFileSync6(b2));
6319
- } catch {
6320
- return false;
6922
+ const to = join9(root, profile.rulesFile);
6923
+ if (plan.action !== "unchanged") {
6924
+ assertProjectFilePath(root, to);
6925
+ mkdirSync5(dirname5(to), { recursive: true });
6926
+ writeFileSync5(to, plan.content);
6321
6927
  }
6322
- }
6323
- function tryRemoveEmptyDir(dir) {
6324
- if (!existsSync7(dir))
6928
+ destination.managedRules = plan.next;
6929
+ writeManifest(join9(root, profile.manifestDir), destination);
6930
+ if (entries(source).length)
6325
6931
  return;
6326
- try {
6327
- rmdirSync2(dir);
6328
- } catch {}
6932
+ if (!otherRulesOwners(root, old).length) {
6933
+ const stripped = removeRulesBlockWithMarkers(existing, old.sentinelBegin, old.sentinelEnd);
6934
+ assertProjectFilePath(root, from);
6935
+ writeFileSync5(from, stripped.content);
6936
+ summary.sentinelStripped = stripped.removed;
6937
+ }
6938
+ delete source.managedRules;
6939
+ }
6940
+ function deleteArtifacts(root, orphan) {
6941
+ const summary = summaryFor(orphan, "deleted");
6942
+ if (!existsSync9(orphan.manifestPath))
6943
+ return summary;
6944
+ const source = begin(root, orphan);
6945
+ for (const entry of entries(source)) {
6946
+ const path = entryPath(root, orphan.profile, entry);
6947
+ const plan = planManagedRemoval(root, { name: entry.name, path, storedHash: entry.hash, config: entry.kind === "configs", pruneRoot: join9(root, orphan.profile.manifestDir) });
6948
+ const result = executeManagedRemoval(root, plan);
6949
+ if (result.action === "removed")
6950
+ record(summary, entry);
6951
+ else if (result.action === "preserved_local")
6952
+ summary.skipped.push({ path, reason: `preserved_local: ${result.reason}` });
6953
+ retire(source, entry);
6954
+ persistSource(root, orphan.profile, source);
6955
+ }
6956
+ commitSourceRules(root, orphan.profile, source, () => {
6957
+ if (source.managedRules) {
6958
+ const path = join9(root, orphan.profile.rulesFile);
6959
+ const plan = planManagedRules(root, orphan.profile, undefined, false, source.managedRules);
6960
+ if (plan.isConflict)
6961
+ summary.skipped.push({ path, reason: "managed rules require explicit resolution: missing baseline or local edits" });
6962
+ else {
6963
+ if (plan.action === "removed") {
6964
+ assertProjectFilePath(root, path);
6965
+ writeFileSync5(path, plan.content);
6966
+ summary.sentinelStripped = true;
6967
+ }
6968
+ delete source.managedRules;
6969
+ }
6970
+ }
6971
+ });
6972
+ return summary;
6329
6973
  }
6330
6974
 
6331
6975
  // src/lib/tool-prompt.ts
6332
6976
  async function resolveToolProfile(flagOverride, projectRoot = process.cwd()) {
6333
- const profile = await resolveProfileOnly(flagOverride, projectRoot);
6334
- await handleToolSwitch(projectRoot, profile);
6335
- return profile;
6977
+ return resolveProfileOnly(flagOverride, projectRoot);
6336
6978
  }
6337
6979
  async function resolveProfileOnly(flagOverride, projectRoot) {
6338
6980
  if (flagOverride) {
6339
- const canonicalId = canonicalToolId(flagOverride);
6340
6981
  const profile = getToolProfile(flagOverride);
6341
6982
  if (!profile) {
6342
6983
  throw new Error(`Unknown tool '${flagOverride}'. Supported: ${Object.keys(PROFILES).join(", ")}`);
6343
6984
  }
6344
- const existing = readToolConfig();
6345
- if (existing?.tool !== canonicalId) {
6346
- saveToolConfig({ ...existing ?? {}, tool: canonicalId });
6347
- if (process.stdout.isTTY) {
6348
- process.stderr.write(`Default tool set to ${profile.displayName}.
6349
- `);
6350
- }
6351
- }
6352
6985
  return profile;
6353
6986
  }
6354
6987
  const config = readToolConfig();
6355
6988
  if (config?.tool) {
6356
6989
  const profile = getToolProfile(config.tool);
6357
6990
  if (profile) {
6358
- const canonicalId = canonicalToolId(config.tool);
6359
- if (canonicalId !== config.tool) {
6360
- saveToolConfig({ ...config, tool: canonicalId });
6361
- }
6362
6991
  return profile;
6363
6992
  }
6364
6993
  }
@@ -6383,11 +7012,25 @@ async function resolveProfileOnly(flagOverride, projectRoot) {
6383
7012
  ve("Using default (Claude Code).");
6384
7013
  return PROFILES[DEFAULT_TOOL];
6385
7014
  }
6386
- saveToolConfig({ ...config ?? {}, tool: choice });
6387
7015
  return PROFILES[choice];
6388
7016
  }
6389
7017
  return PROFILES[DEFAULT_TOOL];
6390
7018
  }
7019
+ async function prepareToolForWrite(projectRoot, profile, course, patch = {}, interactive = true) {
7020
+ assertProjectCourse(projectRoot, course);
7021
+ if (interactive && process.stdout.isTTY) {
7022
+ for (const orphan of findOrphanedManifests(projectRoot, profile))
7023
+ preflightProfilePaths(projectRoot, orphan, profile);
7024
+ }
7025
+ establishProjectCourse(projectRoot, course);
7026
+ if (interactive)
7027
+ await handleToolSwitch(projectRoot, profile);
7028
+ const previous = readToolConfig();
7029
+ updateToolConfig({ ...patch, tool: profile.toolId });
7030
+ if (previous?.tool !== profile.toolId && process.stdout.isTTY)
7031
+ process.stderr.write(`Default tool set to ${profile.displayName}.
7032
+ `);
7033
+ }
6391
7034
  async function handleToolSwitch(projectRoot, newProfile) {
6392
7035
  if (!process.stdout.isTTY)
6393
7036
  return;
@@ -6473,9 +7116,8 @@ function resolveCourseRulesFlag(argv) {
6473
7116
  return true;
6474
7117
  return;
6475
7118
  }
6476
- var DEFAULT_COURSE2 = "10xdevs3";
6477
7119
  function registerGetCommand(cli) {
6478
- cli.command("get <ref>", "Fetch and apply a lesson pack").option("--dry-run", "Show what would be written without touching the filesystem").option("--course <course>", "Override the course slug (default: 10xdevs3)").option("--tool <tool>", "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").option("--name <name>", "Artifact name filter (requires --type)").option("--lang <lang>", "Content language: en (default) or pl").option("--no-course-rules", "Skip applying the course rules block to your rules file (CLAUDE.md/AGENTS.md)").action(async (ref, options) => {
7120
+ cli.command("get <ref>", "Fetch and apply a lesson pack").option("--dry-run", "Show what would be written without touching the filesystem").option("--course <course>", "Select course ID or slug (default: project edition or API recommendation)").option("--tool <tool>", "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").option("--name <name>", "Artifact name filter (requires --type)").option("--lang <lang>", "Content language: en (default) or pl").option("--no-course-rules", "Skip applying the course rules block to your rules file (CLAUDE.md/AGENTS.md)").action(async (ref, options) => {
6479
7121
  const ctx = resolveContext(options);
6480
7122
  await runGet(ctx, ref, options);
6481
7123
  });
@@ -6495,29 +7137,28 @@ async function runGet(ctx, ref, options) {
6495
7137
  if (options.lang && !SUPPORTED_LANGS.includes(options.lang)) {
6496
7138
  outputError(ctx, "invalid_lang", `Unknown language '${options.lang}'.`, ExitCodes.USAGE, `Supported languages: ${SUPPORTED_LANGS.join(", ")}`);
6497
7139
  }
6498
- const auth = await requireAuth(ctx);
6499
- const course = options.course ?? DEFAULT_COURSE2;
7140
+ const selection = await resolveCourseSelection(ctx, { explicit: options.course, writing: !options.dryRun && !options.print });
7141
+ const { auth, course } = selection;
7142
+ let release;
7143
+ if (course === "10xdevs4" || course === "10xdevs-4") {
7144
+ const catalog = await fetchCatalog(course, auth.access_token);
7145
+ if (!catalog.ok)
7146
+ handleLessonError(ctx, catalog.status, catalog.code, catalog.error, catalog.payload);
7147
+ release = catalogRelease(catalog.data);
7148
+ }
6500
7149
  const profile = await resolveToolProfile(options.tool, process.cwd());
6501
7150
  const dryRun = options.dryRun === true;
6502
7151
  const lang = options.lang ?? readToolConfig()?.lang ?? "en";
6503
7152
  const explicitCourseRules = resolveCourseRulesFlag(process.argv);
6504
7153
  const resolvedCourseRules = explicitCourseRules ?? readToolConfig()?.courseRules ?? true;
6505
- if (!dryRun && (options.lang || explicitCourseRules !== undefined)) {
6506
- const tool = readToolConfig()?.tool ?? profile.toolId;
6507
- const patch = { tool };
6508
- if (options.lang)
6509
- patch.lang = options.lang;
6510
- if (explicitCourseRules !== undefined)
6511
- patch.courseRules = explicitCourseRules;
6512
- updateToolConfig(patch);
6513
- }
6514
7154
  if (options.print) {
6515
- await runPrintMode(ctx, parsed.lessonId, course, profile, auth.access_token, lang, options);
7155
+ await runPrintMode(ctx, parsed.lessonId, course, profile, auth.access_token, lang, options, selection.reason, release);
6516
7156
  return;
6517
7157
  }
6518
7158
  verbose(ctx, `fetching lesson ${course}/${parsed.lessonId}`);
6519
7159
  const result = await fetchLesson(course, parsed.lessonId, auth.access_token, {
6520
7160
  lang,
7161
+ ...release ? { release } : {},
6521
7162
  tool: contentToolId(profile)
6522
7163
  });
6523
7164
  if (!result.ok) {
@@ -6536,19 +7177,34 @@ async function runGet(ctx, ref, options) {
6536
7177
  const isFiltered = options.type !== undefined;
6537
7178
  const bundle = filterBundle(ctx, result.data, options);
6538
7179
  const isTTY = process.stdout.isTTY === true;
6539
- const prevManifest = readManifest(join8(process.cwd(), profile.manifestDir));
7180
+ const prevManifest = readManifest(join10(process.cwd(), profile.manifestDir));
6540
7181
  if (prevManifest && prevManifest.manifestVersion === 2) {
6541
7182
  showUpgradeNotice(isTTY);
6542
7183
  }
6543
7184
  const applyCourseRules = isFiltered ? true : resolvedCourseRules;
6544
- const writeResult = await applyBundle(bundle, process.cwd(), {
6545
- dryRun,
6546
- profile,
6547
- partial: isFiltered,
6548
- onConflict: createConflictResolver(isTTY),
6549
- applyCourseRules
6550
- });
7185
+ let writeResult;
7186
+ try {
7187
+ planBundle(bundle, process.cwd(), { course, profile, applyCourseRules });
7188
+ if (!dryRun)
7189
+ await prepareToolForWrite(process.cwd(), profile, course, {
7190
+ ...options.lang ? { lang: options.lang } : {},
7191
+ ...explicitCourseRules !== undefined ? { courseRules: explicitCourseRules } : {}
7192
+ });
7193
+ writeResult = await applyBundle(bundle, process.cwd(), {
7194
+ course,
7195
+ dryRun,
7196
+ profile,
7197
+ partial: isFiltered,
7198
+ lang: contentLang ?? lang,
7199
+ onConflict: createConflictResolver(isTTY && !dryRun),
7200
+ applyCourseRules
7201
+ });
7202
+ } catch (error) {
7203
+ reportCourseError(ctx, error);
7204
+ }
6551
7205
  renderGetResult(ctx, bundle, writeResult, dryRun, profile, {
7206
+ course,
7207
+ selectionReason: selection.reason,
6552
7208
  language: contentLang ?? lang,
6553
7209
  languageFallback: isFallback,
6554
7210
  applyCourseRules
@@ -6581,7 +7237,10 @@ function filterBundle(ctx, bundle, options) {
6581
7237
  }
6582
7238
  return { ...empty, [type]: bundle[type] };
6583
7239
  }
6584
- async function runPrintMode(ctx, lessonId, course, profile, token, lang, options) {
7240
+ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options, selectionReason, release) {
7241
+ if (!ctx.json)
7242
+ process.stderr.write(`Course: ${course} (${selectionReason})
7243
+ `);
6585
7244
  if (!options.type) {
6586
7245
  outputError(ctx, "missing_type", "Specify --type for --print mode.", ExitCodes.USAGE, "Usage: 10x get m1l1 --print --type skills [--name code-review]");
6587
7246
  }
@@ -6590,12 +7249,12 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
6590
7249
  }
6591
7250
  if (options.name) {
6592
7251
  verbose(ctx, `fetching artifact ${course}/${lessonId}/${options.type}/${options.name}`);
6593
- const result = await fetchArtifact(course, lessonId, options.type, options.name, contentToolId(profile), token, { lang });
7252
+ const result = await fetchArtifact(course, lessonId, options.type, options.name, contentToolId(profile), token, { lang, ...release ? { release } : {} });
6594
7253
  if (!result.ok) {
6595
7254
  handleLessonError(ctx, result.status, result.code, result.error, result.payload);
6596
7255
  }
6597
7256
  if (ctx.json) {
6598
- output(ctx, "", result.data);
7257
+ output(ctx, "", { ...result.data, course, selectionReason });
6599
7258
  } else {
6600
7259
  const data = result.data;
6601
7260
  if (data.type === "skills") {
@@ -6608,7 +7267,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
6608
7267
  }
6609
7268
  } else {
6610
7269
  verbose(ctx, `fetching lesson ${course}/${lessonId} (filtering by ${options.type})`);
6611
- const result = await fetchLesson(course, lessonId, token, { lang, tool: contentToolId(profile) });
7270
+ const result = await fetchLesson(course, lessonId, token, { lang, tool: contentToolId(profile), ...release ? { release } : {} });
6612
7271
  if (!result.ok) {
6613
7272
  handleLessonError(ctx, result.status, result.code, result.error, result.payload);
6614
7273
  }
@@ -6616,7 +7275,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
6616
7275
  if (type === "skills") {
6617
7276
  const skills = result.data.skills;
6618
7277
  if (ctx.json) {
6619
- output(ctx, "", skills);
7278
+ output(ctx, "", { course, selectionReason, artifacts: skills });
6620
7279
  } else {
6621
7280
  const contents = skills.map((s) => s.files.find((f2) => f2.path === "SKILL.md")?.content ?? "");
6622
7281
  process.stdout.write(contents.join(`
@@ -6628,7 +7287,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
6628
7287
  } else {
6629
7288
  const artifacts = result.data[type];
6630
7289
  if (ctx.json) {
6631
- output(ctx, "", artifacts);
7290
+ output(ctx, "", { course, selectionReason, artifacts });
6632
7291
  } else {
6633
7292
  const contents = artifacts.map((a2) => a2.content);
6634
7293
  process.stdout.write(contents.join(`
@@ -6648,7 +7307,9 @@ function emitMultiFileSkillNotice(skillName, files) {
6648
7307
  }
6649
7308
  function handleLessonError(ctx, status, code, error, payload) {
6650
7309
  verbose(ctx, `lesson fetch failed: status=${status} code=${code}`);
6651
- if (status === 403) {
7310
+ if (code === "course_access_denied" || code === "course_unavailable")
7311
+ outputError(ctx, code, error, code === "course_access_denied" ? ExitCodes.FORBIDDEN : ExitCodes.ERROR);
7312
+ if (status === 403 && code === "module_locked") {
6652
7313
  const moduleNum = payload?.["module"];
6653
7314
  const releaseAt = payload?.["releaseAt"];
6654
7315
  const hasModule = typeof moduleNum === "number";
@@ -6671,7 +7332,7 @@ function handleLessonError(ctx, status, code, error, payload) {
6671
7332
  if (status === 401) {
6672
7333
  outputError(ctx, "auth_required", "Your session is no longer valid.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
6673
7334
  }
6674
- if (code === "signature_error" || code === "signature_missing" || code === "signature_internal_error") {
7335
+ if (code === "release_mismatch" || code === "signature_error" || code === "signature_missing" || code === "signature_internal_error") {
6675
7336
  outputError(ctx, code, error, ExitCodes.ERROR);
6676
7337
  }
6677
7338
  if (status === 0) {
@@ -6692,14 +7353,18 @@ function formatAction(action) {
6692
7353
  }
6693
7354
  }
6694
7355
  function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
7356
+ course: "10xdevs3",
7357
+ selectionReason: "backend_recommendation",
6695
7358
  language: "en",
6696
7359
  languageFallback: false,
6697
7360
  applyCourseRules: true
6698
7361
  }) {
6699
7362
  const applyCourseRules = langMeta.applyCourseRules;
6700
- const totalRemovals = writeResult.removals.skills.length + writeResult.removals.prompts.length + writeResult.removals.configs.length;
7363
+ const totalRemovals = [...writeResult.removals.skills, ...writeResult.removals.prompts, ...writeResult.removals.configs].filter((entry) => entry.action === "removed").length;
6701
7364
  if (ctx.json) {
6702
7365
  output(ctx, "", {
7366
+ course: langMeta.course,
7367
+ selectionReason: langMeta.selectionReason,
6703
7368
  lessonId: bundle.lessonId,
6704
7369
  title: bundle.title,
6705
7370
  summary: bundle.summary,
@@ -6726,6 +7391,7 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
6726
7391
  }
6727
7392
  const targetDir = profile.manifestDir;
6728
7393
  const lines = [];
7394
+ lines.push(`Course: ${langMeta.course} (${langMeta.selectionReason})`);
6729
7395
  lines.push(`${bundle.lessonId} — ${bundle.title}`);
6730
7396
  if (bundle.summary)
6731
7397
  lines.push(bundle.summary);
@@ -6746,7 +7412,9 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
6746
7412
  lines.push(` [${formatAction(prompt.action)}] prompt ${prompt.path}`);
6747
7413
  }
6748
7414
  if (!applyCourseRules) {
6749
- if (writeResult.rules.action === "removed") {
7415
+ if (writeResult.rules.action === "conflict_skipped") {
7416
+ lines.push(` [conflict: skipped] rules ${profile.rulesFile} (${writeResult.rules.reason ?? "explicit resolution required"})`);
7417
+ } else if (writeResult.rules.action === "removed") {
6750
7418
  lines.push(` [removed] rules ${profile.rulesFile}`);
6751
7419
  } else {
6752
7420
  verbose(ctx, `course rules disabled — ${profile.rulesFile} left untouched`);
@@ -6758,56 +7426,66 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
6758
7426
  lines.push(` [${config.action}] config ${config.path}`);
6759
7427
  }
6760
7428
  for (const entry of writeResult.removals.skills) {
6761
- lines.push(` [removed] skill ${entry.path}`);
7429
+ lines.push(` [${entry.action}] skill ${entry.path}${entry.reason ? ` (${entry.reason})` : ""}`);
6762
7430
  }
6763
7431
  for (const entry of writeResult.removals.prompts) {
6764
- lines.push(` [removed] prompt ${entry.path}`);
7432
+ lines.push(` [${entry.action}] prompt ${entry.path}${entry.reason ? ` (${entry.reason})` : ""}`);
6765
7433
  }
6766
7434
  for (const entry of writeResult.removals.configs) {
6767
- lines.push(` [removed] config ${entry.path}`);
7435
+ lines.push(` [${entry.action}] config ${entry.path}${entry.reason ? ` (${entry.reason})` : ""}`);
6768
7436
  }
6769
7437
  output(ctx, lines.join(`
6770
7438
  `), undefined);
6771
7439
  }
6772
7440
 
6773
7441
  // src/commands/list.ts
6774
- var DEFAULT_COURSE3 = "10xdevs3";
6775
7442
  function registerListCommand(cli) {
6776
- cli.command("list [module]", "Browse available modules and lessons").option("--course <course>", "Override the course slug (default: 10xdevs3)").action(async (moduleArg, options) => {
7443
+ cli.command("list [module]", "Browse available modules and lessons").option("--course <course>", "Select course ID or slug (default: project edition or API recommendation)").action(async (moduleArg, options) => {
6777
7444
  const ctx = resolveContext(options);
6778
7445
  await runList(ctx, moduleArg, options);
6779
7446
  });
6780
7447
  }
6781
7448
  async function runList(ctx, moduleArg, options) {
6782
- const auth = await requireAuth(ctx);
6783
- const course = options.course ?? DEFAULT_COURSE3;
7449
+ const selection = await resolveCourseSelection(ctx, { explicit: options.course });
7450
+ const { auth, course } = selection;
6784
7451
  if (moduleArg === undefined) {
6785
- await listAllModules(ctx, course, auth.access_token);
7452
+ await listAllModules(ctx, course, auth.access_token, selection.reason);
6786
7453
  return;
6787
7454
  }
6788
7455
  const module = parseModuleRef(moduleArg);
6789
7456
  if (module === null) {
6790
7457
  outputError(ctx, "invalid_module", `'${moduleArg}' is not a valid module reference.`, ExitCodes.USAGE, `Pass a module between ${MIN_MODULE} and ${MAX_MODULE}, for example '10x list 1' or '10x list m1'.`);
6791
7458
  }
6792
- await listModuleDetail(ctx, course, module, auth.access_token);
7459
+ await listModuleDetail(ctx, course, module, auth.access_token, selection.reason);
6793
7460
  }
6794
- async function listAllModules(ctx, course, token) {
7461
+ async function listAllModules(ctx, course, token, reason) {
6795
7462
  verbose(ctx, `fetching catalog for ${course}`);
6796
7463
  const result = await fetchCatalog(course, token);
6797
7464
  if (!result.ok) {
6798
7465
  handleListError(ctx, result.status, result.code, result.error);
6799
7466
  }
6800
- renderCatalog(ctx, result.data);
7467
+ renderCatalog(ctx, result.data, reason);
6801
7468
  }
6802
- async function listModuleDetail(ctx, course, module, token) {
7469
+ async function listModuleDetail(ctx, course, module, token, reason) {
6803
7470
  verbose(ctx, `fetching module detail ${course}/${module}`);
6804
- const result = await fetchModuleDetail(course, module, token);
7471
+ let release;
7472
+ if (course === "10xdevs4" || course === "10xdevs-4") {
7473
+ const catalog = await fetchCatalog(course, token);
7474
+ if (!catalog.ok)
7475
+ handleListError(ctx, catalog.status, catalog.code, catalog.error);
7476
+ release = catalogRelease(catalog.data);
7477
+ }
7478
+ const result = await fetchModuleDetail(course, module, token, release ? { release } : {});
6805
7479
  if (!result.ok) {
6806
7480
  handleListError(ctx, result.status, result.code, result.error);
6807
7481
  }
6808
- renderModuleDetail(ctx, result.data);
7482
+ renderModuleDetail(ctx, result.data, course, reason);
6809
7483
  }
6810
7484
  function handleListError(ctx, status, code, error) {
7485
+ if (["course_access_denied", "module_locked", "course_unavailable"].includes(code))
7486
+ outputError(ctx, code, error, status === 403 ? ExitCodes.FORBIDDEN : ExitCodes.ERROR);
7487
+ if (code === "release_mismatch")
7488
+ outputError(ctx, code, error, ExitCodes.ERROR);
6811
7489
  if (status === 404) {
6812
7490
  outputError(ctx, "not_found", "Couldn't find that course or module.", ExitCodes.NOT_FOUND, "Run '10x list' to see available modules.");
6813
7491
  }
@@ -6819,7 +7497,7 @@ function handleListError(ctx, status, code, error) {
6819
7497
  }
6820
7498
  outputError(ctx, code || "list_failed", "Failed to load the catalog.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
6821
7499
  }
6822
- function renderCatalog(ctx, catalog) {
7500
+ function renderCatalog(ctx, catalog, reason) {
6823
7501
  const lessonCountByModule = new Map;
6824
7502
  for (const lesson of catalog.lessons) {
6825
7503
  lessonCountByModule.set(lesson.module, (lessonCountByModule.get(lesson.module) ?? 0) + 1);
@@ -6827,6 +7505,7 @@ function renderCatalog(ctx, catalog) {
6827
7505
  if (ctx.json) {
6828
7506
  output(ctx, "", {
6829
7507
  course: catalog.course,
7508
+ selectionReason: reason,
6830
7509
  modules: catalog.modules.map((m3) => ({
6831
7510
  module: m3.module,
6832
7511
  title: m3.title,
@@ -6843,7 +7522,7 @@ function renderCatalog(ctx, catalog) {
6843
7522
  }
6844
7523
  const exampleModule = catalog.modules.find((m3) => m3.effectiveState === "unlocked")?.module ?? catalog.modules[0]?.module ?? 0;
6845
7524
  const lines = [];
6846
- lines.push(`Course: ${catalog.course}`);
7525
+ lines.push(`Course: ${catalog.course} (${reason})`);
6847
7526
  lines.push("");
6848
7527
  for (const m3 of catalog.modules) {
6849
7528
  lines.push(formatModuleRow(m3, lessonCountByModule.get(m3.module) ?? 0));
@@ -6859,10 +7538,12 @@ function formatModuleRow(m3, lessonCount) {
6859
7538
  const label = `Module ${m3.module}: ${m3.title}`;
6860
7539
  return ` ${icon} ${label} — ${lessonCount} lesson${lessonCount === 1 ? "" : "s"} [${state}]`;
6861
7540
  }
6862
- function renderModuleDetail(ctx, module) {
7541
+ function renderModuleDetail(ctx, module, course, reason) {
6863
7542
  const isLocked = module.effectiveState === "locked";
6864
7543
  if (ctx.json) {
6865
7544
  output(ctx, "", {
7545
+ course,
7546
+ selectionReason: reason,
6866
7547
  module: module.module,
6867
7548
  title: module.title,
6868
7549
  state: module.effectiveState,
@@ -6879,6 +7560,7 @@ function renderModuleDetail(ctx, module) {
6879
7560
  }
6880
7561
  const stateLabel = isLocked ? `locked — unlocks ${formatReleaseAt(module.releaseAt)}` : "unlocked";
6881
7562
  const lines = [];
7563
+ lines.push(`Course: ${course} (${reason})`);
6882
7564
  lines.push(`Module ${module.module}: ${module.title} [${stateLabel}]`);
6883
7565
  lines.push("");
6884
7566
  if (isLocked) {
@@ -6906,11 +7588,53 @@ function renderModuleDetail(ctx, module) {
6906
7588
  }
6907
7589
 
6908
7590
  // src/commands/sync.ts
6909
- import { join as join9 } from "node:path";
6910
- var DEFAULT_COURSE4 = "10xdevs3";
7591
+ import { join as join12 } from "node:path";
7592
+
7593
+ // src/lib/sync-freshness.ts
7594
+ import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
7595
+ import { join as join11 } from "node:path";
7596
+ function isLessonFresh(root, manifest, id, profile, lang, courseRules) {
7597
+ const lesson = manifest?.lessons?.[id];
7598
+ const representation = lesson?.representation;
7599
+ if (!manifest || !lesson || !representation || representation.lang !== lang || representation.tool !== profile.toolId || representation.courseRules !== courseRules || typeof representation.rules !== "boolean")
7600
+ return false;
7601
+ const matches = (path, hash) => {
7602
+ assertProjectFilePath(root, path);
7603
+ return !!hash && existsSync10(path) && contentHash(readFileSync9(path)) === hash;
7604
+ };
7605
+ try {
7606
+ for (const [name, skill] of Object.entries(lesson.skills)) {
7607
+ if (!isSafeName(name))
7608
+ return false;
7609
+ for (const file of skill.files)
7610
+ if (!isSafeSkillFilePath(file) || !matches(join11(root, profile.skillDir(name), file), manifest.files.skills[name]?.contentHashes?.[file]))
7611
+ return false;
7612
+ }
7613
+ for (const name of lesson.prompts)
7614
+ if (!isSafeName(name) || !matches(join11(root, profile.promptPath(name.replace(/\.md$/, ""))), manifest.files.promptHashes?.[name]))
7615
+ return false;
7616
+ for (const name of lesson.configs)
7617
+ if (!isSafeName(name) || !matches(join11(root, profile.configPath(name)), manifest.files.configHashes?.[name]))
7618
+ return false;
7619
+ const rulesPath = join11(root, profile.rulesFile);
7620
+ assertProjectFilePath(root, rulesPath);
7621
+ const existing = existsSync10(rulesPath) ? readFileSync9(rulesPath, "utf8") : "";
7622
+ const block = inspectRulesBlock(existing, profile.sentinelBegin, profile.sentinelEnd);
7623
+ if (representation.rules) {
7624
+ const rules = manifest.managedRules;
7625
+ if (!rules || rules.path !== profile.rulesFile || rules.begin !== profile.sentinelBegin || rules.end !== profile.sentinelEnd || !block || contentHash(block.text) !== rules.upstreamHash)
7626
+ return false;
7627
+ }
7628
+ return true;
7629
+ } catch {
7630
+ return false;
7631
+ }
7632
+ }
7633
+
7634
+ // src/commands/sync.ts
6911
7635
  var SUPPORTED_LANGS = ["en", "pl"];
6912
7636
  function registerSyncCommand(cli) {
6913
- cli.command("sync", "Bulk-download and update lessons, reporting what changed upstream").option("--all", "Sync every unlocked lesson (default: only lessons you've already downloaded)").option("--dry-run", "Show what would change without writing").option("--force", "Ignore the cheap-skip digest and overwrite local edits with upstream").option("--module <module>", "Limit to one module (e.g. 'm2' or '2')").option("--course <course>", "Override the course slug (default: 10xdevs3)").option("--tool <tool>", "AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, generic)").option("--lang <lang>", "Content language: en (default) or pl").option("--no-course-rules", "Skip applying the course rules block to your rules file (CLAUDE.md/AGENTS.md)").action(async (options) => {
7637
+ cli.command("sync", "Bulk-download and update lessons, reporting what changed upstream").option("--all", "Sync every unlocked lesson (default: only lessons you've already downloaded)").option("--dry-run", "Show what would change without writing").option("--force", "Ignore the cheap-skip digest and overwrite local edits with upstream").option("--module <module>", "Limit to one module (e.g. 'm2' or '2')").option("--course <course>", "Select course ID or slug (default: project edition or API recommendation)").option("--tool <tool>", "AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, generic)").option("--lang <lang>", "Content language: en (default) or pl").option("--no-course-rules", "Skip applying the course rules block to your rules file (CLAUDE.md/AGENTS.md)").action(async (options) => {
6914
7638
  const ctx = resolveContext(options);
6915
7639
  await runSync(ctx, options);
6916
7640
  });
@@ -6927,8 +7651,8 @@ async function runSync(ctx, options) {
6927
7651
  }
6928
7652
  moduleFilter = parsed;
6929
7653
  }
6930
- const auth = await requireAuth(ctx);
6931
- const course = options.course ?? DEFAULT_COURSE4;
7654
+ const selection = await resolveCourseSelection(ctx, { explicit: options.course, writing: !options.dryRun });
7655
+ const { auth, course } = selection;
6932
7656
  const profile = await resolveToolProfile(options.tool, process.cwd());
6933
7657
  const dryRun = options.dryRun === true;
6934
7658
  const force = options.force === true;
@@ -6941,6 +7665,7 @@ async function runSync(ctx, options) {
6941
7665
  handleCatalogError(ctx, catalogResult.status, catalogResult.code, catalogResult.error);
6942
7666
  }
6943
7667
  const catalog = catalogResult.data;
7668
+ const release = catalogRelease(catalog);
6944
7669
  const moduleState = new Map;
6945
7670
  for (const mod of catalog.modules)
6946
7671
  moduleState.set(mod.module, mod.effectiveState);
@@ -6952,8 +7677,8 @@ async function runSync(ctx, options) {
6952
7677
  }
6953
7678
  return true;
6954
7679
  });
6955
- const manifest = readManifest(join9(process.cwd(), profile.manifestDir));
6956
- const manifestLessonIds = new Set(manifest?.lessons ? Object.keys(manifest.lessons) : []);
7680
+ const manifest = readManifest(join12(process.cwd(), profile.manifestDir));
7681
+ const manifestLessonIds = new Set(manifest?.lessons ? Object.keys(manifest.lessons) : manifest ? [manifest.lessonId] : []);
6957
7682
  let targets = unlocked;
6958
7683
  if (moduleFilter !== undefined)
6959
7684
  targets = targets.filter((l2) => l2.module === moduleFilter);
@@ -6964,12 +7689,24 @@ async function runSync(ctx, options) {
6964
7689
  const onSigint = () => controller.abort();
6965
7690
  process.once("SIGINT", onSigint);
6966
7691
  const outcomes = [];
7692
+ let prepared = false;
7693
+ const beforeWrite = async () => {
7694
+ if (prepared)
7695
+ return;
7696
+ await prepareToolForWrite(process.cwd(), profile, course, {
7697
+ ...options.lang ? { lang: options.lang } : {},
7698
+ ...explicitCourseRules !== undefined ? { courseRules: explicitCourseRules } : {}
7699
+ }, false);
7700
+ prepared = true;
7701
+ };
6967
7702
  try {
6968
7703
  for (const lesson of targets) {
6969
7704
  if (controller.signal.aborted)
6970
7705
  break;
6971
7706
  outcomes.push(await syncLesson(ctx, lesson, {
6972
7707
  course,
7708
+ beforeWrite,
7709
+ release,
6973
7710
  profile,
6974
7711
  lang,
6975
7712
  dryRun,
@@ -6985,6 +7722,7 @@ async function runSync(ctx, options) {
6985
7722
  }
6986
7723
  renderReport(ctx, profile, {
6987
7724
  course,
7725
+ selectionReason: selection.reason,
6988
7726
  dryRun,
6989
7727
  force,
6990
7728
  mode: options.all === true ? "all" : "downloaded",
@@ -6997,8 +7735,9 @@ async function runSync(ctx, options) {
6997
7735
  }
6998
7736
  }
6999
7737
  async function syncLesson(ctx, lesson, opts) {
7000
- const stored = opts.manifest?.lessons?.[lesson.lessonId]?.catalogContentHash;
7001
- if (!opts.force && lesson.contentHash !== undefined && stored !== undefined && lesson.contentHash === stored) {
7738
+ const currentManifest = readManifest(join12(process.cwd(), opts.profile.manifestDir));
7739
+ const stored = currentManifest?.lessons?.[lesson.lessonId]?.catalogContentHash;
7740
+ if (!opts.force && lesson.contentHash !== undefined && stored !== undefined && lesson.contentHash === stored && isLessonFresh(process.cwd(), currentManifest, lesson.lessonId, opts.profile, opts.lang, opts.applyCourseRules)) {
7002
7741
  verbose(ctx, `${lesson.lessonId}: upstream unchanged (digest match) — skipping fetch`);
7003
7742
  return {
7004
7743
  lessonId: lesson.lessonId,
@@ -7012,7 +7751,8 @@ async function syncLesson(ctx, lesson, opts) {
7012
7751
  const result = await fetchLesson(opts.course, lesson.lessonId, opts.token, {
7013
7752
  lang: opts.lang,
7014
7753
  tool: contentToolId(opts.profile),
7015
- signal: opts.signal
7754
+ signal: opts.signal,
7755
+ ...opts.release ? { release: opts.release } : {}
7016
7756
  });
7017
7757
  if (!result.ok) {
7018
7758
  return {
@@ -7043,14 +7783,23 @@ async function syncLesson(ctx, lesson, opts) {
7043
7783
  resources: resources2
7044
7784
  };
7045
7785
  }
7046
- const onConflict = opts.force ? async () => "overwrite" : async () => "skip";
7047
- const writeResult = await applyBundle(bundle, process.cwd(), {
7048
- course: opts.course,
7049
- profile: opts.profile,
7050
- onConflict,
7051
- applyCourseRules: opts.applyCourseRules,
7052
- catalogContentHash: lesson.contentHash
7053
- });
7786
+ const onConflict = opts.force ? async (info) => info.artifactType === "rules" ? "skip" : "overwrite" : async () => "skip";
7787
+ let writeResult;
7788
+ try {
7789
+ planBundle(bundle, process.cwd(), { course: opts.course, profile: opts.profile, applyCourseRules: opts.applyCourseRules });
7790
+ await opts.beforeWrite();
7791
+ writeResult = await applyBundle(bundle, process.cwd(), {
7792
+ course: opts.course,
7793
+ profile: opts.profile,
7794
+ onConflict,
7795
+ applyCourseRules: opts.applyCourseRules,
7796
+ catalogContentHash: lesson.contentHash,
7797
+ lang: result.responseHeaders.get("X-Content-Language") ?? opts.lang
7798
+ });
7799
+ } catch (error) {
7800
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "write_failed";
7801
+ return { lessonId: lesson.lessonId, title: lesson.title, fetched: true, status: "errored", resources: [], error: { code, message: error instanceof Error ? error.message : String(error), retry: `10x get ${lesson.lessonId}` } };
7802
+ }
7054
7803
  const resources = classifyFromWriteResult(writeResult, lesson.lessonId);
7055
7804
  return {
7056
7805
  lessonId: lesson.lessonId,
@@ -7068,6 +7817,8 @@ function actionToBucket(action) {
7068
7817
  case "conflict_overwritten":
7069
7818
  case "conflict_saved_user":
7070
7819
  return "upstream-updated";
7820
+ case "preserved_local":
7821
+ return "preserved-local";
7071
7822
  case "conflict_skipped":
7072
7823
  return "skipped-conflict";
7073
7824
  case "removed":
@@ -7104,18 +7855,18 @@ function classifyFromWriteResult(result, lessonId) {
7104
7855
  ...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "prompts", prompt.name) } : {}
7105
7856
  });
7106
7857
  }
7107
- out.push({ type: "rules", name: "course-rules", bucket: actionToBucket(result.rules.action) });
7858
+ out.push({ type: "rules", name: "course-rules", bucket: actionToBucket(result.rules.action), remediation: `10x get ${lessonId} --type rules` });
7108
7859
  for (const config of result.configs) {
7109
7860
  out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
7110
7861
  }
7111
7862
  for (const entry of result.removals.skills) {
7112
- out.push({ type: "skills", name: entry.name, bucket: "removed" });
7863
+ out.push({ type: "skills", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
7113
7864
  }
7114
7865
  for (const entry of result.removals.prompts) {
7115
- out.push({ type: "prompts", name: entry.name, bucket: "removed" });
7866
+ out.push({ type: "prompts", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
7116
7867
  }
7117
7868
  for (const entry of result.removals.configs) {
7118
- out.push({ type: "configs", name: entry.name, bucket: "removed" });
7869
+ out.push({ type: "configs", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
7119
7870
  }
7120
7871
  return out;
7121
7872
  }
@@ -7143,18 +7894,18 @@ function classifyFromPlan(plan, lessonId, force) {
7143
7894
  ...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "prompts", prompt.name) } : {}
7144
7895
  });
7145
7896
  }
7146
- out.push({ type: "rules", name: "course-rules", bucket: actionToBucket(plan.rules.action) });
7897
+ out.push({ type: "rules", name: "course-rules", bucket: plan.rules.isConflict ? "skipped-conflict" : actionToBucket(plan.rules.action), remediation: `10x get ${lessonId} --type rules` });
7147
7898
  for (const config of plan.configs) {
7148
7899
  out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
7149
7900
  }
7150
7901
  for (const entry of plan.removals.skills) {
7151
- out.push({ type: "skills", name: entry.name, bucket: "removed" });
7902
+ out.push({ type: "skills", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
7152
7903
  }
7153
7904
  for (const entry of plan.removals.prompts) {
7154
- out.push({ type: "prompts", name: entry.name, bucket: "removed" });
7905
+ out.push({ type: "prompts", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
7155
7906
  }
7156
7907
  for (const entry of plan.removals.configs) {
7157
- out.push({ type: "configs", name: entry.name, bucket: "removed" });
7908
+ out.push({ type: "configs", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
7158
7909
  }
7159
7910
  return out;
7160
7911
  }
@@ -7172,7 +7923,8 @@ function countBuckets(outcomes) {
7172
7923
  "upstream-updated": 0,
7173
7924
  unchanged: 0,
7174
7925
  "skipped-conflict": 0,
7175
- removed: 0
7926
+ removed: 0,
7927
+ "preserved-local": 0
7176
7928
  };
7177
7929
  for (const o of outcomes) {
7178
7930
  for (const r2 of o.resources)
@@ -7188,6 +7940,7 @@ function renderReport(ctx, profile, input) {
7188
7940
  if (ctx.json) {
7189
7941
  output(ctx, "", {
7190
7942
  course: input.course,
7943
+ selectionReason: input.selectionReason,
7191
7944
  tool: profile.toolId,
7192
7945
  dryRun: input.dryRun,
7193
7946
  force: input.force,
@@ -7207,7 +7960,8 @@ function renderReport(ctx, profile, input) {
7207
7960
  upstreamUpdated: buckets["upstream-updated"],
7208
7961
  unchanged: buckets.unchanged,
7209
7962
  skippedConflict: buckets["skipped-conflict"],
7210
- removed: buckets.removed
7963
+ removed: buckets.removed,
7964
+ preservedLocal: buckets["preserved-local"]
7211
7965
  }
7212
7966
  }
7213
7967
  });
@@ -7217,7 +7971,7 @@ function renderReport(ctx, profile, input) {
7217
7971
  const verb = input.dryRun ? "Would sync" : "Synced";
7218
7972
  const scope = input.mode === "all" ? "all unlocked lessons" : "downloaded lessons";
7219
7973
  const moduleNote = input.module !== undefined ? ` in module ${input.module}` : "";
7220
- lines.push(`${verb} ${input.course} — ${scope}${moduleNote}:`);
7974
+ lines.push(`${verb} ${input.course} (${input.selectionReason}) — ${scope}${moduleNote}:`);
7221
7975
  if (outcomes.length === 0) {
7222
7976
  lines.push(" (nothing to sync)");
7223
7977
  }
@@ -7231,6 +7985,10 @@ function renderReport(ctx, profile, input) {
7231
7985
  const summary = summarizeLesson(o);
7232
7986
  lines.push(` ${o.lessonId} — ${o.status}${summary ? ` (${summary})` : ""}`);
7233
7987
  for (const r2 of o.resources) {
7988
+ if (r2.bucket === "preserved-local") {
7989
+ lines.push(` preserved ${r2.type}/${r2.name} — ${r2.reason ?? "local file"}`);
7990
+ continue;
7991
+ }
7234
7992
  if (r2.bucket !== "skipped-conflict")
7235
7993
  continue;
7236
7994
  const label = r2.file ? `${r2.type}/${r2.name} (${r2.file})` : `${r2.type}/${r2.name}`;
@@ -7244,9 +8002,9 @@ function renderReport(ctx, profile, input) {
7244
8002
  lines.push(` ${ex.lessonId} — ${ex.reason}`);
7245
8003
  }
7246
8004
  lines.push("");
7247
- lines.push(`Totals: ${buckets["upstream-updated"]} updated, ${buckets.created} new, ` + `${buckets.unchanged} unchanged, ${buckets["skipped-conflict"]} skipped (conflicts), ` + `${buckets.removed} removed.`);
8005
+ lines.push(`Totals: ${buckets["upstream-updated"]} updated, ${buckets.created} new, ` + `${buckets.unchanged} unchanged, ${buckets["skipped-conflict"]} skipped (conflicts), ` + `${buckets.removed} removed, ${buckets["preserved-local"]} preserved locally.`);
7248
8006
  if (lessonsConflicts > 0 && !input.force) {
7249
- lines.push("To take all upstream updates over your local edits: 10x sync --force");
8007
+ lines.push("To replace edited skills and prompts: 10x sync --force. Resolve rule conflicts with 10x get <lesson> --type rules.");
7250
8008
  }
7251
8009
  output(ctx, lines.join(`
7252
8010
  `), undefined);
@@ -7257,7 +8015,8 @@ function summarizeLesson(o) {
7257
8015
  "upstream-updated": 0,
7258
8016
  unchanged: 0,
7259
8017
  "skipped-conflict": 0,
7260
- removed: 0
8018
+ removed: 0,
8019
+ "preserved-local": 0
7261
8020
  };
7262
8021
  for (const r2 of o.resources)
7263
8022
  counts[r2.bucket]++;
@@ -7275,6 +8034,10 @@ function summarizeLesson(o) {
7275
8034
  return parts.join(", ");
7276
8035
  }
7277
8036
  function handleCatalogError(ctx, status, code, error) {
8037
+ if (["course_access_denied", "module_locked", "course_unavailable"].includes(code))
8038
+ outputError(ctx, code, error, status === 403 ? ExitCodes.FORBIDDEN : ExitCodes.ERROR);
8039
+ if (code === "release_mismatch")
8040
+ outputError(ctx, code, error, ExitCodes.ERROR);
7278
8041
  if (status === 401) {
7279
8042
  outputError(ctx, "auth_required", "Your session is no longer valid.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
7280
8043
  }
@@ -7287,7 +8050,11 @@ function handleCatalogError(ctx, status, code, error) {
7287
8050
  outputError(ctx, code || "catalog_fetch_failed", "Failed to fetch the course catalog.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
7288
8051
  }
7289
8052
  function lessonErrorMessage(status, code, error) {
7290
- if (status === 403)
8053
+ if (code === "release_mismatch")
8054
+ return error;
8055
+ if (code === "course_access_denied" || code === "course_unavailable")
8056
+ return error;
8057
+ if (status === 403 && code === "module_locked")
7291
8058
  return "Module is locked.";
7292
8059
  if (status === 404)
7293
8060
  return "Lesson not found.";