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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/auto_update.py +284 -1
- package/src/cli.py +14 -2
- package/src/client_sync.py +64 -0
- package/src/plugins/update.py +47 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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.
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
package/src/client_sync.py
CHANGED
|
@@ -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,
|
package/src/plugins/update.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
1349
|
-
|
|
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("")
|