claude-smart 0.2.37 → 0.2.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -39
- package/bin/claude-smart.js +31 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/pyproject.toml +7 -11
- package/plugin/scripts/_lib.sh +26 -0
- package/plugin/scripts/backend-service.sh +17 -1
- package/plugin/scripts/cli.sh +1 -1
- package/plugin/scripts/hook_entry.sh +36 -2
- package/plugin/scripts/smart-install.sh +42 -16
- package/plugin/src/claude_smart/cli.py +132 -15
- package/plugin/src/claude_smart/hook_log.py +1 -1
- package/plugin/src/claude_smart/internal_call.py +11 -11
- package/plugin/uv.lock +918 -910
|
@@ -14,9 +14,8 @@ Exposes the following subcommands:
|
|
|
14
14
|
- ``learn``: publish unpublished interactions and force reflexio
|
|
15
15
|
extraction now over the active session buffer.
|
|
16
16
|
- ``restart``: stop and restart the reflexio backend + dashboard services
|
|
17
|
-
(rebuilding the dashboard bundle) so local
|
|
18
|
-
|
|
19
|
-
Code.
|
|
17
|
+
(rebuilding the dashboard bundle) so local Reflexio checkout edits or
|
|
18
|
+
``plugin/dashboard/`` edits take effect without restarting Claude Code.
|
|
20
19
|
"""
|
|
21
20
|
|
|
22
21
|
from __future__ import annotations
|
|
@@ -30,6 +29,7 @@ import shutil
|
|
|
30
29
|
import subprocess
|
|
31
30
|
import sys
|
|
32
31
|
import time
|
|
32
|
+
from collections.abc import Iterable
|
|
33
33
|
from dataclasses import dataclass
|
|
34
34
|
from pathlib import Path
|
|
35
35
|
|
|
@@ -82,6 +82,7 @@ _LOCAL_DATA_NOTICE = (
|
|
|
82
82
|
" rm -rf ~/.claude-smart ~/.reflexio\n"
|
|
83
83
|
)
|
|
84
84
|
_INSTALL_FAILURE_MARKER = _STATE_DIR / "install-failed"
|
|
85
|
+
_SERVICE_STATUS_PROBE_FAILED = "probe_failed"
|
|
85
86
|
_DEFAULT_STORAGE_ROOT = _REFLEXIO_DIR / "data"
|
|
86
87
|
_REFLEXIO_CONFIG_PATH = _REFLEXIO_DIR / "configs" / "config_self-host-org.json"
|
|
87
88
|
_LOCAL_STORAGE_ENV = "LOCAL_STORAGE_PATH"
|
|
@@ -108,6 +109,94 @@ _COPYTREE_IGNORE = shutil.ignore_patterns(
|
|
|
108
109
|
)
|
|
109
110
|
|
|
110
111
|
|
|
112
|
+
def _windows_path_text(path: str) -> str:
|
|
113
|
+
"""Normalize slashes/case for Windows path checks."""
|
|
114
|
+
return path.replace("/", "\\").lower()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_WINDOWS_SYSTEM_BASH_SUFFIXES = (
|
|
118
|
+
"\\windows\\system32\\bash.exe",
|
|
119
|
+
"\\windows\\sysnative\\bash.exe",
|
|
120
|
+
"\\windows\\syswow64\\bash.exe",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_windows_system_bash(path: str) -> bool:
|
|
125
|
+
normalized = _windows_path_text(path)
|
|
126
|
+
return normalized.endswith(_WINDOWS_SYSTEM_BASH_SUFFIXES)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _resolve_candidate(candidate: str) -> str | None:
|
|
130
|
+
expanded = os.path.expandvars(os.path.expanduser(candidate))
|
|
131
|
+
if os.path.isfile(expanded):
|
|
132
|
+
return expanded
|
|
133
|
+
return shutil.which(expanded)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _iter_path_candidates(names: tuple[str, ...]) -> list[str]:
|
|
137
|
+
found: list[str] = []
|
|
138
|
+
for raw_dir in os.environ.get("PATH", "").split(os.pathsep):
|
|
139
|
+
if not raw_dir:
|
|
140
|
+
continue
|
|
141
|
+
directory = os.path.expandvars(os.path.expanduser(raw_dir))
|
|
142
|
+
for name in names:
|
|
143
|
+
candidate = os.path.join(directory, name)
|
|
144
|
+
if os.path.isfile(candidate):
|
|
145
|
+
found.append(candidate)
|
|
146
|
+
return found
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _first_non_system_bash(candidates: Iterable[str]) -> str | None:
|
|
150
|
+
for candidate in candidates:
|
|
151
|
+
resolved = _resolve_candidate(candidate)
|
|
152
|
+
if resolved and not _is_windows_system_bash(resolved):
|
|
153
|
+
return resolved
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _resolve_bash() -> str | None:
|
|
158
|
+
"""Resolve a bash executable suitable for running bundled shell scripts.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Absolute path to a usable bash, or None when none is available.
|
|
162
|
+
On Windows, the WSL stub under ``\\Windows\\System32`` (and the
|
|
163
|
+
``Sysnative`` / ``SysWOW64`` redirectors) is rejected so that bundled
|
|
164
|
+
``*.sh`` scripts run under Git-Bash instead.
|
|
165
|
+
"""
|
|
166
|
+
if os.name != "nt":
|
|
167
|
+
return shutil.which("bash")
|
|
168
|
+
|
|
169
|
+
sources: list[Iterable[str]] = []
|
|
170
|
+
bash_env = os.environ.get("BASH", "").strip()
|
|
171
|
+
if bash_env:
|
|
172
|
+
sources.append((bash_env,))
|
|
173
|
+
sources.append(
|
|
174
|
+
(
|
|
175
|
+
r"C:\Program Files\Git\bin\bash.exe",
|
|
176
|
+
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
sources.append(_iter_path_candidates(("bash.exe", "bash")))
|
|
180
|
+
sources.append(("bash.exe", "bash"))
|
|
181
|
+
|
|
182
|
+
for source in sources:
|
|
183
|
+
resolved = _first_non_system_bash(source)
|
|
184
|
+
if resolved:
|
|
185
|
+
return resolved
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _resolve_npm() -> str | None:
|
|
190
|
+
"""Resolve npm, accounting for Windows .cmd/.exe shims."""
|
|
191
|
+
if os.name == "nt":
|
|
192
|
+
for candidate in ("npm.cmd", "npm.exe", "npm"):
|
|
193
|
+
resolved = shutil.which(candidate)
|
|
194
|
+
if resolved:
|
|
195
|
+
return resolved
|
|
196
|
+
return None
|
|
197
|
+
return shutil.which("npm")
|
|
198
|
+
|
|
199
|
+
|
|
111
200
|
def _latest_session_id() -> str | None:
|
|
112
201
|
"""Most-recently-modified session JSONL in the state dir. None if none exist."""
|
|
113
202
|
root = state.state_dir()
|
|
@@ -201,7 +290,7 @@ def _bootstrap_claude_code_install() -> tuple[bool, str]:
|
|
|
201
290
|
_force_plugin_root(plugin_root)
|
|
202
291
|
except OSError as exc:
|
|
203
292
|
return False, str(exc)
|
|
204
|
-
bash =
|
|
293
|
+
bash = _resolve_bash()
|
|
205
294
|
if not bash:
|
|
206
295
|
return False, "bash is required to bootstrap claude-smart dependencies"
|
|
207
296
|
result = subprocess.run(
|
|
@@ -1251,8 +1340,12 @@ def _run_service(script: Path, subcmd: str) -> int:
|
|
|
1251
1340
|
if not script.exists():
|
|
1252
1341
|
sys.stderr.write(f"error: {script} not found\n")
|
|
1253
1342
|
return 1
|
|
1343
|
+
bash = _resolve_bash()
|
|
1344
|
+
if not bash:
|
|
1345
|
+
sys.stderr.write("error: bash is required to run claude-smart service scripts\n")
|
|
1346
|
+
return 1
|
|
1254
1347
|
try:
|
|
1255
|
-
subprocess.run([str(script), subcmd], check=True)
|
|
1348
|
+
subprocess.run([bash, str(script), subcmd], check=True)
|
|
1256
1349
|
return 0
|
|
1257
1350
|
except subprocess.CalledProcessError as exc:
|
|
1258
1351
|
return exc.returncode or 1
|
|
@@ -1277,10 +1370,16 @@ def _service_status(script: Path, wait_ready_s: float = 3.0) -> str:
|
|
|
1277
1370
|
"""
|
|
1278
1371
|
if not script.exists():
|
|
1279
1372
|
return "script missing"
|
|
1373
|
+
bash = _resolve_bash()
|
|
1374
|
+
if not bash:
|
|
1375
|
+
return _SERVICE_STATUS_PROBE_FAILED
|
|
1280
1376
|
deadline = time.monotonic() + wait_ready_s
|
|
1281
1377
|
while True:
|
|
1282
1378
|
result = subprocess.run(
|
|
1283
|
-
[str(script), "status"],
|
|
1379
|
+
[bash, str(script), "status"],
|
|
1380
|
+
capture_output=True,
|
|
1381
|
+
text=True,
|
|
1382
|
+
check=False,
|
|
1284
1383
|
)
|
|
1285
1384
|
status = result.stdout.strip() or "unknown"
|
|
1286
1385
|
if status != "not running" or time.monotonic() >= deadline:
|
|
@@ -1305,6 +1404,10 @@ def _is_running_status(status: str) -> bool:
|
|
|
1305
1404
|
return status.startswith("running on ")
|
|
1306
1405
|
|
|
1307
1406
|
|
|
1407
|
+
def _is_confirmed_stopped_status(status: str) -> bool:
|
|
1408
|
+
return status == "not running"
|
|
1409
|
+
|
|
1410
|
+
|
|
1308
1411
|
def _read_dotenv_value(env_path: Path, key: str) -> str | None:
|
|
1309
1412
|
"""Read one simple KEY=VALUE binding from a dotenv file."""
|
|
1310
1413
|
if not env_path.is_file():
|
|
@@ -1507,7 +1610,7 @@ def cmd_restart(args: argparse.Namespace) -> int:
|
|
|
1507
1610
|
Stops both long-lived services, optionally rebuilds the dashboard's
|
|
1508
1611
|
Next.js bundle so source edits under ``plugin/dashboard/`` take effect,
|
|
1509
1612
|
then starts them again. Useful during local development when iterating
|
|
1510
|
-
on the
|
|
1613
|
+
on the local Reflexio checkout or the dashboard.
|
|
1511
1614
|
|
|
1512
1615
|
Args:
|
|
1513
1616
|
args (argparse.Namespace): Parsed CLI args. Honors ``args.skip_backend``,
|
|
@@ -1536,15 +1639,15 @@ def cmd_restart(args: argparse.Namespace) -> int:
|
|
|
1536
1639
|
sys.stderr.write(
|
|
1537
1640
|
f"warning: dashboard dir {_DASHBOARD_DIR} missing; skipping rebuild\n"
|
|
1538
1641
|
)
|
|
1539
|
-
elif not
|
|
1642
|
+
elif not (npm := _resolve_npm()):
|
|
1540
1643
|
sys.stderr.write("warning: npm not on PATH; serving previous build\n")
|
|
1541
1644
|
else:
|
|
1542
1645
|
next_bin = _DASHBOARD_DIR / "node_modules" / ".bin" / "next"
|
|
1543
1646
|
if not next_bin.exists():
|
|
1544
1647
|
install_cmd = (
|
|
1545
|
-
[
|
|
1648
|
+
[npm, "ci", "--no-audit", "--no-fund"]
|
|
1546
1649
|
if (_DASHBOARD_DIR / "package-lock.json").exists()
|
|
1547
|
-
else [
|
|
1650
|
+
else [npm, "install", "--no-audit", "--no-fund"]
|
|
1548
1651
|
)
|
|
1549
1652
|
install_label = " ".join(install_cmd)
|
|
1550
1653
|
sys.stdout.write(
|
|
@@ -1573,10 +1676,11 @@ def cmd_restart(args: argparse.Namespace) -> int:
|
|
|
1573
1676
|
"Rebuilding dashboard (npm run build, may take a minute)…\n"
|
|
1574
1677
|
)
|
|
1575
1678
|
try:
|
|
1576
|
-
subprocess.run([
|
|
1577
|
-
except subprocess.CalledProcessError as exc:
|
|
1679
|
+
subprocess.run([npm, "run", "build"], cwd=_DASHBOARD_DIR, check=True)
|
|
1680
|
+
except (subprocess.CalledProcessError, OSError) as exc:
|
|
1681
|
+
returncode = getattr(exc, "returncode", 1) or 1
|
|
1578
1682
|
sys.stderr.write(
|
|
1579
|
-
f"error: dashboard build failed (exit {
|
|
1683
|
+
f"error: dashboard build failed (exit {returncode}); "
|
|
1580
1684
|
"not starting dashboard.\n"
|
|
1581
1685
|
)
|
|
1582
1686
|
if do_backend:
|
|
@@ -1585,7 +1689,7 @@ def cmd_restart(args: argparse.Namespace) -> int:
|
|
|
1585
1689
|
sys.stdout.write(
|
|
1586
1690
|
f"reflexio backend: {_service_status(_BACKEND_SCRIPT)}\n"
|
|
1587
1691
|
)
|
|
1588
|
-
return
|
|
1692
|
+
return returncode
|
|
1589
1693
|
|
|
1590
1694
|
start_rc = 0
|
|
1591
1695
|
if do_backend:
|
|
@@ -1643,7 +1747,14 @@ def cmd_clear_all(args: argparse.Namespace) -> int:
|
|
|
1643
1747
|
)
|
|
1644
1748
|
return 1
|
|
1645
1749
|
|
|
1646
|
-
|
|
1750
|
+
initial_status = _service_status(_BACKEND_SCRIPT, wait_ready_s=0.0)
|
|
1751
|
+
was_running = _is_running_status(initial_status)
|
|
1752
|
+
if not was_running and not _is_confirmed_stopped_status(initial_status):
|
|
1753
|
+
sys.stderr.write(
|
|
1754
|
+
"error: could not confirm reflexio backend is stopped "
|
|
1755
|
+
f"({initial_status}); aborting without deleting data\n"
|
|
1756
|
+
)
|
|
1757
|
+
return 1
|
|
1647
1758
|
if was_running:
|
|
1648
1759
|
sys.stdout.write("Stopping reflexio backend…\n")
|
|
1649
1760
|
stop_rc = _run_service(_BACKEND_SCRIPT, "stop")
|
|
@@ -1659,6 +1770,12 @@ def cmd_clear_all(args: argparse.Namespace) -> int:
|
|
|
1659
1770
|
"aborting without deleting data\n"
|
|
1660
1771
|
)
|
|
1661
1772
|
return 1
|
|
1773
|
+
if not _is_confirmed_stopped_status(stopped_status):
|
|
1774
|
+
sys.stderr.write(
|
|
1775
|
+
"error: could not confirm reflexio backend stopped "
|
|
1776
|
+
f"({stopped_status}); aborting without deleting data\n"
|
|
1777
|
+
)
|
|
1778
|
+
return 1
|
|
1662
1779
|
|
|
1663
1780
|
removed_targets = 0
|
|
1664
1781
|
try:
|
|
@@ -79,7 +79,7 @@ def log_event(
|
|
|
79
79
|
fires against workspace directories.
|
|
80
80
|
internal_skipped (bool): True when ``is_internal_invocation``
|
|
81
81
|
short-circuited the dispatcher (reflexio-internal sub-claude
|
|
82
|
-
calls, or invocations from inside
|
|
82
|
+
calls, or invocations from inside a Reflexio checkout).
|
|
83
83
|
handler_status (str): ``"ok"`` for a clean return, ``"unknown_event"``
|
|
84
84
|
when no handler is registered, or ``"raised:<ExcClass>: <msg>"``
|
|
85
85
|
when the handler raised — formatted by ``hook.main``.
|
|
@@ -26,9 +26,9 @@ Detection signals, OR'd:
|
|
|
26
26
|
- Env var ``CLAUDE_SMART_INTERNAL=1``, set by reflexio's provider
|
|
27
27
|
before spawning ``claude``. Belt-and-suspenders for case (1) in
|
|
28
28
|
case the entrypoint check ever misses a future SDK variant.
|
|
29
|
-
- ``payload.cwd`` resolves inside the reflexio
|
|
30
|
-
direct interactive ``claude`` runs from inside reflexio
|
|
31
|
-
debugging
|
|
29
|
+
- ``payload.cwd`` resolves inside the side-by-side reflexio checkout. Catches
|
|
30
|
+
direct interactive ``claude`` runs from inside reflexio during local
|
|
31
|
+
debugging that would otherwise pollute the corpus.
|
|
32
32
|
- Known Codex-internal prompt templates (title generation and home-screen
|
|
33
33
|
suggestions). These are model calls made by Codex itself, not user
|
|
34
34
|
coding turns, and must never be reflected into claude-smart memory.
|
|
@@ -61,11 +61,11 @@ _CODEX_SUGGESTIONS_APPS_MARKER = (
|
|
|
61
61
|
"their connected apps."
|
|
62
62
|
)
|
|
63
63
|
|
|
64
|
-
# Reflexio
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
64
|
+
# Reflexio usually lives next to claude-smart during local development:
|
|
65
|
+
# <workspace>/claude-smart and <workspace>/reflexio. Anchor relative to this
|
|
66
|
+
# file so the check follows the real checkout if the repo is relocated. In
|
|
67
|
+
# install mode that sibling checkout is absent — the env marker is the primary
|
|
68
|
+
# signal and this path never matches.
|
|
69
69
|
#
|
|
70
70
|
# The path computation is tightly coupled to the current layout: if this
|
|
71
71
|
# module moves, ``_REFLEXIO_DIR`` silently stops matching and only the
|
|
@@ -73,8 +73,8 @@ _CODEX_SUGGESTIONS_APPS_MARKER = (
|
|
|
73
73
|
# tests) override the path without touching the module.
|
|
74
74
|
_THIS_DIR = Path(__file__).resolve().parent
|
|
75
75
|
_REFLEXIO_DIR = Path(
|
|
76
|
-
os.environ.get("CLAUDE_SMART_REFLEXIO_DIR") or _THIS_DIR.parents[
|
|
77
|
-
)
|
|
76
|
+
os.environ.get("CLAUDE_SMART_REFLEXIO_DIR") or _THIS_DIR.parents[3] / "reflexio"
|
|
77
|
+
).resolve()
|
|
78
78
|
|
|
79
79
|
|
|
80
80
|
def is_internal_invocation(payload: dict[str, Any]) -> bool:
|
|
@@ -86,7 +86,7 @@ def is_internal_invocation(payload: dict[str, Any]) -> bool:
|
|
|
86
86
|
|
|
87
87
|
Returns:
|
|
88
88
|
bool: True when the env marker is set or ``cwd`` points inside
|
|
89
|
-
the
|
|
89
|
+
the Reflexio checkout. False otherwise, including when
|
|
90
90
|
``cwd`` is missing or unresolvable.
|
|
91
91
|
"""
|
|
92
92
|
if runtime.is_internal_invocation_env():
|