nexo-brain 5.2.1 → 5.3.1

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/src/cli.py CHANGED
@@ -25,6 +25,7 @@ Entry points:
25
25
  nexo clients sync [--json]
26
26
  nexo contributor status|on|off [--json]
27
27
  nexo doctor [--tier boot|runtime|deep|all] [--json] [--fix]
28
+ nexo uninstall [--dry-run] [--delete-data] [--json]
28
29
  """
29
30
  from __future__ import annotations
30
31
 
@@ -39,7 +40,9 @@ import subprocess
39
40
  import sys
40
41
  from pathlib import Path
41
42
 
42
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
43
+ from runtime_home import export_resolved_nexo_home
44
+
45
+ NEXO_HOME = export_resolved_nexo_home()
43
46
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
44
47
  TERMINAL_CLIENT_LABELS = {
45
48
  "claude_code": "Claude Code",
@@ -1281,6 +1284,218 @@ def _skills_compose(args):
1281
1284
  return 0 if result.get("ok") else 1
1282
1285
 
1283
1286
 
1287
+ def _uninstall(args):
1288
+ """Stop all crons, remove MCP config and hooks, preserve user data."""
1289
+ from pathlib import Path
1290
+
1291
+ nexo_home = Path(os.environ.get("NEXO_HOME", Path.home() / ".nexo"))
1292
+ dry_run = args.dry_run
1293
+ delete_data = args.delete_data
1294
+ use_json = args.json
1295
+ platform = sys.platform
1296
+
1297
+ actions: list[dict] = []
1298
+ errors: list[str] = []
1299
+
1300
+ def log_action(category: str, detail: str, path: str = ""):
1301
+ actions.append({"category": category, "detail": detail, "path": path})
1302
+ if not use_json:
1303
+ tag = "[DRY-RUN] " if dry_run else ""
1304
+ print(f" {tag}{category}: {detail}")
1305
+
1306
+ # ── 1. Stop and remove LaunchAgents (macOS) ──
1307
+ if platform == "darwin":
1308
+ la_dir = Path.home() / "Library" / "LaunchAgents"
1309
+ if la_dir.exists():
1310
+ uid = os.getuid()
1311
+ for plist in sorted(la_dir.glob("com.nexo.*.plist")):
1312
+ label = plist.stem
1313
+ # Stop the agent
1314
+ if not dry_run:
1315
+ subprocess.run(
1316
+ ["launchctl", "bootout", f"gui/{uid}", str(plist)],
1317
+ capture_output=True,
1318
+ )
1319
+ log_action("stop-cron", f"launchctl bootout {label}", str(plist))
1320
+ # Remove plist file
1321
+ if not dry_run:
1322
+ plist.unlink(missing_ok=True)
1323
+ log_action("remove-plist", label, str(plist))
1324
+ # ── systemd (Linux) ──
1325
+ elif platform == "linux":
1326
+ systemd_dir = Path.home() / ".config" / "systemd" / "user"
1327
+ if systemd_dir.exists():
1328
+ for unit in sorted(list(systemd_dir.glob("nexo-*.timer")) + list(systemd_dir.glob("nexo-*.service"))):
1329
+ if not dry_run:
1330
+ if unit.suffix == ".timer":
1331
+ subprocess.run(["systemctl", "--user", "stop", unit.name], capture_output=True)
1332
+ subprocess.run(["systemctl", "--user", "disable", unit.name], capture_output=True)
1333
+ unit.unlink(missing_ok=True)
1334
+ log_action("remove-systemd", unit.name, str(unit))
1335
+ if not dry_run:
1336
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
1337
+
1338
+ # ── 2. Remove MCP server and hooks from Claude Code settings ──
1339
+ claude_settings = Path.home() / ".claude" / "settings.json"
1340
+ if claude_settings.exists():
1341
+ try:
1342
+ settings = json.loads(claude_settings.read_text())
1343
+ changed = False
1344
+
1345
+ # Remove nexo MCP server
1346
+ mcp = settings.get("mcpServers", {})
1347
+ if "nexo" in mcp:
1348
+ if not dry_run:
1349
+ del mcp["nexo"]
1350
+ changed = True
1351
+ log_action("remove-mcp", "nexo server from settings.json", str(claude_settings))
1352
+
1353
+ # Remove NEXO hooks (hooks referencing NEXO_HOME)
1354
+ hooks = settings.get("hooks", {})
1355
+ nexo_home_str = str(nexo_home)
1356
+ for event_name in list(hooks.keys()):
1357
+ hook_list = hooks[event_name]
1358
+ if isinstance(hook_list, list):
1359
+ original_len = len(hook_list)
1360
+ filtered = [
1361
+ h for h in hook_list
1362
+ if not (
1363
+ isinstance(h, dict)
1364
+ and nexo_home_str in (h.get("command", "") + " ".join(h.get("args", [])))
1365
+ )
1366
+ ]
1367
+ if len(filtered) < original_len:
1368
+ if not dry_run:
1369
+ hooks[event_name] = filtered
1370
+ changed = True
1371
+ removed_count = original_len - len(filtered)
1372
+ log_action("remove-hooks", f"{removed_count} hook(s) from {event_name}", str(claude_settings))
1373
+ # Clean up empty hook lists
1374
+ if isinstance(hooks.get(event_name), list) and len(hooks.get(event_name, [])) == 0:
1375
+ if not dry_run:
1376
+ del hooks[event_name]
1377
+ changed = True
1378
+
1379
+ if changed and not dry_run:
1380
+ claude_settings.write_text(json.dumps(settings, indent=2, ensure_ascii=False))
1381
+ except Exception as exc:
1382
+ errors.append(f"Failed to clean settings.json: {exc}")
1383
+
1384
+ # ── 3. Remove Codex AGENTS.md bootstrap ──
1385
+ codex_agents = Path.home() / "AGENTS.md"
1386
+ if codex_agents.exists():
1387
+ try:
1388
+ content = codex_agents.read_text()
1389
+ if "NEXO" in content or "nexo" in content:
1390
+ log_action("preserve-note", "AGENTS.md contains NEXO references — remove manually if desired", str(codex_agents))
1391
+ except Exception:
1392
+ pass
1393
+
1394
+ # ── 4. Remove runtime files, PRESERVE user data ──
1395
+ # User data directories that are NEVER deleted (unless --delete-data)
1396
+ user_data_dirs = {"data", "brain", "operations", "coordination", "config", "logs", "backups"}
1397
+ user_data_files = {"version.json"} # keeps reinstall detection working
1398
+
1399
+ # Runtime directories that get removed
1400
+ runtime_dirs = {"plugins", "hooks", "dashboard", "cognitive", "db", "rules", "crons", "doctor", "skills"}
1401
+ # Runtime flat files: any .py/.txt at NEXO_HOME root is core runtime.
1402
+ # User data always lives in subdirectories (data/, brain/, scripts/, etc.)
1403
+ # so this is safe and doesn't need updating when new core files are added.
1404
+ runtime_file_extensions = {".py", ".txt"}
1405
+
1406
+ if nexo_home.exists():
1407
+ # Remove runtime directories
1408
+ for d in sorted(runtime_dirs):
1409
+ dir_path = nexo_home / d
1410
+ if dir_path.is_dir():
1411
+ if not dry_run:
1412
+ shutil.rmtree(dir_path, ignore_errors=True)
1413
+ log_action("remove-runtime-dir", d, str(dir_path))
1414
+
1415
+ # Remove runtime flat files (any .py/.txt at root level)
1416
+ for file_path in sorted(nexo_home.iterdir()):
1417
+ if file_path.is_file() and file_path.suffix in runtime_file_extensions:
1418
+ if not dry_run:
1419
+ file_path.unlink(missing_ok=True)
1420
+ log_action("remove-runtime-file", file_path.name, str(file_path))
1421
+
1422
+ # Remove core scripts (nexo-*.py/sh in scripts/)
1423
+ scripts_dir = nexo_home / "scripts"
1424
+ if scripts_dir.is_dir():
1425
+ for script in sorted(scripts_dir.glob("nexo-*")):
1426
+ if script.is_file():
1427
+ if not dry_run:
1428
+ script.unlink(missing_ok=True)
1429
+ log_action("remove-core-script", script.name, str(script))
1430
+ # Remove deep-sleep directory (core)
1431
+ ds_dir = scripts_dir / "deep-sleep"
1432
+ if ds_dir.is_dir():
1433
+ if not dry_run:
1434
+ shutil.rmtree(ds_dir, ignore_errors=True)
1435
+ log_action("remove-runtime-dir", "scripts/deep-sleep", str(ds_dir))
1436
+
1437
+ # List preserved user data
1438
+ for d in sorted(user_data_dirs):
1439
+ dir_path = nexo_home / d
1440
+ if dir_path.is_dir():
1441
+ if delete_data:
1442
+ if not dry_run:
1443
+ shutil.rmtree(dir_path, ignore_errors=True)
1444
+ log_action("DELETE-user-data", d, str(dir_path))
1445
+ else:
1446
+ log_action("preserve-data", d, str(dir_path))
1447
+
1448
+ # Preserve personal scripts (non nexo-* files in scripts/)
1449
+ if scripts_dir.is_dir():
1450
+ personal = [f.name for f in scripts_dir.iterdir() if f.is_file() and not f.name.startswith("nexo-")]
1451
+ if personal:
1452
+ log_action("preserve-scripts", f"{len(personal)} personal script(s)", str(scripts_dir))
1453
+
1454
+ # Preserve templates/
1455
+ templates_dir = nexo_home / "templates"
1456
+ if templates_dir.is_dir():
1457
+ log_action("preserve-data", "templates", str(templates_dir))
1458
+
1459
+ # ── 5. Write uninstall marker for reinstall detection ──
1460
+ if not dry_run and nexo_home.exists():
1461
+ marker = nexo_home / ".uninstalled"
1462
+ marker.write_text(json.dumps({
1463
+ "uninstalled_at": __import__("datetime").datetime.now().isoformat(),
1464
+ "nexo_home": str(nexo_home),
1465
+ "data_preserved": not delete_data,
1466
+ }, indent=2))
1467
+ log_action("write-marker", ".uninstalled marker for reinstall detection", str(marker))
1468
+
1469
+ # ── Summary ──
1470
+ result = {
1471
+ "ok": len(errors) == 0,
1472
+ "dry_run": dry_run,
1473
+ "nexo_home": str(nexo_home),
1474
+ "actions": actions,
1475
+ "errors": errors,
1476
+ "data_preserved": not delete_data,
1477
+ }
1478
+
1479
+ if use_json:
1480
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1481
+ else:
1482
+ print()
1483
+ if dry_run:
1484
+ print(f" DRY RUN complete. {len(actions)} action(s) would be taken.")
1485
+ print(" Run without --dry-run to execute.")
1486
+ else:
1487
+ print(f" Uninstall complete. {len(actions)} action(s) taken.")
1488
+ if not delete_data:
1489
+ print(f"\n Your data is preserved in: {nexo_home}")
1490
+ print(" To reinstall: npm install -g nexo-brain && nexo-brain")
1491
+ if errors:
1492
+ print(f"\n {len(errors)} error(s):")
1493
+ for e in errors:
1494
+ print(f" - {e}")
1495
+
1496
+ return 1 if errors else 0
1497
+
1498
+
1284
1499
  def _print_help():
1285
1500
  v = _get_version()
1286
1501
  print(f"""NEXO Runtime CLI v{v}
