claude-smart 0.2.28 → 0.2.30

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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-smart"
3
- version = "0.2.28"
3
+ version = "0.2.30"
4
4
  description = "Self-improving Claude Code plugin — learns from corrections via reflexio"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -233,6 +233,44 @@ claude_smart_npm_available() {
233
233
  "$npm_bin" --version >/dev/null 2>&1
234
234
  }
235
235
 
236
+ claude_smart_log_max_bytes() {
237
+ printf '%s\n' "10000000"
238
+ }
239
+
240
+ claude_smart_trim_log_file() {
241
+ local file max_bytes size tmp
242
+ file="$1"
243
+ max_bytes="${2:-$(claude_smart_log_max_bytes)}"
244
+ case "$max_bytes" in
245
+ ''|*[!0-9]*) return 0 ;;
246
+ esac
247
+ [ -f "$file" ] || return 0
248
+ size=$(wc -c < "$file" 2>/dev/null | tr -d '[:space:]') || return 0
249
+ case "$size" in
250
+ ''|*[!0-9]*) return 0 ;;
251
+ esac
252
+ [ "$size" -le "$max_bytes" ] && return 0
253
+
254
+ tmp="${file}.trim.$$"
255
+ if tail -c "$max_bytes" "$file" > "$tmp" 2>/dev/null; then
256
+ # Rewrite the existing path instead of replacing it, so a process with
257
+ # this file already open in append mode keeps writing to the capped file.
258
+ cat "$tmp" > "$file"
259
+ fi
260
+ rm -f "$tmp"
261
+ }
262
+
263
+ claude_smart_append_capped_log() {
264
+ local file max_bytes
265
+ file="$1"
266
+ max_bytes="${2:-$(claude_smart_log_max_bytes)}"
267
+ shift 2
268
+ mkdir -p "$(dirname "$file")"
269
+ claude_smart_trim_log_file "$file" "$max_bytes"
270
+ printf '%s\n' "$*" >> "$file"
271
+ claude_smart_trim_log_file "$file" "$max_bytes"
272
+ }
273
+
236
274
  # Spawn a command fully detached from the current shell so a hook timeout
237
275
  # (Claude Code's install/SessionStart budget) cannot kill it mid-flight.
238
276
  # POSIX: setsid → python3 os.setsid → nohup (in that order of strength).
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bash
2
+ # Run the backend command and stream its stdout/stderr into a capped log file.
3
+ set -eu
4
+
5
+ HERE="$(cd "$(dirname "$0")" && pwd)"
6
+ # shellcheck source=_lib.sh
7
+ . "$HERE/_lib.sh"
8
+
9
+ if [ "$#" -lt 4 ] || [ "$3" != "--" ]; then
10
+ exit 2
11
+ fi
12
+
13
+ LOG_FILE="$1"
14
+ MAX_BYTES="$2"
15
+ shift 3
16
+
17
+ mkdir -p "$(dirname "$LOG_FILE")"
18
+ claude_smart_trim_log_file "$LOG_FILE" "$MAX_BYTES"
19
+
20
+ STATUS_FILE="${TMPDIR:-/tmp}/claude-smart-backend-log-runner.$$.status"
21
+ rm -f "$STATUS_FILE"
22
+
23
+ (
24
+ set +e
25
+ "$@" 2>&1
26
+ printf '%s\n' "$?" > "$STATUS_FILE"
27
+ ) | while IFS= read -r line || [ -n "$line" ]; do
28
+ claude_smart_append_capped_log "$LOG_FILE" "$MAX_BYTES" "$line"
29
+ done
30
+
31
+ status=$(cat "$STATUS_FILE" 2>/dev/null || printf '1')
32
+ rm -f "$STATUS_FILE"
33
+ exit "$status"
@@ -41,9 +41,11 @@ PLUGIN_ROOT="$(cd "$HERE/.." && pwd)"
41
41
 
