@shaykec/claude-teach 0.2.0 → 0.4.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 +962 -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.4.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.3.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
|
+
}
|