@erclx/aitk 0.100.0 → 0.102.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.
@@ -0,0 +1,650 @@
1
+ import { relative } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import {
4
+ defineTerms,
5
+ type ListOutcome,
6
+ listWorkspaces,
7
+ type OpenOutcome,
8
+ openWorkspace,
9
+ type ReadOutcome,
10
+ readWorkspace,
11
+ recordSources,
12
+ type Source,
13
+ type SourceOutcome,
14
+ type TeachRefused,
15
+ type Term,
16
+ type TermOutcome,
17
+ type WorkspaceSummary,
18
+ } from '@/teach/workspace'
19
+ import {
20
+ intro,
21
+ logAdd,
22
+ logError,
23
+ logInfo,
24
+ logStep,
25
+ logWarn,
26
+ outro,
27
+ pipeOutput,
28
+ } from '@/ui'
29
+ import { mainWorktreeRoot } from '@/worktree'
30
+
31
+ /**
32
+ * A pair as the command line spells it, split on the first `=` so a value
33
+ * carrying its own separator survives. A source URL is the case that needs it.
34
+ */
35
+ const PAIR = /^([^=]+)=([\s\S]+)$/
36
+
37
+ interface ListCommandOptions {
38
+ readonly json?: boolean
39
+ readonly root?: string
40
+ }
41
+
42
+ interface OpenCommandOptions {
43
+ readonly date?: string
44
+ readonly json?: boolean
45
+ readonly outOfScope?: readonly string[]
46
+ readonly root?: string
47
+ readonly startingPoint?: string
48
+ readonly subject?: string
49
+ readonly success?: readonly string[]
50
+ readonly title?: string
51
+ }
52
+
53
+ interface ResourceCommandOptions {
54
+ readonly json?: boolean
55
+ readonly lead?: readonly string[]
56
+ readonly read?: readonly string[]
57
+ readonly root?: string
58
+ }
59
+
60
+ interface GlossaryCommandOptions {
61
+ readonly firstSeen?: string
62
+ readonly json?: boolean
63
+ readonly root?: string
64
+ readonly term?: readonly string[]
65
+ }
66
+
67
+ export function register(program: Command): void {
68
+ const teach = program
69
+ .command('teach')
70
+ .description('Manage learning workspaces in .claude/teach/')
71
+ .helpOption('-h, --help', 'Show this help message')
72
+
73
+ teach
74
+ .command('list')
75
+ .description('List learning workspaces, or what one workspace holds')
76
+ .argument('[topic]', 'Workspace folder or topic, as in regular-expressions')
77
+ .helpOption('-h, --help', 'Show this help message')
78
+ .option('--json', 'Emit a machine-readable record on stdout')
79
+ .option('--root <path>', 'Teach root, defaulting to the main worktree')
80
+ .addHelpText(
81
+ 'after',
82
+ [
83
+ '',
84
+ 'Exit codes:',
85
+ ' 0 the workspaces or their contents were listed',
86
+ ' 1 refused, with the reason on stderr or in the JSON record',
87
+ '',
88
+ 'With no topic it reports one line per workspace and the ordinal an',
89
+ 'open would take. With one it reports the files behind each count.',
90
+ '',
91
+ 'A workspace not named NN-<topic> is still listed. It sorts last and',
92
+ 'moves no ordinal, since dropping it hides the folder needing a fix.',
93
+ '',
94
+ 'Examples:',
95
+ ' aitk teach list',
96
+ ' aitk teach list regular-expressions --json',
97
+ '',
98
+ ].join('\n'),
99
+ )
100
+ .action(async (topic: string | undefined, opts: ListCommandOptions) => {
101
+ process.exitCode = await runList(topic, opts)
102
+ })
103
+
104
+ teach
105
+ .command('open')
106
+ .description('Open a workspace at the next ordinal with its required files')
107
+ .argument('<topic>', 'Kebab-case topic, as in regular-expressions')
108
+ .helpOption('-h, --help', 'Show this help message')
109
+ .option('--subject <line>', 'One line stating what the workspace covers')
110
+ .option('--starting-point <text>', 'What the learner already knows')
111
+ .option(
112
+ '--success <line>',
113
+ 'Observable thing the learner will be able to do, repeatable',
114
+ collect,
115
+ [] as string[],
116
+ )
117
+ .option(
118
+ '--out-of-scope <line>',
119
+ 'What this workspace does not cover, repeatable',
120
+ collect,
121
+ [] as string[],
122
+ )
123
+ .option('--title <text>', 'Title, defaulting to the topic in sentence case')
124
+ .option('--date <YYYY-MM-DD>', 'Opening date, defaulting to today')
125
+ .option('--json', 'Emit a machine-readable record on stdout')
126
+ .option('--root <path>', 'Teach root, defaulting to the main worktree')
127
+ .addHelpText(
128
+ 'after',
129
+ [
130
+ '',
131
+ 'Exit codes:',
132
+ ' 0 the workspace was created',
133
+ ' 1 refused, with the reason on stderr or in the JSON record',
134
+ '',
135
+ 'It derives the ordinal from the highest already present and writes',
136
+ 'MISSION.md, RESOURCES.md, and GLOSSARY.md. A topic another workspace',
137
+ 'already covers is refused, since a second one forks the records.',
138
+ '',
139
+ 'Examples:',
140
+ ' aitk teach open regular-expressions --subject "Reading and writing regular expressions" \\',
141
+ ' --starting-point "Comfortable with the shell, has never written a group" \\',
142
+ ' --success "Write a pattern matching a date" --success "Explain a backreference"',
143
+ '',
144
+ ].join('\n'),
145
+ )
146
+ .action(async (topic: string, opts: OpenCommandOptions) => {
147
+ process.exitCode = await runOpen(topic, opts)
148
+ })
149
+
150
+ teach
151
+ .command('resource')
152
+ .description('Record sources and leads in a workspace RESOURCES.md')
153
+ .argument('<topic>', 'Workspace folder or topic, as in regular-expressions')
154
+ .helpOption('-h, --help', 'Show this help message')
155
+ .option(
156
+ '--read <title=url>',
157
+ 'Source that stands behind the material, repeatable',
158
+ collect,
159
+ [] as string[],
160
+ )
161
+ .option(
162
+ '--lead <title=url>',
163
+ 'Source found and not opened, repeatable',
164
+ collect,
165
+ [] as string[],
166
+ )
167
+ .option('--json', 'Emit a machine-readable record on stdout')
168
+ .option('--root <path>', 'Teach root, defaulting to the main worktree')
169
+ .addHelpText(
170
+ 'after',
171
+ [
172
+ '',
173
+ 'Exit codes:',
174
+ ' 0 every source was written',
175
+ ' 1 refused, with the reason on stderr or in the JSON record',
176
+ '',
177
+ 'The pair splits on the first =, so a URL carrying one survives. Say',
178
+ 'in the title which claims rest on the source, since the entry is the',
179
+ 'only place a later session reads that from.',
180
+ '',
181
+ 'A URL already listed under either heading is refused rather than',
182
+ 'repeated, because two entries split what rests on one source.',
183
+ '',
184
+ 'Examples:',
185
+ ' aitk teach resource regular-expressions --read "MDN regular expressions=https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions"',
186
+ ' aitk teach resource regular-expressions --lead "RE2 syntax=https://github.com/google/re2/wiki/Syntax" --json',
187
+ '',
188
+ ].join('\n'),
189
+ )
190
+ .action(async (topic: string, opts: ResourceCommandOptions) => {
191
+ process.exitCode = await runResource(topic, opts)
192
+ })
193
+
194
+ teach
195
+ .command('glossary')
196
+ .description('Add terms to a workspace GLOSSARY.md, alphabetically')
197
+ .argument('<topic>', 'Workspace folder or topic, as in regular-expressions')
198
+ .helpOption('-h, --help', 'Show this help message')
199
+ .option(
200
+ '--term <term=definition>',
201
+ 'Term the subject defines, repeatable',
202
+ collect,
203
+ [] as string[],
204
+ )
205
+ .option(
206
+ '--first-seen <file>',
207
+ 'Lesson or reference page the batch first defines these in',
208
+ )
209
+ .option('--json', 'Emit a machine-readable record on stdout')
210
+ .option('--root <path>', 'Teach root, defaulting to the main worktree')
211
+ .addHelpText(
212
+ 'after',
213
+ [
214
+ '',
215
+ 'Exit codes:',
216
+ ' 0 every term now carries an entry',
217
+ ' 1 refused, with the reason on stderr or in the JSON record',
218
+ '',
219
+ 'One call writes one file, which is what keeps a batch from racing on',
220
+ 'the glossary every term shares. --first-seen names one page for the',
221
+ 'whole batch, since a batch comes from one lesson.',
222
+ '',
223
+ 'A term already defined is refused rather than replaced. A definition',
224
+ 'the subject has moved under is a revision of the entry it has.',
225
+ '',
226
+ 'Examples:',
227
+ ' aitk teach glossary regular-expressions --term "capture group=A parenthesised part of a pattern whose match is kept" --first-seen 0002-groups.html',
228
+ '',
229
+ ].join('\n'),
230
+ )
231
+ .action(async (topic: string, opts: GlossaryCommandOptions) => {
232
+ process.exitCode = await runGlossary(topic, opts)
233
+ })
234
+ }
235
+
236
+ function collect(value: string, previous: string[]): string[] {
237
+ return [...previous, value]
238
+ }
239
+
240
+ /**
241
+ * Splits every pair or reports the ones that carry no separator. Both halves
242
+ * are reported together, so a caller passing four pairs learns about all the
243
+ * broken ones from one run rather than from four.
244
+ */
245
+ function parsePairs(
246
+ raw: readonly string[],
247
+ ): { pairs: Array<[string, string]> } | { invalid: string[] } {
248
+ const pairs: Array<[string, string]> = []
249
+ const invalid: string[] = []
250
+
251
+ for (const entry of raw) {
252
+ const match = PAIR.exec(entry)
253
+ const left = match?.[1].trim()
254
+ const right = match?.[2].trim()
255
+
256
+ if (!left || !right) {
257
+ invalid.push(entry)
258
+ continue
259
+ }
260
+
261
+ pairs.push([left, right])
262
+ }
263
+
264
+ return invalid.length > 0 ? { invalid } : { pairs }
265
+ }
266
+
267
+ function badInput(
268
+ message: string,
269
+ detail: readonly string[] = [],
270
+ ): TeachRefused {
271
+ return { ok: false, reason: 'bad-input', message, detail }
272
+ }
273
+
274
+ async function rootFor(given: string | undefined): Promise<string> {
275
+ return given ?? (await mainWorktreeRoot())
276
+ }
277
+
278
+ async function runList(
279
+ topic: string | undefined,
280
+ opts: ListCommandOptions,
281
+ ): Promise<number> {
282
+ const emitJson = opts.json ?? false
283
+ const root = await rootFor(opts.root)
284
+
285
+ if (topic === undefined) {
286
+ return reportList(await listWorkspaces(root), emitJson, root)
287
+ }
288
+
289
+ return reportWorkspace(await readWorkspace(root, topic), emitJson, root)
290
+ }
291
+
292
+ async function runOpen(
293
+ topic: string,
294
+ opts: OpenCommandOptions,
295
+ ): Promise<number> {
296
+ const emitJson = opts.json ?? false
297
+
298
+ if (!opts.subject) {
299
+ return reportRefusal(
300
+ 'aitk teach open',
301
+ badInput('No subject. Pass --subject <line>.'),
302
+ emitJson,
303
+ process.cwd(),
304
+ )
305
+ }
306
+
307
+ if (!opts.startingPoint) {
308
+ return reportRefusal(
309
+ 'aitk teach open',
310
+ badInput(
311
+ 'No starting point. Pass --starting-point <text>, so difficulty has a floor.',
312
+ ),
313
+ emitJson,
314
+ process.cwd(),
315
+ )
316
+ }
317
+
318
+ const root = await rootFor(opts.root)
319
+
320
+ return reportOpen(
321
+ await openWorkspace(root, {
322
+ topic,
323
+ subject: opts.subject,
324
+ startingPoint: opts.startingPoint,
325
+ success: opts.success ?? [],
326
+ outOfScope: opts.outOfScope ?? [],
327
+ title: opts.title,
328
+ date: opts.date,
329
+ }),
330
+ emitJson,
331
+ root,
332
+ )
333
+ }
334
+
335
+ async function runResource(
336
+ topic: string,
337
+ opts: ResourceCommandOptions,
338
+ ): Promise<number> {
339
+ const emitJson = opts.json ?? false
340
+ const raw = [...(opts.read ?? []), ...(opts.lead ?? [])]
341
+
342
+ if (raw.length === 0) {
343
+ return reportRefusal(
344
+ 'aitk teach resource',
345
+ badInput('No source given. Pass --read or --lead as <title>=<url>.'),
346
+ emitJson,
347
+ process.cwd(),
348
+ )
349
+ }
350
+
351
+ const parsed = parsePairs(raw)
352
+
353
+ if ('invalid' in parsed) {
354
+ return reportRefusal(
355
+ 'aitk teach resource',
356
+ badInput(
357
+ `Not a title and url: ${parsed.invalid.join(', ')}`,
358
+ parsed.invalid,
359
+ ),
360
+ emitJson,
361
+ process.cwd(),
362
+ )
363
+ }
364
+
365
+ const readCount = (opts.read ?? []).length
366
+ const sources: Source[] = parsed.pairs.map(([title, url]) => ({ title, url }))
367
+ const root = await rootFor(opts.root)
368
+
369
+ return reportResource(
370
+ await recordSources(
371
+ root,
372
+ topic,
373
+ sources.slice(0, readCount),
374
+ sources.slice(readCount),
375
+ ),
376
+ emitJson,
377
+ root,
378
+ )
379
+ }
380
+
381
+ async function runGlossary(
382
+ topic: string,
383
+ opts: GlossaryCommandOptions,
384
+ ): Promise<number> {
385
+ const emitJson = opts.json ?? false
386
+ const raw = opts.term ?? []
387
+
388
+ if (raw.length === 0) {
389
+ return reportRefusal(
390
+ 'aitk teach glossary',
391
+ badInput('No term given. Pass --term <term>=<definition>.'),
392
+ emitJson,
393
+ process.cwd(),
394
+ )
395
+ }
396
+
397
+ const parsed = parsePairs(raw)
398
+
399
+ if ('invalid' in parsed) {
400
+ return reportRefusal(
401
+ 'aitk teach glossary',
402
+ badInput(
403
+ `Not a term and definition: ${parsed.invalid.join(', ')}`,
404
+ parsed.invalid,
405
+ ),
406
+ emitJson,
407
+ process.cwd(),
408
+ )
409
+ }
410
+
411
+ const terms: Term[] = parsed.pairs.map(([term, definition]) => ({
412
+ term,
413
+ definition,
414
+ }))
415
+
416
+ const duplicated = terms
417
+ .map((term) => term.term.toLowerCase())
418
+ .filter((term, index, all) => all.indexOf(term) !== index)
419
+
420
+ if (duplicated.length > 0) {
421
+ return reportRefusal(
422
+ 'aitk teach glossary',
423
+ badInput(`Two definitions for ${[...new Set(duplicated)].join(', ')}.`),
424
+ emitJson,
425
+ process.cwd(),
426
+ )
427
+ }
428
+
429
+ const root = await rootFor(opts.root)
430
+
431
+ return reportGlossary(
432
+ await defineTerms(root, topic, terms, opts.firstSeen),
433
+ emitJson,
434
+ root,
435
+ )
436
+ }
437
+
438
+ function reportRefusal(
439
+ title: string,
440
+ refused: TeachRefused,
441
+ emitJson: boolean,
442
+ root: string,
443
+ ): number {
444
+ // The framed branch reaches stderr through logError, so the bare write is
445
+ // what keeps the JSON mode from reporting the reason on stdout alone.
446
+ if (emitJson) {
447
+ process.stderr.write(`${refused.message}\n`)
448
+ process.stdout.write(
449
+ `${JSON.stringify({
450
+ ok: false,
451
+ root,
452
+ reason: refused.reason,
453
+ message: refused.message,
454
+ detail: refused.detail,
455
+ })}\n`,
456
+ )
457
+ return 1
458
+ }
459
+
460
+ intro(title)
461
+ logStep('Refused')
462
+ logError(refused.message)
463
+ if (refused.detail.length > 0) pipeOutput(refused.detail.join('\n'))
464
+ outro()
465
+
466
+ return 1
467
+ }
468
+
469
+ function describe(workspace: WorkspaceSummary): string {
470
+ return `${workspace.slug}: ${workspace.lessons} lesson(s), ${workspace.records} record(s), ${workspace.reference} reference page(s), ${workspace.terms} term(s)`
471
+ }
472
+
473
+ function reportList(
474
+ outcome: ListOutcome,
475
+ emitJson: boolean,
476
+ root: string,
477
+ ): number {
478
+ if (!outcome.ok)
479
+ return reportRefusal('aitk teach list', outcome, emitJson, root)
480
+
481
+ if (emitJson) {
482
+ process.stdout.write(
483
+ `${JSON.stringify({
484
+ ok: true,
485
+ root,
486
+ workspaces: outcome.workspaces,
487
+ next: outcome.next,
488
+ })}\n`,
489
+ )
490
+ return 0
491
+ }
492
+
493
+ intro('aitk teach list')
494
+ logStep(outcome.workspaces.length > 0 ? 'Workspaces' : 'No workspaces')
495
+
496
+ for (const workspace of outcome.workspaces) logInfo(describe(workspace))
497
+
498
+ const incomplete = outcome.workspaces.filter(
499
+ (workspace) => workspace.missing.length > 0,
500
+ )
501
+
502
+ if (incomplete.length > 0) {
503
+ logStep('Missing a required file')
504
+ for (const workspace of incomplete) {
505
+ logWarn(`${workspace.slug}: no ${workspace.missing.join(' and no ')}`)
506
+ }
507
+ }
508
+
509
+ logStep('Next ordinal')
510
+ logInfo(outcome.next)
511
+ outro()
512
+
513
+ return 0
514
+ }
515
+
516
+ function reportWorkspace(
517
+ outcome: ReadOutcome,
518
+ emitJson: boolean,
519
+ root: string,
520
+ ): number {
521
+ if (!outcome.ok)
522
+ return reportRefusal('aitk teach list', outcome, emitJson, root)
523
+
524
+ const workspace = outcome.workspace
525
+
526
+ if (emitJson) {
527
+ process.stdout.write(`${JSON.stringify({ ok: true, root, workspace })}\n`)
528
+ return 0
529
+ }
530
+
531
+ intro('aitk teach list')
532
+ logStep(workspace.slug)
533
+ logInfo(`${workspace.title ?? 'no title'}, opened ${workspace.opened ?? '?'}`)
534
+ logInfo(workspace.path)
535
+
536
+ for (const [label, files] of [
537
+ ['Lessons', workspace.lessonFiles],
538
+ ['Learning records', workspace.recordFiles],
539
+ ['Reference', workspace.referenceFiles],
540
+ ] as const) {
541
+ logStep(files.length > 0 ? label : `${label} (none)`)
542
+ for (const file of files) logInfo(file)
543
+ }
544
+
545
+ logStep(workspace.glossary.length > 0 ? 'Glossary' : 'Glossary (empty)')
546
+ for (const entry of workspace.glossary) logInfo(entry)
547
+
548
+ if (workspace.missing.length > 0) {
549
+ logStep('Missing a required file')
550
+ logWarn(`no ${workspace.missing.join(' and no ')}`)
551
+ }
552
+
553
+ outro()
554
+
555
+ return 0
556
+ }
557
+
558
+ function reportOpen(
559
+ outcome: OpenOutcome,
560
+ emitJson: boolean,
561
+ root: string,
562
+ ): number {
563
+ if (!outcome.ok)
564
+ return reportRefusal('aitk teach open', outcome, emitJson, root)
565
+
566
+ if (emitJson) {
567
+ process.stdout.write(
568
+ `${JSON.stringify({
569
+ ok: true,
570
+ root,
571
+ slug: outcome.slug,
572
+ path: outcome.path,
573
+ created: outcome.created,
574
+ })}\n`,
575
+ )
576
+ return 0
577
+ }
578
+
579
+ intro('aitk teach open')
580
+ logStep('Opened')
581
+ logInfo(outcome.slug)
582
+ for (const file of outcome.created) logAdd(file)
583
+ outro()
584
+
585
+ return 0
586
+ }
587
+
588
+ function reportResource(
589
+ outcome: SourceOutcome,
590
+ emitJson: boolean,
591
+ root: string,
592
+ ): number {
593
+ if (!outcome.ok) {
594
+ return reportRefusal('aitk teach resource', outcome, emitJson, root)
595
+ }
596
+
597
+ if (emitJson) {
598
+ process.stdout.write(
599
+ `${JSON.stringify({
600
+ ok: true,
601
+ root,
602
+ slug: outcome.slug,
603
+ path: relative(root, outcome.path),
604
+ read: outcome.read,
605
+ leads: outcome.leads,
606
+ })}\n`,
607
+ )
608
+ return 0
609
+ }
610
+
611
+ intro('aitk teach resource')
612
+ logStep('Recorded')
613
+ for (const source of outcome.read) logInfo(`read: ${source.title}`)
614
+ for (const source of outcome.leads) logInfo(`lead: ${source.title}`)
615
+ logAdd(relative(root, outcome.path))
616
+ outro()
617
+
618
+ return 0
619
+ }
620
+
621
+ function reportGlossary(
622
+ outcome: TermOutcome,
623
+ emitJson: boolean,
624
+ root: string,
625
+ ): number {
626
+ if (!outcome.ok) {
627
+ return reportRefusal('aitk teach glossary', outcome, emitJson, root)
628
+ }
629
+
630
+ if (emitJson) {
631
+ process.stdout.write(
632
+ `${JSON.stringify({
633
+ ok: true,
634
+ root,
635
+ slug: outcome.slug,
636
+ path: relative(root, outcome.path),
637
+ defined: outcome.defined,
638
+ })}\n`,
639
+ )
640
+ return 0
641
+ }
642
+
643
+ intro('aitk teach glossary')
644
+ logStep('Defined')
645
+ for (const term of outcome.defined) logInfo(term.term)
646
+ logAdd(relative(root, outcome.path))
647
+ outro()
648
+
649
+ return 0
650
+ }