claude-dev-env 1.57.2 → 1.59.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/CLAUDE.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +317 -54
- package/bin/install.test.mjs +478 -3
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_duplicate_body.py +287 -0
- package/hooks/blocking/code_rules_enforcer.py +175 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/intent_only_ending_blocker.py +155 -0
- package/hooks/blocking/session_handoff_blocker.py +190 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
- package/hooks/blocking/test_session_handoff_blocker.py +312 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/messages.py +4 -0
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -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/docstring-prose-matches-implementation.md +43 -0
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/long-horizon-autonomy.md +43 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/autoconverge/SKILL.md +68 -6
- package/skills/autoconverge/reference/closing-report.md +44 -0
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- 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.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +106 -38
- 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/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/update/SKILL.md +37 -5
package/bin/install.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync } from 'node:fs';
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
|
|
4
4
|
import { join, dirname, resolve, relative } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { execSync, execFileSync } from 'node:child_process';
|
|
@@ -165,18 +165,76 @@ 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
|
+
/**
|
|
192
|
+
* Reports whether a resolved interpreter path belongs to the Microsoft Store
|
|
193
|
+
* Python, whose `python.exe` App Execution Alias reparse stub cannot be spawned
|
|
194
|
+
* as a hook subprocess. Both the alias under `Microsoft\WindowsApps` and the
|
|
195
|
+
* package executable under `Program Files\WindowsApps` sit beneath a
|
|
196
|
+
* `WindowsApps` directory, so the installer skips any candidate resolving there.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} executablePath Absolute interpreter path from sys.executable.
|
|
199
|
+
* @returns {boolean} True when the path lives under a WindowsApps directory.
|
|
200
|
+
*/
|
|
201
|
+
export function isWindowsStorePythonStub(executablePath) {
|
|
202
|
+
return /[\\/]windowsapps[\\/]/i.test(executablePath);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Formats an absolute interpreter path as a settings.json hook command prefix:
|
|
207
|
+
* forward-slash separators, double-quoted when the path contains a space so the
|
|
208
|
+
* harness parses the interpreter as a single argument.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} executablePath Absolute interpreter path from sys.executable.
|
|
211
|
+
* @returns {string} The command-prefix form of the interpreter path.
|
|
212
|
+
*/
|
|
213
|
+
export function interpreterCommandFromPath(executablePath) {
|
|
214
|
+
const forwardSlashedPath = executablePath.replace(/\\/g, '/');
|
|
215
|
+
return forwardSlashedPath.includes(' ') ? `"${forwardSlashedPath}"` : forwardSlashedPath;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Picks the interpreter command baked into every managed hook in settings.json.
|
|
220
|
+
* On win32 the first working candidate is resolved to its absolute
|
|
221
|
+
* sys.executable and that path is baked in, so a later PATH change or Microsoft
|
|
222
|
+
* Store update that re-points the `py`/`python` launcher cannot silently break
|
|
223
|
+
* the hooks; candidates resolving to the non-spawnable WindowsApps stub are
|
|
224
|
+
* skipped. Other platforms keep the bare command (e.g. `python3`).
|
|
225
|
+
*
|
|
226
|
+
* @returns {string|null} The interpreter command, or null when none is usable.
|
|
227
|
+
*/
|
|
228
|
+
function detectPython() {
|
|
229
|
+
const candidates = pythonCandidatesForPlatform(process.platform);
|
|
174
230
|
for (const { command, versionFlag } of candidates) {
|
|
175
231
|
try {
|
|
176
232
|
const version = execSync(`${command} ${versionFlag}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
177
|
-
if (version.includes('Python 3.'))
|
|
178
|
-
|
|
179
|
-
}
|
|
233
|
+
if (!version.includes('Python 3.')) continue;
|
|
234
|
+
if (process.platform !== 'win32') return command;
|
|
235
|
+
const executablePath = execSync(`${command} -c "import sys; print(sys.executable)"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
236
|
+
if (!executablePath || isWindowsStorePythonStub(executablePath)) continue;
|
|
237
|
+
return interpreterCommandFromPath(executablePath);
|
|
180
238
|
} catch { /* try next */ }
|
|
181
239
|
}
|
|
182
240
|
return null;
|
|
@@ -235,22 +293,155 @@ function backupClaudeHubBeforeOverwrite(destPath, incomingPath) {
|
|
|
235
293
|
return backupPath;
|
|
236
294
|
}
|
|
237
295
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
296
|
+
/**
|
|
297
|
+
* Builds the set of hook script paths this installer manages, each relative to
|
|
298
|
+
* the hooks directory (e.g. 'blocking/code_rules_enforcer.py'), parsed from the
|
|
299
|
+
* `${CLAUDE_PLUGIN_ROOT}/hooks/<path>` references in hooks.json. Inline
|
|
300
|
+
* `python3 -c` commands reference the hooks directory without a script tail and
|
|
301
|
+
* contribute nothing.
|
|
302
|
+
*
|
|
303
|
+
* @param {{hooks: object}} hooksConfig Parsed hooks.json.
|
|
304
|
+
* @returns {Set<string>} Forward-slash relative script paths under hooks/.
|
|
305
|
+
*/
|
|
306
|
+
export function managedHookScriptRelativePaths(hooksConfig) {
|
|
307
|
+
const relativePaths = new Set();
|
|
308
|
+
const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.py)/g;
|
|
309
|
+
for (const matcherGroups of Object.values(hooksConfig.hooks)) {
|
|
310
|
+
for (const sourceGroup of matcherGroups) {
|
|
311
|
+
for (const hook of sourceGroup.hooks) {
|
|
312
|
+
for (const scriptMatch of hook.command.matchAll(scriptReferencePattern)) {
|
|
313
|
+
relativePaths.add(scriptMatch[1]);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return relativePaths;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Builds the union of managed hook script paths across the given package source
|
|
323
|
+
* roots by parsing each root's hooks/hooks.json. The installer copies hook
|
|
324
|
+
* scripts into ~/.claude/hooks/ but never copies hooks.json itself, so the
|
|
325
|
+
* uninstall and update-refresh purge must read the managed-hook set from the
|
|
326
|
+
* package source the same way the merge does, never from ~/.claude/hooks/.
|
|
327
|
+
* Roots without a hooks.json contribute nothing.
|
|
328
|
+
*
|
|
329
|
+
* @param {string[]} sourceRoots Package roots that hold a hooks/hooks.json.
|
|
330
|
+
* @returns {Set<string>} Forward-slash relative script paths under hooks/.
|
|
331
|
+
*/
|
|
332
|
+
export function managedHookScriptRelativePathsFromSourceRoots(sourceRoots) {
|
|
333
|
+
const relativePaths = new Set();
|
|
334
|
+
for (const sourceRoot of sourceRoots) {
|
|
335
|
+
const hooksJsonPath = join(sourceRoot, 'hooks', 'hooks.json');
|
|
336
|
+
if (!existsSync(hooksJsonPath)) continue;
|
|
337
|
+
const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
|
|
338
|
+
for (const relativePath of managedHookScriptRelativePaths(hooksConfig)) {
|
|
339
|
+
relativePaths.add(relativePath);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return relativePaths;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Resolves every package source root the installer can copy hooks from: this
|
|
347
|
+
* package plus each resolvable dependency package that ships hooks. The purge
|
|
348
|
+
* reads hooks.json from these roots so it prunes managed entries no matter which
|
|
349
|
+
* package contributed them.
|
|
350
|
+
*
|
|
351
|
+
* @returns {string[]} Distinct package roots, this package first.
|
|
352
|
+
*/
|
|
353
|
+
function managedPackageSourceRoots() {
|
|
354
|
+
const dependencyRoots = Object.values(INSTALL_GROUPS)
|
|
355
|
+
.filter(group => group.packageRoot)
|
|
356
|
+
.map(group => group.packageRoot);
|
|
357
|
+
return [...new Set([PACKAGE_ROOT, ...dependencyRoots])];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Reports whether a settings.json hook command points at one of this installer's
|
|
362
|
+
* managed scripts, no matter how the home directory was written ($HOME, ~,
|
|
363
|
+
* ${HOME}, or an absolute path) or which path separator was used. Matching on
|
|
364
|
+
* the `/.claude/hooks/<relative>` tail lets a reinstall prune stale entries from
|
|
365
|
+
* earlier installs that used a different interpreter prefix, while leaving
|
|
366
|
+
* user-authored hooks outside the managed set untouched.
|
|
367
|
+
*
|
|
368
|
+
* @param {string} commandString The hook command from settings.json.
|
|
369
|
+
* @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
|
|
370
|
+
* @returns {boolean} True when the command references a managed script.
|
|
371
|
+
*/
|
|
372
|
+
export function commandReferencesManagedHook(commandString, managedHookRelativePaths) {
|
|
373
|
+
const normalizedCommand = commandString.replace(/\\/g, '/');
|
|
374
|
+
if (commandIsInlineManagedValidatorRunner(normalizedCommand)) {
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
for (const relativePath of managedHookRelativePaths) {
|
|
378
|
+
if (commandTailEndsAtManagedHook(normalizedCommand, relativePath)) {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Reports whether a command contains the `/.claude/hooks/<relative>` tail ending
|
|
387
|
+
* at a path boundary: end of string, or an argument separator (whitespace, quote,
|
|
388
|
+
* or semicolon). Anchoring the tail keeps a user hook whose path is the managed
|
|
389
|
+
* tail plus a suffix (`code_rules_enforcer.py.bak`, `a.py/extra/thing.py`) outside
|
|
390
|
+
* the managed set, so it is never pruned.
|
|
391
|
+
*
|
|
392
|
+
* @param {string} normalizedCommand Forward-slash-normalized hook command.
|
|
393
|
+
* @param {string} relativePath Managed script path under hooks/.
|
|
394
|
+
* @returns {boolean} True when the managed tail ends at a path boundary.
|
|
395
|
+
*/
|
|
396
|
+
function commandTailEndsAtManagedHook(normalizedCommand, relativePath) {
|
|
397
|
+
const commandArgumentBoundary = /[\s'";]/;
|
|
398
|
+
const managedTail = `/.claude/hooks/${relativePath}`;
|
|
399
|
+
let searchStart = normalizedCommand.indexOf(managedTail);
|
|
400
|
+
while (searchStart !== -1) {
|
|
401
|
+
const characterAfterTail = normalizedCommand[searchStart + managedTail.length];
|
|
402
|
+
if (characterAfterTail === undefined || commandArgumentBoundary.test(characterAfterTail)) {
|
|
403
|
+
return true;
|
|
249
404
|
}
|
|
405
|
+
searchStart = normalizedCommand.indexOf(managedTail, searchStart + 1);
|
|
250
406
|
}
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Reports whether a settings.json hook command is the inline validators-runner
|
|
412
|
+
* the installer writes in place of a standalone script. That hook inserts the
|
|
413
|
+
* managed hooks directory onto sys.path and imports run_all_validators, so it
|
|
414
|
+
* carries no `<script>.py` tail for managedHookScriptRelativePaths to record.
|
|
415
|
+
* Matching its shape lets a reinstall prune the prior copy before appending the
|
|
416
|
+
* freshly rewritten one, keeping the merge idempotent.
|
|
417
|
+
*
|
|
418
|
+
* @param {string} normalizedCommand Forward-slash-normalized hook command.
|
|
419
|
+
* @returns {boolean} True when the command is the inline validators runner.
|
|
420
|
+
*/
|
|
421
|
+
export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
|
|
422
|
+
const inlineValidatorRunnerMarker = /sys\.path\.insert\([^)]*\.claude\/hooks[^)]*\)[\s\S]*run_all_validators/;
|
|
423
|
+
return (
|
|
424
|
+
normalizedCommand.includes('/.claude/hooks') &&
|
|
425
|
+
inlineValidatorRunnerMarker.test(normalizedCommand)
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Merges the installer's managed hook groups into a settings object in memory,
|
|
431
|
+
* pruning every prior managed hook (standalone script or inline validators
|
|
432
|
+
* runner) from each matcher group before appending the freshly rewritten copies
|
|
433
|
+
* so repeated merges stay idempotent. User-authored hooks in the same group are
|
|
434
|
+
* preserved untouched.
|
|
435
|
+
*
|
|
436
|
+
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
437
|
+
* @param {{hooks: object}} hooksConfig Parsed hooks.json.
|
|
438
|
+
* @param {string} pluginRootDir Directory ${CLAUDE_PLUGIN_ROOT} resolves to.
|
|
439
|
+
* @param {string} pythonCommand Interpreter command that replaces python3.
|
|
440
|
+
* @returns {number} Count of matcher groups merged.
|
|
441
|
+
*/
|
|
442
|
+
export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand) {
|
|
443
|
+
const managedHookRelativePaths = managedHookScriptRelativePaths(hooksConfig);
|
|
251
444
|
if (!settings.hooks) settings.hooks = {};
|
|
252
|
-
const installedHooksDir = join(CLAUDE_HOME, 'hooks');
|
|
253
|
-
const pluginRootDir = CLAUDE_HOME;
|
|
254
445
|
let groupCount = 0;
|
|
255
446
|
for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
|
|
256
447
|
if (!settings.hooks[eventType]) settings.hooks[eventType] = [];
|
|
@@ -267,7 +458,7 @@ function mergeHooks(hooksSourceRoot, pythonCommand) {
|
|
|
267
458
|
if (existingIndex >= 0) {
|
|
268
459
|
const existing = settings.hooks[eventType][existingIndex];
|
|
269
460
|
const userHooks = existing.hooks.filter(
|
|
270
|
-
hook => !hook.command
|
|
461
|
+
hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
|
|
271
462
|
);
|
|
272
463
|
settings.hooks[eventType][existingIndex] = {
|
|
273
464
|
...existing,
|
|
@@ -279,6 +470,51 @@ function mergeHooks(hooksSourceRoot, pythonCommand) {
|
|
|
279
470
|
groupCount++;
|
|
280
471
|
}
|
|
281
472
|
}
|
|
473
|
+
return groupCount;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Removes every managed hook (standalone script or inline validators runner)
|
|
478
|
+
* from a settings object in memory, matching each command through
|
|
479
|
+
* commandReferencesManagedHook so entries written with any home-path style
|
|
480
|
+
* ($HOME, ~, ${HOME}, or absolute) and any path separator are pruned. Matcher
|
|
481
|
+
* groups left empty are dropped, and an empty hooks map is removed entirely.
|
|
482
|
+
* User-authored hooks outside the managed set are preserved untouched.
|
|
483
|
+
*
|
|
484
|
+
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
485
|
+
* @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
|
|
486
|
+
* @returns {void}
|
|
487
|
+
*/
|
|
488
|
+
export function pruneManagedHooksFromSettings(settings, managedHookRelativePaths) {
|
|
489
|
+
if (!settings.hooks) return;
|
|
490
|
+
for (const [eventType, matcherGroups] of Object.entries(settings.hooks)) {
|
|
491
|
+
settings.hooks[eventType] = matcherGroups
|
|
492
|
+
.map(group => ({
|
|
493
|
+
...group,
|
|
494
|
+
hooks: group.hooks.filter(
|
|
495
|
+
hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
|
|
496
|
+
),
|
|
497
|
+
}))
|
|
498
|
+
.filter(group => group.hooks.length > 0);
|
|
499
|
+
if (settings.hooks[eventType].length === 0) delete settings.hooks[eventType];
|
|
500
|
+
}
|
|
501
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function mergeHooks(hooksSourceRoot, pythonCommand) {
|
|
505
|
+
const hooksJsonPath = join(hooksSourceRoot, 'hooks', 'hooks.json');
|
|
506
|
+
if (!existsSync(hooksJsonPath)) return 0;
|
|
507
|
+
const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
|
|
508
|
+
const settingsPath = join(CLAUDE_HOME, 'settings.json');
|
|
509
|
+
let settings = {};
|
|
510
|
+
if (existsSync(settingsPath)) {
|
|
511
|
+
const raw = readFileSync(settingsPath, 'utf8').trim();
|
|
512
|
+
if (raw) {
|
|
513
|
+
try { settings = JSON.parse(raw); }
|
|
514
|
+
catch { console.error(' ERROR: settings.json is malformed JSON. Fix it and rerun.'); process.exit(1); }
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const groupCount = mergeHooksIntoSettings(settings, hooksConfig, CLAUDE_HOME, pythonCommand);
|
|
282
518
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
283
519
|
return groupCount;
|
|
284
520
|
}
|
|
@@ -304,7 +540,7 @@ function install(selectedGroups, options = {}) {
|
|
|
304
540
|
abortWhenPackageSourceHasConflicts(PACKAGE_ROOT);
|
|
305
541
|
const pythonCommand = detectPython();
|
|
306
542
|
if (!pythonCommand) {
|
|
307
|
-
console.error('ERROR: Python 3
|
|
543
|
+
console.error('ERROR: No usable Python 3 found. Install Python 3.8+ from python.org and ensure py, python3, or python is on PATH. On Windows the Microsoft Store python.exe alias is rejected because it cannot run hooks.');
|
|
308
544
|
process.exit(1);
|
|
309
545
|
}
|
|
310
546
|
console.log(` Python: ${pythonCommand}`);
|
|
@@ -564,17 +800,10 @@ function purgeManagedInstallation({ requireManifest }) {
|
|
|
564
800
|
if (existsSync(settingsPath)) {
|
|
565
801
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
566
802
|
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;
|
|
803
|
+
const managedHookRelativePaths = managedHookScriptRelativePathsFromSourceRoots(
|
|
804
|
+
managedPackageSourceRoots()
|
|
805
|
+
);
|
|
806
|
+
pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
|
|
578
807
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
579
808
|
console.log(' Hook entries removed from settings.json');
|
|
580
809
|
}
|
|
@@ -625,28 +854,62 @@ writes the previous contents to ~/.claude/backups/CLAUDE.md.<timestamp>.bak firs
|
|
|
625
854
|
`);
|
|
626
855
|
}
|
|
627
856
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
857
|
+
/**
|
|
858
|
+
* Reports whether this module is the process entry point (run as
|
|
859
|
+
* `node install.mjs`, or through a bin symlink such as the npm-installed
|
|
860
|
+
* `claude-dev-env` launcher) rather than imported by another module such as the
|
|
861
|
+
* test suite. The install/uninstall dispatch runs only when true, so importing
|
|
862
|
+
* the module carries no side effects.
|
|
863
|
+
*
|
|
864
|
+
* Both sides resolve to their real on-disk paths before comparison, so a
|
|
865
|
+
* symlinked launcher whose target is this module still counts as the entry
|
|
866
|
+
* point even though `process.argv[1]` keeps the symlink path while
|
|
867
|
+
* `import.meta.url` reports the resolved target. When either path cannot be
|
|
868
|
+
* resolved on disk (for example a synthetic path in a unit test), the raw
|
|
869
|
+
* paths are compared instead.
|
|
870
|
+
*
|
|
871
|
+
* @param {string} moduleUrl The module's import.meta.url.
|
|
872
|
+
* @param {string|undefined} entryScriptPath The invoked script path (process.argv[1]).
|
|
873
|
+
* @returns {boolean} True when the module is the process entry point.
|
|
874
|
+
*/
|
|
875
|
+
export function invokedAsEntryPoint(moduleUrl, entryScriptPath) {
|
|
876
|
+
if (!entryScriptPath) return false;
|
|
877
|
+
const modulePath = fileURLToPath(moduleUrl);
|
|
878
|
+
return realPathOrSelf(modulePath) === realPathOrSelf(entryScriptPath);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function realPathOrSelf(filesystemPath) {
|
|
882
|
+
try {
|
|
883
|
+
return realpathSync(filesystemPath);
|
|
884
|
+
} catch {
|
|
885
|
+
return filesystemPath;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (invokedAsEntryPoint(import.meta.url, process.argv[1])) {
|
|
890
|
+
const rawArgs = process.argv.slice(2);
|
|
891
|
+
const args = rawArgs.filter((flag) => flag !== '--update');
|
|
892
|
+
const isUpdateRefresh = rawArgs.includes('--update');
|
|
893
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
894
|
+
printHelp();
|
|
895
|
+
} else if (args.includes('--uninstall')) {
|
|
896
|
+
uninstall();
|
|
897
|
+
} else {
|
|
898
|
+
const onlyIndex = args.indexOf('--only');
|
|
899
|
+
let selectedGroups = null;
|
|
900
|
+
if (onlyIndex !== -1) {
|
|
901
|
+
const onlyValue = args[onlyIndex + 1];
|
|
902
|
+
if (!onlyValue || onlyValue.startsWith('--')) {
|
|
903
|
+
console.error(`ERROR: --only requires a comma-separated list of groups.\nAvailable groups: ${Object.keys(INSTALL_GROUPS).join(', ')}`);
|
|
904
|
+
process.exit(1);
|
|
905
|
+
}
|
|
906
|
+
selectedGroups = onlyValue.split(',').map(name => name.trim());
|
|
907
|
+
const invalidGroups = selectedGroups.filter(name => !INSTALL_GROUPS[name]);
|
|
908
|
+
if (invalidGroups.length > 0) {
|
|
909
|
+
console.error(`ERROR: Unknown group(s): ${invalidGroups.join(', ')}\nAvailable groups: ${Object.keys(INSTALL_GROUPS).join(', ')}`);
|
|
910
|
+
process.exit(1);
|
|
911
|
+
}
|
|
649
912
|
}
|
|
913
|
+
install(selectedGroups, { isUpdateRefresh });
|
|
650
914
|
}
|
|
651
|
-
install(selectedGroups, { isUpdateRefresh });
|
|
652
915
|
}
|