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