claude-dev-env 1.22.0 → 1.22.1

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": "1.22.0",
3
+ "version": "1.22.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,20 +45,11 @@ User wants automated convergence on a clean PR without babysitting each step. Ty
45
45
 
46
46
  Refusal cases — check in order; first match short-circuits and stops:
47
47
 
48
- 1. **Agent teams not enabled.** Verify by reading `~/.claude/settings.json` and `os.environ` directly:
49
-
50
- ```python
51
- import json, os
52
- from pathlib import Path
53
- settings = json.loads(Path.home().joinpath('.claude', 'settings.json').read_text(encoding='utf-8'))
54
- is_enabled_in_settings = settings.get('env', {}).get('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS') == '1'
55
- is_enabled_in_environment = os.environ.get('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS') == '1'
56
- ```
57
-
58
- If neither is `'1'`, respond: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 not set. /bugteam requires the agent teams feature. See https://code.claude.com/docs/en/agent-teams#enable-agent-teams.` and stop.
59
- 2. **Claude Code version too old.** Run `claude --version`. If older than v2.1.32, respond: `Claude Code v<version> is older than the v2.1.32 minimum for agent teams. Upgrade first.` and stop.
60
- 3. **No PR or upstream diff.** Respond exactly: `No PR or upstream diff. /bugteam needs a target.` and stop.
61
- 4. **Working tree dirty with uncommitted changes the user did not stage.** Respond: `Uncommitted changes detected. Stash, commit, or revert before /bugteam.` and stop. Reason: the fix teammate will commit the working tree, mixing user-uncommitted work into automated fixes.
48
+ - **Agent teams not enabled.** Check `claude config get env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` and `~/.claude/settings.json`. If neither sets it to `"1"`, respond: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 not set. /bugteam requires the agent teams feature. See https://code.claude.com/docs/en/agent-teams#enable-agent-teams.` and stop.
49
+ - **Claude Code version too old.** Run `claude --version`. If older than v2.1.32, respond: `Claude Code v<version> is older than the v2.1.32 minimum for agent teams. Upgrade first.` and stop.
50
+ - **No PR or upstream diff.** Respond exactly: `No PR or upstream diff. /bugteam needs a target.` and stop.
51
+ - **Working tree dirty with uncommitted changes the user did not stage.** Respond: `Uncommitted changes detected. Stash, commit, or revert before /bugteam.` and stop. Reason: the fix teammate will commit the working tree, mixing user-uncommitted work into automated fixes.
52
+ - **Required subagents not installed.** Before Step 0, verify `code-quality-agent` and `clean-coder` subagent types exist in the available agents list. If either is missing, respond: `Required subagent type <name> not installed. /bugteam needs both code-quality-agent and clean-coder available.` and stop.
62
53
 
63
54
  ## The Process
64
55
 
@@ -80,9 +71,11 @@ Refusal cases — check in order; first match short-circuits and stops:
80
71
  Before spawning any teammates, grant the team session write access to the project's `.claude/**` tree:
81
72
 
82
73
  ```bash
83
- python scripts/grant_project_claude_permissions.py
74
+ python "${CLAUDE_SKILL_DIR}/grant_project_claude_permissions.py"
84
75
  ```
85
76
 
77
+ Note: `${CLAUDE_SKILL_DIR}` is a Claude Code host-managed token, pre-substituted by the runtime before any shell sees it. Unlike `${TMPDIR}` and similar shell parameter expansions, it does not depend on the shell's expansion semantics, so it works identically on Unix and Windows shells.
78
+
86
79
  The script reads `Path.cwd()` and writes idempotent allow rules into `~/.claude/settings.json`. Run from the project root. If it fails (non-zero exit), surface the error and stop — do not proceed without the grant.
87
80
 
88
81
  This is the FIRST action of every `/bugteam` invocation, before any team creation, before any agent spawn. The corresponding revoke runs at Step 5 regardless of how the cycle exits.
@@ -103,7 +96,9 @@ This session is the **team lead**. Create a team using the agent teams feature.
103
96
 
104
97
  Team specification:
105
98
 
