Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import os
import shlex
import sqlite3
import sys
import tomllib
from collections.abc import Callable
from datetime import UTC, datetime
Expand All @@ -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):",
Expand Down Expand Up @@ -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":
Expand Down
5 changes: 3 additions & 2 deletions session_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions setup_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Comment on lines +141 to 142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Quote Windows commands for the shell

When Claude Code invokes this through Git Bash, subprocess.list2cmdline is still Windows argv quoting, not shell quoting: it leaves Bash metacharacters unescaped, so a valid path like C:\Users\R&D\.claude\usage-statusline.py becomes C:/Users/R&D/... and Bash treats & as a control operator before the hook runs. The statusLine docs say the command field runs in a shell, so after making the command Bash-compatible with forward slashes this also needs shell-safe quoting for Windows paths with characters such as &, $, or parentheses.

Useful? React with 👍 / 👎.

return shlex.quote(value)

Expand All @@ -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()
Comment on lines +168 to +171

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Migrate companion hook commands too

For Windows users who already enabled Resume or Terse mode before upgrading, the existing hooks.SessionStart/UserPromptSubmit commands were written by the old _shell_arg with backslash paths, but this migration only rewrites statusLine. Claude hooks are shell commands as well, so those opt-in hooks continue to fail under Git Bash until the user manually disables and re-enables them; self-heal should also rewrite usage-owned usage_session_resume, usage_terse_mode, and reminder hook commands.

Useful? React with 👍 / 👎.

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)
Expand Down Expand Up @@ -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"}:
Expand Down
29 changes: 29 additions & 0 deletions tests/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
import os
import sqlite3
import sys
from datetime import UTC, datetime
from pathlib import Path

Expand Down Expand Up @@ -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,
Expand Down
36 changes: 30 additions & 6 deletions tests/test_setup_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"


Expand Down
5 changes: 4 additions & 1 deletion tests/test_setup_hook_coexistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/test_setup_hook_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions tests/test_setup_hook_terse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down