claude-smart 0.2.23 → 0.2.24

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.
Files changed (113) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +69 -27
  3. package/bin/claude-smart.js +296 -11
  4. package/package.json +11 -1
  5. package/plugin/.claude-plugin/plugin.json +17 -0
  6. package/plugin/.codex-plugin/plugin.json +35 -0
  7. package/plugin/LICENSE +202 -0
  8. package/plugin/README.md +37 -0
  9. package/plugin/bin/cs-cite +77 -0
  10. package/plugin/commands/clear-all.md +8 -0
  11. package/plugin/commands/dashboard.md +8 -0
  12. package/plugin/commands/learn.md +12 -0
  13. package/plugin/commands/restart.md +8 -0
  14. package/plugin/commands/show.md +8 -0
  15. package/plugin/dashboard/AGENTS.md +6 -0
  16. package/plugin/dashboard/app/api/claude-settings/route.ts +19 -0
  17. package/plugin/dashboard/app/api/config/route.ts +16 -0
  18. package/plugin/dashboard/app/api/health/route.ts +10 -0
  19. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +63 -0
  20. package/plugin/dashboard/app/api/sessions/[id]/route.ts +28 -0
  21. package/plugin/dashboard/app/api/sessions/route.ts +14 -0
  22. package/plugin/dashboard/app/configure/env/page.tsx +318 -0
  23. package/plugin/dashboard/app/configure/layout.tsx +47 -0
  24. package/plugin/dashboard/app/configure/page.tsx +5 -0
  25. package/plugin/dashboard/app/configure/server/page.tsx +258 -0
  26. package/plugin/dashboard/app/dashboard/page.tsx +227 -0
  27. package/plugin/dashboard/app/globals.css +129 -0
  28. package/plugin/dashboard/app/icon.png +0 -0
  29. package/plugin/dashboard/app/layout.tsx +40 -0
  30. package/plugin/dashboard/app/page.tsx +5 -0
  31. package/plugin/dashboard/app/preferences/[id]/page.tsx +531 -0
  32. package/plugin/dashboard/app/preferences/page.tsx +126 -0
  33. package/plugin/dashboard/app/providers.tsx +12 -0
  34. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +321 -0
  35. package/plugin/dashboard/app/sessions/page.tsx +186 -0
  36. package/plugin/dashboard/app/skills/page.tsx +362 -0
  37. package/plugin/dashboard/app/skills/project/[id]/page.tsx +597 -0
  38. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +830 -0
  39. package/plugin/dashboard/components/common/delete-all-button.tsx +45 -0
  40. package/plugin/dashboard/components/common/empty-state.tsx +34 -0
  41. package/plugin/dashboard/components/common/learnings-badge.tsx +34 -0
  42. package/plugin/dashboard/components/common/page-header.tsx +34 -0
  43. package/plugin/dashboard/components/common/page-tabs.tsx +115 -0
  44. package/plugin/dashboard/components/common/stat-card.tsx +38 -0
  45. package/plugin/dashboard/components/layout/nav-items.ts +22 -0
  46. package/plugin/dashboard/components/layout/sidebar.tsx +45 -0
  47. package/plugin/dashboard/components/layout/top-bar.tsx +64 -0
  48. package/plugin/dashboard/components/stall-banner.tsx +53 -0
  49. package/plugin/dashboard/components/ui/badge.tsx +52 -0
  50. package/plugin/dashboard/components/ui/button.tsx +60 -0
  51. package/plugin/dashboard/components/ui/collapsible.tsx +21 -0
  52. package/plugin/dashboard/components/ui/input.tsx +20 -0
  53. package/plugin/dashboard/components/ui/label.tsx +20 -0
  54. package/plugin/dashboard/components/ui/scroll-area.tsx +55 -0
  55. package/plugin/dashboard/components/ui/select.tsx +201 -0
  56. package/plugin/dashboard/components/ui/separator.tsx +25 -0
  57. package/plugin/dashboard/components/ui/sheet.tsx +135 -0
  58. package/plugin/dashboard/components/ui/switch.tsx +32 -0
  59. package/plugin/dashboard/components.json +25 -0
  60. package/plugin/dashboard/eslint.config.mjs +16 -0
  61. package/plugin/dashboard/hooks/use-settings.tsx +88 -0
  62. package/plugin/dashboard/hooks/use-stall-state.ts +59 -0
  63. package/plugin/dashboard/lib/claude-settings-file.ts +114 -0
  64. package/plugin/dashboard/lib/config-file.ts +131 -0
  65. package/plugin/dashboard/lib/format.ts +58 -0
  66. package/plugin/dashboard/lib/reflexio-client.ts +238 -0
  67. package/plugin/dashboard/lib/reflexio-url.ts +17 -0
  68. package/plugin/dashboard/lib/session-reader.ts +245 -0
  69. package/plugin/dashboard/lib/status.ts +24 -0
  70. package/plugin/dashboard/lib/types.ts +145 -0
  71. package/plugin/dashboard/lib/utils.ts +6 -0
  72. package/plugin/dashboard/next.config.ts +7 -0
  73. package/plugin/dashboard/package-lock.json +10275 -0
  74. package/plugin/dashboard/package.json +37 -0
  75. package/plugin/dashboard/postcss.config.mjs +7 -0
  76. package/plugin/dashboard/public/claude-smart-icon.png +0 -0
  77. package/plugin/dashboard/tsconfig.json +34 -0
  78. package/plugin/hooks/codex-hooks.json +67 -0
  79. package/plugin/hooks/hooks.json +111 -0
  80. package/plugin/pyproject.toml +49 -0
  81. package/plugin/scripts/_codex_env.sh +27 -0
  82. package/plugin/scripts/_lib.sh +325 -0
  83. package/plugin/scripts/backend-service.sh +208 -0
  84. package/plugin/scripts/cli.sh +40 -0
  85. package/plugin/scripts/dashboard-build.sh +139 -0
  86. package/plugin/scripts/dashboard-open.sh +107 -0
  87. package/plugin/scripts/dashboard-service.sh +195 -0
  88. package/plugin/scripts/ensure-plugin-root.sh +84 -0
  89. package/plugin/scripts/hook_entry.sh +70 -0
  90. package/plugin/scripts/smart-install.sh +411 -0
  91. package/plugin/src/claude_smart/__init__.py +3 -0
  92. package/plugin/src/claude_smart/cli.py +1273 -0
  93. package/plugin/src/claude_smart/context_format.py +277 -0
  94. package/plugin/src/claude_smart/context_inject.py +92 -0
  95. package/plugin/src/claude_smart/cs_cite.py +236 -0
  96. package/plugin/src/claude_smart/events/__init__.py +1 -0
  97. package/plugin/src/claude_smart/events/post_tool.py +148 -0
  98. package/plugin/src/claude_smart/events/pre_tool.py +52 -0
  99. package/plugin/src/claude_smart/events/session_end.py +20 -0
  100. package/plugin/src/claude_smart/events/session_start.py +119 -0
  101. package/plugin/src/claude_smart/events/stop.py +393 -0
  102. package/plugin/src/claude_smart/events/user_prompt.py +73 -0
  103. package/plugin/src/claude_smart/hook.py +114 -0
  104. package/plugin/src/claude_smart/ids.py +56 -0
  105. package/plugin/src/claude_smart/internal_call.py +89 -0
  106. package/plugin/src/claude_smart/optimizer_assistant.py +203 -0
  107. package/plugin/src/claude_smart/publish.py +71 -0
  108. package/plugin/src/claude_smart/query_compose.py +51 -0
  109. package/plugin/src/claude_smart/reflexio_adapter.py +403 -0
  110. package/plugin/src/claude_smart/runtime.py +52 -0
  111. package/plugin/src/claude_smart/stall_banner.py +61 -0
  112. package/plugin/src/claude_smart/state.py +276 -0
  113. package/plugin/uv.lock +3720 -0
