From 25e9d260c9b1f36e60054578ffac7aa7208f2e67 Mon Sep 17 00:00:00 2001 From: Loll <44865998+aqua5230@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:37:16 +0800 Subject: [PATCH] Fix Windows statusLine hook never firing under Git Bash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code runs Windows statusLine commands through Git Bash when it is installed, where backslashes are escape characters — so the backslash-separated python.exe/script paths setup_hook.py wrote there silently failed to launch, and usage-status.json was never produced. _shell_arg now emits forward-slash paths on Windows (valid for both Git Bash and PowerShell). Existing installs are migrated automatically by self-heal and --setup. doctor.py's new "status command" line flags any remaining un-migrated backslash command. Also fixes a latent bug this exposed in _resume_restore_context: it compared the resume hook's installed command against str(Path) (backslash form) to classify whether the command still points at the source or target script, so on Windows that classification silently degraded to "other" once the command itself switched to forward slashes. Compares against as_posix() now. --- CHANGELOG.md | 3 ++- CHANGELOG.zh-TW.md | 3 ++- doctor.py | 17 +++++++++++++ session_hooks.py | 5 ++-- setup_hook.py | 31 ++++++++++++++++++++++++ tests/test_doctor.py | 29 ++++++++++++++++++++++ tests/test_setup_hook.py | 36 +++++++++++++++++++++++----- tests/test_setup_hook_coexistence.py | 5 +++- tests/test_setup_hook_resume.py | 4 ++-- tests/test_setup_hook_terse.py | 6 ++--- 10 files changed, 123 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c96cd5..2c8c006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,11 @@ All notable changes to usage are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/). -## Unreleased +## [Unreleased] ### Fixed - **Antigravity quota probing now reads the current Windows CLI OAuth credential**: when the legacy token file is missing or unusable, usage falls back read-only to the `gemini:antigravity` Windows Credential Manager entry. The quota request's user agent now also identifies the actual host platform instead of always claiming Darwin/arm64. +- **Claude Code quota collection now works with Git Bash on Windows**: status-line commands were written with Windows backslashes, but Claude Code runs them through Git Bash when it is installed, where those backslashes escape the path and prevent the Python hook from launching. New commands use portable forward slashes; startup self-heal migrates existing usage-owned commands, and `--doctor` identifies the legacy form and the recovery step. ## [0.28.2] - 2026-07-15 diff --git a/CHANGELOG.zh-TW.md b/CHANGELOG.zh-TW.md index bf87255..74b49b8 100644 --- a/CHANGELOG.zh-TW.md +++ b/CHANGELOG.zh-TW.md @@ -4,10 +4,11 @@ 本檔記錄 usage 所有重要變更。格式參考 [Keep a Changelog](https://keepachangelog.com/)。 -## Unreleased +## [Unreleased] ### 修正 - **Antigravity 額度探測現在會讀取 Windows CLI 目前的 OAuth 憑證**:舊版 token 檔案不存在或不可用時,usage 會唯讀退回 Windows Credential Manager 的 `gemini:antigravity` 項目。額度請求的 user agent 也會識別實際主機平台,不再一律宣稱是 Darwin/arm64。 +- **Windows 搭配 Git Bash 時 Claude Code 額度資料現在可正常收集**:狀態列指令過去寫入 Windows 反斜線路徑;但 Claude Code 偵測到 Git Bash 時會透過它執行指令,反斜線會被當作跳脫字元,導致 Python hook 無法啟動。新指令改用兩種 Windows shell 都可用的正斜線路徑;啟動時的自我修復會遷移既有的 usage 指令,`--doctor` 也會辨識舊格式並提示復原步驟。 ## [0.28.2] - 2026-07-15 diff --git a/doctor.py b/doctor.py index 0430daf..f258023 100644 --- a/doctor.py +++ b/doctor.py @@ -9,6 +9,7 @@ import os import shlex import sqlite3 +import sys import tomllib from collections.abc import Callable from datetime import UTC, datetime @@ -30,6 +31,7 @@ def render() -> str: f"hook script: {_script_status(setup_hook.HOOK_TARGET)}", f"forwarder script: {_script_status(setup_hook.FORWARDER_TARGET)}", f"status file: {_field(_status_file)}", + f"status command: {_field(_status_command)}", f"external hooks: {_field(_external_hooks)}", f"forwarder prompt: {_field(_forwarder_prompt)}", "self-heal log (last 5):", @@ -97,6 +99,21 @@ def _status_file() -> str: return f"{display} (wrote {_ago(path.stat().st_mtime)} ago)" +def _status_command() -> str: + settings = setup_hook._load_settings() + sl = settings.get("statusLine") + command = sl.get("command") if isinstance(sl, dict) else None + if not isinstance(command, str): + return "not configured" + if ( + sys.platform == "win32" + and "usage-statusline" in command + and "\\" in command + ): + return "Windows Git Bash-incompatible paths; run usage --setup, then restart Claude Code" + return "ok" + + def _external_hooks() -> str: state = setup_hook._detect_current_state() if state != "external": diff --git a/session_hooks.py b/session_hooks.py index acc0583..b6600e8 100644 --- a/session_hooks.py +++ b/session_hooks.py @@ -911,8 +911,8 @@ def _resume_restore_context(missing: list[str]) -> str: parts.append(f"seconds_since_previous_restore={elapsed}") command = _installed_resume_command() if command: - source = str(_resolve_resume_source()) - target = str(RESUME_HOOK_TARGET) + source = _resolve_resume_source().as_posix() + target = RESUME_HOOK_TARGET.as_posix() if source in command: parts.append("registered=source") elif target in command: @@ -1039,6 +1039,7 @@ def self_heal() -> None: if state in {"external", "legacy-tt"}: return _migrate_bundled_python_commands_if_needed(settings) + setup_hook._migrate_windows_statusline_command_if_needed(settings) if not is_setup() and "statusLine" not in settings: exit_code = _run_quietly(setup) if exit_code == 0: diff --git a/setup_hook.py b/setup_hook.py index 237164d..bfa8d73 100644 --- a/setup_hook.py +++ b/setup_hook.py @@ -135,6 +135,10 @@ def _find_system_python() -> str: def _shell_arg(value: str) -> str: if sys.platform == "win32": + # Claude Code runs statusLine commands through Git Bash when it is + # installed. Backslashes are escape characters there, while forward + # slashes also work in PowerShell, so emit the portable Windows form. + value = value.replace("\\", "/") return subprocess.list2cmdline([value]) return shlex.quote(value) @@ -148,6 +152,32 @@ def _uses_bundled_app_python(command: str) -> bool: return ".app/Contents" in command +def _migrate_windows_statusline_command_if_needed( + settings: dict[str, Any] | None = None, +) -> None: + """Replace legacy backslash paths in usage-owned Windows statusLine commands.""" + if sys.platform != "win32": + return + data = _load_settings() if settings is None else settings + sl = data.get("statusLine") + if not isinstance(sl, dict): + return + command = sl.get("command") + if not isinstance(command, str) or "\\" not in command: + return + if "usage-statusline-forwarder" in command: + new_command = _forwarder_command() + elif "usage-statusline" in command: + new_command = _statusline_command() + else: + return + if command == new_command: + return + sl["command"] = new_command + _save_settings(data) + _append_hook_repair_log("migrate_windows_statusline", "backslash paths -> forward slashes") + + def _append_hook_repair_log(action: str, detail: str) -> None: settings = _load_settings() usage_settings = settings.get(BACKUP_KEY) @@ -600,6 +630,7 @@ def setup(force_forwarder: bool = False) -> int: if has_claude: settings = _load_settings() _migrate_bundled_python_commands_if_needed(settings) + _migrate_windows_statusline_command_if_needed(settings) state = _detect_current_state(settings) if force_forwarder or state in {"external", "legacy-tt"}: diff --git a/tests/test_doctor.py b/tests/test_doctor.py index cf43b55..98d2de7 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -9,6 +9,7 @@ import json import os import sqlite3 +import sys from datetime import UTC, datetime from pathlib import Path @@ -66,6 +67,34 @@ def test_doctor_reports_external_hook_keyword( assert "external hooks: ccusage" in output +def test_doctor_flags_windows_backslash_statusline_command( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + settings = claude_dir / "settings.json" + settings.write_text( + json.dumps( + { + "statusLine": { + "type": "command", + "command": ( + r"C:\Python\python.exe " + r"C:\Users\test\.claude\usage-statusline.py" + ), + } + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(setup_hook, "CLAUDE_SETTINGS", settings) + + output = doctor.render() + + assert "status command: Windows Git Bash-incompatible paths" in output + + def test_doctor_reports_codex_diagnostics( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/test_setup_hook.py b/tests/test_setup_hook.py index 59e2a1f..0846d48 100644 --- a/tests/test_setup_hook.py +++ b/tests/test_setup_hook.py @@ -35,7 +35,7 @@ def test_setup_creates_new_settings_with_usage_statusline(setup_paths: SetupHook assert exit_code == 0 assert data["statusLine"]["type"] == "command" - assert str(hook_target) in data["statusLine"]["command"] + assert data["statusLine"]["command"] == expected_statusline_command(hook_target) assert hook_target.exists() @@ -216,14 +216,38 @@ def test_windows_hook_commands_use_double_quotes(monkeypatch: pytest.MonkeyPatch ) assert setup_hook._statusline_command() == ( - '"C:\\Program Files\\Python\\python.exe" ' - '"C:\\Users\\test user\\.claude\\usage-statusline.py"' + '"C:/Program Files/Python/python.exe" ' + '"C:/Users/test user/.claude/usage-statusline.py"' ) assert setup_hook._forwarder_command() == ( - '"C:\\Program Files\\Python\\python.exe" ' - '"C:\\Users\\test user\\.claude\\usage-statusline-forwarder.py"' + '"C:/Program Files/Python/python.exe" ' + '"C:/Users/test user/.claude/usage-statusline-forwarder.py"' + ) + + +def test_windows_statusline_migration_rewrites_legacy_backslash_paths( + monkeypatch: pytest.MonkeyPatch, + setup_paths: SetupHookPaths, +) -> None: + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr( + setup_hook, "_find_system_python", lambda: r"C:\Program Files\Python\python.exe" + ) + command = ( + r"C:\Program Files\Python\python.exe " + rf"{setup_paths.hook_target}".replace("/", "\\") + ) + setup_paths.settings.write_text( + json.dumps({"statusLine": {"type": "command", "command": command}}), + encoding="utf-8", ) + setup_hook._migrate_windows_statusline_command_if_needed() + + data = json.loads(setup_paths.settings.read_text(encoding="utf-8")) + assert data["statusLine"]["command"] == expected_statusline_command(setup_paths.hook_target) + assert data["usage"]["selfHealLog"][-1]["action"] == "migrate_windows_statusline" + def test_setup_codex_replaces_only_tui_status_line( monkeypatch: pytest.MonkeyPatch, tmp_path: Path @@ -415,7 +439,7 @@ def test_self_heal_installs_when_no_statusline(setup_paths: SetupHookPaths) -> N session_hooks.self_heal() data = json.loads(settings.read_text(encoding="utf-8")) - assert str(hook_target) in data["statusLine"]["command"] + assert data["statusLine"]["command"] == expected_statusline_command(hook_target) assert data["usage"]["selfHealLog"][-1]["action"] == "install_hook" diff --git a/tests/test_setup_hook_coexistence.py b/tests/test_setup_hook_coexistence.py index a24f24c..dac4adb 100644 --- a/tests/test_setup_hook_coexistence.py +++ b/tests/test_setup_hook_coexistence.py @@ -72,7 +72,10 @@ def test_install_when_forwarder_already_exists( ) -> None: settings = setup_paths.settings forwarder_target = setup_paths.forwarder_target - existing = {"type": "command", "command": f"/usr/bin/python3 {forwarder_target}"} + existing = { + "type": "command", + "command": expected_statusline_command(forwarder_target), + } settings.write_text(json.dumps({"statusLine": existing}), encoding="utf-8") assert setup_hook.setup() == 0 diff --git a/tests/test_setup_hook_resume.py b/tests/test_setup_hook_resume.py index cacc162..2cc7ddf 100644 --- a/tests/test_setup_hook_resume.py +++ b/tests/test_setup_hook_resume.py @@ -48,7 +48,7 @@ def test_enable_registers_hook_and_writes_sidecar( command = first_hook["command"] assert isinstance(command, str) assert str(resume_target) not in command - assert str(resume_paths.source) in command + assert resume_paths.source.as_posix() in command # Sidecar carries the i18n-sourced prompt template for every shipped language. bundle = json.loads(sidecar.read_text(encoding="utf-8")) assert {"zh-TW", "en", "ja", "ko", "zh-CN"} <= set(bundle) @@ -190,7 +190,7 @@ def test_self_heal_migrates_existing_target_command( assert migrated_hook["type"] == "command" assert migrated_hook["timeout"] == 3 assert str(resume_target) not in migrated_hook["command"] - assert str(source) in migrated_hook["command"] + assert source.as_posix() in migrated_hook["command"] assert session_entry["hooks"][1]["command"] == "other" assert data["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == "guard" # The stale "1.2" target also triggers a version update in the same pass, so the diff --git a/tests/test_setup_hook_terse.py b/tests/test_setup_hook_terse.py index c396aa1..391fada 100644 --- a/tests/test_setup_hook_terse.py +++ b/tests/test_setup_hook_terse.py @@ -56,7 +56,7 @@ def test_enable_registers_hook_and_writes_sidecar(terse_paths: TerseHookPaths) - command = first_hook["command"] assert isinstance(command, str) assert str(terse_paths.terse_target) not in command - assert str(terse_paths.source) in command + assert terse_paths.source.as_posix() in command bundle = json.loads(terse_paths.sidecar.read_text(encoding="utf-8")) assert {"zh-TW", "en", "ja", "ko", "zh-CN"} <= set(bundle) assert "Terse mode is on for this entire conversation" in bundle["en"]["instruction"] @@ -180,7 +180,7 @@ def test_enable_installs_codex_when_present(terse_paths: TerseHookPaths) -> None hook = hooks_list[0] assert isinstance(hook, dict) assert hook["timeout"] == 5 - assert str(terse_paths.codex_terse_target) in hook["command"] + assert terse_paths.codex_terse_target.as_posix() in hook["command"] def test_enable_idempotent_on_codex_features_and_entries(terse_paths: TerseHookPaths) -> None: @@ -289,7 +289,7 @@ def test_enable_registers_reminder_hook(terse_paths: TerseHookPaths) -> None: command = first_hook["command"] assert isinstance(command, str) assert str(terse_paths.terse_reminder_target) not in command - assert str(terse_paths.reminder_source) in command + assert terse_paths.reminder_source.as_posix() in command def test_enable_reminder_is_idempotent(terse_paths: TerseHookPaths) -> None: