claude-dev-env 1.36.0 → 1.36.2

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.
Files changed (42) hide show
  1. package/_shared/pr-loop/audit-contract.md +159 -0
  2. package/_shared/pr-loop/code-rules-gate.md +64 -0
  3. package/_shared/pr-loop/fix-protocol.md +37 -0
  4. package/_shared/pr-loop/gh-payloads.md +85 -0
  5. package/_shared/pr-loop/scripts/README.md +20 -0
  6. package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
  8. package/_shared/pr-loop/scripts/config/__init__.py +0 -0
  9. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
  10. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
  11. package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
  12. package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
  13. package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
  14. package/_shared/pr-loop/scripts/config/preflight_constants.py +47 -0
  15. package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
  16. package/_shared/pr-loop/scripts/gh_util.py +193 -0
  17. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
  18. package/_shared/pr-loop/scripts/preflight.py +227 -0
  19. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
  20. package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
  21. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
  22. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
  23. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
  24. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
  25. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
  27. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
  28. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
  29. package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
  30. package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
  31. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
  32. package/_shared/pr-loop/scripts/tests/test_preflight.py +333 -0
  33. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +82 -0
  34. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
  35. package/_shared/pr-loop/state-schema.md +81 -0
  36. package/package.json +2 -1
  37. package/skills/bugteam/SKILL.md +332 -108
  38. package/skills/bugteam/scripts/reflow_skill_md.py +298 -0
  39. package/skills/bugteam/test_team_lifecycle.py +9 -0
  40. package/skills/pr-converge/SKILL.md +1005 -395
  41. package/skills/pr-converge/scripts/reflow_skill_md.py +288 -0
  42. package/skills/pr-converge/test_team_lifecycle.py +9 -0