42
42
  if [ -z "${CLAUDE_SMART_CLI_PATH:-}" ]; then
43
43
  if [ "${CLAUDE_SMART_HOST:-claude-code}" = "codex" ]; then
44
- # Prefer the compatibility executable for older provider entrypoints; the
45
- # provider can also resolve `codex` directly when this override is absent.
46
- export CLAUDE_SMART_CLI_PATH="$PLUGIN_ROOT/scripts/codex-claude-compat.py"
44
+ # Reflexio's provider still calls CLAUDE_SMART_CLI_PATH with Claude CLI
45
+ # flags. Use a small compatibility executable that translates that narrow
46
+ # contract to `codex exec`.
47
+ claude_smart_prepend_node_bins
48
+ export CLAUDE_SMART_CLI_PATH="$PLUGIN_ROOT/scripts/codex-claude-compat"
47
49
  elif _cs_cli_path=$(command -v claude 2>/dev/null) && [ -n "$_cs_cli_path" ]; then
48
50
  export CLAUDE_SMART_CLI_PATH="$_cs_cli_path"
49
51
  elif [ -x "$HOME/.local/bin/claude" ]; then
@@ -55,7 +57,9 @@ fi
55
57
  STATE_DIR="$HOME/.claude-smart"
56
58
  PID_FILE="$STATE_DIR/backend.pid"
57
59
  LOG_FILE="$STATE_DIR/backend.log"
60
+ LOG_MAX_BYTES="$(claude_smart_log_max_bytes)"
58
61
  mkdir -p "$STATE_DIR"
62
+ claude_smart_trim_log_file "$LOG_FILE" "$LOG_MAX_BYTES"
59
63
 
60
64
  emit_ok() { echo '{"continue":true,"suppressOutput":true}'; }
61
65
 
@@ -178,12 +182,20 @@ case "$CMD" in
178
182
  if port_occupied; then
179
183
  # Something answered the TCP probe but /health didn't — don't
180
184
  # start a second uvicorn on top of it.
181
- echo "[claude-smart] backend: port $PORT held by another process; skipping" >>"$LOG_FILE"
185
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "[claude-smart] backend: port $PORT held by another process; skipping"
182
186
  emit_ok; exit 0
183
187
  fi
184
188
  if ! command -v uv >/dev/null 2>&1; then
185
- echo "[claude-smart] backend: uv not on PATH; skipping" >>"$LOG_FILE"
186
- emit_ok; exit 0
189
+ if [ "${CLAUDE_SMART_BOOTSTRAPPING:-}" != "1" ] && [ -x "$PLUGIN_ROOT/scripts/smart-install.sh" ]; then
190
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "[claude-smart] backend: uv not on PATH; running installer"
191
+ CLAUDE_SMART_BOOTSTRAPPING=1 bash "$PLUGIN_ROOT/scripts/smart-install.sh" >>"$STATE_DIR/install.log" 2>&1 || true
192
+ claude_smart_source_login_path
193
+ claude_smart_prepend_astral_bins
194
+ fi
195
+ if ! command -v uv >/dev/null 2>&1; then
196
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "[claude-smart] backend: uv not on PATH after installer; skipping"
197
+ emit_ok; exit 0
198
+ fi
187
199
  fi
188
200
  cd "$PLUGIN_ROOT"
189
201
 
@@ -198,12 +210,12 @@ case "$CMD" in
198
210
  # bookkeeping harder and we don't need hot-reload for a user-facing
199
211
  # service. Detach via claude_smart_spawn_detached so the same code
200
212
  # path covers Linux (setsid), macOS (python3 os.setsid), and Windows
