nexo-brain 2.6.9 → 2.6.11

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
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.9",
3
+ "version": "2.6.11",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![GitHub stars](https://img.shields.io/github/stars/wazionapps/nexo?style=social)](https://github.com/wazionapps/nexo/stargazers)
7
7
  [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
8
8
 
9
- > Local cognitive runtime for Claude Code — persistent memory, overnight learning, runtime CLI, recovery-aware background jobs, startup preflight, and doctor diagnostics. 150+ MCP tools. Benchmarked on LoCoMo (F1 0.588, +55% vs GPT-4). Submitted to the Claude Code plugin marketplace.
9
+ > Local cognitive runtime for Claude Code — persistent memory, overnight learning, runtime CLI, recovery-aware background jobs, startup preflight, and doctor diagnostics. 150+ MCP tools. Benchmarked on LoCoMo (F1 0.588, +55% vs GPT-4). Claude Code plugin packaging included.
10
10
 
11
11
  **NEXO Brain transforms any MCP-compatible AI agent from a stateless assistant into a cognitive partner that remembers, learns, forgets, adapts, and builds a relationship with you over time.**
12
12
 
@@ -536,7 +536,7 @@ Files and areas that cause repeated errors accumulate a risk score (0.0–1.0).
536
536
  npx nexo-brain
537
537
  ```
538
538
 
539
- The installer handles everything:
539
+ The installer handles everything and syncs the same `nexo` MCP brain into Claude Code, Claude Desktop, and Codex when those clients are present:
540
540
 
541
541
  ```
542
542
  How should I call myself? (default: NEXO) > Atlas
@@ -570,6 +570,7 @@ After install, use the runtime CLI:
570
570
  nexo chat # Launch Claude Code with NEXO as operator
571
571
  nexo doctor # Check runtime health
572
572
  nexo update # Pull latest version and sync
573
+ nexo clients sync # Re-sync Claude Code/Desktop/Codex to the same brain
573
574
  nexo scripts list # See your personal scripts
574
575
  ```
575
576
 
@@ -583,7 +584,7 @@ Your operator will greet you immediately — adapted to the time of day, resumin
583
584
  |-----------|------|-------|
584
585
  | Cognitive engine | Python: fastembed, numpy, vector search | pip packages |
585
586
  | MCP server | 150+ tools for memory, cognition, learning, guard | NEXO_HOME/ |
586
- | Claude Code Plugin | Submitted to the Claude Code plugin marketplace (Anthropic) | `.claude-plugin/` |
587
+ | Claude Code Plugin | Marketplace-ready (packaging verified) | `.claude-plugin/` |
587
588
  | Plugins | Guard, episodic memory, cognitive memory, entities, preferences, update, etc. | Code: src/plugins/, Personal: NEXO_HOME/plugins/ |
588
589
  | Hooks (7) | SessionStart, Stop, PostToolUse, PreCompact, PostCompact | NEXO_HOME/hooks/ |
589
590
  | Nervous system | 13 core recovery-aware jobs + optional helpers (dashboard, prevent-sleep) | NEXO_HOME/scripts/ |
@@ -596,6 +597,7 @@ Your operator will greet you immediately — adapted to the time of day, resumin
596
597
  | Schedule config | schedule.json with customizable process times and timezone | NEXO_HOME/config/ |
597
598
  | Auto-update | Non-blocking startup check (5s max), opt-out via schedule.json | Built into server startup |
598
599
  | CLAUDE.md tracker | Version-tracked core sections with safe updates preserving customizations | Built into auto-update |
600
+ | Shared client sync | Same `nexo` MCP entry wired into Claude Code, Claude Desktop, and Codex | User config dirs |
599
601
  | Auto-diary | 3-layer system: PostToolUse every 10 calls, PreCompact emergency, heartbeat DIARY_OVERDUE | Built into hooks |
600
602
  | Claude Code config | MCP server + 7 hooks + 15 processes registered | ~/.claude/settings.json |
601
603
 
@@ -653,6 +655,7 @@ NEXO Brain separates **code** (immutable, in the repo or npm package) from **dat
653
655
  | `NEXO_HOME/data/` | SQLite databases (nexo.db, cognitive.db), migration state |
654
656
 
655
657
  The plugin loader scans `src/plugins/` first (base), then `NEXO_HOME/plugins/` (personal override by filename). This dual-directory approach lets you extend NEXO without forking the repo.
658
+ The client sync layer points Claude Code, Claude Desktop, and Codex at the same runtime and `NEXO_HOME`, so all three clients share one brain instead of drifting into separate local memories.
656
659
 
657
660
  ### 150+ MCP Tools across 21+ Categories
658
661
 
@@ -743,6 +746,14 @@ npx nexo-brain
743
746
 
744
747
  All 150+ tools are available immediately after installation. The installer configures Claude Code's `~/.claude/settings.json` automatically.
745
748
 
749
+ ### Claude Desktop
750
+
751
+ When Claude Desktop is installed, `nexo-brain`, `nexo update`, and `nexo clients sync` keep `claude_desktop_config.json` pointed at the same local NEXO runtime and `NEXO_HOME`.
752
+
753
+ ### Codex
754
+
755
+ When Codex CLI is available, `nexo-brain`, `nexo update`, and `nexo clients sync` register the same `nexo` MCP server via `codex mcp add`, so Codex uses the same local memory store as Claude Code and Claude Desktop.
756
+
746
757
  ### OpenClaw
747
758
 
748
759
  NEXO Brain also works as a cognitive memory backend for [OpenClaw](https://github.com/openclaw/openclaw):
@@ -812,7 +823,7 @@ NEXO Brain works with any application that supports the MCP protocol. Configure
812
823
  | mcpservers.org | MCP Directory | [mcpservers.org](https://mcpservers.org) |
813
824
  | OpenClaw | Native Plugin | [openclaw.com](https://openclaw.ai) |
814
825
  | dev.to | Technical Article | [How I Applied Cognitive Psychology to AI Agents](https://dev.to/wazionapps/how-i-applied-cognitive-psychology-to-give-ai-agents-real-memory-2oce) |
815
- | Claude Code | Plugin (pending review) | Submitted to Anthropic's plugin marketplace |
826
+ | Claude Code | Plugin (marketplace-ready) | Packaging verified, included in npm tarball |
816
827
  | nexo-brain.com | Official Website | [nexo-brain.com](https://nexo-brain.com) |
817
828
 
818
829
  ## Support the Project
@@ -828,6 +839,13 @@ If NEXO Brain is useful to you, consider:
828
839
 
829
840
  ## Changelog
830
841
 
842
+ ### v2.6.9 — Integration Sync, CI/CD Pipeline (2026-04-04)
843
+ - **Release artifact sync**: Automated version synchronization across Claude Code plugin, OpenClaw package, and ClawHub skill before every publish.
844
+ - **CI/CD pipeline**: Full GitHub Actions workflow for publish + verification of all integration channels.
845
+ - **OpenClaw plugin hardened**: Contract tests, correct runtime path, synchronized version. Published as @wazionapps/openclaw-memory-nexo-brain@2.6.9.
846
+ - **ClawHub skill hardened**: Version-synced metadata, correct server path, post-publish smoke verification.
847
+ - **Claude Code plugin packaging**: Verified plugin.json, .mcp.json, hooks included in npm tarball. Marketplace-ready.
848
+
831
849
  ### v2.6.5 — Power Helper Hardening, Recovery Contracts (2026-04-04)
832
850
  - Power helper semantics explicit and safer: `always_on` = platform helper for best-effort background availability.
833
851
  - Catch-up recovery suppresses duplicate relaunches for in-flight `cron_runs`.
@@ -915,7 +933,7 @@ If NEXO Brain is useful to you, consider:
915
933
  - **HNSW Vector Index**: Optional approximate nearest neighbor acceleration (auto-activates above 10,000 memories)
916
934
  - **Claim Graph**: Decomposes blob memories into atomic verifiable facts with provenance and contradiction detection
917
935
  - **Inter-terminal Auto-inbox (D+)**: `nexo_startup` accepts `claude_session_id` for automatic inbox delivery between parallel terminals
918
- - **Tests**: 24 pytest tests across 3 suites (cognitive, knowledge graph, migrations)
936
+ - **Tests**: 156 pytest tests across 3 suites (cognitive, knowledge graph, migrations)
919
937
 
920
938
  ### v1.4.1 — Multi-AI Code Review (2026-03-29)
921
939
  - **Fix**: 3 bugs found by GPT-5.4 (Codex CLI) + Gemini 2.5 (Gemini CLI) reviewing full codebase
package/bin/nexo-brain.js CHANGED
@@ -89,6 +89,44 @@ function syncWatchdogHashRegistry(nexoHome) {
89
89
  }
90
90
  }
91
91
 
92
+ function getCoreRuntimeFlatFiles() {
93
+ return [
94
+ "server.py",
95
+ "plugin_loader.py",
96
+ "knowledge_graph.py",
97
+ "kg_populate.py",
98
+ "maintenance.py",
99
+ "storage_router.py",
100
+ "claim_graph.py",
101
+ "hnsw_index.py",
102
+ "evolution_cycle.py",
103
+ "migrate_embeddings.py",
104
+ "auto_close_sessions.py",
105
+ "client_sync.py",
106
+ "auto_update.py",
107
+ "tools_sessions.py",
108
+ "tools_coordination.py",
109
+ "tools_reminders.py",
110
+ "tools_reminders_crud.py",
111
+ "tools_learnings.py",
112
+ "tools_credentials.py",
113
+ "tools_task_history.py",
114
+ "tools_menu.py",
115
+ "cli.py",
116
+ "script_registry.py",
117
+ "skills_runtime.py",
118
+ "user_context.py",
119
+ "public_contribution.py",
120
+ "cron_recovery.py",
121
+ "runtime_power.py",
122
+ "requirements.txt",
123
+ ];
124
+ }
125
+
126
+ function getCoreRuntimePackages() {
127
+ return ["db", "cognitive", "doctor"];
128
+ }
129
+
92
130
  function isProtectedMacPath(candidate) {
93
131
  if (process.platform !== "darwin" || !candidate) return false;
94
132
  const homeDir = require("os").homedir();
@@ -981,16 +1019,7 @@ async function main() {
981
1019
  log(" Hooks updated.");
982
1020
 
983
1021
  // Update core Python files (flat .py files in src/)
984
- const coreFlatFiles = [
985
- "server.py", "plugin_loader.py",
986
- "knowledge_graph.py", "kg_populate.py", "maintenance.py", "storage_router.py",
987
- "claim_graph.py", "hnsw_index.py", "evolution_cycle.py", "migrate_embeddings.py",
988
- "auto_close_sessions.py", "auto_update.py",
989
- "tools_sessions.py", "tools_coordination.py", "tools_reminders.py",
990
- "tools_reminders_crud.py", "tools_learnings.py", "tools_credentials.py",
991
- "tools_task_history.py", "tools_menu.py",
992
- "requirements.txt",
993
- ];
1022
+ const coreFlatFiles = getCoreRuntimeFlatFiles();
994
1023
  coreFlatFiles.forEach((f) => {
995
1024
  const src = path.join(srcDir, f);
996
1025
  if (fs.existsSync(src)) {
@@ -998,7 +1027,7 @@ async function main() {
998
1027
  }
999
1028
  });
1000
1029
  // Update core packages (db/, cognitive/) — full directory copy
1001
- ["db", "cognitive"].forEach(pkg => {
1030
+ getCoreRuntimePackages().forEach(pkg => {
1002
1031
  const pkgSrc = path.join(srcDir, pkg);
1003
1032
  if (fs.existsSync(pkgSrc)) {
1004
1033
  copyDirRec(pkgSrc, path.join(NEXO_HOME, pkg));
@@ -1752,32 +1781,7 @@ async function main() {
1752
1781
  };
1753
1782
 
1754
1783
  // Core flat files (single .py files in src/)
1755
- const coreFiles = [
1756
- "server.py",
1757
- "plugin_loader.py",
1758
- "knowledge_graph.py",
1759
- "kg_populate.py",
1760
- "maintenance.py",
1761
- "storage_router.py",
1762
- "claim_graph.py",
1763
- "hnsw_index.py",
1764
- "evolution_cycle.py",
1765
- "migrate_embeddings.py",
1766
- "auto_close_sessions.py",
1767
- "auto_update.py",
1768
- "tools_sessions.py",
1769
- "tools_coordination.py",
1770
- "tools_reminders.py",
1771
- "tools_reminders_crud.py",
1772
- "tools_learnings.py",
1773
- "tools_credentials.py",
1774
- "tools_task_history.py",
1775
- "tools_menu.py",
1776
- "requirements.txt",
1777
- "cli.py",
1778
- "script_registry.py",
1779
- "skills_runtime.py",
1780
- ];
1784
+ const coreFiles = getCoreRuntimeFlatFiles();
1781
1785
  coreFiles.forEach((f) => {
1782
1786
  const src = path.join(srcDir, f);
1783
1787
  if (fs.existsSync(src)) {
@@ -1810,7 +1814,7 @@ async function main() {
1810
1814
 
1811
1815
  log("Copying core packages...");
1812
1816
  // Core packages (directories with __init__.py)
1813
- ["db", "cognitive", "doctor"].forEach(pkg => {
1817
+ getCoreRuntimePackages().forEach(pkg => {
1814
1818
  const pkgSrc = path.join(srcDir, pkg);
1815
1819
  if (fs.existsSync(pkgSrc)) {
1816
1820
  copyDirRecursive(pkgSrc, path.join(NEXO_HOME, pkg));
@@ -2366,6 +2370,40 @@ ${doScan ? `- Stack: ${Object.keys(profileData.code.languages || {}).slice(0, 5)
2366
2370
  fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
2367
2371
  log("MCP server + 8 core hooks configured in Claude Code settings.");
2368
2372
 
2373
+ const syncClientsScript = path.join(NEXO_HOME, "scripts", "nexo-sync-clients.py");
2374
+ if (fs.existsSync(syncClientsScript)) {
2375
+ const syncResult = spawnSync(
2376
+ python,
2377
+ [
2378
+ syncClientsScript,
2379
+ "--nexo-home", NEXO_HOME,
2380
+ "--runtime-root", NEXO_HOME,
2381
+ "--python", python,
2382
+ "--operator-name", operatorName,
2383
+ "--json",
2384
+ ],
2385
+ { encoding: "utf8" }
2386
+ );
2387
+ if (syncResult.status === 0) {
2388
+ try {
2389
+ const payload = JSON.parse(syncResult.stdout || "{}");
2390
+ const clients = payload.clients || {};
2391
+ const fmt = (name, key) => {
2392
+ const item = clients[key] || {};
2393
+ if (item.skipped) return `${name}: skipped`;
2394
+ if (item.ok) return `${name}: synced`;
2395
+ return `${name}: warning`;
2396
+ };
2397
+ log(`Shared brain client sync complete (${fmt("Claude Code", "claude_code")}, ${fmt("Claude Desktop", "claude_desktop")}, ${fmt("Codex", "codex")}).`);
2398
+ } catch {
2399
+ log("Shared brain client sync complete.");
2400
+ }
2401
+ } else {
2402
+ const errMsg = (syncResult.stderr || syncResult.stdout || "").trim();
2403
+ log(`WARN: shared brain client sync failed: ${errMsg || "unknown error"}`);
2404
+ }
2405
+ }
2406
+
2369
2407
  // Step 7: Create schedule.json (only on fresh install) and install core processes
2370
2408
  log("Setting up automated processes...");
2371
2409
  let schedule = loadOrCreateSchedule(NEXO_HOME);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.9",
3
+ "version": "2.6.11",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO — local cognitive runtime for Claude Code. Persistent memory, overnight learning, recovery-aware crons, personal scripts, doctor diagnostics, startup preflight, and optional power helper.",
6
6
  "bin": {
@@ -1197,10 +1197,12 @@ def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
1197
1197
  "server.py", "plugin_loader.py", "knowledge_graph.py", "kg_populate.py",
1198
1198
  "maintenance.py", "storage_router.py", "claim_graph.py", "hnsw_index.py",
1199
1199
  "evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
1200
+ "client_sync.py",
1200
1201
  "auto_update.py", "tools_sessions.py", "tools_coordination.py",
1201
1202
  "tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
1202
1203
  "tools_credentials.py", "tools_task_history.py", "tools_menu.py",
1203
1204
  "cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
1205
+ "public_contribution.py",
1204
1206
  "cron_recovery.py", "runtime_power.py", "requirements.txt", "package.json", "version.json",
1205
1207
  ]
1206
1208
  for name in code_dirs:
@@ -1244,10 +1246,12 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1244
1246
  "server.py", "plugin_loader.py", "knowledge_graph.py", "kg_populate.py",
1245
1247
  "maintenance.py", "storage_router.py", "claim_graph.py", "hnsw_index.py",
1246
1248
  "evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
1249
+ "client_sync.py",
1247
1250
  "auto_update.py", "tools_sessions.py", "tools_coordination.py",
1248
1251
  "tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
1249
1252
  "tools_credentials.py", "tools_task_history.py", "tools_menu.py",
1250
1253
  "cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
1254
+ "public_contribution.py",
1251
1255
  "cron_recovery.py", "runtime_power.py", "requirements.txt",
1252
1256
  ]
1253
1257
  copied_packages = 0
@@ -1381,12 +1385,14 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
1381
1385
  sys.executable,
1382
1386
  "-c",
1383
1387
  (
1388
+ "import json; "
1384
1389
  "import db; "
1385
1390
  "init_db = getattr(db, 'init_db', None); "
1386
1391
  "init_db() if callable(init_db) else None; "
1387
1392
  "import script_registry; "
1388
1393
  "reconcile_scripts = getattr(script_registry, 'reconcile_personal_scripts', None); "
1389
- "reconcile_scripts(dry_run=False) if callable(reconcile_scripts) else None"
1394
+ "result = reconcile_scripts(dry_run=False) if callable(reconcile_scripts) else {}; "
1395
+ "print(json.dumps(result))"
1390
1396
  ),
1391
1397
  ],
1392
1398
  cwd=str(dest),
@@ -1398,6 +1404,11 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
1398
1404
  if init_result.returncode != 0:
1399
1405
  return False, [init_result.stderr.strip() or init_result.stdout.strip() or "runtime init failed"]
1400
1406
  actions.append("db+personal-sync")
1407
+ reconcile_payload = _parse_runtime_init_payload(init_result.stdout or "")
1408
+ extra_actions, reconcile_message = _personal_schedule_reconcile_summary(reconcile_payload)
1409
+ actions.extend(extra_actions)
1410
+ if reconcile_message:
1411
+ _emit_progress(progress_fn, reconcile_message)
1401
1412
  except Exception as e:
1402
1413
  return False, [f"runtime init error: {e}"]
1403
1414
 
@@ -1432,6 +1443,18 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
1432
1443
  if power_result.get("ok"):
1433
1444
  actions.append(f"power:{power_result.get('action')}")
1434
1445
 
1446
+ _emit_progress(progress_fn, "Refreshing shared client configs...")
1447
+ try:
1448
+ from client_sync import sync_all_clients
1449
+
1450
+ client_sync_result = sync_all_clients(nexo_home=dest, runtime_root=dest)
1451
+ if client_sync_result.get("ok"):
1452
+ actions.append("client-sync")
1453
+ else:
1454
+ actions.append("client-sync-warning")
1455
+ except Exception as e:
1456
+ actions.append(f"client-sync-warning:{e}")
1457
+
1435
1458
  _emit_progress(progress_fn, "Verifying runtime imports...")
1436
1459
  verify = subprocess.run(
1437
1460
  [sys.executable, "-c", "import server"],
@@ -1479,6 +1502,46 @@ def _emit_progress(progress_fn, message: str) -> None:
1479
1502
  pass
1480
1503
 
1481
1504
 
1505
+ def _parse_runtime_init_payload(stdout: str) -> dict:
1506
+ """Extract the JSON payload emitted by the runtime init helper."""
1507
+ lines = [line.strip() for line in stdout.splitlines() if line.strip()]
1508
+ for line in reversed(lines):
1509
+ try:
1510
+ payload = json.loads(line)
1511
+ except Exception:
1512
+ continue
1513
+ if isinstance(payload, dict):
1514
+ return payload
1515
+ return {}
1516
+
1517
+
1518
+ def _personal_schedule_reconcile_summary(reconcile_result: dict) -> tuple[list[str], str | None]:
1519
+ """Turn reconcile_personal_scripts() output into stable update actions."""
1520
+ if not isinstance(reconcile_result, dict):
1521
+ return [], None
1522
+
1523
+ ensured = reconcile_result.get("ensure_schedules", {})
1524
+ if not isinstance(ensured, dict):
1525
+ return [], None
1526
+
1527
+ created = len(ensured.get("created", []) or [])
1528
+ repaired = len(ensured.get("repaired", []) or [])
1529
+ invalid = len(ensured.get("invalid", []) or [])
1530
+
1531
+ actions: list[str] = []
1532
+ parts: list[str] = []
1533
+ if created or repaired:
1534
+ actions.append(f"personal-schedules-healed:{created + repaired}")
1535
+ parts.append(f"{created} created")
1536
+ parts.append(f"{repaired} repaired")
1537
+ if invalid:
1538
+ actions.append(f"personal-schedules-invalid:{invalid}")
1539
+ parts.append(f"{invalid} invalid")
1540
+ if not parts:
1541
+ return [], None
1542
+ return actions, "Personal schedules: " + ", ".join(parts) + "."
1543
+
1544
+
1482
1545
  def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = True, progress_fn=None) -> dict:
1483
1546
  src_dir, repo_dir = _resolve_sync_source()
1484
1547
  if src_dir is None or repo_dir is None:
@@ -1585,8 +1648,12 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
1585
1648
  _ensure_runtime_cli_wrapper()
1586
1649
  _ensure_runtime_cli_in_shell()
1587
1650
  init_db()
1588
- reconcile_personal_scripts(dry_run=False)
1651
+ reconcile_result = reconcile_personal_scripts(dry_run=False)
1589
1652
  result["actions"].append("db+personal-sync")
1653
+ extra_actions, reconcile_message = _personal_schedule_reconcile_summary(reconcile_result)
1654
+ result["actions"].extend(extra_actions)
1655
+ if reconcile_message:
1656
+ _log(reconcile_message)
1590
1657
  except Exception as e:
1591
1658
  result["error"] = str(e)
1592
1659
  _write_update_summary(result)
package/src/cli.py CHANGED
@@ -22,6 +22,7 @@ Entry points:
22
22
  nexo skills approve ID [--execution-level ...] [--approved-by ...] [--json]
23
23
  nexo skills featured [--limit N] [--json]
24
24
  nexo skills evolution [--json]
25
+ nexo clients sync [--json]
25
26
  nexo contributor status|on|off [--json]
26
27
  nexo doctor [--tier boot|runtime|deep|all] [--json] [--fix]
27
28
  """
@@ -63,6 +64,123 @@ if str(NEXO_CODE) not in sys.path:
63
64
  sys.path.insert(0, str(NEXO_CODE))
64
65
 
65
66
 
67
+ def _missing_runtime_module_message(module_name: str, exc: Exception) -> str:
68
+ missing = getattr(exc, "name", None) or module_name
69
+ return (
70
+ f"{module_name} is unavailable in the current runtime ({missing}). "
71
+ "Continuing with safe defaults so `nexo update` can repair the installation."
72
+ )
73
+
74
+
75
+ def _load_runtime_power_support() -> dict:
76
+ try:
77
+ from runtime_power import (
78
+ ensure_power_policy_choice,
79
+ apply_power_policy,
80
+ format_power_policy_label,
81
+ ensure_full_disk_access_choice,
82
+ format_full_disk_access_label,
83
+ )
84
+ return {
85
+ "available": True,
86
+ "message": "",
87
+ "ensure_power_policy_choice": ensure_power_policy_choice,
88
+ "apply_power_policy": apply_power_policy,
89
+ "format_power_policy_label": format_power_policy_label,
90
+ "ensure_full_disk_access_choice": ensure_full_disk_access_choice,
91
+ "format_full_disk_access_label": format_full_disk_access_label,
92
+ }
93
+ except ImportError as exc:
94
+ message = _missing_runtime_module_message("runtime_power", exc)
95
+
96
+ def ensure_power_policy_choice(**kwargs):
97
+ return {"policy": "disabled", "prompted": False, "message": message}
98
+
99
+ def apply_power_policy(policy=None):
100
+ return {"ok": True, "action": "skipped", "details": [], "message": message}
101
+
102
+ def format_power_policy_label(policy):
103
+ return policy or "disabled"
104
+
105
+ def ensure_full_disk_access_choice(**kwargs):
106
+ return {"status": "unset", "prompted": False, "reasons": [], "message": message}
107
+
108
+ def format_full_disk_access_label(status):
109
+ return status or "unset"
110
+
111
+ return {
112
+ "available": False,
113
+ "message": message,
114
+ "ensure_power_policy_choice": ensure_power_policy_choice,
115
+ "apply_power_policy": apply_power_policy,
116
+ "format_power_policy_label": format_power_policy_label,
117
+ "ensure_full_disk_access_choice": ensure_full_disk_access_choice,
118
+ "format_full_disk_access_label": format_full_disk_access_label,
119
+ }
120
+
121
+
122
+ def _load_public_contribution_support() -> dict:
123
+ try:
124
+ from public_contribution import (
125
+ ensure_public_contribution_choice,
126
+ format_public_contribution_label,
127
+ load_public_contribution_config,
128
+ refresh_public_contribution_state,
129
+ disable_public_contribution,
130
+ )
131
+ return {
132
+ "available": True,
133
+ "message": "",
134
+ "ensure_public_contribution_choice": ensure_public_contribution_choice,
135
+ "format_public_contribution_label": format_public_contribution_label,
136
+ "load_public_contribution_config": load_public_contribution_config,
137
+ "refresh_public_contribution_state": refresh_public_contribution_state,
138
+ "disable_public_contribution": disable_public_contribution,
139
+ }
140
+ except ImportError as exc:
141
+ message = _missing_runtime_module_message("public_contribution", exc)
142
+
143
+ def _default_config(config=None):
144
+ payload = {
145
+ "enabled": False,
146
+ "mode": "disabled",
147
+ "status": "unavailable",
148
+ "prompted": False,
149
+ "message": message,
150
+ }
151
+ if isinstance(config, dict):
152
+ payload.update(config)
153
+ return payload
154
+
155
+ def ensure_public_contribution_choice(**kwargs):
156
+ return _default_config()
157
+
158
+ def format_public_contribution_label(config=None):
159
+ cfg = _default_config(config)
160
+ if cfg.get("status") == "unavailable":
161
+ return "disabled (runtime repair needed)"
162
+ return cfg.get("mode") or "disabled"
163
+
164
+ def load_public_contribution_config():
165
+ return _default_config()
166
+
167
+ def refresh_public_contribution_state(config=None):
168
+ return _default_config(config)
169
+
170
+ def disable_public_contribution():
171
+ return _default_config()
172
+
173
+ return {
174
+ "available": False,
175
+ "message": message,
176
+ "ensure_public_contribution_choice": ensure_public_contribution_choice,
177
+ "format_public_contribution_label": format_public_contribution_label,
178
+ "load_public_contribution_config": load_public_contribution_config,
179
+ "refresh_public_contribution_state": refresh_public_contribution_state,
180
+ "disable_public_contribution": disable_public_contribution,
181
+ }
182
+
183
+
66
184
  def _scripts_list(args):
67
185
  from db import init_db, list_personal_scripts
68
186
  from script_registry import list_scripts, sync_personal_scripts
@@ -452,17 +570,6 @@ def _update(args):
452
570
  - Packaged/runtime-only install: delegate to plugins.update handle_update()
453
571
  """
454
572
  from auto_update import manual_sync_update, _resolve_sync_source
455
- from runtime_power import (
456
- ensure_power_policy_choice,
457
- apply_power_policy,
458
- format_power_policy_label,
459
- ensure_full_disk_access_choice,
460
- format_full_disk_access_label,
461
- )
462
- from public_contribution import (
463
- ensure_public_contribution_choice,
464
- format_public_contribution_label,
465
- )
466
573
 
467
574
  interactive = sys.stdin.isatty() and sys.stdout.isatty()
468
575
  progress_messages: list[str] = []
@@ -487,10 +594,12 @@ def _update(args):
487
594
  return 1
488
595
 
489
596
  result = handle_update(progress_fn=progress)
490
- choice = ensure_power_policy_choice(interactive=interactive, reason="update")
491
- power_result = apply_power_policy(choice.get("policy"))
492
- fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
493
- contrib_choice = ensure_public_contribution_choice(interactive=interactive, reason="update")
597
+ runtime_power = _load_runtime_power_support()
598
+ public_contribution = _load_public_contribution_support()
599
+ choice = runtime_power["ensure_power_policy_choice"](interactive=interactive, reason="update")
600
+ power_result = runtime_power["apply_power_policy"](choice.get("policy"))
601
+ fda_choice = runtime_power["ensure_full_disk_access_choice"](interactive=interactive, reason="update")
602
+ contrib_choice = public_contribution["ensure_public_contribution_choice"](interactive=interactive, reason="update")
494
603
  if args.json:
495
604
  print(json.dumps({
496
605
  "mode": "packaged",
@@ -509,24 +618,26 @@ def _update(args):
509
618
  else:
510
619
  print(result)
511
620
  if choice.get("prompted"):
512
- print(f"Power policy: {format_power_policy_label(choice.get('policy'))}")
621
+ print(f"Power policy: {runtime_power['format_power_policy_label'](choice.get('policy'))}")
513
622
  if power_result.get("message"):
514
623
  print(f"Power helper: {power_result.get('message')}")
515
624
  if fda_choice.get("prompted"):
516
- print(f"Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
625
+ print(f"Full Disk Access: {runtime_power['format_full_disk_access_label'](fda_choice.get('status'))}")
517
626
  if fda_choice.get("message"):
518
627
  print(f"Full Disk Access: {fda_choice.get('message')}")
519
628
  if contrib_choice.get("prompted"):
520
- print(f"Contributor mode: {format_public_contribution_label(contrib_choice)}")
629
+ print(f"Contributor mode: {public_contribution['format_public_contribution_label'](contrib_choice)}")
521
630
  if contrib_choice.get("message"):
522
631
  print(f"Contributor mode: {contrib_choice.get('message')}")
523
632
  return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
524
633
 
525
- choice = ensure_power_policy_choice(interactive=interactive, reason="update")
526
- power_result = apply_power_policy(choice.get("policy"))
527
- fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
528
- contrib_choice = ensure_public_contribution_choice(interactive=interactive, reason="update")
529
634
  result = manual_sync_update(interactive=interactive, allow_source_pull=True, progress_fn=progress)
635
+ runtime_power = _load_runtime_power_support()
636
+ public_contribution = _load_public_contribution_support()
637
+ choice = runtime_power["ensure_power_policy_choice"](interactive=interactive, reason="update")
638
+ power_result = runtime_power["apply_power_policy"](choice.get("policy"))
639
+ fda_choice = runtime_power["ensure_full_disk_access_choice"](interactive=interactive, reason="update")
640
+ contrib_choice = public_contribution["ensure_public_contribution_choice"](interactive=interactive, reason="update")
530
641
  result["power_policy"] = choice.get("policy")
531
642
  result["power_action"] = power_result.get("action")
532
643
  result["power_details"] = power_result.get("details")
@@ -550,18 +661,35 @@ def _update(args):
550
661
  f" {result.get('packages', 0)} packages, {result.get('files', 0)} files synced from "
551
662
  f"{result.get('source', src_dir)}"
552
663
  )
664
+ healed = 0
665
+ invalid = 0
666
+ for action in result.get("actions", []):
667
+ if action.startswith("personal-schedules-healed:"):
668
+ try:
669
+ healed += int(action.split(":", 1)[1])
670
+ except ValueError:
671
+ pass
672
+ elif action.startswith("personal-schedules-invalid:"):
673
+ try:
674
+ invalid += int(action.split(":", 1)[1])
675
+ except ValueError:
676
+ pass
677
+ if healed:
678
+ print(f" Personal schedules: self-healed {healed}")
679
+ if invalid:
680
+ print(f" Personal schedules: {invalid} declarations need review")
553
681
  if result.get("pulled_source"):
554
682
  print(" Source repo: pulled latest fast-forward before sync")
555
683
  if choice.get("prompted"):
556
- print(f" Power policy: {format_power_policy_label(choice.get('policy'))}")
684
+ print(f" Power policy: {runtime_power['format_power_policy_label'](choice.get('policy'))}")
557
685
  if power_result.get("message"):
558
686
  print(f" Power helper: {power_result.get('message')}")
559
687
  if fda_choice.get("prompted"):
560
- print(f" Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
688
+ print(f" Full Disk Access: {runtime_power['format_full_disk_access_label'](fda_choice.get('status'))}")
561
689
  if fda_choice.get("message"):
562
690
  print(f" Full Disk Access: {fda_choice.get('message')}")
563
691
  if contrib_choice.get("prompted"):
564
- print(f" Contributor mode: {format_public_contribution_label(contrib_choice)}")
692
+ print(f" Contributor mode: {public_contribution['format_public_contribution_label'](contrib_choice)}")
565
693
  if contrib_choice.get("message"):
566
694
  print(f" Contributor mode: {contrib_choice.get('message')}")
567
695
  else:
@@ -569,30 +697,41 @@ def _update(args):
569
697
  return 0 if result.get("ok") else 1
570
698
 
571
699
 
700
+ def _clients_sync(args):
701
+ from client_sync import format_sync_summary, sync_all_clients
702
+
703
+ result = sync_all_clients(nexo_home=NEXO_HOME, runtime_root=NEXO_CODE)
704
+ if args.json:
705
+ print(json.dumps(result, indent=2, ensure_ascii=False))
706
+ else:
707
+ print(format_sync_summary(result))
708
+ return 0 if result.get("ok") else 1
709
+
710
+
572
711
  def _contributor_status(args):
573
- from public_contribution import (
574
- format_public_contribution_label,
575
- load_public_contribution_config,
576
- refresh_public_contribution_state,
712
+ public_contribution = _load_public_contribution_support()
713
+ config = public_contribution["refresh_public_contribution_state"](
714
+ public_contribution["load_public_contribution_config"]()
577
715
  )
578
-
579
- config = refresh_public_contribution_state(load_public_contribution_config())
580
716
  payload = {
581
717
  "enabled": bool(config.get("enabled")),
582
718
  "mode": config.get("mode"),
583
719
  "status": config.get("status"),
584
- "label": format_public_contribution_label(config),
720
+ "label": public_contribution["format_public_contribution_label"](config),
585
721
  "github_user": config.get("github_user"),
586
722
  "fork_repo": config.get("fork_repo"),
587
723
  "active_pr_url": config.get("active_pr_url"),
588
724
  "active_branch": config.get("active_branch"),
589
725
  "cooldown_until": config.get("cooldown_until"),
590
726
  "last_result": config.get("last_result"),
727
+ "message": config.get("message") or public_contribution.get("message"),
591
728
  }
592
729
  if args.json:
593
730
  print(json.dumps(payload, indent=2, ensure_ascii=False))
594
731
  else:
595
732
  print(f"Contributor mode: {payload['label']}")
733
+ if payload["message"]:
734
+ print(f" {payload['message']}")
596
735
  if payload["github_user"]:
597
736
  print(f" GitHub user: {payload['github_user']}")
598
737
  if payload["fork_repo"]:
@@ -607,30 +746,40 @@ def _contributor_status(args):
607
746
 
608
747
 
609
748
  def _contributor_on(args):
610
- from public_contribution import ensure_public_contribution_choice, format_public_contribution_label
749
+ public_contribution = _load_public_contribution_support()
611
750
 
612
751
  interactive = sys.stdin.isatty() and sys.stdout.isatty()
613
752
  if not interactive:
614
753
  print("Contributor mode requires an interactive terminal to confirm GitHub Draft PR consent.", file=sys.stderr)
615
754
  return 1
616
- config = ensure_public_contribution_choice(interactive=True, reason="contributor", force_prompt=True)
755
+ if not public_contribution["available"]:
756
+ print(public_contribution["message"], file=sys.stderr)
757
+ return 1
758
+ config = public_contribution["ensure_public_contribution_choice"](
759
+ interactive=True,
760
+ reason="contributor",
761
+ force_prompt=True,
762
+ )
617
763
  if args.json:
618
764
  print(json.dumps(config, indent=2, ensure_ascii=False))
619
765
  else:
620
- print(f"Contributor mode: {format_public_contribution_label(config)}")
766
+ print(f"Contributor mode: {public_contribution['format_public_contribution_label'](config)}")
621
767
  if config.get("message"):
622
768
  print(config.get("message"))
623
769
  return 0 if config.get("mode") == "draft_prs" else 1
624
770
 
625
771
 
626
772
  def _contributor_off(args):
627
- from public_contribution import disable_public_contribution, format_public_contribution_label
773
+ public_contribution = _load_public_contribution_support()
628
774
 
629
- config = disable_public_contribution()
775
+ if not public_contribution["available"]:
776
+ print(public_contribution["message"], file=sys.stderr)
777
+ return 1
778
+ config = public_contribution["disable_public_contribution"]()
630
779
  if args.json:
631
780
  print(json.dumps(config, indent=2, ensure_ascii=False))
632
781
  else:
633
- print(f"Contributor mode: {format_public_contribution_label(config)}")
782
+ print(f"Contributor mode: {public_contribution['format_public_contribution_label'](config)}")
634
783
  return 0
635
784
 
636
785
 
@@ -861,6 +1010,7 @@ Commands:
861
1010
  nexo scripts list|create|classify|sync|reconcile|ensure-schedules|schedules|run|doctor|call|unschedule|remove
862
1011
  Personal scripts
863
1012
  nexo skills list|apply|sync|approve Executable skills
1013
+ nexo clients sync Sync Claude Code/Desktop/Codex MCP configs
864
1014
  nexo update Update installed runtime
865
1015
  nexo contributor status|on|off Public Draft PR contribution mode
866
1016
  nexo dashboard on|off|status Web dashboard control
@@ -950,6 +1100,12 @@ def main():
950
1100
  update_parser = sub.add_parser("update", help="Update installed runtime")
951
1101
  update_parser.add_argument("--json", action="store_true", help="JSON output")
952
1102
 
1103
+ # -- clients --
1104
+ clients_parser = sub.add_parser("clients", help="Shared client config management")
1105
+ clients_sub = clients_parser.add_subparsers(dest="clients_command")
1106
+ clients_sync_p = clients_sub.add_parser("sync", help="Sync Claude Code, Claude Desktop, and Codex to the same NEXO brain")
1107
+ clients_sync_p.add_argument("--json", action="store_true", help="JSON output")
1108
+
953
1109
  # -- doctor --
954
1110
  doctor_parser = sub.add_parser("doctor", help="Unified diagnostics")
955
1111
  doctor_parser.add_argument("--tier", default="boot", choices=["boot", "runtime", "deep", "all"],
@@ -1045,6 +1201,11 @@ def main():
1045
1201
  return _chat(args)
1046
1202
  elif args.command == "update":
1047
1203
  return _update(args)
1204
+ elif args.command == "clients":
1205
+ if args.clients_command == "sync":
1206
+ return _clients_sync(args)
1207
+ clients_parser.print_help()
1208
+ return 0
1048
1209
  elif args.command == "doctor":
1049
1210
  return _doctor(args)
1050
1211
  elif args.command == "contributor":
@@ -0,0 +1,327 @@
1
+ from __future__ import annotations
2
+
3
+ """Shared client sync for Claude Code, Claude Desktop, and Codex."""
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+
14
+ def _user_home() -> Path:
15
+ return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
16
+
17
+
18
+ def _default_nexo_home() -> Path:
19
+ return Path(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo"))).expanduser()
20
+
21
+
22
+ def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
23
+ explicit = (explicit or "").strip()
24
+ if explicit:
25
+ return explicit
26
+ env_name = os.environ.get("NEXO_NAME", "").strip()
27
+ if env_name:
28
+ return env_name
29
+ version_file = nexo_home / "version.json"
30
+ if version_file.is_file():
31
+ try:
32
+ return str(json.loads(version_file.read_text()).get("operator_name", "")).strip()
33
+ except Exception:
34
+ pass
35
+ return ""
36
+
37
+
38
+ def _resolve_runtime_root(nexo_home: Path, runtime_root: str | os.PathLike[str] | None = None) -> Path:
39
+ candidates: list[Path] = []
40
+ if runtime_root:
41
+ candidates.append(Path(runtime_root).expanduser())
42
+ code_env = os.environ.get("NEXO_CODE", "").strip()
43
+ if code_env:
44
+ code_path = Path(code_env).expanduser()
45
+ candidates.extend([code_path, code_path / "src"])
46
+ candidates.extend([nexo_home, Path.cwd(), Path.cwd() / "src"])
47
+
48
+ seen: set[Path] = set()
49
+ for candidate in candidates:
50
+ resolved = candidate.resolve()
51
+ if resolved in seen:
52
+ continue
53
+ seen.add(resolved)
54
+ if (resolved / "server.py").is_file():
55
+ return resolved
56
+ raise FileNotFoundError(f"Could not locate runtime root with server.py (tried {len(seen)} locations)")
57
+
58
+
59
+ def _resolve_python(nexo_home: Path, explicit: str = "") -> str:
60
+ candidates = [
61
+ explicit,
62
+ str(nexo_home / ".venv" / "bin" / "python3"),
63
+ str(nexo_home / ".venv" / "bin" / "python"),
64
+ str(nexo_home / ".venv" / "Scripts" / "python.exe"),
65
+ shutil.which("python3") or "",
66
+ shutil.which("python") or "",
67
+ sys.executable,
68
+ ]
69
+ for candidate in candidates:
70
+ if candidate and Path(candidate).exists():
71
+ return str(Path(candidate))
72
+ return explicit or sys.executable
73
+
74
+
75
+ def build_server_config(
76
+ *,
77
+ nexo_home: str | os.PathLike[str] | None = None,
78
+ runtime_root: str | os.PathLike[str] | None = None,
79
+ python_path: str = "",
80
+ operator_name: str = "",
81
+ ) -> dict:
82
+ nexo_home_path = Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()
83
+ runtime_root_path = _resolve_runtime_root(nexo_home_path, runtime_root)
84
+ config = {
85
+ "command": _resolve_python(nexo_home_path, python_path),
86
+ "args": [str(runtime_root_path / "server.py")],
87
+ "env": {
88
+ "NEXO_HOME": str(nexo_home_path),
89
+ "NEXO_CODE": str(runtime_root_path),
90
+ },
91
+ }
92
+ resolved_name = _resolve_operator_name(nexo_home_path, explicit=operator_name)
93
+ if resolved_name:
94
+ config["env"]["NEXO_NAME"] = resolved_name
95
+ return config
96
+
97
+
98
+ def _claude_code_settings_path(home: Path | None = None) -> Path:
99
+ base = home or _user_home()
100
+ return base / ".claude" / "settings.json"
101
+
102
+
103
+ def _claude_desktop_config_path(home: Path | None = None) -> Path:
104
+ base = home or _user_home()
105
+ if sys.platform == "darwin":
106
+ return base / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
107
+ if os.name == "nt":
108
+ return base / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json"
109
+ return base / ".config" / "Claude" / "claude_desktop_config.json"
110
+
111
+
112
+ def _codex_config_path(home: Path | None = None) -> Path:
113
+ base = home or _user_home()
114
+ return base / ".codex" / "config.toml"
115
+
116
+
117
+ def _load_json_object(path: Path) -> dict:
118
+ if not path.is_file():
119
+ return {}
120
+ try:
121
+ data = json.loads(path.read_text())
122
+ except Exception as exc:
123
+ raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
124
+ if not isinstance(data, dict):
125
+ raise ValueError(f"Expected JSON object in {path}")
126
+ return data
127
+
128
+
129
+ def _write_json_object(path: Path, payload: dict) -> None:
130
+ path.parent.mkdir(parents=True, exist_ok=True)
131
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
132
+
133
+
134
+ def _sync_json_client(path: Path, server_config: dict, label: str) -> dict:
135
+ payload = _load_json_object(path)
136
+ mcp_servers = payload.setdefault("mcpServers", {})
137
+ if not isinstance(mcp_servers, dict):
138
+ mcp_servers = {}
139
+ payload["mcpServers"] = mcp_servers
140
+ action = "updated" if "nexo" in mcp_servers else "created"
141
+ mcp_servers["nexo"] = server_config
142
+ _write_json_object(path, payload)
143
+ return {
144
+ "ok": True,
145
+ "client": label,
146
+ "action": action,
147
+ "path": str(path),
148
+ }
149
+
150
+
151
+ def sync_claude_code(
152
+ *,
153
+ nexo_home: str | os.PathLike[str] | None = None,
154
+ runtime_root: str | os.PathLike[str] | None = None,
155
+ python_path: str = "",
156
+ operator_name: str = "",
157
+ user_home: str | os.PathLike[str] | None = None,
158
+ ) -> dict:
159
+ server_config = build_server_config(
160
+ nexo_home=nexo_home,
161
+ runtime_root=runtime_root,
162
+ python_path=python_path,
163
+ operator_name=operator_name,
164
+ )
165
+ return _sync_json_client(
166
+ _claude_code_settings_path(Path(user_home).expanduser() if user_home else None),
167
+ server_config,
168
+ "claude_code",
169
+ )
170
+
171
+
172
+ def sync_claude_desktop(
173
+ *,
174
+ nexo_home: str | os.PathLike[str] | None = None,
175
+ runtime_root: str | os.PathLike[str] | None = None,
176
+ python_path: str = "",
177
+ operator_name: str = "",
178
+ user_home: str | os.PathLike[str] | None = None,
179
+ ) -> dict:
180
+ server_config = build_server_config(
181
+ nexo_home=nexo_home,
182
+ runtime_root=runtime_root,
183
+ python_path=python_path,
184
+ operator_name=operator_name,
185
+ )
186
+ return _sync_json_client(
187
+ _claude_desktop_config_path(Path(user_home).expanduser() if user_home else None),
188
+ server_config,
189
+ "claude_desktop",
190
+ )
191
+
192
+
193
+ def sync_codex(
194
+ *,
195
+ nexo_home: str | os.PathLike[str] | None = None,
196
+ runtime_root: str | os.PathLike[str] | None = None,
197
+ python_path: str = "",
198
+ operator_name: str = "",
199
+ user_home: str | os.PathLike[str] | None = None,
200
+ ) -> dict:
201
+ nexo_home_path = Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()
202
+ home_path = Path(user_home).expanduser() if user_home else _user_home()
203
+ server_config = build_server_config(
204
+ nexo_home=nexo_home_path,
205
+ runtime_root=runtime_root,
206
+ python_path=python_path,
207
+ operator_name=operator_name,
208
+ )
209
+ codex_bin = shutil.which("codex")
210
+ config_path = _codex_config_path(home_path)
211
+ if not codex_bin:
212
+ return {
213
+ "ok": True,
214
+ "client": "codex",
215
+ "skipped": True,
216
+ "reason": "codex binary not found in PATH",
217
+ "path": str(config_path),
218
+ }
219
+
220
+ cmd = [codex_bin, "mcp", "add", "nexo"]
221
+ for key, value in sorted(server_config.get("env", {}).items()):
222
+ cmd.extend(["--env", f"{key}={value}"])
223
+ cmd.extend(["--", server_config["command"], *server_config.get("args", [])])
224
+ env = {**os.environ, "HOME": str(home_path)}
225
+ result = subprocess.run(
226
+ cmd,
227
+ capture_output=True,
228
+ text=True,
229
+ timeout=30,
230
+ env=env,
231
+ )
232
+ if result.returncode != 0:
233
+ return {
234
+ "ok": False,
235
+ "client": "codex",
236
+ "path": str(config_path),
237
+ "error": (result.stderr or result.stdout or "codex mcp add failed").strip(),
238
+ }
239
+ return {
240
+ "ok": True,
241
+ "client": "codex",
242
+ "action": "updated",
243
+ "path": str(config_path),
244
+ "mode": "cli",
245
+ }
246
+
247
+
248
+ def sync_all_clients(
249
+ *,
250
+ nexo_home: str | os.PathLike[str] | None = None,
251
+ runtime_root: str | os.PathLike[str] | None = None,
252
+ python_path: str = "",
253
+ operator_name: str = "",
254
+ user_home: str | os.PathLike[str] | None = None,
255
+ ) -> dict:
256
+ def _safe(label: str, fn) -> dict:
257
+ try:
258
+ return fn(
259
+ nexo_home=nexo_home,
260
+ runtime_root=runtime_root,
261
+ python_path=python_path,
262
+ operator_name=operator_name,
263
+ user_home=user_home,
264
+ )
265
+ except Exception as exc:
266
+ return {"ok": False, "client": label, "error": str(exc)}
267
+
268
+ results = {
269
+ "claude_code": _safe("claude_code", sync_claude_code),
270
+ "claude_desktop": _safe("claude_desktop", sync_claude_desktop),
271
+ "codex": _safe("codex", sync_codex),
272
+ }
273
+ ok = all(item.get("ok") or item.get("skipped") for item in results.values())
274
+ return {
275
+ "ok": ok,
276
+ "nexo_home": str(Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()),
277
+ "runtime_root": str(_resolve_runtime_root(
278
+ Path(nexo_home).expanduser() if nexo_home else _default_nexo_home(),
279
+ runtime_root,
280
+ )),
281
+ "clients": results,
282
+ }
283
+
284
+
285
+ def format_sync_summary(result: dict) -> str:
286
+ labels = {
287
+ "claude_code": "Claude Code",
288
+ "claude_desktop": "Claude Desktop",
289
+ "codex": "Codex",
290
+ }
291
+ lines = ["SHARED BRAIN SYNC"]
292
+ for key in ["claude_code", "claude_desktop", "codex"]:
293
+ item = result.get("clients", {}).get(key, {})
294
+ label = labels[key]
295
+ if item.get("skipped"):
296
+ lines.append(f" {label}: skipped ({item.get('reason', 'not available')})")
297
+ elif item.get("ok"):
298
+ lines.append(f" {label}: {item.get('action', 'synced')} -> {item.get('path', '')}")
299
+ else:
300
+ lines.append(f" {label}: ERROR -> {item.get('error', 'unknown error')}")
301
+ return "\n".join(lines)
302
+
303
+
304
+ def main(argv: list[str] | None = None) -> int:
305
+ parser = argparse.ArgumentParser(description="Sync NEXO MCP config across Claude Code, Claude Desktop, and Codex.")
306
+ parser.add_argument("--nexo-home", default=str(_default_nexo_home()))
307
+ parser.add_argument("--runtime-root", default="")
308
+ parser.add_argument("--python", dest="python_path", default="")
309
+ parser.add_argument("--operator-name", default="")
310
+ parser.add_argument("--json", action="store_true")
311
+ args = parser.parse_args(argv)
312
+
313
+ result = sync_all_clients(
314
+ nexo_home=args.nexo_home,
315
+ runtime_root=args.runtime_root or None,
316
+ python_path=args.python_path,
317
+ operator_name=args.operator_name,
318
+ )
319
+ if args.json:
320
+ print(json.dumps(result, indent=2, ensure_ascii=False))
321
+ else:
322
+ print(format_sync_summary(result))
323
+ return 0 if result.get("ok") else 1
324
+
325
+
326
+ if __name__ == "__main__":
327
+ raise SystemExit(main())
@@ -584,6 +584,21 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
584
584
  except Exception as e:
585
585
  pass # Non-critical, log in function
586
586
 
587
+ # Step 10: Sync shared client configs
588
+ try:
589
+ _emit_progress(progress_fn, "Refreshing shared client configs...")
590
+ from client_sync import sync_all_clients
591
+
592
+ client_sync_result = sync_all_clients(
593
+ nexo_home=NEXO_HOME,
594
+ runtime_root=SRC_DIR,
595
+ operator_name=os.environ.get("NEXO_NAME", ""),
596
+ )
597
+ if client_sync_result.get("ok"):
598
+ steps_done.append("client-sync")
599
+ except Exception:
600
+ pass # Non-critical, configs can be re-synced later
601
+
587
602
  # Build result
588
603
  if pull_out == "Already up to date.":
589
604
  return f"Already up to date (v{old_version}). No changes pulled."
@@ -603,6 +618,8 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
603
618
  lines.append(" Crons: synced with manifest")
604
619
  if "hook-sync" in steps_done:
605
620
  lines.append(" Hooks: synced to NEXO_HOME")
621
+ if "client-sync" in steps_done:
622
+ lines.append(" Clients: Claude Code/Desktop/Codex synced")
606
623
  lines.append("")
607
624
  lines.append("MCP server restart needed to load new code.")
608
625
  return "\n".join(lines)
@@ -120,6 +120,30 @@ def _acquire_lock():
120
120
  return handle
121
121
 
122
122
 
123
+ def _heal_personal_schedules() -> dict:
124
+ """Recreate declared personal schedules before catch-up checks missed windows."""
125
+ summary = {"created": 0, "repaired": 0, "invalid": 0, "error": ""}
126
+ try:
127
+ from script_registry import reconcile_personal_scripts
128
+
129
+ result = reconcile_personal_scripts(dry_run=False)
130
+ ensured = result.get("ensure_schedules", {})
131
+ summary["created"] = len(ensured.get("created", []))
132
+ summary["repaired"] = len(ensured.get("repaired", []))
133
+ summary["invalid"] = len(ensured.get("invalid", []))
134
+ if summary["created"] or summary["repaired"]:
135
+ log(
136
+ "Repaired declared personal schedules before catch-up: "
137
+ f"{summary['created']} created, {summary['repaired']} repaired."
138
+ )
139
+ if summary["invalid"]:
140
+ log(f"WARNING: {summary['invalid']} declared personal schedules are invalid.")
141
+ except Exception as e:
142
+ summary["error"] = str(e)
143
+ log(f"Personal schedule self-heal skipped: {e}")
144
+ return summary
145
+
146
+
123
147
  def run_task(candidate: dict, state: dict) -> bool:
124
148
  """Execute a task and update state."""
125
149
  name = candidate["cron_id"]
@@ -172,6 +196,7 @@ def main():
172
196
  log("Catch-Up already running; skipping overlapping invocation.")
173
197
  return
174
198
 
199
+ _heal_personal_schedules()
175
200
  state = load_state()
176
201
  tasks = catchup_candidates()
177
202
 
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ if str(ROOT) not in sys.path:
10
+ sys.path.insert(0, str(ROOT))
11
+
12
+ from client_sync import main
13
+
14
+
15
+ if __name__ == "__main__":
16
+ raise SystemExit(main())
@@ -247,6 +247,17 @@ if $CRON_SYNC_OK && [ -d "$SRC_DIR/crons" ]; then
247
247
  log "Refreshed installed crons manifest."
248
248
  fi
249
249
 
250
+ # --- Step 9: Sync shared client configs ---
251
+ CLIENT_SYNC="$SRC_DIR/scripts/nexo-sync-clients.py"
252
+ if [ -f "$CLIENT_SYNC" ]; then
253
+ log "Syncing Claude Code, Claude Desktop, and Codex configs..."
254
+ if NEXO_HOME="$NEXO_HOME" NEXO_CODE="$SRC_DIR" python3 "$CLIENT_SYNC" --nexo-home "$NEXO_HOME" --runtime-root "$SRC_DIR" --json >/dev/null 2>&1; then
255
+ log "Shared client configs synced."
256
+ else
257
+ warn "Client config sync failed (non-fatal). Run 'nexo clients sync' later."
258
+ fi
259
+ fi
260
+
250
261
  # --- Done ---
251
262
  echo ""
252
263
  log "========================================="