106
- - **Team name:** `bugteam-pr-<number>` (or `bugteam-<head-branch>` if no PR)
99
+ - **Team name:** `bugteam-pr-<number>-<YYYYMMDDHHMMSS>` (or `bugteam-<sanitized-head-branch>-<YYYYMMDDHHMMSS>` if no PR). The timestamp is captured at team-creation time from the lead session and prevents two concurrent invocations on the same PR from colliding.
100
+ - **Branch-name sanitization (no-PR fallback only):** Before substituting `<head-branch>` into the team_name template, replace every character that is NOT in `[A-Za-z0-9._-]` with `-`. This whitelist covers safe portable filename characters and rejects all OS-reserved or shell-special chars including `/ \ : * ? < > | "` and ASCII control chars (0x00–0x1F). Example: `feat/foo*bar` → `feat-foo-bar`; team_name becomes `bugteam-feat-foo-bar-<YYYYMMDDHHMMSS>`. Apply this sanitization BEFORE the team_name is captured, not after — every downstream use of `team_name` (team creation, scoped temp dir, cleanup) sees the safe form.
101
+ - **Per-team temp directory (resolved once, reused everywhere):** After team_name is captured, resolve a portable absolute path with a Claude-side lookup using Python's `tempfile.gettempdir()`, which honors `TMPDIR`, `TEMP`, and `TMP` in the platform-correct order and falls back to `C:\Users\<user>\AppData\Local\Temp` on Windows or `/tmp` on Unix: `Path(tempfile.gettempdir()) / team_name` (requires `import tempfile`). The `team_name` value already carries the `bugteam-` prefix, so do NOT add it again here. Avoid hand-rolled env var chains. Capture the resolved absolute path as `<team_temp_dir>` and pass that literal path to every shell command that follows. Shell-side parameter expansion (`${TMPDIR:-/tmp}`) is forbidden because cmd.exe and PowerShell do not expand it.
107
102
  - **Roles defined up front (spawned per loop, not at team creation):**
108
103
  - `bugfind` — uses teammate role `code-quality-agent`, model sonnet
109
104
  - `bugfix` — uses teammate role `clean-coder`, model sonnet
@@ -119,7 +114,8 @@ last_action="fresh"
119
114
  last_findings=""
120
115
  audit_log=""
121
116
  starting_sha="$(git rev-parse HEAD)" # captured once, used in the final report
122
- team_name="bugteam-pr-<number>"
117
+ team_name="bugteam-pr-<number>-<YYYYMMDDHHMMSS>" # no-PR fallback uses sanitized branch
118
+ team_temp_dir="<resolved-absolute-path>/<team_name>"
123
119
  loop_comment_index="" # reset at every AUDIT, see scope note below
124
120
  ```
125
121
 
@@ -179,13 +175,16 @@ Repeat until an exit condition fires:
179
175
 
180
176
  ### AUDIT action (clean-room teammate, fresh per loop)
181
177
 
182
- Capture a fresh PR diff for this loop:
178
+ Capture a fresh PR diff for this loop into the per-team scoped directory so concurrent `/bugteam` runs do not collide. Use the literal `<team_temp_dir>` resolved once in Step 2 — do NOT rewrite the path with shell expansion:
183
179
 
184
180
  ```
185
- gh pr diff <number> -R <owner>/<repo> > .bugteam-loop-<N>.patch
181
+ mkdir -p "<team_temp_dir>"
182
+ gh pr diff <number> -R <owner>/<repo> > "<team_temp_dir>/loop-<N>.patch"
186
183
  ```
187
184
 
188
- Spawn a NEW `bugfind` teammate for this loop using the `code-quality-agent` teammate role. The teammate is fresh: no prior loop's findings, no chat history, no inherited audit context. Per the docs: *"The lead's conversation history does not carry over."* and we further guarantee independence by spawning a new teammate per loop rather than reusing one.
185
+ `<team_temp_dir>` is the absolute path captured in Step 2 (already includes the sanitized team_name and timestamp suffix, and `team_name` itself is already prefixed with `bugteam-`). Claude resolves the portable temp root once via `Path(tempfile.gettempdir()) / team_name` (requires `import tempfile`) and passes the literal absolute path to every shell command. `tempfile.gettempdir()` honors `TMPDIR`, `TEMP`, and `TMP` in the platform-correct order and falls back to `C:\Users\<user>\AppData\Local\Temp` on Windows or `/tmp` on Unix, so this works identically on macOS, Linux, Windows cmd.exe, and PowerShell because the shell never has to interpret `${TMPDIR:-/tmp}` or `%TEMP%`.
186
+
187
+ Spawn a NEW `bugfind` teammate for this loop using the `code-quality-agent` subagent type. The teammate is fresh: no prior loop's findings, no chat history, no inherited audit context. Per the docs: *"The lead's conversation history does not carry over."* — and we further guarantee independence by spawning a new teammate per loop rather than reusing one.
189
188
 
190
189
  The teammate's spawn prompt is the full XML below — copy it verbatim with the placeholders substituted. **Forbid all conversation references** in the spawn prompt. No "as we discussed," "the earlier issue," "fix from the prior loop," "you previously identified." Each loop's audit teammate has no idea other loops happened.
191
190
 
@@ -199,7 +198,7 @@ The teammate's spawn prompt is the full XML below — copy it verbatim with the
199
198
  </context>
200
199
 
201
200
  <scope>
202
- <diff_path>absolute path to .bugteam-loop-N.patch</diff_path>
201
+ <diff_path>Absolute path to the loop-N patch file under team_temp_dir from Step 2 (same path as gh pr diff redirect in AUDIT)</diff_path>
203
202
  <scope_rule>Audit only lines added or modified in the diff. Pre-existing code on untouched lines is out of scope.</scope_rule>
204
203
  </scope>
205
204
 
@@ -377,7 +376,7 @@ If `git rev-parse HEAD` did not change, exit reason = `stuck — bugfix teammate
377
376
  When the cycle exits (any reason):
