@przeprogramowani/10x-cli 1.19.1 → 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.19.1",
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,12 +3799,302 @@ async function requireAuth(ctx, options = {}) {
3690
3799
  throw new Error("requireAuth: unreachable");
3691
3800
  }
3692
3801
 
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
+ }
3810
+ }
3811
+ var KEYSET = [
3812
+ {
3813
+ keyId: 1,
3814
+ publicKey: "MCowBQYDK2VwAyEANXEwq1y+9CYAJS6ze9m/c212fL4r1BauXwKdT6Zo5Ko="
3815
+ }
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;
3830
+ }
3831
+ if (hostname !== "localhost" && hostname !== "127.0.0.1") {
3832
+ return KEYSET;
3833
+ }
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;
3841
+ }
3842
+ }
3843
+ return parsed;
3844
+ } catch {
3845
+ return KEYSET;
3846
+ }
3847
+ }
3848
+ function sha256Hex(body) {
3849
+ return createHash("sha256").update(body).digest("hex");
3850
+ }
3851
+ function canonicalSigningString(keyId, hashHex) {
3852
+ return `v1:${keyId}:${hashHex}`;
3853
+ }
3854
+ function verifyBundleSignature(rawBody, signature, keyId, headerHash) {
3855
+ verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, loadEffectiveKeyset());
3856
+ }
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`);
3861
+ }
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.");
3865
+ }
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.");
3875
+ }
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.");
3881
+ }
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
+
3693
4088
  // src/commands/auth.ts
4089
+ var AUTH_METHODS = ["email", "circle"];
4090
+ function isAuthMethod(value) {
4091
+ return AUTH_METHODS.includes(value);
4092
+ }
3694
4093
  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) => {
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) => {
3696
4095
  const ctx = resolveContext(options);
3697
4096
  if (options.status) {
3698
- runStatus(ctx);
4097
+ await runStatus(ctx);
3699
4098
  return;
3700
4099
  }
3701
4100
  if (options.logout) {
@@ -3705,7 +4104,7 @@ function registerAuthCommand(cli) {
3705
4104
  await runLogin(ctx, options);
3706
4105
  });
3707
4106
  }
3708
- function runStatus(ctx) {
4107
+ async function runStatus(ctx) {
3709
4108
  const auth = readAuth();
3710
4109
  if (!auth) {
3711
4110
  outputError(ctx, "auth_required", "You're not signed in.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in.");
@@ -3715,12 +4114,21 @@ function runStatus(ctx) {
3715
4114
  if (expired) {
3716
4115
  outputError(ctx, "auth_expired", `Your session for ${auth.email} has expired.`, ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
3717
4116
  }
3718
- output(ctx, `Signed in as ${auth.email} — session expires ${formatExpiry(expiresAt)}.`, {
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}`, {
3719
4121
  email: auth.email,
3720
4122
  expires_at: auth.expires_at,
3721
- is_valid: true
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 } }
3722
4127
  });
3723
4128
  }
4129
+ function describeMethod(method) {
4130
+ return method === "circle" ? "Circle message" : "email magic link";
4131
+ }
3724
4132
  function formatExpiry(expiresAt) {
3725
4133
  if (!Number.isFinite(expiresAt.getTime()))
3726
4134
  return "at an unknown time";
@@ -3745,7 +4153,12 @@ function runLogout(ctx) {
3745
4153
  });
3746
4154
  }
3747
4155
  async function runLogin(ctx, options) {
4156
+ const method = await collectMethod(ctx, options);
3748
4157
  const email = await collectEmail(ctx, options);
4158
+ if (method === "circle") {
4159
+ await runCircleLogin(ctx, email);
4160
+ return;
4161
+ }
3749
4162
  if (!ctx.json)
3750
4163
  we("10x auth");
3751
4164
  const sp = ctx.json ? null : L2();
@@ -3774,7 +4187,8 @@ async function runLogin(ctx, options) {
3774
4187
  access_token: result.tokens.token,
3775
4188
  refresh_token: result.tokens.refresh_token,
3776
4189
  expires_at: result.tokens.expires_at,
3777
- created_at: new Date().toISOString()
4190
+ created_at: new Date().toISOString(),
4191
+ method: "email"
3778
4192
  };
3779
4193
  saveAuth(auth);
3780
4194
  if (!ctx.json)
@@ -3782,7 +4196,8 @@ async function runLogin(ctx, options) {
3782
4196
  output(ctx, "", {
3783
4197
  authenticated: true,
3784
4198
  email,
3785
- expires_at: auth.expires_at
4199
+ expires_at: auth.expires_at,
4200
+ method: "email"
3786
4201
  });
3787
4202
  return;
3788
4203
  }
@@ -3798,8 +4213,130 @@ async function runLogin(ctx, options) {
3798
4213
  pollSpinner?.stop("Cancelled.", 1);
3799
4214
  outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
3800
4215
  }
3801
- pollSpinner?.stop("Authentication failed.", 1);
3802
- outputError(ctx, result.code || "auth_error", result.message || "Authentication failed.", ExitCodes.ERROR);
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;
3803
4340
  }
3804
4341
  async function collectEmail(ctx, options) {
3805
4342
  if (options.email) {
@@ -4099,31 +4636,279 @@ function formatRow(entry, rank, name, detail, rankWidth, nameWidth, detailWidth)
4099
4636
 
4100
4637
  // src/commands/bench-kit.ts
4101
4638
  import { spawn } from "node:child_process";
4102
- import { existsSync as existsSync4, mkdtempSync, readFileSync as readFileSync3, rmSync as rmSync3 } from "node:fs";
4639
+ import { existsSync as existsSync4, mkdtempSync, readFileSync as readFileSync4, rmSync as rmSync4 } from "node:fs";
4103
4640
  import { tmpdir } from "node:os";
4104
- import { basename, join as join4, resolve } from "node:path";
4641
+ import { basename, join as join5, resolve as resolve2 } from "node:path";
4105
4642
 
4106
4643
  // src/lib/tool-detect.ts
4107
4644
  import { existsSync as existsSync3 } from "node:fs";
4108
- import { join as join3 } from "node:path";
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
+
4657
+ // src/lib/tool-profile.ts
4658
+ var SENTINEL_BEGIN = "<!-- BEGIN @przeprogramowani/10x-cli -->";
4659
+ var SENTINEL_END = "<!-- END @przeprogramowani/10x-cli -->";
4660
+ var PROFILES = {
4661
+ "claude-code": {
4662
+ toolId: "claude-code",
4663
+ displayName: "Claude Code",
4664
+ skillPath: (n) => `.claude/skills/${n}/SKILL.md`,
4665
+ skillDir: (n) => `.claude/skills/${n}`,
4666
+ promptPath: (n) => `.claude/prompts/${n}.md`,
4667
+ configPath: (n) => `.claude/config-templates/${n}`,
4668
+ rulesFile: "CLAUDE.md",
4669
+ manifestDir: ".claude",
4670
+ sentinelBegin: SENTINEL_BEGIN,
4671
+ sentinelEnd: SENTINEL_END
4672
+ },
4673
+ cursor: {
4674
+ toolId: "cursor",
4675
+ displayName: "Cursor",
4676
+ skillPath: (n) => `.cursor/skills/${n}/SKILL.md`,
4677
+ skillDir: (n) => `.cursor/skills/${n}`,
4678
+ promptPath: (n) => `.cursor/prompts/${n}.md`,
4679
+ configPath: (n) => `.cursor/config-templates/${n}`,
4680
+ rulesFile: ".cursor/rules/10x-course.mdc",
4681
+ manifestDir: ".cursor",
4682
+ sentinelBegin: SENTINEL_BEGIN,
4683
+ sentinelEnd: SENTINEL_END
4684
+ },
4685
+ copilot: {
4686
+ toolId: "copilot",
4687
+ displayName: "GitHub Copilot",
4688
+ skillPath: (n) => `.github/skills/${n}/SKILL.md`,
4689
+ skillDir: (n) => `.github/skills/${n}`,
4690
+ promptPath: (n) => `.github/prompts/${n}.md`,
4691
+ configPath: (n) => `.github/config-templates/${n}`,
4692
+ rulesFile: ".github/copilot-instructions.md",
4693
+ manifestDir: ".github",
4694
+ sentinelBegin: SENTINEL_BEGIN,
4695
+ sentinelEnd: SENTINEL_END
4696
+ },
4697
+ codex: {
4698
+ toolId: "codex",
4699
+ displayName: "Codex CLI",
4700
+ skillPath: (n) => `.agents/skills/${n}/SKILL.md`,
4701
+ skillDir: (n) => `.agents/skills/${n}`,
4702
+ promptPath: (n) => `.agents/prompts/${n}.md`,
4703
+ configPath: (n) => `.agents/config-templates/${n}`,
4704
+ rulesFile: "AGENTS.md",
4705
+ manifestDir: ".agents",
4706
+ sentinelBegin: SENTINEL_BEGIN,
4707
+ sentinelEnd: SENTINEL_END
4708
+ },
4709
+ "devin-desktop": {
4710
+ toolId: "devin-desktop",
4711
+ contentToolId: "windsurf",
4712
+ displayName: "Devin Desktop",
4713
+ skillPath: (n) => `.devin/skills/${n}/SKILL.md`,
4714
+ skillDir: (n) => `.devin/skills/${n}`,
4715
+ promptPath: (n) => `.devin/prompts/${n}.md`,
4716
+ configPath: (n) => `.devin/config-templates/${n}`,
4717
+ rulesFile: "AGENTS.md",
4718
+ manifestDir: ".devin",
4719
+ sentinelBegin: SENTINEL_BEGIN,
4720
+ sentinelEnd: SENTINEL_END
4721
+ },
4722
+ gemini: {
4723
+ toolId: "gemini",
4724
+ displayName: "Gemini CLI",
4725
+ skillPath: (n) => `.gemini/skills/${n}/SKILL.md`,
4726
+ skillDir: (n) => `.gemini/skills/${n}`,
4727
+ promptPath: (n) => `.gemini/prompts/${n}.md`,
4728
+ configPath: (n) => `.gemini/config-templates/${n}`,
4729
+ rulesFile: "GEMINI.md",
4730
+ manifestDir: ".gemini",
4731
+ sentinelBegin: SENTINEL_BEGIN,
4732
+ sentinelEnd: SENTINEL_END
4733
+ },
4734
+ generic: {
4735
+ toolId: "generic",
4736
+ displayName: "Other / Generic",
4737
+ skillPath: (n) => `.ai/skills/${n}/SKILL.md`,
4738
+ skillDir: (n) => `.ai/skills/${n}`,
4739
+ promptPath: (n) => `.ai/prompts/${n}.md`,
4740
+ configPath: (n) => `.ai/config-templates/${n}`,
4741
+ rulesFile: "AGENTS.md",
4742
+ manifestDir: ".ai",
4743
+ sentinelBegin: SENTINEL_BEGIN,
4744
+ sentinelEnd: SENTINEL_END
4745
+ }
4746
+ };
4747
+ var TOOL_ALIASES = {
4748
+ windsurf: "devin-desktop"
4749
+ };
4750
+ var LEGACY_PROFILES = {
4751
+ windsurf: {
4752
+ toolId: "windsurf",
4753
+ displayName: "Windsurf",
4754
+ skillPath: (n) => `.windsurf/skills/${n}/SKILL.md`,
4755
+ skillDir: (n) => `.windsurf/skills/${n}`,
4756
+ promptPath: (n) => `.windsurf/prompts/${n}.md`,
4757
+ configPath: (n) => `.windsurf/config-templates/${n}`,
4758
+ rulesFile: ".windsurfrules",
4759
+ manifestDir: ".windsurf",
4760
+ sentinelBegin: SENTINEL_BEGIN,
4761
+ sentinelEnd: SENTINEL_END
4762
+ }
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
+ }
4109
4896
 
4110
4897
  // 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
4898
  var MANIFEST_FILENAME = ".10x-cli-manifest.json";
4115
4899
  var CLI_PACKAGE_NAME = "@przeprogramowani/10x-cli";
4116
4900
  var MANIFEST_VERSION = 3;
4117
4901
  function contentHash(content) {
4118
- return createHash("sha256").update(content, "utf8").digest("hex");
4902
+ return createHash2("sha256").update(content).digest("hex");
4119
4903
  }
4120
4904
  function readManifest(dir) {
4121
- const manifestPath = join2(dir, MANIFEST_FILENAME);
4905
+ const manifestPath = join3(dir, MANIFEST_FILENAME);
4906
+ assertProjectFilePath(dirname2(dir), manifestPath);
4122
4907
  if (!existsSync2(manifestPath))
4123
4908
  return null;
4124
4909
  let raw;
4125
4910
  try {
4126
- raw = readFileSync2(manifestPath, "utf8");
4911
+ raw = readFileSync3(manifestPath, "utf8");
4127
4912
  } catch {
4128
4913
  return null;
4129
4914
  }
@@ -4138,22 +4923,28 @@ function readManifest(dir) {
4138
4923
  return parsed;
4139
4924
  }
4140
4925
  function writeManifest(dir, manifest) {
4926
+ assertProjectFilePath(dirname2(dir), join3(dir, MANIFEST_FILENAME));
4927
+ assertProjectFilePath(dirname2(dir), join3(dir, `${MANIFEST_FILENAME}.tmp`));
4141
4928
  if (!existsSync2(dir)) {
4142
4929
  mkdirSync3(dir, { recursive: true });
4143
4930
  }
4144
- const manifestPath = join2(dir, MANIFEST_FILENAME);
4931
+ const manifestPath = join3(dir, MANIFEST_FILENAME);
4145
4932
  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);
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
+ }
4151
4942
  }
4152
4943
  function isManifest(value) {
4153
4944
  if (typeof value !== "object" || value === null)
4154
4945
  return false;
4155
4946
  const v3 = value;
4156
- if (v3["package"] !== CLI_PACKAGE_NAME)
4947
+ if (v3["package"] !== CLI_PACKAGE_NAME || "directSkills" in v3)
4157
4948
  return false;
4158
4949
  if (typeof v3["version"] !== "string")
4159
4950
  return false;
@@ -4170,8 +4961,20 @@ function isManifest(value) {
4170
4961
  if (typeof files !== "object" || files === null)
4171
4962
  return false;
4172
4963
  const f2 = files;
4964
+ if ("directSkills" in f2 || f2["promptHashes"] !== undefined && !isStringRecord(f2["promptHashes"]))
4965
+ return false;
4173
4966
  if (!(isSkillsRecord(f2["skills"]) && isStringArray(f2["prompts"]) && isStringArray(f2["configs"])))
4174
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
+ }
4175
4978
  const lessons = v3["lessons"];
4176
4979
  if (lessons !== undefined && !isLessonsRecord(lessons))
4177
4980
  return false;
@@ -4184,11 +4987,14 @@ function isSkillsRecord(value) {
4184
4987
  if (typeof entry !== "object" || entry === null)
4185
4988
  return false;
4186
4989
  const e2 = entry;
4187
- if (!isStringArray(e2["files"]))
4990
+ if (!isStringArray(e2["files"]) || e2["contentHashes"] !== undefined && !isStringRecord(e2["contentHashes"]))
4188
4991
  return false;
4189
4992
  }
4190
4993
  return true;
4191
4994
  }
4995
+ function isStringRecord(value) {
4996
+ return !!value && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string");
4997
+ }
4192
4998
  function isStringArray(value) {
4193
4999
  return Array.isArray(value) && value.every((item) => typeof item === "string");
4194
5000
  }
@@ -4209,6 +5015,14 @@ function isLessonFilesEntry(value) {
4209
5015
  if (e2["catalogContentHash"] !== undefined && typeof e2["catalogContentHash"] !== "string") {
4210
5016
  return false;
4211
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
+ }
4212
5026
  return isStringArray(e2["prompts"]) && isStringArray(e2["configs"]);
4213
5027
  }
4214
5028
  function isLessonsRecord(value) {
@@ -4224,120 +5038,51 @@ function buildUnionFiles(lessons) {
4224
5038
  const skills = {};
4225
5039
  const prompts = new Set;
4226
5040
  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] };
4245
- }
4246
-
4247
- // src/lib/tool-profile.ts
4248
- var SENTINEL_BEGIN = "<!-- BEGIN @przeprogramowani/10x-cli -->";
4249
- var SENTINEL_END = "<!-- END @przeprogramowani/10x-cli -->";
4250
- var PROFILES = {
4251
- "claude-code": {
4252
- toolId: "claude-code",
4253
- displayName: "Claude Code",
4254
- skillPath: (n) => `.claude/skills/${n}/SKILL.md`,
4255
- skillDir: (n) => `.claude/skills/${n}`,
4256
- promptPath: (n) => `.claude/prompts/${n}.md`,
4257
- configPath: (n) => `.claude/config-templates/${n}`,
4258
- rulesFile: "CLAUDE.md",
4259
- manifestDir: ".claude",
4260
- sentinelBegin: SENTINEL_BEGIN,
4261
- sentinelEnd: SENTINEL_END
4262
- },
4263
- cursor: {
4264
- toolId: "cursor",
4265
- displayName: "Cursor",
4266
- skillPath: (n) => `.cursor/skills/${n}/SKILL.md`,
4267
- skillDir: (n) => `.cursor/skills/${n}`,
4268
- promptPath: (n) => `.cursor/prompts/${n}.md`,
4269
- configPath: (n) => `.cursor/config-templates/${n}`,
4270
- rulesFile: ".cursor/rules/10x-course.mdc",
4271
- manifestDir: ".cursor",
4272
- sentinelBegin: SENTINEL_BEGIN,
4273
- sentinelEnd: SENTINEL_END
4274
- },
4275
- copilot: {
4276
- toolId: "copilot",
4277
- displayName: "GitHub Copilot",
4278
- skillPath: (n) => `.github/skills/${n}/SKILL.md`,
4279
- skillDir: (n) => `.github/skills/${n}`,
4280
- promptPath: (n) => `.github/prompts/${n}.md`,
4281
- configPath: (n) => `.github/config-templates/${n}`,
4282
- rulesFile: ".github/copilot-instructions.md",
4283
- manifestDir: ".github",
4284
- sentinelBegin: SENTINEL_BEGIN,
4285
- sentinelEnd: SENTINEL_END
4286
- },
4287
- codex: {
4288
- toolId: "codex",
4289
- displayName: "Codex CLI",
4290
- skillPath: (n) => `.agents/skills/${n}/SKILL.md`,
4291
- skillDir: (n) => `.agents/skills/${n}`,
4292
- promptPath: (n) => `.agents/prompts/${n}.md`,
4293
- configPath: (n) => `.agents/config-templates/${n}`,
4294
- rulesFile: "AGENTS.md",
4295
- manifestDir: ".agents",
4296
- sentinelBegin: SENTINEL_BEGIN,
4297
- sentinelEnd: SENTINEL_END
4298
- },
4299
- windsurf: {
4300
- toolId: "windsurf",
4301
- displayName: "Windsurf",
4302
- skillPath: (n) => `.windsurf/skills/${n}/SKILL.md`,
4303
- skillDir: (n) => `.windsurf/skills/${n}`,
4304
- promptPath: (n) => `.windsurf/prompts/${n}.md`,
4305
- configPath: (n) => `.windsurf/config-templates/${n}`,
4306
- rulesFile: ".windsurfrules",
4307
- manifestDir: ".windsurf",
4308
- sentinelBegin: SENTINEL_BEGIN,
4309
- sentinelEnd: SENTINEL_END
4310
- },
4311
- gemini: {
4312
- toolId: "gemini",
4313
- displayName: "Gemini CLI",
4314
- skillPath: (n) => `.gemini/skills/${n}/SKILL.md`,
4315
- skillDir: (n) => `.gemini/skills/${n}`,
4316
- promptPath: (n) => `.gemini/prompts/${n}.md`,
4317
- configPath: (n) => `.gemini/config-templates/${n}`,
4318
- rulesFile: "GEMINI.md",
4319
- manifestDir: ".gemini",
4320
- sentinelBegin: SENTINEL_BEGIN,
4321
- sentinelEnd: SENTINEL_END
4322
- },
4323
- generic: {
4324
- toolId: "generic",
4325
- displayName: "Other / Generic",
4326
- skillPath: (n) => `.ai/skills/${n}/SKILL.md`,
4327
- skillDir: (n) => `.ai/skills/${n}`,
4328
- promptPath: (n) => `.ai/prompts/${n}.md`,
4329
- configPath: (n) => `.ai/config-templates/${n}`,
4330
- rulesFile: "AGENTS.md",
4331
- manifestDir: ".ai",
4332
- sentinelBegin: SENTINEL_BEGIN,
4333
- sentinelEnd: SENTINEL_END
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);
4334
5057
  }
