@antoneeo/course-creator 0.1.0-beta.1

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +25 -0
  5. package/gemini-extension.json +6 -0
  6. package/package.json +52 -0
  7. package/scripts/init.js +62 -0
  8. package/scripts/lib.js +734 -0
  9. package/scripts/postinstall.js +16 -0
  10. package/scripts/preuninstall.js +12 -0
  11. package/skills/course-creator/ENFORCEMENT.md +272 -0
  12. package/skills/course-creator/LICENSE +202 -0
  13. package/skills/course-creator/NOTICE +5 -0
  14. package/skills/course-creator/SKILL.md +95 -0
  15. package/skills/course-creator/dispatch.md +140 -0
  16. package/skills/course-creator/elicitation.md +196 -0
  17. package/skills/course-creator/feedback.md +7 -0
  18. package/skills/course-creator/guides.md +291 -0
  19. package/skills/course-creator/learning_design.md +79 -0
  20. package/skills/course-creator/memory.md +88 -0
  21. package/skills/course-creator/review.md +450 -0
  22. package/skills/course-creator/routing.md +118 -0
  23. package/skills/course-creator/scripts/course_check.py +479 -0
  24. package/skills/course-creator/scripts/entry_point.py +25 -0
  25. package/skills/course-creator/scripts/knowledge.py +2089 -0
  26. package/skills/course-creator/scripts/sdlc_check.py +73 -0
  27. package/skills/course-creator/scripts/sdlc_core.py +2707 -0
  28. package/skills/course-creator/scripts/shared_files.py +215 -0
  29. package/skills/course-creator/scripts/shared_manifest.json +27 -0
  30. package/skills/course-creator/simulation.md +47 -0
  31. package/skills/course-creator/slide_content.md +78 -0
  32. package/skills/course-creator/source_check.md +28 -0
  33. package/skills/course-creator/templates.md +222 -0
  34. package/skills/course-creator/vision.md +245 -0