@@ -0,0 +1,1273 @@
1
+ """User-facing CLI for claude-smart.
2
+
3
+ Exposes the following subcommands:
4
+
5
+ - ``install``: register the GitHub marketplace and install the plugin into
6
+ Claude Code, then seed ``~/.reflexio/.env`` with the local-provider flags.
7
+ - ``update``: update the plugin to the latest version via the native Claude
8
+ Code plugin CLI.
9
+ - ``uninstall``: remove the plugin from Claude Code via the native plugin
10
+ CLI. Local data under ``~/.reflexio/`` and ``~/.claude-smart/`` is left
11
+ in place.
12
+ - ``show``: print current project-specific skills and project preferences
13
+ (as markdown).
14
+ - ``learn``: publish unpublished interactions and force reflexio
15
+ extraction now over the active session buffer.
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.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import re
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import time
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ from claude_smart import context_format, ids, publish, state
36
+ from claude_smart.reflexio_adapter import Adapter
37
+
38
+ _REFLEXIO_ENV_PATH = Path.home() / ".reflexio" / ".env"
39
+ _DEFAULT_MARKETPLACE_SOURCE = "ReflexioAI/claude-smart"
40
+ _PLUGIN_SPEC = "claude-smart@reflexioai"
41
+ _CODEX_MARKETPLACE_NAME = "reflexioai"
42
+ _CODEX_MARKETPLACE_DISPLAY_NAME = "ReflexioAI"
43
+ _CODEX_PLUGIN_ID = f"claude-smart@{_CODEX_MARKETPLACE_NAME}"
44
+ _CODEX_CONFIG_PATH = Path.home() / ".codex" / "config.toml"
45
+ _CODEX_LOCAL_MARKETPLACE_ROOT = (
46
+ Path.home() / ".claude" / "plugins" / "marketplaces" / _CODEX_MARKETPLACE_NAME
47
+ )
48
+ _CODEX_LOCAL_PLUGIN_PATH = Path("plugins") / "claude-smart"
49
+ _CODEX_PLUGIN_CACHE_DIR = (
50
+ Path.home()
51
+ / ".codex"
52
+ / "plugins"
53
+ / "cache"
54
+ / _CODEX_MARKETPLACE_NAME
55
+ / "claude-smart"
56
+ )
57
+ _CODEX_CLI_TIMEOUT_SECONDS = 30
58
+ _REFLEXIO_UNREACHABLE_MSG = (
59
+ "Failed to reach reflexio. Check ~/.claude-smart/backend.log "
60
+ "or restart Claude Code.\n"
61
+ )
62
+
63
+ _THIS_DIR = Path(__file__).resolve().parent
64
+ _PLUGIN_ROOT = _THIS_DIR.parents[1] # plugin/src/claude_smart/ -> plugin/
65
+ _REPO_ROOT = _PLUGIN_ROOT.parent
66
+ _SCRIPTS_DIR = _PLUGIN_ROOT / "scripts"
67
+ _DASHBOARD_DIR = _PLUGIN_ROOT / "dashboard"
68
+ _BACKEND_SCRIPT = _SCRIPTS_DIR / "backend-service.sh"
69
+ _DASHBOARD_SCRIPT = _SCRIPTS_DIR / "dashboard-service.sh"
70
+ _REFLEXIO_DIR = Path.home() / ".reflexio"
71
+ _DEFAULT_STORAGE_ROOT = _REFLEXIO_DIR / "data"
72
+ _REFLEXIO_CONFIG_PATH = _REFLEXIO_DIR / "configs" / "config_self-host-org.json"
73
+ _LOCAL_STORAGE_ENV = "LOCAL_STORAGE_PATH"
74
+ _CODEX_REQUIRED_FILES = (
75
+ Path(".agents/plugins/marketplace.json"),
76
+ Path("plugin/.codex-plugin/plugin.json"),
77
+ Path("plugin/hooks/codex-hooks.json"),
78
+ Path("plugin/scripts/_codex_env.sh"),
79
+ )
80
+ _COPYTREE_IGNORE = shutil.ignore_patterns(
81
+ ".venv",
82
+ "__pycache__",
83
+ "*.pyc",
84
+ "*.pyo",
85
+ ".pytest_cache",
86
+ ".ruff_cache",
87
+ ".next",
88
+ "node_modules",
89
+ )
90
+
91
+
92
+ def _latest_session_id() -> str | None:
93
+ """Most-recently-modified session JSONL in the state dir. None if none exist."""
94
+ root = state.state_dir()
95
+ if not root.is_dir():
96
+ return None
97
+ files = sorted(root.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
98
+ if not files:
99
+ return None
100
+ return files[0].stem
101
+
102
+
103
+ def _seed_reflexio_env() -> list[str]:
104
+ """Append the two local-provider flags to ``~/.reflexio/.env``, idempotently.
105
+
106
+ Returns:
107
+ list[str]: Flag names that were newly appended (empty if already present).
108
+ """
109
+ _REFLEXIO_ENV_PATH.parent.mkdir(parents=True, exist_ok=True)
110
+ _REFLEXIO_ENV_PATH.touch(exist_ok=True)
111
+ existing = _REFLEXIO_ENV_PATH.read_text()
112
+ flags = ("CLAUDE_SMART_USE_LOCAL_CLI", "CLAUDE_SMART_USE_LOCAL_EMBEDDING")
113
+ missing = [f for f in flags if f"{f}=" not in existing]
114
+ if not missing:
115
+ return []
116
+ prefix = "" if not existing or existing.endswith("\n") else "\n"
117
+ with _REFLEXIO_ENV_PATH.open("a") as fh:
118
+ fh.write(prefix + "\n".join(f"{f}=1" for f in missing) + "\n")
119
+ return missing
120
+
121
+
122
+ def _missing_codex_marketplace_files(root: Path) -> list[Path]:
123
+ return [entry for entry in _CODEX_REQUIRED_FILES if not (root / entry).is_file()]
124
+
125
+
126
+ def _prepare_codex_local_marketplace() -> Path:
127
+ """Create the local Codex marketplace wrapper used for install.
128
+
129
+ Wipes any prior wrapper at ``_CODEX_LOCAL_MARKETPLACE_ROOT``, copies
130
+ this checkout's ``plugin/`` directory under ``plugins/claude-smart``
131
+ (skipping dev artifacts in ``_COPYTREE_IGNORE``), and writes a fresh
132
+ ``.agents/plugins/marketplace.json`` pointing at it. The directory
133
+ name ``plugins/claude-smart`` keeps the on-disk plugin folder aligned
134
+ with the manifest name so Codex resolves it cleanly.
135
+
136
+ Returns:
137
+ Path: The marketplace root that Codex should register.
138
+ """
139
+ marketplace_root = _CODEX_LOCAL_MARKETPLACE_ROOT
140
+ marketplace_manifest = marketplace_root / ".agents" / "plugins" / "marketplace.json"
141
+ plugin_dir = marketplace_root / _CODEX_LOCAL_PLUGIN_PATH
142
+
143
+ if marketplace_root.exists() or marketplace_root.is_symlink():
144
+ if marketplace_root.is_dir() and not marketplace_root.is_symlink():
145
+ shutil.rmtree(marketplace_root)
146
+ else:
147
+ marketplace_root.unlink()
148
+ marketplace_manifest.parent.mkdir(parents=True, exist_ok=True)
149
+ plugin_dir.parent.mkdir(parents=True, exist_ok=True)
150
+ shutil.copytree(_PLUGIN_ROOT, plugin_dir, symlinks=False, ignore=_COPYTREE_IGNORE)
151
+
152
+ marketplace_manifest.write_text(
153
+ json.dumps(
154
+ {
155
+ "name": _CODEX_MARKETPLACE_NAME,
156
+ "interface": {"displayName": _CODEX_MARKETPLACE_DISPLAY_NAME},
157
+ "plugins": [
158
+ {
159
+ "name": "claude-smart",
160
+ "source": {
161
+ "source": "local",
162
+ "path": f"./{_CODEX_LOCAL_PLUGIN_PATH.as_posix()}",
163
+ },
164
+ "policy": {
165
+ "installation": "AVAILABLE",
166
+ "authentication": "ON_INSTALL",
167
+ },
168
+ "category": "Productivity",
169
+ }
170
+ ],
171
+ },
172
+ indent=2,
173
+ )
174
+ + "\n"
175
+ )
176
+ return marketplace_root
177
+
178
+
179
+ def _run_codex(args: list[str]) -> subprocess.CompletedProcess[str]:
180
+ """Invoke the ``codex`` CLI with a hard timeout.
181
+
182
+ Args:
183
+ args: Argument list after ``codex`` (e.g. ``["features", "enable",
184
+ "plugin_hooks"]``).
185
+
186
+ Returns:
187
+ subprocess.CompletedProcess[str]: The completed run. On timeout,
188
+ returns a synthetic process with exit code 124 and a stderr
189
+ message identifying the hung command.
190
+ """
191
+ command = ["codex", *args]
192
+ try:
193
+ return subprocess.run(
194
+ command,
195
+ capture_output=True,
196
+ text=True,
197
+ check=False,
198
+ timeout=_CODEX_CLI_TIMEOUT_SECONDS,
199
+ )
200
+ except subprocess.TimeoutExpired:
201
+ return subprocess.CompletedProcess(
202
+ command,
203
+ 124,
204
+ "",
205
+ f"Codex CLI timed out after {_CODEX_CLI_TIMEOUT_SECONDS}s: {' '.join(command)}",
206
+ )
207
+
208
+
209
+ def _set_toml_feature(path: Path, feature: str, value: bool) -> bool:
210
+ """Set one boolean under ``[features]`` in a minimal TOML file."""
211
+ desired = f"{feature} = {'true' if value else 'false'}"
212
+ section_re = re.compile(r"^\s*\[([^\]]+)\]\s*(?:#.*)?$")
213
+ feature_re = re.compile(rf"^\s*{re.escape(feature)}\s*=")
214
+ try:
215
+ text = path.read_text() if path.exists() else ""
216
+ except OSError:
217
+ return False
218
+
219
+ lines = text.splitlines()
220
+ in_features = False
221
+ features_idx: int | None = None
222
+ insert_idx: int | None = None
223
+ changed = False
224
+ out: list[str] = []
225
+
226
+ for line in lines:
227
+ section_match = section_re.match(line)
228
+ if section_match:
229
+ if in_features and insert_idx is None:
230
+ insert_idx = len(out)
231
+ in_features = section_match.group(1).strip() == "features"
232
+ if in_features:
233
+ features_idx = len(out)
234
+ out.append(line)
235
+ continue
236
+ if in_features and feature_re.match(line):
237
+ out.append(desired)
238
+ changed = changed or line != desired
239
+ continue
240
+ out.append(line)
241
+
242
+ if features_idx is None:
243
+ if out and out[-1].strip():
244
+ out.append("")
245
+ out.extend(["[features]", desired])
246
+ changed = True
247
+ else:
248
+ section_end = insert_idx if insert_idx is not None else len(out)
249
+ if not any(
250
+ feature_re.match(line) for line in out[features_idx + 1 : section_end]
251
+ ):
252
+ idx = insert_idx if insert_idx is not None else len(out)
253
+ out.insert(idx, desired)
254
+ changed = True
255
+
256
+ if not changed and text.endswith("\n"):
257
+ return True
258
+ try:
259
+ path.parent.mkdir(parents=True, exist_ok=True)
260
+ path.write_text("\n".join(out) + "\n")
261
+ except OSError:
262
+ return False
263
+ return True
264
+
265
+
266
+ def _remove_toml_sections(
267
+ path: Path, *, exact: set[str], prefixes: tuple[str, ...] = ()
268
+ ) -> bool:
269
+ """Remove simple top-level TOML sections by section name.
270
+
271
+ Walks ``path`` line by line, dropping every line from a matching
272
+ ``[section]`` header through the next header. Intended for minimal
273
+ TOML files like ``~/.codex/config.toml``; nested array-of-tables
274
+ (``[[…]]``) and inline tables are not supported.
275
+
276
+ Args:
277
+ path: TOML file to rewrite. A missing file is treated as success.
278
+ exact: Section names to drop (e.g. ``"marketplaces.reflexioai"``).
279
+ prefixes: Section-name prefixes to drop (e.g.
280
+ ``("hooks.state.\"claude-smart@reflexioai:",)``).
281
+
282
+ Returns:
283
+ bool: ``True`` if the file is now consistent (including the
284
+ no-op case). ``False`` only if the file existed but could
285
+ not be read or written.
286
+ """
287
+ try:
288
+ text = path.read_text() if path.exists() else ""
289
+ except OSError:
290
+ return False
291
+ if not text:
292
+ return True
293
+
294
+ section_re = re.compile(r"^\s*\[([^\]]+)\]\s*(?:#.*)?$")
295
+ changed = False
296
+ dropping = False
297
+ out: list[str] = []
298
+ for line in text.splitlines(keepends=True):
299
+ section_match = section_re.match(line)
300
+ if section_match:
301
+ name = section_match.group(1).strip()
302
+ dropping = name in exact or any(
303
+ name.startswith(prefix) for prefix in prefixes
304
+ )
305
+ changed = changed or dropping
306
+ if not dropping:
307
+ out.append(line)
308
+
309
+ if not changed:
310
+ return True
311
+ try:
312
+ path.write_text("".join(out))
313
+ except OSError:
314
+ return False
315
+ return True
316
+
317
+
318
+ def _cleanup_codex_install_state() -> bool:
319
+ """Remove Codex's local install artifacts while preserving shared learning data.
320
+
321
+ Drops the ``[plugins."claude-smart@reflexioai"]`` /
322
+ ``[marketplaces.reflexioai]`` / ``[hooks.state."claude-smart@reflexioai:…"]``
323
+ sections from ``~/.codex/config.toml``, removes the marketplace
324
+ wrapper and the Codex-side plugin cache, and tries to clean the
325
+ parent cache directory if it is now empty. Shared
326
+ ``~/.reflexio/`` and ``~/.claude-smart/`` data, and Codex's global
327
+ ``plugin_hooks`` feature, are intentionally left in place.
328
+
329
+ Returns:
330
+ bool: ``True`` if the TOML rewrite succeeded (or no rewrite was
331
+ needed). Filesystem cleanup errors are swallowed.
332
+ """
333
+ ok = _remove_toml_sections(
334
+ _CODEX_CONFIG_PATH,
335
+ exact={
336
+ f'plugins."{_CODEX_PLUGIN_ID}"',
337
+ f"marketplaces.{_CODEX_MARKETPLACE_NAME}",
338
+ },
339
+ prefixes=(f'hooks.state."{_CODEX_PLUGIN_ID}:',),
340
+ )
341
+ shutil.rmtree(_CODEX_LOCAL_MARKETPLACE_ROOT, ignore_errors=True)
342
+ shutil.rmtree(_CODEX_PLUGIN_CACHE_DIR, ignore_errors=True)
343
+ try:
344
+ _CODEX_PLUGIN_CACHE_DIR.parent.rmdir()
345
+ except OSError:
346
+ pass
347
+ return ok
348
+
349
+
350
+ def _enable_codex_plugin_hooks() -> tuple[bool, str]:
351
+ """Enable Codex's ``plugin_hooks`` feature, falling back to editing config.toml.
352
+
353
+ Returns:
354
+ tuple[bool, str]: ``(success, message)``. The message describes
355
+ either the successful path taken (CLI vs. direct config write)
356
+ or the failure mode.
357
+ """
358
+ result = _run_codex(["features", "enable", "plugin_hooks"])
359
+ if result.returncode == 0:
360
+ return True, "codex features enable plugin_hooks"
361
+ if _set_toml_feature(_CODEX_CONFIG_PATH, "plugin_hooks", True):
362
+ return True, f"set plugin_hooks = true in {_CODEX_CONFIG_PATH}"
363
+ return False, (
364
+ result.stderr or result.stdout or "could not update Codex config"
365
+ ).strip()
366
+
367
+
368
+ def _register_codex_marketplace(root: Path) -> tuple[bool, str]:
369
+ """Register ``root`` as a Codex marketplace, replacing a stale entry if needed.
370
+
371
+ On success runs ``plugin marketplace upgrade reflexioai`` to refresh
372
+ Codex's cache. On the "different source" error path, removes the
373
+ existing registration and retries once.
374
+
375
+ Args:
376
+ root: Local marketplace directory to register.
377
+
378
+ Returns:
379
+ tuple[bool, str]: ``(success, message)`` where the message
380
+ captures the path taken or the Codex CLI's error output.
381
+ """
382
+ result = _run_codex(["plugin", "marketplace", "add", str(root)])
383
+ if result.returncode == 0:
384
+ upgrade = _run_codex(
385
+ ["plugin", "marketplace", "upgrade", _CODEX_MARKETPLACE_NAME]
386
+ )
387
+ suffix = " and refreshed cache" if upgrade.returncode == 0 else ""
388
+ return True, f"registered Codex marketplace{suffix}"
389
+ output = (result.stderr or result.stdout or "").strip()
390
+ if "different source" in output:
391
+ removed = _run_codex(
392
+ ["plugin", "marketplace", "remove", _CODEX_MARKETPLACE_NAME]
393
+ )
394
+ if removed.returncode == 0:
395
+ added = _run_codex(["plugin", "marketplace", "add", str(root)])
396
+ if added.returncode == 0:
397
+ return True, "replaced stale Codex marketplace registration"
398
+ return False, output or "Codex CLI does not expose plugin marketplace commands"
399
+
400
+
401
+ def cmd_install_codex(_args: argparse.Namespace) -> int:
402
+ """Install the claude-smart plugin marketplace for Codex.
403
+
404
+ Only supported from a source checkout — the wheel ships the Python
405
+ package without the repo-level ``.agents/`` and top-level ``plugin/``
406
+ layout this command expects. End users install via the npm wrapper.
407
+
408
+ Args:
409
+ _args: Parsed CLI args (unused).
410
+
411
+ Returns:
412
+ int: 0 on success, non-zero on failure or unsupported runtime.
413
+ """
414
+ if not shutil.which("codex"):
415
+ sys.stderr.write("error: 'codex' CLI not found on PATH. Install Codex first.\n")
416
+ return 1
417
+ if not shutil.which("uv"):
418
+ sys.stderr.write(
419
+ "error: 'uv' not found on PATH. Install uv or restart your shell.\n"
420
+ )
421
+ return 1
422
+
423
+ missing = _missing_codex_marketplace_files(_REPO_ROOT)
424
+ if missing:
425
+ sys.stderr.write(
426
+ "error: `claude-smart install --host codex` (Python path) only runs "
427
+ "from a source checkout. End users: `npx claude-smart install --host "
428
+ "codex` instead.\n"
429
+ )
430
+ return 1
431
+ marketplace_root = _prepare_codex_local_marketplace()
432
+
433
+ added = _seed_reflexio_env()
434
+ if added:
435
+ sys.stdout.write(f"Seeded {_REFLEXIO_ENV_PATH} with {', '.join(added)}.\n")
436
+
437
+ hooks_ok, hooks_msg = _enable_codex_plugin_hooks()
438
+ if hooks_ok:
439
+ sys.stdout.write(f"Enabled Codex plugin hooks ({hooks_msg}).\n")
440
+ else:
441
+ sys.stderr.write(f"warning: could not enable Codex plugin hooks: {hooks_msg}\n")
442
+
443
+ registered, registration_msg = _register_codex_marketplace(marketplace_root)
444
+ if registered:
445
+ sys.stdout.write(f"{registration_msg}.\n")
446
+ else:
447
+ sys.stderr.write(f"warning: {registration_msg}\n")
448
+
449
+ if registered:
450
+ sys.stdout.write(
451
+ "\nclaude-smart Codex support is prepared.\n"
452
+ "Fully quit and reopen Codex in this repo, run /plugins, install claude-smart from "
453
+ f"the {_CODEX_MARKETPLACE_DISPLAY_NAME} marketplace if it is not already installed, "
454
+ "then restart Codex so hooks reload. Uninstall removes the marketplace "
455
+ "registration but leaves shared claude-smart data and Codex's global "
456
+ "plugin_hooks feature intact.\n"
457
+ )
458
+ else:
459
+ sys.stdout.write(
460
+ "\nCodex hooks enabled; marketplace registration failed.\n"
461
+ f"Install manually with `codex plugin marketplace add {marketplace_root}`, "
462
+ "then fully quit and reopen Codex, run /plugins, install claude-smart from the "
463
+ f"{_CODEX_MARKETPLACE_DISPLAY_NAME} marketplace, and restart Codex so hooks reload.\n"
464
+ )
465
+ return 0 if hooks_ok and registered else 1
466
+
467
+
468
+ def cmd_install(args: argparse.Namespace) -> int:
469
+ """Install claude-smart into Claude Code via the native plugin CLI.
470
+
471
+ Runs ``claude plugin marketplace add`` followed by ``claude plugin install``,
472
+ then appends the local-provider flags to ``~/.reflexio/.env`` so reflexio
473
+ can route generation through the local Claude Code CLI.
474
+
475
+ Args:
476
+ args (argparse.Namespace): Parsed CLI args. Uses ``args.source`` as the
477
+ marketplace ref (``owner/repo`` on GitHub, or a local directory).
478
+
479
+ Returns:
480
+ int: 0 on success, non-zero if the ``claude`` CLI is missing or fails.
481
+ """
482
+ if getattr(args, "host", "claude-code") == "codex":
483
+ return cmd_install_codex(args)
484
+
485
+ if not shutil.which("claude"):
486
+ sys.stderr.write(
487
+ "error: 'claude' CLI not found on PATH. "
488
+ "Install Claude Code first: https://claude.com/claude-code\n"
489
+ )
490
+ return 1
491
+
492
+ for cmd in (
493
+ ["claude", "plugin", "marketplace", "add", args.source],
494
+ ["claude", "plugin", "install", _PLUGIN_SPEC],
495
+ ):
496
+ try:
497
+ subprocess.run(cmd, check=True)
498
+ except subprocess.CalledProcessError as exc:
499
+ sys.stderr.write(f"error: {' '.join(cmd)} failed (exit {exc.returncode})\n")
500
+ return exc.returncode or 1
501
+
502
+ added = _seed_reflexio_env()
503
+ if added:
504
+ sys.stdout.write(f"Seeded {_REFLEXIO_ENV_PATH} with {', '.join(added)}.\n")
505
+
506
+ sys.stdout.write("\nclaude-smart installed. Restart Claude Code in your project.\n")
507
+ return 0
508
+
509
+
510
+ def cmd_update(_args: argparse.Namespace) -> int:
511
+ """Update claude-smart to the latest version via the native plugin CLI.
512
+
513
+ Runs ``claude plugin update claude-smart@reflexioai``.
514
+
515
+ Args:
516
+ args (argparse.Namespace): Parsed CLI args (unused).
517
+
518
+ Returns:
519
+ int: 0 on success, non-zero if the ``claude`` CLI is missing or fails.
520
+ """
521
+ if not shutil.which("claude"):
522
+ sys.stderr.write(
523
+ "error: 'claude' CLI not found on PATH. "
524
+ "Install Claude Code first: https://claude.com/claude-code\n"
525
+ )
526
+ return 1
527
+
528
+ cmd = ["claude", "plugin", "update", _PLUGIN_SPEC]
529
+ try:
530
+ subprocess.run(cmd, check=True)
531
+ except subprocess.CalledProcessError as exc:
532
+ sys.stderr.write(f"error: {' '.join(cmd)} failed (exit {exc.returncode})\n")
533
+ return exc.returncode or 1
534
+
535
+ sys.stdout.write("\nclaude-smart updated. Restart Claude Code to apply.\n")
536
+ return 0
537
+
538
+
539
+ def cmd_uninstall(_args: argparse.Namespace) -> int:
540
+ """Uninstall claude-smart from Claude Code via the native plugin CLI.
541
+
542
+ Runs ``claude plugin uninstall claude-smart@reflexioai``. Local data under
543
+ ``~/.reflexio/`` and ``~/.claude-smart/`` is left in place.
544
+
545
+ Args:
546
+ args (argparse.Namespace): Parsed CLI args (unused).
547
+
548
+ Returns:
549
+ int: 0 on success, non-zero if the ``claude`` CLI is missing or fails.
550
+ """
551
+ if getattr(_args, "host", "claude-code") == "codex":
552
+ return cmd_uninstall_codex(_args)
553
+
554
+ if not shutil.which("claude"):
555
+ sys.stderr.write(
556
+ "error: 'claude' CLI not found on PATH. "
557
+ "Install Claude Code first: https://claude.com/claude-code\n"
558
+ )
559
+ return 1
560
+
561
+ cmd = ["claude", "plugin", "uninstall", _PLUGIN_SPEC]
562
+ try:
563
+ subprocess.run(cmd, check=True)
564
+ except subprocess.CalledProcessError as exc:
565
+ sys.stderr.write(f"error: {' '.join(cmd)} failed (exit {exc.returncode})\n")
566
+ return exc.returncode or 1
567
+
568
+ sys.stdout.write(
569
+ "\nclaude-smart uninstalled. Restart Claude Code to apply.\n"
570
+ "Local data in ~/.reflexio/ and ~/.claude-smart/ was left in place — "
571
+ "remove manually if desired.\n"
572
+ )
573
+ return 0
574
+
575
+
576
+ def cmd_uninstall_codex(_args: argparse.Namespace) -> int:
577
+ """Remove the Codex marketplace registration and local install state.
578
+
579
+ Runs ``codex plugin marketplace remove reflexioai`` (skipped when the
580
+ Codex CLI is absent) and then cleans the on-disk marketplace
581
+ wrapper, plugin cache, and the corresponding sections of
582
+ ``~/.codex/config.toml``. Shared learning data is preserved.
583
+
584
+ Args:
585
+ _args: Parsed CLI args (unused).
586
+
587
+ Returns:
588
+ int: 0 on success or partial cleanup; non-zero is reserved for
589
+ future failure modes — current paths swallow Codex CLI
590
+ errors and continue cleaning local state.
591
+ """
592
+ if not shutil.which("codex"):
593
+ sys.stdout.write("Codex CLI not found; skipping marketplace removal.\n")
594
+ _cleanup_codex_install_state()
595
+ return 0
596
+ result = _run_codex(["plugin", "marketplace", "remove", _CODEX_MARKETPLACE_NAME])
597
+ if result.returncode != 0:
598
+ output = (result.stderr or result.stdout or "").strip()
599
+ sys.stderr.write(
600
+ "warning: Codex marketplace removal failed"
601
+ + (f": {output}" if output else "")
602
+ + "\n"
603
+ )
604
+ if not _cleanup_codex_install_state():
605
+ sys.stderr.write(f"warning: could not update {_CODEX_CONFIG_PATH}\n")
606
+ sys.stdout.write(
607
+ "claude-smart Codex plugin and marketplace state removed. Restart Codex to apply. "
608
+ "Codex's global plugin_hooks feature and local data under ~/.reflexio "
609
+ "and ~/.claude-smart were left in place.\n"
610
+ )
611
+ return 0
612
+
613
+
614
+ def cmd_show(args: argparse.Namespace) -> int:
615
+ """Print this project's skills + preferences, plus globally-shared skills.
616
+
617
+ User playbooks (this project's skills) and preferences are scoped via
618
+ ``project_id`` (resolved from cwd via ``ids.resolve_project_id``).
619
+ Agent playbooks are global by construction — they're aggregated across
620
+ every project, so lessons learned anywhere surface here.
621
+
622
+ Args:
623
+ args (argparse.Namespace): Parsed CLI args. Honors ``args.project``
624
+ (override project id for the project-scoped fetches).
625
+
626
+ Returns:
627
+ int: 0 on success.
628
+ """
629
+ project_id = args.project or ids.resolve_project_id()
630
+ adapter = Adapter()
631
+ user_playbooks, agent_playbooks, profiles = adapter.fetch_all(
632
+ project_id=project_id,
633
+ user_playbook_top_k=3,
634
+ agent_playbook_top_k=3,
635
+ profile_top_k=3,
636
+ )
637
+ md = context_format.render(
638
+ project_id=project_id,
639
+ user_playbooks=user_playbooks,
640
+ agent_playbooks=agent_playbooks,
641
+ profiles=profiles,
642
+ )
643
+ sys.stdout.write(
644
+ md or f"_No skills or preferences yet for project `{project_id}`._\n"
645
+ )
646
+ return 0
647
+
648
+
649
+ def cmd_learn(args: argparse.Namespace) -> int:
650
+ """Force reflexio extraction over the active session's interactions.
651
+
652
+ Publishes unpublished interactions with ``force_extraction=True`` so
653
+ extraction runs immediately rather than at the next batch interval.
654
+ When ``args.note`` is provided, the note is appended to the session
655
+ buffer as a neutral User turn before publishing — letting the user
656
+ capture an explicit insight, preference, or workflow note alongside
657
+ whatever has already been buffered.
658
+
659
+ Args:
660
+ args (argparse.Namespace): Parsed CLI args. Honors ``args.session``
661
+ (defaults to most-recent), ``args.project`` (defaults to
662
+ ``ids.resolve_project_id()``), and ``args.note`` (free-form
663
+ text appended as a User turn before publish).
664
+
665
+ Returns:
666
+ int: 0 on success or no-op (no active session, or nothing to
667
+ publish), 1 if reflexio is unreachable.
668
+ """
669
+ session_id = args.session or _latest_session_id()
670
+ if not session_id:
671
+ sys.stdout.write("No active claude-smart session buffer found.\n")
672
+ return 0
673
+ project_id = args.project or ids.resolve_project_id()
674
+
675
+ note = (args.note or "").strip()
676
+ if note:
677
+ state.append(
678
+ session_id,
679
+ {
680
+ "ts": int(time.time()),
681
+ "role": "User",
682
+ "content": note,
683
+ "user_id": project_id,
684
+ },
685
+ )
686
+
687
+ status, count = publish.publish_unpublished(
688
+ session_id=session_id,
689
+ project_id=project_id,
690
+ force_extraction=True,
691
+ skip_aggregation=False,
692
+ )
693
+ if status == "ok":
694
+ suffix = " (including your note)" if note else ""
695
+ sys.stdout.write(
696
+ f"Forced extraction on session `{session_id}` over {count} interactions{suffix}.\n"
697
+ )
698
+ return 0
699
+ if status == "nothing":
700
+ sys.stdout.write(f"No unpublished interactions on session `{session_id}`.\n")
701
+ return 0
702
+ sys.stdout.write(_REFLEXIO_UNREACHABLE_MSG)
703
+ return 1
704
+
705
+
706
+ def _run_service(script: Path, subcmd: str) -> int:
707
+ """Invoke a service script (``backend-service.sh`` / ``dashboard-service.sh``).
708
+
709
+ Args:
710
+ script (Path): Absolute path to the service shell script.
711
+ subcmd (str): Subcommand to pass (``start``, ``stop``, ``status``).
712
+
713
+ Returns:
714
+ int: The script's exit code, or 1 if the script is missing.
715
+ """
716
+ if not script.exists():
717
+ sys.stderr.write(f"error: {script} not found\n")
718
+ return 1
719
+ try:
720
+ subprocess.run([str(script), subcmd], check=True)
721
+ return 0
722
+ except subprocess.CalledProcessError as exc:
723
+ return exc.returncode or 1
724
+
725
+
726
+ def _service_status(script: Path, wait_ready_s: float = 3.0) -> str:
727
+ """Return the one-line status string for a service script.
728
+
729
+ Service scripts spawn their targets detached and return immediately, so
730
+ a status probe fired right after ``start`` can race the child's cold
731
+ boot (e.g. Next.js takes ~150ms to bind). Poll briefly until the script
732
+ reports something other than ``not running`` or the deadline expires.
733
+
734
+ Args:
735
+ script (Path): Path to the service script.
736
+ wait_ready_s (float): Max seconds to wait for a ready status before
737
+ returning the last observed value.
738
+
739
+ Returns:
740
+ str: One-line status string (e.g. ``"running on http://..."`` or
741
+ ``"not running"``).
742
+ """
743
+ if not script.exists():
744
+ return "script missing"
745
+ deadline = time.monotonic() + wait_ready_s
746
+ while True:
747
+ result = subprocess.run(
748
+ [str(script), "status"], capture_output=True, text=True, check=False
749
+ )
750
+ status = result.stdout.strip() or "unknown"
751
+ if status != "not running" or time.monotonic() >= deadline:
752
+ return status
753
+ time.sleep(0.2)
754
+
755
+
756
+ class _ClearAllError(RuntimeError):
757
+ """Raised when a destructive clear-all target is unsafe or unsupported."""
758
+
759
+
760
+ @dataclass(frozen=True)
761
+ class _ClearAllTarget:
762
+ """One filesystem target removed by ``clear-all``."""
763
+
764
+ path: Path
765
+ kind: str
766
+ label: str
767
+
768
+
769
+ def _is_running_status(status: str) -> bool:
770
+ return status.startswith("running on ")
771
+
772
+
773
+ def _read_dotenv_value(env_path: Path, key: str) -> str | None:
774
+ """Read one simple KEY=VALUE binding from a dotenv file."""
775
+ if not env_path.is_file():
776
+ return None
777
+ try:
778
+ lines = env_path.read_text().splitlines()
779
+ except OSError:
780
+ return None
781
+ prefix = f"{key}="
782
+ for raw_line in lines:
783
+ line = raw_line.strip()
784
+ if not line or line.startswith("#"):
785
+ continue
786
+ if line.startswith("export "):
787
+ line = line[7:].lstrip()
788
+ if not line.startswith(prefix):
789
+ continue
790
+ value = line[len(prefix) :].strip()
791
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
792
+ value = value[1:-1]
793
+ return value.strip()
794
+ return None
795
+
796
+
797
+ def _resolve_absolute_path(raw_path: str | Path, *, source: str) -> Path:
798
+ path = Path(raw_path).expanduser()
799
+ if not path.is_absolute():
800
+ raise _ClearAllError(f"{source} must be an absolute path: {raw_path}")
801
+ return path
802
+
803
+
804
+ def _effective_storage_root() -> Path:
805
+ raw = os.environ.get(_LOCAL_STORAGE_ENV, "").strip()
806
+ if not raw:
807
+ raw = _read_dotenv_value(_REFLEXIO_ENV_PATH, _LOCAL_STORAGE_ENV) or ""
808
+ return _resolve_absolute_path(
809
+ raw or _DEFAULT_STORAGE_ROOT, source=_LOCAL_STORAGE_ENV
810
+ )
811
+
812
+
813
+ def _load_reflexio_config() -> dict[str, object] | None:
814
+ if not _REFLEXIO_CONFIG_PATH.is_file():
815
+ return None
816
+ try:
817
+ loaded = json.loads(_REFLEXIO_CONFIG_PATH.read_text())
818
+ except (OSError, json.JSONDecodeError) as exc:
819
+ raise _ClearAllError(
820
+ f"could not read reflexio config {_REFLEXIO_CONFIG_PATH}: {exc}"
821
+ ) from exc
822
+ if not isinstance(loaded, dict):
823
+ raise _ClearAllError(
824
+ f"reflexio config {_REFLEXIO_CONFIG_PATH} is not a JSON object"
825
+ )
826
+ return loaded
827
+
828
+
829
+ def _storage_config_kind(storage_config: dict[str, object]) -> str:
830
+ if storage_config.get("managed_by") == "platform":
831
+ return "remote"
832
+ explicit_type = str(
833
+ storage_config.get("type") or storage_config.get("storage_type") or ""
834
+ ).lower()
835
+ if explicit_type in {"supabase", "postgres"}:
836
+ return "remote"
837
+ if explicit_type == "disk":
838
+ return "disk"
839
+ if explicit_type == "sqlite":
840
+ return "sqlite"
841
+ if (
842
+ "url" in storage_config
843
+ and "key" in storage_config
844
+ and "db_url" in storage_config
845
+ ):
846
+ return "remote"
847
+ if "db_url" in storage_config:
848
+ return "remote"
849
+ if "dir_path" in storage_config:
850
+ return "disk"
851
+ if "db_path" in storage_config or not storage_config:
852
+ return "sqlite"
853
+ raise _ClearAllError(
854
+ "unsupported reflexio storage_config shape; refusing to delete local data"
855
+ )
856
+
857
+
858
+ def _dangerous_clear_all_paths() -> set[Path]:
859
+ home = Path.home().resolve(strict=False)
860
+ reflexio_dir = _resolve_absolute_path(_REFLEXIO_DIR, source="reflexio dir")
861
+ return {
862
+ Path("/").resolve(strict=False),
863
+ home,
864
+ reflexio_dir.resolve(strict=False),
865
+ _resolve_absolute_path(
866
+ reflexio_dir / "configs", source="reflexio configs"
867
+ ).resolve(strict=False),
868
+ _PLUGIN_ROOT.resolve(strict=False),
869
+ _PLUGIN_ROOT.parent.resolve(strict=False),
870
+ }
871
+
872
+
873
+ def _validate_deletion_target(path: Path) -> None:
874
+ if path.exists() and path.is_symlink():
875
+ raise _ClearAllError(f"refusing to delete symlink target: {path}")
876
+ resolved = path.resolve(strict=False)
877
+ if resolved in _dangerous_clear_all_paths():
878
+ raise _ClearAllError(f"refusing to delete dangerous path: {resolved}")
879
+
880
+
881
+ def _disk_org_targets(base_dir: Path) -> list[_ClearAllTarget]:
882
+ if base_dir.exists() and base_dir.is_symlink():
883
+ raise _ClearAllError(
884
+ f"refusing to inspect symlink disk storage dir: {base_dir}"
885
+ )
886
+ if not base_dir.exists():
887
+ return []
888
+ if not base_dir.is_dir():
889
+ raise _ClearAllError(
890
+ f"configured disk storage path is not a directory: {base_dir}"
891
+ )
892
+ targets: list[_ClearAllTarget] = []
893
+ for child in sorted(base_dir.glob("disk_*")):
894
+ targets.append(_ClearAllTarget(child, "dir", "disk org data"))
895
+ return targets
896
+
897
+
898
+ def _resolve_clear_all_targets() -> list[_ClearAllTarget]:
899
+ targets = [
900
+ _ClearAllTarget(_effective_storage_root(), "dir", "managed local storage root")
901
+ ]
902
+
903
+ config = _load_reflexio_config()
904
+ storage_config = config.get("storage_config") if config else None
905
+ if storage_config is not None:
906
+ if not isinstance(storage_config, dict):
907
+ raise _ClearAllError("reflexio storage_config is not a JSON object")
908
+ kind = _storage_config_kind(storage_config)
909
+ if kind == "remote":
910
+ raise _ClearAllError(
911
+ "clear-all only resets local Reflexio storage; "
912
+ "remote Supabase/Postgres storage is not supported"
913
+ )
914
+ if kind == "sqlite":
915
+ raw_db_path = storage_config.get("db_path")
916
+ if isinstance(raw_db_path, str) and raw_db_path.strip():
917
+ db_path = _resolve_absolute_path(
918
+ raw_db_path.strip(), source="configured SQLite db_path"
919
+ )
920
+ for suffix in ("", "-wal", "-shm", "-journal"):
921
+ targets.append(
922
+ _ClearAllTarget(
923
+ Path(f"{db_path}{suffix}"),
924
+ "file",
925
+ "configured SQLite data",
926
+ )
927
+ )
928
+ elif kind == "disk":
929
+ raw_dir_path = storage_config.get("dir_path")
930
+ if not isinstance(raw_dir_path, str) or not raw_dir_path.strip():
931
+ raise _ClearAllError("configured disk storage is missing dir_path")
932
+ disk_base = _resolve_absolute_path(
933
+ raw_dir_path.strip(), source="configured disk dir_path"
934
+ )
935
+ targets.extend(_disk_org_targets(disk_base))
936
+
937
+ deduped: list[_ClearAllTarget] = []
938
+ seen: set[Path] = set()
939
+ for target in targets:
940
+ _validate_deletion_target(target.path)
941
+ resolved = target.path.resolve(strict=False)
942
+ if resolved in seen:
943
+ continue
944
+ deduped.append(_ClearAllTarget(resolved, target.kind, target.label))
945
+ seen.add(resolved)
946
+ return deduped
947
+
948
+
949
+ def _remove_clear_all_target(target: _ClearAllTarget) -> bool:
950
+ """Remove a target. Returns True when something was actually removed."""
951
+ path = target.path
952
+ if not path.exists():
953
+ return False
954
+ if target.kind == "dir":
955
+ if not path.is_dir():
956
+ raise _ClearAllError(
957
+ f"expected directory target but found non-directory: {path}"
958
+ )
959
+ shutil.rmtree(path)
960
+ return True
961
+ if target.kind == "file":
962
+ if not path.is_file():
963
+ raise _ClearAllError(f"expected file target but found non-file: {path}")
964
+ path.unlink()
965
+ return True
966
+ raise _ClearAllError(f"unknown clear-all target kind: {target.kind}")
967
+
968
+
969
+ def cmd_restart(args: argparse.Namespace) -> int:
970
+ """Restart the reflexio backend and claude-smart dashboard services.
971
+
972
+ Stops both long-lived services, optionally rebuilds the dashboard's
973
+ Next.js bundle so source edits under ``plugin/dashboard/`` take effect,
974
+ then starts them again. Useful during local development when iterating
975
+ on the ``reflexio`` submodule or the dashboard.
976
+
977
+ Args:
978
+ args (argparse.Namespace): Parsed CLI args. Honors ``args.skip_backend``,
979
+ ``args.skip_dashboard``, and ``args.no_rebuild``.
980
+
981
+ Returns:
982
+ int: 0 on success, non-zero if the dashboard rebuild fails or
983
+ either service's ``start`` subcommand exits non-zero.
984
+ """
985
+ do_backend = not args.skip_backend
986
+ do_dashboard = not args.skip_dashboard
987
+
988
+ if not (do_backend or do_dashboard):
989
+ sys.stdout.write("Nothing to restart (both services skipped).\n")
990
+ return 0
991
+
992
+ if do_backend:
993
+ sys.stdout.write("Stopping reflexio backend…\n")
994
+ _run_service(_BACKEND_SCRIPT, "stop")
995
+ if do_dashboard:
996
+ sys.stdout.write("Stopping dashboard…\n")
997
+ _run_service(_DASHBOARD_SCRIPT, "stop")
998
+
999
+ if do_dashboard and not args.no_rebuild:
1000
+ if not _DASHBOARD_DIR.is_dir():
1001
+ sys.stderr.write(
1002
+ f"warning: dashboard dir {_DASHBOARD_DIR} missing; skipping rebuild\n"
1003
+ )
1004
+ elif not shutil.which("npm"):
1005
+ sys.stderr.write("warning: npm not on PATH; serving previous build\n")
1006
+ else:
1007
+ next_bin = _DASHBOARD_DIR / "node_modules" / ".bin" / "next"
1008
+ if not next_bin.exists():
1009
+ install_cmd = (
1010
+ ["npm", "ci", "--no-audit", "--no-fund"]
1011
+ if (_DASHBOARD_DIR / "package-lock.json").exists()
1012
+ else ["npm", "install", "--no-audit", "--no-fund"]
1013
+ )
1014
+ install_label = " ".join(install_cmd)
1015
+ sys.stdout.write(
1016
+ "Installing dashboard dependencies "
1017
+ f"({install_label}, may take a minute)…\n"
1018
+ )
1019
+ try:
1020
+ subprocess.run(
1021
+ install_cmd,
1022
+ cwd=_DASHBOARD_DIR,
1023
+ check=True,
1024
+ )
1025
+ except subprocess.CalledProcessError as exc:
1026
+ sys.stderr.write(
1027
+ f"error: {install_label} failed (exit {exc.returncode}); "
1028
+ "not starting dashboard.\n"
1029
+ )
1030
+ if do_backend:
1031
+ sys.stdout.write("Starting reflexio backend…\n")
1032
+ _run_service(_BACKEND_SCRIPT, "start")
1033
+ sys.stdout.write(
1034
+ f"reflexio backend: {_service_status(_BACKEND_SCRIPT)}\n"
1035
+ )
1036
+ return exc.returncode or 1
1037
+ sys.stdout.write(
1038
+ "Rebuilding dashboard (npm run build, may take a minute)…\n"
1039
+ )
1040
+ try:
1041
+ subprocess.run(["npm", "run", "build"], cwd=_DASHBOARD_DIR, check=True)
1042
+ except subprocess.CalledProcessError as exc:
1043
+ sys.stderr.write(
1044
+ f"error: dashboard build failed (exit {exc.returncode}); "
1045
+ "not starting dashboard.\n"
1046
+ )
1047
+ if do_backend:
1048
+ sys.stdout.write("Starting reflexio backend…\n")
1049
+ _run_service(_BACKEND_SCRIPT, "start")
1050
+ sys.stdout.write(
1051
+ f"reflexio backend: {_service_status(_BACKEND_SCRIPT)}\n"
1052
+ )
1053
+ return exc.returncode or 1
1054
+
1055
+ start_rc = 0
1056
+ if do_backend:
1057
+ sys.stdout.write("Starting reflexio backend…\n")
1058
+ rc = _run_service(_BACKEND_SCRIPT, "start")
1059
+ if rc != 0:
1060
+ sys.stderr.write(f"error: reflexio backend failed to start (exit {rc})\n")
1061
+ start_rc = rc
1062
+ if do_dashboard:
1063
+ sys.stdout.write("Starting dashboard…\n")
1064
+ rc = _run_service(_DASHBOARD_SCRIPT, "start")
1065
+ if rc != 0:
1066
+ sys.stderr.write(f"error: dashboard failed to start (exit {rc})\n")
1067
+ start_rc = start_rc or rc
1068
+
1069
+ sys.stdout.write("\n")
1070
+ if do_backend:
1071
+ sys.stdout.write(f"reflexio backend: {_service_status(_BACKEND_SCRIPT)}\n")
1072
+ if do_dashboard:
1073
+ sys.stdout.write(f"dashboard: {_service_status(_DASHBOARD_SCRIPT)}\n")
1074
+ return start_rc
1075
+
1076
+
1077
+ def cmd_clear_all(args: argparse.Namespace) -> int:
1078
+ """Delete all local reflexio data and claude-smart session buffers.
1079
+
1080
+ Stops the managed backend first when it is running, wipes local Reflexio
1081
+ data targets, removes local session JSONL buffers under ``state_dir()``,
1082
+ then restarts the backend if it was running before. Requires ``--yes``.
1083
+
1084
+ Args:
1085
+ args (argparse.Namespace): Parsed CLI args. Honors ``args.yes``
1086
+ (skip the confirmation prompt).
1087
+
1088
+ Returns:
1089
+ int: 0 on success, 1 if reset is unsafe, unsupported, or fails.
1090
+ """
1091
+ try:
1092
+ targets = _resolve_clear_all_targets()
1093
+ except _ClearAllError as exc:
1094
+ sys.stderr.write(f"error: {exc}\n")
1095
+ return 1
1096
+
1097
+ if not args.yes:
1098
+ target_lines = "\n".join(
1099
+ f" - {target.path} ({target.label})" for target in targets
1100
+ )
1101
+ sys.stdout.write(
1102
+ "This will permanently delete ALL local reflexio data at:\n"
1103
+ f"{target_lines}\n"
1104
+ f"and local session buffers under {state.state_dir()}.\n"
1105
+ "If the reflexio backend is running, it will be stopped first "
1106
+ "and restarted afterward.\n"
1107
+ "Re-run with --yes to confirm.\n"
1108
+ )
1109
+ return 1
1110
+
1111
+ was_running = _is_running_status(_service_status(_BACKEND_SCRIPT, wait_ready_s=0.0))
1112
+ if was_running:
1113
+ sys.stdout.write("Stopping reflexio backend…\n")
1114
+ stop_rc = _run_service(_BACKEND_SCRIPT, "stop")
1115
+ if stop_rc != 0:
1116
+ sys.stderr.write(
1117
+ f"error: reflexio backend failed to stop (exit {stop_rc})\n"
1118
+ )
1119
+ return stop_rc or 1
1120
+ stopped_status = _service_status(_BACKEND_SCRIPT, wait_ready_s=0.0)
1121
+ if _is_running_status(stopped_status):
1122
+ sys.stderr.write(
1123
+ "error: reflexio backend is still running after stop; "
1124
+ "aborting without deleting data\n"
1125
+ )
1126
+ return 1
1127
+
1128
+ removed_targets = 0
1129
+ try:
1130
+ for target in targets:
1131
+ if _remove_clear_all_target(target):
1132
+ removed_targets += 1
1133
+ except (OSError, _ClearAllError) as exc:
1134
+ sys.stderr.write(f"error: could not remove reflexio data: {exc}\n")
1135
+ return 1
1136
+
1137
+ removed_buffers = 0
1138
+ root = state.state_dir()
1139
+ if root.is_dir():
1140
+ for buf in root.glob("*.jsonl"):
1141
+ try:
1142
+ buf.unlink()
1143
+ removed_buffers += 1
1144
+ except OSError as exc:
1145
+ sys.stderr.write(f"warning: could not remove {buf}: {exc}\n")
1146
+
1147
+ start_rc = 0
1148
+ backend_status = "not running"
1149
+ if was_running:
1150
+ sys.stdout.write("Starting reflexio backend…\n")
1151
+ start_rc = _run_service(_BACKEND_SCRIPT, "start")
1152
+ backend_status = _service_status(_BACKEND_SCRIPT)
1153
+ if start_rc != 0:
1154
+ sys.stderr.write(
1155
+ f"error: reflexio backend failed to start (exit {start_rc})\n"
1156
+ )
1157
+ elif not _is_running_status(backend_status):
1158
+ sys.stderr.write(
1159
+ f"error: reflexio backend did not report running: {backend_status}\n"
1160
+ )
1161
+ start_rc = 1
1162
+
1163
+ target_summary = (
1164
+ f"removed {removed_targets} data target(s)"
1165
+ if removed_targets
1166
+ else "nothing to wipe"
1167
+ )
1168
+ sys.stdout.write(
1169
+ f"Cleared reflexio: {target_summary}. "
1170
+ f"Removed {removed_buffers} local session buffer(s).\n"
1171
+ )
1172
+ if was_running:
1173
+ sys.stdout.write(f"reflexio backend: {backend_status}\n")
1174
+ else:
1175
+ sys.stdout.write("reflexio backend was not running; left stopped.\n")
1176
+ return start_rc or 0
1177
+
1178
+
1179
+ def _build_parser() -> argparse.ArgumentParser:
1180
+ parser = argparse.ArgumentParser(prog="claude-smart")
1181
+ sub = parser.add_subparsers(dest="command", required=True)
1182
+
1183
+ inst = sub.add_parser("install", help="Install claude-smart")
1184
+ inst.add_argument(
1185
+ "--host",
1186
+ choices=("claude-code", "codex"),
1187
+ default="claude-code",
1188
+ help="Install target host",
1189
+ )
1190
+ inst.add_argument(
1191
+ "--source",
1192
+ default=_DEFAULT_MARKETPLACE_SOURCE,
1193
+ help="Marketplace ref — GitHub owner/repo, or a local directory path",
1194
+ )
1195
+ inst.set_defaults(func=cmd_install)
1196
+
1197
+ upd = sub.add_parser("update", help="Update claude-smart to the latest version")
1198
+ upd.set_defaults(func=cmd_update)
1199
+
1200
+ uni = sub.add_parser("uninstall", help="Remove claude-smart")
1201
+ uni.add_argument(
1202
+ "--host",
1203
+ choices=("claude-code", "codex"),
1204
+ default="claude-code",
1205
+ help="Uninstall target host",
1206
+ )
1207
+ uni.set_defaults(func=cmd_uninstall)
1208
+
1209
+ sh = sub.add_parser(
1210
+ "show",
1211
+ help="Show current project-specific skills and project preferences",
1212
+ )
1213
+ sh.add_argument("--project", help="Override project id")
1214
+ sh.set_defaults(func=cmd_show)
1215
+
1216
+ ln = sub.add_parser(
1217
+ "learn",
1218
+ help="Force reflexio extraction over the active session now",
1219
+ )
1220
+ ln.add_argument("--session", help="Session id (defaults to latest)")
1221
+ ln.add_argument("--project", help="Override project id")
1222
+ ln.add_argument(
1223
+ "--note",
1224
+ default=None,
1225
+ help=(
1226
+ "Free-form note to publish as a User turn before extraction "
1227
+ "(e.g. an insight, preference, or workflow rule)"
1228
+ ),
1229
+ )
1230
+ ln.set_defaults(func=cmd_learn)
1231
+
1232
+ ca = sub.add_parser(
1233
+ "clear-all",
1234
+ help="Delete all local reflexio data and restart the backend",
1235
+ )
1236
+ ca.add_argument(
1237
+ "--yes",
1238
+ action="store_true",
1239
+ help="Confirm the destructive clear without prompting",
1240
+ )
1241
+ ca.set_defaults(func=cmd_clear_all)
1242
+
1243
+ rs = sub.add_parser(
1244
+ "restart",
1245
+ help="Restart the reflexio backend and dashboard to pick up changes",
1246
+ )
1247
+ rs.add_argument(
1248
+ "--skip-backend",
1249
+ action="store_true",
1250
+ help="Do not stop/start the reflexio backend",
1251
+ )
1252
+ rs.add_argument(
1253
+ "--skip-dashboard",
1254
+ action="store_true",
1255
+ help="Do not stop/start the dashboard",
1256
+ )
1257
+ rs.add_argument(
1258
+ "--no-rebuild",
1259
+ action="store_true",
1260
+ help="Skip the `npm run build` step before restarting the dashboard",
1261
+ )
1262
+ rs.set_defaults(func=cmd_restart)
1263
+ return parser
1264
+
1265
+
1266
+ def main(argv: list[str] | None = None) -> int:
1267
+ parser = _build_parser()
1268
+ args = parser.parse_args(argv)
1269
+ return args.func(args)
1270
+
1271
+
1272
+ if __name__ == "__main__":
1273
+ raise SystemExit(main())