201
- # (nohup; no process groups). Caller-side stdout/stderr redirection
202
- # works across all three primitives Git Bash routes the > and 2>&1
203
- # through to the underlying CRT before nohup execs the child.
204
- claude_smart_spawn_detached uv run --project "$PLUGIN_ROOT" --quiet \
205
- reflexio services start --only backend --no-reload \
206
- >>"$LOG_FILE" 2>&1
213
+ # (nohup; no process groups). backend-log-runner.sh owns stdout/stderr
214
+ # capture so process output cannot grow backend.log past its cap.
215
+ claude_smart_spawn_detached bash "$HERE/backend-log-runner.sh" \
216
+ "$LOG_FILE" "$LOG_MAX_BYTES" -- \
217
+ uv run --project "$PLUGIN_ROOT" --quiet \
218
+ reflexio services start --only backend --no-reload
207
219
  svc_pid=$!
208
220
  # Record the spawned pid, not a pgid sampled with ps. On POSIX,
209
221
  # setsid/python os.setsid make this pid the new process group leader;
@@ -32,9 +32,33 @@ if [ -f "$FAILURE_MARKER" ]; then
32
32
  fi
33
33
 
34
34
  if ! command -v uv >/dev/null 2>&1; then
35
- echo "claude-smart: 'uv' not found on PATH." >&2
36
- echo "Install it from https://docs.astral.sh/uv/ or restart Claude Code so the Setup hook can install it." >&2
37
- exit 1
35
+ # Self-heal: the Setup/SessionStart hook may have been skipped (trust
36
+ # prompt declined, plugin enabled mid-session, etc.) leaving the install
37
+ # half-done. Run smart-install.sh inline so the user does not have to
38
+ # restart Claude Code just to recover.
39
+ # Guard against recursion: if smart-install.sh ever shells back through
40
+ # this wrapper (e.g. a future migration step) we must not loop forever.
41
+ if [ "${CLAUDE_SMART_BOOTSTRAPPING:-}" = "1" ]; then
42
+ echo "claude-smart: bootstrap recursion detected; aborting." >&2
43
+ exit 1
44
+ fi
45
+ if [ -x "$PLUGIN_ROOT/scripts/smart-install.sh" ]; then
46
+ echo "claude-smart: 'uv' not found — bootstrapping dependencies (~1-3 min on first install)..." >&2
47
+ CLAUDE_SMART_BOOTSTRAPPING=1 bash "$PLUGIN_ROOT/scripts/smart-install.sh" >&2
48
+ claude_smart_prepend_astral_bins
49
+ claude_smart_prepend_node_bins
50
+ fi
51
+ if ! command -v uv >/dev/null 2>&1; then
52
+ if [ -f "$FAILURE_MARKER" ]; then
53
+ msg="$(cat "$FAILURE_MARKER" 2>/dev/null || echo "unknown error")"
54
+ echo "claude-smart: install failed: $msg" >&2
55
+ echo "Fix the underlying issue and delete $FAILURE_MARKER to retry." >&2
56
+ else
57
+ echo "claude-smart: 'uv' not found on PATH after bootstrap attempt." >&2
58
+ echo "Install it from https://docs.astral.sh/uv/ or rerun $PLUGIN_ROOT/scripts/smart-install.sh manually." >&2
59
+ fi
60
+ exit 1
61
+ fi
38
62
  fi
39
63
 
