@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaykec/claude-teach",
3
- "version": "0.2.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.1.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
+ }