@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 +5 -3
- package/src/cli.e2e.helpers.js +289 -0
- package/src/cli.e2e.test.js +673 -0
- package/src/cli.js +206 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shaykec/claude-teach",
|
|
3
|
-
"version": "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.
|
|
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 {
|
|
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
|
// =====================================================================
|