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
26 changes: 21 additions & 5 deletions setup_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,19 @@ def _statusline_command_target_exists() -> bool:
def _find_system_python() -> str:
if sys.platform == "win32":
executable = sys.executable
if executable and not getattr(sys, "frozen", False):
if (
executable
and not getattr(sys, "frozen", False)
and _is_ascii_path(executable)
):
return executable
return shutil.which("python") or "python"
# Claude Code can fail to spawn a command containing non-ASCII paths
# on Windows. The hook is stdlib-only, so an ASCII-path Python from
# PATH (or the Windows launcher) is preferable to this app's venv.
for candidate in (shutil.which("python"), shutil.which("py")):
if candidate and _is_ascii_path(candidate):
return candidate
return "python"
if os.path.exists("/usr/bin/python3"):
return "/usr/bin/python3"
executable = sys.executable
Expand All @@ -143,6 +153,10 @@ def _find_system_python() -> str:
return shutil.which("python3") or "python3"


def _is_ascii_path(value: str) -> bool:
return value.isascii()


def _shell_arg(value: str) -> str:
if sys.platform == "win32":
# Claude Code runs statusLine commands through Git Bash when it is
Expand All @@ -165,15 +179,15 @@ def _uses_bundled_app_python(command: str) -> bool:
def _migrate_windows_statusline_command_if_needed(
settings: dict[str, Any] | None = None,
) -> None:
"""Replace legacy backslash paths in usage-owned Windows statusLine commands."""
"""Repair Windows usage commands with backslash or non-ASCII paths."""
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:
if not isinstance(command, str) or ("\\" not in command and command.isascii()):
return
if "usage-statusline-forwarder" in command:
new_command = _forwarder_command()
Expand All @@ -185,7 +199,9 @@ def _migrate_windows_statusline_command_if_needed(
return
sl["command"] = new_command
_save_settings(data)
_append_hook_repair_log("migrate_windows_statusline", "backslash paths -> forward slashes")
_append_hook_repair_log(
"migrate_windows_statusline", "backslash/non-ASCII paths -> ASCII forward-slash command"
)


def _append_hook_repair_log(action: str, detail: str) -> None:
Expand Down
47 changes: 47 additions & 0 deletions tests/test_session_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from __future__ import annotations

import io
import json
import os
import sys
Expand All @@ -18,6 +19,31 @@
import usage_session_resume as mod


def test_detect_lang_uses_windows_system_lang_when_env_is_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
for key in ("USAGE_LANG", "TT_LANG", "LANG"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(mod, "_windows_system_lang", lambda: "zh_TW")

assert mod._detect_lang() == "zh-TW"


def test_detect_lang_prefers_usage_lang_over_windows_system_lang(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("USAGE_LANG", "ja")
monkeypatch.setattr(mod, "_windows_system_lang", lambda: "zh_TW")

assert mod._detect_lang() == "ja"


def test_windows_system_lang_is_empty_off_windows(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(mod, "os", SimpleNamespace(name="posix"))

assert mod._windows_system_lang() == ""


def test_windows_output_reconfigures_both_streams(monkeypatch: pytest.MonkeyPatch) -> None:
class Stream:
def __init__(self) -> None:
Expand Down Expand Up @@ -549,6 +575,27 @@ def test_main_emits_greeting_when_no_progress(
assert out["hookSpecificOutput"]["additionalContext"] == "GREETING::"


def test_main_reads_utf8_bytes_when_stdin_uses_cp950(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setenv("LANG", "en_US.UTF-8")
_sidecar(tmp_path, monkeypatch)
project = _project_dir(tmp_path)
current = project / "current.jsonl"
current.write_text("", encoding="utf-8")
payload = json.dumps(
{"transcript_path": str(current), "cwd": r"C:\\Users\\USER\\Desktop\\GitHub專案\\usage"},
ensure_ascii=False,
)
monkeypatch.setattr(
sys, "stdin", io.TextIOWrapper(io.BytesIO(payload.encode("utf-8")), encoding="cp950")
)

assert mod.main() == 0
out = json.loads(capsys.readouterr().out)
assert out["hookSpecificOutput"]["hookEventName"] == "SessionStart"


def test_extract_commit_title_handles_heredoc_forms() -> None:
# `git commit -F - <<'EOF'` has no `cat` prefix — the most common form, previously missed.
assert (
Expand Down
46 changes: 46 additions & 0 deletions tests/test_setup_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import json
import shutil
import sys
import tomllib
from collections.abc import Callable
Expand Down Expand Up @@ -220,6 +221,20 @@ def test_find_system_python_uses_current_interpreter_on_windows(
assert setup_hook._find_system_python() == r"C:\\Program Files\\Python\\python.exe"


def test_find_system_python_avoids_non_ascii_windows_interpreter(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(sys, "executable", r"C:\\專案\\usage\\.venv\\Scripts\\python.exe")
monkeypatch.setattr(
shutil,
"which",
lambda name: r"C:\\Program Files\\Python\\python.exe" if name == "python" else None,
)

assert setup_hook._find_system_python() == r"C:\\Program Files\\Python\\python.exe"


def test_windows_hook_commands_use_double_quotes(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(
Expand Down Expand Up @@ -270,6 +285,37 @@ def test_windows_statusline_migration_rewrites_legacy_backslash_paths(
assert data["usage"]["selfHealLog"][-1]["action"] == "migrate_windows_statusline"


def test_windows_statusline_migration_rewrites_non_ascii_interpreter(
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"
)
setup_paths.settings.write_text(
json.dumps(
{
"statusLine": {
"type": "command",
"command": (
r"C:/Users/USER/Desktop/GitHub專案/usage/.venv/Scripts/python.exe "
f"{setup_paths.hook_target}"
),
}
}
),
encoding="utf-8",
)

setup_hook._migrate_windows_statusline_command_if_needed()

data = json.loads(setup_paths.settings.read_text(encoding="utf-8"))
command = data["statusLine"]["command"]
assert command == expected_statusline_command(setup_paths.hook_target)
assert command.isascii()


def test_setup_codex_replaces_only_tui_status_line(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
Expand Down
103 changes: 103 additions & 0 deletions tests/test_usage_statusline.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,89 @@
import usage_statusline


def test_get_width_uses_conout_width_after_windows_pipe_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def raise_oserror(fd: int) -> os.terminal_size:
_ = fd
raise OSError("not a terminal")

monkeypatch.setattr(
usage_statusline,
"os",
SimpleNamespace(name="nt", get_terminal_size=raise_oserror),
)
monkeypatch.setattr(usage_statusline, "_conout_columns", lambda: 220)

assert usage_statusline.get_width() == 216


@pytest.mark.parametrize("columns", (None, 0))
def test_get_width_keeps_default_when_conout_is_unavailable(
monkeypatch: pytest.MonkeyPatch,
columns: int | None,
) -> None:
def raise_oserror(fd: int) -> os.terminal_size:
_ = fd
raise OSError("not a terminal")

monkeypatch.setattr(
usage_statusline,
"os",
SimpleNamespace(name="nt", get_terminal_size=raise_oserror),
)
monkeypatch.setattr(usage_statusline, "_conout_columns", lambda: columns)

assert usage_statusline.get_width() == 116


def test_get_width_does_not_probe_conout_off_windows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def raise_oserror(fd: int) -> os.terminal_size:
_ = fd
raise OSError("not a terminal")

def fail_probe() -> int:
raise AssertionError("CONOUT$ probe should not run off Windows")

monkeypatch.setattr(
usage_statusline,
"os",
SimpleNamespace(name="posix", get_terminal_size=raise_oserror),
)
monkeypatch.setattr(usage_statusline, "_conout_columns", fail_probe)

assert usage_statusline.get_width() == 116


def test_statusline_detect_lang_uses_windows_system_lang_when_env_is_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
for key in ("USAGE_LANG", "TT_LANG", "LANG"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(usage_statusline, "_windows_system_lang", lambda: "zh_TW")

assert usage_statusline._statusline_detect_lang({}) == "en"
assert usage_statusline._statusline_detect_lang() == "zh-TW"


def test_statusline_detect_lang_prefers_usage_lang_over_windows_system_lang(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(usage_statusline, "_windows_system_lang", lambda: "zh_TW")

assert usage_statusline._statusline_detect_lang({"USAGE_LANG": "ja"}) == "ja"


def test_statusline_windows_system_lang_is_empty_off_windows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(usage_statusline, "os", SimpleNamespace(name="posix"))

assert usage_statusline._windows_system_lang() == ""


def test_windows_output_reconfigures_both_streams(monkeypatch: pytest.MonkeyPatch) -> None:
class Stream:
def __init__(self) -> None:
Expand Down Expand Up @@ -411,6 +494,26 @@ def test_main_writes_valid_json_object(
assert capsys.readouterr().out == "usage\n"


def test_main_reads_utf8_bytes_when_stdin_uses_cp950(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
status_file = tmp_path / "usage-status.json"
payload = {"cwd": r"C:\\Users\\USER\\Desktop\\GitHub專案\\usage"}
stdin = io.TextIOWrapper(
io.BytesIO(json.dumps(payload, ensure_ascii=False).encode("utf-8")), encoding="cp950"
)
monkeypatch.setattr(usage_statusline, "STATUS_FILE", str(status_file))
monkeypatch.setattr(usage_statusline, "LOCK_FILE", str(tmp_path / "usage-status.lock"))
monkeypatch.setattr(sys, "stdin", stdin)

usage_statusline.main()

assert json.loads(status_file.read_text(encoding="utf-8"))["cwd"] == payload["cwd"]
assert capsys.readouterr().out == "usage\n"


def test_main_returns_when_stdin_read_raises(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
Expand Down
Loading