@@ -0,0 +1,159 @@
1
+ # Audit contract
2
+
3
+ Shared output schema and audit-loop contract used by `/bugteam`, `/qbug`, `/findbugs`, and `/fixbugs`. Changing a shape here is a breaking change for every consuming skill.
4
+
5
+ ## Contents
6
+
7
+ - Finding schema (Shape A, Shape B)
8
+ - Adversarial second pass
9
+ - Haiku secondary auditor
10
+ - Post-fix self-audit
11
+ - Persistence (loop-N-audit.json, loop-N-diagnostics.json)
12
+
13
+ ## Finding schema
14
+
15
+ Each finding an audit produces MUST be one of exactly two shapes.
16
+
17
+ ### Shape A — structured finding
18
+
19
+ ```json
20
+ {
21
+ "id": "loop<N>-<K>",
22
+ "file": "path/relative/to/repo/root.py",
23
+ "line": 123,
24
+ "category": "A | B | C | D | E | F | G | H | I | J",
25
+ "severity": "P0 | P1 | P2",
26
+ "excerpt": "verbatim code snippet from the offending line(s)",
27
+ "failure_mode": "one sentence describing what goes wrong and when",
28
+ "evidence_files": ["additional/files/opened.py"]
29
+ }
30
+ ```
31
+
32
+ `id` is `loop<N>-<K>` where `N` is the loop counter (1-based) and `K` is the 1-based index within the loop. For `/findbugs` which runs once, use `find<K>`.
33
+
34
+ ### Shape B — structured proof-of-absence
35
+
36
+ Used when an audit investigates a category and does NOT find a bug. Bare "verified clean" claims are REJECTED because they hide shallow reading.
37
+
38
+ ```json
39
+ {
40
+ "category": "A | B | C | D | E | F | G | H | I | J",
41
+ "files_opened": ["file1.py", "file2.py"],
42
+ "lines_quoted": [
43
+ {"file": "file1.py", "line": 88, "text": "verbatim line content"}
44
+ ],
45
+ "adversarial_probes": [
46
+ "what failure mode was tested for and how it was ruled out"
47
+ ]
48
+ }
49
+ ```
50
+
51
+ Every category an audit touches MUST have either at least one Shape A finding OR at least one Shape B proof-of-absence entry. A category with neither is a protocol violation.
52
+
53
+ ### Example — Shape A
54
+
55
+ ```json
56
+ {
57
+ "id": "loop1-1",
58
+ "file": "scripts/db/neon.py",
59
+ "line": 43,
60
+ "category": "C",
61
+ "severity": "P1",
62
+ "excerpt": "load_dotenv(env_path, override=False)",
63
+ "failure_mode": "Called on every connect() — repeats file I/O per connection in scripts that open multiple short-lived connections.",
64
+ "evidence_files": ["scripts/db/neon.py", "scripts/update_new_releases.py"]
65
+ }
66
+ ```
67
+
68
+ ### Example — Shape B
69
+
70
+ ```json
71
+ {
72
+ "category": "H",
73
+ "files_opened": ["scripts/db/neon.py", "scripts/db/config.py"],
74
+ "lines_quoted": [
75
+ {"file": "scripts/db/neon.py", "line": 30, "text": "dsn = os.environ.get(\"DATABASE_URL\")"}
76
+ ],
77
+ "adversarial_probes": [
78
+ "Checked whether DATABASE_URL is interpolated into a shell — it is passed to psycopg.connect() directly with no shell involvement.",
79
+ "Checked whether the env path is user-controlled — it is derived from a fixed Y: drive constant, not user input."
80
+ ]
81
+ }
82
+ ```
83
+
84
+ ## Adversarial second pass
85
+
86
+ After the primary finding list is complete, every audit runs a second pass against itself with the prompt:
87
+
88
+ > Assume your first pass missed at least 3 P1 bugs. Where are they?
89
+
90
+ The audit must either produce new Shape A findings citing new file:line references not present in the first pass, or cite explicit Shape B adversarial-probe entries for each category it re-examined. An adversarial pass that returns "nothing new, confident first pass was complete" is REJECTED — produce evidence or findings, not confidence.
91
+
92
+ ## Haiku secondary auditor
93
+
94
+ For single-subagent skills (`/qbug`, `/findbugs`) the LEAD spawns two `Agent()` calls in one message:
95
+
96
+ - **Primary** — `subagent_type=clean-coder`, `model=sonnet` (for qbug cycle) or `subagent_type=code-quality-agent`, `model=sonnet` (for findbugs clean-room).
97
+ - **Secondary (Haiku)** — `subagent_type=code-quality-agent`, `model=haiku`, same self-contained clean-room prompt shape used by `/findbugs`.
98
+
99
+ Both audit the same diff. The secondary returns findings to the LEAD only — never posted to the PR.
100
+
101
+ Merge rules:
102
+
103
+ - **De-dup key**: `(file, line, category)`.
104
+ - **Severity conflict**: max wins (P0 > P1 > P2).
105
+ - **Unique-to-Haiku findings**: added to the primary set with Haiku's severity and source annotation.
106
+ - **Unique-to-primary findings**: kept as-is.
107
+ - **Zero Haiku findings**: primary set trusted; proceed.
108
+ - **Malformed or non-parseable Haiku output**: lead trusts the primary set, logs the event in `loop-<N>-diagnostics.json` under `haiku_findings` as `[{"parse_error": "<message>"}]`.
109
+
110
+ For multi-subagent skills (`/bugteam`) the parallel-auditors pattern in [`audit-and-teammates.md`](audit-and-teammates.md) already provides cross-model coverage via the three variant teammates.
111
+
112
+ ## Post-fix self-audit
113
+
114
+ Audit-and-fix skills (`/qbug`, `/bugteam`) MUST re-audit modified files between `py_compile` and `git add`. This catches fix-induced regressions in the same loop that introduced them rather than on loop N+1.
115
+
116
+ Sequence:
117
+
118
+ 1. Capture pre-fix file contents for every file this FIX will touch.
119
+ 2. Apply edits.
120
+ 3. Run `py_compile` (or language-equivalent) on each modified file.
121
+ 4. Compute `fix_diff` against pre-fix contents for the modified set.
122
+ 5. Run `bugteam_code_rules_gate.py` with explicit paths for every modified file.
123
+ 6. Spawn a scoped audit of `fix_diff` with full A–J rigor, Shape A/B contract, adversarial pass, AND Haiku secondary in parallel (paranoid mode on post-fix).
124
+ 7. Any new findings become same-loop fix-targets. Internal iteration count increments by one.
125
+ 8. After 3 internal iterations with fresh findings each time, exit `stuck: post-fix audit not converging`.
126
+ 9. Only when `gate_findings` empty AND `post_fix_findings` empty: `git add`, commit, push.
127
+
128
+ `converged` exit condition: `primary_audit_clean AND post_fix_audit_clean` for the committing loop.
129
+
130
+ ## Persistence
131
+
132
+ Every audit loop writes two JSON files under the skill's scoped temp directory (resolved via `tempfile.gettempdir()`):
133
+
134
+ ### `loop-<N>-audit.json`
135
+
136
+ ```json
137
+ {
138
+ "findings": [],
139
+ "proof_of_absence": [],
140
+ "source": "primary | haiku | adversarial | merged"
141
+ }
142
+ ```
143
+
144
+ ### `loop-<N>-diagnostics.json`
145
+
146
+ ```json
147
+ {
148
+ "loop": 1,
149
+ "gate_findings": [],
150
+ "primary_findings": [],
151
+ "adversarial_findings": [],
152
+ "haiku_findings": [],
153
+ "post_fix_findings": [],
154
+ "merged": [],
155
+ "deduped": []
156
+ }
157
+ ```
158
+
159
+ All eight keys MUST be present. Missing keys break convergence debugging.
@@ -0,0 +1,64 @@
1
+ # CODE_RULES gate
2
+
3
+ Pre-audit validator run before each AUDIT (and pre-commit when applicable). Wraps `validate_content` from `~/.claude/hooks/blocking/code_rules_enforcer.py`.
4
+
5
+ ## Script location
6
+
7
+ Canonical: `~/.claude/_shared/pr-loop/scripts/code_rules_gate.py` after install.
8
+
9
+ Workflows reference it via:
10
+ - all skills (bugteam, qbug, pr-converge, monitor-many): `${CLAUDE_SKILL_DIR}/../../_shared/pr-loop/scripts/code_rules_gate.py`
11
+
12
+ Cross-skill path traversal works because every skill installs as a flat top-level dir under `~/.claude/skills/` and `_shared/` lives alongside `skills/` under `~/.claude/`.
13
+
14
+ ## Invocation
15
+
16
+ Default mode (full PR diff against base):
17
+ ```bash
18
+ python "<gate_script>" --base origin/<base_branch>
19
+ ```
20
+
21
+ Staged mode (pre-commit subset):
22
+ ```bash
23
+ python "<gate_script>" --staged
24
+ ```
25
+
26
+ Path-scoped mode:
27
+ ```bash
28
+ python "<gate_script>" --base origin/<base_branch> --only-under <prefix> [--only-under <other_prefix>]
29
+ ```
30
+
31
+ ## Exit codes
32
+
33
+ - `0` — no violations on changed lines (advisories on touched-but-unchanged lines may still print)
34
+ - `1` — at least one blocking violation on changed lines; halt
35
+ - `2` — gate execution error (missing enforcer, git failure, malformed diff)
36
+
37
+ ## Workflow integration
38
+
39
+ - **bugteam:** before every AUDIT in Step 3 of the loop. Non-zero → spawn clean-coder for standards fix; loop until exit 0 or 5 consecutive gate failures → `error: code rules gate failed pre-audit`.
40
+ - **qbug:** before every AUDIT inside the subagent's loop. Three consecutive failures → `error: code rules gate failed pre-audit`.
41
+ - **pr-converge:** before every push in the Fix protocol. Halt on non-zero, fix violations, re-run.
42
+ - **monitor-many:** before every commit during the fix loop. Halt on non-zero, fix violations, re-run.
43
+
44
+ ## Coverage
45
+
46
+ - File-global UPPER_SNAKE constants (must live in `config/` outside exempt path families)
47
+ - Magic values in production function bodies (literals other than 0, 1, -1)
48
+ - Imports outside top of module
49
+ - New comments in production code (existing comments preserved untouched)
50
+ - File-global constants used by fewer than 2 functions/methods
51
+ - Logging format-arg violations
52
+ - Database column-name string magic (snake_case strings as first element of 2-tuples in function bodies)
53
+ - Public-wrapper-drops-optional-kwargs of same-file delegates
54
+
55
+ Test files (`test_*.py`, `*_test.py`, `*.spec.*`, `conftest.py`, paths under `/tests/`) are exempt from comment, magic-value, and constants-location rules.
56
+
57
+ ## Halt-fix-rerun protocol
58
+
59
+ When the gate exits non-zero:
60
+ 1. Capture stderr (gate prints `<file>: Line <N>: <issue>` per violation)
61
+ 2. Group violations by file
62
+ 3. Apply fixes in the smallest change set (extract constants to `config/`, rename collection params with `all_` prefix, replace literals with named constants, etc.)
63
+ 4. Re-run the gate with the same arguments
64
+ 5. Cap at 5 consecutive failures (bugteam, monitor-many) or 3 (qbug); on cap exceeded, exit `error`
@@ -0,0 +1,37 @@
1
+ # Fix protocol
2
+
3
+ ## Sequence
4
+
5
+ 1. **Read each referenced file:line** before editing. The repo enforces read-before-edit at the tool layer.
6
+ 2. **Capture pre-fix SHA:** `git rev-parse HEAD`. Store as `pre_fix_sha`.
7
+ 3. **Capture pre-fix file contents** for every file this fix will touch. Used in step 8 for the post-fix self-audit diff.
8
+ 4. **TDD where applicable:** when the finding has behavior to test, write a failing test first; for pure doc, comment, or naming nits with no behavior, fix directly.
9
+ 5. **Apply each fix:**
10
+ - Preserve existing comments on lines left unchanged
11
+ - Add complete type hints on every signature touched
12
+ - Use positive framing in any new prose (no banned negatives)
13
+ 6. **Validate each modified Python file:** `python -m py_compile <path>`. Halt on syntax error; fix and re-run.
14
+ 7. **Compute fix diff:** the diff between pre-fix and post-fix file contents for every modified file.
15
+ 8. **Post-fix self-audit:** follow [`audit-contract.md`](audit-contract.md) post-fix self-audit sequence. Internal iteration cap: 3. Three rounds with fresh findings → exit `stuck: post-fix audit not converging`. Only when `gate_findings` empty AND `post_fix_findings` empty → proceed to git add.
16
+ 9. **Stage by explicit path:** `git add <path>` for each modified file. Avoid `git add -A` and `git add .`.
17
+ 10. **Create one commit** summarizing the fixed findings. Let every git hook run. When a hook blocks the commit, capture stderr, mark every finding in this loop `status=hook_blocked`, and move to the next iteration without retrying this loop.
18
+ 11. **Push fast-forward:** `git push origin <branch>`. Verify `git fetch origin <branch> && git rev-parse origin/<branch>` matches `HEAD`.
19
+ 12. **Reply inline** on each finding's comment thread using the [`gh-payloads.md`](gh-payloads.md) reply shape. Reply body is one of:
20
+ - `Fixed in <short_sha>`
21
+ - `Could not address this loop: <one-line reason>`
22
+ - `Hook blocked the fix commit: <one-line summary>`
23
+ 13. **Re-trigger reviewer** when the calling workflow specifies. Workflow-specific:
24
+ - `pr-converge`: post `bugbot run` issue comment after every push (Cursor Bugbot)
25
+ - `monitor-many`: post `bugbot run` issue comment AND call `requested_reviewers` API for Copilot
26
+ - `bugteam` / `qbug`: skip — Claude itself is the reviewer; the next loop iteration audits
27
+
28
+ ## Stuck detection
29
+
30
+ After step 11, when `git rev-parse HEAD` is unchanged from `pre_fix_sha`, the fix produced no commit. Exit reason: `stuck — could not address findings`. Record unresolved findings as `{file, line, severity, title, reason}` quadruples.
31
+
32
+ ## Constraints
33
+
34
+ - Edit only files reachable from the PR diff's scope.
35
+ - Append commits; the branch stays linear (one commit per fix loop, fast-forward push only).
36
+ - No comment deletion on lines left unchanged.
37
+ - No `--no-verify`. Hook rejections flag real underlying issues worth investigating.
@@ -0,0 +1,85 @@
1
+ # gh API payloads
2
+
3
+ Shared payload shapes for posting PR reviews and replies. Used by `bugteam`, `qbug`, `pr-converge`, `monitor-many`.
4
+
5
+ ## Build payloads with jq + gh api --input
6
+
7
+ Build JSON with `jq --rawfile` / `-Rs` reading per-finding markdown bodies from temp files; pipe to `gh api ... --input -`. Avoids shell-quoting hazards and satisfies the `gh-body-backtick-guard` hook.
8
+
9
+ ## One review per loop
10
+
11
+ POST to `repos/<owner>/<repo>/pulls/<number>/reviews` once per audit loop. Payload: `event: "COMMENT"`, the review body, and one `comments[]` object per anchored finding.
12
+
13
+ ```bash
14
+ jq -n \
15
+ --rawfile review_body <tmp_review_body.md> \
16
+ --arg commit_id "$(git rev-parse HEAD)" \
17
+ --rawfile finding_body_1 <tmp_finding_1.md> \
18
+ --arg path_1 "<file_1>" \
19
+ --argjson line_1 <line_1> \
20
+ [... one finding_body_K / path_K / line_K triple per finding ...] \
21
+ '{
22
+ commit_id: $commit_id,
23
+ event: "COMMENT",
24
+ body: $review_body,
25
+ comments: [
26
+ {path: $path_1, line: $line_1, side: "RIGHT", body: $finding_body_1}
27
+ [, ... ]
28
+ ]
29
+ }' \
30
+ | gh api repos/<owner>/<repo>/pulls/<number>/reviews -X POST --input -
31
+ ```
32
+
33
+ Single-line anchors: `{path, line, side: "RIGHT", body}`. Multi-line anchors add `start_line` and `start_side: "RIGHT"`.
34
+
35
+ Zero findings still post one review. Body line: `## /<workflow> loop <N> audit: 0P0 / 0P1 / 0P2 → clean`. `comments: []`.
36
+
37
+ ## Review body template
38
+
39
+ ```
40
+ ## /<workflow> loop <N> audit: <P0>P0 / <P1>P1 / <P2>P2
41
+
42
+ ### Findings without a diff anchor
43
+ (only when needed)
44
+ - **[severity] title** — <file>:<line> — <one-line description>
45
+ ```
46
+
47
+ `<workflow>` is the calling skill name (`bugteam`, `qbug`, `pr-converge`).
48
+
49
+ ## Reply to a finding
50
+
51
+ POST to `repos/<owner>/<repo>/pulls/<number>/comments/<finding_comment_id>/replies`:
52
+
53
+ ```bash
54
+ jq -Rs '{body: .}' <tmp_reply.md \
55
+ | gh api repos/<owner>/<repo>/pulls/<number>/comments/<finding_comment_id>/replies -X POST --input -
56
+ ```
57
+
58
+ ## Anchor fallback (line not in diff)
59
+
60
+ Lines not in the PR diff cannot anchor an inline comment. Omit them from `comments[]` and list under the review body's `### Findings without a diff anchor` section. Outcome record per finding: `used_fallback="true"`, empty `finding_comment_id`, `finding_comment_url` = parent review URL.
61
+
62
+ ## Review POST failure fallback (issue comment)
63
+
64
+ When the review POST fails, post one issue comment carrying the full review body to `repos/<owner>/<repo>/issues/<number>/comments`:
65
+
66
+ ```bash
67
+ jq -Rs '{body: .}' <tmp_fallback.md \
68
+ | gh api repos/<owner>/<repo>/issues/<number>/comments -X POST --input -
69
+ ```
70
+
71
+ All findings in the loop record `used_fallback="true"`; `finding_comment_url` = issue comment URL.
72
+
73
+ ## Endpoints
74
+
75
+ - Review POST: `repos/{owner}/{repo}/pulls/{pull}/reviews`
76
+ - Reply POST: `repos/{owner}/{repo}/pulls/{pull}/comments/{id}/replies`
77
+ - Fallback issue comment: `repos/{owner}/{repo}/issues/{issue}/comments` (`issue` = PR number)
78
+
79
+ ## SHA capture timing
80
+
81
+ `commit_id` and any `<head_sha_at_post_time>` reference: `git rev-parse HEAD` immediately before the POST, in the cwd of whichever subagent or process is posting.
82
+
83
+ ## Body file UTF-8 encoding
84
+
85
+ Write each markdown body to a temp file via the BOM-free PowerShell pattern (`[IO.File]::WriteAllText($path, $content, [Text.UTF8Encoding]::new($false))`) before `gh api` consumes it. See `~/.claude/rules/gh-body-file.md`.
@@ -0,0 +1,20 @@
1
+ # Shared PR-loop scripts
2
+
3
+ Runnable helpers used by **bugteam**, **qbug**, **pr-converge**, and related skills. These files are **executed** from the repository (or a `~/.claude` install); they are not meant as primary model-reading context.
4
+
5
+ ## Inventory
6
+
7
+ | File | Purpose |
8
+ | --- | --- |
9
+ | `preflight.py` | Local checks before a PR-loop run (pytest discovery, optional pre-commit, hooksPath sanity). |
10
+ | `code_rules_gate.py` | CODE_RULES gate over PR-scoped diffs (`--base`, staged-only, path filters). |
11
+ | `fix_hookspath.py` | Repair `core.hooksPath` when it does not point at the packaged git-hooks tree. |
12
+ | `gh_util.py` | GitHub CLI helpers (pagination-safe JSON parsing, review fetches). |
13
+ | `grant_project_claude_permissions.py` / `revoke_project_claude_permissions.py` | Claude Code permission JSON helpers used during publish-style flows. |
14
+ | `_claude_permissions_common.py` | Shared implementation for the permission scripts. |
15
+
16
+ Configuration lives under `config/` next to these scripts (for example `preflight_constants.py`, `code_rules_gate_constants.py`).
17
+
18
+ ## Gate semantics
19
+
20
+ Merge-base selection, diff scoping, and exit codes for `code_rules_gate.py` are documented in [`../code-rules-gate.md`](../code-rules-gate.md).
@@ -0,0 +1,234 @@
1
+ """Shared helpers for grant_project_claude_permissions and revoke_project_claude_permissions.
2
+
3
+ Writes to ~/.claude/settings.json are atomic and permission-preserving: the
4
+ target file's existing POSIX mode is captured, a sibling temp file is
5
+ created via os.open with O_CREAT | O_EXCL and the preserved mode, content
6
+ is written, then os.replace swaps it into place. Output is serialized with
7
+ sort_keys=True for a stable on-disk layout; the first run on a hand-ordered
8
+ settings file produces a one-time re-sort diff, subsequent writes are stable.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import secrets
14
+ import stat
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import NoReturn
18
+
19
+ sys.modules.pop("config", None)
20
+ if str(Path(__file__).resolve().parent) not in sys.path:
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+
23
+ from config.claude_permissions_constants import (
24
+ CLAUDE_SETTINGS_DIRECTORY_NAME,
25
+ GIT_DIRECTORY_NAME,
26
+ TEXT_FILE_ENCODING as TEXT_FILE_ENCODING,
27
+ UNIQUE_TEMPORARY_SUFFIX_BYTE_LENGTH,
28
+ )
29
+
30
+
31
+ def exit_with_error(message: str) -> NoReturn:
32
+ print(f"Error: {message}", file=sys.stderr)
33
+ raise SystemExit(1)
34
+
35
+
36
+ def path_contains_glob_metacharacters(candidate_path: str) -> bool:
37
+ all_glob_metacharacters_in_path: tuple[str, ...] = (
38
+ "*",
39
+ "?",
40
+ "[",
41
+ "]",
42
+ "{",
43
+ "}",
44
+ )
45
+ return any(
46
+ each_character in candidate_path
47
+ for each_character in all_glob_metacharacters_in_path
48
+ )
49
+
50
+
51
+ def get_current_project_path() -> str:
52
+ normalized_project_path = str(Path.cwd()).replace("\\", "/")
53
+ if path_contains_glob_metacharacters(normalized_project_path):
54
+ raise ValueError(
55
+ f"Current directory path contains glob metacharacters and cannot "
56
+ f"be used to build permission rules safely: {normalized_project_path}"
57
+ )
58
+ return normalized_project_path
59
+
60
+
61
+ def build_permission_rule(tool_name: str, project_path: str) -> str:
62
+ return f"{tool_name}({project_path}/.claude/**)"
63
+
64
+
65
+ def build_permission_rules(
66
+ project_path: str, all_permission_allow_tools: tuple[str, ...]
67
+ ) -> list[str]:
68
+ return [
69
+ build_permission_rule(each_tool, project_path)
70
+ for each_tool in all_permission_allow_tools
71
+ ]
72
+
73
+
74
+ def load_settings(settings_path: Path) -> dict[str, object]:
75
+ if not settings_path.exists():
76
+ return {}
77
+ parsed_settings: dict[str, object] = {}
78
+ try:
79
+ raw_text = settings_path.read_text(encoding=TEXT_FILE_ENCODING)
80
+ except OSError as read_error:
81
+ exit_with_error(f"Failed to read {settings_path}: {read_error}")
82
+ try:
83
+ parsed_settings = json.loads(raw_text)
84
+ except json.JSONDecodeError as decode_error:
85
+ exit_with_error(
86
+ f"Refusing to modify {settings_path}: existing file is not valid JSON "
87
+ f"({decode_error}). Fix or back up the file manually, then re-run."
88
+ )
89
+ if not isinstance(parsed_settings, dict):
90
+ exit_with_error(
91
+ f"Refusing to modify {settings_path}: existing file's root is "
92
+ f"{type(parsed_settings).__name__}, not a JSON object. Fix or back up "
93
+ f"the file manually, then re-run."
94
+ )
95
+ return parsed_settings
96
+
97
+
98
+ def serialize_settings_to_json_text(all_settings: dict[str, object]) -> str:
99
+ json_indent_width_columns: int = len(" ")
100
+ return json.dumps(
101
+ all_settings,
102
+ indent=json_indent_width_columns,
103
+ sort_keys=True,
104
+ )
105
+
106
+
107
+ def get_mode_to_preserve(settings_path: Path) -> int:
108
+ default_settings_file_mode: int = 0o600
109
+ try:
110
+ stat_result = os.stat(settings_path)
111
+ except FileNotFoundError:
112
+ return default_settings_file_mode
113
+ except OSError as stat_error:
114
+ exit_with_error(f"Failed to stat {settings_path}: {stat_error}")
115
+ return stat.S_IMODE(stat_result.st_mode)
116
+
117
+
118
+ def write_atomically_with_mode(
119
+ temporary_path: Path, serialized_content: str, file_mode: int
120
+ ) -> None:
121
+ file_descriptor = os.open(
122
+ str(temporary_path),
123
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
124
+ file_mode,
125
+ )
126
+ try:
127
+ writer = os.fdopen(file_descriptor, "w", encoding=TEXT_FILE_ENCODING)
128
+ except BaseException:
129
+ os.close(file_descriptor)
130
+ raise
131
+ with writer:
132
+ writer.write(serialized_content)
133
+
134
+
135
+ def save_settings(settings_path: Path, all_settings: dict[str, object]) -> None:
136
+ unique_temporary_suffix_byte_length = UNIQUE_TEMPORARY_SUFFIX_BYTE_LENGTH
137
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
138
+ serialized_settings = serialize_settings_to_json_text(all_settings)
139
+ unique_temporary_suffix = (
140
+ f".tmp.{os.getpid()}.{secrets.token_hex(unique_temporary_suffix_byte_length)}"
141
+ )
142
+ temporary_path = settings_path.with_suffix(
143
+ settings_path.suffix + unique_temporary_suffix
144
+ )
145
+ mode_to_preserve = get_mode_to_preserve(settings_path)
146
+ try:
147
+ try:
148
+ write_atomically_with_mode(
149
+ temporary_path, serialized_settings, mode_to_preserve
150
+ )
151
+ os.replace(str(temporary_path), str(settings_path))
152
+ os.chmod(str(settings_path), mode_to_preserve)
153
+ except OSError as os_error:
154
+ exit_with_error(
155
+ f"Failed to write settings atomically to {settings_path}: {os_error}"
156
+ )
157
+ finally:
158
+ if temporary_path.exists():
159
+ try:
160
+ temporary_path.unlink()
161
+ except OSError:
162
+ pass
163
+
164
+
165
+ def append_if_missing(all_target_list: list[object], new_value: str) -> bool:
166
+ if new_value in all_target_list:
167
+ return False
168
+ all_target_list.append(new_value)
169
+ return True
170
+
171
+
172
+ def ensure_dict_section(
173
+ all_settings: dict[str, object], section_name: str
174
+ ) -> dict[str, object]:
175
+ """Return an existing dict section or create an empty one if absent.
176
+
177
+ A missing key and an explicit JSON null are treated identically: both
178
+ produce a fresh empty dict stored back into settings. Any other non-dict
179
+ value (string, list, number, bool) calls exit_with_error to avoid
180
+ overwriting user data.
181
+ """
182
+ existing_section = all_settings.get(section_name)
183
+ if existing_section is None:
184
+ replacement_section: dict[str, object] = {}
185
+ all_settings[section_name] = replacement_section
186
+ return replacement_section
187
+ if not isinstance(existing_section, dict):
188
+ exit_with_error(
189
+ f"Refusing to modify settings key {section_name!r}: existing value "
190
+ f"is {type(existing_section).__name__}, not a JSON object. Fix or "
191
+ f"remove the key manually, then re-run."
192
+ )
193
+ return existing_section
194
+
195
+
196
+ def ensure_list_entry(all_section: dict[str, object], entry_name: str) -> list[object]:
197
+ """Return an existing list entry or create an empty one if absent.
198
+
199
+ A missing key and an explicit JSON null are treated identically: both
200
+ produce a fresh empty list stored back into the section. Any other
201
+ non-list value (string, dict, number, bool) calls exit_with_error to
202
+ avoid overwriting user data.
203
+ """
204
+ existing_entry = all_section.get(entry_name)
205
+ if existing_entry is None:
206
+ replacement_entry: list[object] = []
207
+ all_section[entry_name] = replacement_entry
208
+ return replacement_entry
209
+ if not isinstance(existing_entry, list):
210
+ exit_with_error(
211
+ f"Refusing to modify settings entry {entry_name!r}: existing value "
212
+ f"is {type(existing_entry).__name__}, not a JSON array. Fix or "
213
+ f"remove the entry manually, then re-run."
214
+ )
215
+ return existing_entry
216
+
217
+
218
+ def is_valid_project_root(candidate_path: Path) -> bool:
219
+ git_marker_path = candidate_path / GIT_DIRECTORY_NAME
220
+ claude_marker_path = candidate_path / CLAUDE_SETTINGS_DIRECTORY_NAME
221
+ return git_marker_path.exists() or claude_marker_path.exists()
222
+
223
+
224
+ def prune_empty_list_then_empty_section(
225
+ all_settings: dict[str, object], section_key: str, list_key: str
226
+ ) -> None:
227
+ section = all_settings.get(section_key)
228
+ if not isinstance(section, dict):
229
+ return
230
+ list_entry = section.get(list_key)
231
+ if isinstance(list_entry, list) and len(list_entry) == 0:
232
+ del section[list_key]
233
+ if len(section) == 0:
234
+ del all_settings[section_key]