4335
- };
4336
- var DEFAULT_TOOL = "claude-code";
5058
+ return { skills, prompts: [...prompts], configs: [...configs] };
5059
+ }
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
+ };
5081
+ }
4337
5082
 
4338
5083
  // src/lib/tool-detect.ts
4339
5084
  function detectTools(projectRoot) {
4340
- const hit = (rel) => existsSync3(join3(projectRoot, rel));
5085
+ const hit = (rel) => existsSync3(join4(projectRoot, rel));
4341
5086
  const signals = [];
4342
5087
  if (hit(".claude/" + MANIFEST_FILENAME)) {
4343
5088
  signals.push({
@@ -4391,16 +5136,34 @@ function detectTools(projectRoot) {
4391
5136
  } else if (hit("AGENTS.md") && !hit(".ai")) {
4392
5137
  signals.push({ profileId: "codex", confidence: "medium", reason: "AGENTS.md" });
4393
5138
  }
4394
- if (hit(".windsurf/" + MANIFEST_FILENAME)) {
5139
+ if (hit(".devin/" + MANIFEST_FILENAME)) {
5140
+ signals.push({
5141
+ profileId: "devin-desktop",
5142
+ confidence: "strong",
5143
+ reason: ".devin/.10x-cli-manifest.json"
5144
+ });
5145
+ } else if (hit(".devin/rules")) {
5146
+ signals.push({ profileId: "devin-desktop", confidence: "strong", reason: ".devin/rules/" });
5147
+ } else if (hit(".devin")) {
5148
+ signals.push({ profileId: "devin-desktop", confidence: "medium", reason: ".devin/ directory" });
5149
+ } else if (hit(".windsurf/" + MANIFEST_FILENAME)) {
4395
5150
  signals.push({
4396
- profileId: "windsurf",
5151
+ profileId: "devin-desktop",
4397
5152
  confidence: "strong",
4398
- reason: ".windsurf/.10x-cli-manifest.json"
5153
+ reason: ".windsurf/.10x-cli-manifest.json (legacy)"
4399
5154
  });
4400
5155
  } else if (hit(".windsurfrules")) {
4401
- signals.push({ profileId: "windsurf", confidence: "strong", reason: ".windsurfrules" });
5156
+ signals.push({
5157
+ profileId: "devin-desktop",
5158
+ confidence: "strong",
5159
+ reason: ".windsurfrules (legacy)"
5160
+ });
4402
5161
  } else if (hit(".windsurf")) {
4403
- signals.push({ profileId: "windsurf", confidence: "medium", reason: ".windsurf/ directory" });
5162
+ signals.push({
5163
+ profileId: "devin-desktop",
5164
+ confidence: "medium",
5165
+ reason: ".windsurf/ directory (legacy)"
5166
+ });
4404
5167
  }
4405
5168
  if (hit(".gemini/" + MANIFEST_FILENAME)) {
4406
5169
  signals.push({
@@ -4427,7 +5190,15 @@ function detectTools(projectRoot) {
4427
5190
  return rankSignals(signals);
4428
5191
  }
4429
5192
  var CONFIDENCE_ORDER = { strong: 3, medium: 2, weak: 1 };
4430
- var PROFILE_ORDER = ["claude-code", "cursor", "copilot", "codex", "windsurf", "gemini", "generic"];
5193
+ var PROFILE_ORDER = [
5194
+ "claude-code",
5195
+ "cursor",
5196
+ "copilot",
5197
+ "codex",
5198
+ "devin-desktop",
5199
+ "gemini",
5200
+ "generic"
5201
+ ];
4431
5202
  function rankSignals(signals) {
4432
5203
  return [...signals].sort((a2, b2) => {
4433
5204
  const c2 = CONFIDENCE_ORDER[b2.confidence] - CONFIDENCE_ORDER[a2.confidence];
@@ -4445,7 +5216,7 @@ function topDetectedProfile(signals) {
4445
5216
 
4446
5217
  // src/commands/bench-kit.ts
4447
5218
  var TEMPLATE_REPO_URL = "https://github.com/przeprogramowani/10x-bench-kit";
4448
- var BOOTSTRAP_ENTRY = join4(".bench-kit", "bootstrap", "index.mjs");
5219
+ var BOOTSTRAP_ENTRY = join5(".bench-kit", "bootstrap", "index.mjs");
4449
5220
  var CONTRACT_VERSION = 1;
4450
5221
  function registerBenchKitCommand(cli) {
4451
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) => {
@@ -4462,7 +5233,7 @@ function registerBenchKitCommand(cli) {
4462
5233
  });
4463
5234
  }
4464
5235
  async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
4465
- const targetDir = resolve(dirArg ?? ".");
5236
+ const targetDir = resolve2(dirArg ?? ".");
4466
5237
  const requestedRef = normalizeRef(ctx, options.templateVersion);
4467
5238
  await preflight(ctx, deps);
4468
5239
  const existingVersion = readInstanceVersion(targetDir);
@@ -4470,7 +5241,7 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
4470
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.");
4471
5242
  }
4472
5243
  const toolChoice = await resolveInstanceTool(ctx, options, deps, existingVersion !== null);
4473
- const scratch = mkdtempSync(join4(tmpdir(), "bench-kit-"));
5244
+ const scratch = mkdtempSync(join5(tmpdir(), "bench-kit-"));
4474
5245
  try {
4475
5246
  await cloneTemplateOrDie(ctx, deps, requestedRef, scratch, "init");
4476
5247
  const entry = requireBootstrap(ctx, scratch);
@@ -4507,14 +5278,14 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
4507
5278
  }
4508
5279
  let baseRepoClone = "skipped";
4509
5280
  if (res.baseRepoClone != null) {
4510
- const cloneDest = join4(targetDir, res.baseRepoClone.dest);
5281
+ const cloneDest = join5(targetDir, res.baseRepoClone.dest);
4511
5282
  verbose(ctx, `cloning base repo into ${res.baseRepoClone.dest}`);
4512
5283
  const cloned = await deps.cloneBaseRepo(res.baseRepoClone, cloneDest);
4513
5284
  if (cloned.ok) {
4514
5285
  baseRepoClone = "cloned";
4515
5286
  } else {
4516
5287
  baseRepoClone = "failed";
4517
- rmSync3(cloneDest, { recursive: true, force: true });
5288
+ rmSync4(cloneDest, { recursive: true, force: true });
4518
5289
  verbose(ctx, `base repo clone failed (${cloned.error.trim().split(`
4519
5290
  `).pop() ?? ""})`);
4520
5291
  }
@@ -4541,7 +5312,7 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
4541
5312
  ...res.runnerDeps === "failed" ? ["Runner dependencies did not install — run 'npm ci --prefix .bench-kit/runner' yourself."] : res.runnerDeps === "installed" ? ["Runner dependencies installed (.bench-kit/runner/node_modules)."] : [],
4542
5313
  res.committed === true ? "Initialized a fresh git repository with an initial commit." : "Initialized a fresh git repository (initial commit skipped — commit the files yourself).",
4543
5314
  ...(res.warnings ?? []).map((warning) => `Warning: ${warning}`),
4544
- "Next: wire up secrets, then run 'bench validate' before the first run."
5315
+ `Next: go to ${targetDir}, launch you preferred agent and apply skill *bench-wiring*`
4545
5316
  ];
4546
5317
  output(ctx, humanLines.join(`
4547
5318
  `), {
@@ -4561,11 +5332,11 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
4561
5332
  warnings: res.warnings ?? []
4562
5333
  });
4563
5334
  } finally {
4564
- rmSync3(scratch, { recursive: true, force: true });
5335
+ rmSync4(scratch, { recursive: true, force: true });
4565
5336
  }
4566
5337
  }
4567
5338
  async function runBenchKitUpdate(ctx, dirArg, options, deps = defaultDeps) {
4568
- const targetDir = resolve(dirArg ?? ".");
5339
+ const targetDir = resolve2(dirArg ?? ".");
4569
5340
  const requestedRef = normalizeRef(ctx, options.templateVersion);
4570
5341
  await preflight(ctx, deps);
4571
5342
  const currentVersion = readInstanceVersion(targetDir);
@@ -4580,7 +5351,7 @@ async function runBenchKitUpdate(ctx, dirArg, options, deps = defaultDeps) {
4580
5351
  if (!status.ok) {
4581
5352
  verbose(ctx, "not a git repository — proceeding, but review the changes without git diff");
4582
5353
  }
4583
- const scratch = mkdtempSync(join4(tmpdir(), "bench-kit-"));
5354
+ const scratch = mkdtempSync(join5(tmpdir(), "bench-kit-"));
4584
5355
  try {
4585
5356
  await cloneTemplateOrDie(ctx, deps, requestedRef, scratch, "update");
4586
5357
  const entry = requireBootstrap(ctx, scratch);
@@ -4638,7 +5409,7 @@ async function runBenchKitUpdate(ctx, dirArg, options, deps = defaultDeps) {
4638
5409
  warnings: res.warnings ?? []
4639
5410
  });
4640
5411
  } finally {
4641
- rmSync3(scratch, { recursive: true, force: true });
5412
+ rmSync4(scratch, { recursive: true, force: true });
4642
5413
  }
4643
5414
  }
4644
5415
  function describeSync(counts) {
@@ -4648,10 +5419,11 @@ function describeSync(counts) {
4648
5419
  }
4649
5420
  async function resolveInstanceTool(ctx, options, deps, existingInstance) {
4650
5421
  if (options.tool !== undefined) {
4651
- if (!PROFILES[options.tool]) {
5422
+ const canonicalId = canonicalToolId(options.tool);
5423
+ if (!getToolProfile(options.tool)) {
4652
5424
  outputError(ctx, "unknown_tool", `'${options.tool}' is not a supported agent tool.`, ExitCodes.USAGE, `Supported: ${Object.keys(PROFILES).join(", ")}.`);
4653
5425
  }
4654
- return { id: options.tool, explicit: true };
5426
+ return { id: canonicalId, explicit: true };
4655
5427
  }
4656
5428
  const signals = deps.detectToolSignals(process.cwd());
4657
5429
  const top = signals[0];
@@ -4666,7 +5438,7 @@ async function resolveInstanceTool(ctx, options, deps, existingInstance) {
4666
5438
  return { id: choice !== null && PROFILES[choice] ? choice : initial, explicit: choice !== null };
4667
5439
  }
4668
5440
  function skillRootFor(toolId) {
4669
- const profile = PROFILES[toolId] ?? PROFILES[DEFAULT_TOOL];
5441
+ const profile = getToolProfile(toolId) ?? PROFILES[DEFAULT_TOOL];
4670
5442
  return `${profile.manifestDir}/skills`;
4671
5443
  }
4672
5444
  function allToolProfiles() {
@@ -4700,17 +5472,17 @@ async function cloneTemplateOrDie(ctx, deps, ref, scratch, action) {
4700
5472
  }
4701
5473
  }
4702
5474
  function requireBootstrap(ctx, scratch) {
4703
- const entry = join4(scratch, BOOTSTRAP_ENTRY);
5475
+ const entry = join5(scratch, BOOTSTRAP_ENTRY);
4704
5476
  if (!existsSync4(entry)) {
4705
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.");
4706
5478
  }
4707
5479
  return entry;
4708
5480
  }
4709
5481
  function readInstanceVersion(dir) {
4710
- const versionFile = join4(dir, ".bench-kit", "VERSION");
5482
+ const versionFile = join5(dir, ".bench-kit", "VERSION");
4711
5483
  if (!existsSync4(versionFile))
4712
5484
  return null;
4713
- return readFileSync3(versionFile, "utf8").trim();
5485
+ return readFileSync4(versionFile, "utf8").trim();
4714
5486
  }
4715
5487
  function toHttpsUrl(url) {
4716
5488
  const scp = url.match(/^git@([^:/]+):(.+)$/);
@@ -4836,214 +5608,76 @@ var defaultDeps = {
4836
5608
 
4837
5609
  // src/commands/doctor.ts
4838
5610
  import { existsSync as existsSync5, accessSync, constants } from "node:fs";
4839
- import { join as join5 } from "node:path";
5611
+ import { join as join6 } from "node:path";
4840
5612
 
4841
- // src/lib/signing.ts
4842
- import { createHash as createHash2, createPublicKey, verify } from "node:crypto";
4843
-
4844
- class SignatureError extends Error {
4845
- constructor(message) {
5613
+ // src/lib/course-selection.ts
5614
+ class CourseSelectionError extends Error {
5615
+ code;
5616
+ constructor(code, message) {
4846
5617
  super(message);
4847
- this.name = "SignatureError";
4848
- }
4849
- }
4850
- var KEYSET = [
4851
- {
4852
- keyId: 1,
4853
- publicKey: "MCowBQYDK2VwAyEANXEwq1y+9CYAJS6ze9m/c212fL4r1BauXwKdT6Zo5Ko="
4854
- }
4855
- ];
4856
- var REQUIRE_SIGNATURES = true;
4857
- function loadEffectiveKeyset() {
4858
- const override = process.env["BUNDLE_PUBLIC_KEYSET"];
4859
- if (!override)
4860
- return KEYSET;
4861
- const apiBase = process.env["API_BASE_URL"];
4862
- if (!apiBase)
4863
- return KEYSET;
4864
- let hostname;
4865
- try {
4866
- hostname = new URL(apiBase).hostname;
4867
- } catch {
4868
- return KEYSET;
4869
- }
4870
- if (hostname !== "localhost" && hostname !== "127.0.0.1") {
4871
- return KEYSET;
4872
- }
4873
- try {
4874
- const parsed = JSON.parse(override);
4875
- if (!Array.isArray(parsed) || parsed.length === 0)
4876
- return KEYSET;
4877
- for (const k3 of parsed) {
4878
- if (typeof k3.keyId !== "number" || typeof k3.publicKey !== "string") {
4879
- return KEYSET;
4880
- }
4881
- }
4882
- return parsed;
4883
- } catch {
4884
- return KEYSET;
4885
- }
4886
- }
4887
- function sha256Hex(body) {
4888
- return createHash2("sha256").update(body).digest("hex");
4889
- }
4890
- function canonicalSigningString(keyId, hashHex) {
4891
- return `v1:${keyId}:${hashHex}`;
4892
- }
4893
- function verifyBundleSignature(rawBody, signature, keyId, headerHash) {
4894
- verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, loadEffectiveKeyset());
4895
- }
4896
- function verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, keyset) {
4897
- const key = keyset.find((k3) => k3.keyId === keyId);
4898
- if (!key) {
4899
- throw new SignatureError(`Unknown signing key (key_id=${keyId}). Update your CLI: npm update -g @przeprogramowani/10x-cli`);
4900
- }
4901
- const computedHash = sha256Hex(rawBody);
4902
- if (computedHash !== headerHash) {
4903
- 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.");
4904
- }
4905
- 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) {
4906
5640
  try {
4907
- pubKey = createPublicKey({
4908
- key: Buffer.from(key.publicKey, "base64"),
4909
- format: "der",
4910
- type: "spki"
4911
- });
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);
4912
5646
  } catch {
4913
- throw new SignatureError("Failed to load the signing public key. Your CLI binary may be corrupted — reinstall it.");
4914
- }
4915
- const canonical = canonicalSigningString(keyId, computedHash);
4916
- const sig = Buffer.from(signature, "base64");
4917
- const valid = verify(null, Buffer.from(canonical), pubKey, sig);
4918
- if (!valid) {
4919
- 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.");
4920
- }
4921
- }
4922
-
4923
- // src/lib/api-content.ts
4924
- function fetchCatalog(course, token, options = {}) {
4925
- return apiGet(`/api/catalog/${encodeURIComponent(course)}`, { token, signal: options.signal });
4926
- }
4927
- function fetchModuleDetail(course, module, token, options = {}) {
4928
- return apiGet(`/api/modules/${encodeURIComponent(course)}/${module}`, { token, signal: options.signal });
4929
- }
4930
- async function fetchLesson(course, lessonId, token, options = {}) {
4931
- const params = new URLSearchParams;
4932
- if (options.lang)
4933
- params.set("lang", options.lang);
4934
- if (options.tool)
4935
- params.set("tool", options.tool);
4936
- const qs = params.toString();
4937
- const path = `/api/lessons/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}${qs ? `?${qs}` : ""}`;
4938
- const result = await apiGet(path, { token, signal: options.signal });
4939
- if (!result.ok)
4940
- return result;
4941
- const signature = result.responseHeaders.get("X-Bundle-Signature");
4942
- const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
4943
- const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
4944
- if (signature && keyIdRaw && headerHash) {
4945
- const keyId = Number(keyIdRaw);
4946
- try {
4947
- verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
4948
- } catch (err) {
4949
- const message = err instanceof Error ? err.message : String(err);
4950
- return {
4951
- ok: false,
4952
- status: 0,
4953
- code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
4954
- error: message
4955
- };
4956
- }
4957
- } else if (signature || keyIdRaw || headerHash) {
4958
- return {
4959
- ok: false,
4960
- status: 0,
4961
- code: "signature_error",
4962
- 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."
4963
- };
4964
- } else if (REQUIRE_SIGNATURES) {
4965
- return {
4966
- ok: false,
4967
- status: 0,
4968
- code: "signature_missing",
4969
- error: "Bundle is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
4970
- };
4971
- } else {
4972
- process.stderr.write(`Warning: bundle is not signed. Signature verification skipped.
4973
- `);
5647
+ return false;
4974
5648
  }
4975
- return result;
4976
5649
  }
4977
- async function fetchArtifact(course, lessonId, type, name, tool, token, options = {}) {
4978
- const params = new URLSearchParams({ tool });
4979
- if (options.lang)
4980
- params.set("lang", options.lang);
4981
- const path = `/api/artifacts/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?${params}`;
4982
- const result = await apiGet(path, { token, signal: options.signal });
4983
- if (!result.ok)
4984
- return result;
4985
- const signature = result.responseHeaders.get("X-Bundle-Signature");
4986
- const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
4987
- const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
4988
- if (signature && keyIdRaw && headerHash) {
4989
- const keyId = Number(keyIdRaw);
4990
- try {
4991
- verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
4992
- } catch (err) {
4993
- const message = err instanceof Error ? err.message : String(err);
4994
- return {
4995
- ok: false,
4996
- status: 0,
4997
- code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
4998
- error: message
4999
- };
5000
- }
5001
- } else if (signature || keyIdRaw || headerHash) {
5002
- return {
5003
- ok: false,
5004
- status: 0,
5005
- code: "signature_error",
5006
- 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."
5007
- };
5008
- } else if (REQUIRE_SIGNATURES) {
5009
- return {
5010
- ok: false,
5011
- status: 0,
5012
- code: "signature_missing",
5013
- error: "Artifact is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
5014
- };
5015
- } else {
5016
- process.stderr.write(`Warning: artifact is not signed. Signature verification skipped.
5017
- `);
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);
5018
5653
  }
5019
- return result;
5654
+ throw error;
5020
5655
  }
5021
- async function fetchHealth(options = {}) {
5022
- const timeoutMs = options.timeoutMs ?? 5000;
5023
- const controller = new AbortController;
5024
- const timer = setTimeout(() => controller.abort(), timeoutMs);
5025
- 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();
5026
5660
  try {
5027
- const result = await apiGet("/health", { signal: controller.signal });
5028
- const latencyMs = Date.now() - started;
5029
- return { ...result, latencyMs };
5030
- } catch (err) {
5031
- const latencyMs = Date.now() - started;
5032
- const aborted = controller.signal.aborted;
5033
- return {
5034
- ok: false,
5035
- status: 0,
5036
- code: aborted ? "timeout" : "network_error",
5037
- error: err instanceof Error ? err.message : String(err),
5038
- latencyMs
5039
- };
5040
- } finally {
5041
- 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);
5042
5679
  }
5043
5680
  }
5044
- function apiBaseUrl() {
5045
- return resolveApiBase();
5046
- }
5047
5681
 
5048
5682
  // src/lib/update-check.ts
5049
5683
  var NPM_REGISTRY_URL = "https://registry.npmjs.org/@przeprogramowani/10x-cli/latest";
@@ -5118,7 +5752,7 @@ function registerDoctorCommand(cli) {
5118
5752
  async function runDoctor(ctx) {
5119
5753
  verbose(ctx, "running doctor checks");
5120
5754
  const checks = [];
5121
- checks.push(checkAuth());
5755
+ checks.push(await checkAuth());
5122
5756
  checks.push(await checkApiConnectivity());
5123
5757
  checks.push(checkConfigDirectory());
5124
5758
  checks.push(await checkCliVersion());
@@ -5167,7 +5801,7 @@ async function runDoctor(ctx) {
5167
5801
  process.exit(EX_CONFIG);
5168
5802
  }
5169
5803
  }
5170
- function checkAuth() {
5804
+ async function checkAuth() {
5171
5805
  const auth = readAuth();
5172
5806
  if (!auth) {
5173
5807
  return {
@@ -5188,6 +5822,16 @@ function checkAuth() {
5188
5822
  details: { email: auth.email, expires_at: auth.expires_at }
5189
5823
  };
5190
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 };
5191
5835
  const nearExpiry = isNearExpiry(auth);
5192
5836
  const prettyDate = formatReleaseAt(auth.expires_at);
5193
5837
  if (nearExpiry) {
@@ -5197,15 +5841,15 @@ function checkAuth() {
5197
5841
  status: "warn",
5198
5842
  message: `Signed in as ${auth.email}, but the session expires ${prettyDate.toLowerCase()}.`,
5199
5843
  hint: "The next command will transparently refresh the token.",
5200
- details: { email: auth.email, expires_at: auth.expires_at }
5844
+ details: { email: auth.email, expires_at: auth.expires_at, ...accessDetails }
5201
5845
  };
5202
5846
  }
5203
5847
  return {
5204
5848
  name: "auth",
5205
5849
  label: "Auth",
5206
5850
  status: "pass",
5207
- message: `Signed in as ${auth.email} — session expires ${prettyDate.toLowerCase()}.`,
5208
- 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 }
5209
5853
  };
5210
5854
  }
5211
5855
  async function checkApiConnectivity() {
@@ -5304,9 +5948,9 @@ async function checkCliVersion() {
5304
5948
  function checkToolDirectory() {
5305
5949
  const cwd = process.cwd();
5306
5950
  const toolId = readToolConfig()?.tool ?? DEFAULT_TOOL;
5307
- const profile = PROFILES[toolId] ?? PROFILES[DEFAULT_TOOL];
5951
+ const profile = getToolProfile(toolId) ?? PROFILES[DEFAULT_TOOL];
5308
5952
  const dirName = profile.manifestDir;
5309
- const toolDir = join5(cwd, dirName);
5953
+ const toolDir = join6(cwd, dirName);
5310
5954
  if (!existsSync5(toolDir)) {
5311
5955
  return {
5312
5956
  name: "tool-dir",
@@ -5339,7 +5983,7 @@ function checkToolDirectory() {
5339
5983
  }
5340
5984
 
5341
5985
  // src/commands/get.ts
5342
- import { join as join8 } from "node:path";
5986
+ import { join as join10 } from "node:path";
5343
5987
 
5344
5988
  // src/lib/conflict-prompt.ts
5345
5989
  init_dist2();
@@ -5427,108 +6071,163 @@ function parseModuleRef(raw) {
5427
6071
  init_dist2();
5428
6072
 
5429
6073
  // src/lib/tool-switch.ts
5430
- import {
5431
- existsSync as existsSync7,
5432
- lstatSync,
5433
- mkdirSync as mkdirSync5,
5434
- readFileSync as readFileSync6,
5435
- renameSync as renameSync3,
5436
- rmdirSync as rmdirSync2,
5437
- rmSync as rmSync5,
5438
- statSync,
5439
- writeFileSync as writeFileSync4
5440
- } from "node:fs";
5441
- 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";
5442
6077
 
5443
- // src/lib/fs-utils.ts
5444
- import { readFileSync as readFileSync4 } from "node:fs";
5445
- function readFileOrNull(path) {
5446
- try {
5447
- return readFileSync4(path, "utf8");
5448
- } catch {
5449
- 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);
5450
6123
  }
5451
6124
  }
5452
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
+
5453
6130
  // src/lib/sentinel-migration.ts
5454
6131
  var OLD_BEGIN = "<!-- BEGIN @przeprogramowani/10x-toolkit -->";
5455
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
+ }
5456
6146
  function removeRulesBlockWithMarkers(existing, begin, end) {
5457
- const beginIdx = existing.indexOf(begin);
5458
- const endIdx = existing.indexOf(end);
5459
- if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
6147
+ const block = inspectRulesBlock(existing, begin, end);
6148
+ if (!block)
5460
6149
  return { content: existing, removed: false };
5461
- }
5462
- const beforeRaw = existing.slice(0, beginIdx);
5463
- const afterRaw = existing.slice(endIdx + end.length);
5464
- const before = beforeRaw.replace(/[\r\n]*$/, "");
5465
- const after = afterRaw.replace(/^[\r\n]*/, "");
5466
- const joiner = before.length > 0 && after.length > 0 ? `
5467
-
5468
- ` : "";
5469
- const combined = before + joiner + after;
5470
- const content = combined.length === 0 ? "" : combined.replace(/[\r\n]*$/, `
5471
- `);
5472
- return { content, removed: true };
6150
+ return { content: existing.slice(0, block.start) + existing.slice(block.finish), removed: true };
5473
6151
  }
5474
- function applyRulesBlockWithMarkers(existingContent, rulesBody, beginMarker, endMarker) {
5475
- for (const marker of [OLD_BEGIN, OLD_END, beginMarker, endMarker]) {
5476
- if (rulesBody.includes(marker)) {
5477
- throw new Error(`rules body contains a sentinel marker (${JSON.stringify(marker)}) — refusing to write to prevent rules file corruption`);
5478
- }
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)})`);
5479
6156
  }
5480
- const warnings = [];
5481
- let content = existingContent;
5482
- content = stripBlock(content, OLD_BEGIN, OLD_END, "@przeprogramowani/10x-toolkit", warnings);
5483
- content = stripBlock(content, beginMarker, endMarker, "@przeprogramowani/10x-cli", warnings);
5484
- content = content.replace(/\n{3,}/g, `
5485
-
5486
- `).trim();
5487
- 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}
5488
6160
 
5489
- ${rulesBody.trim()}
5490
-
5491
- ${endMarker}`;
5492
- const result = content.length > 0 ? `${content}
5493
-
5494
- ${block}
5495
- ` : `${block}
5496
- `;
5497
- return { content: result, warnings };
5498
- }
5499
- function stripBlock(content, begin, end, label, warnings) {
5500
- const beginIdx = content.indexOf(begin);
5501
- const endIdx = content.indexOf(end);
5502
- if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
5503
- return content.slice(0, beginIdx) + content.slice(endIdx + end.length);
5504
- }
5505
- if (beginIdx !== -1 && endIdx === -1) {
5506
- warnings.push(`CLAUDE.md has an orphan ${label} BEGIN marker; truncating from the marker onward.`);
5507
- return content.slice(0, beginIdx);
5508
- }
5509
- if (beginIdx === -1 && endIdx !== -1) {
5510
- warnings.push(`CLAUDE.md has an orphan ${label} END marker; truncating content before the marker.`);
5511
- return content.slice(0, endIdx);
5512
- }
5513
- if (beginIdx !== -1 && endIdx !== -1 && endIdx < beginIdx) {
5514
- warnings.push(`CLAUDE.md has out-of-order ${label} markers; repairing by truncating to the first marker.`);
5515
- return content.slice(0, Math.min(beginIdx, endIdx));
5516
- }
5517
- 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 };
5518
6220
  }
5519
6221
 
5520
6222
  // src/lib/writer.ts
5521
6223
  import {
5522
6224
  chmodSync as chmodSync2,
5523
- existsSync as existsSync6,
6225
+ existsSync as existsSync8,
5524
6226
  mkdirSync as mkdirSync4,
5525
- readFileSync as readFileSync5,
5526
- readdirSync,
5527
- rmdirSync,
5528
- rmSync as rmSync4,
5529
- writeFileSync as writeFileSync3
6227
+ readFileSync as readFileSync7,
6228
+ writeFileSync as writeFileSync4
5530
6229
  } from "node:fs";
5531
- import { dirname as dirname3, join as join6 } from "node:path";
6230
+ import { dirname as dirname4, join as join8 } from "node:path";
5532
6231
  var CLI_VERSION = package_default.version;
5533
6232
  var DEFAULT_COURSE = "10xdevs3";
5534
6233
  async function applyBundle(bundle, projectRoot, options = {}) {
@@ -5536,221 +6235,208 @@ async function applyBundle(bundle, projectRoot, options = {}) {
5536
6235
  const partial = options.partial === true;
5537
6236
  const course = options.course ?? DEFAULT_COURSE;
5538
6237
  const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
5539
- const onConflict = options.onConflict;
5540
6238
  const applyCourseRules = options.applyCourseRules !== false;
5541
- const manifestDir = join6(projectRoot, profile.manifestDir);
5542
- const prevManifest = readManifest(manifestDir);
5543
- const plan = planBundle(bundle, projectRoot, { profile, applyCourseRules });
5544
- const nextSkillHashes = {};
5545
- const nextPromptHashes = {};
5546
- 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
+ };
5547
6310
  for (let si = 0;si < bundle.skills.length; si++) {
5548
6311
  const skill = bundle.skills[si];
5549
- const planSkill = plan.skills[si];
5550
- const prevEntry = prevManifest?.files.skills[skill.name];
5551
- const fileWrites = [];
5552
- const skillHashes = {};
6312
+ const writes = [];
6313
+ result.skills.push({ name: skill.name, files: writes });
5553
6314
  for (let fi = 0;fi < skill.files.length; fi++) {
5554
6315
  const file = skill.files[fi];
5555
- const planFile = planSkill.files[fi];
5556
- const target = planFile.path;
5557
- const storedHash = prevEntry?.contentHashes?.[file.path];
5558
- const { action, isConflict } = planFile;
5559
- let finalAction = action;
5560
- let userBackupPath;
5561
- if (isConflict) {
5562
- const resolution = onConflict ? await onConflict({
5563
- artifactType: "skill",
5564
- artifactName: `${skill.name}/${file.path}`,
5565
- filePath: target,
5566
- relativePath: file.path
5567
- }) : "skip";
5568
- if (resolution === "overwrite") {
5569
- finalAction = "conflict_overwritten";
5570
- if (!dryRun)
5571
- writeFileAt(target, file.content);
5572
- skillHashes[file.path] = contentHash(file.content);
5573
- } else if (resolution === "save_user") {
5574
- finalAction = "conflict_saved_user";
5575
- if (!dryRun) {
5576
- userBackupPath = buildUserBackupPath(target);
5577
- copyFileSync(target, userBackupPath);
5578
- writeFileAt(target, file.content);
5579
- }
5580
- skillHashes[file.path] = contentHash(file.content);
5581
- } else {
5582
- finalAction = "conflict_skipped";
5583
- if (storedHash)
5584
- skillHashes[file.path] = storedHash;
5585
- }
5586
- } else {
5587
- if (!dryRun && action !== "unchanged") {
5588
- 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);
5589
6323
  }
5590
- if (!dryRun && file.executable === true && action !== "unchanged") {
5591
- chmodSync2(target, 493);
5592
- }
5593
- 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();
5594
6332
  }
5595
- fileWrites.push({ path: file.path, absolutePath: target, action: finalAction, userBackupPath });
5596
6333
  }
5597
- nextSkillHashes[skill.name] = skillHashes;
5598
- skills.push({ name: skill.name, files: fileWrites });
5599
6334
  }
5600
- const prompts = [];
5601
6335
  for (let pi = 0;pi < bundle.prompts.length; pi++) {
5602
6336
  const prompt = bundle.prompts[pi];
5603
- const planPrompt = plan.prompts[pi];
5604
- const target = planPrompt.path;
5605
- const promptFilename = `${prompt.name}.md`;
5606
- const storedHash = prevManifest?.files.promptHashes?.[promptFilename];
5607
- const { action, isConflict } = planPrompt;
5608
- let finalAction = action;
5609
- let userBackupPath;
5610
- if (isConflict) {
5611
- const resolution = onConflict ? await onConflict({
5612
- artifactType: "prompt",
5613
- artifactName: prompt.name,
5614
- filePath: target,
5615
- relativePath: promptFilename
5616
- }) : "skip";
5617
- if (resolution === "overwrite") {
5618
- finalAction = "conflict_overwritten";
5619
- if (!dryRun)
5620
- writeFileAt(target, prompt.content);
5621
- nextPromptHashes[promptFilename] = contentHash(prompt.content);
5622
- } else if (resolution === "save_user") {
5623
- finalAction = "conflict_saved_user";
5624
- if (!dryRun) {
5625
- userBackupPath = buildUserBackupPath(target);
5626
- copyFileSync(target, userBackupPath);
5627
- writeFileAt(target, prompt.content);
5628
- }
5629
- nextPromptHashes[promptFilename] = contentHash(prompt.content);
5630
- } else {
5631
- finalAction = "conflict_skipped";
5632
- if (storedHash)
5633
- nextPromptHashes[promptFilename] = storedHash;
5634
- }
5635
- } else {
5636
- if (!dryRun && action !== "unchanged") {
5637
- 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);
5638
6366
  }
5639
- nextPromptHashes[promptFilename] = contentHash(prompt.content);
5640
6367
  }
5641
- prompts.push({ name: prompt.name, path: target, action: finalAction, userBackupPath });
5642
- }
5643
- const rulesFilePath = join6(projectRoot, profile.rulesFile);
5644
- const existingRules = readFileOrEmpty(rulesFilePath);
5645
- const { action: rulesAction, content: newRulesContent } = planRules(existingRules, bundle, profile, applyCourseRules);
5646
- if (!dryRun && rulesAction !== "unchanged") {
5647
- writeFileAt(rulesFilePath, newRulesContent);
5648
6368
  }
5649
- const configs = bundle.configs.map((config, ci) => {
5650
- const planConfig = plan.configs[ci];
5651
- if (!dryRun && planConfig.action === "created") {
5652
- writeFileAt(planConfig.path, config.content);
5653
- }
5654
- return { name: config.name, path: planConfig.path, action: planConfig.action };
5655
- });
5656
- const removed = computeRemovals(prevManifest, bundle, profile, projectRoot);
5657
- const removalResult = {
5658
- skills: [],
5659
- prompts: [],
5660
- configs: []
5661
- };
5662
- for (const entry of removed.skillDirs) {
5663
- removalResult.skills.push({ name: entry.name, path: entry.path, action: "removed" });
5664
- if (!dryRun && !partial)
5665
- rmSync4(entry.path, { recursive: true, force: true });
5666
- }
5667
- for (const entry of removed.skillFiles) {
5668
- removalResult.skills.push({ name: entry.name, path: entry.path, action: "removed" });
5669
- if (!dryRun && !partial) {
5670
- rmSync4(entry.path, { force: true });
5671
- removeEmptyParentDirs(entry.path, entry.skillDirAbs);
5672
- }
5673
- }
5674
- for (const entry of removed.prompts) {
5675
- removalResult.prompts.push({ name: entry.name, path: entry.path, action: "removed" });
5676
- if (!dryRun && !partial)
5677
- rmSync4(entry.path, { force: true });
5678
- }
5679
- for (const entry of removed.configs) {
5680
- removalResult.configs.push({ name: entry.name, path: entry.path, action: "removed" });
5681
- if (!dryRun && !partial)
5682
- rmSync4(entry.path, { force: true });
5683
- }
5684
- if (!dryRun && !partial) {
5685
- const catalogContentHash = options.catalogContentHash ?? prevManifest?.lessons?.[bundle.lessonId]?.catalogContentHash;
5686
- const newLessonEntry = {
5687
- appliedAt: new Date().toISOString(),
5688
- skills: Object.fromEntries(bundle.skills.map((s) => [s.name, { files: s.files.map((f2) => f2.path) }])),
5689
- prompts: bundle.prompts.map((p2) => `${p2.name}.md`),
5690
- configs: bundle.configs.map((c2) => c2.name),
5691
- ...catalogContentHash !== undefined ? { catalogContentHash } : {}
5692
- };
5693
- let baseLessons = {};
5694
- if (prevManifest && !prevManifest.lessons) {
5695
- baseLessons[prevManifest.lessonId] = {
5696
- appliedAt: prevManifest.lastApplied,
5697
- skills: Object.fromEntries(Object.entries(prevManifest.files.skills).map(([name, entry]) => [
5698
- name,
5699
- { files: [...entry.files] }
5700
- ])),
5701
- prompts: [...prevManifest.files.prompts],
5702
- configs: [...prevManifest.files.configs]
5703
- };
5704
- } else if (prevManifest?.lessons) {
5705
- baseLessons = { ...prevManifest.lessons };
5706
- }
5707
- const lessons = {
5708
- ...baseLessons,
5709
- [bundle.lessonId]: newLessonEntry
5710
- };
5711
- const union = buildUnionFiles(lessons);
5712
- const unionSkills = {};
5713
- for (const [name, skill] of Object.entries(union.skills)) {
5714
- const prevHash = prevManifest?.files.skills[name]?.contentHashes;
5715
- const currentHash = nextSkillHashes[name];
5716
- unionSkills[name] = {
5717
- files: skill.files,
5718
- contentHashes: { ...prevHash, ...currentHash }
5719
- };
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
+ }
5720
6420
  }
5721
- const unionPromptHashes = {
5722
- ...prevManifest?.files.promptHashes,
5723
- ...nextPromptHashes
5724
- };
5725
- const nextManifest = {
5726
- package: CLI_PACKAGE_NAME,
5727
- version: CLI_VERSION,
5728
- manifestVersion: MANIFEST_VERSION,
5729
- lastApplied: new Date().toISOString(),
5730
- lessonId: bundle.lessonId,
5731
- course,
5732
- tool: profile.toolId,
5733
- files: {
5734
- skills: unionSkills,
5735
- prompts: union.prompts,
5736
- configs: union.configs,
5737
- promptHashes: unionPromptHashes
5738
- },
5739
- lessons
5740
- };
5741
- writeManifest(manifestDir, nextManifest);
5742
6421
  }
5743
- return {
5744
- skills,
5745
- prompts,
5746
- rules: { action: rulesAction },
5747
- configs,
5748
- removals: removalResult
5749
- };
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;
5750
6433
  }
5751
6434
  function planBundle(bundle, projectRoot, options = {}) {
5752
6435
  const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
5753
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");
5754
6440
  for (const skill of bundle.skills) {
5755
6441
  assertSafeName(skill.name, "skill");
5756
6442
  for (const file of skill.files)
@@ -5760,13 +6446,15 @@ function planBundle(bundle, projectRoot, options = {}) {
5760
6446
  assertSafeName(prompt.name, "prompt");
5761
6447
  for (const config of bundle.configs)
5762
6448
  assertSafeName(config.name, "config");
5763
- const manifestDir = join6(projectRoot, profile.manifestDir);
6449
+ const manifestDir = join8(projectRoot, profile.manifestDir);
6450
+ assertProjectFilePath(projectRoot, join8(manifestDir, MANIFEST_FILENAME));
5764
6451
  const prevManifest = readManifest(manifestDir);
5765
6452
  const skills = bundle.skills.map((skill) => {
5766
- const skillDir = join6(projectRoot, profile.skillDir(skill.name));
6453
+ const skillDir = join8(projectRoot, profile.skillDir(skill.name));
5767
6454
  const prevEntry = prevManifest?.files.skills[skill.name];
5768
6455
  const files = skill.files.map((file) => {
5769
- const target = join6(skillDir, file.path);
6456
+ const target = join8(skillDir, file.path);
6457
+ assertProjectFilePath(projectRoot, target);
5770
6458
  const storedHash = prevEntry?.contentHashes?.[file.path];
5771
6459
  const { action, isConflict } = computeFileAction(target, file.content, storedHash);
5772
6460
  return {
@@ -5780,7 +6468,8 @@ function planBundle(bundle, projectRoot, options = {}) {
5780
6468
  return { name: skill.name, files };
5781
6469
  });
5782
6470
  const prompts = bundle.prompts.map((prompt) => {
5783
- const target = join6(projectRoot, profile.promptPath(prompt.name));
6471
+ const target = join8(projectRoot, profile.promptPath(prompt.name));
6472
+ assertProjectFilePath(projectRoot, target);
5784
6473
  const storedHash = prevManifest?.files.promptHashes?.[`${prompt.name}.md`];
5785
6474
  const { action, isConflict } = computeFileAction(target, prompt.content, storedHash);
5786
6475
  return {
@@ -5791,12 +6480,14 @@ function planBundle(bundle, projectRoot, options = {}) {
5791
6480
  upstreamChanged: computeUpstreamChanged(prompt.content, storedHash, action)
5792
6481
  };
5793
6482
  });
5794
- const rulesFilePath = join6(projectRoot, profile.rulesFile);
5795
- const existingRules = readFileOrEmpty(rulesFilePath);
5796
- 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);
5797
6487
  const configs = bundle.configs.map((config) => {
5798
- const target = join6(projectRoot, profile.configPath(config.name));
5799
- 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";
5800
6491
  return {
5801
6492
  name: config.name,
5802
6493
  path: target,
@@ -5806,58 +6497,56 @@ function planBundle(bundle, projectRoot, options = {}) {
5806
6497
  };
5807
6498
  });
5808
6499
  const removed = computeRemovals(prevManifest, bundle, profile, projectRoot);
5809
- const removals = {
5810
- skills: [
5811
- ...removed.skillDirs.map((e2) => ({ name: e2.name, path: e2.path })),
5812
- ...removed.skillFiles.map((e2) => ({ name: e2.name, path: e2.path }))
5813
- ],
5814
- prompts: removed.prompts.map((e2) => ({ name: e2.name, path: e2.path })),
5815
- configs: removed.configs.map((e2) => ({ name: e2.name, path: e2.path }))
5816
- };
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;
5817
6512
  return {
5818
6513
  skills,
5819
6514
  prompts,
5820
- rules: { action: rulesAction, upstreamChanged: rulesAction !== "unchanged" },
6515
+ rules: { action: rulesPlan.action, isConflict: rulesPlan.isConflict, reason: rulesPlan.reason, upstreamChanged: rulesPlan.action !== "unchanged" },
5821
6516
  configs,
5822
6517
  removals
5823
6518
  };
5824
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
+ }
5825
6541
  function computeUpstreamChanged(newContent, storedHash, action) {
5826
6542
  if (storedHash === undefined)
5827
6543
  return action !== "unchanged";
5828
6544
  return contentHash(newContent) !== storedHash;
5829
6545
  }
5830
- function planRules(existingRules, bundle, profile, applyCourseRules) {
5831
- if (!applyCourseRules) {
5832
- const { content: stripped, removed } = removeRulesBlockWithMarkers(existingRules, profile.sentinelBegin, profile.sentinelEnd);
5833
- if (removed && stripped !== existingRules)
5834
- return { action: "removed", content: stripped };
5835
- return { action: "unchanged", content: existingRules };
5836
- }
5837
- if (bundle.rules.length === 0)
5838
- return { action: "unchanged", content: existingRules };
5839
- const rulesBody = bundle.rules.map((r2) => r2.content.trim()).join(`
5840
-
5841
- `);
5842
- const { content: newRules } = applyRulesBlockWithMarkers(existingRules, rulesBody, profile.sentinelBegin, profile.sentinelEnd);
5843
- let action;
5844
- if (newRules === existingRules)
5845
- action = "unchanged";
5846
- else if (existingRules.length === 0)
5847
- action = "created";
5848
- else
5849
- action = "updated";
5850
- return { action, content: newRules };
5851
- }
5852
6546
  function computeFileAction(filePath, newContent, storedHash) {
5853
- if (!existsSync6(filePath))
6547
+ if (!existsSync8(filePath))
5854
6548
  return { action: "created", isConflict: false };
5855
- let current;
5856
- try {
5857
- current = readFileSync5(filePath, "utf8");
5858
- } catch {
5859
- return { action: "updated", isConflict: false };
5860
- }
6549
+ const current = readFileSync7(filePath, "utf8");
5861
6550
  if (current === newContent)
5862
6551
  return { action: "unchanged", isConflict: false };
5863
6552
  if (storedHash !== undefined) {
@@ -5870,152 +6559,62 @@ function computeFileAction(filePath, newContent, storedHash) {
5870
6559
  }
5871
6560
  function buildUserBackupPath(filePath) {
5872
6561
  const lastDot = filePath.lastIndexOf(".");
5873
- if (lastDot === -1)
5874
- return `${filePath}.user`;
5875
- 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;
5876
6569
  }
5877
6570
  function copyFileSync(src, dest) {
5878
- mkdirSync4(dirname3(dest), { recursive: true });
5879
- writeFileSync3(dest, readFileSync5(src));
5880
- }
5881
- function readFileOrEmpty(filePath) {
5882
- if (!existsSync6(filePath))
5883
- return "";
5884
- try {
5885
- return readFileSync5(filePath, "utf8");
5886
- } catch {
5887
- return "";
5888
- }
6571
+ mkdirSync4(dirname4(dest), { recursive: true });
6572
+ writeFileSync4(dest, readFileSync7(src));
5889
6573
  }
5890
6574
  function writeFileAt(filePath, content) {
5891
- mkdirSync4(dirname3(filePath), { recursive: true });
5892
- writeFileSync3(filePath, content);
5893
- }
5894
- function computeRemovals(prevManifest, bundle, profile, projectRoot) {
5895
- const empty = {
5896
- skillDirs: [],
5897
- skillFiles: [],
5898
- prompts: [],
5899
- configs: []
5900
- };
5901
- if (!prevManifest?.lessons)
5902
- return empty;
5903
- const prevLessonEntry = prevManifest.lessons[bundle.lessonId];
5904
- if (!prevLessonEntry)
5905
- return empty;
5906
- const protectedSkills = new Map;
5907
- const protectedPrompts = new Set;
5908
- const protectedConfigs = new Set;
5909
- for (const [lessonId, entry] of Object.entries(prevManifest.lessons)) {
5910
- if (lessonId === bundle.lessonId)
5911
- continue;
5912
- for (const [name, skill] of Object.entries(entry.skills)) {
5913
- if (!protectedSkills.has(name))
5914
- protectedSkills.set(name, new Set);
5915
- for (const f2 of skill.files)
5916
- protectedSkills.get(name).add(f2);
5917
- }
5918
- for (const p2 of entry.prompts)
5919
- protectedPrompts.add(p2);
5920
- for (const c2 of entry.configs)
5921
- protectedConfigs.add(c2);
5922
- }
5923
- const currentSkills = new Map(bundle.skills.map((s) => [s.name, new Set(s.files.map((f2) => f2.path))]));
5924
- const currentPrompts = new Set(bundle.prompts.map((p2) => `${p2.name}.md`));
5925
- const currentConfigs = new Set(bundle.configs.map((c2) => c2.name));
5926
- const removed = {
5927
- skillDirs: [],
5928
- skillFiles: [],
5929
- prompts: [],
5930
- configs: []
5931
- };
5932
- for (const [skillName, skill] of Object.entries(prevLessonEntry.skills)) {
5933
- if (!isSafeName(skillName))
5934
- continue;
5935
- const skillDirAbs = join6(projectRoot, profile.skillDir(skillName));
5936
- if (!currentSkills.has(skillName)) {
5937
- if (protectedSkills.has(skillName)) {
5938
- const prot = protectedSkills.get(skillName);
5939
- for (const relPath of skill.files) {
5940
- if (prot.has(relPath))
5941
- continue;
5942
- if (!isSafeSkillFilePath(relPath))
5943
- continue;
5944
- removed.skillFiles.push({
5945
- name: `${skillName}/${relPath}`,
5946
- path: join6(skillDirAbs, relPath),
5947
- skillDirAbs
5948
- });
5949
- }
5950
- } else {
5951
- removed.skillDirs.push({ name: skillName, path: skillDirAbs });
5952
- }
5953
- continue;
5954
- }
5955
- const currentFiles = currentSkills.get(skillName);
5956
- for (const relPath of skill.files) {
5957
- if (currentFiles.has(relPath))
5958
- continue;
5959
- if (protectedSkills.get(skillName)?.has(relPath))
5960
- continue;
5961
- 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)))
5962
6594
  continue;
5963
- removed.skillFiles.push({
5964
- name: `${skillName}/${relPath}`,
5965
- path: join6(skillDirAbs, relPath),
5966
- skillDirAbs
5967
- });
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)) }));
5968
6596
  }
5969
6597
  }
5970
- for (const promptFile of prevLessonEntry.prompts) {
5971
- if (currentPrompts.has(promptFile))
5972
- continue;
5973
- if (protectedPrompts.has(promptFile))
5974
- continue;
5975
- if (!isSafeName(promptFile))
5976
- continue;
5977
- const promptName = promptFile.replace(/\.md$/, "");
5978
- removed.prompts.push({
5979
- name: promptFile,
5980
- path: join6(projectRoot, profile.promptPath(promptName))
5981
- });
5982
- }
5983
- for (const configFile of prevLessonEntry.configs) {
5984
- if (currentConfigs.has(configFile))
5985
- continue;
5986
- if (protectedConfigs.has(configFile))
5987
- continue;
5988
- if (!isSafeName(configFile))
5989
- continue;
5990
- removed.configs.push({
5991
- name: configFile,
5992
- path: join6(projectRoot, profile.configPath(configFile))
5993
- });
5994
- }
5995
- return removed;
5996
- }
5997
- function removeEmptyParentDirs(filePath, skillDirAbs) {
5998
- let dir = dirname3(filePath);
5999
- while (dir.startsWith(skillDirAbs) && dir !== skillDirAbs) {
6000
- try {
6001
- if (readdirSync(dir).length > 0)
6002
- return;
6003
- rmdirSync(dir);
6004
- } catch {
6005
- 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" }));
6006
6606
  }
6007
- dir = dirname3(dir);
6008
- }
6607
+ return out;
6009
6608
  }
6010
6609
  function findOrphanedManifests(projectRoot, currentProfile) {
6011
6610
  const out = [];
6012
- for (const profile of Object.values(PROFILES)) {
6611
+ for (const profile of [...Object.values(PROFILES), ...Object.values(LEGACY_PROFILES)]) {
6013
6612
  if (profile.toolId === currentProfile.toolId)
6014
6613
  continue;
6015
- const manifestPath = join6(projectRoot, profile.manifestDir, MANIFEST_FILENAME);
6016
- if (!existsSync6(manifestPath))
6614
+ const manifestPath = join8(projectRoot, profile.manifestDir, MANIFEST_FILENAME);
6615
+ if (!existsSync8(manifestPath))
6017
6616
  continue;
6018
- const manifest = readManifest(join6(projectRoot, profile.manifestDir));
6617
+ const manifest = readManifest(join8(projectRoot, profile.manifestDir));
6019
6618
  if (!manifest)
6020
6619
  continue;
6021
6620
  out.push({ profile, manifestPath, manifest });
@@ -6080,225 +6679,317 @@ function isSafeName(name) {
6080
6679
  }
6081
6680
 
6082
6681
  // src/lib/tool-switch.ts
6083
- function migrateArtifacts(projectRoot, orphan, newProfile) {
6084
- const summary = {
6085
- action: "migrated",
6086
- oldToolId: orphan.profile.toolId,
6087
- movedOrRemoved: { skills: [], prompts: [], configs: [] },
6088
- sentinelStripped: false,
6089
- skipped: []
6090
- };
6091
- const oldProfile = orphan.profile;
6092
- for (const [skillName, entry] of Object.entries(orphan.manifest.files.skills)) {
6093
- if (!isSafeName(skillName)) {
6094
- 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))
6095
6700
  continue;
6096
- }
6097
- const fromSkillDir = join7(projectRoot, oldProfile.skillDir(skillName));
6098
- const toSkillDir = join7(projectRoot, newProfile.skillDir(skillName));
6099
- let anyMoved = false;
6100
- for (const relPath of entry.files) {
6101
- if (!isSafeSkillFilePath(relPath)) {
6102
- summary.skipped.push({
6103
- path: `${skillName}/${relPath}`,
6104
- reason: "unsafe path in manifest"
6105
- });
6106
- continue;
6107
- }
6108
- const from = join7(fromSkillDir, relPath);
6109
- const to = join7(toSkillDir, relPath);
6110
- if (moveIfSafe(from, to, summary.skipped))
6111
- anyMoved = true;
6112
- }
6113
- tryRemoveEmptyDir(fromSkillDir);
6114
- if (anyMoved)
6115
- summary.movedOrRemoved.skills.push(skillName);
6116
- }
6117
- for (const promptFile of orphan.manifest.files.prompts) {
6118
- if (!isSafeName(promptFile)) {
6119
- 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))
6120
6716
  continue;
6121
- }
6122
- const promptName = promptFile.replace(/\.md$/, "");
6123
- const from = join7(projectRoot, oldProfile.promptPath(promptName));
6124
- const to = join7(projectRoot, newProfile.promptPath(promptName));
6125
- if (moveIfSafe(from, to, summary.skipped)) {
6126
- summary.movedOrRemoved.prompts.push(promptFile);
6127
- }
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;
6128
6748
  }
6129
- for (const configFile of orphan.manifest.files.configs) {
6130
- if (!isSafeName(configFile)) {
6131
- summary.skipped.push({ path: configFile, reason: "unsafe name in manifest" });
6132
- continue;
6133
- }
6134
- const from = join7(projectRoot, oldProfile.configPath(configFile));
6135
- const to = join7(projectRoot, newProfile.configPath(configFile));
6136
- if (moveIfSafe(from, to, summary.skipped)) {
6137
- 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
+ }
6138
6775
  }
6776
+ throw error;
6139
6777
  }
6140
- summary.sentinelStripped = stripSentinelFromRulesFile(projectRoot, oldProfile);
6141
- rmSync5(orphan.manifestPath, { force: true });
6142
- tryRemoveEmptyDir(join7(projectRoot, oldProfile.manifestDir));
6143
- return summary;
6144
6778
  }
6145
- function deleteArtifacts(projectRoot, orphan) {
6146
- const summary = {
6147
- action: "deleted",
6148
- oldToolId: orphan.profile.toolId,
6149
- movedOrRemoved: { skills: [], prompts: [], configs: [] },
6150
- sentinelStripped: false,
6151
- skipped: []
6152
- };
6153
- const oldProfile = orphan.profile;
6154
- for (const [skillName, entry] of Object.entries(orphan.manifest.files.skills)) {
6155
- if (!isSafeName(skillName)) {
6156
- 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);
6157
6847
  continue;
6158
6848
  }
6159
- const skillDir = join7(projectRoot, oldProfile.skillDir(skillName));
6160
- for (const relPath of entry.files) {
6161
- if (!isSafeSkillFilePath(relPath)) {
6162
- summary.skipped.push({
6163
- path: `${skillName}/${relPath}`,
6164
- reason: "unsafe path in manifest"
6165
- });
6166
- continue;
6167
- }
6168
- rmSync5(join7(skillDir, relPath), { force: true });
6169
- }
6170
- rmSync5(skillDir, { recursive: true, force: true });
6171
- summary.movedOrRemoved.skills.push(skillName);
6172
- }
6173
- for (const promptFile of orphan.manifest.files.prompts) {
6174
- if (!isSafeName(promptFile)) {
6175
- 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" });
6176
6852
  continue;
6177
6853
  }
6178
- const promptName = promptFile.replace(/\.md$/, "");
6179
- rmSync5(join7(projectRoot, oldProfile.promptPath(promptName)), { force: true });
6180
- summary.movedOrRemoved.prompts.push(promptFile);
6181
- }
6182
- for (const configFile of orphan.manifest.files.configs) {
6183
- if (!isSafeName(configFile)) {
6184
- 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);
6185
6867
  continue;
6186
6868
  }
6187
- rmSync5(join7(projectRoot, oldProfile.configPath(configFile)), { force: true });
6188
- 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));
6189
6881
  }
6190
- summary.sentinelStripped = stripSentinelFromRulesFile(projectRoot, oldProfile);
6191
- rmSync5(orphan.manifestPath, { force: true });
6192
- const manifestDirAbs = join7(projectRoot, oldProfile.manifestDir);
6193
- tryRemoveEmptyDir(join7(manifestDirAbs, "skills"));
6194
- tryRemoveEmptyDir(join7(manifestDirAbs, "prompts"));
6195
- tryRemoveEmptyDir(join7(manifestDirAbs, "config-templates"));
6196
- tryRemoveEmptyDir(manifestDirAbs);
6882
+ commitSourceRules(root, orphan.profile, source, () => {
6883
+ transferRules(root, orphan.profile, profile, source, destination, summary);
6884
+ });
6197
6885
  return summary;
6198
6886
  }
6199
- function moveIfSafe(from, to, skipped) {
6200
- if (!existsSync7(from)) {
6201
- 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;
6202
6895
  }
6203
- if (lstatSync(from).isSymbolicLink()) {
6204
- skipped.push({ path: from, reason: "source is a symlink" });
6205
- 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;
6206
6901
  }
6207
- if (existsSync7(to)) {
6208
- if (!filesAreIdentical(from, to)) {
6209
- skipped.push({
6210
- path: to,
6211
- reason: "destination already exists with different content"
6212
- });
6213
- return false;
6214
- }
6215
- rmSync5(from, { force: true });
6216
- 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;
6217
6905
  }
6218
- mkdirSync5(dirname4(to), { recursive: true });
6219
- try {
6220
- renameSync3(from, to);
6221
- } catch (err) {
6222
- const code = err?.code;
6223
- if (code !== "EXDEV")
6224
- throw err;
6225
- const bytes = readFileSync6(from);
6226
- const tmp = `${to}.tmp`;
6227
- writeFileSync4(tmp, bytes);
6228
- renameSync3(tmp, to);
6229
- try {
6230
- rmSync5(from, { force: true });
6231
- } catch {
6232
- skipped.push({
6233
- path: from,
6234
- reason: "copied to destination but could not remove source"
6235
- });
6236
- 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;
6237
6910
  }
6911
+ destination.managedRules = { ...metadata };
6912
+ writeManifest(join9(root, profile.manifestDir), destination);
6913
+ if (!entries(source).length)
6914
+ delete source.managedRules;
6915
+ return;
6238
6916
  }
6239
- return true;
6240
- }
6241
- function stripSentinelFromRulesFile(projectRoot, oldProfile) {
6242
- const rulesPath = join7(projectRoot, oldProfile.rulesFile);
6243
- if (!existsSync7(rulesPath))
6244
- return false;
6245
- const existing = readFileOrNull(rulesPath);
6246
- if (existing === null)
6247
- return false;
6248
- const { content, removed } = removeRulesBlockWithMarkers(existing, oldProfile.sentinelBegin, oldProfile.sentinelEnd);
6249
- if (!removed)
6250
- return false;
6251
- if (content.trim().length === 0) {
6252
- rmSync5(rulesPath, { force: true });
6253
- } else {
6254
- 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;
6255
6921
  }
6256
- return true;
6257
- }
6258
- function filesAreIdentical(a2, b2) {
6259
- try {
6260
- const aStat = statSync(a2);
6261
- const bStat = statSync(b2);
6262
- if (aStat.size !== bStat.size)
6263
- return false;
6264
- return readFileSync6(a2).equals(readFileSync6(b2));
6265
- } catch {
6266
- 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);
6267
6927
  }
6268
- }
6269
- function tryRemoveEmptyDir(dir) {
6270
- if (!existsSync7(dir))
6928
+ destination.managedRules = plan.next;
6929
+ writeManifest(join9(root, profile.manifestDir), destination);
6930
+ if (entries(source).length)
6271
6931
  return;
6272
- try {
6273
- rmdirSync2(dir);
6274
- } 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;
6275
6973
  }
6276
6974
 
6277
6975
  // src/lib/tool-prompt.ts
6278
6976
  async function resolveToolProfile(flagOverride, projectRoot = process.cwd()) {
6279
- const profile = await resolveProfileOnly(flagOverride, projectRoot);
6280
- await handleToolSwitch(projectRoot, profile);
6281
- return profile;
6977
+ return resolveProfileOnly(flagOverride, projectRoot);
6282
6978
  }
6283
6979
  async function resolveProfileOnly(flagOverride, projectRoot) {
6284
6980
  if (flagOverride) {
6285
- const profile = PROFILES[flagOverride];
6981
+ const profile = getToolProfile(flagOverride);
6286
6982
  if (!profile) {
6287
6983
  throw new Error(`Unknown tool '${flagOverride}'. Supported: ${Object.keys(PROFILES).join(", ")}`);
6288
6984
  }
6289
- const existing = readToolConfig();
6290
- if (existing?.tool !== flagOverride) {
6291
- saveToolConfig({ ...existing ?? {}, tool: flagOverride });
6292
- if (process.stdout.isTTY) {
6293
- process.stderr.write(`Default tool set to ${profile.displayName}.
6294
- `);
6295
- }
6296
- }
6297
6985
  return profile;
6298
6986
  }
6299
6987
  const config = readToolConfig();
6300
- if (config?.tool && PROFILES[config.tool]) {
6301
- return PROFILES[config.tool];
6988
+ if (config?.tool) {
6989
+ const profile = getToolProfile(config.tool);
6990
+ if (profile) {
6991
+ return profile;
6992
+ }
6302
6993
  }
6303
6994
  if (process.stdout.isTTY) {
6304
6995
  const signals = detectTools(projectRoot);
@@ -6321,11 +7012,25 @@ async function resolveProfileOnly(flagOverride, projectRoot) {
6321
7012
  ve("Using default (Claude Code).");
6322
7013
  return PROFILES[DEFAULT_TOOL];
6323
7014
  }
6324
- saveToolConfig({ ...config ?? {}, tool: choice });
6325
7015
  return PROFILES[choice];
6326
7016
  }
6327
7017
  return PROFILES[DEFAULT_TOOL];
6328
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
+ }
6329
7034
  async function handleToolSwitch(projectRoot, newProfile) {
6330
7035
  if (!process.stdout.isTTY)
6331
7036
  return;
@@ -6411,9 +7116,8 @@ function resolveCourseRulesFlag(argv) {
6411
7116
  return true;
6412
7117
  return;
6413
7118
  }
6414
- var DEFAULT_COURSE2 = "10xdevs3";
6415
7119
  function registerGetCommand(cli) {
6416
- 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, windsurf, 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) => {
6417
7121
  const ctx = resolveContext(options);
6418
7122
  await runGet(ctx, ref, options);
6419
7123
  });
@@ -6433,30 +7137,29 @@ async function runGet(ctx, ref, options) {
6433
7137
  if (options.lang && !SUPPORTED_LANGS.includes(options.lang)) {
6434
7138
  outputError(ctx, "invalid_lang", `Unknown language '${options.lang}'.`, ExitCodes.USAGE, `Supported languages: ${SUPPORTED_LANGS.join(", ")}`);
6435
7139
  }
6436
- const auth = await requireAuth(ctx);
6437
- 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
+ }
6438
7149
  const profile = await resolveToolProfile(options.tool, process.cwd());
6439
7150
  const dryRun = options.dryRun === true;
6440
7151
  const lang = options.lang ?? readToolConfig()?.lang ?? "en";
6441
7152
  const explicitCourseRules = resolveCourseRulesFlag(process.argv);
6442
7153
  const resolvedCourseRules = explicitCourseRules ?? readToolConfig()?.courseRules ?? true;
6443
- if (!dryRun && (options.lang || explicitCourseRules !== undefined)) {
6444
- const tool = readToolConfig()?.tool ?? profile.toolId;
6445
- const patch = { tool };
6446
- if (options.lang)
6447
- patch.lang = options.lang;
6448
- if (explicitCourseRules !== undefined)
6449
- patch.courseRules = explicitCourseRules;
6450
- updateToolConfig(patch);
6451
- }
6452
7154
  if (options.print) {
6453
- 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);
6454
7156
  return;
6455
7157
  }
6456
7158
  verbose(ctx, `fetching lesson ${course}/${parsed.lessonId}`);
6457
7159
  const result = await fetchLesson(course, parsed.lessonId, auth.access_token, {
6458
7160
  lang,
6459
- tool: profile.toolId
7161
+ ...release ? { release } : {},
7162
+ tool: contentToolId(profile)
6460
7163
  });
6461
7164
  if (!result.ok) {
6462
7165
  handleLessonError(ctx, result.status, result.code, result.error, result.payload);
@@ -6474,19 +7177,34 @@ async function runGet(ctx, ref, options) {
6474
7177
  const isFiltered = options.type !== undefined;
6475
7178
  const bundle = filterBundle(ctx, result.data, options);
6476
7179
  const isTTY = process.stdout.isTTY === true;
6477
- const prevManifest = readManifest(join8(process.cwd(), profile.manifestDir));
7180
+ const prevManifest = readManifest(join10(process.cwd(), profile.manifestDir));
6478
7181
  if (prevManifest && prevManifest.manifestVersion === 2) {
6479
7182
  showUpgradeNotice(isTTY);
6480
7183
  }
6481
7184
  const applyCourseRules = isFiltered ? true : resolvedCourseRules;
6482
- const writeResult = await applyBundle(bundle, process.cwd(), {
6483
- dryRun,
6484
- profile,
6485
- partial: isFiltered,
6486
- onConflict: createConflictResolver(isTTY),
6487
- applyCourseRules
6488
- });
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
+ }
6489
7205
  renderGetResult(ctx, bundle, writeResult, dryRun, profile, {
7206
+ course,
7207
+ selectionReason: selection.reason,
6490
7208
  language: contentLang ?? lang,
6491
7209
  languageFallback: isFallback,
6492
7210
  applyCourseRules
@@ -6519,7 +7237,10 @@ function filterBundle(ctx, bundle, options) {
6519
7237
  }
6520
7238
  return { ...empty, [type]: bundle[type] };
6521
7239
  }
6522
- 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
+ `);
6523
7244
  if (!options.type) {
6524
7245
  outputError(ctx, "missing_type", "Specify --type for --print mode.", ExitCodes.USAGE, "Usage: 10x get m1l1 --print --type skills [--name code-review]");
6525
7246
  }
@@ -6528,12 +7249,12 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
6528
7249
  }
6529
7250
  if (options.name) {
6530
7251
  verbose(ctx, `fetching artifact ${course}/${lessonId}/${options.type}/${options.name}`);
6531
- const result = await fetchArtifact(course, lessonId, options.type, options.name, profile.toolId, token, { lang });
7252
+ const result = await fetchArtifact(course, lessonId, options.type, options.name, contentToolId(profile), token, { lang, ...release ? { release } : {} });
6532
7253
  if (!result.ok) {
6533
7254
  handleLessonError(ctx, result.status, result.code, result.error, result.payload);
6534
7255
  }
6535
7256
  if (ctx.json) {
6536
- output(ctx, "", result.data);
7257
+ output(ctx, "", { ...result.data, course, selectionReason });
6537
7258
  } else {
6538
7259
  const data = result.data;
6539
7260
  if (data.type === "skills") {
@@ -6546,7 +7267,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
6546
7267
  }
6547
7268
  } else {
6548
7269
  verbose(ctx, `fetching lesson ${course}/${lessonId} (filtering by ${options.type})`);
6549
- const result = await fetchLesson(course, lessonId, token, { lang, tool: profile.toolId });
7270
+ const result = await fetchLesson(course, lessonId, token, { lang, tool: contentToolId(profile), ...release ? { release } : {} });
6550
7271
  if (!result.ok) {
6551
7272
  handleLessonError(ctx, result.status, result.code, result.error, result.payload);
6552
7273
  }
@@ -6554,7 +7275,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
6554
7275
  if (type === "skills") {
6555
7276
  const skills = result.data.skills;
6556
7277
  if (ctx.json) {
6557
- output(ctx, "", skills);
7278
+ output(ctx, "", { course, selectionReason, artifacts: skills });
6558
7279
  } else {
6559
7280
  const contents = skills.map((s) => s.files.find((f2) => f2.path === "SKILL.md")?.content ?? "");
6560
7281
  process.stdout.write(contents.join(`
@@ -6566,7 +7287,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
6566
7287
  } else {
6567
7288
  const artifacts = result.data[type];
6568
7289
  if (ctx.json) {
6569
- output(ctx, "", artifacts);
7290
+ output(ctx, "", { course, selectionReason, artifacts });
6570
7291
  } else {
6571
7292
  const contents = artifacts.map((a2) => a2.content);
6572
7293
  process.stdout.write(contents.join(`
@@ -6586,7 +7307,9 @@ function emitMultiFileSkillNotice(skillName, files) {
6586
7307
  }
6587
7308
  function handleLessonError(ctx, status, code, error, payload) {
6588
7309
  verbose(ctx, `lesson fetch failed: status=${status} code=${code}`);
6589
- 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") {
6590
7313
  const moduleNum = payload?.["module"];
6591
7314
  const releaseAt = payload?.["releaseAt"];
6592
7315
  const hasModule = typeof moduleNum === "number";
@@ -6609,7 +7332,7 @@ function handleLessonError(ctx, status, code, error, payload) {
6609
7332
  if (status === 401) {
6610
7333
  outputError(ctx, "auth_required", "Your session is no longer valid.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
6611
7334
  }
6612
- 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") {
6613
7336
  outputError(ctx, code, error, ExitCodes.ERROR);
6614
7337
  }
6615
7338
  if (status === 0) {
@@ -6630,14 +7353,18 @@ function formatAction(action) {
6630
7353
  }
6631
7354
  }
6632
7355
  function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
7356
+ course: "10xdevs3",
7357
+ selectionReason: "backend_recommendation",
6633
7358
  language: "en",
6634
7359
  languageFallback: false,
6635
7360
  applyCourseRules: true
6636
7361
  }) {
6637
7362
  const applyCourseRules = langMeta.applyCourseRules;
6638
- 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;
6639
7364
  if (ctx.json) {
6640
7365
  output(ctx, "", {
7366
+ course: langMeta.course,
7367
+ selectionReason: langMeta.selectionReason,
6641
7368
  lessonId: bundle.lessonId,
6642
7369
  title: bundle.title,
6643
7370
  summary: bundle.summary,
@@ -6664,6 +7391,7 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
6664
7391
  }
6665
7392
  const targetDir = profile.manifestDir;
6666
7393
  const lines = [];
7394
+ lines.push(`Course: ${langMeta.course} (${langMeta.selectionReason})`);
6667
7395
  lines.push(`${bundle.lessonId} — ${bundle.title}`);
6668
7396
  if (bundle.summary)
6669
7397
  lines.push(bundle.summary);
@@ -6684,7 +7412,9 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
6684
7412
  lines.push(` [${formatAction(prompt.action)}] prompt ${prompt.path}`);
6685
7413
  }
6686
7414
  if (!applyCourseRules) {
6687
- 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") {
6688
7418
  lines.push(` [removed] rules ${profile.rulesFile}`);
6689
7419
  } else {
6690
7420
  verbose(ctx, `course rules disabled — ${profile.rulesFile} left untouched`);
@@ -6696,56 +7426,66 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
6696
7426
  lines.push(` [${config.action}] config ${config.path}`);
6697
7427
  }
6698
7428
  for (const entry of writeResult.removals.skills) {
6699
- lines.push(` [removed] skill ${entry.path}`);
7429
+ lines.push(` [${entry.action}] skill ${entry.path}${entry.reason ? ` (${entry.reason})` : ""}`);
6700
7430
  }
6701
7431
  for (const entry of writeResult.removals.prompts) {
6702
- lines.push(` [removed] prompt ${entry.path}`);
7432
+ lines.push(` [${entry.action}] prompt ${entry.path}${entry.reason ? ` (${entry.reason})` : ""}`);
6703
7433
  }
6704
7434
  for (const entry of writeResult.removals.configs) {
6705
- lines.push(` [removed] config ${entry.path}`);
7435
+ lines.push(` [${entry.action}] config ${entry.path}${entry.reason ? ` (${entry.reason})` : ""}`);
6706
7436
  }
6707
7437
  output(ctx, lines.join(`
6708
7438
  `), undefined);
6709
7439
  }
6710
7440
 
6711
7441
  // src/commands/list.ts
6712
- var DEFAULT_COURSE3 = "10xdevs3";
6713
7442
  function registerListCommand(cli) {
6714
- 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) => {
6715
7444
  const ctx = resolveContext(options);
6716
7445
  await runList(ctx, moduleArg, options);
6717
7446
  });
6718
7447
  }
6719
7448
  async function runList(ctx, moduleArg, options) {
6720
- const auth = await requireAuth(ctx);
6721
- const course = options.course ?? DEFAULT_COURSE3;
7449
+ const selection = await resolveCourseSelection(ctx, { explicit: options.course });
7450
+ const { auth, course } = selection;
6722
7451
  if (moduleArg === undefined) {
6723
- await listAllModules(ctx, course, auth.access_token);
7452
+ await listAllModules(ctx, course, auth.access_token, selection.reason);
6724
7453
  return;
6725
7454
  }
6726
7455
  const module = parseModuleRef(moduleArg);
6727
7456
  if (module === null) {
6728
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'.`);
6729
7458
  }
6730
- await listModuleDetail(ctx, course, module, auth.access_token);
7459
+ await listModuleDetail(ctx, course, module, auth.access_token, selection.reason);
6731
7460
  }
6732
- async function listAllModules(ctx, course, token) {
7461
+ async function listAllModules(ctx, course, token, reason) {
6733
7462
  verbose(ctx, `fetching catalog for ${course}`);
6734
7463
  const result = await fetchCatalog(course, token);
6735
7464
  if (!result.ok) {
6736
7465
  handleListError(ctx, result.status, result.code, result.error);
6737
7466
  }
6738
- renderCatalog(ctx, result.data);
7467
+ renderCatalog(ctx, result.data, reason);
6739
7468
  }
6740
- async function listModuleDetail(ctx, course, module, token) {
7469
+ async function listModuleDetail(ctx, course, module, token, reason) {
6741
7470
  verbose(ctx, `fetching module detail ${course}/${module}`);
6742
- 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 } : {});
6743
7479
  if (!result.ok) {
6744
7480
  handleListError(ctx, result.status, result.code, result.error);
6745
7481
  }
6746
- renderModuleDetail(ctx, result.data);
7482
+ renderModuleDetail(ctx, result.data, course, reason);
6747
7483
  }
6748
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);
6749
7489
  if (status === 404) {
6750
7490
  outputError(ctx, "not_found", "Couldn't find that course or module.", ExitCodes.NOT_FOUND, "Run '10x list' to see available modules.");
6751
7491
  }
@@ -6757,7 +7497,7 @@ function handleListError(ctx, status, code, error) {
6757
7497
  }
6758
7498
  outputError(ctx, code || "list_failed", "Failed to load the catalog.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
6759
7499
  }
6760
- function renderCatalog(ctx, catalog) {
7500
+ function renderCatalog(ctx, catalog, reason) {
6761
7501
  const lessonCountByModule = new Map;
6762
7502
  for (const lesson of catalog.lessons) {
6763
7503
  lessonCountByModule.set(lesson.module, (lessonCountByModule.get(lesson.module) ?? 0) + 1);
@@ -6765,6 +7505,7 @@ function renderCatalog(ctx, catalog) {
6765
7505
  if (ctx.json) {
6766
7506
  output(ctx, "", {
6767
7507
  course: catalog.course,
7508
+ selectionReason: reason,
6768
7509
  modules: catalog.modules.map((m3) => ({
6769
7510
  module: m3.module,
6770
7511
  title: m3.title,
@@ -6781,7 +7522,7 @@ function renderCatalog(ctx, catalog) {
6781
7522
  }
6782
7523
  const exampleModule = catalog.modules.find((m3) => m3.effectiveState === "unlocked")?.module ?? catalog.modules[0]?.module ?? 0;
6783
7524
  const lines = [];
6784
- lines.push(`Course: ${catalog.course}`);
7525
+ lines.push(`Course: ${catalog.course} (${reason})`);
6785
7526
  lines.push("");
6786
7527
  for (const m3 of catalog.modules) {
6787
7528
  lines.push(formatModuleRow(m3, lessonCountByModule.get(m3.module) ?? 0));
@@ -6797,10 +7538,12 @@ function formatModuleRow(m3, lessonCount) {
6797
7538
  const label = `Module ${m3.module}: ${m3.title}`;
6798
7539
  return ` ${icon} ${label} — ${lessonCount} lesson${lessonCount === 1 ? "" : "s"} [${state}]`;
6799
7540
  }
6800
- function renderModuleDetail(ctx, module) {
7541
+ function renderModuleDetail(ctx, module, course, reason) {
6801
7542
  const isLocked = module.effectiveState === "locked";
6802
7543
  if (ctx.json) {
6803
7544
  output(ctx, "", {
7545
+ course,
7546
+ selectionReason: reason,
6804
7547
  module: module.module,
6805
7548
  title: module.title,
6806
7549
  state: module.effectiveState,
@@ -6817,6 +7560,7 @@ function renderModuleDetail(ctx, module) {
6817
7560
  }
6818
7561
  const stateLabel = isLocked ? `locked — unlocks ${formatReleaseAt(module.releaseAt)}` : "unlocked";
6819
7562
  const lines = [];
7563
+ lines.push(`Course: ${course} (${reason})`);
6820
7564
  lines.push(`Module ${module.module}: ${module.title} [${stateLabel}]`);
6821
7565
  lines.push("");
6822
7566
  if (isLocked) {
@@ -6844,11 +7588,53 @@ function renderModuleDetail(ctx, module) {
6844
7588
  }
6845
7589
 
6846
7590
  // src/commands/sync.ts
6847
- import { join as join9 } from "node:path";
6848
- 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
6849
7635
  var SUPPORTED_LANGS = ["en", "pl"];
6850
7636
  function registerSyncCommand(cli) {
6851
- 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, windsurf, 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) => {
6852
7638
  const ctx = resolveContext(options);
6853
7639
  await runSync(ctx, options);
6854
7640
  });
@@ -6865,8 +7651,8 @@ async function runSync(ctx, options) {
6865
7651
  }
6866
7652
  moduleFilter = parsed;
6867
7653
  }
6868
- const auth = await requireAuth(ctx);
6869
- const course = options.course ?? DEFAULT_COURSE4;
7654
+ const selection = await resolveCourseSelection(ctx, { explicit: options.course, writing: !options.dryRun });
7655
+ const { auth, course } = selection;
6870
7656
  const profile = await resolveToolProfile(options.tool, process.cwd());
6871
7657
  const dryRun = options.dryRun === true;
6872
7658
  const force = options.force === true;
@@ -6879,6 +7665,7 @@ async function runSync(ctx, options) {
6879
7665
  handleCatalogError(ctx, catalogResult.status, catalogResult.code, catalogResult.error);
6880
7666
  }
6881
7667
  const catalog = catalogResult.data;
7668
+ const release = catalogRelease(catalog);
6882
7669
  const moduleState = new Map;
6883
7670
  for (const mod of catalog.modules)
6884
7671
  moduleState.set(mod.module, mod.effectiveState);
@@ -6890,8 +7677,8 @@ async function runSync(ctx, options) {
6890
7677
  }
6891
7678
  return true;
6892
7679
  });
6893
- const manifest = readManifest(join9(process.cwd(), profile.manifestDir));
6894
- 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] : []);
6895
7682
  let targets = unlocked;
6896
7683
  if (moduleFilter !== undefined)
6897
7684
  targets = targets.filter((l2) => l2.module === moduleFilter);
@@ -6902,12 +7689,24 @@ async function runSync(ctx, options) {
6902
7689
  const onSigint = () => controller.abort();
6903
7690
  process.once("SIGINT", onSigint);
6904
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
+ };
6905
7702
  try {
6906
7703
  for (const lesson of targets) {
6907
7704
  if (controller.signal.aborted)
6908
7705
  break;
6909
7706
  outcomes.push(await syncLesson(ctx, lesson, {
6910
7707
  course,
7708
+ beforeWrite,
7709
+ release,
6911
7710
  profile,
6912
7711
  lang,
6913
7712
  dryRun,
@@ -6923,6 +7722,7 @@ async function runSync(ctx, options) {
6923
7722
  }
6924
7723
  renderReport(ctx, profile, {
6925
7724
  course,
7725
+ selectionReason: selection.reason,
6926
7726
  dryRun,
6927
7727
  force,
6928
7728
  mode: options.all === true ? "all" : "downloaded",
@@ -6935,8 +7735,9 @@ async function runSync(ctx, options) {
6935
7735
  }
6936
7736
  }
6937
7737
  async function syncLesson(ctx, lesson, opts) {
6938
- const stored = opts.manifest?.lessons?.[lesson.lessonId]?.catalogContentHash;
6939
- 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)) {
6940
7741
  verbose(ctx, `${lesson.lessonId}: upstream unchanged (digest match) — skipping fetch`);
6941
7742
  return {
6942
7743
  lessonId: lesson.lessonId,
@@ -6949,8 +7750,9 @@ async function syncLesson(ctx, lesson, opts) {
6949
7750
  verbose(ctx, `${lesson.lessonId}: fetching`);
6950
7751
  const result = await fetchLesson(opts.course, lesson.lessonId, opts.token, {
6951
7752
  lang: opts.lang,
6952
- tool: opts.profile.toolId,
6953
- signal: opts.signal
7753
+ tool: contentToolId(opts.profile),
7754
+ signal: opts.signal,
7755
+ ...opts.release ? { release: opts.release } : {}
6954
7756
  });
6955
7757
  if (!result.ok) {
6956
7758
  return {
@@ -6981,14 +7783,23 @@ async function syncLesson(ctx, lesson, opts) {
6981
7783
  resources: resources2
6982
7784
  };
6983
7785
  }
6984
- const onConflict = opts.force ? async () => "overwrite" : async () => "skip";
6985
- const writeResult = await applyBundle(bundle, process.cwd(), {
6986
- course: opts.course,
6987
- profile: opts.profile,
6988
- onConflict,
6989
- applyCourseRules: opts.applyCourseRules,
6990
- catalogContentHash: lesson.contentHash
6991
- });
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
+ }
6992
7803
  const resources = classifyFromWriteResult(writeResult, lesson.lessonId);
6993
7804
  return {
6994
7805
  lessonId: lesson.lessonId,
@@ -7006,6 +7817,8 @@ function actionToBucket(action) {
7006
7817
  case "conflict_overwritten":
7007
7818
  case "conflict_saved_user":
7008
7819
  return "upstream-updated";
7820
+ case "preserved_local":
7821
+ return "preserved-local";
7009
7822
  case "conflict_skipped":
7010
7823
  return "skipped-conflict";
7011
7824
  case "removed":
@@ -7042,18 +7855,18 @@ function classifyFromWriteResult(result, lessonId) {
7042
7855
  ...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "prompts", prompt.name) } : {}
7043
7856
  });
7044
7857
  }
7045
- 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` });
7046
7859
  for (const config of result.configs) {
7047
7860
  out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
7048
7861
  }
7049
7862
  for (const entry of result.removals.skills) {
7050
- out.push({ type: "skills", name: entry.name, bucket: "removed" });
7863
+ out.push({ type: "skills", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
7051
7864
  }
7052
7865
  for (const entry of result.removals.prompts) {
7053
- out.push({ type: "prompts", name: entry.name, bucket: "removed" });
7866
+ out.push({ type: "prompts", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
7054
7867
  }
7055
7868
  for (const entry of result.removals.configs) {
7056
- out.push({ type: "configs", name: entry.name, bucket: "removed" });
7869
+ out.push({ type: "configs", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
7057
7870
  }
7058
7871
  return out;
7059
7872
  }
@@ -7081,18 +7894,18 @@ function classifyFromPlan(plan, lessonId, force) {
7081
7894
  ...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "prompts", prompt.name) } : {}
7082
7895
  });
7083
7896
  }
7084
- 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` });
7085
7898
  for (const config of plan.configs) {
7086
7899
  out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
7087
7900
  }
7088
7901
  for (const entry of plan.removals.skills) {
7089
- out.push({ type: "skills", name: entry.name, bucket: "removed" });
7902
+ out.push({ type: "skills", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
7090
7903
  }
7091
7904
  for (const entry of plan.removals.prompts) {
7092
- out.push({ type: "prompts", name: entry.name, bucket: "removed" });
7905
+ out.push({ type: "prompts", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
7093
7906
  }
7094
7907
  for (const entry of plan.removals.configs) {
7095
- out.push({ type: "configs", name: entry.name, bucket: "removed" });
7908
+ out.push({ type: "configs", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
7096
7909
  }
7097
7910
  return out;
7098
7911
  }
@@ -7110,7 +7923,8 @@ function countBuckets(outcomes) {
7110
7923
  "upstream-updated": 0,
7111
7924
  unchanged: 0,
7112
7925
  "skipped-conflict": 0,
7113
- removed: 0
7926
+ removed: 0,
7927
+ "preserved-local": 0
7114
7928
  };
7115
7929
  for (const o of outcomes) {
7116
7930
  for (const r2 of o.resources)
@@ -7126,6 +7940,7 @@ function renderReport(ctx, profile, input) {
7126
7940
  if (ctx.json) {
7127
7941
  output(ctx, "", {
7128
7942
  course: input.course,
7943
+ selectionReason: input.selectionReason,
7129
7944
  tool: profile.toolId,
7130
7945
  dryRun: input.dryRun,
7131
7946
  force: input.force,
@@ -7145,7 +7960,8 @@ function renderReport(ctx, profile, input) {
7145
7960
  upstreamUpdated: buckets["upstream-updated"],
7146
7961
  unchanged: buckets.unchanged,
7147
7962
  skippedConflict: buckets["skipped-conflict"],
7148
- removed: buckets.removed
7963
+ removed: buckets.removed,
7964
+ preservedLocal: buckets["preserved-local"]
7149
7965
  }
7150
7966
  }
7151
7967
  });
@@ -7155,7 +7971,7 @@ function renderReport(ctx, profile, input) {
7155
7971
  const verb = input.dryRun ? "Would sync" : "Synced";
7156
7972
  const scope = input.mode === "all" ? "all unlocked lessons" : "downloaded lessons";
7157
7973
  const moduleNote = input.module !== undefined ? ` in module ${input.module}` : "";
7158
- lines.push(`${verb} ${input.course} — ${scope}${moduleNote}:`);
7974
+ lines.push(`${verb} ${input.course} (${input.selectionReason}) — ${scope}${moduleNote}:`);
7159
7975
  if (outcomes.length === 0) {
7160
7976
  lines.push(" (nothing to sync)");
7161
7977
  }
@@ -7169,6 +7985,10 @@ function renderReport(ctx, profile, input) {
7169
7985
  const summary = summarizeLesson(o);
7170
7986
  lines.push(` ${o.lessonId} — ${o.status}${summary ? ` (${summary})` : ""}`);
7171
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
+ }
7172
7992
  if (r2.bucket !== "skipped-conflict")
7173
7993
  continue;
7174
7994
  const label = r2.file ? `${r2.type}/${r2.name} (${r2.file})` : `${r2.type}/${r2.name}`;
@@ -7182,9 +8002,9 @@ function renderReport(ctx, profile, input) {
7182
8002
  lines.push(` ${ex.lessonId} — ${ex.reason}`);
7183
8003
  }
7184
8004
  lines.push("");
7185
- 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.`);
7186
8006
  if (lessonsConflicts > 0 && !input.force) {
7187
- 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.");
7188
8008
  }
7189
8009
  output(ctx, lines.join(`
7190
8010
  `), undefined);
@@ -7195,7 +8015,8 @@ function summarizeLesson(o) {
7195
8015
  "upstream-updated": 0,
7196
8016
  unchanged: 0,
7197
8017
  "skipped-conflict": 0,
7198
- removed: 0
8018
+ removed: 0,
8019
+ "preserved-local": 0
7199
8020
  };
7200
8021
  for (const r2 of o.resources)
7201
8022
  counts[r2.bucket]++;
@@ -7213,6 +8034,10 @@ function summarizeLesson(o) {
7213
8034
  return parts.join(", ");
7214
8035
  }
7215
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);
7216
8041
  if (status === 401) {
7217
8042
  outputError(ctx, "auth_required", "Your session is no longer valid.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
7218
8043
  }
@@ -7225,7 +8050,11 @@ function handleCatalogError(ctx, status, code, error) {
7225
8050
  outputError(ctx, code || "catalog_fetch_failed", "Failed to fetch the course catalog.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
7226
8051
  }
7227
8052
  function lessonErrorMessage(status, code, error) {
7228
- 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")
7229
8058
  return "Module is locked.";
7230
8059
  if (status === 404)
7231
8060
  return "Lesson not found.";