claude-dev-env 1.57.1 → 1.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/install.mjs +217 -27
- package/bin/install.test.mjs +344 -1
- package/hooks/blocking/intent_only_ending_blocker.py +155 -0
- package/hooks/blocking/session_handoff_blocker.py +190 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
- package/hooks/blocking/test_session_handoff_blocker.py +312 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/messages.py +4 -0
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
- package/hooks/workflow/auto_formatter.py +26 -1
- package/hooks/workflow/test_auto_formatter.py +134 -0
- package/package.json +1 -1
- package/rules/conservative-action.md +1 -0
- package/rules/long-horizon-autonomy.md +43 -0
- package/skills/autoconverge/SKILL.md +56 -6
- package/skills/autoconverge/reference/closing-report.md +44 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
- package/skills/autoconverge/workflow/converge.mjs +12 -14
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
- package/skills/autoconverge/workflow/render_report.py +903 -0
- package/skills/autoconverge/workflow/test_render_report.py +484 -0
package/bin/install.mjs
CHANGED
|
@@ -165,12 +165,31 @@ const INSTALL_GROUPS = {
|
|
|
165
165
|
...discoverDependencyGroups(),
|
|
166
166
|
};
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
/**
|
|
169
|
+
* Returns the ordered python interpreter candidates to probe for the given
|
|
170
|
+
* platform. On win32 the `py -3` launcher is probed first because it resolves
|
|
171
|
+
* through the Windows registry and is immune to the Microsoft Store
|
|
172
|
+
* `python.exe` App Execution Alias that otherwise gets baked into settings.json.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} platform A value from `process.platform` (e.g. 'win32', 'linux').
|
|
175
|
+
* @returns {{command: string, versionFlag: string}[]} Candidates in probe order.
|
|
176
|
+
*/
|
|
177
|
+
export function pythonCandidatesForPlatform(platform) {
|
|
178
|
+
const windowsOrder = [
|
|
179
|
+
{ command: 'py -3', versionFlag: '--version' },
|
|
180
|
+
{ command: 'python3', versionFlag: '--version' },
|
|
181
|
+
{ command: 'python', versionFlag: '--version' },
|
|
182
|
+
];
|
|
183
|
+
const defaultOrder = [
|
|
170
184
|
{ command: 'python3', versionFlag: '--version' },
|
|
171
185
|
{ command: 'python', versionFlag: '--version' },
|
|
172
186
|
{ command: 'py -3', versionFlag: '--version' },
|
|
173
187
|
];
|
|
188
|
+
return platform === 'win32' ? windowsOrder : defaultOrder;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function detectPython() {
|
|
192
|
+
const candidates = pythonCandidatesForPlatform(process.platform);
|
|
174
193
|
for (const { command, versionFlag } of candidates) {
|
|
175
194
|
try {
|
|
176
195
|
const version = execSync(`${command} ${versionFlag}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
@@ -235,22 +254,155 @@ function backupClaudeHubBeforeOverwrite(destPath, incomingPath) {
|
|
|
235
254
|
return backupPath;
|
|
236
255
|
}
|
|
237
256
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Builds the set of hook script paths this installer manages, each relative to
|
|
259
|
+
* the hooks directory (e.g. 'blocking/code_rules_enforcer.py'), parsed from the
|
|
260
|
+
* `${CLAUDE_PLUGIN_ROOT}/hooks/<path>` references in hooks.json. Inline
|
|
261
|
+
* `python3 -c` commands reference the hooks directory without a script tail and
|
|
262
|
+
* contribute nothing.
|
|
263
|
+
*
|
|
264
|
+
* @param {{hooks: object}} hooksConfig Parsed hooks.json.
|
|
265
|
+
* @returns {Set<string>} Forward-slash relative script paths under hooks/.
|
|
266
|
+
*/
|
|
267
|
+
export function managedHookScriptRelativePaths(hooksConfig) {
|
|
268
|
+
const relativePaths = new Set();
|
|
269
|
+
const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.py)/g;
|
|
270
|
+
for (const matcherGroups of Object.values(hooksConfig.hooks)) {
|
|
271
|
+
for (const sourceGroup of matcherGroups) {
|
|
272
|
+
for (const hook of sourceGroup.hooks) {
|
|
273
|
+
for (const scriptMatch of hook.command.matchAll(scriptReferencePattern)) {
|
|
274
|
+
relativePaths.add(scriptMatch[1]);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return relativePaths;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Builds the union of managed hook script paths across the given package source
|
|
284
|
+
* roots by parsing each root's hooks/hooks.json. The installer copies hook
|
|
285
|
+
* scripts into ~/.claude/hooks/ but never copies hooks.json itself, so the
|
|
286
|
+
* uninstall and update-refresh purge must read the managed-hook set from the
|
|
287
|
+
* package source the same way the merge does, never from ~/.claude/hooks/.
|
|
288
|
+
* Roots without a hooks.json contribute nothing.
|
|
289
|
+
*
|
|
290
|
+
* @param {string[]} sourceRoots Package roots that hold a hooks/hooks.json.
|
|
291
|
+
* @returns {Set<string>} Forward-slash relative script paths under hooks/.
|
|
292
|
+
*/
|
|
293
|
+
export function managedHookScriptRelativePathsFromSourceRoots(sourceRoots) {
|
|
294
|
+
const relativePaths = new Set();
|
|
295
|
+
for (const sourceRoot of sourceRoots) {
|
|
296
|
+
const hooksJsonPath = join(sourceRoot, 'hooks', 'hooks.json');
|
|
297
|
+
if (!existsSync(hooksJsonPath)) continue;
|
|
298
|
+
const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
|
|
299
|
+
for (const relativePath of managedHookScriptRelativePaths(hooksConfig)) {
|
|
300
|
+
relativePaths.add(relativePath);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return relativePaths;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolves every package source root the installer can copy hooks from: this
|
|
308
|
+
* package plus each resolvable dependency package that ships hooks. The purge
|
|
309
|
+
* reads hooks.json from these roots so it prunes managed entries no matter which
|
|
310
|
+
* package contributed them.
|
|
311
|
+
*
|
|
312
|
+
* @returns {string[]} Distinct package roots, this package first.
|
|
313
|
+
*/
|
|
314
|
+
function managedPackageSourceRoots() {
|
|
315
|
+
const dependencyRoots = Object.values(INSTALL_GROUPS)
|
|
316
|
+
.filter(group => group.packageRoot)
|
|
317
|
+
.map(group => group.packageRoot);
|
|
318
|
+
return [...new Set([PACKAGE_ROOT, ...dependencyRoots])];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Reports whether a settings.json hook command points at one of this installer's
|
|
323
|
+
* managed scripts, no matter how the home directory was written ($HOME, ~,
|
|
324
|
+
* ${HOME}, or an absolute path) or which path separator was used. Matching on
|
|
325
|
+
* the `/.claude/hooks/<relative>` tail lets a reinstall prune stale entries from
|
|
326
|
+
* earlier installs that used a different interpreter prefix, while leaving
|
|
327
|
+
* user-authored hooks outside the managed set untouched.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} commandString The hook command from settings.json.
|
|
330
|
+
* @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
|
|
331
|
+
* @returns {boolean} True when the command references a managed script.
|
|
332
|
+
*/
|
|
333
|
+
export function commandReferencesManagedHook(commandString, managedHookRelativePaths) {
|
|
334
|
+
const normalizedCommand = commandString.replace(/\\/g, '/');
|
|
335
|
+
if (commandIsInlineManagedValidatorRunner(normalizedCommand)) {
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
for (const relativePath of managedHookRelativePaths) {
|
|
339
|
+
if (commandTailEndsAtManagedHook(normalizedCommand, relativePath)) {
|
|
340
|
+
return true;
|
|
249
341
|
}
|
|
250
342
|
}
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Reports whether a command contains the `/.claude/hooks/<relative>` tail ending
|
|
348
|
+
* at a path boundary: end of string, or an argument separator (whitespace, quote,
|
|
349
|
+
* or semicolon). Anchoring the tail keeps a user hook whose path is the managed
|
|
350
|
+
* tail plus a suffix (`code_rules_enforcer.py.bak`, `a.py/extra/thing.py`) outside
|
|
351
|
+
* the managed set, so it is never pruned.
|
|
352
|
+
*
|
|
353
|
+
* @param {string} normalizedCommand Forward-slash-normalized hook command.
|
|
354
|
+
* @param {string} relativePath Managed script path under hooks/.
|
|
355
|
+
* @returns {boolean} True when the managed tail ends at a path boundary.
|
|
356
|
+
*/
|
|
357
|
+
function commandTailEndsAtManagedHook(normalizedCommand, relativePath) {
|
|
358
|
+
const commandArgumentBoundary = /[\s'";]/;
|
|
359
|
+
const managedTail = `/.claude/hooks/${relativePath}`;
|
|
360
|
+
let searchStart = normalizedCommand.indexOf(managedTail);
|
|
361
|
+
while (searchStart !== -1) {
|
|
362
|
+
const characterAfterTail = normalizedCommand[searchStart + managedTail.length];
|
|
363
|
+
if (characterAfterTail === undefined || commandArgumentBoundary.test(characterAfterTail)) {
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
searchStart = normalizedCommand.indexOf(managedTail, searchStart + 1);
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Reports whether a settings.json hook command is the inline validators-runner
|
|
373
|
+
* the installer writes in place of a standalone script. That hook inserts the
|
|
374
|
+
* managed hooks directory onto sys.path and imports run_all_validators, so it
|
|
375
|
+
* carries no `<script>.py` tail for managedHookScriptRelativePaths to record.
|
|
376
|
+
* Matching its shape lets a reinstall prune the prior copy before appending the
|
|
377
|
+
* freshly rewritten one, keeping the merge idempotent.
|
|
378
|
+
*
|
|
379
|
+
* @param {string} normalizedCommand Forward-slash-normalized hook command.
|
|
380
|
+
* @returns {boolean} True when the command is the inline validators runner.
|
|
381
|
+
*/
|
|
382
|
+
export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
|
|
383
|
+
const inlineValidatorRunnerMarker = /sys\.path\.insert\([^)]*\.claude\/hooks[^)]*\)[\s\S]*run_all_validators/;
|
|
384
|
+
return (
|
|
385
|
+
normalizedCommand.includes('/.claude/hooks') &&
|
|
386
|
+
inlineValidatorRunnerMarker.test(normalizedCommand)
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Merges the installer's managed hook groups into a settings object in memory,
|
|
392
|
+
* pruning every prior managed hook (standalone script or inline validators
|
|
393
|
+
* runner) from each matcher group before appending the freshly rewritten copies
|
|
394
|
+
* so repeated merges stay idempotent. User-authored hooks in the same group are
|
|
395
|
+
* preserved untouched.
|
|
396
|
+
*
|
|
397
|
+
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
398
|
+
* @param {{hooks: object}} hooksConfig Parsed hooks.json.
|
|
399
|
+
* @param {string} pluginRootDir Directory ${CLAUDE_PLUGIN_ROOT} resolves to.
|
|
400
|
+
* @param {string} pythonCommand Interpreter command that replaces python3.
|
|
401
|
+
* @returns {number} Count of matcher groups merged.
|
|
402
|
+
*/
|
|
403
|
+
export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand) {
|
|
404
|
+
const managedHookRelativePaths = managedHookScriptRelativePaths(hooksConfig);
|
|
251
405
|
if (!settings.hooks) settings.hooks = {};
|
|
252
|
-
const installedHooksDir = join(CLAUDE_HOME, 'hooks');
|
|
253
|
-
const pluginRootDir = CLAUDE_HOME;
|
|
254
406
|
let groupCount = 0;
|
|
255
407
|
for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
|
|
256
408
|
if (!settings.hooks[eventType]) settings.hooks[eventType] = [];
|
|
@@ -267,7 +419,7 @@ function mergeHooks(hooksSourceRoot, pythonCommand) {
|
|
|
267
419
|
if (existingIndex >= 0) {
|
|
268
420
|
const existing = settings.hooks[eventType][existingIndex];
|
|
269
421
|
const userHooks = existing.hooks.filter(
|
|
270
|
-
hook => !hook.command
|
|
422
|
+
hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
|
|
271
423
|
);
|
|
272
424
|
settings.hooks[eventType][existingIndex] = {
|
|
273
425
|
...existing,
|
|
@@ -279,6 +431,51 @@ function mergeHooks(hooksSourceRoot, pythonCommand) {
|
|
|
279
431
|
groupCount++;
|
|
280
432
|
}
|
|
281
433
|
}
|
|
434
|
+
return groupCount;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Removes every managed hook (standalone script or inline validators runner)
|
|
439
|
+
* from a settings object in memory, matching each command through
|
|
440
|
+
* commandReferencesManagedHook so entries written with any home-path style
|
|
441
|
+
* ($HOME, ~, ${HOME}, or absolute) and any path separator are pruned. Matcher
|
|
442
|
+
* groups left empty are dropped, and an empty hooks map is removed entirely.
|
|
443
|
+
* User-authored hooks outside the managed set are preserved untouched.
|
|
444
|
+
*
|
|
445
|
+
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
446
|
+
* @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
|
|
447
|
+
* @returns {void}
|
|
448
|
+
*/
|
|
449
|
+
export function pruneManagedHooksFromSettings(settings, managedHookRelativePaths) {
|
|
450
|
+
if (!settings.hooks) return;
|
|
451
|
+
for (const [eventType, matcherGroups] of Object.entries(settings.hooks)) {
|
|
452
|
+
settings.hooks[eventType] = matcherGroups
|
|
453
|
+
.map(group => ({
|
|
454
|
+
...group,
|
|
455
|
+
hooks: group.hooks.filter(
|
|
456
|
+
hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
|
|
457
|
+
),
|
|
458
|
+
}))
|
|
459
|
+
.filter(group => group.hooks.length > 0);
|
|
460
|
+
if (settings.hooks[eventType].length === 0) delete settings.hooks[eventType];
|
|
461
|
+
}
|
|
462
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function mergeHooks(hooksSourceRoot, pythonCommand) {
|
|
466
|
+
const hooksJsonPath = join(hooksSourceRoot, 'hooks', 'hooks.json');
|
|
467
|
+
if (!existsSync(hooksJsonPath)) return 0;
|
|
468
|
+
const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
|
|
469
|
+
const settingsPath = join(CLAUDE_HOME, 'settings.json');
|
|
470
|
+
let settings = {};
|
|
471
|
+
if (existsSync(settingsPath)) {
|
|
472
|
+
const raw = readFileSync(settingsPath, 'utf8').trim();
|
|
473
|
+
if (raw) {
|
|
474
|
+
try { settings = JSON.parse(raw); }
|
|
475
|
+
catch { console.error(' ERROR: settings.json is malformed JSON. Fix it and rerun.'); process.exit(1); }
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const groupCount = mergeHooksIntoSettings(settings, hooksConfig, CLAUDE_HOME, pythonCommand);
|
|
282
479
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
283
480
|
return groupCount;
|
|
284
481
|
}
|
|
@@ -564,17 +761,10 @@ function purgeManagedInstallation({ requireManifest }) {
|
|
|
564
761
|
if (existsSync(settingsPath)) {
|
|
565
762
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
566
763
|
if (settings.hooks) {
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
...group,
|
|
572
|
-
hooks: group.hooks.filter(hook => !hook.command.includes(installedHooksDir)),
|
|
573
|
-
}))
|
|
574
|
-
.filter(group => group.hooks.length > 0);
|
|
575
|
-
if (settings.hooks[eventType].length === 0) delete settings.hooks[eventType];
|
|
576
|
-
}
|
|
577
|
-
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
764
|
+
const managedHookRelativePaths = managedHookScriptRelativePathsFromSourceRoots(
|
|
765
|
+
managedPackageSourceRoots()
|
|
766
|
+
);
|
|
767
|
+
pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
|
|
578
768
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
579
769
|
console.log(' Hook entries removed from settings.json');
|
|
580
770
|
}
|
package/bin/install.test.mjs
CHANGED
|
@@ -5,7 +5,16 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
collectPackageSourceConflicts,
|
|
10
|
+
CONTENT_DIRECTORIES,
|
|
11
|
+
pythonCandidatesForPlatform,
|
|
12
|
+
managedHookScriptRelativePaths,
|
|
13
|
+
managedHookScriptRelativePathsFromSourceRoots,
|
|
14
|
+
commandReferencesManagedHook,
|
|
15
|
+
mergeHooksIntoSettings,
|
|
16
|
+
pruneManagedHooksFromSettings,
|
|
17
|
+
} from './install.mjs';
|
|
9
18
|
|
|
10
19
|
|
|
11
20
|
function createTemporaryGitRepository() {
|
|
@@ -172,3 +181,337 @@ test('collectPackageSourceConflicts surfaces both-added and deleted-by-them entr
|
|
|
172
181
|
rmSync(repositoryRoot, { recursive: true, force: true });
|
|
173
182
|
}
|
|
174
183
|
});
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
test('pythonCandidatesForPlatform prefers py -3 ahead of python on win32 so the Microsoft Store stub is never probed first', () => {
|
|
187
|
+
const commands = pythonCandidatesForPlatform('win32').map(candidate => candidate.command);
|
|
188
|
+
assert.equal(commands[0], 'py -3');
|
|
189
|
+
assert.ok(commands.indexOf('py -3') < commands.indexOf('python'));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
test('pythonCandidatesForPlatform keeps python3 first on non-Windows platforms', () => {
|
|
194
|
+
const commands = pythonCandidatesForPlatform('linux').map(candidate => candidate.command);
|
|
195
|
+
assert.equal(commands[0], 'python3');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
test('pythonCandidatesForPlatform still offers python as a win32 fallback when py -3 and python3 are absent', () => {
|
|
200
|
+
const commands = pythonCandidatesForPlatform('win32').map(candidate => candidate.command);
|
|
201
|
+
assert.ok(commands.includes('python'));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
const SAMPLE_HOOKS_CONFIG = {
|
|
206
|
+
hooks: {
|
|
207
|
+
Stop: [
|
|
208
|
+
{
|
|
209
|
+
matcher: '',
|
|
210
|
+
hooks: [
|
|
211
|
+
{ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/notification/attention_needed_notify.py', timeout: 15 },
|
|
212
|
+
{ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/hedging_language_blocker.py', timeout: 10 },
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
PreToolUse: [
|
|
217
|
+
{
|
|
218
|
+
matcher: 'Write',
|
|
219
|
+
hooks: [
|
|
220
|
+
{ command: 'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\'); print(1)"', timeout: 5 },
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
test('managedHookScriptRelativePaths collects every installed hook script path and ignores inline -c commands', () => {
|
|
229
|
+
const relativePaths = managedHookScriptRelativePaths(SAMPLE_HOOKS_CONFIG);
|
|
230
|
+
assert.ok(relativePaths.has('notification/attention_needed_notify.py'));
|
|
231
|
+
assert.ok(relativePaths.has('blocking/hedging_language_blocker.py'));
|
|
232
|
+
assert.equal([...relativePaths].length, 2);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
test('commandReferencesManagedHook matches managed scripts written with $HOME, ~, ${HOME}, and absolute path styles', () => {
|
|
237
|
+
const managedPaths = new Set(['notification/attention_needed_notify.py']);
|
|
238
|
+
assert.ok(commandReferencesManagedHook('python $HOME/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
|
|
239
|
+
assert.ok(commandReferencesManagedHook('python ~/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
|
|
240
|
+
assert.ok(commandReferencesManagedHook('python ${HOME}/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
|
|
241
|
+
assert.ok(commandReferencesManagedHook('py -3 C:/Users/jonlo/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
|
|
242
|
+
assert.ok(commandReferencesManagedHook('python /Users/jon/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
test('commandReferencesManagedHook matches Windows backslash paths', () => {
|
|
247
|
+
const managedPaths = new Set(['blocking/hedging_language_blocker.py']);
|
|
248
|
+
assert.ok(commandReferencesManagedHook('py -3 C:\\Users\\jonlo\\.claude\\hooks\\blocking\\hedging_language_blocker.py', managedPaths));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
test('commandReferencesManagedHook leaves user hooks outside the managed set untouched', () => {
|
|
253
|
+
const managedPaths = new Set(['notification/attention_needed_notify.py']);
|
|
254
|
+
assert.equal(commandReferencesManagedHook('python /home/me/custom-tools/my_own_hook.py', managedPaths), false);
|
|
255
|
+
assert.equal(commandReferencesManagedHook('py -3 ~/.claude/hooks/blocking/some_unmanaged_user_hook.py', managedPaths), false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
test('commandReferencesManagedHook leaves a user hook whose path is a managed tail plus a suffix untouched', () => {
|
|
260
|
+
const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
|
|
261
|
+
assert.equal(commandReferencesManagedHook('python ~/.claude/hooks/blocking/code_rules_enforcer.py.bak', managedPaths), false);
|
|
262
|
+
assert.equal(commandReferencesManagedHook('python ~/.claude/hooks/blocking/code_rules_enforcer.py2', managedPaths), false);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
test('commandReferencesManagedHook leaves a command whose managed tail is mid-path untouched', () => {
|
|
267
|
+
const managedPaths = new Set(['blocking/a.py']);
|
|
268
|
+
assert.equal(commandReferencesManagedHook('python /x/.claude/hooks/blocking/a.py/extra/thing.py', managedPaths), false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
test('commandReferencesManagedHook matches a managed script followed by a whitespace-separated argument', () => {
|
|
273
|
+
const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
|
|
274
|
+
assert.ok(commandReferencesManagedHook('python ~/.claude/hooks/blocking/code_rules_enforcer.py PreToolUse', managedPaths));
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
test('commandReferencesManagedHook matches the rewritten inline validators-runner hook that carries no script tail', () => {
|
|
279
|
+
const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
|
|
280
|
+
const rewrittenInlineCommand =
|
|
281
|
+
"py -3 -c \"import sys; sys.path.insert(0, r'C:/Users/jonlo/.claude/hooks'); from validators.run_all_validators import main; sys.exit(main())\"";
|
|
282
|
+
assert.ok(commandReferencesManagedHook(rewrittenInlineCommand, managedPaths));
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
test('commandReferencesManagedHook leaves an unmanaged inline -c command that imports a different module untouched', () => {
|
|
287
|
+
const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
|
|
288
|
+
const userInlineCommand =
|
|
289
|
+
"python -c \"import sys; sys.path.insert(0, r'/home/me/tools'); from my_tools.runner import main; sys.exit(main())\"";
|
|
290
|
+
assert.equal(commandReferencesManagedHook(userInlineCommand, managedPaths), false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
function countManagedRunAllValidatorsHooks(settings) {
|
|
295
|
+
const writeEditGroups = (settings.hooks.PreToolUse || []).filter(
|
|
296
|
+
group => group.matcher === 'Write|Edit'
|
|
297
|
+
);
|
|
298
|
+
let runAllValidatorsCount = 0;
|
|
299
|
+
for (const group of writeEditGroups) {
|
|
300
|
+
for (const hook of group.hooks) {
|
|
301
|
+
if (hook.command.includes('run_all_validators')) {
|
|
302
|
+
runAllValidatorsCount++;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return runAllValidatorsCount;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
test('mergeHooksIntoSettings is idempotent for the inline -c validators hook across two installs', () => {
|
|
311
|
+
const hooksConfig = {
|
|
312
|
+
hooks: {
|
|
313
|
+
'PreToolUse': [
|
|
314
|
+
{
|
|
315
|
+
matcher: 'Write|Edit',
|
|
316
|
+
hooks: [
|
|
317
|
+
{ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py', timeout: 30 },
|
|
318
|
+
{
|
|
319
|
+
command:
|
|
320
|
+
'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\'); from validators.run_all_validators import main; sys.exit(main())"',
|
|
321
|
+
timeout: 15,
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
const settings = {};
|
|
329
|
+
const pluginRootDir = 'C:/Users/jonlo/.claude';
|
|
330
|
+
|
|
331
|
+
mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, 'py -3');
|
|
332
|
+
mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, 'py -3');
|
|
333
|
+
|
|
334
|
+
assert.equal(countManagedRunAllValidatorsHooks(settings), 1);
|
|
335
|
+
const writeEditGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit');
|
|
336
|
+
assert.equal(writeEditGroup.hooks.length, 2);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
test('mergeHooksIntoSettings preserves user hooks in a managed matcher group across re-merges', () => {
|
|
341
|
+
const hooksConfig = {
|
|
342
|
+
hooks: {
|
|
343
|
+
'PreToolUse': [
|
|
344
|
+
{
|
|
345
|
+
matcher: 'Write|Edit',
|
|
346
|
+
hooks: [
|
|
347
|
+
{
|
|
348
|
+
command:
|
|
349
|
+
'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\'); from validators.run_all_validators import main; sys.exit(main())"',
|
|
350
|
+
timeout: 15,
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
const userHookCommand = 'python /home/me/custom-tools/my_own_hook.py';
|
|
358
|
+
const settings = {
|
|
359
|
+
hooks: {
|
|
360
|
+
PreToolUse: [
|
|
361
|
+
{ matcher: 'Write|Edit', hooks: [{ command: userHookCommand, timeout: 5 }] },
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/jonlo/.claude', 'py -3');
|
|
367
|
+
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/jonlo/.claude', 'py -3');
|
|
368
|
+
|
|
369
|
+
const writeEditGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit');
|
|
370
|
+
const userHookSurvivors = writeEditGroup.hooks.filter(hook => hook.command === userHookCommand);
|
|
371
|
+
assert.equal(userHookSurvivors.length, 1);
|
|
372
|
+
assert.equal(countManagedRunAllValidatorsHooks(settings), 1);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('pruneManagedHooksFromSettings removes a managed hook command written with the ~ home-path style', () => {
|
|
376
|
+
const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
|
|
377
|
+
const settings = {
|
|
378
|
+
hooks: {
|
|
379
|
+
PreToolUse: [
|
|
380
|
+
{
|
|
381
|
+
matcher: 'Write|Edit',
|
|
382
|
+
hooks: [
|
|
383
|
+
{ command: 'python ~/.claude/hooks/blocking/code_rules_enforcer.py', timeout: 30 },
|
|
384
|
+
],
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
pruneManagedHooksFromSettings(settings, managedPaths);
|
|
391
|
+
|
|
392
|
+
assert.equal(settings.hooks, undefined);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
test('pruneManagedHooksFromSettings removes managed hooks in every home-path and separator style while keeping user hooks', () => {
|
|
397
|
+
const managedPaths = new Set(['notification/attention_needed_notify.py']);
|
|
398
|
+
const userHookCommand = 'python /home/me/custom-tools/my_own_hook.py';
|
|
399
|
+
const settings = {
|
|
400
|
+
hooks: {
|
|
401
|
+
Stop: [
|
|
402
|
+
{
|
|
403
|
+
matcher: '',
|
|
404
|
+
hooks: [
|
|
405
|
+
{ command: 'python $HOME/.claude/hooks/notification/attention_needed_notify.py', timeout: 15 },
|
|
406
|
+
{ command: 'python ${HOME}/.claude/hooks/notification/attention_needed_notify.py', timeout: 15 },
|
|
407
|
+
{ command: 'py -3 C:\\Users\\jonlo\\.claude\\hooks\\notification\\attention_needed_notify.py', timeout: 15 },
|
|
408
|
+
{ command: userHookCommand, timeout: 5 },
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
pruneManagedHooksFromSettings(settings, managedPaths);
|
|
416
|
+
|
|
417
|
+
const stopGroup = settings.hooks.Stop.find(group => group.matcher === '');
|
|
418
|
+
assert.equal(stopGroup.hooks.length, 1);
|
|
419
|
+
assert.equal(stopGroup.hooks[0].command, userHookCommand);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
function writeHooksJsonAtRoot(sourceRoot, hooksConfig) {
|
|
423
|
+
mkdirSync(join(sourceRoot, 'hooks'), { recursive: true });
|
|
424
|
+
writeFileSync(join(sourceRoot, 'hooks', 'hooks.json'), JSON.stringify(hooksConfig));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
test('managedHookScriptRelativePathsFromSourceRoots reads each root hooks.json so purge matches every installed script', () => {
|
|
429
|
+
const sourceRoot = mkdtempSync(join(tmpdir(), 'cdev-purge-set-'));
|
|
430
|
+
try {
|
|
431
|
+
writeHooksJsonAtRoot(sourceRoot, SAMPLE_HOOKS_CONFIG);
|
|
432
|
+
|
|
433
|
+
const relativePaths = managedHookScriptRelativePathsFromSourceRoots([sourceRoot]);
|
|
434
|
+
|
|
435
|
+
assert.ok(relativePaths.has('notification/attention_needed_notify.py'));
|
|
436
|
+
assert.ok(relativePaths.has('blocking/hedging_language_blocker.py'));
|
|
437
|
+
assert.equal([...relativePaths].length, 2);
|
|
438
|
+
} finally {
|
|
439
|
+
rmSync(sourceRoot, { recursive: true, force: true });
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
test('managedHookScriptRelativePathsFromSourceRoots unions managed scripts across multiple package roots', () => {
|
|
445
|
+
const builtinRoot = mkdtempSync(join(tmpdir(), 'cdev-purge-builtin-'));
|
|
446
|
+
const dependencyRoot = mkdtempSync(join(tmpdir(), 'cdev-purge-dependency-'));
|
|
447
|
+
try {
|
|
448
|
+
writeHooksJsonAtRoot(builtinRoot, {
|
|
449
|
+
hooks: { Stop: [{ matcher: '', hooks: [{ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py' }] }] },
|
|
450
|
+
});
|
|
451
|
+
writeHooksJsonAtRoot(dependencyRoot, {
|
|
452
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pwsh_enforcer.py' }] }] },
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const relativePaths = managedHookScriptRelativePathsFromSourceRoots([builtinRoot, dependencyRoot]);
|
|
456
|
+
|
|
457
|
+
assert.ok(relativePaths.has('blocking/code_rules_enforcer.py'));
|
|
458
|
+
assert.ok(relativePaths.has('blocking/pwsh_enforcer.py'));
|
|
459
|
+
assert.equal([...relativePaths].length, 2);
|
|
460
|
+
} finally {
|
|
461
|
+
rmSync(builtinRoot, { recursive: true, force: true });
|
|
462
|
+
rmSync(dependencyRoot, { recursive: true, force: true });
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
test('managedHookScriptRelativePathsFromSourceRoots skips roots whose hooks.json is absent', () => {
|
|
468
|
+
const rootWithoutHooks = mkdtempSync(join(tmpdir(), 'cdev-purge-empty-'));
|
|
469
|
+
try {
|
|
470
|
+
const relativePaths = managedHookScriptRelativePathsFromSourceRoots([rootWithoutHooks]);
|
|
471
|
+
assert.equal([...relativePaths].length, 0);
|
|
472
|
+
} finally {
|
|
473
|
+
rmSync(rootWithoutHooks, { recursive: true, force: true });
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
test('purge set sourced from package hooks.json prunes standalone managed script hooks and keeps user hooks', () => {
|
|
479
|
+
const sourceRoot = mkdtempSync(join(tmpdir(), 'cdev-purge-prune-'));
|
|
480
|
+
try {
|
|
481
|
+
writeHooksJsonAtRoot(sourceRoot, {
|
|
482
|
+
hooks: {
|
|
483
|
+
PreToolUse: [
|
|
484
|
+
{
|
|
485
|
+
matcher: 'Write|Edit',
|
|
486
|
+
hooks: [
|
|
487
|
+
{ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py', timeout: 30 },
|
|
488
|
+
],
|
|
489
|
+
},
|
|
490
|
+
],
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
const userHookCommand = 'python /home/me/custom-tools/my_own_hook.py';
|
|
494
|
+
const settings = {
|
|
495
|
+
hooks: {
|
|
496
|
+
PreToolUse: [
|
|
497
|
+
{
|
|
498
|
+
matcher: 'Write|Edit',
|
|
499
|
+
hooks: [
|
|
500
|
+
{ command: 'py -3 C:\\Users\\jonlo\\.claude\\hooks\\blocking\\code_rules_enforcer.py', timeout: 30 },
|
|
501
|
+
{ command: userHookCommand, timeout: 5 },
|
|
502
|
+
],
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const managedHookRelativePaths = managedHookScriptRelativePathsFromSourceRoots([sourceRoot]);
|
|
509
|
+
pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
|
|
510
|
+
|
|
511
|
+
const writeEditGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit');
|
|
512
|
+
assert.equal(writeEditGroup.hooks.length, 1);
|
|
513
|
+
assert.equal(writeEditGroup.hooks[0].command, userHookCommand);
|
|
514
|
+
} finally {
|
|
515
|
+
rmSync(sourceRoot, { recursive: true, force: true });
|
|
516
|
+
}
|
|
517
|
+
});
|