@marcomigozzi/trailhead 0.3.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/bin/trailhead.js CHANGED
@@ -3,17 +3,27 @@
3
3
  // trailhead installer: places the skill, commands, hooks, and templates into
4
4
  // an agent's config dir, and registers the hooks in settings.json.
5
5
  // No dependencies. Idempotent. Usage:
6
- // npx @marcomigozzi/trailhead install for Claude Code (~/.claude or $CLAUDE_CONFIG_DIR)
7
- // npx @marcomigozzi/trailhead --symlink dev install (symlink to the package, live edits)
6
+ // npx @marcomigozzi/trailhead install (auto-detects the CLI on your PATH; asks if it can't tell)
7
+ // npx @marcomigozzi/trailhead --claude force install for Claude Code (~/.claude or $CLAUDE_CONFIG_DIR)
8
+ // npx @marcomigozzi/trailhead --codex force install for Codex ($CODEX_HOME or ~/.codex)
9
+ // npx @marcomigozzi/trailhead --symlink dev install (symlink to the package, live edits; Claude only)
8
10
  // npx @marcomigozzi/trailhead --uninstall
9
11
  // npx @marcomigozzi/trailhead --dir=/path/to/configdir
10
12
  //
11
- // Multi-CLI: only the Claude adapter exists today. Add adapters below (config dir,
12
- // commands/hooks layout) to target Codex, Gemini, etc. See ADAPTERS.
13
+ // Multi-host: host layout/behaviour comes from bin/lib/host-descriptor.js
14
+ // (the descriptor table for claude/codex) and, for Codex, the artifact
15
+ // projection in bin/lib/codex-projection.js (engine text rewrites, prompt
16
+ // generation). With no --codex/--claude flag the host is auto-detected from
17
+ // the CLIs on $PATH; the interactive prompt only fires when that is ambiguous.
13
18
 
14
19
  const fs = require('fs');
15
20
  const os = require('os');
16
21
  const path = require('path');
22
+ const readline = require('readline');
23
+ const { spawnSync } = require('child_process');
24
+
25
+ const { getHost, configDirFor, codexVersionGate } = require('./lib/host-descriptor.js');
26
+ const { codexLayout, convertToCodex, injectCodexAdapterHeader, codexAgentsYaml, codexHookEntries, enableCodexHooksFeature, codexAgentTomlPlan, codexVerbSkillPlan, enableCodexMultiAgentV2Feature } = require('./lib/codex-projection.js');
17
27
 
18
28
  const PKG = path.resolve(__dirname, '..');
19
29
  const SRC = path.join(PKG, 'plugins', 'trailhead');
@@ -23,38 +33,149 @@ const useSymlink = has('--symlink');
23
33
  const uninstall = has('--uninstall');
24
34
  const dirArg = (args.find((a) => a.startsWith('--dir=')) || '').split('=')[1];
25
35
 
26
- // --- ADAPTERS: one config layout per target agent. Extend this map for multi-CLI. ---
27
- const ADAPTERS = {
28
- claude: {
29
- label: 'Claude Code',
30
- configDir: process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'),
31
- },
32
- // codex: { label: 'Codex', configDir: process.env.CODEX_HOME || path.join(os.homedir(), '.codex') },
33
- // gemini: { label: 'Gemini', configDir: process.env.GEMINI_CONFIG_DIR || path.join(os.homedir(), '.gemini') },
34
- };
35
- const adapter = ADAPTERS.claude; // only Claude for now
36
- const configDir = dirArg || adapter.configDir;
37
-
38
- const P = {
39
- skill: path.join(configDir, 'skills', 'trailhead'),
40
- commands: path.join(configDir, 'commands', 'trailhead'),
41
- hooksDir: path.join(configDir, 'hooks'),
42
- templates: path.join(configDir, 'trailhead', 'templates'),
43
- settings: path.join(configDir, 'settings.json'),
44
- };
45
- const HOOK_FILES = ['trailhead-commit-guard.js', 'trailhead-issue-injection-scanner.js', 'trailhead-secret-guard.js', 'trailhead-check-update.js'];
46
- const hookCmd = (name) => `node "${path.join(P.hooksDir, name)}"`;
47
-
48
36
  const rmrf = (p) => fs.rmSync(p, { recursive: true, force: true });
