nexo-brain 2.3.1 → 2.3.2

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.
@@ -13,19 +13,71 @@ from pathlib import Path
13
13
  _THIS_DIR = Path(__file__).resolve().parent
14
14
  REPO_DIR = _THIS_DIR.parent.parent
15
15
  PACKAGE_JSON = REPO_DIR / "package.json"
16
- SRC_DIR = REPO_DIR / "src"
17
16
 
18
17
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
19
18
  DATA_DIR = NEXO_HOME / "data"
20
19
  BACKUP_BASE = NEXO_HOME / "backups"
21
20
 
21
+ # In packaged installs, update.py lives at ~/.nexo/plugins/update.py
22
+ # so REPO_DIR would be ~/ (wrong). Detect this and fix paths.
23
+ _PACKAGED_INSTALL = not (REPO_DIR / ".git").exists() and not (REPO_DIR / ".git").is_file()
24
+
25
+ if _PACKAGED_INSTALL:
26
+ # In packaged mode, core .py files live directly in NEXO_HOME
27
+ SRC_DIR = NEXO_HOME
28
+ else:
29
+ SRC_DIR = REPO_DIR / "src"
30
+
31
+
32
+ def _find_npm_pkg_src() -> Path | None:
33
+ """Locate the nexo-brain npm package's src/ directory for requirements.txt."""
34
+ try:
35
+ result = subprocess.run(
36
+ ["npm", "root", "-g"],
37
+ capture_output=True, text=True, timeout=10,
38
+ )
39
+ if result.returncode == 0:
40
+ npm_src = Path(result.stdout.strip()) / "nexo-brain" / "src"
41
+ if npm_src.is_dir():
42
+ return npm_src
43
+ except Exception:
44
+ pass
45
+ return None
46
+
47
+ def _is_git_repo() -> bool:
48
+ """Check if REPO_DIR is a valid git repository."""
49
+ return (REPO_DIR / ".git").exists() or (REPO_DIR / ".git").is_file()
50
+
51
+
52
+ def _refresh_installed_manifest():
53
+ """Copy source crons/ to NEXO_HOME/crons/ so catchup & watchdog stay current."""
54
+ try:
55
+ src_crons = SRC_DIR / "crons"
56
+ dst_crons = NEXO_HOME / "crons"
57
+ if src_crons.exists():
58
+ dst_crons.mkdir(parents=True, exist_ok=True)
59
+ for f in src_crons.iterdir():
60
+ if f.is_file():
61
+ shutil.copy2(str(f), str(dst_crons / f.name))
62
+ except Exception:
63
+ pass
64
+
22
65
 
23
66
  def _read_version() -> str:
24
- """Read version from package.json."""
67
+ """Read version from package.json or NEXO_HOME/version.json (packaged installs)."""
25
68
  try:
26
- return json.loads(PACKAGE_JSON.read_text()).get("version", "unknown")
69
+ if PACKAGE_JSON.exists():
70
+ return json.loads(PACKAGE_JSON.read_text()).get("version", "unknown")
27
71
  except Exception:
28
- return "unknown"
72
+ pass
73
+ # Packaged installs don't ship package.json — check version.json in NEXO_HOME
74
+ try:
75
+ version_file = NEXO_HOME / "version.json"
76
+ if version_file.exists():
77
+ return json.loads(version_file.read_text()).get("version", "unknown")
78
+ except Exception:
79
+ pass
80
+ return "unknown"
29
81
 
30
82
 
31
83
  def _git(*args, cwd=None) -> tuple[int, str, str]:
@@ -40,13 +92,28 @@ def _git(*args, cwd=None) -> tuple[int, str, str]:
40
92
  return result.returncode, result.stdout.strip(), result.stderr.strip()
41
93
 
42
94
 
95
+ def _requirements_hash() -> str:
96
+ """Return a content hash of requirements.txt, or empty string if missing."""
97
+ import hashlib
98
+ req_file = SRC_DIR / "requirements.txt"
99
+ if not req_file.exists() and _PACKAGED_INSTALL:
100
+ npm_src = _find_npm_pkg_src()
101
+ if npm_src:
102
+ req_file = npm_src / "requirements.txt"
103
+ if req_file.exists():
104
+ return hashlib.sha256(req_file.read_bytes()).hexdigest()
105
+ return ""
106
+
107
+
43
108
  def _check_dirty() -> str | None:
