nexo-brain 2.5.0 → 2.6.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 +33 -0
- package/.mcp.json +12 -0
- package/README.md +48 -23
- package/bin/nexo-brain.js +65 -33
- package/hooks/hooks.json +14 -0
- package/package.json +15 -3
- package/src/auto_update.py +79 -2
- package/src/cli.py +490 -11
- package/src/cron_recovery.py +283 -0
- package/src/crons/manifest.json +79 -21
- package/src/crons/sync.py +132 -27
- package/src/db/__init__.py +11 -0
- package/src/db/_personal_scripts.py +548 -0
- package/src/db/_schema.py +44 -1
- package/src/doctor/providers/runtime.py +272 -75
- package/src/evolution_cycle.py +90 -7
- package/src/nexo.db +0 -0
- package/src/plugins/evolution.py +9 -2
- package/src/plugins/personal_scripts.py +117 -0
- package/src/plugins/schedule.py +116 -27
- package/src/script_registry.py +877 -28
- package/src/scripts/nexo-catchup.py +74 -109
- package/src/scripts/nexo-evolution-run.py +178 -67
- package/src/scripts/nexo-watchdog.sh +242 -54
- package/src/tools_learnings.py +8 -0
- package/templates/launchagents/com.nexo.catchup.plist +7 -6
- package/templates/script-template.py +3 -0
- package/templates/script-template.sh +13 -0
- package/src/scripts/nexo-day-orchestrator.sh +0 -139
package/src/cli.py
CHANGED
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
"""NEXO Runtime CLI — operational commands for scripts and diagnostics.
|
|
3
3
|
|
|
4
4
|
Entry points:
|
|
5
|
+
nexo chat [PATH]
|
|
5
6
|
nexo scripts list [--all] [--json]
|
|
7
|
+
nexo scripts create NAME [--runtime python|shell] [--description TEXT]
|
|
8
|
+
nexo scripts classify [--json]
|
|
9
|
+
nexo scripts sync [--json]
|
|
10
|
+
nexo scripts reconcile [--dry-run] [--json]
|
|
11
|
+
nexo scripts ensure-schedules [--dry-run] [--json]
|
|
12
|
+
nexo scripts schedules [--json]
|
|
13
|
+
nexo scripts unschedule NAME [--json]
|
|
14
|
+
nexo scripts remove NAME [--keep-file] [--json]
|
|
6
15
|
nexo scripts run NAME_OR_PATH [-- args...]
|
|
7
16
|
nexo scripts doctor [NAME_OR_PATH] [--json]
|
|
8
17
|
nexo scripts call TOOL --input JSON [--json-output]
|
|
@@ -23,6 +32,7 @@ import contextlib
|
|
|
23
32
|
import io
|
|
24
33
|
import json
|
|
25
34
|
import os
|
|
35
|
+
import shutil
|
|
26
36
|
import subprocess
|
|
27
37
|
import sys
|
|
28
38
|
from pathlib import Path
|
|
@@ -30,14 +40,39 @@ from pathlib import Path
|
|
|
30
40
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
31
41
|
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
|
|
32
42
|
|
|
43
|
+
|
|
44
|
+
def _get_version() -> str:
|
|
45
|
+
"""Read version from runtime version.json or package.json automatically."""
|
|
46
|
+
json_candidates = [
|
|
47
|
+
(NEXO_HOME / "version.json", "version"),
|
|
48
|
+
(NEXO_CODE.parent / "version.json", "version"),
|
|
49
|
+
(NEXO_CODE.parent / "package.json", "version"),
|
|
50
|
+
(NEXO_HOME / "package.json", "version"),
|
|
51
|
+
]
|
|
52
|
+
for candidate, key in json_candidates:
|
|
53
|
+
try:
|
|
54
|
+
if candidate.is_file():
|
|
55
|
+
return json.loads(candidate.read_text()).get(key, "?")
|
|
56
|
+
except Exception:
|
|
57
|
+
continue
|
|
58
|
+
return "?"
|
|
59
|
+
|
|
33
60
|
# Ensure src/ is on path for imports
|
|
34
61
|
if str(NEXO_CODE) not in sys.path:
|
|
35
62
|
sys.path.insert(0, str(NEXO_CODE))
|
|
36
63
|
|
|
37
64
|
|
|
38
65
|
def _scripts_list(args):
|
|
39
|
-
from
|
|
40
|
-
|
|
66
|
+
from db import init_db, list_personal_scripts
|
|
67
|
+
from script_registry import list_scripts, sync_personal_scripts
|
|
68
|
+
|
|
69
|
+
init_db()
|
|
70
|
+
sync_personal_scripts()
|
|
71
|
+
if args.all:
|
|
72
|
+
scripts = list_scripts(include_core=True)
|
|
73
|
+
else:
|
|
74
|
+
scripts = list_personal_scripts()
|
|
75
|
+
|
|
41
76
|
if args.json:
|
|
42
77
|
print(json.dumps(scripts, indent=2))
|
|
43
78
|
else:
|
|
@@ -49,13 +84,169 @@ def _scripts_list(args):
|
|
|
49
84
|
rt_w = max(len(s["runtime"]) for s in scripts)
|
|
50
85
|
for s in scripts:
|
|
51
86
|
tag = " [core]" if s.get("core") else ""
|
|
52
|
-
|
|
87
|
+
schedule_tag = ""
|
|
88
|
+
if s.get("has_schedule"):
|
|
89
|
+
schedule_labels = [sch.get("schedule_label", "") for sch in s.get("schedules", []) if sch.get("schedule_label")]
|
|
90
|
+
if schedule_labels:
|
|
91
|
+
schedule_tag = f" [{'; '.join(schedule_labels[:2])}]"
|
|
92
|
+
print(f" {s['name']:<{name_w}} {s['runtime']:<{rt_w}} {s.get('description', '')}{schedule_tag}{tag}")
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _scripts_sync(args):
|
|
97
|
+
from db import init_db
|
|
98
|
+
from script_registry import sync_personal_scripts
|
|
99
|
+
|
|
100
|
+
init_db()
|
|
101
|
+
result = sync_personal_scripts()
|
|
102
|
+
if args.json:
|
|
103
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
104
|
+
else:
|
|
105
|
+
print(
|
|
106
|
+
f"Synced personal scripts: {result['scripts_upserted']} script(s), "
|
|
107
|
+
f"{result['schedules_upserted']} schedule(s), "
|
|
108
|
+
f"{result['scripts_pruned']} script(s) pruned, "
|
|
109
|
+
f"{result['schedules_pruned']} schedule(s) pruned."
|
|
110
|
+
)
|
|
53
111
|
return 0
|
|
54
112
|
|
|
55
113
|
|
|
114
|
+
def _scripts_classify(args):
|
|
115
|
+
from script_registry import classify_scripts_dir
|
|
116
|
+
|
|
117
|
+
report = classify_scripts_dir()
|
|
118
|
+
if args.json:
|
|
119
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
entries = report.get("entries", [])
|
|
123
|
+
if not entries:
|
|
124
|
+
print("No scripts directory found:", report.get("scripts_dir", NEXO_HOME / "scripts"))
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
path_w = max(len(Path(entry["path"]).name) for entry in entries)
|
|
128
|
+
for entry in entries:
|
|
129
|
+
reason = f" — {entry['reason']}" if entry.get("reason") else ""
|
|
130
|
+
print(f" {Path(entry['path']).name:<{path_w}} {entry['classification']}{reason}")
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _scripts_reconcile(args):
|
|
135
|
+
from script_registry import reconcile_personal_scripts
|
|
136
|
+
|
|
137
|
+
result = reconcile_personal_scripts(dry_run=args.dry_run)
|
|
138
|
+
if args.json:
|
|
139
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
140
|
+
else:
|
|
141
|
+
sync = result.get("sync", {})
|
|
142
|
+
ensured = result.get("ensure_schedules", {})
|
|
143
|
+
print(
|
|
144
|
+
f"Reconciled personal scripts: {sync.get('registered_scripts', 0)} registered, "
|
|
145
|
+
f"{len(ensured.get('created', []))} schedule(s) created, "
|
|
146
|
+
f"{len(ensured.get('repaired', []))} repaired, "
|
|
147
|
+
f"{len(ensured.get('invalid', []))} invalid."
|
|
148
|
+
)
|
|
149
|
+
if args.dry_run:
|
|
150
|
+
print(" Dry run only — no schedules changed.")
|
|
151
|
+
return 0 if not result.get("ensure_schedules", {}).get("invalid") else 1
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _scripts_ensure_schedules(args):
|
|
155
|
+
from script_registry import ensure_personal_schedules
|
|
156
|
+
|
|
157
|
+
result = ensure_personal_schedules(dry_run=args.dry_run)
|
|
158
|
+
if args.json:
|
|
159
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
160
|
+
else:
|
|
161
|
+
print(
|
|
162
|
+
f"Ensured schedules: {len(result.get('created', []))} created, "
|
|
163
|
+
f"{len(result.get('repaired', []))} repaired, "
|
|
164
|
+
f"{len(result.get('already_present', []))} already present, "
|
|
165
|
+
f"{len(result.get('invalid', []))} invalid."
|
|
166
|
+
)
|
|
167
|
+
if args.dry_run:
|
|
168
|
+
print(" Dry run only — no schedules changed.")
|
|
169
|
+
return 0 if not result.get("invalid") else 1
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _scripts_create(args):
|
|
173
|
+
from script_registry import create_script
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
result = create_script(
|
|
177
|
+
args.name,
|
|
178
|
+
description=args.description,
|
|
179
|
+
runtime=args.runtime,
|
|
180
|
+
force=args.force,
|
|
181
|
+
)
|
|
182
|
+
except FileExistsError as e:
|
|
183
|
+
print(str(e), file=sys.stderr)
|
|
184
|
+
return 1
|
|
185
|
+
|
|
186
|
+
if args.json:
|
|
187
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
188
|
+
else:
|
|
189
|
+
print(f"Created personal script: {result['path']}")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _scripts_schedules(args):
|
|
194
|
+
from db import init_db, list_personal_script_schedules
|
|
195
|
+
from script_registry import sync_personal_scripts
|
|
196
|
+
|
|
197
|
+
init_db()
|
|
198
|
+
sync_personal_scripts()
|
|
199
|
+
schedules = list_personal_script_schedules()
|
|
200
|
+
if args.json:
|
|
201
|
+
print(json.dumps(schedules, indent=2, ensure_ascii=False))
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
if not schedules:
|
|
205
|
+
print("No personal script schedules registered.")
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
cron_w = max(len(s["cron_id"]) for s in schedules)
|
|
209
|
+
for schedule in schedules:
|
|
210
|
+
label = schedule.get("schedule_label") or schedule.get("schedule_value") or schedule.get("schedule_type")
|
|
211
|
+
print(f" {schedule['cron_id']:<{cron_w}} {label}")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _scripts_unschedule(args):
|
|
216
|
+
from script_registry import unschedule_personal_script
|
|
217
|
+
|
|
218
|
+
result = unschedule_personal_script(args.name)
|
|
219
|
+
if args.json:
|
|
220
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
221
|
+
else:
|
|
222
|
+
if not result.get("ok"):
|
|
223
|
+
print(result.get("error", "Failed to unschedule script"), file=sys.stderr)
|
|
224
|
+
return 1
|
|
225
|
+
print(f"Removed {len(result.get('removed_schedules', []))} schedule(s) from {result['script']}")
|
|
226
|
+
return 0 if result.get("ok") else 1
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _scripts_remove(args):
|
|
230
|
+
from script_registry import remove_personal_script
|
|
231
|
+
|
|
232
|
+
result = remove_personal_script(args.name, keep_file=args.keep_file)
|
|
233
|
+
if args.json:
|
|
234
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
235
|
+
else:
|
|
236
|
+
if not result.get("ok"):
|
|
237
|
+
print(result.get("error", "Failed to remove script"), file=sys.stderr)
|
|
238
|
+
return 1
|
|
239
|
+
action = "unregistered" if args.keep_file else "removed"
|
|
240
|
+
print(f"Script {result['script']} {action}")
|
|
241
|
+
return 0 if result.get("ok") else 1
|
|
242
|
+
|
|
243
|
+
|
|
56
244
|
def _scripts_run(args):
|
|
57
|
-
from
|
|
245
|
+
from db import init_db, record_personal_script_run
|
|
246
|
+
from script_registry import resolve_script_reference, sync_personal_scripts
|
|
58
247
|
|
|
248
|
+
init_db()
|
|
249
|
+
sync_personal_scripts()
|
|
59
250
|
info = resolve_script_reference(args.name)
|
|
60
251
|
if not info:
|
|
61
252
|
print(f"Script not found: {args.name}", file=sys.stderr)
|
|
@@ -95,23 +286,37 @@ def _scripts_run(args):
|
|
|
95
286
|
cmd = [sys.executable, str(path)] + args.script_args
|
|
96
287
|
elif runtime == "shell":
|
|
97
288
|
cmd = ["bash", str(path)] + args.script_args
|
|
289
|
+
elif runtime == "node":
|
|
290
|
+
cmd = ["node", str(path)] + args.script_args
|
|
291
|
+
elif runtime == "php":
|
|
292
|
+
cmd = ["php", str(path)] + args.script_args
|
|
98
293
|
else:
|
|
99
294
|
# Try to execute directly
|
|
100
295
|
cmd = [str(path)] + args.script_args
|
|
101
296
|
|
|
102
297
|
try:
|
|
103
298
|
result = subprocess.run(cmd, env=env, timeout=timeout)
|
|
299
|
+
if not is_core:
|
|
300
|
+
record_personal_script_run(str(path), result.returncode)
|
|
104
301
|
return result.returncode
|
|
105
302
|
except subprocess.TimeoutExpired:
|
|
303
|
+
if not is_core:
|
|
304
|
+
record_personal_script_run(str(path), 124)
|
|
106
305
|
print(f"Script timed out after {timeout}s", file=sys.stderr)
|
|
107
306
|
return 124
|
|
108
307
|
except Exception as e:
|
|
308
|
+
if not is_core:
|
|
309
|
+
record_personal_script_run(str(path), 1)
|
|
109
310
|
print(f"Error running script: {e}", file=sys.stderr)
|
|
110
311
|
return 1
|
|
111
312
|
|
|
112
313
|
|
|
113
314
|
def _scripts_doctor(args):
|
|
114
|
-
from
|
|
315
|
+
from db import init_db
|
|
316
|
+
from script_registry import doctor_script, doctor_all_scripts, sync_personal_scripts
|
|
317
|
+
|
|
318
|
+
init_db()
|
|
319
|
+
sync_personal_scripts()
|
|
115
320
|
|
|
116
321
|
if args.name:
|
|
117
322
|
results = [doctor_script(args.name)]
|
|
@@ -238,13 +443,90 @@ def _scripts_call(args):
|
|
|
238
443
|
|
|
239
444
|
|
|
240
445
|
def _update(args):
|
|
241
|
-
"""
|
|
446
|
+
"""Update the installed runtime.
|
|
447
|
+
|
|
448
|
+
Modes:
|
|
449
|
+
- Dev-linked runtime: sync from the source repo recorded in version.json
|
|
450
|
+
- Explicit dev env: sync from NEXO_CODE/src
|
|
451
|
+
- Packaged/runtime-only install: delegate to plugins.update handle_update()
|
|
452
|
+
"""
|
|
242
453
|
import shutil
|
|
243
454
|
|
|
244
|
-
src_dir = NEXO_CODE
|
|
245
|
-
repo_dir = NEXO_CODE.parent
|
|
246
455
|
dest = NEXO_HOME
|
|
247
456
|
|
|
457
|
+
def _runtime_version_source() -> Path | None:
|
|
458
|
+
version_file = NEXO_HOME / "version.json"
|
|
459
|
+
if not version_file.is_file():
|
|
460
|
+
return None
|
|
461
|
+
try:
|
|
462
|
+
data = json.loads(version_file.read_text())
|
|
463
|
+
except Exception:
|
|
464
|
+
return None
|
|
465
|
+
source = str(data.get("source", "")).strip()
|
|
466
|
+
if not source:
|
|
467
|
+
return None
|
|
468
|
+
candidate = Path(source).expanduser()
|
|
469
|
+
if (candidate / "src").is_dir() and (candidate / "package.json").is_file():
|
|
470
|
+
return candidate
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
def _resolve_sync_source() -> tuple[Path | None, Path | None]:
|
|
474
|
+
try:
|
|
475
|
+
same_as_runtime = NEXO_CODE.resolve() == dest.resolve()
|
|
476
|
+
except Exception:
|
|
477
|
+
same_as_runtime = NEXO_CODE == dest
|
|
478
|
+
|
|
479
|
+
# Explicit dev mode: NEXO_CODE points at repo/src, never the installed runtime itself.
|
|
480
|
+
if (
|
|
481
|
+
not same_as_runtime
|
|
482
|
+
and (NEXO_CODE / "db").is_dir()
|
|
483
|
+
and (NEXO_CODE.parent / "package.json").is_file()
|
|
484
|
+
):
|
|
485
|
+
return NEXO_CODE, NEXO_CODE.parent
|
|
486
|
+
|
|
487
|
+
# Installed runtime linked back to a source checkout
|
|
488
|
+
version_source = _runtime_version_source()
|
|
489
|
+
if version_source:
|
|
490
|
+
return version_source / "src", version_source
|
|
491
|
+
|
|
492
|
+
return None, None
|
|
493
|
+
|
|
494
|
+
src_dir, repo_dir = _resolve_sync_source()
|
|
495
|
+
|
|
496
|
+
if src_dir is not None:
|
|
497
|
+
try:
|
|
498
|
+
if src_dir.resolve() == dest.resolve():
|
|
499
|
+
version_source = _runtime_version_source()
|
|
500
|
+
if version_source:
|
|
501
|
+
src_dir = version_source / "src"
|
|
502
|
+
repo_dir = version_source
|
|
503
|
+
else:
|
|
504
|
+
src_dir = None
|
|
505
|
+
repo_dir = None
|
|
506
|
+
except Exception:
|
|
507
|
+
pass
|
|
508
|
+
|
|
509
|
+
if src_dir is None or repo_dir is None:
|
|
510
|
+
try:
|
|
511
|
+
from plugins.update import handle_update
|
|
512
|
+
except Exception as e:
|
|
513
|
+
print(
|
|
514
|
+
"No source repo recorded for this runtime and packaged updater is unavailable: "
|
|
515
|
+
f"{e}",
|
|
516
|
+
file=sys.stderr,
|
|
517
|
+
)
|
|
518
|
+
return 1
|
|
519
|
+
|
|
520
|
+
result = handle_update()
|
|
521
|
+
if args.json:
|
|
522
|
+
print(json.dumps({
|
|
523
|
+
"mode": "packaged",
|
|
524
|
+
"message": result,
|
|
525
|
+
}, indent=2, ensure_ascii=False))
|
|
526
|
+
else:
|
|
527
|
+
print(result)
|
|
528
|
+
return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
|
|
529
|
+
|
|
248
530
|
# Packages (directories with __init__.py or known structure)
|
|
249
531
|
packages = ["db", "cognitive", "doctor", "dashboard", "rules", "crons", "hooks"]
|
|
250
532
|
copied_packages = 0
|
|
@@ -269,6 +551,7 @@ def _update(args):
|
|
|
269
551
|
"tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
|
|
270
552
|
"tools_credentials.py", "tools_task_history.py", "tools_menu.py",
|
|
271
553
|
"cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
|
|
554
|
+
"cron_recovery.py",
|
|
272
555
|
"requirements.txt",
|
|
273
556
|
]
|
|
274
557
|
copied_files = 0
|
|
@@ -291,6 +574,7 @@ def _update(args):
|
|
|
291
574
|
scripts_src = src_dir / "scripts"
|
|
292
575
|
scripts_dest = dest / "scripts"
|
|
293
576
|
if scripts_src.is_dir():
|
|
577
|
+
scripts_dest.mkdir(parents=True, exist_ok=True)
|
|
294
578
|
for f in scripts_src.iterdir():
|
|
295
579
|
if f.name == "__pycache__" or f.name.startswith("."):
|
|
296
580
|
continue
|
|
@@ -313,6 +597,20 @@ def _update(args):
|
|
|
313
597
|
if f.is_file():
|
|
314
598
|
shutil.copy2(str(f), str(templates_dest / f.name))
|
|
315
599
|
|
|
600
|
+
# Runtime version metadata
|
|
601
|
+
package_json = repo_dir / "package.json"
|
|
602
|
+
if package_json.is_file():
|
|
603
|
+
shutil.copy2(str(package_json), str(dest / "package.json"))
|
|
604
|
+
try:
|
|
605
|
+
pkg = json.loads(package_json.read_text())
|
|
606
|
+
version_payload = {
|
|
607
|
+
"version": pkg.get("version", "?"),
|
|
608
|
+
"source": str(repo_dir),
|
|
609
|
+
}
|
|
610
|
+
(dest / "version.json").write_text(json.dumps(version_payload, indent=2))
|
|
611
|
+
except Exception:
|
|
612
|
+
pass
|
|
613
|
+
|
|
316
614
|
# Core skills
|
|
317
615
|
skills_src = src_dir / "skills"
|
|
318
616
|
skills_dest = dest / "skills-core"
|
|
@@ -343,7 +641,17 @@ def _update(args):
|
|
|
343
641
|
wrapper.write_text(wrapper_content)
|
|
344
642
|
wrapper.chmod(0o755)
|
|
345
643
|
|
|
644
|
+
try:
|
|
645
|
+
from db import init_db
|
|
646
|
+
from script_registry import sync_personal_scripts
|
|
647
|
+
|
|
648
|
+
init_db()
|
|
649
|
+
sync_personal_scripts()
|
|
650
|
+
except Exception:
|
|
651
|
+
pass
|
|
652
|
+
|
|
346
653
|
result = {
|
|
654
|
+
"mode": "sync",
|
|
347
655
|
"packages": copied_packages,
|
|
348
656
|
"files": copied_files,
|
|
349
657
|
"nexo_home": str(dest),
|
|
@@ -357,6 +665,81 @@ def _update(args):
|
|
|
357
665
|
return 0
|
|
358
666
|
|
|
359
667
|
|
|
668
|
+
def _service_control(service_name: str, action: str) -> int:
|
|
669
|
+
"""Control a LaunchAgent/systemd service: on, off, status."""
|
|
670
|
+
import platform as plat
|
|
671
|
+
|
|
672
|
+
label = f"com.nexo.{service_name}"
|
|
673
|
+
|
|
674
|
+
if plat.system() != "Darwin":
|
|
675
|
+
print(f"Service control only supported on macOS for now.", file=sys.stderr)
|
|
676
|
+
return 1
|
|
677
|
+
|
|
678
|
+
plist_path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
|
679
|
+
uid = os.getuid()
|
|
680
|
+
|
|
681
|
+
if action == "status":
|
|
682
|
+
result = subprocess.run(
|
|
683
|
+
["launchctl", "list"],
|
|
684
|
+
capture_output=True, text=True,
|
|
685
|
+
)
|
|
686
|
+
running = label in (result.stdout or "")
|
|
687
|
+
if running:
|
|
688
|
+
print(f"{service_name}: running")
|
|
689
|
+
else:
|
|
690
|
+
print(f"{service_name}: stopped")
|
|
691
|
+
return 0
|
|
692
|
+
|
|
693
|
+
if action == "on":
|
|
694
|
+
if not plist_path.is_file():
|
|
695
|
+
print(f"LaunchAgent not found: {plist_path}", file=sys.stderr)
|
|
696
|
+
print(f"Run 'nexo-brain' to install it, or enable it during setup.", file=sys.stderr)
|
|
697
|
+
return 1
|
|
698
|
+
subprocess.run(
|
|
699
|
+
["launchctl", "bootout", f"gui/{uid}", str(plist_path)],
|
|
700
|
+
capture_output=True,
|
|
701
|
+
)
|
|
702
|
+
result = subprocess.run(
|
|
703
|
+
["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
|
|
704
|
+
capture_output=True, text=True,
|
|
705
|
+
)
|
|
706
|
+
if result.returncode == 0:
|
|
707
|
+
print(f"{service_name}: started")
|
|
708
|
+
else:
|
|
709
|
+
print(f"Failed to start {service_name}: {result.stderr.strip()}", file=sys.stderr)
|
|
710
|
+
return 1
|
|
711
|
+
return 0
|
|
712
|
+
|
|
713
|
+
if action == "off":
|
|
714
|
+
result = subprocess.run(
|
|
715
|
+
["launchctl", "bootout", f"gui/{uid}", str(plist_path)],
|
|
716
|
+
capture_output=True, text=True,
|
|
717
|
+
)
|
|
718
|
+
print(f"{service_name}: stopped")
|
|
719
|
+
return 0
|
|
720
|
+
|
|
721
|
+
print(f"Unknown action: {action}. Use on, off, or status.", file=sys.stderr)
|
|
722
|
+
return 1
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _dashboard(args):
|
|
726
|
+
return _service_control("dashboard", args.action)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _chat(args):
|
|
730
|
+
target = args.path or "."
|
|
731
|
+
claude_bin = os.environ.get("CLAUDE_BIN") or shutil.which("claude")
|
|
732
|
+
if not claude_bin:
|
|
733
|
+
print("Claude Code launcher not found in PATH. Install `claude` first.", file=sys.stderr)
|
|
734
|
+
return 1
|
|
735
|
+
|
|
736
|
+
result = subprocess.run(
|
|
737
|
+
[claude_bin, "--dangerously-skip-permissions", target],
|
|
738
|
+
env=os.environ.copy(),
|
|
739
|
+
)
|
|
740
|
+
return int(result.returncode)
|
|
741
|
+
|
|
742
|
+
|
|
360
743
|
def _doctor(args):
|
|
361
744
|
"""Run unified doctor diagnostics."""
|
|
362
745
|
try:
|
|
@@ -482,10 +865,34 @@ def _skills_evolution(args):
|
|
|
482
865
|
return 0
|
|
483
866
|
|
|
484
867
|
|
|
868
|
+
def _print_help():
|
|
869
|
+
v = _get_version()
|
|
870
|
+
print(f"""NEXO Runtime CLI v{v}
|
|
871
|
+
|
|
872
|
+
Commands:
|
|
873
|
+
nexo chat [path] Launch Claude Code
|
|
874
|
+
nexo doctor [--tier boot|runtime|deep|all] [--fix] System diagnostics
|
|
875
|
+
nexo scripts list|create|classify|sync|reconcile|ensure-schedules|schedules|run|doctor|call|unschedule|remove
|
|
876
|
+
Personal scripts
|
|
877
|
+
nexo skills list|apply|sync|approve Executable skills
|
|
878
|
+
nexo update Update installed runtime
|
|
879
|
+
nexo dashboard on|off|status Web dashboard control
|
|
880
|
+
|
|
881
|
+
Run 'nexo <command> --help' for details.
|
|
882
|
+
Homepage: https://nexo-brain.com
|
|
883
|
+
GitHub: https://github.com/wazionapps/nexo""")
|
|
884
|
+
|
|
885
|
+
|
|
485
886
|
def main():
|
|
486
|
-
parser = argparse.ArgumentParser(prog="nexo", description="NEXO Runtime CLI")
|
|
887
|
+
parser = argparse.ArgumentParser(prog="nexo", description="NEXO Runtime CLI", add_help=False)
|
|
888
|
+
parser.add_argument("-h", "--help", action="store_true", help="Show help")
|
|
889
|
+
parser.add_argument("-v", "--version", action="store_true", help="Show version")
|
|
487
890
|
sub = parser.add_subparsers(dest="command")
|
|
488
891
|
|
|
892
|
+
# -- chat --
|
|
893
|
+
chat_parser = sub.add_parser("chat", help="Launch Claude Code")
|
|
894
|
+
chat_parser.add_argument("path", nargs="?", default=".", help="Working directory (default: current directory)")
|
|
895
|
+
|
|
489
896
|
# -- scripts --
|
|
490
897
|
scripts_parser = sub.add_parser("scripts", help="Manage personal scripts")
|
|
491
898
|
scripts_sub = scripts_parser.add_subparsers(dest="scripts_command")
|
|
@@ -495,6 +902,47 @@ def main():
|
|
|
495
902
|
list_p.add_argument("--all", action="store_true", help="Include core/internal scripts")
|
|
496
903
|
list_p.add_argument("--json", action="store_true", help="JSON output")
|
|
497
904
|
|
|
905
|
+
# scripts create
|
|
906
|
+
create_p = scripts_sub.add_parser("create", help="Create a personal script scaffold")
|
|
907
|
+
create_p.add_argument("name", help="Human/script name")
|
|
908
|
+
create_p.add_argument("--description", default="", help="One-line description")
|
|
909
|
+
create_p.add_argument("--runtime", default="python", choices=["python", "shell"], help="Script runtime")
|
|
910
|
+
create_p.add_argument("--force", action="store_true", help="Overwrite if the target file exists")
|
|
911
|
+
create_p.add_argument("--json", action="store_true", help="JSON output")
|
|
912
|
+
|
|
913
|
+
# scripts classify
|
|
914
|
+
classify_p = scripts_sub.add_parser("classify", help="Classify all files in NEXO_HOME/scripts")
|
|
915
|
+
classify_p.add_argument("--json", action="store_true", help="JSON output")
|
|
916
|
+
|
|
917
|
+
# scripts sync
|
|
918
|
+
sync_p = scripts_sub.add_parser("sync", help="Sync script registry from filesystem and personal LaunchAgents")
|
|
919
|
+
sync_p.add_argument("--json", action="store_true", help="JSON output")
|
|
920
|
+
|
|
921
|
+
# scripts reconcile
|
|
922
|
+
reconcile_p = scripts_sub.add_parser("reconcile", help="Classify, sync, and ensure declared schedules")
|
|
923
|
+
reconcile_p.add_argument("--dry-run", action="store_true", help="Show what would change without editing schedules")
|
|
924
|
+
reconcile_p.add_argument("--json", action="store_true", help="JSON output")
|
|
925
|
+
|
|
926
|
+
# scripts ensure-schedules
|
|
927
|
+
ensure_p = scripts_sub.add_parser("ensure-schedules", help="Create or repair declared personal schedules")
|
|
928
|
+
ensure_p.add_argument("--dry-run", action="store_true", help="Show what would change without editing schedules")
|
|
929
|
+
ensure_p.add_argument("--json", action="store_true", help="JSON output")
|
|
930
|
+
|
|
931
|
+
# scripts schedules
|
|
932
|
+
schedules_p = scripts_sub.add_parser("schedules", help="List registered personal script schedules")
|
|
933
|
+
schedules_p.add_argument("--json", action="store_true", help="JSON output")
|
|
934
|
+
|
|
935
|
+
# scripts unschedule
|
|
936
|
+
unschedule_p = scripts_sub.add_parser("unschedule", help="Remove all personal schedules from a script")
|
|
937
|
+
unschedule_p.add_argument("name", help="Script name or path")
|
|
938
|
+
unschedule_p.add_argument("--json", action="store_true", help="JSON output")
|
|
939
|
+
|
|
940
|
+
# scripts remove
|
|
941
|
+
remove_p = scripts_sub.add_parser("remove", help="Remove a personal script and any attached schedules")
|
|
942
|
+
remove_p.add_argument("name", help="Script name or path")
|
|
943
|
+
remove_p.add_argument("--keep-file", action="store_true", help="Keep the script file and only unregister/unschedule it")
|
|
944
|
+
remove_p.add_argument("--json", action="store_true", help="JSON output")
|
|
945
|
+
|
|
498
946
|
# scripts run
|
|
499
947
|
run_p = scripts_sub.add_parser("run", help="Run a script by name")
|
|
500
948
|
run_p.add_argument("name", help="Script name")
|
|
@@ -512,7 +960,7 @@ def main():
|
|
|
512
960
|
call_p.add_argument("--json-output", action="store_true", help="Force JSON output")
|
|
513
961
|
|
|
514
962
|
# -- update --
|
|
515
|
-
update_parser = sub.add_parser("update", help="
|
|
963
|
+
update_parser = sub.add_parser("update", help="Update installed runtime")
|
|
516
964
|
update_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
517
965
|
|
|
518
966
|
# -- doctor --
|
|
@@ -560,11 +1008,38 @@ def main():
|
|
|
560
1008
|
skills_evolution_p = skills_sub.add_parser("evolution", help="Evolution candidates")
|
|
561
1009
|
skills_evolution_p.add_argument("--json", action="store_true", help="JSON output")
|
|
562
1010
|
|
|
1011
|
+
# -- dashboard --
|
|
1012
|
+
dashboard_parser = sub.add_parser("dashboard", help="Web dashboard control")
|
|
1013
|
+
dashboard_parser.add_argument("action", choices=["on", "off", "status"], help="Start, stop, or check dashboard")
|
|
1014
|
+
|
|
563
1015
|
args = parser.parse_args()
|
|
564
1016
|
|
|
1017
|
+
if args.help or (not args.command and not args.version):
|
|
1018
|
+
_print_help()
|
|
1019
|
+
return 0
|
|
1020
|
+
if args.version:
|
|
1021
|
+
print(f"nexo v{_get_version()}")
|
|
1022
|
+
return 0
|
|
1023
|
+
|
|
565
1024
|
if args.command == "scripts":
|
|
566
1025
|
if args.scripts_command == "list":
|
|
567
1026
|
return _scripts_list(args)
|
|
1027
|
+
elif args.scripts_command == "create":
|
|
1028
|
+
return _scripts_create(args)
|
|
1029
|
+
elif args.scripts_command == "classify":
|
|
1030
|
+
return _scripts_classify(args)
|
|
1031
|
+
elif args.scripts_command == "sync":
|
|
1032
|
+
return _scripts_sync(args)
|
|
1033
|
+
elif args.scripts_command == "reconcile":
|
|
1034
|
+
return _scripts_reconcile(args)
|
|
1035
|
+
elif args.scripts_command == "ensure-schedules":
|
|
1036
|
+
return _scripts_ensure_schedules(args)
|
|
1037
|
+
elif args.scripts_command == "schedules":
|
|
1038
|
+
return _scripts_schedules(args)
|
|
1039
|
+
elif args.scripts_command == "unschedule":
|
|
1040
|
+
return _scripts_unschedule(args)
|
|
1041
|
+
elif args.scripts_command == "remove":
|
|
1042
|
+
return _scripts_remove(args)
|
|
568
1043
|
elif args.scripts_command == "run":
|
|
569
1044
|
return _scripts_run(args)
|
|
570
1045
|
elif args.scripts_command == "doctor":
|
|
@@ -574,6 +1049,8 @@ def main():
|
|
|
574
1049
|
else:
|
|
575
1050
|
scripts_parser.print_help()
|
|
576
1051
|
return 0
|
|
1052
|
+
elif args.command == "chat":
|
|
1053
|
+
return _chat(args)
|
|
577
1054
|
elif args.command == "update":
|
|
578
1055
|
return _update(args)
|
|
579
1056
|
elif args.command == "doctor":
|
|
@@ -596,8 +1073,10 @@ def main():
|
|
|
596
1073
|
else:
|
|
597
1074
|
skills_parser.print_help()
|
|
598
1075
|
return 0
|
|
1076
|
+
elif args.command == "dashboard":
|
|
1077
|
+
return _dashboard(args)
|
|
599
1078
|
else:
|
|
600
|
-
|
|
1079
|
+
_print_help()
|
|
601
1080
|
return 0
|
|
602
1081
|
|
|
603
1082
|
|