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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -5
- package/bin/nexo-brain.js +76 -38
- package/package.json +1 -1
- package/src/auto_update.py +69 -2
- package/src/cli.py +199 -38
- package/src/client_sync.py +327 -0
- package/src/plugins/update.py +17 -0
- package/src/scripts/nexo-catchup.py +25 -0
- package/src/scripts/nexo-sync-clients.py +16 -0
- package/src/scripts/nexo-update.sh +11 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "2.6.
|
|
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
|
[](https://github.com/wazionapps/nexo/stargazers)
|
|
7
7
|
[](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).
|
|
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 |
|
|
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 (
|
|
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**:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/src/auto_update.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
773
|
+
public_contribution = _load_public_contribution_support()
|
|
628
774
|
|
|
629
|
-
|
|
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())
|
package/src/plugins/update.py
CHANGED
|
@@ -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 "========================================="
|