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/__init__.py +8 -0
- nightshift/__main__.py +4 -0
- nightshift/adapters/__init__.py +61 -0
- nightshift/adapters/_process.py +219 -0
- nightshift/adapters/base.py +171 -0
- nightshift/adapters/claude_code.py +372 -0
- nightshift/adapters/codex.py +360 -0
- nightshift/adapters/copilot.py +51 -0
- nightshift/budget.py +150 -0
- nightshift/cli.py +602 -0
- nightshift/config.py +447 -0
- nightshift/cron.py +96 -0
- nightshift/events.py +293 -0
- nightshift/lock.py +197 -0
- nightshift/prompts/code_review.md +24 -0
- nightshift/prompts/dead_links.md +21 -0
- nightshift/prompts/deps_audit.md +23 -0
- nightshift/prompts/docs_drift.md +21 -0
- nightshift/prompts/security_audit.md +23 -0
- nightshift/prompts.py +66 -0
- nightshift/queue.py +92 -0
- nightshift/report.py +394 -0
- nightshift/scheduler.py +425 -0
- nightshift/sessions.py +62 -0
- nightshift/store.py +48 -0
- nightshift_cli-0.1.0.dist-info/METADATA +430 -0
- nightshift_cli-0.1.0.dist-info/RECORD +30 -0
- nightshift_cli-0.1.0.dist-info/WHEEL +4 -0
- nightshift_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nightshift_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|