@shaykec/claude-teach 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaykec/claude-teach",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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,8 +15,10 @@
15
15
  "commander": "^12.0.0",
16
16
  "js-yaml": "^4.1.0",
17
17
  "chalk": "^5.3.0",
18
- "@shaykec/bridge": "0.1.0",
19
- "@shaykec/shared": "0.1.0"
18
+ "@shaykec/bridge": "0.2.0",
19
+ "@shaykec/shared": "0.1.0",
20
+ "@shaykec/plugin": "0.1.0",
21
+ "@shaykec/extension": "0.1.0"
20
22
  },
21
23
  "publishConfig": {
22
24
  "access": "public"
@@ -0,0 +1,289 @@
1
+ /**
2
+ * E2E test helpers for running the claude-teach CLI binary.
3
+ */
4
+
5
+ import { execFileSync } from 'child_process';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, copyFileSync } from 'fs';
7
+ import { join, resolve, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { tmpdir } from 'os';
10
+ import yaml from 'js-yaml';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+
15
+ export const CLI_PATH = resolve(__dirname, 'cli.js');
16
+ export const PROJECT_ROOT = resolve(__dirname, '..', '..', '..');
17
+ const PROGRESS_FILE = '.teach-progress.yaml';
18
+
19
+ /**
20
+ * Strip ANSI escape codes from a string.
21
+ */
22
+ export function stripAnsi(str) {
23
+ return str.replace(
24
+ // eslint-disable-next-line no-control-regex
25
+ /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
26
+ '',
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Run the CLI binary and capture output.
32
+ *
33
+ * @param {string[]} args — CLI arguments
34
+ * @param {object} [opts]
35
+ * @param {string} [opts.cwd] — working directory (defaults to PROJECT_ROOT)
36
+ * @param {object} [opts.env] — extra env vars (merged with process.env)
37
+ * @param {number} [opts.timeout] — timeout in ms (default 10000)
38
+ * @param {boolean} [opts.expectError] — if true, don't throw on non-zero exit
39
+ * @returns {{ stdout: string, stderr: string, exitCode: number }}
40
+ */
41
+ export function runCli(args, opts = {}) {
42
+ const {
43
+ cwd = PROJECT_ROOT,
44
+ env = {},
45
+ timeout = 10000,
46
+ expectError = false,
47
+ } = opts;
48
+
49
+ const mergedEnv = { ...process.env, ...env };
50
+
51
+ try {
52
+ const stdout = execFileSync('node', [CLI_PATH, ...args], {
53
+ cwd,
54
+ env: mergedEnv,
55
+ timeout,
56
+ encoding: 'utf-8',
57
+ stdio: ['pipe', 'pipe', 'pipe'],
58
+ });
59
+ return { stdout, stderr: '', exitCode: 0 };
60
+ } catch (err) {
61
+ if (expectError || err.status !== null) {
62
+ return {
63
+ stdout: err.stdout || '',
64
+ stderr: err.stderr || '',
65
+ exitCode: err.status ?? 1,
66
+ };
67
+ }
68
+ throw err;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Run the package via npx and capture output.
74
+ *
75
+ * @param {string[]} args — CLI arguments
76
+ * @param {object} [opts]
77
+ * @param {number} [opts.timeout] — timeout in ms (default 30000)
78
+ * @param {boolean} [opts.expectError] — if true, don't throw on non-zero exit
79
+ * @returns {{ stdout: string, stderr: string, exitCode: number }}
80
+ */
81
+ export function runNpx(args, opts = {}) {
82
+ const { timeout = 30000, expectError = false } = opts;
83
+
84
+ try {
85
+ const stdout = execFileSync('npx', ['@shaykec/claude-teach', ...args], {
86
+ cwd: PROJECT_ROOT,
87
+ env: process.env,
88
+ timeout,
89
+ encoding: 'utf-8',
90
+ stdio: ['pipe', 'pipe', 'pipe'],
91
+ });
92
+ return { stdout, stderr: '', exitCode: 0 };
93
+ } catch (err) {
94
+ if (expectError || err.status !== null) {
95
+ return {
96
+ stdout: err.stdout || '',
97
+ stderr: err.stderr || '',
98
+ exitCode: err.status ?? 1,
99
+ };
100
+ }
101
+ throw err;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Create an isolated temp HOME directory with .claude-teach/ structure.
107
+ * Marketplace commands use homedir() for paths, so setting HOME isolates them.
108
+ *
109
+ * @returns {{ path: string, env: object, cleanup: () => void }}
110
+ */
111
+ export function createTempHome() {
112
+ const tmpHome = mkdtempSync(join(tmpdir(), 'teach-e2e-home-'));
113
+ const teachDir = join(tmpHome, '.claude-teach');
114
+ const modulesDir = join(teachDir, 'modules');
115
+ const localDir = join(modulesDir, 'local');
116
+
117
+ mkdirSync(localDir, { recursive: true });
118
+
119
+ return {
120
+ path: tmpHome,
121
+ env: { HOME: tmpHome },
122
+ cleanup() {
123
+ rmSync(tmpHome, { recursive: true, force: true });
124
+ },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Create a valid test module pack in the given directory.
130
+ *
131
+ * @param {string} dir — parent directory to create the pack in
132
+ * @param {string} name — pack name
133
+ * @param {string[]} [modules] — module names to create
134
+ * @returns {string} — path to the created pack
135
+ */
136
+ export function createTestPack(dir, name, modules = []) {
137
+ const packDir = join(dir, name);
138
+ const modulesDir = join(packDir, 'modules');
139
+ mkdirSync(modulesDir, { recursive: true });
140
+
141
+ const packYaml = {
142
+ name,
143
+ author: 'test',
144
+ description: 'Test pack for e2e tests',
145
+ version: '1.0.0',
146
+ modules,
147
+ };
148
+
149
+ writeFileSync(
150
+ join(packDir, 'pack.yaml'),
151
+ yaml.dump(packYaml, { lineWidth: 120 }),
152
+ 'utf-8',
153
+ );
154
+
155
+ writeFileSync(join(packDir, 'README.md'), `# ${name}\n`, 'utf-8');
156
+
157
+ for (const mod of modules) {
158
+ const moduleDir = join(modulesDir, mod);
159
+ mkdirSync(moduleDir, { recursive: true });
160
+
161
+ const moduleYaml = {
162
+ slug: mod,
163
+ title: mod.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
164
+ version: '1.0.0',
165
+ description: 'Test module',
166
+ category: 'developer-skills',
167
+ tags: ['test'],
168
+ difficulty: 'beginner',
169
+ xp: { read: 10 },
170
+ time: { read: 5 },
171
+ prerequisites: [],
172
+ related: [],
173
+ triggers: [],
174
+ };
175
+
176
+ writeFileSync(
177
+ join(moduleDir, 'module.yaml'),
178
+ yaml.dump(moduleYaml, { lineWidth: 120 }),
179
+ 'utf-8',
180
+ );
181
+
182
+ writeFileSync(
183
+ join(moduleDir, 'content.md'),
184
+ `# ${moduleYaml.title}\n\nTest content.\n`,
185
+ 'utf-8',
186
+ );
187
+ }
188
+
189
+ return packDir;
190
+ }
191
+
192
+ /**
193
+ * Backup the progress file if it exists.
194
+ * @returns {string|null} — path to backup, or null if no file existed
195
+ */
196
+ export function backupProgress() {
197
+ const filePath = join(PROJECT_ROOT, PROGRESS_FILE);
198
+ if (!existsSync(filePath)) return null;
199
+
200
+ const backupPath = join(PROJECT_ROOT, `${PROGRESS_FILE}.e2e-backup`);
201
+ copyFileSync(filePath, backupPath);
202
+ return backupPath;
203
+ }
204
+
205
+ /**
206
+ * Restore the progress file from backup, or remove it if no backup existed.
207
+ * @param {string|null} backupPath — path from backupProgress()
208
+ */
209
+ export function restoreProgress(backupPath) {
210
+ const filePath = join(PROJECT_ROOT, PROGRESS_FILE);
211
+
212
+ if (backupPath && existsSync(backupPath)) {
213
+ copyFileSync(backupPath, filePath);
214
+ rmSync(backupPath, { force: true });
215
+ } else {
216
+ // No backup means file didn't exist before — remove any created during test
217
+ if (existsSync(filePath)) rmSync(filePath, { force: true });
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Write a seeded progress file for testing.
223
+ * @param {object} progress — progress data
224
+ */
225
+ export function seedProgress(progress) {
226
+ const filePath = join(PROJECT_ROOT, PROGRESS_FILE);
227
+ writeFileSync(filePath, yaml.dump(progress, { lineWidth: 120, noRefs: true }), 'utf-8');
228
+ }
229
+
230
+ /**
231
+ * Create a bare git repo with a valid pack.yaml for testing install.
232
+ * @param {string} parentDir — directory to create the fixture in
233
+ * @param {string} packName — pack name
234
+ * @param {string[]} [modules] — module names
235
+ * @returns {string} — file:// URL to the bare repo
236
+ */
237
+ export function createGitFixture(parentDir, packName, modules = []) {
238
+ const bareRepo = join(parentDir, `${packName}.git`);
239
+ const tmpClone = join(parentDir, `${packName}-clone`);
240
+
241
+ // Create bare repo
242
+ execFileSync('git', ['init', '--bare', bareRepo], { stdio: 'pipe' });
243
+
244
+ // Clone, add content, push
245
+ execFileSync('git', ['clone', bareRepo, tmpClone], { stdio: 'pipe' });
246
+
247
+ // Create pack structure in clone
248
+ createTestPack(tmpClone, '.', modules);
249
+
250
+ // Move files up (createTestPack creates a subdirectory, but we want them at root)
251
+ // Actually, createTestPack creates in dir/name, so for root we need to handle differently
252
+ // Let's just write the files directly
253
+ const modulesDir = join(tmpClone, 'modules');
254
+ if (!existsSync(modulesDir)) mkdirSync(modulesDir, { recursive: true });
255
+
256
+ const packYaml = {
257
+ name: packName,
258
+ author: 'test',
259
+ description: 'Test fixture pack',
260
+ version: '1.0.0',
261
+ modules,
262
+ };
263
+ writeFileSync(join(tmpClone, 'pack.yaml'), yaml.dump(packYaml), 'utf-8');
264
+
265
+ for (const mod of modules) {
266
+ const moduleDir = join(modulesDir, mod);
267
+ mkdirSync(moduleDir, { recursive: true });
268
+ writeFileSync(join(moduleDir, 'module.yaml'), yaml.dump({
269
+ slug: mod,
270
+ title: mod,
271
+ version: '1.0.0',
272
+ description: 'test',
273
+ category: 'developer-skills',
274
+ difficulty: 'beginner',
275
+ xp: { read: 10 },
276
+ }), 'utf-8');
277
+ writeFileSync(join(moduleDir, 'content.md'), `# ${mod}\n`, 'utf-8');
278
+ }
279
+
280
+ // Git add + commit + push
281
+ execFileSync('git', ['-C', tmpClone, 'add', '.'], { stdio: 'pipe' });
282
+ execFileSync('git', ['-C', tmpClone, '-c', 'user.name=test', '-c', 'user.email=test@test.com', 'commit', '-m', 'init'], { stdio: 'pipe' });
283
+ execFileSync('git', ['-C', tmpClone, 'push'], { stdio: 'pipe' });
284
+
285
+ // Clean up the clone
286
+ rmSync(tmpClone, { recursive: true, force: true });
287
+
288
+ return `file://${bareRepo}`;
289
+ }
@@ -0,0 +1,673 @@
1
+ /**
2
+ * Level 1 E2E tests — run the claude-teach CLI binary and assert on output.
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
6
+ import { spawn } from 'child_process';
7
+ import { existsSync, mkdtempSync, rmSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { tmpdir } from 'os';
10
+ import yaml from 'js-yaml';
11
+ import { writeFileSync, mkdirSync } from 'fs';
12
+ import {
13
+ runCli,
14
+ runNpx,
15
+ stripAnsi,
16
+ createTempHome,
17
+ createTestPack,
18
+ backupProgress,
19
+ restoreProgress,
20
+ seedProgress,
21
+ PROJECT_ROOT,
22
+ } from './cli.e2e.helpers.js';
23
+
24
+ // =====================================================================
25
+ // Group A — Learning commands (read-only, no isolation needed)
26
+ // =====================================================================
27
+
28
+ describe('CLI E2E: Learning commands', () => {
29
+ describe('list', () => {
30
+ it('lists available modules with count', () => {
31
+ const { stdout } = runCli(['list']);
32
+ const clean = stripAnsi(stdout);
33
+ expect(clean).toContain('Available Modules');
34
+ expect(clean).toContain('git');
35
+ expect(clean).toContain('hooks');
36
+ expect(clean).toContain('daily-workflow');
37
+ });
38
+
39
+ it('filters by difficulty beginner', () => {
40
+ const { stdout } = runCli(['list', '--difficulty', 'beginner']);
41
+ const clean = stripAnsi(stdout);
42
+ expect(clean).toContain('git');
43
+ });
44
+
45
+ it('shows no modules for non-matching difficulty', () => {
46
+ const { stdout } = runCli(['list', '--difficulty', 'nonexistent-level']);
47
+ const clean = stripAnsi(stdout);
48
+ expect(clean).toContain('No modules found');
49
+ });
50
+
51
+ it('filters by category', () => {
52
+ const { stdout } = runCli(['list', '--category', 'developer-skills']);
53
+ const clean = stripAnsi(stdout);
54
+ expect(clean).toContain('git');
55
+ });
56
+
57
+ it('filters by tag', () => {
58
+ const { stdout } = runCli(['list', '--tag', 'git']);
59
+ const clean = stripAnsi(stdout);
60
+ expect(clean).toContain('git');
61
+ });
62
+
63
+ it('filters by source', () => {
64
+ const { stdout } = runCli(['list', '--source', 'built-in']);
65
+ const clean = stripAnsi(stdout);
66
+ expect(clean).toContain('Available Modules');
67
+ });
68
+ });
69
+
70
+ describe('get', () => {
71
+ it('outputs walkthrough content for git module', () => {
72
+ const { stdout, exitCode } = runCli(['get', 'git']);
73
+ expect(exitCode).toBe(0);
74
+ // walkthrough.md exists for git
75
+ expect(stdout.length).toBeGreaterThan(100);
76
+ });
77
+
78
+ it('outputs quick reference for git module', () => {
79
+ const { stdout, exitCode } = runCli(['get', 'git', '--quick']);
80
+ expect(exitCode).toBe(0);
81
+ expect(stdout.length).toBeGreaterThan(50);
82
+ });
83
+
84
+ it('outputs quiz for git module', () => {
85
+ const { stdout, exitCode } = runCli(['get', 'git', '--quiz-only']);
86
+ expect(exitCode).toBe(0);
87
+ expect(stdout.length).toBeGreaterThan(50);
88
+ });
89
+
90
+ it('shows no progress for unstudied module', () => {
91
+ const { stdout, exitCode } = runCli(['get', 'git', '--status']);
92
+ const clean = stripAnsi(stdout);
93
+ expect(exitCode).toBe(0);
94
+ expect(clean).toContain('No progress recorded');
95
+ });
96
+
97
+ it('fails for nonexistent module', () => {
98
+ const { stderr, exitCode } = runCli(['get', 'nonexistent-module-xyz'], { expectError: true });
99
+ expect(exitCode).toBe(1);
100
+ expect(stderr).toContain('not found');
101
+ });
102
+
103
+ it('fails for quick ref on module without it', () => {
104
+ const { stderr, exitCode } = runCli(['get', 'hooks', '--quick'], { expectError: true });
105
+ expect(exitCode).toBe(1);
106
+ expect(stderr).toContain('No quick reference');
107
+ });
108
+
109
+ it('outputs content.md for module without walkthrough', () => {
110
+ const { stdout, exitCode } = runCli(['get', 'hooks']);
111
+ expect(exitCode).toBe(0);
112
+ expect(stdout.length).toBeGreaterThan(50);
113
+ });
114
+ });
115
+
116
+ describe('stats', () => {
117
+ it('outputs dashboard with default belt', () => {
118
+ const { stdout, exitCode } = runCli(['stats']);
119
+ const clean = stripAnsi(stdout);
120
+ expect(exitCode).toBe(0);
121
+ expect(clean).toContain('ClaudeTeach Dashboard');
122
+ });
123
+ });
124
+
125
+ describe('level-up', () => {
126
+ it('outputs belt info and recommendations', () => {
127
+ const { stdout, exitCode } = runCli(['level-up']);
128
+ const clean = stripAnsi(stdout);
129
+ expect(exitCode).toBe(0);
130
+ // Should mention at least one thing about the belt system or recommendations
131
+ expect(clean.length).toBeGreaterThan(20);
132
+ });
133
+ });
134
+
135
+ describe('global flags', () => {
136
+ it('outputs version matching semver', () => {
137
+ const { stdout, exitCode } = runCli(['--version']);
138
+ expect(exitCode).toBe(0);
139
+ expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
140
+ });
141
+
142
+ it('outputs help text', () => {
143
+ const { stdout, exitCode } = runCli(['--help']);
144
+ expect(exitCode).toBe(0);
145
+ expect(stdout).toContain('Socratic AI teaching platform');
146
+ });
147
+ });
148
+ });
149
+
150
+ // =====================================================================
151
+ // Group B — Progress commands (backup/restore .teach-progress.yaml)
152
+ // =====================================================================
153
+
154
+ describe('CLI E2E: Progress commands', () => {
155
+ let progressBackup;
156
+
157
+ beforeEach(() => {
158
+ progressBackup = backupProgress();
159
+ });
160
+
161
+ afterEach(() => {
162
+ restoreProgress(progressBackup);
163
+ });
164
+
165
+ it('resets progress for a module', () => {
166
+ // Seed some progress first
167
+ seedProgress({
168
+ user: { xp: 50, belt: 'yellow', modules_completed: 0 },
169
+ modules: { git: { status: 'in-progress', xp_earned: 50 } },
170
+ });
171
+
172
+ const { stdout, exitCode } = runCli(['get', 'git', '--reset']);
173
+ const clean = stripAnsi(stdout);
174
+ expect(exitCode).toBe(0);
175
+ expect(clean).toContain('Progress reset');
176
+ });
177
+
178
+ it('shows status for module with progress', () => {
179
+ seedProgress({
180
+ user: { xp: 100, belt: 'yellow', modules_completed: 0 },
181
+ modules: { git: { status: 'in-progress', xp_earned: 100, walkthrough_step: 3 } },
182
+ });
183
+
184
+ const { stdout, exitCode } = runCli(['get', 'git', '--status']);
185
+ expect(exitCode).toBe(0);
186
+ const parsed = JSON.parse(stdout);
187
+ expect(parsed.status).toBe('in-progress');
188
+ expect(parsed.xp_earned).toBe(100);
189
+ });
190
+
191
+ it('stats shows seeded XP and belt', () => {
192
+ seedProgress({
193
+ user: { xp: 200, belt: 'green', modules_completed: 1 },
194
+ modules: { git: { status: 'completed', xp_earned: 200 } },
195
+ });
196
+
197
+ const { stdout, exitCode } = runCli(['stats']);
198
+ const clean = stripAnsi(stdout);
199
+ expect(exitCode).toBe(0);
200
+ expect(clean).toContain('200');
201
+ });
202
+ });
203
+
204
+ // =====================================================================
205
+ // Group C — Authoring commands (temp dir as cwd)
206
+ // =====================================================================
207
+
208
+ describe('CLI E2E: Authoring commands', () => {
209
+ let tmpDir;
210
+
211
+ beforeEach(() => {
212
+ tmpDir = mkdtempSync(join(tmpdir(), 'teach-e2e-author-'));
213
+ });
214
+
215
+ afterEach(() => {
216
+ rmSync(tmpDir, { recursive: true, force: true });
217
+ });
218
+
219
+ describe('author init', () => {
220
+ it('scaffolds a new pack', () => {
221
+ const { stdout, exitCode } = runCli(['author', 'init', 'test-pack'], { cwd: tmpDir });
222
+ const clean = stripAnsi(stdout);
223
+ expect(exitCode).toBe(0);
224
+ expect(clean).toContain('scaffolded');
225
+
226
+ // Verify file structure
227
+ expect(existsSync(join(tmpDir, 'test-pack', 'pack.yaml'))).toBe(true);
228
+ expect(existsSync(join(tmpDir, 'test-pack', 'modules'))).toBe(true);
229
+ expect(existsSync(join(tmpDir, 'test-pack', 'README.md'))).toBe(true);
230
+ });
231
+
232
+ it('fails if directory already exists', () => {
233
+ runCli(['author', 'init', 'test-pack'], { cwd: tmpDir });
234
+ const { stderr, exitCode } = runCli(['author', 'init', 'test-pack'], {
235
+ cwd: tmpDir,
236
+ expectError: true,
237
+ });
238
+ expect(exitCode).toBe(1);
239
+ expect(stderr).toContain('already exists');
240
+ });
241
+ });
242
+
243
+ describe('author add', () => {
244
+ it('adds a module to a pack', () => {
245
+ runCli(['author', 'init', 'test-pack'], { cwd: tmpDir });
246
+ const { stdout, exitCode } = runCli(['author', 'add', 'test-pack/my-module'], { cwd: tmpDir });
247
+ const clean = stripAnsi(stdout);
248
+ expect(exitCode).toBe(0);
249
+ expect(clean).toContain('my-module');
250
+
251
+ const moduleDir = join(tmpDir, 'test-pack', 'modules', 'my-module');
252
+ expect(existsSync(join(moduleDir, 'module.yaml'))).toBe(true);
253
+ expect(existsSync(join(moduleDir, 'content.md'))).toBe(true);
254
+ });
255
+
256
+ it('fails for nonexistent pack', () => {
257
+ const { stderr, exitCode } = runCli(['author', 'add', 'no-pack/mod'], {
258
+ cwd: tmpDir,
259
+ expectError: true,
260
+ });
261
+ expect(exitCode).toBe(1);
262
+ expect(stderr).toContain('No pack.yaml');
263
+ });
264
+
265
+ it('fails for duplicate module', () => {
266
+ runCli(['author', 'init', 'test-pack'], { cwd: tmpDir });
267
+ runCli(['author', 'add', 'test-pack/my-module'], { cwd: tmpDir });
268
+ const { stderr, exitCode } = runCli(['author', 'add', 'test-pack/my-module'], {
269
+ cwd: tmpDir,
270
+ expectError: true,
271
+ });
272
+ expect(exitCode).toBe(1);
273
+ expect(stderr).toContain('already exists');
274
+ });
275
+ });
276
+
277
+ describe('author validate', () => {
278
+ it('validates a correct pack', () => {
279
+ runCli(['author', 'init', 'test-pack'], { cwd: tmpDir });
280
+ runCli(['author', 'add', 'test-pack/my-module'], { cwd: tmpDir });
281
+ const { stdout, exitCode } = runCli(['author', 'validate', 'test-pack'], { cwd: tmpDir });
282
+ const clean = stripAnsi(stdout);
283
+ expect(exitCode).toBe(0);
284
+ expect(clean).toContain('Pack is valid');
285
+ });
286
+
287
+ it('fails for missing pack.yaml', () => {
288
+ const { stdout, exitCode } = runCli(['author', 'validate', '.'], {
289
+ cwd: tmpDir,
290
+ expectError: true,
291
+ });
292
+ const clean = stripAnsi(stdout + '');
293
+ expect(exitCode).toBe(1);
294
+ });
295
+ });
296
+
297
+ describe('author preview', () => {
298
+ let tempHome;
299
+
300
+ beforeEach(() => {
301
+ tempHome = createTempHome();
302
+ });
303
+
304
+ afterEach(() => {
305
+ tempHome.cleanup();
306
+ });
307
+
308
+ it('previews a valid module', () => {
309
+ // Create a module directory with module.yaml
310
+ const packDir = createTestPack(tmpDir, 'test-pack', ['my-mod']);
311
+ const modulePath = join(packDir, 'modules', 'my-mod');
312
+
313
+ const { stdout, exitCode } = runCli(['author', 'preview', modulePath], {
314
+ cwd: tmpDir,
315
+ env: tempHome.env,
316
+ });
317
+ const clean = stripAnsi(stdout);
318
+ expect(exitCode).toBe(0);
319
+ expect(clean).toContain('Previewing');
320
+ });
321
+
322
+ it('fails for path without module.yaml', () => {
323
+ const { stderr, exitCode } = runCli(['author', 'preview', tmpDir], {
324
+ cwd: tmpDir,
325
+ env: tempHome.env,
326
+ expectError: true,
327
+ });
328
+ expect(exitCode).toBe(1);
329
+ expect(stderr).toContain('No module.yaml');
330
+ });
331
+ });
332
+ });
333
+
334
+ // =====================================================================
335
+ // Group D — Marketplace commands (HOME isolation)
336
+ // =====================================================================
337
+
338
+ describe('CLI E2E: Marketplace commands', () => {
339
+ let tempHome;
340
+
341
+ beforeEach(() => {
342
+ tempHome = createTempHome();
343
+ });
344
+
345
+ afterEach(() => {
346
+ tempHome.cleanup();
347
+ });
348
+
349
+ describe('packs', () => {
350
+ it('shows no packs when none installed', () => {
351
+ const { stdout, exitCode } = runCli(['packs'], { env: tempHome.env });
352
+ const clean = stripAnsi(stdout);
353
+ expect(exitCode).toBe(0);
354
+ expect(clean).toContain('No module packs installed');
355
+ });
356
+ });
357
+
358
+ describe('install error handling', () => {
359
+ it('fails for invalid URL format', () => {
360
+ const { stderr, exitCode } = runCli(['install', 'not-a-valid!!!url'], {
361
+ env: tempHome.env,
362
+ expectError: true,
363
+ });
364
+ expect(exitCode).toBe(1);
365
+ expect(stderr).toContain('Invalid URL');
366
+ });
367
+
368
+ it('fails for unreachable git repo', () => {
369
+ const { stderr, exitCode } = runCli(
370
+ ['install', 'https://github.com/nonexistent-user-xyz/nonexistent-repo-xyz'],
371
+ { env: tempHome.env, expectError: true, timeout: 20000 },
372
+ );
373
+ expect(exitCode).toBe(1);
374
+ expect(stderr).toContain('Error');
375
+ });
376
+ });
377
+
378
+ describe('packs + remove (with manually placed pack)', () => {
379
+ /**
380
+ * Simulate a previously installed pack by writing files directly
381
+ * into the HOME/.claude-teach/modules/ directory.
382
+ */
383
+ function placePack(homePath, packName) {
384
+ const packDir = join(homePath, '.claude-teach', 'modules', packName);
385
+ const modulesDir = join(packDir, 'modules', 'test-mod');
386
+ mkdirSync(modulesDir, { recursive: true });
387
+
388
+ writeFileSync(join(packDir, 'pack.yaml'), yaml.dump({
389
+ name: packName,
390
+ author: 'test',
391
+ description: 'A test pack',
392
+ version: '1.0.0',
393
+ modules: ['test-mod'],
394
+ }), 'utf-8');
395
+
396
+ writeFileSync(join(modulesDir, 'module.yaml'), yaml.dump({
397
+ slug: 'test-mod',
398
+ title: 'Test Module',
399
+ version: '1.0.0',
400
+ difficulty: 'beginner',
401
+ xp: { read: 10 },
402
+ }), 'utf-8');
403
+
404
+ writeFileSync(join(modulesDir, 'content.md'), '# Test\n', 'utf-8');
405
+ }
406
+
407
+ it('lists an installed pack', () => {
408
+ placePack(tempHome.path, 'test-pack');
409
+
410
+ const { stdout, exitCode } = runCli(['packs'], { env: tempHome.env });
411
+ const clean = stripAnsi(stdout);
412
+ expect(exitCode).toBe(0);
413
+ expect(clean).toContain('test-pack');
414
+ expect(clean).toContain('Installed Packs');
415
+ });
416
+
417
+ it('removes an installed pack', () => {
418
+ placePack(tempHome.path, 'test-pack');
419
+
420
+ const { stdout, exitCode } = runCli(['remove', 'test-pack'], { env: tempHome.env });
421
+ const clean = stripAnsi(stdout);
422
+ expect(exitCode).toBe(0);
423
+ expect(clean).toContain('removed');
424
+
425
+ const packDir = join(tempHome.path, '.claude-teach', 'modules', 'test-pack');
426
+ expect(existsSync(packDir)).toBe(false);
427
+ });
428
+ });
429
+
430
+ describe('remove edge cases', () => {
431
+ it('fails for nonexistent pack', () => {
432
+ const { stderr, exitCode } = runCli(['remove', 'nonexistent-pack'], {
433
+ env: tempHome.env,
434
+ expectError: true,
435
+ });
436
+ expect(exitCode).toBe(1);
437
+ expect(stderr).toContain('not installed');
438
+ });
439
+
440
+ it('fails for reserved "local" name', () => {
441
+ const { stderr, exitCode } = runCli(['remove', 'local'], {
442
+ env: tempHome.env,
443
+ expectError: true,
444
+ });
445
+ expect(exitCode).toBe(1);
446
+ expect(stderr).toContain('cannot be removed');
447
+ });
448
+ });
449
+
450
+ describe('update', () => {
451
+ it('fails for nonexistent pack', () => {
452
+ const { stderr, exitCode } = runCli(['update', 'nonexistent-pack'], {
453
+ env: tempHome.env,
454
+ expectError: true,
455
+ });
456
+ expect(exitCode).toBe(1);
457
+ expect(stderr).toContain('not installed');
458
+ });
459
+
460
+ it('fails for reserved "local" name', () => {
461
+ const { stderr, exitCode } = runCli(['update', 'local'], {
462
+ env: tempHome.env,
463
+ expectError: true,
464
+ });
465
+ expect(exitCode).toBe(1);
466
+ expect(stderr).toContain('cannot be updated');
467
+ });
468
+ });
469
+
470
+ describe('search', () => {
471
+ it('handles no results gracefully', () => {
472
+ const { stdout, exitCode } = runCli(['search', 'xyznonexistent999'], {
473
+ env: tempHome.env,
474
+ timeout: 15000,
475
+ });
476
+ const clean = stripAnsi(stdout);
477
+ expect(exitCode).toBe(0);
478
+ expect(clean).toContain('No packs found');
479
+ });
480
+ });
481
+
482
+ describe('registry', () => {
483
+ it('lists default registry', () => {
484
+ const { stdout, exitCode } = runCli(['registry', 'list'], { env: tempHome.env });
485
+ const clean = stripAnsi(stdout);
486
+ expect(exitCode).toBe(0);
487
+ expect(clean).toContain('default');
488
+ expect(clean).toContain('Configured Registries');
489
+ });
490
+
491
+ it('adds a registry', () => {
492
+ const { stdout, exitCode } = runCli(['registry', 'add', 'user/test-registry'], {
493
+ env: tempHome.env,
494
+ });
495
+ const clean = stripAnsi(stdout);
496
+ expect(exitCode).toBe(0);
497
+ expect(clean).toContain('added');
498
+ });
499
+
500
+ it('lists added registry', () => {
501
+ runCli(['registry', 'add', 'user/test-registry'], { env: tempHome.env });
502
+
503
+ const { stdout, exitCode } = runCli(['registry', 'list'], { env: tempHome.env });
504
+ const clean = stripAnsi(stdout);
505
+ expect(exitCode).toBe(0);
506
+ expect(clean).toContain('test-registry');
507
+ });
508
+
509
+ it('removes a registry', () => {
510
+ runCli(['registry', 'add', 'user/test-registry'], { env: tempHome.env });
511
+
512
+ const { stdout, exitCode } = runCli(['registry', 'remove', 'test-registry'], {
513
+ env: tempHome.env,
514
+ });
515
+ const clean = stripAnsi(stdout);
516
+ expect(exitCode).toBe(0);
517
+ expect(clean).toContain('removed');
518
+ });
519
+
520
+ it('fails to remove nonexistent registry', () => {
521
+ const { stderr, exitCode } = runCli(['registry', 'remove', 'nonexistent'], {
522
+ env: tempHome.env,
523
+ expectError: true,
524
+ });
525
+ expect(exitCode).toBe(1);
526
+ expect(stderr).toContain('not found');
527
+ });
528
+
529
+ it('fails to add duplicate registry', () => {
530
+ runCli(['registry', 'add', 'user/test-registry'], { env: tempHome.env });
531
+ const { stderr, exitCode } = runCli(['registry', 'add', 'user/test-registry'], {
532
+ env: tempHome.env,
533
+ expectError: true,
534
+ });
535
+ expect(exitCode).toBe(1);
536
+ expect(stderr).toContain('already configured');
537
+ });
538
+ });
539
+ });
540
+
541
+ // =====================================================================
542
+ // Group E — Server command (spawn + kill)
543
+ // =====================================================================
544
+
545
+ describe('CLI E2E: Server command', () => {
546
+ it('serve starts and responds to /health', async () => {
547
+ const port = 13456 + Math.floor(Math.random() * 1000);
548
+ const child = spawn('node', [
549
+ join(PROJECT_ROOT, 'packages/core/src/cli.js'),
550
+ 'serve',
551
+ '--port',
552
+ String(port),
553
+ ], {
554
+ cwd: PROJECT_ROOT,
555
+ stdio: ['ignore', 'pipe', 'pipe'],
556
+ });
557
+
558
+ try {
559
+ // Wait for server to be ready (try hitting /health with retries)
560
+ let ready = false;
561
+ for (let i = 0; i < 20; i++) {
562
+ await new Promise(r => setTimeout(r, 500));
563
+ try {
564
+ const resp = await fetch(`http://localhost:${port}/health`);
565
+ if (resp.ok) {
566
+ ready = true;
567
+ break;
568
+ }
569
+ } catch {
570
+ // Server not ready yet
571
+ }
572
+ }
573
+
574
+ expect(ready).toBe(true);
575
+
576
+ // Verify health response
577
+ const resp = await fetch(`http://localhost:${port}/health`);
578
+ const data = await resp.json();
579
+ expect(data).toHaveProperty('status', 'ok');
580
+ } finally {
581
+ child.kill('SIGTERM');
582
+ await new Promise(resolve => child.on('close', resolve));
583
+ }
584
+ }, 15000);
585
+ });
586
+
587
+ // =====================================================================
588
+ // Group F — System commands
589
+ // =====================================================================
590
+
591
+ describe('CLI E2E: System commands', () => {
592
+ it('registry:build outputs rebuild summary', () => {
593
+ const { stdout, exitCode } = runCli(['registry:build']);
594
+ const clean = stripAnsi(stdout);
595
+ expect(exitCode).toBe(0);
596
+ expect(clean).toContain('Registry rebuilt');
597
+ expect(clean).toContain('built-in');
598
+ });
599
+ });
600
+
601
+ // =====================================================================
602
+ // Group G — npx commands (published package)
603
+ // =====================================================================
604
+
605
+ describe('CLI E2E: npx @shaykec/claude-teach', () => {
606
+ it('npx --version outputs semver', () => {
607
+ const { stdout, exitCode } = runNpx(['--version']);
608
+ expect(exitCode).toBe(0);
609
+ expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
610
+ });
611
+
612
+ it('npx list shows available modules', () => {
613
+ const { stdout, exitCode } = runNpx(['list']);
614
+ const clean = stripAnsi(stdout);
615
+ expect(exitCode).toBe(0);
616
+ expect(clean).toContain('Available Modules');
617
+ expect(clean).toContain('git');
618
+ });
619
+
620
+ it('npx stats shows dashboard', () => {
621
+ const { stdout, exitCode } = runNpx(['stats']);
622
+ const clean = stripAnsi(stdout);
623
+ expect(exitCode).toBe(0);
624
+ expect(clean).toContain('ClaudeTeach Dashboard');
625
+ });
626
+
627
+ it('npx get git outputs module content', () => {
628
+ const { stdout, exitCode } = runNpx(['get', 'git']);
629
+ expect(exitCode).toBe(0);
630
+ expect(stdout.length).toBeGreaterThan(100);
631
+ });
632
+
633
+ it('npx --help shows description', () => {
634
+ const { stdout, exitCode } = runNpx(['--help']);
635
+ expect(exitCode).toBe(0);
636
+ expect(stdout).toContain('Socratic AI teaching platform');
637
+ });
638
+ });
639
+
640
+ // =====================================================================
641
+ // Group H — Plugin integration (setup + start)
642
+ // =====================================================================
643
+
644
+ describe('CLI E2E: Plugin integration', () => {
645
+ it('setup outputs plugin and extension paths', () => {
646
+ const { stdout, exitCode } = runCli(['setup', '--show-path']);
647
+ expect(exitCode).toBe(0);
648
+ expect(stdout.trim()).toContain('packages/plugin');
649
+ });
650
+
651
+ it('setup shows checklist with plugin and extension info', () => {
652
+ // setup without --show-path prints the full checklist but also
653
+ // starts the bridge + opens browser. We can't let it do that in tests.
654
+ // Instead, just test --show-path (machine-readable) works.
655
+ const { stdout, exitCode } = runCli(['setup', '--show-path']);
656
+ expect(exitCode).toBe(0);
657
+ expect(stdout.trim().length).toBeGreaterThan(0);
658
+ });
659
+
660
+ it('start --help shows command description', () => {
661
+ const { stdout, exitCode } = runCli(['start', '--help']);
662
+ expect(exitCode).toBe(0);
663
+ const clean = stripAnsi(stdout);
664
+ expect(clean).toContain('Launch Claude Code');
665
+ });
666
+
667
+ it('setup --help shows command description', () => {
668
+ const { stdout, exitCode } = runCli(['setup', '--help']);
669
+ expect(exitCode).toBe(0);
670
+ const clean = stripAnsi(stdout);
671
+ expect(clean).toContain('Check environment');
672
+ });
673
+ });
package/src/cli.js CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  import { Command } from 'commander';
4
4
  import { readFileSync, existsSync } from 'fs';
5
- import { resolve, join } from 'path';
5
+ import { resolve, join, dirname } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
- import { dirname } from 'path';
7
+ import { exec, spawn, execFileSync as execFileSyncChild } from 'child_process';
8
+ import { createRequire } from 'module';
8
9
  import chalk from 'chalk';
9
10
  import { buildRegistry, loadRegistry, getModule } from './registry.js';
10
11
  import { loadProgress, saveProgress, getStats } from './progress.js';
@@ -14,6 +15,73 @@ const __filename = fileURLToPath(import.meta.url);
14
15
  const __dirname = dirname(__filename);
15
16
  const ROOT = resolve(__dirname, '..', '..', '..');
16
17
 
18
+ // =====================================================================
19
+ // Helpers — plugin, extension, and Claude Code resolution
20
+ // =====================================================================
21
+
22
+ const require_ = createRequire(import.meta.url);
23
+
24
+ /** Resolve the Claude Code plugin directory. */
25
+ function resolvePluginDir() {
26
+ // Strategy 1: monorepo layout
27
+ const monoRepo = join(ROOT, 'packages', 'plugin');
28
+ if (existsSync(join(monoRepo, '.claude-plugin', 'plugin.json'))) return monoRepo;
29
+ // Strategy 2: resolve installed @shaykec/plugin package
30
+ try {
31
+ const pkgPath = require_.resolve('@shaykec/plugin/package.json');
32
+ const dir = dirname(pkgPath);
33
+ if (existsSync(join(dir, '.claude-plugin', 'plugin.json'))) return dir;
34
+ } catch { /* not installed */ }
35
+ return null;
36
+ }
37
+
38
+ /** Resolve the Chrome extension directory. */
39
+ function resolveExtensionDir() {
40
+ const monoRepo = join(ROOT, 'packages', 'extension');
41
+ if (existsSync(join(monoRepo, 'manifest.json'))) return monoRepo;
42
+ try {
43
+ const pkgPath = require_.resolve('@shaykec/extension/package.json');
44
+ const dir = dirname(pkgPath);
45
+ if (existsSync(join(dir, 'manifest.json'))) return dir;
46
+ } catch { /* not installed */ }
47
+ return null;
48
+ }
49
+
50
+ /** Check if the `claude` CLI is available on PATH. */
51
+ function findClaude() {
52
+ try {
53
+ const version = execFileSyncChild('claude', ['--version'], {
54
+ stdio: 'pipe', encoding: 'utf-8',
55
+ }).trim();
56
+ return { found: true, version };
57
+ } catch {
58
+ return { found: false, version: null };
59
+ }
60
+ }
61
+
62
+ /** Open the extension folder + chrome://extensions page (cross-platform). */
63
+ function openExtensionInstall(extensionDir) {
64
+ const platform = process.platform;
65
+ if (platform === 'darwin') {
66
+ exec(`open "${extensionDir}"`);
67
+ exec('open -a "Google Chrome" "chrome://extensions"');
68
+ } else if (platform === 'linux') {
69
+ exec(`xdg-open "${extensionDir}"`);
70
+ exec('google-chrome "chrome://extensions" 2>/dev/null || chromium-browser "chrome://extensions" 2>/dev/null');
71
+ } else if (platform === 'win32') {
72
+ exec(`explorer "${extensionDir}"`);
73
+ exec('start chrome "chrome://extensions"');
74
+ }
75
+ }
76
+
77
+ /** Open a URL in the default browser (cross-platform). */
78
+ function openUrl(url) {
79
+ const platform = process.platform;
80
+ if (platform === 'darwin') exec(`open "${url}"`);
81
+ else if (platform === 'linux') exec(`xdg-open "${url}"`);
82
+ else if (platform === 'win32') exec(`start "" "${url}"`);
83
+ }
84
+
17
85
  const program = new Command();
18
86
 
19
87
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
@@ -36,6 +104,10 @@ Examples:
36
104
  $ claude-teach search docker Search registries for packs
37
105
  $ claude-teach author init my-pack Scaffold a new module pack
38
106
  $ claude-teach author validate ./my-pack Validate a pack
107
+ $ claude-teach start Launch Claude Code with teaching skills
108
+ $ claude-teach start -p "teach me git" One-shot teaching prompt
109
+ $ claude-teach setup Check environment and plugin path
110
+ $ claude-teach setup --extension Install the Chrome extension
39
111
  $ claude-teach serve Start the bridge server for visuals`)
40
112
  .version(pkg.version);
41
113
 
@@ -218,6 +290,138 @@ program
218
290
  showInbox();
219
291
  });
220
292
 
293
+ // =====================================================================
294
+ // Plugin integration commands — launch Claude Code, setup environment
295
+ // =====================================================================
296
+
297
+ // --- start ---
298
+ program
299
+ .command('start')
300
+ .description('Launch Claude Code with ClaudeTeach — starts bridge server + loads teaching skills')
301
+ .option('--no-server', 'Skip starting the bridge server')
302
+ .option('--port <port>', 'Bridge server port', '3456')
303
+ .allowUnknownOption(true)
304
+ .action(async (opts, cmd) => {
305
+ // 1. Resolve plugin
306
+ const pluginDir = resolvePluginDir();
307
+ if (!pluginDir) {
308
+ console.error(chalk.red('Could not find the ClaudeTeach plugin directory.'));
309
+ console.error('Make sure @shaykec/plugin is installed or you are in the ClaudeTeach repo.');
310
+ process.exit(1);
311
+ }
312
+
313
+ // 2. Check claude
314
+ const claude = findClaude();
315
+ if (!claude.found) {
316
+ console.error(chalk.red('Claude Code CLI not found.'));
317
+ console.error('Install it from: https://claude.ai/download');
318
+ process.exit(1);
319
+ }
320
+
321
+ // 3. Start bridge server (unless --no-server)
322
+ let server;
323
+ if (opts.server !== false) {
324
+ try {
325
+ const { startServer } = await import('@shaykec/bridge');
326
+ server = startServer({
327
+ port: parseInt(opts.port, 10),
328
+ progressProvider: { getProgress: () => loadProgress(ROOT) },
329
+ });
330
+ console.log(chalk.dim(` Bridge server on port ${opts.port}`));
331
+ } catch (err) {
332
+ console.warn(chalk.yellow(` Bridge server failed to start: ${err.message}`));
333
+ }
334
+ }
335
+
336
+ // 4. Launch
337
+ console.log(chalk.green('Launching Claude Code with ClaudeTeach...'));
338
+ console.log(chalk.dim(` Plugin: ${pluginDir}`));
339
+
340
+ const claudeArgs = ['--plugin-dir', pluginDir, ...cmd.args];
341
+ const child = spawn('claude', claudeArgs, { stdio: 'inherit' });
342
+
343
+ child.on('error', (err) => {
344
+ console.error(chalk.red(`Failed to start Claude Code: ${err.message}`));
345
+ if (server) server.close();
346
+ process.exit(1);
347
+ });
348
+
349
+ child.on('close', (code) => {
350
+ if (server) server.close();
351
+ process.exit(code ?? 0);
352
+ });
353
+ });
354
+
355
+ // --- setup ---
356
+ program
357
+ .command('setup')
358
+ .description('Check environment, show plugin status, and install Chrome extension')
359
+ .option('--show-path', 'Print only the plugin directory path')
360
+ .option('--extension', 'Open Chrome to install the ClaudeTeach extension')
361
+ .action(async (opts) => {
362
+ const pluginDir = resolvePluginDir();
363
+ const extensionDir = resolveExtensionDir();
364
+
365
+ // --show-path: machine-readable plugin path
366
+ if (opts.showPath) {
367
+ if (!pluginDir) process.exit(1);
368
+ console.log(pluginDir);
369
+ return;
370
+ }
371
+
372
+ // --extension: open Finder + Chrome extensions page
373
+ if (opts.extension) {
374
+ if (!extensionDir) {
375
+ console.error(chalk.red('Could not find the ClaudeTeach extension directory.'));
376
+ process.exit(1);
377
+ }
378
+ openExtensionInstall(extensionDir);
379
+ console.log(chalk.green('\nChrome Extensions page + extension folder opened.'));
380
+ console.log(`\n 1. Enable ${chalk.cyan('Developer mode')} (toggle, top-right)`);
381
+ console.log(` 2. Click ${chalk.cyan('Load unpacked')}`);
382
+ console.log(` 3. Select the folder that just opened: ${chalk.cyan(extensionDir)}`);
383
+ return;
384
+ }
385
+
386
+ // Default: full setup — checklist + welcome page
387
+ const claude = findClaude();
388
+
389
+ console.log(chalk.bold('\n ClaudeTeach Setup\n'));
390
+ console.log(` Plugin path: ${pluginDir ? chalk.green(pluginDir) : chalk.red('not found')}`);
391
+ console.log(` Extension path: ${extensionDir ? chalk.green(extensionDir) : chalk.red('not found')}`);
392
+ console.log(` Claude Code: ${claude.found ? chalk.green(claude.version) : chalk.red('not installed')}`);
393
+
394
+ if (!pluginDir) {
395
+ console.error(chalk.red('\n Plugin not found. Make sure @shaykec/plugin is installed.'));
396
+ process.exit(1);
397
+ }
398
+
399
+ if (!claude.found) {
400
+ console.log(chalk.yellow('\n Claude Code not found. Install from: https://claude.ai/download'));
401
+ }
402
+
403
+ // Start bridge + open welcome page
404
+ let server;
405
+ try {
406
+ const { startServer } = await import('@shaykec/bridge');
407
+ const port = 3456;
408
+ server = startServer({
409
+ port,
410
+ progressProvider: { getProgress: () => loadProgress(ROOT) },
411
+ });
412
+ console.log(chalk.dim(`\n Bridge server started on port ${port}`));
413
+ openUrl(`http://localhost:${port}/?mode=welcome`);
414
+ console.log(chalk.green(' Welcome page opened in your browser.'));
415
+ } catch (err) {
416
+ console.warn(chalk.yellow(`\n Could not start bridge server: ${err.message}`));
417
+ }
418
+
419
+ console.log(chalk.bold('\n Next steps:\n'));
420
+ console.log(` ${chalk.cyan('claude-teach start')} Launch Claude Code with teaching skills`);
421
+ console.log(` ${chalk.cyan('claude-teach setup --extension')} Install the Chrome extension`);
422
+ console.log();
423
+ });
424
+
221
425
  // =====================================================================
222
426
  // System commands
223
427
  // =====================================================================