nightshift-cli 0.1.0__py3-none-any.whl

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.
nightshift/cli.py ADDED
@@ -0,0 +1,602 @@
1
+ """The ``nightshift`` command line.
2
+
3
+ Design rule for everything here: expected conditions exit 0 with one readable
4
+ line. Cron runs these commands unattended, and a tool that mails a stack trace
5
+ every hour gets uninstalled by lunchtime.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import shutil
12
+ import sys
13
+ import textwrap
14
+ import time
15
+ from datetime import date, datetime
16
+ from pathlib import Path
17
+
18
+ import click
19
+
20
+ from nightshift import __version__
21
+ from nightshift import adapters as adapter_registry
22
+ from nightshift import cron, events, prompts, report, scheduler
23
+ from nightshift.adapters.base import Event
24
+ from nightshift.budget import Ledger
25
+ from nightshift.config import (
26
+ Config,
27
+ ConfigError,
28
+ config_path,
29
+ expand,
30
+ load,
31
+ parse_window,
32
+ state_dir,
33
+ )
34
+ from nightshift.queue import Queue
35
+
36
+ DEFAULT_TASKS = ("code_review", "security_audit", "deps_audit")
37
+
38
+ log = logging.getLogger("nightshift")
39
+
40
+
41
+ def _setup_logging(verbose: bool) -> None:
42
+ logging.basicConfig(
43
+ level=logging.DEBUG if verbose else logging.INFO,
44
+ format="%(message)s",
45
+ stream=sys.stderr,
46
+ )
47
+
48
+
49
+ def _load_or_exit() -> Config:
50
+ try:
51
+ return load()
52
+ except ConfigError as exc:
53
+ raise click.ClickException(str(exc)) from None
54
+
55
+
56
+ def _echo_quiet(message: str) -> None:
57
+ """A gate refused. One line, exit 0."""
58
+ click.echo(message)
59
+
60
+
61
+ #: Wrapped lines of narration shown per text block. The agent explains itself
62
+ #: at length and every word of it reaches the digest; what a live view needs
63
+ #: is the shape of the run, so the prose is topped and tailed here.
64
+ PROSE_LINES = 2
65
+
66
+ #: Terminal colour per severity, alongside the digest's emoji.
67
+ _SEVERITY_FG = {"HIGH": "red", "MED": "yellow", "LOW": "cyan"}
68
+
69
+
70
+ def _wrap_width() -> int:
71
+ return max(min(shutil.get_terminal_size((80, 24)).columns, 100) - 4, 40)
72
+
73
+
74
+ def _clip(text: str, width: int) -> str:
75
+ text = " ".join(text.split())
76
+ return text if len(text) <= width else text[: width - 1] + "…"
77
+
78
+
79
+ def _echo_finding(finding, width: int) -> None:
80
+ """One finding, one line — severity first, because that is what ranks it."""
81
+ head = f" {finding.emoji} {finding.severity:<4} "
82
+ ref = f"{finding.ref} · " if finding.ref else ""
83
+ body = _clip(f"{ref}{finding.text}", max(width - len(head) + 2, 24))
84
+ click.echo(head + click.style(body, fg=_SEVERITY_FG.get(finding.severity)))
85
+
86
+
87
+ def _echo_prose(lines: list[str], width: int) -> None:
88
+ if not lines:
89
+ return
90
+ wrapped = textwrap.wrap(" ".join(lines), width=width)
91
+ for line in wrapped[:PROSE_LINES]:
92
+ click.echo(click.style(f" {line}", dim=True))
93
+ if len(wrapped) > PROSE_LINES:
94
+ click.echo(click.style(" …", dim=True))
95
+ lines.clear()
96
+
97
+
98
+ def _render_event(event: Event) -> None:
99
+ """Print one adapter event, Claude-Code style.
100
+
101
+ Purely cosmetic — the digest is built from the run's result, so anything
102
+ dropped here costs nothing but the view.
103
+ """
104
+ if event.kind == "start":
105
+ click.echo(click.style(" ⏺ ", fg="green") + click.style(event.text, dim=True))
106
+
107
+ elif event.kind == "thinking":
108
+ click.echo(click.style(" ✻ thinking", fg="magenta", dim=True))
109
+
110
+ elif event.kind == "tool":
111
+ line = click.style(" ⏺ ", fg="cyan") + click.style(event.tool, bold=True)
112
+ if event.detail:
113
+ line += click.style(f"({event.detail})", dim=True)
114
+ click.echo(line)
115
+
116
+ elif event.kind == "tool_result":
117
+ if event.text:
118
+ click.echo(click.style(f" ⎿ {_clip(event.text, _wrap_width())}", dim=True))
119
+
120
+ elif event.kind == "text":
121
+ width = _wrap_width()
122
+ prose: list[str] = []
123
+ for raw in event.text.splitlines():
124
+ if not raw.strip():
125
+ continue
126
+ finding = report.parse_finding_line(raw)
127
+ if finding is None:
128
+ prose.append(raw.strip())
129
+ continue
130
+ # Keep the order the agent wrote in: whatever it said before this
131
+ # finding belongs above it, not collected at the end.
132
+ _echo_prose(prose, width)
133
+ _echo_finding(finding, width)
134
+ _echo_prose(prose, width)
135
+
136
+ elif event.kind == "error":
137
+ click.echo(click.style(f" ✗ {event.text}", fg="red"))
138
+
139
+
140
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
141
+ @click.version_option(__version__, prog_name="nightshift")
142
+ def main() -> None:
143
+ """Your AI works the night shift.
144
+
145
+ Read-only reviews of your projects while you're busy, one digest every
146
+ morning. nightshift never modifies your code.
147
+ """
148
+
149
+
150
+ # ---------------------------------------------------------------- init
151
+
152
+
153
+ def _detect_providers() -> list[tuple[str, bool, str]]:
154
+ found = []
155
+ for name in adapter_registry.names():
156
+ adapter = adapter_registry.get(name)
157
+ availability = adapter.availability()
158
+ found.append((name, availability.ok, availability.reason))
159
+ return found
160
+
161
+
162
+ def _render_config(
163
+ enabled: list[str],
164
+ projects: list[tuple[str, Path, list[str]]],
165
+ windows: list[str],
166
+ idle_minutes: int,
167
+ digest_dir: Path,
168
+ ) -> str:
169
+ lines: list[str] = [
170
+ "# nightshift config — https://github.com/kishormorol/nightshift",
171
+ "# Edit freely; `nightshift status` validates it.",
172
+ "",
173
+ "providers:",
174
+ ]
175
+ for name in adapter_registry.names():
176
+ on = name in enabled
177
+ lines.append(f" {name}:")
178
+ lines.append(f" enabled: {'true' if on else 'false'}")
179
+ if on:
180
+ lines.append(" budget:")
181
+ lines.append(" max_runs_per_day: 6")
182
+ lines.append(" max_runs_per_week: 30")
183
+ lines.append("")
184
+ lines.append("projects:")
185
+ for name, path, tasks in projects:
186
+ lines.append(f" - name: {name}")
187
+ lines.append(f" path: {path}")
188
+ lines.append(f" tasks: [{', '.join(tasks)}]")
189
+ lines.append("")
190
+ lines.append("schedule:")
191
+ lines.append(f" windows: [{', '.join(f'{w!r}' for w in windows)}]")
192
+ lines.append(f" idle_minutes: {idle_minutes}")
193
+ lines.append("")
194
+ lines.append("digest:")
195
+ lines.append(f" dir: {digest_dir}")
196
+ lines.append("")
197
+ lines.append("run:")
198
+ lines.append(" timeout_s: 600")
199
+ lines.append("")
200
+ return "\n".join(lines)
201
+
202
+
203
+ @main.command()
204
+ @click.option("--force", is_flag=True, help="Overwrite an existing config.")
205
+ def init(force: bool) -> None:
206
+ """Detect your AI CLIs, register projects, and set the schedule."""
207
+ path = config_path()
208
+ if path.exists() and not force:
209
+ click.echo(f"Config already exists at {path}")
210
+ if not click.confirm("Replace it?", default=False):
211
+ click.echo("Left it alone. Nothing changed.")
212
+ return
213
+
214
+ click.echo("Looking for AI CLIs on your PATH…\n")
215
+ detected = _detect_providers()
216
+ enabled: list[str] = []
217
+ for name, ok, reason in detected:
218
+ if ok:
219
+ click.echo(f" ✓ {name} {reason}")
220
+ enabled.append(name)
221
+ else:
222
+ click.echo(f" ✗ {name} {reason}")
223
+ click.echo()
224
+
225
+ if not enabled:
226
+ raise click.ClickException(
227
+ "No usable AI CLI found. Install Claude Code or Codex and re-run "
228
+ "`nightshift init`."
229
+ )
230
+
231
+ click.echo("Which projects should nightshift review?")
232
+ click.echo("Enter a path per line; press Enter on a blank line when done.\n")
233
+ projects: list[tuple[str, Path, list[str]]] = []
234
+ while True:
235
+ raw = click.prompt(
236
+ " project path", default="", show_default=False, type=str
237
+ ).strip()
238
+ if not raw:
239
+ break
240
+ resolved = expand(raw)
241
+ if not resolved.is_dir():
242
+ click.echo(f" ✗ {resolved} is not a directory — skipped")
243
+ continue
244
+ name = click.prompt(" name", default=resolved.name, type=str).strip()
245
+ if any(p[0] == name for p in projects):
246
+ click.echo(f" ✗ {name!r} is already registered — skipped")
247
+ continue
248
+ available = prompts.available_tasks()
249
+ default_tasks = [t for t in DEFAULT_TASKS if t in available] or available[:1]
250
+ click.echo(f" available tasks: {', '.join(available)}")
251
+ chosen = click.prompt(
252
+ " tasks", default=", ".join(default_tasks), type=str
253
+ )
254
+ tasks = [t.strip() for t in chosen.replace(",", " ").split() if t.strip()]
255
+ unknown = [t for t in tasks if t not in available]
256
+ if unknown:
257
+ click.echo(f" ✗ unknown task(s): {', '.join(unknown)} — skipped")
258
+ continue
259
+ projects.append((name, resolved, tasks))
260
+ click.echo(f" ✓ {name} · {', '.join(tasks)}\n")
261
+
262
+ if not projects:
263
+ raise click.ClickException("No projects registered — nothing to schedule.")
264
+
265
+ click.echo()
266
+ windows_raw = click.prompt(
267
+ "When may nightshift run? (comma-separated HH:MM-HH:MM)",
268
+ default="00:00-06:00",
269
+ type=str,
270
+ )
271
+ windows = [w.strip() for w in windows_raw.split(",") if w.strip()]
272
+ for i, w in enumerate(windows):
273
+ try:
274
+ parse_window(w, f"schedule.windows[{i}]")
275
+ except ConfigError as exc:
276
+ raise click.ClickException(str(exc)) from None
277
+
278
+ idle_minutes = click.prompt(
279
+ "Minutes of provider idleness required before a run",
280
+ default=60,
281
+ type=click.IntRange(min=0),
282
+ )
283
+ digest_dir = expand(
284
+ click.prompt("Where should digests go?", default="~/nightshift-reports", type=str)
285
+ )
286
+
287
+ text = _render_config(enabled, projects, windows, idle_minutes, digest_dir)
288
+ path.parent.mkdir(parents=True, exist_ok=True)
289
+ path.write_text(text, encoding="utf-8")
290
+ click.echo(f"\nWrote {path}")
291
+
292
+ try:
293
+ cfg = load(path)
294
+ except ConfigError as exc: # pragma: no cover - we just generated it
295
+ raise click.ClickException(f"generated config is invalid — please report this:\n{exc}")
296
+ cfg.digest_dir.mkdir(parents=True, exist_ok=True)
297
+
298
+ click.echo("\nnightshift has no daemon — cron drives it. Add these lines:\n")
299
+ for line in cron.entries():
300
+ click.echo(f" {line}")
301
+ click.echo()
302
+ if click.confirm("Install them into your crontab now?", default=False):
303
+ try:
304
+ cron.install()
305
+ except RuntimeError as exc:
306
+ click.echo(f"Could not install automatically: {exc}")
307
+ click.echo("Add the lines above with `crontab -e`.")
308
+ else:
309
+ click.echo("Installed.")
310
+ else:
311
+ click.echo("Skipped — add them with `crontab -e` when you're ready.")
312
+
313
+ click.echo("\nTry a run right now: nightshift run --now")
314
+
315
+
316
+ # ----------------------------------------------------------------- run
317
+
318
+
319
+ @main.command()
320
+ @click.option("--now", "force", is_flag=True, help="Skip the window and idle checks (not budget).")
321
+ @click.option("--provider", default=None, help="Restrict to one provider.")
322
+ @click.option(
323
+ "--stream/--no-stream",
324
+ default=None,
325
+ help="Show the run live. Defaults on at a terminal, off under cron.",
326
+ )
327
+ @click.option("-v", "--verbose", is_flag=True, help="Debug logging.")
328
+ def run(force: bool, provider: str | None, stream: bool | None, verbose: bool) -> None:
329
+ """Do one gated run, or exit quietly explaining why not."""
330
+ _setup_logging(verbose)
331
+ cfg = _load_or_exit()
332
+
333
+ # Cron gets the quiet single-line summary it has always got; a human at a
334
+ # terminal gets to watch. --stream/--no-stream overrides the guess.
335
+ if stream is None:
336
+ stream = sys.stdout.isatty()
337
+
338
+ outcome = scheduler.run_once(
339
+ cfg,
340
+ force=force,
341
+ provider=provider,
342
+ on_event=_render_event if stream else None,
343
+ )
344
+ if not outcome.ran:
345
+ _echo_quiet(f"nothing to do — {outcome.reason}")
346
+ return
347
+
348
+ if stream:
349
+ click.echo()
350
+
351
+ for result in outcome.results:
352
+ if result.status == "skipped":
353
+ continue
354
+ line = (
355
+ f"{result.status:>7} {result.project} · {result.task} "
356
+ f"({result.provider}, {report.format_duration(result)})"
357
+ )
358
+ if result.detail:
359
+ line += f" — {result.detail}"
360
+ click.echo(line)
361
+ if result.status == "ok":
362
+ findings = report.parse_findings(result)
363
+ click.echo(
364
+ f" {len(findings)} finding{'s' if len(findings) != 1 else ''}"
365
+ if findings
366
+ else " no findings"
367
+ )
368
+
369
+
370
+ # --------------------------------------------------------------- watch
371
+
372
+
373
+ def _fmt_secs(seconds: float) -> str:
374
+ seconds = int(seconds)
375
+ return f"{seconds}s" if seconds < 60 else f"{seconds // 60}m{seconds % 60:02d}s"
376
+
377
+
378
+ def _render_log_event(payload: dict) -> None:
379
+ """Render one line from an event log, as `watch` sees it."""
380
+ kind = payload.get("kind", "")
381
+
382
+ if kind == "meta":
383
+ started = str(payload.get("started_at", ""))[11:19]
384
+ head = f"{payload.get('project', '?')} · {payload.get('task', '?')}"
385
+ attempt = payload.get("attempt", 1)
386
+ if isinstance(attempt, int) and attempt > 1:
387
+ head += f" retry {attempt - 1}"
388
+ click.echo()
389
+ click.echo(
390
+ click.style("┌ ", fg="blue")
391
+ + click.style(head, bold=True)
392
+ + click.style(f" {payload.get('provider', '?')} · {started}", dim=True)
393
+ )
394
+ return
395
+
396
+ if kind == "end":
397
+ status = str(payload.get("status", "?"))
398
+ colour = {"ok": "green", "failed": "red", "timeout": "yellow"}.get(status, "white")
399
+ mark = {"ok": "✓", "failed": "✗", "timeout": "⧗"}.get(status, "·")
400
+ findings = payload.get("findings", 0)
401
+ tail = f"{findings} finding{'' if findings == 1 else 's'}"
402
+ if payload.get("detail"):
403
+ tail += f" · {payload['detail']}"
404
+ click.echo(
405
+ click.style("└ ", fg="blue")
406
+ + click.style(f"{mark} {status}", fg=colour, bold=True)
407
+ + click.style(f" {_fmt_secs(payload.get('duration_s', 0))} · {tail}", dim=True)
408
+ )
409
+ return
410
+
411
+ _render_event(
412
+ Event(
413
+ kind=kind, # type: ignore[arg-type]
414
+ text=str(payload.get("text", "")),
415
+ tool=str(payload.get("tool", "")),
416
+ detail=str(payload.get("detail", "")),
417
+ )
418
+ )
419
+
420
+
421
+ @main.command()
422
+ @click.option("-n", "--history", default=1, help="Replay this many finished runs first.")
423
+ def watch(history: int) -> None:
424
+ """Follow runs as they happen, including ones cron started."""
425
+ click.echo(click.style("nightshift · watching for runs — ctrl-c to stop", dim=True))
426
+
427
+ seen: set[Path] = set()
428
+
429
+ if history > 0:
430
+ for path in reversed(events.recent_logs(limit=history)):
431
+ seen.add(path)
432
+ for payload in events.read(path):
433
+ _render_log_event(payload)
434
+
435
+ # Anything already on disk that we did not just replay is history too;
436
+ # marking it seen keeps `watch` from re-rendering old runs on startup.
437
+ # A log with no `end` is only worth following if it is still being
438
+ # written — an interrupted run leaves one behind forever, and following
439
+ # that would wedge the watcher instead of showing the next real run.
440
+ for path in events.recent_logs(limit=200):
441
+ if events.is_finished(path) or events.is_stale(path):
442
+ seen.add(path)
443
+
444
+ try:
445
+ while True:
446
+ fresh = [p for p in events.recent_logs(limit=20) if p not in seen]
447
+ if not fresh:
448
+ time.sleep(events.POLL_S)
449
+ continue
450
+ for path in reversed(fresh):
451
+ seen.add(path)
452
+ # Startup skips abandoned logs; the loop has to as well. A run
453
+ # interrupted while we are watching leaves an `end`-less log,
454
+ # and following it would park the watcher for the whole stale
455
+ # window with real runs going unrendered behind it.
456
+ if not events.is_finished(path) and events.is_stale(path):
457
+ continue
458
+ for payload in events.follow(path):
459
+ _render_log_event(payload)
460
+ except KeyboardInterrupt:
461
+ click.echo(click.style("\nstopped watching.", dim=True))
462
+
463
+
464
+ # -------------------------------------------------------------- digest
465
+
466
+
467
+ @main.command()
468
+ @click.option(
469
+ "--date",
470
+ "on",
471
+ default=None,
472
+ help="Day to render (YYYY-MM-DD). Defaults to today.",
473
+ )
474
+ @click.option("--stdout", "to_stdout", is_flag=True, help="Print instead of writing.")
475
+ def digest(on: str | None, to_stdout: bool) -> None:
476
+ """Render the morning digest for a day."""
477
+ cfg = _load_or_exit()
478
+ if on is None:
479
+ day = date.today()
480
+ else:
481
+ try:
482
+ day = date.fromisoformat(on)
483
+ except ValueError:
484
+ raise click.ClickException(
485
+ f"--date {on!r} is not a valid date — expected YYYY-MM-DD"
486
+ ) from None
487
+
488
+ ledger = Ledger()
489
+ if to_stdout:
490
+ click.echo(report.render_digest(cfg, day, ledger=ledger), nl=False)
491
+ return
492
+ path = report.write_digest(cfg, day, ledger=ledger)
493
+ results = report.load_results(cfg, day)
494
+ click.echo(f"{path} ({len(results)} run{'s' if len(results) != 1 else ''})")
495
+
496
+
497
+ # -------------------------------------------------------------- status
498
+
499
+
500
+ def _fmt_next(cfg: Config, now: datetime) -> str:
501
+ if cfg.schedule.is_open(now):
502
+ return "now (a window is open)"
503
+ nxt = scheduler.next_eligible(cfg, now)
504
+ if nxt is None:
505
+ return "unknown"
506
+ delta = nxt - now
507
+ hours, rem = divmod(int(delta.total_seconds()), 3600)
508
+ mins = rem // 60
509
+ when = nxt.strftime("%a %H:%M")
510
+ ago = f"in {hours}h{mins:02d}m" if hours else f"in {mins}m"
511
+ return f"{when} ({ago})"
512
+
513
+
514
+ @main.command()
515
+ def status() -> None:
516
+ """Budget, recent runs, next window, and provider availability."""
517
+ cfg = _load_or_exit()
518
+ now = datetime.now()
519
+ ledger = Ledger()
520
+
521
+ click.echo(f"config {cfg.source or config_path()}")
522
+ click.echo(f"state {state_dir()}")
523
+ click.echo(f"digests {cfg.digest_dir}")
524
+ click.echo()
525
+
526
+ click.echo("providers")
527
+ for provider in cfg.providers.values():
528
+ if not provider.enabled:
529
+ click.echo(f" {provider.name:<12} disabled")
530
+ continue
531
+ try:
532
+ adapter = adapter_registry.get(provider.name, provider.binary)
533
+ except adapter_registry.AdapterError as exc:
534
+ # A `binary` the registry rejects must not take `status` down with
535
+ # it — status is the command you reach for to find out what is wrong.
536
+ click.echo(f" ✗ {provider.name:<10} {exc}")
537
+ continue
538
+ availability = adapter.availability()
539
+ mark = "✓" if availability.ok else "✗"
540
+ usage = ledger.usage(provider.name, provider.budget, now)
541
+ bar = report.budget_bar(usage.day, usage.max_day)
542
+ click.echo(
543
+ f" {mark} {provider.name:<10} {bar} "
544
+ f"{usage.day}/{usage.max_day} today · "
545
+ f"{usage.week}/{usage.max_week} week"
546
+ )
547
+ if not availability.ok:
548
+ click.echo(f" {availability.reason}")
549
+ elif usage.exhausted:
550
+ click.echo(f" {usage.reason()}")
551
+ click.echo()
552
+
553
+ windows = ", ".join(w.raw for w in cfg.schedule.windows)
554
+ click.echo(f"schedule {windows} · needs {cfg.schedule.idle_minutes}m idle")
555
+ click.echo(f"next run {_fmt_next(cfg, now)}")
556
+
557
+ queue = Queue()
558
+ upcoming = queue.peek(cfg.pairs())
559
+ if upcoming:
560
+ line = f"up next {upcoming[0]} · {upcoming[1]}"
561
+ pinned = next(
562
+ (p.provider for p in cfg.projects if p.name == upcoming[0] and p.provider),
563
+ None,
564
+ )
565
+ # Only shown for a pin, which is a fact from the config. Naming a
566
+ # provider for an unpinned project would be a guess about who happens to
567
+ # be idle and under budget by the time the window comes round.
568
+ if pinned:
569
+ line += f" · {pinned}"
570
+ click.echo(line)
571
+ click.echo()
572
+
573
+ recent = report.load_results(cfg, now.date())
574
+ if not recent:
575
+ yesterday = date.fromordinal(now.date().toordinal() - 1)
576
+ recent = report.load_results(cfg, yesterday)
577
+ if recent:
578
+ click.echo("last runs")
579
+ for r in recent[-5:]:
580
+ detail = f" — {r.detail}" if r.detail else ""
581
+ click.echo(
582
+ f" {r.started_at.strftime('%H:%M')} {r.status:<7} "
583
+ f"{r.project} · {r.task}{detail}"
584
+ )
585
+ else:
586
+ click.echo("last runs none recorded yet")
587
+
588
+ missing = [
589
+ t
590
+ for project in cfg.projects
591
+ for t in project.tasks
592
+ if prompts.find(t) is None
593
+ ]
594
+ if missing:
595
+ click.echo()
596
+ click.echo(
597
+ f"warning no prompt template for: {', '.join(sorted(set(missing)))}"
598
+ )
599
+
600
+
601
+ if __name__ == "__main__": # pragma: no cover
602
+ main()