nexo-brain 2.5.1 → 2.6.1

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.
@@ -5,8 +5,11 @@ Personal scripts use CLI as stable interface, never direct DB access.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import contextlib
8
9
  import json
9
10
  import os
11
+ import platform
12
+ import plistlib
10
13
  import re
11
14
  import shutil
12
15
  import stat
@@ -22,6 +25,10 @@ _IGNORED_FILES = {
22
25
  ".watchdog-fails",
23
26
  ".watchdog-nexo-repair.lock",
24
27
  "nexo-cron-wrapper.sh",
28
+ "nexo-dashboard.sh",
29
+ "nexo-prevent-sleep.sh",
30
+ "nexo-proactive-dashboard.py",
31
+ "nexo-tcc-approve.sh",
25
32
  }
26
33
  _IGNORED_DIRS = {"deep-sleep", "__pycache__"}
27
34
 
@@ -38,7 +45,22 @@ _FORBIDDEN_PATTERNS = [
38
45
  re.compile(r"\bfrom\s+cognitive\s+import\b"),
39
46
  ]
40
47
 
41
- METADATA_KEYS = {"name", "description", "runtime", "timeout", "requires", "tools", "hidden"}
48
+ METADATA_KEYS = {
49
+ "name",
50
+ "description",
51
+ "runtime",
52
+ "timeout",
53
+ "requires",
54
+ "tools",
55
+ "hidden",
56
+ "category",
57
+ "cron_id",
58
+ "schedule",
59
+ "interval_seconds",
60
+ "schedule_required",
61
+ }
62
+ SUPPORTED_RUNTIMES = {"python", "shell", "node", "php", "unknown"}
63
+ PERSONAL_SCHEDULE_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
42
64
 
43
65
 
44
66
  def get_nexo_home() -> Path:
@@ -67,7 +89,12 @@ def load_core_script_names() -> set[str]:
67
89
 
68
90
 
69
91
  def parse_inline_metadata(path: Path) -> dict:
70
- """Parse # nexo: key=value metadata from first 25 lines."""
92
+ """Parse inline metadata from first 25 lines.
93
+
94
+ Supported comment prefixes:
95
+ - # nexo:
96
+ - // nexo:
97
+ """
71
98
  meta: dict[str, str] = {}
72
99
  try:
73
100
  lines = path.read_text(errors="ignore").splitlines()[:25]
@@ -76,9 +103,13 @@ def parse_inline_metadata(path: Path) -> dict:
76
103
 
77
104
  for line in lines:
78
105
  stripped = line.strip()
79
- if not stripped.startswith("# nexo:"):
106
+ payload = ""
107
+ if stripped.startswith("# nexo:"):
108
+ payload = stripped[len("# nexo:"):].strip()
109
+ elif stripped.startswith("// nexo:"):
110
+ payload = stripped[len("// nexo:"):].strip()
111
+ else:
80
112
  continue
81
- payload = stripped[len("# nexo:"):].strip()
82
113
  if "=" not in payload:
83
114
  continue
84
115
  key, value = payload.split("=", 1)
@@ -100,10 +131,10 @@ def _detect_shebang(path: Path) -> str | None:
100
131
 
101
132
 
102
133
  def classify_runtime(path: Path, metadata: dict) -> str:
103
- """Detect script runtime: python, shell, or unknown."""
134
+ """Detect script runtime: python, shell, node, php, or unknown."""
104
135
  # 1. Metadata
105
136
  rt = metadata.get("runtime", "").lower()
106
- if rt in ("python", "shell"):
137
+ if rt in ("python", "shell", "node", "php"):
107
138
  return rt
108
139
 
109
140
  # 2. Shebang
@@ -113,6 +144,10 @@ def classify_runtime(path: Path, metadata: dict) -> str:
113
144
  return "python"
114
145
  if "bash" in shebang or "/sh" in shebang:
115
146
  return "shell"
147
+ if "node" in shebang:
148
+ return "node"
149
+ if "php" in shebang:
150
+ return "php"
116
151
 
117
152
  # 3. Extension
118
153
  ext = path.suffix.lower()
@@ -120,6 +155,10 @@ def classify_runtime(path: Path, metadata: dict) -> str:
120
155
  return "python"
121
156
  if ext == ".sh":
122
157
  return "shell"
158
+ if ext == ".js":
159
+ return "node"
160
+ if ext == ".php":
161
+ return "php"
123
162
 
124
163
  return "unknown"
125
164
 
@@ -136,46 +175,237 @@ def _is_ignored(path: Path) -> bool:
136
175
  return False
137
176
 
138
177
 
