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/__init__.py
ADDED
nightshift/__main__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Provider adapters.
|
|
2
|
+
|
|
3
|
+
Each adapter wraps one AI coding CLI the user already pays for. Adapters are
|
|
4
|
+
strictly read-only: they may inspect a project, never modify it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
|
|
11
|
+
from nightshift.adapters.base import Adapter, AdapterError, RunResult, Status
|
|
12
|
+
from nightshift.adapters.claude_code import ClaudeCodeAdapter
|
|
13
|
+
from nightshift.adapters.codex import CodexAdapter
|
|
14
|
+
from nightshift.adapters.copilot import CopilotAdapter
|
|
15
|
+
|
|
16
|
+
_REGISTRY: dict[str, type] = {
|
|
17
|
+
"claude_code": ClaudeCodeAdapter,
|
|
18
|
+
"codex": CodexAdapter,
|
|
19
|
+
"copilot": CopilotAdapter,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get(name: str, binary: str | None = None) -> Adapter:
|
|
24
|
+
"""Instantiate the adapter registered under ``name``.
|
|
25
|
+
|
|
26
|
+
``binary`` overrides where the adapter looks for its CLI. Left ``None``, the
|
|
27
|
+
adapter keeps its own default and finds it on PATH as before.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
cls = _REGISTRY[name]
|
|
31
|
+
except KeyError:
|
|
32
|
+
known = ", ".join(sorted(_REGISTRY))
|
|
33
|
+
raise AdapterError(f"unknown provider {name!r} — known: {known}") from None
|
|
34
|
+
if binary is None:
|
|
35
|
+
return cls() # type: ignore[return-value]
|
|
36
|
+
# Asked by name rather than caught as a TypeError: `cls(binary=...)` raising
|
|
37
|
+
# TypeError from somewhere deeper inside __init__ would look identical, and
|
|
38
|
+
# we would report the wrong cause for it.
|
|
39
|
+
if "binary" not in inspect.signature(cls).parameters:
|
|
40
|
+
raise AdapterError(
|
|
41
|
+
f"provider {name!r} does not take a custom binary path — it does not "
|
|
42
|
+
f"run a CLI of its own. Remove `binary` from providers.{name}."
|
|
43
|
+
)
|
|
44
|
+
return cls(binary=binary) # type: ignore[return-value]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def names() -> list[str]:
|
|
48
|
+
return sorted(_REGISTRY)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"Adapter",
|
|
53
|
+
"AdapterError",
|
|
54
|
+
"ClaudeCodeAdapter",
|
|
55
|
+
"CodexAdapter",
|
|
56
|
+
"CopilotAdapter",
|
|
57
|
+
"RunResult",
|
|
58
|
+
"Status",
|
|
59
|
+
"get",
|
|
60
|
+
"names",
|
|
61
|
+
]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Process machinery shared by adapters that drive a streaming CLI.
|
|
2
|
+
|
|
3
|
+
Extracted when the Codex adapter arrived. Both it and Claude Code spawn a CLI,
|
|
4
|
+
read newline-delimited JSON off its stdout, and must guarantee the child tree
|
|
5
|
+
dies at the deadline instead of living on holding provider quota while cron sits
|
|
6
|
+
on the lock.
|
|
7
|
+
|
|
8
|
+
That guarantee is the reason this module exists rather than a second copy of it.
|
|
9
|
+
Getting it right took two fixes — a deadline disarmed before the child was
|
|
10
|
+
reaped (13c0d3f) and a kill aimed at a pid that could already have been reused
|
|
11
|
+
— and a duplicate would have needed both, twice, discovered twice.
|
|
12
|
+
|
|
13
|
+
Nothing here knows what any event *means*. Translating lines into findings is
|
|
14
|
+
each adapter's job; this only promises the process starts, is read to the end,
|
|
15
|
+
and is dead when we return.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import signal
|
|
23
|
+
import subprocess
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Callable
|
|
29
|
+
|
|
30
|
+
from nightshift.adapters.base import Event, OnEvent
|
|
31
|
+
|
|
32
|
+
#: Slack given to a process that should already be dying, before we conclude the
|
|
33
|
+
#: deadline timer failed and kill the tree ourselves.
|
|
34
|
+
REAP_GRACE_S = 10
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class StreamOutcome:
|
|
39
|
+
"""How a streamed run ended. Says nothing about what it produced."""
|
|
40
|
+
|
|
41
|
+
returncode: int
|
|
42
|
+
timed_out: bool
|
|
43
|
+
stderr_lines: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def stderr_head(self) -> str:
|
|
47
|
+
"""First line of stderr, or an exit-code summary — for ``detail``."""
|
|
48
|
+
stderr = "\n".join(self.stderr_lines).strip()
|
|
49
|
+
if stderr:
|
|
50
|
+
return stderr.splitlines()[0]
|
|
51
|
+
return f"exit {self.returncode}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_line(line: str) -> dict | None:
|
|
55
|
+
"""One NDJSON event, or ``None`` for anything we can't read.
|
|
56
|
+
|
|
57
|
+
A malformed line is skipped rather than fatal: the stream is telemetry, and
|
|
58
|
+
the run's outcome must not hinge on our parsing every frame of it.
|
|
59
|
+
"""
|
|
60
|
+
line = line.strip()
|
|
61
|
+
if not line:
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
payload = json.loads(line)
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
return None
|
|
67
|
+
return payload if isinstance(payload, dict) else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def clip(text: str, limit: int) -> str:
|
|
71
|
+
text = str(text or "").replace("\n", " ").strip()
|
|
72
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def format_input(value: object, limit: int = 60) -> str:
|
|
76
|
+
"""Render tool input as ``key: value``, short enough for one line."""
|
|
77
|
+
if not isinstance(value, dict) or not value:
|
|
78
|
+
return ""
|
|
79
|
+
return ", ".join(f"{k}: {clip(v, limit)}" for k, v in list(value.items())[:2])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def summarize(content: object, limit: int = 70) -> str:
|
|
83
|
+
"""One line describing a tool result."""
|
|
84
|
+
if isinstance(content, list):
|
|
85
|
+
content = " ".join(
|
|
86
|
+
str(b.get("text", "")) for b in content if isinstance(b, dict)
|
|
87
|
+
)
|
|
88
|
+
text = str(content or "").strip()
|
|
89
|
+
if not text:
|
|
90
|
+
return ""
|
|
91
|
+
lines = text.splitlines()
|
|
92
|
+
first = lines[0]
|
|
93
|
+
if len(first) > limit:
|
|
94
|
+
first = first[: limit - 1] + "…"
|
|
95
|
+
if len(lines) > 1:
|
|
96
|
+
first += f" (+{len(lines) - 1} lines)"
|
|
97
|
+
return first
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def emit(on_event: OnEvent, event: Event) -> None:
|
|
101
|
+
"""A broken renderer must not fail a run that has already been billed."""
|
|
102
|
+
try:
|
|
103
|
+
on_event(event)
|
|
104
|
+
except Exception: # noqa: BLE001 - the callback is the caller's problem
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _drain(stream, sink: list[str]) -> None:
|
|
109
|
+
if stream is None:
|
|
110
|
+
return
|
|
111
|
+
try:
|
|
112
|
+
for line in stream:
|
|
113
|
+
sink.append(line.rstrip("\n"))
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def kill_tree(proc: subprocess.Popen) -> None:
|
|
119
|
+
"""Kill the CLI and anything it spawned.
|
|
120
|
+
|
|
121
|
+
``start_new_session`` made it a process-group leader precisely so this can
|
|
122
|
+
reach its children.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
126
|
+
except (OSError, ProcessLookupError):
|
|
127
|
+
try:
|
|
128
|
+
proc.kill()
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def stream_ndjson(
|
|
134
|
+
argv: list[str],
|
|
135
|
+
cwd: Path,
|
|
136
|
+
timeout_s: int,
|
|
137
|
+
on_line: Callable[[dict], None],
|
|
138
|
+
) -> StreamOutcome:
|
|
139
|
+
"""Run ``argv``, feeding each NDJSON line on stdout to ``on_line``.
|
|
140
|
+
|
|
141
|
+
Returns once the child is reaped. Raises ``FileNotFoundError`` / ``OSError``
|
|
142
|
+
if it could not be started at all — the caller owns that story, since only it
|
|
143
|
+
knows which binary was missing.
|
|
144
|
+
|
|
145
|
+
``on_line`` runs on this thread and must not raise.
|
|
146
|
+
"""
|
|
147
|
+
proc = subprocess.Popen(
|
|
148
|
+
argv,
|
|
149
|
+
cwd=str(cwd),
|
|
150
|
+
stdout=subprocess.PIPE,
|
|
151
|
+
stderr=subprocess.PIPE,
|
|
152
|
+
stdin=subprocess.DEVNULL,
|
|
153
|
+
text=True,
|
|
154
|
+
bufsize=1,
|
|
155
|
+
start_new_session=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
timed_out = threading.Event()
|
|
159
|
+
guard = threading.Lock()
|
|
160
|
+
reaped = False
|
|
161
|
+
|
|
162
|
+
def on_deadline() -> None:
|
|
163
|
+
# The timer races the normal exit. Once the process has been waited for,
|
|
164
|
+
# its pid can be reused — killing "it" would then SIGKILL an unrelated
|
|
165
|
+
# process group.
|
|
166
|
+
with guard:
|
|
167
|
+
if reaped or proc.poll() is not None:
|
|
168
|
+
return
|
|
169
|
+
timed_out.set()
|
|
170
|
+
kill_tree(proc)
|
|
171
|
+
|
|
172
|
+
deadline = time.monotonic() + timeout_s
|
|
173
|
+
timer = threading.Timer(timeout_s, on_deadline)
|
|
174
|
+
timer.daemon = True
|
|
175
|
+
timer.start()
|
|
176
|
+
|
|
177
|
+
stderr_lines: list[str] = []
|
|
178
|
+
drain = threading.Thread(target=_drain, args=(proc.stderr, stderr_lines), daemon=True)
|
|
179
|
+
drain.start()
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
for line in proc.stdout or ():
|
|
183
|
+
payload = parse_line(line)
|
|
184
|
+
if payload is not None:
|
|
185
|
+
on_line(payload)
|
|
186
|
+
finally:
|
|
187
|
+
# The timer stays armed across the wait, because it is the only thing
|
|
188
|
+
# that can end a child which closed stdout and then hung — an EOF here
|
|
189
|
+
# does not mean the process is going to exit. Cancelling it before an
|
|
190
|
+
# unbounded wait() left nothing to terminate such a run, and cron would
|
|
191
|
+
# sit on the lock until someone noticed.
|
|
192
|
+
try:
|
|
193
|
+
proc.wait(timeout=max(deadline - time.monotonic(), 0) + REAP_GRACE_S)
|
|
194
|
+
except subprocess.TimeoutExpired:
|
|
195
|
+
# The timer should have fired by now; it did not, so do its job.
|
|
196
|
+
kill_tree(proc)
|
|
197
|
+
try:
|
|
198
|
+
proc.wait(timeout=REAP_GRACE_S)
|
|
199
|
+
except subprocess.TimeoutExpired:
|
|
200
|
+
pass
|
|
201
|
+
timer.cancel()
|
|
202
|
+
with guard:
|
|
203
|
+
# Only now may on_deadline be told to keep its hands off: the process
|
|
204
|
+
# is reaped and its pid is free to be reused.
|
|
205
|
+
reaped = True
|
|
206
|
+
drain.join(timeout=2)
|
|
207
|
+
# Close the pipes rather than leaving them to the collector.
|
|
208
|
+
for pipe in (proc.stdout, proc.stderr):
|
|
209
|
+
try:
|
|
210
|
+
if pipe is not None:
|
|
211
|
+
pipe.close()
|
|
212
|
+
except OSError:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
return StreamOutcome(
|
|
216
|
+
returncode=proc.returncode,
|
|
217
|
+
timed_out=timed_out.is_set(),
|
|
218
|
+
stderr_lines=stderr_lines,
|
|
219
|
+
)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""The adapter contract: what nightshift needs from any AI coding CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, Literal, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
Status = Literal["ok", "failed", "timeout", "skipped"]
|
|
11
|
+
|
|
12
|
+
#: Statuses that consumed provider quota and so must hit the ledger.
|
|
13
|
+
BILLED_STATUSES: frozenset[str] = frozenset({"ok", "failed", "timeout"})
|
|
14
|
+
|
|
15
|
+
EventKind = Literal["start", "thinking", "text", "tool", "tool_result", "result", "error"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AdapterError(Exception):
|
|
19
|
+
"""A problem with an adapter itself, not with the run it attempted."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Event:
|
|
24
|
+
"""One thing an adapter saw while a run was still in flight.
|
|
25
|
+
|
|
26
|
+
Adapters emit these only when a caller asks for them; the digest is built
|
|
27
|
+
from :class:`RunResult`, never from events. Nothing here is load-bearing —
|
|
28
|
+
dropping every event must still leave the run correct.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
kind: EventKind
|
|
32
|
+
#: Assistant prose, a tool result summary, or an error message.
|
|
33
|
+
text: str = ""
|
|
34
|
+
#: Tool name, for ``tool``/``tool_result``.
|
|
35
|
+
tool: str = ""
|
|
36
|
+
#: Short rendering of tool input, e.g. ``pattern: *.py``.
|
|
37
|
+
detail: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
#: Called on the run's thread as events arrive. Must never raise.
|
|
41
|
+
OnEvent = Callable[[Event], None]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class RunResult:
|
|
46
|
+
"""The outcome of one (project, task) attempt.
|
|
47
|
+
|
|
48
|
+
``skipped`` never comes from an adapter — the scheduler synthesises it when
|
|
49
|
+
a gate refuses the run, so the digest can show what did *not* happen.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
provider: str
|
|
53
|
+
project: str
|
|
54
|
+
task: str
|
|
55
|
+
status: Status
|
|
56
|
+
findings_md: str
|
|
57
|
+
started_at: datetime
|
|
58
|
+
duration_s: float
|
|
59
|
+
#: Populated for skipped/failed runs; shown in the digest run log.
|
|
60
|
+
detail: str = ""
|
|
61
|
+
attempt: int = 1
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict:
|
|
64
|
+
return {
|
|
65
|
+
"provider": self.provider,
|
|
66
|
+
"project": self.project,
|
|
67
|
+
"task": self.task,
|
|
68
|
+
"status": self.status,
|
|
69
|
+
"findings_md": self.findings_md,
|
|
70
|
+
"started_at": self.started_at.isoformat(),
|
|
71
|
+
"duration_s": round(self.duration_s, 3),
|
|
72
|
+
"detail": self.detail,
|
|
73
|
+
"attempt": self.attempt,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_dict(cls, data: dict) -> RunResult:
|
|
78
|
+
return cls(
|
|
79
|
+
provider=data["provider"],
|
|
80
|
+
project=data["project"],
|
|
81
|
+
task=data["task"],
|
|
82
|
+
status=data["status"],
|
|
83
|
+
findings_md=data.get("findings_md", ""),
|
|
84
|
+
started_at=datetime.fromisoformat(data["started_at"]),
|
|
85
|
+
duration_s=float(data.get("duration_s", 0.0)),
|
|
86
|
+
detail=data.get("detail", ""),
|
|
87
|
+
attempt=int(data.get("attempt", 1)),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def billed(self) -> bool:
|
|
92
|
+
"""Did this attempt consume provider quota?"""
|
|
93
|
+
return self.status in BILLED_STATUSES
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class Availability:
|
|
98
|
+
"""Why an adapter can or cannot be used right now."""
|
|
99
|
+
|
|
100
|
+
ok: bool
|
|
101
|
+
reason: str = ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@runtime_checkable
|
|
105
|
+
class Adapter(Protocol):
|
|
106
|
+
"""A read-only wrapper around one AI coding CLI."""
|
|
107
|
+
|
|
108
|
+
name: str
|
|
109
|
+
|
|
110
|
+
def available(self) -> bool:
|
|
111
|
+
"""Is the CLI installed, on PATH, and authenticated?"""
|
|
112
|
+
...
|
|
113
|
+
|
|
114
|
+
def availability(self) -> Availability:
|
|
115
|
+
"""Like :meth:`available`, but explains itself for ``nightshift status``."""
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
def last_human_use(self) -> datetime | None:
|
|
119
|
+
"""When a human last drove this CLI, or ``None`` if unknowable.
|
|
120
|
+
|
|
121
|
+
The scheduler uses this to stay out of the user's way; ``None`` is read
|
|
122
|
+
as "idle", since an adapter that cannot tell should not block runs.
|
|
123
|
+
"""
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
def run(
|
|
127
|
+
self,
|
|
128
|
+
prompt: str,
|
|
129
|
+
project_dir: Path,
|
|
130
|
+
timeout_s: int,
|
|
131
|
+
on_event: OnEvent | None = None,
|
|
132
|
+
) -> RunResult:
|
|
133
|
+
"""Execute ``prompt`` against ``project_dir`` read-only.
|
|
134
|
+
|
|
135
|
+
When ``on_event`` is given the adapter should report progress as it
|
|
136
|
+
happens; when it is ``None`` the adapter stays silent, which is what
|
|
137
|
+
cron wants. The :class:`RunResult` must not depend on which was used.
|
|
138
|
+
"""
|
|
139
|
+
...
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class StubAdapter:
|
|
144
|
+
"""Base for adapters that are documented but not yet implemented."""
|
|
145
|
+
|
|
146
|
+
name: str = "stub"
|
|
147
|
+
help_wanted_url: str = "https://github.com/kishormorol/nightshift/issues"
|
|
148
|
+
|
|
149
|
+
def available(self) -> bool:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def availability(self) -> Availability:
|
|
153
|
+
return Availability(
|
|
154
|
+
ok=False,
|
|
155
|
+
reason=f"the {self.name} adapter is not implemented yet — help wanted: "
|
|
156
|
+
f"{self.help_wanted_url}",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def last_human_use(self) -> datetime | None:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def run(
|
|
163
|
+
self,
|
|
164
|
+
prompt: str,
|
|
165
|
+
project_dir: Path,
|
|
166
|
+
timeout_s: int,
|
|
167
|
+
on_event: OnEvent | None = None,
|
|
168
|
+
) -> RunResult:
|
|
169
|
+
raise NotImplementedError(
|
|
170
|
+
f"the {self.name} adapter is a documented stub — see {self.help_wanted_url}"
|
|
171
|
+
)
|