claude-smart 0.2.37 → 0.2.40
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 +108 -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 +11 -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/codex-hook.js +1 -1
- package/plugin/scripts/hook_entry.sh +40 -5
- package/plugin/scripts/smart-install.sh +105 -16
- package/plugin/src/claude_smart/cli.py +139 -16
- package/plugin/src/claude_smart/context_format.py +50 -5
- package/plugin/src/claude_smart/cs_cite.py +12 -1
- package/plugin/src/claude_smart/env_config.py +74 -0
- package/plugin/src/claude_smart/hook_log.py +1 -1
- package/plugin/src/claude_smart/internal_call.py +11 -11
- package/plugin/uv.lock +932 -910
- package/scripts/setup-claude-smart.sh +31 -3
|
@@ -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(
|
|
@@ -805,7 +894,7 @@ def _register_codex_marketplace(root: Path) -> tuple[bool, str]:
|
|
|
805
894
|
|
|
806
895
|
|
|
807
896
|
def _configure_reflexio_setup() -> bool:
|
|
808
|
-
"""Load setup state
|
|
897
|
+
"""Load setup state and ensure local defaults when unmanaged.
|
|
809
898
|
|
|
810
899
|
Returns:
|
|
811
900
|
bool: Whether read-only mode is enabled.
|
|
@@ -850,7 +939,11 @@ def _configure_reflexio_setup() -> bool:
|
|
|
850
939
|
else:
|
|
851
940
|
os.environ.pop(env_config.REFLEXIO_URL_ENV, None)
|
|
852
941
|
os.environ.pop(env_config.REFLEXIO_API_KEY_ENV, None)
|
|
942
|
+
os.environ.pop("REFLEXIO_USER_ID", None)
|
|
853
943
|
os.environ.pop("CLAUDE_SMART_MANAGED_SETUP", None)
|
|
944
|
+
added = env_config.ensure_local_env_defaults(_REFLEXIO_ENV_PATH)
|
|
945
|
+
if added:
|
|
946
|
+
sys.stdout.write(f"Seeded {_REFLEXIO_ENV_PATH} with {', '.join(added)}.\n")
|
|
854
947
|
return read_only
|
|
855
948
|
|
|
856
949
|
|
|
@@ -1251,8 +1344,14 @@ def _run_service(script: Path, subcmd: str) -> int:
|
|
|
1251
1344
|
if not script.exists():
|
|
1252
1345
|
sys.stderr.write(f"error: {script} not found\n")
|
|
1253
1346
|
return 1
|
|
1347
|
+
bash = _resolve_bash()
|
|
1348
|
+
if not bash:
|
|
1349
|
+
sys.stderr.write(
|
|
1350
|
+
"error: bash is required to run claude-smart service scripts\n"
|
|
1351
|
+
)
|
|
1352
|
+
return 1
|
|
1254
1353
|
try:
|
|
1255
|
-
subprocess.run([str(script), subcmd], check=True)
|
|
1354
|
+
subprocess.run([bash, str(script), subcmd], check=True)
|
|
1256
1355
|
return 0
|
|
1257
1356
|
except subprocess.CalledProcessError as exc:
|
|
1258
1357
|
return exc.returncode or 1
|
|
@@ -1277,10 +1376,16 @@ def _service_status(script: Path, wait_ready_s: float = 3.0) -> str:
|
|
|
1277
1376
|
"""
|
|
1278
1377
|
if not script.exists():
|
|
1279
1378
|
return "script missing"
|
|
1379
|
+
bash = _resolve_bash()
|
|
1380
|
+
if not bash:
|
|
1381
|
+
return _SERVICE_STATUS_PROBE_FAILED
|
|
1280
1382
|
deadline = time.monotonic() + wait_ready_s
|
|
1281
1383
|
while True:
|
|
1282
1384
|
result = subprocess.run(
|
|
1283
|
-
[str(script), "status"],
|
|
1385
|
+
[bash, str(script), "status"],
|
|
1386
|
+
capture_output=True,
|
|
1387
|
+
text=True,
|
|
1388
|
+
check=False,
|
|
1284
1389
|
)
|
|
1285
1390
|
status = result.stdout.strip() or "unknown"
|
|
1286
1391
|
if status != "not running" or time.monotonic() >= deadline:
|
|
@@ -1305,6 +1410,10 @@ def _is_running_status(status: str) -> bool:
|
|
|
1305
1410
|
return status.startswith("running on ")
|
|
1306
1411
|
|
|
1307
1412
|
|
|
1413
|
+
def _is_confirmed_stopped_status(status: str) -> bool:
|
|
1414
|
+
return status == "not running"
|
|
1415
|
+
|
|
1416
|
+
|
|
1308
1417
|
def _read_dotenv_value(env_path: Path, key: str) -> str | None:
|
|
1309
1418
|
"""Read one simple KEY=VALUE binding from a dotenv file."""
|
|
1310
1419
|
if not env_path.is_file():
|
|
@@ -1507,7 +1616,7 @@ def cmd_restart(args: argparse.Namespace) -> int:
|
|
|
1507
1616
|
Stops both long-lived services, optionally rebuilds the dashboard's
|
|
1508
1617
|
Next.js bundle so source edits under ``plugin/dashboard/`` take effect,
|
|
1509
1618
|
then starts them again. Useful during local development when iterating
|
|
1510
|
-
on the
|
|
1619
|
+
on the local Reflexio checkout or the dashboard.
|
|
1511
1620
|
|
|
1512
1621
|
Args:
|
|
1513
1622
|
args (argparse.Namespace): Parsed CLI args. Honors ``args.skip_backend``,
|
|
@@ -1536,15 +1645,15 @@ def cmd_restart(args: argparse.Namespace) -> int:
|
|
|
1536
1645
|
sys.stderr.write(
|
|
1537
1646
|
f"warning: dashboard dir {_DASHBOARD_DIR} missing; skipping rebuild\n"
|
|
1538
1647
|
)
|
|
1539
|
-
elif not
|
|
1648
|
+
elif not (npm := _resolve_npm()):
|
|
1540
1649
|
sys.stderr.write("warning: npm not on PATH; serving previous build\n")
|
|
1541
1650
|
else:
|
|
1542
1651
|
next_bin = _DASHBOARD_DIR / "node_modules" / ".bin" / "next"
|
|
1543
1652
|
if not next_bin.exists():
|
|
1544
1653
|
install_cmd = (
|
|
1545
|
-
[
|
|
1654
|
+
[npm, "ci", "--no-audit", "--no-fund"]
|
|
1546
1655
|
if (_DASHBOARD_DIR / "package-lock.json").exists()
|
|
1547
|
-
else [
|
|
1656
|
+
else [npm, "install", "--no-audit", "--no-fund"]
|
|
1548
1657
|
)
|
|
1549
1658
|
install_label = " ".join(install_cmd)
|
|
1550
1659
|
sys.stdout.write(
|
|
@@ -1573,10 +1682,11 @@ def cmd_restart(args: argparse.Namespace) -> int:
|
|
|
1573
1682
|
"Rebuilding dashboard (npm run build, may take a minute)…\n"
|
|
1574
1683
|
)
|
|
1575
1684
|
try:
|
|
1576
|
-
subprocess.run([
|
|
1577
|
-
except subprocess.CalledProcessError as exc:
|
|
1685
|
+
subprocess.run([npm, "run", "build"], cwd=_DASHBOARD_DIR, check=True)
|
|
1686
|
+
except (subprocess.CalledProcessError, OSError) as exc:
|
|
1687
|
+
returncode = getattr(exc, "returncode", 1) or 1
|
|
1578
1688
|
sys.stderr.write(
|
|
1579
|
-
f"error: dashboard build failed (exit {
|
|
1689
|
+
f"error: dashboard build failed (exit {returncode}); "
|
|
1580
1690
|
"not starting dashboard.\n"
|
|
1581
1691
|
)
|
|
1582
1692
|
if do_backend:
|
|
@@ -1585,7 +1695,7 @@ def cmd_restart(args: argparse.Namespace) -> int:
|
|
|
1585
1695
|
sys.stdout.write(
|
|
1586
1696
|
f"reflexio backend: {_service_status(_BACKEND_SCRIPT)}\n"
|
|
1587
1697
|
)
|
|
1588
|
-
return
|
|
1698
|
+
return returncode
|
|
1589
1699
|
|
|
1590
1700
|
start_rc = 0
|
|
1591
1701
|
if do_backend:
|
|
@@ -1643,7 +1753,14 @@ def cmd_clear_all(args: argparse.Namespace) -> int:
|
|
|
1643
1753
|
)
|
|
1644
1754
|
return 1
|
|
1645
1755
|
|
|
1646
|
-
|
|
1756
|
+
initial_status = _service_status(_BACKEND_SCRIPT, wait_ready_s=0.0)
|
|
1757
|
+
was_running = _is_running_status(initial_status)
|
|
1758
|
+
if not was_running and not _is_confirmed_stopped_status(initial_status):
|
|
1759
|
+
sys.stderr.write(
|
|
1760
|
+
"error: could not confirm reflexio backend is stopped "
|
|
1761
|
+
f"({initial_status}); aborting without deleting data\n"
|
|
1762
|
+
)
|
|
1763
|
+
return 1
|
|
1647
1764
|
if was_running:
|
|
1648
1765
|
sys.stdout.write("Stopping reflexio backend…\n")
|
|
1649
1766
|
stop_rc = _run_service(_BACKEND_SCRIPT, "stop")
|
|
@@ -1659,6 +1776,12 @@ def cmd_clear_all(args: argparse.Namespace) -> int:
|
|
|
1659
1776
|
"aborting without deleting data\n"
|
|
1660
1777
|
)
|
|
1661
1778
|
return 1
|
|
1779
|
+
if not _is_confirmed_stopped_status(stopped_status):
|
|
1780
|
+
sys.stderr.write(
|
|
1781
|
+
"error: could not confirm reflexio backend stopped "
|
|
1782
|
+
f"({stopped_status}); aborting without deleting data\n"
|
|
1783
|
+
)
|
|
1784
|
+
return 1
|
|
1662
1785
|
|
|
1663
1786
|
removed_targets = 0
|
|
1664
1787
|
try:
|
|
@@ -238,6 +238,11 @@ def render_inline_compact_with_registry(
|
|
|
238
238
|
item = f"{content} (title: {title}"
|
|
239
239
|
if rule_url:
|
|
240
240
|
item += f"; open: {rule_url}"
|
|
241
|
+
marker_parts.append(
|
|
242
|
+
_markdown_link(
|
|
243
|
+
rule_url, _strip_trailing_sentence_punctuation(title)
|
|
244
|
+
)
|
|
245
|
+
)
|
|
241
246
|
item += ")"
|
|
242
247
|
if entry.get("kind") == "profile":
|
|
243
248
|
preference_parts.append(item)
|
|
@@ -280,6 +285,17 @@ def _compact_citation_instruction(marker_parts: list[str] | None = None) -> str:
|
|
|
280
285
|
"the same linked memory text; keep the link, but do not show the "
|
|
281
286
|
"URL. Skip when unrelated."
|
|
282
287
|
)
|
|
288
|
+
if marker_parts:
|
|
289
|
+
marker = f"✨ claude-smart rule applied: {' | '.join(marker_parts)}"
|
|
290
|
+
separator_instruction = (
|
|
291
|
+
" Separate multiple linked memories with the visible ` | ` separator."
|
|
292
|
+
if len(marker_parts) > 1
|
|
293
|
+
else ""
|
|
294
|
+
)
|
|
295
|
+
return _remoteize_citation_instruction(
|
|
296
|
+
f"If used, copy this final marker exactly with markdown links: "
|
|
297
|
+
f"`{marker}`.{separator_instruction} Skip when unrelated."
|
|
298
|
+
)
|
|
283
299
|
return _remoteize_citation_instruction(
|
|
284
300
|
"Only if a listed [cs:...] item materially changes your answer, end "
|
|
285
301
|
"with one final marker like `✨ claude-smart rule applied: "
|
|
@@ -368,7 +384,7 @@ def _append_playbook_bullet(
|
|
|
368
384
|
item_id = cs_cite.rank_id("playbook", rank, real_id)
|
|
369
385
|
title = _title_from_content(content)
|
|
370
386
|
dashboard_url = _dashboard_url("playbook", real_id, source_kind)
|
|
371
|
-
rule_url = _rule_url(item_id, "playbook")
|
|
387
|
+
rule_url = _rule_url(item_id, "playbook", real_id, source_kind)
|
|
372
388
|
bullet = f"- [cs:{item_id}] {content}"
|
|
373
389
|
if trigger:
|
|
374
390
|
bullet += f" _(when: {trigger})_"
|
|
@@ -407,7 +423,7 @@ def _format_profiles(
|
|
|
407
423
|
item_id = cs_cite.rank_id("profile", rank, real_id)
|
|
408
424
|
title = _title_from_content(content)
|
|
409
425
|
dashboard_url = _dashboard_url("profile", real_id)
|
|
410
|
-
rule_url = _rule_url(item_id, "profile")
|
|
426
|
+
rule_url = _rule_url(item_id, "profile", real_id)
|
|
411
427
|
bullet = f"- [cs:{item_id}] {content}"
|
|
412
428
|
if rule_url:
|
|
413
429
|
bullet += f" _(open: {rule_url})_"
|
|
@@ -427,7 +443,7 @@ def _format_profiles(
|
|
|
427
443
|
|
|
428
444
|
|
|
429
445
|
def _dashboard_url(kind: str, real_id: Any, source_kind: str | None = None) -> str:
|
|
430
|
-
remote_url =
|
|
446
|
+
remote_url = _remote_reflexio_item_url(kind, real_id, source_kind)
|
|
431
447
|
if remote_url:
|
|
432
448
|
return remote_url
|
|
433
449
|
if real_id is None:
|
|
@@ -442,8 +458,10 @@ def _dashboard_url(kind: str, real_id: Any, source_kind: str | None = None) -> s
|
|
|
442
458
|
return ""
|
|
443
459
|
|
|
444
460
|
|
|
445
|
-
def _rule_url(
|
|
446
|
-
|
|
461
|
+
def _rule_url(
|
|
462
|
+
item_id: str, kind: str, real_id: Any = None, source_kind: str | None = None
|
|
463
|
+
) -> str:
|
|
464
|
+
remote_url = _remote_reflexio_item_url(kind, real_id, source_kind)
|
|
447
465
|
if remote_url:
|
|
448
466
|
return remote_url
|
|
449
467
|
if not item_id:
|
|
@@ -453,6 +471,27 @@ def _rule_url(item_id: str, kind: str) -> str:
|
|
|
453
471
|
return f"{base}/rules/{encoded_id}"
|
|
454
472
|
|
|
455
473
|
|
|
474
|
+
def _remote_reflexio_item_url(
|
|
475
|
+
kind: str, real_id: Any, source_kind: str | None = None
|
|
476
|
+
) -> str:
|
|
477
|
+
origin = _remote_reflexio_origin()
|
|
478
|
+
if not origin:
|
|
479
|
+
return ""
|
|
480
|
+
if real_id is None:
|
|
481
|
+
return _remote_reflexio_page_url(kind)
|
|
482
|
+
encoded_id = quote(str(real_id), safe="")
|
|
483
|
+
if kind == "profile":
|
|
484
|
+
return f"{origin}/profiles?profile_id={encoded_id}"
|
|
485
|
+
if kind == "playbook":
|
|
486
|
+
if source_kind == "user_playbook":
|
|
487
|
+
return (
|
|
488
|
+
f"{origin}/playbooks?resource=user_playbook&"
|
|
489
|
+
f"user_playbook_id={encoded_id}"
|
|
490
|
+
)
|
|
491
|
+
return f"{origin}/playbooks?agent_playbook_id={encoded_id}"
|
|
492
|
+
return ""
|
|
493
|
+
|
|
494
|
+
|
|
456
495
|
def _remote_reflexio_page_url(kind: str) -> str:
|
|
457
496
|
origin = _remote_reflexio_origin()
|
|
458
497
|
if not origin:
|
|
@@ -504,6 +543,12 @@ def _osc8_link(url: str, label: str) -> str:
|
|
|
504
543
|
return f"\x1b]8;;{url}\x1b\\{label}\x1b]8;;\x1b\\"
|
|
505
544
|
|
|
506
545
|
|
|
546
|
+
def _markdown_link(url: str, label: str) -> str:
|
|
547
|
+
safe_label = label.replace("[", "\\[").replace("]", "\\]")
|
|
548
|
+
safe_url = url.replace(")", "%29")
|
|
549
|
+
return f"[{safe_label}]({safe_url})"
|
|
550
|
+
|
|
551
|
+
|
|
507
552
|
def _strip_trailing_sentence_punctuation(text: str) -> str:
|
|
508
553
|
return text.rstrip().rstrip(".!?")
|
|
509
554
|
|
|
@@ -39,7 +39,7 @@ from __future__ import annotations
|
|
|
39
39
|
|
|
40
40
|
import re
|
|
41
41
|
from typing import Any
|
|
42
|
-
from urllib.parse import unquote, urlparse
|
|
42
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
43
43
|
|
|
44
44
|
_FINGERPRINT_LEN = 4
|
|
45
45
|
|
|
@@ -287,6 +287,17 @@ def dashboard_url_token(url: str) -> str:
|
|
|
287
287
|
parsed = urlparse(url)
|
|
288
288
|
path = parsed.path if parsed.scheme else url.split("?", 1)[0].split("#", 1)[0]
|
|
289
289
|
parts = [unquote(part) for part in path.strip("/").split("/") if part]
|
|
290
|
+
query = parse_qs(parsed.query)
|
|
291
|
+
if len(parts) == 1 and parts[0] == "profiles":
|
|
292
|
+
profile_ids = query.get("profile_id") or []
|
|
293
|
+
return f"route:profile:profile:{profile_ids[0]}" if profile_ids else ""
|
|
294
|
+
if len(parts) == 1 and parts[0] == "playbooks":
|
|
295
|
+
user_playbook_ids = query.get("user_playbook_id") or []
|
|
296
|
+
if query.get("resource") == ["user_playbook"] and user_playbook_ids:
|
|
297
|
+
return f"route:playbook:user_playbook:{user_playbook_ids[0]}"
|
|
298
|
+
agent_playbook_ids = query.get("agent_playbook_id") or []
|
|
299
|
+
if agent_playbook_ids:
|
|
300
|
+
return f"route:playbook:agent_playbook:{agent_playbook_ids[0]}"
|
|
290
301
|
if len(parts) == 3 and parts[0] == "skills" and parts[1] in {"project", "shared"}:
|
|
291
302
|
source_kind = "user_playbook" if parts[1] == "project" else "agent_playbook"
|
|
292
303
|
return f"route:playbook:{source_kind}:{parts[2]}"
|
|
@@ -10,6 +10,27 @@ MANAGED_REFLEXIO_URL = "https://www.reflexio.ai/"
|
|
|
10
10
|
REFLEXIO_URL_ENV = "REFLEXIO_URL"
|
|
11
11
|
REFLEXIO_API_KEY_ENV = "REFLEXIO_API_KEY"
|
|
12
12
|
CLAUDE_SMART_READ_ONLY_ENV = "CLAUDE_SMART_READ_ONLY"
|
|
13
|
+
CLAUDE_SMART_USE_LOCAL_CLI_ENV = "CLAUDE_SMART_USE_LOCAL_CLI"
|
|
14
|
+
CLAUDE_SMART_USE_LOCAL_EMBEDDING_ENV = "CLAUDE_SMART_USE_LOCAL_EMBEDDING"
|
|
15
|
+
|
|
16
|
+
_LOCAL_DEFAULT_ENTRIES = (
|
|
17
|
+
(
|
|
18
|
+
"# Route reflexio generation through the local Claude Code CLI",
|
|
19
|
+
CLAUDE_SMART_USE_LOCAL_CLI_ENV,
|
|
20
|
+
"1",
|
|
21
|
+
),
|
|
22
|
+
(
|
|
23
|
+
"# Use the in-process ONNX embedder (chromadb) - no API key for semantic search",
|
|
24
|
+
CLAUDE_SMART_USE_LOCAL_EMBEDDING_ENV,
|
|
25
|
+
"1",
|
|
26
|
+
),
|
|
27
|
+
(None, CLAUDE_SMART_READ_ONLY_ENV, "0"),
|
|
28
|
+
)
|
|
29
|
+
_LOCAL_MODE_PRUNE_KEYS = {
|
|
30
|
+
REFLEXIO_URL_ENV,
|
|
31
|
+
REFLEXIO_API_KEY_ENV,
|
|
32
|
+
"REFLEXIO_USER_ID",
|
|
33
|
+
}
|
|
13
34
|
|
|
14
35
|
|
|
15
36
|
def parse_env_line(line: str) -> tuple[str, str] | None:
|
|
@@ -89,6 +110,59 @@ def set_env_vars(path: Path, values: dict[str, str]) -> list[str]:
|
|
|
89
110
|
return added
|
|
90
111
|
|
|
91
112
|
|
|
113
|
+
def ensure_local_env_defaults(path: Path | None = None) -> list[str]:
|
|
114
|
+
"""Create or augment ``~/.reflexio/.env`` for claude-smart local mode.
|
|
115
|
+
|
|
116
|
+
Existing active assignments win. This repairs first installs and deleted
|
|
117
|
+
env files without clobbering explicit user overrides such as
|
|
118
|
+
``CLAUDE_SMART_READ_ONLY=1``.
|
|
119
|
+
"""
|
|
120
|
+
path = path or REFLEXIO_ENV_PATH
|
|
121
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
try:
|
|
123
|
+
existing = path.read_text()
|
|
124
|
+
except OSError:
|
|
125
|
+
existing = ""
|
|
126
|
+
|
|
127
|
+
present: set[str] = set()
|
|
128
|
+
kept_lines: list[str] = []
|
|
129
|
+
pruned = False
|
|
130
|
+
for line in existing.splitlines():
|
|
131
|
+
parsed = parse_env_line(line)
|
|
132
|
+
if parsed is not None:
|
|
133
|
+
key, _value = parsed
|
|
134
|
+
if key in _LOCAL_MODE_PRUNE_KEYS:
|
|
135
|
+
pruned = True
|
|
136
|
+
continue
|
|
137
|
+
present.add(key)
|
|
138
|
+
kept_lines.append(line)
|
|
139
|
+
|
|
140
|
+
additions: list[str] = []
|
|
141
|
+
added_keys: list[str] = []
|
|
142
|
+
for comment, key, value in _LOCAL_DEFAULT_ENTRIES:
|
|
143
|
+
if key in present:
|
|
144
|
+
continue
|
|
145
|
+
if comment:
|
|
146
|
+
additions.append(comment)
|
|
147
|
+
if key == CLAUDE_SMART_READ_ONLY_ENV:
|
|
148
|
+
additions.append(f'{key}="{_escape_env_value(value)}"')
|
|
149
|
+
else:
|
|
150
|
+
additions.append(f"{key}={_escape_env_value(value)}")
|
|
151
|
+
added_keys.append(key)
|
|
152
|
+
|
|
153
|
+
if additions or pruned:
|
|
154
|
+
content = "\n".join(kept_lines)
|
|
155
|
+
if additions:
|
|
156
|
+
prefix = "" if not content or content.endswith("\n") else "\n"
|
|
157
|
+
content = content + prefix + "\n".join(additions)
|
|
158
|
+
content = content + ("\n" if content else "")
|
|
159
|
+
path.write_text(content, encoding="utf-8")
|
|
160
|
+
elif not path.exists():
|
|
161
|
+
path.touch()
|
|
162
|
+
path.chmod(0o600)
|
|
163
|
+
return added_keys
|
|
164
|
+
|
|
165
|
+
|
|
92
166
|
def env_truthy(name: str) -> bool:
|
|
93
167
|
"""Return True when an environment flag is explicitly enabled."""
|
|
94
168
|
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
|
@@ -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():
|