@@ -1293,6 +1508,7 @@ Commands:
1293
1508
  nexo skills list|apply|sync|approve Executable skills
1294
1509
  nexo clients sync Sync Claude/Codex shared-brain configs and bootstrap files
1295
1510
  nexo update Update installed runtime
1511
+ nexo uninstall [--dry-run] [--delete-data] Stop crons, remove runtime (keeps data)
1296
1512
  nexo contributor status|on|off Public Draft PR contribution mode
1297
1513
  nexo dashboard on|off|status Web dashboard control
1298
1514
 
@@ -1477,6 +1693,12 @@ def main():
1477
1693
  skills_compose_p.add_argument("--trigger-patterns", default="[]", help="JSON array or comma-separated trigger patterns")
1478
1694
  skills_compose_p.add_argument("--json", action="store_true", help="JSON output")
1479
1695
 
1696
+ # -- uninstall --
1697
+ uninstall_parser = sub.add_parser("uninstall", help="Stop all crons, remove runtime, keep user data")
1698
+ uninstall_parser.add_argument("--dry-run", action="store_true", help="Show what would be done without doing it")
1699
+ uninstall_parser.add_argument("--delete-data", action="store_true", help="Also delete databases and user data (DESTRUCTIVE)")
1700
+ uninstall_parser.add_argument("--json", action="store_true", help="JSON output")
1701
+
1480
1702
  # -- dashboard --