378
377
 
379
378
  1. **Clean up the team as the lead.** Per the docs: *"When you're done, ask the lead to clean up: 'Clean up the team'. This removes the shared team resources. When the lead runs cleanup, it checks for active teammates and fails if any are still running, so shut them down first."* The lead is THIS session — call cleanup directly. If any teammate is still alive (e.g., from an aborted shutdown), shut it down first.
380
- 2. Delete every `.bugteam-loop-*.patch` from the working directory.
379
+ 2. Delete the per-team scoped temp directory using Python: `shutil.rmtree(team_temp_dir, ignore_errors=True)` (requires `import shutil`). This works on every platform without OS-detection branching. Pass the literal absolute path Claude resolved at Step 2 — do NOT defer to the shell, and never use shell `${TMPDIR:-/tmp}` or `%TEMP%` expansion at this step either.
381
380
 
382
381
  ### Step 4.5: Finalize the PR description (mandatory)
383
382
 
@@ -407,7 +406,7 @@ If Step 4.5 fails for any reason (agent error, hook block, network), surface the
407
406
  After team cleanup completes — including on error, cap-reached, or stuck exits — run:
408
407
 
409
408
  ```bash
410
- python scripts/revoke_project_claude_permissions.py
409
+ python "${CLAUDE_SKILL_DIR}/revoke_project_claude_permissions.py"
411
410
  ```
412
411
 
413
412
  This removes the allow rules and additionalDirectories entry that Step 0 added. Revoke is non-negotiable: leaving the grant in place means future sessions inherit elevated permissions on this project's `.claude/**` tree without the user opting in. Run this even if Step 4 cleanup partially failed; surface the cleanup error separately in the final report.
@@ -445,6 +444,7 @@ If exit = `cap reached`, name the remaining bug count and recommend `/findbugs`
445
444
  - **One commit per fix action.** Loops produce one commit per loop, not one per bug.
446
445
  - **No `--force`, no `--amend`, no rebase, no base change** at any point.
447
446
  - **Lead-only cleanup.** Per the docs: *"Always use the lead to clean up. Teammates should not run cleanup because their team context may not resolve correctly, potentially leaving resources in an inconsistent state."* This session is the lead; teammates never call cleanup.
447
+ - **Cleanup the per-team scoped temp directory on exit.** The resolved `<team_temp_dir>` (absolute literal captured in Step 2) is deleted entirely so no loop patches leak between runs.
448
448
  - **Cleanup all `.bugteam-*` files on exit.** `.bugteam-loop-*.patch`, `.bugteam-loop-*.outcomes.xml`, `.bugteam-final.diff`, `.bugteam-original-body.md`, `.bugteam-final-body.md`. Working directory ends clean.
449
449
  - **Teammates own audit/fix comment posting.** Bugfind posts the loop comment and finding comments (with issue-comment fallback). Bugfix posts the fix replies after committing. The lead never calls `gh pr comment` or `gh api repos/.../comments` for these.
450
450
  - **Lead owns the final PR description rewrite only** (Step 4.5), and only via the `pr-description-writer` agent. The lead does not compose the description inline.
