From dfc1ff47ee341018b2d4dcee8cfb4192b322469f Mon Sep 17 00:00:00 2001 From: Yi Lu Date: Wed, 3 Jun 2026 20:02:35 +0000 Subject: [PATCH] fix: resolve issue #65 session runtime copies --- plugin/scripts/_lib.sh | 74 +++++++++++++++++++++++++++++ plugin/scripts/backend-service.sh | 1 + plugin/scripts/cli.sh | 1 + plugin/scripts/dashboard-service.sh | 1 + plugin/scripts/hook_entry.sh | 1 + plugin/scripts/smart-install.sh | 1 + tests/test_install_scripts.py | 65 +++++++++++++++++++++++++ 7 files changed, 144 insertions(+) diff --git a/plugin/scripts/_lib.sh b/plugin/scripts/_lib.sh index 9f63035..3db86ab 100644 --- a/plugin/scripts/_lib.sh +++ b/plugin/scripts/_lib.sh @@ -220,6 +220,80 @@ importlib.import_module(sys.argv[1]) PY } +claude_smart_canonical_dir() { + local dir + dir="$1" + [ -d "$dir" ] || return 1 + (cd "$dir" 2>/dev/null && pwd -P) +} + +claude_smart_is_reflexio_session_copy() { + local plugin_root root reflexio_root + plugin_root="$(claude_smart_canonical_dir "$1" 2>/dev/null || true)" + [ -n "$plugin_root" ] || return 1 + reflexio_root="$(claude_smart_canonical_dir "$HOME/.reflexio" 2>/dev/null || true)" + [ -n "$reflexio_root" ] || return 1 + case "$plugin_root" in + "$reflexio_root"/Cu*) ;; + *) return 1 ;; + esac + root="${plugin_root#"$reflexio_root"/}" + case "$root" in + Cu*/*|Cu*) return 0 ;; + esac + return 1 +} + +claude_smart_stable_plugin_root_for_session_copy() { + local current candidate current_real candidate_real glob + current="$1" + if ! claude_smart_is_reflexio_session_copy "$current"; then + return 1 + fi + current_real="$(claude_smart_canonical_dir "$current" 2>/dev/null || true)" + + for candidate in \ + "$HOME/.reflexio/plugin-root" \ + "$HOME/.claude/plugins/marketplaces/reflexioai/plugin" \ + "$HOME/.codex/plugins/cache/reflexioai/claude-smart/current" + do + [ -f "$candidate/pyproject.toml" ] || continue + candidate_real="$(claude_smart_canonical_dir "$candidate" 2>/dev/null || true)" + [ -n "$candidate_real" ] || continue + [ "$candidate_real" != "$current_real" ] || continue + if claude_smart_is_reflexio_session_copy "$candidate_real"; then + continue + fi + printf '%s\n' "$candidate_real" + return 0 + done + + for glob in "$HOME/.claude/plugins/cache/reflexioai/claude-smart"/* "$HOME/.codex/plugins/cache/reflexioai/claude-smart"/*; do + [ -f "$glob/pyproject.toml" ] || continue + candidate_real="$(claude_smart_canonical_dir "$glob" 2>/dev/null || true)" + [ -n "$candidate_real" ] || continue + [ "$candidate_real" != "$current_real" ] || continue + if claude_smart_is_reflexio_session_copy "$candidate_real"; then + continue + fi + printf '%s\n' "$candidate_real" + return 0 + done + return 1 +} + +claude_smart_reexec_stable_plugin_root_if_needed() { + local plugin_root script stable + plugin_root="$1" + script="$2" + stable="$(claude_smart_stable_plugin_root_for_session_copy "$plugin_root" 2>/dev/null || true)" + [ -n "$stable" ] || return 0 + [ -x "$stable/scripts/$script" ] || return 0 + echo "[claude-smart] redirecting per-session plugin copy $plugin_root to stable root $stable" >&2 + shift 2 + exec bash "$stable/scripts/$script" "$@" +} + claude_smart_download() { local url dest src _CS_PY url="$1" diff --git a/plugin/scripts/backend-service.sh b/plugin/scripts/backend-service.sh index 0056bf5..df4a344 100755 --- a/plugin/scripts/backend-service.sh +++ b/plugin/scripts/backend-service.sh @@ -45,6 +45,7 @@ fi # CLI dir (commonly ~/.local/bin or /opt/homebrew/bin). Pin the CLI # explicitly if we can resolve it from our own (post-login-path) PATH. PLUGIN_ROOT="$(cd "$HERE/.." && pwd)" +claude_smart_reexec_stable_plugin_root_if_needed "$PLUGIN_ROOT" "backend-service.sh" "$@" if [ -z "${CLAUDE_SMART_CLI_PATH:-}" ]; then if [ "${CLAUDE_SMART_HOST:-claude-code}" = "codex" ]; then diff --git a/plugin/scripts/cli.sh b/plugin/scripts/cli.sh index 5b9d7b4..42d691f 100755 --- a/plugin/scripts/cli.sh +++ b/plugin/scripts/cli.sh @@ -18,6 +18,7 @@ claude_smart_prepend_node_bins claude_smart_source_reflexio_env PLUGIN_ROOT="$(cd "$HERE/.." && pwd)" +claude_smart_reexec_stable_plugin_root_if_needed "$PLUGIN_ROOT" "cli.sh" "$@" # If the Setup hook recorded an install failure, surface that reason # instead of falling through to a generic "uv not found" — mirrors the diff --git a/plugin/scripts/dashboard-service.sh b/plugin/scripts/dashboard-service.sh index b292384..004abc5 100755 --- a/plugin/scripts/dashboard-service.sh +++ b/plugin/scripts/dashboard-service.sh @@ -29,6 +29,7 @@ CMD="${1:-start}" PORT=3001 PLUGIN_ROOT="$(cd "$HERE/.." && pwd)" +claude_smart_reexec_stable_plugin_root_if_needed "$PLUGIN_ROOT" "dashboard-service.sh" "$@" DASHBOARD_DIR="$PLUGIN_ROOT/dashboard" WORKSPACE_CWD="${PWD:-}" diff --git a/plugin/scripts/hook_entry.sh b/plugin/scripts/hook_entry.sh index c496dac..27b98f3 100755 --- a/plugin/scripts/hook_entry.sh +++ b/plugin/scripts/hook_entry.sh @@ -46,6 +46,7 @@ claude_smart_prepend_astral_bins claude_smart_source_reflexio_env PLUGIN_ROOT="$(cd "$HERE/.." && pwd)" +claude_smart_reexec_stable_plugin_root_if_needed "$PLUGIN_ROOT" "hook_entry.sh" "$@" FAILURE_MARKER="$HOME/.claude-smart/install-failed" STATE_DIR="$HOME/.claude-smart" diff --git a/plugin/scripts/smart-install.sh b/plugin/scripts/smart-install.sh index ee85884..d706b2c 100755 --- a/plugin/scripts/smart-install.sh +++ b/plugin/scripts/smart-install.sh @@ -17,6 +17,7 @@ claude_smart_prepend_node_bins claude_smart_source_reflexio_env PLUGIN_ROOT="$(cd "$HERE/.." && pwd)" +claude_smart_reexec_stable_plugin_root_if_needed "$PLUGIN_ROOT" "smart-install.sh" "$@" REPO_ROOT="$(cd "$HERE/../.." && pwd)" MARKER_DIR="$HOME/.claude-smart" diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py index 06f7505..87e87ed 100644 --- a/tests/test_install_scripts.py +++ b/tests/test_install_scripts.py @@ -1351,6 +1351,71 @@ def test_smart_install_installs_vendored_reflexio(tmp_path: Path) -> None: assert f"-e {vendor}" in uv_log +def test_smart_install_redirects_reflexio_session_copies_to_stable_root( + tmp_path: Path, +) -> None: + """Regression: Claude Code can expose CLAUDE_PLUGIN_ROOT as a per-session + ~/.reflexio/Cu* copy. Installing .venv/node_modules there duplicates the + full runtime for every session; smart-install must instead use the stable + plugin root recorded in ~/.reflexio/plugin-root. + """ + session_root = tmp_path / ".reflexio" / "Cu123" / "plugin" + stable_root = tmp_path / ".claude" / "plugins" / "cache" / "reflexioai" / "claude-smart" / "0.2.42" + fake_bin = tmp_path / "bin" + for root in (session_root, stable_root): + scripts = root / "scripts" + scripts.mkdir(parents=True) + shutil.copy2(SMART_INSTALL, scripts / "smart-install.sh") + shutil.copy2(LIB, scripts / "_lib.sh") + shutil.copy2( + REPO_ROOT / "plugin" / "scripts" / "ensure-plugin-root.sh", + scripts / "ensure-plugin-root.sh", + ) + (root / "pyproject.toml").write_text("[project]\nname='claude-smart'\n") + (root / "uv.lock").write_text("") + + (tmp_path / ".reflexio").mkdir(exist_ok=True) + (tmp_path / ".reflexio" / "plugin-root").symlink_to( + stable_root, + target_is_directory=True, + ) + fake_bin.mkdir() + uv = fake_bin / "uv" + uv.write_text( + "#!/bin/sh\n" + 'printf "%s|%s\\n" "$PWD" "$*" >> "$HOME/uv.log"\n' + 'if [ "$1" = "sync" ]; then\n' + ' mkdir -p "$PWD/.venv/bin"\n' + ' printf "#!/bin/sh\\nexit 0\\n" > "$PWD/.venv/bin/python"\n' + ' chmod +x "$PWD/.venv/bin/python"\n' + "fi\n" + "exit 0\n" + ) + uv.chmod(uv.stat().st_mode | stat.S_IXUSR) + + env = _isolated_env(tmp_path) + env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" + result = subprocess.run( + [ + "/bin/bash", + "--noprofile", + "--norc", + str(session_root / "scripts" / "smart-install.sh"), + ], + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert not (session_root / ".venv").exists() + assert (stable_root / ".venv" / "bin" / "python").exists() + uv_log = (tmp_path / "uv.log").read_text() + assert f"{stable_root}|sync --locked --python 3.12 --quiet" in uv_log + assert str(session_root) not in uv_log + + def test_smart_install_repairs_reflexio_template_env_drift(tmp_path: Path) -> None: plugin_root = tmp_path / "plugin" scripts = plugin_root / "scripts"