@shaykec/core 0.1.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/src/author.js ADDED
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Module authoring tools — scaffold, validate, and preview module packs.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, symlinkSync, rmSync, statSync } from 'fs';
6
+ import { join, resolve, basename } from 'path';
7
+ import yaml from 'js-yaml';
8
+ import chalk from 'chalk';
9
+ import { ensureHome, LOCAL_MODULES } from './marketplace.js';
10
+
11
+ /* ------------------------------------------------------------------ */
12
+ /* Scaffold a new module pack */
13
+ /* ------------------------------------------------------------------ */
14
+
15
+ /**
16
+ * Create a new module pack directory with the required structure.
17
+ * <cwd>/<name>/
18
+ * pack.yaml
19
+ * modules/
20
+ * README.md
21
+ */
22
+ export async function initPack(name) {
23
+ const dest = resolve(process.cwd(), name);
24
+
25
+ if (existsSync(dest)) {
26
+ throw new Error(`Directory "${name}" already exists.`);
27
+ }
28
+
29
+ mkdirSync(join(dest, 'modules'), { recursive: true });
30
+
31
+ const packYaml = {
32
+ name,
33
+ author: '',
34
+ description: '',
35
+ version: '1.0.0',
36
+ modules: [],
37
+ };
38
+
39
+ writeFileSync(
40
+ join(dest, 'pack.yaml'),
41
+ yaml.dump(packYaml, { lineWidth: 120 }),
42
+ 'utf-8',
43
+ );
44
+
45
+ writeFileSync(
46
+ join(dest, 'README.md'),
47
+ [
48
+ `# ${name}`,
49
+ '',
50
+ 'A ClaudeTeach module pack.',
51
+ '',
52
+ '## Modules',
53
+ '',
54
+ '_None yet. Run `claude-teach author add <pack>/<module>` to add one._',
55
+ '',
56
+ '## Install',
57
+ '',
58
+ '```bash',
59
+ `claude-teach install <your-git-url>`,
60
+ '```',
61
+ '',
62
+ ].join('\n'),
63
+ 'utf-8',
64
+ );
65
+
66
+ console.log(chalk.green(`Pack "${name}" scaffolded at ${dest}`));
67
+ console.log(` Next: cd ${name} && claude-teach author add ${name}/my-module`);
68
+ }
69
+
70
+ /* ------------------------------------------------------------------ */
71
+ /* Add a module to a pack */
72
+ /* ------------------------------------------------------------------ */
73
+
74
+ /**
75
+ * Add a new module inside an existing pack.
76
+ * <packPath>/modules/<moduleName>/
77
+ * module.yaml
78
+ * content.md
79
+ *
80
+ * @param {string} packPath — path to the pack directory
81
+ * @param {string} moduleName — slug for the new module
82
+ */
83
+ export async function addModule(packPath, moduleName) {
84
+ const absPackPath = resolve(process.cwd(), packPath);
85
+
86
+ if (!existsSync(join(absPackPath, 'pack.yaml'))) {
87
+ throw new Error(`No pack.yaml found at "${absPackPath}". Is this a module pack?`);
88
+ }
89
+
90
+ const moduleDir = join(absPackPath, 'modules', moduleName);
91
+ if (existsSync(moduleDir)) {
92
+ throw new Error(`Module "${moduleName}" already exists in this pack.`);
93
+ }
94
+
95
+ mkdirSync(moduleDir, { recursive: true });
96
+
97
+ const moduleYaml = {
98
+ slug: moduleName,
99
+ title: moduleName.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
100
+ version: '1.0.0',
101
+ description: '',
102
+ category: 'uncategorized',
103
+ tags: [],
104
+ difficulty: 'beginner',
105
+ xp: { read: 10 },
106
+ time: { read: 10 },
107
+ prerequisites: [],
108
+ related: [],
109
+ triggers: [],
110
+ };
111
+
112
+ writeFileSync(
113
+ join(moduleDir, 'module.yaml'),
114
+ yaml.dump(moduleYaml, { lineWidth: 120 }),
115
+ 'utf-8',
116
+ );
117
+
118
+ writeFileSync(
119
+ join(moduleDir, 'content.md'),
120
+ [
121
+ `# ${moduleYaml.title}`,
122
+ '',
123
+ '_Write your module content here._',
124
+ '',
125
+ ].join('\n'),
126
+ 'utf-8',
127
+ );
128
+
129
+ // Update pack.yaml modules list
130
+ const packYamlPath = join(absPackPath, 'pack.yaml');
131
+ const pack = yaml.load(readFileSync(packYamlPath, 'utf-8')) || {};
132
+ if (!Array.isArray(pack.modules)) pack.modules = [];
133
+ if (!pack.modules.includes(moduleName)) {
134
+ pack.modules.push(moduleName);
135
+ writeFileSync(packYamlPath, yaml.dump(pack, { lineWidth: 120 }), 'utf-8');
136
+ }
137
+
138
+ console.log(chalk.green(`Module "${moduleName}" added to pack at ${moduleDir}`));
139
+ }
140
+
141
+ /* ------------------------------------------------------------------ */
142
+ /* Validate a pack */
143
+ /* ------------------------------------------------------------------ */
144
+
145
+ /**
146
+ * Validate a module pack for correctness.
147
+ *
148
+ * Checks:
149
+ * - pack.yaml exists and has required fields (name, version)
150
+ * - Each listed module has a directory with module.yaml
151
+ * - Each module.yaml has required fields (slug, title)
152
+ * - Referenced files (content.md, walkthrough.md, etc.) exist
153
+ * - XP values are numbers
154
+ * - Triggers are arrays
155
+ * - Prerequisites reference valid slugs (within pack or built-in)
156
+ *
157
+ * @param {string} packPath — path to the pack directory
158
+ * @param {string[]} [builtinSlugs] — optional list of built-in module slugs for prereq validation
159
+ * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
160
+ */
161
+ export async function validatePack(packPath, builtinSlugs = []) {
162
+ const absPath = resolve(process.cwd(), packPath);
163
+ const errors = [];
164
+ const warnings = [];
165
+
166
+ // 1. pack.yaml
167
+ const packYamlPath = join(absPath, 'pack.yaml');
168
+ if (!existsSync(packYamlPath)) {
169
+ errors.push('Missing pack.yaml');
170
+ return { valid: false, errors, warnings };
171
+ }
172
+
173
+ let pack;
174
+ try {
175
+ pack = yaml.load(readFileSync(packYamlPath, 'utf-8'));
176
+ } catch (err) {
177
+ errors.push(`pack.yaml parse error: ${err.message}`);
178
+ return { valid: false, errors, warnings };
179
+ }
180
+
181
+ if (!pack.name) errors.push('pack.yaml: missing "name" field');
182
+ if (!pack.version) warnings.push('pack.yaml: missing "version" field');
183
+ if (!pack.author) warnings.push('pack.yaml: missing "author" field');
184
+ if (!pack.description) warnings.push('pack.yaml: missing "description" field');
185
+
186
+ // 2. Modules directory
187
+ const modulesDir = join(absPath, 'modules');
188
+ if (!existsSync(modulesDir)) {
189
+ errors.push('Missing modules/ directory');
190
+ return { valid: errors.length === 0, errors, warnings };
191
+ }
192
+
193
+ const listedModules = Array.isArray(pack.modules) ? pack.modules : [];
194
+ const allPackSlugs = [];
195
+
196
+ // Scan actual module directories
197
+ const actualDirs = readdirSync(modulesDir).filter(name => {
198
+ const full = join(modulesDir, name);
199
+ return existsSync(full) && statSync(full).isDirectory();
200
+ });
201
+
202
+ // Warn about modules listed in pack.yaml but not found on disk
203
+ for (const listed of listedModules) {
204
+ if (!actualDirs.includes(listed)) {
205
+ errors.push(`Module "${listed}" is listed in pack.yaml but has no directory`);
206
+ }
207
+ }
208
+
209
+ // Warn about directories not listed in pack.yaml
210
+ for (const dir of actualDirs) {
211
+ if (!listedModules.includes(dir)) {
212
+ warnings.push(`Directory "modules/${dir}" exists but is not listed in pack.yaml`);
213
+ }
214
+ }
215
+
216
+ // 3. Validate each module directory
217
+ for (const dirName of actualDirs) {
218
+ const moduleDir = join(modulesDir, dirName);
219
+ const moduleYamlPath = join(moduleDir, 'module.yaml');
220
+
221
+ if (!existsSync(moduleYamlPath)) {
222
+ errors.push(`modules/${dirName}: missing module.yaml`);
223
+ continue;
224
+ }
225
+
226
+ let mod;
227
+ try {
228
+ mod = yaml.load(readFileSync(moduleYamlPath, 'utf-8'));
229
+ } catch (err) {
230
+ errors.push(`modules/${dirName}/module.yaml: parse error — ${err.message}`);
231
+ continue;
232
+ }
233
+
234
+ const slug = mod.slug || dirName;
235
+ allPackSlugs.push(slug);
236
+
237
+ if (!mod.slug) warnings.push(`modules/${dirName}: missing "slug" field`);
238
+ if (!mod.title) errors.push(`modules/${dirName}: missing "title" field`);
239
+
240
+ // content.md must exist
241
+ if (!existsSync(join(moduleDir, 'content.md'))) {
242
+ errors.push(`modules/${dirName}: missing content.md`);
243
+ }
244
+
245
+ // Optional files — warn if referenced but missing (via capabilities detection)
246
+ for (const opt of ['walkthrough.md', 'exercises.md', 'quiz.md', 'quick-ref.md']) {
247
+ // We don't error if optional files are missing — they're optional.
248
+ // But we do check if referenced in visuals or similar.
249
+ }
250
+
251
+ // XP values must be numbers
252
+ if (mod.xp && typeof mod.xp === 'object') {
253
+ for (const [key, val] of Object.entries(mod.xp)) {
254
+ if (typeof val !== 'number') {
255
+ errors.push(`modules/${dirName}: xp.${key} must be a number, got ${typeof val}`);
256
+ }
257
+ }
258
+ }
259
+
260
+ // Triggers must be an array
261
+ if (mod.triggers !== undefined && !Array.isArray(mod.triggers)) {
262
+ errors.push(`modules/${dirName}: triggers must be an array`);
263
+ }
264
+
265
+ // Time values must be numbers
266
+ if (mod.time && typeof mod.time === 'object') {
267
+ for (const [key, val] of Object.entries(mod.time)) {
268
+ if (typeof val !== 'number') {
269
+ errors.push(`modules/${dirName}: time.${key} must be a number, got ${typeof val}`);
270
+ }
271
+ }
272
+ }
273
+ }
274
+
275
+ // 4. Validate prerequisites
276
+ const validSlugs = new Set([...allPackSlugs, ...builtinSlugs]);
277
+ for (const dirName of actualDirs) {
278
+ const moduleYamlPath = join(modulesDir, dirName, 'module.yaml');
279
+ if (!existsSync(moduleYamlPath)) continue;
280
+ try {
281
+ const mod = yaml.load(readFileSync(moduleYamlPath, 'utf-8'));
282
+ const prereqs = mod.prerequisites || [];
283
+ for (const prereq of prereqs) {
284
+ if (!validSlugs.has(prereq)) {
285
+ warnings.push(`modules/${dirName}: prerequisite "${prereq}" not found in pack or built-in modules`);
286
+ }
287
+ }
288
+ } catch {
289
+ // Already reported parse error above
290
+ }
291
+ }
292
+
293
+ const valid = errors.length === 0;
294
+ return { valid, errors, warnings };
295
+ }
296
+
297
+ /* ------------------------------------------------------------------ */
298
+ /* Preview a module locally */
299
+ /* ------------------------------------------------------------------ */
300
+
301
+ /**
302
+ * Preview a module by copying/symlinking it to ~/.claude-teach/modules/local/_preview/.
303
+ *
304
+ * @param {string} modulePath — path to a module directory (must contain module.yaml)
305
+ */
306
+ export async function previewModule(modulePath) {
307
+ ensureHome();
308
+
309
+ const absPath = resolve(process.cwd(), modulePath);
310
+
311
+ if (!existsSync(join(absPath, 'module.yaml'))) {
312
+ throw new Error(`No module.yaml found at "${absPath}". Point to a module directory.`);
313
+ }
314
+
315
+ const previewDir = join(LOCAL_MODULES, '_preview');
316
+ if (!existsSync(previewDir)) {
317
+ mkdirSync(previewDir, { recursive: true });
318
+ }
319
+
320
+ const moduleName = basename(absPath);
321
+ const dest = join(previewDir, moduleName);
322
+
323
+ // Remove previous preview of the same module
324
+ if (existsSync(dest)) {
325
+ rmSync(dest, { recursive: true, force: true });
326
+ }
327
+
328
+ // Try symlink first, fall back to copy
329
+ try {
330
+ symlinkSync(absPath, dest, 'dir');
331
+ } catch {
332
+ // Symlink failed (e.g. Windows without admin), copy instead
333
+ copyDirRecursive(absPath, dest);
334
+ }
335
+
336
+ console.log(chalk.green(`Previewing "${moduleName}" — available in the module registry.`));
337
+ console.log(` Run ${chalk.cyan('claude-teach list')} to verify.`);
338
+ console.log(` To stop previewing, delete ${dest}`);
339
+ }
340
+
341
+ /**
342
+ * Simple recursive directory copy (no external deps).
343
+ */
344
+ function copyDirRecursive(src, dest) {
345
+ mkdirSync(dest, { recursive: true });
346
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
347
+ const srcPath = join(src, entry.name);
348
+ const destPath = join(dest, entry.name);
349
+ if (entry.isDirectory()) {
350
+ copyDirRecursive(srcPath, destPath);
351
+ } else {
352
+ writeFileSync(destPath, readFileSync(srcPath));
353
+ }
354
+ }
355
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * DEPRECATED — Bridge server has moved to @shaykec/bridge.
3
+ *
4
+ * This file is preserved for backward compatibility.
5
+ * The `serve` command in cli.js now uses the new bridge package.
6
+ * The `showInbox` function is re-exported from the new location.
7
+ *
8
+ * Migration:
9
+ * Old: import { startServer } from './bridge-server.js'
10
+ * New: import { startServer } from '@shaykec/bridge'
11
+ */
12
+
13
+ import { readFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
14
+ import { join } from 'path';
15
+
16
+ const INBOX_DIR = '.teach/inbox';
17
+
18
+ /**
19
+ * Start the bridge server — delegates to @shaykec/bridge.
20
+ * @deprecated Use `import { startServer } from '@shaykec/bridge'` instead.
21
+ */
22
+ export async function startServer(port = 3456) {
23
+ const { startServer: start } = await import('@shaykec/bridge');
24
+ return start({ port });
25
+ }
26
+
27
+ /**
28
+ * Show inbox contents.
29
+ * Preserved here since the inbox command still references this file.
30
+ */
31
+ export function showInbox() {
32
+ ensureInboxDir();
33
+
34
+ const files = readdirSync(INBOX_DIR).filter(f => f.endsWith('.json')).sort();
35
+
36
+ if (files.length === 0) {
37
+ console.log('Inbox is empty. Start the bridge server and capture content from your browser.');
38
+ return;
39
+ }
40
+
41
+ console.log(`\n Inbox (${files.length} items)\n`);
42
+ for (const file of files) {
43
+ try {
44
+ const data = JSON.parse(readFileSync(join(INBOX_DIR, file), 'utf-8'));
45
+ const title = data.title || 'Untitled';
46
+ const url = data.url || '';
47
+ console.log(` \u2022 ${title}`);
48
+ if (url) console.log(` ${url}`);
49
+ } catch {
50
+ console.log(` \u2022 ${file} (unreadable)`);
51
+ }
52
+ }
53
+ console.log();
54
+ }
55
+
56
+ function ensureInboxDir() {
57
+ if (!existsSync(INBOX_DIR)) {
58
+ mkdirSync(INBOX_DIR, { recursive: true });
59
+ }
60
+ }