claude-dev-env 8.4.1 → 8.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.4.1",
3
+ "version": "8.4.3",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,8 +32,15 @@ $scriptsRoot = Split-Path -Parent (Split-Path -Parent $entrypoint)
32
32
  $exitCode = 1
33
33
  Push-Location $scriptsRoot
34
34
  try {
35
- & bun $entrypoint @commandArguments
36
- $exitCode = $LASTEXITCODE
35
+ & bun -e 'import { ensureDependenciesInstalled } from "./bootstrap.ts"; ensureDependenciesInstalled();'
36
+ $bootstrapExitCode = $LASTEXITCODE
37
+ if ($bootstrapExitCode -ne 0) {
38
+ Write-Error "The pstack dependencies could not be installed"
39
+ $exitCode = $bootstrapExitCode
40
+ } else {
41
+ & bun $entrypoint @commandArguments
42
+ $exitCode = $LASTEXITCODE
43
+ }
37
44
  }
38
45
  finally {
39
46
  Pop-Location
@@ -1,81 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Stop-hook dispatcher that hosts the Stop-chain blockers in one process.
3
-
4
- Reads the Stop payload from stdin once, runs each hosted hook in registration
5
- order via the shared hosted-hook runner, and emits the first block decision the
6
- chain produced. Later hooks still run after an earlier block so every hosted
7
- hook gets a chance to run. A single hosted hook crash fails open and does not
8
- suppress the remaining hooks.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import json
14
- import sys
15
-
16
- import _path_setup # noqa: F401
17
-
18
- from hooks_constants.hosted_hook_runner import (
19
- run_hook_capturing_output,
20
- HostedHookRun,
21
- resolved_hook_script_path,
22
- )
23
- from hooks_constants.stop_dispatcher_constants import (
24
- ALL_STOP_HOSTED_HOOK_PATHS,
25
- BLOCK_DECISION,
26
- DECISION_KEY,
27
- )
28
-
29
-
30
- def _is_block_decision(stdout_text: str) -> bool:
31
- """Return True when stdout carries a Stop decision of block."""
32
- stripped_text = stdout_text.strip()
33
- if not stripped_text:
34
- return False
35
- try:
36
- parsed_output = json.loads(stripped_text)
37
- except json.JSONDecodeError:
38
- return False
39
- if not isinstance(parsed_output, dict):
40
- return False
41
- return parsed_output.get(DECISION_KEY) == BLOCK_DECISION
42
-
43
-
44
- def select_first_block_stdout(all_runs: list[HostedHookRun]) -> str:
45
- """Return the first non-crashed block stdout, or empty when none blocked."""
46
- for each_run in all_runs:
47
- if each_run.did_crash:
48
- continue
49
- if _is_block_decision(each_run.captured_stdout):
50
- return each_run.captured_stdout.strip()
51
- return ""
52
-
53
-
54
- def dispatch(payload_text: str) -> None:
55
- """Run every Stop hosted hook and emit the first block decision if any."""
56
- all_runs: list[HostedHookRun] = []
57
- for each_relative_path in ALL_STOP_HOSTED_HOOK_PATHS:
58
- script_path = resolved_hook_script_path(each_relative_path)
59
- hook_run = run_hook_capturing_output(script_path, payload_text)
60
- all_runs.append(hook_run)
61
- block_stdout = select_first_block_stdout(all_runs)
62
- if block_stdout:
63
- sys.stdout.write(block_stdout + "\n")
64
- sys.stdout.flush()
65
-
66
-
67
- def main() -> None:
68
- """Read stdin once and dispatch the Stop hosted-hook chain."""
69
- payload_text = sys.stdin.read()
70
- if not payload_text.strip():
71
- sys.exit(0)
72
- try:
73
- json.loads(payload_text)
74
- except json.JSONDecodeError:
75
- sys.exit(0)
76
- dispatch(payload_text)
77
- sys.exit(0)
78
-
79
-
80
- if __name__ == "__main__":
81
- main()
@@ -1,165 +0,0 @@
1
- """Behavior tests for the Stop-hook dispatcher."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- import subprocess
8
- import sys
9
- from pathlib import Path
10
-
11
- import pytest
12
-
13
- _HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
14
- _BLOCKING_DIR = Path(__file__).resolve().parent
15
- if _HOOKS_DIR not in sys.path:
16
- sys.path.insert(0, _HOOKS_DIR)
17
- _blocking_dir_text = str(_BLOCKING_DIR)
18
- if _blocking_dir_text not in sys.path:
19
- sys.path.insert(0, _blocking_dir_text)
20
-
21
- from hooks_constants.hosted_hook_runner import HostedHookRun # noqa: E402
22
- from stop_dispatcher import dispatch, select_first_block_stdout # noqa: E402
23
-
24
- _DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "stop_dispatcher.py")
25
-
26
-
27
- def _block_json(
28
- reason: str, *, system_message: str = "", suppress_output: bool = False
29
- ) -> str:
30
- """Build a Stop block stdout string with optional supplementary fields."""
31
- payload: dict[str, object] = {"decision": "block", "reason": reason}
32
- if system_message:
33
- payload["systemMessage"] = system_message
34
- if suppress_output:
35
- payload["suppressOutput"] = True
36
- return json.dumps(payload)
37
-
38
-
39
- def test_first_block_wins_among_non_crashed_runs() -> None:
40
- """The first non-crashed block decision is the one the dispatcher emits."""
41
- all_runs = [
42
- HostedHookRun(captured_stdout="", did_crash=False),
43
- HostedHookRun(captured_stdout=_block_json("first"), did_crash=False),
44
- HostedHookRun(captured_stdout=_block_json("second"), did_crash=False),
45
- ]
46
- selected = select_first_block_stdout(all_runs)
47
- assert json.loads(selected)["reason"] == "first"
48
-
49
-
50
- def test_crashed_hook_before_block_is_skipped() -> None:
51
- """A crash before a block fails open so a later block still surfaces."""
52
- all_runs = [
53
- HostedHookRun(captured_stdout="", did_crash=True),
54
- HostedHookRun(captured_stdout=_block_json("later"), did_crash=False),
55
- ]
56
- selected = select_first_block_stdout(all_runs)
57
- assert json.loads(selected)["reason"] == "later"
58
-
59
-
60
- def test_no_block_when_every_hook_is_silent() -> None:
61
- """A silent chain produces no block stdout."""
62
- assert select_first_block_stdout([HostedHookRun("", False), HostedHookRun("", False)]) == ""
63
-
64
-
65
- def test_select_preserves_system_message_and_suppress_output() -> None:
66
- """select_first_block_stdout keeps supplementary fields on the winning block."""
67
- all_runs = [
68
- HostedHookRun(captured_stdout="", did_crash=True),
69
- HostedHookRun(
70
- captured_stdout=_block_json(
71
- "keep fields",
72
- system_message="rewrite the ending",
73
- suppress_output=True,
74
- ),
75
- did_crash=False,
76
- ),
77
- ]
78
- selected = json.loads(select_first_block_stdout(all_runs))
79
- assert selected["reason"] == "keep fields"
80
- assert selected["systemMessage"] == "rewrite the ending"
81
- assert selected["suppressOutput"] is True
82
-
83
-
84
- def test_dispatch_re_emits_first_block_with_system_message_and_suppress(
85
- monkeypatch: pytest.MonkeyPatch,
86
- capsys: pytest.CaptureFixture[str],
87
- ) -> None:
88
- """dispatch re-emits the first block's full JSON including supplementary fields."""
89
- all_responses = [
90
- HostedHookRun(captured_stdout="", did_crash=False),
91
- HostedHookRun(
92
- captured_stdout=_block_json(
93
- "keep fields",
94
- system_message="rewrite the ending",
95
- suppress_output=True,
96
- ),
97
- did_crash=False,
98
- ),
99
- HostedHookRun(captured_stdout=_block_json("later block"), did_crash=False),
100
- ]
101
- response_iterator = iter(all_responses)
102
-
103
- def _fake_run_hook(script_path: str, payload_text: str) -> HostedHookRun:
104
- del script_path, payload_text
105
- return next(response_iterator)
106
-
107
- monkeypatch.setattr("stop_dispatcher.run_hook_capturing_output", _fake_run_hook)
108
- monkeypatch.setattr(
109
- "stop_dispatcher.ALL_STOP_HOSTED_HOOK_PATHS",
110
- (
111
- "blocking/first_stub_hook.py",
112
- "blocking/question_to_user_enforcer.py",
113
- "blocking/second_stub_hook.py",
114
- ),
115
- )
116
- dispatch('{"session_id": "test"}')
117
- emitted_payload = json.loads(capsys.readouterr().out.strip())
118
- assert emitted_payload["decision"] == "block"
119
- assert emitted_payload["reason"] == "keep fields"
120
- assert emitted_payload["systemMessage"] == "rewrite the ending"
121
- assert emitted_payload["suppressOutput"] is True
122
-
123
-
124
- def test_dispatch_writes_nothing_when_no_hook_blocks(
125
- monkeypatch: pytest.MonkeyPatch,
126
- capsys: pytest.CaptureFixture[str],
127
- ) -> None:
128
- """dispatch leaves stdout empty when every hosted hook is silent or allows."""
129
- monkeypatch.setattr(
130
- "stop_dispatcher.run_hook_capturing_output",
131
- lambda script_path, payload_text: HostedHookRun(captured_stdout="", did_crash=False),
132
- )
133
- monkeypatch.setattr(
134
- "stop_dispatcher.ALL_STOP_HOSTED_HOOK_PATHS",
135
- ("blocking/first_stub_hook.py",),
136
- )
137
- dispatch('{"session_id": "test"}')
138
- assert capsys.readouterr().out == ""
139
-
140
-
141
- def test_dispatcher_exits_zero_on_empty_payload() -> None:
142
- """The dispatcher exits zero when stdin is empty."""
143
- completed = subprocess.run(
144
- [sys.executable, _DISPATCHER_SCRIPT],
145
- check=False,
146
- input="",
147
- capture_output=True,
148
- text=True,
149
- )
150
- assert completed.returncode == 0
151
- assert completed.stdout.strip() == ""
152
-
153
-
154
- def test_dispatcher_imports_standalone_with_only_blocking_on_the_path() -> None:
155
- """The dispatcher's bootstrap resolves hooks_constants without hooks/ on PYTHONPATH."""
156
- subprocess_environment = {**os.environ, "PYTHONPATH": str(_BLOCKING_DIR)}
157
- completed = subprocess.run(
158
- [sys.executable, "-c", "import stop_dispatcher; print('ok')"],
159
- check=False,
160
- capture_output=True,
161
- text=True,
162
- env=subprocess_environment,
163
- )
164
- assert completed.returncode == 0, completed.stderr
165
- assert completed.stdout.strip() == "ok"
@@ -1,20 +0,0 @@
1
- """Constants for the Stop-hook dispatcher.
2
-
3
- Holds the ordered hosted-hook roster for the Stop chain. The dispatcher imports
4
- this roster to run every Stop hook in registration order inside one process.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- __all__ = [
10
- "ALL_STOP_HOSTED_HOOK_PATHS",
11
- "BLOCK_DECISION",
12
- "DECISION_KEY",
13
- "REASON_KEY",
14
- ]
15
-
16
- BLOCK_DECISION = "block"
17
- DECISION_KEY = "decision"
18
- REASON_KEY = "reason"
19
-
20
- ALL_STOP_HOSTED_HOOK_PATHS: tuple[str, ...] = ()
@@ -1,7 +0,0 @@
1
- """Tests for the Stop-hook dispatcher hosted-hook roster."""
2
-
3
- from hooks_constants.stop_dispatcher_constants import ALL_STOP_HOSTED_HOOK_PATHS
4
-
5
-
6
- def test_roster_has_no_blocking_hooks() -> None:
7
- assert ALL_STOP_HOSTED_HOOK_PATHS == ()
@@ -1,59 +0,0 @@
1
- import { writeFileSync } from "node:fs";
2
- import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
-
5
- import { auditHooks } from "./audit-hooks.mjs";
6
-
7
- function parseArguments(argv) {
8
- const options = {
9
- catalogPath: null,
10
- format: "text",
11
- homeDirectory: null,
12
- includeInstalled: false,
13
- outputPath: null,
14
- requireNonblocking: false,
15
- repositoryRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."),
16
- };
17
- for (let offset = 0; offset < argv.length; offset += 1) {
18
- const argument = argv[offset];
19
- if (argument === "--installed") options.includeInstalled = true;
20
- else if (argument === "--catalog") options.catalogPath = argv[++offset];
21
- else if (argument === "--format") options.format = argv[++offset];
22
- else if (argument === "--home") options.homeDirectory = argv[++offset];
23
- else if (argument === "--output") options.outputPath = argv[++offset];
24
- else if (argument === "--require-nonblocking") options.requireNonblocking = true;
25
- else if (argument === "--repository-root") options.repositoryRoot = argv[++offset];
26
- else throw new Error(`unknown argument ${argument}`);
27
- }
28
- return options;
29
- }
30
-
31
- function renderText(report) {
32
- const summary = report.summary;
33
- return [
34
- `canonical direct: ${summary.directCount}`,
35
- `dispatchers: ${summary.dispatcherCount}`,
36
- `hosted entries: ${summary.effectiveCount}`,
37
- `logical associations: ${summary.logicalAssociationCount}`,
38
- `unclassified targets: ${summary.unclassifiedCount}`,
39
- `installed Claude direct: ${summary.claudeDirectCount}`,
40
- `installed Codex direct: ${summary.codexDirectCount}`,
41
- `active native Git hooks: ${summary.gitHookCount}`,
42
- `known native Git hooks: ${summary.knownGitHookCount}`,
43
- `registration eligibility: ${report.registrationEligibility.status}`,
44
- "runtime proof: separate verification required",
45
- `findings: ${summary.findingCount}`,
46
- ].join("\n");
47
- }
48
-
49
- function main() {
50
- const options = parseArguments(process.argv.slice(2));
51
- const report = auditHooks(options);
52
- const rendered =
53
- options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : `${renderText(report)}\n`;
54
- if (options.outputPath) writeFileSync(options.outputPath, rendered, "utf8");
55
- else process.stdout.write(rendered);
56
- process.exitCode = report.findings.some(({ severity }) => severity === "error") ? 1 : 0;
57
- }
58
-
59
- main();