claude-dev-env 2.2.0 → 2.2.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/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pyproject.toml +22 -0
- package/hooks/blocking/code_rules_shared.py +4 -2
- package/hooks/blocking/pii_payload_scan.py +78 -20
- package/hooks/blocking/pii_prevention_blocker.py +3 -1
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
- package/hooks/validators/ruff_integration.py +2 -1
- package/hooks/validators/run_all_validators.py +460 -24
- package/hooks/validators/test_ruff_integration.py +21 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/cleanup-command-forms.md +23 -0
- package/scripts/check.ps1 +32 -6
- package/skills/usage-pause/SKILL.md +5 -4
- package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +7 -3
|
@@ -8,12 +8,19 @@ when that content violates a validator, rather than grading the whole branch.
|
|
|
8
8
|
import json
|
|
9
9
|
import subprocess
|
|
10
10
|
import sys
|
|
11
|
+
from collections import Counter
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from unittest.mock import patch
|
|
13
14
|
|
|
14
15
|
import pytest
|
|
15
16
|
|
|
16
|
-
from .run_all_validators import
|
|
17
|
+
from .run_all_validators import (
|
|
18
|
+
ValidatorResult,
|
|
19
|
+
_scope_new_and_preexisting,
|
|
20
|
+
_violation_line_number,
|
|
21
|
+
main,
|
|
22
|
+
run_validators_entrypoint_subprocess,
|
|
23
|
+
)
|
|
17
24
|
|
|
18
25
|
CLEAN_PYTHON_SOURCE = (
|
|
19
26
|
"def add_two_numbers(first_number: int, second_number: int) -> int:\n"
|
|
@@ -26,7 +33,9 @@ VIOLATING_PYTHON_SOURCE = (
|
|
|
26
33
|
|
|
27
34
|
|
|
28
35
|
def run_gate(payload: dict[str, object]) -> "subprocess.CompletedProcess[str]":
|
|
29
|
-
return run_validators_entrypoint_subprocess(
|
|
36
|
+
return run_validators_entrypoint_subprocess(
|
|
37
|
+
["--pre-tool-use"], stdin_text=json.dumps(payload)
|
|
38
|
+
)
|
|
30
39
|
|
|
31
40
|
|
|
32
41
|
class TestPreToolUseGate:
|
|
@@ -73,7 +82,9 @@ class TestPreToolUseGate:
|
|
|
73
82
|
assert completed.returncode == 0, completed.stderr
|
|
74
83
|
assert "deny" not in completed.stdout
|
|
75
84
|
|
|
76
|
-
def test_edit_validates_reconstructed_post_edit_content(
|
|
85
|
+
def test_edit_validates_reconstructed_post_edit_content(
|
|
86
|
+
self, tmp_path: Path
|
|
87
|
+
) -> None:
|
|
77
88
|
target_file = tmp_path / "calculate.py"
|
|
78
89
|
target_file.write_text(CLEAN_PYTHON_SOURCE, encoding="utf-8")
|
|
79
90
|
completed = run_gate(
|
|
@@ -116,3 +127,212 @@ class TestCliModeRegression:
|
|
|
116
127
|
captured = capsys.readouterr()
|
|
117
128
|
assert exit_code == 1
|
|
118
129
|
assert "PRE-PUSH VALIDATOR RESULTS" in captured.out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _over_long_function_source(function_name: str) -> str:
|
|
133
|
+
"""Return a syntactically clean function whose only defect is exceeding 30 lines."""
|
|
134
|
+
body_lines = "".join(" running_total = running_total + 1\n" for _ in range(35))
|
|
135
|
+
return (
|
|
136
|
+
f"def {function_name}(running_total: int) -> int:\n"
|
|
137
|
+
f"{body_lines}"
|
|
138
|
+
" return running_total\n"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
CLEAN_MARKER_FUNCTION = "def clean_marker() -> int:\n return 1\n"
|
|
143
|
+
CLEAN_MARKER_EDITED = (
|
|
144
|
+
"def clean_marker() -> int:\n computed_value = 1\n return computed_value\n"
|
|
145
|
+
)
|
|
146
|
+
MAGIC_VALUE_MARKER_FUNCTION = "def clean_marker() -> int:\n return 199\n"
|
|
147
|
+
PRE_EXISTING_VIOLATION_SOURCE = (
|
|
148
|
+
_over_long_function_source("over_long_function") + "\n" + CLEAN_MARKER_FUNCTION
|
|
149
|
+
)
|
|
150
|
+
MAGIC_VALUE_FUNCTION_SOURCE = "def compute_total() -> int:\n return 199\n"
|
|
151
|
+
RUFF_DIRTY_FUNCTION_SOURCE = (
|
|
152
|
+
"def compute_total() -> int:\n unused_intermediate = 1\n return 1\n"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestBaselineScopedGate:
|
|
157
|
+
def test_clean_edit_to_violating_file_is_allowed_with_warning(
|
|
158
|
+
self, tmp_path: Path
|
|
159
|
+
) -> None:
|
|
160
|
+
target_file = tmp_path / "legacy_module.py"
|
|
161
|
+
target_file.write_text(PRE_EXISTING_VIOLATION_SOURCE, encoding="utf-8")
|
|
162
|
+
completed = run_gate(
|
|
163
|
+
{
|
|
164
|
+
"tool_name": "Edit",
|
|
165
|
+
"tool_input": {
|
|
166
|
+
"file_path": str(target_file),
|
|
167
|
+
"old_string": CLEAN_MARKER_FUNCTION,
|
|
168
|
+
"new_string": CLEAN_MARKER_EDITED,
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
assert completed.returncode == 0, completed.stderr
|
|
173
|
+
assert '"permissionDecision": "deny"' not in completed.stdout
|
|
174
|
+
assert "Code Quality" in completed.stderr
|
|
175
|
+
|
|
176
|
+
def test_edit_introducing_new_violation_denies_naming_only_the_new_one(
|
|
177
|
+
self, tmp_path: Path
|
|
178
|
+
) -> None:
|
|
179
|
+
target_file = tmp_path / "legacy_module.py"
|
|
180
|
+
target_file.write_text(PRE_EXISTING_VIOLATION_SOURCE, encoding="utf-8")
|
|
181
|
+
completed = run_gate(
|
|
182
|
+
{
|
|
183
|
+
"tool_name": "Edit",
|
|
184
|
+
"tool_input": {
|
|
185
|
+
"file_path": str(target_file),
|
|
186
|
+
"old_string": CLEAN_MARKER_FUNCTION,
|
|
187
|
+
"new_string": MAGIC_VALUE_MARKER_FUNCTION,
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
assert completed.returncode == 0, completed.stderr
|
|
192
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
193
|
+
assert "Magic Values" in completed.stdout
|
|
194
|
+
assert "Code Quality" not in completed.stdout
|
|
195
|
+
|
|
196
|
+
def test_new_violation_in_clean_file_denies(self, tmp_path: Path) -> None:
|
|
197
|
+
target_file = tmp_path / "clean_module.py"
|
|
198
|
+
target_file.write_text(CLEAN_MARKER_FUNCTION, encoding="utf-8")
|
|
199
|
+
completed = run_gate(
|
|
200
|
+
{
|
|
201
|
+
"tool_name": "Edit",
|
|
202
|
+
"tool_input": {
|
|
203
|
+
"file_path": str(target_file),
|
|
204
|
+
"old_string": " return 1\n",
|
|
205
|
+
"new_string": " return 199\n",
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
assert completed.returncode == 0, completed.stderr
|
|
210
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
211
|
+
assert "Magic Values" in completed.stdout
|
|
212
|
+
|
|
213
|
+
def test_clean_edit_to_clean_file_is_allowed(self, tmp_path: Path) -> None:
|
|
214
|
+
target_file = tmp_path / "clean_module.py"
|
|
215
|
+
target_file.write_text(CLEAN_MARKER_FUNCTION, encoding="utf-8")
|
|
216
|
+
completed = run_gate(
|
|
217
|
+
{
|
|
218
|
+
"tool_name": "Edit",
|
|
219
|
+
"tool_input": {
|
|
220
|
+
"file_path": str(target_file),
|
|
221
|
+
"old_string": " return 1\n",
|
|
222
|
+
"new_string": " computed_value = 1\n return computed_value\n",
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
assert completed.returncode == 0, completed.stderr
|
|
227
|
+
assert "deny" not in completed.stdout
|
|
228
|
+
|
|
229
|
+
def test_growing_a_function_with_existing_length_violation_is_allowed_with_warning(
|
|
230
|
+
self, tmp_path: Path
|
|
231
|
+
) -> None:
|
|
232
|
+
target_file = tmp_path / "legacy_module.py"
|
|
233
|
+
target_file.write_text(
|
|
234
|
+
_over_long_function_source("over_long_function"), encoding="utf-8"
|
|
235
|
+
)
|
|
236
|
+
completed = run_gate(
|
|
237
|
+
{
|
|
238
|
+
"tool_name": "Edit",
|
|
239
|
+
"tool_input": {
|
|
240
|
+
"file_path": str(target_file),
|
|
241
|
+
"old_string": " return running_total\n",
|
|
242
|
+
"new_string": (
|
|
243
|
+
" running_total = running_total + 1\n"
|
|
244
|
+
" running_total = running_total + 1\n"
|
|
245
|
+
" return running_total\n"
|
|
246
|
+
),
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
assert completed.returncode == 0, completed.stderr
|
|
251
|
+
assert '"permissionDecision": "deny"' not in completed.stdout
|
|
252
|
+
assert "Code Quality" in completed.stderr
|
|
253
|
+
|
|
254
|
+
def test_new_same_validator_violation_in_dirty_function_denies(
|
|
255
|
+
self, tmp_path: Path
|
|
256
|
+
) -> None:
|
|
257
|
+
target_file = tmp_path / "legacy_module.py"
|
|
258
|
+
target_file.write_text(MAGIC_VALUE_FUNCTION_SOURCE, encoding="utf-8")
|
|
259
|
+
completed = run_gate(
|
|
260
|
+
{
|
|
261
|
+
"tool_name": "Edit",
|
|
262
|
+
"tool_input": {
|
|
263
|
+
"file_path": str(target_file),
|
|
264
|
+
"old_string": " return 199\n",
|
|
265
|
+
"new_string": " threshold = 42\n return 199 + threshold\n",
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
assert completed.returncode == 0, completed.stderr
|
|
270
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
271
|
+
assert "Magic Values" in completed.stdout
|
|
272
|
+
|
|
273
|
+
def test_new_module_scope_ruff_violation_with_dirty_function_denies(
|
|
274
|
+
self, tmp_path: Path
|
|
275
|
+
) -> None:
|
|
276
|
+
target_file = tmp_path / "legacy_module.py"
|
|
277
|
+
target_file.write_text(RUFF_DIRTY_FUNCTION_SOURCE, encoding="utf-8")
|
|
278
|
+
completed = run_gate(
|
|
279
|
+
{
|
|
280
|
+
"tool_name": "Edit",
|
|
281
|
+
"tool_input": {
|
|
282
|
+
"file_path": str(target_file),
|
|
283
|
+
"old_string": "def compute_total",
|
|
284
|
+
"new_string": "import os\n\n\ndef compute_total",
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
assert completed.returncode == 0, completed.stderr
|
|
289
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
290
|
+
assert "F401" in completed.stdout
|
|
291
|
+
assert "F841" not in completed.stdout
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class TestScopeNewAndPreexisting:
|
|
295
|
+
def test_failed_result_without_located_lines_is_treated_as_new(self) -> None:
|
|
296
|
+
summary_only_result = ValidatorResult(
|
|
297
|
+
name="Ruff", checks="37", passed=False, output="Found 1 error."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
new_results, preexisting_results = _scope_new_and_preexisting(
|
|
301
|
+
[summary_only_result], "import os\n", Counter()
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
assert [each_result.name for each_result in new_results] == ["Ruff"]
|
|
305
|
+
assert preexisting_results == []
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class TestViolationLineNumber:
|
|
309
|
+
def test_ruff_line_col_prefix_returns_line_not_column(self) -> None:
|
|
310
|
+
ruff_shaped_line = "/pkg/legacy_module.py:37:5: F401 `os` imported but unused"
|
|
311
|
+
|
|
312
|
+
assert _violation_line_number(ruff_shaped_line) == 37
|
|
313
|
+
|
|
314
|
+
def test_windows_drive_prefix_returns_line(self) -> None:
|
|
315
|
+
windows_shaped_line = r"C:\repo\tmp\legacy_module.py:37:5: F401 unused import"
|
|
316
|
+
|
|
317
|
+
assert _violation_line_number(windows_shaped_line) == 37
|
|
318
|
+
|
|
319
|
+
def test_check_module_line_prefix_returns_line(self) -> None:
|
|
320
|
+
check_module_line = "/pkg/legacy_module.py:37: magic number 199"
|
|
321
|
+
|
|
322
|
+
assert _violation_line_number(check_module_line) == 37
|
|
323
|
+
|
|
324
|
+
def test_summary_line_without_location_returns_zero(self) -> None:
|
|
325
|
+
assert _violation_line_number("Found 3 errors.") == 0
|
|
326
|
+
|
|
327
|
+
def test_code_frame_line_quoting_colon_digits_returns_zero(self) -> None:
|
|
328
|
+
frame_line = '4 | message = "err:37: bad"'
|
|
329
|
+
|
|
330
|
+
assert _violation_line_number(frame_line) == 0
|
|
331
|
+
|
|
332
|
+
def test_code_frame_source_line_returns_zero(self) -> None:
|
|
333
|
+
assert _violation_line_number("17 | import os") == 0
|
|
334
|
+
|
|
335
|
+
def test_path_with_spaces_returns_line(self) -> None:
|
|
336
|
+
spaced_path_line = "/tmp/my dir/legacy module.py:37: magic number 199"
|
|
337
|
+
|
|
338
|
+
assert _violation_line_number(spaced_path_line) == 37
|
package/package.json
CHANGED
package/rules/CLAUDE.md
CHANGED
|
@@ -16,6 +16,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. A rule withou
|
|
|
16
16
|
| `ask-user-question-required.md` | Every user-directed question goes through the `AskUserQuestion` tool — no plain-text questions |
|
|
17
17
|
| `bdd.md` | BDD discovery-driven development workflow and Example Mapping reference |
|
|
18
18
|
| `claude-md-orphan-file.md` | Every backticked bare filename in a per-directory `CLAUDE.md` table's first column names a file in that directory's subtree |
|
|
19
|
+
| `cleanup-command-forms.md` | Never use bash `rm` to clean up; use the PowerShell `Remove-Item` and `git worktree remove --force` forms the `destructive_command_blocker` hook never prompts on, and carry the rule into every subagent prompt |
|
|
19
20
|
| `cleanup-temp-files.md` | Remove temporary files created during a task when the task is complete |
|
|
20
21
|
| `code-reviews.md` | Mandatory protocol for responding to GitHub PR review feedback |
|
|
21
22
|
| `code-standards.md` | Pointer to `CODE_RULES.md` as the single source of truth |
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Cleanup Command Forms
|
|
2
|
+
|
|
3
|
+
Never use bash `rm` in any form to clean up. The `destructive_command_blocker` hook watches every Bash-tool command and matches `rm -rf` (and the rest of the destructive patterns) as raw text. It allows an `rm` without a prompt only for a narrow set of shapes it can prove safe: an `rm` whose every target is an absolute path under the OS temp root, `/tmp`, `/temp`, or a worktrees directory — standalone, or in a chain whose other segments are plain reporting commands such as `echo` or `cat`; an `rm` run from an ephemeral working directory; and an `rm` whose every target sits inside `~/.claude`. It falls through to a permission prompt on anything outside that set: a `$`, `$(...)`, or backtick expansion whose value it cannot resolve, a target it cannot place in a safe directory, a glob basename, or a string-executing wrapper (`bash -c 'rm -rf …'`). In a background or auto-mode run no human can answer that prompt, so the call stalls.
|
|
4
|
+
|
|
5
|
+
Remove files with these forms, which the hook never prompts on:
|
|
6
|
+
|
|
7
|
+
- **Scratch and probe files:** the PowerShell tool — `Remove-Item -Recurse -Force -Confirm:$false <absolute path>`. The hook watches only the Bash tool, so a PowerShell removal never reaches it. A file left in the OS temp dir or `$CLAUDE_JOB_DIR/tmp` is ephemeral and needs no explicit removal.
|
|
8
|
+
- **Worktrees:** `git worktree remove --force <path>`. This matches no destructive pattern.
|
|
9
|
+
- **When bash `rm` is unavoidable:** one standalone `rm` command, an absolute literal path under the OS temp root or a worktrees directory, no chaining, no variables, no globs. The hook auto-allows this shape without a prompt.
|
|
10
|
+
|
|
11
|
+
## Every subagent prompt carries the rule
|
|
12
|
+
|
|
13
|
+
A prompt-delivered directive reaches only the agent that gets it. An agent that spawns its own workers — review lenses, fix agents, verifiers — copies this line into every subagent prompt it issues, so a grandchild cleaning up its own probe file uses an allowed form:
|
|
14
|
+
|
|
15
|
+
> Never use bash rm in any form. Delete scratch/probe files with the PowerShell tool (Remove-Item -Recurse -Force -Confirm:$false <absolute path>), or leave them in the OS temp dir; remove worktrees only via git worktree remove --force.
|
|
16
|
+
|
|
17
|
+
Prefer that a child leaves its scratch files in place for the parent to remove at teardown with `Remove-Item`.
|
|
18
|
+
|
|
19
|
+
## Sibling rules
|
|
20
|
+
|
|
21
|
+
- [`no-inline-destructive-literals`](no-inline-destructive-literals.md) — keep a destructive literal out of the Bash command string even when it rides only as data.
|
|
22
|
+
- [`cleanup-temp-files`](cleanup-temp-files.md) — remove the scratch files a task created once the task is done.
|
|
23
|
+
- [`windows-filesystem-safe`](windows-filesystem-safe.md) — the safe `rmtree` / `force_rmtree` patterns for read-only Windows files.
|
package/scripts/check.ps1
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env pwsh
|
|
2
2
|
<#
|
|
3
3
|
.SYNOPSIS
|
|
4
|
-
One-shot quality gate
|
|
4
|
+
One-shot quality gate — runs ruff, hooks mypy, pr-loop mypy, and the
|
|
5
5
|
blocking pytest suite from a single entry point.
|
|
6
6
|
|
|
7
7
|
.DESCRIPTION
|
|
8
8
|
Resolves paths relative to $PSScriptRoot so the script works from any CWD
|
|
9
9
|
and from both the worktree (packages/claude-dev-env/scripts/check.ps1)
|
|
10
10
|
and the installed runtime (~/.claude/scripts/check.ps1, after install.mjs
|
|
11
|
-
propagates this file).
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
propagates this file). Tools: ruff over hooks/, mypy over hooks blocking
|
|
12
|
+
and validators, mypy-pr-loop over _shared/pr-loop/scripts production
|
|
13
|
+
modules, and optional pytest over the blocking enforcer suite. Each tool
|
|
14
|
+
runs sequentially; the first non-zero exit code is preserved as the
|
|
15
|
+
script's exit code so CI/pre-commit can short-circuit on the first failure.
|
|
14
16
|
|
|
15
17
|
.PARAMETER SkipTests
|
|
16
18
|
Skip the pytest run. Useful during local iteration when you want only the
|
|
17
19
|
static-analysis gates.
|
|
18
20
|
|
|
19
21
|
.PARAMETER SkipMypy
|
|
20
|
-
Skip
|
|
22
|
+
Skip both mypy runs (hooks mypy and mypy-pr-loop).
|
|
21
23
|
|
|
22
24
|
.PARAMETER SkipRuff
|
|
23
25
|
Skip the ruff run.
|
|
@@ -25,7 +27,7 @@
|
|
|
25
27
|
.OUTPUTS
|
|
26
28
|
Per-tool status lines on stdout. Final summary line:
|
|
27
29
|
CHECK: OK
|
|
28
|
-
CHECK: FAILED tools=ruff,mypy,pytest
|
|
30
|
+
CHECK: FAILED tools=ruff,mypy,mypy-pr-loop,pytest
|
|
29
31
|
#>
|
|
30
32
|
[CmdletBinding()]
|
|
31
33
|
param(
|
|
@@ -38,6 +40,7 @@ $ErrorActionPreference = 'Stop'
|
|
|
38
40
|
|
|
39
41
|
$hooksRoot = Resolve-Path (Join-Path $PSScriptRoot '..' 'hooks')
|
|
40
42
|
$blockingRoot = Join-Path $hooksRoot 'blocking'
|
|
43
|
+
$prLoopScriptsRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '_shared' 'pr-loop' 'scripts')
|
|
41
44
|
|
|
42
45
|
$failedTools = @()
|
|
43
46
|
$firstNonZeroExitCode = 0
|
|
@@ -82,6 +85,29 @@ if (-not $SkipMypy) {
|
|
|
82
85
|
Pop-Location
|
|
83
86
|
}
|
|
84
87
|
}
|
|
88
|
+
|
|
89
|
+
Invoke-Tool -Label 'mypy-pr-loop' -Action {
|
|
90
|
+
Push-Location $prLoopScriptsRoot
|
|
91
|
+
try {
|
|
92
|
+
mypy --config-file (Join-Path $prLoopScriptsRoot 'pyproject.toml') `
|
|
93
|
+
_claude_permissions_common.py `
|
|
94
|
+
code_rules_gate.py `
|
|
95
|
+
copilot_quota.py `
|
|
96
|
+
fix_hookspath.py `
|
|
97
|
+
grant_project_claude_permissions.py `
|
|
98
|
+
post_audit_thread.py `
|
|
99
|
+
preflight_self_heal.py `
|
|
100
|
+
preflight.py `
|
|
101
|
+
reviewer_availability.py `
|
|
102
|
+
reviews_disabled.py `
|
|
103
|
+
revoke_project_claude_permissions.py `
|
|
104
|
+
terminology_sweep.py `
|
|
105
|
+
code_rules_gate_parts `
|
|
106
|
+
pr_loop_shared_constants
|
|
107
|
+
} finally {
|
|
108
|
+
Pop-Location
|
|
109
|
+
}
|
|
110
|
+
}
|
|
85
111
|
}
|
|
86
112
|
|
|
87
113
|
if (-not $SkipTests) {
|
|
@@ -41,10 +41,10 @@ On exit 2 the script prints `{"error": ...}`. Ask the user for a manual reset ti
|
|
|
41
41
|
|
|
42
42
|
`scripts/resolve_usage_window.py` is the source of truth for live probe behavior. Endpoint URL, header names/values, credential path and token keys, response bucket keys, stage sizing, and the weekly warn threshold all live in `scripts/usage_pause_constants/resolve_usage_window_constants.py` — read those modules for the current values; do not restate them here.
|
|
43
43
|
|
|
44
|
-
In short: the resolver picks a bearer token, probes the OAuth usage endpoint the interactive `/usage` panel uses, and returns the session and weekly buckets with utilization and reset times. Token sources
|
|
44
|
+
In short: the resolver picks a bearer token, probes the OAuth usage endpoint the interactive `/usage` panel uses, and returns the session and weekly buckets with utilization and reset times. Token sources depend on the host:
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
- **Desktop host** (the `CLAUDE_CODE_ENTRYPOINT` variable is `claude-desktop`): the resolver does not read the CLI credential file, which belongs to a different authentication session than the one the desktop app counts usage against. It uses the session ingress token when one is set, and otherwise takes the manual-override path.
|
|
47
|
+
- **Every other host**: the resolver reads the Claude Code CLI's stored OAuth access token first (honored only while unexpired), then the session ingress bearer token file named by `CLAUDE_SESSION_INGRESS_TOKEN_FILE` (cloud sessions) when the credential token is unavailable.
|
|
48
48
|
|
|
49
49
|
Fallbacks, in order: both token sources unavailable (expired/unreadable credential and no ingress file), a failed request, or a response with no readable session-window reset time all end in exit 2 — the manual-override ask above. The manual path works with no probe at all, so the skill functions even when both token sources are unavailable.
|
|
50
50
|
|
|
@@ -106,8 +106,9 @@ Fill each `<slot>` at schedule time: `<remaining_stage_durations>` is the tail o
|
|
|
106
106
|
| `SKILL.md` | This flow: resolve, weekly guard, stage chain, templates |
|
|
107
107
|
| `scripts/resolve_usage_window.py` | The window resolver and stage planner CLI |
|
|
108
108
|
| `scripts/test_resolve_usage_window.py` | Behavioral tests for parsing, staging, token reading, extraction, CLI |
|
|
109
|
-
| `scripts/usage_pause_constants/resolve_usage_window_constants.py` | Endpoint, credential keys, stage sizing, thresholds, result keys |
|
|
109
|
+
| `scripts/usage_pause_constants/resolve_usage_window_constants.py` | Endpoint, credential keys, host detection, stage sizing, thresholds, result keys |
|
|
110
110
|
|
|
111
111
|
## Gotchas
|
|
112
112
|
|
|
113
113
|
- The stored access token lives about 8 hours and the CLI rewrites it on its own schedule, so a mid-afternoon probe can find it expired even while the CLI itself still works. That is the designed exit-2 path: give a manual time.
|
|
114
|
+
- On the desktop host the resolver never reads the CLI credential file, because that credential is a different authentication session than the one the desktop app counts usage against. With no session ingress token set, the desktop host takes the manual path: read the reset time from the interactive `/usage` panel and pass it, for example `/usage-pause 10:20pm`.
|
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
{"source": "override", "reset_at": "2026-07-08T10:14:00-07:00",
|
|
8
8
|
"seconds_until_reset": 4440, "stages_seconds": [3480, 960, 120], ...}
|
|
9
9
|
|
|
10
|
-
With no ``--override``, the script resolves a bearer token
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
With no ``--override``, the script resolves a bearer token. On the desktop
|
|
11
|
+
host it uses only the session ingress token. On every other host it reads the
|
|
12
|
+
Claude Code OAuth access token from the CLI credential file, then the session
|
|
13
|
+
ingress token file when that credential is unavailable. It asks the OAuth
|
|
14
|
+
usage endpoint for the ``five_hour`` and ``seven_day`` windows. Exit code 2
|
|
15
|
+
means the probe cannot resolve; the caller then asks the user for a manual
|
|
16
|
+
reset time.
|
|
15
17
|
"""
|
|
16
18
|
|
|
17
19
|
from __future__ import annotations
|
|
@@ -41,7 +43,9 @@ from usage_pause_constants.resolve_usage_window_constants import (
|
|
|
41
43
|
CREDENTIALS_ACCESS_TOKEN_KEY,
|
|
42
44
|
CREDENTIALS_EXPIRES_AT_KEY,
|
|
43
45
|
CREDENTIALS_OAUTH_SECTION_KEY,
|
|
46
|
+
DESKTOP_ENTRYPOINT_VALUE,
|
|
44
47
|
DURATION_PATTERN,
|
|
48
|
+
ENTRYPOINT_ENV_VAR,
|
|
45
49
|
EPOCH_MILLISECONDS_THRESHOLD,
|
|
46
50
|
EXIT_CODE_PROBE_UNAVAILABLE,
|
|
47
51
|
EXIT_CODE_RESOLVED,
|
|
@@ -267,14 +271,36 @@ def read_session_ingress_token() -> str | None:
|
|
|
267
271
|
return token_text
|
|
268
272
|
|
|
269
273
|
|
|
274
|
+
def running_on_desktop_host() -> bool:
|
|
275
|
+
"""Tell whether this process runs under the Claude desktop app.
|
|
276
|
+
|
|
277
|
+
::
|
|
278
|
+
|
|
279
|
+
entrypoint is the desktop marker -> True
|
|
280
|
+
entrypoint is the CLI or unset -> False
|
|
281
|
+
|
|
282
|
+
The desktop app meters its session under a different auth session than
|
|
283
|
+
the CLI credential file, so a true answer tells the resolver to leave
|
|
284
|
+
that credential file alone.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
True when the entrypoint environment variable names the desktop app.
|
|
288
|
+
"""
|
|
289
|
+
return os.environ.get(ENTRYPOINT_ENV_VAR) == DESKTOP_ENTRYPOINT_VALUE
|
|
290
|
+
|
|
291
|
+
|
|
270
292
|
def resolve_access_token(credentials_path: Path, now: datetime) -> str | None:
|
|
271
293
|
"""Choose the usage-endpoint bearer token from its available sources.
|
|
272
294
|
|
|
273
295
|
::
|
|
274
296
|
|
|
275
|
-
credential
|
|
276
|
-
credential token unavailable
|
|
277
|
-
|
|
297
|
+
CLI host, credential token valid -> the credential token
|
|
298
|
+
CLI host, credential token unavailable -> the session ingress token
|
|
299
|
+
desktop host -> the session ingress token
|
|
300
|
+
no source available -> None
|
|
301
|
+
|
|
302
|
+
On the desktop host the CLI credential file belongs to a different auth
|
|
303
|
+
session, so it is skipped and only the session ingress token is honored.
|
|
278
304
|
|
|
279
305
|
Args:
|
|
280
306
|
credentials_path: The CLI credential file holding the OAuth section.
|
|
@@ -283,6 +309,8 @@ def resolve_access_token(credentials_path: Path, now: datetime) -> str | None:
|
|
|
283
309
|
Returns:
|
|
284
310
|
The bearer token for the usage endpoint, or None when no source has one.
|
|
285
311
|
"""
|
|
312
|
+
if running_on_desktop_host():
|
|
313
|
+
return read_session_ingress_token()
|
|
286
314
|
credential_token = read_oauth_access_token(credentials_path, now)
|
|
287
315
|
return credential_token or read_session_ingress_token()
|
|
288
316
|
|
|
@@ -447,6 +475,37 @@ def _describe_ingress_token_source() -> str:
|
|
|
447
475
|
return f"the session ingress token file ({SESSION_INGRESS_TOKEN_FILE_ENV_VAR} set but empty)"
|
|
448
476
|
|
|
449
477
|
|
|
478
|
+
def _no_token_error_message(credentials_path: Path) -> str:
|
|
479
|
+
"""Build the no-usable-token exit-2 error, host-aware for the desktop app.
|
|
480
|
+
|
|
481
|
+
::
|
|
482
|
+
|
|
483
|
+
desktop host -> names the different-session skip and the /usage remedy
|
|
484
|
+
other host -> names the credential path and the ingress source tried
|
|
485
|
+
|
|
486
|
+
On the desktop host the CLI credential file is never read, so the message
|
|
487
|
+
points the user at the interactive /usage panel for a manual reset time.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
credentials_path: The CLI credential file the non-desktop message names.
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
The error text for the no-usable-token exit-2 path.
|
|
494
|
+
"""
|
|
495
|
+
if running_on_desktop_host():
|
|
496
|
+
return (
|
|
497
|
+
"on the desktop host the CLI credential file belongs to a different "
|
|
498
|
+
f"auth session and is not read, and {_describe_ingress_token_source()} "
|
|
499
|
+
"has no token; read the reset time from the interactive /usage panel "
|
|
500
|
+
"and give it manually, for example /usage-pause 10:20pm or /usage-pause 74m"
|
|
501
|
+
)
|
|
502
|
+
return (
|
|
503
|
+
"no usable bearer token from the OAuth credential file at "
|
|
504
|
+
f"{credentials_path} or {_describe_ingress_token_source()}; give a "
|
|
505
|
+
"manual reset time, for example /usage-pause 10:20pm or /usage-pause 74m"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
450
509
|
def _parse_arguments() -> argparse.Namespace:
|
|
451
510
|
parser = argparse.ArgumentParser(
|
|
452
511
|
description="Resolve the 5-hour usage window reset and plan the pause stage chain.",
|
|
@@ -503,11 +562,7 @@ def main() -> int:
|
|
|
503
562
|
)
|
|
504
563
|
access_token = resolve_access_token(credentials_path, now)
|
|
505
564
|
if access_token is None:
|
|
506
|
-
return _emit_error(
|
|
507
|
-
"no usable bearer token from the OAuth credential file at "
|
|
508
|
-
f"{credentials_path} or {_describe_ingress_token_source()}; give a "
|
|
509
|
-
"manual reset time, for example /usage-pause 10:20pm or /usage-pause 74m"
|
|
510
|
-
)
|
|
565
|
+
return _emit_error(_no_token_error_message(credentials_path))
|
|
511
566
|
try:
|
|
512
567
|
usage_payload = _fetch_usage_payload(access_token)
|
|
513
568
|
except (
|