claude-smart 0.2.22 → 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.
- package/.agents/plugins/marketplace.json +20 -0
- package/README.md +69 -27
- package/bin/claude-smart.js +296 -11
- package/package.json +11 -1
- package/plugin/.claude-plugin/plugin.json +17 -0
- package/plugin/.codex-plugin/plugin.json +35 -0
- package/plugin/LICENSE +202 -0
- package/plugin/README.md +37 -0
- package/plugin/bin/cs-cite +77 -0
- package/plugin/commands/clear-all.md +8 -0
- package/plugin/commands/dashboard.md +8 -0
- package/plugin/commands/learn.md +12 -0
- package/plugin/commands/restart.md +8 -0
- package/plugin/commands/show.md +8 -0
- package/plugin/dashboard/AGENTS.md +6 -0
- package/plugin/dashboard/app/api/claude-settings/route.ts +19 -0
- package/plugin/dashboard/app/api/config/route.ts +16 -0
- package/plugin/dashboard/app/api/health/route.ts +10 -0
- package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +63 -0
- package/plugin/dashboard/app/api/sessions/[id]/route.ts +28 -0
- package/plugin/dashboard/app/api/sessions/route.ts +14 -0
- package/plugin/dashboard/app/configure/env/page.tsx +318 -0
- package/plugin/dashboard/app/configure/layout.tsx +47 -0
- package/plugin/dashboard/app/configure/page.tsx +5 -0
- package/plugin/dashboard/app/configure/server/page.tsx +258 -0
- package/plugin/dashboard/app/dashboard/page.tsx +227 -0
- package/plugin/dashboard/app/globals.css +129 -0
- package/plugin/dashboard/app/icon.png +0 -0
- package/plugin/dashboard/app/layout.tsx +40 -0
- package/plugin/dashboard/app/page.tsx +5 -0
- package/plugin/dashboard/app/preferences/[id]/page.tsx +531 -0
- package/plugin/dashboard/app/preferences/page.tsx +126 -0
- package/plugin/dashboard/app/providers.tsx +12 -0
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +321 -0
- package/plugin/dashboard/app/sessions/page.tsx +186 -0
- package/plugin/dashboard/app/skills/page.tsx +362 -0
- package/plugin/dashboard/app/skills/project/[id]/page.tsx +597 -0
- package/plugin/dashboard/app/skills/shared/[id]/page.tsx +830 -0
- package/plugin/dashboard/components/common/delete-all-button.tsx +45 -0
- package/plugin/dashboard/components/common/empty-state.tsx +34 -0
- package/plugin/dashboard/components/common/learnings-badge.tsx +34 -0
- package/plugin/dashboard/components/common/page-header.tsx +34 -0
- package/plugin/dashboard/components/common/page-tabs.tsx +115 -0
- package/plugin/dashboard/components/common/stat-card.tsx +38 -0
- package/plugin/dashboard/components/layout/nav-items.ts +22 -0
- package/plugin/dashboard/components/layout/sidebar.tsx +45 -0
- package/plugin/dashboard/components/layout/top-bar.tsx +64 -0
- package/plugin/dashboard/components/stall-banner.tsx +53 -0
- package/plugin/dashboard/components/ui/badge.tsx +52 -0
- package/plugin/dashboard/components/ui/button.tsx +60 -0
- package/plugin/dashboard/components/ui/collapsible.tsx +21 -0
- package/plugin/dashboard/components/ui/input.tsx +20 -0
- package/plugin/dashboard/components/ui/label.tsx +20 -0
- package/plugin/dashboard/components/ui/scroll-area.tsx +55 -0
- package/plugin/dashboard/components/ui/select.tsx +201 -0
- package/plugin/dashboard/components/ui/separator.tsx +25 -0
- package/plugin/dashboard/components/ui/sheet.tsx +135 -0
- package/plugin/dashboard/components/ui/switch.tsx +32 -0
- package/plugin/dashboard/components.json +25 -0
- package/plugin/dashboard/eslint.config.mjs +16 -0
- package/plugin/dashboard/hooks/use-settings.tsx +88 -0
- package/plugin/dashboard/hooks/use-stall-state.ts +59 -0
- package/plugin/dashboard/lib/claude-settings-file.ts +114 -0
- package/plugin/dashboard/lib/config-file.ts +131 -0
- package/plugin/dashboard/lib/format.ts +58 -0
- package/plugin/dashboard/lib/reflexio-client.ts +238 -0
- package/plugin/dashboard/lib/reflexio-url.ts +17 -0
- package/plugin/dashboard/lib/session-reader.ts +245 -0
- package/plugin/dashboard/lib/status.ts +24 -0
- package/plugin/dashboard/lib/types.ts +145 -0
- package/plugin/dashboard/lib/utils.ts +6 -0
- package/plugin/dashboard/next.config.ts +7 -0
- package/plugin/dashboard/package-lock.json +10275 -0
- package/plugin/dashboard/package.json +37 -0
- package/plugin/dashboard/postcss.config.mjs +7 -0
- package/plugin/dashboard/public/claude-smart-icon.png +0 -0
- package/plugin/dashboard/tsconfig.json +34 -0
- package/plugin/hooks/codex-hooks.json +67 -0
- package/plugin/hooks/hooks.json +111 -0
- package/plugin/pyproject.toml +49 -0
- package/plugin/scripts/_codex_env.sh +27 -0
- package/plugin/scripts/_lib.sh +325 -0
- package/plugin/scripts/backend-service.sh +208 -0
- package/plugin/scripts/cli.sh +40 -0
- package/plugin/scripts/dashboard-build.sh +139 -0
- package/plugin/scripts/dashboard-open.sh +107 -0
- package/plugin/scripts/dashboard-service.sh +195 -0
- package/plugin/scripts/ensure-plugin-root.sh +84 -0
- package/plugin/scripts/hook_entry.sh +70 -0
- package/plugin/scripts/smart-install.sh +411 -0
- package/plugin/src/claude_smart/__init__.py +3 -0
- package/plugin/src/claude_smart/cli.py +1273 -0
- package/plugin/src/claude_smart/context_format.py +277 -0
- package/plugin/src/claude_smart/context_inject.py +92 -0
- package/plugin/src/claude_smart/cs_cite.py +236 -0
- package/plugin/src/claude_smart/events/__init__.py +1 -0
- package/plugin/src/claude_smart/events/post_tool.py +148 -0
- package/plugin/src/claude_smart/events/pre_tool.py +52 -0
- package/plugin/src/claude_smart/events/session_end.py +20 -0
- package/plugin/src/claude_smart/events/session_start.py +119 -0
- package/plugin/src/claude_smart/events/stop.py +393 -0
- package/plugin/src/claude_smart/events/user_prompt.py +73 -0
- package/plugin/src/claude_smart/hook.py +114 -0
- package/plugin/src/claude_smart/ids.py +56 -0
- package/plugin/src/claude_smart/internal_call.py +89 -0
- package/plugin/src/claude_smart/optimizer_assistant.py +203 -0
- package/plugin/src/claude_smart/publish.py +71 -0
- package/plugin/src/claude_smart/query_compose.py +51 -0
- package/plugin/src/claude_smart/reflexio_adapter.py +403 -0
- package/plugin/src/claude_smart/runtime.py +52 -0
- package/plugin/src/claude_smart/stall_banner.py +61 -0
- package/plugin/src/claude_smart/state.py +276 -0
- 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())
|