139
- def list_scripts(include_core: bool = False) -> list[dict]:
140
- """List scripts in NEXO_HOME/scripts/.
178
+ def _is_script_candidate(path: Path, metadata: dict | None = None) -> bool:
179
+ metadata = metadata or {}
180
+ runtime = classify_runtime(path, metadata)
181
+ if runtime != "unknown":
182
+ return True
183
+ if _detect_shebang(path):
184
+ return True
185
+ try:
186
+ return os.access(path, os.X_OK)
187
+ except Exception:
188
+ return False
141
189
 
142
- By default only personal scripts. With include_core=True, also shows core/cron scripts.
143
- """
190
+
191
+ def _truthy(value: str | bool | None) -> bool:
192
+ if isinstance(value, bool):
193
+ return value
194
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
195
+
196
+
197
+ def _safe_slug(value: str) -> str:
198
+ chars: list[str] = []
199
+ for ch in value.lower():
200
+ if ch.isalnum():
201
+ chars.append(ch)
202
+ elif ch in {"-", "_", " "}:
203
+ chars.append("-")
204
+ slug = "".join(chars).strip("-")
205
+ return slug or "script"
206
+
207
+
208
+ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
209
+ """Parse desired schedule metadata from inline script metadata."""
210
+ explicit_name = metadata.get("name", "").strip()
211
+ explicit_runtime = metadata.get("runtime", "").strip().lower()
212
+ explicit_cron_id = metadata.get("cron_id", "").strip()
213
+ cron_id = explicit_cron_id or _safe_slug(default_name or explicit_name or "script")
214
+ interval_raw = metadata.get("interval_seconds", "").strip()
215
+ schedule_raw = metadata.get("schedule", "").strip()
216
+ schedule_required = _truthy(metadata.get("schedule_required"))
217
+ required = schedule_required or bool(interval_raw or schedule_raw)
218
+
219
+ if required:
220
+ missing = []
221
+ if not explicit_name:
222
+ missing.append("name")
223
+ if not explicit_runtime:
224
+ missing.append("runtime")
225
+ elif explicit_runtime not in SUPPORTED_RUNTIMES - {"unknown"}:
226
+ return {
227
+ "required": required,
228
+ "valid": False,
229
+ "error": f"Invalid runtime metadata for scheduled script: {explicit_runtime}",
230
+ "cron_id": cron_id,
231
+ }
232
+ if not explicit_cron_id:
233
+ missing.append("cron_id")
234
+ if not schedule_required:
235
+ missing.append("schedule_required=true")
236
+ if missing:
237
+ return {
238
+ "required": required,
239
+ "valid": False,
240
+ "error": f"Scheduled scripts must declare {', '.join(missing)}",
241
+ "cron_id": cron_id,
242
+ }
243
+
244
+ if interval_raw and schedule_raw:
245
+ return {
246
+ "required": required,
247
+ "valid": False,
248
+ "error": "Both schedule and interval_seconds are set; choose one.",
249
+ "cron_id": cron_id,
250
+ }
251
+
252
+ if interval_raw:
253
+ try:
254
+ interval = int(interval_raw)
255
+ except ValueError:
256
+ return {
257
+ "required": required,
258
+ "valid": False,
259
+ "error": f"Invalid interval_seconds: {interval_raw}",
260
+ "cron_id": cron_id,
261
+ }
262
+ if interval <= 0:
263
+ return {
264
+ "required": required,
265
+ "valid": False,
266
+ "error": f"interval_seconds must be > 0 (got {interval_raw})",
267
+ "cron_id": cron_id,
268
+ }
269
+ return {
270
+ "required": required,
271
+ "valid": True,
272
+ "cron_id": cron_id,
273
+ "schedule_type": "interval",
274
+ "schedule_value": str(interval),
275
+ "schedule_label": f"every {interval}s",
276
+ "schedule": "",
277
+ "interval_seconds": interval,
278
+ }
279
+
280
+ if schedule_raw:
281
+ parts = schedule_raw.split(":")
282
+ if len(parts) not in {2, 3}:
283
+ return {
284
+ "required": required,
285
+ "valid": False,
286
+ "error": f"Invalid schedule format: {schedule_raw}",
287
+ "cron_id": cron_id,
288
+ }
289
+ try:
290
+ hour = int(parts[0])
291
+ minute = int(parts[1])
292
+ weekday = int(parts[2]) if len(parts) == 3 else None
293
+ except ValueError:
294
+ return {
295
+ "required": required,
296
+ "valid": False,
297
+ "error": f"Invalid schedule format: {schedule_raw}",
298
+ "cron_id": cron_id,
299
+ }
300
+ if not (0 <= hour <= 23 and 0 <= minute <= 59):
301
+ return {
302
+ "required": required,
303
+ "valid": False,
304
+ "error": f"Invalid schedule time: {schedule_raw}",
305
+ "cron_id": cron_id,
306
+ }
307
+ if weekday is not None and not (0 <= weekday <= 6):
308
+ return {
309
+ "required": required,
310
+ "valid": False,
311
+ "error": f"Invalid schedule weekday: {schedule_raw}",
312
+ "cron_id": cron_id,
313
+ }
314
+ label = f"{hour:02d}:{minute:02d}"
315
+ if weekday is not None:
316
+ label += f" weekday={weekday}"
317
+ else:
318
+ label += " daily"
319
+ return {
320
+ "required": required,
321
+ "valid": True,
322
+ "cron_id": cron_id,
323
+ "schedule_type": "calendar",
324
+ "schedule_value": schedule_raw,
325
+ "schedule_label": label,
326
+ "schedule": schedule_raw,
327
+ "interval_seconds": 0,
328
+ }
329
+
330
+ return {
331
+ "required": required,
332
+ "valid": not required,
333
+ "error": "" if not required else "schedule_required=true but no schedule metadata was provided.",
334
+ "cron_id": cron_id,
335
+ }
336
+
337
+
338
+ def _script_entry(path: Path, meta: dict, *, is_core: bool, classification: str, reason: str = "") -> dict:
339
+ runtime = classify_runtime(path, meta)
340
+ name = meta.get("name", path.stem)
341
+ return {
342
+ "name": name,
343
+ "runtime": runtime,
344
+ "description": meta.get("description", ""),
345
+ "path": str(path),
346
+ "core": is_core,
347
+ "metadata": meta,
348
+ "classification": classification,
349
+ "reason": reason,
350
+ "declared_schedule": get_declared_schedule(meta, name),
351
+ }
352
+
353
+
354
+ def classify_scripts_dir() -> dict:
355
+ """Classify every file in NEXO_HOME/scripts into personal/core/ignored/non-script buckets."""
144
356
  scripts_dir = get_scripts_dir()
