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/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 script_registry import list_scripts
40
- scripts = list_scripts(include_core=args.all)
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
- print(f" {s['name']:<{name_w}} {s['runtime']:<{rt_w}} {s['description']}{tag}")
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 script_registry import resolve_script_reference
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 script_registry import doctor_script, doctor_all_scripts
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
- """Sync all repo files to NEXO_HOME."""
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="Sync all repo files to NEXO_HOME")
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
- parser.print_help()
1079
+ _print_help()
601
1080
  return 0
602
1081
 
603
1082