claude-smart 0.2.35 → 0.2.37
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 +1 -1
- package/bin/claude-smart.js +145 -115
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/README.md +3 -0
- package/plugin/dashboard/app/configure/env/page.tsx +15 -0
- package/plugin/dashboard/lib/config-file.ts +3 -2
- package/plugin/dashboard/lib/types.ts +1 -1
- package/plugin/pyproject.toml +1 -1
- package/plugin/scripts/_lib.sh +6 -6
- package/plugin/scripts/codex-hook.js +1 -0
- package/plugin/scripts/smart-install.sh +1 -39
- package/plugin/src/claude_smart/cli.py +137 -92
- package/plugin/src/claude_smart/env_config.py +6 -1
- package/plugin/src/claude_smart/events/pre_tool.py +1 -1
- package/plugin/src/claude_smart/events/session_end.py +5 -2
- package/plugin/src/claude_smart/events/stop.py +27 -7
- package/plugin/src/claude_smart/events/user_prompt.py +1 -1
- package/plugin/src/claude_smart/hook.py +22 -2
- package/plugin/src/claude_smart/hook_log.py +7 -2
- package/plugin/src/claude_smart/ids.py +23 -26
- package/plugin/src/claude_smart/internal_call.py +6 -6
- package/plugin/src/claude_smart/publish.py +4 -5
- package/plugin/src/claude_smart/reflexio_adapter.py +2 -3
- package/plugin/src/claude_smart/state.py +9 -0
- package/plugin/uv.lock +16 -13
- package/scripts/setup-claude-smart.sh +329 -0
|
@@ -30,7 +30,6 @@ import shutil
|
|
|
30
30
|
import subprocess
|
|
31
31
|
import sys
|
|
32
32
|
import time
|
|
33
|
-
import uuid
|
|
34
33
|
from dataclasses import dataclass
|
|
35
34
|
from pathlib import Path
|
|
36
35
|
|
|
@@ -120,59 +119,6 @@ def _latest_session_id() -> str | None:
|
|
|
120
119
|
return files[0].stem
|
|
121
120
|
|
|
122
121
|
|
|
123
|
-
def _seed_reflexio_env() -> list[str]:
|
|
124
|
-
"""Append the two local-provider flags to ``~/.reflexio/.env``, idempotently.
|
|
125
|
-
|
|
126
|
-
Returns:
|
|
127
|
-
list[str]: Flag names that were newly appended (empty if already present).
|
|
128
|
-
"""
|
|
129
|
-
_REFLEXIO_ENV_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
-
_REFLEXIO_ENV_PATH.touch(exist_ok=True)
|
|
131
|
-
existing = _REFLEXIO_ENV_PATH.read_text()
|
|
132
|
-
flags = (
|
|
133
|
-
"CLAUDE_SMART_USE_LOCAL_CLI",
|
|
134
|
-
"CLAUDE_SMART_USE_LOCAL_EMBEDDING",
|
|
135
|
-
)
|
|
136
|
-
missing = [f for f in flags if f"{f}=" not in existing]
|
|
137
|
-
if not missing:
|
|
138
|
-
return []
|
|
139
|
-
prefix = "" if not existing or existing.endswith("\n") else "\n"
|
|
140
|
-
with _REFLEXIO_ENV_PATH.open("a") as fh:
|
|
141
|
-
fh.write(prefix + "\n".join(f"{f}=1" for f in missing) + "\n")
|
|
142
|
-
_REFLEXIO_ENV_PATH.chmod(0o600)
|
|
143
|
-
return missing
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _seed_managed_reflexio_env(*, api_key: str, reflexio_url: str) -> list[str]:
|
|
147
|
-
"""Write managed Reflexio connection settings to ``~/.reflexio/.env``."""
|
|
148
|
-
user_id = _read_reflexio_env_value(env_config.REFLEXIO_USER_ID_ENV) or str(
|
|
149
|
-
uuid.uuid4()
|
|
150
|
-
)
|
|
151
|
-
return env_config.set_env_vars(
|
|
152
|
-
_REFLEXIO_ENV_PATH,
|
|
153
|
-
{
|
|
154
|
-
env_config.REFLEXIO_URL_ENV: reflexio_url,
|
|
155
|
-
env_config.REFLEXIO_API_KEY_ENV: api_key,
|
|
156
|
-
env_config.REFLEXIO_USER_ID_ENV: user_id,
|
|
157
|
-
},
|
|
158
|
-
)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def _read_reflexio_env_value(key: str) -> str:
|
|
162
|
-
try:
|
|
163
|
-
text = _REFLEXIO_ENV_PATH.read_text()
|
|
164
|
-
except OSError:
|
|
165
|
-
return ""
|
|
166
|
-
for line in text.splitlines():
|
|
167
|
-
parsed = env_config.parse_env_line(line)
|
|
168
|
-
if parsed is None:
|
|
169
|
-
continue
|
|
170
|
-
parsed_key, value = parsed
|
|
171
|
-
if parsed_key == key:
|
|
172
|
-
return value
|
|
173
|
-
return ""
|
|
174
|
-
|
|
175
|
-
|
|
176
122
|
def _semver_tuple(path: Path) -> tuple[int, int, int] | None:
|
|
177
123
|
parts = path.name.split(".", 2)
|
|
178
124
|
if len(parts) != 3:
|
|
@@ -328,6 +274,69 @@ def _prepare_codex_local_marketplace() -> Path:
|
|
|
328
274
|
return marketplace_root
|
|
329
275
|
|
|
330
276
|
|
|
277
|
+
def _command_is_publish_hook(command: object) -> bool:
|
|
278
|
+
if not isinstance(command, str):
|
|
279
|
+
return False
|
|
280
|
+
return bool(
|
|
281
|
+
re.search(
|
|
282
|
+
r"hook_entry\.sh\b[\s\"']+(?:codex|claude-code)[\s\"']+(?:stop|session-end)\b",
|
|
283
|
+
command,
|
|
284
|
+
)
|
|
285
|
+
or re.search(
|
|
286
|
+
r"codex-hook\.js\"?(?:\s+\"?hook\"?){1}\s+\"?(?:stop|session-end)\"?",
|
|
287
|
+
command,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _prune_publish_hooks_for_read_only(plugin_root: Path) -> None:
|
|
293
|
+
"""Remove publish-to-reflexio hook commands from a copied/installed plugin."""
|
|
294
|
+
for hook_file in ("hooks.json", "codex-hooks.json"):
|
|
295
|
+
hook_path = plugin_root / "hooks" / hook_file
|
|
296
|
+
if not hook_path.is_file():
|
|
297
|
+
continue
|
|
298
|
+
parsed = json.loads(hook_path.read_text())
|
|
299
|
+
hooks_by_event = parsed.get("hooks")
|
|
300
|
+
if not isinstance(hooks_by_event, dict):
|
|
301
|
+
continue
|
|
302
|
+
for event in list(hooks_by_event):
|
|
303
|
+
blocks = []
|
|
304
|
+
for block in hooks_by_event.get(event) or []:
|
|
305
|
+
if not isinstance(block, dict):
|
|
306
|
+
continue
|
|
307
|
+
kept_hooks = [
|
|
308
|
+
hook
|
|
309
|
+
for hook in block.get("hooks") or []
|
|
310
|
+
if not _command_is_publish_hook(
|
|
311
|
+
hook.get("command") if isinstance(hook, dict) else None
|
|
312
|
+
)
|
|
313
|
+
]
|
|
314
|
+
if kept_hooks:
|
|
315
|
+
next_block = dict(block)
|
|
316
|
+
next_block["hooks"] = kept_hooks
|
|
317
|
+
blocks.append(next_block)
|
|
318
|
+
if blocks:
|
|
319
|
+
hooks_by_event[event] = blocks
|
|
320
|
+
else:
|
|
321
|
+
del hooks_by_event[event]
|
|
322
|
+
hook_path.write_text(json.dumps(parsed, indent=2) + "\n")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _restore_publish_hooks_from_source(plugin_root: Path) -> None:
|
|
326
|
+
"""Restore full hook manifests before applying the current read-only state."""
|
|
327
|
+
source_hooks_dir = _PLUGIN_ROOT / "hooks"
|
|
328
|
+
target_hooks_dir = plugin_root / "hooks"
|
|
329
|
+
for hook_file in ("hooks.json", "codex-hooks.json"):
|
|
330
|
+
source_path = source_hooks_dir / hook_file
|
|
331
|
+
target_path = target_hooks_dir / hook_file
|
|
332
|
+
if (
|
|
333
|
+
source_path.is_file()
|
|
334
|
+
and target_path.is_file()
|
|
335
|
+
and source_path.resolve() != target_path.resolve()
|
|
336
|
+
):
|
|
337
|
+
shutil.copyfile(source_path, target_path)
|
|
338
|
+
|
|
339
|
+
|
|
331
340
|
def _run_codex(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
332
341
|
"""Invoke the ``codex`` CLI with a hard timeout.
|
|
333
342
|
|
|
@@ -795,23 +804,54 @@ def _register_codex_marketplace(root: Path) -> tuple[bool, str]:
|
|
|
795
804
|
return False, output or "Codex CLI does not expose plugin marketplace commands"
|
|
796
805
|
|
|
797
806
|
|
|
798
|
-
def _configure_reflexio_setup(
|
|
799
|
-
|
|
807
|
+
def _configure_reflexio_setup() -> bool:
|
|
808
|
+
"""Load setup state from ``~/.reflexio/.env`` without writing local defaults.
|
|
809
|
+
|
|
810
|
+
Returns:
|
|
811
|
+
bool: Whether read-only mode is enabled.
|
|
812
|
+
"""
|
|
813
|
+
env_config.load_reflexio_env(_REFLEXIO_ENV_PATH)
|
|
814
|
+
try:
|
|
815
|
+
env_text = _REFLEXIO_ENV_PATH.read_text()
|
|
816
|
+
except OSError:
|
|
817
|
+
env_text = ""
|
|
818
|
+
read_only_value = ""
|
|
819
|
+
file_api_key = ""
|
|
820
|
+
file_url = ""
|
|
821
|
+
for line in env_text.splitlines():
|
|
822
|
+
parsed = env_config.parse_env_line(line)
|
|
823
|
+
if parsed is None:
|
|
824
|
+
continue
|
|
825
|
+
key, value = parsed
|
|
826
|
+
if key == env_config.REFLEXIO_API_KEY_ENV:
|
|
827
|
+
file_api_key = value
|
|
828
|
+
elif key == env_config.REFLEXIO_URL_ENV:
|
|
829
|
+
file_url = value
|
|
830
|
+
elif key == "REFLEXIO_USER_ID":
|
|
831
|
+
os.environ[key] = value
|
|
832
|
+
elif key == env_config.CLAUDE_SMART_READ_ONLY_ENV:
|
|
833
|
+
read_only_value = value
|
|
834
|
+
api_key = (
|
|
835
|
+
file_api_key or os.environ.get(env_config.REFLEXIO_API_KEY_ENV, "")
|
|
836
|
+
).strip()
|
|
837
|
+
read_only = read_only_value.strip().lower() in {"1", "true", "yes", "on"}
|
|
800
838
|
if api_key:
|
|
801
839
|
reflexio_url = (
|
|
802
|
-
|
|
840
|
+
file_url
|
|
841
|
+
or os.environ.get(env_config.REFLEXIO_URL_ENV, _MANAGED_REFLEXIO_URL)
|
|
803
842
|
).strip()
|
|
804
|
-
|
|
805
|
-
|
|
843
|
+
os.environ[env_config.REFLEXIO_URL_ENV] = reflexio_url
|
|
844
|
+
os.environ[env_config.REFLEXIO_API_KEY_ENV] = api_key
|
|
845
|
+
os.environ["CLAUDE_SMART_MANAGED_SETUP"] = "1"
|
|
806
846
|
sys.stdout.write(
|
|
807
|
-
f"
|
|
808
|
-
f"(
|
|
847
|
+
f"Using managed Reflexio at {reflexio_url} "
|
|
848
|
+
f"(API key {env_config.mask_secret(api_key)}).\n"
|
|
809
849
|
)
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
850
|
+
else:
|
|
851
|
+
os.environ.pop(env_config.REFLEXIO_URL_ENV, None)
|
|
852
|
+
os.environ.pop(env_config.REFLEXIO_API_KEY_ENV, None)
|
|
853
|
+
os.environ.pop("CLAUDE_SMART_MANAGED_SETUP", None)
|
|
854
|
+
return read_only
|
|
815
855
|
|
|
816
856
|
|
|
817
857
|
def cmd_install_codex(args: argparse.Namespace) -> int:
|
|
@@ -835,7 +875,7 @@ def cmd_install_codex(args: argparse.Namespace) -> int:
|
|
|
835
875
|
"error: 'uv' not found on PATH. Install uv or restart your shell.\n"
|
|
836
876
|
)
|
|
837
877
|
return 1
|
|
838
|
-
_configure_reflexio_setup(
|
|
878
|
+
read_only = _configure_reflexio_setup()
|
|
839
879
|
|
|
840
880
|
missing = _missing_codex_marketplace_files(_REPO_ROOT)
|
|
841
881
|
if missing:
|
|
@@ -846,6 +886,8 @@ def cmd_install_codex(args: argparse.Namespace) -> int:
|
|
|
846
886
|
)
|
|
847
887
|
return 1
|
|
848
888
|
marketplace_root = _prepare_codex_local_marketplace()
|
|
889
|
+
if read_only:
|
|
890
|
+
_prune_publish_hooks_for_read_only(marketplace_root / _CODEX_LOCAL_PLUGIN_PATH)
|
|
849
891
|
|
|
850
892
|
hooks_ok, hooks_msg = _enable_codex_plugin_hooks()
|
|
851
893
|
if hooks_ok:
|
|
@@ -869,6 +911,10 @@ def cmd_install_codex(args: argparse.Namespace) -> int:
|
|
|
869
911
|
)
|
|
870
912
|
if installed:
|
|
871
913
|
sys.stdout.write(f"{install_msg}.\n")
|
|
914
|
+
if read_only:
|
|
915
|
+
sys.stdout.write(
|
|
916
|
+
"Installed read-only hook manifest; publish interactions hooks are disabled.\n"
|
|
917
|
+
)
|
|
872
918
|
trusted, trust_msg = _trust_codex_plugin_hooks(Path.cwd())
|
|
873
919
|
if not trusted:
|
|
874
920
|
# The app-server can race against the just-installed cache.
|
|
@@ -936,7 +982,7 @@ def cmd_install(args: argparse.Namespace) -> int:
|
|
|
936
982
|
"Install Claude Code first: https://claude.com/claude-code\n"
|
|
937
983
|
)
|
|
938
984
|
return 1
|
|
939
|
-
_configure_reflexio_setup(
|
|
985
|
+
read_only = _configure_reflexio_setup()
|
|
940
986
|
|
|
941
987
|
for cmd in (
|
|
942
988
|
["claude", "plugin", "marketplace", "add", args.source],
|
|
@@ -957,6 +1003,12 @@ def cmd_install(args: argparse.Namespace) -> int:
|
|
|
957
1003
|
"Fix the issue above, then run /claude-smart:restart or restart Claude Code to retry.\n"
|
|
958
1004
|
)
|
|
959
1005
|
return 1
|
|
1006
|
+
_restore_publish_hooks_from_source(Path(message))
|
|
1007
|
+
if read_only:
|
|
1008
|
+
_prune_publish_hooks_for_read_only(Path(message))
|
|
1009
|
+
sys.stdout.write(
|
|
1010
|
+
"Installed read-only hook manifest; publish interactions hooks are disabled.\n"
|
|
1011
|
+
)
|
|
960
1012
|
sys.stdout.write(f"Prepared claude-smart runtime at {message}.\n")
|
|
961
1013
|
|
|
962
1014
|
sys.stdout.write(
|
|
@@ -984,6 +1036,7 @@ def cmd_update(args: argparse.Namespace) -> int:
|
|
|
984
1036
|
)
|
|
985
1037
|
return 1
|
|
986
1038
|
|
|
1039
|
+
read_only = _configure_reflexio_setup()
|
|
987
1040
|
cmd = ["claude", "plugin", "update", _PLUGIN_SPEC]
|
|
988
1041
|
try:
|
|
989
1042
|
subprocess.run(cmd, check=True)
|
|
@@ -992,8 +1045,14 @@ def cmd_update(args: argparse.Namespace) -> int:
|
|
|
992
1045
|
return exc.returncode or 1
|
|
993
1046
|
|
|
994
1047
|
sys.stdout.write("\nclaude-smart updated. Restart Claude Code to apply.\n")
|
|
995
|
-
|
|
996
|
-
|
|
1048
|
+
plugin_root = _find_claude_code_plugin_root()
|
|
1049
|
+
if plugin_root is not None:
|
|
1050
|
+
_restore_publish_hooks_from_source(plugin_root)
|
|
1051
|
+
if read_only:
|
|
1052
|
+
_prune_publish_hooks_for_read_only(plugin_root)
|
|
1053
|
+
sys.stdout.write(
|
|
1054
|
+
"Installed read-only hook manifest; publish interactions hooks are disabled.\n"
|
|
1055
|
+
)
|
|
997
1056
|
return 0
|
|
998
1057
|
|
|
999
1058
|
|
|
@@ -1086,7 +1145,7 @@ def cmd_show(args: argparse.Namespace) -> int:
|
|
|
1086
1145
|
Returns:
|
|
1087
1146
|
int: 0 on success.
|
|
1088
1147
|
"""
|
|
1089
|
-
project_id = args.project or ids.
|
|
1148
|
+
project_id = args.project or ids.resolve_user_id()
|
|
1090
1149
|
adapter = Adapter()
|
|
1091
1150
|
user_playbooks, agent_playbooks, profiles = adapter.fetch_all(
|
|
1092
1151
|
project_id=project_id,
|
|
@@ -1135,11 +1194,17 @@ def cmd_learn(args: argparse.Namespace) -> int:
|
|
|
1135
1194
|
int: 0 on success or no-op (no active session, or nothing to
|
|
1136
1195
|
publish), 1 if reflexio is unreachable.
|
|
1137
1196
|
"""
|
|
1197
|
+
if env_config.env_truthy(env_config.CLAUDE_SMART_READ_ONLY_ENV):
|
|
1198
|
+
sys.stdout.write(
|
|
1199
|
+
"claude-smart is in read-only mode (CLAUDE_SMART_READ_ONLY=1); "
|
|
1200
|
+
"skipping publish.\n"
|
|
1201
|
+
)
|
|
1202
|
+
return 0
|
|
1138
1203
|
session_id = args.session or _latest_session_id()
|
|
1139
1204
|
if not session_id:
|
|
1140
1205
|
sys.stdout.write("No active claude-smart session buffer found.\n")
|
|
1141
1206
|
return 0
|
|
1142
|
-
project_id = args.project or ids.
|
|
1207
|
+
project_id = args.project or ids.resolve_user_id()
|
|
1143
1208
|
|
|
1144
1209
|
note = (args.note or "").strip()
|
|
1145
1210
|
if note:
|
|
@@ -1769,29 +1834,9 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1769
1834
|
default=_DEFAULT_MARKETPLACE_SOURCE,
|
|
1770
1835
|
help="Marketplace ref — GitHub owner/repo, or a local directory path",
|
|
1771
1836
|
)
|
|
1772
|
-
inst.add_argument(
|
|
1773
|
-
"--api-key",
|
|
1774
|
-
default="",
|
|
1775
|
-
help="Configure managed Reflexio with this API key instead of local mode",
|
|
1776
|
-
)
|
|
1777
|
-
inst.add_argument(
|
|
1778
|
-
"--reflexio-url",
|
|
1779
|
-
default=_MANAGED_REFLEXIO_URL,
|
|
1780
|
-
help="Managed Reflexio URL used only when --api-key is provided",
|
|
1781
|
-
)
|
|
1782
1837
|
inst.set_defaults(func=cmd_install)
|
|
1783
1838
|
|
|
1784
1839
|
upd = sub.add_parser("update", help="Update claude-smart to the latest version")
|
|
1785
|
-
upd.add_argument(
|
|
1786
|
-
"--api-key",
|
|
1787
|
-
default="",
|
|
1788
|
-
help="Configure managed Reflexio with this API key after updating",
|
|
1789
|
-
)
|
|
1790
|
-
upd.add_argument(
|
|
1791
|
-
"--reflexio-url",
|
|
1792
|
-
default=_MANAGED_REFLEXIO_URL,
|
|
1793
|
-
help="Managed Reflexio URL used only when --api-key is provided",
|
|
1794
|
-
)
|
|
1795
1840
|
upd.set_defaults(func=cmd_update)
|
|
1796
1841
|
|
|
1797
1842
|
uni = sub.add_parser("uninstall", help="Remove claude-smart")
|
|
@@ -9,7 +9,7 @@ REFLEXIO_ENV_PATH = Path.home() / ".reflexio" / ".env"
|
|
|
9
9
|
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
13
|
|
|
14
14
|
|
|
15
15
|
def parse_env_line(line: str) -> tuple[str, str] | None:
|
|
@@ -89,6 +89,11 @@ def set_env_vars(path: Path, values: dict[str, str]) -> list[str]:
|
|
|
89
89
|
return added
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
def env_truthy(name: str) -> bool:
|
|
93
|
+
"""Return True when an environment flag is explicitly enabled."""
|
|
94
|
+
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
|
95
|
+
|
|
96
|
+
|
|
92
97
|
def mask_secret(value: str) -> str:
|
|
93
98
|
"""Return a display-safe API key preview."""
|
|
94
99
|
if not value:
|
|
@@ -36,7 +36,7 @@ def handle(payload: dict[str, Any]) -> None:
|
|
|
36
36
|
hook.emit_continue()
|
|
37
37
|
return
|
|
38
38
|
|
|
39
|
-
project_id = ids.
|
|
39
|
+
project_id = ids.resolve_user_id(payload.get("cwd"))
|
|
40
40
|
try:
|
|
41
41
|
emitted = context_inject.emit_context(
|
|
42
42
|
session_id=session_id,
|
|
@@ -21,7 +21,7 @@ import time
|
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
from typing import Any
|
|
23
23
|
|
|
24
|
-
from claude_smart import ids, publish, state
|
|
24
|
+
from claude_smart import env_config, ids, publish, state
|
|
25
25
|
from claude_smart.events.stop import (
|
|
26
26
|
_read_transcript_entries,
|
|
27
27
|
_scan_transcript_for_assistant_text,
|
|
@@ -51,13 +51,16 @@ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
|
|
|
51
51
|
session_id = payload.get("session_id")
|
|
52
52
|
if not session_id:
|
|
53
53
|
return None
|
|
54
|
-
project_id = ids.
|
|
54
|
+
project_id = ids.resolve_user_id(payload.get("cwd"))
|
|
55
55
|
|
|
56
56
|
_maybe_synthesize_assistant_anchor(
|
|
57
57
|
session_id=session_id,
|
|
58
58
|
project_id=project_id,
|
|
59
59
|
transcript_path=payload.get("transcript_path"),
|
|
60
60
|
)
|
|
61
|
+
if env_config.env_truthy(env_config.CLAUDE_SMART_READ_ONLY_ENV):
|
|
62
|
+
state.mark_all_published(session_id)
|
|
63
|
+
return ("nothing", 0)
|
|
61
64
|
|
|
62
65
|
# ``force_extraction=True`` makes the reflexio server run the
|
|
63
66
|
# extractor synchronously for this publish, so by the time the HTTP
|
|
@@ -9,7 +9,15 @@ import time
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
-
from claude_smart import
|
|
12
|
+
from claude_smart import (
|
|
13
|
+
cs_cite,
|
|
14
|
+
env_config,
|
|
15
|
+
ids,
|
|
16
|
+
internal_call,
|
|
17
|
+
publish,
|
|
18
|
+
runtime,
|
|
19
|
+
state,
|
|
20
|
+
)
|
|
13
21
|
|
|
14
22
|
_LOGGER = logging.getLogger(__name__)
|
|
15
23
|
|
|
@@ -354,7 +362,7 @@ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
|
|
|
354
362
|
# without this placeholder, those tools would be misattributed to the
|
|
355
363
|
# next assistant turn.
|
|
356
364
|
transcript_path = payload.get("transcript_path")
|
|
357
|
-
project_id = ids.
|
|
365
|
+
project_id = ids.resolve_user_id(payload.get("cwd"))
|
|
358
366
|
|
|
359
367
|
entries: list[dict[str, Any]] = []
|
|
360
368
|
if transcript_path:
|
|
@@ -362,9 +370,7 @@ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
|
|
|
362
370
|
if path.is_file():
|
|
363
371
|
entries = _load_transcript_with_retry(path)
|
|
364
372
|
|
|
365
|
-
prompt = payload.get("prompt") or (
|
|
366
|
-
_scan_transcript_for_user_text(entries) if runtime.is_codex() else ""
|
|
367
|
-
)
|
|
373
|
+
prompt = payload.get("prompt") or _scan_transcript_for_user_text(entries)
|
|
368
374
|
if runtime.is_codex() and internal_call.is_codex_internal_prompt(prompt):
|
|
369
375
|
return None
|
|
370
376
|
|
|
@@ -393,13 +399,24 @@ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
|
|
|
393
399
|
plan_decisions = _scan_transcript_for_plan_decisions(entries)
|
|
394
400
|
|
|
395
401
|
now = int(time.time())
|
|
396
|
-
|
|
402
|
+
if plan_decisions:
|
|
403
|
+
for decision_text in plan_decisions:
|
|
404
|
+
state.append(
|
|
405
|
+
session_id,
|
|
406
|
+
{
|
|
407
|
+
"ts": now,
|
|
408
|
+
"role": "User",
|
|
409
|
+
"content": decision_text,
|
|
410
|
+
"user_id": project_id,
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
elif prompt and not _has_unpublished_user_turn(session_id):
|
|
397
414
|
state.append(
|
|
398
415
|
session_id,
|
|
399
416
|
{
|
|
400
417
|
"ts": now,
|
|
401
418
|
"role": "User",
|
|
402
|
-
"content":
|
|
419
|
+
"content": prompt,
|
|
403
420
|
"user_id": project_id,
|
|
404
421
|
},
|
|
405
422
|
)
|
|
@@ -413,6 +430,9 @@ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
|
|
|
413
430
|
if cited_items:
|
|
414
431
|
record["cited_items"] = cited_items
|
|
415
432
|
state.append(session_id, record)
|
|
433
|
+
if env_config.env_truthy(env_config.CLAUDE_SMART_READ_ONLY_ENV):
|
|
434
|
+
state.mark_all_published(session_id)
|
|
435
|
+
return ("nothing", 0)
|
|
416
436
|
return publish.publish_unpublished(
|
|
417
437
|
session_id=session_id,
|
|
418
438
|
project_id=project_id,
|
|
@@ -24,7 +24,7 @@ from contextlib import redirect_stdout
|
|
|
24
24
|
from io import StringIO
|
|
25
25
|
from typing import Any
|
|
26
26
|
|
|
27
|
-
from claude_smart import hook_log, ids, runtime
|
|
27
|
+
from claude_smart import env_config, hook_log, ids, runtime
|
|
28
28
|
from claude_smart.internal_call import is_internal_invocation
|
|
29
29
|
|
|
30
30
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -68,7 +68,26 @@ def _read_stdin_json() -> dict[str, Any]:
|
|
|
68
68
|
except json.JSONDecodeError as exc:
|
|
69
69
|
_LOGGER.debug("stdin JSON decode failed: %s", exc)
|
|
70
70
|
return {}
|
|
71
|
-
return parsed if isinstance(parsed, dict) else {}
|
|
71
|
+
return _normalize_payload_keys(parsed) if isinstance(parsed, dict) else {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _normalize_payload_keys(payload: dict[str, Any]) -> dict[str, Any]:
|
|
75
|
+
"""Accept both classic Claude Code and Desktop/Cowork hook key shapes."""
|
|
76
|
+
aliases = {
|
|
77
|
+
"sessionId": "session_id",
|
|
78
|
+
"transcriptPath": "transcript_path",
|
|
79
|
+
"toolName": "tool_name",
|
|
80
|
+
"toolInput": "tool_input",
|
|
81
|
+
"toolResponse": "tool_response",
|
|
82
|
+
"lastAssistantMessage": "last_assistant_message",
|
|
83
|
+
"workingDirectory": "cwd",
|
|
84
|
+
"currentWorkingDirectory": "cwd",
|
|
85
|
+
}
|
|
86
|
+
normalized = dict(payload)
|
|
87
|
+
for source, target in aliases.items():
|
|
88
|
+
if target not in normalized and source in normalized:
|
|
89
|
+
normalized[target] = normalized[source]
|
|
90
|
+
return normalized
|
|
72
91
|
|
|
73
92
|
|
|
74
93
|
def emit_continue() -> None:
|
|
@@ -94,6 +113,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
94
113
|
argv = argv if argv is not None else sys.argv[1:]
|
|
95
114
|
host, event = _parse_args(argv)
|
|
96
115
|
runtime.set_host(host)
|
|
116
|
+
env_config.load_reflexio_env()
|
|
97
117
|
if not event:
|
|
98
118
|
_LOGGER.warning("hook dispatcher called with no event name")
|
|
99
119
|
emit_continue()
|
|
@@ -163,8 +163,8 @@ def log_path() -> Path:
|
|
|
163
163
|
|
|
164
164
|
Resolution order:
|
|
165
165
|
|
|
166
|
-
1. ``CLAUDE_SMART_HOOK_LOG`` env var —
|
|
167
|
-
|
|
166
|
+
1. ``CLAUDE_SMART_HOOK_LOG`` env var — boolean-like values enable the
|
|
167
|
+
default log, while path-like values redirect it.
|
|
168
168
|
2. ``_LOG_PATH`` module attribute — what monkeypatched tests override.
|
|
169
169
|
3. ``~/.claude-smart/hook.log`` — the default.
|
|
170
170
|
|
|
@@ -173,6 +173,11 @@ def log_path() -> Path:
|
|
|
173
173
|
"""
|
|
174
174
|
override = os.environ.get("CLAUDE_SMART_HOOK_LOG")
|
|
175
175
|
if override:
|
|
176
|
+
override = override.strip()
|
|
177
|
+
if not override:
|
|
178
|
+
return _LOG_PATH
|
|
179
|
+
if override.lower() in {"1", "true", "yes", "on"}:
|
|
180
|
+
return _LOG_PATH
|
|
176
181
|
return Path(override)
|
|
177
182
|
return _LOG_PATH
|
|
178
183
|
|
|
@@ -19,21 +19,20 @@ from __future__ import annotations
|
|
|
19
19
|
import logging
|
|
20
20
|
import os
|
|
21
21
|
import subprocess # noqa: S404 — git invocation with a fixed flag set.
|
|
22
|
-
import uuid
|
|
23
22
|
from pathlib import Path
|
|
24
23
|
|
|
25
24
|
from claude_smart import env_config
|
|
26
25
|
|
|
27
26
|
_LOGGER = logging.getLogger(__name__)
|
|
28
27
|
|
|
28
|
+
_USER_ID_OVERRIDE_ENV = "REFLEXIO_USER_ID"
|
|
29
|
+
|
|
29
30
|
|
|
30
31
|
def resolve_project_id(cwd: str | os.PathLike[str] | None = None) -> str:
|
|
31
32
|
"""Return a stable project identifier for the given working directory.
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
``REFLEXIO_USER_ID`` UUID is returned. Local installs without an API key
|
|
36
|
-
keep using the git/project name.
|
|
34
|
+
Local and managed/API-key installs both use the git/project name as
|
|
35
|
+
Reflexio's ``user_id``.
|
|
37
36
|
|
|
38
37
|
Prefers the basename of the git toplevel (so worktrees, submodules, and
|
|
39
38
|
`cd src/` all still map to the same project). Falls back to the cwd
|
|
@@ -45,10 +44,6 @@ def resolve_project_id(cwd: str | os.PathLike[str] | None = None) -> str:
|
|
|
45
44
|
Returns:
|
|
46
45
|
str: A non-empty identifier. Never raises.
|
|
47
46
|
"""
|
|
48
|
-
managed_user_id = _managed_user_id()
|
|
49
|
-
if managed_user_id:
|
|
50
|
-
return managed_user_id
|
|
51
|
-
|
|
52
47
|
base = Path(cwd) if cwd is not None else Path.cwd()
|
|
53
48
|
try:
|
|
54
49
|
result = subprocess.run( # noqa: S603, S607 — fixed argv, cwd is a Path.
|
|
@@ -68,21 +63,23 @@ def resolve_project_id(cwd: str | os.PathLike[str] | None = None) -> str:
|
|
|
68
63
|
return base.name or "unknown-project"
|
|
69
64
|
|
|
70
65
|
|
|
71
|
-
def
|
|
72
|
-
|
|
73
|
-
if not os.environ.get(env_config.REFLEXIO_API_KEY_ENV):
|
|
74
|
-
return ""
|
|
75
|
-
user_id = os.environ.get(env_config.REFLEXIO_USER_ID_ENV, "").strip()
|
|
76
|
-
if user_id:
|
|
77
|
-
return user_id
|
|
66
|
+
def resolve_user_id(cwd: str | os.PathLike[str] | None = None) -> str:
|
|
67
|
+
"""Return the Reflexio ``user_id`` for the given working directory.
|
|
78
68
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
69
|
+
Honors the ``REFLEXIO_USER_ID`` env var as an explicit override (e.g. so a
|
|
70
|
+
user can point multiple projects at a single Reflexio identity), and
|
|
71
|
+
otherwise falls back to :func:`resolve_project_id`. The env var is
|
|
72
|
+
loaded from ``~/.reflexio/.env`` before reading the process environment so
|
|
73
|
+
CLI commands and hooks use the same identity resolution.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
cwd: Working directory to resolve when falling back to the project id.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
str: The override value if set and non-empty, else the project id.
|
|
80
|
+
"""
|
|
81
|
+
env_config.load_reflexio_env()
|
|
82
|
+
override = os.environ.get(_USER_ID_OVERRIDE_ENV, "").strip()
|
|
83
|
+
if override:
|
|
84
|
+
return override
|
|
85
|
+
return resolve_project_id(cwd)
|
|
@@ -19,10 +19,10 @@ Two distinct sources of unwanted hook fires:
|
|
|
19
19
|
tool..."`` into reflexio as a fake user interaction.
|
|
20
20
|
|
|
21
21
|
Detection signals, OR'd:
|
|
22
|
-
- ``CLAUDE_CODE_ENTRYPOINT`` is anything other than
|
|
23
|
-
|
|
24
|
-
``sdk-cli`` (and the SDKs may set other values). This
|
|
25
|
-
case (2) for any third-party tool, not just claude-mem.
|
|
22
|
+
- ``CLAUDE_CODE_ENTRYPOINT`` is anything other than an interactive entrypoint
|
|
23
|
+
(``"cli"`` or Claude Desktop's ``"claude-desktop"``). Headless
|
|
24
|
+
``claude -p`` sets ``sdk-cli`` (and the SDKs may set other values). This
|
|
25
|
+
catches case (2) for any third-party tool, not just claude-mem.
|
|
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.
|
|
@@ -46,7 +46,7 @@ from typing import Any
|
|
|
46
46
|
from claude_smart import runtime
|
|
47
47
|
|
|
48
48
|
_ENTRYPOINT_VAR = "CLAUDE_CODE_ENTRYPOINT"
|
|
49
|
-
|
|
49
|
+
_INTERACTIVE_ENTRYPOINTS = frozenset({"cli", "claude-desktop"})
|
|
50
50
|
_CODEX_TITLE_PROMPT_PREFIX = (
|
|
51
51
|
"You are a helpful assistant. You will be presented with a user prompt, "
|
|
52
52
|
"and your job is to provide a short title for a task"
|
|
@@ -92,7 +92,7 @@ def is_internal_invocation(payload: dict[str, Any]) -> bool:
|
|
|
92
92
|
if runtime.is_internal_invocation_env():
|
|
93
93
|
return True
|
|
94
94
|
entrypoint = os.environ.get(_ENTRYPOINT_VAR)
|
|
95
|
-
if entrypoint and entrypoint
|
|
95
|
+
if entrypoint and entrypoint not in _INTERACTIVE_ENTRYPOINTS:
|
|
96
96
|
return True
|
|
97
97
|
if runtime.is_codex() and is_codex_internal_prompt(payload.get("prompt")):
|
|
98
98
|
return True
|
|
@@ -29,11 +29,10 @@ def publish_unpublished(
|
|
|
29
29
|
|
|
30
30
|
Args:
|
|
31
31
|
session_id (str): Claude Code session id, attached to each interaction.
|
|
32
|
-
project_id (str): Stable user-scope id resolved by ``ids
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
globally per agent rather than per project.
|
|
32
|
+
project_id (str): Stable user-scope id resolved by ``ids`` from the
|
|
33
|
+
project name. ``agent_version`` is hardcoded to ``"claude-code"``
|
|
34
|
+
in the adapter so skills roll up globally per agent rather than
|
|
35
|
+
per project.
|
|
37
36
|
force_extraction (bool): Whether to ask reflexio to run extraction
|
|
38
37
|
synchronously instead of queuing for the next sweep.
|
|
39
38
|
override_learning_stall (bool): Whether to bypass a recorded
|