@przeprogramowani/10x-cli 1.7.0 → 1.9.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.
Files changed (3) hide show
  1. package/README.md +73 -0
  2. package/dist/index.mjs +608 -75
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -47,6 +47,7 @@ Once installed, just tell your agent to **set up 10x-cli** and it will pick up t
47
47
  10x auth # Authenticate with your email
48
48
  10x list # Browse available modules and lessons
49
49
  10x get m1l1 # Fetch and apply lesson artifacts
50
+ 10x sync # Update everything you've downloaded; show what changed
50
51
  10x doctor # Check everything is working
51
52
  ```
52
53
 
@@ -57,6 +58,7 @@ Once installed, just tell your agent to **set up 10x-cli** and it will pick up t
57
58
  | `10x auth` | Magic-link login with your Circle-registered email |
58
59
  | `10x list` | Browse modules and lessons in your course |
59
60
  | `10x get <ref>` | Fetch a lesson and apply artifacts to your workspace |
61
+ | `10x sync` | Bulk-download / refresh lessons and report what changed upstream |
60
62
  | `10x doctor` | Diagnose auth, API connectivity, and local config |
61
63
 
62
64
  ### `10x get` Flags
@@ -69,6 +71,7 @@ Once installed, just tell your agent to **set up 10x-cli** and it will pick up t
69
71
  | `--name <name>` | Filter by artifact name (requires `--type`) |
70
72
  | `--dry-run` | Show what would be written without touching the filesystem |
71
73
  | `--course <slug>` | Override the course slug (default: `10xdevs3`) |
74
+ | `--no-course-rules` | Skip the course rules block in your rules file (`CLAUDE.md`/`AGENTS.md`); strips an existing one. Use `--course-rules` to re-enable. |
72
75
 
73
76
  #### Examples
74
77
 
@@ -88,8 +91,78 @@ Once installed, just tell your agent to **set up 10x-cli** and it will pick up t
88
91
 
89
92
  # Use with a different AI coding tool
90
93
  10x get m1l1 --tool cursor
94
+
95
+ # Skip the course rules block (use only your rules). Persisted across runs;
96
+ # a previously-applied block is stripped. Re-enable later with --course-rules.
97
+ 10x get m1l1 --no-course-rules
98
+ 10x get m1l2 --course-rules
99
+
100
+ # An explicit rules request always applies, even with the opt-out persisted
101
+ 10x get m1l1 --type rules
91
102
  ```
92
103
 
104
+ > The `--no-course-rules` / `--course-rules` choice is saved as `courseRules`
105
+ > in `config.json` and applies to subsequent plain `10x get` runs. An explicit
106
+ > `--type rules` request overrides the opt-out for that run. Skills, prompts,
107
+ > and config-templates are unaffected.
108
+
109
+ ### `10x sync`
110
+
111
+ `10x sync` keeps your downloaded lessons up to date and tells you **what changed
112
+ upstream** since you last fetched. By default it refreshes only the lessons you've
113
+ already downloaded; `--all` pulls every unlocked lesson at once.
114
+
115
+ Unchanged lessons are skipped **without a download** — the catalog advertises a
116
+ per-lesson `contentHash` that the CLI compares against what it last applied, so the
117
+ common "nothing changed" case is a single catalog request.
118
+
119
+ | Flag | Description |
120
+ |------|-------------|
121
+ | `--all` | Sync every unlocked lesson, not just the ones you've downloaded |
122
+ | `--module <m>` | Limit to one module (e.g. `m2` or `2`) |
123
+ | `--dry-run` | Preview what would change without writing anything |
124
+ | `--force` | Ignore the cheap-skip digest and overwrite local edits with upstream |
125
+ | `--tool <tool>` | AI coding tool (same set as `get`) |
126
+ | `--lang <lang>` | Content language: `en` (default) or `pl` |
127
+ | `--course <slug>` | Override the course slug (default: `10xdevs3`) |
128
+ | `--no-course-rules` | Skip the course rules block (same semantics as `get`) |
129
+
130
+ ```bash
131
+ # Refresh everything you've already downloaded; report what moved
132
+ 10x sync
133
+
134
+ # Pull every unlocked lesson in one shot (fresh project)
135
+ 10x sync --all
136
+
137
+ # Preview changes without writing
138
+ 10x sync --dry-run
139
+
140
+ # Only module 2
141
+ 10x sync --module m2
142
+
143
+ # Take all upstream updates, overwriting local edits
144
+ 10x sync --force
145
+ ```
146
+
147
+ The report classifies every resource as **upstream-updated**, **created**,
148
+ **unchanged**, **skipped (conflict)**, or **removed**. When a file you edited
149
+ locally also changed upstream, sync **keeps your edit** and prints the exact
150
+ command to take the update, e.g.:
151
+
152
+ ```
153
+ m2l3 — conflicts (1 skipped)
154
+ skipped skills/auth-skill (SKILL.md) — you edited it → 10x get m2l3 --type skills --name auth-skill
155
+ ```
156
+
157
+ Run that `10x get …` to take a single update, or `10x sync --force` to take them
158
+ all. **Change visibility covers skills and prompts** — configs are create-only
159
+ (never overwritten) and rules are sentinel-managed, so they aren't part of the
160
+ "what changed" report.
161
+
162
+ **Exit code is worst-outcome:** `0` when everything is clean/unchanged (a skipped
163
+ conflict is reported, not a failure), `1` if any lesson failed to fetch. The full
164
+ report is still emitted on a partial failure.
165
+
93
166
  ### Global Flags
94
167
 
95
168
  - `--json` — Machine-readable JSON output (auto-detected when piped)
package/dist/index.mjs CHANGED
@@ -2271,7 +2271,7 @@ var cac = (name = "") => new CAC(name);
2271
2271
  // package.json