44
- """Return error message if src/ has uncommitted changes, else None."""
45
- rc, out, _ = _git("status", "--porcelain", "--", "src/")
109
+ """Return error message if worktree has uncommitted changes, else None."""
110
+ if not _is_git_repo():
111
+ return None # Not a git repo, skip dirty check
112
+ rc, out, _ = _git("status", "--porcelain")
46
113
  if rc != 0:
47
114
  return "Failed to check git status."
48
115
  if out:
49
- return f"Uncommitted changes in src/:\n{out}\nCommit or stash before updating."
116
+ return f"Uncommitted changes:\n{out}\nCommit or stash before updating."
50
117
  return None
51
118
 
52
119
 
@@ -102,12 +169,51 @@ def _restore_databases(backup_dir: str):
102
169
  break
103
170
 
104
171
 
172
+ def _reinstall_pip_deps() -> str | None:
173
+ """Reinstall Python dependencies from requirements.txt into the managed venv."""
174
+ req_file = SRC_DIR / "requirements.txt"
175
+ if not req_file.exists() and _PACKAGED_INSTALL:
176
+ # In packaged mode, requirements.txt lives in the npm package's src/ dir
177
+ npm_src = _find_npm_pkg_src()
178
+ if npm_src:
179
+ req_file = npm_src / "requirements.txt"
180
+ if not req_file.exists():
181
+ return None # No requirements file, skip
182
+ venv_pip = NEXO_HOME / ".venv" / "bin" / "pip"
183
+ if not venv_pip.exists():
184
+ venv_pip = NEXO_HOME / ".venv" / "bin" / "pip3"
185
+ if not venv_pip.exists():
186
+ # No venv, try system pip with --break-system-packages
187
+ try:
188
+ result = subprocess.run(
189
+ [sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_file), "--break-system-packages"],
190
+ capture_output=True, text=True, timeout=120,
191
+ )
192
+ if result.returncode != 0:
193
+ return f"pip install failed: {result.stderr or result.stdout}"
194
+ except Exception as e:
195
+ return f"pip install error: {e}"
196
+ return None
197
+ try:
198
+ result = subprocess.run(
199
+ [str(venv_pip), "install", "--quiet", "-r", str(req_file)],
200
+ capture_output=True, text=True, timeout=120,
201
+ )
202
+ if result.returncode != 0:
203
+ return f"pip install failed: {result.stderr or result.stdout}"
204
+ except Exception as e:
205
+ return f"pip install error: {e}"
206
+ return None
207
+
208
+
105
209
  def _run_migrations() -> str | None:
106
210
  """Run init_db() to apply pending migrations. Returns error or None."""
211
+ # In packaged mode, db/ lives in NEXO_HOME; in dev mode, in SRC_DIR
212
+ cwd = str(NEXO_HOME) if _PACKAGED_INSTALL else str(SRC_DIR)
107
213
  try:
108
214
  result = subprocess.run(
109
215
  [sys.executable, "-c", "import db; db.init_db()"],
110
- cwd=str(SRC_DIR),
216
+ cwd=cwd,
111
217
  capture_output=True,
112
218
  text=True,
113
219
  timeout=30,
@@ -121,10 +227,12 @@ def _run_migrations() -> str | None:
121
227
 
122
228
  def _verify_import() -> str | None:
123
229
  """Verify server.py can be imported successfully."""
230
+ # In packaged mode, server.py lives in NEXO_HOME; in dev mode, in SRC_DIR
231
+ cwd = str(NEXO_HOME) if _PACKAGED_INSTALL else str(SRC_DIR)
124
232
  try:
125
233
  result = subprocess.run(
126
234
  [sys.executable, "-c", "import server"],
127
- cwd=str(SRC_DIR),
235
+ cwd=cwd,
128
236
  capture_output=True,
129
237
  text=True,
130
238
  timeout=15,
@@ -136,27 +244,235 @@ def _verify_import() -> str | None:
136
244
  return None
137
245
 
138
246
 
247
+ def _sync_hooks_to_home():
248
+ """Copy hook scripts from src/hooks/ to NEXO_HOME/hooks/ after update."""
249
+ import shutil
250
+ hooks_src = SRC_DIR / "hooks"
251
+ hooks_dest = NEXO_HOME / "hooks"
252
+ if not hooks_src.is_dir():
253
+ return
254
+ hooks_dest.mkdir(parents=True, exist_ok=True)
255
+ synced = 0
256
+ for f in hooks_src.iterdir():
257
+ if f.is_file() and f.suffix == ".sh":
258
+ dest = hooks_dest / f.name
259
+ shutil.copy2(str(f), str(dest))
260
+ os.chmod(str(dest), 0o755)
261
+ synced += 1
262
+ if synced:
263
+ print(f"[NEXO update] Synced {synced} hook(s) to {hooks_dest}", file=sys.stderr)
264
+
265
+
266
+ def _backup_code_tree() -> tuple[str | None, str | None]:
267
+ """Snapshot NEXO_HOME code dirs before npm update. Returns (backup_dir, error)."""
268
+ timestamp = time.strftime("%Y-%m-%d-%H%M%S")
269
+ backup_dir = BACKUP_BASE / f"code-tree-{timestamp}"
270
+ # Directories and flat files that postinstall copies into NEXO_HOME
271
+ code_dirs = ["hooks", "plugins", "db", "cognitive", "dashboard", "rules", "crons", "scripts"]
272
+ code_files_glob = ["*.py", "requirements.txt"]
273
+ try:
274
+ backup_dir.mkdir(parents=True, exist_ok=True)
275
+ # Backup directories
276
+ for d in code_dirs:
277
+ src = NEXO_HOME / d
278
+ if src.is_dir():
279
+ shutil.copytree(src, backup_dir / d, dirs_exist_ok=True)
280
+ # Backup flat code files in NEXO_HOME root
281
+ for pattern in code_files_glob:
282
+ for f in NEXO_HOME.glob(pattern):
283
+ if f.is_file():
284
+ shutil.copy2(f, backup_dir / f.name)
285
+ # Backup version.json
286
+ vf = NEXO_HOME / "version.json"
287
+ if vf.is_file():
288
+ shutil.copy2(vf, backup_dir / "version.json")
289
+ except Exception as e:
290
+ return None, f"Code tree backup failed: {e}"
291
+ return str(backup_dir), None
292
+
293
+
294
+ def _restore_code_tree(backup_dir: str) -> str | None:
295
+ """Restore NEXO_HOME code dirs from a backup snapshot. Returns error or None."""
296
+ bdir = Path(backup_dir)
297
+ if not bdir.is_dir():
298
+ return f"Code tree backup dir not found: {backup_dir}"
299
+ try:
300
+ for item in bdir.iterdir():
301
+ dest = NEXO_HOME / item.name
302
+ if item.is_dir():
303
+ if dest.is_dir():
304
+ shutil.rmtree(dest)
305
+ shutil.copytree(item, dest)
306
+ elif item.is_file():
307
+ shutil.copy2(item, dest)
308
+ except Exception as e:
309
+ return f"Code tree restore failed: {e}"
310
+ return None
311
+
312
+
313
+ def _rollback_npm_package(target_version: str) -> str | None:
314
+ """Rollback nexo-brain npm package to a specific version.
315
+
316
+ Uses NEXO_SKIP_POSTINSTALL because we restore the code tree
317
+ from our own pre-update backup — no need for postinstall migration.
318
+ """
319
+ try:
320
+ result = subprocess.run(
321
+ ["npm", "install", "-g", f"nexo-brain@{target_version}"],
322
+ capture_output=True, text=True, timeout=120,
323
+ env={**os.environ, "NEXO_SKIP_POSTINSTALL": "1", "NEXO_HOME": str(NEXO_HOME)},
324
+ )
325
+ if result.returncode != 0:
326
+ return f"npm rollback failed: {result.stderr or result.stdout}"
327
+ except Exception as e:
328
+ return f"npm rollback error: {e}"
329
+ return None
330
+
331
+
332
+ def _handle_packaged_update() -> str:
333
+ """Update a packaged (npm) install — no git repo available."""
334
+ old_version = _read_version()
335
+
336
+ # 1. Backup databases BEFORE any changes
337
+ backup_dir, backup_err = _backup_databases()
338
+ if backup_err:
339
+ return f"ABORTED at backup: {backup_err}"
340
+
341
+ # 2. Backup NEXO_HOME code tree BEFORE npm update
342
+ # postinstall copies hooks/core/plugins/scripts into NEXO_HOME,
343
+ # so we need a full snapshot to restore on failure.
344
+ code_backup_dir, code_err = _backup_code_tree()
345
+ if code_err:
346
+ return f"ABORTED at code tree backup: {code_err}"
347
+
348
+ # 3. Run npm update (postinstall.js will migrate NEXO_HOME in-place)
349
+ try:
350
+ result = subprocess.run(
351
+ ["npm", "update", "-g", "nexo-brain"],
352
+ capture_output=True, text=True, timeout=120,
353
+ env={**os.environ, "NEXO_HOME": str(NEXO_HOME)},
354
+ )
355
+ if result.returncode != 0:
356
+ # npm failed (including postinstall failures) — full rollback
357
+ if backup_dir:
358
+ _restore_databases(backup_dir)
359
+ if code_backup_dir:
360
+ _restore_code_tree(code_backup_dir)
361
+ # Reinstall pip deps from restored old requirements.txt
362
+ _reinstall_pip_deps()
363
+ rollback_err = _rollback_npm_package(old_version)
364
+ msg = f"ABORTED: npm update failed: {result.stderr or result.stdout}"
365
+ if rollback_err:
366
+ msg += f"\n WARNING: npm rollback also failed: {rollback_err}"
367
+ msg += f"\n Manual rollback: npm install -g nexo-brain@{old_version}"
368
+ return msg
369
+ except FileNotFoundError:
370
+ return "ABORTED: npm not found. Install Node.js to update packaged installs."
371
+ except Exception as e:
372
+ if backup_dir:
373
+ _restore_databases(backup_dir)
374
+ if code_backup_dir:
375
+ _restore_code_tree(code_backup_dir)
376
+ # Reinstall pip deps from restored old requirements.txt
377
+ _reinstall_pip_deps()
378
+ rollback_err = _rollback_npm_package(old_version)
379
+ msg = f"ABORTED: npm update error: {e}"
380
+ if rollback_err:
381
+ msg += f"\n WARNING: npm rollback also failed: {rollback_err}"
382
+ msg += f"\n Manual rollback: npm install -g nexo-brain@{old_version}"
383
+ return msg
384
+
385
+ new_version = _read_version()
386
+ if old_version == new_version:
387
+ return f"Already up to date (v{old_version}). No changes."
388
+
389
+ # 4. Post-npm verification steps
390
+ errors = []
391
+
392
+ # Reinstall pip deps for new version
393
+ pip_err = _reinstall_pip_deps()
394
+ if pip_err:
395
+ errors.append(f"pip deps: {pip_err}")
396
+
397
+ # Run migrations
398
+ mig_err = _run_migrations()
399
+ if mig_err:
400
+ errors.append(f"migrations: {mig_err}")
401
+
402
+ # Verify server can still import
403
+ verify_err = _verify_import()
404
+ if verify_err:
405
+ errors.append(f"verification: {verify_err}")
406
+
407
+ if errors:
408
+ # 5. Full rollback: restore code tree + DBs + pip deps + rollback npm package
409
+ if code_backup_dir:
410
+ tree_err = _restore_code_tree(code_backup_dir)
411
+ else:
412
+ tree_err = "no code tree backup available"
413
+ if backup_dir:
414
+ _restore_databases(backup_dir)
415
+ # Reinstall pip deps from the restored (old) requirements.txt
416
+ # so the venv matches the rolled-back code tree
417
+ pip_rollback_err = _reinstall_pip_deps() if not tree_err else None
418
+ rollback_err = _rollback_npm_package(old_version)
419
+ lines = [f"UPDATE FAILED (packaged install, v{old_version} -> v{new_version})"]
420
+ for err in errors:
421
+ lines.append(f" ERROR: {err}")
422
+ lines.append(f" Databases restored from: {backup_dir}")
423
+ if tree_err:
424
+ lines.append(f" WARNING: code tree restore failed: {tree_err}")
425
+ else:
426
+ lines.append(f" Code tree restored from: {code_backup_dir}")
427
+ if pip_rollback_err:
428
+ lines.append(f" WARNING: pip deps rollback failed: {pip_rollback_err}")
429
+ elif not tree_err:
430
+ lines.append(" Python deps: reinstalled from old requirements.txt")
431
+ if rollback_err:
432
+ lines.append(f" WARNING: npm rollback failed: {rollback_err}")
433
+ lines.append(f" Manual rollback: npm install -g nexo-brain@{old_version}")
434
+ else:
435
+ lines.append(f" npm package rolled back to v{old_version}")
436
+ lines.append("")
437
+ lines.append("Fix the errors above, then run nexo_update again.")
438
+ return "\n".join(lines)
439
+
440
+ lines = ["UPDATE SUCCESSFUL (packaged install)"]
441
+ lines.append(f" Version: {old_version} -> {new_version}")
442
+ lines.append(f" Backup: {backup_dir}")
443
+ lines.append("")
444
+ lines.append("MCP server restart needed to load new code.")
445
+ return "\n".join(lines)
446
+
447
+
139
448
  def handle_update(remote: str = "origin", branch: str = "main") -> str:
140
449
  """Pull latest NEXO code, backup databases, run migrations, and verify.
141
450
 
142
- Full update flow:
143
- 1. Check for uncommitted changes in src/
451
+ Supports both git checkouts and packaged (npm) installs.
452
+
453
+ Full update flow (git):
454
+ 1. Check for uncommitted changes in entire worktree
144
455
  2. Backup all .db files
145
456
  3. git pull
146
- 4. Run migrations if version changed
147
- 5. Verify server.py imports
148
- 6. Rollback on failure
457
+ 4. Reinstall Python dependencies if version changed
458
+ 5. Run migrations if version changed
459
+ 6. Verify server.py imports
460
+ 7. Rollback on failure (git reset --hard to saved commit)
149
461
 
150
462
  Args:
151
463
  remote: Git remote name (default: origin)
152
464
  branch: Git branch to pull (default: main)
153
465
  """
466
+ # Packaged install — no git repo
467
+ if not _is_git_repo():
468
+ return _handle_packaged_update()
469
+
154
470
  steps_done = []
155
471
  old_commit = None
156
472
  backup_dir = None
157
473
 
158
474
  try:
159
- # Step 1: Check dirty
475
+ # Step 1: Check dirty (full worktree)
160
476
  dirty_err = _check_dirty()
161
477
  if dirty_err:
162
478
  return f"ABORTED: {dirty_err}"
@@ -164,6 +480,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
164
480
 
165
481
  # Record current state
166
482
  old_version = _read_version()
483
+ old_req_hash = _requirements_hash()
167
484
  rc, old_commit, _ = _git("rev-parse", "HEAD")
168
485
  if rc != 0:
169
486
  return "ABORTED: Not a git repository or git not available."
@@ -180,39 +497,59 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
180
497
  return f"ABORTED at git pull: {pull_err or pull_out}"
181
498
  steps_done.append("git-pull")
182
499
 
183
- # Step 4: Check version change
500
+ # Step 4: Check version and dependency changes
184
501
  new_version = _read_version()
185
502
  version_changed = old_version != new_version
503
+ new_req_hash = _requirements_hash()
504
+ deps_changed = old_req_hash != new_req_hash
186
505
 
187
- # Step 5: Run migrations if version changed
506
+ # Step 5: Reinstall pip dependencies if requirements.txt changed
507
+ if deps_changed or version_changed:
508
+ pip_err = _reinstall_pip_deps()
509
+ if pip_err:
510
+ raise RuntimeError(f"Pip install failed: {pip_err}")
511
+ steps_done.append("pip-deps")
512
+
513
+ # Step 6: Run migrations if version changed
188
514
  if version_changed:
189
515
  mig_err = _run_migrations()
190
516
  if mig_err:
191
517
  raise RuntimeError(f"Migration failed: {mig_err}")
192
518
  steps_done.append("migrations")
193
519
 
194
- # Step 6: Verify import
520
+ # Step 7: Verify import
195
521
  verify_err = _verify_import()
196
522
  if verify_err:
197
523
  raise RuntimeError(f"Verification failed: {verify_err}")
198
524
  steps_done.append("verify")
199
525
 
200
- # Step 7: Sync crons with manifest
526
+ # Step 8: Sync crons with manifest
201
527
  cron_sync_result = ""
202
528
  try:
203
- cron_sync_path = NEXO_CODE / "crons" / "sync.py"
529
+ cron_sync_path = SRC_DIR / "crons" / "sync.py"
204
530
  if cron_sync_path.exists():
205
- import subprocess as _sp
206
- r = _sp.run(
531
+ r = subprocess.run(
207
532
  [sys.executable, str(cron_sync_path)],
208
533
  capture_output=True, text=True, timeout=30,
209
- env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(NEXO_CODE)},
534
+ env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(SRC_DIR)},
210
535
  )
211
536
  cron_sync_result = r.stdout.strip()
212
- steps_done.append("cron-sync")
537
+ if r.returncode == 0:
538
+ steps_done.append("cron-sync")
539
+ # Refresh installed manifest only after successful sync
540
+ _refresh_installed_manifest()
541
+ else:
542
+ cron_sync_result = f"Cron sync failed (exit {r.returncode}): {r.stderr or r.stdout}"
213
543
  except Exception as e:
214
544
  cron_sync_result = f"Cron sync warning: {e}"
215
545
 
546
+ # Step 9: Sync hooks to NEXO_HOME
547
+ try:
548
+ _sync_hooks_to_home()
549
+ steps_done.append("hook-sync")
550
+ except Exception as e:
551
+ pass # Non-critical, log in function
552
+
216
553
  # Build result
217
554
  if pull_out == "Already up to date.":
218
555
  return f"Already up to date (v{old_version}). No changes pulled."
@@ -224,22 +561,35 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
224
561
  lines.append(f" Version: {old_version} (unchanged)")
225
562
  lines.append(f" Branch: {remote}/{branch}")
226
563
  lines.append(f" Backup: {backup_dir}")
564
+ if "pip-deps" in steps_done:
565
+ lines.append(" Python deps: reinstalled")
227
566
  if version_changed:
228
567
  lines.append(" Migrations: applied")
229
568
  if "cron-sync" in steps_done:
230
569
  lines.append(" Crons: synced with manifest")
570
+ if "hook-sync" in steps_done:
571
+ lines.append(" Hooks: synced to NEXO_HOME")
231
572
  lines.append("")
232
573
  lines.append("MCP server restart needed to load new code.")
233
574
  return "\n".join(lines)
234
575
 
235
576
  except Exception as e:
236
- # Rollback
577
+ # Rollback — use git checkout to saved commit (safer than reset --hard)
237
578
  rollback_lines = [f"UPDATE FAILED: {e}", "", "Rolling back..."]
238
579
 
239
580
  if old_commit and "git-pull" in steps_done:
581
+ # Full rollback: reset HEAD + index + worktree to old commit
240
582
  rc, _, err = _git("reset", "--hard", old_commit)
241
583
  if rc == 0:
242
- rollback_lines.append(f" Git: reset to {old_commit[:8]}")
584
+ rollback_lines.append(f" Git: restored files to {old_commit[:8]}")
585
+ # Reinstall pip deps from the restored old requirements.txt
586
+ # so the venv matches the rolled-back code
587
+ if "pip-deps" in steps_done:
588
+ pip_rb_err = _reinstall_pip_deps()
589
+ if pip_rb_err:
590
+ rollback_lines.append(f" WARNING: pip deps rollback failed: {pip_rb_err}")
591
+ else:
592
+ rollback_lines.append(" Python deps: reinstalled from old requirements.txt")
243
593
  else:
244
594
  rollback_lines.append(f" Git rollback FAILED: {err}")
245
595
 
@@ -18,10 +18,32 @@ import sys
18
18
  from datetime import datetime, timedelta
19
19
  from pathlib import Path
20
20
 
21
- CLAUDE_CLI = Path.home() / ".local" / "bin" / "claude"
22
-
23
21
  HOME = Path.home()
24
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
22
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(HOME / ".nexo")))
23
+
24
+
25
+ def _resolve_claude_cli() -> Path:
26
+ """Find claude CLI: saved path > PATH > common locations."""
27
+ saved = NEXO_HOME / "config" / "claude-cli-path"
28
+ if saved.exists():
29
+ p = Path(saved.read_text().strip())
30
+ if p.exists():
31
+ return p
32
+ import shutil
33
+ found = shutil.which("claude")
34
+ if found:
35
+ return Path(found)
36
+ for candidate in [
37
+ HOME / ".local" / "bin" / "claude",
38
+ HOME / ".npm-global" / "bin" / "claude",
39
+ Path("/usr/local/bin/claude"),
40
+ ]:
41
+ if candidate.exists():
42
+ return candidate
43
+ return HOME / ".local" / "bin" / "claude" # last resort
44
+
45
+
46
+ CLAUDE_CLI = _resolve_claude_cli()
25
47
  LOG_DIR = NEXO_HOME / "logs"