@@ -0,0 +1,224 @@
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 stat
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any, NoReturn
17
+
18
+
19
+ TEXT_FILE_ENCODING: str = "utf-8"
20
+ GLOB_METACHARACTERS_IN_PATH: tuple[str, ...] = (
21
+ "*",
22
+ "?",
23
+ "[",
24
+ "]",
25
+ "(",
26
+ ")",
27
+ "{",
28
+ "}",
29
+ ",",
30
+ )
31
+
32
+ JSON_INDENT_SPACES: int = 2
33
+ PERMISSION_ALLOW_TOOLS: tuple[str, ...] = ("Edit", "Write", "Read")
34
+
35
+ DEFAULT_SETTINGS_FILE_MODE: int = 0o600
36
+ ATOMIC_WRITE_TEMPORARY_SUFFIX: str = ".tmp"
37
+
38
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE: str = (
39
+ "Trusted local workspace: {project_path}/.claude/** is the user's "
40
+ "project Claude Code config tree; edits inside are routine"
41
+ )
42
+
43
+
44
+ def exit_with_error(message: str) -> NoReturn:
45
+ print(f"Error: {message}", file=sys.stderr)
46
+ raise SystemExit(1)
47
+
48
+
49
+ def path_contains_glob_metacharacters(candidate_path: str) -> bool:
50
+ return any(
51
+ each_character in candidate_path
52
+ for each_character in GLOB_METACHARACTERS_IN_PATH
53
+ )
54
+
55
+
56
+ def path_contains_whitespace(candidate_path: str) -> bool:
57
+ return any(each_character.isspace() for each_character in candidate_path)
58
+
59
+
60
+ def get_current_project_path() -> str:
61
+ normalized_project_path = str(Path.cwd()).replace("\\", "/")
62
+ if path_contains_glob_metacharacters(normalized_project_path):
63
+ raise ValueError(
64
+ f"Current directory path contains glob metacharacters and cannot "
65
+ f"be used to build permission rules safely: {normalized_project_path}"
66
+ )
67
+ if path_contains_whitespace(normalized_project_path):
68
+ raise ValueError(
69
+ f"Current directory path contains whitespace and cannot be used "
70
+ f"to build permission rules safely: {normalized_project_path}"
71
+ )
72
+ return normalized_project_path
73
+
74
+
75
+ def build_permission_rule(tool_name: str, project_path: str) -> str:
76
+ return f"{tool_name}({project_path}/.claude/**)"
77
+
78
+
79
+ def build_permission_rules(
80
+ project_path: str, permission_allow_tools: tuple[str, ...]
81
+ ) -> list[str]:
82
+ return [
83
+ build_permission_rule(each_tool, project_path)
84
+ for each_tool in permission_allow_tools
85
+ ]
86
+
87
+
88
+ def load_settings(settings_path: Path) -> dict[str, Any]:
89
+ if not settings_path.exists():
90
+ return {}
91
+ parsed_settings: dict[str, Any] = {}
92
+ try:
93
+ raw_text = settings_path.read_text(encoding=TEXT_FILE_ENCODING)
94
+ except OSError as read_error:
95
+ exit_with_error(f"Failed to read {settings_path}: {read_error}")
96
+ try:
97
+ parsed_settings = json.loads(raw_text)
98
+ except json.JSONDecodeError as decode_error:
99
+ exit_with_error(
100
+ f"Refusing to modify {settings_path}: existing file is not valid JSON "
101
+ f"({decode_error}). Fix or back up the file manually, then re-run."
102
+ )
103
+ if not isinstance(parsed_settings, dict):
104
+ exit_with_error(
105
+ f"Refusing to modify {settings_path}: existing file's root is "
106
+ f"{type(parsed_settings).__name__}, not a JSON object. Fix or back up "
107
+ f"the file manually, then re-run."
108
+ )
109
+ return parsed_settings
110
+
111
+
112
+ def get_mode_to_preserve(settings_path: Path) -> int:
113
+ try:
114
+ stat_result = os.stat(settings_path)
115
+ except FileNotFoundError:
116
+ return DEFAULT_SETTINGS_FILE_MODE
117
+ except OSError as stat_error:
118
+ exit_with_error(f"Failed to stat {settings_path}: {stat_error}")
119
+ return stat.S_IMODE(stat_result.st_mode)
120
+
121
+
122
+ def write_atomically_with_mode(
123
+ temporary_path: Path, serialized_content: str, file_mode: int
124
+ ) -> None:
125
+ file_descriptor = os.open(
126
+ str(temporary_path),
127
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
128
+ file_mode,
129
+ )
130
+ with os.fdopen(file_descriptor, "w", encoding=TEXT_FILE_ENCODING) as writer:
131
+ writer.write(serialized_content)
132
+
133
+
134
+ def save_settings(settings_path: Path, settings: dict[str, Any]) -> None:
135
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
136
+ serialized_settings = json.dumps(
137
+ settings, indent=JSON_INDENT_SPACES, sort_keys=True
138
+ )
139
+ temporary_path = settings_path.with_suffix(
140
+ settings_path.suffix + ATOMIC_WRITE_TEMPORARY_SUFFIX
141
+ )
142
+ mode_to_preserve = get_mode_to_preserve(settings_path)
143
+ try:
144
+ try:
145
+ write_atomically_with_mode(
146
+ temporary_path, serialized_settings, mode_to_preserve
147
+ )
148
+ os.replace(str(temporary_path), str(settings_path))
149
+ except OSError as os_error:
150
+ exit_with_error(
151
+ f"Failed to write settings atomically to {settings_path}: {os_error}"
152
+ )
153
+ finally:
154
+ if temporary_path.exists():
155
+ try:
156
+ temporary_path.unlink()
157
+ except OSError:
158
+ pass
159
+
160
+
161
+ def append_if_missing(target_list: list[str], new_value: str) -> bool:
162
+ if new_value in target_list:
163
+ return False
164
+ target_list.append(new_value)
165
+ return True
166
+
167
+
168
+ def ensure_dict_section(
169
+ settings: dict[str, Any], section_name: str
170
+ ) -> dict[str, Any]:
171
+ """Return an existing dict section or create an empty one if absent.
172
+
173
+ A missing key and an explicit JSON null are treated identically: both
174
+ produce a fresh empty dict stored back into settings. Any other non-dict
175
+ value (string, list, number, bool) calls exit_with_error to avoid
176
+ overwriting user data.
177
+ """
178
+ existing_section = settings.get(section_name)
179
+ if existing_section is None:
180
+ replacement_section: dict[str, Any] = {}
181
+ settings[section_name] = replacement_section
182
+ return replacement_section
183
+ if not isinstance(existing_section, dict):
184
+ exit_with_error(
185
+ f"Refusing to modify settings key {section_name!r}: existing value "
186
+ f"is {type(existing_section).__name__}, not a JSON object. Fix or "
187
+ f"remove the key manually, then re-run."
188
+ )
189
+ return existing_section
190
+
191
+
192
+ def ensure_list_entry(section: dict[str, Any], entry_name: str) -> list[Any]:
193
+ """Return an existing list entry or create an empty one if absent.
194
+
195
+ A missing key and an explicit JSON null are treated identically: both
196
+ produce a fresh empty list stored back into the section. Any other
197
+ non-list value (string, dict, number, bool) calls exit_with_error to
198
+ avoid overwriting user data.
199
+ """
200
+ existing_entry = section.get(entry_name)
201
+ if existing_entry is None:
202
+ replacement_entry: list[Any] = []
203
+ section[entry_name] = replacement_entry
204
+ return replacement_entry
205
+ if not isinstance(existing_entry, list):
206
+ exit_with_error(
207
+ f"Refusing to modify settings entry {entry_name!r}: existing value "
208
+ f"is {type(existing_entry).__name__}, not a JSON array. Fix or "
209
+ f"remove the entry manually, then re-run."
210
+ )
211
+ return existing_entry
212
+
213
+
214
+ def prune_empty_list_then_empty_section(
215
+ settings: dict[str, Any], section_key: str, list_key: str
216
+ ) -> None:
217
+ section = settings.get(section_key)
218
+ if not isinstance(section, dict):
219
+ return
220
+ list_entry = section.get(list_key)
221
+ if isinstance(list_entry, list) and len(list_entry) == 0:
222
+ del section[list_key]
223
+ if len(section) == 0:
224
+ del settings[section_key]
@@ -0,0 +1,110 @@
1
+ """Grant Edit/Write/Read permissions on the current directory's .claude tree.
2
+
3
+ Run from the project root whose .claude/** you want a Claude Code session
4
+ (including spawned subagents) to edit without prompting. Writes idempotent
5
+ entries into the user-scope settings at ~/.claude/settings.json and prints
6
+ the changes applied. No-op when the entries already exist.
7
+ """
8
+
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
14
+
15
+ from _claude_permissions_common import ( # noqa: E402
16
+ append_if_missing,
17
+ build_permission_rules,
18
+ ensure_dict_section,
19
+ ensure_list_entry,
20
+ exit_with_error,
21
+ get_current_project_path,
22
+ load_settings,
23
+ save_settings,
24
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
25
+ PERMISSION_ALLOW_TOOLS,
26
+ )
27
+
28
+
29
+ CLAUDE_USER_SETTINGS_PATH: Path = Path.home() / ".claude" / "settings.json"
30
+
31
+
32
+ def is_valid_project_root(candidate_path: Path) -> bool:
33
+ git_marker_path = candidate_path / ".git"
34
+ claude_marker_path = candidate_path / ".claude"
35
+ return git_marker_path.exists() or claude_marker_path.exists()
36
+
37
+
38
+ def add_rules_to_allow_list(settings: dict[str, Any], rules_to_add: list[str]) -> int:
39
+ permissions_section = ensure_dict_section(settings, "permissions")
40
+ existing_allow_list = ensure_list_entry(permissions_section, "allow")
41
+ return sum(
42
+ 1
43
+ for each_rule in rules_to_add
44
+ if append_if_missing(existing_allow_list, each_rule)
45
+ )
46
+
47
+
48
+ def add_directory_to_additional_directories(
49
+ settings: dict[str, Any], directory_path: str
50
+ ) -> int:
51
+ permissions_section = ensure_dict_section(settings, "permissions")
52
+ existing_directories = ensure_list_entry(
53
+ permissions_section, "additionalDirectories"
54
+ )
55
+ if append_if_missing(existing_directories, directory_path):
56
+ return 1
57
+ return 0
58
+
59
+
60
+ def add_auto_mode_environment_entry(settings: dict[str, Any], entry_text: str) -> int:
61
+ auto_mode_section = ensure_dict_section(settings, "autoMode")
62
+ existing_environment = ensure_list_entry(auto_mode_section, "environment")
63
+ if append_if_missing(existing_environment, entry_text):
64
+ return 1
65
+ return 0
66
+
67
+
68
+ def grant_permissions_for_current_directory() -> None:
69
+ project_root_path = Path.cwd()
70
+ if not is_valid_project_root(project_root_path):
71
+ print(
72
+ f"ERROR: cwd {project_root_path} is not a project root "
73
+ f"(no .git or .claude). Run from a project root.",
74
+ file=sys.stderr,
75
+ )
76
+ raise SystemExit(1)
77
+ project_path = get_current_project_path()
78
+ permission_rules = build_permission_rules(project_path, PERMISSION_ALLOW_TOOLS)
79
+ environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
80
+ project_path=project_path
81
+ )
82
+ settings = load_settings(CLAUDE_USER_SETTINGS_PATH)
83
+ rules_added_count = add_rules_to_allow_list(settings, permission_rules)
84
+ directories_added_count = add_directory_to_additional_directories(
85
+ settings, project_path
86
+ )
87
+ environment_entries_added_count = add_auto_mode_environment_entry(
88
+ settings, environment_entry
89
+ )
90
+ total_changes_count = (
91
+ rules_added_count + directories_added_count + environment_entries_added_count
92
+ )
93
+ if total_changes_count == 0:
94
+ print(f"Project path: {project_path}")
95
+ print(f"Settings file: {CLAUDE_USER_SETTINGS_PATH}")
96
+ print("No changes needed; settings file left untouched.")
97
+ return
98
+ save_settings(CLAUDE_USER_SETTINGS_PATH, settings)
99
+ print(f"Project path: {project_path}")
100
+ print(f"Settings file: {CLAUDE_USER_SETTINGS_PATH}")
101
+ print(f"Allow rules added: {rules_added_count} of {len(permission_rules)}")
102
+ print(f"Additional directories added: {directories_added_count}")
103
+ print(f"Auto-mode environment entries added: {environment_entries_added_count}")
104
+
105
+
106
+ if __name__ == "__main__":
107
+ try:
108
+ grant_permissions_for_current_directory()
109
+ except ValueError as path_error:
110
+ exit_with_error(str(path_error))
@@ -0,0 +1,136 @@
1
+ """Revoke the permissions previously granted by grant_project_claude_permissions.
2
+
3
+ Run from the same project root you previously granted. Removes the matching
4
+ allow rules, the additionalDirectories entry, and the autoMode environment
5
+ entry from ~/.claude/settings.json. Safe to run when no prior grant exists.
6
+ After removals, prunes any newly empty lists and their parent permissions or
7
+ autoMode sections so repeated grant/revoke cycles leave no dead structure.
8
+ """
9
+
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
15
+
16
+ from _claude_permissions_common import ( # noqa: E402
17
+ build_permission_rules,
18
+ exit_with_error,
19
+ get_current_project_path,
20
+ load_settings,
21
+ prune_empty_list_then_empty_section,
22
+ save_settings,
23
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
24
+ PERMISSION_ALLOW_TOOLS,
25
+ )
26
+
27
+
28
+ CLAUDE_USER_SETTINGS_PATH: Path = Path.home() / ".claude" / "settings.json"
29
+
30
+
31
+ def is_valid_project_root(candidate_path: Path) -> bool:
32
+ git_marker_path = candidate_path / ".git"
33
+ claude_marker_path = candidate_path / ".claude"
34
+ return git_marker_path.exists() or claude_marker_path.exists()
35
+
36
+
37
+ def remove_values_from_list(target_list: list[str], values_to_remove: set[str]) -> int:
38
+ original_length = len(target_list)
39
+ target_list[:] = [
40
+ each_value for each_value in target_list if each_value not in values_to_remove
41
+ ]
42
+ return original_length - len(target_list)
43
+
44
+
45
+ def remove_rules_from_allow_list(
46
+ settings: dict[str, Any], rules_to_remove: list[str]
47
+ ) -> int:
48
+ permissions_section = settings.get("permissions")
49
+ if not isinstance(permissions_section, dict):
50
+ return 0
51
+ existing_allow_list = permissions_section.get("allow")
52
+ if not isinstance(existing_allow_list, list):
53
+ return 0
54
+ return remove_values_from_list(existing_allow_list, set(rules_to_remove))
55
+
56
+
57
+ def remove_directory_from_additional_directories(
58
+ settings: dict[str, Any], directory_path: str
59
+ ) -> int:
60
+ permissions_section = settings.get("permissions")
61
+ if not isinstance(permissions_section, dict):
62
+ return 0
63
+ existing_directories = permissions_section.get("additionalDirectories")
64
+ if not isinstance(existing_directories, list):
65
+ return 0
66
+ return remove_values_from_list(existing_directories, {directory_path})
67
+
68
+
69
+ def remove_auto_mode_environment_entry(
70
+ settings: dict[str, Any], entry_text: str
71
+ ) -> int:
72
+ auto_mode_section = settings.get("autoMode")
73
+ if not isinstance(auto_mode_section, dict):
74
+ return 0
75
+ existing_environment = auto_mode_section.get("environment")
76
+ if not isinstance(existing_environment, list):
77
+ return 0
78
+ return remove_values_from_list(existing_environment, {entry_text})
79
+
80
+
81
+ def prune_settings_after_revoke(settings: dict[str, Any]) -> None:
82
+ prune_empty_list_then_empty_section(settings, "permissions", "allow")
83
+ prune_empty_list_then_empty_section(
84
+ settings, "permissions", "additionalDirectories"
85
+ )
86
+ prune_empty_list_then_empty_section(settings, "autoMode", "environment")
87
+
88
+
89
+ def revoke_permissions_for_current_directory() -> None:
90
+ project_root_path = Path.cwd()
91
+ if not is_valid_project_root(project_root_path):
92
+ print(
93
+ f"ERROR: cwd {project_root_path} is not a project root "
94
+ f"(no .git or .claude). Run from a project root.",
95
+ file=sys.stderr,
96
+ )
97
+ raise SystemExit(1)
98
+ project_path = get_current_project_path()
99
+ permission_rules = build_permission_rules(project_path, PERMISSION_ALLOW_TOOLS)
100
+ environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
101
+ project_path=project_path
102
+ )
103
+ settings = load_settings(CLAUDE_USER_SETTINGS_PATH)
104
+ rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
105
+ directories_removed_count = remove_directory_from_additional_directories(
106
+ settings, project_path
107
+ )
108
+ environment_entries_removed_count = remove_auto_mode_environment_entry(
109
+ settings, environment_entry
110
+ )
111
+ total_changes_count = (
112
+ rules_removed_count
113
+ + directories_removed_count
114
+ + environment_entries_removed_count
115
+ )
116
+ if total_changes_count == 0:
117
+ print(f"Project path: {project_path}")
118
+ print(f"Settings file: {CLAUDE_USER_SETTINGS_PATH}")
119
+ print("No changes to revoke; settings file left untouched.")
120
+ return
121
+ prune_settings_after_revoke(settings)
122
+ save_settings(CLAUDE_USER_SETTINGS_PATH, settings)
123
+ print(f"Project path: {project_path}")
124
+ print(f"Settings file: {CLAUDE_USER_SETTINGS_PATH}")
125
+ print(f"Allow rules removed: {rules_removed_count} of {len(permission_rules)}")
126
+ print(f"Additional directories removed: {directories_removed_count}")
127
+ print(
128
+ f"Auto-mode environment entries removed: {environment_entries_removed_count}"
129
+ )
130
+
131
+
132
+ if __name__ == "__main__":
133
+ try:
134
+ revoke_permissions_for_current_directory()
135
+ except ValueError as path_error:
136
+ exit_with_error(str(path_error))
@@ -30,9 +30,18 @@ Determine the audit target in this order:
30
30
 
