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 ADDED
@@ -0,0 +1,8 @@
1
+ """nightshift — your AI works the night shift.
2
+
3
+ Read-only reviews of your projects while you're busy, one digest every morning.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ APP_NAME = "nightshift"
nightshift/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from nightshift.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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
+ )