2272
2272
  var package_default = {
2273
2273
  name: "@przeprogramowani/10x-cli",
2274
- version: "1.7.0",
2274
+ version: "1.9.0",
2275
2275
  description: "Open-source CLI for 10xDevs course content",
2276
2276
  repository: {
2277
2277
  type: "git",
@@ -2992,6 +2992,9 @@ function readToolConfig() {
2992
2992
  if (parsed.acknowledgedOrphans !== undefined && (!Array.isArray(parsed.acknowledgedOrphans) || !parsed.acknowledgedOrphans.every((x2) => typeof x2 === "string"))) {
2993
2993
  delete parsed.acknowledgedOrphans;
2994
2994
  }
2995
+ if (parsed.courseRules !== undefined && typeof parsed.courseRules !== "boolean") {
2996
+ delete parsed.courseRules;
2997
+ }
2995
2998
  return parsed;
2996
2999
  } catch {
2997
3000
  return null;
@@ -3004,6 +3007,14 @@ function saveToolConfig(config) {
3004
3007
  }
3005
3008
  writeJsonAtomic(toolConfigPath(), config);
3006
3009
  }
3010
+ function updateToolConfig(patch) {
3011
+ const current = readToolConfig();
3012
+ const merged = { ...current, ...patch };
3013
+ if (typeof merged.tool !== "string") {
3014
+ throw new Error("updateToolConfig: refusing to persist a config without a `tool` — " + "no prior config exists and the patch did not seed one.");
3015
+ }
3016
+ saveToolConfig(merged);
3017
+ }
3007
3018
 
3008
3019
  // src/lib/api-client.ts
3009
3020
  var DEFAULT_API_BASE = "https://10x-toolkit-api.przeprogramowani.workers.dev";
@@ -4213,7 +4224,7 @@ function parseModuleRef(raw) {
4213
4224
 
4214
4225
  // src/lib/manifest.ts
4215
4226
  import { createHash as createHash2 } from "node:crypto";
4216
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
4227
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
4217
4228
  import { dirname as dirname2, join as join3 } from "node:path";
4218
4229
  var MANIFEST_FILENAME = ".10x-cli-manifest.json";
4219
4230
  var CLI_PACKAGE_NAME = "@przeprogramowani/10x-cli";
@@ -4247,8 +4258,11 @@ function writeManifest(dir, manifest) {
4247
4258
  }
4248
4259
  const manifestPath = join3(dir, MANIFEST_FILENAME);
4249
4260
  mkdirSync3(dirname2(manifestPath), { recursive: true });
4250
- writeFileSync2(manifestPath, `${JSON.stringify(manifest, null, 2)}
4261
+ const tmp = `${manifestPath}.tmp`;
4262
+ rmSync2(tmp, { force: true });
4263
+ writeFileSync2(tmp, `${JSON.stringify(manifest, null, 2)}
4251
4264
  `);
4265
+ renameSync2(tmp, manifestPath);
4252
4266
  }
4253
4267
  function isManifest(value) {
4254
4268
  if (typeof value !== "object" || value === null)
@@ -4307,6 +4321,9 @@ function isLessonFilesEntry(value) {
4307
4321
  if (!isStringArray(skill["files"]))
4308
4322
  return false;
4309
4323
  }
4324
+ if (e2["catalogContentHash"] !== undefined && typeof e2["catalogContentHash"] !== "string") {
4325
+ return false;
4326
+ }
4310
4327
  return isStringArray(e2["prompts"]) && isStringArray(e2["configs"]);
4311
4328
  }
4312
4329
  function isLessonsRecord(value) {
@@ -4458,9 +4475,9 @@ import {
4458
4475
  lstatSync,
4459
4476
  mkdirSync as mkdirSync5,
4460
4477
  readFileSync as readFileSync5,
4461
- renameSync as renameSync2,
4478
+ renameSync as renameSync3,
4462
4479
  rmdirSync as rmdirSync2,
4463
- rmSync as rmSync3,
4480
+ rmSync as rmSync4,
4464
4481
  statSync,
4465
4482
  writeFileSync as writeFileSync4
4466
4483
  } from "node:fs";
@@ -4551,7 +4568,7 @@ import {
4551
4568
  readFileSync as readFileSync4,
4552
4569
  readdirSync,
4553
4570
  rmdirSync,
4554
- rmSync as rmSync2,
4571
+ rmSync as rmSync3,
4555
4572
  writeFileSync as writeFileSync3
4556
4573
  } from "node:fs";
4557
4574
  import { dirname as dirname3, join as join5 } from "node:path";
@@ -4563,30 +4580,25 @@ async function applyBundle(bundle, projectRoot, options = {}) {
4563
4580
  const course = options.course ?? DEFAULT_COURSE;
4564
4581
  const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
4565
4582
  const onConflict = options.onConflict;
4583
+ const applyCourseRules = options.applyCourseRules !== false;
4566
4584
  const manifestDir = join5(projectRoot, profile.manifestDir);
4567
4585
  const prevManifest = readManifest(manifestDir);
4568
- for (const skill of bundle.skills) {
4569
- assertSafeName(skill.name, "skill");
4570
- for (const file of skill.files) {
4571
- assertSafeSkillFilePath(file.path, skill.name);
4572
- }
4573
- }
4574
- for (const prompt of bundle.prompts)
4575
- assertSafeName(prompt.name, "prompt");
4576
- for (const config of bundle.configs)
4577
- assertSafeName(config.name, "config");
4586
+ const plan = planBundle(bundle, projectRoot, { profile, applyCourseRules });
4578
4587
  const nextSkillHashes = {};
4579
4588
  const nextPromptHashes = {};
4580
4589
  const skills = [];
4581
- for (const skill of bundle.skills) {
4582
- const skillDir = join5(projectRoot, profile.skillDir(skill.name));
4590
+ for (let si = 0;si < bundle.skills.length; si++) {
4591
+ const skill = bundle.skills[si];
4592
+ const planSkill = plan.skills[si];
4583
4593
  const prevEntry = prevManifest?.files.skills[skill.name];
4584
4594
  const fileWrites = [];
4585
4595
  const skillHashes = {};
4586
- for (const file of skill.files) {
4587
- const target = join5(skillDir, file.path);
4596
+ for (let fi = 0;fi < skill.files.length; fi++) {
4597
+ const file = skill.files[fi];
4598
+ const planFile = planSkill.files[fi];
4599
+ const target = planFile.path;
4588
4600
  const storedHash = prevEntry?.contentHashes?.[file.path];
4589
- const { action, isConflict } = computeFileAction(target, file.content, storedHash);
4601
+ const { action, isConflict } = planFile;
4590
4602
  let finalAction = action;
4591
4603
  let userBackupPath;
4592
4604
  if (isConflict) {
@@ -4629,11 +4641,13 @@ async function applyBundle(bundle, projectRoot, options = {}) {
4629
4641
  skills.push({ name: skill.name, files: fileWrites });
4630
4642
  }
4631
4643
  const prompts = [];
4632
- for (const prompt of bundle.prompts) {
4633
- const target = join5(projectRoot, profile.promptPath(prompt.name));
4644
+ for (let pi = 0;pi < bundle.prompts.length; pi++) {
4645
+ const prompt = bundle.prompts[pi];
4646
+ const planPrompt = plan.prompts[pi];
4647
+ const target = planPrompt.path;
4634
4648
  const promptFilename = `${prompt.name}.md`;
4635
4649
  const storedHash = prevManifest?.files.promptHashes?.[promptFilename];
4636
- const { action, isConflict } = computeFileAction(target, prompt.content, storedHash);
4650
+ const { action, isConflict } = planPrompt;
4637
4651
  let finalAction = action;
4638
4652
  let userBackupPath;
4639
4653
  if (isConflict) {
@@ -4671,32 +4685,16 @@ async function applyBundle(bundle, projectRoot, options = {}) {
4671
4685
  }
4672
4686
  const rulesFilePath = join5(projectRoot, profile.rulesFile);
4673
4687
  const existingRules = readFileOrEmpty(rulesFilePath);
4674
- let rulesAction;
4675
- if (bundle.rules.length === 0) {
4676
- rulesAction = "unchanged";
4677
- } else {
4678
- const rulesBody = bundle.rules.map((r2) => r2.content.trim()).join(`
4679
-
4680
- `);
4681
- const { content: newRules } = applyRulesBlockWithMarkers(existingRules, rulesBody, profile.sentinelBegin, profile.sentinelEnd);
4682
- if (newRules === existingRules) {
4683
- rulesAction = "unchanged";
4684
- } else if (existingRules.length === 0) {
4685
- rulesAction = "created";
4686
- } else {
4687
- rulesAction = "updated";
4688
- }
4689
- if (!dryRun && rulesAction !== "unchanged") {
4690
- writeFileAt(rulesFilePath, newRules);
4691
- }
4688
+ const { action: rulesAction, content: newRulesContent } = planRules(existingRules, bundle, profile, applyCourseRules);
4689
+ if (!dryRun && rulesAction !== "unchanged") {
4690
+ writeFileAt(rulesFilePath, newRulesContent);
4692
4691
  }
4693
- const configs = bundle.configs.map((config) => {
4694
- const target = join5(projectRoot, profile.configPath(config.name));
4695
- const action = existsSync5(target) ? "skipped" : "created";
4696
- if (!dryRun && action === "created") {
4697
- writeFileAt(target, config.content);
4692
+ const configs = bundle.configs.map((config, ci) => {
4693
+ const planConfig = plan.configs[ci];
4694
+ if (!dryRun && planConfig.action === "created") {
4695
+ writeFileAt(planConfig.path, config.content);
4698
4696
  }
4699
- return { name: config.name, path: target, action };
4697
+ return { name: config.name, path: planConfig.path, action: planConfig.action };
4700
4698
  });
4701
4699
  const removed = computeRemovals(prevManifest, bundle, profile, projectRoot);
4702
4700
  const removalResult = {
@@ -4707,31 +4705,33 @@ async function applyBundle(bundle, projectRoot, options = {}) {
4707
4705
  for (const entry of removed.skillDirs) {
4708
4706
  removalResult.skills.push({ name: entry.name, path: entry.path, action: "removed" });
4709
4707
  if (!dryRun && !partial)
4710
- rmSync2(entry.path, { recursive: true, force: true });
4708
+ rmSync3(entry.path, { recursive: true, force: true });
4711
4709
  }
4712
4710
  for (const entry of removed.skillFiles) {
4713
4711
  removalResult.skills.push({ name: entry.name, path: entry.path, action: "removed" });
4714
4712
  if (!dryRun && !partial) {
4715
- rmSync2(entry.path, { force: true });
4713
+ rmSync3(entry.path, { force: true });
4716
4714
  removeEmptyParentDirs(entry.path, entry.skillDirAbs);
4717
4715
  }
4718
4716
  }
4719
4717
  for (const entry of removed.prompts) {
4720
4718
  removalResult.prompts.push({ name: entry.name, path: entry.path, action: "removed" });
4721
4719
  if (!dryRun && !partial)
4722
- rmSync2(entry.path, { force: true });
4720
+ rmSync3(entry.path, { force: true });
4723
4721
  }
4724
4722
  for (const entry of removed.configs) {
4725
4723
  removalResult.configs.push({ name: entry.name, path: entry.path, action: "removed" });
4726
4724
  if (!dryRun && !partial)
4727
- rmSync2(entry.path, { force: true });
4725
+ rmSync3(entry.path, { force: true });
4728
4726
  }
4729
4727
  if (!dryRun && !partial) {
4728
+ const catalogContentHash = options.catalogContentHash ?? prevManifest?.lessons?.[bundle.lessonId]?.catalogContentHash;
4730
4729
  const newLessonEntry = {
4731
4730
  appliedAt: new Date().toISOString(),
4732
4731
  skills: Object.fromEntries(bundle.skills.map((s) => [s.name, { files: s.files.map((f2) => f2.path) }])),
4733
4732
  prompts: bundle.prompts.map((p2) => `${p2.name}.md`),
4734
- configs: bundle.configs.map((c2) => c2.name)
4733
+ configs: bundle.configs.map((c2) => c2.name),
4734
+ ...catalogContentHash !== undefined ? { catalogContentHash } : {}
4735
4735
  };
4736
4736
  let baseLessons = {};
4737
4737
  if (prevManifest && !prevManifest.lessons) {
@@ -4791,6 +4791,107 @@ async function applyBundle(bundle, projectRoot, options = {}) {
4791
4791
  removals: removalResult
4792
4792
  };
4793
4793
  }
4794
+ function planBundle(bundle, projectRoot, options = {}) {
4795
+ const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
4796
+ const applyCourseRules = options.applyCourseRules !== false;
4797
+ for (const skill of bundle.skills) {
4798
+ assertSafeName(skill.name, "skill");
4799
+ for (const file of skill.files)
4800
+ assertSafeSkillFilePath(file.path, skill.name);
4801
+ }
4802
+ for (const prompt of bundle.prompts)
4803
+ assertSafeName(prompt.name, "prompt");
4804
+ for (const config of bundle.configs)
4805
+ assertSafeName(config.name, "config");
4806
+ const manifestDir = join5(projectRoot, profile.manifestDir);
4807
+ const prevManifest = readManifest(manifestDir);
4808
+ const skills = bundle.skills.map((skill) => {
4809
+ const skillDir = join5(projectRoot, profile.skillDir(skill.name));
4810
+ const prevEntry = prevManifest?.files.skills[skill.name];
4811
+ const files = skill.files.map((file) => {
4812
+ const target = join5(skillDir, file.path);
4813
+ const storedHash = prevEntry?.contentHashes?.[file.path];
4814
+ const { action, isConflict } = computeFileAction(target, file.content, storedHash);
4815
+ return {
4816
+ relativePath: file.path,
4817
+ path: target,
4818
+ action,
4819
+ isConflict,
4820
+ upstreamChanged: computeUpstreamChanged(file.content, storedHash, action)
4821
+ };
4822
+ });
4823
+ return { name: skill.name, files };
4824
+ });
4825
+ const prompts = bundle.prompts.map((prompt) => {
4826
+ const target = join5(projectRoot, profile.promptPath(prompt.name));
4827
+ const storedHash = prevManifest?.files.promptHashes?.[`${prompt.name}.md`];
4828
+ const { action, isConflict } = computeFileAction(target, prompt.content, storedHash);
4829
+ return {
4830
+ name: prompt.name,
4831
+ path: target,
4832
+ action,
4833
+ isConflict,
4834
+ upstreamChanged: computeUpstreamChanged(prompt.content, storedHash, action)
4835
+ };
4836
+ });
4837
+ const rulesFilePath = join5(projectRoot, profile.rulesFile);
4838
+ const existingRules = readFileOrEmpty(rulesFilePath);
4839
+ const { action: rulesAction } = planRules(existingRules, bundle, profile, applyCourseRules);
4840
+ const configs = bundle.configs.map((config) => {
4841
+ const target = join5(projectRoot, profile.configPath(config.name));
4842
+ const action = existsSync5(target) ? "skipped" : "created";
4843
+ return {
4844
+ name: config.name,
4845
+ path: target,
4846
+ action,
4847
+ isConflict: false,
4848
+ upstreamChanged: action === "created"
4849
+ };
4850
+ });
4851
+ const removed = computeRemovals(prevManifest, bundle, profile, projectRoot);
4852
+ const removals = {
4853
+ skills: [
4854
+ ...removed.skillDirs.map((e2) => ({ name: e2.name, path: e2.path })),
4855
+ ...removed.skillFiles.map((e2) => ({ name: e2.name, path: e2.path }))
4856
+ ],
4857
+ prompts: removed.prompts.map((e2) => ({ name: e2.name, path: e2.path })),
4858
+ configs: removed.configs.map((e2) => ({ name: e2.name, path: e2.path }))
4859
+ };
4860
+ return {
4861
+ skills,
4862
+ prompts,
4863
+ rules: { action: rulesAction, upstreamChanged: rulesAction !== "unchanged" },
4864
+ configs,
4865
+ removals
4866
+ };
4867
+ }
4868
+ function computeUpstreamChanged(newContent, storedHash, action) {
4869
+ if (storedHash === undefined)
4870
+ return action !== "unchanged";
4871
+ return contentHash(newContent) !== storedHash;
4872
+ }
4873
+ function planRules(existingRules, bundle, profile, applyCourseRules) {
4874
+ if (!applyCourseRules) {
4875
+ const { content: stripped, removed } = removeRulesBlockWithMarkers(existingRules, profile.sentinelBegin, profile.sentinelEnd);
4876
+ if (removed && stripped !== existingRules)
4877
+ return { action: "removed", content: stripped };
4878
+ return { action: "unchanged", content: existingRules };
4879
+ }
4880
+ if (bundle.rules.length === 0)
4881
+ return { action: "unchanged", content: existingRules };
4882
+ const rulesBody = bundle.rules.map((r2) => r2.content.trim()).join(`
4883
+
4884
+ `);
4885
+ const { content: newRules } = applyRulesBlockWithMarkers(existingRules, rulesBody, profile.sentinelBegin, profile.sentinelEnd);
4886
+ let action;
4887
+ if (newRules === existingRules)
4888
+ action = "unchanged";
4889
+ else if (existingRules.length === 0)
4890
+ action = "created";
4891
+ else
4892
+ action = "updated";
4893
+ return { action, content: newRules };
4894
+ }
4794
4895
  function computeFileAction(filePath, newContent, storedHash) {
4795
4896
  if (!existsSync5(filePath))
4796
4897
  return { action: "created", isConflict: false };
@@ -5080,7 +5181,7 @@ function migrateArtifacts(projectRoot, orphan, newProfile) {
5080
5181
  }
5081
5182
  }
5082
5183
  summary.sentinelStripped = stripSentinelFromRulesFile(projectRoot, oldProfile);
5083
- rmSync3(orphan.manifestPath, { force: true });
5184
+ rmSync4(orphan.manifestPath, { force: true });
5084
5185
  tryRemoveEmptyDir(join6(projectRoot, oldProfile.manifestDir));
5085
5186
  return summary;
5086
5187
  }
@@ -5107,9 +5208,9 @@ function deleteArtifacts(projectRoot, orphan) {
5107
5208
  });
5108
5209
  continue;
5109
5210
  }
5110
- rmSync3(join6(skillDir, relPath), { force: true });
5211
+ rmSync4(join6(skillDir, relPath), { force: true });
5111
5212
  }
5112
- rmSync3(skillDir, { recursive: true, force: true });
5213
+ rmSync4(skillDir, { recursive: true, force: true });
5113
5214
  summary.movedOrRemoved.skills.push(skillName);
5114
5215
  }
5115
5216
  for (const promptFile of orphan.manifest.files.prompts) {
@@ -5118,7 +5219,7 @@ function deleteArtifacts(projectRoot, orphan) {
5118
5219
  continue;
5119
5220
  }
5120
5221
  const promptName = promptFile.replace(/\.md$/, "");
5121
- rmSync3(join6(projectRoot, oldProfile.promptPath(promptName)), { force: true });
5222
+ rmSync4(join6(projectRoot, oldProfile.promptPath(promptName)), { force: true });
5122
5223
  summary.movedOrRemoved.prompts.push(promptFile);
5123
5224
  }
5124
5225
  for (const configFile of orphan.manifest.files.configs) {
@@ -5126,11 +5227,11 @@ function deleteArtifacts(projectRoot, orphan) {
5126
5227
  summary.skipped.push({ path: configFile, reason: "unsafe name in manifest" });
5127
5228
  continue;
5128
5229
  }
5129
- rmSync3(join6(projectRoot, oldProfile.configPath(configFile)), { force: true });
5230
+ rmSync4(join6(projectRoot, oldProfile.configPath(configFile)), { force: true });
5130
5231
  summary.movedOrRemoved.configs.push(configFile);
5131
5232
  }
5132
5233
  summary.sentinelStripped = stripSentinelFromRulesFile(projectRoot, oldProfile);
5133
- rmSync3(orphan.manifestPath, { force: true });
5234
+ rmSync4(orphan.manifestPath, { force: true });
5134
5235
  const manifestDirAbs = join6(projectRoot, oldProfile.manifestDir);
5135
5236
  tryRemoveEmptyDir(join6(manifestDirAbs, "skills"));
5136
5237
  tryRemoveEmptyDir(join6(manifestDirAbs, "prompts"));
@@ -5154,12 +5255,12 @@ function moveIfSafe(from, to, skipped) {
5154
5255
  });
5155
5256
  return false;
5156
5257
  }
5157
- rmSync3(from, { force: true });
5258
+ rmSync4(from, { force: true });
5158
5259
  return true;
5159
5260
  }
5160
5261
  mkdirSync5(dirname4(to), { recursive: true });
5161
5262
  try {
5162
- renameSync2(from, to);
5263
+ renameSync3(from, to);
5163
5264
  } catch (err) {
5164
5265
  const code = err?.code;
5165
5266
  if (code !== "EXDEV")
@@ -5167,9 +5268,9 @@ function moveIfSafe(from, to, skipped) {
5167
5268
  const bytes = readFileSync5(from);
5168
5269
  const tmp = `${to}.tmp`;
5169
5270
  writeFileSync4(tmp, bytes);
5170
- renameSync2(tmp, to);
5271
+ renameSync3(tmp, to);
5171
5272
  try {
5172
- rmSync3(from, { force: true });
5273
+ rmSync4(from, { force: true });
5173
5274
  } catch {
5174
5275
  skipped.push({
5175
5276
  path: from,
@@ -5191,7 +5292,7 @@ function stripSentinelFromRulesFile(projectRoot, oldProfile) {
5191
5292
  if (!removed)
5192
5293
  return false;
5193
5294
  if (content.trim().length === 0) {
5194
- rmSync3(rulesPath, { force: true });
5295
+ rmSync4(rulesPath, { force: true });
5195
5296
  } else {
5196
5297
  writeFileSync4(rulesPath, content);
5197
5298
  }
@@ -5346,9 +5447,16 @@ var ARTIFACT_TYPES = ["skills", "prompts", "rules", "configs"];
5346
5447
  function isArtifactType(s) {
5347
5448
  return ARTIFACT_TYPES.includes(s);
5348
5449
  }
5450
+ function resolveCourseRulesFlag(argv) {
5451
+ if (argv.includes("--no-course-rules"))
5452
+ return false;
5453
+ if (argv.includes("--course-rules"))
5454
+ return true;
5455
+ return;
5456
+ }
5349
5457
  var DEFAULT_COURSE2 = "10xdevs3";
5350
5458
  function registerGetCommand(cli) {
5351
- 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").action(async (ref, options) => {
5459
+ 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) => {
5352
5460
  const ctx = resolveContext(options);
5353
5461
  await runGet(ctx, ref, options);
5354
5462
  });
@@ -5371,10 +5479,18 @@ async function runGet(ctx, ref, options) {
5371
5479
  const auth = await requireAuth(ctx);
5372
5480
  const course = options.course ?? DEFAULT_COURSE2;
5373
5481
  const profile = await resolveToolProfile(options.tool, process.cwd());
5482
+ const dryRun = options.dryRun === true;
5374
5483
  const lang = options.lang ?? readToolConfig()?.lang ?? "en";
5375
- if (options.lang) {
5376
- const existing = readToolConfig();
5377
- saveToolConfig({ tool: existing?.tool ?? profile.toolId, lang: options.lang });
5484
+ const explicitCourseRules = resolveCourseRulesFlag(process.argv);
5485
+ const resolvedCourseRules = explicitCourseRules ?? readToolConfig()?.courseRules ?? true;
5486
+ if (!dryRun && (options.lang || explicitCourseRules !== undefined)) {
5487
+ const tool = readToolConfig()?.tool ?? profile.toolId;
5488
+ const patch = { tool };
5489
+ if (options.lang)
5490
+ patch.lang = options.lang;
5491
+ if (explicitCourseRules !== undefined)
5492
+ patch.courseRules = explicitCourseRules;
5493
+ updateToolConfig(patch);
5378
5494
  }
5379
5495
  if (options.print) {
5380
5496
  await runPrintMode(ctx, parsed.lessonId, course, profile, auth.access_token, lang, options);
@@ -5405,15 +5521,18 @@ async function runGet(ctx, ref, options) {
5405
5521
  if (prevManifest && prevManifest.manifestVersion === 2) {
5406
5522
  showUpgradeNotice(isTTY);
5407
5523
  }
5524
+ const applyCourseRules = isFiltered ? true : resolvedCourseRules;
5408
5525
  const writeResult = await applyBundle(bundle, process.cwd(), {
5409
- dryRun: options.dryRun === true,
5526
+ dryRun,
5410
5527
  profile,
5411
5528
  partial: isFiltered,
5412
- onConflict: createConflictResolver(isTTY)
5529
+ onConflict: createConflictResolver(isTTY),
5530
+ applyCourseRules
5413
5531
  });
5414
- renderGetResult(ctx, bundle, writeResult, options.dryRun === true, profile, {
5532
+ renderGetResult(ctx, bundle, writeResult, dryRun, profile, {
5415
5533
  language: contentLang ?? lang,
5416
- languageFallback: isFallback
5534
+ languageFallback: isFallback,
5535
+ applyCourseRules
5417
5536
  });
5418
5537
  }
5419
5538
  function filterBundle(ctx, bundle, options) {
@@ -5553,7 +5672,12 @@ function formatAction(action) {
5553
5672
  return action;
5554
5673
  }
5555
5674
  }
5556
- function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = { language: "en", languageFallback: false }) {
5675
+ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
5676
+ language: "en",
5677
+ languageFallback: false,
5678
+ applyCourseRules: true
5679
+ }) {
5680
+ const applyCourseRules = langMeta.applyCourseRules;
5557
5681
  const totalRemovals = writeResult.removals.skills.length + writeResult.removals.prompts.length + writeResult.removals.configs.length;
5558
5682
  if (ctx.json) {
5559
5683
  output(ctx, "", {
@@ -5574,7 +5698,7 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
5574
5698
  counts: {
5575
5699
  skills: writeResult.skills.length,
5576
5700
  prompts: writeResult.prompts.length,
5577
- rules: bundle.rules.length,
5701
+ rules: applyCourseRules ? bundle.rules.length : 0,
5578
5702
  configs: writeResult.configs.length,
5579
5703
  removals: totalRemovals
5580
5704
  }
@@ -5602,7 +5726,13 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
5602
5726
  for (const prompt of writeResult.prompts) {
5603
5727
  lines.push(` [${formatAction(prompt.action)}] prompt ${prompt.path}`);
5604
5728
  }
5605
- if (bundle.rules.length > 0) {
5729
+ if (!applyCourseRules) {
5730
+ if (writeResult.rules.action === "removed") {
5731
+ lines.push(` [removed] rules ${profile.rulesFile}`);
5732
+ } else {
5733
+ verbose(ctx, `course rules disabled — ${profile.rulesFile} left untouched`);
5734
+ }
5735
+ } else if (bundle.rules.length > 0) {
5606
5736
  lines.push(` [${writeResult.rules.action}] rules ${profile.rulesFile} (${bundle.rules.length} block${bundle.rules.length === 1 ? "" : "s"})`);
5607
5737
  }
5608
5738
  for (const config of writeResult.configs) {
@@ -5756,6 +5886,408 @@ function renderModuleDetail(ctx, module) {
5756
5886
  `), undefined);
5757
5887
  }
5758
5888
 
5889
+ // src/commands/sync.ts
5890
+ import { join as join8 } from "node:path";
5891
+ var DEFAULT_COURSE4 = "10xdevs3";
5892
+ var SUPPORTED_LANGS = ["en", "pl"];
5893
+ function registerSyncCommand(cli) {
5894
+ 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) => {
5895
+ const ctx = resolveContext(options);
5896
+ await runSync(ctx, options);
5897
+ });
5898
+ }
5899
+ async function runSync(ctx, options) {
5900
+ if (options.lang && !SUPPORTED_LANGS.includes(options.lang)) {
5901
+ outputError(ctx, "invalid_lang", `Unknown language '${options.lang}'.`, ExitCodes.USAGE, `Supported languages: ${SUPPORTED_LANGS.join(", ")}`);
5902
+ }
5903
+ let moduleFilter;
5904
+ if (options.module !== undefined) {
5905
+ const parsed = parseModule(options.module);
5906
+ if (parsed === null) {
5907
+ outputError(ctx, "invalid_module", `'${options.module}' is not a valid module.`, ExitCodes.USAGE, "Use a module number like '2' or 'm2'.");
5908
+ }
5909
+ moduleFilter = parsed;
5910
+ }
5911
+ const auth = await requireAuth(ctx);
5912
+ const course = options.course ?? DEFAULT_COURSE4;
5913
+ const profile = await resolveToolProfile(options.tool, process.cwd());
5914
+ const dryRun = options.dryRun === true;
5915
+ const force = options.force === true;
5916
+ const lang = options.lang ?? readToolConfig()?.lang ?? "en";
5917
+ const explicitCourseRules = resolveCourseRulesFlag(process.argv);
5918
+ const applyCourseRules = explicitCourseRules ?? readToolConfig()?.courseRules ?? true;
5919
+ verbose(ctx, `fetching catalog ${course}`);
5920
+ const catalogResult = await fetchCatalog(course, auth.access_token);
5921
+ if (!catalogResult.ok) {
5922
+ handleCatalogError(ctx, catalogResult.status, catalogResult.code, catalogResult.error);
5923
+ }
5924
+ const catalog = catalogResult.data;
5925
+ const moduleState = new Map;
5926
+ for (const mod of catalog.modules)
5927
+ moduleState.set(mod.module, mod.effectiveState);
5928
+ const exclusions = [];
5929
+ const unlocked = catalog.lessons.filter((lesson) => {
5930
+ if (moduleState.get(lesson.module) === "locked") {
5931
+ exclusions.push({ lessonId: lesson.lessonId, reason: `module ${lesson.module} is locked` });
5932
+ return false;
5933
+ }
5934
+ return true;
5935
+ });
5936
+ const manifest = readManifest(join8(process.cwd(), profile.manifestDir));
5937
+ const manifestLessonIds = new Set(manifest?.lessons ? Object.keys(manifest.lessons) : []);
5938
+ let targets = unlocked;
5939
+ if (moduleFilter !== undefined)
5940
+ targets = targets.filter((l2) => l2.module === moduleFilter);
5941
+ if (options.all !== true)
5942
+ targets = targets.filter((l2) => manifestLessonIds.has(l2.lessonId));
5943
+ targets = [...targets].sort((a2, b2) => a2.module - b2.module || a2.lesson - b2.lesson);
5944
+ const controller = new AbortController;
5945
+ const onSigint = () => controller.abort();
5946
+ process.once("SIGINT", onSigint);
5947
+ const outcomes = [];
5948
+ try {
5949
+ for (const lesson of targets) {
5950
+ if (controller.signal.aborted)
5951
+ break;
5952
+ outcomes.push(await syncLesson(ctx, lesson, {
5953
+ course,
5954
+ profile,
5955
+ lang,
5956
+ dryRun,
5957
+ force,
5958
+ applyCourseRules,
5959
+ token: auth.access_token,
5960
+ manifest,
5961
+ signal: controller.signal
5962
+ }));
5963
+ }
5964
+ } finally {
5965
+ process.removeListener("SIGINT", onSigint);
5966
+ }
5967
+ renderReport(ctx, profile, {
5968
+ course,
5969
+ dryRun,
5970
+ force,
5971
+ mode: options.all === true ? "all" : "downloaded",
5972
+ module: moduleFilter,
5973
+ outcomes,
5974
+ exclusions
5975
+ });
5976
+ if (outcomes.some((o) => o.status === "errored")) {
5977
+ process.exit(ExitCodes.ERROR);
5978
+ }
5979
+ }
5980
+ async function syncLesson(ctx, lesson, opts) {
5981
+ const stored = opts.manifest?.lessons?.[lesson.lessonId]?.catalogContentHash;
5982
+ if (!opts.force && lesson.contentHash !== undefined && stored !== undefined && lesson.contentHash === stored) {
5983
+ verbose(ctx, `${lesson.lessonId}: upstream unchanged (digest match) — skipping fetch`);
5984
+ return {
5985
+ lessonId: lesson.lessonId,
5986
+ title: lesson.title,
5987
+ fetched: false,
5988
+ status: "unchanged",
5989
+ resources: []
5990
+ };
5991
+ }
5992
+ verbose(ctx, `${lesson.lessonId}: fetching`);
5993
+ const result = await fetchLesson(opts.course, lesson.lessonId, opts.token, {
5994
+ lang: opts.lang,
5995
+ tool: opts.profile.toolId,
5996
+ signal: opts.signal
5997
+ });
5998
+ if (!result.ok) {
5999
+ return {
6000
+ lessonId: lesson.lessonId,
6001
+ title: lesson.title,
6002
+ fetched: true,
6003
+ status: "errored",
6004
+ resources: [],
6005
+ error: {
6006
+ code: result.code || "lesson_fetch_failed",
6007
+ message: lessonErrorMessage(result.status, result.code, result.error),
6008
+ retry: `10x get ${lesson.lessonId}`
6009
+ }
6010
+ };
6011
+ }
6012
+ const bundle = result.data;
6013
+ if (opts.dryRun) {
6014
+ const plan = planBundle(bundle, process.cwd(), {
6015
+ profile: opts.profile,
6016
+ applyCourseRules: opts.applyCourseRules
6017
+ });
6018
+ const resources2 = classifyFromPlan(plan, lesson.lessonId, opts.force);
6019
+ return {
6020
+ lessonId: lesson.lessonId,
6021
+ title: lesson.title,
6022
+ fetched: true,
6023
+ status: lessonStatus(resources2),
6024
+ resources: resources2
6025
+ };
6026
+ }
6027
+ const onConflict = opts.force ? async () => "overwrite" : async () => "skip";
6028
+ const writeResult = await applyBundle(bundle, process.cwd(), {
6029
+ course: opts.course,
6030
+ profile: opts.profile,
6031
+ onConflict,
6032
+ applyCourseRules: opts.applyCourseRules,
6033
+ catalogContentHash: lesson.contentHash
6034
+ });
6035
+ const resources = classifyFromWriteResult(writeResult, lesson.lessonId);
6036
+ return {
6037
+ lessonId: lesson.lessonId,
6038
+ title: lesson.title,
6039
+ fetched: true,
6040
+ status: lessonStatus(resources),
6041
+ resources
6042
+ };
6043
+ }
6044
+ function actionToBucket(action) {
6045
+ switch (action) {
6046
+ case "created":
6047
+ return "created";
6048
+ case "updated":
6049
+ case "conflict_overwritten":
6050
+ case "conflict_saved_user":
6051
+ return "upstream-updated";
6052
+ case "conflict_skipped":
6053
+ return "skipped-conflict";
6054
+ case "removed":
6055
+ return "removed";
6056
+ case "unchanged":
6057
+ case "skipped":
6058
+ default:
6059
+ return "unchanged";
6060
+ }
6061
+ }
6062
+ function remediation(lessonId, type, name) {
6063
+ return `10x get ${lessonId} --type ${type} --name ${name}`;
6064
+ }
6065
+ function classifyFromWriteResult(result, lessonId) {
6066
+ const out = [];
6067
+ for (const skill of result.skills) {
6068
+ for (const file of skill.files) {
6069
+ const bucket = actionToBucket(file.action);
6070
+ out.push({
6071
+ type: "skills",
6072
+ name: skill.name,
6073
+ file: file.path,
6074
+ bucket,
6075
+ ...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "skills", skill.name) } : {}
6076
+ });
6077
+ }
6078
+ }
6079
+ for (const prompt of result.prompts) {
6080
+ const bucket = actionToBucket(prompt.action);
6081
+ out.push({
6082
+ type: "prompts",
6083
+ name: prompt.name,
6084
+ bucket,
6085
+ ...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "prompts", prompt.name) } : {}
6086
+ });
6087
+ }
6088
+ out.push({ type: "rules", name: "course-rules", bucket: actionToBucket(result.rules.action) });
6089
+ for (const config of result.configs) {
6090
+ out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
6091
+ }
6092
+ for (const entry of result.removals.skills) {
6093
+ out.push({ type: "skills", name: entry.name, bucket: "removed" });
6094
+ }
6095
+ for (const entry of result.removals.prompts) {
6096
+ out.push({ type: "prompts", name: entry.name, bucket: "removed" });
6097
+ }
6098
+ for (const entry of result.removals.configs) {
6099
+ out.push({ type: "configs", name: entry.name, bucket: "removed" });
6100
+ }
6101
+ return out;
6102
+ }
6103
+ function classifyFromPlan(plan, lessonId, force) {
6104
+ const out = [];
6105
+ const conflictBucket = force ? "upstream-updated" : "skipped-conflict";
6106
+ for (const skill of plan.skills) {
6107
+ for (const file of skill.files) {
6108
+ const bucket = file.isConflict ? conflictBucket : actionToBucket(file.action);
6109
+ out.push({
6110
+ type: "skills",
6111
+ name: skill.name,
6112
+ file: file.relativePath,
6113
+ bucket,
6114
+ ...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "skills", skill.name) } : {}
6115
+ });
6116
+ }
6117
+ }
6118
+ for (const prompt of plan.prompts) {
6119
+ const bucket = prompt.isConflict ? conflictBucket : actionToBucket(prompt.action);
6120
+ out.push({
6121
+ type: "prompts",
6122
+ name: prompt.name,
6123
+ bucket,
6124
+ ...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "prompts", prompt.name) } : {}
6125
+ });
6126
+ }
6127
+ out.push({ type: "rules", name: "course-rules", bucket: actionToBucket(plan.rules.action) });
6128
+ for (const config of plan.configs) {
6129
+ out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
6130
+ }
6131
+ for (const entry of plan.removals.skills) {
6132
+ out.push({ type: "skills", name: entry.name, bucket: "removed" });
6133
+ }
6134
+ for (const entry of plan.removals.prompts) {
6135
+ out.push({ type: "prompts", name: entry.name, bucket: "removed" });
6136
+ }
6137
+ for (const entry of plan.removals.configs) {
6138
+ out.push({ type: "configs", name: entry.name, bucket: "removed" });
6139
+ }
6140
+ return out;
6141
+ }
6142
+ function lessonStatus(resources) {
6143
+ if (resources.some((r2) => r2.bucket === "created" || r2.bucket === "upstream-updated" || r2.bucket === "removed")) {
6144
+ return "updated";
6145
+ }
6146
+ if (resources.some((r2) => r2.bucket === "skipped-conflict"))
6147
+ return "conflicts";
6148
+ return "unchanged";
6149
+ }
6150
+ function countBuckets(outcomes) {
6151
+ const totals = {
6152
+ created: 0,
6153
+ "upstream-updated": 0,
6154
+ unchanged: 0,
6155
+ "skipped-conflict": 0,
6156
+ removed: 0
6157
+ };
6158
+ for (const o of outcomes) {
6159
+ for (const r2 of o.resources)
6160
+ totals[r2.bucket]++;
6161
+ }
6162
+ return totals;
6163
+ }
6164
+ function renderReport(ctx, profile, input) {
6165
+ const { outcomes, exclusions } = input;
6166
+ const buckets = countBuckets(outcomes);
6167
+ const lessonsErrored = outcomes.filter((o) => o.status === "errored").length;
6168
+ const lessonsConflicts = outcomes.filter((o) => o.status === "conflicts").length;
6169
+ if (ctx.json) {
6170
+ output(ctx, "", {
6171
+ course: input.course,
6172
+ tool: profile.toolId,
6173
+ dryRun: input.dryRun,
6174
+ force: input.force,
6175
+ mode: input.mode,
6176
+ module: input.module ?? null,
6177
+ lessons: outcomes,
6178
+ excluded: exclusions,
6179
+ totals: {
6180
+ lessons: outcomes.length,
6181
+ updated: outcomes.filter((o) => o.status === "updated").length,
6182
+ unchanged: outcomes.filter((o) => o.status === "unchanged").length,
6183
+ conflicts: lessonsConflicts,
6184
+ errored: lessonsErrored,
6185
+ excluded: exclusions.length,
6186
+ resources: {
6187
+ created: buckets.created,
6188
+ upstreamUpdated: buckets["upstream-updated"],
6189
+ unchanged: buckets.unchanged,
6190
+ skippedConflict: buckets["skipped-conflict"],
6191
+ removed: buckets.removed
6192
+ }
6193
+ }
6194
+ });
6195
+ return;
6196
+ }
6197
+ const lines = [];
6198
+ const verb = input.dryRun ? "Would sync" : "Synced";
6199
+ const scope = input.mode === "all" ? "all unlocked lessons" : "downloaded lessons";
6200
+ const moduleNote = input.module !== undefined ? ` in module ${input.module}` : "";
6201
+ lines.push(`${verb} ${input.course} — ${scope}${moduleNote}:`);
6202
+ if (outcomes.length === 0) {
6203
+ lines.push(" (nothing to sync)");
6204
+ }
6205
+ for (const o of outcomes) {
6206
+ if (o.status === "errored") {
6207
+ lines.push(` ${o.lessonId} — error: ${o.error?.message ?? "failed"}`);
6208
+ if (o.error)
6209
+ lines.push(` → retry: ${o.error.retry}`);
6210
+ continue;
6211
+ }
6212
+ const summary = summarizeLesson(o);
6213
+ lines.push(` ${o.lessonId} — ${o.status}${summary ? ` (${summary})` : ""}`);
6214
+ for (const r2 of o.resources) {
6215
+ if (r2.bucket !== "skipped-conflict")
6216
+ continue;
6217
+ const label = r2.file ? `${r2.type}/${r2.name} (${r2.file})` : `${r2.type}/${r2.name}`;
6218
+ lines.push(` skipped ${label} — you edited it → ${r2.remediation}`);
6219
+ }
6220
+ }
6221
+ if (exclusions.length > 0) {
6222
+ lines.push("");
6223
+ lines.push("Excluded:");
6224
+ for (const ex of exclusions)
6225
+ lines.push(` ${ex.lessonId} — ${ex.reason}`);
6226
+ }
6227
+ lines.push("");
6228
+ lines.push(`Totals: ${buckets["upstream-updated"]} updated, ${buckets.created} new, ` + `${buckets.unchanged} unchanged, ${buckets["skipped-conflict"]} skipped (conflicts), ` + `${buckets.removed} removed.`);
6229
+ if (lessonsConflicts > 0 && !input.force) {
6230
+ lines.push("To take all upstream updates over your local edits: 10x sync --force");
6231
+ }
6232
+ output(ctx, lines.join(`
6233
+ `), undefined);
6234
+ }
6235
+ function summarizeLesson(o) {
6236
+ const counts = {
6237
+ created: 0,
6238
+ "upstream-updated": 0,
6239
+ unchanged: 0,
6240
+ "skipped-conflict": 0,
6241
+ removed: 0
6242
+ };
6243
+ for (const r2 of o.resources)
6244
+ counts[r2.bucket]++;
6245
+ const parts = [];
6246
+ if (counts["upstream-updated"])
6247
+ parts.push(`${counts["upstream-updated"]} updated`);
6248
+ if (counts.created)
6249
+ parts.push(`${counts.created} new`);
6250
+ if (counts["skipped-conflict"])
6251
+ parts.push(`${counts["skipped-conflict"]} skipped`);
6252
+ if (counts.removed)
6253
+ parts.push(`${counts.removed} removed`);
6254
+ if (!o.fetched)
6255
+ parts.push("not downloaded");
6256
+ return parts.join(", ");
6257
+ }
6258
+ function handleCatalogError(ctx, status, code, error) {
6259
+ if (status === 401) {
6260
+ outputError(ctx, "auth_required", "Your session is no longer valid.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
6261
+ }
6262
+ if (status === 404) {
6263
+ outputError(ctx, "course_not_found", "Couldn't find that course.", ExitCodes.NOT_FOUND, "Check the --course slug, or omit it to use the default.");
6264
+ }
6265
+ if (status === 0) {
6266
+ outputError(ctx, "network_error", "Could not reach the 10x-toolkit API.", ExitCodes.ERROR, "Check your internet connection and run the command again.");
6267
+ }
6268
+ outputError(ctx, code || "catalog_fetch_failed", "Failed to fetch the course catalog.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
6269
+ }
6270
+ function lessonErrorMessage(status, code, error) {
6271
+ if (status === 403)
6272
+ return "Module is locked.";
6273
+ if (status === 404)
6274
+ return "Lesson not found.";
6275
+ if (status === 401)
6276
+ return "Session expired.";
6277
+ if (status === 0)
6278
+ return "Network error reaching the API.";
6279
+ if (code === "signature_error" || code === "signature_missing" || code === "signature_internal_error") {
6280
+ return "Bundle signature verification failed.";
6281
+ }
6282
+ return error ? `Fetch failed: ${error}` : "Fetch failed.";
6283
+ }
6284
+ function parseModule(value) {
6285
+ const match = /^m?(\d+)$/i.exec(value.trim());
6286
+ if (!match)
6287
+ return null;
6288
+ return Number(match[1]);
6289
+ }
6290
+
5759
6291
  // src/index.ts
5760
6292
  var cli = cac("10x");
5761
6293
  cli.option("--json", "Output as JSON (auto-detected when piped)");
@@ -5763,6 +6295,7 @@ cli.option("--verbose", "Show detailed output on stderr");
5763
6295
  registerAuthCommand(cli);
5764
6296
  registerGetCommand(cli);
5765
6297
  registerListCommand(cli);
6298
+ registerSyncCommand(cli);
5766
6299
  registerDoctorCommand(cli);
5767
6300
  cli.help();
5768
6301
  cli.version(package_default.version);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@przeprogramowani/10x-cli",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Open-source CLI for 10xDevs course content",
5
5
  "repository": {
6
6
  "type": "git",