145
357
  if not scripts_dir.is_dir():
146
- return []
358
+ return {"scripts_dir": str(scripts_dir), "entries": [], "summary": {}}
147
359
 
148
360
  core_names = load_core_script_names()
149
- results = []
150
-
361
+ entries: list[dict] = []
151
362
  for f in sorted(scripts_dir.iterdir()):
152
363
  if not f.is_file():
153
364
  continue
365
+
366
+ meta = parse_inline_metadata(f)
154
367
  if _is_ignored(f):
368
+ entries.append(_script_entry(f, meta, is_core=False, classification="ignored", reason="internal or hidden artifact"))
155
369
  continue
156
370
 
157
- is_core = f.name in core_names
158
- if is_core and not include_core:
371
+ if not _is_script_candidate(f, meta):
372
+ entries.append(_script_entry(f, meta, is_core=False, classification="non-script", reason="not an executable/script candidate"))
159
373
  continue
160
374
 
161
- meta = parse_inline_metadata(f)
162
- runtime = classify_runtime(f, meta)
163
- name = meta.get("name", f.stem)
164
- hidden = meta.get("hidden", "").lower() in ("true", "1", "yes")
375
+ is_core = f.name in core_names
376
+ classification = "core" if is_core else "personal"
377
+ entries.append(_script_entry(f, meta, is_core=is_core, classification=classification))
378
+
379
+ summary: dict[str, int] = {}
380
+ for entry in entries:
381
+ summary[entry["classification"]] = summary.get(entry["classification"], 0) + 1
382
+ return {"scripts_dir": str(scripts_dir), "entries": entries, "summary": summary}
383
+
384
+
385
+ def list_scripts(include_core: bool = False) -> list[dict]:
386
+ """List scripts in NEXO_HOME/scripts/.
165
387
 
388
+ By default only personal scripts. With include_core=True, also shows core/cron scripts.
389
+ """
390
+ results = []
391
+ for entry in classify_scripts_dir()["entries"]:
392
+ if entry["classification"] not in {"personal", "core"}:
393
+ continue
394
+ if entry["core"] and not include_core:
395
+ continue
396
+ hidden = _truthy(entry.get("metadata", {}).get("hidden"))
166
397
  if hidden and not include_core:
167
398
  continue
399
+ results.append(entry)
400
+ return results
168
401
 
169
- results.append({
170
- "name": name,
171
- "runtime": runtime,
172
- "description": meta.get("description", ""),
173
- "path": str(f),
174
- "core": is_core,
175
- "metadata": meta,
176
- })
177
402
 
178
- return results
403
+ def _within_scripts_dir(path: Path) -> bool:
404
+ try:
405
+ path.resolve().relative_to(get_scripts_dir().resolve())
406
+ return True
407
+ except Exception:
408
+ return False
179
409
 
180
410
 
181
411
  def resolve_script(name: str) -> dict | None:
@@ -188,6 +418,8 @@ def resolve_script(name: str) -> dict | None:
188
418
  if not f.is_file() or _is_ignored(f):
189
419
  continue
190
420
  meta = parse_inline_metadata(f)
421
+ if not _is_script_candidate(f, meta):
422
+ continue
191
423
  script_name = meta.get("name", f.stem)
192
424
  if script_name == name or f.stem == name:
193
425
  runtime = classify_runtime(f, meta)
@@ -218,6 +450,611 @@ def resolve_script_reference(ref: str) -> dict | None:
218
450
  return resolve_script(ref)
219
451
 
220
452
 
