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.
@@ -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 edits under the ``reflexio``
18
- submodule or ``plugin/dashboard/`` take effect without restarting Claude
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 = shutil.which("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"], capture_output=True, text=True, check=False
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 ``reflexio`` submodule or the dashboard.
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 shutil.which("npm"):
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
- ["npm", "ci", "--no-audit", "--no-fund"]
1648
+ [npm, "ci", "--no-audit", "--no-fund"]
1546
1649
  if (_DASHBOARD_DIR / "package-lock.json").exists()
1547
- else ["npm", "install", "--no-audit", "--no-fund"]
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(["npm", "run", "build"], cwd=_DASHBOARD_DIR, check=True)
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 {exc.returncode}); "
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 exc.returncode or 1
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
- was_running = _is_running_status(_service_status(_BACKEND_SCRIPT, wait_ready_s=0.0))
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 the reflexio submodule).
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 submodule. Catches
30
- direct interactive ``claude`` runs from inside reflexio (manual
31
- debugging) that would otherwise pollute the corpus.
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 submodule lives at <repo>/reflexio when this package runs from
65
- # a dev checkout (<repo>/plugin/src/claude_smart/internal_call.py); anchor
66
- # relative to this file so the check follows the real checkout if the
67
- # repo is relocated. In install mode the submodule is absent — the env
68
- # marker is the primary signal and this path never matches.
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[2] / "reflexio"
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 reflexio submodule. False otherwise, including when
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():