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/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -0
- package/bin/nexo-brain.js +30 -4
- package/bin/nexo.js +43 -7
- package/package.json +2 -1
- package/scripts/sync_release_artifacts.py +128 -0
- package/src/auto_update.py +66 -8
- package/src/bootstrap_docs.py +53 -2
- package/src/cli.py +225 -1
- package/src/client_sync.py +2 -1
- package/src/db/_personal_scripts.py +30 -8
- package/src/doctor/providers/runtime.py +37 -1
- package/src/hooks/session-start.sh +8 -4
- package/src/plugins/update.py +90 -3
- package/src/runtime_home.py +46 -0
- package/src/script_registry.py +18 -6
- package/src/scripts/nexo-cognitive-decay.py +1 -1
- package/src/scripts/nexo-cortex-cycle.py +2 -2
- package/src/scripts/nexo-hook-record.py +1 -1
- package/src/scripts/nexo-migrate.py +38 -10
- package/src/state_watchers_runtime.py +1 -1
- package/templates/CLAUDE.md.template +7 -1
- package/templates/CODEX.AGENTS.md.template +7 -1
- package/templates/nexo_helper.py +5 -1
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
|
-
|
|
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:
|
package/src/client_sync.py
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
136
|
-
|
|
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(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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(),
|
|
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 =
|
|
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
|
-
|
|
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
|
|
package/src/plugins/update.py
CHANGED
|
@@ -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 =
|
|
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 = [
|
|
334
|
-
|
|
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
|