From 328630a4f2fcb6030eb61c0a523210fa59f3e83b Mon Sep 17 00:00:00 2001 From: Florian DAVID Date: Thu, 25 Jun 2026 23:03:00 +0200 Subject: [PATCH] fix: resolve claude.cmd shim on Windows before spawning (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows the npm global install ships the CLI only as a `claude.cmd` shim (no `claude.exe`). `subprocess.run(["claude", ...])` goes through CreateProcess, which resolves only `.exe` from a bare name, so every spawn raised `FileNotFoundError: [WinError 2]` — silently killing every auto-save (now.md / today-*.md / recent.md never generated). Resolve the binary with `shutil.which("claude")`, which honours PATHEXT and returns the full claude.cmd path subprocess can launch — no shell=True, no argv-length regression, cross-platform safe. Overridable via REMEMBER_CLAUDE_BIN, mirroring the REMEMBER_MODEL / _MAX_TURNS knobs. Bump 0.8.2 -> 0.8.3. Co-Authored-By: Max --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 6 ++++ README.md | 2 +- pipeline/haiku.py | 24 +++++++++++++++- tests/test_haiku.py | 57 +++++++++++++++++++++++++++++++++++++- 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 948e7ed..170ade8 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "remember", "description": "Continuous memory for Claude Code. Extracts, summarizes, and compresses conversations into tiered daily logs. Claude remembers what you did yesterday.", - "version": "0.8.2", + "version": "0.8.3", "author": { "name": "Digital Process Tools" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 58545a1..8bee3c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.3] — Windows: resolve the claude.cmd shim before spawning + +### Fixed + +- **Every auto-save silently failed on Windows** ([#120](https://github.com/Digital-Process-Tools/claude-remember/issues/120)) — `pipeline/haiku.py` spawned the CLI in list-form as `subprocess.run(["claude", ...])`. The npm global install ships the CLI only as a `claude.cmd` shim (no `claude.exe`), and Python's `subprocess` goes through `CreateProcess`, which resolves only `.exe` from a bare name — so every spawn raised `FileNotFoundError: [WinError 2]`. The pipeline aborted right after `[haiku] calling`, so `now.md` / `today-*.md` / `recent.md` were never generated (the SessionStart hook and `/remember` skill kept working since they don't spawn `claude`). The binary is now resolved with `shutil.which("claude")`, which honours `PATHEXT` and returns the full `claude.cmd` path that `subprocess` launches fine — no `shell=True`, no argv-length regression, and cross-platform safe (returns the plain path on Linux/macOS). Override via `REMEMBER_CLAUDE_BIN`. Reported with a precise diagnosis and tested patch by the issue author. + ## [0.8.2] — Oversized-extract guard keeps long sessions saving ### Fixed diff --git a/README.md b/README.md index 75e5934..05f90e0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Python](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org/) [![OS](https://img.shields.io/badge/tested%20on-Linux%20%7C%20macOS%20%7C%20Windows-blue)](https://github.com/Digital-Process-Tools/claude-remember/actions/workflows/tests.yml) [![License](https://img.shields.io/badge/license-Community-brightgreen)](LICENSE) -[![Version](https://img.shields.io/badge/version-0.8.2-orange)](.claude-plugin/plugin.json) +[![Version](https://img.shields.io/badge/version-0.8.3-orange)](.claude-plugin/plugin.json) Claude Code starts every session blank. It doesn't know what you worked on yesterday, what conventions your team follows, or what mistakes it already made. You re-explain everything, every time. diff --git a/pipeline/haiku.py b/pipeline/haiku.py index 937f66b..e54d6b4 100644 --- a/pipeline/haiku.py +++ b/pipeline/haiku.py @@ -22,6 +22,7 @@ import json import os import re +import shutil import subprocess import tempfile @@ -71,6 +72,27 @@ def _resolve_model() -> str: return raw if raw else DEFAULT_MODEL +def _resolve_claude_bin() -> str: + """Full path to the ``claude`` executable, resolved before spawning. + + On Windows the npm global install ships the CLI only as a ``claude.cmd`` + shim (no ``claude.exe``). ``subprocess`` goes through ``CreateProcess``, + which only resolves ``.exe`` from a bare name — so ``["claude", ...]`` dies + with ``FileNotFoundError: [WinError 2]`` and silently kills every auto-save + (#120). ``shutil.which`` honours ``PATHEXT`` and returns the full + ``claude.cmd`` path, which ``subprocess`` launches fine (no ``shell=True``, + no argv-length regression); on Linux/macOS it returns the plain path. + + REMEMBER_CLAUDE_BIN overrides the lookup (mirrors REMEMBER_MODEL / + REMEMBER_MAX_TURNS). When ``which`` finds nothing, fall back to the bare + name so behaviour matches the pre-fix code on a misconfigured PATH. + """ + override = os.environ.get("REMEMBER_CLAUDE_BIN", "").strip() + if override: + return override + return shutil.which("claude") or "claude" + + def _child_env() -> dict[str, str]: """Environment for the nested ``claude -p`` with the PARENT session vars stripped. @@ -119,7 +141,7 @@ def call_haiku( # list too long") at exec time and silently kills saves of long sessions. # `claude -p` with no positional prompt reads the prompt from stdin. cmd = [ - "claude", + _resolve_claude_bin(), "-p", "--output-format", "json", "--no-session-persistence", diff --git a/tests/test_haiku.py b/tests/test_haiku.py index 9cddb8e..8a25984 100644 --- a/tests/test_haiku.py +++ b/tests/test_haiku.py @@ -126,7 +126,7 @@ def test_call_haiku_success(mock_run, monkeypatch): args = mock_run.call_args cmd = args[0][0] - assert "claude" in cmd + assert os.path.basename(cmd[0]).startswith("claude") assert "--model" in cmd assert "haiku" in cmd # One-shot summarization subprocess: never resume these, never write to disk @@ -430,3 +430,58 @@ def test_reject_gate_custom_pattern_applies(monkeypatch): monkeypatch.setenv("REMEMBER_REJECT_PATTERN", r"^banana") assert _parse_response(_mock_claude_response("banana split")).is_skip is True assert _parse_response(_mock_claude_response("I cannot do that.")).is_skip is False + + +# --- REMEMBER_CLAUDE_BIN: resolve the claude.cmd shim on Windows (#120) ------- +from pipeline.haiku import _resolve_claude_bin + + +def test_resolve_claude_bin_uses_which(monkeypatch): + """Default resolves the full path via shutil.which (queried for "claude").""" + monkeypatch.delenv("REMEMBER_CLAUDE_BIN", raising=False) + with patch("pipeline.haiku.shutil.which", return_value="/usr/local/bin/claude") as w: + assert _resolve_claude_bin() == "/usr/local/bin/claude" + w.assert_called_once_with("claude") + + +def test_resolve_claude_bin_windows_cmd_shim(monkeypatch): + """shutil.which honours PATHEXT and returns the full claude.cmd path, which + subprocess CAN launch — a bare "claude" cannot (CreateProcess only resolves + .exe from a bare name), which is what kills every auto-save on Windows (#120).""" + monkeypatch.delenv("REMEMBER_CLAUDE_BIN", raising=False) + shim = r"C:\Users\x\AppData\Roaming\npm\claude.cmd" + with patch("pipeline.haiku.shutil.which", return_value=shim): + assert _resolve_claude_bin() == shim + + +def test_resolve_claude_bin_env_override(monkeypatch): + """REMEMBER_CLAUDE_BIN wins over which (mirrors REMEMBER_MODEL / _MAX_TURNS).""" + monkeypatch.setenv("REMEMBER_CLAUDE_BIN", "/opt/claude/bin/claude") + with patch("pipeline.haiku.shutil.which", return_value="/usr/local/bin/claude"): + assert _resolve_claude_bin() == "/opt/claude/bin/claude" + + +def test_resolve_claude_bin_blank_override_falls_back(monkeypatch): + monkeypatch.setenv("REMEMBER_CLAUDE_BIN", " ") + with patch("pipeline.haiku.shutil.which", return_value="/usr/local/bin/claude"): + assert _resolve_claude_bin() == "/usr/local/bin/claude" + + +def test_resolve_claude_bin_not_on_path_falls_back(monkeypatch): + """which finds nothing → fall back to the bare name, preserving the prior + behaviour on a misconfigured PATH instead of returning None / crashing.""" + monkeypatch.delenv("REMEMBER_CLAUDE_BIN", raising=False) + with patch("pipeline.haiku.shutil.which", return_value=None): + assert _resolve_claude_bin() == "claude" + + +@patch("pipeline.haiku.subprocess.run") +def test_call_haiku_uses_resolved_bin(mock_run, monkeypatch): + """cmd[0] must be the RESOLVED binary path, not the bare "claude" — else + Windows' CreateProcess raises WinError 2 on the claude.cmd shim (#120).""" + monkeypatch.delenv("REMEMBER_CLAUDE_BIN", raising=False) + mock_run.return_value = MagicMock( + returncode=0, stdout=_mock_claude_response("x"), stderr="") + with patch("pipeline.haiku.shutil.which", return_value="/usr/local/bin/claude"): + call_haiku("p") + assert mock_run.call_args[0][0][0] == "/usr/local/bin/claude"