31
31
  ### Step 2: Capture the full PR diff
32
32
 
33
- When a PR exists: `gh pr diff <number> -R <owner>/<repo> > .findbugs-pr.patch`.
33
+ Resolve the temp diff path **once, Claude-side**, before invoking any shell command. Use Python's `tempfile.gettempdir()` which honors `TMPDIR`, `TEMP`, and `TMP` in the platform-correct order and falls back to `C:\Users\<user>\AppData\Local\Temp` on Windows or `/tmp` on Unix. Avoid hand-rolled env var chains. The lookup works on macOS, Linux, Windows cmd.exe, and PowerShell:
34
34
 
35
- When falling back to merge-base diff: `git diff <merge-base>...HEAD > .findbugs-pr.patch`.
35
+ ```
36
+ import tempfile
37
+ diff_temp_path = Path(tempfile.gettempdir()) / f"findbugs-pr-{os.getpid()}.patch"
38
+ ```
39
+
40
+ `os.getpid()` supplies the per-invocation suffix that prevents collisions with parallel `/findbugs` runs (a UUID or timestamp is equally acceptable). Capture the resolved absolute path as `<diff_temp_path>` and pass that **literal** path to every shell command that follows. Shell-side parameter expansion (`${TMPDIR:-/tmp}`, `$$`, `%TEMP%`) is forbidden because cmd.exe and PowerShell do not honor it.
41
+
42
+ When a PR exists: `gh pr diff <number> -R <owner>/<repo> > "<diff_temp_path>"`.
43
+
44
+ When falling back to merge-base diff: `git diff <merge-base>...HEAD > "<diff_temp_path>"`.
36
45
 