package/scripts/lib.js ADDED
@@ -0,0 +1,734 @@
1
+ // Shared helpers for the Course Creator npm scripts (init / postinstall / preuninstall).
2
+ // Single source for client detection and skill-target paths.
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const crypto = require('crypto');
8
+ const { execSync } = require('child_process');
9
+
10
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
11
+ const SKILL_SOURCE = path.join(PACKAGE_ROOT, 'skills', 'course-creator');
12
+ const TEMPLATES_PATH = path.join(SKILL_SOURCE, 'templates.md');
13
+ // The directory name each client loads the skill from. Derived from the manifest,
14
+ // never hard-coded by a consumer: four distributions share these scripts, and a
15
+ // literal here is how a copy-fork starts installing under its sibling's name.
16
+ const INSTALLED_SKILL_NAME = (() => {
17
+ const m = fs.readFileSync(path.join(SKILL_SOURCE, 'SKILL.md'), 'utf8').match(/^name:\s*(\S+)/m);
18
+ if (!m) throw new Error(`SKILL.md carries no 'name:' field: ${SKILL_SOURCE}`);
19
+ return m[1];
20
+ })();
21
+
22
+ // The family's lens table, read from the shared `routing.md` rather than restated
23
+ // here. Same reason as INSTALLED_SKILL_NAME above, and the same failure it prevents:
24
+ // BOTH the row for this lens and the rows for its siblings used to be literals copied
25
+ // between distributions, so kb and mkt wrote a multi-lens note announcing themselves
26
+ // as the code lens. routing.md is one of the byte-identical shared files, so this
27
+ // lookup cannot drift between distributions.
28
+ // Lazy on purpose: preuninstall.js needs INSTALLED_SKILL_NAME and nothing else, and
29
+ // must keep working on an installation damaged badly enough to have lost routing.md.
30
+ function lensTable() {
31
+ const routing = path.join(SKILL_SOURCE, 'routing.md');
32
+ if (!fs.existsSync(routing)) throw new Error(`routing.md missing from the skill: ${SKILL_SOURCE}`);
33
+ // Parsed as a table, not matched with a regex: a mis-escaped pattern matches the
34
+ // empty string and yields undefined instead of throwing, which is how this lookup
35
+ // failed the first time it was written.
36
+ const table = new Map();
37
+ for (const line of fs.readFileSync(routing, 'utf8').split(/\r?\n/)) {
38
+ const cells = line.split('|').map((cell) => cell.trim());
39
+ if (cells.length < 4) continue;
40
+ const [, lens, skill] = cells;
41
+ if (!/^[a-z]+$/.test(lens) || !/^`[a-z0-9-]+`$/.test(skill)) continue;
42
+ const name = skill.slice(1, -1);
43
+ // A duplicate row would otherwise be won silently by whichever came first, and a
44
+ // wrong routing.md propagates byte-identically to every distribution.
45
+ if (table.has(name)) throw new Error(`routing.md lists '${name}' more than once`);
46
+ table.set(name, lens);
47
+ }
48
+ if (!table.size) throw new Error(`routing.md carries no lens table: ${routing}`);
49
+ return table;
50
+ }
51
+
52
+ function selfLens() {
53
+ const lens = lensTable().get(INSTALLED_SKILL_NAME);
54
+ if (!lens) throw new Error(`routing.md has no lens row for '${INSTALLED_SKILL_NAME}'`);
55
+ return lens;
56
+ }
57
+
58
+ // Sibling lenses of the same family: one shared core, one docs tree, a different
59
+ // fidelity discipline each. Keyed by the installed skill directory name.
60
+ function siblingLenses() {
61
+ const table = lensTable();
62
+ selfLens(); // this lens must be in the table too
63
+ table.delete(INSTALLED_SKILL_NAME);
64
+ return Object.fromEntries(table);
65
+ }
66
+
67
+ // One entry per supported AI client. `home` may be overridden by an env var
68
+ // (Claude Desktop / portable installs); presence of the home dir counts as
69
+ // detection even when the CLI is not on PATH.
70
+
71
+ const CLIENTS = [
72
+ {
73
+ key: 'claude',
74
+ label: 'Claude Code',
75
+ cmd: 'claude',
76
+ home: process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'),
77
+ envVar: 'CLAUDE_CONFIG_DIR',
78
+ reload: 'Restart Claude Code to load it. Invoke via Skill tool as "course-creator".',
79
+ },
80
+ {
81
+ key: 'gemini',
82
+ label: 'Gemini CLI',
83
+ cmd: 'gemini',
84
+ home: process.env.GEMINI_HOME || path.join(os.homedir(), '.gemini'),
85
+ envVar: 'GEMINI_HOME',
86
+ reload: 'Run "gemini skills reload" or restart Gemini CLI to load it.',
87
+ },
88
+ {
89
+ key: 'codex',
90
+ label: 'Codex AI',
91
+ cmd: 'codex',
92
+ home: process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
93
+ envVar: 'CODEX_HOME',
94
+ reload: 'Restart Codex to load it. Invoke it as "$course-creator" or by asking for Course Creator.',
95
+ },
96
+ {
97
+ key: 'antigravity',
98
+ label: 'Google Antigravity',
99
+ cmd: 'agy',
100
+ home: process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini'),
101
+ envVar: 'ANTIGRAVITY_HOME',
102
+ skillsSubdir: 'config/skills',
103
+ homeMarker: path.join(
104
+ process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini'),
105
+ 'config',
106
+ 'skills',
107
+ ),
108
+ reload: 'Restart Antigravity, or run "agy skills reload", to load it. Invoke by asking for Course Creator.',
109
+ },
110
+ ];
111
+
112
+ function commandExists(cmd) {
113
+ try {
114
+ execSync(`${cmd} --version`, { stdio: 'ignore' });
115
+ return true;
116
+ } catch (e) {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function clientDetected(client) {
122
+ const homePathToCheck = client.homeMarker || client.home;
123
+ return commandExists(client.cmd)
124
+ || Boolean(process.env[client.envVar])
125
+ || fs.existsSync(homePathToCheck);
126
+ }
127
+
128
+ function skillTarget(client) {
129
+ const subdir = client.skillsSubdir ? client.skillsSubdir.split('/') : ['skills'];
130
+ return path.join(client.home, ...subdir, INSTALLED_SKILL_NAME);
131
+ }
132
+
133
+ function copyRecursive(src, dest) {
134
+ if (typeof fs.cpSync === 'function') {
135
+ fs.cpSync(src, dest, { recursive: true, force: true });
136
+ return;
137
+ }
138
+ if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
139
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
140
+ const s = path.join(src, entry.name);
141
+ const d = path.join(dest, entry.name);
142
+ if (entry.isDirectory()) copyRecursive(s, d);
143
+ else fs.copyFileSync(s, d);
144
+ }
145
+ }
146
+
147
+ function loadTemplates() {
148
+ const text = fs.readFileSync(TEMPLATES_PATH, 'utf8');
149
+ const lines = text.split(/\r?\n/);
150
+ const sections = {};
151
+ let heading = null;
152
+ let block = null;
153
+ for (const line of lines) {
154
+ const h = line.match(/^##\s+(.*)$/);
155
+ if (h && block === null) {
156
+ heading = h[1].trim();
157
+ sections[heading] = sections[heading] || [];
158
+ continue;
159
+ }
160
+ if (/^```/.test(line)) {
161
+ if (block === null) {
162
+ block = [];
163
+ } else {
164
+ if (heading) sections[heading].push(block.join('\n') + '\n');
165
+ block = null;
166
+ }
167
+ continue;
168
+ }
169
+ if (block !== null) block.push(line);
170
+ }
171
+ return sections;
172
+ }
173
+
174
+ function templateFor(sections, needle, index = 0) {
175
+ const heading = Object.keys(sections).find((h) => h.includes(needle));
176
+ const blocks = heading ? sections[heading] : undefined;
177
+ if (!blocks || !blocks[index]) {
178
+ throw new Error(
179
+ `Template section containing "${needle}" (block ${index}) not found in ${TEMPLATES_PATH}. ` +
180
+ 'The package is corrupted or templates.md was restructured: fix templates.md, do not improvise content.'
181
+ );
182
+ }
183
+ return blocks[index];
184
+ }
185
+
186
+ // --- SessionStart orientation hook (F-036) ---------------------------------
187
+ // ENFORCEMENT.md §4 says to wire this on every project that has a docs root and
188
+ // a Python interpreter. Until F-036 nothing did: it was a manual step, so it was
189
+ // skipped, and the guide router reached no agent that did not already know to
190
+ // look for it. The hook only INJECTS orientation -- it cannot force a skill
191
+ // invocation, and the mechanism that could (a blocking PreToolUse gate) is
192
+ // refused by the Vision's no-ceremony-ratchet Non-Goal.
193
+
194
+ const ORIENT_HOOK_TIMEOUT = 10;
195
+
196
+ // The validator's filename is DERIVED, never written here: the marketing lens
197
+ // ships `mkt_check.py` where the others ship `sdlc_check.py`, and this block is
198
+ // copied verbatim into all four distributions. A literal would make two of the
199
+ // three look for an entry point that does not exist.
200
+ const ORIENT_ENTRY_POINTS = ['sdlc_check.py', 'mkt_check.py'];
201
+ const ORIENT_ENTRY_POINT = (() => {
202
+ for (const name of ORIENT_ENTRY_POINTS) {
203
+ if (fs.existsSync(path.join(SKILL_SOURCE, 'scripts', name))) return name;
204
+ }
205
+ return ORIENT_ENTRY_POINTS[0];
206
+ })();
207
+
208
+ // Both settings layers are ALWAYS inspected for an existing hook, whichever one
209
+ // we would write to. Scanning only the target file left two holes: a project
210
+ // that starts un-vendored and later vendors flips the target from the local file
211
+ // to the shared one, finds nothing there, and appends a second hook; and a dead
212
+ // hook in the layer we are not writing stays invisible.
213
+ const ORIENT_SETTINGS_FILES = ['settings.json', 'settings.local.json'];
214
+
215
+ // A vendored path is built from a directory name read out of the TARGET
216
+ // repository, and the result is executed by the client as a shell command. That
217
+ // is repo-controlled input reaching a command line, so each segment must match
218
+ // this exactly -- `$(...)`, backticks and newlines are not "unusual names", they
219
+ // are the payload. The absolute branch cannot reach this input (it is built from
220
+ // os.homedir() and the client roster) but is filtered too, below.
221
+ const ORIENT_SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
222
+ // Refused anywhere in a command path. NOT backslash: a Windows path is full of
223
+ // them and the shape this project already commits uses them.
224
+ const ORIENT_UNSAFE_IN_PATH = /["$`\r\n]/;
225
+
226
+ // Where a repo may legitimately vendor the validator. ENFORCEMENT §2 tells users
227
+ // to copy it next to their CI config (`tools/`), and a skill-authoring repo
228
+ // carries it under `skills/<lens>/scripts/`. Both are checked; the lens matching
229
+ // this distribution wins, so a monorepo vendoring several lenses cannot wire a
230
+ // sibling's validator against this lens's docs root.
231
+ function vendoredValidator(cwd) {
232
+ const direct = [];
233
+ for (const dir of ['tools', 'scripts']) {
234
+ direct.push([dir, ORIENT_ENTRY_POINT].join('/'));
235
+ }
236
+ const skillDirs = (() => {
237
+ let entries;
238
+ try {
239
+ entries = fs.readdirSync(path.join(cwd, 'skills'), { withFileTypes: true });
240
+ } catch (e) {
241
+ return []; // no skills/ here, or it is not a directory
242
+ }
243
+ const names = entries.filter((e) => e.isDirectory()).map((e) => e.name)
244
+ .filter((n) => ORIENT_SAFE_SEGMENT.test(n));
245
+ // This lens first: readdir order must not decide which lens we orient.
246
+ names.sort((a, b) => (a === INSTALLED_SKILL_NAME ? -1 : 0)
247
+ - (b === INSTALLED_SKILL_NAME ? -1 : 0));
248
+ return names.map((n) => ['skills', n, 'scripts', ORIENT_ENTRY_POINT].join('/'));
249
+ })();
250
+ for (const rel of direct.concat(skillDirs)) {
251
+ if (fs.existsSync(path.join(cwd, rel))) return rel;
252
+ }
253
+ return null;
254
+ }
255
+
256
+ function orientHookCommand(python, validator, hybrid) {
257
+ return python + ' "' + validator + '" orient' + (hybrid ? ' --hybrid' : '');
258
+ }
259
+
260
+ // The token in an EXISTING command that names a validator -- quoted or bare, and
261
+ // NOT simply "the first quoted thing". That naive rule failed both ways: a
262
+ // command that quotes the interpreter (`"C:\Py\python.exe" "...sdlc_check.py"`)
263
+ // returned the interpreter, which exists, so a DEAD hook reported as healthy;
264
+ // and a command with no quotes at all returned nothing, so a WORKING hook was
265
+ // reported broken. Returns null when no token names a validator: the caller must
266
+ // then say it cannot tell, never that the hook is broken.
267
+ function orientHookValidator(command) {
268
+ const cmd = String(command || '');
269
+ const re = /"([^"]*)"|(\S+)/g;
270
+ let m;
271
+ while ((m = re.exec(cmd)) !== null) {
272
+ const token = m[1] !== undefined ? m[1] : m[2];
273
+ if (ORIENT_ENTRY_POINTS.some((n) => token.endsWith(n))) return token;
274
+ }
275
+ return null;
276
+ }
277
+
278
+ // Accepts both shapes seen in the wild: the documented groups
279
+ // (`SessionStart: [{ hooks: [...] }]`) and hook objects placed directly in the
280
+ // array. Anything else is reported by the caller rather than silently replaced.
281
+ function findOrientHook(settings) {
282
+ const groups = settings && settings.hooks && settings.hooks.SessionStart;
283
+ if (!Array.isArray(groups)) return null;
284
+ for (const group of groups) {
285
+ if (!group || typeof group !== 'object') continue;
286
+ const inner = Array.isArray(group.hooks) ? group.hooks : [group];
287
+ for (const h of inner) {
288
+ const cmd = h && typeof h.command === 'string' ? h.command : '';
289
+ if (ORIENT_ENTRY_POINTS.some((n) => cmd.includes(n)) && / orient(\s|$)/.test(cmd)) {
290
+ return cmd;
291
+ }
292
+ }
293
+ }
294
+ return null;
295
+ }
296
+
297
+ // A settings object we can safely merge into: absent, or carrying the shapes we
298
+ // know. "I do not recognise this" must mean "write nothing", never "treat it as
299
+ // empty" -- the second is how a hand-written hook disappears.
300
+ function orientSettingsState(target) {
301
+ if (!fs.existsSync(target)) return { ok: true, settings: {} };
302
+ let settings;
303
+ try {
304
+ settings = JSON.parse(fs.readFileSync(target, 'utf8'));
305
+ } catch (e) {
306
+ return { ok: false, why: 'unreadable' };
307
+ }
308
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
309
+ return { ok: false, why: 'not-an-object' };
310
+ }
311
+ const hooks = settings.hooks;
312
+ if (hooks !== undefined
313
+ && (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))) {
314
+ return { ok: false, why: 'unexpected-hooks' };
315
+ }
316
+ if (hooks && hooks.SessionStart !== undefined && !Array.isArray(hooks.SessionStart)) {
317
+ return { ok: false, why: 'unexpected-hooks' };
318
+ }
319
+ return { ok: true, settings };
320
+ }
321
+
322
+ // Returns a RESULT CODE, never printed text: the caller owns the wording and the
323
+ // battery asserts on the code. Codes:
324
+ // wired | already | broken | unverifiable | malformed
325
+ // no-validator | no-python | unsafe-path | write-failed
326
+ function wireOrientHook(options) {
327
+ const cwd = options.cwd;
328
+ const client = options.client;
329
+ const python = options.python;
330
+ const hybrid = !!options.hybrid;
331
+ const docsLabel = options.docsLabel || 'project';
332
+
333
+ const vendored = vendoredValidator(cwd);
334
+ const absolute = path.join(skillTarget(client), 'scripts', ORIENT_ENTRY_POINT);
335
+ const validator = vendored || absolute;
336
+ if (!vendored && !fs.existsSync(absolute)) return { code: 'no-validator', validator };
337
+ if (!python) return { code: 'no-python', validator };
338
+ if (ORIENT_UNSAFE_IN_PATH.test(validator)) return { code: 'unsafe-path', validator };
339
+
340
+ const command = orientHookCommand(python, validator, hybrid);
341
+
342
+ // Inspect BOTH layers before deciding to write anything.
343
+ for (const name of ORIENT_SETTINGS_FILES) {
344
+ const p = path.join(cwd, '.claude', name);
345
+ const state = orientSettingsState(p);
346
+ if (!state.ok) {
347
+ if (fs.existsSync(p)) return { code: 'malformed', file: name, target: p, why: state.why, command };
348
+ continue;
349
+ }
350
+ const existing = findOrientHook(state.settings);
351
+ if (!existing) continue;
352
+ const found = orientHookValidator(existing);
353
+ if (found === null) {
354
+ return { code: 'unverifiable', file: name, target: p, existing, command };
355
+ }
356
+ const resolves = fs.existsSync(path.isAbsolute(found) ? found : path.join(cwd, found));
357
+ return resolves
358
+ ? { code: 'already', file: name, target: p, command: existing }
359
+ : { code: 'broken', file: name, target: p, existing, command };
360
+ }
361
+
362
+ // F-042: a resolving MACHINE-GLOBAL hook (user-level settings) already
363
+ // orients every project. Checked AFTER the project scan on purpose: a
364
+ // project-level broken/malformed/unverifiable must be reported first — a
365
+ // pre-check would mask a dead project hook behind "covered globally", the
366
+ // wired-and-DEAD state F-041 calls the worst of the three. A dead or
367
+ // unverifiable global hook falls through to the project write (the wrapper
368
+ // may be emitting nothing) and the caller gets `globalNote` to print the
369
+ // correction. A user-level token that is not absolute has no cwd to resolve
370
+ // against, so it is treated as unverifiable.
371
+ let globalNote;
372
+ {
373
+ const userTarget = path.join(client.home, 'settings.json');
374
+ const ustate = orientSettingsState(userTarget);
375
+ if (ustate.ok) {
376
+ const uexisting = findOrientHook(ustate.settings);
377
+ if (uexisting) {
378
+ const ufound = orientHookValidator(uexisting);
379
+ if (ufound !== null && path.isAbsolute(ufound) && fs.existsSync(ufound)) {
380
+ return { code: 'global', target: userTarget, command: uexisting };
381
+ }
382
+ globalNote = (ufound === null || !path.isAbsolute(ufound)) ? 'unverifiable' : 'dead';
383
+ }
384
+ }
385
+ }
386
+
387
+ // A machine-specific command must not reach the shared, committed file.
388
+ const file = vendored ? 'settings.json' : 'settings.local.json';
389
+ const target = path.join(cwd, '.claude', file);
390
+ const state = orientSettingsState(target);
391
+ if (!state.ok) return { code: 'malformed', file, target, why: state.why, command };
392
+ const settings = state.settings;
393
+
394
+ if (!settings.hooks) settings.hooks = {};
395
+ if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
396
+ settings.hooks.SessionStart.push({
397
+ hooks: [{
398
+ type: 'command',
399
+ command,
400
+ timeout: ORIENT_HOOK_TIMEOUT,
401
+ statusMessage: 'Loading ' + docsLabel + ' orientation...',
402
+ }],
403
+ });
404
+
405
+ // The installer has already written seed files by this point; an unwritable
406
+ // settings file must not take the whole run down with a stack trace.
407
+ try {
408
+ fs.mkdirSync(path.dirname(target), { recursive: true });
409
+ fs.writeFileSync(target, JSON.stringify(settings, null, 2) + String.fromCharCode(10), 'utf8');
410
+ } catch (e) {
411
+ return { code: 'write-failed', file, target, command, error: e.message };
412
+ }
413
+ return { code: 'wired', file, target, command, local: !vendored, globalNote };
414
+ }
415
+
416
+ // --- F-042: the machine-global hook (install-time wiring) -------------------
417
+ // The F-041 field lesson: the check note asked a question a normal user cannot
418
+ // evaluate. The owner's ruling: the hook is wired by the act the user already
419
+ // performs — installing/updating the package — at the USER level, which the
420
+ // installer knows exactly. `orient` is fail-open (no docs root ⇒ silent), so a
421
+ // global hook is safe by construction on unmanaged projects. Removal is a
422
+ // STANDING opt-out: a per-target marker remembers every settings file ever
423
+ // wired, and no install or update ever re-adds a removed hook. Deleting the
424
+ // marker file is the documented re-enable gesture (ENFORCEMENT §4).
425
+
426
+ // Python detection, shared by init (project wiring + index) and postinstall
427
+ // (global wiring). One list, one probe.
428
+ const PYTHON_CANDIDATES = ['python', 'python3', 'py'];
429
+ function detectPython() {
430
+ for (const py of PYTHON_CANDIDATES) {
431
+ try {
432
+ execSync(`${py} --version`, { stdio: 'ignore' });
433
+ return py;
434
+ } catch (e) { /* try the next interpreter */ }
435
+ }
436
+ return null;
437
+ }
438
+
439
+ // Case/separator normalization uses the PLATFORM'S OWN path.relative: win32
440
+ // folds case and separators; forcing win32 semantics on POSIX would fold
441
+ // genuinely distinct paths (a false positive). One helper guards BOTH the
442
+ // uninstall attribution and the marker lookup — a naive prefix/string match
443
+ // misses `c:\` vs `C:\`, which on attribution re-manufactures the dead hook
444
+ // and on the marker re-wires against a standing opt-out.
445
+ function pathsEqual(a, b) {
446
+ try {
447
+ return path.relative(String(a), String(b)) === '';
448
+ } catch (e) {
449
+ return false;
450
+ }
451
+ }
452
+ function pathInside(child, parent) {
453
+ try {
454
+ const rel = path.relative(String(parent), String(child));
455
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
456
+ } catch (e) {
457
+ return false;
458
+ }
459
+ }
460
+
461
+ // Ownership is per file. An existing directory without our marker belongs to its
462
+ // owner, and a file edited after installation is retained on uninstall.
463
+ const INSTALL_MARKER = '.course-creator-install.json';
464
+ function hashFile(file) {
465
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
466
+ }
467
+ function sourceFiles(dir = SKILL_SOURCE, prefix = '') {
468
+ const found = [];
469
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
470
+ if (entry.isSymbolicLink()) continue;
471
+ const rel = path.join(prefix, entry.name);
472
+ if (entry.isDirectory()) found.push(...sourceFiles(path.join(dir, entry.name), rel));
473
+ else if (entry.isFile()) found.push(rel);
474
+ }
475
+ return found;
476
+ }
477
+ function safeOwnedPath(target, rel) {
478
+ if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return null;
479
+ const full = path.resolve(target, rel);
480
+ if (!pathInside(full, target)) return null;
481
+ let cur = path.resolve(target);
482
+ for (const part of path.relative(cur, full).split(path.sep)) {
483
+ cur = path.join(cur, part);
484
+ if (fs.existsSync(cur) && fs.lstatSync(cur).isSymbolicLink()) return null;
485
+ }
486
+ return full;
487
+ }
488
+ function ownedMarker(target) {
489
+ const marker = path.join(target, INSTALL_MARKER);
490
+ if (!fs.existsSync(marker)) return null;
491
+ try {
492
+ if (!fs.lstatSync(marker).isFile() || fs.lstatSync(marker).isSymbolicLink()) return null;
493
+ const data = JSON.parse(fs.readFileSync(marker, 'utf8'));
494
+ return data.skill === INSTALLED_SKILL_NAME && data.files &&
495
+ typeof data.files === 'object' ? data : null;
496
+ } catch (e) { return null; }
497
+ }
498
+ function installOwned(target) {
499
+ if (!fs.existsSync(SKILL_SOURCE)) return { code: 'missing-source' };
500
+ if (fs.existsSync(target) && (!fs.lstatSync(target).isDirectory() ||
501
+ fs.lstatSync(target).isSymbolicLink())) return { code: 'unowned' };
502
+ const existing = fs.existsSync(target) ? fs.readdirSync(target) : [];
503
+ const prior = ownedMarker(target);
504
+ if (existing.length && !prior) return { code: 'unowned' };
505
+ fs.mkdirSync(target, { recursive: true });
506
+ const owned = { ...(prior ? prior.files : {}) };
507
+ let copied = 0, preserved = 0;
508
+ for (const rel of sourceFiles()) {
509
+ const dest = safeOwnedPath(target, rel);
510
+ if (!dest) { preserved++; continue; }
511
+ if (fs.existsSync(dest)) {
512
+ if (!Object.hasOwn(owned, rel) || hashFile(dest) !== owned[rel]) {
513
+ preserved++; continue;
514
+ }
515
+ }
516
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
517
+ fs.copyFileSync(path.join(SKILL_SOURCE, rel), dest);
518
+ owned[rel] = hashFile(dest);
519
+ copied++;
520
+ }
521
+ fs.writeFileSync(path.join(target, INSTALL_MARKER),
522
+ JSON.stringify({ skill: INSTALLED_SKILL_NAME, files: owned }, null, 2) + '\n');
523
+ return { code: 'installed', copied, preserved };
524
+ }
525
+ function uninstallOwned(target) {
526
+ if (!fs.existsSync(target) || fs.lstatSync(target).isSymbolicLink()) {
527
+ return { code: 'unowned' };
528
+ }
529
+ const prior = ownedMarker(target);
530
+ if (!prior) return { code: 'unowned' };
531
+ let removed = 0, preserved = 0;
532
+ for (const [rel, hash] of Object.entries(prior.files)) {
533
+ const dest = safeOwnedPath(target, rel);
534
+ if (!dest || !fs.existsSync(dest) || !fs.lstatSync(dest).isFile() ||
535
+ hashFile(dest) !== hash) { preserved++; continue; }
536
+ fs.unlinkSync(dest);
537
+ removed++;
538
+ }
539
+ fs.unlinkSync(path.join(target, INSTALL_MARKER));
540
+ // Remove only empty directories; a user-owned file keeps its parent.
541
+ const dirs = new Set(Object.keys(prior.files).map(rel => path.dirname(rel)));
542
+ for (const rel of [...dirs].sort((a, b) => b.length - a.length)) {
543
+ const dir = path.resolve(target, rel);
544
+ if (!pathInside(dir, target)) continue;
545
+ try { fs.rmdirSync(dir); } catch (e) { /* non-empty or absent */ }
546
+ }
547
+ try { fs.rmdirSync(target); } catch (e) { /* user files remain */ }
548
+ return { code: 'removed', removed, preserved };
549
+ }
550
+
551
+ // The marker lives under the family's agent-global root (the same root the
552
+ // validator's AGENTIC_SDLC_KB_ROOT seam names), read at call time so the test
553
+ // battery's stubbed env applies. Every helper is fail-open: a broken marker
554
+ // must never break an install, and a failed marker write never undoes a wire.
555
+ function orientMarkerPath() {
556
+ const root = process.env.AGENTIC_SDLC_KB_ROOT
557
+ || path.join(os.homedir(), '.agentic-sdlc');
558
+ return path.join(root, 'orient-hook-wired');
559
+ }
560
+ function orientMarkerTargets() {
561
+ try {
562
+ return fs.readFileSync(orientMarkerPath(), 'utf8')
563
+ .split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
564
+ } catch (e) {
565
+ return [];
566
+ }
567
+ }
568
+ function orientMarkerHas(target) {
569
+ return orientMarkerTargets().some((t) => pathsEqual(t, target));
570
+ }
571
+ function orientMarkerAdd(target) {
572
+ try {
573
+ if (orientMarkerHas(target)) return;
574
+ fs.mkdirSync(path.dirname(orientMarkerPath()), { recursive: true });
575
+ fs.appendFileSync(orientMarkerPath(), target + String.fromCharCode(10), 'utf8');
576
+ } catch (e) { /* fail-open */ }
577
+ }
578
+ function orientMarkerRemove(target) {
579
+ try {
580
+ const rest = orientMarkerTargets().filter((t) => !pathsEqual(t, target));
581
+ if (rest.length) {
582
+ fs.writeFileSync(orientMarkerPath(), rest.join(String.fromCharCode(10)) + String.fromCharCode(10), 'utf8');
583
+ } else {
584
+ fs.rmSync(orientMarkerPath(), { force: true });
585
+ }
586
+ } catch (e) { /* fail-open */ }
587
+ }
588
+
589
+ // Wire the machine-global hook into client.home/settings.json — exactly ONE
590
+ // file (user level has no local split; the path is machine-local by nature).
591
+ // Codes follow the wireOrientHook contract, plus `opted-out`.
592
+ function wireGlobalOrientHook(options) {
593
+ const client = options.client;
594
+ const python = options.python;
595
+
596
+ const validator = path.join(skillTarget(client), 'scripts', ORIENT_ENTRY_POINT);
597
+ if (!fs.existsSync(validator)) return { code: 'no-validator', validator };
598
+ if (!python) return { code: 'no-python', validator };
599
+ if (ORIENT_UNSAFE_IN_PATH.test(validator)) return { code: 'unsafe-path', validator };
600
+ const command = orientHookCommand(python, validator, false);
601
+
602
+ const target = path.join(client.home, 'settings.json');
603
+ const state = orientSettingsState(target);
604
+ if (!state.ok) return { code: 'malformed', target, why: state.why, command };
605
+
606
+ const existing = findOrientHook(state.settings);
607
+ if (existing) {
608
+ const found = orientHookValidator(existing);
609
+ if (found === null || !path.isAbsolute(found)) {
610
+ // No cwd exists at user level, so an unresolvable token is "cannot
611
+ // tell" — left alone, never remembered as ours.
612
+ return { code: 'unverifiable', target, existing, command };
613
+ }
614
+ if (fs.existsSync(found)) {
615
+ // A hand-wired live hook, once removed, deserves the same respect as
616
+ // one this installer wrote: remember the target.
617
+ orientMarkerAdd(target);
618
+ return { code: 'already', target, command: existing };
619
+ }
620
+ return { code: 'broken', target, existing, command };
621
+ }
622
+
623
+ if (orientMarkerHas(target)) return { code: 'opted-out', target };
624
+
625
+ const settings = state.settings;
626
+ if (!settings.hooks) settings.hooks = {};
627
+ if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
628
+ settings.hooks.SessionStart.push({
629
+ hooks: [{
630
+ type: 'command',
631
+ command,
632
+ timeout: ORIENT_HOOK_TIMEOUT,
633
+ statusMessage: 'Loading docs orientation...',
634
+ }],
635
+ });
636
+ try {
637
+ fs.mkdirSync(path.dirname(target), { recursive: true });
638
+ fs.writeFileSync(target, JSON.stringify(settings, null, 2) + String.fromCharCode(10), 'utf8');
639
+ } catch (e) {
640
+ return { code: 'write-failed', target, command, error: e.message };
641
+ }
642
+ orientMarkerAdd(target);
643
+ return { code: 'wired', target, command };
644
+ }
645
+
646
+ // Uninstall symmetry: removing the skill dirs would leave any global hook
647
+ // naming them wired-and-DEAD. So, across EVERY settings target the marker
648
+ // remembers (a hook wired under CLAUDE_CONFIG_DIR=/x must be cleaned from a
649
+ // shell without that env) unioned with the current client.home, remove ONLY
650
+ // the SessionStart entries whose validator token resolves INTO a root being
651
+ // removed. Foreign or unverifiable entries are never touched. The marker line
652
+ // is cleared ONLY when an entry was actually removed — when the user had
653
+ // already removed the hook, the line stays and the opt-out survives uninstall,
654
+ // reinstall, and sibling-lens uninstalls.
655
+ function removeGlobalOrientHooks(removedRoots) {
656
+ const targets = [];
657
+ const claude = CLIENTS.find((c) => c.key === 'claude');
658
+ if (claude) targets.push(path.join(claude.home, 'settings.json'));
659
+ for (const t of orientMarkerTargets()) {
660
+ if (!targets.some((x) => pathsEqual(x, t))) targets.push(t);
661
+ }
662
+ const removedFrom = [];
663
+ for (const target of targets) {
664
+ try {
665
+ const state = orientSettingsState(target);
666
+ if (!state.ok || !fs.existsSync(target)) continue;
667
+ const hooks = state.settings.hooks;
668
+ if (!hooks || !Array.isArray(hooks.SessionStart)) continue;
669
+ let removedHere = false;
670
+ const ours = (h) => {
671
+ const cmd = h && typeof h.command === 'string' ? h.command : '';
672
+ if (!(ORIENT_ENTRY_POINTS.some((n) => cmd.includes(n)) && / orient(\s|$)/.test(cmd))) return false;
673
+ const tok = orientHookValidator(cmd);
674
+ if (tok === null || !path.isAbsolute(tok)) return false; // cannot attribute: keep
675
+ return removedRoots.some((root) => pathInside(tok, root));
676
+ };
677
+ const kept = [];
678
+ for (const group of hooks.SessionStart) {
679
+ if (!group || typeof group !== 'object') { kept.push(group); continue; }
680
+ if (Array.isArray(group.hooks)) {
681
+ const inner = group.hooks.filter((h) => {
682
+ if (ours(h)) { removedHere = true; return false; }
683
+ return true;
684
+ });
685
+ if (inner.length) kept.push({ ...group, hooks: inner });
686
+ else if (inner.length !== group.hooks.length) { /* emptied: prune */ }
687
+ else kept.push(group);
688
+ } else if (ours(group)) {
689
+ removedHere = true; // direct-object shape: prune
690
+ } else {
691
+ kept.push(group);
692
+ }
693
+ }
694
+ if (!removedHere) continue;
695
+ state.settings.hooks.SessionStart = kept;
696
+ if (!kept.length) delete state.settings.hooks.SessionStart;
697
+ if (state.settings.hooks && !Object.keys(state.settings.hooks).length) {
698
+ delete state.settings.hooks;
699
+ }
700
+ fs.writeFileSync(target, JSON.stringify(state.settings, null, 2) + String.fromCharCode(10), 'utf8');
701
+ orientMarkerRemove(target);
702
+ removedFrom.push(target);
703
+ } catch (e) { /* fail-open per target: never abort an uninstall */ }
704
+ }
705
+ return removedFrom;
706
+ }
707
+
708
+ module.exports = {
709
+ PACKAGE_ROOT,
710
+ SKILL_SOURCE,
711
+ INSTALLED_SKILL_NAME,
712
+ TEMPLATES_PATH,
713
+ CLIENTS,
714
+ commandExists,
715
+ clientDetected,
716
+ skillTarget,
717
+ wireOrientHook,
718
+ wireGlobalOrientHook,
719
+ removeGlobalOrientHooks,
720
+ detectPython,
721
+ pathsEqual,
722
+ orientHookCommand,
723
+ copyRecursive,
724
+ installOwned,
725
+ uninstallOwned,
726
+ ownedMarker,
727
+ loadTemplates,
728
+ templateFor,
729
+ };
730
+
731
+ // Lazy: reading routing.md is deferred to the consumer that actually asks.
732
+ Object.defineProperty(module.exports, 'SELF_LENS', { enumerable: true, get: selfLens });
733
+ Object.defineProperty(module.exports, 'SIBLING_LENSES', { enumerable: true, get: siblingLenses });
734
+