@shaykec/claude-teach 0.6.2 → 0.6.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaykec/claude-teach",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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.0",
19
- "@shaykec/shared": "0.1.0",
20
- "@shaykec/plugin": "0.2.1",
18
+ "@shaykec/bridge": "0.4.5",
19
+ "@shaykec/shared": "0.1.2",
20
+ "@shaykec/plugin": "0.2.11",
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);
@@ -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: parseInt(opts.port, 10),
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,
@@ -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 three sources and builds
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 three module sources and build a merged registry.
87
- * Built-in modules win on slug collisions.
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 (first-seen wins)
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
- console.warn(
111
- chalk.yellow(` Warning: slug "${entry.slug}" from pack "${packDir}" collides with ${slugMap.get(entry.slug).source} — skipping.`)
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
- console.warn(
128
- chalk.yellow(` Warning: local slug "${entry.slug}" collides with ${slugMap.get(entry.slug).source} — skipping.`)
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
- console.warn(
143
- chalk.yellow(` Warning: preview slug "${entry.slug}" collides with ${slugMap.get(entry.slug).source} — skipping.`)
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);
@@ -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
+ });