claude-dev-env 8.100.1 → 8.102.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/codex-trimmed-sol.test.mjs +1 -0
- package/bin/install-pstack-plugin.mjs +48 -3
- package/bin/install-pstack-plugin.test.mjs +58 -1
- package/bin/install.agents-home.test.mjs +1 -0
- package/bin/install.codex-rules.test.mjs +2 -0
- package/bin/install.cursor-rules.test.mjs +1 -0
- package/bin/install.mjs +9 -0
- package/bin/install.pr-visual.test.mjs +1 -1
- package/bin/install.prune.test.mjs +1 -0
- package/bin/install.question-presentation.test.mjs +4 -0
- package/bin/install.settings-defaults.test.mjs +1 -0
- package/bin/install.shared-settings.test.mjs +1 -0
- package/bin/install.test.mjs +54 -0
- package/bin/install.transaction.test.mjs +8 -0
- package/bin/install.uninstall-transaction.test.mjs +2 -0
- package/bin/install.update-keeps-hooks.test.mjs +1 -0
- package/hooks/advisory/edit_lint_context.py +292 -0
- package/hooks/advisory/test_edit_lint_context.py +269 -0
- package/hooks/features/post-write-validation.md +8 -4
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/edit_lint_context_constants.py +40 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +4 -0
- package/hooks/validation/test_post_tool_use_dispatcher.py +1 -0
- package/package.json +1 -1
|
@@ -35,6 +35,7 @@ function installInScratchHome(homeDirectory, argumentsList = [], environment = {
|
|
|
35
35
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
36
36
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
37
37
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
38
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
38
39
|
CLAUDE_DEV_ENV_INSTALL_FAULT: undefined,
|
|
39
40
|
HOME: homeDirectory,
|
|
40
41
|
USERPROFILE: homeDirectory,
|
|
@@ -59,6 +59,27 @@ export const SUBAGENT_MODELS_PLUGIN_SPEC = Object.freeze({
|
|
|
59
59
|
optOutVariable: 'CDE_INSTALL_SUBAGENT_MODELS',
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Anthropic's security-guidance plugin, which reviews each edit, turn, and
|
|
64
|
+
* commit an agent makes for security problems.
|
|
65
|
+
*
|
|
66
|
+
* Its marketplace holds every official Anthropic plugin and may supply other
|
|
67
|
+
* plugins the user installed, so the install never removes that marketplace
|
|
68
|
+
* to rebuild it.
|
|
69
|
+
*/
|
|
70
|
+
export const SECURITY_GUIDANCE_PLUGIN_SPEC = Object.freeze({
|
|
71
|
+
name: 'security-guidance',
|
|
72
|
+
label: 'Security-guidance',
|
|
73
|
+
marketplaceRepository: 'anthropics/claude-plugins-official',
|
|
74
|
+
marketplaceName: 'claude-plugins-official',
|
|
75
|
+
sparsePaths: Object.freeze([]),
|
|
76
|
+
pluginIdentifier: 'security-guidance@claude-plugins-official',
|
|
77
|
+
hosts: Object.freeze(['claude']),
|
|
78
|
+
optOutFlag: '--no-security-guidance',
|
|
79
|
+
optOutVariable: 'CDE_INSTALL_SECURITY_GUIDANCE',
|
|
80
|
+
sharesMarketplace: true,
|
|
81
|
+
});
|
|
82
|
+
|
|
62
83
|
const HOST_DEFINITIONS = Object.freeze({
|
|
63
84
|
claude: Object.freeze({
|
|
64
85
|
executable: 'claude',
|
|
@@ -303,7 +324,8 @@ function lockedFolderDetail(allSpecs, host, homeDirectory, errorText, findLocker
|
|
|
303
324
|
}).join('');
|
|
304
325
|
}
|
|
305
326
|
|
|
306
|
-
const
|
|
327
|
+
const MARKETPLACE_SOURCE_MISMATCH_PATTERN = /doesn't match its extraKnownMarketplaces entry/;
|
|
328
|
+
const REBUILD_REQUIRED_PATTERN = new RegExp(`${MARKETPLACE_SOURCE_MISMATCH_PATTERN.source}|Source path does not exist`);
|
|
307
329
|
|
|
308
330
|
function commandFailureDetail(outcome) {
|
|
309
331
|
return singleLineDetail(outcome.stderr) || singleLineDetail(outcome.stdout)
|
|
@@ -356,7 +378,25 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
356
378
|
return { host, executable, status: 'installed', warning: null };
|
|
357
379
|
}
|
|
358
380
|
const refreshOutput = `${refreshOutcome.outcome.stderr ?? ''}\n${refreshOutcome.outcome.stdout ?? ''}`;
|
|
359
|
-
|
|
381
|
+
const marketplaceIsShared = allSpecs.some(eachSpec => eachSpec.sharesMarketplace);
|
|
382
|
+
const sourceMismatchOnAdd = refreshOutcome.commandArguments === plan.refreshCommands[0]
|
|
383
|
+
&& MARKETPLACE_SOURCE_MISMATCH_PATTERN.test(refreshOutput);
|
|
384
|
+
if (marketplaceIsShared && sourceMismatchOnAdd) {
|
|
385
|
+
const knownMarketplacePlan = { ...plan, refreshCommands: plan.refreshCommands.slice(1) };
|
|
386
|
+
const knownMarketplaceOutcome = refreshInPlace(
|
|
387
|
+
knownMarketplacePlan, executable, commandEnvironment, runCommand,
|
|
388
|
+
);
|
|
389
|
+
if (knownMarketplaceOutcome.state === 'refreshed') {
|
|
390
|
+
removeOrphanedVersionsForHost(allSpecs, host, homeDirectory);
|
|
391
|
+
return { host, executable, status: 'installed', warning: null };
|
|
392
|
+
}
|
|
393
|
+
if (knownMarketplaceOutcome.state === 'absent') return absentHostOutcome(allSpecs, host, executable);
|
|
394
|
+
return failedCommandOutcome(
|
|
395
|
+
allSpecs, host, homeDirectory, executable,
|
|
396
|
+
knownMarketplaceOutcome.commandArguments, knownMarketplaceOutcome.outcome, findLockers,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (marketplaceIsShared || !REBUILD_REQUIRED_PATTERN.test(refreshOutput)) {
|
|
360
400
|
return failedCommandOutcome(
|
|
361
401
|
allSpecs, host, homeDirectory, executable,
|
|
362
402
|
refreshOutcome.commandArguments, refreshOutcome.outcome, findLockers,
|
|
@@ -418,7 +458,12 @@ export function restartNotice(allSpecs, allInstalledHosts) {
|
|
|
418
458
|
* back to the clean install Codex always runs: it removes every plugin in the
|
|
419
459
|
* set and then the marketplace, so an older version never survives beside the
|
|
420
460
|
* new one, and adds them again. A removal that finds nothing to remove exits
|
|
421
|
-
* non-zero on a fresh home, and the install goes on.
|
|
461
|
+
* non-zero on a fresh home, and the install goes on. A spec that marks its
|
|
462
|
+
* marketplace as shared skips the rebuild, because removing that marketplace
|
|
463
|
+
* would uninstall plugins this run does not own. When the shared marketplace
|
|
464
|
+
* is already known under another source, the add fails on the mismatch and
|
|
465
|
+
* the host runs the rest of the refresh against the known entry; any other
|
|
466
|
+
* refresh failure reports the host failed.
|
|
422
467
|
* Before each Claude marketplace add, the install deletes the half-made clone
|
|
423
468
|
* folder an interrupted earlier add left, so the add starts in an empty slot.
|
|
424
469
|
*
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
PSTACK_PLUGIN_IDENTIFIER,
|
|
10
10
|
ALL_RETIRED_PLUGIN_SPECS,
|
|
11
11
|
PSTACK_PLUGIN_SPEC,
|
|
12
|
+
SECURITY_GUIDANCE_PLUGIN_SPEC,
|
|
12
13
|
SUBAGENT_MODELS_PLUGIN_SPEC,
|
|
13
14
|
USAGE_WRAPUP_PLUGIN_SPEC,
|
|
14
15
|
hostCommandInvocation,
|
|
@@ -584,4 +585,60 @@ test('a run that installs nothing prints no restart notice', () => {
|
|
|
584
585
|
assert.equal(outcome.status, 'skipped');
|
|
585
586
|
assert.deepEqual(allLoggedLines, []);
|
|
586
587
|
assert.equal(outcome.restartNotice, null);
|
|
587
|
-
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test('should install security-guidance from the official marketplace on Claude only', () => {
|
|
591
|
+
const runner = recordingRunner();
|
|
592
|
+
const outcome = installMarketplacePlugins([SECURITY_GUIDANCE_PLUGIN_SPEC], ROOTS, runner);
|
|
593
|
+
assert.equal(outcome.status, 'installed');
|
|
594
|
+
assert.deepEqual(outcome.hosts.map(host => host.host), ['claude']);
|
|
595
|
+
assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
|
|
596
|
+
['claude', 'plugin', 'marketplace', 'add', 'anthropics/claude-plugins-official'],
|
|
597
|
+
['claude', 'plugin', 'marketplace', 'update', 'claude-plugins-official'],
|
|
598
|
+
['claude', 'plugin', 'install', 'security-guidance@claude-plugins-official'],
|
|
599
|
+
['claude', 'plugin', 'update', 'security-guidance@claude-plugins-official'],
|
|
600
|
+
]);
|
|
601
|
+
assert.equal(runner.calls[0].environment.CLAUDE_CONFIG_DIR, ROOTS.claudeRoot);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test('should install against an official marketplace already known under another source', () => {
|
|
605
|
+
const runner = recordingRunner((executable, commandArguments) => (
|
|
606
|
+
commandArguments[2] === 'add'
|
|
607
|
+
? { status: 1, stderr: "its source doesn't match its extraKnownMarketplaces entry" }
|
|
608
|
+
: { status: 0, stderr: '' }));
|
|
609
|
+
const outcome = installMarketplacePlugins([SECURITY_GUIDANCE_PLUGIN_SPEC], ROOTS, runner);
|
|
610
|
+
assert.equal(outcome.status, 'installed');
|
|
611
|
+
assert.deepEqual(runner.calls.map(call => call.commandArguments), [
|
|
612
|
+
['plugin', 'marketplace', 'add', 'anthropics/claude-plugins-official'],
|
|
613
|
+
['plugin', 'marketplace', 'update', 'claude-plugins-official'],
|
|
614
|
+
['plugin', 'install', 'security-guidance@claude-plugins-official'],
|
|
615
|
+
['plugin', 'update', 'security-guidance@claude-plugins-official'],
|
|
616
|
+
]);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test('should leave the shared official marketplace in place when its refresh needs a rebuild', () => {
|
|
620
|
+
const runner = recordingRunner((executable, commandArguments) => (
|
|
621
|
+
commandArguments[1] === 'install'
|
|
622
|
+
? { status: 1, stderr: 'Source path does not exist: plugins/security-guidance' }
|
|
623
|
+
: { status: 0, stderr: '' }));
|
|
624
|
+
const outcome = installMarketplacePlugins([SECURITY_GUIDANCE_PLUGIN_SPEC], ROOTS, runner);
|
|
625
|
+
assert.equal(outcome.status, 'failed');
|
|
626
|
+
assert.match(outcome.warning, /Source path does not exist/);
|
|
627
|
+
assert.equal(
|
|
628
|
+
runner.calls.some(call => call.commandArguments.includes('remove') || call.commandArguments.includes('uninstall')),
|
|
629
|
+
false,
|
|
630
|
+
);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test('should turn off only security-guidance with its opt-out flag or variable', () => {
|
|
634
|
+
assert.equal(shouldInstallMarketplacePlugin(SECURITY_GUIDANCE_PLUGIN_SPEC, [], {}), true);
|
|
635
|
+
assert.equal(shouldInstallMarketplacePlugin(SECURITY_GUIDANCE_PLUGIN_SPEC, ['--no-security-guidance'], {}), false);
|
|
636
|
+
assert.equal(
|
|
637
|
+
shouldInstallMarketplacePlugin(SECURITY_GUIDANCE_PLUGIN_SPEC, [], { CDE_INSTALL_SECURITY_GUIDANCE: '0' }),
|
|
638
|
+
false,
|
|
639
|
+
);
|
|
640
|
+
assert.equal(
|
|
641
|
+
shouldInstallMarketplacePlugin(PSTACK_PLUGIN_SPEC, ['--no-security-guidance'], { CDE_INSTALL_SECURITY_GUIDANCE: '0' }),
|
|
642
|
+
true,
|
|
643
|
+
);
|
|
644
|
+
});
|
|
@@ -56,6 +56,7 @@ function runInstaller(homeDirectory, extraArguments, environmentOverrides = {})
|
|
|
56
56
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
57
57
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
58
58
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
59
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
59
60
|
HOME: homeDirectory,
|
|
60
61
|
USERPROFILE: homeDirectory,
|
|
61
62
|
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
@@ -62,6 +62,7 @@ function runInstaller(homeDirectory, extraArguments) {
|
|
|
62
62
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
63
63
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
64
64
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
65
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
65
66
|
HOME: homeDirectory,
|
|
66
67
|
USERPROFILE: homeDirectory,
|
|
67
68
|
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
@@ -95,6 +96,7 @@ function scanTextWithProductionPiiScanner(scannedText) {
|
|
|
95
96
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
96
97
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
97
98
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
99
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
98
100
|
PYTHONPATH: BLOCKING_HOOKS_DIRECTORY,
|
|
99
101
|
CLAUDE_LOCAL_IDENTITY_PATH: join(
|
|
100
102
|
temporaryDirectory,
|
|
@@ -36,6 +36,7 @@ function runInstaller(homeDirectory, extraArguments, environmentOverrides = {})
|
|
|
36
36
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
37
37
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
38
38
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
39
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
39
40
|
HOME: homeDirectory,
|
|
40
41
|
USERPROFILE: homeDirectory,
|
|
41
42
|
CODEX_HOME: join(homeDirectory, '.codex'),
|
package/bin/install.mjs
CHANGED
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
import {
|
|
59
59
|
ALL_RETIRED_PLUGIN_SPECS,
|
|
60
60
|
PSTACK_PLUGIN_SPEC,
|
|
61
|
+
SECURITY_GUIDANCE_PLUGIN_SPEC,
|
|
61
62
|
SUBAGENT_MODELS_PLUGIN_SPEC,
|
|
62
63
|
USAGE_WRAPUP_PLUGIN_SPEC,
|
|
63
64
|
installMarketplacePlugins,
|
|
@@ -3121,6 +3122,13 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
3121
3122
|
summary.repositoryPlugins = repositoryPlugins;
|
|
3122
3123
|
printPluginHostOutcomes(allRepositoryPluginSpecs, repositoryPlugins);
|
|
3123
3124
|
}
|
|
3125
|
+
if (!selectedGroups && shouldInstallMarketplacePlugin(SECURITY_GUIDANCE_PLUGIN_SPEC)) {
|
|
3126
|
+
const securityGuidancePlugin = installMarketplacePlugins([SECURITY_GUIDANCE_PLUGIN_SPEC], {
|
|
3127
|
+
claudeRoot: CLAUDE_HOME,
|
|
3128
|
+
});
|
|
3129
|
+
summary.securityGuidancePlugin = securityGuidancePlugin;
|
|
3130
|
+
printPluginHostOutcomes([SECURITY_GUIDANCE_PLUGIN_SPEC], securityGuidancePlugin);
|
|
3131
|
+
}
|
|
3124
3132
|
if (!selectedGroups) {
|
|
3125
3133
|
const packageGuidancePath = writeCodexPackageGuidance(
|
|
3126
3134
|
INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
|
|
@@ -3686,6 +3694,7 @@ Usage:
|
|
|
3686
3694
|
npx ${PACKAGE_NAME} --no-deepstack Full install without the DeepStack plugin (also CDE_INSTALL_DEEPSTACK=0)
|
|
3687
3695
|
npx ${PACKAGE_NAME} --no-usage-wrapup Full install without the usage-wrapup plugin (also CDE_INSTALL_USAGE_WRAPUP=0)
|
|
3688
3696
|
npx ${PACKAGE_NAME} --no-subagent-models Full install without the subagent-models plugin (also CDE_INSTALL_SUBAGENT_MODELS=0)
|
|
3697
|
+
npx ${PACKAGE_NAME} --no-security-guidance Full install without Anthropic's security-guidance plugin (also CDE_INSTALL_SECURITY_GUIDANCE=0)
|
|
3689
3698
|
npx ${PACKAGE_NAME} --uninstall Remove installed files from the selected root
|
|
3690
3699
|
npx ${PACKAGE_NAME} --help Show this help
|
|
3691
3700
|
|
|
@@ -22,7 +22,7 @@ test('packed full and core installs ship the runnable PR visual skill to the sha
|
|
|
22
22
|
const homeDirectory = join(sandbox, `home-${eachIndex}`);
|
|
23
23
|
execFileSync(process.execPath, [join(packaged, 'bin/install.mjs'), ...eachArguments], {
|
|
24
24
|
encoding: 'utf8',
|
|
25
|
-
env: { ...process.env, CLAUDE_CONFIG_DIR: undefined, LLM_SETTINGS_PROFILES_ROOT: undefined, HOME: homeDirectory, USERPROFILE: homeDirectory, CODEX_HOME: join(homeDirectory, '.codex'), GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'), CDE_INSTALL_PSTACK: '0', CDE_INSTALL_DEEPSTACK: '0', CDE_INSTALL_USAGE_WRAPUP: '0', CDE_INSTALL_SUBAGENT_MODELS: '0', CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0' },
|
|
25
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: undefined, LLM_SETTINGS_PROFILES_ROOT: undefined, HOME: homeDirectory, USERPROFILE: homeDirectory, CODEX_HOME: join(homeDirectory, '.codex'), GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'), CDE_INSTALL_PSTACK: '0', CDE_INSTALL_DEEPSTACK: '0', CDE_INSTALL_USAGE_WRAPUP: '0', CDE_INSTALL_SUBAGENT_MODELS: '0', CDE_INSTALL_SECURITY_GUIDANCE: '0', CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0' },
|
|
26
26
|
});
|
|
27
27
|
const installed = join(homeDirectory, ...SKILL_PATH_SEGMENTS);
|
|
28
28
|
for (const eachFile of REQUIRED_FILES) assert.deepEqual(readFileSync(join(installed, eachFile)), readFileSync(join(packaged, ...SKILL_PATH_SEGMENTS, eachFile)));
|
|
@@ -266,6 +266,7 @@ function resolveInstallerInvocation(homeDirectory) {
|
|
|
266
266
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
267
267
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
268
268
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
269
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
269
270
|
HOME: homeDirectory,
|
|
270
271
|
USERPROFILE: homeDirectory,
|
|
271
272
|
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
@@ -28,6 +28,7 @@ function installInScratchHome(homeDirectory, argumentsList = [], extraEnvironmen
|
|
|
28
28
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
29
29
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
30
30
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
31
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
31
32
|
HOME: homeDirectory,
|
|
32
33
|
USERPROFILE: homeDirectory,
|
|
33
34
|
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
@@ -185,6 +186,7 @@ test('a successful pstack install omits the retired skill block and gains the qu
|
|
|
185
186
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
186
187
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
187
188
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
189
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
188
190
|
CDE_CODEX_EXECUTABLE: commandPath,
|
|
189
191
|
CDE_CLAUDE_EXECUTABLE: commandPath,
|
|
190
192
|
});
|
|
@@ -214,6 +216,7 @@ test('enabling pstack after a pstack-off install preserves question guidance', (
|
|
|
214
216
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
215
217
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
216
218
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
219
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
217
220
|
CDE_CODEX_EXECUTABLE: commandPath,
|
|
218
221
|
CDE_CLAUDE_EXECUTABLE: commandPath,
|
|
219
222
|
});
|
|
@@ -244,6 +247,7 @@ test('a failed pstack transition restores question-only guidance and removes its
|
|
|
244
247
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
245
248
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
246
249
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
250
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
247
251
|
CDE_CODEX_EXECUTABLE: commandPath,
|
|
248
252
|
CDE_CLAUDE_EXECUTABLE: commandPath,
|
|
249
253
|
CLAUDE_DEV_ENV_INSTALL_FAULT: 'after_file_staging',
|
|
@@ -74,6 +74,7 @@ function runInstallerInSandbox(sandboxHome, installerArguments = []) {
|
|
|
74
74
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
75
75
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
76
76
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
77
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
77
78
|
HOME: sandboxHome,
|
|
78
79
|
USERPROFILE: sandboxHome,
|
|
79
80
|
GIT_CONFIG_GLOBAL: gitConfigGlobal,
|
package/bin/install.test.mjs
CHANGED
|
@@ -3050,6 +3050,7 @@ test('a failed update preserves an existing skills lookup pointer', () => {
|
|
|
3050
3050
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
3051
3051
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
3052
3052
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
3053
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
3053
3054
|
};
|
|
3054
3055
|
const argumentsForCoreInstall = [PSTACK_TEST_INSTALLER_PATH, '--only', 'core'];
|
|
3055
3056
|
const firstRun = spawnSync(process.execPath, argumentsForCoreInstall, {
|
|
@@ -3104,6 +3105,7 @@ function runPstackInstaller(homeDirectory, extraArguments, environmentOverrides
|
|
|
3104
3105
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
3105
3106
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
3106
3107
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
3108
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
3107
3109
|
...environmentOverrides,
|
|
3108
3110
|
},
|
|
3109
3111
|
});
|
|
@@ -3529,6 +3531,54 @@ for (const [caseName, extraArguments, environmentOverrides] of [
|
|
|
3529
3531
|
});
|
|
3530
3532
|
}
|
|
3531
3533
|
|
|
3534
|
+
test('should install security-guidance from the official marketplace on a full install', t => {
|
|
3535
|
+
const sandbox = pstackPluginSandbox(t);
|
|
3536
|
+
|
|
3537
|
+
const installerOutput = runPstackInstaller(sandbox.homeDirectory, ['--no-pstack'], {
|
|
3538
|
+
...sandbox.environment,
|
|
3539
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '1',
|
|
3540
|
+
});
|
|
3541
|
+
|
|
3542
|
+
assert.deepEqual(sandbox.recordedCommands(), [
|
|
3543
|
+
'claude plugin marketplace add anthropics/claude-plugins-official',
|
|
3544
|
+
'claude plugin marketplace update claude-plugins-official',
|
|
3545
|
+
'claude plugin install security-guidance@claude-plugins-official',
|
|
3546
|
+
'claude plugin update security-guidance@claude-plugins-official',
|
|
3547
|
+
]);
|
|
3548
|
+
assert.match(installerOutput, /Security-guidance \(claude\): installed/);
|
|
3549
|
+
});
|
|
3550
|
+
|
|
3551
|
+
for (const [caseName, extraArguments, environmentOverrides] of [
|
|
3552
|
+
['--no-security-guidance', ['--no-security-guidance'], { CDE_INSTALL_SECURITY_GUIDANCE: '1' }],
|
|
3553
|
+
['CDE_INSTALL_SECURITY_GUIDANCE=0', [], { CDE_INSTALL_SECURITY_GUIDANCE: '0' }],
|
|
3554
|
+
['an --only run', ['--only', 'core'], { CDE_INSTALL_SECURITY_GUIDANCE: '1' }],
|
|
3555
|
+
]) {
|
|
3556
|
+
test(`should skip security-guidance on ${caseName}`, t => {
|
|
3557
|
+
const sandbox = pstackPluginSandbox(t);
|
|
3558
|
+
|
|
3559
|
+
const installerOutput = runPstackInstaller(sandbox.homeDirectory, extraArguments, {
|
|
3560
|
+
...sandbox.environment,
|
|
3561
|
+
...environmentOverrides,
|
|
3562
|
+
});
|
|
3563
|
+
|
|
3564
|
+
assert.equal(
|
|
3565
|
+
sandbox.recordedCommands().some(eachCommand => eachCommand.includes('security-guidance')),
|
|
3566
|
+
false,
|
|
3567
|
+
);
|
|
3568
|
+
assert.doesNotMatch(installerOutput, /Security-guidance \(/);
|
|
3569
|
+
});
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
test('should name the security-guidance opt-out in the help output', () => {
|
|
3573
|
+
const helpRun = spawnSync(process.execPath, [PSTACK_TEST_INSTALLER_PATH, '--help'], {
|
|
3574
|
+
encoding: 'utf8',
|
|
3575
|
+
env: process.env,
|
|
3576
|
+
});
|
|
3577
|
+
|
|
3578
|
+
assert.equal(helpRun.status, 0, helpRun.stderr);
|
|
3579
|
+
assert.match(helpRun.stdout, /--no-security-guidance .*CDE_INSTALL_SECURITY_GUIDANCE=0/);
|
|
3580
|
+
});
|
|
3581
|
+
|
|
3532
3582
|
test('should name the subagent-models opt-out in the help output', () => {
|
|
3533
3583
|
const helpRun = spawnSync(process.execPath, [PSTACK_TEST_INSTALLER_PATH, '--help'], {
|
|
3534
3584
|
encoding: 'utf8',
|
|
@@ -3623,6 +3673,7 @@ function installWithGlobalPrefix(context, globalPrefix) {
|
|
|
3623
3673
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
3624
3674
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
3625
3675
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
3676
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
3626
3677
|
HOME: homeDirectory,
|
|
3627
3678
|
USERPROFILE: homeDirectory,
|
|
3628
3679
|
APPDATA: join(homeDirectory, 'AppData', 'Roaming'),
|
|
@@ -3679,6 +3730,7 @@ test('install adds the open-tasks Stop gate to every profile with its own settin
|
|
|
3679
3730
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
3680
3731
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
3681
3732
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
3733
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
3682
3734
|
HOME: homeDirectory,
|
|
3683
3735
|
USERPROFILE: homeDirectory,
|
|
3684
3736
|
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
@@ -3721,6 +3773,7 @@ test('a profile settings.json that is not valid JSON is left alone and named und
|
|
|
3721
3773
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
3722
3774
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
3723
3775
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
3776
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
3724
3777
|
HOME: homeDirectory,
|
|
3725
3778
|
USERPROFILE: homeDirectory,
|
|
3726
3779
|
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
@@ -3766,6 +3819,7 @@ test('uninstall stops before changing anything while a profile settings.json tha
|
|
|
3766
3819
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
3767
3820
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
3768
3821
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
3822
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
3769
3823
|
HOME: homeDirectory,
|
|
3770
3824
|
USERPROFILE: homeDirectory,
|
|
3771
3825
|
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
@@ -91,6 +91,7 @@ test('capture and restore recover settings, manifest, files, and hooksPath', ()
|
|
|
91
91
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
92
92
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
93
93
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
94
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
94
95
|
HOME: box.root,
|
|
95
96
|
USERPROFILE: box.root,
|
|
96
97
|
GIT_CONFIG_GLOBAL: gitConfigPath,
|
|
@@ -157,6 +158,7 @@ test('capture and restore recover an additional host settings file', () => {
|
|
|
157
158
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
158
159
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
159
160
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
161
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
160
162
|
HOME: box.root,
|
|
161
163
|
USERPROFILE: box.root,
|
|
162
164
|
GIT_CONFIG_GLOBAL: gitConfigPath,
|
|
@@ -196,6 +198,7 @@ test('runWithInstallTransaction restores prior state on injected fault', () => {
|
|
|
196
198
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
197
199
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
198
200
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
201
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
199
202
|
HOME: box.root,
|
|
200
203
|
USERPROFILE: box.root,
|
|
201
204
|
GIT_CONFIG_GLOBAL: gitConfigPath,
|
|
@@ -254,6 +257,7 @@ test('runWithInstallTransaction commits and discards journal on success', () =>
|
|
|
254
257
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
255
258
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
256
259
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
260
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
257
261
|
HOME: box.root,
|
|
258
262
|
USERPROFILE: box.root,
|
|
259
263
|
GIT_CONFIG_GLOBAL: gitConfigPath,
|
|
@@ -301,6 +305,7 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
|
|
|
301
305
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
302
306
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
303
307
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
308
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
304
309
|
HOME: homeDirectory,
|
|
305
310
|
USERPROFILE: homeDirectory,
|
|
306
311
|
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
@@ -338,6 +343,7 @@ test('a later install fault removes newly seeded Codex pstack files', () => {
|
|
|
338
343
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
339
344
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
340
345
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
346
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
341
347
|
CDE_CODEX_EXECUTABLE: codexCommandPath,
|
|
342
348
|
},
|
|
343
349
|
});
|
|
@@ -485,6 +491,7 @@ test('installer fault after_git_config restores prior core.hooksPath', () => {
|
|
|
485
491
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
486
492
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
487
493
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
494
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
488
495
|
HOME: homeDirectory,
|
|
489
496
|
USERPROFILE: homeDirectory,
|
|
490
497
|
GIT_CONFIG_GLOBAL: gitConfigPath,
|
|
@@ -506,6 +513,7 @@ test('installer fault after_git_config restores prior core.hooksPath', () => {
|
|
|
506
513
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
507
514
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
508
515
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
516
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
509
517
|
HOME: homeDirectory,
|
|
510
518
|
USERPROFILE: homeDirectory,
|
|
511
519
|
GIT_CONFIG_GLOBAL: gitConfigPath,
|
|
@@ -77,6 +77,7 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
|
|
|
77
77
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
78
78
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
79
79
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
80
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
80
81
|
HOME: homeDirectory,
|
|
81
82
|
USERPROFILE: homeDirectory,
|
|
82
83
|
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
@@ -315,6 +316,7 @@ test('uninstall fault after_git_config restores prior core.hooksPath', () => {
|
|
|
315
316
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
316
317
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
317
318
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
319
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
318
320
|
HOME: homeDirectory,
|
|
319
321
|
USERPROFILE: homeDirectory,
|
|
320
322
|
GIT_CONFIG_GLOBAL: gitConfigPath,
|
|
@@ -28,6 +28,7 @@ function installerEnvironment(homeDirectory) {
|
|
|
28
28
|
CDE_INSTALL_DEEPSTACK: '0',
|
|
29
29
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
30
30
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
31
|
+
CDE_INSTALL_SECURITY_GUIDANCE: '0',
|
|
31
32
|
HOME: homeDirectory,
|
|
32
33
|
USERPROFILE: homeDirectory,
|
|
33
34
|
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook that runs a repository's lint on each file the agent edits.
|
|
3
|
+
|
|
4
|
+
The agent edits ``tools/report.py`` in a repository that opts in. The hook runs
|
|
5
|
+
that repository's lint command on the file and hands the findings back to the
|
|
6
|
+
agent, so it fixes them in the same turn::
|
|
7
|
+
|
|
8
|
+
environment list: owner/repo
|
|
9
|
+
<repo>/.claude/edit-lint.json:
|
|
10
|
+
{"file_suffixes": [".py"],
|
|
11
|
+
"command": ["python", "lint.py", "--advisory", "{file}"]}
|
|
12
|
+
|
|
13
|
+
Edit tools/report.py -> additionalContext:
|
|
14
|
+
"The repository's lint found these problems in tools/report.py ..."
|
|
15
|
+
|
|
16
|
+
A repository takes part only when its origin ``owner/repo`` slug is in
|
|
17
|
+
``CLAUDE_EDIT_LINT_REPOSITORIES``, it commits ``.claude/edit-lint.json``, and
|
|
18
|
+
its root sits inside the session's project folder, the folder Claude Code's
|
|
19
|
+
workspace trust covers. A checkout elsewhere never runs its declared command.
|
|
20
|
+
The command runs from the repository root, and ``{file}`` in it becomes the
|
|
21
|
+
edited path with every symlink resolved. Its standard output is the findings; empty output adds nothing.
|
|
22
|
+
A command that exits non-zero, times out, or cannot start adds nothing and
|
|
23
|
+
writes one diagnostic line to stderr. The hook never blocks and never edits
|
|
24
|
+
the file.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
38
|
+
_blocking_directory = str(Path(_hooks_directory) / "blocking")
|
|
39
|
+
for each_bootstrap_directory in (_hooks_directory, _blocking_directory):
|
|
40
|
+
if each_bootstrap_directory not in sys.path:
|
|
41
|
+
sys.path.insert(0, each_bootstrap_directory)
|
|
42
|
+
from hooks_constants.edit_lint_context_constants import (
|
|
43
|
+
ALL_EDIT_TOOL_NAMES,
|
|
44
|
+
ALL_GIT_TOPLEVEL_COMMAND,
|
|
45
|
+
COMMAND_KEY,
|
|
46
|
+
CONFIG_RELATIVE_PATH,
|
|
47
|
+
FILE_PLACEHOLDER,
|
|
48
|
+
FILE_SUFFIXES_KEY,
|
|
49
|
+
FINDINGS_CONTEXT_TEMPLATE,
|
|
50
|
+
GIT_COMMAND_TIMEOUT_SECONDS,
|
|
51
|
+
LINT_COMMAND_TIMEOUT_SECONDS,
|
|
52
|
+
LINT_FAILURE_DIAGNOSTIC_TEMPLATE,
|
|
53
|
+
MAXIMUM_FINDINGS_CHARACTERS,
|
|
54
|
+
PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE,
|
|
55
|
+
REPOSITORIES_ENVIRONMENT_VARIABLE,
|
|
56
|
+
REPOSITORY_SEPARATOR,
|
|
57
|
+
TRUNCATION_NOTICE,
|
|
58
|
+
)
|
|
59
|
+
from hooks_constants.post_tool_use_context import write_post_tool_use_context_to_stdout
|
|
60
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
61
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
62
|
+
from pii_prevention_blocker_parts.repository_exemption import repository_origin_slug
|
|
63
|
+
except ImportError as import_error:
|
|
64
|
+
raise ImportError(
|
|
65
|
+
"edit_lint_context: cannot import its sibling modules; "
|
|
66
|
+
"ensure the hooks directory is importable."
|
|
67
|
+
) from import_error
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class EditLintConfig:
|
|
72
|
+
"""A repository's edit lint settings from ``.claude/edit-lint.json``.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
all_file_suffixes: File suffixes the command lints, such as ``.py``.
|
|
76
|
+
all_command_parts: The command and its arguments, with ``{file}``
|
|
77
|
+
where the edited path goes.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
all_file_suffixes: tuple[str, ...]
|
|
81
|
+
all_command_parts: tuple[str, ...]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def opted_in_repository_slugs() -> frozenset[str]:
|
|
85
|
+
"""Return the lowercased ``owner/repo`` slugs the environment value lists.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Each non-empty comma-separated entry of
|
|
89
|
+
``CLAUDE_EDIT_LINT_REPOSITORIES``, stripped and lowercased.
|
|
90
|
+
"""
|
|
91
|
+
listed = os.environ.get(REPOSITORIES_ENVIRONMENT_VARIABLE, "")
|
|
92
|
+
return frozenset(
|
|
93
|
+
each.strip().casefold() for each in listed.split(REPOSITORY_SEPARATOR) if each.strip()
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def repository_root_of(file_path: Path) -> Path | None:
|
|
98
|
+
"""Return the git work tree root that holds the file, or None.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
file_path: Absolute path of the edited file.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The work tree root, or None when the file sits outside a git work tree
|
|
105
|
+
or git cannot run.
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
completed_process = subprocess.run(
|
|
109
|
+
list(ALL_GIT_TOPLEVEL_COMMAND),
|
|
110
|
+
check=False,
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
114
|
+
cwd=str(file_path.parent),
|
|
115
|
+
creationflags=hidden_window_creation_flags(),
|
|
116
|
+
)
|
|
117
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
118
|
+
return None
|
|
119
|
+
if completed_process.returncode != 0:
|
|
120
|
+
return None
|
|
121
|
+
return Path(completed_process.stdout.strip())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def is_inside_project_directory(repository_root: Path) -> bool:
|
|
125
|
+
"""Return whether the repository root sits inside the session's project folder.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
repository_root: The git work tree root.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True when ``CLAUDE_PROJECT_DIR`` is set and the resolved root equals it
|
|
132
|
+
or sits below it. False when the variable is unset or the root lies
|
|
133
|
+
outside it.
|
|
134
|
+
"""
|
|
135
|
+
project_directory = os.environ.get(PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE, "")
|
|
136
|
+
if not project_directory:
|
|
137
|
+
return False
|
|
138
|
+
resolved_root = repository_root.resolve()
|
|
139
|
+
resolved_project = Path(project_directory).resolve()
|
|
140
|
+
return resolved_root == resolved_project or resolved_project in resolved_root.parents
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_edit_lint_config(repository_root: Path) -> EditLintConfig | None:
|
|
144
|
+
"""Read the repository's ``.claude/edit-lint.json``, or None when unusable.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
repository_root: The git work tree root.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The parsed settings, or None when the file is missing, is not JSON, or
|
|
151
|
+
lacks a non-empty string list under ``file_suffixes`` or ``command``.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
raw_config = json.loads(
|
|
155
|
+
(repository_root / CONFIG_RELATIVE_PATH).read_text(encoding="utf-8")
|
|
156
|
+
)
|
|
157
|
+
except (OSError, ValueError):
|
|
158
|
+
return None
|
|
159
|
+
if not isinstance(raw_config, dict):
|
|
160
|
+
return None
|
|
161
|
+
all_file_suffixes = _string_tuple(raw_config.get(FILE_SUFFIXES_KEY))
|
|
162
|
+
all_command_parts = _string_tuple(raw_config.get(COMMAND_KEY))
|
|
163
|
+
if not all_file_suffixes or not all_command_parts:
|
|
164
|
+
return None
|
|
165
|
+
return EditLintConfig(all_file_suffixes, all_command_parts)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _string_tuple(raw_value: object) -> tuple[str, ...]:
|
|
169
|
+
if not isinstance(raw_value, list):
|
|
170
|
+
return ()
|
|
171
|
+
if not all(isinstance(each, str) and each for each in raw_value):
|
|
172
|
+
return ()
|
|
173
|
+
return tuple(raw_value)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def lint_findings(config: EditLintConfig, repository_root: Path, file_path: Path) -> str:
|
|
177
|
+
"""Run the lint command on one file and return its findings text.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
config: The repository's edit lint settings.
|
|
181
|
+
repository_root: The directory the command runs from.
|
|
182
|
+
file_path: The edited file, put where ``{file}`` stands.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The command's standard output, stripped. An empty string when the
|
|
186
|
+
command printed nothing, exited non-zero, timed out, or could not
|
|
187
|
+
start.
|
|
188
|
+
"""
|
|
189
|
+
completed_process = _run_lint_command(config, repository_root, file_path)
|
|
190
|
+
if completed_process is None:
|
|
191
|
+
return ""
|
|
192
|
+
if completed_process.returncode != 0:
|
|
193
|
+
last_error_line = (completed_process.stderr.strip().splitlines() or [""])[-1]
|
|
194
|
+
_write_failure_diagnostic(
|
|
195
|
+
file_path, f"exit {completed_process.returncode}: {last_error_line}"
|
|
196
|
+
)
|
|
197
|
+
return ""
|
|
198
|
+
return completed_process.stdout.strip()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _run_lint_command(
|
|
202
|
+
config: EditLintConfig, repository_root: Path, file_path: Path
|
|
203
|
+
) -> subprocess.CompletedProcess[str] | None:
|
|
204
|
+
all_arguments = [
|
|
205
|
+
each_part.replace(FILE_PLACEHOLDER, str(file_path))
|
|
206
|
+
for each_part in config.all_command_parts
|
|
207
|
+
]
|
|
208
|
+
try:
|
|
209
|
+
return subprocess.run(
|
|
210
|
+
all_arguments,
|
|
211
|
+
check=False,
|
|
212
|
+
capture_output=True,
|
|
213
|
+
text=True,
|
|
214
|
+
encoding="utf-8",
|
|
215
|
+
errors="replace",
|
|
216
|
+
timeout=LINT_COMMAND_TIMEOUT_SECONDS,
|
|
217
|
+
cwd=str(repository_root),
|
|
218
|
+
creationflags=hidden_window_creation_flags(),
|
|
219
|
+
)
|
|
220
|
+
except subprocess.TimeoutExpired:
|
|
221
|
+
_write_failure_diagnostic(file_path, "timed out")
|
|
222
|
+
except OSError as start_error:
|
|
223
|
+
_write_failure_diagnostic(file_path, str(start_error))
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _write_failure_diagnostic(file_path: Path, failure_text: str) -> None:
|
|
228
|
+
sys.stderr.write(LINT_FAILURE_DIAGNOSTIC_TEMPLATE % (file_path, failure_text))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def findings_context(file_label: str, findings_text: str) -> str:
|
|
232
|
+
"""Return the note the agent reads, with the findings cut to the size limit.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
file_label: The edited file as the note names it.
|
|
236
|
+
findings_text: The lint command's findings.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
The note text.
|
|
240
|
+
"""
|
|
241
|
+
if len(findings_text) > MAXIMUM_FINDINGS_CHARACTERS:
|
|
242
|
+
findings_text = findings_text[:MAXIMUM_FINDINGS_CHARACTERS] + TRUNCATION_NOTICE.format(
|
|
243
|
+
limit=MAXIMUM_FINDINGS_CHARACTERS
|
|
244
|
+
)
|
|
245
|
+
return FINDINGS_CONTEXT_TEMPLATE.format(file=file_label, findings=findings_text)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _edited_file_path(all_hook_fields: dict[str, object]) -> Path | None:
|
|
249
|
+
if all_hook_fields.get("tool_name") not in ALL_EDIT_TOOL_NAMES:
|
|
250
|
+
return None
|
|
251
|
+
tool_input = all_hook_fields.get("tool_input")
|
|
252
|
+
if not isinstance(tool_input, dict):
|
|
253
|
+
return None
|
|
254
|
+
raw_path = tool_input.get("file_path")
|
|
255
|
+
if not isinstance(raw_path, str) or not raw_path:
|
|
256
|
+
return None
|
|
257
|
+
file_path = Path(raw_path)
|
|
258
|
+
if not file_path.is_absolute() or not file_path.is_file():
|
|
259
|
+
return None
|
|
260
|
+
return file_path.resolve()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def main() -> None:
|
|
264
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
265
|
+
if hook_input is None:
|
|
266
|
+
return
|
|
267
|
+
all_opted_in_slugs = opted_in_repository_slugs()
|
|
268
|
+
if not all_opted_in_slugs:
|
|
269
|
+
return
|
|
270
|
+
file_path = _edited_file_path(hook_input)
|
|
271
|
+
if file_path is None:
|
|
272
|
+
return
|
|
273
|
+
repository_root = repository_root_of(file_path)
|
|
274
|
+
if repository_root is None or not is_inside_project_directory(repository_root):
|
|
275
|
+
return
|
|
276
|
+
if repository_origin_slug(repository_root) not in all_opted_in_slugs:
|
|
277
|
+
return
|
|
278
|
+
config = load_edit_lint_config(repository_root)
|
|
279
|
+
if config is None or file_path.suffix not in config.all_file_suffixes:
|
|
280
|
+
return
|
|
281
|
+
findings_text = lint_findings(config, repository_root, file_path)
|
|
282
|
+
if not findings_text:
|
|
283
|
+
return
|
|
284
|
+
try:
|
|
285
|
+
file_label = file_path.relative_to(repository_root.resolve()).as_posix()
|
|
286
|
+
except ValueError:
|
|
287
|
+
file_label = str(file_path)
|
|
288
|
+
write_post_tool_use_context_to_stdout(findings_context(file_label, findings_text))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__":
|
|
292
|
+
main()
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Behavior tests for the edit lint context hook.
|
|
2
|
+
|
|
3
|
+
Each test builds a disposable git repository whose origin points at a
|
|
4
|
+
github.com slug and whose ``.claude/edit-lint.json`` names a small lint script.
|
|
5
|
+
The script prints one finding for every line holding ``BAD``. The hook runs
|
|
6
|
+
through the PostToolUse dispatcher the way the live entry runs it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
advisory_directory = str(Path(__file__).resolve().parent)
|
|
21
|
+
if advisory_directory not in sys.path:
|
|
22
|
+
sys.path.insert(0, advisory_directory)
|
|
23
|
+
import edit_lint_context
|
|
24
|
+
except ImportError as import_error:
|
|
25
|
+
raise ImportError(
|
|
26
|
+
"test_edit_lint_context: cannot import its sibling modules; "
|
|
27
|
+
"ensure the advisory directory is importable."
|
|
28
|
+
) from import_error
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
32
|
+
DISPATCHER_PATH = HOOKS_DIRECTORY / "validation" / "post_tool_use_dispatcher.py"
|
|
33
|
+
HOOKS_JSON_PATH = HOOKS_DIRECTORY / "hooks.json"
|
|
34
|
+
ORIGIN_SLUG = "LintOwner/lint-repo"
|
|
35
|
+
ORIGIN_URL = f"https://github.com/{ORIGIN_SLUG}.git"
|
|
36
|
+
LINT_SCRIPT_SOURCE = (
|
|
37
|
+
"import sys\n"
|
|
38
|
+
"path = sys.argv[1]\n"
|
|
39
|
+
"for number, line in enumerate(open(path, encoding='utf-8'), 1):\n"
|
|
40
|
+
" if 'BAD' in line:\n"
|
|
41
|
+
" print(f'{path}:{number}: F821 undefined name')\n"
|
|
42
|
+
" if 'CRASH' in line:\n"
|
|
43
|
+
" sys.stderr.write('lint tool missing\\n')\n"
|
|
44
|
+
" sys.exit(3)\n"
|
|
45
|
+
)
|
|
46
|
+
DISPATCHER_TIMEOUT_SECONDS = 60
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _git_environment() -> dict[str, str]:
|
|
50
|
+
return {
|
|
51
|
+
each_name: each_value
|
|
52
|
+
for each_name, each_value in os.environ.items()
|
|
53
|
+
if not each_name.upper().startswith("GIT_")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_git(repository_root: Path, *all_arguments: str) -> None:
|
|
58
|
+
subprocess.run(
|
|
59
|
+
["git", *all_arguments],
|
|
60
|
+
cwd=repository_root,
|
|
61
|
+
check=True,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
env=_git_environment(),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def lint_repository(tmp_path: Path) -> Path:
|
|
69
|
+
repository_root = tmp_path / "lint-repo"
|
|
70
|
+
repository_root.mkdir()
|
|
71
|
+
_run_git(repository_root, "init", "--quiet")
|
|
72
|
+
_run_git(repository_root, "remote", "add", "origin", ORIGIN_URL)
|
|
73
|
+
(repository_root / "lint.py").write_text(LINT_SCRIPT_SOURCE, encoding="utf-8")
|
|
74
|
+
config_path = repository_root / ".claude" / "edit-lint.json"
|
|
75
|
+
config_path.parent.mkdir()
|
|
76
|
+
config_path.write_text(
|
|
77
|
+
json.dumps({"file_suffixes": [".py"], "command": [sys.executable, "lint.py", "{file}"]}),
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
)
|
|
80
|
+
return repository_root
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _run_dispatcher(
|
|
84
|
+
tool_name: str,
|
|
85
|
+
file_path: Path,
|
|
86
|
+
listed_repositories: str | None,
|
|
87
|
+
project_directory: Path | None = None,
|
|
88
|
+
) -> str:
|
|
89
|
+
environment = _git_environment()
|
|
90
|
+
environment["CLAUDE_PROJECT_DIR"] = str(project_directory or file_path.parents[-2])
|
|
91
|
+
environment.pop("CLAUDE_EDIT_LINT_REPOSITORIES", None)
|
|
92
|
+
if listed_repositories is not None:
|
|
93
|
+
environment["CLAUDE_EDIT_LINT_REPOSITORIES"] = listed_repositories
|
|
94
|
+
completed_process = subprocess.run(
|
|
95
|
+
[sys.executable, str(DISPATCHER_PATH)],
|
|
96
|
+
input=json.dumps({"tool_name": tool_name, "tool_input": {"file_path": str(file_path)}}),
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
env=environment,
|
|
100
|
+
timeout=DISPATCHER_TIMEOUT_SECONDS,
|
|
101
|
+
check=False,
|
|
102
|
+
)
|
|
103
|
+
assert completed_process.returncode == 0, completed_process.stderr
|
|
104
|
+
return completed_process.stdout.strip()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _additional_context(dispatcher_output: str) -> str:
|
|
108
|
+
return json.loads(dispatcher_output)["hookSpecificOutput"]["additionalContext"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def should_add_findings_after_an_edit_in_an_opted_in_repository(lint_repository: Path) -> None:
|
|
112
|
+
edited_file = lint_repository / "module.py"
|
|
113
|
+
edited_file.write_text("VALUE = 1\nRESULT = BAD\n", encoding="utf-8")
|
|
114
|
+
|
|
115
|
+
note = _additional_context(_run_dispatcher("Edit", edited_file, ORIGIN_SLUG.lower()))
|
|
116
|
+
|
|
117
|
+
assert "module.py:2: F821 undefined name" in note
|
|
118
|
+
assert note.startswith("The repository's lint found these problems in module.py")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def should_add_findings_after_a_write_and_a_multi_edit(lint_repository: Path) -> None:
|
|
122
|
+
edited_file = lint_repository / "module.py"
|
|
123
|
+
edited_file.write_text("RESULT = BAD\n", encoding="utf-8")
|
|
124
|
+
|
|
125
|
+
for each_tool_name in ("Write", "MultiEdit"):
|
|
126
|
+
note = _additional_context(_run_dispatcher(each_tool_name, edited_file, ORIGIN_SLUG))
|
|
127
|
+
assert "module.py:1: F821" in note
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def should_pass_the_lint_command_the_path_with_symlinks_resolved(
|
|
131
|
+
lint_repository: Path, tmp_path: Path
|
|
132
|
+
) -> None:
|
|
133
|
+
package_directory = lint_repository / "package"
|
|
134
|
+
package_directory.mkdir()
|
|
135
|
+
(package_directory / "module.py").write_text("RESULT = BAD\n", encoding="utf-8")
|
|
136
|
+
linked_directory = tmp_path / "linked-package"
|
|
137
|
+
linked_directory.symlink_to(package_directory, target_is_directory=True)
|
|
138
|
+
|
|
139
|
+
note = _additional_context(
|
|
140
|
+
_run_dispatcher("Edit", linked_directory / "module.py", ORIGIN_SLUG)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
assert f"{(package_directory / 'module.py').resolve()}:1: F821" in note
|
|
144
|
+
assert "linked-package" not in note
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def should_add_nothing_when_the_environment_lists_no_repository(lint_repository: Path) -> None:
|
|
148
|
+
edited_file = lint_repository / "module.py"
|
|
149
|
+
edited_file.write_text("RESULT = BAD\n", encoding="utf-8")
|
|
150
|
+
|
|
151
|
+
assert _run_dispatcher("Edit", edited_file, None) == ""
|
|
152
|
+
assert _run_dispatcher("Edit", edited_file, "other/repo") == ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def should_add_nothing_for_a_repository_outside_the_project_folder(
|
|
156
|
+
lint_repository: Path, tmp_path: Path
|
|
157
|
+
) -> None:
|
|
158
|
+
edited_file = lint_repository / "module.py"
|
|
159
|
+
edited_file.write_text("RESULT = BAD\n", encoding="utf-8")
|
|
160
|
+
other_project = tmp_path / "other-project"
|
|
161
|
+
other_project.mkdir()
|
|
162
|
+
|
|
163
|
+
assert _run_dispatcher("Edit", edited_file, ORIGIN_SLUG, other_project) == ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def should_accept_a_root_inside_the_project_folder(
|
|
167
|
+
lint_repository: Path, monkeypatch: pytest.MonkeyPatch
|
|
168
|
+
) -> None:
|
|
169
|
+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(lint_repository.parent))
|
|
170
|
+
assert edit_lint_context.is_inside_project_directory(lint_repository)
|
|
171
|
+
|
|
172
|
+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(lint_repository / "package"))
|
|
173
|
+
assert not edit_lint_context.is_inside_project_directory(lint_repository)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def should_add_nothing_after_a_clean_edit(lint_repository: Path) -> None:
|
|
177
|
+
edited_file = lint_repository / "module.py"
|
|
178
|
+
edited_file.write_text("VALUE = 1\n", encoding="utf-8")
|
|
179
|
+
|
|
180
|
+
assert _run_dispatcher("Edit", edited_file, ORIGIN_SLUG) == ""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def should_add_nothing_for_a_suffix_the_repository_does_not_lint(lint_repository: Path) -> None:
|
|
184
|
+
edited_file = lint_repository / "notes.md"
|
|
185
|
+
edited_file.write_text("BAD\n", encoding="utf-8")
|
|
186
|
+
|
|
187
|
+
assert _run_dispatcher("Edit", edited_file, ORIGIN_SLUG) == ""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def should_add_nothing_when_the_lint_command_fails(lint_repository: Path) -> None:
|
|
191
|
+
edited_file = lint_repository / "module.py"
|
|
192
|
+
edited_file.write_text("RESULT = BAD\nCRASH\n", encoding="utf-8")
|
|
193
|
+
|
|
194
|
+
assert _run_dispatcher("Edit", edited_file, ORIGIN_SLUG) == ""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def should_add_nothing_for_a_file_outside_a_git_repository(tmp_path: Path) -> None:
|
|
198
|
+
loose_file = tmp_path / "loose.py"
|
|
199
|
+
loose_file.write_text("RESULT = BAD\n", encoding="utf-8")
|
|
200
|
+
|
|
201
|
+
assert _run_dispatcher("Edit", loose_file, ORIGIN_SLUG) == ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def should_register_the_dispatcher_for_edit_write_and_multi_edit() -> None:
|
|
205
|
+
registrations = json.loads(HOOKS_JSON_PATH.read_text(encoding="utf-8"))
|
|
206
|
+
all_matchers = [
|
|
207
|
+
each_group["matcher"]
|
|
208
|
+
for each_group in registrations["hooks"]["PostToolUse"]
|
|
209
|
+
if any(
|
|
210
|
+
"post_tool_use_dispatcher.py" in each_hook["command"]
|
|
211
|
+
for each_hook in each_group["hooks"]
|
|
212
|
+
)
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
assert all_matchers == ["Write|Edit|MultiEdit"]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def should_read_listed_slugs_lowercased(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
219
|
+
monkeypatch.setenv("CLAUDE_EDIT_LINT_REPOSITORIES", " Owner/One , ,owner/two")
|
|
220
|
+
|
|
221
|
+
assert edit_lint_context.opted_in_repository_slugs() == frozenset({"owner/one", "owner/two"})
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def should_find_the_work_tree_root_of_a_nested_file(lint_repository: Path) -> None:
|
|
225
|
+
nested_directory = lint_repository / "package"
|
|
226
|
+
nested_directory.mkdir()
|
|
227
|
+
nested_file = nested_directory / "inner.py"
|
|
228
|
+
nested_file.write_text("VALUE = 1\n", encoding="utf-8")
|
|
229
|
+
|
|
230
|
+
found_root = edit_lint_context.repository_root_of(nested_file)
|
|
231
|
+
|
|
232
|
+
assert found_root is not None
|
|
233
|
+
assert found_root.resolve() == lint_repository.resolve()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def should_reject_a_config_without_a_command(lint_repository: Path) -> None:
|
|
237
|
+
config_path = lint_repository / ".claude" / "edit-lint.json"
|
|
238
|
+
config_path.write_text(json.dumps({"file_suffixes": [".py"], "command": []}), encoding="utf-8")
|
|
239
|
+
|
|
240
|
+
assert edit_lint_context.load_edit_lint_config(lint_repository) is None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def should_load_the_suffixes_and_command(lint_repository: Path) -> None:
|
|
244
|
+
config = edit_lint_context.load_edit_lint_config(lint_repository)
|
|
245
|
+
|
|
246
|
+
assert config == edit_lint_context.EditLintConfig(
|
|
247
|
+
(".py",), (sys.executable, "lint.py", "{file}")
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def should_return_the_lint_output_for_one_file(lint_repository: Path) -> None:
|
|
252
|
+
edited_file = lint_repository / "module.py"
|
|
253
|
+
edited_file.write_text("RESULT = BAD\n", encoding="utf-8")
|
|
254
|
+
config = edit_lint_context.load_edit_lint_config(lint_repository)
|
|
255
|
+
assert config is not None
|
|
256
|
+
|
|
257
|
+
assert edit_lint_context.lint_findings(config, lint_repository, edited_file) == (
|
|
258
|
+
f"{edited_file}:1: F821 undefined name"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def should_cut_long_findings_to_the_limit() -> None:
|
|
263
|
+
long_findings = "x" * 5000
|
|
264
|
+
|
|
265
|
+
note = edit_lint_context.findings_context("module.py", long_findings)
|
|
266
|
+
|
|
267
|
+
assert "x" * 4000 in note
|
|
268
|
+
assert "x" * 4001 not in note
|
|
269
|
+
assert note.endswith("[edit lint output cut at 4000 characters]")
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
# Post-write validation
|
|
2
2
|
|
|
3
|
-
This family checks a completed Write or
|
|
3
|
+
This family checks a completed Write, Edit or MultiEdit call, formats eligible new source files, and runs an opted-in repository's lint on the edited file. The agent receives a combined block decision if a hosted check blocks.
|
|
4
4
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
7
7
|
- `validation/post_tool_use_dispatcher.py` runs the post-write roster in order and combines any block reasons.
|
|
8
8
|
- `workflow/auto_formatter.py` formats eligible untracked source files after a Write and never blocks the write.
|
|
9
|
+
- `advisory/edit_lint_context.py` runs the lint command a repository names in `.claude/edit-lint.json` on the edited file and adds its findings to the agent's context. It runs only for a repository whose origin `owner/repo` slug is listed in `CLAUDE_EDIT_LINT_REPOSITORIES`, comma-separated, and whose root sits inside `CLAUDE_PROJECT_DIR`, the folder the session's workspace trust covers. It never blocks and never edits the file.
|
|
9
10
|
|
|
10
11
|
## When it fires
|
|
11
12
|
|
|
12
|
-
- `validation/post_tool_use_dispatcher.py` runs on `PostToolUse`, matcher `Write|Edit`, timeout `180` seconds in `hooks.json`.
|
|
13
|
-
- `workflow/auto_formatter.py` runs inside that dispatcher on `PostToolUse`, matcher `Write|Edit`, timeout `180` seconds, as named by `ALL_POST_HOSTED_HOOK_ENTRIES`. Its eligibility check accepts only a Write of an untracked source file.
|
|
13
|
+
- `validation/post_tool_use_dispatcher.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit`, timeout `180` seconds in `hooks.json`.
|
|
14
|
+
- `workflow/auto_formatter.py` runs inside that dispatcher on `PostToolUse`, matcher `Write|Edit|MultiEdit`, timeout `180` seconds, as named by `ALL_POST_HOSTED_HOOK_ENTRIES`. Its eligibility check accepts only a Write of an untracked source file.
|
|
15
|
+
- `advisory/edit_lint_context.py` runs inside that dispatcher after the formatter, so it lints the file as the formatter left it.
|
|
14
16
|
|
|
15
17
|
## Proving it
|
|
16
18
|
|
|
@@ -19,10 +21,12 @@ Preconditions:
|
|
|
19
21
|
- Run each command from the repository root with Python and pytest available. The formatter tests make disposable repositories.
|
|
20
22
|
|
|
21
23
|
- **Post-write dispatch.** Input is an Edit of plain text. Run `python -m pytest packages/claude-dev-env/hooks/validation/test_post_tool_use_dispatcher.py -q`. The adjacent test observes an allow result and the hosted formatter's selection.
|
|
24
|
+
- **Edit lint context.** Input is an Edit, Write or MultiEdit of a file in a disposable repository that lists a lint script. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_edit_lint_context.py -q`. The adjacent test observes the findings note for an opted-in repository and no output for a clean edit, an unlisted repository, an unlisted suffix, or a failed lint command.
|
|
22
25
|
- **Formatter eligibility.** Input is a Write of an untracked source file. Run `python -m pytest packages/claude-dev-env/hooks/workflow/test_auto_formatter.py -q`. The adjacent test observes formatting for eligible writes and no edit of a tracked file.
|
|
23
26
|
|
|
24
27
|
## Gotchas
|
|
25
28
|
|
|
26
|
-
- The current post-write roster contains
|
|
29
|
+
- The current post-write roster contains `workflow/auto_formatter.py` and `advisory/edit_lint_context.py`. The dispatcher's docstring mentions other hosted work from an older roster, so use the roster for membership.
|
|
27
30
|
- The formatter can change the file after the original Write. A later hosted script would read the formatted file because the dispatcher preserves roster order.
|
|
28
31
|
- Formatter eligibility protects the hook tree and checks whether the file is tracked.
|
|
32
|
+
- The edit lint command's standard output is the findings. A repository's command prints nothing on a clean file and sends progress lines to stderr. A non-zero exit adds no note and writes one diagnostic line to stderr, so the command runs the lint in a mode that exits 0 when it reports findings.
|
package/hooks/hooks.json
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Constants for the PostToolUse hook that lints each edited file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"REPOSITORIES_ENVIRONMENT_VARIABLE",
|
|
7
|
+
"REPOSITORY_SEPARATOR",
|
|
8
|
+
"PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE",
|
|
9
|
+
"CONFIG_RELATIVE_PATH",
|
|
10
|
+
"FILE_SUFFIXES_KEY",
|
|
11
|
+
"COMMAND_KEY",
|
|
12
|
+
"FILE_PLACEHOLDER",
|
|
13
|
+
"ALL_EDIT_TOOL_NAMES",
|
|
14
|
+
"ALL_GIT_TOPLEVEL_COMMAND",
|
|
15
|
+
"GIT_COMMAND_TIMEOUT_SECONDS",
|
|
16
|
+
"LINT_COMMAND_TIMEOUT_SECONDS",
|
|
17
|
+
"MAXIMUM_FINDINGS_CHARACTERS",
|
|
18
|
+
"TRUNCATION_NOTICE",
|
|
19
|
+
"FINDINGS_CONTEXT_TEMPLATE",
|
|
20
|
+
"LINT_FAILURE_DIAGNOSTIC_TEMPLATE",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
REPOSITORIES_ENVIRONMENT_VARIABLE = "CLAUDE_EDIT_LINT_REPOSITORIES"
|
|
24
|
+
REPOSITORY_SEPARATOR = ","
|
|
25
|
+
PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE = "CLAUDE_PROJECT_DIR"
|
|
26
|
+
CONFIG_RELATIVE_PATH = ".claude/edit-lint.json"
|
|
27
|
+
FILE_SUFFIXES_KEY = "file_suffixes"
|
|
28
|
+
COMMAND_KEY = "command"
|
|
29
|
+
FILE_PLACEHOLDER = "{file}"
|
|
30
|
+
ALL_EDIT_TOOL_NAMES = frozenset({"Write", "Edit", "MultiEdit"})
|
|
31
|
+
ALL_GIT_TOPLEVEL_COMMAND = ("git", "rev-parse", "--show-toplevel")
|
|
32
|
+
GIT_COMMAND_TIMEOUT_SECONDS = 10
|
|
33
|
+
LINT_COMMAND_TIMEOUT_SECONDS = 60
|
|
34
|
+
MAXIMUM_FINDINGS_CHARACTERS = 4000
|
|
35
|
+
TRUNCATION_NOTICE = "\n[edit lint output cut at {limit} characters]"
|
|
36
|
+
FINDINGS_CONTEXT_TEMPLATE = (
|
|
37
|
+
"The repository's lint found these problems in {file} after this edit. "
|
|
38
|
+
"Fix them in this turn:\n{findings}"
|
|
39
|
+
)
|
|
40
|
+
LINT_FAILURE_DIAGNOSTIC_TEMPLATE = "[edit_lint_context] lint command for %s did not finish: %s\n"
|
|
@@ -366,4 +366,5 @@ def test_live_roster_contains_only_nonblocking_hooks() -> None:
|
|
|
366
366
|
assert all(not each_entry.is_blocking for each_entry in ALL_POST_HOSTED_HOOK_ENTRIES)
|
|
367
367
|
assert tuple(each_entry.script_relative_path for each_entry in ALL_POST_HOSTED_HOOK_ENTRIES) == (
|
|
368
368
|
"workflow/auto_formatter.py",
|
|
369
|
+
"advisory/edit_lint_context.py",
|
|
369
370
|
)
|