453
+ def _extract_script_path_from_program_args(program_args: list) -> Path | None:
454
+ candidate = _extract_script_path_candidate(program_args)
455
+ if candidate is None:
456
+ return None
457
+ if not candidate.is_file():
458
+ return None
459
+ if not _within_scripts_dir(candidate):
460
+ return None
461
+ if _is_ignored(candidate):
462
+ return None
463
+ return candidate
464
+
465
+
466
+ def _extract_script_path_candidate(program_args: list) -> Path | None:
467
+ candidates: list[Path] = []
468
+ for arg in program_args or []:
469
+ if not isinstance(arg, str):
470
+ continue
471
+ candidate = Path(arg).expanduser()
472
+ if not str(candidate).startswith("/") and not str(arg).startswith("~"):
473
+ continue
474
+ candidates.append(candidate)
475
+ if not candidates:
476
+ return None
477
+ return candidates[-1]
478
+
479
+
480
+ def _format_schedule_from_plist(plist_data: dict) -> tuple[str, str, str]:
481
+ if plist_data.get("KeepAlive") is True:
482
+ return "keep_alive", "true", "keep alive"
483
+ if plist_data.get("RunAtLoad") is True and "StartInterval" not in plist_data and "StartCalendarInterval" not in plist_data:
484
+ return "run_at_load", "true", "run at load"
485
+
486
+ if "StartInterval" in plist_data:
487
+ interval = int(plist_data["StartInterval"])
488
+ return "interval", str(interval), f"every {interval}s"
489
+
490
+ cal = plist_data.get("StartCalendarInterval")
491
+ if cal:
492
+ if isinstance(cal, list):
493
+ value = json.dumps(cal, ensure_ascii=False)
494
+ return "calendar", value, "calendar"
495
+ hour = cal.get("Hour")
496
+ minute = cal.get("Minute")
497
+ weekday = cal.get("Weekday")
498
+ if weekday is not None and hour is not None and minute is not None:
499
+ return "calendar", json.dumps(cal, ensure_ascii=False), f"{hour:02d}:{minute:02d} weekday={weekday}"
500
+ if hour is not None and minute is not None:
501
+ return "calendar", json.dumps(cal, ensure_ascii=False), f"{hour:02d}:{minute:02d} daily"
502
+ return "calendar", json.dumps(cal, ensure_ascii=False), "calendar"
503
+
504
+ return "manual", "", ""
505
+
506
+
507
+ def _calendar_payload_from_declared(schedule_value: str) -> dict | list | None:
508
+ if not schedule_value:
509
+ return None
510
+ if schedule_value.lstrip().startswith("{") or schedule_value.lstrip().startswith("["):
511
+ try:
512
+ parsed = json.loads(schedule_value)
513
+ except Exception:
514
+ return None
515
+ return parsed if isinstance(parsed, (dict, list)) else None
516
+
517
+ parts = schedule_value.split(":")
518
+ if len(parts) not in {2, 3}:
519
+ return None
520
+ try:
521
+ hour = int(parts[0])
522
+ minute = int(parts[1])
523
+ weekday = int(parts[2]) if len(parts) == 3 else None
524
+ except ValueError:
525
+ return None
526
+
527
+ payload = {"Hour": hour, "Minute": minute}
528
+ if weekday is not None:
529
+ payload["Weekday"] = weekday
530
+ return payload
531
+
532
+
533
+ def _canonical_schedule_value(schedule_type: str, schedule_value: str | dict | list) -> str:
534
+ if schedule_type == "calendar":
535
+ payload = _calendar_payload_from_declared(str(schedule_value)) if isinstance(schedule_value, str) else schedule_value
536
+ if payload is None:
537
+ return str(schedule_value or "")
538
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
539
+ return str(schedule_value or "")
540
+
541
+
542
+ def _discover_personal_schedule_records() -> list[dict]:
543
+ """Inspect macOS LaunchAgents and return raw personal schedule records."""
544
+ if platform.system() != "Darwin":
545
+ return []
546
+
547
+ results = []
548
+ launch_agents_dir = Path.home() / "Library" / "LaunchAgents"
549
+ if not launch_agents_dir.is_dir():
550
+ return results
551
+
552
+ core_names = load_core_script_names()
553
+ for plist_path in sorted(launch_agents_dir.glob("com.nexo.*.plist")):
554
+ try:
555
+ with plist_path.open("rb") as fh:
556
+ plist_data = plistlib.load(fh)
557
+ except Exception:
558
+ continue
559
+
560
+ env = plist_data.get("EnvironmentVariables") or {}
561
+ if env.get("NEXO_MANAGED_CORE_CRON") == "1":
562
+ continue
563
+
564
+ program_args = plist_data.get("ProgramArguments") or []
565
+ candidate = _extract_script_path_candidate(program_args)
566
+ label = str(plist_data.get("Label", plist_path.stem))
567
+ cron_id = label.replace("com.nexo.", "", 1)
568
+ script_path = candidate.expanduser() if candidate is not None else None
569
+ in_scripts_dir = bool(script_path and _within_scripts_dir(script_path))
570
+ exists = bool(script_path and script_path.is_file())
571
+ ignored = bool(script_path and in_scripts_dir and _is_ignored(script_path))
572
+ is_core = bool(script_path and exists and script_path.name in core_names)
573
+ if is_core or ignored:
574
+ continue
575
+
576
+ schedule_type, schedule_value, schedule_label = _format_schedule_from_plist(plist_data)
577
+ results.append({
578
+ "cron_id": cron_id,
579
+ "script_path": str(script_path) if script_path else "",
580
+ "schedule_type": schedule_type,
581
+ "schedule_value": schedule_value,
582
+ "schedule_label": schedule_label,
583
+ "launchd_label": label,
584
+ "plist_path": str(plist_path),
585
+ "enabled": True,
586
+ "description": "",
587
+ "managed_marker": env.get(PERSONAL_SCHEDULE_MANAGED_ENV) == "1",
588
+ "script_exists": exists,
589
+ "script_within_scripts_dir": in_scripts_dir,
590
+ })
591
+
592
+ return results
593
+
594
+
595
+ def audit_personal_schedules() -> dict:
596
+ """Return semantic schedule audit for personal LaunchAgents.
597
+
598
+ Only schedules created/repaired through the official flow count as managed.
599
+ Manual plists are discovered for visibility and repair, but never blessed.
600
+ """
601
+ classification = classify_scripts_dir()
602
+ personal_scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
603
+ scripts_by_path = {
604
+ str(Path(entry["path"]).expanduser().resolve(strict=False)): entry
605
+ for entry in personal_scripts
606
+ }
607
+
608
+ audited: list[dict] = []
609
+ summary = {
610
+ "declared_managed": 0,
611
+ "discovered_manual": 0,
612
+ "orphan_schedule": 0,
613
+ "healthy": 0,
614
+ "problems": 0,
615
+ "managed_registered": 0,
616
+ }
617
+
618
+ for record in _discover_personal_schedule_records():
619
+ script_path = record.get("script_path", "")
620
+ resolved_path = str(Path(script_path).expanduser().resolve(strict=False)) if script_path else ""
621
+ script = scripts_by_path.get(resolved_path)
622
+ declared = script.get("declared_schedule", {}) if script else {}
623
+ declared_valid = bool(script and declared.get("required") and declared.get("valid"))
624
+ matches = declared_valid and _schedule_matches(record, declared)
625
+
626
+ if record.get("managed_marker") and declared_valid:
627
+ schedule_origin = "declared_managed"
628
+ elif declared_valid:
629
+ schedule_origin = "discovered_manual"
630
+ else:
631
+ schedule_origin = "orphan_schedule"
632
+
633
+ problems: list[str] = []
634
+ if not record.get("script_within_scripts_dir"):
635
+ problems.append("schedule points outside NEXO_HOME/scripts")
636
+ elif not record.get("script_path"):
637
+ problems.append("schedule does not resolve a script path")
638
+ elif not record.get("script_exists"):
639
+ problems.append(f"scheduled script missing: {record['script_path']}")
640
+ elif not script:
641
+ problems.append("schedule points to a script that is not a registered personal script")
642
+
643
+ if script and not declared.get("required"):
644
+ problems.append("personal schedule exists without declared inline metadata")
645
+ elif script and declared.get("required") and not declared.get("valid"):
646
+ problems.append(declared.get("error", "invalid declared schedule metadata"))
647
+ elif declared_valid and not matches:
648
+ problems.append(
649
+ f"schedule drift: actual {record.get('schedule_label') or record.get('schedule_value') or record.get('schedule_type')} "
650
+ f"!= declared {declared.get('schedule_label') or declared.get('cron_id')}"
651
+ )
652
+
653
+ if declared_valid and not record.get("managed_marker"):
654
+ problems.append("schedule was discovered manually and must be recreated via nexo scripts reconcile")
655
+
656
+ schedule_managed = bool(schedule_origin == "declared_managed" and matches and not problems)
657
+ if schedule_managed:
658
+ schedule_state = "healthy"
659
+ elif schedule_origin == "declared_managed":
660
+ schedule_state = "drifted"
661
+ elif schedule_origin == "discovered_manual" and matches:
662
+ schedule_state = "manual_matching_declared"
663
+ elif schedule_origin == "discovered_manual":
664
+ schedule_state = "manual_drift"
665
+ else:
666
+ schedule_state = "orphaned"
667
+
668
+ audited_record = dict(record)
669
+ audited_record.update({
670
+ "schedule_origin": schedule_origin,
671
+ "schedule_declared": declared_valid,
672
+ "schedule_managed": schedule_managed,
673
+ "schedule_matches_declared": matches,
674
+ "schedule_state": schedule_state,
675
+ "problems": problems,
676
+ "script_name": script.get("name", "") if script else "",
677
+ "declared_schedule": declared if script else {},
678
+ })
679
+ audited.append(audited_record)
680
+ summary[schedule_origin] += 1
681
+ if schedule_managed:
682
+ summary["healthy"] += 1
683
+ summary["managed_registered"] += 1
684
+ else:
685
+ summary["problems"] += 1
686
+
687
+ return {
688
+ "schedules": audited,
689
+ "summary": summary,
690
+ }
691
+
692
+
693
+ def discover_personal_schedules() -> list[dict]:
694
+ """Return only healthy managed personal schedules."""
695
+ managed: list[dict] = []
696
+ for record in audit_personal_schedules()["schedules"]:
697
+ if record.get("schedule_managed"):
698
+ managed.append({
699
+ "cron_id": record["cron_id"],
700
+ "script_path": record["script_path"],
701
+ "schedule_type": record["schedule_type"],
702
+ "schedule_value": record["schedule_value"],
703
+ "schedule_label": record["schedule_label"],
704
+ "launchd_label": record["launchd_label"],
705
+ "plist_path": record["plist_path"],
706
+ "enabled": record.get("enabled", True),
707
+ "description": record.get("description", ""),
708
+ })
709
+ return managed
710
+
711
+
712
+ def sync_personal_scripts(prune_missing: bool = True) -> dict:
713
+ """Sync filesystem + scheduler state into the DB-backed personal scripts registry."""
714
+ from db import init_db, sync_personal_scripts_registry
715
+
716
+ init_db()
717
+ classification = classify_scripts_dir()
718
+ scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
719
+ schedule_audit = audit_personal_schedules()
720
+ schedules = [record for record in schedule_audit["schedules"] if record.get("schedule_managed")]
721
+ result = sync_personal_scripts_registry(scripts, schedules, prune_missing=prune_missing)
722
+ result["classification"] = classification["summary"]
723
+ missing_declared = []
724
+ managed_by_path: dict[str, list[dict]] = {}
725
+ for schedule in schedules:
726
+ managed_by_path.setdefault(schedule["script_path"], []).append(schedule)
727
+ schedules_by_path: dict[str, list[dict]] = {}
728
+ for schedule in schedule_audit["schedules"]:
729
+ schedules_by_path.setdefault(schedule["script_path"], []).append(schedule)
730
+ for script in scripts:
731
+ declared = script.get("declared_schedule", {})
732
+ if not declared.get("required"):
733
+ continue
734
+ healthy = managed_by_path.get(script["path"], [])
735
+ if healthy:
736
+ continue
737
+ attached = schedules_by_path.get(script["path"], [])
738
+ if not attached:
739
+ missing_declared.append({
740
+ "name": script["name"],
741
+ "path": script["path"],
742
+ "declared_schedule": declared,
743
+ "reason": "no schedule discovered",
744
+ })
745
+ continue
746
+ attached_states = [item.get("schedule_state", item.get("schedule_origin", "unknown")) for item in attached]
747
+ missing_declared.append({
748
+ "name": script["name"],
749
+ "path": script["path"],
750
+ "declared_schedule": declared,
751
+ "reason": f"schedule discovered but not managed ({', '.join(attached_states)})",
752
+ })
753
+ result["schedule_audit"] = schedule_audit
754
+ result["missing_declared_schedules"] = missing_declared
755
+ return result
756
+
757
+
758
+ def _schedule_matches(existing: dict, declared: dict) -> bool:
759
+ if not existing or not declared.get("valid"):
760
+ return False
761
+ if existing.get("cron_id") != declared.get("cron_id"):
762
+ return False
763
+ if existing.get("schedule_type") != declared.get("schedule_type"):
764
+ return False
765
+ existing_value = _canonical_schedule_value(existing.get("schedule_type", ""), existing.get("schedule_value", ""))
766
+ declared_value = _canonical_schedule_value(declared.get("schedule_type", ""), declared.get("schedule_value", ""))
767
+ if existing_value != declared_value:
768
+ return False
769
+ return True
770
+
771
+
772
+ def _remove_schedule_file(*, cron_id: str, plist_path: str) -> dict:
773
+ removed = {
774
+ "cron_id": cron_id,
775
+ "plist_path": plist_path,
776
+ "deleted": False,
777
+ }
778
+ plist = Path(plist_path) if plist_path else None
779
+ if plist and platform.system() == "Darwin" and plist.exists():
780
+ subprocess.run(
781
+ ["launchctl", "bootout", f"gui/{os.getuid()}", str(plist)],
782
+ capture_output=True,
783
+ )
784
+ with contextlib.suppress(FileNotFoundError):
785
+ plist.unlink()
786
+ removed["deleted"] = True
787
+ return removed
788
+
789
+
790
+ def ensure_personal_schedules(*, dry_run: bool = False) -> dict:
791
+ """Create or repair personal schedules declared in inline script metadata."""
792
+ classification = classify_scripts_dir()
793
+ scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
794
+ schedule_audit = audit_personal_schedules()
795
+ schedules_by_path: dict[str, list[dict]] = {}
796
+ for schedule in schedule_audit["schedules"]:
797
+ schedules_by_path.setdefault(schedule["script_path"], []).append(schedule)
798
+
799
+ report = {
800
+ "ok": True,
801
+ "dry_run": dry_run,
802
+ "created": [],
803
+ "repaired": [],
804
+ "already_present": [],
805
+ "skipped": [],
806
+ "invalid": [],
807
+ }
808
+
809
+ for script in scripts:
810
+ declared = script.get("declared_schedule", {})
811
+ if not declared.get("required"):
812
+ report["skipped"].append({
813
+ "name": script["name"],
814
+ "reason": "no declared schedule",
815
+ })
816
+ continue
817
+ if not declared.get("valid"):
818
+ report["invalid"].append({
819
+ "name": script["name"],
820
+ "path": script["path"],
821
+ "error": declared.get("error", "invalid schedule metadata"),
822
+ })
823
+ continue
824
+
825
+ existing = schedules_by_path.get(script["path"], [])
826
+ matching = next((item for item in existing if item.get("schedule_managed") and _schedule_matches(item, declared)), None)
827
+ if matching:
828
+ report["already_present"].append({
829
+ "name": script["name"],
830
+ "cron_id": matching["cron_id"],
831
+ "schedule_label": matching.get("schedule_label", ""),
832
+ })
833
+ continue
834
+
835
+ repair_reasons = [item.get("schedule_state", item.get("schedule_origin", "unknown")) for item in existing]
836
+ if dry_run:
837
+ report["repaired" if existing else "created"].append({
838
+ "name": script["name"],
839
+ "cron_id": declared["cron_id"],
840
+ "schedule_label": declared["schedule_label"],
841
+ "dry_run": True,
842
+ "reason": ", ".join(repair_reasons) if repair_reasons else "missing schedule",
843
+ })
844
+ continue
845
+
846
+ removed = []
847
+ if existing:
848
+ for item in existing:
849
+ removed.append(_remove_schedule_file(cron_id=item["cron_id"], plist_path=item.get("plist_path", "")))
850
+ from db import delete_personal_script_schedule
851
+
852
+ for item in existing:
853
+ delete_personal_script_schedule(item["cron_id"])
854
+
855
+ from plugins.schedule import handle_schedule_add
856
+
857
+ response = handle_schedule_add(
858
+ cron_id=declared["cron_id"],
859
+ script=script["path"],
860
+ schedule=declared.get("schedule", ""),
861
+ interval_seconds=declared.get("interval_seconds", 0),
862
+ description=script.get("description", ""),
863
+ script_type=script.get("runtime", "auto"),
864
+ )
865
+ target = report["repaired" if existing else "created"]
866
+ target.append({
867
+ "name": script["name"],
868
+ "cron_id": declared["cron_id"],
869
+ "schedule_label": declared["schedule_label"],
870
+ "reason": ", ".join(repair_reasons) if repair_reasons else "missing schedule",
871
+ "removed": removed,
872
+ "result": response,
873
+ })
874
+
875
+ sync_result = sync_personal_scripts()
876
+ report["sync"] = sync_result
877
+ report["classification"] = classification["summary"]
878
+ return report
879
+
880
+
881
+ def reconcile_personal_scripts(*, dry_run: bool = False) -> dict:
882
+ """Full lifecycle reconciliation: classify, sync registry, ensure declared schedules."""
883
+ sync_result = sync_personal_scripts()
884
+ ensure_result = ensure_personal_schedules(dry_run=dry_run)
885
+ return {
886
+ "ok": True,
887
+ "dry_run": dry_run,
888
+ "sync": sync_result,
889
+ "ensure_schedules": ensure_result,
890
+ "classification": ensure_result.get("classification", sync_result.get("classification", {})),
891
+ }
892
+
893
+
894
+ def _template_path(filename: str) -> Path | None:
895
+ candidates = [
896
+ NEXO_HOME / "templates" / filename,
897
+ NEXO_CODE.parent / "templates" / filename,
898
+ NEXO_CODE / "templates" / filename,
899
+ ]
900
+ for candidate in candidates:
901
+ if candidate.is_file():
902
+ return candidate
903
+ return None
904
+
905
+
906
+ def _script_filename_from_name(name: str, runtime: str) -> str:
907
+ slug = []
908
+ for ch in name.strip().lower():
909
+ if ch.isalnum():
910
+ slug.append(ch)
911
+ elif ch in {" ", "-", "_"}:
912
+ slug.append("-")
913
+ stem = "".join(slug).strip("-") or "personal-script"
914
+ ext = {
915
+ "python": ".py",
916
+ "shell": ".sh",
917
+ "node": ".js",
918
+ "php": ".php",
919
+ }.get(runtime, ".py")
920
+ return stem + ext
921
+
922
+
923
+ def create_script(name: str, *, description: str = "", runtime: str = "python", force: bool = False) -> dict:
924
+ runtime = runtime if runtime in SUPPORTED_RUNTIMES else "python"
925
+ if runtime == "unknown":
926
+ runtime = "python"
927
+
928
+ scripts_dir = get_scripts_dir()
929
+ scripts_dir.mkdir(parents=True, exist_ok=True)
930
+ filename = _script_filename_from_name(name, runtime)
931
+ path = scripts_dir / filename
932
+ if path.exists() and not force:
933
+ raise FileExistsError(f"Script already exists: {path}")
934
+
935
+ if runtime == "shell":
936
+ template_path = _template_path("script-template.sh")
937
+ else:
938
+ template_path = _template_path("script-template.py")
939
+
940
+ if template_path:
941
+ content = template_path.read_text()
942
+ elif runtime == "shell":
943
+ content = (
944
+ "#!/usr/bin/env bash\n"
945
+ "# nexo: name=example-script\n"
946
+ "# nexo: description=Example shell script using NEXO\n"
947
+ "# nexo: runtime=shell\n"
948
+ "set -euo pipefail\n"
949
+ "echo \"Hello from NEXO personal script\"\n"
950
+ )
951
+ else:
952
+ content = (
953
+ "#!/usr/bin/env python3\n"
954
+ "# nexo: name=example-script\n"
955
+ "# nexo: description=Example personal script using NEXO\n"
956
+ "# nexo: runtime=python\n"
957
+ "print('hello')\n"
958
+ )
959
+
960
+ script_name = Path(filename).stem
961
+ content = content.replace("example-script", script_name)
962
+ content = content.replace("Example personal script using the stable NEXO CLI", description or f"Personal script: {script_name}")
963
+ content = content.replace("Example shell script using NEXO", description or f"Personal script: {script_name}")
964
+
965
+ path.write_text(content)
966
+ if runtime in {"shell", "python"}:
967
+ path.chmod(0o755)
968
+ sync_result = sync_personal_scripts()
969
+ return {
970
+ "ok": True,
971
+ "name": script_name,
972
+ "path": str(path),
973
+ "runtime": runtime,
974
+ "description": description,
975
+ "sync": sync_result,
976
+ }
977
+
978
+
979
+ def unschedule_personal_script(name_or_path: str) -> dict:
980
+ """Remove all personal schedules attached to a script and prune registry entries."""
981
+ from db import (
982
+ init_db,
983
+ get_personal_script,
984
+ delete_personal_script_schedule,
985
+ )
986
+
987
+ init_db()
988
+ sync_personal_scripts()
989
+ script = get_personal_script(name_or_path)
990
+ if not script:
991
+ resolved = resolve_script(name_or_path)
992
+ if not resolved or resolved.get("core"):
993
+ return {"ok": False, "error": f"Personal script not found: {name_or_path}"}
994
+ script = resolved
995
+
996
+ removed: list[dict] = []
997
+ audited = audit_personal_schedules()
998
+ discovered = [
999
+ item for item in audited["schedules"]
1000
+ if item.get("script_path") == script.get("path")
1001
+ ]
1002
+ for schedule in discovered:
1003
+ removed.append(_remove_schedule_file(cron_id=schedule["cron_id"], plist_path=schedule.get("plist_path", "")))
1004
+
1005
+ for schedule in script.get("schedules", []):
1006
+ delete_personal_script_schedule(schedule["cron_id"])
1007
+ if not any(item["cron_id"] == schedule["cron_id"] for item in removed):
1008
+ removed.append({
1009
+ "cron_id": schedule["cron_id"],
1010
+ "plist_path": schedule.get("plist_path", ""),
1011
+ "deleted": False,
1012
+ })
1013
+
1014
+ sync_result = sync_personal_scripts()
1015
+ return {
1016
+ "ok": True,
1017
+ "script": script["name"],
1018
+ "removed_schedules": removed,
1019
+ "sync": sync_result,
1020
+ }
1021
+
1022
+
1023
+ def remove_personal_script(name_or_path: str, *, keep_file: bool = False) -> dict:
1024
+ """Remove a personal script from the runtime and registry."""
1025
+ from db import init_db, get_personal_script, delete_personal_script
1026
+
1027
+ init_db()
1028
+ sync_personal_scripts()
1029
+ script = get_personal_script(name_or_path)
1030
+ if not script:
1031
+ resolved = resolve_script(name_or_path)
1032
+ if not resolved or resolved.get("core"):
1033
+ return {"ok": False, "error": f"Personal script not found: {name_or_path}"}
1034
+ script = resolved
1035
+
1036
+ if script.get("core"):
1037
+ return {"ok": False, "error": "Refusing to remove a core script via personal scripts lifecycle."}
1038
+
1039
+ unschedule_result = unschedule_personal_script(script["path"])
1040
+ deleted_file = False
1041
+ path = Path(script["path"])
1042
+ if not keep_file and path.is_file() and _within_scripts_dir(path):
1043
+ path.unlink()
1044
+ deleted_file = True
1045
+ delete_personal_script(script["path"])
1046
+ sync_result = sync_personal_scripts()
1047
+ return {
1048
+ "ok": True,
1049
+ "script": script["name"],
1050
+ "path": script["path"],
1051
+ "deleted_file": deleted_file,
1052
+ "keep_file": keep_file,
1053
+ "unschedule": unschedule_result,
1054
+ "sync": sync_result,
1055
+ }
1056
+
1057
+
221
1058
  def doctor_script(path_or_name: str) -> dict:
222
1059
  """Validate a single script. Returns dict with pass/warn/fail items."""
223
1060
  # Resolve
@@ -280,6 +1117,18 @@ def doctor_script(path_or_name: str) -> dict:
280
1117
  except ValueError:
281
1118
  items.append({"level": "fail", "msg": f"Invalid timeout value: {timeout_str}"})
282
1119
 
1120
+ declared = get_declared_schedule(meta, name)
1121
+ if declared.get("required"):
1122
+ if declared.get("valid"):
1123
+ items.append({"level": "pass", "msg": f"Declared schedule: {declared['schedule_label']}"})
1124
+ else:
1125
+ items.append({"level": "fail", "msg": declared.get("error", "Invalid declared schedule metadata")})
1126
+
1127
+ if runtime == "node" and not shutil.which("node"):
1128
+ items.append({"level": "fail", "msg": "Node runtime not found in PATH"})
1129
+ if runtime == "php" and not shutil.which("php"):
1130
+ items.append({"level": "fail", "msg": "PHP runtime not found in PATH"})
1131
+
283
1132
  # Requires check
284
1133
  requires = meta.get("requires", "")
285
1134
  if requires: