explico 0.13.1 → 0.15.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 (256) hide show
  1. package/CHANGELOG.md +990 -1
  2. package/README.md +200 -30
  3. package/dist/compiler/astro.d.ts +15 -2
  4. package/dist/compiler/astro.js +40 -11
  5. package/dist/compiler/cli-main.d.ts +100 -0
  6. package/dist/compiler/cli-main.js +433 -0
  7. package/dist/compiler/cli-reference.d.ts +50 -0
  8. package/dist/compiler/cli-reference.js +213 -0
  9. package/dist/compiler/cli-verbs.d.ts +45 -0
  10. package/dist/compiler/cli-verbs.js +224 -0
  11. package/dist/compiler/cli-verify.d.ts +51 -0
  12. package/dist/compiler/cli-verify.js +151 -0
  13. package/dist/compiler/cli.d.ts +408 -24
  14. package/dist/compiler/cli.js +1328 -8
  15. package/dist/compiler/collections.d.ts +88 -1
  16. package/dist/compiler/collections.js +170 -25
  17. package/dist/compiler/content-release.d.ts +49 -0
  18. package/dist/compiler/content-release.js +63 -0
  19. package/dist/compiler/content-schemas.d.ts +2 -2
  20. package/dist/compiler/course-config.d.ts +1 -1
  21. package/dist/compiler/course-config.js +105 -1
  22. package/dist/compiler/decks.d.ts +161 -0
  23. package/dist/compiler/decks.js +235 -0
  24. package/dist/compiler/exports/combined-html.d.ts +5 -1
  25. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  26. package/dist/compiler/exports/deck-fit.js +90 -0
  27. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  28. package/dist/compiler/exports/export-chapters.js +64 -5
  29. package/dist/compiler/exports/export-pages.d.ts +30 -3
  30. package/dist/compiler/exports/export-pages.js +24 -1
  31. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  32. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  33. package/dist/compiler/exports/latex/cache.js +129 -0
  34. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  35. package/dist/compiler/exports/latex/capture.js +3 -1
  36. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  37. package/dist/compiler/exports/latex/compile.js +12 -1
  38. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  39. package/dist/compiler/exports/latex/deck.js +349 -0
  40. package/dist/compiler/exports/latex/document.d.ts +51 -6
  41. package/dist/compiler/exports/latex/document.js +71 -12
  42. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  43. package/dist/compiler/exports/latex/explico.cls +13 -183
  44. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  45. package/dist/compiler/exports/latex/serialize.js +4 -2
  46. package/dist/compiler/exports/latex-export.d.ts +24 -2
  47. package/dist/compiler/exports/latex-export.js +107 -26
  48. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  49. package/dist/compiler/exports/markdown/serialize.js +85 -8
  50. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  51. package/dist/compiler/exports/markdown-export.js +50 -11
  52. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  53. package/dist/compiler/exports/standalone-html.js +3 -3
  54. package/dist/compiler/exports/static-tree.d.ts +38 -1
  55. package/dist/compiler/exports/static-tree.js +120 -4
  56. package/dist/compiler/knowledge-version.d.ts +26 -2
  57. package/dist/compiler/knowledge-version.js +27 -17
  58. package/dist/compiler/lesson-derivation.d.ts +143 -6
  59. package/dist/compiler/lesson-derivation.js +145 -13
  60. package/dist/compiler/manifest.d.ts +133 -4
  61. package/dist/compiler/manifest.js +364 -58
  62. package/dist/compiler/page-versions.d.ts +184 -0
  63. package/dist/compiler/page-versions.js +217 -0
  64. package/dist/compiler/render-diagnostics.d.ts +5 -1
  65. package/dist/compiler/resolution-report.d.ts +24 -6
  66. package/dist/compiler/resolution-report.js +22 -4
  67. package/dist/compiler/validate-content.d.ts +7 -0
  68. package/dist/compiler/validate-content.js +160 -0
  69. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  70. package/dist/curriculum/competency-graph.d.ts +54 -0
  71. package/dist/curriculum/competency-graph.js +41 -0
  72. package/dist/reference/base-library.d.mts +8 -0
  73. package/dist/reference/base-library.mjs +19 -0
  74. package/dist/reference/consistency.d.ts +8 -4
  75. package/dist/reference/consistency.js +82 -26
  76. package/dist/reference/deck-remark.d.mts +123 -0
  77. package/dist/reference/deck-remark.mjs +600 -0
  78. package/dist/reference/deck-source.d.ts +107 -0
  79. package/dist/reference/deck-source.js +140 -0
  80. package/dist/reference/decks-validate.d.ts +133 -0
  81. package/dist/reference/decks-validate.js +359 -0
  82. package/dist/reference/derived-entries.d.ts +5 -1
  83. package/dist/reference/diagram-fit.d.ts +6 -2
  84. package/dist/reference/diagram-fit.js +5 -1
  85. package/dist/reference/figure-symbols.d.ts +23 -6
  86. package/dist/reference/figure-symbols.js +51 -15
  87. package/dist/reference/float-name.d.ts +43 -0
  88. package/dist/reference/float-name.js +58 -0
  89. package/dist/reference/folds-validate.d.ts +1 -1
  90. package/dist/reference/gate-abbreviations.d.ts +1 -1
  91. package/dist/reference/gate-indices.js +5 -4
  92. package/dist/reference/gate-math.d.ts +29 -2
  93. package/dist/reference/gate-math.js +99 -18
  94. package/dist/reference/gate-part-pages.d.ts +1 -1
  95. package/dist/reference/lab-katex-validate.d.ts +10 -6
  96. package/dist/reference/lab-katex-validate.js +17 -9
  97. package/dist/reference/lab-math-scope.js +29 -4
  98. package/dist/reference/math-glyphs.d.mts +25 -0
  99. package/dist/reference/math-glyphs.mjs +189 -25
  100. package/dist/reference/page-notation.d.ts +22 -3
  101. package/dist/reference/page-notation.js +32 -10
  102. package/dist/reference/references.d.ts +14 -1
  103. package/dist/reference/references.js +13 -0
  104. package/dist/reference/registry-scope.d.mts +33 -0
  105. package/dist/reference/registry-scope.mjs +34 -0
  106. package/dist/reference/registry.js +29 -10
  107. package/dist/reference/rehype-deck.d.mts +6 -0
  108. package/dist/reference/rehype-deck.mjs +286 -0
  109. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  110. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  111. package/dist/reference/remark-notation.mjs +249 -40
  112. package/dist/reference/source-lines.d.mts +12 -0
  113. package/dist/reference/source-lines.mjs +65 -0
  114. package/dist/reference/types.d.ts +28 -0
  115. package/dist/rules/catalog.d.ts +21 -4
  116. package/dist/rules/catalog.js +237 -2
  117. package/dist/rules/course-rules.d.ts +5 -1
  118. package/dist/rules/course-rules.js +17 -3
  119. package/dist/rules/course-rules.schema.json +12 -0
  120. package/dist/rules/in-effect.d.ts +13 -3
  121. package/dist/rules/in-effect.js +34 -10
  122. package/dist/rules/rules.schema.json +280 -1
  123. package/dist/rules/settings.d.ts +1 -1
  124. package/dist/session/PreferenceStore.d.ts +6 -0
  125. package/dist/session/PreferenceStore.js +18 -1
  126. package/dist/session/deck-memory.d.ts +116 -0
  127. package/dist/session/deck-memory.js +143 -0
  128. package/dist/session/reading-memory.d.ts +96 -0
  129. package/dist/session/reading-memory.js +133 -0
  130. package/docs/VERSIONING.md +21 -18
  131. package/docs/authoring/README.md +31 -9
  132. package/docs/authoring/agents.md +72 -18
  133. package/docs/authoring/cli-reference.json +2172 -0
  134. package/docs/authoring/cli.md +887 -0
  135. package/docs/authoring/components.md +15 -0
  136. package/docs/authoring/deck.md +612 -0
  137. package/docs/authoring/migrating.md +1195 -16
  138. package/docs/authoring/notation.md +49 -6
  139. package/docs/authoring/structure.md +80 -1
  140. package/docs/authoring/versioning.md +56 -3
  141. package/docs/authoring/visualizations.md +7 -2
  142. package/docs/course-agent/AGENTS.md +40 -0
  143. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  144. package/docs/course-agent/skills/draft-lesson/SKILL.md +38 -0
  145. package/docs/course-agent/skills/migrate-course/SKILL.md +32 -0
  146. package/docs/course-agent/skills/release-content/SKILL.md +26 -0
  147. package/docs/diagram-scaling.md +148 -0
  148. package/docs/privacy.md +59 -0
  149. package/package.json +8 -2
  150. package/src/compiler/astro.ts +60 -21
  151. package/src/compiler/cli-main.ts +557 -0
  152. package/src/compiler/cli-reference.ts +285 -0
  153. package/src/compiler/cli-verbs.ts +270 -0
  154. package/src/compiler/cli-verify.ts +241 -0
  155. package/src/compiler/cli.ts +2119 -170
  156. package/src/compiler/collections.ts +284 -25
  157. package/src/compiler/content-release.ts +91 -0
  158. package/src/compiler/course-config.ts +267 -0
  159. package/src/compiler/decks.ts +455 -0
  160. package/src/compiler/exports/deck-fit.ts +127 -0
  161. package/src/compiler/exports/export-chapters.ts +105 -6
  162. package/src/compiler/exports/export-pages.ts +46 -2
  163. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  164. package/src/compiler/exports/latex/cache.ts +183 -0
  165. package/src/compiler/exports/latex/capture.ts +5 -2
  166. package/src/compiler/exports/latex/compile.ts +18 -0
  167. package/src/compiler/exports/latex/deck.ts +419 -0
  168. package/src/compiler/exports/latex/document.ts +117 -21
  169. package/src/compiler/exports/latex/explico-common.sty +198 -0
  170. package/src/compiler/exports/latex/explico.cls +13 -183
  171. package/src/compiler/exports/latex/serialize.ts +25 -21
  172. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  173. package/src/compiler/exports/latex-export.ts +187 -45
  174. package/src/compiler/exports/markdown/serialize.ts +118 -9
  175. package/src/compiler/exports/markdown-export.ts +56 -11
  176. package/src/compiler/exports/standalone-html.ts +8 -7
  177. package/src/compiler/exports/static-tree.ts +175 -3
  178. package/src/compiler/knowledge-version.ts +56 -21
  179. package/src/compiler/lesson-derivation.ts +282 -16
  180. package/src/compiler/manifest.ts +568 -63
  181. package/src/compiler/page-versions.ts +401 -0
  182. package/src/compiler/resolution-report.ts +38 -7
  183. package/src/compiler/validate-content.ts +215 -0
  184. package/src/components/CourseContents.astro +124 -6
  185. package/src/components/CurriculumMap.astro +49 -1
  186. package/src/components/FloatIndex.astro +1 -1
  187. package/src/components/PartOverview.astro +51 -5
  188. package/src/components/assessments/AssessmentSet.astro +41 -15
  189. package/src/components/block-heading.ts +15 -6
  190. package/src/components/card-carry.ts +7 -4
  191. package/src/components/card-manager.ts +14 -2
  192. package/src/components/card-memory.ts +13 -9
  193. package/src/components/deck/DeckEnhancer.astro +13 -0
  194. package/src/components/deck/deck-context.ts +104 -0
  195. package/src/components/deck/deck-controller.ts +1287 -0
  196. package/src/components/deck/deck-map.ts +91 -0
  197. package/src/components/deck-lane.ts +163 -0
  198. package/src/components/diagram/Diagram.astro +131 -86
  199. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  200. package/src/components/figure/Figure.astro +7 -2
  201. package/src/components/figure/FloatCaption.astro +5 -3
  202. package/src/components/fold/Fold.astro +2 -2
  203. package/src/components/notation/NotationFormula.astro +81 -11
  204. package/src/components/notation/NotationGlossary.astro +132 -1
  205. package/src/components/notation/NotationLayer.astro +15 -7
  206. package/src/components/starlight/AboutPanel.astro +186 -3
  207. package/src/components/starlight/CollapsibleSections.astro +24 -3
  208. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  209. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  210. package/src/components/starlight/LayoutHeader.astro +17 -0
  211. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  212. package/src/components/starlight/LayoutSidebar.astro +807 -21
  213. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  214. package/src/components/starlight/LessonExportActions.astro +27 -9
  215. package/src/components/starlight/LessonFooter.astro +59 -0
  216. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  217. package/src/components/starlight/TextSizeControl.astro +24 -13
  218. package/src/components/starlight/page-marks.ts +139 -0
  219. package/src/components/starlight/section-summary.ts +1 -1
  220. package/src/components/starlight/sidebar-editions.ts +245 -0
  221. package/src/components/table/Table.astro +15 -7
  222. package/src/content-config.ts +87 -1
  223. package/src/curriculum/competency-graph.ts +95 -0
  224. package/src/reference/base-library.mjs +19 -0
  225. package/src/reference/consistency.ts +137 -39
  226. package/src/reference/deck-remark.mjs +600 -0
  227. package/src/reference/deck-source.ts +273 -0
  228. package/src/reference/decks-validate.ts +604 -0
  229. package/src/reference/diagram-fit.ts +18 -4
  230. package/src/reference/diagram-layout.ts +3 -1
  231. package/src/reference/figure-symbols.ts +108 -31
  232. package/src/reference/folds-validate.ts +1 -1
  233. package/src/reference/gate-indices.ts +5 -3
  234. package/src/reference/gate-math.ts +169 -25
  235. package/src/reference/lab-katex-validate.ts +30 -15
  236. package/src/reference/lab-math-scope.ts +34 -3
  237. package/src/reference/math-glyphs.mjs +189 -25
  238. package/src/reference/page-notation.ts +69 -15
  239. package/src/reference/references.ts +28 -0
  240. package/src/reference/registry-scope.mjs +34 -0
  241. package/src/reference/registry.ts +41 -11
  242. package/src/reference/rehype-deck.mjs +286 -0
  243. package/src/reference/rehype-heading-anchors.mjs +48 -6
  244. package/src/reference/remark-notation.mjs +249 -40
  245. package/src/reference/source-lines.mjs +65 -0
  246. package/src/reference/types.ts +28 -0
  247. package/src/rules/catalog.ts +274 -4
  248. package/src/rules/course-rules.ts +21 -1
  249. package/src/rules/in-effect.ts +58 -11
  250. package/src/session/PreferenceStore.ts +23 -0
  251. package/src/session/deck-memory.ts +263 -0
  252. package/src/session/reading-memory.ts +243 -0
  253. package/src/styles/deck.css +1150 -0
  254. package/src/styles/global.css +958 -27
  255. package/src/styles/zones.css +277 -0
  256. /package/src/{components → reference}/float-name.ts +0 -0
@@ -0,0 +1,557 @@
1
+ /**
2
+ * `pnpm content <command>` — the AI-authoring surface, whole.
3
+ *
4
+ * The engine owns the dispatch, the usage text, the flag parsing, the text
5
+ * rendering and the exit codes; a course's `scripts/content.ts` is three
6
+ * lines that hand it the course config:
7
+ *
8
+ * import { runContentCli } from 'explico/compiler/cli-main';
9
+ * import { courseConfig } from '../content/course.config';
10
+ * process.exitCode = await runContentCli(courseConfig);
11
+ *
12
+ * so every verb reaches every course with the package and no course forks
13
+ * the router. Each verb's logic lives in `./cli`; this module only parses
14
+ * argv, prints (`--json` = data only, no stack traces), and returns the exit
15
+ * code. Everything except `new` and `verify` is read-only; `move`, `rename`,
16
+ * `fix` and `init-agent` print a diff and change nothing without `--write`.
17
+ * Each verb's row — usage, flags, what it writes, the type its `--json`
18
+ * prints — is in `./cli-verbs`; `content help <verb>` prints it.
19
+ */
20
+ import { mkdirSync, writeFileSync } from 'node:fs';
21
+ import { dirname, join } from 'node:path';
22
+
23
+ import {
24
+ ContentCliError,
25
+ applyEditPlan,
26
+ changedLessonIds,
27
+ contentBindings,
28
+ contentCheck,
29
+ contentContext,
30
+ contentDiagnostics,
31
+ contentEquation,
32
+ contentMigrate,
33
+ contentRule,
34
+ contentRules,
35
+ contentStatus,
36
+ contentSymbol,
37
+ contentWarnings,
38
+ filterDiagnostics,
39
+ findLesson,
40
+ formatBindingsReport,
41
+ formatCliDiagnostic,
42
+ formatDiagnosticsSummary,
43
+ formatEquationReport,
44
+ formatMigrateReport,
45
+ formatRuleReport,
46
+ formatSymbolReport,
47
+ ownerTag,
48
+ parseRulesFilter,
49
+ planFixMany,
50
+ planInitAgent,
51
+ planMove,
52
+ planRename,
53
+ renderEditPlan,
54
+ scaffoldDeck,
55
+ scaffoldLesson,
56
+ scaffoldTerm,
57
+ summarizeDiagnostics,
58
+ type ContentCliCode,
59
+ type EditPlan,
60
+ type RulesReport,
61
+ type Scaffold,
62
+ } from './cli';
63
+ import { formatVerbHelp, loadCliReference, verbHelp, type HelpJson } from './cli-reference';
64
+ import { CONTENT_USAGE } from './cli-verbs';
65
+ import { planContentRelease } from './content-release';
66
+ import { formatVerifyReport, formatVerifyStep, runVerify, type VerifyReport } from './cli-verify';
67
+ import type { CourseConfig } from './course-config';
68
+ import { compileManifest, type Manifest } from './manifest';
69
+
70
+ export { CONTENT_USAGE };
71
+
72
+ /** `--json` of `rules`: the report without its Markdown page. */
73
+ export type RulesJson = Pick<RulesReport, 'filter' | 'count' | 'rules'>;
74
+
75
+ /** `--json` of `move`, `rename`, `fix` and `init-agent`: the plan, and whether `--write` applied it. */
76
+ export type EditPlanJson = EditPlan & {
77
+ /** True when `--write` applied the edits; false for a dry run. */
78
+ readonly written: boolean;
79
+ };
80
+
81
+ /** `--json` of `new lesson`, `new deck` and `new term`. */
82
+ export interface NewJson {
83
+ readonly ok: true;
84
+ /** Course-relative paths of the files it created. */
85
+ readonly created: readonly string[];
86
+ }
87
+
88
+ /** `--json` of any verb whose run failed: a stable code and a message, never a stack. */
89
+ export interface ErrorJson {
90
+ readonly ok: false;
91
+ readonly error: {
92
+ /** `internal` for an unexpected throw; every other code is stable. */
93
+ readonly code: ContentCliCode | 'internal';
94
+ readonly message: string;
95
+ };
96
+ }
97
+
98
+ export interface ContentArgs {
99
+ readonly command: string;
100
+ readonly positionals: readonly string[];
101
+ readonly json: boolean;
102
+ readonly changed: boolean;
103
+ /** `--element a,b` — a value-taking flag; also `--element=a,b`. */
104
+ readonly element?: string;
105
+ /** `--enforcement script` — a value-taking flag; also `--enforcement=script`. */
106
+ readonly enforcement?: string;
107
+ /** `--edition deck` — a value-taking flag; also `--edition=deck`. */
108
+ readonly edition?: string;
109
+ /** `--all`: list every diagnostic instead of the counts. */
110
+ readonly all: boolean;
111
+ /** `--code <code>`: only that diagnostic code. */
112
+ readonly code?: string;
113
+ /** `--lesson <id>`: only that lesson's diagnostics; the scope for `equation`. */
114
+ readonly lesson?: string;
115
+ /** `--from <version>` / `--to <version>` for `migrate`; `--to <key>` for `move`. */
116
+ readonly from?: string;
117
+ readonly to?: string;
118
+ /** `--write`: apply a planned edit instead of only showing its diff. */
119
+ readonly write: boolean;
120
+ /** `--e2e`: `verify` runs the browser tests too. */
121
+ readonly e2e: boolean;
122
+ /** `--force`: `verify` runs even on a tree its last pass covered. */
123
+ readonly force: boolean;
124
+ }
125
+
126
+ const VALUED_FLAGS = ['element', 'enforcement', 'edition', 'code', 'lesson', 'from', 'to'] as const;
127
+
128
+ export function parseContentArgs(argv: readonly string[]): ContentArgs {
129
+ const positionals: string[] = [];
130
+ const values: Partial<Record<(typeof VALUED_FLAGS)[number], string>> = {};
131
+ let json = false;
132
+ let changed = false;
133
+ let all = false;
134
+ let write = false;
135
+ let e2e = false;
136
+ let force = false;
137
+ const valued = new RegExp(`^--(${VALUED_FLAGS.join('|')})(?:=(.*))?$`);
138
+ for (let i = 0; i < argv.length; i += 1) {
139
+ const arg = argv[i]!;
140
+ const match = valued.exec(arg);
141
+ if (arg === '--json') json = true;
142
+ else if (arg === '--changed') changed = true;
143
+ else if (arg === '--all') all = true;
144
+ else if (arg === '--write') write = true;
145
+ else if (arg === '--e2e') e2e = true;
146
+ else if (arg === '--force') force = true;
147
+ else if (match) values[match[1] as (typeof VALUED_FLAGS)[number]] = match[2] ?? argv[(i += 1)] ?? '';
148
+ else positionals.push(arg);
149
+ }
150
+ return {
151
+ command: positionals[0] ?? '',
152
+ positionals: positionals.slice(1),
153
+ json,
154
+ changed,
155
+ all,
156
+ write,
157
+ e2e,
158
+ force,
159
+ ...values,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * A lesson's diagnostics include the shared cards it shows (a fault in one is
165
+ * the page's too), so `context` says how many sit in the lesson's own file —
166
+ * the number `diagnostics --lesson` lists.
167
+ */
168
+ function diagnosticsWhere(diagnostics: readonly { readonly file?: string }[], file: string): string {
169
+ const own = diagnostics.filter((diagnostic) => diagnostic.file === file).length;
170
+ return own === diagnostics.length ? '' : ` (${own} in ${file}, ${diagnostics.length - own} in the shared cards it shows)`;
171
+ }
172
+
173
+ /** Where a run prints; the process streams by default, a buffer in a test. */
174
+ export interface ContentIo {
175
+ readonly out: (text: string) => void;
176
+ readonly err: (text: string) => void;
177
+ }
178
+
179
+ const PROCESS_IO: ContentIo = {
180
+ out: (text) => process.stdout.write(text),
181
+ err: (text) => process.stderr.write(text),
182
+ };
183
+
184
+ /** A usage or input mistake, reported like a `ContentCliError` and ending the run with 1. */
185
+ class Usage extends Error {
186
+ constructor(
187
+ readonly code: ContentCliCode,
188
+ message: string,
189
+ ) {
190
+ super(message);
191
+ }
192
+ }
193
+
194
+ function usage(code: ContentCliCode, message: string): never {
195
+ throw new Usage(code, message);
196
+ }
197
+
198
+ function writeScaffold(scaffold: Scaffold): string[] {
199
+ const written: string[] = [];
200
+ for (const file of [{ path: scaffold.path, contents: scaffold.contents }, ...scaffold.companions]) {
201
+ const absolute = join(process.cwd(), file.path);
202
+ mkdirSync(dirname(absolute), { recursive: true });
203
+ writeFileSync(absolute, file.contents, { flag: 'wx' });
204
+ written.push(file.path);
205
+ }
206
+ return written;
207
+ }
208
+
209
+ /**
210
+ * Run one `content` command for this course and return its exit code: 0 on
211
+ * success, 1 on a usage error, a `ContentCliError`, a check that failed, or
212
+ * an unexpected throw (reported by message, never a stack).
213
+ */
214
+ export async function runContentCli(
215
+ courseConfig: CourseConfig,
216
+ argv: readonly string[] = process.argv.slice(2),
217
+ io: ContentIo = PROCESS_IO,
218
+ /** A manifest already compiled for this course, reused instead of compiling one: a test running many verbs compiles once. */
219
+ compiled?: Manifest,
220
+ ): Promise<number> {
221
+ const args = parseContentArgs(argv);
222
+ const json = (value: unknown) => io.out(`${JSON.stringify(value, null, 2)}\n`);
223
+ try {
224
+ return await dispatch(courseConfig, args, io, json, compiled);
225
+ } catch (error) {
226
+ const code =
227
+ error instanceof Usage || error instanceof ContentCliError ? error.code : 'internal';
228
+ const message = error instanceof Error ? error.message : String(error);
229
+ if (args.json) json({ ok: false, error: { code, message } } satisfies ErrorJson);
230
+ else io.err(`Error [${code}]: ${message}\n`);
231
+ return 1;
232
+ }
233
+ }
234
+
235
+ async function dispatch(
236
+ courseConfig: CourseConfig,
237
+ args: ContentArgs,
238
+ io: ContentIo,
239
+ json: (value: unknown) => void,
240
+ compiled: Manifest | undefined,
241
+ ): Promise<number> {
242
+ const out = io.out;
243
+ if (args.command === '' || args.command === '--help' || (args.command === 'help' && args.positionals.length === 0 && !args.json)) {
244
+ out(`${CONTENT_USAGE}\n`);
245
+ return 0;
246
+ }
247
+
248
+ // `help` reads the generated reference beside the engine: no manifest.
249
+ if (args.command === 'help') {
250
+ if (args.positionals.length > 1) usage('usage', `content help takes one verb; got ${args.positionals.join(' ')}.`);
251
+ const reference = loadCliReference();
252
+ const [word] = args.positionals;
253
+ if (word === undefined) {
254
+ json(reference satisfies HelpJson);
255
+ return 0;
256
+ }
257
+ const help = verbHelp(reference, word);
258
+ if (args.json) json(help satisfies HelpJson);
259
+ else out(formatVerbHelp(reference, help));
260
+ return 0;
261
+ }
262
+
263
+ // `verify` runs the course's own scripts, each in its own process: no manifest here.
264
+ if (args.command === 'verify') {
265
+ if (args.positionals.length > 0) usage('usage', `content verify takes no argument; got ${args.positionals.join(' ')}.`);
266
+ if (!args.json) out(`verify (${args.e2e ? 'with' : 'without'} e2e; whole output under node_modules/.cache/explico/verify/)\n`);
267
+ const report = runVerify({
268
+ e2e: args.e2e,
269
+ force: args.force,
270
+ ...(args.json ? {} : { onStep: (event) => event.phase === 'end' && out(formatVerifyStep(event.step)) }),
271
+ });
272
+ if (args.json) json(report satisfies VerifyReport);
273
+ else out(report.cached ? formatVerifyReport(report) : `\n${formatVerifyReport(report)}`);
274
+ return report.ok ? 0 : 1;
275
+ }
276
+
277
+ if (args.command === 'new') {
278
+ const [kind, id] = args.positionals;
279
+ if (kind !== 'lesson' && kind !== 'deck' && kind !== 'term') {
280
+ usage('unknown-kind', 'content new expects "lesson", "deck" or "term".');
281
+ }
282
+ if (!id) usage('usage', `content new ${kind} needs an id.`);
283
+ if (args.positionals.length > 2) {
284
+ usage('usage', `content new ${kind} takes one id; got also ${args.positionals.slice(2).join(' ')}.`);
285
+ }
286
+ const scaffold = kind === 'lesson' ? scaffoldLesson(id) : kind === 'deck' ? scaffoldDeck(id) : scaffoldTerm(id);
287
+ const written = writeScaffold(scaffold);
288
+ if (args.json) json({ ok: true, created: written } satisfies NewJson);
289
+ else out(`Created:\n${written.map((p) => ` ${p}`).join('\n')}\n`);
290
+ return 0;
291
+ }
292
+
293
+ // An argument a command does not take is a mistake, never silently dropped:
294
+ // `content status extra` would otherwise look like it did something with it.
295
+ if (['status', 'rules', 'warnings'].includes(args.command) && args.positionals.length > 0) {
296
+ usage('usage', `content ${args.command} takes no <lesson>; got ${args.positionals.join(' ')}.`);
297
+ }
298
+
299
+ // `init-agent` copies the engine's procedures into the course, `release`
300
+ // edits the changelog and the config: no manifest for either.
301
+ if (args.command === 'init-agent' || args.command === 'release') {
302
+ let plan;
303
+ if (args.command === 'init-agent') {
304
+ if (args.positionals.length > 0) usage('usage', `content init-agent takes no argument; got ${args.positionals.join(' ')}.`);
305
+ plan = planInitAgent();
306
+ } else {
307
+ if (args.positionals.length > 1) usage('usage', `content release takes one of major, minor, patch; got ${args.positionals.join(' ')}.`);
308
+ plan = planContentRelease(args.positionals[0]);
309
+ }
310
+ if (args.json) json({ ...plan, written: args.write } satisfies EditPlanJson);
311
+ else out(renderEditPlan(plan));
312
+ if (plan.edits.length === 0) return 0;
313
+ const next = plan.next.length > 0 ? plan.next.join(' · ') : '';
314
+ if (args.write) {
315
+ const touched = applyEditPlan(plan);
316
+ if (!args.json) out(`\nwritten: ${touched.join(', ')}${next === '' ? '' : `\nnext: ${next}`}\n`);
317
+ } else if (!args.json) out(`\ndry run: nothing written. Add --write to apply${next === '' ? '' : `; then ${next}`}.\n`);
318
+ return 0;
319
+ }
320
+
321
+ // `migrate` reads the guide beside the engine, not the course: no manifest.
322
+ if (args.command === 'migrate') {
323
+ if (args.positionals.length > 0) {
324
+ usage('usage', `content migrate takes no positional argument; got ${args.positionals.join(' ')}. Use --from and --to.`);
325
+ }
326
+ const report = contentMigrate({
327
+ ...(args.from === undefined ? {} : { from: args.from }),
328
+ ...(args.to === undefined ? {} : { to: args.to }),
329
+ });
330
+ if (args.json) json(report);
331
+ else out(formatMigrateReport(report));
332
+ return 0;
333
+ }
334
+
335
+ const known = [
336
+ 'status', 'move', 'rename', 'fix', 'rule', 'rules', 'warnings', 'diagnostics',
337
+ 'context', 'equation', 'bindings', 'symbol', 'check',
338
+ ];
339
+ if (!known.includes(args.command)) {
340
+ usage('unknown-command', `Unknown command "${args.command}".\n${CONTENT_USAGE}`);
341
+ }
342
+
343
+ const manifest = compiled ?? (await compileManifest(courseConfig));
344
+
345
+ switch (args.command) {
346
+ case 'status': {
347
+ const report = contentStatus(manifest);
348
+ if (args.json) {
349
+ json(report);
350
+ return 0;
351
+ }
352
+ const c = report.counts;
353
+ const unreleased = report.pageVersions.unreleased;
354
+ const UNRELEASED_SHOWN = 6;
355
+ const unreleasedList =
356
+ unreleased.length <= UNRELEASED_SHOWN
357
+ ? unreleased.join(', ')
358
+ : `${unreleased.slice(0, UNRELEASED_SHOWN).join(', ')}, … ${unreleased.length - UNRELEASED_SHOWN} more in --json`;
359
+ out(
360
+ `Knowledge version ${report.knowledgeVersion}\n` +
361
+ `Page versions: ${report.pageVersions.tracked} page(s) tracked, ` +
362
+ `${unreleased.length} with changes not yet released` +
363
+ `${unreleased.length > 0 ? ` (${unreleasedList})` : ''}\n` +
364
+ `Lessons ${c.lessons} · competencies ${c.competencies} · assessments ${c.assessments} · ` +
365
+ `sources ${c.sources} · tracks ${c.tracks} · notation ${c.notationDefinitions} · ` +
366
+ `keyed equations ${c.keyedEquations} · tables ${c.keyedTables} · ` +
367
+ `figures ${c.keyedFigures} · diagrams ${c.keyedDiagrams} · example sets ${c.keyedExamples}\n` +
368
+ `Diagnostics: ${report.diagnostics.errors} error(s), ${report.diagnostics.warnings} warning(s), ${report.diagnostics.infos} info\n` +
369
+ `Orphans: ${report.orphans.length}\n` +
370
+ `Agent surfaces (estimated tokens): ${report.agentSurfaces
371
+ .map((s) => `${s.name} ${(s.tokens / 1000).toFixed(1)}k`)
372
+ .join(' · ')}\n` +
373
+ `Agent guide: ${
374
+ report.agentGuide.agentsFile === null
375
+ ? 'no AGENTS.md — `pnpm content init-agent --write` installs the baseline'
376
+ : report.agentGuide.linksGuide
377
+ ? `${report.agentGuide.agentsFile} points at the engine's guide`
378
+ : `${report.agentGuide.agentsFile} does not point at node_modules/explico/docs/authoring/agents.md — add that line`
379
+ }; procedures ${report.agentGuide.skillsInstalled ? 'installed' : 'not installed (`pnpm content init-agent --write`)'}\n`,
380
+ );
381
+ return 0;
382
+ }
383
+
384
+ case 'move':
385
+ case 'rename':
386
+ case 'fix': {
387
+ let plan;
388
+ if (args.command === 'move') {
389
+ const [target, ...extra] = args.positionals;
390
+ const [lesson, key] = (target ?? '').split(':');
391
+ if (!lesson || !key || extra.length > 0) usage('usage', 'content move needs <lesson>:<key> and optionally --to <key>.');
392
+ plan = planMove(manifest, lesson, key, args.to === undefined ? {} : { to: args.to });
393
+ } else if (args.command === 'rename') {
394
+ const [oldKey, newKey, ...extra] = args.positionals;
395
+ if (!oldKey || !newKey || extra.length > 0) usage('usage', 'content rename needs <old> <new>.');
396
+ plan = planRename(manifest, oldKey, newKey);
397
+ } else {
398
+ if (args.all === (args.positionals.length > 0)) usage('usage', 'content fix needs <lesson> ids, or --all for every lesson.');
399
+ plan = planFixMany(manifest, args.all ? manifest.lessons.map((lesson) => lesson.id) : args.positionals);
400
+ }
401
+ if (args.json) json({ ...plan, written: args.write } satisfies EditPlanJson);
402
+ else out(renderEditPlan(plan));
403
+ if (plan.edits.length === 0) return 0;
404
+ if (args.write) {
405
+ const touched = applyEditPlan(plan);
406
+ if (!args.json) out(`\nwritten: ${touched.join(', ')}\nnext: ${plan.next.join(' · ')}\n`);
407
+ } else if (!args.json) {
408
+ out(`\ndry run: nothing written. Add --write to apply; then ${plan.next.join(' · ')}.\n`);
409
+ }
410
+ return 0;
411
+ }
412
+
413
+ case 'rule': {
414
+ const [id, ...extra] = args.positionals;
415
+ if (!id) usage('usage', 'content rule needs a rule ID: N4, V8, X1, or an invariant such as keyed-floats.');
416
+ if (extra.length > 0) usage('usage', `content rule takes one ID; got also ${extra.join(' ')}.`);
417
+ const report = contentRule(manifest, id);
418
+ if (args.json) json(report);
419
+ else out(formatRuleReport(report));
420
+ return report.status === 'unknown' ? 1 : 0;
421
+ }
422
+
423
+ case 'rules': {
424
+ const filter = parseRulesFilter({
425
+ ...(args.element === undefined ? {} : { element: args.element }),
426
+ ...(args.enforcement === undefined ? {} : { enforcement: args.enforcement }),
427
+ ...(args.edition === undefined ? {} : { edition: args.edition }),
428
+ });
429
+ const report = contentRules(manifest, filter);
430
+ if (args.json) json({ filter: report.filter, count: report.count, rules: report.rules } satisfies RulesJson);
431
+ else out(report.markdown);
432
+ return 0;
433
+ }
434
+
435
+ case 'warnings': {
436
+ const report = contentWarnings(manifest);
437
+ if (args.json) json(report);
438
+ else {
439
+ for (const warning of report.warnings) out(`${formatCliDiagnostic(warning)}\n`);
440
+ out(`${report.count} warning(s)\n`);
441
+ }
442
+ return 0;
443
+ }
444
+
445
+ case 'diagnostics': {
446
+ const [requested = 'info', ...extra] = args.positionals;
447
+ if (extra.length > 0 || (requested !== 'info' && requested !== 'warning' && requested !== 'error')) {
448
+ usage('usage', 'content diagnostics expects one of: info, warning, error.');
449
+ }
450
+ // The counts are the default; a filter or --all lists the lines.
451
+ let report = contentDiagnostics(manifest, requested);
452
+ let narrowed = false;
453
+ if (args.code !== undefined) {
454
+ report = filterDiagnostics(report, { code: args.code });
455
+ narrowed = true;
456
+ }
457
+ if (args.lesson !== undefined) {
458
+ report = filterDiagnostics(report, { files: [findLesson(manifest, args.lesson).file] });
459
+ narrowed = true;
460
+ }
461
+ if (args.changed) {
462
+ const ids = new Set(changedLessonIds());
463
+ const files = manifest.lessons.filter((l) => ids.has(l.id)).map((l) => l.file);
464
+ report = filterDiagnostics(report, { files });
465
+ narrowed = true;
466
+ }
467
+ if (args.json) json(report);
468
+ else if (args.all || narrowed) {
469
+ for (const diagnostic of report.diagnostics) out(`${formatCliDiagnostic(diagnostic)}\n`);
470
+ out(`${report.count} diagnostic(s) at ${report.minimumSeverity} or higher\n`);
471
+ } else out(formatDiagnosticsSummary(summarizeDiagnostics(report)));
472
+ return 0;
473
+ }
474
+
475
+ case 'context': {
476
+ const [lesson, ...extra] = args.positionals;
477
+ if (!lesson) usage('usage', 'content context needs a <lesson> id.');
478
+ if (extra.length > 0) usage('usage', `content context takes one <lesson> id; got also ${extra.join(' ')}.`);
479
+ const report = contentContext(manifest, lesson);
480
+ if (args.json) {
481
+ json(report);
482
+ return 0;
483
+ }
484
+ const t = report.tokens;
485
+ out(
486
+ `${report.lesson.id} — ${report.lesson.title}\n` +
487
+ `teaches: ${report.lesson.teaches.join(', ') || '(none)'}\n` +
488
+ `requires: ${report.lesson.requires.join(', ') || '(none)'}\n` +
489
+ `notation: ${report.notation.map((n) => n.key).join(', ') || '(none)'}\n` +
490
+ `sources: ${report.sources.map((s) => s.id).join(', ') || '(none)'}\n` +
491
+ `diagnostics: ${report.diagnostics.length}${diagnosticsWhere(report.diagnostics, report.lesson.file)}\n` +
492
+ `tokens: context ${t.context} vs doc set ${t.docSet} + corpus ${t.corpus} ` +
493
+ `(${(t.ratio * 100).toFixed(2)}% of the baseline)\n`,
494
+ );
495
+ return 0;
496
+ }
497
+
498
+ case 'equation': {
499
+ const [latex, ...extra] = args.positionals;
500
+ if (!latex) usage('usage', "content equation needs one quoted LaTeX string: content equation 'v_0 \\sin(\\theta)' --lesson <id>.");
501
+ if (extra.length > 0) usage('usage', `content equation takes one string; got also ${extra.join(' ')}. Quote the whole expression.`);
502
+ const report = contentEquation(manifest, latex, args.lesson === undefined ? {} : { lesson: args.lesson });
503
+ if (args.json) json(report);
504
+ else out(formatEquationReport(report));
505
+ return report.ok ? 0 : 1;
506
+ }
507
+
508
+ case 'bindings': {
509
+ const [lesson, ...extra] = args.positionals;
510
+ if (!lesson) usage('usage', 'content bindings needs a <lesson> id.');
511
+ if (extra.length > 0) usage('usage', `content bindings takes one <lesson> id; got also ${extra.join(' ')}.`);
512
+ const report = contentBindings(manifest, lesson);
513
+ if (args.json) json(report);
514
+ else out(formatBindingsReport(report));
515
+ return 0;
516
+ }
517
+
518
+ case 'symbol': {
519
+ const [query, ...extra] = args.positionals;
520
+ if (!query) usage('usage', "content symbol needs a <latex | key>; quote a backslash: '\\theta'.");
521
+ if (extra.length > 0) {
522
+ usage('usage', `content symbol takes one <latex | key>; got also ${extra.join(' ')}. Quote a symbol with spaces: 'v(t)'.`);
523
+ }
524
+ const report = contentSymbol(manifest, query);
525
+ if (args.json) json(report);
526
+ else out(formatSymbolReport(report));
527
+ return 0;
528
+ }
529
+
530
+ case 'check': {
531
+ // Every lesson named is checked, not only the first.
532
+ const report = await contentCheck(manifest, { lessons: args.positionals, changed: args.changed });
533
+ if (args.json) json(report);
534
+ else {
535
+ for (const entry of report.lessons) {
536
+ out(`${entry.ok ? 'ok ' : 'FAIL'} ${entry.lesson}${entry.rendered ? '' : ' (render failed)'}\n`);
537
+ // Errors and warnings line by line; info, which never fails a check, counted by code.
538
+ const infos = new Map<string, number>();
539
+ for (const diagnostic of entry.diagnostics) {
540
+ if (diagnostic.severity === 'info') infos.set(diagnostic.code, (infos.get(diagnostic.code) ?? 0) + 1);
541
+ else out(` [${diagnostic.severity}] ${diagnostic.code}${ownerTag(diagnostic)}: ${diagnostic.message}\n`);
542
+ }
543
+ if (infos.size > 0) {
544
+ const total = [...infos.values()].reduce((sum, n) => sum + n, 0);
545
+ const byCode = [...infos].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([code, n]) => `${code} ${n}`).join(', ');
546
+ out(` + ${total} info (${byCode}); content diagnostics --lesson ${entry.lesson} lists them\n`);
547
+ }
548
+ }
549
+ if (report.lessons.length === 0) out('No changed lessons.\n');
550
+ }
551
+ return report.ok ? 0 : 1;
552
+ }
553
+
554
+ default:
555
+ return usage('unknown-command', `Unknown command "${args.command}".\n${CONTENT_USAGE}`);
556
+ }
557
+ }