26
48
  LOG_DIR.mkdir(parents=True, exist_ok=True)
27
49
  LOG_FILE = LOG_DIR / "catchup.log"
@@ -47,7 +69,10 @@ def _resolve_python() -> str:
47
69
 
48
70
  NEXO_PYTHON = _resolve_python()
49
71
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
50
- MANIFEST = NEXO_CODE / "crons" / "manifest.json"
72
+ # Look for manifest in NEXO_HOME first (packaged install), then NEXO_CODE (dev/repo)
73
+ _manifest_home = NEXO_HOME / "crons" / "manifest.json"
74
+ _manifest_code = NEXO_CODE / "crons" / "manifest.json"
75
+ MANIFEST = _manifest_home if _manifest_home.exists() else _manifest_code
51
76
 
52
77
 
53
78
  def _load_tasks_from_manifest() -> list[tuple]:
@@ -43,7 +43,27 @@ RUNTIME_PREFLIGHT_SUMMARY = LOG_DIR / "runtime-preflight-summary.json"
43
43
  WATCHDOG_SMOKE_SUMMARY = LOG_DIR / "watchdog-smoke-summary.json"
44
44
  RESTORE_LOG = LOG_DIR / "snapshot-restores.log"
45
45
  CORTEX_LOG_DIR = NEXO_HOME / "brain" / "logs"