40
64
  exec uv run --project "$PLUGIN_ROOT" --quiet python -m claude_smart.cli "$@"
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env sh
2
+ set -eu
3
+
4
+ case "$0" in
5
+ */*) SCRIPT_DIR=${0%/*} ;;
6
+ *) SCRIPT_DIR=. ;;
7
+ esac
8
+ SCRIPT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR" && pwd)
9
+ exec node "$SCRIPT_DIR/codex-claude-compat.js" "$@"
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ setlocal
3
+ set "SCRIPT_DIR=%~dp0"
4
+ node "%SCRIPT_DIR%codex-claude-compat.js" %*
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /*
5
+ * Translate Reflexio's Claude CLI provider contract to `codex exec`.
6
+ *
7
+ * Reflexio shells out to CLAUDE_SMART_CLI_PATH as if it were Claude Code:
8
+ *
9
+ * <path> -p --output-format stream-json --model <model> ...
10
+ *
11
+ * Under Codex, this small bridge preserves that executable contract while
12
+ * routing the actual generation through the authenticated Codex CLI.
13
+ */
14
+
15
+ const { spawnSync } = require("node:child_process");
16
+ const crypto = require("node:crypto");
17
+ const fs = require("node:fs");
18
+ const os = require("node:os");
19
+ const path = require("node:path");
20
+
21
+ const TIMEOUT_MS = 120_000;
22
+
23
+ function main(argv) {
24
+ try {
25
+ const { outputFormat, systemPrompt } = parseSupportedArgs(argv);
26
+ const content = runCodex({
27
+ prompt: fs.readFileSync(0, "utf8"),
28
+ systemPrompt,
29
+ });
30
+ const payload =
31
+ outputFormat === "stream-json"
32
+ ? { type: "result", subtype: "success", result: content }
33
+ : { result: content };
34
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
35
+ return 0;
36
+ } catch (error) {
37
+ const message = error instanceof Error ? error.message : String(error);
38
+ process.stderr.write(`codex-claude-compat: ${message}\n`);
39
+ return 1;
40
+ }
41
+ }
42
+
43
+ function parseSupportedArgs(argv) {
44
+ let outputFormat = "json";
45
+ let systemPrompt = "";
46
+ let idx = 0;
47
+ while (idx < argv.length) {
48
+ const arg = argv[idx];
49
+ if (arg === "-p") {
50
+ idx += 1;
51
+ } else if (arg === "--output-format") {
52
+ outputFormat = requireValue(argv, idx, arg);
53
+ idx += 2;
54
+ } else if (arg === "--model") {
55
+ requireValue(argv, idx, arg);
56
+ idx += 2;
57
+ } else if (arg === "--verbose" || arg === "--include-partial-messages") {
58
+ idx += 1;
59
+ } else if (arg === "--append-system-prompt") {
60
+ systemPrompt = requireValue(argv, idx, arg);
61
+ idx += 2;
62
+ } else {
63
+ throw new Error(`unsupported Claude CLI argument: ${arg}`);
64
+ }
65
+ }
66
+ if (outputFormat !== "json" && outputFormat !== "stream-json") {
67
+ throw new Error(`unsupported --output-format: ${outputFormat}`);
68
+ }
69
+ return { outputFormat, systemPrompt };
70
+ }
71
+
72
+ function requireValue(argv, idx, name) {
73
+ if (idx + 1 >= argv.length) {
74
+ throw new Error(`${name} requires a value`);
75
+ }
76
+ return argv[idx + 1];
77
+ }
78
+
79
+ function runCodex({ prompt, systemPrompt }) {
80
+ const codexPath = process.env.CLAUDE_SMART_CODEX_PATH || commandPath(codexNames());
81
+ if (!codexPath) {
82
+ throw new Error("codex CLI not found on PATH");
83
+ }
84
+
85
+ const outputPath = temporaryOutputPath();
86
+ const args = [
87
+ "exec",
88
+ "--sandbox",
89
+ "read-only",
90
+ "--skip-git-repo-check",
91
+ "--ephemeral",
92
+ "--ignore-rules",
93
+ "--output-last-message",
94
+ outputPath,
95
+ "-",
96
+ ];
97
+ const env = {
98
+ ...process.env,
99
+ CLAUDE_SMART_HOST: "codex",
100
+ CLAUDE_SMART_INTERNAL: "1",
101
+ CLAUDE_CODE_ENTRYPOINT: "optimizer",
102
+ };
103
+
104
+ try {
105
+ const proc = spawnSync(codexPath, args, {
106
+ input: codexPrompt({ prompt, systemPrompt }),
107
+ encoding: "utf8",
108
+ env,
109
+ timeout: TIMEOUT_MS,
110
+ windowsHide: true,
111
+ shell: process.platform === "win32" && /\.(?:cmd|bat)$/i.test(codexPath),
112
+ });
113
+ if (proc.error) {
114
+ if (proc.error.code === "ETIMEDOUT") {
115
+ throw new Error(`codex CLI timed out after ${TIMEOUT_MS / 1000}s`);
116
+ }
117
+ throw proc.error;
118
+ }
119
+ if (proc.status !== 0) {
120
+ const stderr = String(proc.stderr || "").trim().slice(0, 500);
121
+ throw new Error(`codex CLI exited ${proc.status}: ${stderr}`);
122
+ }
123
+ const content = fs.readFileSync(outputPath, "utf8").trim();
124
+ if (!content) {
125
+ throw new Error("codex CLI returned empty output");
126
+ }
127
+ return content;
128
+ } finally {
129
+ try {
130
+ fs.unlinkSync(outputPath);
131
+ } catch {
132
+ // Best effort cleanup only.
133
+ }
134
+ }
135
+ }
136
+
137
+ function codexNames() {
138
+ return process.platform === "win32" ? ["codex.cmd", "codex.exe", "codex"] : ["codex"];
139
+ }
140
+
141
+ function commandPath(names) {
142
+ const pathParts = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
143
+ for (const dir of pathParts) {
144
+ for (const name of names) {
145
+ const candidate = path.join(dir, name);
146
+ if (fs.existsSync(candidate)) return candidate;
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+
152
+ function temporaryOutputPath() {
153
+ const suffix = crypto.randomBytes(8).toString("hex");
154
+ return path.join(os.tmpdir(), `claude-smart-codex-${process.pid}-${Date.now()}-${suffix}`);
155
+ }
156
+
157
+ function codexPrompt({ prompt, systemPrompt }) {
158
+ if (!systemPrompt) return prompt;
159
+ return `${systemPrompt}\n\n## Task\n${prompt}`;
160
+ }
161
+
162
+ process.exitCode = main(process.argv.slice(2));
@@ -13,6 +13,7 @@ const REFLEXIO_DIR = path.join(HOME, ".reflexio");
13
13
  const DEFAULT_BACKEND_PORT = 8071;
14
14
  const FALLBACK_BACKEND_PORT = 8072;
15
15
  const DASHBOARD_PORT = 3001;
16
+ const LOG_MAX_BYTES = 10000000;
16
17
 
17
18
  function emitOk() {
18
19
  process.stdout.write('{"continue":true,"suppressOutput":true}\n');
@@ -22,9 +23,31 @@ function ensureDir(dir) {
22
23
  fs.mkdirSync(dir, { recursive: true });
23
24
  }
24
25
 
26
+ function trimLog(file) {
27
+ if (!Number.isFinite(LOG_MAX_BYTES) || LOG_MAX_BYTES < 1) return;
28
+ let stat;
29
+ try {
30
+ stat = fs.statSync(file);
31
+ } catch {
32
+ return;
33
+ }
34
+ if (!stat.isFile() || stat.size <= LOG_MAX_BYTES) return;
35
+ const fd = fs.openSync(file, "r");
36
+ try {
37
+ const buffer = Buffer.alloc(LOG_MAX_BYTES);
38
+ fs.readSync(fd, buffer, 0, LOG_MAX_BYTES, stat.size - LOG_MAX_BYTES);
39
+ fs.writeFileSync(file, buffer);
40
+ } finally {
41
+ fs.closeSync(fd);
42
+ }
43
+ }
44
+
25
45
  function appendLog(name, line) {
26
46
  ensureDir(STATE_DIR);
27
- fs.appendFileSync(path.join(STATE_DIR, name), `${line}\n`);
47
+ const file = path.join(STATE_DIR, name);
48
+ trimLog(file);
49
+ fs.appendFileSync(file, `${line}\n`);
50
+ trimLog(file);
28
51
  }
29
52
 
30
53
  function pluginRoot() {
@@ -81,6 +104,10 @@ function npmPath() {
81
104
  return commandPath(process.platform === "win32" ? ["npm.cmd", "npm.exe", "npm"] : ["npm"]);
82
105
  }
83
106
 
107
+ function bashPath() {
108
+ return commandPath(process.platform === "win32" ? ["bash.exe", "bash"] : ["bash"]);
109
+ }
110
+
84
111
  function stateFile(name) {
85
112
  return path.join(STATE_DIR, name);
86
113
  }
@@ -95,7 +122,10 @@ function writeBackendUrl(port) {
95
122
  }
96
123
 
97
124
  function codexCompatPath(root) {
98
- return path.join(root, "scripts", "codex-claude-compat.py");
125
+ const filename = process.platform === "win32"
126
+ ? "codex-claude-compat.cmd"
127
+ : "codex-claude-compat";
128
+ return path.join(root, "scripts", filename);
99
129
  }
100
130
 
101
131
  function readBackendUrl() {
@@ -176,6 +206,49 @@ function detached(command, args, options = {}) {
176
206
  return child.pid;
177
207
  }
178
208
 
209
+ function runInstaller(root, reason) {
210
+ if (process.env.CLAUDE_SMART_BOOTSTRAPPING === "1") return false;
211
+ const script = path.join(root, "scripts", "smart-install.sh");
212
+ if (!fs.existsSync(script)) return false;
213
+ const bash = bashPath();
214
+ if (!bash) return false;
215
+ appendLog("backend.log", `[claude-smart] ${reason}; running installer`);
216
+ const result = spawnSync(bash, [script], {
217
+ cwd: root,
218
+ env: {
219
+ ...process.env,
220
+ CLAUDE_SMART_BOOTSTRAPPING: "1",
221
+ },
222
+ encoding: "utf8",
223
+ maxBuffer: 20 * 1024 * 1024,
224
+ windowsHide: true,
225
+ });
226
+ const output = `${result.stdout || ""}${result.stderr || ""}`.trim();
227
+ if (output) {
228
+ ensureDir(STATE_DIR);
229
+ fs.appendFileSync(path.join(STATE_DIR, "install.log"), `${output}\n`);
230
+ trimLog(path.join(STATE_DIR, "install.log"));
231
+ }
232
+ prependRuntimePath();
233
+ return result.status === 0;
234
+ }
235
+
236
+ function startInstallerDetached(root, reason) {
237
+ if (process.env.CLAUDE_SMART_BOOTSTRAPPING === "1") return false;
238
+ const script = path.join(root, "scripts", "smart-install.sh");
239
+ const bash = bashPath();
240
+ if (!bash || !fs.existsSync(script)) return false;
241
+ appendLog("backend.log", `[claude-smart] ${reason}; starting installer in background`);
242
+ detached(bash, [script], {
243
+ cwd: root,
244
+ env: {
245
+ ...process.env,
246
+ CLAUDE_SMART_BOOTSTRAPPING: "1",
247
+ },
248
+ });
249
+ return true;
250
+ }
251
+
179
252
  function readPid(file) {
180
253
  try {
181
254
  const value = fs.readFileSync(file, "utf8").trim();
@@ -216,6 +289,7 @@ function ensurePluginRoot(root) {
216
289
  }
217
290
 
218
291
  async function startBackend(root) {
292
+ trimLog(path.join(STATE_DIR, "backend.log"));
219
293
  if (process.env.CLAUDE_SMART_BACKEND_AUTOSTART === "0") {
220
294
  emitOk();
221
295
  return;
@@ -235,7 +309,11 @@ async function startBackend(root) {
235
309
  }
236
310
  const uv = uvPath();
237
311
  if (!uv) {
238
- appendLog("backend.log", "[claude-smart] backend: uv not on PATH; skipping");
312
+ runInstaller(root, "backend: uv not on PATH");
313
+ }
314
+ const readyUv = uvPath();
315
+ if (!readyUv) {
316
+ appendLog("backend.log", "[claude-smart] backend: uv not on PATH after installer; skipping");
239
317
  emitOk();
240
318
  return;
241
319
  }
@@ -257,7 +335,7 @@ async function startBackend(root) {
257
335
  INTERACTION_CLEANUP_DELETE_COUNT: process.env.INTERACTION_CLEANUP_DELETE_COUNT || "200",
258
336
  };
259
337
  const pid = detached(
260
- uv,
338
+ readyUv,
261
339
  [
262
340
  "run",
263
341
  "--project",
@@ -299,14 +377,18 @@ async function startDashboard(root) {
299
377
  }
300
378
  const npm = npmPath();
301
379
  if (!npm) {
302
- appendLog("dashboard.log", "[claude-smart] dashboard: npm not on PATH; skipping");
380
+ runInstaller(root, "dashboard: npm not on PATH");
381
+ }
382
+ const readyNpm = npmPath();
383
+ if (!readyNpm) {
384
+ appendLog("dashboard.log", "[claude-smart] dashboard: npm not on PATH after installer; skipping");
303
385
  emitOk();
304
386
  return;
305
387
  }
306
388
  if (!fs.existsSync(path.join(dashboard, ".next"))) {
307
389
  const buildPidFile = path.join(STATE_DIR, "dashboard-build.pid");
308
390
  if (!pidAlive(readPid(buildPidFile))) {
309
- const pid = detached(npm, ["run", "build"], { cwd: dashboard });
391
+ const pid = detached(readyNpm, ["run", "build"], { cwd: dashboard });
310
392
  writePid(buildPidFile, pid);
311
393
  appendLog("dashboard.log", "[claude-smart] dashboard: .next missing; started background build");
312
394
  }
@@ -319,18 +401,27 @@ async function startDashboard(root) {
319
401
  REFLEXIO_URL: readBackendUrl(),
320
402
  CLAUDE_SMART_DASHBOARD_WORKSPACE: process.cwd(),
321
403
  };
322
- const pid = detached(npm, ["run", "start"], { cwd: dashboard, env });
404
+ const pid = detached(readyNpm, ["run", "start"], { cwd: dashboard, env });
323
405
  writePid(pidFile, pid);
324
406
  await waitForHealth(DASHBOARD_PORT, "/api/health", "x-claude-smart-dashboard", 5);
325
407
  emitOk();
326
408
  }
327
409
 
328
410
  function runHook(root, event) {
329
- const uv = uvPath();
411
+ trimLog(path.join(STATE_DIR, "backend.log"));
412
+ let uv = uvPath();
330
413
  if (!uv) {
331
- appendLog("backend.log", "[claude-smart] hook: uv not on PATH; skipping");
332
- emitOk();
333
- return 0;
414
+ if (event === "session-start") {
415
+ runInstaller(root, "hook: uv not on PATH");
416
+ uv = uvPath();
417
+ } else {
418
+ startInstallerDetached(root, "hook: uv not on PATH");
419
+ }
420
+ if (!uv) {
421
+ appendLog("backend.log", "[claude-smart] hook: uv not on PATH after installer; skipping");
422
+ emitOk();
423
+ return 0;
424
+ }
334
425
  }
335
426
  const input = fs.readFileSync(0);
336
427
  const result = spawnSync(
@@ -100,10 +100,19 @@ case "$CMD" in
100
100
  fi
101
101
  NPM_BIN=$(claude_smart_resolve_npm || true)
102
102
  if [ -z "$NPM_BIN" ] || ! "$NPM_BIN" --version >/dev/null 2>&1; then
103
- reason="npm is not on PATH; dashboard cannot start"
104
- echo "[claude-smart] dashboard: $reason; skipping" >>"$LOG_FILE"
105
- claude_smart_write_dashboard_unavailable "$reason"
106
- emit_ok; exit 0
103
+ if [ "${CLAUDE_SMART_BOOTSTRAPPING:-}" != "1" ] && [ -x "$PLUGIN_ROOT/scripts/smart-install.sh" ]; then
104
+ echo "[claude-smart] dashboard: npm is not on PATH; running installer" >>"$LOG_FILE"
105
+ CLAUDE_SMART_BOOTSTRAPPING=1 bash "$PLUGIN_ROOT/scripts/smart-install.sh" >>"$STATE_DIR/install.log" 2>&1 || true
106
+ claude_smart_source_login_path
107
+ claude_smart_prepend_node_bins
108
+ NPM_BIN=$(claude_smart_resolve_npm || true)
109
+ fi
110
+ if [ -z "$NPM_BIN" ] || ! "$NPM_BIN" --version >/dev/null 2>&1; then
111
+ reason="npm is not on PATH after installer; dashboard cannot start"
112
+ echo "[claude-smart] dashboard: $reason; skipping" >>"$LOG_FILE"
113
+ claude_smart_write_dashboard_unavailable "$reason"
114
+ emit_ok; exit 0
115
+ fi
107
116
  fi
108
117
 
109
118
  # `npm run start` requires a prior `next build`. Do NOT build in the
@@ -37,6 +37,7 @@ claude_smart_prepend_astral_bins
37
37
  PLUGIN_ROOT="$(cd "$HERE/.." && pwd)"
38
38
 
39
39
  FAILURE_MARKER="$HOME/.claude-smart/install-failed"
40
+ STATE_DIR="$HOME/.claude-smart"
40
41
  if [ -f "$FAILURE_MARKER" ]; then
41
42
  if [ "$EVENT" = "session-start" ] && command -v python3 >/dev/null 2>&1; then
42
43
  python3 - "$FAILURE_MARKER" <<'PY'
@@ -61,9 +62,34 @@ PY
61
62
  fi
62
63
 
63
64
  if ! command -v uv >/dev/null 2>&1; then
64
- # uv missing post-install don't crash the session, just no-op.
65
- echo '{"continue":true,"suppressOutput":true}'
66
- exit 0
65
+ # Self-heal from skipped Setup/SessionStart bootstrap. SessionStart can
66
+ # afford to wait because it has the install budget; prompt/tool hooks start
67
+ # the same installer detached so normal work is not blocked by first-run
68
+ # dependency setup.
69
+ if [ "${CLAUDE_SMART_BOOTSTRAPPING:-}" = "1" ]; then
70
+ echo '{"continue":true,"suppressOutput":true}'
71
+ exit 0
72
+ fi
73
+ if [ -x "$PLUGIN_ROOT/scripts/smart-install.sh" ]; then
74
+ mkdir -p "$STATE_DIR"
75
+ if [ "$EVENT" = "session-start" ]; then
76
+ CLAUDE_SMART_BOOTSTRAPPING=1 bash "$PLUGIN_ROOT/scripts/smart-install.sh" >&2
77
+ claude_smart_prepend_astral_bins
78
+ claude_smart_prepend_node_bins
79
+ if command -v uv >/dev/null 2>&1; then
80
+ bash "$HERE/backend-service.sh" start >/dev/null 2>&1 || true
81
+ bash "$HERE/dashboard-service.sh" start >/dev/null 2>&1 || true
82
+ fi
83
+ else
84
+ claude_smart_spawn_detached env CLAUDE_SMART_BOOTSTRAPPING=1 \
85
+ bash "$PLUGIN_ROOT/scripts/smart-install.sh" \
86
+ >>"$STATE_DIR/install.log" 2>&1 || true
87
+ fi
88
+ fi
89
+ if ! command -v uv >/dev/null 2>&1; then
90
+ echo '{"continue":true,"suppressOutput":true}'
91
+ exit 0
92
+ fi
67
93
  fi
68
94
 
69
95
  # Stdin is the hook payload JSON — stream it through to the Python CLI.