@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
package/src/author.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module authoring tools — scaffold, validate, and preview module packs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, symlinkSync, rmSync, statSync } from 'fs';
|
|
6
|
+
import { join, resolve, basename } from 'path';
|
|
7
|
+
import yaml from 'js-yaml';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { ensureHome, LOCAL_MODULES } from './marketplace.js';
|
|
10
|
+
|
|
11
|
+
/* ------------------------------------------------------------------ */
|
|
12
|
+
/* Scaffold a new module pack */
|
|
13
|
+
/* ------------------------------------------------------------------ */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create a new module pack directory with the required structure.
|
|
17
|
+
* <cwd>/<name>/
|
|
18
|
+
* pack.yaml
|
|
19
|
+
* modules/
|
|
20
|
+
* README.md
|
|
21
|
+
*/
|
|
22
|
+
export async function initPack(name) {
|
|
23
|
+
const dest = resolve(process.cwd(), name);
|
|
24
|
+
|
|
25
|
+
if (existsSync(dest)) {
|
|
26
|
+
throw new Error(`Directory "${name}" already exists.`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
mkdirSync(join(dest, 'modules'), { recursive: true });
|
|
30
|
+
|
|
31
|
+
const packYaml = {
|
|
32
|
+
name,
|
|
33
|
+
author: '',
|
|
34
|
+
description: '',
|
|
35
|
+
version: '1.0.0',
|
|
36
|
+
modules: [],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
writeFileSync(
|
|
40
|
+
join(dest, 'pack.yaml'),
|
|
41
|
+
yaml.dump(packYaml, { lineWidth: 120 }),
|
|
42
|
+
'utf-8',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
writeFileSync(
|
|
46
|
+
join(dest, 'README.md'),
|
|
47
|
+
[
|
|
48
|
+
`# ${name}`,
|
|
49
|
+
'',
|
|
50
|
+
'A ClaudeTeach module pack.',
|
|
51
|
+
'',
|
|
52
|
+
'## Modules',
|
|
53
|
+
'',
|
|
54
|
+
'_None yet. Run `claude-teach author add <pack>/<module>` to add one._',
|
|
55
|
+
'',
|
|
56
|
+
'## Install',
|
|
57
|
+
'',
|
|
58
|
+
'```bash',
|
|
59
|
+
`claude-teach install <your-git-url>`,
|
|
60
|
+
'```',
|
|
61
|
+
'',
|
|
62
|
+
].join('\n'),
|
|
63
|
+
'utf-8',
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
console.log(chalk.green(`Pack "${name}" scaffolded at ${dest}`));
|
|
67
|
+
console.log(` Next: cd ${name} && claude-teach author add ${name}/my-module`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ------------------------------------------------------------------ */
|
|
71
|
+
/* Add a module to a pack */
|
|
72
|
+
/* ------------------------------------------------------------------ */
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Add a new module inside an existing pack.
|
|
76
|
+
* <packPath>/modules/<moduleName>/
|
|
77
|
+
* module.yaml
|
|
78
|
+
* content.md
|
|
79
|
+
*
|
|
80
|
+
* @param {string} packPath — path to the pack directory
|
|
81
|
+
* @param {string} moduleName — slug for the new module
|
|
82
|
+
*/
|
|
83
|
+
export async function addModule(packPath, moduleName) {
|
|
84
|
+
const absPackPath = resolve(process.cwd(), packPath);
|
|
85
|
+
|
|
86
|
+
if (!existsSync(join(absPackPath, 'pack.yaml'))) {
|
|
87
|
+
throw new Error(`No pack.yaml found at "${absPackPath}". Is this a module pack?`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const moduleDir = join(absPackPath, 'modules', moduleName);
|
|
91
|
+
if (existsSync(moduleDir)) {
|
|
92
|
+
throw new Error(`Module "${moduleName}" already exists in this pack.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
mkdirSync(moduleDir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
const moduleYaml = {
|
|
98
|
+
slug: moduleName,
|
|
99
|
+
title: moduleName.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
|
|
100
|
+
version: '1.0.0',
|
|
101
|
+
description: '',
|
|
102
|
+
category: 'uncategorized',
|
|
103
|
+
tags: [],
|
|
104
|
+
difficulty: 'beginner',
|
|
105
|
+
xp: { read: 10 },
|
|
106
|
+
time: { read: 10 },
|
|
107
|
+
prerequisites: [],
|
|
108
|
+
related: [],
|
|
109
|
+
triggers: [],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
writeFileSync(
|
|
113
|
+
join(moduleDir, 'module.yaml'),
|
|
114
|
+
yaml.dump(moduleYaml, { lineWidth: 120 }),
|
|
115
|
+
'utf-8',
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
writeFileSync(
|
|
119
|
+
join(moduleDir, 'content.md'),
|
|
120
|
+
[
|
|
121
|
+
`# ${moduleYaml.title}`,
|
|
122
|
+
'',
|
|
123
|
+
'_Write your module content here._',
|
|
124
|
+
'',
|
|
125
|
+
].join('\n'),
|
|
126
|
+
'utf-8',
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Update pack.yaml modules list
|
|
130
|
+
const packYamlPath = join(absPackPath, 'pack.yaml');
|
|
131
|
+
const pack = yaml.load(readFileSync(packYamlPath, 'utf-8')) || {};
|
|
132
|
+
if (!Array.isArray(pack.modules)) pack.modules = [];
|
|
133
|
+
if (!pack.modules.includes(moduleName)) {
|
|
134
|
+
pack.modules.push(moduleName);
|
|
135
|
+
writeFileSync(packYamlPath, yaml.dump(pack, { lineWidth: 120 }), 'utf-8');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log(chalk.green(`Module "${moduleName}" added to pack at ${moduleDir}`));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ------------------------------------------------------------------ */
|
|
142
|
+
/* Validate a pack */
|
|
143
|
+
/* ------------------------------------------------------------------ */
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Validate a module pack for correctness.
|
|
147
|
+
*
|
|
148
|
+
* Checks:
|
|
149
|
+
* - pack.yaml exists and has required fields (name, version)
|
|
150
|
+
* - Each listed module has a directory with module.yaml
|
|
151
|
+
* - Each module.yaml has required fields (slug, title)
|
|
152
|
+
* - Referenced files (content.md, walkthrough.md, etc.) exist
|
|
153
|
+
* - XP values are numbers
|
|
154
|
+
* - Triggers are arrays
|
|
155
|
+
* - Prerequisites reference valid slugs (within pack or built-in)
|
|
156
|
+
*
|
|
157
|
+
* @param {string} packPath — path to the pack directory
|
|
158
|
+
* @param {string[]} [builtinSlugs] — optional list of built-in module slugs for prereq validation
|
|
159
|
+
* @returns {{ valid: boolean, errors: string[], warnings: string[] }}
|
|
160
|
+
*/
|
|
161
|
+
export async function validatePack(packPath, builtinSlugs = []) {
|
|
162
|
+
const absPath = resolve(process.cwd(), packPath);
|
|
163
|
+
const errors = [];
|
|
164
|
+
const warnings = [];
|
|
165
|
+
|
|
166
|
+
// 1. pack.yaml
|
|
167
|
+
const packYamlPath = join(absPath, 'pack.yaml');
|
|
168
|
+
if (!existsSync(packYamlPath)) {
|
|
169
|
+
errors.push('Missing pack.yaml');
|
|
170
|
+
return { valid: false, errors, warnings };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let pack;
|
|
174
|
+
try {
|
|
175
|
+
pack = yaml.load(readFileSync(packYamlPath, 'utf-8'));
|
|
176
|
+
} catch (err) {
|
|
177
|
+
errors.push(`pack.yaml parse error: ${err.message}`);
|
|
178
|
+
return { valid: false, errors, warnings };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!pack.name) errors.push('pack.yaml: missing "name" field');
|
|
182
|
+
if (!pack.version) warnings.push('pack.yaml: missing "version" field');
|
|
183
|
+
if (!pack.author) warnings.push('pack.yaml: missing "author" field');
|
|
184
|
+
if (!pack.description) warnings.push('pack.yaml: missing "description" field');
|
|
185
|
+
|
|
186
|
+
// 2. Modules directory
|
|
187
|
+
const modulesDir = join(absPath, 'modules');
|
|
188
|
+
if (!existsSync(modulesDir)) {
|
|
189
|
+
errors.push('Missing modules/ directory');
|
|
190
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const listedModules = Array.isArray(pack.modules) ? pack.modules : [];
|
|
194
|
+
const allPackSlugs = [];
|
|
195
|
+
|
|
196
|
+
// Scan actual module directories
|
|
197
|
+
const actualDirs = readdirSync(modulesDir).filter(name => {
|
|
198
|
+
const full = join(modulesDir, name);
|
|
199
|
+
return existsSync(full) && statSync(full).isDirectory();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Warn about modules listed in pack.yaml but not found on disk
|
|
203
|
+
for (const listed of listedModules) {
|
|
204
|
+
if (!actualDirs.includes(listed)) {
|
|
205
|
+
errors.push(`Module "${listed}" is listed in pack.yaml but has no directory`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Warn about directories not listed in pack.yaml
|
|
210
|
+
for (const dir of actualDirs) {
|
|
211
|
+
if (!listedModules.includes(dir)) {
|
|
212
|
+
warnings.push(`Directory "modules/${dir}" exists but is not listed in pack.yaml`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 3. Validate each module directory
|
|
217
|
+
for (const dirName of actualDirs) {
|
|
218
|
+
const moduleDir = join(modulesDir, dirName);
|
|
219
|
+
const moduleYamlPath = join(moduleDir, 'module.yaml');
|
|
220
|
+
|
|
221
|
+
if (!existsSync(moduleYamlPath)) {
|
|
222
|
+
errors.push(`modules/${dirName}: missing module.yaml`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let mod;
|
|
227
|
+
try {
|
|
228
|
+
mod = yaml.load(readFileSync(moduleYamlPath, 'utf-8'));
|
|
229
|
+
} catch (err) {
|
|
230
|
+
errors.push(`modules/${dirName}/module.yaml: parse error — ${err.message}`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const slug = mod.slug || dirName;
|
|
235
|
+
allPackSlugs.push(slug);
|
|
236
|
+
|
|
237
|
+
if (!mod.slug) warnings.push(`modules/${dirName}: missing "slug" field`);
|
|
238
|
+
if (!mod.title) errors.push(`modules/${dirName}: missing "title" field`);
|
|
239
|
+
|
|
240
|
+
// content.md must exist
|
|
241
|
+
if (!existsSync(join(moduleDir, 'content.md'))) {
|
|
242
|
+
errors.push(`modules/${dirName}: missing content.md`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Optional files — warn if referenced but missing (via capabilities detection)
|
|
246
|
+
for (const opt of ['walkthrough.md', 'exercises.md', 'quiz.md', 'quick-ref.md']) {
|
|
247
|
+
// We don't error if optional files are missing — they're optional.
|
|
248
|
+
// But we do check if referenced in visuals or similar.
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// XP values must be numbers
|
|
252
|
+
if (mod.xp && typeof mod.xp === 'object') {
|
|
253
|
+
for (const [key, val] of Object.entries(mod.xp)) {
|
|
254
|
+
if (typeof val !== 'number') {
|
|
255
|
+
errors.push(`modules/${dirName}: xp.${key} must be a number, got ${typeof val}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Triggers must be an array
|
|
261
|
+
if (mod.triggers !== undefined && !Array.isArray(mod.triggers)) {
|
|
262
|
+
errors.push(`modules/${dirName}: triggers must be an array`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Time values must be numbers
|
|
266
|
+
if (mod.time && typeof mod.time === 'object') {
|
|
267
|
+
for (const [key, val] of Object.entries(mod.time)) {
|
|
268
|
+
if (typeof val !== 'number') {
|
|
269
|
+
errors.push(`modules/${dirName}: time.${key} must be a number, got ${typeof val}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 4. Validate prerequisites
|
|
276
|
+
const validSlugs = new Set([...allPackSlugs, ...builtinSlugs]);
|
|
277
|
+
for (const dirName of actualDirs) {
|
|
278
|
+
const moduleYamlPath = join(modulesDir, dirName, 'module.yaml');
|
|
279
|
+
if (!existsSync(moduleYamlPath)) continue;
|
|
280
|
+
try {
|
|
281
|
+
const mod = yaml.load(readFileSync(moduleYamlPath, 'utf-8'));
|
|
282
|
+
const prereqs = mod.prerequisites || [];
|
|
283
|
+
for (const prereq of prereqs) {
|
|
284
|
+
if (!validSlugs.has(prereq)) {
|
|
285
|
+
warnings.push(`modules/${dirName}: prerequisite "${prereq}" not found in pack or built-in modules`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
// Already reported parse error above
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const valid = errors.length === 0;
|
|
294
|
+
return { valid, errors, warnings };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* ------------------------------------------------------------------ */
|
|
298
|
+
/* Preview a module locally */
|
|
299
|
+
/* ------------------------------------------------------------------ */
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Preview a module by copying/symlinking it to ~/.claude-teach/modules/local/_preview/.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} modulePath — path to a module directory (must contain module.yaml)
|
|
305
|
+
*/
|
|
306
|
+
export async function previewModule(modulePath) {
|
|
307
|
+
ensureHome();
|
|
308
|
+
|
|
309
|
+
const absPath = resolve(process.cwd(), modulePath);
|
|
310
|
+
|
|
311
|
+
if (!existsSync(join(absPath, 'module.yaml'))) {
|
|
312
|
+
throw new Error(`No module.yaml found at "${absPath}". Point to a module directory.`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const previewDir = join(LOCAL_MODULES, '_preview');
|
|
316
|
+
if (!existsSync(previewDir)) {
|
|
317
|
+
mkdirSync(previewDir, { recursive: true });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const moduleName = basename(absPath);
|
|
321
|
+
const dest = join(previewDir, moduleName);
|
|
322
|
+
|
|
323
|
+
// Remove previous preview of the same module
|
|
324
|
+
if (existsSync(dest)) {
|
|
325
|
+
rmSync(dest, { recursive: true, force: true });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Try symlink first, fall back to copy
|
|
329
|
+
try {
|
|
330
|
+
symlinkSync(absPath, dest, 'dir');
|
|
331
|
+
} catch {
|
|
332
|
+
// Symlink failed (e.g. Windows without admin), copy instead
|
|
333
|
+
copyDirRecursive(absPath, dest);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
console.log(chalk.green(`Previewing "${moduleName}" — available in the module registry.`));
|
|
337
|
+
console.log(` Run ${chalk.cyan('claude-teach list')} to verify.`);
|
|
338
|
+
console.log(` To stop previewing, delete ${dest}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Simple recursive directory copy (no external deps).
|
|
343
|
+
*/
|
|
344
|
+
function copyDirRecursive(src, dest) {
|
|
345
|
+
mkdirSync(dest, { recursive: true });
|
|
346
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
347
|
+
const srcPath = join(src, entry.name);
|
|
348
|
+
const destPath = join(dest, entry.name);
|
|
349
|
+
if (entry.isDirectory()) {
|
|
350
|
+
copyDirRecursive(srcPath, destPath);
|
|
351
|
+
} else {
|
|
352
|
+
writeFileSync(destPath, readFileSync(srcPath));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DEPRECATED — Bridge server has moved to @shaykec/bridge.
|
|
3
|
+
*
|
|
4
|
+
* This file is preserved for backward compatibility.
|
|
5
|
+
* The `serve` command in cli.js now uses the new bridge package.
|
|
6
|
+
* The `showInbox` function is re-exported from the new location.
|
|
7
|
+
*
|
|
8
|
+
* Migration:
|
|
9
|
+
* Old: import { startServer } from './bridge-server.js'
|
|
10
|
+
* New: import { startServer } from '@shaykec/bridge'
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
|
|
16
|
+
const INBOX_DIR = '.teach/inbox';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Start the bridge server — delegates to @shaykec/bridge.
|
|
20
|
+
* @deprecated Use `import { startServer } from '@shaykec/bridge'` instead.
|
|
21
|
+
*/
|
|
22
|
+
export async function startServer(port = 3456) {
|
|
23
|
+
const { startServer: start } = await import('@shaykec/bridge');
|
|
24
|
+
return start({ port });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Show inbox contents.
|
|
29
|
+
* Preserved here since the inbox command still references this file.
|
|
30
|
+
*/
|
|
31
|
+
export function showInbox() {
|
|
32
|
+
ensureInboxDir();
|
|
33
|
+
|
|
34
|
+
const files = readdirSync(INBOX_DIR).filter(f => f.endsWith('.json')).sort();
|
|
35
|
+
|
|
36
|
+
if (files.length === 0) {
|
|
37
|
+
console.log('Inbox is empty. Start the bridge server and capture content from your browser.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(`\n Inbox (${files.length} items)\n`);
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
try {
|
|
44
|
+
const data = JSON.parse(readFileSync(join(INBOX_DIR, file), 'utf-8'));
|
|
45
|
+
const title = data.title || 'Untitled';
|
|
46
|
+
const url = data.url || '';
|
|
47
|
+
console.log(` \u2022 ${title}`);
|
|
48
|
+
if (url) console.log(` ${url}`);
|
|
49
|
+
} catch {
|
|
50
|
+
console.log(` \u2022 ${file} (unreadable)`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
console.log();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ensureInboxDir() {
|
|
57
|
+
if (!existsSync(INBOX_DIR)) {
|
|
58
|
+
mkdirSync(INBOX_DIR, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
}
|