@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/README.md +69 -0
- package/commands/teach.md +78 -0
- package/engine/authoring.md +150 -0
- package/engine/teach-command.md +76 -0
- package/package.json +28 -0
- package/src/author.js +355 -0
- package/src/bridge-server.js +60 -0
- package/src/cli.js +512 -0
- package/src/gamification.js +176 -0
- package/src/gamification.test.js +509 -0
- package/src/marketplace.js +349 -0
- package/src/progress.js +157 -0
- package/src/progress.test.js +360 -0
- package/src/registry.js +201 -0
- package/src/registry.test.js +309 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { loadProgress, saveProgress, recordXP, completeModule, updateWalkthroughStep, recordQuizScore, getStats } from './progress.js';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
let tmpDir;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teach-test-'));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/* ================================================================== */
|
|
18
|
+
/* loadProgress */
|
|
19
|
+
/* ================================================================== */
|
|
20
|
+
|
|
21
|
+
describe('loadProgress', () => {
|
|
22
|
+
it('should return default progress when file does not exist', () => {
|
|
23
|
+
const progress = loadProgress(tmpDir);
|
|
24
|
+
expect(progress).toEqual({
|
|
25
|
+
user: { xp: 0, belt: 'white', modules_completed: 0 },
|
|
26
|
+
modules: {},
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return default progress when file is empty', () => {
|
|
31
|
+
fs.writeFileSync(path.join(tmpDir, '.teach-progress.yaml'), '', 'utf-8');
|
|
32
|
+
const progress = loadProgress(tmpDir);
|
|
33
|
+
expect(progress).toEqual({
|
|
34
|
+
user: { xp: 0, belt: 'white', modules_completed: 0 },
|
|
35
|
+
modules: {},
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return default progress when file has invalid YAML', () => {
|
|
40
|
+
fs.writeFileSync(path.join(tmpDir, '.teach-progress.yaml'), '{{invalid yaml: [', 'utf-8');
|
|
41
|
+
const progress = loadProgress(tmpDir);
|
|
42
|
+
expect(progress).toEqual({
|
|
43
|
+
user: { xp: 0, belt: 'white', modules_completed: 0 },
|
|
44
|
+
modules: {},
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should load valid progress from file', () => {
|
|
49
|
+
const data = {
|
|
50
|
+
user: { xp: 500, belt: 'blue', modules_completed: 3 },
|
|
51
|
+
modules: {
|
|
52
|
+
git: { status: 'completed', xp_earned: 200 },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
fs.writeFileSync(
|
|
56
|
+
path.join(tmpDir, '.teach-progress.yaml'),
|
|
57
|
+
'user:\n xp: 500\n belt: blue\n modules_completed: 3\nmodules:\n git:\n status: completed\n xp_earned: 200\n',
|
|
58
|
+
'utf-8',
|
|
59
|
+
);
|
|
60
|
+
const progress = loadProgress(tmpDir);
|
|
61
|
+
expect(progress.user.xp).toBe(500);
|
|
62
|
+
expect(progress.user.belt).toBe('blue');
|
|
63
|
+
expect(progress.modules.git.status).toBe('completed');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/* ================================================================== */
|
|
68
|
+
/* saveProgress + loadProgress roundtrip */
|
|
69
|
+
/* ================================================================== */
|
|
70
|
+
|
|
71
|
+
describe('saveProgress + loadProgress roundtrip', () => {
|
|
72
|
+
it('should save and reload progress identically', () => {
|
|
73
|
+
const data = {
|
|
74
|
+
user: { xp: 1234, belt: 'purple', modules_completed: 5 },
|
|
75
|
+
modules: {
|
|
76
|
+
git: { status: 'completed', xp_earned: 200, quiz_score: '5/5' },
|
|
77
|
+
hooks: { status: 'in-progress', xp_earned: 50, walkthrough_step: 3 },
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
saveProgress(tmpDir, data);
|
|
81
|
+
|
|
82
|
+
const loaded = loadProgress(tmpDir);
|
|
83
|
+
expect(loaded.user.xp).toBe(1234);
|
|
84
|
+
expect(loaded.user.belt).toBe('purple');
|
|
85
|
+
expect(loaded.user.modules_completed).toBe(5);
|
|
86
|
+
expect(loaded.modules.git.status).toBe('completed');
|
|
87
|
+
expect(loaded.modules.git.xp_earned).toBe(200);
|
|
88
|
+
expect(loaded.modules.git.quiz_score).toBe('5/5');
|
|
89
|
+
expect(loaded.modules.hooks.status).toBe('in-progress');
|
|
90
|
+
expect(loaded.modules.hooks.walkthrough_step).toBe(3);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should create the progress file on disk', () => {
|
|
94
|
+
const data = { user: { xp: 0 }, modules: {} };
|
|
95
|
+
saveProgress(tmpDir, data);
|
|
96
|
+
const filePath = path.join(tmpDir, '.teach-progress.yaml');
|
|
97
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should overwrite existing progress file', () => {
|
|
101
|
+
saveProgress(tmpDir, { user: { xp: 100 }, modules: {} });
|
|
102
|
+
saveProgress(tmpDir, { user: { xp: 999 }, modules: {} });
|
|
103
|
+
const loaded = loadProgress(tmpDir);
|
|
104
|
+
expect(loaded.user.xp).toBe(999);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/* ================================================================== */
|
|
109
|
+
/* recordXP */
|
|
110
|
+
/* ================================================================== */
|
|
111
|
+
|
|
112
|
+
describe('recordXP', () => {
|
|
113
|
+
it('should add XP to user total for a new module', () => {
|
|
114
|
+
const result = recordXP(tmpDir, 'git', 'read', 25);
|
|
115
|
+
expect(result.user.xp).toBe(25);
|
|
116
|
+
expect(result.modules.git).toBeDefined();
|
|
117
|
+
expect(result.modules.git.xp_earned).toBe(25);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should create module entry with in-progress status when new', () => {
|
|
121
|
+
const result = recordXP(tmpDir, 'git', 'read', 10);
|
|
122
|
+
expect(result.modules.git.status).toBe('in-progress');
|
|
123
|
+
expect(result.modules.git.started).toBeDefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should accumulate XP on existing module', () => {
|
|
127
|
+
recordXP(tmpDir, 'git', 'read', 10);
|
|
128
|
+
const result = recordXP(tmpDir, 'git', 'walkthrough', 20);
|
|
129
|
+
expect(result.modules.git.xp_earned).toBe(30);
|
|
130
|
+
expect(result.user.xp).toBe(30);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should accumulate XP across different modules', () => {
|
|
134
|
+
recordXP(tmpDir, 'git', 'read', 10);
|
|
135
|
+
const result = recordXP(tmpDir, 'hooks', 'read', 15);
|
|
136
|
+
expect(result.user.xp).toBe(25);
|
|
137
|
+
expect(result.modules.git.xp_earned).toBe(10);
|
|
138
|
+
expect(result.modules.hooks.xp_earned).toBe(15);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should persist XP to disk', () => {
|
|
142
|
+
recordXP(tmpDir, 'git', 'read', 42);
|
|
143
|
+
const loaded = loadProgress(tmpDir);
|
|
144
|
+
expect(loaded.user.xp).toBe(42);
|
|
145
|
+
expect(loaded.modules.git.xp_earned).toBe(42);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should update last_session date', () => {
|
|
149
|
+
const result = recordXP(tmpDir, 'git', 'read', 10);
|
|
150
|
+
const today = new Date().toISOString().split('T')[0];
|
|
151
|
+
expect(result.modules.git.last_session).toBe(today);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should count completed modules correctly', () => {
|
|
155
|
+
// First complete a module, then record XP on another
|
|
156
|
+
completeModule(tmpDir, 'git');
|
|
157
|
+
const result = recordXP(tmpDir, 'hooks', 'read', 10);
|
|
158
|
+
expect(result.user.modules_completed).toBe(1);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/* ================================================================== */
|
|
163
|
+
/* completeModule */
|
|
164
|
+
/* ================================================================== */
|
|
165
|
+
|
|
166
|
+
describe('completeModule', () => {
|
|
167
|
+
it('should set status to completed', () => {
|
|
168
|
+
const result = completeModule(tmpDir, 'git');
|
|
169
|
+
expect(result.modules.git.status).toBe('completed');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should set completion date', () => {
|
|
173
|
+
const result = completeModule(tmpDir, 'git');
|
|
174
|
+
const today = new Date().toISOString().split('T')[0];
|
|
175
|
+
expect(result.modules.git.completed).toBe(today);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should increment modules_completed count', () => {
|
|
179
|
+
completeModule(tmpDir, 'git');
|
|
180
|
+
const result = completeModule(tmpDir, 'hooks');
|
|
181
|
+
expect(result.user.modules_completed).toBe(2);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should create module entry if it did not exist', () => {
|
|
185
|
+
const result = completeModule(tmpDir, 'new-module');
|
|
186
|
+
expect(result.modules['new-module']).toBeDefined();
|
|
187
|
+
expect(result.modules['new-module'].status).toBe('completed');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should preserve existing module data when completing', () => {
|
|
191
|
+
recordXP(tmpDir, 'git', 'read', 25);
|
|
192
|
+
const result = completeModule(tmpDir, 'git');
|
|
193
|
+
expect(result.modules.git.xp_earned).toBe(25);
|
|
194
|
+
expect(result.modules.git.status).toBe('completed');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should persist completion to disk', () => {
|
|
198
|
+
completeModule(tmpDir, 'git');
|
|
199
|
+
const loaded = loadProgress(tmpDir);
|
|
200
|
+
expect(loaded.modules.git.status).toBe('completed');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/* ================================================================== */
|
|
205
|
+
/* updateWalkthroughStep */
|
|
206
|
+
/* ================================================================== */
|
|
207
|
+
|
|
208
|
+
describe('updateWalkthroughStep', () => {
|
|
209
|
+
it('should update the walkthrough step number', () => {
|
|
210
|
+
const result = updateWalkthroughStep(tmpDir, 'git', 3);
|
|
211
|
+
expect(result.modules.git.walkthrough_step).toBe(3);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should create module entry if it did not exist', () => {
|
|
215
|
+
const result = updateWalkthroughStep(tmpDir, 'new-mod', 1);
|
|
216
|
+
expect(result.modules['new-mod']).toBeDefined();
|
|
217
|
+
expect(result.modules['new-mod'].status).toBe('in-progress');
|
|
218
|
+
expect(result.modules['new-mod'].walkthrough_step).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should update last_session date', () => {
|
|
222
|
+
const result = updateWalkthroughStep(tmpDir, 'git', 2);
|
|
223
|
+
const today = new Date().toISOString().split('T')[0];
|
|
224
|
+
expect(result.modules.git.last_session).toBe(today);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should overwrite previous step number', () => {
|
|
228
|
+
updateWalkthroughStep(tmpDir, 'git', 1);
|
|
229
|
+
const result = updateWalkthroughStep(tmpDir, 'git', 5);
|
|
230
|
+
expect(result.modules.git.walkthrough_step).toBe(5);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should persist step to disk', () => {
|
|
234
|
+
updateWalkthroughStep(tmpDir, 'git', 7);
|
|
235
|
+
const loaded = loadProgress(tmpDir);
|
|
236
|
+
expect(loaded.modules.git.walkthrough_step).toBe(7);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should preserve existing module data', () => {
|
|
240
|
+
recordXP(tmpDir, 'git', 'read', 20);
|
|
241
|
+
const result = updateWalkthroughStep(tmpDir, 'git', 4);
|
|
242
|
+
expect(result.modules.git.xp_earned).toBe(20);
|
|
243
|
+
expect(result.modules.git.walkthrough_step).toBe(4);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
/* ================================================================== */
|
|
248
|
+
/* recordQuizScore */
|
|
249
|
+
/* ================================================================== */
|
|
250
|
+
|
|
251
|
+
describe('recordQuizScore', () => {
|
|
252
|
+
it('should record the quiz score string', () => {
|
|
253
|
+
const result = recordQuizScore(tmpDir, 'git', '4/5');
|
|
254
|
+
expect(result.modules.git.quiz_score).toBe('4/5');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should create module entry if it did not exist', () => {
|
|
258
|
+
const result = recordQuizScore(tmpDir, 'new-mod', '3/5');
|
|
259
|
+
expect(result.modules['new-mod']).toBeDefined();
|
|
260
|
+
expect(result.modules['new-mod'].status).toBe('in-progress');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should update last_session date', () => {
|
|
264
|
+
const result = recordQuizScore(tmpDir, 'git', '5/5');
|
|
265
|
+
const today = new Date().toISOString().split('T')[0];
|
|
266
|
+
expect(result.modules.git.last_session).toBe(today);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should overwrite previous quiz score', () => {
|
|
270
|
+
recordQuizScore(tmpDir, 'git', '2/5');
|
|
271
|
+
const result = recordQuizScore(tmpDir, 'git', '4/5');
|
|
272
|
+
expect(result.modules.git.quiz_score).toBe('4/5');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should persist score to disk', () => {
|
|
276
|
+
recordQuizScore(tmpDir, 'git', '5/5');
|
|
277
|
+
const loaded = loadProgress(tmpDir);
|
|
278
|
+
expect(loaded.modules.git.quiz_score).toBe('5/5');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should handle numeric score as well', () => {
|
|
282
|
+
const result = recordQuizScore(tmpDir, 'git', 80);
|
|
283
|
+
expect(result.modules.git.quiz_score).toBe(80);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should preserve existing module data', () => {
|
|
287
|
+
recordXP(tmpDir, 'git', 'read', 15);
|
|
288
|
+
updateWalkthroughStep(tmpDir, 'git', 5);
|
|
289
|
+
const result = recordQuizScore(tmpDir, 'git', '4/5');
|
|
290
|
+
expect(result.modules.git.xp_earned).toBe(15);
|
|
291
|
+
expect(result.modules.git.walkthrough_step).toBe(5);
|
|
292
|
+
expect(result.modules.git.quiz_score).toBe('4/5');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
/* ================================================================== */
|
|
297
|
+
/* getStats */
|
|
298
|
+
/* ================================================================== */
|
|
299
|
+
|
|
300
|
+
describe('getStats', () => {
|
|
301
|
+
it('should return xp, belt, modulesCompleted, and modulesInProgress', () => {
|
|
302
|
+
const progress = {
|
|
303
|
+
user: { xp: 300, belt: 'green', modules_completed: 2 },
|
|
304
|
+
modules: {
|
|
305
|
+
git: { status: 'completed' },
|
|
306
|
+
hooks: { status: 'completed' },
|
|
307
|
+
daily: { status: 'in-progress' },
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
const stats = getStats(progress);
|
|
311
|
+
expect(stats.xp).toBe(300);
|
|
312
|
+
expect(stats.belt).toBe('green');
|
|
313
|
+
expect(stats.modulesCompleted).toBe(2);
|
|
314
|
+
expect(stats.modulesInProgress).toBe(1);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should handle empty progress', () => {
|
|
318
|
+
const stats = getStats({ user: {}, modules: {} });
|
|
319
|
+
expect(stats.xp).toBe(0);
|
|
320
|
+
expect(stats.belt).toBe('white');
|
|
321
|
+
expect(stats.modulesCompleted).toBe(0);
|
|
322
|
+
expect(stats.modulesInProgress).toBe(0);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should handle missing user field', () => {
|
|
326
|
+
const stats = getStats({ modules: {} });
|
|
327
|
+
expect(stats.xp).toBe(0);
|
|
328
|
+
expect(stats.belt).toBe('white');
|
|
329
|
+
expect(stats.modulesCompleted).toBe(0);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should handle missing modules field', () => {
|
|
333
|
+
const stats = getStats({ user: { xp: 100 } });
|
|
334
|
+
expect(stats.xp).toBe(100);
|
|
335
|
+
expect(stats.modulesInProgress).toBe(0);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should handle completely empty object', () => {
|
|
339
|
+
const stats = getStats({});
|
|
340
|
+
expect(stats.xp).toBe(0);
|
|
341
|
+
expect(stats.belt).toBe('white');
|
|
342
|
+
expect(stats.modulesCompleted).toBe(0);
|
|
343
|
+
expect(stats.modulesInProgress).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should count only in-progress modules, not completed', () => {
|
|
347
|
+
const progress = {
|
|
348
|
+
user: { xp: 100, modules_completed: 1 },
|
|
349
|
+
modules: {
|
|
350
|
+
a: { status: 'completed' },
|
|
351
|
+
b: { status: 'in-progress' },
|
|
352
|
+
c: { status: 'in-progress' },
|
|
353
|
+
d: { status: 'completed' },
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
const stats = getStats(progress);
|
|
357
|
+
expect(stats.modulesInProgress).toBe(2);
|
|
358
|
+
expect(stats.modulesCompleted).toBe(1); // from user field, not recounted
|
|
359
|
+
});
|
|
360
|
+
});
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module registry — discovers module.yaml files from three sources and builds
|
|
3
|
+
* a unified searchable index.
|
|
4
|
+
*
|
|
5
|
+
* Sources:
|
|
6
|
+
* 1. Built-in — packages/modules/ (ships with ClaudeTeach)
|
|
7
|
+
* 2. Packs — ~/.claude-teach/modules/<pack>/modules/ (installed via git)
|
|
8
|
+
* 3. Local — ~/.claude-teach/modules/local/ (user-authored)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
12
|
+
import { join, resolve } from 'path';
|
|
13
|
+
import yaml from 'js-yaml';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import { MODULES_HOME, LOCAL_MODULES, ensureHome } from './marketplace.js';
|
|
16
|
+
|
|
17
|
+
const BUILTIN_MODULES_DIR = 'packages/modules';
|
|
18
|
+
const REGISTRY_FILE = 'registry.yaml';
|
|
19
|
+
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
/* Scanning helpers */
|
|
22
|
+
/* ------------------------------------------------------------------ */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Scan a directory for subdirectories containing module.yaml files.
|
|
26
|
+
* Returns an array of { meta, modulePath } objects.
|
|
27
|
+
*/
|
|
28
|
+
function scanModuleDir(dir) {
|
|
29
|
+
const results = [];
|
|
30
|
+
if (!existsSync(dir)) return results;
|
|
31
|
+
|
|
32
|
+
const entries = readdirSync(dir).filter(name => {
|
|
33
|
+
const fullPath = join(dir, name);
|
|
34
|
+
try {
|
|
35
|
+
return statSync(fullPath).isDirectory() && existsSync(join(fullPath, 'module.yaml'));
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
for (const dirName of entries) {
|
|
42
|
+
const modulePath = join(dir, dirName);
|
|
43
|
+
const yamlPath = join(modulePath, 'module.yaml');
|
|
44
|
+
try {
|
|
45
|
+
const raw = readFileSync(yamlPath, 'utf-8');
|
|
46
|
+
const meta = yaml.load(raw);
|
|
47
|
+
results.push({ meta, modulePath, dirName });
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.warn(` Skipping ${modulePath}: ${err.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Turn raw scan result + source tag into a module registry entry.
|
|
58
|
+
*/
|
|
59
|
+
function toRegistryEntry({ meta, modulePath, dirName }, source) {
|
|
60
|
+
const capabilities = detectCapabilities(modulePath);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
slug: meta.slug || dirName,
|
|
64
|
+
title: meta.title || dirName,
|
|
65
|
+
description: meta.description || '',
|
|
66
|
+
category: meta.category || 'uncategorized',
|
|
67
|
+
difficulty: meta.difficulty || 'beginner',
|
|
68
|
+
tags: meta.tags || [],
|
|
69
|
+
narrative: meta.narrative || false,
|
|
70
|
+
prerequisites: meta.prerequisites || [],
|
|
71
|
+
related: meta.related || [],
|
|
72
|
+
xp: meta.xp || {},
|
|
73
|
+
time: meta.time || {},
|
|
74
|
+
triggers: meta.triggers || [],
|
|
75
|
+
source,
|
|
76
|
+
capabilities,
|
|
77
|
+
_path: modulePath,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ------------------------------------------------------------------ */
|
|
82
|
+
/* Build unified registry */
|
|
83
|
+
/* ------------------------------------------------------------------ */
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Scan all three module sources and build a merged registry.
|
|
87
|
+
* Built-in modules win on slug collisions.
|
|
88
|
+
*/
|
|
89
|
+
export async function buildRegistry(rootDir) {
|
|
90
|
+
const slugMap = new Map(); // slug -> entry (first-seen wins)
|
|
91
|
+
const allModules = [];
|
|
92
|
+
|
|
93
|
+
// --- 1. Built-in modules ---
|
|
94
|
+
const builtinDir = resolve(rootDir, BUILTIN_MODULES_DIR);
|
|
95
|
+
for (const item of scanModuleDir(builtinDir)) {
|
|
96
|
+
const entry = toRegistryEntry(item, 'built-in');
|
|
97
|
+
slugMap.set(entry.slug, entry);
|
|
98
|
+
allModules.push(entry);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- 2. Installed packs ---
|
|
102
|
+
ensureHome();
|
|
103
|
+
if (existsSync(MODULES_HOME)) {
|
|
104
|
+
for (const packDir of readdirSync(MODULES_HOME)) {
|
|
105
|
+
if (packDir === 'local') continue;
|
|
106
|
+
const packModulesDir = join(MODULES_HOME, packDir, 'modules');
|
|
107
|
+
for (const item of scanModuleDir(packModulesDir)) {
|
|
108
|
+
const entry = toRegistryEntry(item, `pack:${packDir}`);
|
|
109
|
+
if (slugMap.has(entry.slug)) {
|
|
110
|
+
console.warn(
|
|
111
|
+
chalk.yellow(` Warning: slug "${entry.slug}" from pack "${packDir}" collides with ${slugMap.get(entry.slug).source} — skipping.`)
|
|
112
|
+
);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
slugMap.set(entry.slug, entry);
|
|
116
|
+
allModules.push(entry);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- 3. Local custom modules ---
|
|
122
|
+
if (existsSync(LOCAL_MODULES)) {
|
|
123
|
+
// Scan top-level directories inside local/
|
|
124
|
+
for (const item of scanModuleDir(LOCAL_MODULES)) {
|
|
125
|
+
const entry = toRegistryEntry(item, 'local');
|
|
126
|
+
if (slugMap.has(entry.slug)) {
|
|
127
|
+
console.warn(
|
|
128
|
+
chalk.yellow(` Warning: local slug "${entry.slug}" collides with ${slugMap.get(entry.slug).source} — skipping.`)
|
|
129
|
+
);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
slugMap.set(entry.slug, entry);
|
|
133
|
+
allModules.push(entry);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Also scan local/_preview/ for preview modules
|
|
137
|
+
const previewDir = join(LOCAL_MODULES, '_preview');
|
|
138
|
+
if (existsSync(previewDir)) {
|
|
139
|
+
for (const item of scanModuleDir(previewDir)) {
|
|
140
|
+
const entry = toRegistryEntry(item, 'local');
|
|
141
|
+
if (slugMap.has(entry.slug)) {
|
|
142
|
+
console.warn(
|
|
143
|
+
chalk.yellow(` Warning: preview slug "${entry.slug}" collides with ${slugMap.get(entry.slug).source} — skipping.`)
|
|
144
|
+
);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
slugMap.set(entry.slug, entry);
|
|
148
|
+
allModules.push(entry);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Sort: beginner first, then intermediate, then advanced
|
|
154
|
+
const diffOrder = { beginner: 0, intermediate: 1, advanced: 2 };
|
|
155
|
+
allModules.sort((a, b) => (diffOrder[a.difficulty] || 0) - (diffOrder[b.difficulty] || 0));
|
|
156
|
+
|
|
157
|
+
const registry = { modules: allModules };
|
|
158
|
+
|
|
159
|
+
// Write registry.yaml for built-in modules only (preserves original behavior)
|
|
160
|
+
const registryPath = join(builtinDir, REGISTRY_FILE);
|
|
161
|
+
const builtinOnly = allModules
|
|
162
|
+
.filter(m => m.source === 'built-in')
|
|
163
|
+
.map(({ _path, ...rest }) => rest);
|
|
164
|
+
writeFileSync(
|
|
165
|
+
registryPath,
|
|
166
|
+
yaml.dump({ modules: builtinOnly }, { lineWidth: 120, noRefs: true }),
|
|
167
|
+
'utf-8',
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return registry;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Load existing registry.yaml (built-in only), then merge in packs + local on the fly.
|
|
175
|
+
*/
|
|
176
|
+
export async function loadRegistry(rootDir) {
|
|
177
|
+
// Always do a full scan to include all three sources.
|
|
178
|
+
// This is fast — just reads module.yaml files from known directories.
|
|
179
|
+
return buildRegistry(rootDir);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Find a module by slug.
|
|
184
|
+
*/
|
|
185
|
+
export function getModule(registry, slug) {
|
|
186
|
+
return registry.modules?.find(m => m.slug === slug) || null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Detect capabilities from which files exist in a module directory.
|
|
191
|
+
*/
|
|
192
|
+
function detectCapabilities(modulePath) {
|
|
193
|
+
const caps = ['content']; // content.md is required
|
|
194
|
+
|
|
195
|
+
if (existsSync(join(modulePath, 'walkthrough.md'))) caps.push('walkthrough');
|
|
196
|
+
if (existsSync(join(modulePath, 'exercises.md'))) caps.push('exercises');
|
|
197
|
+
if (existsSync(join(modulePath, 'quiz.md'))) caps.push('quiz');
|
|
198
|
+
if (existsSync(join(modulePath, 'quick-ref.md'))) caps.push('quick-ref');
|
|
199
|
+
|
|
200
|
+
return caps;
|
|
201
|
+
}
|