@marcomigozzi/trailhead 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +2 -2
- package/README.md +2 -2
- package/bin/lib/install-verify.js +257 -0
- package/bin/lib/install-verify.test.js +379 -0
- package/bin/trailhead.js +86 -5
- package/bin/trailhead.test.js +500 -4
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.js +58 -2
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +103 -13
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +375 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-a319156 +57 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +399 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +437 -0
- package/plugins/trailhead/hooks/lib/model-defaults.json +12 -0
- package/plugins/trailhead/hooks/trailhead-check-update.js +69 -14
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +32 -3
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +41 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +9 -6
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/principles.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +4 -4
- package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +15 -3
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +5 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
- package/plugins/trailhead/templates/trailhead-commit-msg +49 -2
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +171 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for install-verify.js. Run: node install-verify.test.js
|
|
3
|
+
// No framework: plain asserts, mirrors the style of codex-projection.test.js.
|
|
4
|
+
// Fixtures live under os.tmpdir(); every spec passes an explicit env/home,
|
|
5
|
+
// never the real process.env/os.homedir(), so the tests stay hermetic.
|
|
6
|
+
const assert = require('assert');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const {
|
|
11
|
+
sharedCoreFiles,
|
|
12
|
+
hookScriptTokens,
|
|
13
|
+
expandTarget,
|
|
14
|
+
verifyInstall,
|
|
15
|
+
claudeVerifySpec,
|
|
16
|
+
codexVerifySpec,
|
|
17
|
+
formatVerifyFailure,
|
|
18
|
+
formatVerifyWarnings,
|
|
19
|
+
} = require('./install-verify.js');
|
|
20
|
+
|
|
21
|
+
let passed = 0;
|
|
22
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
23
|
+
|
|
24
|
+
const tmpDirs = [];
|
|
25
|
+
function mktmp() {
|
|
26
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'th-iv-'));
|
|
27
|
+
tmpDirs.push(d);
|
|
28
|
+
return d;
|
|
29
|
+
}
|
|
30
|
+
function write(p, content = '') {
|
|
31
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
32
|
+
fs.writeFileSync(p, content);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- sharedCoreFiles -----------------------------------------------------------
|
|
36
|
+
const loadFirstPath = path.join(__dirname, '../../plugins/trailhead/skills/_shared/load-first.md');
|
|
37
|
+
const loadFirstSrc = fs.readFileSync(loadFirstPath, 'utf8');
|
|
38
|
+
ok('sharedCoreFiles on the real load-first.md returns the 7 core names',
|
|
39
|
+
JSON.stringify(sharedCoreFiles(loadFirstSrc)) === JSON.stringify([
|
|
40
|
+
'load-first.md', 'principles.md', 'ticket-language.md', 'substrate.md',
|
|
41
|
+
'session-handoff.md', 'configuration.md', 'techniques.md',
|
|
42
|
+
]));
|
|
43
|
+
ok("sharedCoreFiles('') returns just load-first.md", JSON.stringify(sharedCoreFiles('')) === JSON.stringify(['load-first.md']));
|
|
44
|
+
ok('sharedCoreFiles de-duplicates repeated entries', JSON.stringify(sharedCoreFiles(
|
|
45
|
+
'1. `../_shared/a.md`: x\n2. `../_shared/a.md`: x again\n3. `../_shared/b.md`: y\n'
|
|
46
|
+
)) === JSON.stringify(['load-first.md', 'a.md', 'b.md']));
|
|
47
|
+
|
|
48
|
+
// --- hookScriptTokens ----------------------------------------------------------
|
|
49
|
+
ok('hookScriptTokens: node "<p>"', JSON.stringify(hookScriptTokens('node "/x/trailhead-a.js"')) === JSON.stringify(['/x/trailhead-a.js']));
|
|
50
|
+
ok('hookScriptTokens: node <p>', JSON.stringify(hookScriptTokens('node /x/trailhead-a.js')) === JSON.stringify(['/x/trailhead-a.js']));
|
|
51
|
+
ok('hookScriptTokens: node <p> --flag', JSON.stringify(hookScriptTokens('node /x/trailhead-a.js --flag')) === JSON.stringify(['/x/trailhead-a.js']));
|
|
52
|
+
ok("hookScriptTokens: bash -c 'node <p>'", JSON.stringify(hookScriptTokens("bash -c 'node /x/trailhead-a.js'")) === JSON.stringify(['/x/trailhead-a.js']));
|
|
53
|
+
ok("hookScriptTokens: /usr/bin/env node '<p>'", JSON.stringify(hookScriptTokens("/usr/bin/env node '/x/trailhead-a.js'")) === JSON.stringify(['/x/trailhead-a.js']));
|
|
54
|
+
ok('hookScriptTokens: no trailhead script gives []', JSON.stringify(hookScriptTokens('node /x/other.js')) === JSON.stringify([]));
|
|
55
|
+
ok('hookScriptTokens: trailhead-x.json does not match', JSON.stringify(hookScriptTokens('node /x/trailhead-x.json')) === JSON.stringify([]));
|
|
56
|
+
ok('hookScriptTokens: my-trailhead-x.js does not match', JSON.stringify(hookScriptTokens('node /x/my-trailhead-x.js')) === JSON.stringify([]));
|
|
57
|
+
|
|
58
|
+
// --- expandTarget ----------------------------------------------------------------
|
|
59
|
+
const H = '/home/fixture-user';
|
|
60
|
+
ok('expandTarget ~/a becomes home', expandTarget('~/a', { env: {}, home: H }).path === path.join(H, 'a'));
|
|
61
|
+
ok('expandTarget ~ alone becomes home', expandTarget('~', { env: {}, home: H }).path === H);
|
|
62
|
+
ok('expandTarget $A/x expands with A defined', expandTarget('$A/x', { env: { A: '/v' }, home: H }).path === '/v/x');
|
|
63
|
+
ok('expandTarget ${A}/x expands with A defined', expandTarget('${A}/x', { env: { A: '/v' }, home: H }).path === '/v/x');
|
|
64
|
+
ok('expandTarget undefined ${CLAUDE_PLUGIN_ROOT} is unresolved',
|
|
65
|
+
expandTarget('${CLAUDE_PLUGIN_ROOT}/x', { env: {}, home: H }).unresolved === 'CLAUDE_PLUGIN_ROOT');
|
|
66
|
+
ok('expandTarget substitutes an empty-string variable', expandTarget('$A/x', { env: { A: '' }, home: H }).path === '/x');
|
|
67
|
+
|
|
68
|
+
// --- verifyInstall: clean fixtures ----------------------------------------------
|
|
69
|
+
function buildClaudeFixture() {
|
|
70
|
+
const configDir = mktmp();
|
|
71
|
+
const skillDirs = ['trailhead', '_shared', 'trailhead-work'];
|
|
72
|
+
const sharedFiles = ['load-first.md', 'substrate.md'];
|
|
73
|
+
const hookFiles = ['trailhead-commit-guard.js', 'trailhead-body-guard.js'];
|
|
74
|
+
const hookLibFiles = ['commit-message-check.js'];
|
|
75
|
+
write(path.join(configDir, 'skills', 'trailhead', 'SKILL.md'), 'x');
|
|
76
|
+
write(path.join(configDir, 'skills', 'trailhead-work', 'SKILL.md'), 'x');
|
|
77
|
+
write(path.join(configDir, 'skills', '_shared', 'load-first.md'), 'x');
|
|
78
|
+
write(path.join(configDir, 'skills', '_shared', 'substrate.md'), 'x');
|
|
79
|
+
write(path.join(configDir, 'hooks', 'trailhead-commit-guard.js'), 'x');
|
|
80
|
+
write(path.join(configDir, 'hooks', 'trailhead-body-guard.js'), 'x');
|
|
81
|
+
write(path.join(configDir, 'hooks', 'lib', 'commit-message-check.js'), 'x');
|
|
82
|
+
write(path.join(configDir, 'agents', 'trailhead-execute.md'), 'x');
|
|
83
|
+
const settings = {
|
|
84
|
+
hooks: {
|
|
85
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
86
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
87
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
88
|
+
] }],
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify(settings, null, 2));
|
|
92
|
+
const spec = claudeVerifySpec(configDir, { skillDirs, sharedFiles, hookFiles, hookLibFiles, env: {}, home: H });
|
|
93
|
+
return { configDir, spec, skillDirs, sharedFiles, hookFiles, hookLibFiles };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildCodexFixture() {
|
|
97
|
+
const configDir = mktmp();
|
|
98
|
+
const skillDirs = ['trailhead', '_shared', 'trailhead-work'];
|
|
99
|
+
const sharedFiles = ['load-first.md', 'substrate.md'];
|
|
100
|
+
const hookFiles = ['trailhead-commit-guard.js', 'trailhead-body-guard.js'];
|
|
101
|
+
const hookLibFiles = ['commit-message-check.js'];
|
|
102
|
+
const hooksScriptsDir = path.join(configDir, 'skills', 'trailhead', 'hooks');
|
|
103
|
+
write(path.join(configDir, 'skills', 'trailhead', 'SKILL.md'), 'x');
|
|
104
|
+
write(path.join(configDir, 'skills', 'trailhead-work', 'SKILL.md'), 'x');
|
|
105
|
+
write(path.join(configDir, 'skills', '_shared', 'load-first.md'), 'x');
|
|
106
|
+
write(path.join(configDir, 'skills', '_shared', 'substrate.md'), 'x');
|
|
107
|
+
write(path.join(hooksScriptsDir, 'trailhead-commit-guard.js'), 'x');
|
|
108
|
+
write(path.join(hooksScriptsDir, 'trailhead-body-guard.js'), 'x');
|
|
109
|
+
write(path.join(hooksScriptsDir, 'lib', 'commit-message-check.js'), 'x');
|
|
110
|
+
write(path.join(configDir, 'agents', 'trailhead-execute.toml'), 'x');
|
|
111
|
+
const hooksJson = {
|
|
112
|
+
hooks: {
|
|
113
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
114
|
+
{ type: 'command', command: `node "${path.join(hooksScriptsDir, 'trailhead-commit-guard.js')}"` },
|
|
115
|
+
{ type: 'command', command: `node "${path.join(hooksScriptsDir, 'trailhead-body-guard.js')}"` },
|
|
116
|
+
] }],
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
write(path.join(configDir, 'hooks.json'), JSON.stringify(hooksJson, null, 2));
|
|
120
|
+
const spec = codexVerifySpec(configDir, { skillDirs, sharedFiles, hookFiles, hookLibFiles, env: {}, home: H });
|
|
121
|
+
return { configDir, spec, skillDirs, sharedFiles, hookFiles, hookLibFiles, hooksScriptsDir };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
const { spec } = buildClaudeFixture();
|
|
126
|
+
const r = verifyInstall(spec);
|
|
127
|
+
ok('verifyInstall claude clean fixture: no missing/unverified', r.missing.length === 0 && r.unverified.length === 0);
|
|
128
|
+
}
|
|
129
|
+
{
|
|
130
|
+
const { spec } = buildCodexFixture();
|
|
131
|
+
const r = verifyInstall(spec);
|
|
132
|
+
ok('verifyInstall codex clean fixture: no missing/unverified', r.missing.length === 0 && r.unverified.length === 0);
|
|
133
|
+
}
|
|
134
|
+
// The Codex spec never flags search-guard (it is never in hookFiles for Codex).
|
|
135
|
+
{
|
|
136
|
+
const { spec } = buildCodexFixture();
|
|
137
|
+
const r = verifyInstall(spec);
|
|
138
|
+
ok('verifyInstall codex spec never names search-guard',
|
|
139
|
+
!r.missing.some((m) => m.includes('search-guard')) && !r.unverified.some((u) => u.includes('search-guard')));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- verifyInstall: one named `missing` entry each ------------------------------
|
|
143
|
+
{
|
|
144
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
145
|
+
fs.rmSync(path.join(configDir, 'hooks', 'trailhead-body-guard.js'));
|
|
146
|
+
const r = verifyInstall(spec);
|
|
147
|
+
ok('missing: a missing hook file', r.missing.includes('hook script trailhead-body-guard.js'));
|
|
148
|
+
}
|
|
149
|
+
{
|
|
150
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
151
|
+
fs.rmSync(path.join(configDir, 'hooks', 'trailhead-body-guard.js'));
|
|
152
|
+
fs.mkdirSync(path.join(configDir, 'hooks', 'trailhead-body-guard.js'), { recursive: true });
|
|
153
|
+
const r = verifyInstall(spec);
|
|
154
|
+
ok('missing: a hook path that is a directory', r.missing.includes('hook script trailhead-body-guard.js'));
|
|
155
|
+
}
|
|
156
|
+
{
|
|
157
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
158
|
+
fs.rmSync(path.join(configDir, 'hooks', 'lib', 'commit-message-check.js'));
|
|
159
|
+
const r = verifyInstall(spec);
|
|
160
|
+
ok('missing: a missing lib file', r.missing.includes('hook lib lib/commit-message-check.js'));
|
|
161
|
+
}
|
|
162
|
+
{
|
|
163
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
164
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
165
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
166
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
167
|
+
] }] },
|
|
168
|
+
}));
|
|
169
|
+
const r = verifyInstall(spec);
|
|
170
|
+
ok('missing: an unregistered hook', r.missing.includes('hook registration trailhead-body-guard.js (settings.json)'));
|
|
171
|
+
}
|
|
172
|
+
{
|
|
173
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
174
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
175
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
176
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
177
|
+
{ type: 'command', command: 'node "/other/dir/trailhead-body-guard.js"' },
|
|
178
|
+
] }] },
|
|
179
|
+
}));
|
|
180
|
+
const r = verifyInstall(spec);
|
|
181
|
+
ok('missing: a lookalike registration at /other/dir/trailhead-commit-guard.js is still unregistered',
|
|
182
|
+
r.missing.includes('hook registration trailhead-body-guard.js (settings.json)') &&
|
|
183
|
+
r.missing.includes('registered hook target /other/dir/trailhead-body-guard.js (PreToolUse, settings.json)'));
|
|
184
|
+
}
|
|
185
|
+
{
|
|
186
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
187
|
+
fs.rmSync(path.join(configDir, 'agents', 'trailhead-execute.md'));
|
|
188
|
+
write(path.join(configDir, 'agents', 'my-custom-agent.md'), 'x');
|
|
189
|
+
const r = verifyInstall(spec);
|
|
190
|
+
ok('missing: zero agents, with a non-trailhead agent present', r.missing.some((m) => m.startsWith('agents: no trailhead-* agents')));
|
|
191
|
+
}
|
|
192
|
+
{
|
|
193
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
194
|
+
fs.rmSync(path.join(configDir, 'skills', 'trailhead-work', 'SKILL.md'));
|
|
195
|
+
const r = verifyInstall(spec);
|
|
196
|
+
ok('missing: a cluster with no SKILL.md', r.missing.includes('skill trailhead-work/SKILL.md'));
|
|
197
|
+
}
|
|
198
|
+
{
|
|
199
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
200
|
+
fs.rmSync(path.join(configDir, 'skills', '_shared'), { recursive: true, force: true });
|
|
201
|
+
const r = verifyInstall(spec);
|
|
202
|
+
ok('missing: _shared missing', r.missing.includes('shared core _shared/load-first.md') && r.missing.includes('shared core _shared/substrate.md'));
|
|
203
|
+
}
|
|
204
|
+
{
|
|
205
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
206
|
+
fs.rmSync(path.join(configDir, 'skills', '_shared', 'substrate.md'));
|
|
207
|
+
const r = verifyInstall(spec);
|
|
208
|
+
ok('missing: _shared present without substrate.md', r.missing.includes('shared core _shared/substrate.md') && !r.missing.includes('shared core _shared/load-first.md'));
|
|
209
|
+
}
|
|
210
|
+
{
|
|
211
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
212
|
+
const dangling = path.join(configDir, 'hooks', 'trailhead-body-guard.js');
|
|
213
|
+
fs.rmSync(dangling);
|
|
214
|
+
fs.symlinkSync(path.join(configDir, 'nonexistent-target.js'), dangling);
|
|
215
|
+
const r = verifyInstall(spec);
|
|
216
|
+
ok('missing: a dangling symlink', r.missing.includes('hook script trailhead-body-guard.js'));
|
|
217
|
+
}
|
|
218
|
+
{
|
|
219
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
220
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
221
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
222
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
223
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
224
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-old-guard.js')}"` },
|
|
225
|
+
] }] },
|
|
226
|
+
}));
|
|
227
|
+
const r = verifyInstall(spec);
|
|
228
|
+
ok('missing: a stale trailhead-old-guard.js registration',
|
|
229
|
+
r.missing.includes(`registered hook target ${path.join(configDir, 'hooks', 'trailhead-old-guard.js')} (PreToolUse, settings.json)`));
|
|
230
|
+
}
|
|
231
|
+
{
|
|
232
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
233
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
234
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
235
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
236
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
237
|
+
{ type: 'command', command: 'node hooks/trailhead-x-guard.js' },
|
|
238
|
+
] }] },
|
|
239
|
+
}));
|
|
240
|
+
const r = verifyInstall(spec);
|
|
241
|
+
ok('missing: a relative target', r.missing.includes('registered hook target hooks/trailhead-x-guard.js is relative (PreToolUse, settings.json)'));
|
|
242
|
+
}
|
|
243
|
+
{
|
|
244
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
245
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
246
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
247
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
248
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
249
|
+
{ type: 'command', command: 'node "~/gone/trailhead-x-guard.js"' },
|
|
250
|
+
] }] },
|
|
251
|
+
}));
|
|
252
|
+
const r = verifyInstall(spec);
|
|
253
|
+
ok('missing: a ~/gone/trailhead-x-guard.js target (expands under the fixture home, not found)',
|
|
254
|
+
r.missing.includes(`registered hook target ${path.join(H, 'gone', 'trailhead-x-guard.js')} (PreToolUse, settings.json)`));
|
|
255
|
+
}
|
|
256
|
+
{
|
|
257
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
258
|
+
spec.env = { A: configDir };
|
|
259
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
260
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
261
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
262
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
263
|
+
{ type: 'command', command: 'node "$A/trailhead-x-guard.js"' },
|
|
264
|
+
] }] },
|
|
265
|
+
}));
|
|
266
|
+
const r = verifyInstall(spec);
|
|
267
|
+
ok('missing: a $A/trailhead-x-guard.js target with A defined but the file absent',
|
|
268
|
+
r.missing.includes(`registered hook target ${path.join(configDir, 'trailhead-x-guard.js')} (PreToolUse, settings.json)`));
|
|
269
|
+
}
|
|
270
|
+
{
|
|
271
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
272
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
273
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
274
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
275
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
276
|
+
{ type: 'command', command: `bash -c 'node "${path.join(configDir, 'hooks', 'trailhead-gone.js')}"'` },
|
|
277
|
+
] }] },
|
|
278
|
+
}));
|
|
279
|
+
const r = verifyInstall(spec);
|
|
280
|
+
ok("missing: a script inside bash -c '...' whose file is gone",
|
|
281
|
+
r.missing.includes(`registered hook target ${path.join(configDir, 'hooks', 'trailhead-gone.js')} (PreToolUse, settings.json)`));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- verifyInstall: resolution cases ---------------------------------------------
|
|
285
|
+
{
|
|
286
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
287
|
+
spec.env = {};
|
|
288
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
289
|
+
hooks: { SessionStart: [{ matcher: '', hooks: [
|
|
290
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
291
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
292
|
+
{ type: 'command', command: 'node "${CLAUDE_PLUGIN_ROOT}/hooks/trailhead-x.js"' },
|
|
293
|
+
] }] },
|
|
294
|
+
}));
|
|
295
|
+
const r = verifyInstall(spec);
|
|
296
|
+
ok('resolution: undefined ${CLAUDE_PLUGIN_ROOT} gives missing: [] plus one unverified entry naming the variable',
|
|
297
|
+
r.missing.length === 0 && r.unverified.some((u) => u.includes('CLAUDE_PLUGIN_ROOT undefined')));
|
|
298
|
+
}
|
|
299
|
+
{
|
|
300
|
+
// ~/... and $A/... pointing at EXISTING fixture files both pass.
|
|
301
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
302
|
+
const homeDir = mktmp();
|
|
303
|
+
spec.home = homeDir;
|
|
304
|
+
spec.env = { A: configDir };
|
|
305
|
+
write(path.join(homeDir, 'sub', 'trailhead-tilde.js'), 'x');
|
|
306
|
+
write(path.join(configDir, 'trailhead-dollar.js'), 'x');
|
|
307
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
308
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
309
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
310
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
311
|
+
{ type: 'command', command: 'node "~/sub/trailhead-tilde.js"' },
|
|
312
|
+
{ type: 'command', command: 'node "$A/trailhead-dollar.js"' },
|
|
313
|
+
] }] },
|
|
314
|
+
}));
|
|
315
|
+
const r = verifyInstall(spec);
|
|
316
|
+
ok('resolution: ~/... and $A/... pointing at existing fixture files pass (no new missing/unverified)',
|
|
317
|
+
r.missing.length === 0 && r.unverified.length === 0);
|
|
318
|
+
}
|
|
319
|
+
{
|
|
320
|
+
// A non-trailhead hook with a missing target is ignored entirely.
|
|
321
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
322
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
323
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
324
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
325
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
326
|
+
{ type: 'command', command: 'node "/gone/other.js"' },
|
|
327
|
+
] }] },
|
|
328
|
+
}));
|
|
329
|
+
const r = verifyInstall(spec);
|
|
330
|
+
ok('resolution: a non-trailhead hook with a missing target is ignored', r.missing.length === 0 && r.unverified.length === 0);
|
|
331
|
+
}
|
|
332
|
+
{
|
|
333
|
+
// Missing/malformed registry: no crash, every expected hook unregistered.
|
|
334
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
335
|
+
fs.rmSync(path.join(configDir, 'settings.json'));
|
|
336
|
+
const r1 = verifyInstall(spec);
|
|
337
|
+
ok('resolution: a missing registry file does not crash and flags every hook unregistered',
|
|
338
|
+
r1.missing.includes('hook registration trailhead-commit-guard.js (settings.json)') &&
|
|
339
|
+
r1.missing.includes('hook registration trailhead-body-guard.js (settings.json)'));
|
|
340
|
+
write(path.join(configDir, 'settings.json'), '{not json');
|
|
341
|
+
const r2 = verifyInstall(spec);
|
|
342
|
+
ok('resolution: a malformed registry file does not crash and flags every hook unregistered',
|
|
343
|
+
r2.missing.includes('hook registration trailhead-commit-guard.js (settings.json)'));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- A2: unresolved expected registration stays warning-only ---------------------
|
|
347
|
+
{
|
|
348
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
349
|
+
fs.rmSync(path.join(configDir, 'hooks', 'trailhead-body-guard.js'));
|
|
350
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
351
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
352
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
353
|
+
{ type: 'command', command: 'node "${CLAUDE_PLUGIN_ROOT}/hooks/trailhead-body-guard.js"' },
|
|
354
|
+
] }] },
|
|
355
|
+
}));
|
|
356
|
+
const r = verifyInstall(spec);
|
|
357
|
+
ok('A2: an unresolved token whose basename matches the expected file stays unverified, not also missing',
|
|
358
|
+
!r.missing.includes('hook registration trailhead-body-guard.js (settings.json)') &&
|
|
359
|
+
r.unverified.some((u) => u.includes('CLAUDE_PLUGIN_ROOT undefined')));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- formatters --------------------------------------------------------------
|
|
363
|
+
{
|
|
364
|
+
const out = formatVerifyFailure('Claude Code', '/c/dir', { missing: ['skill trailhead-work/SKILL.md'], unverified: ['/x/trailhead-y.js (PreToolUse, settings.json: $Y undefined)'] });
|
|
365
|
+
ok('formatVerifyFailure contains the host', out.includes('Claude Code'));
|
|
366
|
+
ok('formatVerifyFailure contains the dir', out.includes('/c/dir'));
|
|
367
|
+
ok('formatVerifyFailure contains the missing entry', out.includes('skill trailhead-work/SKILL.md'));
|
|
368
|
+
ok('formatVerifyFailure contains the unverified entry', out.includes('$Y undefined'));
|
|
369
|
+
}
|
|
370
|
+
ok('formatVerifyWarnings([]) is []', JSON.stringify(formatVerifyWarnings([])) === '[]');
|
|
371
|
+
ok('formatVerifyWarnings has one line per entry', (() => {
|
|
372
|
+
const lines = formatVerifyWarnings(['a', 'b']);
|
|
373
|
+
return lines.length === 2 && lines[0].includes('⚠') && lines[0].includes('a') && lines[1].includes('b');
|
|
374
|
+
})());
|
|
375
|
+
|
|
376
|
+
// --- cleanup ---------------------------------------------------------------
|
|
377
|
+
for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
|
|
378
|
+
|
|
379
|
+
console.log(`✓ install-verify: ${passed} assertions passed`);
|
package/bin/trailhead.js
CHANGED
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
// projection in bin/lib/codex-projection.js (engine text rewrites, prompt
|
|
16
16
|
// generation). With no --codex/--claude flag the host is auto-detected from
|
|
17
17
|
// the CLIs on $PATH; the interactive prompt only fires when that is ambiguous.
|
|
18
|
+
//
|
|
19
|
+
// Post-install check (#182): just before printing the final ✓, both
|
|
20
|
+
// installers run bin/lib/install-verify.js's verifyInstall against what's
|
|
21
|
+
// actually on disk (and, for the registered hook targets, what's actually in
|
|
22
|
+
// the host's registry file). A gap fails the run loudly instead of a silent
|
|
23
|
+
// ✓; a registered target that depends on an env var this process doesn't
|
|
24
|
+
// have set prints as a ⚠ warning alongside the ✓, not a failure.
|
|
18
25
|
|
|
19
26
|
const fs = require('fs');
|
|
20
27
|
const os = require('os');
|
|
@@ -24,6 +31,7 @@ const { spawnSync } = require('child_process');
|
|
|
24
31
|
|
|
25
32
|
const { getHost, configDirFor, codexVersionGate } = require('./lib/host-descriptor.js');
|
|
26
33
|
const { codexLayout, convertToCodex, injectCodexAdapterHeader, codexAgentsYaml, codexClusterAgentsYaml, codexHookEntries, enableCodexHooksFeature, codexAgentTomlPlan, codexVerbSkillPlan, enableCodexMultiAgentV2Feature } = require('./lib/codex-projection.js');
|
|
34
|
+
const { sharedCoreFiles, claudeVerifySpec, codexVerifySpec, verifyInstall, formatVerifyFailure, formatVerifyWarnings } = require('./lib/install-verify.js');
|
|
27
35
|
|
|
28
36
|
const PKG = path.resolve(__dirname, '..');
|
|
29
37
|
const SRC = path.join(PKG, 'plugins', 'trailhead');
|
|
@@ -42,6 +50,28 @@ function engineSkillDirs() {
|
|
|
42
50
|
.sort();
|
|
43
51
|
}
|
|
44
52
|
|
|
53
|
+
// Every subdirectory shipped under plugins/trailhead/skills, with no SKILL.md
|
|
54
|
+
// filter (unlike engineSkillDirs). This is the EXPECTED skill set the
|
|
55
|
+
// post-install check verifies against, so a cluster whose SKILL.md failed to
|
|
56
|
+
// copy is caught as missing rather than silently dropped from the set.
|
|
57
|
+
function shippedSkillDirs() {
|
|
58
|
+
const skillsRoot = path.join(SRC, 'skills');
|
|
59
|
+
if (!fs.existsSync(skillsRoot)) return [];
|
|
60
|
+
return fs.readdirSync(skillsRoot, { withFileTypes: true })
|
|
61
|
+
.filter((e) => e.isDirectory())
|
|
62
|
+
.map((e) => e.name)
|
|
63
|
+
.sort();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The shared-core file list, single-sourced from the source copy of
|
|
67
|
+
// _shared/load-first.md (see install-verify.js's sharedCoreFiles). Read at
|
|
68
|
+
// verify time by each installer; a missing file yields just load-first.md.
|
|
69
|
+
function readSharedCoreFiles() {
|
|
70
|
+
let loadFirstText = '';
|
|
71
|
+
try { loadFirstText = fs.readFileSync(path.join(SRC, 'skills', '_shared', 'load-first.md'), 'utf8'); } catch { /* missing: falls back to just load-first.md */ }
|
|
72
|
+
return sharedCoreFiles(loadFirstText);
|
|
73
|
+
}
|
|
74
|
+
|
|
45
75
|
// Sweep every trailhead-owned skill dir under a skills root, by name: the
|
|
46
76
|
// dispatcher (`trailhead`), the shared core (`_shared`), and every
|
|
47
77
|
// `trailhead-<cluster|verb>` sibling. Clears a prior layout's stale dirs (an
|
|
@@ -218,10 +248,16 @@ const HOOK_FILES = ['trailhead-commit-guard.js', 'trailhead-issue-injection-scan
|
|
|
218
248
|
// require('./lib/commit-message-check.js'); trailhead-search-guard.js and
|
|
219
249
|
// trailhead-secret-read-guard.js both do require('./lib/shell-scan.js'), #135
|
|
220
250
|
// follow-up; trailhead-body-guard.js and trailhead-secret-guard.js both do
|
|
221
|
-
// require('./lib/gh-subcommand.js'), #153 follow-up
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
251
|
+
// require('./lib/gh-subcommand.js'), #153 follow-up; trailhead-check-update.js
|
|
252
|
+
// does require('./lib/model-defaults-review.js'), which in turn reads the
|
|
253
|
+
// sibling model-defaults.json, #186). Copied by name, never a recursive
|
|
254
|
+
// sweep: the Claude hooks/lib dir is shared with other plugins, so we must
|
|
255
|
+
// not clobber a co-tenant's lib nor ship our own *.test.js files.
|
|
256
|
+
const HOOK_LIB_FILES = ['commit-message-check.js', 'shell-scan.js', 'gh-subcommand.js', 'model-defaults-review.js', 'model-defaults.json'];
|
|
257
|
+
// search-guard is Claude-Code-specific (#169): excluded from both the Codex
|
|
258
|
+
// hook copy and the Codex verify spec's expected hookFiles, so a healthy
|
|
259
|
+
// Codex install is never flagged for a script it never ships.
|
|
260
|
+
const CODEX_HOOK_EXCLUDE = ['trailhead-search-guard.js'];
|
|
225
261
|
|
|
226
262
|
// Copy the hook scripts and their runtime libs into a destination hooks dir
|
|
227
263
|
// (Claude's ~/.claude/hooks or Codex's skills/trailhead/hooks). Shared by both
|
|
@@ -234,8 +270,12 @@ const HOOK_LIB_FILES = ['commit-message-check.js', 'shell-scan.js', 'gh-subcomma
|
|
|
234
270
|
// dest first so a reinstall over an existing file or symlink never hits EEXIST.
|
|
235
271
|
function copyHookScripts(destHooksDir, { useSymlink = false, exclude = [] } = {}) {
|
|
236
272
|
ensure(destHooksDir);
|
|
273
|
+
// rmrf the dest first, always: a reinstall that finds its source gone must
|
|
274
|
+
// still remove whatever an earlier install left behind, not just skip the
|
|
275
|
+
// copy and leave a stale file in place.
|
|
237
276
|
const put = (src, dest) => {
|
|
238
277
|
rmrf(dest);
|
|
278
|
+
if (!fs.existsSync(src)) return;
|
|
239
279
|
if (useSymlink) {
|
|
240
280
|
fs.symlinkSync(src, dest, 'file');
|
|
241
281
|
} else {
|
|
@@ -384,6 +424,24 @@ function installClaude(configDir, { useSymlink }) {
|
|
|
384
424
|
].some(Boolean);
|
|
385
425
|
writeJSON(P.settings, s);
|
|
386
426
|
|
|
427
|
+
// Verify the install actually landed before reporting success (#182): a
|
|
428
|
+
// filesystem-only check, run just before the ✓ line, that never trusts the
|
|
429
|
+
// copy/write calls above to have succeeded silently.
|
|
430
|
+
const spec = claudeVerifySpec(configDir, {
|
|
431
|
+
skillDirs: shippedSkillDirs(),
|
|
432
|
+
sharedFiles: readSharedCoreFiles(),
|
|
433
|
+
hookFiles: HOOK_FILES,
|
|
434
|
+
hookLibFiles: HOOK_LIB_FILES,
|
|
435
|
+
env: process.env,
|
|
436
|
+
home: os.homedir(),
|
|
437
|
+
});
|
|
438
|
+
const { missing, unverified } = verifyInstall(spec);
|
|
439
|
+
if (missing.length) {
|
|
440
|
+
console.error(formatVerifyFailure('Claude Code', configDir, { missing, unverified }));
|
|
441
|
+
process.exitCode = 1;
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
387
445
|
console.log(`✓ trailhead installed for Claude Code → ${configDir}`);
|
|
388
446
|
console.log(` skills → ${path.join(configDir, 'skills')}/ (${skillDirs.join(', ')})${useSymlink ? ' (symlink)' : ''}`);
|
|
389
447
|
console.log(` commands → ${P.commands} (/trailhead:*)`);
|
|
@@ -392,6 +450,7 @@ function installClaude(configDir, { useSymlink }) {
|
|
|
392
450
|
console.log(` templates → ${P.templates}`);
|
|
393
451
|
console.log('\nRestart or reload your agent to pick up the commands, then run /trailhead to start.');
|
|
394
452
|
console.log('Note: the commit guard now runs on every git commit (conventional + no Co-Authored-By).');
|
|
453
|
+
for (const w of formatVerifyWarnings(unverified)) console.log(w);
|
|
395
454
|
}
|
|
396
455
|
|
|
397
456
|
// --- Codex adapter -----------------------------------------------------------
|
|
@@ -580,8 +639,12 @@ function installCodex(configDir, { useSymlink }) {
|
|
|
580
639
|
// settings.json hooks block). search-guard is excluded: it is
|
|
581
640
|
// Claude-Code-specific (see codexHookEntries), so it is neither copied nor
|
|
582
641
|
// registered here.
|
|
583
|
-
copyHookScripts(L.hooksScriptsDir, { useSymlink, exclude:
|
|
642
|
+
copyHookScripts(L.hooksScriptsDir, { useSymlink, exclude: CODEX_HOOK_EXCLUDE });
|
|
584
643
|
const h = readJSON(L.hooksJson);
|
|
644
|
+
// Migration: strip a pre-#169 search-guard registration. Its target is
|
|
645
|
+
// always gone now (search-guard is never copied to Codex), so a prior
|
|
646
|
+
// install's stale entry would otherwise fail the post-install check below.
|
|
647
|
+
stripHook(h, 'PreToolUse', 'trailhead-search-guard.js');
|
|
585
648
|
for (const e of codexHookEntries(L.hooksScriptsDir)) addHook(h, e.event, e.matcher, e.command);
|
|
586
649
|
writeJSON(L.hooksJson, h);
|
|
587
650
|
|
|
@@ -632,6 +695,23 @@ function installCodex(configDir, { useSymlink }) {
|
|
|
632
695
|
}
|
|
633
696
|
}
|
|
634
697
|
|
|
698
|
+
// Verify the install actually landed before reporting success (#182),
|
|
699
|
+
// mirroring installClaude's check.
|
|
700
|
+
const spec = codexVerifySpec(configDir, {
|
|
701
|
+
skillDirs: shippedSkillDirs(),
|
|
702
|
+
sharedFiles: readSharedCoreFiles(),
|
|
703
|
+
hookFiles: HOOK_FILES.filter((f) => !CODEX_HOOK_EXCLUDE.includes(f)),
|
|
704
|
+
hookLibFiles: HOOK_LIB_FILES,
|
|
705
|
+
env: process.env,
|
|
706
|
+
home: os.homedir(),
|
|
707
|
+
});
|
|
708
|
+
const { missing, unverified } = verifyInstall(spec);
|
|
709
|
+
if (missing.length) {
|
|
710
|
+
console.error(formatVerifyFailure('Codex', configDir, { missing, unverified }));
|
|
711
|
+
process.exitCode = 1;
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
635
715
|
console.log(`✓ trailhead installed for Codex → ${configDir}`);
|
|
636
716
|
console.log(` skill → ${L.skillDir}/ (invoke $trailhead)`);
|
|
637
717
|
console.log(` templates → ${L.templatesDir}/`);
|
|
@@ -656,6 +736,7 @@ function installCodex(configDir, { useSymlink }) {
|
|
|
656
736
|
if (featureManualV2) {
|
|
657
737
|
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.`);
|
|
658
738
|
}
|
|
739
|
+
for (const w of formatVerifyWarnings(unverified)) console.log(w);
|
|
659
740
|
}
|
|
660
741
|
|
|
661
742
|
function uninstallCodex(configDir) {
|