@shaykec/claude-teach 0.6.6 → 0.6.8

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.6",
3
+ "version": "0.6.8",
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.7",
18
+ "@shaykec/bridge": "0.4.9",
19
19
  "@shaykec/shared": "0.1.3",
20
- "@shaykec/plugin": "0.2.11",
20
+ "@shaykec/plugin": "0.2.12",
21
21
  "@shaykec/extension": "0.1.0"
22
22
  },
23
23
  "publishConfig": {
@@ -674,7 +674,7 @@ describe('CLI E2E: Setup command', () => {
674
674
  const { stdout, exitCode } = runCli(['start', '--help']);
675
675
  expect(exitCode).toBe(0);
676
676
  const clean = stripAnsi(stdout);
677
- expect(clean).toContain('Launch Claude Code');
677
+ expect(clean).toContain('Launch ClaudeTeach');
678
678
  expect(clean).toContain('--no-server');
679
679
  expect(clean).toContain('--port');
680
680
  });
@@ -881,7 +881,7 @@ describe('CLI E2E: Subcommand help texts', () => {
881
881
  { args: ['level-up', '--help'], contains: 'belt roadmap' },
882
882
  { args: ['serve', '--help'], contains: 'bridge server' },
883
883
  { args: ['inbox', '--help'], contains: 'captured from the browser' },
884
- { args: ['start', '--help'], contains: 'Launch Claude Code' },
884
+ { args: ['start', '--help'], contains: 'Launch ClaudeTeach' },
885
885
  { args: ['setup', '--help'], contains: 'Check environment' },
886
886
  { args: ['registry:build', '--help'], contains: 'Rebuild the module registry' },
887
887
  { args: ['install', '--help'], contains: 'Install a module pack' },
@@ -916,7 +916,7 @@ describe('CLI E2E: npx comprehensive', () => {
916
916
  it('npx start --help shows launch description', () => {
917
917
  const { stdout, exitCode } = runNpx(['start', '--help'], { timeout: 60000 });
918
918
  expect(exitCode).toBe(0);
919
- expect(stripAnsi(stdout)).toContain('Launch Claude Code');
919
+ expect(stripAnsi(stdout)).toContain('Launch ClaudeTeach');
920
920
  });
921
921
 
922
922
  it('npx setup --help shows setup options', () => {
package/src/cli.js CHANGED
@@ -435,7 +435,8 @@ program
435
435
  // --- start ---
436
436
  program
437
437
  .command('start')
438
- .description('Launch Claude Code with ClaudeTeach starts bridge server + loads teaching skills')
438
+ .description('Launch ClaudeTeach browser chat by default, or --terminal for CLI mode')
439
+ .option('--terminal', 'Use terminal CLI mode instead of browser chat')
439
440
  .option('--no-server', 'Skip starting the bridge server')
440
441
  .option('--no-browser', 'Skip opening the canvas in the browser')
441
442
  .option('--port <port>', 'Bridge server port', '3456')
@@ -470,10 +471,10 @@ program
470
471
  server = await new Promise((resolve, reject) => {
471
472
  const srv = startServer({
472
473
  port,
474
+ pluginDir,
473
475
  progressProvider: { getProgress: () => loadProgress(ROOT) },
474
476
  onReady: () => resolve(srv),
475
477
  });
476
- // Handle port-in-use and other startup errors
477
478
  srv.server.on('error', (err) => {
478
479
  if (err.code === 'EADDRINUSE') {
479
480
  reject(new Error(`Port ${port} is already in use. Kill the old process or use --port <other>`));
@@ -492,38 +493,51 @@ program
492
493
  // 4. Open canvas in browser (unless --no-browser or --no-server)
493
494
  if (server && opts.browser !== false) {
494
495
  const canvasUrl = `http://localhost:${opts.port}`;
495
- const platform = process.platform;
496
- if (platform === 'darwin') {
497
- exec(`open "${canvasUrl}"`);
498
- } else if (platform === 'linux') {
499
- exec(`xdg-open "${canvasUrl}"`);
500
- } else if (platform === 'win32') {
501
- exec(`start "" "${canvasUrl}"`);
502
- }
496
+ openUrl(canvasUrl);
503
497
  console.log(chalk.dim(` Canvas: ${canvasUrl}`));
504
498
  }
505
499
 
506
- // 5. Launch
507
- console.log(chalk.green('Launching Claude Code with ClaudeTeach...'));
508
- console.log(chalk.dim(` Plugin: ${pluginDir}`));
500
+ if (opts.terminal) {
501
+ // --- Terminal mode: spawn Claude Code with stdio: 'inherit' (legacy) ---
502
+ console.log(chalk.green('Launching Claude Code in terminal mode...'));
503
+ console.log(chalk.dim(` Plugin: ${pluginDir}`));
509
504
 
510
- const claudeArgs = [
511
- '--plugin-dir', pluginDir,
512
- '--allowedTools', 'Bash(*),Read,Write,Edit,Glob,Grep',
513
- ...cmd.args,
514
- ];
515
- const child = spawn('claude', claudeArgs, { stdio: 'inherit' });
505
+ const claudeArgs = [
506
+ '--plugin-dir', pluginDir,
507
+ '--allowedTools', 'Bash(*),Read,Write,Edit,Glob,Grep',
508
+ ...cmd.args,
509
+ ];
510
+ const child = spawn('claude', claudeArgs, { stdio: 'inherit' });
516
511
 
517
- child.on('error', (err) => {
518
- console.error(chalk.red(`Failed to start Claude Code: ${err.message}`));
519
- if (server) server.close();
520
- process.exit(1);
521
- });
512
+ child.on('error', (err) => {
513
+ console.error(chalk.red(`Failed to start Claude Code: ${err.message}`));
514
+ if (server) server.close();
515
+ process.exit(1);
516
+ });
522
517
 
523
- child.on('close', (code) => {
524
- if (server) server.close();
525
- process.exit(code ?? 0);
526
- });
518
+ child.on('close', (code) => {
519
+ if (server) server.close();
520
+ process.exit(code ?? 0);
521
+ });
522
+ } else {
523
+ // --- Browser chat mode (default): Claude runs via SDK in the bridge ---
524
+ console.log(chalk.green('ClaudeTeach is ready — chat in your browser!'));
525
+ console.log(chalk.dim(` Plugin: ${pluginDir}`));
526
+ console.log(chalk.dim(` Claude Code runs via the Agent SDK (no terminal TTY)`));
527
+ console.log(chalk.dim(` Use --terminal flag for classic CLI mode`));
528
+
529
+ // Keep the process alive — the bridge server handles everything.
530
+ // On SIGINT, clean up and exit.
531
+ process.on('SIGINT', () => {
532
+ console.log(chalk.dim('\nShutting down...'));
533
+ if (server) server.close();
534
+ process.exit(0);
535
+ });
536
+ process.on('SIGTERM', () => {
537
+ if (server) server.close();
538
+ process.exit(0);
539
+ });
540
+ }
527
541
  });
528
542
 
529
543
  // --- setup ---
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Validates game.yaml structure for all modules that include one.
3
+ * Ensures YAML is well-formed, every game has a valid type, and
4
+ * game-type-specific required fields are present.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import yaml from 'js-yaml';
11
+ import { GAME_TYPES } from '../../shared/src/constants.js';
12
+
13
+ const MODULES_DIR = path.resolve(import.meta.dirname, '../../modules');
14
+
15
+ const VALID_TYPES = new Set(GAME_TYPES);
16
+
17
+ const REQUIRED_FIELDS = {
18
+ 'speed-round': ['rounds'],
19
+ 'bug-hunt': ['snippets'],
20
+ 'classify': ['categories', 'items'],
21
+ 'scenario': ['steps'],
22
+ 'command-sprint': ['challenges'],
23
+ 'memory-match': ['pairs'],
24
+ };
25
+
26
+ function getModulesWithGames() {
27
+ const modules = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
28
+ .filter(d => d.isDirectory());
29
+
30
+ const result = [];
31
+ for (const mod of modules) {
32
+ const gamePath = path.join(MODULES_DIR, mod.name, 'game.yaml');
33
+ if (fs.existsSync(gamePath)) {
34
+ result.push({ slug: mod.name, gamePath });
35
+ }
36
+ }
37
+ return result;
38
+ }
39
+
40
+ const modulesWithGames = getModulesWithGames();
41
+
42
+ describe('game.yaml validation', () => {
43
+ it('finds at least 5 modules with game.yaml', () => {
44
+ expect(modulesWithGames.length).toBeGreaterThanOrEqual(5);
45
+ });
46
+
47
+ describe.each(modulesWithGames)('$slug', ({ slug, gamePath }) => {
48
+ let doc;
49
+
50
+ it('parses as valid YAML', () => {
51
+ const raw = fs.readFileSync(gamePath, 'utf-8');
52
+ doc = yaml.load(raw);
53
+ expect(doc).toBeTruthy();
54
+ });
55
+
56
+ it('has a top-level "games" array with at least one entry', () => {
57
+ const raw = fs.readFileSync(gamePath, 'utf-8');
58
+ doc = yaml.load(raw);
59
+ expect(Array.isArray(doc.games)).toBe(true);
60
+ expect(doc.games.length).toBeGreaterThanOrEqual(1);
61
+ });
62
+
63
+ it('every game has a valid type', () => {
64
+ const raw = fs.readFileSync(gamePath, 'utf-8');
65
+ doc = yaml.load(raw);
66
+ for (const game of doc.games) {
67
+ expect(VALID_TYPES.has(game.type), `invalid type "${game.type}" in ${slug}`).toBe(true);
68
+ }
69
+ });
70
+
71
+ it('every game has a non-empty title', () => {
72
+ const raw = fs.readFileSync(gamePath, 'utf-8');
73
+ doc = yaml.load(raw);
74
+ for (const game of doc.games) {
75
+ expect(typeof game.title).toBe('string');
76
+ expect(game.title.length).toBeGreaterThan(0);
77
+ }
78
+ });
79
+
80
+ it('every game has its type-specific required fields', () => {
81
+ const raw = fs.readFileSync(gamePath, 'utf-8');
82
+ doc = yaml.load(raw);
83
+ for (const game of doc.games) {
84
+ const required = REQUIRED_FIELDS[game.type] || [];
85
+ for (const field of required) {
86
+ expect(game[field], `missing "${field}" in ${slug}/${game.type}/${game.title}`).toBeDefined();
87
+ }
88
+ }
89
+ });
90
+
91
+ it('speed-round games have valid rounds', () => {
92
+ const raw = fs.readFileSync(gamePath, 'utf-8');
93
+ doc = yaml.load(raw);
94
+ for (const game of doc.games) {
95
+ if (game.type !== 'speed-round') continue;
96
+ expect(Array.isArray(game.rounds)).toBe(true);
97
+ expect(game.rounds.length).toBeGreaterThanOrEqual(3);
98
+ for (const round of game.rounds) {
99
+ expect(typeof round.question).toBe('string');
100
+ expect(Array.isArray(round.options)).toBe(true);
101
+ expect(round.options.length).toBeGreaterThanOrEqual(2);
102
+ expect(typeof round.answer).toBe('number');
103
+ expect(round.answer).toBeGreaterThanOrEqual(0);
104
+ expect(round.answer).toBeLessThan(round.options.length);
105
+ }
106
+ }
107
+ });
108
+
109
+ it('bug-hunt games have valid snippets', () => {
110
+ const raw = fs.readFileSync(gamePath, 'utf-8');
111
+ doc = yaml.load(raw);
112
+ for (const game of doc.games) {
113
+ if (game.type !== 'bug-hunt') continue;
114
+ expect(Array.isArray(game.snippets)).toBe(true);
115
+ expect(game.snippets.length).toBeGreaterThanOrEqual(3);
116
+ for (const snippet of game.snippets) {
117
+ expect(typeof snippet.code).toBe('string');
118
+ expect(snippet.code.trim().length).toBeGreaterThan(0);
119
+ expect(typeof snippet.bugLine).toBe('number');
120
+ expect(snippet.bugLine).toBeGreaterThanOrEqual(1);
121
+ expect(typeof snippet.explanation).toBe('string');
122
+ }
123
+ }
124
+ });
125
+
126
+ it('classify games have valid categories and items', () => {
127
+ const raw = fs.readFileSync(gamePath, 'utf-8');
128
+ doc = yaml.load(raw);
129
+ for (const game of doc.games) {
130
+ if (game.type !== 'classify') continue;
131
+ expect(Array.isArray(game.categories)).toBe(true);
132
+ expect(game.categories.length).toBeGreaterThanOrEqual(2);
133
+ const catNames = new Set(game.categories.map(c => c.name));
134
+ for (const cat of game.categories) {
135
+ expect(typeof cat.name).toBe('string');
136
+ expect(typeof cat.color).toBe('string');
137
+ expect(cat.color).toMatch(/^#[0-9a-fA-F]{6}$/);
138
+ }
139
+ expect(Array.isArray(game.items)).toBe(true);
140
+ expect(game.items.length).toBeGreaterThanOrEqual(4);
141
+ for (const item of game.items) {
142
+ expect(typeof item.text).toBe('string');
143
+ expect(catNames.has(item.category),
144
+ `item "${item.text}" references unknown category "${item.category}"`).toBe(true);
145
+ }
146
+ }
147
+ });
148
+
149
+ it('scenario games have valid steps', () => {
150
+ const raw = fs.readFileSync(gamePath, 'utf-8');
151
+ doc = yaml.load(raw);
152
+ for (const game of doc.games) {
153
+ if (game.type !== 'scenario') continue;
154
+ expect(Array.isArray(game.steps)).toBe(true);
155
+ expect(game.steps.length).toBeGreaterThanOrEqual(3);
156
+ expect(typeof game.startHealth).toBe('number');
157
+ expect(game.startHealth).toBeGreaterThan(0);
158
+ const stepIds = new Set(game.steps.map(s => s.id));
159
+ stepIds.add('end');
160
+ expect(stepIds.has('start')).toBe(true);
161
+ for (const step of game.steps) {
162
+ expect(typeof step.id).toBe('string');
163
+ expect(typeof step.situation).toBe('string');
164
+ expect(Array.isArray(step.choices)).toBe(true);
165
+ expect(step.choices.length).toBeGreaterThanOrEqual(1);
166
+ for (const choice of step.choices) {
167
+ expect(typeof choice.text).toBe('string');
168
+ expect(typeof choice.consequence).toBe('string');
169
+ expect(typeof choice.health).toBe('number');
170
+ expect(stepIds.has(choice.next),
171
+ `choice in step "${step.id}" points to unknown next "${choice.next}"`).toBe(true);
172
+ }
173
+ }
174
+ }
175
+ });
176
+
177
+ it('command-sprint games have valid challenges', () => {
178
+ const raw = fs.readFileSync(gamePath, 'utf-8');
179
+ doc = yaml.load(raw);
180
+ for (const game of doc.games) {
181
+ if (game.type !== 'command-sprint') continue;
182
+ expect(Array.isArray(game.challenges)).toBe(true);
183
+ expect(game.challenges.length).toBeGreaterThanOrEqual(3);
184
+ for (const ch of game.challenges) {
185
+ expect(typeof ch.prompt).toBe('string');
186
+ expect(typeof ch.answer).toBe('string');
187
+ expect(ch.answer.trim().length).toBeGreaterThan(0);
188
+ if (ch.alternates !== undefined) {
189
+ expect(Array.isArray(ch.alternates)).toBe(true);
190
+ }
191
+ }
192
+ }
193
+ });
194
+
195
+ it('memory-match games have valid pairs', () => {
196
+ const raw = fs.readFileSync(gamePath, 'utf-8');
197
+ doc = yaml.load(raw);
198
+ for (const game of doc.games) {
199
+ if (game.type !== 'memory-match') continue;
200
+ expect(Array.isArray(game.pairs)).toBe(true);
201
+ expect(game.pairs.length).toBeGreaterThanOrEqual(3);
202
+ for (const pair of game.pairs) {
203
+ expect(typeof pair.term).toBe('string');
204
+ expect(typeof pair.definition).toBe('string');
205
+ }
206
+ }
207
+ });
208
+ });
209
+ });
package/src/progress.js CHANGED
@@ -132,6 +132,33 @@ export function recordQuizScore(rootDir, slug, score) {
132
132
  return progress;
133
133
  }
134
134
 
135
+ /**
136
+ * Record a game score for a module. Tracks latest and best scores.
137
+ */
138
+ export function recordGameScore(rootDir, slug, score, maxScore) {
139
+ const progress = loadProgress(rootDir);
140
+
141
+ if (!progress.modules[slug]) {
142
+ progress.modules[slug] = {
143
+ status: 'in-progress',
144
+ started: new Date().toISOString().split('T')[0],
145
+ xp_earned: 0,
146
+ };
147
+ }
148
+
149
+ const mod = progress.modules[slug];
150
+ mod.game_score = `${score}/${maxScore}`;
151
+ mod.last_session = new Date().toISOString().split('T')[0];
152
+
153
+ const currentBest = mod.game_best ? parseInt(mod.game_best.split('/')[0], 10) : 0;
154
+ if (score > currentBest) {
155
+ mod.game_best = `${score}/${maxScore}`;
156
+ }
157
+
158
+ saveProgress(rootDir, progress);
159
+ return progress;
160
+ }
161
+
135
162
  /**
136
163
  * Get summary stats from progress.
137
164
  */
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { loadProgress, saveProgress, recordXP, completeModule, updateWalkthroughStep, recordQuizScore, getStats } from './progress.js';
2
+ import { loadProgress, saveProgress, recordXP, completeModule, updateWalkthroughStep, recordQuizScore, recordGameScore, getStats } from './progress.js';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
  import os from 'os';
@@ -293,6 +293,59 @@ describe('recordQuizScore', () => {
293
293
  });
294
294
  });
295
295
 
296
+ /* ================================================================== */
297
+ /* recordGameScore */
298
+ /* ================================================================== */
299
+
300
+ describe('recordGameScore', () => {
301
+ it('should record the game score as a fraction string', () => {
302
+ const result = recordGameScore(tmpDir, 'git', 8, 10);
303
+ expect(result.modules.git.game_score).toBe('8/10');
304
+ });
305
+
306
+ it('should create module entry if it did not exist', () => {
307
+ const result = recordGameScore(tmpDir, 'new-mod', 5, 10);
308
+ expect(result.modules['new-mod']).toBeDefined();
309
+ expect(result.modules['new-mod'].status).toBe('in-progress');
310
+ });
311
+
312
+ it('should update last_session date', () => {
313
+ const result = recordGameScore(tmpDir, 'git', 7, 10);
314
+ const today = new Date().toISOString().split('T')[0];
315
+ expect(result.modules.git.last_session).toBe(today);
316
+ });
317
+
318
+ it('should track best score', () => {
319
+ recordGameScore(tmpDir, 'git', 5, 10);
320
+ const result = recordGameScore(tmpDir, 'git', 8, 10);
321
+ expect(result.modules.git.game_best).toBe('8/10');
322
+ expect(result.modules.git.game_score).toBe('8/10');
323
+ });
324
+
325
+ it('should not downgrade best score', () => {
326
+ recordGameScore(tmpDir, 'git', 9, 10);
327
+ const result = recordGameScore(tmpDir, 'git', 3, 10);
328
+ expect(result.modules.git.game_best).toBe('9/10');
329
+ expect(result.modules.git.game_score).toBe('3/10');
330
+ });
331
+
332
+ it('should persist to disk', () => {
333
+ recordGameScore(tmpDir, 'git', 6, 10);
334
+ const loaded = loadProgress(tmpDir);
335
+ expect(loaded.modules.git.game_score).toBe('6/10');
336
+ expect(loaded.modules.git.game_best).toBe('6/10');
337
+ });
338
+
339
+ it('should preserve existing module data', () => {
340
+ recordXP(tmpDir, 'git', 'read', 15);
341
+ recordQuizScore(tmpDir, 'git', '4/5');
342
+ const result = recordGameScore(tmpDir, 'git', 7, 10);
343
+ expect(result.modules.git.xp_earned).toBe(15);
344
+ expect(result.modules.git.quiz_score).toBe('4/5');
345
+ expect(result.modules.git.game_score).toBe('7/10');
346
+ });
347
+ });
348
+
296
349
  /* ================================================================== */
297
350
  /* getStats */
298
351
  /* ================================================================== */
package/src/registry.js CHANGED
@@ -217,6 +217,7 @@ function detectCapabilities(modulePath) {
217
217
  if (existsSync(join(modulePath, 'quiz.md'))) caps.push('quiz');
218
218
  if (existsSync(join(modulePath, 'quick-ref.md'))) caps.push('quick-ref');
219
219
  if (existsSync(join(modulePath, 'resources.md'))) caps.push('resources');
220
+ if (existsSync(join(modulePath, 'game.yaml'))) caps.push('game');
220
221
 
221
222
  // Check for media entries in module.yaml
222
223
  try {
@@ -345,6 +345,27 @@ describe('buildRegistry (filesystem)', () => {
345
345
  expect(mod.capabilities).not.toContain('media');
346
346
  });
347
347
 
348
+ it('should detect game capability when game.yaml exists', async () => {
349
+ const moduleDir = createModule('game-test');
350
+ fs.writeFileSync(path.join(moduleDir, 'game.yaml'), 'games:\n - type: speed-round\n title: Test\n', 'utf-8');
351
+
352
+ const { buildRegistry } = await import('./registry.js');
353
+ const registry = await buildRegistry(tmpDir);
354
+
355
+ const mod = registry.modules.find(m => m.slug === 'game-test');
356
+ expect(mod.capabilities).toContain('game');
357
+ });
358
+
359
+ it('should not detect game capability when game.yaml is absent', async () => {
360
+ createModule('no-game-test');
361
+
362
+ const { buildRegistry } = await import('./registry.js');
363
+ const registry = await buildRegistry(tmpDir);
364
+
365
+ const mod = registry.modules.find(m => m.slug === 'no-game-test');
366
+ expect(mod.capabilities).not.toContain('game');
367
+ });
368
+
348
369
  it('should skip directories without module.yaml', async () => {
349
370
  // Create a directory without module.yaml
350
371
  const emptyDir = path.join(tmpDir, 'packages', 'modules', 'no-yaml');