46
- CLAUDE_CLI = Path.home() / ".local" / "bin" / "claude"
46
+ def _resolve_claude_cli() -> Path:
47
+ """Find claude CLI: saved path > PATH > common locations."""
48
+ import shutil as _shutil
49
+ saved = NEXO_HOME / "config" / "claude-cli-path"
50
+ if saved.exists():
51
+ p = Path(saved.read_text().strip())
52
+ if p.exists():
53
+ return p
54
+ found = _shutil.which("claude")
55
+ if found:
56
+ return Path(found)
57
+ for candidate in [
58
+ Path.home() / ".local" / "bin" / "claude",
59
+ Path.home() / ".npm-global" / "bin" / "claude",
60
+ Path("/usr/local/bin/claude"),
61
+ ]:
62
+ if candidate.exists():
63
+ return candidate
64
+ return Path.home() / ".local" / "bin" / "claude"
65
+
66
+ CLAUDE_CLI = _resolve_claude_cli()
47
67
 
48
68
  findings = []
49
69
 
@@ -64,7 +64,27 @@ IMMUTABLE_FILES = {
64
64
  }
65
65
 
66
66
  # ── Claude CLI path ──────────────────────────────────────────────────────
67
- CLAUDE_CLI = Path.home() / ".local" / "bin" / "claude"
67
+ def _resolve_claude_cli() -> Path:
68
+ """Find claude CLI: saved path > PATH > common locations."""
69
+ import shutil as _shutil
70
+ saved = NEXO_HOME / "config" / "claude-cli-path"
71
+ if saved.exists():
72
+ p = Path(saved.read_text().strip())
73
+ if p.exists():
74
+ return p
75
+ found = _shutil.which("claude")
76
+ if found:
77
+ return Path(found)
78
+ for candidate in [
79
+ Path.home() / ".local" / "bin" / "claude",
80
+ Path.home() / ".npm-global" / "bin" / "claude",
81
+ Path("/usr/local/bin/claude"),
82
+ ]:
83
+ if candidate.exists():
84
+ return candidate
85
+ return Path.home() / ".local" / "bin" / "claude"
86
+
87
+ CLAUDE_CLI = _resolve_claude_cli()
68
88
 
69
89
  # ── Logging ──────────────────────────────────────────────────────────────
70
90
  LOG_DIR.mkdir(parents=True, exist_ok=True)