claude-dev-env 8.78.3 → 8.80.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/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
- package/bin/install.settings-defaults.test.mjs +27 -2
- package/bin/merge_settings_defaults.mjs +39 -21
- package/bin/merge_settings_defaults.test.mjs +42 -0
- package/docs/account-broker.md +1 -1
- package/docs/codex-accounts.md +6 -5
- package/hooks/blocking/cloud_graphql_gate.py +213 -0
- package/hooks/blocking/headless_claude_broker_gate.py +1 -8
- package/hooks/blocking/merge_over_review_offer_gate.py +114 -0
- package/hooks/blocking/merge_review_gate.py +65 -3
- package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
- package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
- package/hooks/blocking/test_merge_over_review_offer_gate.py +138 -0
- package/hooks/blocking/test_merge_review_gate.py +63 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
- package/hooks/conftest.py +1 -1
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +5 -2
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
- package/hooks/hooks_constants/cloud_session.py +17 -0
- package/hooks/hooks_constants/cloud_session_constants.py +11 -0
- package/hooks/hooks_constants/gh_command_words.py +52 -0
- package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
- package/hooks/hooks_constants/hook_block_logger.py +21 -3
- package/hooks/hooks_constants/merge_over_review_offer_gate_constants.py +34 -0
- package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
- package/hooks/hooks_constants/mod_handoff.py +2 -3
- package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
- package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
- package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
- package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/test_cloud_session.py +27 -0
- package/hooks/hooks_constants/test_gh_command_words.py +45 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
- package/hooks/hooks_constants/test_mod_handoff.py +3 -2
- package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
- package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
- package/hooks/test_conftest.py +1 -1
- package/package.json +1 -1
- package/scripts/account_broker.py +23 -11
- package/scripts/account_broker_support.py +23 -14
- package/scripts/codex_account_meters.py +30 -43
- package/scripts/conftest.py +25 -0
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +36 -25
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +57 -14
- package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
- package/scripts/merge_review_receipt.py +56 -12
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +145 -3
- package/scripts/test_account_broker_support.py +88 -6
- package/scripts/test_claude_chain_usage.py +3 -11
- package/scripts/test_codex_account_meters.py +26 -14
- package/scripts/test_merge_review_receipt.py +86 -0
- package/scripts/test_shared_tree_paths.py +58 -36
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/settings.json +3 -0
|
@@ -8,6 +8,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
|
|
|
8
8
|
- `blocking/msys_rev_path_rewriter.py` adds a narrow `MSYS2_ARG_CONV_EXCL` prefix for affected revision and path tokens.
|
|
9
9
|
- `blocking/headless_claude_broker_gate.py` denies a headless `claude -p` or `claude --print` call and names the broker command to run instead.
|
|
10
10
|
- `blocking/gh_global_account_switch_gate.py` denies `gh auth switch`, `gh auth login`, and `gh auth logout` and names the per-command `GH_TOKEN` form to run instead.
|
|
11
|
+
- `blocking/cloud_graphql_gate.py` denies the `gh` commands that call GraphQL (`gh api graphql` with any flags before the endpoint, `gh pr`, `gh issue`, `gh project` and `gh discussion` except `gh pr diff` and `gh pr list --web`, `gh status`, `gh org list`, `gh release list`, `gh release view` with a tag, `gh workflow run` without `--ref`, and a few `repo`, `label`, `search`, `ruleset` and `gist` commands, read past `if`, `!` and command substitutions) and an HTTP client or `python -c` style call to `api.github.com/graphql`, with or without a port, inside a cloud session, where the proxy answers GraphQL with HTTP 403, and names the REST routes to run instead.
|
|
11
12
|
- `blocking/bash_post_call_dispatcher.py` runs hosted observers and joins their context output without blocking the call.
|
|
12
13
|
- `advisory/pr_done_reminder.py` adds a pull request checklist after a successful push or pull request creation.
|
|
13
14
|
|
|
@@ -17,6 +18,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
|
|
|
17
18
|
- `blocking/msys_rev_path_rewriter.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds. `ALL_BASH_HOSTED_HOOK_ENTRIES` selects the `Bash` tool.
|
|
18
19
|
- `blocking/headless_claude_broker_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools.
|
|
19
20
|
- `blocking/gh_global_account_switch_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools.
|
|
21
|
+
- `blocking/cloud_graphql_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools, when `CLAUDE_CODE_REMOTE` is `true`.
|
|
20
22
|
- `blocking/bash_post_call_dispatcher.py` runs on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds in `hooks.json`.
|
|
21
23
|
- `advisory/pr_done_reminder.py` runs inside that dispatcher on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds. `ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES` selects `Bash` and `PowerShell`.
|
|
22
24
|
|
|
@@ -29,6 +31,7 @@ Preconditions:
|
|
|
29
31
|
- **Pre-call dispatch.** Input is a clean Bash command. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_bash_pre_tool_use_dispatcher.py -q`. The adjacent test observes the combined decision and hosted precedence.
|
|
30
32
|
- **Revision rewrite.** Input is a Git command with `origin/main:.claude/settings.json`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_msys_rev_path_rewriter.py -q`. The adjacent test observes the `origin/main:` exclusion prefix.
|
|
31
33
|
- **Broker gate.** Input is a `claude -p task` command. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_headless_claude_broker_gate.py -q`. The adjacent test observes a denial that names the broker command.
|
|
34
|
+
- **Cloud GraphQL gate.** Input is a `gh api graphql` command with `CLAUDE_CODE_REMOTE=true`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_cloud_graphql_gate.py -q`. The adjacent test observes a denial that names the REST routes, and silence outside a cloud session.
|
|
32
35
|
- **Post-call dispatch.** Input is a Bash call that yields hosted context. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_bash_post_call_dispatcher.py -q`. The adjacent test observes a joined context payload and exit code zero.
|
|
33
36
|
- **Pull request reminder.** Input is a completed push or pull request creation. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_pr_done_reminder.py -q`. The adjacent test checks the reminder trigger and checklist verdict.
|
|
34
37
|
|
|
@@ -12,7 +12,8 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
12
12
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
13
13
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
14
14
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
15
|
-
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets.
|
|
15
|
+
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets.
|
|
16
|
+
- `blocking/merge_over_review_offer_gate.py` denies a decision card or question that offers a merge while it names open review findings, a red check, unaddressed review comments, or an override. It reads the whole card as one text: every question, header, context, reason, and option, whether the option is a string or a label with a consequence or description.
|
|
16
17
|
- `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
|
|
17
18
|
|
|
18
19
|
## When it fires
|
|
@@ -26,6 +27,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
26
27
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
27
28
|
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
|
|
28
29
|
- `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
|
|
30
|
+
- `blocking/merge_over_review_offer_gate.py` runs on `PreToolUse`, matcher `AskUserQuestion|mcp__.*__ask_decision`, timeout `10` seconds in `hooks.json`.
|
|
29
31
|
- `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
|
|
30
32
|
|
|
31
33
|
## Proving it
|
|
@@ -43,6 +45,7 @@ Preconditions:
|
|
|
43
45
|
- **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
|
|
44
46
|
- **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
|
|
45
47
|
- **Clean review.** Input is a merge with no receipt, a receipt with findings, a merge pinned to a newer head, an unpinned merge, or auto-merge with no head. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_review_gate.py -q`. The adjacent test observes a denial for each; a merge or auto-merge pinned to the clean head and a merge in an exempt repository pass.
|
|
48
|
+
- **Merge offer.** Input is a decision card or AskUserQuestion that offers "Merge now", the merge queue, or a yes to "Merge it now?" while any part of the card names open findings or unaddressed review comments. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_over_review_offer_gate.py -q`. The adjacent test observes a denial for each, including an offer and findings in separate questions; a card with no merge offer, a card about the merge review check itself, a card about shipping a docs change, and a merge offer after a clean review pass.
|
|
46
49
|
- **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
|
|
47
50
|
|
|
48
51
|
## Gotchas
|
|
@@ -54,4 +57,4 @@ Preconditions:
|
|
|
54
57
|
- The handoff close gate reads the `body` and `comment` fields of issue tools only. A `gh issue close --comment` command in Bash and a pull request body that closes the issue with `Closes #N` do not reach it.
|
|
55
58
|
- The lifecycle gate counts only invocations after the last compaction, so a compacted session invokes `pr-lifecycle` again. A missing or unreadable transcript allows the call.
|
|
56
59
|
- The dark mode gate denies a page publish when Node, Playwright or Chromium is missing, and its reason names the install command. It checks both dark signals, `prefers-color-scheme: dark` and `data-theme="dark"` on the root element.
|
|
57
|
-
- The clean-review gate trusts the receipt. A receipt the agent writes by hand with zero findings passes, so the pr-lifecycle skill names the review loop that writes it. A `gh pr merge` that names no repository is denied, because the gate cannot tell which repository the current branch belongs to.
|
|
60
|
+
- The clean-review gate trusts the receipt. A receipt the agent writes by hand with zero findings, or with every finding ruled out and a rulings link, passes, so the pr-lifecycle skill names the review loop that writes it. A `gh pr merge` that names no repository is denied, because the gate cannot tell which repository the current branch belongs to.
|
package/hooks/hooks.json
CHANGED
|
@@ -164,6 +164,16 @@
|
|
|
164
164
|
}
|
|
165
165
|
]
|
|
166
166
|
},
|
|
167
|
+
{
|
|
168
|
+
"matcher": "AskUserQuestion|mcp__.*__ask_decision",
|
|
169
|
+
"hooks": [
|
|
170
|
+
{
|
|
171
|
+
"type": "command",
|
|
172
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/merge_over_review_offer_gate.py",
|
|
173
|
+
"timeout": 10
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
},
|
|
167
177
|
{
|
|
168
178
|
"matcher": "mcp__.*__(add_issue_comment|issue_write|update_issue_comment)",
|
|
169
179
|
"hooks": [
|
|
@@ -75,4 +75,8 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
|
|
|
75
75
|
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
76
76
|
replaced_by_plugin_name=SHELL_GUARDS_PLUGIN_NAME,
|
|
77
77
|
),
|
|
78
|
+
BashHostedHookEntry(
|
|
79
|
+
script_relative_path="blocking/cloud_graphql_gate.py",
|
|
80
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
81
|
+
),
|
|
78
82
|
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Constants for the gate that stops cloud sessions from calling GitHub GraphQL.
|
|
2
|
+
|
|
3
|
+
Holds the ``gh`` commands that call GraphQL, the ``gh`` subcommands inside
|
|
4
|
+
those groups that call REST, the options that keep a GraphQL command on REST,
|
|
5
|
+
the commands that call GraphQL only with an operand or only with certain
|
|
6
|
+
options, the help flags that reach no network, the HTTP client and script
|
|
7
|
+
interpreter programs and the GraphQL endpoint pattern they could name, the
|
|
8
|
+
hook name the block log records, and the deny message that names the REST
|
|
9
|
+
routes to run instead.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"GH_PROGRAM_NAME",
|
|
18
|
+
"GH_API_SUBCOMMAND",
|
|
19
|
+
"GH_GRAPHQL_ENDPOINT",
|
|
20
|
+
"GH_ENDPOINT_PATH_SEPARATOR",
|
|
21
|
+
"GH_COMMAND_PATH_LENGTH",
|
|
22
|
+
"ALL_GH_GRAPHQL_COMMAND_GROUPS",
|
|
23
|
+
"ALL_GH_REST_COMMAND_PATHS_IN_GRAPHQL_GROUPS",
|
|
24
|
+
"ALL_GH_GRAPHQL_COMMAND_PATHS",
|
|
25
|
+
"ALL_GH_HELP_FLAGS",
|
|
26
|
+
"ALL_GH_REST_OPTIONS_BY_COMMAND_PATH",
|
|
27
|
+
"ALL_GH_COMMAND_PATHS_CALLING_GRAPHQL_WITH_AN_OPERAND",
|
|
28
|
+
"ALL_GH_GRAPHQL_OPTIONS_BY_COMMAND_PATH",
|
|
29
|
+
"ALL_HTTP_CLIENT_PROGRAM_NAMES",
|
|
30
|
+
"ALL_SCRIPT_INTERPRETER_PROGRAM_NAMES",
|
|
31
|
+
"GITHUB_GRAPHQL_ENDPOINT_PATTERN",
|
|
32
|
+
"GATE_HOOK_NAME",
|
|
33
|
+
"CLOUD_GRAPHQL_DENY_REASON",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
GH_PROGRAM_NAME: str = "gh"
|
|
37
|
+
GH_API_SUBCOMMAND: str = "api"
|
|
38
|
+
GH_GRAPHQL_ENDPOINT: str = "graphql"
|
|
39
|
+
GH_ENDPOINT_PATH_SEPARATOR: str = "/"
|
|
40
|
+
GH_COMMAND_PATH_LENGTH: int = 2
|
|
41
|
+
ALL_GH_GRAPHQL_COMMAND_GROUPS: frozenset[str] = frozenset({"pr", "issue", "project", "discussion"})
|
|
42
|
+
ALL_GH_REST_COMMAND_PATHS_IN_GRAPHQL_GROUPS: frozenset[tuple[str, ...]] = frozenset(
|
|
43
|
+
{("pr", "diff")}
|
|
44
|
+
)
|
|
45
|
+
ALL_GH_GRAPHQL_COMMAND_PATHS: frozenset[tuple[str, ...]] = frozenset(
|
|
46
|
+
{
|
|
47
|
+
("status",),
|
|
48
|
+
("org", "list"),
|
|
49
|
+
("repo", "view"),
|
|
50
|
+
("repo", "clone"),
|
|
51
|
+
("repo", "list"),
|
|
52
|
+
("repo", "archive"),
|
|
53
|
+
("repo", "unarchive"),
|
|
54
|
+
("label", "list"),
|
|
55
|
+
("search", "prs"),
|
|
56
|
+
("search", "issues"),
|
|
57
|
+
("ruleset", "list"),
|
|
58
|
+
("gist", "list"),
|
|
59
|
+
("release", "list"),
|
|
60
|
+
("workflow", "run"),
|
|
61
|
+
("repo", "sync"),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
ALL_GH_HELP_FLAGS: frozenset[str] = frozenset({"--help", "-h"})
|
|
65
|
+
ALL_GH_REST_OPTIONS_BY_COMMAND_PATH: dict[tuple[str, ...], frozenset[str]] = {
|
|
66
|
+
("pr", "list"): frozenset({"--web", "-w"}),
|
|
67
|
+
("workflow", "run"): frozenset({"--ref", "-r"}),
|
|
68
|
+
("repo", "sync"): frozenset({"--branch", "-b"}),
|
|
69
|
+
}
|
|
70
|
+
ALL_GH_GRAPHQL_OPTIONS_BY_COMMAND_PATH: dict[tuple[str, ...], frozenset[str]] = {
|
|
71
|
+
("repo", "create"): frozenset({"--template", "-p"}),
|
|
72
|
+
("repo", "edit"): frozenset(
|
|
73
|
+
{
|
|
74
|
+
"--enable-advanced-security",
|
|
75
|
+
"--enable-secret-scanning",
|
|
76
|
+
"--enable-secret-scanning-push-protection",
|
|
77
|
+
}
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
ALL_GH_COMMAND_PATHS_CALLING_GRAPHQL_WITH_AN_OPERAND: frozenset[tuple[str, ...]] = frozenset(
|
|
81
|
+
{("release", "view")}
|
|
82
|
+
)
|
|
83
|
+
ALL_HTTP_CLIENT_PROGRAM_NAMES: frozenset[str] = frozenset(
|
|
84
|
+
{"curl", "wget", "http", "https", "invoke-restmethod", "invoke-webrequest", "irm", "iwr"}
|
|
85
|
+
)
|
|
86
|
+
ALL_SCRIPT_INTERPRETER_PROGRAM_NAMES: frozenset[str] = frozenset(
|
|
87
|
+
{"node", "deno", "bun", "ruby", "perl", "php"}
|
|
88
|
+
)
|
|
89
|
+
GITHUB_GRAPHQL_ENDPOINT_PATTERN: re.Pattern[str] = re.compile(
|
|
90
|
+
r"api\.github\.com(?::\d+)?/graphql", re.IGNORECASE
|
|
91
|
+
)
|
|
92
|
+
GATE_HOOK_NAME: str = "cloud_graphql_gate.py"
|
|
93
|
+
CLOUD_GRAPHQL_DENY_REASON: str = (
|
|
94
|
+
"BLOCKED: the Claude Code cloud proxy answers every GitHub GraphQL request with "
|
|
95
|
+
"HTTP 403, whatever the token. gh api graphql calls it, and so do gh pr and gh issue "
|
|
96
|
+
"(all but gh pr diff and gh pr list --web), gh project, gh discussion, gh status, "
|
|
97
|
+
"gh org list, gh repo view/clone/list/archive/unarchive, gh label list, "
|
|
98
|
+
"gh search prs/issues, gh ruleset list, gh gist list, gh release list, "
|
|
99
|
+
"gh release view with a tag, gh workflow run without --ref, gh repo sync without "
|
|
100
|
+
"--branch, gh repo create --template, and gh repo edit with a security setting. "
|
|
101
|
+
"Use REST through gh api repos/{owner}/{repo}/... or the mcp__github__ tools. "
|
|
102
|
+
"Open a PR: mcp__github__create_pull_request, or gh api repos/{owner}/{repo}/pulls "
|
|
103
|
+
"--input pr.json with title, head, base, body and draft in the JSON. "
|
|
104
|
+
"Read a PR: gh api repos/{owner}/{repo}/pulls/{n}. "
|
|
105
|
+
"Checks: gh api repos/{owner}/{repo}/commits/{sha}/check-runs. "
|
|
106
|
+
"Issues: gh api repos/{owner}/{repo}/issues/{n}. "
|
|
107
|
+
"Clone: git clone https://github.com/{owner}/{repo}. "
|
|
108
|
+
"Review threads: GET /repos/{owner}/{repo}/pulls/{n}/ccr/review_threads. "
|
|
109
|
+
"Resolve a thread: POST /repos/{owner}/{repo}/pulls/{n}/ccr/comments/{comment_id}/resolve "
|
|
110
|
+
"(or /unresolve). "
|
|
111
|
+
"Auto-merge: PUT or DELETE /repos/{owner}/{repo}/pulls/{n}/ccr/auto_merge. "
|
|
112
|
+
"Draft state: POST /repos/{owner}/{repo}/pulls/{n}/ccr/ready_for_review "
|
|
113
|
+
"or /ccr/convert_to_draft. "
|
|
114
|
+
"A query with no REST route cannot run in this session; it needs a machine outside "
|
|
115
|
+
"the cloud proxy."
|
|
116
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Tell a hook whether it runs inside a Claude Code cloud session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from hooks_constants.cloud_session_constants import (
|
|
8
|
+
CLOUD_SESSION_ENV_TRUE_VALUE,
|
|
9
|
+
CLOUD_SESSION_ENV_VAR,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = ["is_cloud_session"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_cloud_session() -> bool:
|
|
16
|
+
"""Return True when ``CLAUDE_CODE_REMOTE`` is ``true``, whatever its case or padding."""
|
|
17
|
+
return os.environ.get(CLOUD_SESSION_ENV_VAR, "").strip().lower() == CLOUD_SESSION_ENV_TRUE_VALUE
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Names the environment variable and value a Claude Code cloud session sets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"CLOUD_SESSION_ENV_VAR",
|
|
7
|
+
"CLOUD_SESSION_ENV_TRUE_VALUE",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
CLOUD_SESSION_ENV_VAR: str = "CLAUDE_CODE_REMOTE"
|
|
11
|
+
CLOUD_SESSION_ENV_TRUE_VALUE: str = "true"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Read the command words and the standalone options of a ``gh`` call.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
gh -R o/r pr list --web words ["pr", "list"], options ["-R", "--web"]
|
|
6
|
+
gh api -X POST graphql words ["api", "graphql"], options ["-X"]
|
|
7
|
+
gh pr merge 12 --body -h words ["pr", "merge", "12"], options ["--body"]
|
|
8
|
+
|
|
9
|
+
An option's separate value is neither a word nor an option, so a value that
|
|
10
|
+
looks like a flag, such as the ``-h`` body above, reads as text.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from hooks_constants.gh_command_words_constants import (
|
|
16
|
+
ALL_GH_OPTIONS_WITH_A_VALUE,
|
|
17
|
+
GH_OPTION_PREFIX,
|
|
18
|
+
GH_OPTION_VALUE_SEPARATOR,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = ["gh_command_words", "gh_command_words_and_options"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def gh_command_words_and_options(all_arguments: list[str]) -> tuple[list[str], list[str]]:
|
|
25
|
+
"""Return the command words and the option names in one pass.
|
|
26
|
+
|
|
27
|
+
Each option's separate value is skipped, and an option name drops any
|
|
28
|
+
``=value`` part.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
all_arguments: The tokens after the ``gh`` program name.
|
|
32
|
+
"""
|
|
33
|
+
all_command_words: list[str] = []
|
|
34
|
+
all_option_names: list[str] = []
|
|
35
|
+
is_option_value = False
|
|
36
|
+
for each_argument in all_arguments:
|
|
37
|
+
is_option = not is_option_value and each_argument.startswith(GH_OPTION_PREFIX)
|
|
38
|
+
if is_option:
|
|
39
|
+
all_option_names.append(each_argument.split(GH_OPTION_VALUE_SEPARATOR, 1)[0])
|
|
40
|
+
if not is_option and not is_option_value:
|
|
41
|
+
all_command_words.append(each_argument)
|
|
42
|
+
is_option_value = is_option and each_argument in ALL_GH_OPTIONS_WITH_A_VALUE
|
|
43
|
+
return all_command_words, all_option_names
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def gh_command_words(all_arguments: list[str]) -> list[str]:
|
|
47
|
+
"""Return the gh arguments that are neither an option nor an option's separate value.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
all_arguments: The tokens after the ``gh`` program name.
|
|
51
|
+
"""
|
|
52
|
+
return gh_command_words_and_options(all_arguments)[0]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Names the ``gh`` options that take a separate value, and the option prefix and value separator.
|
|
2
|
+
|
|
3
|
+
A reader of a ``gh`` command skips the token after each of these options, so
|
|
4
|
+
``gh -R o/r pr list`` reads as ``pr list`` and ``gh pr merge 12 --body -h``
|
|
5
|
+
reads ``-h`` as the body text. ``-f``, ``-F``, ``-H``, ``-p`` and ``-t`` take a value in
|
|
6
|
+
``gh api``, ``gh workflow run`` and ``gh pr create`` alike.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ALL_GH_OPTIONS_WITH_A_VALUE",
|
|
13
|
+
"GH_OPTION_PREFIX",
|
|
14
|
+
"GH_OPTION_VALUE_SEPARATOR",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
ALL_GH_OPTIONS_WITH_A_VALUE: frozenset[str] = frozenset(
|
|
18
|
+
{
|
|
19
|
+
"-R",
|
|
20
|
+
"--repo",
|
|
21
|
+
"--hostname",
|
|
22
|
+
"--config",
|
|
23
|
+
"-H",
|
|
24
|
+
"--header",
|
|
25
|
+
"--head",
|
|
26
|
+
"-X",
|
|
27
|
+
"--method",
|
|
28
|
+
"-F",
|
|
29
|
+
"--field",
|
|
30
|
+
"--body-file",
|
|
31
|
+
"-f",
|
|
32
|
+
"--raw-field",
|
|
33
|
+
"--input",
|
|
34
|
+
"-q",
|
|
35
|
+
"--jq",
|
|
36
|
+
"-t",
|
|
37
|
+
"--template",
|
|
38
|
+
"-p",
|
|
39
|
+
"--preview",
|
|
40
|
+
"--title",
|
|
41
|
+
"--cache",
|
|
42
|
+
"--json",
|
|
43
|
+
"-b",
|
|
44
|
+
"--body",
|
|
45
|
+
"-B",
|
|
46
|
+
"--base",
|
|
47
|
+
"-S",
|
|
48
|
+
"--search",
|
|
49
|
+
"-L",
|
|
50
|
+
"--limit",
|
|
51
|
+
"-s",
|
|
52
|
+
"--state",
|
|
53
|
+
"-l",
|
|
54
|
+
"--label",
|
|
55
|
+
"-a",
|
|
56
|
+
"--assignee",
|
|
57
|
+
"-A",
|
|
58
|
+
"--author",
|
|
59
|
+
"-m",
|
|
60
|
+
"--milestone",
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
GH_OPTION_PREFIX: str = "-"
|
|
64
|
+
GH_OPTION_VALUE_SEPARATOR: str = "="
|
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
Holds the Claude program name and its profile-launcher prefix, the Windows
|
|
4
4
|
launcher suffixes stripped before the name check, the two print-mode flags, the
|
|
5
5
|
hook name the block log records, and the deny message that names the broker
|
|
6
|
-
command to run instead.
|
|
7
|
-
cloud session sets, where the gate stands aside because the session has one
|
|
8
|
-
account.
|
|
6
|
+
command to run instead.
|
|
9
7
|
"""
|
|
10
8
|
|
|
11
9
|
from __future__ import annotations
|
|
@@ -18,8 +16,6 @@ __all__ = [
|
|
|
18
16
|
"PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX",
|
|
19
17
|
"GATE_HOOK_NAME",
|
|
20
18
|
"BROKER_COMMAND_DENY_REASON",
|
|
21
|
-
"CLOUD_SESSION_ENV_VAR",
|
|
22
|
-
"CLOUD_SESSION_ENV_TRUE_VALUE",
|
|
23
19
|
]
|
|
24
20
|
|
|
25
21
|
CLAUDE_PROGRAM_NAME: str = "claude"
|
|
@@ -28,8 +24,6 @@ ALL_WINDOWS_LAUNCHER_SUFFIXES: tuple[str, ...] = (".cmd", ".exe", ".ps1", ".bat"
|
|
|
28
24
|
ALL_PRINT_MODE_FLAGS: frozenset[str] = frozenset({"-p", "--print"})
|
|
29
25
|
PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX: str = "--print="
|
|
30
26
|
GATE_HOOK_NAME: str = "headless_claude_broker_gate.py"
|
|
31
|
-
CLOUD_SESSION_ENV_VAR: str = "CLAUDE_CODE_REMOTE"
|
|
32
|
-
CLOUD_SESSION_ENV_TRUE_VALUE: str = "true"
|
|
33
27
|
BROKER_COMMAND_DENY_REASON: str = (
|
|
34
28
|
"BLOCKED: Start a headless Claude session through the account broker. "
|
|
35
29
|
"Run: python \"$HOME/.claude/scripts/account_broker.py\" run --product claude "
|
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
"""Shared fail-safe logger for hook block events.
|
|
2
2
|
|
|
3
3
|
Every blocking hook calls log_hook_block at the moment it decides to block,
|
|
4
|
-
so the user has a single log showing what tripped and why.
|
|
4
|
+
so the user has a single log showing what tripped and why. A GitHub token or
|
|
5
|
+
an Authorization header value in the preview is replaced before it is written.
|
|
5
6
|
"""
|
|
6
7
|
|
|
7
8
|
import datetime
|
|
8
9
|
import json
|
|
10
|
+
import re
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
|
|
11
13
|
_HOOK_BLOCKS_LOG_RELATIVE_PATH = ".claude/logs/hook-blocks.log"
|
|
12
14
|
_MAX_PREVIEW_LENGTH = 500
|
|
15
|
+
_REDACTION_MARKER = "[REDACTED]"
|
|
16
|
+
_GITHUB_TOKEN_PATTERN = re.compile(r"\b(?:gh[pousr]_|github_pat_)[A-Za-z0-9_]+")
|
|
17
|
+
_AUTHORIZATION_VALUE_PATTERN = re.compile(
|
|
18
|
+
r"(?P<header>authorization['\"]?\s*:\s*['\"]?)[^'\"\r\n]+", re.IGNORECASE
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _preview_without_secrets(offending_input_preview: str) -> str:
|
|
23
|
+
"""Return the preview with GitHub tokens and Authorization header values replaced."""
|
|
24
|
+
without_tokens = _GITHUB_TOKEN_PATTERN.sub(_REDACTION_MARKER, offending_input_preview)
|
|
25
|
+
return _AUTHORIZATION_VALUE_PATTERN.sub(
|
|
26
|
+
lambda each_match: each_match.group("header") + _REDACTION_MARKER, without_tokens
|
|
27
|
+
)
|
|
13
28
|
|
|
14
29
|
|
|
15
30
|
def log_hook_block(
|
|
@@ -31,7 +46,8 @@ def log_hook_block(
|
|
|
31
46
|
block_reason: The human-readable reason the hook is blocking.
|
|
32
47
|
tool_name: The Claude tool name when available, e.g. ``Bash``.
|
|
33
48
|
offending_input_preview: A short excerpt of the input that triggered
|
|
34
|
-
the block; truncated to 500
|
|
49
|
+
the block; secrets are replaced, then it is truncated to 500
|
|
50
|
+
characters before writing.
|
|
35
51
|
"""
|
|
36
52
|
try:
|
|
37
53
|
home_directory = Path.home()
|
|
@@ -51,7 +67,9 @@ def log_hook_block(
|
|
|
51
67
|
if tool_name is not None:
|
|
52
68
|
log_record["tool"] = tool_name
|
|
53
69
|
if offending_input_preview is not None:
|
|
54
|
-
log_record["preview"] = offending_input_preview[
|
|
70
|
+
log_record["preview"] = _preview_without_secrets(offending_input_preview)[
|
|
71
|
+
:_MAX_PREVIEW_LENGTH
|
|
72
|
+
]
|
|
55
73
|
|
|
56
74
|
with log_path.open("a", encoding="utf-8") as log_file:
|
|
57
75
|
log_file.write(json.dumps(log_record) + "\n")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Constants for the gate that keeps merge-over-review offers off decision cards."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
TOOL_INPUT_KEY = "tool_input"
|
|
6
|
+
HOOK_EVENT_NAME = "PreToolUse"
|
|
7
|
+
DENY_DECISION = "deny"
|
|
8
|
+
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
9
|
+
ALL_CARD_TEXT_FIELDS = ("question", "header", "context", "reason")
|
|
10
|
+
QUESTIONS_FIELD = "questions"
|
|
11
|
+
OPTIONS_FIELD = "options"
|
|
12
|
+
TEXT_SEPARATOR = " "
|
|
13
|
+
ALL_OPTION_TEXT_FIELDS = ("label", "consequence", "description")
|
|
14
|
+
MERGE_OFFER_PATTERN = re.compile(
|
|
15
|
+
r"\b(?:merg(?:e|es|ing)|squash(?:es|ing)?|auto-merge)\b"
|
|
16
|
+
r"(?!\s+(?:review|check|base|conflicts?|state|status|gate)\b)",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
CLEAN_REVIEW_PATTERN = re.compile(
|
|
20
|
+
r"\b(?:no|zero|0)\s+(?:open\s+|review\s+|blocking\s+)?(?:findings?|failures?|threads?|comments?)\b",
|
|
21
|
+
re.IGNORECASE,
|
|
22
|
+
)
|
|
23
|
+
OPEN_REVIEW_PATTERN = re.compile(
|
|
24
|
+
r"\b(?:findings?|override|red|failing|failed|failures?|not clean|unresolved|unaddressed"
|
|
25
|
+
r"|changes requested|review comments?|open (?:review )?threads?|issues? (?:left|remain(?:ing)?)"
|
|
26
|
+
r"|\d+ remaining)\b",
|
|
27
|
+
re.IGNORECASE,
|
|
28
|
+
)
|
|
29
|
+
MERGE_OVER_REVIEW_OFFER_REASON = (
|
|
30
|
+
"This card offers a merge while it names open review findings, a red check, or an"
|
|
31
|
+
" override. A pull request merges only after a review pass on its head reports zero"
|
|
32
|
+
" findings. Drop the merge offer, keep fixing and rerunning the review, and ask only"
|
|
33
|
+
" about choices that keep that bar."
|
|
34
|
+
)
|
|
@@ -25,6 +25,10 @@ PR_NUMBER_PATTERN = re.compile(r"(?:^|\s)#?(?P<number>\d+)(?:\s|$)")
|
|
|
25
25
|
PR_URL_PATTERN = re.compile(
|
|
26
26
|
r"github\.com/(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)/pull/(?P<number>\d+)"
|
|
27
27
|
)
|
|
28
|
+
RULINGS_LINK_PATTERN = re.compile(
|
|
29
|
+
r"https://github\.com/(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)/pull/(?P<number>\d+)"
|
|
30
|
+
r"#issuecomment-\d+"
|
|
31
|
+
)
|
|
28
32
|
REPO_OPTION_PATTERN = re.compile(r"(?:-R|--repo)[\s=](?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)")
|
|
29
33
|
MATCH_HEAD_PATTERN = re.compile(r"--match-head-commit[\s=](?P<sha>[0-9a-fA-F]{7,40})")
|
|
30
34
|
API_SHA_FIELD_PATTERN = re.compile(r"(?:-f|-F|--field|--raw-field)[\s=]sha=(?P<sha>[0-9a-fA-F]{7,40})")
|
|
@@ -43,15 +47,21 @@ RECEIPT_FILE_NAME_TEMPLATE = "{owner}__{repo}__{number}.json"
|
|
|
43
47
|
REPOSITORY_SEPARATOR = ","
|
|
44
48
|
RECEIPT_HEAD_KEY = "head"
|
|
45
49
|
RECEIPT_FINDINGS_KEY = "findings"
|
|
50
|
+
RECEIPT_RULED_OUT_KEY = "ruled_out"
|
|
51
|
+
RECEIPT_RULINGS_KEY = "rulings"
|
|
46
52
|
RECORD_COMMAND = "python3 ~/.claude/scripts/merge_review_receipt.py"
|
|
47
53
|
|
|
48
54
|
NO_CLEAN_REVIEW_REASON_TEMPLATE = (
|
|
49
55
|
"{slug}#{number} has no clean review on the head you are merging. Run the review "
|
|
50
56
|
"loop: review the pull request head with `{review_command}`, fix and push every "
|
|
51
57
|
"finding, and review again "
|
|
52
|
-
"until a pass reports zero findings
|
|
58
|
+
"until a pass reports zero findings or every finding is ruled out with file:line "
|
|
59
|
+
"evidence. Record that pass with `"
|
|
53
60
|
+ RECORD_COMMAND
|
|
54
|
-
+ " {slug} {number} <head sha>
|
|
61
|
+
+ " {slug} {number} <head sha> <finding count>`, adding `--ruled-out <finding count> "
|
|
62
|
+
"--rulings <link to the rulings comment on this pull request>` when every finding is "
|
|
63
|
+
"ruled out, then merge "
|
|
64
|
+
"pinned to that head."
|
|
55
65
|
)
|
|
56
66
|
UNPINNED_MERGE_REASON_TEMPLATE = (
|
|
57
67
|
"{slug}#{number} has a clean review for {head}, but this merge names no head. "
|
|
@@ -60,7 +70,8 @@ UNPINNED_MERGE_REASON_TEMPLATE = (
|
|
|
60
70
|
)
|
|
61
71
|
STALE_REVIEW_REASON_TEMPLATE = (
|
|
62
72
|
"{slug}#{number} was reviewed clean at {head}, and this merge names {pinned}. "
|
|
63
|
-
"Review the new head until a pass reports zero findings
|
|
73
|
+
"Review the new head until a pass reports zero findings or every finding is ruled "
|
|
74
|
+
"out, record it, then merge."
|
|
64
75
|
)
|
|
65
76
|
AUTO_MERGE_REASON_TEMPLATE = (
|
|
66
77
|
"Auto-merge on {slug}#{number} names no head, so it could merge a head no review "
|
|
@@ -13,10 +13,9 @@ import json
|
|
|
13
13
|
import os
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
+
from hooks_constants.cloud_session import is_cloud_session
|
|
16
17
|
from hooks_constants.mod_handoff_constants import (
|
|
17
18
|
ALL_SETTINGS_FILE_NAMES_IN_PRECEDENCE_ORDER,
|
|
18
|
-
CLOUD_SESSION_ENV_TRUE_VALUE,
|
|
19
|
-
CLOUD_SESSION_ENV_VAR,
|
|
20
19
|
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
21
20
|
CLAUDE_PROJECT_DIR_ENV_VAR,
|
|
22
21
|
DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME,
|
|
@@ -81,7 +80,7 @@ def is_mod_plugin_enabled(plugin_name: str) -> bool:
|
|
|
81
80
|
Args:
|
|
82
81
|
plugin_name: The plugin that holds the mod replacing the calling hook.
|
|
83
82
|
"""
|
|
84
|
-
if
|
|
83
|
+
if is_cloud_session():
|
|
85
84
|
return False
|
|
86
85
|
is_enabled = False
|
|
87
86
|
for each_path in all_settings_paths_in_precedence_order():
|
|
@@ -8,8 +8,6 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
__all__ = [
|
|
10
10
|
"ALL_SETTINGS_FILE_NAMES_IN_PRECEDENCE_ORDER",
|
|
11
|
-
"CLOUD_SESSION_ENV_TRUE_VALUE",
|
|
12
|
-
"CLOUD_SESSION_ENV_VAR",
|
|
13
11
|
"CLAUDE_CONFIG_DIR_ENV_VAR",
|
|
14
12
|
"CLAUDE_PROJECT_DIR_ENV_VAR",
|
|
15
13
|
"DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME",
|
|
@@ -22,8 +20,6 @@ __all__ = [
|
|
|
22
20
|
]
|
|
23
21
|
|
|
24
22
|
ENABLED_PLUGINS_SETTINGS_KEY = "enabledPlugins"
|
|
25
|
-
CLOUD_SESSION_ENV_VAR = "CLAUDE_CODE_REMOTE"
|
|
26
|
-
CLOUD_SESSION_ENV_TRUE_VALUE = "true"
|
|
27
23
|
PLUGIN_MARKETPLACE_SEPARATOR = "@"
|
|
28
24
|
SHELL_GUARDS_PLUGIN_NAME = "shell-guards"
|
|
29
25
|
SESSION_PROMPTS_PLUGIN_NAME = "session-prompts"
|
|
@@ -19,8 +19,9 @@ that consumes these program, wrapper, and option constants lives in
|
|
|
19
19
|
``pytest_invocation``.
|
|
20
20
|
|
|
21
21
|
Two wrapper shapes reach the program behind them differently. ``sudo``,
|
|
22
|
-
``uvx``, ``xargs``, ``env``, and ``
|
|
23
|
-
command, and
|
|
22
|
+
``uvx``, ``xargs``, ``env``, ``timeout``, ``command``, and ``exec`` take their own
|
|
23
|
+
options and then the command, and a shell keyword such as ``if``, ``then``,
|
|
24
|
+
``do``, or ``!`` runs the command after it, and ``ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME`` names each one's
|
|
24
25
|
value-taking options, its flag-only options, and the operands it reads before
|
|
25
26
|
the command, so ``xargs -I {} rm {}`` and ``timeout --signal KILL 5 git push``
|
|
26
27
|
both reach the program. ``uv``, ``poetry``, ``pipenv``, ``pdm``, ``hatch``, ``rye``, and
|
|
@@ -149,6 +150,8 @@ __all__ = [
|
|
|
149
150
|
"WrapperOptionGrammar",
|
|
150
151
|
"WINDOWS_EXECUTABLE_SUFFIX",
|
|
151
152
|
"RUN_SUBCOMMAND_OPTION_GRAMMAR",
|
|
153
|
+
"SHELL_KEYWORD_OPTION_GRAMMAR",
|
|
154
|
+
"ALL_SHELL_KEYWORDS_BEFORE_A_COMMAND",
|
|
152
155
|
"ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME",
|
|
153
156
|
"ALL_RUN_SUBCOMMAND_WRAPPER_COMMANDS",
|
|
154
157
|
"RUN_SUBCOMMAND_NAME",
|
|
@@ -312,6 +315,14 @@ RUN_SUBCOMMAND_OPTION_GRAMMAR = WrapperOptionGrammar(
|
|
|
312
315
|
all_flag_options=frozenset(),
|
|
313
316
|
leading_operand_count=0,
|
|
314
317
|
)
|
|
318
|
+
SHELL_KEYWORD_OPTION_GRAMMAR = WrapperOptionGrammar(
|
|
319
|
+
all_value_taking_options=frozenset(),
|
|
320
|
+
all_flag_options=frozenset(),
|
|
321
|
+
leading_operand_count=0,
|
|
322
|
+
)
|
|
323
|
+
ALL_SHELL_KEYWORDS_BEFORE_A_COMMAND: frozenset[str] = frozenset(
|
|
324
|
+
{"if", "then", "elif", "else", "while", "until", "do", "!", "{"}
|
|
325
|
+
)
|
|
315
326
|
ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME: dict[str, WrapperOptionGrammar] = {
|
|
316
327
|
"sudo": WrapperOptionGrammar(
|
|
317
328
|
all_value_taking_options=frozenset(
|
|
@@ -365,6 +376,20 @@ ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME: dict[str, WrapperOptionGrammar] = {
|
|
|
365
376
|
all_flag_options=frozenset({"-v", "--verbose", "--preserve-status", "--foreground"}),
|
|
366
377
|
leading_operand_count=1,
|
|
367
378
|
),
|
|
379
|
+
"command": WrapperOptionGrammar(
|
|
380
|
+
all_value_taking_options=frozenset(),
|
|
381
|
+
all_flag_options=frozenset({"-p"}),
|
|
382
|
+
leading_operand_count=0,
|
|
383
|
+
),
|
|
384
|
+
"exec": WrapperOptionGrammar(
|
|
385
|
+
all_value_taking_options=frozenset({"-a"}),
|
|
386
|
+
all_flag_options=frozenset({"-c", "-l"}),
|
|
387
|
+
leading_operand_count=0,
|
|
388
|
+
),
|
|
389
|
+
**{
|
|
390
|
+
each_keyword: SHELL_KEYWORD_OPTION_GRAMMAR
|
|
391
|
+
for each_keyword in ALL_SHELL_KEYWORDS_BEFORE_A_COMMAND
|
|
392
|
+
},
|
|
368
393
|
}
|
|
369
394
|
ALL_RUN_SUBCOMMAND_WRAPPER_COMMANDS: frozenset[str] = frozenset(
|
|
370
395
|
{
|
|
@@ -25,7 +25,6 @@ COMMAND_SEPARATORS = ";|&\n()`"
|
|
|
25
25
|
COMMAND_WHITESPACE = " \t\r"
|
|
26
26
|
OPTION_AND_VALUE_WORD_COUNT = 2
|
|
27
27
|
GIT_GLOBAL_OPTIONS_WITH_VALUE = frozenset({"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"})
|
|
28
|
-
GH_OPTIONS_WITH_VALUE = frozenset({"-R", "--repo", "--hostname", "--config", "-H", "--header", "-X", "--method", "-F", "--field", "-f", "--raw-field", "--input", "--jq", "-q", "--template", "-t", "--cache"})
|
|
29
28
|
GIT_ACTIONS = frozenset({"commit", "push"})
|
|
30
29
|
GH_PULL_REQUEST_COMMAND = "pr"
|
|
31
30
|
GH_API_COMMAND = "api"
|