@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,309 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { getModule } from './registry.js';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import yaml from 'js-yaml';
|
|
7
|
+
|
|
8
|
+
/* ================================================================== */
|
|
9
|
+
/* getModule(registry, slug) */
|
|
10
|
+
/* ================================================================== */
|
|
11
|
+
|
|
12
|
+
describe('getModule', () => {
|
|
13
|
+
const registry = {
|
|
14
|
+
modules: [
|
|
15
|
+
{ slug: 'git', title: 'Git Basics', source: 'built-in', difficulty: 'beginner', capabilities: ['content', 'walkthrough'] },
|
|
16
|
+
{ slug: 'hooks', title: 'Git Hooks', source: 'built-in', difficulty: 'intermediate', capabilities: ['content', 'quiz'] },
|
|
17
|
+
{ slug: 'daily-workflow', title: 'Daily Workflow', source: 'built-in', difficulty: 'beginner', capabilities: ['content'] },
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
it('should return the module for a known slug', () => {
|
|
22
|
+
const mod = getModule(registry, 'git');
|
|
23
|
+
expect(mod).not.toBeNull();
|
|
24
|
+
expect(mod.slug).toBe('git');
|
|
25
|
+
expect(mod.title).toBe('Git Basics');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should return null for an unknown slug', () => {
|
|
29
|
+
const mod = getModule(registry, 'nonexistent');
|
|
30
|
+
expect(mod).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return the correct module when multiple exist', () => {
|
|
34
|
+
const mod = getModule(registry, 'hooks');
|
|
35
|
+
expect(mod.slug).toBe('hooks');
|
|
36
|
+
expect(mod.title).toBe('Git Hooks');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should handle empty modules array', () => {
|
|
40
|
+
const mod = getModule({ modules: [] }, 'git');
|
|
41
|
+
expect(mod).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should handle undefined modules', () => {
|
|
45
|
+
const mod = getModule({}, 'git');
|
|
46
|
+
expect(mod).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should throw when passed null registry (no null guard on registry itself)', () => {
|
|
50
|
+
// getModule uses registry.modules?.find — the optional chaining is on .modules,
|
|
51
|
+
// but registry itself being null causes a TypeError
|
|
52
|
+
expect(() => getModule(null, 'git')).toThrow(TypeError);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should return module with all expected fields', () => {
|
|
56
|
+
const mod = getModule(registry, 'git');
|
|
57
|
+
expect(mod).toHaveProperty('slug');
|
|
58
|
+
expect(mod).toHaveProperty('title');
|
|
59
|
+
expect(mod).toHaveProperty('source');
|
|
60
|
+
expect(mod).toHaveProperty('difficulty');
|
|
61
|
+
expect(mod).toHaveProperty('capabilities');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should match exact slug only (not partial)', () => {
|
|
65
|
+
const mod = getModule(registry, 'gi');
|
|
66
|
+
expect(mod).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/* ================================================================== */
|
|
71
|
+
/* Registry entry shape */
|
|
72
|
+
/* ================================================================== */
|
|
73
|
+
|
|
74
|
+
describe('Registry entry structure', () => {
|
|
75
|
+
const sampleEntry = {
|
|
76
|
+
slug: 'git',
|
|
77
|
+
title: 'Git Basics',
|
|
78
|
+
description: 'Learn git fundamentals',
|
|
79
|
+
category: 'version-control',
|
|
80
|
+
difficulty: 'beginner',
|
|
81
|
+
tags: ['git', 'vcs'],
|
|
82
|
+
narrative: false,
|
|
83
|
+
prerequisites: [],
|
|
84
|
+
related: ['hooks'],
|
|
85
|
+
xp: { read: 10, walkthrough: 20 },
|
|
86
|
+
time: { read: 10 },
|
|
87
|
+
triggers: ['git-init'],
|
|
88
|
+
source: 'built-in',
|
|
89
|
+
capabilities: ['content', 'walkthrough'],
|
|
90
|
+
_path: '/some/path',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
it('each entry should have a slug string', () => {
|
|
94
|
+
expect(typeof sampleEntry.slug).toBe('string');
|
|
95
|
+
expect(sampleEntry.slug.length).toBeGreaterThan(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('each entry should have a title string', () => {
|
|
99
|
+
expect(typeof sampleEntry.title).toBe('string');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('each entry should have a source field', () => {
|
|
103
|
+
expect(typeof sampleEntry.source).toBe('string');
|
|
104
|
+
expect(['built-in', 'local'].includes(sampleEntry.source) || sampleEntry.source.startsWith('pack:')).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('each entry should have a capabilities array', () => {
|
|
108
|
+
expect(Array.isArray(sampleEntry.capabilities)).toBe(true);
|
|
109
|
+
expect(sampleEntry.capabilities.length).toBeGreaterThan(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('capabilities should always include content', () => {
|
|
113
|
+
expect(sampleEntry.capabilities).toContain('content');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('xp should be an object with numeric values', () => {
|
|
117
|
+
expect(typeof sampleEntry.xp).toBe('object');
|
|
118
|
+
for (const val of Object.values(sampleEntry.xp)) {
|
|
119
|
+
expect(typeof val).toBe('number');
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('prerequisites should be an array', () => {
|
|
124
|
+
expect(Array.isArray(sampleEntry.prerequisites)).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('tags should be an array', () => {
|
|
128
|
+
expect(Array.isArray(sampleEntry.tags)).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('difficulty should be one of beginner, intermediate, advanced', () => {
|
|
132
|
+
expect(['beginner', 'intermediate', 'advanced']).toContain(sampleEntry.difficulty);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/* ================================================================== */
|
|
137
|
+
/* buildRegistry — integration test with temp filesystem */
|
|
138
|
+
/* ================================================================== */
|
|
139
|
+
|
|
140
|
+
describe('buildRegistry (filesystem)', () => {
|
|
141
|
+
let tmpDir;
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teach-registry-'));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Helper: create a minimal module directory structure under
|
|
153
|
+
* <tmpDir>/packages/modules/<slug>/
|
|
154
|
+
*/
|
|
155
|
+
function createModule(slug, meta = {}) {
|
|
156
|
+
const moduleDir = path.join(tmpDir, 'packages', 'modules', slug);
|
|
157
|
+
fs.mkdirSync(moduleDir, { recursive: true });
|
|
158
|
+
|
|
159
|
+
const defaults = {
|
|
160
|
+
slug,
|
|
161
|
+
title: slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
|
|
162
|
+
description: `Description for ${slug}`,
|
|
163
|
+
category: 'test',
|
|
164
|
+
difficulty: 'beginner',
|
|
165
|
+
tags: [],
|
|
166
|
+
xp: { read: 10 },
|
|
167
|
+
time: { read: 5 },
|
|
168
|
+
prerequisites: [],
|
|
169
|
+
related: [],
|
|
170
|
+
triggers: [],
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const merged = { ...defaults, ...meta };
|
|
174
|
+
fs.writeFileSync(path.join(moduleDir, 'module.yaml'), yaml.dump(merged), 'utf-8');
|
|
175
|
+
fs.writeFileSync(path.join(moduleDir, 'content.md'), `# ${merged.title}\n\nContent here.\n`, 'utf-8');
|
|
176
|
+
|
|
177
|
+
return moduleDir;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
it('should return an object with a modules array', async () => {
|
|
181
|
+
// Even with no modules, buildRegistry should return { modules: [] }
|
|
182
|
+
// Need to create the packages/modules dir so it doesn't fail
|
|
183
|
+
fs.mkdirSync(path.join(tmpDir, 'packages', 'modules'), { recursive: true });
|
|
184
|
+
|
|
185
|
+
// Import buildRegistry dynamically to avoid side effects at module scope
|
|
186
|
+
const { buildRegistry } = await import('./registry.js');
|
|
187
|
+
const registry = await buildRegistry(tmpDir);
|
|
188
|
+
expect(registry).toHaveProperty('modules');
|
|
189
|
+
expect(Array.isArray(registry.modules)).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should discover modules from packages/modules/', async () => {
|
|
193
|
+
createModule('test-mod-a');
|
|
194
|
+
createModule('test-mod-b', { difficulty: 'intermediate' });
|
|
195
|
+
|
|
196
|
+
const { buildRegistry } = await import('./registry.js');
|
|
197
|
+
const registry = await buildRegistry(tmpDir);
|
|
198
|
+
|
|
199
|
+
// At minimum should contain our two test modules (may also contain packs/local from home dir)
|
|
200
|
+
const slugs = registry.modules.map(m => m.slug);
|
|
201
|
+
expect(slugs).toContain('test-mod-a');
|
|
202
|
+
expect(slugs).toContain('test-mod-b');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should populate entry fields from module.yaml', async () => {
|
|
206
|
+
createModule('my-module', {
|
|
207
|
+
title: 'My Test Module',
|
|
208
|
+
description: 'A test',
|
|
209
|
+
category: 'testing',
|
|
210
|
+
difficulty: 'advanced',
|
|
211
|
+
tags: ['test'],
|
|
212
|
+
xp: { read: 15, quiz: 25 },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const { buildRegistry } = await import('./registry.js');
|
|
216
|
+
const registry = await buildRegistry(tmpDir);
|
|
217
|
+
|
|
218
|
+
const mod = registry.modules.find(m => m.slug === 'my-module');
|
|
219
|
+
expect(mod).toBeDefined();
|
|
220
|
+
expect(mod.title).toBe('My Test Module');
|
|
221
|
+
expect(mod.description).toBe('A test');
|
|
222
|
+
expect(mod.category).toBe('testing');
|
|
223
|
+
expect(mod.difficulty).toBe('advanced');
|
|
224
|
+
expect(mod.tags).toEqual(['test']);
|
|
225
|
+
expect(mod.xp).toEqual({ read: 15, quiz: 25 });
|
|
226
|
+
expect(mod.source).toBe('built-in');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should detect capabilities from existing files', async () => {
|
|
230
|
+
const moduleDir = createModule('cap-test');
|
|
231
|
+
// Add optional files
|
|
232
|
+
fs.writeFileSync(path.join(moduleDir, 'walkthrough.md'), '# Walkthrough\n', 'utf-8');
|
|
233
|
+
fs.writeFileSync(path.join(moduleDir, 'quiz.md'), '# Quiz\n', 'utf-8');
|
|
234
|
+
|
|
235
|
+
const { buildRegistry } = await import('./registry.js');
|
|
236
|
+
const registry = await buildRegistry(tmpDir);
|
|
237
|
+
|
|
238
|
+
const mod = registry.modules.find(m => m.slug === 'cap-test');
|
|
239
|
+
expect(mod.capabilities).toContain('content');
|
|
240
|
+
expect(mod.capabilities).toContain('walkthrough');
|
|
241
|
+
expect(mod.capabilities).toContain('quiz');
|
|
242
|
+
expect(mod.capabilities).not.toContain('exercises');
|
|
243
|
+
expect(mod.capabilities).not.toContain('quick-ref');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should sort modules by difficulty (beginner first)', async () => {
|
|
247
|
+
createModule('adv-mod', { difficulty: 'advanced' });
|
|
248
|
+
createModule('beg-mod', { difficulty: 'beginner' });
|
|
249
|
+
createModule('int-mod', { difficulty: 'intermediate' });
|
|
250
|
+
|
|
251
|
+
const { buildRegistry } = await import('./registry.js');
|
|
252
|
+
const registry = await buildRegistry(tmpDir);
|
|
253
|
+
|
|
254
|
+
// Filter to only our test modules
|
|
255
|
+
const testMods = registry.modules.filter(m => ['adv-mod', 'beg-mod', 'int-mod'].includes(m.slug));
|
|
256
|
+
const slugOrder = testMods.map(m => m.slug);
|
|
257
|
+
expect(slugOrder.indexOf('beg-mod')).toBeLessThan(slugOrder.indexOf('int-mod'));
|
|
258
|
+
expect(slugOrder.indexOf('int-mod')).toBeLessThan(slugOrder.indexOf('adv-mod'));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should write registry.yaml for built-in modules', async () => {
|
|
262
|
+
createModule('persist-mod');
|
|
263
|
+
|
|
264
|
+
const { buildRegistry } = await import('./registry.js');
|
|
265
|
+
await buildRegistry(tmpDir);
|
|
266
|
+
|
|
267
|
+
const registryPath = path.join(tmpDir, 'packages', 'modules', 'registry.yaml');
|
|
268
|
+
expect(fs.existsSync(registryPath)).toBe(true);
|
|
269
|
+
|
|
270
|
+
const content = yaml.load(fs.readFileSync(registryPath, 'utf-8'));
|
|
271
|
+
expect(content).toHaveProperty('modules');
|
|
272
|
+
const slugs = content.modules.map(m => m.slug);
|
|
273
|
+
expect(slugs).toContain('persist-mod');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should use directory name as fallback slug', async () => {
|
|
277
|
+
const moduleDir = path.join(tmpDir, 'packages', 'modules', 'fallback-dir');
|
|
278
|
+
fs.mkdirSync(moduleDir, { recursive: true });
|
|
279
|
+
|
|
280
|
+
// Write module.yaml without a slug field
|
|
281
|
+
const meta = { title: 'Fallback Module', description: 'No slug' };
|
|
282
|
+
fs.writeFileSync(path.join(moduleDir, 'module.yaml'), yaml.dump(meta), 'utf-8');
|
|
283
|
+
fs.writeFileSync(path.join(moduleDir, 'content.md'), '# Fallback\n', 'utf-8');
|
|
284
|
+
|
|
285
|
+
const { buildRegistry } = await import('./registry.js');
|
|
286
|
+
const registry = await buildRegistry(tmpDir);
|
|
287
|
+
|
|
288
|
+
const mod = registry.modules.find(m => m.slug === 'fallback-dir');
|
|
289
|
+
expect(mod).toBeDefined();
|
|
290
|
+
expect(mod.title).toBe('Fallback Module');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should skip directories without module.yaml', async () => {
|
|
294
|
+
// Create a directory without module.yaml
|
|
295
|
+
const emptyDir = path.join(tmpDir, 'packages', 'modules', 'no-yaml');
|
|
296
|
+
fs.mkdirSync(emptyDir, { recursive: true });
|
|
297
|
+
fs.writeFileSync(path.join(emptyDir, 'random.txt'), 'not a module', 'utf-8');
|
|
298
|
+
|
|
299
|
+
// Create a valid module
|
|
300
|
+
createModule('valid-mod');
|
|
301
|
+
|
|
302
|
+
const { buildRegistry } = await import('./registry.js');
|
|
303
|
+
const registry = await buildRegistry(tmpDir);
|
|
304
|
+
|
|
305
|
+
const slugs = registry.modules.map(m => m.slug);
|
|
306
|
+
expect(slugs).toContain('valid-mod');
|
|
307
|
+
expect(slugs).not.toContain('no-yaml');
|
|
308
|
+
});
|
|
309
|
+
});
|