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.
@@ -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 main, run_validators_entrypoint_subprocess
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(["--pre-tool-use"], stdin_text=json.dumps(payload))
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(self, tmp_path: Path) -> None:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
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 for the hooks package — runs ruff, mypy, and the
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). Each tool runs sequentially; the first non-zero
12
- exit code is preserved as the script's exit code so CI/pre-commit can
13
- short-circuit on the first failure.
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 the mypy run.
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, in order:
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
- 1. The Claude Code CLI's stored OAuth access token (honored only while unexpired).
47
- 2. The session ingress bearer token file named by `CLAUDE_SESSION_INGRESS_TOKEN_FILE` (cloud sessions).
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: the Claude Code
11
- OAuth access token from the CLI credential file, or the session ingress token
12
- file when that credential is unavailable. It asks the OAuth usage endpoint for the
13
- ``five_hour`` and ``seven_day`` windows. Exit code 2 means the probe cannot
14
- resolve; the caller then asks the user for a manual reset time.
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 file token valid -> the credential token
276
- credential token unavailable -> the session ingress token
277
- neither source available -> None
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 (