1481
1703
  dashboard_parser = sub.add_parser("dashboard", help="Web dashboard control")
1482
1704
  dashboard_parser.add_argument("action", choices=["on", "off", "status"], help="Start, stop, or check dashboard")
@@ -1566,6 +1788,8 @@ def main():
1566
1788
  else:
1567
1789
  skills_parser.print_help()
1568
1790
  return 0
1791
+ elif args.command == "uninstall":
1792
+ return _uninstall(args)
1569
1793
  elif args.command == "dashboard":
1570
1794
  return _dashboard(args)
1571
1795
  else:
@@ -18,6 +18,7 @@ except ModuleNotFoundError: # Python < 3.11
18
18
  import tomli as tomllib
19
19
 
20
20
  from bootstrap_docs import sync_client_bootstrap
21
+ from runtime_home import resolve_nexo_home
21
22
 
22
23
  try:
23
24
  from client_preferences import (
@@ -73,7 +74,7 @@ def _user_home() -> Path:
73
74
 
74
75
 
75
76
  def _default_nexo_home() -> Path:
76
- return Path(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo"))).expanduser()
77
+ return resolve_nexo_home(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo")))
77
78
 
78
79
 
79
80
  def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
@@ -12,15 +12,30 @@ import os
12
12
  from pathlib import Path
13
13
 
14
14
  from db._core import get_db
15
+ from runtime_home import resolve_nexo_home
15
16
 
16
17
 
17
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
18
+ NEXO_HOME = resolve_nexo_home()
18
19
 
19
20
 
20
21
  def _now_text() -> str:
21
22
  return datetime.datetime.now().isoformat(timespec="seconds")
22
23
 
23
24
 
25
+ def _canonical_scripts_dir() -> Path:
26
+ home = resolve_nexo_home(os.environ.get("NEXO_HOME", str(NEXO_HOME)))
27
+ return home / "scripts"
28
+
29
+
30
+ def _normalize_script_path(path: str | Path) -> str:
31
+ candidate = Path(path).expanduser()
32
+ try:
33
+ relative = candidate.resolve(strict=False).relative_to(_canonical_scripts_dir().resolve(strict=False))
34
+ except Exception:
35
+ return str(candidate)
36
+ return str(_canonical_scripts_dir() / relative)
37
+
38
+
24
39
  def _row_to_dict(row) -> dict:
25
40
  return dict(row) if row is not None else {}
26
41
 
@@ -61,6 +76,7 @@ def _safe_slug(value: str) -> str:
61
76
 
62
77
 
63
78
  def _ensure_script_id(conn, name: str, path: str) -> str:
79
+ path = _normalize_script_path(path)
64
80
  existing = conn.execute(
65
81
  "SELECT id FROM personal_scripts WHERE path = ? LIMIT 1",
66
82
  (path,),
@@ -90,6 +106,7 @@ def upsert_personal_script(
90
106
  has_inline_metadata: bool = False,
91
107
  ) -> dict:
92
108
  conn = get_db()
109
+ path = _normalize_script_path(path)
93
110
  script_id = _ensure_script_id(conn, name, path)
94
111
  now = _now_text()
95
112
  conn.execute(
@@ -132,11 +149,12 @@ def upsert_personal_script(
132
149
 
133
150
  def delete_missing_personal_scripts(active_paths: list[str]) -> int:
134
151
  conn = get_db()
135
- if active_paths:
136
- placeholders = ",".join("?" for _ in active_paths)
152
+ normalized_paths = [_normalize_script_path(path) for path in active_paths]
153
+ if normalized_paths:
154
+ placeholders = ",".join("?" for _ in normalized_paths)
137
155
  rows = conn.execute(
138
156
  f"SELECT id FROM personal_scripts WHERE path NOT IN ({placeholders})",
139
- tuple(active_paths),
157
+ tuple(normalized_paths),
140
158
  ).fetchall()
141
159
  else:
142
160
  rows = conn.execute("SELECT id FROM personal_scripts").fetchall()
@@ -160,6 +178,7 @@ def register_personal_script_schedule(
160
178
  enabled: bool = True,
161
179
  ) -> dict | None:
162
180
  conn = get_db()
181
+ script_path = _normalize_script_path(script_path)
163
182
  script = conn.execute(
164
183
  "SELECT id FROM personal_scripts WHERE path = ?",
165
184
  (script_path,),
@@ -342,6 +361,7 @@ def list_personal_scripts(include_disabled: bool = True) -> list[dict]:
342
361
 
343
362
  def get_personal_script(name_or_path: str) -> dict | None:
344
363
  conn = get_db()
364
+ normalized_path = _normalize_script_path(name_or_path)
345
365
  row = conn.execute(
346
366
  """
347
367
  SELECT * FROM personal_scripts
@@ -349,7 +369,7 @@ def get_personal_script(name_or_path: str) -> dict | None:
349
369
  ORDER BY path = ? DESC
350
370
  LIMIT 1
351
371
  """,
352
- (name_or_path, name_or_path, name_or_path),
372
+ (normalized_path, name_or_path, normalized_path),
353
373
  ).fetchone()
354
374
  if not row:
355
375
  return None
@@ -361,9 +381,10 @@ def get_personal_script(name_or_path: str) -> dict | None:
361
381
 
362
382
  def delete_personal_script(name_or_path: str) -> int:
363
383
  conn = get_db()
384
+ normalized_path = _normalize_script_path(name_or_path)
364
385
  result = conn.execute(
365
386
  "DELETE FROM personal_scripts WHERE path = ? OR name = ? OR id = ?",
366
- (name_or_path, name_or_path, name_or_path),
387
+ (normalized_path, name_or_path, name_or_path),
367
388
  )
368
389
  return int(result.rowcount or 0)
369
390
 
@@ -371,13 +392,14 @@ def delete_personal_script(name_or_path: str) -> int:
371
392
  def record_personal_script_run(name_or_path: str, exit_code: int, run_at: str | None = None) -> None:
372
393
  conn = get_db()
373
394
  run_at = run_at or _now_text()
395
+ normalized_path = _normalize_script_path(name_or_path)
374
396
  conn.execute(
375
397
  """
376
398
  UPDATE personal_scripts
377
399
  SET last_run_at = ?, last_exit_code = ?, updated_at = ?
378
400
  WHERE path = ? OR name = ?
379
401
  """,
380
- (run_at, exit_code, _now_text(), name_or_path, name_or_path),
402
+ (run_at, exit_code, _now_text(), normalized_path, name_or_path),
381
403
  )
382
404
 
383
405
 
@@ -393,7 +415,7 @@ def sync_personal_scripts_registry(
393
415
  scheduled = 0
394
416
 
395
417
  for record in script_records:
396
- path = str(record["path"])
418
+ path = _normalize_script_path(record["path"])
397
419
  active_paths.append(path)
398
420
  upsert_personal_script(
399
421
  name=record.get("name") or Path(path).stem,
@@ -84,6 +84,28 @@ def _release_root() -> Path:
84
84
  return Path(NEXO_CODE)
85
85
 
86
86
 
87
+ def _has_release_publish_context(release_root: Path) -> bool:
88
+ git_dir = release_root / ".git"
89
+ if git_dir.exists() or git_dir.is_file():
90
+ return True
91
+ if _recorded_source_root() is not None:
92
+ return True
93
+ try:
94
+ release_root_resolved = release_root.resolve()
95
+ nexo_home_resolved = NEXO_HOME.resolve()
96
+ except Exception:
97
+ release_root_resolved = release_root
98
+ nexo_home_resolved = NEXO_HOME
99
+ if release_root_resolved == nexo_home_resolved:
100
+ return False
101
+ has_release_files = (
102
+ (release_root / "package.json").is_file()
103
+ and (release_root / "CHANGELOG.md").is_file()
104
+ and (release_root / "scripts" / "sync_release_artifacts.py").is_file()
105
+ )
106
+ return has_release_files
107
+
108
+
87
109
  def _package_json_path() -> Path:
88
110
  if PACKAGE_JSON.is_file():
89
111
  return PACKAGE_JSON
@@ -2660,13 +2682,27 @@ def check_release_artifact_sync() -> DoctorCheck:
2660
2682
  if changelog_version:
2661
2683
  evidence.append(f"top changelog version: {changelog_version}")
2662
2684
 
2685
+ release_root = _release_root()
2686
+ if not _has_release_publish_context(release_root):
2687
+ if version:
2688
+ evidence.append(f"package version: {version}")
2689
+ evidence.append(f"release root: {release_root}")
2690
+ evidence.append("packaged runtime without source repo; release artifact audit skipped")
2691
+ return DoctorCheck(
2692
+ id="runtime.release_artifacts",
2693
+ tier="runtime",
2694
+ status="healthy",
2695
+ severity="info",
2696
+ summary="Release artifact audit skipped for packaged runtime",
2697
+ evidence=evidence,
2698
+ )
2699
+
2663
2700
  if version and changelog_version and version != changelog_version:
2664
2701
  status = "critical"
2665
2702
  severity = "error"
2666
2703
  evidence.append("package/changelog release version mismatch")
2667
2704
  repair_plan.append("Bump or align CHANGELOG.md before publishing")
2668
2705
 
2669
- release_root = _release_root()
2670
2706
  sync_script = release_root / "scripts" / "sync_release_artifacts.py"
2671
2707
  if not sync_script.is_file():
2672
2708
  status = "critical"
@@ -4,6 +4,8 @@
4
4
  # Caches output for 1 hour to avoid regenerating on rapid successive sessions.
5
5
  set -uo pipefail
6
6
 
7
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
8
+
7
9
  # Fase 3 item 7: hook lifecycle observability — record duration + exit code
8
10
  # in hook_runs on EXIT. Best-effort: a failure here must not break the hook.
9
11
  NEXO_HOOK_START_MS=$(python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || echo 0)
@@ -18,8 +20,12 @@ _nexo_record_hook_run() {
18
20
  duration_ms=$((now_ms - NEXO_HOOK_START_MS))
19
21
  fi
20
22
  fi
21
- local recorder
22
- recorder="${NEXO_CODE:-/Users/franciscoc/Documents/_PhpstormProjects/nexo/src}/scripts/nexo-hook-record.py"
23
+ local recorder=""
24
+ if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/scripts/nexo-hook-record.py" ]; then
25
+ recorder="${NEXO_CODE%/}/scripts/nexo-hook-record.py"
26
+ elif [ -f "$NEXO_HOME/scripts/nexo-hook-record.py" ]; then
27
+ recorder="$NEXO_HOME/scripts/nexo-hook-record.py"
28
+ fi
23
29
  if [ -f "$recorder" ]; then
24
30
  python3 "$recorder" record \
25
31
  --hook "$NEXO_HOOK_NAME" \
@@ -30,8 +36,6 @@ _nexo_record_hook_run() {
30
36
  fi
31
37
  }
32
38
  trap _nexo_record_hook_run EXIT
33
-
34
- NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
35
39
  BRIEFING_FILE="$NEXO_HOME/coordination/session-briefing.txt"
36
40
  MAX_AGE_SECONDS=3600 # 1 hour cache
37
41
 
@@ -9,6 +9,8 @@ import sys
9
9
  import time
10
10
  from pathlib import Path
11
11
 
12
+ from runtime_home import export_resolved_nexo_home
13
+
12
14
  # Code root is the parent of plugins/:
13
15
  # - source checkout: <repo>/src
14
16
  # - packaged runtime: <NEXO_HOME>
@@ -16,7 +18,7 @@ _THIS_DIR = Path(__file__).resolve().parent
16
18
  CODE_ROOT = _THIS_DIR.parent
17
19
  _REPO_CANDIDATE = CODE_ROOT.parent
18
20
 
19
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
21
+ NEXO_HOME = export_resolved_nexo_home()
20
22
  DATA_DIR = NEXO_HOME / "data"
21
23
  BACKUP_BASE = NEXO_HOME / "backups"
22
24
 
@@ -330,8 +332,23 @@ def _backup_code_tree() -> tuple[str | None, str | None]:
330
332
  timestamp = time.strftime("%Y-%m-%d-%H%M%S")
331
333
  backup_dir = BACKUP_BASE / f"code-tree-{timestamp}"
332
334
  # Directories and flat files that postinstall copies into NEXO_HOME
333
- code_dirs = ["hooks", "plugins", "db", "cognitive", "dashboard", "rules", "crons", "scripts"]
334
- code_files_glob = ["*.py", "requirements.txt"]
335
+ code_dirs = [
336
+ "bin",
337
+ "hooks",
338
+ "plugins",
339
+ "db",
340
+ "cognitive",
341
+ "dashboard",
342
+ "rules",
343
+ "crons",
344
+ "scripts",
345
+ "doctor",
346
+ "skills",
347
+ "skills-core",
348
+ "skills-runtime",
349
+ "templates",
350
+ ]
351
+ code_files_glob = ["*.py", "requirements.txt", "package.json"]
335
352
  try:
336
353
  backup_dir.mkdir(parents=True, exist_ok=True)
337
354
  # Backup directories
@@ -372,6 +389,54 @@ def _restore_code_tree(backup_dir: str) -> str | None:
372
389
  return None
373
390
 
374
391
 
392
+ def _normalize_preferences_for_client_sync() -> dict:
393
+ from client_preferences import normalize_client_preferences
394
+
395
+ schedule_path = NEXO_HOME / "config" / "schedule.json"
396
+ schedule_payload = json.loads(schedule_path.read_text()) if schedule_path.exists() else {}
397
+ normalized_preferences = normalize_client_preferences(schedule_payload)
398
+ if normalized_preferences != {
399
+ key: schedule_payload.get(key)
400
+ for key in normalized_preferences
401
+ }:
402
+ merged_schedule = dict(schedule_payload)
403
+ merged_schedule.update(normalized_preferences)
404
+ schedule_path.parent.mkdir(parents=True, exist_ok=True)
405
+ schedule_path.write_text(json.dumps(merged_schedule, indent=2, ensure_ascii=False) + "\n")
406
+ return normalized_preferences
407
+
408
+
409
+ def _sync_packaged_clients() -> tuple[bool, str | None]:
410
+ try:
411
+ from client_sync import sync_all_clients
412
+ except Exception as e:
413
+ return False, f"client sync import failed: {e}"
414
+
415
+ try:
416
+ preferences = _normalize_preferences_for_client_sync()
417
+ result = sync_all_clients(
418
+ nexo_home=NEXO_HOME,
419
+ runtime_root=NEXO_HOME,
420
+ operator_name=os.environ.get("NEXO_NAME", ""),
421
+ preferences=preferences,
422
+ )
423
+ except Exception as e:
424
+ return False, f"client sync failed: {e}"
425
+
426
+ if result.get("ok"):
427
+ return True, None
428
+
429
+ clients = result.get("clients", {})
430
+ failures = []
431
+ for key, payload in clients.items():
432
+ if payload.get("ok") or payload.get("skipped"):
433
+ continue
434
+ failures.append(f"{key}: {payload.get('error', 'unknown error')}")
435
+ if not failures:
436
+ failures.append("unknown client sync failure")
437
+ return False, "; ".join(failures)
438
+
439
+
375
440
  def _rollback_npm_package(target_version: str) -> str | None:
376
441
  """Rollback nexo-brain npm package to a specific version.
377
442
 
@@ -480,6 +545,20 @@ def _handle_packaged_update(progress_fn=None) -> str:
480
545
  if verify_err:
481
546
  errors.append(f"verification: {verify_err}")
482
547
 
548
+ hook_sync_warning = None
549
+ try:
550
+ _emit_progress(progress_fn, "Refreshing installed hooks and manifests...")
551
+ _refresh_installed_manifest()
552
+ _sync_hooks_to_home()
553
+ except Exception as e:
554
+ hook_sync_warning = f"{e}"
555
+
556
+ client_sync_warning = None
557
+ _emit_progress(progress_fn, "Refreshing shared client configs...")
558
+ clients_ok, client_sync_error = _sync_packaged_clients()
559
+ if not clients_ok:
560
+ client_sync_warning = client_sync_error or "unknown client sync error"
561
+
483
562
  if errors:
484
563
  # 5. Full rollback: restore code tree + DBs + pip deps + rollback npm package
485
564
  if code_backup_dir:
@@ -516,6 +595,14 @@ def _handle_packaged_update(progress_fn=None) -> str:
516
595
  lines = ["UPDATE SUCCESSFUL (packaged install)"]
517
596
  lines.append(f" Version: {old_version} -> {new_version}")
518
597
  lines.append(f" Backup: {backup_dir}")
598
+ if not hook_sync_warning:
599
+ lines.append(" Hooks: synced to NEXO_HOME")
600
+ else:
601
+ lines.append(f" WARNING: hook sync: {hook_sync_warning}")
602
+ if not client_sync_warning:
603
+ lines.append(" Clients: configured client targets synced")
604
+ else:
605
+ lines.append(f" WARNING: client sync: {client_sync_warning}")
519
606
  lines.append("")
520
607
  lines.append("MCP server restart needed to load new code.")
521
608
  return "\n".join(lines)
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ """Shared helpers to resolve the managed NEXO home path."""
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def user_home() -> Path:
10
+ return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
11
+
12
+
13
+ def managed_nexo_home(*, home: Path | None = None) -> Path:
14
+ return (home or user_home()) / ".nexo"
15
+
16
+
17
+ def legacy_nexo_home(*, home: Path | None = None) -> Path:
18
+ return (home or user_home()) / "claude"
19
+
20
+
21
+ def resolve_nexo_home(value: str | os.PathLike[str] | None = None) -> Path:
22
+ home = user_home()
23
+ managed = managed_nexo_home(home=home)
24
+ candidate = Path(value).expanduser() if value else Path(
25
+ os.environ.get("NEXO_HOME", str(managed))
26
+ ).expanduser()
27
+ legacy = legacy_nexo_home(home=home)
28
+
29
+ if candidate == managed:
30
+ return managed
31
+ if candidate == legacy:
32
+ return managed if managed.exists() or legacy.is_symlink() else candidate
33
+
34
+ try:
35
+ if managed.exists() and candidate.resolve(strict=False) == managed.resolve(strict=False):
36
+ return managed
37
+ except Exception:
38
+ pass
39
+
40
+ return candidate
41
+
42
+
43
+ def export_resolved_nexo_home(value: str | os.PathLike[str] | None = None) -> Path:
44
+ resolved = resolve_nexo_home(value)
45
+ os.environ["NEXO_HOME"] = str(resolved)
46
+ return resolved