49
37
  const ensure = (p) => fs.mkdirSync(p, { recursive: true });
50
- function place(src, dest) {
38
+ const readJSON = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; } };
39
+ const writeJSON = (p, o) => { ensure(path.dirname(p)); fs.writeFileSync(p, JSON.stringify(o, null, 2) + '\n'); };
40
+
41
+ // --- readCodexModels ---------------------------------------------------------
42
+ // Resolve the merged models.codex.* config for the Codex agent-TOML projection:
43
+ // the project-local .trailhead/config.json (found by walking up from cwd, capped
44
+ // at 8 levels) overlaid on the Codex-side global at <codexHome>/trailhead/config.json,
45
+ // project winning per key. Never throws: any missing/malformed file just
46
+ // contributes nothing.
47
+ function findProjectConfig(startDir) {
48
+ let dir = startDir;
49
+ for (let i = 0; i < 8; i++) {
50
+ const candidate = path.join(dir, '.trailhead', 'config.json');
51
+ if (fs.existsSync(candidate)) return readJSON(candidate);
52
+ const parent = path.dirname(dir);
53
+ if (parent === dir) break;
54
+ dir = parent;
55
+ }
56
+ return {};
57
+ }
58
+
59
+ function readCodexModels(codexHome) {
60
+ try {
61
+ const projectCfg = findProjectConfig(process.cwd()) || {};
62
+ const globalCfg = readJSON(path.join(codexHome, 'trailhead', 'config.json')) || {};
63
+ const globalCodex = globalCfg.models && typeof globalCfg.models === 'object' && typeof globalCfg.models.codex === 'object' && globalCfg.models.codex ? globalCfg.models.codex : {};
64
+ const projectCodex = projectCfg.models && typeof projectCfg.models === 'object' && typeof projectCfg.models.codex === 'object' && projectCfg.models.codex ? projectCfg.models.codex : {};
65
+ return { ...globalCodex, ...projectCodex };
66
+ } catch {
67
+ return {};
68
+ }
69
+ }
70
+
71
+ // --- host resolution -------------------------------------------------------
72
+ // Explicit --codex / --claude win outright. With neither, the host is
73
+ // auto-detected from the CLIs on $PATH: exactly one found -> install for it,
74
+ // no question; ambiguous (none, or several) on an interactive TTY -> ask; an
75
+ // ambiguous non-interactive run (npx/CI, or --dir=) falls back to claude so
76
+ // scripted installs never hang on a prompt nobody can answer.
77
+ const KNOWN_HOSTS = ['claude', 'codex']; // preference order for the fallback; mirrors host-descriptor HOSTS
78
+
79
+ // True if an executable named `bin` sits in one of $PATH's directories. No
80
+ // child process: we just probe each PATH entry for an executable file.
81
+ function isOnPath(bin, env = process.env) {
82
+ for (const dir of (env.PATH || '').split(path.delimiter)) {
83
+ if (!dir) continue;
84
+ try {
85
+ fs.accessSync(path.join(dir, bin), fs.constants.X_OK);
86
+ return true;
87
+ } catch { /* not here, keep looking */ }
88
+ }
89
+ return false;
90
+ }
91
+
92
+ function detectInstalledHosts(env = process.env) {
93
+ return KNOWN_HOSTS.filter((name) => isOnPath(name, env));
94
+ }
95
+
96
+ function promptForHost(detected = []) {
97
+ const mark = (name) => (detected.includes(name) ? ' (detected)' : '');
98
+ return new Promise((resolve) => {
99
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
100
+ rl.question(
101
+ 'Install trailhead for which host?\n' +
102
+ ` 1) Claude Code${mark('claude')} (default)\n` +
103
+ ` 2) Codex${mark('codex')}\n> `,
104
+ (answer) => {
105
+ rl.close();
106
+ const choice = String(answer).trim().toLowerCase();
107
+ resolve(choice === '2' || choice === 'codex' ? 'codex' : 'claude');
108
+ }
109
+ );
110
+ });
111
+ }
112
+
113
+ async function resolveHostName() {
114
+ const wantCodex = has('--codex');
115
+ const wantClaude = has('--claude');
116
+ if (wantCodex && wantClaude) {
117
+ console.error('Pass only one of --codex or --claude.');
118
+ process.exit(1);
119
+ }
120
+ if (wantCodex) return 'codex';
121
+ if (wantClaude) return 'claude';
122
+
123
+ // No explicit flag: auto-detect from the CLIs on $PATH.
124
+ const detected = detectInstalledHosts();
125
+ if (detected.length === 1) {
126
+ console.log(`Detected ${getHost(detected[0]).label} on your PATH; installing for it.`);
127
+ return detected[0];
128
+ }
129
+ // --dir= alone signals scripted/automation usage (tests, CI harnesses), even
130
+ // when stdin happens to be a TTY, so it never triggers the prompt. Ambiguous
131
+ // (0 or 2+ CLIs) + interactive -> ask; otherwise fall back to claude.
132
+ if (process.stdin.isTTY && !dirArg) {
133
+ return promptForHost(detected);
134
+ }
135
+ return 'claude';
136
+ }
137
+
138
+ // --- Claude adapter (unchanged behaviour) -----------------------------------
139
+ function claudePaths(configDir) {
140
+ return {
141
+ skill: path.join(configDir, 'skills', 'trailhead'),
142
+ commands: path.join(configDir, 'commands', 'trailhead'),
143
+ hooksDir: path.join(configDir, 'hooks'),
144
+ templates: path.join(configDir, 'trailhead', 'templates'),
145
+ settings: path.join(configDir, 'settings.json'),
146
+ };
147
+ }
148
+ const HOOK_FILES = ['trailhead-commit-guard.js', 'trailhead-issue-injection-scanner.js', 'trailhead-secret-guard.js', 'trailhead-check-update.js'];
149
+ // Runtime libs the hook scripts require (trailhead-commit-guard.js does
150
+ // require('./lib/commit-message-check.js')). Copied by name, never a recursive
151
+ // sweep: the Claude hooks/lib dir is shared with other plugins, so we must not
152
+ // clobber a co-tenant's lib nor ship our own *.test.js files.
153
+ const HOOK_LIB_FILES = ['commit-message-check.js'];
154
+
155
+ // Copy the hook scripts and their runtime libs into a destination hooks dir
156
+ // (Claude's ~/.claude/hooks or Codex's skills/trailhead/hooks). Shared by both
157
+ // install paths so the lib copy can never drift from the script copy again.
158
+ function copyHookScripts(destHooksDir) {
159
+ ensure(destHooksDir);
160
+ for (const f of HOOK_FILES) {
161
+ fs.copyFileSync(path.join(SRC, 'hooks', f), path.join(destHooksDir, f));
162
+ fs.chmodSync(path.join(destHooksDir, f), 0o755);
163
+ }
164
+ if (HOOK_LIB_FILES.length) {
165
+ const libDest = path.join(destHooksDir, 'lib');
166
+ ensure(libDest);
167
+ for (const f of HOOK_LIB_FILES) {
168
+ fs.copyFileSync(path.join(SRC, 'hooks', 'lib', f), path.join(libDest, f));
169
+ }
170
+ }
171
+ }
172
+
173
+ function place(src, dest, symlink) {
51
174
  rmrf(dest);
52
175
  ensure(path.dirname(dest));
53
- if (useSymlink) fs.symlinkSync(src, dest, 'dir');
176
+ if (symlink) fs.symlinkSync(src, dest, 'dir');
54
177
  else fs.cpSync(src, dest, { recursive: true });
55
178
  }
