@shaykec/claude-teach 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,512 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { readFileSync, existsSync } from 'fs';
5
+ import { resolve, join } from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import { dirname } from 'path';
8
+ import chalk from 'chalk';
9
+ import { buildRegistry, loadRegistry, getModule } from './registry.js';
10
+ import { loadProgress, saveProgress, getStats } from './progress.js';
11
+ import { getBelt, formatDashboard, suggestNext } from './gamification.js';
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+ const ROOT = resolve(__dirname, '..', '..', '..');
16
+
17
+ const program = new Command();
18
+
19
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
20
+
21
+ program
22
+ .name('claude-teach')
23
+ .description(`Socratic AI teaching platform with gamification and visual ecosystem.
24
+
25
+ Learn through guided dialogue — the AI asks YOU questions, tracks your
26
+ progress with XP and belt ranks, and enhances lessons with browser-based
27
+ diagrams, interactive quizzes, and code playgrounds.
28
+
29
+ Examples:
30
+ $ claude-teach list List available modules
31
+ $ claude-teach list --difficulty beginner Filter by difficulty
32
+ $ claude-teach get git Start the Git walkthrough
33
+ $ claude-teach get git --quick Show Git quick reference
34
+ $ claude-teach stats View your XP and belt progress
35
+ $ claude-teach install user/my-pack Install a module pack from GitHub
36
+ $ claude-teach search docker Search registries for packs
37
+ $ claude-teach author init my-pack Scaffold a new module pack
38
+ $ claude-teach author validate ./my-pack Validate a pack
39
+ $ claude-teach serve Start the bridge server for visuals`)
40
+ .version(pkg.version);
41
+
42
+ // =====================================================================
43
+ // Learning commands
44
+ // =====================================================================
45
+
46
+ // --- list ---
47
+ program
48
+ .command('list')
49
+ .description('List available learning modules (filter with --category, --difficulty, --tag, --source)')
50
+ .option('-c, --category <category>', 'Filter by category')
51
+ .option('-d, --difficulty <level>', 'Filter by difficulty (beginner|intermediate|advanced)')
52
+ .option('-t, --tag <tag>', 'Filter by tag')
53
+ .option('-s, --source <source>', 'Filter by source (built-in|pack:<name>|local)')
54
+ .action(async (opts) => {
55
+ const registry = await loadRegistry(ROOT);
56
+ let modules = registry.modules;
57
+
58
+ if (opts.category) {
59
+ modules = modules.filter(m => m.category === opts.category);
60
+ }
61
+ if (opts.difficulty) {
62
+ modules = modules.filter(m => m.difficulty === opts.difficulty);
63
+ }
64
+ if (opts.tag) {
65
+ modules = modules.filter(m => m.tags?.includes(opts.tag));
66
+ }
67
+ if (opts.source) {
68
+ modules = modules.filter(m => m.source === opts.source);
69
+ }
70
+
71
+ if (modules.length === 0) {
72
+ console.log('No modules found matching your filters.');
73
+ return;
74
+ }
75
+
76
+ const progress = loadProgress(ROOT);
77
+ const difficultyIcon = { beginner: '🌱', intermediate: '🌿', advanced: '🌳' };
78
+
79
+ console.log(`\n Available Modules (${modules.length})\n`);
80
+ for (const mod of modules) {
81
+ const icon = difficultyIcon[mod.difficulty] || ' ';
82
+ const status = progress.modules?.[mod.slug]?.status;
83
+ const statusMark = status === 'completed' ? ' ✓' : status === 'in-progress' ? ' ◐' : '';
84
+ const caps = [];
85
+ if (mod.capabilities.includes('walkthrough')) caps.push('guided');
86
+ if (mod.capabilities.includes('exercises')) caps.push('exercises');
87
+ if (mod.capabilities.includes('quiz')) caps.push('quiz');
88
+ if (mod.capabilities.includes('quick-ref')) caps.push('quick');
89
+ const capsStr = caps.length ? ` [${caps.join(', ')}]` : '';
90
+ const sourceTag = mod.source !== 'built-in' ? chalk.dim(` (${mod.source})`) : '';
91
+ console.log(` ${icon} ${mod.slug}${statusMark} — ${mod.title}${capsStr}${sourceTag}`);
92
+ }
93
+ console.log();
94
+ });
95
+
96
+ // --- get ---
97
+ program
98
+ .command('get <module>')
99
+ .description('Get module content — walkthrough by default, or --quick, --quiz-only, --status, --reset')
100
+ .option('--quick', 'Show quick reference')
101
+ .option('--quiz-only', 'Run quiz only')
102
+ .option('--status', 'Show progress for this module')
103
+ .option('--reset', 'Reset progress for this module')
104
+ .action(async (slug, opts) => {
105
+ const registry = await loadRegistry(ROOT);
106
+ const mod = getModule(registry, slug);
107
+ if (!mod) {
108
+ console.error(`Module "${slug}" not found. Run "claude-teach list" to see available modules.`);
109
+ process.exit(1);
110
+ }
111
+
112
+ if (opts.status) {
113
+ const progress = loadProgress(ROOT);
114
+ const entry = progress.modules?.[slug];
115
+ if (!entry) {
116
+ console.log(`No progress recorded for "${slug}".`);
117
+ } else {
118
+ console.log(JSON.stringify(entry, null, 2));
119
+ }
120
+ return;
121
+ }
122
+
123
+ if (opts.reset) {
124
+ const progress = loadProgress(ROOT);
125
+ delete progress.modules?.[slug];
126
+ saveProgress(ROOT, progress);
127
+ console.log(`Progress reset for "${slug}".`);
128
+ return;
129
+ }
130
+
131
+ const modulePath = mod._path;
132
+
133
+ if (opts.quick) {
134
+ const quickPath = join(modulePath, 'quick-ref.md');
135
+ if (!existsSync(quickPath)) {
136
+ console.error(`No quick reference available for "${slug}".`);
137
+ process.exit(1);
138
+ }
139
+ console.log(readFileSync(quickPath, 'utf-8'));
140
+ return;
141
+ }
142
+
143
+ if (opts.quizOnly) {
144
+ const quizPath = join(modulePath, 'quiz.md');
145
+ if (!existsSync(quizPath)) {
146
+ console.error(`No quiz available for "${slug}".`);
147
+ process.exit(1);
148
+ }
149
+ console.log(readFileSync(quizPath, 'utf-8'));
150
+ return;
151
+ }
152
+
153
+ // Default: walkthrough if available, else content
154
+ const walkthroughPath = join(modulePath, 'walkthrough.md');
155
+ const contentPath = join(modulePath, 'content.md');
156
+ if (existsSync(walkthroughPath)) {
157
+ console.log(readFileSync(walkthroughPath, 'utf-8'));
158
+ } else if (existsSync(contentPath)) {
159
+ console.log(readFileSync(contentPath, 'utf-8'));
160
+ } else {
161
+ console.error(`No content found for module "${slug}".`);
162
+ process.exit(1);
163
+ }
164
+ });
165
+
166
+ // --- stats ---
167
+ program
168
+ .command('stats')
169
+ .description('Show gamification dashboard — XP, belt, progress')
170
+ .action(async () => {
171
+ const progress = loadProgress(ROOT);
172
+ const registry = await loadRegistry(ROOT);
173
+ const dashboard = formatDashboard(progress, registry);
174
+ console.log(dashboard);
175
+ });
176
+
177
+ // --- level-up ---
178
+ program
179
+ .command('level-up')
180
+ .description('See your belt roadmap and get suggestions for what to learn next')
181
+ .action(async () => {
182
+ const progress = loadProgress(ROOT);
183
+ const registry = await loadRegistry(ROOT);
184
+ const suggestion = suggestNext(progress, registry);
185
+ console.log(suggestion);
186
+ });
187
+
188
+ // =====================================================================
189
+ // Visual Ecosystem commands
190
+ // =====================================================================
191
+
192
+ // --- serve (bridge server) ---
193
+ program
194
+ .command('serve')
195
+ .description('Start the bridge server for visual canvas — diagrams, quizzes, dashboard (default port: 3456)')
196
+ .option('-p, --port <port>', 'Port number', '3456')
197
+ .action(async (opts) => {
198
+ const { startServer } = await import('@shaykec/bridge');
199
+ const progress = loadProgress(ROOT);
200
+ startServer({
201
+ port: parseInt(opts.port, 10),
202
+ progressProvider: {
203
+ getProgress: () => loadProgress(ROOT),
204
+ },
205
+ onTierChange: (oldTier, newTier) => {
206
+ const labels = { 1: 'Full', 2: 'Canvas', 3: 'Terminal' };
207
+ console.log(` Tier changed: ${labels[oldTier]} -> ${labels[newTier]}`);
208
+ },
209
+ });
210
+ });
211
+
212
+ // --- inbox ---
213
+ program
214
+ .command('inbox')
215
+ .description('Show content captured from the browser bridge')
216
+ .action(async () => {
217
+ const { showInbox } = await import('./bridge-server.js');
218
+ showInbox();
219
+ });
220
+
221
+ // =====================================================================
222
+ // System commands
223
+ // =====================================================================
224
+
225
+ // --- registry:build ---
226
+ program
227
+ .command('registry:build')
228
+ .description('Rebuild the module registry from all sources (built-in, packs, local)')
229
+ .action(async () => {
230
+ const registry = await buildRegistry(ROOT);
231
+ const count = registry.modules.length;
232
+ const sources = {};
233
+ for (const m of registry.modules) {
234
+ const key = m.source.startsWith('pack:') ? 'pack' : m.source;
235
+ sources[key] = (sources[key] || 0) + 1;
236
+ }
237
+ console.log(chalk.green(`Registry rebuilt: ${count} module(s).`));
238
+ for (const [src, n] of Object.entries(sources)) {
239
+ console.log(` ${src}: ${n}`);
240
+ }
241
+ });
242
+
243
+ // =====================================================================
244
+ // Marketplace commands — install, manage, and discover module packs
245
+ // =====================================================================
246
+
247
+ // --- install ---
248
+ program
249
+ .command('install <url>')
250
+ .description('Install a module pack from a git URL or GitHub shorthand (e.g., user/repo)')
251
+ .action(async (url) => {
252
+ try {
253
+ const { installPack } = await import('./marketplace.js');
254
+ await installPack(url);
255
+ // Rebuild registry after install
256
+ await buildRegistry(ROOT);
257
+ console.log(chalk.green('Registry rebuilt.'));
258
+ } catch (err) {
259
+ console.error(chalk.red(`Error: ${err.message}`));
260
+ process.exit(1);
261
+ }
262
+ });
263
+
264
+ // --- packs ---
265
+ program
266
+ .command('packs')
267
+ .description('List installed module packs')
268
+ .action(async () => {
269
+ try {
270
+ const { listPacks } = await import('./marketplace.js');
271
+ const packs = await listPacks();
272
+
273
+ if (packs.length === 0) {
274
+ console.log('No module packs installed.');
275
+ console.log(` Install one with: ${chalk.cyan('claude-teach install <url>')}`);
276
+ return;
277
+ }
278
+
279
+ console.log(`\n Installed Packs (${packs.length})\n`);
280
+ for (const pack of packs) {
281
+ console.log(` ${chalk.bold(pack.name)} v${pack.version} — ${pack.description || '(no description)'}`);
282
+ console.log(` Author: ${pack.author || 'unknown'} | Modules: ${pack.modules.length}`);
283
+ console.log(` Path: ${chalk.dim(pack.path)}`);
284
+ console.log();
285
+ }
286
+ } catch (err) {
287
+ console.error(chalk.red(`Error: ${err.message}`));
288
+ process.exit(1);
289
+ }
290
+ });
291
+
292
+ // --- update ---
293
+ program
294
+ .command('update <pack>')
295
+ .description('Update an installed module pack (git pull)')
296
+ .action(async (packName) => {
297
+ try {
298
+ const { updatePack } = await import('./marketplace.js');
299
+ await updatePack(packName);
300
+ await buildRegistry(ROOT);
301
+ console.log(chalk.green('Registry rebuilt.'));
302
+ } catch (err) {
303
+ console.error(chalk.red(`Error: ${err.message}`));
304
+ process.exit(1);
305
+ }
306
+ });
307
+
308
+ // --- remove ---
309
+ program
310
+ .command('remove <pack>')
311
+ .description('Remove an installed module pack')
312
+ .action(async (packName) => {
313
+ try {
314
+ const { removePack } = await import('./marketplace.js');
315
+ await removePack(packName);
316
+ await buildRegistry(ROOT);
317
+ console.log(chalk.green('Registry rebuilt.'));
318
+ } catch (err) {
319
+ console.error(chalk.red(`Error: ${err.message}`));
320
+ process.exit(1);
321
+ }
322
+ });
323
+
324
+ // --- search ---
325
+ program
326
+ .command('search <query>')
327
+ .description('Search configured registries for module packs')
328
+ .action(async (query) => {
329
+ try {
330
+ const { searchPacks } = await import('./marketplace.js');
331
+ const results = await searchPacks(query);
332
+
333
+ if (results.length === 0) {
334
+ console.log(`No packs found for "${query}".`);
335
+ return;
336
+ }
337
+
338
+ console.log(`\n Search Results (${results.length})\n`);
339
+ for (const pack of results) {
340
+ console.log(` ${chalk.bold(pack.name)} — ${pack.description || '(no description)'}`);
341
+ if (pack.author) console.log(` Author: ${pack.author}`);
342
+ if (pack.tags?.length) console.log(` Tags: ${pack.tags.join(', ')}`);
343
+ if (pack.repo) console.log(` Install: ${chalk.cyan(`claude-teach install ${pack.repo}`)}`);
344
+ console.log(` Registry: ${chalk.dim(pack.registry)}`);
345
+ console.log();
346
+ }
347
+ } catch (err) {
348
+ console.error(chalk.red(`Error: ${err.message}`));
349
+ process.exit(1);
350
+ }
351
+ });
352
+
353
+ // --- registry subcommand ---
354
+ const registryCmd = program
355
+ .command('registry')
356
+ .description('Manage module pack registries');
357
+
358
+ registryCmd
359
+ .command('add <url>')
360
+ .description('Add a registry by URL or GitHub shorthand')
361
+ .action(async (url) => {
362
+ try {
363
+ const { addRegistry } = await import('./marketplace.js');
364
+ addRegistry(url);
365
+ } catch (err) {
366
+ console.error(chalk.red(`Error: ${err.message}`));
367
+ process.exit(1);
368
+ }
369
+ });
370
+
371
+ registryCmd
372
+ .command('list')
373
+ .description('List configured registries')
374
+ .action(async () => {
375
+ try {
376
+ const { getRegistries } = await import('./marketplace.js');
377
+ const regs = getRegistries();
378
+
379
+ if (regs.length === 0) {
380
+ console.log('No registries configured.');
381
+ return;
382
+ }
383
+
384
+ console.log(`\n Configured Registries (${regs.length})\n`);
385
+ for (const reg of regs) {
386
+ const status = reg.enabled !== false ? chalk.green('enabled') : chalk.dim('disabled');
387
+ console.log(` ${chalk.bold(reg.name || '(unnamed)')} [${status}]`);
388
+ console.log(` ${reg.url}`);
389
+ console.log();
390
+ }
391
+ } catch (err) {
392
+ console.error(chalk.red(`Error: ${err.message}`));
393
+ process.exit(1);
394
+ }
395
+ });
396
+
397
+ registryCmd
398
+ .command('remove <name>')
399
+ .description('Remove a registry by name or URL')
400
+ .action(async (nameOrUrl) => {
401
+ try {
402
+ const { removeRegistry } = await import('./marketplace.js');
403
+ removeRegistry(nameOrUrl);
404
+ } catch (err) {
405
+ console.error(chalk.red(`Error: ${err.message}`));
406
+ process.exit(1);
407
+ }
408
+ });
409
+
410
+ // =====================================================================
411
+ // Authoring commands — create, validate, and preview learning modules
412
+ // =====================================================================
413
+
414
+ const authorCmd = program
415
+ .command('author')
416
+ .description('Module authoring tools — scaffold packs, add modules, validate, and preview');
417
+
418
+ authorCmd
419
+ .command('init <name>')
420
+ .description('Scaffold a new module pack')
421
+ .action(async (name) => {
422
+ try {
423
+ const { initPack } = await import('./author.js');
424
+ await initPack(name);
425
+ } catch (err) {
426
+ console.error(chalk.red(`Error: ${err.message}`));
427
+ process.exit(1);
428
+ }
429
+ });
430
+
431
+ authorCmd
432
+ .command('add <pack-and-module>')
433
+ .description('Add a module to a pack (format: pack-path/module-name)')
434
+ .action(async (packAndModule) => {
435
+ try {
436
+ const slashIdx = packAndModule.lastIndexOf('/');
437
+ if (slashIdx === -1) {
438
+ console.error(chalk.red('Usage: claude-teach author add <pack-path>/<module-name>'));
439
+ process.exit(1);
440
+ }
441
+ const packPath = packAndModule.slice(0, slashIdx);
442
+ const moduleName = packAndModule.slice(slashIdx + 1);
443
+ if (!packPath || !moduleName) {
444
+ console.error(chalk.red('Usage: claude-teach author add <pack-path>/<module-name>'));
445
+ process.exit(1);
446
+ }
447
+
448
+ const { addModule } = await import('./author.js');
449
+ await addModule(packPath, moduleName);
450
+ } catch (err) {
451
+ console.error(chalk.red(`Error: ${err.message}`));
452
+ process.exit(1);
453
+ }
454
+ });
455
+
456
+ authorCmd
457
+ .command('validate <path>')
458
+ .description('Validate a module pack')
459
+ .action(async (packPath) => {
460
+ try {
461
+ const { validatePack } = await import('./author.js');
462
+ // Provide built-in slugs for prerequisite validation
463
+ const registry = await loadRegistry(ROOT);
464
+ const builtinSlugs = registry.modules
465
+ .filter(m => m.source === 'built-in')
466
+ .map(m => m.slug);
467
+
468
+ const result = await validatePack(packPath, builtinSlugs);
469
+
470
+ if (result.errors.length > 0) {
471
+ console.log(chalk.red(`\n Errors (${result.errors.length}):\n`));
472
+ for (const err of result.errors) {
473
+ console.log(` ${chalk.red('✗')} ${err}`);
474
+ }
475
+ }
476
+
477
+ if (result.warnings.length > 0) {
478
+ console.log(chalk.yellow(`\n Warnings (${result.warnings.length}):\n`));
479
+ for (const warn of result.warnings) {
480
+ console.log(` ${chalk.yellow('!')} ${warn}`);
481
+ }
482
+ }
483
+
484
+ if (result.valid) {
485
+ console.log(chalk.green('\n Pack is valid.\n'));
486
+ } else {
487
+ console.log(chalk.red('\n Pack has errors. Fix them and try again.\n'));
488
+ process.exit(1);
489
+ }
490
+ } catch (err) {
491
+ console.error(chalk.red(`Error: ${err.message}`));
492
+ process.exit(1);
493
+ }
494
+ });
495
+
496
+ authorCmd
497
+ .command('preview <path>')
498
+ .description('Preview a module locally (adds to registry temporarily)')
499
+ .action(async (modulePath) => {
500
+ try {
501
+ const { previewModule } = await import('./author.js');
502
+ await previewModule(modulePath);
503
+ // Rebuild registry so the previewed module appears
504
+ await buildRegistry(ROOT);
505
+ console.log(chalk.green('Registry rebuilt.'));
506
+ } catch (err) {
507
+ console.error(chalk.red(`Error: ${err.message}`));
508
+ process.exit(1);
509
+ }
510
+ });
511
+
512
+ program.parse();
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Gamification engine — XP scoring, belt thresholds, level progression.
3
+ * Ported from claude-code-guide's game-mode + level-up skills.
4
+ */
5
+
6
+ export const BELTS = [
7
+ { name: 'White', minXP: 0, badge: '⬜' },
8
+ { name: 'Yellow', minXP: 50, badge: '🟨' },
9
+ { name: 'Green', minXP: 150, badge: '🟩' },
10
+ { name: 'Blue', minXP: 400, badge: '🟦' },
11
+ { name: 'Purple', minXP: 800, badge: '🟪' },
12
+ { name: 'Brown', minXP: 1500, badge: '🟫' },
13
+ { name: 'Black', minXP: 3000, badge: '⬛' },
14
+ ];
15
+
16
+ /**
17
+ * Get the current belt for a given XP total.
18
+ */
19
+ export function getBelt(xp) {
20
+ let belt = BELTS[0];
21
+ for (const b of BELTS) {
22
+ if (xp >= b.minXP) belt = b;
23
+ }
24
+
25
+ // Black belt dans
26
+ if (xp >= 3000) {
27
+ const dans = [
28
+ { dan: 1, minXP: 3000 },
29
+ { dan: 2, minXP: 6000 },
30
+ { dan: 3, minXP: 12000 },
31
+ { dan: 4, minXP: 24000 },
32
+ { dan: 5, minXP: 48000 },
33
+ ];
34
+ let currentDan = 0;
35
+ for (const d of dans) {
36
+ if (xp >= d.minXP) currentDan = d.dan;
37
+ }
38
+ if (currentDan > 0) {
39
+ return { ...belt, dan: currentDan, display: `⬛ Black Belt (${ordinal(currentDan)} Dan)` };
40
+ }
41
+ }
42
+
43
+ return { ...belt, display: `${belt.badge} ${belt.name} Belt` };
44
+ }
45
+
46
+ /**
47
+ * Get the next belt target.
48
+ */
49
+ export function getNextBelt(xp) {
50
+ for (const b of BELTS) {
51
+ if (xp < b.minXP) return b;
52
+ }
53
+ // Already at Black — show next dan
54
+ const dans = [6000, 12000, 24000, 48000];
55
+ for (const threshold of dans) {
56
+ if (xp < threshold) return { name: 'Black (next Dan)', minXP: threshold, badge: '⬛' };
57
+ }
58
+ return null; // Maxed out
59
+ }
60
+
61
+ /**
62
+ * Format a full dashboard string.
63
+ */
64
+ export function formatDashboard(progress, registry) {
65
+ const xp = progress.user?.xp || 0;
66
+ const belt = getBelt(xp);
67
+ const next = getNextBelt(xp);
68
+ const modulesCompleted = progress.user?.modules_completed || 0;
69
+ const totalModules = registry?.modules?.length || 0;
70
+
71
+ let out = `\n ╔══════════════════════════════════════╗\n`;
72
+ out += ` ║ ClaudeTeach Dashboard ║\n`;
73
+ out += ` ╠══════════════════════════════════════╣\n`;
74
+ out += ` ║ ${belt.display.padEnd(35)}║\n`;
75
+ out += ` ║ XP: ${String(xp).padEnd(31)}║\n`;
76
+
77
+ if (next) {
78
+ const remaining = next.minXP - xp;
79
+ const progressBar = makeProgressBar(xp, belt.minXP, next.minXP, 20);
80
+ out += ` ║ Next: ${next.badge} ${next.name} (${remaining} XP to go)`.padEnd(39) + `║\n`;
81
+ out += ` ║ ${progressBar.padEnd(35)}║\n`;
82
+ }
83
+
84
+ out += ` ║ Modules: ${modulesCompleted}/${totalModules} completed`.padEnd(39) + `║\n`;
85
+ out += ` ╚══════════════════════════════════════╝\n`;
86
+
87
+ // Per-module breakdown
88
+ if (progress.modules && Object.keys(progress.modules).length > 0) {
89
+ out += `\n Module Progress:\n`;
90
+ for (const [slug, data] of Object.entries(progress.modules)) {
91
+ const statusIcon = data.status === 'completed' ? '✓' : '◐';
92
+ out += ` ${statusIcon} ${slug} — ${data.xp_earned || 0} XP`;
93
+ if (data.quiz_score) out += ` (quiz: ${data.quiz_score})`;
94
+ out += `\n`;
95
+ }
96
+ }
97
+
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Suggest the next module to learn based on progress.
103
+ */
104
+ export function suggestNext(progress, registry) {
105
+ const completedSlugs = new Set(
106
+ Object.entries(progress.modules || {})
107
+ .filter(([_, v]) => v.status === 'completed')
108
+ .map(([k]) => k)
109
+ );
110
+
111
+ const inProgressSlugs = Object.entries(progress.modules || {})
112
+ .filter(([_, v]) => v.status === 'in-progress')
113
+ .map(([k]) => k);
114
+
115
+ const xp = progress.user?.xp || 0;
116
+ const belt = getBelt(xp);
117
+ const next = getNextBelt(xp);
118
+
119
+ let out = `\n ${belt.display}\n`;
120
+ out += ` Total XP: ${xp}\n`;
121
+ if (next) {
122
+ out += ` Next belt: ${next.badge} ${next.name} at ${next.minXP} XP (${next.minXP - xp} to go)\n`;
123
+ }
124
+
125
+ // In-progress modules first
126
+ if (inProgressSlugs.length > 0) {
127
+ out += `\n Continue where you left off:\n`;
128
+ for (const slug of inProgressSlugs) {
129
+ const mod = registry.modules?.find(m => m.slug === slug);
130
+ const data = progress.modules[slug];
131
+ if (mod) {
132
+ out += ` → ${slug}: "${mod.title}"`;
133
+ if (data.walkthrough_step) out += ` (step ${data.walkthrough_step})`;
134
+ out += `\n`;
135
+ }
136
+ }
137
+ }
138
+
139
+ // Suggest uncompleted modules sorted by difficulty
140
+ const available = (registry.modules || [])
141
+ .filter(m => !completedSlugs.has(m.slug) && !inProgressSlugs.includes(m.slug))
142
+ .filter(m => {
143
+ // Check prerequisites
144
+ if (!m.prerequisites || m.prerequisites.length === 0) return true;
145
+ return m.prerequisites.every(p => completedSlugs.has(p));
146
+ });
147
+
148
+ const diffOrder = { beginner: 0, intermediate: 1, advanced: 2 };
149
+ available.sort((a, b) => (diffOrder[a.difficulty] || 0) - (diffOrder[b.difficulty] || 0));
150
+
151
+ if (available.length > 0) {
152
+ out += `\n Recommended next:\n`;
153
+ const suggestions = available.slice(0, 3);
154
+ for (const mod of suggestions) {
155
+ const icon = { beginner: '🌱', intermediate: '🌿', advanced: '🌳' }[mod.difficulty] || ' ';
156
+ const xpInfo = mod.xp ? ` (${Object.values(mod.xp).reduce((a, b) => a + b, 0)} XP)` : '';
157
+ out += ` ${icon} ${mod.slug}: "${mod.title}"${xpInfo}\n`;
158
+ }
159
+ }
160
+
161
+ return out;
162
+ }
163
+
164
+ // --- helpers ---
165
+
166
+ function makeProgressBar(current, rangeStart, rangeEnd, width) {
167
+ const pct = Math.min(1, Math.max(0, (current - rangeStart) / (rangeEnd - rangeStart)));
168
+ const filled = Math.round(pct * width);
169
+ return '[' + '█'.repeat(filled) + '░'.repeat(width - filled) + '] ' + Math.round(pct * 100) + '%';
170
+ }
171
+
172
+ function ordinal(n) {
173
+ const s = ['th', 'st', 'nd', 'rd'];
174
+ const v = n % 100;
175
+ return n + (s[(v - 20) % 10] || s[v] || s[0]);
176
+ }