@shaykec/claude-teach 0.6.2 → 0.6.3
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/package.json +4 -4
- package/src/author.js +64 -1
- package/src/author.test.js +157 -2
- package/src/cli.js +146 -4
- package/src/marketplace.js +2 -1
- package/src/registry.js +37 -17
- package/src/registry.test.js +87 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shaykec/claude-teach",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Socratic AI teaching platform — learn anything through guided dialogue, visual canvas, and gamification",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.js",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
"commander": "^12.0.0",
|
|
16
16
|
"js-yaml": "^4.1.0",
|
|
17
17
|
"chalk": "^5.3.0",
|
|
18
|
-
"@shaykec/bridge": "0.4.
|
|
19
|
-
"@shaykec/shared": "0.1.
|
|
20
|
-
"@shaykec/plugin": "0.2.
|
|
18
|
+
"@shaykec/bridge": "0.4.4",
|
|
19
|
+
"@shaykec/shared": "0.1.2",
|
|
20
|
+
"@shaykec/plugin": "0.2.10",
|
|
21
21
|
"@shaykec/extension": "0.1.0"
|
|
22
22
|
},
|
|
23
23
|
"publishConfig": {
|
package/src/author.js
CHANGED
|
@@ -248,6 +248,10 @@ export async function validatePack(packPath, builtinSlugs = []) {
|
|
|
248
248
|
// But we do check if referenced in visuals or similar.
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
if (!existsSync(join(moduleDir, 'resources.md'))) {
|
|
252
|
+
warnings.push(`modules/${dirName}: missing resources.md — consider adding curated external links`);
|
|
253
|
+
}
|
|
254
|
+
|
|
251
255
|
// XP values must be numbers
|
|
252
256
|
if (mod.xp && typeof mod.xp === 'object') {
|
|
253
257
|
for (const [key, val] of Object.entries(mod.xp)) {
|
|
@@ -369,10 +373,69 @@ export async function previewModule(modulePath) {
|
|
|
369
373
|
console.log(` To stop previewing, delete ${dest}`);
|
|
370
374
|
}
|
|
371
375
|
|
|
376
|
+
/* ------------------------------------------------------------------ */
|
|
377
|
+
/* Validate a single module directory */
|
|
378
|
+
/* ------------------------------------------------------------------ */
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Validate a single module directory (used by import command).
|
|
382
|
+
* Returns { valid, slug, errors, warnings }.
|
|
383
|
+
*/
|
|
384
|
+
export function validateModuleDir(moduleDir) {
|
|
385
|
+
const errors = [];
|
|
386
|
+
const warnings = [];
|
|
387
|
+
|
|
388
|
+
const moduleYamlPath = join(moduleDir, 'module.yaml');
|
|
389
|
+
if (!existsSync(moduleYamlPath)) {
|
|
390
|
+
errors.push('Missing module.yaml');
|
|
391
|
+
return { valid: false, slug: null, errors, warnings };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let mod;
|
|
395
|
+
try {
|
|
396
|
+
mod = yaml.load(readFileSync(moduleYamlPath, 'utf-8'));
|
|
397
|
+
} catch (err) {
|
|
398
|
+
errors.push(`module.yaml parse error: ${err.message}`);
|
|
399
|
+
return { valid: false, slug: null, errors, warnings };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const slug = mod.slug || basename(moduleDir);
|
|
403
|
+
|
|
404
|
+
if (!mod.slug) warnings.push('Missing "slug" field');
|
|
405
|
+
if (!mod.title) errors.push('Missing "title" field');
|
|
406
|
+
if (!mod.description) warnings.push('Missing "description" field');
|
|
407
|
+
|
|
408
|
+
if (!existsSync(join(moduleDir, 'content.md'))) {
|
|
409
|
+
errors.push('Missing content.md');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (mod.xp && typeof mod.xp === 'object') {
|
|
413
|
+
for (const [key, val] of Object.entries(mod.xp)) {
|
|
414
|
+
if (typeof val !== 'number') {
|
|
415
|
+
errors.push(`xp.${key} must be a number, got ${typeof val}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (mod.triggers !== undefined && !Array.isArray(mod.triggers)) {
|
|
421
|
+
errors.push('triggers must be an array');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (mod.time && typeof mod.time === 'object') {
|
|
425
|
+
for (const [key, val] of Object.entries(mod.time)) {
|
|
426
|
+
if (typeof val !== 'number') {
|
|
427
|
+
errors.push(`time.${key} must be a number, got ${typeof val}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return { valid: errors.length === 0, slug, errors, warnings };
|
|
433
|
+
}
|
|
434
|
+
|
|
372
435
|
/**
|
|
373
436
|
* Simple recursive directory copy (no external deps).
|
|
374
437
|
*/
|
|
375
|
-
function copyDirRecursive(src, dest) {
|
|
438
|
+
export function copyDirRecursive(src, dest) {
|
|
376
439
|
mkdirSync(dest, { recursive: true });
|
|
377
440
|
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
378
441
|
const srcPath = join(src, entry.name);
|
package/src/author.test.js
CHANGED
|
@@ -1,10 +1,165 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { validatePack } from './author.js';
|
|
2
|
+
import { validatePack, validateModuleDir, copyDirRecursive } from './author.js';
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import yaml from 'js-yaml';
|
|
7
7
|
|
|
8
|
+
/* ================================================================== */
|
|
9
|
+
/* validateModuleDir */
|
|
10
|
+
/* ================================================================== */
|
|
11
|
+
|
|
12
|
+
describe('validateModuleDir', () => {
|
|
13
|
+
let tmpDir;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teach-validate-'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function createModule(meta = {}, files = {}) {
|
|
24
|
+
const defaults = {
|
|
25
|
+
slug: 'test-mod',
|
|
26
|
+
title: 'Test Module',
|
|
27
|
+
description: 'A test module',
|
|
28
|
+
category: 'test',
|
|
29
|
+
difficulty: 'beginner',
|
|
30
|
+
tags: ['test'],
|
|
31
|
+
xp: { read: 10, walkthrough: 20 },
|
|
32
|
+
time: { read: 5, guided: 15 },
|
|
33
|
+
triggers: ['how do I test?'],
|
|
34
|
+
};
|
|
35
|
+
const merged = { ...defaults, ...meta };
|
|
36
|
+
fs.writeFileSync(path.join(tmpDir, 'module.yaml'), yaml.dump(merged), 'utf-8');
|
|
37
|
+
fs.writeFileSync(path.join(tmpDir, 'content.md'), files.content || '# Test\n\nContent.\n', 'utf-8');
|
|
38
|
+
|
|
39
|
+
if (files.walkthrough) fs.writeFileSync(path.join(tmpDir, 'walkthrough.md'), files.walkthrough, 'utf-8');
|
|
40
|
+
if (files.quiz) fs.writeFileSync(path.join(tmpDir, 'quiz.md'), files.quiz, 'utf-8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
it('should pass for a valid module directory', () => {
|
|
44
|
+
createModule();
|
|
45
|
+
const result = validateModuleDir(tmpDir);
|
|
46
|
+
expect(result.valid).toBe(true);
|
|
47
|
+
expect(result.slug).toBe('test-mod');
|
|
48
|
+
expect(result.errors).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should fail when module.yaml is missing', () => {
|
|
52
|
+
const result = validateModuleDir(tmpDir);
|
|
53
|
+
expect(result.valid).toBe(false);
|
|
54
|
+
expect(result.errors).toContain('Missing module.yaml');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should fail when content.md is missing', () => {
|
|
58
|
+
fs.writeFileSync(path.join(tmpDir, 'module.yaml'), yaml.dump({ slug: 'test', title: 'Test' }), 'utf-8');
|
|
59
|
+
const result = validateModuleDir(tmpDir);
|
|
60
|
+
expect(result.valid).toBe(false);
|
|
61
|
+
expect(result.errors).toContain('Missing content.md');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should fail when title is missing', () => {
|
|
65
|
+
createModule({ title: undefined });
|
|
66
|
+
const raw = yaml.load(fs.readFileSync(path.join(tmpDir, 'module.yaml'), 'utf-8'));
|
|
67
|
+
delete raw.title;
|
|
68
|
+
fs.writeFileSync(path.join(tmpDir, 'module.yaml'), yaml.dump(raw), 'utf-8');
|
|
69
|
+
|
|
70
|
+
const result = validateModuleDir(tmpDir);
|
|
71
|
+
expect(result.valid).toBe(false);
|
|
72
|
+
expect(result.errors.some(e => e.includes('title'))).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should warn when slug is missing (uses directory name fallback)', () => {
|
|
76
|
+
createModule({ slug: undefined });
|
|
77
|
+
const raw = yaml.load(fs.readFileSync(path.join(tmpDir, 'module.yaml'), 'utf-8'));
|
|
78
|
+
delete raw.slug;
|
|
79
|
+
fs.writeFileSync(path.join(tmpDir, 'module.yaml'), yaml.dump(raw), 'utf-8');
|
|
80
|
+
|
|
81
|
+
const result = validateModuleDir(tmpDir);
|
|
82
|
+
expect(result.valid).toBe(true);
|
|
83
|
+
expect(result.warnings.some(w => w.includes('slug'))).toBe(true);
|
|
84
|
+
expect(result.slug).toBe(path.basename(tmpDir));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should fail when xp values are not numbers', () => {
|
|
88
|
+
createModule({ xp: { read: 'ten' } });
|
|
89
|
+
const result = validateModuleDir(tmpDir);
|
|
90
|
+
expect(result.valid).toBe(false);
|
|
91
|
+
expect(result.errors.some(e => e.includes('xp.read'))).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should fail when triggers is not an array', () => {
|
|
95
|
+
createModule({ triggers: 'not an array' });
|
|
96
|
+
const result = validateModuleDir(tmpDir);
|
|
97
|
+
expect(result.valid).toBe(false);
|
|
98
|
+
expect(result.errors.some(e => e.includes('triggers'))).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should fail when time values are not numbers', () => {
|
|
102
|
+
createModule({ time: { read: 'five' } });
|
|
103
|
+
const result = validateModuleDir(tmpDir);
|
|
104
|
+
expect(result.valid).toBe(false);
|
|
105
|
+
expect(result.errors.some(e => e.includes('time.read'))).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/* ================================================================== */
|
|
110
|
+
/* copyDirRecursive */
|
|
111
|
+
/* ================================================================== */
|
|
112
|
+
|
|
113
|
+
describe('copyDirRecursive', () => {
|
|
114
|
+
let srcDir;
|
|
115
|
+
let destDir;
|
|
116
|
+
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teach-copy-src-'));
|
|
119
|
+
destDir = path.join(os.tmpdir(), `teach-copy-dest-${Date.now()}`);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
fs.rmSync(srcDir, { recursive: true, force: true });
|
|
124
|
+
if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true, force: true });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should copy all files from source to destination', () => {
|
|
128
|
+
fs.writeFileSync(path.join(srcDir, 'module.yaml'), 'slug: test\n', 'utf-8');
|
|
129
|
+
fs.writeFileSync(path.join(srcDir, 'content.md'), '# Content\n', 'utf-8');
|
|
130
|
+
|
|
131
|
+
copyDirRecursive(srcDir, destDir);
|
|
132
|
+
|
|
133
|
+
expect(fs.existsSync(path.join(destDir, 'module.yaml'))).toBe(true);
|
|
134
|
+
expect(fs.existsSync(path.join(destDir, 'content.md'))).toBe(true);
|
|
135
|
+
expect(fs.readFileSync(path.join(destDir, 'module.yaml'), 'utf-8')).toBe('slug: test\n');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should copy nested directories recursively', () => {
|
|
139
|
+
fs.mkdirSync(path.join(srcDir, 'sub'), { recursive: true });
|
|
140
|
+
fs.writeFileSync(path.join(srcDir, 'top.txt'), 'top', 'utf-8');
|
|
141
|
+
fs.writeFileSync(path.join(srcDir, 'sub', 'nested.txt'), 'nested', 'utf-8');
|
|
142
|
+
|
|
143
|
+
copyDirRecursive(srcDir, destDir);
|
|
144
|
+
|
|
145
|
+
expect(fs.existsSync(path.join(destDir, 'top.txt'))).toBe(true);
|
|
146
|
+
expect(fs.existsSync(path.join(destDir, 'sub', 'nested.txt'))).toBe(true);
|
|
147
|
+
expect(fs.readFileSync(path.join(destDir, 'sub', 'nested.txt'), 'utf-8')).toBe('nested');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should create destination directory if it does not exist', () => {
|
|
151
|
+
fs.writeFileSync(path.join(srcDir, 'file.txt'), 'data', 'utf-8');
|
|
152
|
+
|
|
153
|
+
expect(fs.existsSync(destDir)).toBe(false);
|
|
154
|
+
copyDirRecursive(srcDir, destDir);
|
|
155
|
+
expect(fs.existsSync(destDir)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/* ================================================================== */
|
|
160
|
+
/* validatePack — media validation */
|
|
161
|
+
/* ================================================================== */
|
|
162
|
+
|
|
8
163
|
describe('validatePack — media validation', () => {
|
|
9
164
|
let tmpDir;
|
|
10
165
|
|
|
@@ -71,7 +226,7 @@ describe('validatePack — media validation', () => {
|
|
|
71
226
|
expect(result.valid).toBe(true);
|
|
72
227
|
expect(result.errors).toHaveLength(0);
|
|
73
228
|
// No warnings for properly filled media entries
|
|
74
|
-
const mediaWarnings = result.warnings.filter(w => w.includes('media'));
|
|
229
|
+
const mediaWarnings = result.warnings.filter(w => w.includes('media['));
|
|
75
230
|
expect(mediaWarnings).toHaveLength(0);
|
|
76
231
|
});
|
|
77
232
|
|
package/src/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { Command } from 'commander';
|
|
4
|
-
import { readFileSync, existsSync } from 'fs';
|
|
5
|
-
import { resolve, join, dirname } from 'path';
|
|
4
|
+
import { readFileSync, existsSync, mkdirSync } from 'fs';
|
|
5
|
+
import { resolve, join, basename, dirname } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { exec, spawn, execFileSync as execFileSyncChild } from 'child_process';
|
|
8
8
|
import { createRequire } from 'module';
|
|
@@ -74,6 +74,20 @@ function openExtensionInstall(extensionDir) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Kill any process listening on a given port. Returns true if something was killed. */
|
|
78
|
+
function killPort(port) {
|
|
79
|
+
try {
|
|
80
|
+
const pids = execFileSyncChild('lsof', ['-ti', `:${port}`], { encoding: 'utf-8' }).trim();
|
|
81
|
+
if (pids) {
|
|
82
|
+
for (const pid of pids.split('\n')) {
|
|
83
|
+
try { process.kill(parseInt(pid, 10), 'SIGTERM'); } catch {}
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
} catch {}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
/** Open a URL in the default browser (cross-platform). */
|
|
78
92
|
function openUrl(url) {
|
|
79
93
|
const platform = process.platform;
|
|
@@ -102,6 +116,9 @@ Examples:
|
|
|
102
116
|
$ claude-teach stats View your XP and belt progress
|
|
103
117
|
$ claude-teach install user/my-pack Install a module pack from GitHub
|
|
104
118
|
$ claude-teach search docker Search registries for packs
|
|
119
|
+
$ claude-teach learn "google sheets" Generate a module on any topic
|
|
120
|
+
$ claude-teach export git --to ~/Desktop Export a module for sharing
|
|
121
|
+
$ claude-teach import ~/Desktop/my-module Import a shared module
|
|
105
122
|
$ claude-teach author init my-pack Scaffold a new module pack
|
|
106
123
|
$ claude-teach author validate ./my-pack Validate a pack
|
|
107
124
|
$ claude-teach start Launch Claude Code with teaching skills
|
|
@@ -122,7 +139,7 @@ program
|
|
|
122
139
|
.option('-c, --category <category>', 'Filter by category')
|
|
123
140
|
.option('-d, --difficulty <level>', 'Filter by difficulty (beginner|intermediate|advanced)')
|
|
124
141
|
.option('-t, --tag <tag>', 'Filter by tag')
|
|
125
|
-
.option('-s, --source <source>', 'Filter by source (built-in|pack:<name>|local)')
|
|
142
|
+
.option('-s, --source <source>', 'Filter by source (built-in|pack:<name>|local|generated)')
|
|
126
143
|
.action(async (opts) => {
|
|
127
144
|
const registry = await loadRegistry(ROOT);
|
|
128
145
|
let modules = registry.modules;
|
|
@@ -257,6 +274,122 @@ program
|
|
|
257
274
|
console.log(suggestion);
|
|
258
275
|
});
|
|
259
276
|
|
|
277
|
+
// --- learn (free-style) ---
|
|
278
|
+
program
|
|
279
|
+
.command('learn <topic>')
|
|
280
|
+
.description('Generate an AI learning module on any topic (saved to ~/.claude-teach/modules/local/_generated/)')
|
|
281
|
+
.action(async (topic) => {
|
|
282
|
+
const { GENERATED_MODULES, ensureHome } = await import('./marketplace.js');
|
|
283
|
+
ensureHome();
|
|
284
|
+
|
|
285
|
+
const slug = topic
|
|
286
|
+
.toLowerCase()
|
|
287
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
288
|
+
.replace(/^-+|-+$/g, '');
|
|
289
|
+
|
|
290
|
+
const moduleDir = join(GENERATED_MODULES, slug);
|
|
291
|
+
if (!existsSync(GENERATED_MODULES)) {
|
|
292
|
+
mkdirSync(GENERATED_MODULES, { recursive: true });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (existsSync(moduleDir)) {
|
|
296
|
+
console.log(chalk.yellow(`Module "${slug}" already exists at ${moduleDir}`));
|
|
297
|
+
console.log(` Use ${chalk.cyan(`/teach:learn ${slug} --regenerate`)} to recreate it.`);
|
|
298
|
+
console.log(` Or ${chalk.cyan(`/teach ${slug}`)} to start learning.`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
mkdirSync(moduleDir, { recursive: true });
|
|
303
|
+
console.log(chalk.green(`Created module directory: ${moduleDir}`));
|
|
304
|
+
console.log(`\n Use ${chalk.cyan(`/teach:learn ${topic}`)} in Claude Code to generate content and start learning.`);
|
|
305
|
+
console.log(` Or author the module files manually in: ${chalk.dim(moduleDir)}`);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// =====================================================================
|
|
309
|
+
// Sharing commands — export and import modules
|
|
310
|
+
// =====================================================================
|
|
311
|
+
|
|
312
|
+
// --- export ---
|
|
313
|
+
program
|
|
314
|
+
.command('export <slug>')
|
|
315
|
+
.description('Export a module directory (copy to current directory or --to <path>)')
|
|
316
|
+
.option('--to <path>', 'Target directory', '.')
|
|
317
|
+
.action(async (slug, opts) => {
|
|
318
|
+
const registry = await loadRegistry(ROOT);
|
|
319
|
+
const mod = getModule(registry, slug);
|
|
320
|
+
if (!mod) {
|
|
321
|
+
console.error(chalk.red(`Module "${slug}" not found. Run "claude-teach list" to see available modules.`));
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const { copyDirRecursive } = await import('./author.js');
|
|
326
|
+
const dest = resolve(process.cwd(), opts.to, slug);
|
|
327
|
+
|
|
328
|
+
if (existsSync(dest)) {
|
|
329
|
+
console.error(chalk.red(`Destination "${dest}" already exists. Remove it first or choose a different path.`));
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
copyDirRecursive(mod._path, dest);
|
|
334
|
+
console.log(chalk.green(`Exported "${slug}" to ${dest}`));
|
|
335
|
+
console.log(` Source: ${chalk.dim(mod.source)}`);
|
|
336
|
+
console.log(` Files: module.yaml, content.md, ${mod.capabilities.filter(c => c !== 'content').join('.md, ')}.md`);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// --- import ---
|
|
340
|
+
program
|
|
341
|
+
.command('import <path>')
|
|
342
|
+
.description('Import a module directory into local modules')
|
|
343
|
+
.action(async (modulePath) => {
|
|
344
|
+
const absPath = resolve(process.cwd(), modulePath);
|
|
345
|
+
|
|
346
|
+
if (!existsSync(absPath)) {
|
|
347
|
+
console.error(chalk.red(`Path "${absPath}" does not exist.`));
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const { validateModuleDir, copyDirRecursive } = await import('./author.js');
|
|
352
|
+
const result = validateModuleDir(absPath);
|
|
353
|
+
|
|
354
|
+
if (!result.valid) {
|
|
355
|
+
console.error(chalk.red(`\n Validation errors:\n`));
|
|
356
|
+
for (const err of result.errors) {
|
|
357
|
+
console.error(` ${chalk.red('✗')} ${err}`);
|
|
358
|
+
}
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (result.warnings.length > 0) {
|
|
363
|
+
for (const warn of result.warnings) {
|
|
364
|
+
console.log(` ${chalk.yellow('!')} ${warn}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const { LOCAL_MODULES, ensureHome } = await import('./marketplace.js');
|
|
369
|
+
ensureHome();
|
|
370
|
+
|
|
371
|
+
const slug = result.slug;
|
|
372
|
+
const dest = join(LOCAL_MODULES, slug);
|
|
373
|
+
|
|
374
|
+
const registry = await loadRegistry(ROOT);
|
|
375
|
+
const existing = getModule(registry, slug);
|
|
376
|
+
if (existing) {
|
|
377
|
+
console.warn(chalk.yellow(` Warning: slug "${slug}" collides with existing module (${existing.source}).`));
|
|
378
|
+
console.warn(` The imported module will be placed in local/ and may not override the existing one.`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (existsSync(dest)) {
|
|
382
|
+
console.error(chalk.red(`Local module "${slug}" already exists at ${dest}. Remove it first.`));
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
copyDirRecursive(absPath, dest);
|
|
387
|
+
|
|
388
|
+
await buildRegistry(ROOT);
|
|
389
|
+
console.log(chalk.green(`Imported "${slug}" to ${dest}`));
|
|
390
|
+
console.log(` Run ${chalk.cyan(`claude-teach get ${slug}`)} or ${chalk.cyan(`/teach ${slug}`)} to start learning.`);
|
|
391
|
+
});
|
|
392
|
+
|
|
260
393
|
// =====================================================================
|
|
261
394
|
// Visual Ecosystem commands
|
|
262
395
|
// =====================================================================
|
|
@@ -267,10 +400,15 @@ program
|
|
|
267
400
|
.description('Start the bridge server for visual canvas — diagrams, quizzes, dashboard (default port: 3456)')
|
|
268
401
|
.option('-p, --port <port>', 'Port number', '3456')
|
|
269
402
|
.action(async (opts) => {
|
|
403
|
+
const port = parseInt(opts.port, 10);
|
|
404
|
+
if (killPort(port)) {
|
|
405
|
+
console.log(chalk.dim(` Killed old process on port ${port}`));
|
|
406
|
+
await new Promise(r => setTimeout(r, 500));
|
|
407
|
+
}
|
|
270
408
|
const { startServer } = await import('@shaykec/bridge');
|
|
271
409
|
const progress = loadProgress(ROOT);
|
|
272
410
|
startServer({
|
|
273
|
-
port
|
|
411
|
+
port,
|
|
274
412
|
progressProvider: {
|
|
275
413
|
getProgress: () => loadProgress(ROOT),
|
|
276
414
|
},
|
|
@@ -325,6 +463,10 @@ program
|
|
|
325
463
|
try {
|
|
326
464
|
const { startServer } = await import('@shaykec/bridge');
|
|
327
465
|
const port = parseInt(opts.port, 10);
|
|
466
|
+
if (killPort(port)) {
|
|
467
|
+
console.log(chalk.dim(` Killed old bridge on port ${port}`));
|
|
468
|
+
await new Promise(r => setTimeout(r, 500));
|
|
469
|
+
}
|
|
328
470
|
server = await new Promise((resolve, reject) => {
|
|
329
471
|
const srv = startServer({
|
|
330
472
|
port,
|
package/src/marketplace.js
CHANGED
|
@@ -19,6 +19,7 @@ import chalk from 'chalk';
|
|
|
19
19
|
const TEACH_HOME = join(homedir(), '.claude-teach');
|
|
20
20
|
const MODULES_HOME = join(TEACH_HOME, 'modules');
|
|
21
21
|
const LOCAL_MODULES = join(MODULES_HOME, 'local');
|
|
22
|
+
const GENERATED_MODULES = join(LOCAL_MODULES, '_generated');
|
|
22
23
|
const CONFIG_PATH = join(TEACH_HOME, 'config.yaml');
|
|
23
24
|
|
|
24
25
|
const DEFAULT_REGISTRY = {
|
|
@@ -346,4 +347,4 @@ export function removeRegistry(nameOrUrl) {
|
|
|
346
347
|
/* Exports for registry.js integration */
|
|
347
348
|
/* ------------------------------------------------------------------ */
|
|
348
349
|
|
|
349
|
-
export { TEACH_HOME, MODULES_HOME, LOCAL_MODULES };
|
|
350
|
+
export { TEACH_HOME, MODULES_HOME, LOCAL_MODULES, GENERATED_MODULES };
|
package/src/registry.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Module registry — discovers module.yaml files from
|
|
2
|
+
* Module registry — discovers module.yaml files from four sources and builds
|
|
3
3
|
* a unified searchable index.
|
|
4
4
|
*
|
|
5
|
-
* Sources:
|
|
5
|
+
* Sources (priority order — highest first):
|
|
6
|
+
* 0. Generated — ~/.claude-teach/modules/local/_generated/ (AI-generated, fork-on-write)
|
|
6
7
|
* 1. Built-in — packages/modules/ (ships with ClaudeTeach)
|
|
7
8
|
* 2. Packs — ~/.claude-teach/modules/<pack>/modules/ (installed via git)
|
|
8
9
|
* 3. Local — ~/.claude-teach/modules/local/ (user-authored)
|
|
10
|
+
*
|
|
11
|
+
* Generated modules override any source on slug collision (fork-on-write priority).
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
12
15
|
import { join, resolve } from 'path';
|
|
13
16
|
import yaml from 'js-yaml';
|
|
14
17
|
import chalk from 'chalk';
|
|
15
|
-
import { MODULES_HOME, LOCAL_MODULES, ensureHome } from './marketplace.js';
|
|
18
|
+
import { MODULES_HOME, LOCAL_MODULES, GENERATED_MODULES, ensureHome } from './marketplace.js';
|
|
16
19
|
|
|
17
20
|
const BUILTIN_MODULES_DIR = 'packages/modules';
|
|
18
21
|
const REGISTRY_FILE = 'registry.yaml';
|
|
@@ -83,23 +86,35 @@ function toRegistryEntry({ meta, modulePath, dirName }, source) {
|
|
|
83
86
|
/* ------------------------------------------------------------------ */
|
|
84
87
|
|
|
85
88
|
/**
|
|
86
|
-
* Scan all
|
|
87
|
-
*
|
|
89
|
+
* Scan all four module sources and build a merged registry.
|
|
90
|
+
* Generated modules (fork-on-write) have highest priority and override
|
|
91
|
+
* any other source on slug collision. All other sources use first-wins.
|
|
88
92
|
*/
|
|
89
93
|
export async function buildRegistry(rootDir) {
|
|
90
|
-
const slugMap = new Map(); // slug -> entry
|
|
94
|
+
const slugMap = new Map(); // slug -> entry
|
|
91
95
|
const allModules = [];
|
|
92
96
|
|
|
97
|
+
// --- 0. AI-generated modules (highest priority — fork-on-write overrides) ---
|
|
98
|
+
ensureHome();
|
|
99
|
+
if (existsSync(GENERATED_MODULES)) {
|
|
100
|
+
for (const item of scanModuleDir(GENERATED_MODULES)) {
|
|
101
|
+
const entry = toRegistryEntry(item, 'generated');
|
|
102
|
+
if (item.meta.forkedFrom) entry.forkedFrom = item.meta.forkedFrom;
|
|
103
|
+
slugMap.set(entry.slug, entry);
|
|
104
|
+
allModules.push(entry);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
93
108
|
// --- 1. Built-in modules ---
|
|
94
109
|
const builtinDir = resolve(rootDir, BUILTIN_MODULES_DIR);
|
|
95
110
|
for (const item of scanModuleDir(builtinDir)) {
|
|
96
111
|
const entry = toRegistryEntry(item, 'built-in');
|
|
112
|
+
if (slugMap.has(entry.slug)) continue; // generated fork takes priority
|
|
97
113
|
slugMap.set(entry.slug, entry);
|
|
98
114
|
allModules.push(entry);
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
// --- 2. Installed packs ---
|
|
102
|
-
ensureHome();
|
|
103
118
|
if (existsSync(MODULES_HOME)) {
|
|
104
119
|
for (const packDir of readdirSync(MODULES_HOME)) {
|
|
105
120
|
if (packDir === 'local') continue;
|
|
@@ -107,9 +122,11 @@ export async function buildRegistry(rootDir) {
|
|
|
107
122
|
for (const item of scanModuleDir(packModulesDir)) {
|
|
108
123
|
const entry = toRegistryEntry(item, `pack:${packDir}`);
|
|
109
124
|
if (slugMap.has(entry.slug)) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
125
|
+
if (slugMap.get(entry.slug).source !== 'generated') {
|
|
126
|
+
console.warn(
|
|
127
|
+
chalk.yellow(` Warning: slug "${entry.slug}" from pack "${packDir}" collides with ${slugMap.get(entry.slug).source} — skipping.`)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
113
130
|
continue;
|
|
114
131
|
}
|
|
115
132
|
slugMap.set(entry.slug, entry);
|
|
@@ -120,13 +137,14 @@ export async function buildRegistry(rootDir) {
|
|
|
120
137
|
|
|
121
138
|
// --- 3. Local custom modules ---
|
|
122
139
|
if (existsSync(LOCAL_MODULES)) {
|
|
123
|
-
// Scan top-level directories inside local/
|
|
124
140
|
for (const item of scanModuleDir(LOCAL_MODULES)) {
|
|
125
141
|
const entry = toRegistryEntry(item, 'local');
|
|
126
142
|
if (slugMap.has(entry.slug)) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
143
|
+
if (slugMap.get(entry.slug).source !== 'generated') {
|
|
144
|
+
console.warn(
|
|
145
|
+
chalk.yellow(` Warning: local slug "${entry.slug}" collides with ${slugMap.get(entry.slug).source} — skipping.`)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
130
148
|
continue;
|
|
131
149
|
}
|
|
132
150
|
slugMap.set(entry.slug, entry);
|
|
@@ -139,9 +157,11 @@ export async function buildRegistry(rootDir) {
|
|
|
139
157
|
for (const item of scanModuleDir(previewDir)) {
|
|
140
158
|
const entry = toRegistryEntry(item, 'local');
|
|
141
159
|
if (slugMap.has(entry.slug)) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
if (slugMap.get(entry.slug).source !== 'generated') {
|
|
161
|
+
console.warn(
|
|
162
|
+
chalk.yellow(` Warning: preview slug "${entry.slug}" collides with ${slugMap.get(entry.slug).source} — skipping.`)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
145
165
|
continue;
|
|
146
166
|
}
|
|
147
167
|
slugMap.set(entry.slug, entry);
|
package/src/registry.test.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { getModule } from './registry.js';
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
@@ -101,7 +101,7 @@ describe('Registry entry structure', () => {
|
|
|
101
101
|
|
|
102
102
|
it('each entry should have a source field', () => {
|
|
103
103
|
expect(typeof sampleEntry.source).toBe('string');
|
|
104
|
-
expect(['built-in', 'local'].includes(sampleEntry.source) || sampleEntry.source.startsWith('pack:')).toBe(true);
|
|
104
|
+
expect(['built-in', 'local', 'generated'].includes(sampleEntry.source) || sampleEntry.source.startsWith('pack:')).toBe(true);
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
it('each entry should have a capabilities array', () => {
|
|
@@ -362,3 +362,88 @@ describe('buildRegistry (filesystem)', () => {
|
|
|
362
362
|
expect(slugs).not.toContain('no-yaml');
|
|
363
363
|
});
|
|
364
364
|
});
|
|
365
|
+
|
|
366
|
+
/* ================================================================== */
|
|
367
|
+
/* Generated modules (_generated/ directory) */
|
|
368
|
+
/* Uses the real GENERATED_MODULES path with unique test slugs. */
|
|
369
|
+
/* ================================================================== */
|
|
370
|
+
|
|
371
|
+
describe('buildRegistry — generated modules', () => {
|
|
372
|
+
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..');
|
|
373
|
+
const testSlugs = [];
|
|
374
|
+
let genDir;
|
|
375
|
+
|
|
376
|
+
beforeEach(async () => {
|
|
377
|
+
const { GENERATED_MODULES, ensureHome } = await import('./marketplace.js');
|
|
378
|
+
ensureHome();
|
|
379
|
+
genDir = GENERATED_MODULES;
|
|
380
|
+
if (!fs.existsSync(genDir)) fs.mkdirSync(genDir, { recursive: true });
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
afterEach(() => {
|
|
384
|
+
for (const slug of testSlugs) {
|
|
385
|
+
const dir = path.join(genDir, slug);
|
|
386
|
+
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
387
|
+
}
|
|
388
|
+
testSlugs.length = 0;
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
function createGeneratedModule(slug, meta = {}) {
|
|
392
|
+
testSlugs.push(slug);
|
|
393
|
+
const moduleDir = path.join(genDir, slug);
|
|
394
|
+
fs.mkdirSync(moduleDir, { recursive: true });
|
|
395
|
+
const defaults = {
|
|
396
|
+
slug, title: `Generated ${slug}`, description: `AI-generated ${slug}`,
|
|
397
|
+
category: 'test', difficulty: 'beginner', tags: [],
|
|
398
|
+
xp: { read: 10 }, time: { read: 5 }, prerequisites: [], related: [], triggers: [],
|
|
399
|
+
};
|
|
400
|
+
fs.writeFileSync(path.join(moduleDir, 'module.yaml'), yaml.dump({ ...defaults, ...meta }), 'utf-8');
|
|
401
|
+
fs.writeFileSync(path.join(moduleDir, 'content.md'), `# ${slug}\n`, 'utf-8');
|
|
402
|
+
return moduleDir;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
it('should discover generated modules with source "generated"', async () => {
|
|
406
|
+
createGeneratedModule('_test-gen-discover');
|
|
407
|
+
|
|
408
|
+
const { buildRegistry } = await import('./registry.js');
|
|
409
|
+
const registry = await buildRegistry(ROOT);
|
|
410
|
+
|
|
411
|
+
const mod = registry.modules.find(m => m.slug === '_test-gen-discover');
|
|
412
|
+
expect(mod).toBeDefined();
|
|
413
|
+
expect(mod.source).toBe('generated');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('generated modules should override built-in modules with same slug', async () => {
|
|
417
|
+
createGeneratedModule('git', { title: 'Git Customized', description: 'Forked version', forkedFrom: 'built-in' });
|
|
418
|
+
|
|
419
|
+
const { buildRegistry } = await import('./registry.js');
|
|
420
|
+
const registry = await buildRegistry(ROOT);
|
|
421
|
+
|
|
422
|
+
const gitModules = registry.modules.filter(m => m.slug === 'git');
|
|
423
|
+
expect(gitModules).toHaveLength(1);
|
|
424
|
+
expect(gitModules[0].source).toBe('generated');
|
|
425
|
+
expect(gitModules[0].title).toBe('Git Customized');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should preserve forkedFrom field from module.yaml', async () => {
|
|
429
|
+
createGeneratedModule('_test-gen-forked', { forkedFrom: 'built-in' });
|
|
430
|
+
|
|
431
|
+
const { buildRegistry } = await import('./registry.js');
|
|
432
|
+
const registry = await buildRegistry(ROOT);
|
|
433
|
+
|
|
434
|
+
const mod = registry.modules.find(m => m.slug === '_test-gen-forked');
|
|
435
|
+
expect(mod).toBeDefined();
|
|
436
|
+
expect(mod.forkedFrom).toBe('built-in');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('generated and built-in modules should coexist when slugs differ', async () => {
|
|
440
|
+
createGeneratedModule('_test-gen-coexist');
|
|
441
|
+
|
|
442
|
+
const { buildRegistry } = await import('./registry.js');
|
|
443
|
+
const registry = await buildRegistry(ROOT);
|
|
444
|
+
|
|
445
|
+
const slugs = registry.modules.map(m => m.slug);
|
|
446
|
+
expect(slugs).toContain('git'); // built-in
|
|
447
|
+
expect(slugs).toContain('_test-gen-coexist'); // generated
|
|
448
|
+
});
|
|
449
|
+
});
|