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.
@@ -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(args: argparse.Namespace) -> None:
799
- api_key = (getattr(args, "api_key", "") or "").strip()
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
- getattr(args, "reflexio_url", "") or _MANAGED_REFLEXIO_URL
840
+ file_url
841
+ or os.environ.get(env_config.REFLEXIO_URL_ENV, _MANAGED_REFLEXIO_URL)
803
842
  ).strip()
804
- added = _seed_managed_reflexio_env(api_key=api_key, reflexio_url=reflexio_url)
805
- changed = ", ".join(added) if added else "managed Reflexio settings"
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"Configured {_REFLEXIO_ENV_PATH} for managed Reflexio "
808
- f"({changed}; API key {env_config.mask_secret(api_key)}).\n"
847
+ f"Using managed Reflexio at {reflexio_url} "
848
+ f"(API key {env_config.mask_secret(api_key)}).\n"
809
849
  )
810
- return
811
-
812
- added = _seed_reflexio_env()
813
- if added:
814
- sys.stdout.write(f"Seeded {_REFLEXIO_ENV_PATH} with {', '.join(added)}.\n")
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(args)
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(args)
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
- if getattr(args, "api_key", ""):
996
- _configure_reflexio_setup(args)
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.resolve_project_id()
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.resolve_project_id()
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
- REFLEXIO_USER_ID_ENV = "REFLEXIO_USER_ID"
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.resolve_project_id(payload.get("cwd"))
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.resolve_project_id(payload.get("cwd"))
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 cs_cite, ids, internal_call, publish, runtime, state
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.resolve_project_id(payload.get("cwd"))
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
- for decision_text in plan_decisions:
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": decision_text,
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,
@@ -50,7 +50,7 @@ def handle(payload: dict[str, Any]) -> None:
50
50
  if not session_id or not prompt:
51
51
  return
52
52
 
53
- project_id = ids.resolve_project_id(payload.get("cwd"))
53
+ project_id = ids.resolve_user_id(payload.get("cwd"))
54
54
  state.append(
55
55
  session_id,
56
56
  {
@@ -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 — escape hatch for tests and
167
- advanced users who want the log elsewhere.
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
- Managed Reflexio installs are user-scoped instead of project-scoped:
34
- when ``REFLEXIO_API_KEY`` is configured, the persisted
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 _managed_user_id() -> str:
72
- env_config.load_reflexio_env()
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
- user_id = str(uuid.uuid4())
80
- try:
81
- env_config.set_env_vars(
82
- env_config.REFLEXIO_ENV_PATH,
83
- {env_config.REFLEXIO_USER_ID_ENV: user_id},
84
- )
85
- except OSError as exc:
86
- _LOGGER.debug("could not persist managed Reflexio user id: %s", exc)
87
- os.environ[env_config.REFLEXIO_USER_ID_ENV] = user_id
88
- return user_id
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 ``"cli"``
23
- the interactive REPL sets ``cli``; headless ``claude -p`` sets
24
- ``sdk-cli`` (and the SDKs may set other values). This catches
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
- _INTERACTIVE_ENTRYPOINT = "cli"
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 != _INTERACTIVE_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``. Local mode
33
- uses the project name; managed/API-key mode uses the persisted
34
- ``REFLEXIO_USER_ID`` UUID. ``agent_version`` is hardcoded to
35
- ``"claude-code"`` in the adapter so skills roll up
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