56
- const readJSON = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; } };
57
- const writeJSON = (p, o) => { ensure(path.dirname(p)); fs.writeFileSync(p, JSON.stringify(o, null, 2) + '\n'); };
58
179
 
59
180
  function addHook(s, event, matcher, command) {
60
181
  s.hooks = s.hooks || {};
@@ -72,9 +193,13 @@ function stripHook(s, event, substr) {
72
193
  s.hooks[event] = s.hooks[event].filter((g) => (g.hooks || []).length);
73
194
  }
74
195
 
75
- if (uninstall) {
196
+ function uninstallClaude(configDir) {
197
+ const P = claudePaths(configDir);
76
198
  [P.skill, P.commands, path.join(configDir, 'trailhead')].forEach(rmrf);
77
199
  HOOK_FILES.forEach((f) => rmrf(path.join(P.hooksDir, f)));
200
+ // Remove only trailhead's own lib files by name: the hooks/lib dir is shared
201
+ // with other plugins, so never rmrf the whole dir.
202
+ HOOK_LIB_FILES.forEach((f) => rmrf(path.join(P.hooksDir, 'lib', f)));
78
203
  const s = readJSON(P.settings);
79
204
  stripHook(s, 'PreToolUse', 'trailhead-commit-guard.js');
80
205
  stripHook(s, 'PreToolUse', 'trailhead-secret-guard.js');
@@ -82,38 +207,301 @@ if (uninstall) {
82
207
  stripHook(s, 'SessionStart', 'trailhead-check-update.js');
83
208
  writeJSON(P.settings, s);
84
209
  console.log(`trailhead uninstalled from ${configDir}`);
85
- process.exit(0);
86
- }
87
-
88
- // install (Claude adapter)
89
- place(path.join(SRC, 'skills', 'trailhead'), P.skill);
90
- place(path.join(SRC, 'commands'), P.commands);
91
- place(path.join(SRC, 'templates'), P.templates);
92
- ensure(P.hooksDir);
93
- for (const f of HOOK_FILES) {
94
- fs.copyFileSync(path.join(SRC, 'hooks', f), path.join(P.hooksDir, f));
95
- fs.chmodSync(path.join(P.hooksDir, f), 0o755);
96
- }
97
- // version marker: serve al check-update hook per la versione installata (canale npm)
98
- const pkgVersion = (readJSON(path.join(SRC, '.claude-plugin', 'plugin.json')).version || '').trim();
99
- if (pkgVersion) {
100
- ensure(path.join(configDir, 'trailhead'));
101
- fs.writeFileSync(path.join(configDir, 'trailhead', 'VERSION'), pkgVersion + '\n');
102
- }
103
-
104
- const s = readJSON(P.settings);
105
- const added = [
106
- addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-commit-guard.js')),
107
- addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-secret-guard.js')),
108
- addHook(s, 'PostToolUse', 'Bash', hookCmd('trailhead-issue-injection-scanner.js')),
109
- addHook(s, 'SessionStart', '', hookCmd('trailhead-check-update.js')),
110
- ].some(Boolean);
111
- writeJSON(P.settings, s);
112
-
113
- console.log(`✓ trailhead installed for ${adapter.label} → ${configDir}`);
114
- console.log(` skill → ${P.skill}${useSymlink ? ' (symlink)' : ''}`);
115
- console.log(` commands → ${P.commands} (/trailhead:*)`);
116
- console.log(` hooks → ${P.hooksDir}/ (${added ? 'registered' : 'already present'} in settings.json)`);
117
- console.log(` templates → ${P.templates}`);
118
- console.log('\nRestart or reload your agent to pick up the commands, then run /trailhead to start.');
119
- console.log('Note: the commit guard now runs on every git commit (conventional + no Co-Authored-By).');
210
+ }
211
+
212
+ function installClaude(configDir, { useSymlink }) {
213
+ const P = claudePaths(configDir);
214
+ const hookCmd = (name) => `node "${path.join(P.hooksDir, name)}"`;
215
+
216
+ place(path.join(SRC, 'skills', 'trailhead'), P.skill, useSymlink);
217
+ place(path.join(SRC, 'commands'), P.commands, useSymlink);
218
+ place(path.join(SRC, 'templates'), P.templates, useSymlink);
219
+ copyHookScripts(P.hooksDir);
220
+ // version marker: serve al check-update hook per la versione installata (canale npm)
221
+ const pkgVersion = (readJSON(path.join(SRC, '.claude-plugin', 'plugin.json')).version || '').trim();
222
+ if (pkgVersion) {
223
+ ensure(path.join(configDir, 'trailhead'));
224
+ fs.writeFileSync(path.join(configDir, 'trailhead', 'VERSION'), pkgVersion + '\n');
225
+ }
226
+
227
+ const s = readJSON(P.settings);
228
+ const added = [
229
+ addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-commit-guard.js')),
230
+ addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-secret-guard.js')),
231
+ addHook(s, 'PostToolUse', 'Bash', hookCmd('trailhead-issue-injection-scanner.js')),
232
+ addHook(s, 'SessionStart', '', hookCmd('trailhead-check-update.js')),
233
+ ].some(Boolean);
234
+ writeJSON(P.settings, s);
235
+
236
+ console.log(`✓ trailhead installed for Claude Code → ${configDir}`);
237
+ console.log(` skill → ${P.skill}${useSymlink ? ' (symlink)' : ''}`);
238
+ console.log(` commands → ${P.commands} (/trailhead:*)`);
239
+ console.log(` hooks → ${P.hooksDir}/ (${added ? 'registered' : 'already present'} in settings.json)`);
240
+ console.log(` templates → ${P.templates}`);
241
+ console.log('\nRestart or reload your agent to pick up the commands, then run /trailhead to start.');
242
+ console.log('Note: the commit guard now runs on every git commit (conventional + no Co-Authored-By).');
243
+ }
244
+
245
+ // --- Codex adapter -----------------------------------------------------------
246
+ // Codex has a native subagent toolkit (the multi_agent tools, on by default at
247
+ // the floor). trailhead installs as one native Codex skill under
248
+ // skills/trailhead/: the engine text is projected with convertToCodex +
249
+ // codexSkillAdapterHeader, and agents/openai.yaml (codexAgentsYaml) registers
250
+ // it for explicit-only `$trailhead <verb>` invocation.
251
+ // Base multi_agent fan-out needs no manifest, but when models.codex.* is set
252
+ // per technique, the installer projects those pins into per-technique
253
+ // trailhead-<technique>.toml files under the real ~/.codex/agents/ dir
254
+ // (codexAgentTomlPlan) and enables features.multi_agent_v2 so Codex honours
255
+ // them; with no models.codex.* set, subagents still inherit the session model.
256
+
257
+ // Recursively copy plugins/trailhead/skills/trailhead into destDir, applying
258
+ // convertToCodex to every .md file (and prepending the adapter header to the
259
+ // top-level SKILL.md). Mirrors the source tree shape (references/, references/techniques/, ...).
260
+ function copySkillConverted(srcDir, destDir, isRoot) {
261
+ ensure(destDir);
262
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
263
+ const srcPath = path.join(srcDir, entry.name);
264
+ const destPath = path.join(destDir, entry.name);
265
+ if (entry.isDirectory()) {
266
+ copySkillConverted(srcPath, destPath, false);
267
+ } else if (entry.name.endsWith('.md')) {
268
+ const raw = fs.readFileSync(srcPath, 'utf8');
269
+ let converted = convertToCodex(raw);
270
+ if (isRoot && entry.name === 'SKILL.md') {
271
+ // Inject the adapter header AFTER the YAML frontmatter so the
272
+ // frontmatter stays at the top and Codex registers the skill (#40).
273
+ converted = injectCodexAdapterHeader(converted);
274
+ }
275
+ fs.writeFileSync(destPath, converted);
276
+ } else {
277
+ fs.copyFileSync(srcPath, destPath);
278
+ }
279
+ }
280
+ }
281
+
282
+ // Read the canonical command surface from plugins/trailhead/commands/*.md:
283
+ // each file's basename is the verb; its frontmatter description/argument-hint
284
+ // (when present) ride along as the Codex prompt shim's discoverability metadata.
285
+ // Single-sources the verb list so the Codex shims never drift from the commands.
286
+ function readCommandVerbs() {
287
+ const dir = path.join(SRC, 'commands');
288
+ if (!fs.existsSync(dir)) return [];
289
+ return fs.readdirSync(dir)
290
+ .filter((f) => f.endsWith('.md'))
291
+ .map((f) => {
292
+ const raw = fs.readFileSync(path.join(dir, f), 'utf8');
293
+ return { verb: f.slice(0, -3), description: fmValue(raw, 'description'), argumentHint: fmValue(raw, 'argument-hint') };
294
+ })
295
+ .sort((a, b) => a.verb.localeCompare(b.verb));
296
+ }
297
+
298
+ // Extract one frontmatter scalar's raw value verbatim (an already-valid YAML
299
+ // scalar re-serialises unchanged). Looks only inside the leading ---...--- block.
300
+ // Returns undefined when the key is absent or its value is empty.
301
+ function fmValue(md, key) {
302
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md);
303
+ const block = fm ? fm[1] : '';
304
+ const m = new RegExp(`^${key}:[ \\t]*(.*)$`, 'm').exec(block);
305
+ if (!m) return undefined;
306
+ const v = m[1].trim();
307
+ return v === '' ? undefined : v;
308
+ }
309
+
310
+ // Best-effort `codex --version` probe for the install-time floor gate. Returns
311
+ // the raw version string, or null when codex is not on PATH / errors out.
312
+ function detectCodexVersion() {
313
+ try {
314
+ const r = spawnSync('codex', ['--version'], { encoding: 'utf8', timeout: 5000 });
315
+ if (r.error || r.status !== 0) return null;
316
+ return String(r.stdout || r.stderr || '').trim() || null;
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
322
+ function installCodex(configDir, { useSymlink }) {
323
+ // Floor gate (decision #27): refuse to project onto a Codex below the
324
+ // multi_agent floor; warn (but proceed) when the version can't be read.
325
+ const gate = codexVersionGate(detectCodexVersion());
326
+ if (!gate.proceed) {
327
+ console.error(`✗ ${gate.message}`);
328
+ process.exitCode = 1;
329
+ return;
330
+ }
331
+ if (gate.undetermined) console.warn(`⚠ ${gate.message}`);
332
+
333
+ const L = codexLayout(configDir);
334
+
335
+ if (useSymlink) {
336
+ console.log('Note: symlink install is unavailable for Codex (artifacts are generated, not copied verbatim); installing copies instead.');
337
+ }
338
+
339
+ // Migration: sweep the pre-#25 layout AND the #42 prompt shims that never
340
+ // surfaced as Codex slash commands (#46): prompts/trailhead*.md + the
341
+ // trailhead/ engine dir. Removing them on every (re)install cleans up an
342
+ // upgrade from the shim layout.
343
+ rmrf(path.join(L.promptsDir, 'trailhead.md'));
344
+ for (const f of (fs.existsSync(L.promptsDir) ? fs.readdirSync(L.promptsDir) : [])) {
345
+ if (f.startsWith('trailhead-')) rmrf(path.join(L.promptsDir, f));
346
+ }
347
+ rmrf(L.legacyEngineDir);
348
+
349
+ // Engine: clean skill dir, then project with conversion.
350
+ rmrf(L.skillDir);
351
+ ensure(L.skillDir);
352
+ copySkillConverted(path.join(SRC, 'skills', 'trailhead'), L.skillDir, true);
353
+
354
+ // Templates (verbatim, never converted), bundled inside the skill dir so
355
+ // ${CLAUDE_PLUGIN_ROOT}/templates resolves to ~/.codex/skills/trailhead/templates.
356
+ ensure(L.templatesDir);
357
+ fs.cpSync(path.join(SRC, 'templates'), L.templatesDir, { recursive: true });
358
+
359
+ // agents/openai.yaml: UI metadata + explicit-only invocation (no auto-trigger).
360
+ ensure(L.agentsDir);
361
+ fs.writeFileSync(L.agentsYaml, codexAgentsYaml());
362
+
363
+ // Per-verb discoverability (#46): one thin Codex skill per verb
364
+ // (skills/trailhead-<verb>/, invocable as $trailhead-<verb>), each delegating
365
+ // to $trailhead <verb>. Codex custom prompts (~/.codex/prompts/) never surface
366
+ // as slash commands, so per-verb skills carry the surface, exactly as GSD does.
367
+ // Sweep stale trailhead-* verb skills first (a removed verb, or an upgrade from
368
+ // the old prompt-shim layout), never the main skills/trailhead. Verbs come from
369
+ // the canonical commands/*.md so the surfaces never drift.
370
+ for (const f of (fs.existsSync(L.skillsRoot) ? fs.readdirSync(L.skillsRoot) : [])) {
371
+ if (f.startsWith('trailhead-')) rmrf(path.join(L.skillsRoot, f));
372
+ }
373
+ const verbSkillPlan = codexVerbSkillPlan(configDir, readCommandVerbs());
374
+ for (const d of verbSkillPlan.dirs) ensure(path.join(d, 'agents'));
375
+ for (const w of verbSkillPlan.writes) fs.writeFileSync(w.path, w.content);
376
+
377
+ const pkgVersion = (readJSON(path.join(SRC, '.claude-plugin', 'plugin.json')).version || '').trim();
378
+ if (pkgVersion) fs.writeFileSync(L.versionFile, pkgVersion + '\n');
379
+
380
+ // Hooks: copy the 4 guard scripts into the skill dir and register them in
381
+ // ~/.codex/hooks.json (same shape as Claude's settings.json hooks block).
382
+ copyHookScripts(L.hooksScriptsDir);
383
+ const h = readJSON(L.hooksJson);
384
+ for (const e of codexHookEntries(L.hooksScriptsDir)) addHook(h, e.event, e.matcher, e.command);
385
+ writeJSON(L.hooksJson, h);
386
+
387
+ // Feature flag: Codex gates the hook bus behind features.hooks = true.
388
+ let current = '';
389
+ try { current = fs.readFileSync(L.configToml, 'utf8'); } catch { current = ''; }
390
+ const updatedToml = enableCodexHooksFeature(current);
391
+ let featureManual = false;
392
+ if (updatedToml && typeof updatedToml === 'string') {
393
+ ensure(path.dirname(L.configToml));
394
+ fs.writeFileSync(L.configToml, updatedToml);
395
+ current = updatedToml;
396
+ } else if (updatedToml && updatedToml.unsafe) {
397
+ featureManual = true;
398
+ }
399
+
400
+ // Per-technique model pins: project models.codex.* (merged project + global
401
+ // config) into trailhead-<technique>.toml under the real ~/.codex/agents/
402
+ // dir. Sweep only trailhead's own files first, so a stale pin from a prior
403
+ // install (or a removed technique) never lingers, without touching the
404
+ // user's other custom-agent TOMLs.
405
+ const codexModels = readCodexModels(configDir);
406
+ ensure(L.codexAgentsDir);
407
+ for (const f of fs.readdirSync(L.codexAgentsDir)) {
408
+ if (/^trailhead-.*\.toml$/.test(f)) rmrf(path.join(L.codexAgentsDir, f));
409
+ }
410
+ const plan = codexAgentTomlPlan(configDir, codexModels);
411
+ for (const w of plan.writes) fs.writeFileSync(w.path, w.content);
412
+
413
+ // Feature flag: Codex gates per-subagent model pinning behind
414
+ // features.multi_agent_v2 = true. Enable it ONLY when we actually projected
415
+ // pins: with no models.codex.* set, base multi_agent already fans out and the
416
+ // adapter's §D runtime detection expects no trailhead agent_type registry, so
417
+ // leaving v2 off keeps that contract sound. (A reinstall that drops all pins
418
+ // sweeps the TOMLs above but leaves any prior v2 flag in place; harmless, as
419
+ // no trailhead-* agents remain for the runtime to dispatch to.)
420
+ let featureManualV2 = false;
421
+ if (plan.writes.length > 0) {
422
+ const updatedTomlV2 = enableCodexMultiAgentV2Feature(current);
423
+ if (updatedTomlV2 && typeof updatedTomlV2 === 'string') {
424
+ ensure(path.dirname(L.configToml));
425
+ fs.writeFileSync(L.configToml, updatedTomlV2);
426
+ } else if (updatedTomlV2 && updatedTomlV2.unsafe) {
427
+ featureManualV2 = true;
428
+ }
429
+ }
430
+
431
+ console.log(`✓ trailhead installed for Codex → ${configDir}`);
432
+ console.log(` skill → ${L.skillDir}/ (invoke $trailhead)`);
433
+ console.log(` templates → ${L.templatesDir}/`);
434
+ console.log(` verb skills → ${L.skillsRoot}/trailhead-<verb>/ (${verbSkillPlan.dirs.length} discoverability skills: invoke $trailhead-<verb>)`);
435
+ console.log(` hooks → ${L.hooksScriptsDir}/ (registered in hooks.json)`);
436
+ if (plan.writes.length > 0) {
437
+ console.log(` agents → ${L.codexAgentsDir}/ (${plan.writes.length} model pin(s): ${plan.writes.map((w) => w.name + '.toml').join(', ')})`);
438
+ } else {
439
+ console.log(' agents → no models.codex.* set, subagents inherit the session model');
440
+ }
441
+ console.log('\nRestart or reload Codex to pick up the skill, then run $trailhead to start.');
442
+ // The trust prompt only fires once the feature flag is on. When we could not
443
+ // edit config.toml, the hooks stay inert until the user sets it by hand, so
444
+ // don't reassure them about a trust review that won't happen yet.
445
+ if (featureManual) {
446
+ console.log(` ⚠ could not edit ${L.configToml} automatically — add \`features.hooks = true\` under [features] by hand, then Codex will ask you to trust trailhead's hooks on next start.`);
447
+ } else {
448
+ console.log('Note: Codex will ask you to trust trailhead\'s hooks on next start (feature `features.hooks`).');
449
+ }
450
+ if (featureManualV2) {
451
+ console.log(` ⚠ could not edit ${L.configToml} automatically, add \`features.multi_agent_v2 = true\` under [features] by hand to let Codex honour the projected model pins.`);
452
+ }
453
+ }
454
+
455
+ function uninstallCodex(configDir) {
456
+ const L = codexLayout(configDir);
457
+ rmrf(L.skillDir);
458
+ // sweep the per-verb discoverability skills (skills/trailhead-<verb>/, #46),
459
+ // never the main skills/trailhead (already removed above; does not match).
460
+ for (const f of (fs.existsSync(L.skillsRoot) ? fs.readdirSync(L.skillsRoot) : [])) {
461
+ if (f.startsWith('trailhead-')) rmrf(path.join(L.skillsRoot, f));
462
+ }
463
+ // sweep any pre-#25 layout AND the #42 prompt shims from prompts/
464
+ rmrf(path.join(L.promptsDir, 'trailhead.md'));
465
+ for (const f of (fs.existsSync(L.promptsDir) ? fs.readdirSync(L.promptsDir) : [])) {
466
+ if (f.startsWith('trailhead-')) rmrf(path.join(L.promptsDir, f));
467
+ }
468
+ rmrf(L.legacyEngineDir);
469
+ // Sweep trailhead's own per-technique model-pin TOMLs, never the user's
470
+ // other custom-agent TOMLs in the same dir.
471
+ if (fs.existsSync(L.codexAgentsDir)) {
472
+ for (const f of fs.readdirSync(L.codexAgentsDir)) {
473
+ if (/^trailhead-.*\.toml$/.test(f)) rmrf(path.join(L.codexAgentsDir, f));
474
+ }
475
+ }
476
+ // Strip trailhead's entries from hooks.json, but only if it already exists
477
+ // (uninstall must never create it). Leave features.hooks and
478
+ // features.multi_agent_v2 in config.toml untouched: the user may rely on
479
+ // them for other hooks/agents, mirroring how uninstallClaude leaves
480
+ // settings.json flags alone.
481
+ if (fs.existsSync(L.hooksJson)) {
482
+ const h = readJSON(L.hooksJson);
483
+ stripHook(h, 'PreToolUse', 'trailhead-commit-guard.js');
484
+ stripHook(h, 'PreToolUse', 'trailhead-secret-guard.js');
485
+ stripHook(h, 'PostToolUse', 'trailhead-issue-injection-scanner.js');
486
+ stripHook(h, 'SessionStart', 'trailhead-check-update.js');
487
+ writeJSON(L.hooksJson, h);
488
+ }
489
+ console.log(`trailhead uninstalled from ${configDir}`);
490
+ }
491
+
492
+ // --- main --------------------------------------------------------------------
493
+ async function main() {
494
+ const host = await resolveHostName();
495
+ const configDir = dirArg || configDirFor(host);
496
+
497
+ if (uninstall) {
498
+ if (host === 'codex') uninstallCodex(configDir);
499
+ else uninstallClaude(configDir);
500
+ return;
501
+ }
502
+
503
+ if (host === 'codex') installCodex(configDir, { useSymlink });
504
+ else installClaude(configDir, { useSymlink });
505
+ }
506
+
507
+ main().catch((e) => { console.error(e.message); process.exit(1); });