@marcomigozzi/trailhead 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +354 -0
- package/README.md +36 -12
- package/bin/lib/codex-projection.js +400 -0
- package/bin/lib/codex-projection.test.js +252 -0
- package/bin/lib/host-descriptor.js +404 -0
- package/bin/lib/host-descriptor.test.js +172 -0
- package/bin/trailhead.js +454 -66
- package/bin/trailhead.test.js +274 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/commands/adopt.md +1 -1
- package/plugins/trailhead/commands/dashboard.md +6 -0
- package/plugins/trailhead/commands/quick.md +6 -0
- package/plugins/trailhead/commands/seed.md +1 -1
- package/plugins/trailhead/commands/todo.md +1 -1
- package/plugins/trailhead/commands/whiteboard.md +6 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +74 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +83 -0
- package/plugins/trailhead/hooks/trailhead-check-update.js +54 -24
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +7 -10
- package/plugins/trailhead/skills/trailhead/SKILL.md +104 -30
- package/plugins/trailhead/skills/trailhead/references/capture.md +6 -0
- package/plugins/trailhead/skills/trailhead/references/configuration.md +7 -1
- package/plugins/trailhead/skills/trailhead/references/teamwork.md +3 -1
- package/plugins/trailhead/skills/trailhead/references/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/trailhead/references/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/trailhead/references/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/trailhead/references/techniques/plan-review.md +1 -1
- package/plugins/trailhead/skills/trailhead/references/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/trailhead/references/techniques/research.md +1 -1
- package/plugins/trailhead/templates/statusline-trailhead.sh +40 -8
- package/plugins/trailhead/templates/trailhead-commit-msg +57 -0
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
|
|
7
|
-
// npx @marcomigozzi/trailhead --
|
|
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-
|
|
12
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
place(path.join(SRC, '
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
console.log(
|
|
114
|
-
console.log(`
|
|
115
|
-
console.log(`
|
|
116
|
-
console.log(
|
|
117
|
-
console.log(
|
|
118
|
-
|
|
119
|
-
|
|
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); });
|