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
91 changes: 91 additions & 0 deletions plugin/scripts/_lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,97 @@ 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)
}

# True when $1 canonicalizes to a plugin directory that physically lives
# *inside* ~/.reflexio. claude-smart's real install never places the live
# plugin tree there: the only entry under ~/.reflexio is the `plugin-root`
# symlink, whose `pwd -P` resolves to a cache dir *outside* ~/.reflexio
# (~/.claude, ~/.codex, or the npm global). So any plugin root that resolves
# to a descendant of ~/.reflexio is a stray host-made copy — e.g. the Windows
# cwd-derived `CUsers...` directories from issue #65 — that we must not
# bootstrap a heavy .venv/node_modules into.
#
# Detection is structural (descendant-of-~/.reflexio + plugin markers) rather
# than name-based on purpose: the host mangles the cwd differently per drive
# and user dir (C:\Users -> CUsers..., D:\repos -> Drepos..., varying case),
# so matching a fixed prefix like `Cu*` misses most real copies.
claude_smart_is_reflexio_session_copy() {
local plugin_root reflexio_root
plugin_root="$(claude_smart_canonical_dir "$1" 2>/dev/null || true)"
[ -n "$plugin_root" ] || return 1
# Must look like a plugin root — not ~/.reflexio/data, /configs, or .env.
[ -f "$plugin_root/pyproject.toml" ] || return 1
[ -d "$plugin_root/scripts" ] || return 1
reflexio_root="$(claude_smart_canonical_dir "$HOME/.reflexio" 2>/dev/null || true)"
[ -n "$reflexio_root" ] || return 1
# Descendant of ~/.reflexio. The trailing slash on the subject plus the
# "/" before * pins the boundary so a sibling like ~/.reflexio-bak/plugin
# does not match.
case "$plugin_root/" in
"$reflexio_root"/*) 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
# `-f` not `-x`: we re-run via `exec bash <script>`, which does not need the
# executable bit. Cached copies on Windows/NTFS often lack +x, so requiring
# -x would no-op the guard on the very platform issue #65 affects.
[ -f "$stable/scripts/$script" ] || return 0
echo "[claude-smart] redirecting stray plugin copy under ~/.reflexio ($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"
Expand Down
1 change: 1 addition & 0 deletions plugin/scripts/backend-service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions plugin/scripts/cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 72 additions & 4 deletions plugin/scripts/codex-hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,82 @@ function appendLog(name, line) {
trimLog(file);
}

function realDir(dir) {
try {
if (!fs.statSync(dir).isDirectory()) return null;
return fs.realpathSync(dir);
} catch {
return null;
}
}

function isInsideDir(parent, child) {
const rel = path.relative(parent, child);
return rel && !rel.startsWith("..") && !path.isAbsolute(rel);
}

function isPluginLikeRoot(root) {
return fs.existsSync(path.join(root, "pyproject.toml")) && fs.existsSync(path.join(root, "scripts"));
}

function isReflexioStrayPluginCopy(root) {
if (!isPluginLikeRoot(root)) return false;
const rootReal = realDir(root);
const reflexioReal = realDir(REFLEXIO_DIR);
if (!rootReal || !reflexioReal) return false;
return isInsideDir(reflexioReal, rootReal);
}

function stablePluginRootForStrayCopy(root) {
if (!isReflexioStrayPluginCopy(root)) return null;
const rootReal = realDir(root);
const candidates = [
path.join(REFLEXIO_DIR, "plugin-root"),
path.join(HOME, ".claude", "plugins", "marketplaces", "reflexioai", "plugin"),
path.join(HOME, ".codex", "plugins", "cache", "reflexioai", "claude-smart", "current"),
];
for (const cacheRoot of [
path.join(HOME, ".claude", "plugins", "cache", "reflexioai", "claude-smart"),
path.join(HOME, ".codex", "plugins", "cache", "reflexioai", "claude-smart"),
]) {
try {
const versions = fs
.readdirSync(cacheRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(cacheRoot, entry.name))
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
candidates.push(...versions);
} catch {
// Missing cache roots are fine; try the next candidate family.
}
}
for (const candidate of candidates) {
if (!isPluginLikeRoot(candidate)) continue;
const candidateReal = realDir(candidate);
if (!candidateReal || candidateReal === rootReal) continue;
if (isReflexioStrayPluginCopy(candidateReal)) continue;
return candidateReal;
}
return null;
}

function stablePluginRoot(root) {
const stable = stablePluginRootForStrayCopy(root);
if (stable) {
appendLog("backend.log", `[claude-smart] redirecting stray plugin copy under ~/.reflexio (${root}) to stable root ${stable}`);
return stable;
}
return root;
}

function pluginRoot() {
for (const value of [process.env.CLAUDE_PLUGIN_ROOT, process.env.PLUGIN_ROOT]) {
if (value && fs.existsSync(path.join(value, "pyproject.toml"))) {
return path.resolve(value);
return stablePluginRoot(path.resolve(value));
}
}
const fromScript = path.resolve(__dirname, "..");
if (fs.existsSync(path.join(fromScript, "pyproject.toml"))) return fromScript;
if (fs.existsSync(path.join(fromScript, "pyproject.toml"))) return stablePluginRoot(fromScript);
const cacheRoot = path.join(HOME, ".codex", "plugins", "cache", "reflexioai", "claude-smart");
try {
const versions = fs
Expand All @@ -105,12 +173,12 @@ function pluginRoot() {
.map((entry) => path.join(cacheRoot, entry.name))
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
for (const candidate of versions) {
if (fs.existsSync(path.join(candidate, "pyproject.toml"))) return candidate;
if (fs.existsSync(path.join(candidate, "pyproject.toml"))) return stablePluginRoot(candidate);
}
} catch {
// Fall through to the stable plugin-root link.
}
return path.join(REFLEXIO_DIR, "plugin-root");
return stablePluginRoot(path.join(REFLEXIO_DIR, "plugin-root"));
}

function prependRuntimePath() {
Expand Down
1 change: 1 addition & 0 deletions plugin/scripts/dashboard-build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ claude_smart_source_login_path
claude_smart_prepend_node_bins

PLUGIN_ROOT="$(cd "$HERE/.." && pwd)"
claude_smart_reexec_stable_plugin_root_if_needed "$PLUGIN_ROOT" "dashboard-build.sh" "$@"
DASHBOARD_DIR="$PLUGIN_ROOT/dashboard"

STATE_DIR="$HOME/.claude-smart"
Expand Down
1 change: 1 addition & 0 deletions plugin/scripts/dashboard-service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}"

Expand Down
1 change: 1 addition & 0 deletions plugin/scripts/hook_entry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions plugin/scripts/smart-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 13 additions & 5 deletions plugin/src/claude_smart/ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import logging
import os
import subprocess # noqa: S404 — git invocation with a fixed flag set.
from pathlib import Path
from pathlib import Path, PureWindowsPath

from claude_smart import env_config

Expand All @@ -28,6 +28,14 @@
_USER_ID_OVERRIDE_ENV = "REFLEXIO_USER_ID"


def _basename(path: str | os.PathLike[str]) -> str:
"""Return a cwd basename, including native Windows paths under Git Bash."""
text = os.fspath(path)
if "\\" in text or (len(text) >= 2 and text[0].isalpha() and text[1] == ":"):
return PureWindowsPath(text).name or "unknown-project"
return Path(path).name or "unknown-project"


def resolve_project_id(cwd: str | os.PathLike[str] | None = None) -> str:
"""Return a stable project identifier for the given working directory.

Expand All @@ -47,7 +55,7 @@ def resolve_project_id(cwd: str | os.PathLike[str] | None = None) -> str:
base = Path(cwd) if cwd is not None else Path.cwd()
try:
result = subprocess.run( # noqa: S603, S607 — fixed argv, cwd is a Path.
["git", "rev-parse", "--show-toplevel"],
["git", "rev-parse", "--show-toplevel"], # noqa: S607
cwd=base,
capture_output=True,
text=True,
Expand All @@ -57,10 +65,10 @@ def resolve_project_id(cwd: str | os.PathLike[str] | None = None) -> str:
if result.returncode == 0:
toplevel = result.stdout.strip()
if toplevel:
return Path(toplevel).name
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
return _basename(toplevel)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
_LOGGER.debug("git toplevel resolution failed: %s", exc)
return base.name or "unknown-project"
return _basename(cwd) if cwd is not None else _basename(base)


def resolve_user_id(cwd: str | os.PathLike[str] | None = None) -> str:
Expand Down
20 changes: 20 additions & 0 deletions tests/test_ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ def _boom(*_a, **_kw):
assert ids.resolve_project_id(tmp_path) == tmp_path.name


def test_resolve_handles_windows_cwd_when_git_fails(monkeypatch) -> None:
def _boom(*_a, **_kw):
raise FileNotFoundError("git missing")

monkeypatch.setattr(subprocess, "run", _boom)
assert ids.resolve_project_id(r"C:\Users\Alice\repo") == "repo"


def test_resolve_handles_windows_git_toplevel(monkeypatch) -> None:
def _git(*_a, **_kw):
return subprocess.CompletedProcess(
args=["git", "rev-parse", "--show-toplevel"],
returncode=0,
stdout="C:\\Users\\Alice\\repo\n",
)

monkeypatch.setattr(subprocess, "run", _git)
assert ids.resolve_project_id(r"C:\Users\Alice\repo\packages\core") == "repo"


def test_resolve_project_id_ignores_user_id_override(tmp_path, monkeypatch) -> None:
_isolate_git_env(monkeypatch)
monkeypatch.setenv("REFLEXIO_API_KEY", "rflx-test-key")
Expand Down
Loading
Loading