claude-dev-env 8.79.0 → 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_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_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 +2 -2
- 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_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_support.py +10 -11
- package/scripts/codex_account_meters.py +30 -43
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +34 -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 +42 -0
- package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
- package/scripts/merge_review_receipt.py +56 -12
- package/scripts/test_account_broker.py +12 -0
- package/scripts/test_account_broker_support.py +32 -6
- package/scripts/test_codex_account_meters.py +26 -14
- package/scripts/test_merge_review_receipt.py +86 -0
- package/settings.json +3 -0
|
@@ -22,7 +22,7 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
|
22
22
|
)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def
|
|
25
|
+
def test_bash_roster_starts_only_the_msys_rewriter_and_the_three_gates() -> None:
|
|
26
26
|
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
27
27
|
BashHostedHookEntry(
|
|
28
28
|
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
@@ -38,4 +38,8 @@ def test_bash_roster_starts_only_the_msys_rewriter_and_the_two_gates() -> None:
|
|
|
38
38
|
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
39
39
|
replaced_by_plugin_name="shell-guards",
|
|
40
40
|
),
|
|
41
|
+
BashHostedHookEntry(
|
|
42
|
+
script_relative_path="blocking/cloud_graphql_gate.py",
|
|
43
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
44
|
+
),
|
|
41
45
|
)
|
|
@@ -270,7 +270,10 @@ def test_should_drop_the_gates_the_shell_guards_mod_holds_while_it_is_on(
|
|
|
270
270
|
) -> None:
|
|
271
271
|
_enable_shell_guards(tmp_path, monkeypatch)
|
|
272
272
|
all_script_paths = [each_entry.script_relative_path for each_entry in select_applicable_entries(BASH_TOOL_NAME)]
|
|
273
|
-
assert all_script_paths == [
|
|
273
|
+
assert all_script_paths == [
|
|
274
|
+
"blocking/msys_rev_path_rewriter.py",
|
|
275
|
+
"blocking/cloud_graphql_gate.py",
|
|
276
|
+
]
|
|
274
277
|
|
|
275
278
|
|
|
276
279
|
def test_should_pass_a_headless_claude_command_while_the_shell_guards_mod_is_on(
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Tests for cloud_graphql_gate, which stops cloud sessions from calling GitHub GraphQL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from io import StringIO
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import cloud_graphql_gate as gate
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _stdout_from_main(payload: dict[str, object]) -> str:
|
|
16
|
+
"""Return what main() wrote to stdout for one hook payload."""
|
|
17
|
+
captured_stdout = StringIO()
|
|
18
|
+
with (
|
|
19
|
+
patch("sys.stdin", StringIO(json.dumps(payload))),
|
|
20
|
+
patch("sys.stdout", captured_stdout),
|
|
21
|
+
patch.object(gate, "log_hook_block"),
|
|
22
|
+
):
|
|
23
|
+
gate.main()
|
|
24
|
+
return captured_stdout.getvalue()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_ENCODED_GH_PR_LIST = base64.b64encode("gh pr list".encode("utf-16-le")).decode()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def cloud_session(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
32
|
+
"""Mark the hook process as running inside a Claude Code cloud session."""
|
|
33
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", "true")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize(
|
|
37
|
+
"command",
|
|
38
|
+
[
|
|
39
|
+
"gh api graphql -f query='query{viewer{login}}'",
|
|
40
|
+
"gh.exe api graphql -F owner=o -f query=@q.graphql",
|
|
41
|
+
"cd repo && gh api graphql --paginate -f query=@q.graphql",
|
|
42
|
+
'bash -c "gh api graphql -f query=@q.graphql"',
|
|
43
|
+
"curl -sS -X POST https://api.github.com/graphql -d @q.json",
|
|
44
|
+
"curl -H 'Authorization: Bearer x' HTTPS://API.GITHUB.COM/GRAPHQL",
|
|
45
|
+
"wget --post-data=@q.json https://api.github.com/graphql",
|
|
46
|
+
"Invoke-RestMethod -Method Post -Uri https://api.github.com/graphql -Body $q",
|
|
47
|
+
"gh pr create --draft --title t --body-file b.md",
|
|
48
|
+
"gh pr view 12 --json title",
|
|
49
|
+
"gh pr checks 12",
|
|
50
|
+
"gh issue list -L 1",
|
|
51
|
+
"gh repo clone o/r",
|
|
52
|
+
"gh repo view o/r",
|
|
53
|
+
"gh label list",
|
|
54
|
+
"gh search prs repo:o/r",
|
|
55
|
+
"gh ruleset list",
|
|
56
|
+
"gh gist list",
|
|
57
|
+
"gh api -X POST graphql -f query='mutation{x}'",
|
|
58
|
+
"gh api --method POST graphql -f query=@q.graphql",
|
|
59
|
+
"gh api -H 'Accept: application/json' graphql -f query=@q",
|
|
60
|
+
"gh api /graphql -f query=@q",
|
|
61
|
+
"gh api https://api.github.com/graphql -f query=@q",
|
|
62
|
+
"gh -R o/r pr list",
|
|
63
|
+
"gh pr -R o/r list",
|
|
64
|
+
"gh pr --repo o/r view 12",
|
|
65
|
+
"gh pr merge 12 --body -h",
|
|
66
|
+
"gh pr view 12 --web",
|
|
67
|
+
"gh issue list --web",
|
|
68
|
+
"gh project item-list 1 --owner o",
|
|
69
|
+
"gh discussion list",
|
|
70
|
+
"gh status",
|
|
71
|
+
"gh status -o o",
|
|
72
|
+
"gh org list",
|
|
73
|
+
"gh repo archive o/r --yes",
|
|
74
|
+
"gh repo unarchive o/r --yes",
|
|
75
|
+
"curl -X POST https://api.github.com:443/graphql -d @q.json",
|
|
76
|
+
"if gh pr view 12; then echo ok; fi",
|
|
77
|
+
"if true; then gh api graphql -f query=@q; fi",
|
|
78
|
+
"while gh pr list; do sleep 1; done",
|
|
79
|
+
"! gh pr list",
|
|
80
|
+
"command gh pr list",
|
|
81
|
+
"exec gh issue list",
|
|
82
|
+
'echo "$(gh pr list)"',
|
|
83
|
+
"echo `gh pr view 12`",
|
|
84
|
+
"PR_URL=$(gh pr create --title t --body-file b.md)",
|
|
85
|
+
"(gh pr view 12)",
|
|
86
|
+
"python3 -c \"import requests; requests.post('https://api.github.com/graphql')\"",
|
|
87
|
+
"node -e \"fetch('https://api.github.com/graphql', {method: 'POST'})\"",
|
|
88
|
+
"gh release list -L 1",
|
|
89
|
+
"gh release view v1 --json name",
|
|
90
|
+
"gh workflow run ci.yml",
|
|
91
|
+
"gh workflow run ci.yml -f -r",
|
|
92
|
+
"gh pr list --search -w",
|
|
93
|
+
"echo \"$(printf ')'; gh pr list)\"",
|
|
94
|
+
"x=`echo \\`gh pr list\\``",
|
|
95
|
+
'echo $(echo "$(gh api graphql -f query=@q)")',
|
|
96
|
+
"python3.12 -c \"u('https://api.github.com/graphql')\"",
|
|
97
|
+
"pypy3 -c \"u('https://api.github.com/graphql')\"",
|
|
98
|
+
'eval "gh api graphql -f query=@q"',
|
|
99
|
+
"find . -maxdepth 0 -exec gh api graphql -f query=@q \\;",
|
|
100
|
+
"Invoke-Expression 'gh pr list'",
|
|
101
|
+
'iex "gh issue list"',
|
|
102
|
+
"find . -exec true \\; -exec gh api graphql -f query=@q \\;",
|
|
103
|
+
"gh repo sync o/r",
|
|
104
|
+
"gh repo create n --template o/t",
|
|
105
|
+
"gh repo create n -p o/t --private",
|
|
106
|
+
"gh repo edit o/r --enable-secret-scanning",
|
|
107
|
+
"gh repo edit --enable-advanced-security=false",
|
|
108
|
+
'Start-Process gh -ArgumentList "api graphql -f query=@q"',
|
|
109
|
+
"Start-Process -FilePath gh -ArgumentList 'pr','list' -Wait",
|
|
110
|
+
"Start-Process -FilePath:gh -ArgumentList:'api graphql -f query=@q'",
|
|
111
|
+
"GH.EXE pr list",
|
|
112
|
+
"pwsh -EncodedCommand " + _ENCODED_GH_PR_LIST,
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
def test_should_detect_a_github_graphql_call(command: str) -> None:
|
|
116
|
+
assert gate.calls_github_graphql(command) is True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize(
|
|
120
|
+
"command",
|
|
121
|
+
[
|
|
122
|
+
"gh api repos/o/r/pulls/1/ccr/review_threads",
|
|
123
|
+
"gh api user",
|
|
124
|
+
"gh pr diff 12",
|
|
125
|
+
"gh pr create --help",
|
|
126
|
+
"gh issue view -h",
|
|
127
|
+
"gh pr list -R o/r --help",
|
|
128
|
+
"gh pr list --web",
|
|
129
|
+
"gh pr list -w -R o/r",
|
|
130
|
+
"gh project",
|
|
131
|
+
"gh api repos/o/r/contents/docs/graphql.md",
|
|
132
|
+
"gh api repos/o/r/issues -f title=graphql",
|
|
133
|
+
"gh repo fork o/r",
|
|
134
|
+
"gh repo rename new-name",
|
|
135
|
+
"echo '$(gh pr list)'",
|
|
136
|
+
"command -v gh",
|
|
137
|
+
"gh pr view -w -h",
|
|
138
|
+
"gh pr list --state open --web",
|
|
139
|
+
"gh release view --json name",
|
|
140
|
+
"gh workflow run ci.yml --ref main",
|
|
141
|
+
"gh workflow run ci.yml -r main",
|
|
142
|
+
"gh workflow run ci.yml --ref=main",
|
|
143
|
+
"gh search repos claude-dev-env",
|
|
144
|
+
"gh run view 12",
|
|
145
|
+
"python3 scripts/review_closure.py o/r 1",
|
|
146
|
+
'eval "gh api repos/o/r/pulls/1"',
|
|
147
|
+
"gh repo sync o/r --branch main",
|
|
148
|
+
"gh repo sync -b main",
|
|
149
|
+
"gh repo create n --private",
|
|
150
|
+
"gh repo edit o/r --description d",
|
|
151
|
+
"gh api repos/o/r --preview graphql",
|
|
152
|
+
"gh api repos/o/r/issues --jq graphql",
|
|
153
|
+
"Start-Process notepad",
|
|
154
|
+
"find . -name '*.py' -exec grep -l graphql {} +",
|
|
155
|
+
"gh pr",
|
|
156
|
+
"gh run list -L 1",
|
|
157
|
+
"gh workflow list",
|
|
158
|
+
"gh label create bug",
|
|
159
|
+
"curl -sS https://api.github.com/repos/o/r",
|
|
160
|
+
'grep -rn "api.github.com/graphql" scripts',
|
|
161
|
+
'echo "gh api graphql"',
|
|
162
|
+
'git commit -m "drop gh api graphql calls"',
|
|
163
|
+
],
|
|
164
|
+
)
|
|
165
|
+
def test_should_pass_rest_calls_and_text_that_only_mentions_graphql(command: str) -> None:
|
|
166
|
+
assert gate.calls_github_graphql(command) is False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_should_deny_gh_api_graphql_in_a_cloud_session_and_name_the_rest_routes(
|
|
170
|
+
cloud_session: None,
|
|
171
|
+
) -> None:
|
|
172
|
+
decision = json.loads(
|
|
173
|
+
_stdout_from_main(
|
|
174
|
+
{"tool_name": "Bash", "tool_input": {"command": "gh api graphql -f query=@q"}}
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
specific_output = decision["hookSpecificOutput"]
|
|
178
|
+
assert specific_output["permissionDecision"] == "deny"
|
|
179
|
+
reason = specific_output["permissionDecisionReason"]
|
|
180
|
+
assert "GET /repos/{owner}/{repo}/pulls/{n}/ccr/review_threads" in reason
|
|
181
|
+
assert "gh api repos/{owner}/{repo}/..." in reason
|
|
182
|
+
assert "mcp__github__create_pull_request" in reason
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_should_deny_a_powershell_endpoint_call_in_a_cloud_session(cloud_session: None) -> None:
|
|
186
|
+
command = "Invoke-RestMethod -Uri https://api.github.com/graphql -Method Post"
|
|
187
|
+
decision = json.loads(
|
|
188
|
+
_stdout_from_main({"tool_name": "PowerShell", "tool_input": {"command": command}})
|
|
189
|
+
)
|
|
190
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_should_stay_quiet_outside_a_cloud_session() -> None:
|
|
194
|
+
assert (
|
|
195
|
+
_stdout_from_main(
|
|
196
|
+
{"tool_name": "Bash", "tool_input": {"command": "gh api graphql -f query=@q"}}
|
|
197
|
+
)
|
|
198
|
+
== ""
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_should_stay_quiet_for_a_rest_call_in_a_cloud_session(cloud_session: None) -> None:
|
|
203
|
+
rest_command = "gh api repos/o/r/pulls/1/ccr/review_threads"
|
|
204
|
+
assert _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": rest_command}}) == ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_should_stay_quiet_for_another_tool_in_a_cloud_session(cloud_session: None) -> None:
|
|
208
|
+
assert (
|
|
209
|
+
_stdout_from_main({"tool_name": "Write", "tool_input": {"command": "gh api graphql"}}) == ""
|
|
210
|
+
)
|
|
@@ -88,13 +88,18 @@ def test_other_tool_payload_emits_nothing() -> None:
|
|
|
88
88
|
assert _stdout_from_main({"tool_name": "Write", "tool_input": {"command": 'claude -p "task"'}}) == ""
|
|
89
89
|
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
@pytest.mark.parametrize("marker_value", ["true", " TRUE "])
|
|
92
|
+
def test_should_pass_bare_headless_claude_in_a_cloud_session(
|
|
93
|
+
monkeypatch: pytest.MonkeyPatch, marker_value: str
|
|
94
|
+
) -> None:
|
|
95
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", marker_value)
|
|
93
96
|
assert _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": 'claude -p "task"'}}) == ""
|
|
94
97
|
|
|
95
98
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
@pytest.mark.parametrize("marker_value", ["false", "1", ""])
|
|
100
|
+
def test_should_deny_bare_headless_claude_when_the_marker_is_not_true(
|
|
101
|
+
monkeypatch: pytest.MonkeyPatch, marker_value: str
|
|
102
|
+
) -> None:
|
|
103
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", marker_value)
|
|
104
|
+
decision = json.loads(_stdout_from_main({"tool_name": "Bash", "tool_input": {"command": 'claude -p "task"'}}))
|
|
105
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
@@ -16,6 +16,7 @@ from blocking import merge_review_gate as gate
|
|
|
16
16
|
|
|
17
17
|
CLEAN_HEAD = "abc1234def5678abc1234def5678abc1234def56"
|
|
18
18
|
NEW_HEAD = "9999999aaaaaaa9999999aaaaaaa9999999aaaaa"
|
|
19
|
+
RULINGS_LINK = "https://github.com/acme/tools/pull/12#issuecomment-1"
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
@pytest.fixture(autouse=True)
|
|
@@ -58,6 +59,68 @@ def test_should_allow_a_merge_pinned_to_the_clean_head() -> None:
|
|
|
58
59
|
assert _reason(_merge_tool(CLEAN_HEAD)) is None
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
def test_should_allow_a_merge_whose_findings_are_all_ruled_out_with_a_link() -> None:
|
|
63
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 2, 2, RULINGS_LINK)
|
|
64
|
+
assert _reason(_merge_tool(CLEAN_HEAD)) is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_should_deny_a_merge_with_a_finding_left_open() -> None:
|
|
68
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 3, 2, RULINGS_LINK)
|
|
69
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize(
|
|
73
|
+
"receipt",
|
|
74
|
+
[
|
|
75
|
+
{"head": CLEAN_HEAD, "findings": 2, "ruled_out": 2},
|
|
76
|
+
{"head": CLEAN_HEAD, "findings": 2, "ruled_out": 2, "rulings": " "},
|
|
77
|
+
{"head": CLEAN_HEAD, "findings": 2, "ruled_out": 2, "rulings": "x"},
|
|
78
|
+
{
|
|
79
|
+
"head": CLEAN_HEAD,
|
|
80
|
+
"findings": 2,
|
|
81
|
+
"ruled_out": 2,
|
|
82
|
+
"rulings": "https://github.com/acme/other/pull/12#issuecomment-1",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"head": CLEAN_HEAD,
|
|
86
|
+
"findings": 2,
|
|
87
|
+
"ruled_out": 2,
|
|
88
|
+
"rulings": "https://github.com/acme/tools/pull/13#issuecomment-1",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"head": CLEAN_HEAD,
|
|
92
|
+
"findings": 2,
|
|
93
|
+
"ruled_out": 2,
|
|
94
|
+
"rulings": "https://evil.example/?https://github.com/acme/tools/pull/12#issuecomment-1",
|
|
95
|
+
},
|
|
96
|
+
{"head": CLEAN_HEAD, "findings": True},
|
|
97
|
+
{"head": CLEAN_HEAD, "findings": "0"},
|
|
98
|
+
{"head": CLEAN_HEAD, "findings": 1, "ruled_out": True, "rulings": RULINGS_LINK},
|
|
99
|
+
{"head": CLEAN_HEAD, "findings": -1, "ruled_out": -1, "rulings": RULINGS_LINK},
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
def test_is_clean_receipt_should_reject_a_receipt_it_cannot_back(receipt: dict[str, object]) -> None:
|
|
103
|
+
assert gate.is_clean_receipt(receipt, "acme", "tools", 12) is False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_is_clean_receipt_should_accept_zero_findings() -> None:
|
|
107
|
+
assert gate.is_clean_receipt({"head": CLEAN_HEAD, "findings": 0}, "acme", "tools", 12) is True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize(
|
|
111
|
+
"rulings", ["x", "https://github.com/acme/tools/pull/13#issuecomment-1"]
|
|
112
|
+
)
|
|
113
|
+
def test_should_deny_a_merge_whose_rulings_link_is_not_a_comment_on_this_pull_request(
|
|
114
|
+
rulings: str,
|
|
115
|
+
) -> None:
|
|
116
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 2, 2, rulings)
|
|
117
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_should_name_the_ruled_out_route_in_the_deny_reason() -> None:
|
|
121
|
+
assert "--ruled-out" in _reason(_merge_tool(CLEAN_HEAD))
|
|
122
|
+
|
|
123
|
+
|
|
61
124
|
def test_should_deny_a_merge_pinned_to_a_head_pushed_after_the_review() -> None:
|
|
62
125
|
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
63
126
|
assert "Review the new head" in _reason(_merge_tool(NEW_HEAD))
|
|
@@ -156,6 +156,7 @@ def test_action_command_shapes(tmp_path: Path) -> None:
|
|
|
156
156
|
"git --git-dir=.git push origin head",
|
|
157
157
|
"gh pr create",
|
|
158
158
|
"gh -R owner/repo pr --repo owner/repo merge",
|
|
159
|
+
"gh -R owner/repo api -X PUT repos/owner/repo/pulls/1/merge",
|
|
159
160
|
"gh api -X PUT repos/owner/repo/pulls/1/merge",
|
|
160
161
|
"gh api graphql -f query='mutation { enablePullRequestAutoMerge }'",
|
|
161
162
|
"gh api graphql -f name=auto-merge",
|
|
@@ -180,6 +181,8 @@ def test_non_action_command_shapes(tmp_path: Path) -> None:
|
|
|
180
181
|
"gh api repos/owner/repo/pulls/1/mergeability",
|
|
181
182
|
"gh issue create --body-file message.txt",
|
|
182
183
|
"gh pr",
|
|
184
|
+
"gh pr --repo owner/repo",
|
|
185
|
+
"gh api -H 'Accept: x' repos/owner/repo/pulls/1/mergeability",
|
|
183
186
|
"GH_TOKEN=x gh issue list",
|
|
184
187
|
"env -u GH_TOKEN git status",
|
|
185
188
|
)
|
package/hooks/conftest.py
CHANGED
|
@@ -23,7 +23,7 @@ _CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME = importlib.import_module(
|
|
|
23
23
|
).CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME
|
|
24
24
|
|
|
25
25
|
_CLOUD_SESSION_ENVIRONMENT_VARIABLE_NAME = importlib.import_module(
|
|
26
|
-
"hooks_constants.
|
|
26
|
+
"hooks_constants.cloud_session_constants"
|
|
27
27
|
).CLOUD_SESSION_ENV_VAR
|
|
28
28
|
|
|
29
29
|
_ROOT_ANCHORED_PROBE_PATH = "/tmp/scratch.py"
|
|
@@ -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,7 @@ 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
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.
|
|
17
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.
|
|
18
18
|
|
|
@@ -57,4 +57,4 @@ Preconditions:
|
|
|
57
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.
|
|
58
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.
|
|
59
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.
|
|
60
|
-
- 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.
|
|
@@ -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]
|