37
46
  The audit's authoritative scope is this single diff file. Do not inject extra files, related history, or "files Claude edited this session" — those introduce anchoring bias.
38
47
 
@@ -63,7 +72,7 @@ The XML prompt skeleton:
63
72
  </context>
64
73
 
65
74
  <scope>
66
- <diff_path>.findbugs-pr.patch (absolute path)</diff_path>
75
+ <diff_path><diff_temp_path> (absolute scoped temp path from Step 2)</diff_path>
67
76
  <scope_rule>Audit only lines added or modified in the diff. Pre-existing code on untouched lines is out of scope.</scope_rule>
68
77
  </scope>
69
78
 
@@ -122,7 +131,7 @@ When the agent returns, report concisely:
122
131
 
123
132
  Offer the next step without auto-executing it: `Want me to spawn clean-coder to fix the P0/P1 findings?`
124
133
 
125
- Delete `.findbugs-pr.patch` after the audit completes (or moves to a fix flow). Temporary diff files do not belong in the working tree.
134
+ Delete the scoped temp diff at `<diff_temp_path>` after the audit completes (or moves to a fix flow). Temporary diff files do not belong in the working tree.
126
135
 
127
136
  Do not paste the full agent transcript or the XML prompt unless the user asks.
128
137
 
@@ -153,7 +162,7 @@ Want me to spawn clean-coder to fix the P0/P1 findings?
153
162
  - **Clean-room prompt.** The agent's prompt is self-contained — no references to chat history, no anchoring hints, no expected outcomes.
154
163
  - **No clean-coder auto-spawn.** Always ask before fixing.
155
164
  - **Trust the agent's verdict.** Pass through P0/P1/P2 categorizations as the agent assigned them; do not re-rank.
156
- - **Temp file cleanup.** Delete `.findbugs-pr.patch` when the audit ends.
165
+ - **Temp file cleanup.** Delete the scoped `<diff_temp_path>` when the audit ends.
157
166
 
158
167
  ## Examples
159
168
 
@@ -23,6 +23,7 @@ Refusal cases:
23
23
  - **No findings in session.** Respond exactly: `No findings in this session. Run /findbugs first.` and stop.
24
24
  - **Most recent /findbugs returned zero bugs.** Respond exactly: `No bugs to fix.` and stop.
25
25
  - **Filter excludes every finding.** Respond: `No bugs match the filter <args>.` and stop.
26
+ - **Agent-prompt skill not installed.** Before Step 1, verify the `agent-prompt` skill is in the available skills list. If missing, respond: `agent-prompt skill not installed. /fixbugs hands off to it; install it first.` and stop.
26
27
 
27
28
  ## The Process
28
29