nexo-brain 5.6.0 → 5.7.0

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": "5.6.0",
3
+ "version": "5.7.0",
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
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `5.6.0` is the current packaged-runtime line: default model upgrade from Opus 4.6 to **Opus 4.7** with `reasoning_effort: "max"` (the new highest tier). Auto-migration on `nexo update` silently upgrades existing users from `claude-opus-4-6*` to `claude-opus-4-7` preserving the 1M context suffix. Codex profiles are untouched.
21
+ Version `5.7.0` is the current packaged-runtime line: `nexo update` now keeps Claude Code and Codex CLIs in lockstep with NEXO Brain itself. When the global `@anthropic-ai/claude-code` or `@openai/codex` packages are installed, the updater checks the npm registry and runs `npm install -g <pkg>@latest` in-line — so the terminal boot model stays aligned with the settings NEXO already wrote to `~/.claude/settings.json`. Packages the operator never installed are skipped silently. Pass `nexo update --no-clis` to keep the terminal CLIs pinned.
22
+
23
+ Previously in `5.6.1`: update-path hardening — 0-byte `.db` orphans from interrupted installs are now purged from `~/.nexo/` and `~/.nexo/data/` before the pre-update backup, and `sync_claude_code_model()` propagates the NEXO-recommended model into `~/.claude/settings.json` whenever `heal_runtime_profiles()` migrates the `claude_code` default.
22
24
 
23
25
  Previously in `5.5.5`: data-loss guardrails + automatic self-heal. The updater now refuses to capture an already-wiped `nexo.db` into a `pre-update-*` snapshot (validated `sqlite3.backup` + pre-flight wipe guard + post-migration row-count gate), and an auto-heal restores `data/nexo.db` from the newest hourly backup on the next server boot when a wipe is detected. New `nexo recover` CLI + `nexo_recover` MCP tool.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.6.0",
3
+ "version": "5.7.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -836,10 +836,53 @@ def _self_heal_if_wiped() -> dict | None:
836
836
  }
837
837
 
838
838
 
839
+ def _purge_zero_byte_db_files() -> list[Path]:
840
+ """Delete 0-byte .db files in NEXO_HOME and its ``data/`` subdir.
841
+
842
+ These are orphans from interrupted installs / aborted ``sqlite3.connect``
843
+ calls. They break backup validation by (a) masking the real DB during
844
+ :func:`_find_primary_db_path` selection when two ``nexo.db`` paths
845
+ coexist, and (b) being copied into the backup as empty shells that later
846
+ confuse :func:`_restore_dbs` on rollback.
847
+
848
+ Never touches SRC_DIR (the repo checkout) or the ``backups/`` tree.
849
+ Returns the list of removed paths for logging; failures are swallowed
850
+ so backup never aborts because of orphan cleanup.
851
+ """
852
+ removed: list[Path] = []
853
+ scan_dirs: list[Path] = []
854
+ if NEXO_HOME.is_dir():
855
+ scan_dirs.append(NEXO_HOME)
856
+ if DATA_DIR.is_dir() and DATA_DIR != NEXO_HOME:
857
+ scan_dirs.append(DATA_DIR)
858
+ for scan_dir in scan_dirs:
859
+ try:
860
+ candidates = [f for f in scan_dir.glob("*.db") if f.is_file()]
861
+ except Exception:
862
+ continue
863
+ for path in candidates:
864
+ try:
865
+ if path.stat().st_size != 0:
866
+ continue
867
+ except Exception:
868
+ continue
869
+ try:
870
+ path.unlink()
871
+ removed.append(path)
872
+ _log(f"Purged zero-byte DB orphan: {path}")
873
+ except Exception as e:
874
+ _log(f"Failed to purge zero-byte DB {path}: {e}")
875
+ return removed
876
+
877
+
839
878
  def _backup_dbs() -> str | None:
840
879
  """Snapshot all .db files before migration. Returns backup dir or None."""
841
880
  import sqlite3
842
881
  import time as _time
882
+ # Drop 0-byte .db orphans first — they mask the real DB during primary
883
+ # path selection and turn into empty shells in the backup, breaking both
884
+ # validation and rollback paths. Safe no-op when there are none.
885
+ _purge_zero_byte_db_files()
843
886
  timestamp = _time.strftime("%Y-%m-%d-%H%M%S")
844
887
  backup_dir = NEXO_HOME / "backups" / f"pre-autoupdate-{timestamp}"
845
888
 
@@ -1125,6 +1168,202 @@ def _check_npm_version() -> str | None:
1125
1168
  return None
1126
1169
 
1127
1170
 
1171
+ # ── External CLI auto-update (Claude Code, Codex) ────────────────────
1172
+ # Keep this list aligned with the third-party CLIs that NEXO drives end-to-end.
1173
+ # Adding a new CLI here makes `nexo update` auto-install/bump it unless the
1174
+ # caller passes include_clis=False. Learning #323: use canonical npm names.
1175
+ _EXTERNAL_CLIS: tuple[str, ...] = (
1176
+ "@anthropic-ai/claude-code",
1177
+ "@openai/codex",
1178
+ )
1179
+
1180
+
1181
+ def _update_external_clis(progress_fn=None) -> dict:
1182
+ """Detect and update NEXO's external terminal CLIs.
1183
+
1184
+ For each package in :data:`_EXTERNAL_CLIS`:
1185
+ 1. Read the installed global version via ``npm list -g --json --depth=0``.
1186
+ 2. Fetch the latest version from the npm registry via ``npm view <pkg> version``.
1187
+ 3. When newer, run ``npm install -g <pkg>@latest``.
1188
+
1189
+ Silently skips packages that are not installed globally — NEXO does not
1190
+ push unsolicited installs of third-party CLIs onto operators.
1191
+
1192
+ Returns a dict keyed by package name. Each entry shape::
1193
+
1194
+ {
1195
+ "old": "2.1.109" | None,
1196
+ "new": "2.1.115" | None,
1197
+ "updated": True | False,
1198
+ "status": "updated" | "already_latest" | "not_installed"
1199
+ | "skipped" | "failed",
1200
+ "error": "<message>" # only when status == "failed"/"skipped"
1201
+ }
1202
+ """
1203
+ # Reuse the npm helpers already hardened in plugins/update.py (version
1204
+ # parsing, TimeoutExpired handling, invalid-name validation). Falling back
1205
+ # to "skipped" keeps a partially-copied runtime from crashing the update.
1206
+ try:
1207
+ from plugins.update import (
1208
+ _get_npm_global_version,
1209
+ _get_npm_registry_version,
1210
+ _validate_npm_name,
1211
+ )
1212
+ except Exception as e: # pragma: no cover — only mid-upgrade installs hit this
1213
+ return {
1214
+ cli: {
1215
+ "old": None,
1216
+ "new": None,
1217
+ "updated": False,
1218
+ "status": "skipped",
1219
+ "error": f"plugins.update helpers unavailable: {e}",
1220
+ }
1221
+ for cli in _EXTERNAL_CLIS
1222
+ }
1223
+
1224
+ results: dict[str, dict] = {}
1225
+
1226
+ for pkg in _EXTERNAL_CLIS:
1227
+ entry: dict = {"old": None, "new": None, "updated": False, "status": "unknown"}
1228
+
1229
+ if not _validate_npm_name(pkg):
1230
+ entry.update({"status": "failed", "error": f"invalid npm name: {pkg!r}"})
1231
+ results[pkg] = entry
1232
+ continue
1233
+
1234
+ old_version = _get_npm_global_version(pkg)
1235
+ if old_version is None:
1236
+ # Not installed globally — don't auto-install third-party CLIs the
1237
+ # operator didn't opt into. Silent skip in the final summary.
1238
+ entry["status"] = "not_installed"
1239
+ results[pkg] = entry
1240
+ continue
1241
+
1242
+ entry["old"] = old_version
1243
+
1244
+ latest = _get_npm_registry_version(pkg)
1245
+ if latest is None:
1246
+ entry.update({
1247
+ "new": old_version,
1248
+ "status": "failed",
1249
+ "error": "npm registry lookup failed",
1250
+ })
1251
+ results[pkg] = entry
1252
+ continue
1253
+
1254
+ if old_version == latest:
1255
+ entry.update({"new": old_version, "status": "already_latest"})
1256
+ results[pkg] = entry
1257
+ continue
1258
+
1259
+ if progress_fn is not None:
1260
+ try:
1261
+ progress_fn(f"Updating {pkg}: {old_version} -> {latest}...")
1262
+ except Exception:
1263
+ pass
1264
+
1265
+ try:
1266
+ r = subprocess.run(
1267
+ ["npm", "install", "-g", f"{pkg}@latest"],
1268
+ capture_output=True,
1269
+ text=True,
1270
+ timeout=180,
1271
+ )
1272
+ except subprocess.TimeoutExpired:
1273
+ # Learning #294: always capture TimeoutExpired explicitly.
1274
+ entry.update({
1275
+ "new": old_version,
1276
+ "status": "failed",
1277
+ "error": "npm install timed out after 180s",
1278
+ })
1279
+ results[pkg] = entry
1280
+ continue
1281
+ except FileNotFoundError:
1282
+ # npm itself missing — no Node.js on PATH. Mark the rest as skipped
1283
+ # and bail: no point retrying subsequent packages.
1284
+ entry.update({
1285
+ "new": old_version,
1286
+ "status": "skipped",
1287
+ "error": "npm not found on PATH",
1288
+ })
1289
+ results[pkg] = entry
1290
+ for remaining in _EXTERNAL_CLIS:
1291
+ results.setdefault(remaining, {
1292
+ "old": None,
1293
+ "new": None,
1294
+ "updated": False,
1295
+ "status": "skipped",
1296
+ "error": "npm not found on PATH",
1297
+ })
1298
+ return results
1299
+ except Exception as e: # pragma: no cover — defensive
1300
+ entry.update({
1301
+ "new": old_version,
1302
+ "status": "failed",
1303
+ "error": str(e)[:500],
1304
+ })
1305
+ results[pkg] = entry
1306
+ continue
1307
+
1308
+ if r.returncode != 0:
1309
+ entry.update({
1310
+ "new": old_version,
1311
+ "status": "failed",
1312
+ "error": (r.stderr or r.stdout or "npm install failed").strip()[:500],
1313
+ })
1314
+ results[pkg] = entry
1315
+ continue
1316
+
1317
+ new_version = _get_npm_global_version(pkg) or latest
1318
+ entry.update({
1319
+ "new": new_version,
1320
+ "updated": new_version != old_version,
1321
+ "status": "updated" if new_version != old_version else "already_latest",
1322
+ })
1323
+ results[pkg] = entry
1324
+
1325
+ return results
1326
+
1327
+
1328
+ def _format_external_clis_results(results: dict) -> list[str]:
1329
+ """Render CLI update results as lines for the ``nexo update`` summary.
1330
+
1331
+ Emits a visible warning per bumped CLI (operator must restart the terminal
1332
+ for the new version to take effect), a warning per failure, and a single
1333
+ informational line when nothing changed but something was checked.
1334
+ """
1335
+ if not results:
1336
+ return []
1337
+
1338
+ lines: list[str] = []
1339
+ any_updated = False
1340
+ any_failed = False
1341
+ any_checked_latest = False
1342
+
1343
+ for pkg, entry in results.items():
1344
+ status = entry.get("status")
1345
+ if status == "updated":
1346
+ any_updated = True
1347
+ lines.append(
1348
+ f" CLI updated: {pkg} {entry.get('old')} -> {entry.get('new')} "
1349
+ f"— reinicia terminal para activar"
1350
+ )
1351
+ elif status == "already_latest":
1352
+ any_checked_latest = True
1353
+ elif status == "failed":
1354
+ any_failed = True
1355
+ lines.append(
1356
+ f" WARNING: CLI {pkg} update failed: {entry.get('error', 'unknown')}"
1357
+ )
1358
+ # "not_installed" and "skipped" are intentionally silent — third-party
1359
+ # CLIs that the operator never installed shouldn't spam the summary.
1360
+
1361
+ if not any_updated and not any_failed and any_checked_latest:
1362
+ lines.append(" CLIs externos: ya en última versión")
1363
+
1364
+ return lines
1365
+
1366
+
1128
1367
  # ── File-based migrations (migrations/ directory) ────────────────────
1129
1368
 
1130
1369
  def _get_applied_migration_version() -> int:
@@ -2271,6 +2510,32 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
2271
2510
  for msg in heal_messages:
2272
2511
  _emit_progress(progress_fn, msg)
2273
2512
  actions.append("model-heal")
2513
+ # Claude Code reads the default model from ~/.claude/settings.json,
2514
+ # not from client_runtime_profiles. If the heal migrated the
2515
+ # claude_code model (e.g. Opus 4.6 → 4.7) the internal profile is
2516
+ # now correct but Claude Code keeps booting on the old model until
2517
+ # settings.json is also updated. Propagate conservatively: only
2518
+ # touch settings.json when it already has a "model" field.
2519
+ existing_cc = existing_profiles.get("claude_code") if isinstance(existing_profiles.get("claude_code"), dict) else None
2520
+ healed_cc = healed_profiles.get("claude_code") if isinstance(healed_profiles.get("claude_code"), dict) else None
2521
+ old_cc_model = str((existing_cc or {}).get("model") or "")
2522
+ new_cc_model = str((healed_cc or {}).get("model") or "")
2523
+ if new_cc_model and new_cc_model != old_cc_model:
2524
+ try:
2525
+ from client_sync import sync_claude_code_model
2526
+ sync_result = sync_claude_code_model(new_cc_model)
2527
+ if sync_result.get("action") == "updated":
2528
+ _emit_progress(
2529
+ progress_fn,
2530
+ f"Synced Claude Code settings.json model → '{new_cc_model}'.",
2531
+ )
2532
+ actions.append("claude-settings-model")
2533
+ elif not sync_result.get("ok"):
2534
+ actions.append(
2535
+ f"claude-settings-model-warning:{sync_result.get('reason', 'unknown')}"
2536
+ )
2537
+ except Exception as e:
2538
+ actions.append(f"claude-settings-model-warning:{e}")
2274
2539
  normalized_preferences = normalize_client_preferences(schedule_payload)
2275
2540
  if normalized_preferences != {
2276
2541
  key: schedule_payload.get(key)
@@ -2380,7 +2645,13 @@ def _personal_schedule_reconcile_summary(reconcile_result: dict) -> tuple[list[s
2380
2645
  return actions, "Personal schedules: " + ", ".join(parts) + "."
2381
2646
 
2382
2647
 
2383
- def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = True, progress_fn=None) -> dict:
2648
+ def manual_sync_update(
2649
+ *,
2650
+ interactive: bool = False,
2651
+ allow_source_pull: bool = True,
2652
+ progress_fn=None,
2653
+ include_clis: bool = True,
2654
+ ) -> dict:
2384
2655
  src_dir, repo_dir = _resolve_sync_source()
2385
2656
  if src_dir is None or repo_dir is None:
2386
2657
  return {"ok": False, "mode": "sync", "error": "No source repo recorded for this runtime."}
@@ -2444,6 +2715,18 @@ def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = T
2444
2715
  except Exception:
2445
2716
  pass # Non-critical
2446
2717
 
2718
+ # Auto-update external terminal CLIs (Claude Code, Codex). Best-effort:
2719
+ # a failed third-party install never aborts the NEXO sync itself.
2720
+ if include_clis:
2721
+ try:
2722
+ _emit_progress(progress_fn, "Checking external CLI updates...")
2723
+ cli_results = _update_external_clis(progress_fn=progress_fn)
2724
+ sync_result["external_clis"] = cli_results
2725
+ except Exception as e:
2726
+ sync_result.setdefault("warnings", []).append(
2727
+ f"external CLI update skipped: {e}"
2728
+ )
2729
+
2447
2730
  _emit_progress(progress_fn, "Runtime update completed.")
2448
2731
  except Exception as e:
2449
2732
  _emit_progress(progress_fn, "Update failed; restoring previous runtime state...")
package/src/cli.py CHANGED
@@ -823,6 +823,7 @@ def _update(args):
823
823
 
824
824
  dest = NEXO_HOME
825
825
  src_dir, repo_dir = _resolve_sync_source()
826
+ include_clis = not getattr(args, "no_clis", False)
826
827
 
827
828
  if src_dir is None or repo_dir is None:
828
829
  try:
@@ -835,7 +836,7 @@ def _update(args):
835
836
  )
836
837
  return 1
837
838
 
838
- result = handle_update(progress_fn=progress)
839
+ result = handle_update(progress_fn=progress, include_clis=include_clis)
839
840
  runtime_power = _load_runtime_power_support()
840
841
  public_contribution = _load_public_contribution_support()
841
842
  choice = runtime_power["ensure_power_policy_choice"](interactive=interactive, reason="update")
@@ -873,7 +874,12 @@ def _update(args):
873
874
  print(f"Contributor mode: {contrib_choice.get('message')}")
874
875
  return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
875
876
 
876
- result = manual_sync_update(interactive=interactive, allow_source_pull=True, progress_fn=progress)
877
+ result = manual_sync_update(
878
+ interactive=interactive,
879
+ allow_source_pull=True,
880
+ progress_fn=progress,
881
+ include_clis=include_clis,
882
+ )
877
883
  runtime_power = _load_runtime_power_support()
878
884
  public_contribution = _load_public_contribution_support()
879
885
  choice = runtime_power["ensure_power_policy_choice"](interactive=interactive, reason="update")
@@ -2015,6 +2021,12 @@ def main():
2015
2021
  # -- update --
2016
2022
  update_parser = sub.add_parser("update", help="Update installed runtime")
2017
2023
  update_parser.add_argument("--json", action="store_true", help="JSON output")
2024
+ update_parser.add_argument(
2025
+ "--no-clis",
2026
+ dest="no_clis",
2027
+ action="store_true",
2028
+ help="Skip auto-updating external terminal CLIs (Claude Code, Codex)",
2029
+ )
2018
2030
 
2019
2031
  # -- recover --
2020
2032
  recover_parser = sub.add_parser(
@@ -802,6 +802,70 @@ def _sync_claude_code_settings(path: Path, server_config: dict) -> dict:
802
802
  }
803
803
 
804
804
 
805
+ def sync_claude_code_model(
806
+ model: str,
807
+ *,
808
+ user_home: str | os.PathLike[str] | None = None,
809
+ ) -> dict:
810
+ """Propagate the NEXO-recommended ``model`` into ``~/.claude/settings.json``.
811
+
812
+ Claude Code actually reads the default model from ``settings.json``, not
813
+ from NEXO's internal ``client_runtime_profiles``. When a NEXO update
814
+ migrates a user's recommended default (e.g. Opus 4.6 → 4.7) the internal
815
+ profile changes but Claude Code keeps booting on the old model until this
816
+ file is rewritten.
817
+
818
+ Intentionally conservative — do NOT seed a field the user never had:
819
+ - ``settings.json`` missing → skip.
820
+ - ``settings.json`` exists but has no
821
+ top-level ``"model"`` key → skip.
822
+ - Current value already equals ``model`` → skip (no write).
823
+ - JSON read/write error → ``ok=False``, skip.
824
+ - Otherwise replace the value.
825
+
826
+ Returns a small result dict: ``{ok, action, path, reason?, previous_model?, new_model?}``.
827
+ ``action`` is ``"updated"`` only when the file was actually rewritten.
828
+ """
829
+ result: dict = {"ok": True, "action": "skipped", "path": ""}
830
+ if not model:
831
+ result["reason"] = "empty model"
832
+ return result
833
+ home_path = Path(user_home).expanduser() if user_home else None
834
+ path = _claude_code_settings_path(home_path)
835
+ result["path"] = str(path)
836
+ if not path.is_file():
837
+ result["reason"] = "settings.json missing"
838
+ return result
839
+ try:
840
+ raw = path.read_text()
841
+ payload = json.loads(raw) if raw.strip() else {}
842
+ except Exception as e:
843
+ result["ok"] = False
844
+ result["reason"] = f"read failed: {e}"
845
+ return result
846
+ if not isinstance(payload, dict):
847
+ result["reason"] = "settings.json is not a JSON object"
848
+ return result
849
+ if "model" not in payload:
850
+ result["reason"] = "no model field in settings.json"
851
+ return result
852
+ current = payload.get("model")
853
+ if current == model:
854
+ result["reason"] = "already matches"
855
+ return result
856
+ payload["model"] = model
857
+ try:
858
+ _write_json_object(path, payload)
859
+ except Exception as e:
860
+ result["ok"] = False
861
+ result["reason"] = f"write failed: {e}"
862
+ return result
863
+ result["action"] = "updated"
864
+ result["previous_model"] = current
865
+ result["new_model"] = model
866
+ return result
867
+
868
+
805
869
  def sync_claude_code(
806
870
  *,
807
871
  nexo_home: str | os.PathLike[str] | None = None,
@@ -961,7 +961,7 @@ def _reload_launch_agents_after_bump() -> dict:
961
961
  return result
962
962
 
963
963
 
964
- def _handle_packaged_update(progress_fn=None) -> str:
964
+ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> str:
965
965
  """Update a packaged (npm) install — no git repo available."""
966
966
  old_version = _read_version()
967
967
 
@@ -1078,6 +1078,19 @@ def _handle_packaged_update(progress_fn=None) -> str:
1078
1078
  except Exception:
1079
1079
  pass # Non-critical
1080
1080
 
1081
+ # Auto-update external terminal CLIs (Claude Code, Codex). Best-effort;
1082
+ # a third-party install failure here never rolls the update back.
1083
+ external_cli_lines: list[str] = []
1084
+ if include_clis:
1085
+ try:
1086
+ from auto_update import _update_external_clis, _format_external_clis_results
1087
+ _emit_progress(progress_fn, "Checking external CLI updates...")
1088
+ external_cli_lines = _format_external_clis_results(
1089
+ _update_external_clis(progress_fn=progress_fn)
1090
+ )
1091
+ except Exception:
1092
+ pass # Non-critical
1093
+
1081
1094
  client_sync_warning = None
1082
1095
  _emit_progress(progress_fn, "Refreshing shared client configs...")
1083
1096
  clients_ok, client_sync_error = _sync_packaged_clients()
@@ -1144,6 +1157,7 @@ def _handle_packaged_update(progress_fn=None) -> str:
1144
1157
  if retired_runtime_files:
1145
1158
  lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
1146
1159
  lines.extend(_format_dep_results(dep_results))
1160
+ lines.extend(external_cli_lines)
1147
1161
  if not client_sync_warning:
1148
1162
  lines.append(" Clients: configured client targets synced")
1149
1163
  else:
@@ -1162,7 +1176,13 @@ def _handle_packaged_update(progress_fn=None) -> str:
1162
1176
  return "\n".join(lines)
1163
1177
 
1164
1178
 
1165
- def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None) -> str:
1179
+ def handle_update(
1180
+ remote: str = "origin",
1181
+ branch: str = "main",
1182
+ progress_fn=None,
1183
+ *,
1184
+ include_clis: bool = True,
1185
+ ) -> str:
1166
1186
  """Pull latest NEXO code, backup databases, run migrations, and verify.
1167
1187
 
1168
1188
  Supports both git checkouts and packaged (npm) installs.
@@ -1179,10 +1199,13 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
1179
1199
  Args:
1180
1200
  remote: Git remote name (default: origin)
1181
1201
  branch: Git branch to pull (default: main)
1202
+ include_clis: When True (default), auto-update external terminal CLIs
1203
+ (Claude Code, Codex). Pass False (``nexo update --no-clis``) to
1204
+ keep the third-party CLIs at their current version.
1182
1205
  """
1183
1206
  # Packaged install — no git repo
1184
1207
  if not _is_git_repo():
1185
- return _handle_packaged_update(progress_fn=progress_fn)
1208
+ return _handle_packaged_update(progress_fn=progress_fn, include_clis=include_clis)
1186
1209
 
1187
1210
  steps_done = []
1188
1211
  old_commit = None
@@ -1302,6 +1325,23 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
1302
1325
  except Exception:
1303
1326
  pass # Non-critical
1304
1327
 
1328
+ # Step 10b: Update external terminal CLIs (Claude Code, Codex). Runs
1329
+ # after migrations and before the verify-style post-steps so a stale
1330
+ # CLI never hides bug fixes shipped with the new NEXO release. Failures
1331
+ # here never abort the git update — the rollback only covers NEXO itself.
1332
+ external_cli_lines: list[str] = []
1333
+ if include_clis:
1334
+ try:
1335
+ from auto_update import _update_external_clis, _format_external_clis_results
1336
+ _emit_progress(progress_fn, "Checking external CLI updates...")
1337
+ external_cli_lines = _format_external_clis_results(
1338
+ _update_external_clis(progress_fn=progress_fn)
1339
+ )
1340
+ if external_cli_lines:
1341
+ steps_done.append("external-clis")
1342
+ except Exception:
1343
+ pass # Non-critical
1344
+
1305
1345
  # Step 11: Sync shared client configs
1306
1346
  try:
1307
1347
  _emit_progress(progress_fn, "Refreshing shared client configs...")
@@ -1345,8 +1385,9 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
1345
1385
  dep_summary_lines = _format_dep_results(dep_results)
1346
1386
  if pull_out == "Already up to date.":
1347
1387
  msg = f"Already up to date (v{old_version}). No changes pulled."
1348
- if dep_summary_lines:
1349
- msg += "\n" + "\n".join(dep_summary_lines)
1388
+ trailing = [*dep_summary_lines, *external_cli_lines]
1389
+ if trailing:
1390
+ msg += "\n" + "\n".join(trailing)
1350
1391
  return msg
1351
1392
 
1352
1393
  lines = ["UPDATE SUCCESSFUL"]
@@ -1367,6 +1408,7 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
1367
1408
  if retired_runtime_files:
1368
1409
  lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
1369
1410
  lines.extend(dep_summary_lines)
1411
+ lines.extend(external_cli_lines)
1370
1412
  if "client-sync" in steps_done:
1371
1413
  lines.append(" Clients: configured client targets synced")
1372
1414
  lines.append("")