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
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""The Claude Code adapter.
|
|
2
|
+
|
|
3
|
+
Read-only is not a convention here, it is enforced by the CLI's own permission
|
|
4
|
+
system: we hand Claude Code an allowlist of read-class tools and an explicit
|
|
5
|
+
denylist of every tool that can mutate a repo. If those flags ever stop
|
|
6
|
+
working, the correct behaviour is to fail the run, not to fall back to an
|
|
7
|
+
unrestricted invocation.
|
|
8
|
+
|
|
9
|
+
Flag names were checked against `claude --help` (CLI v2.x) rather than assumed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from nightshift import sessions
|
|
22
|
+
from nightshift.adapters._process import (
|
|
23
|
+
emit as _emit,
|
|
24
|
+
)
|
|
25
|
+
from nightshift.adapters._process import (
|
|
26
|
+
format_input as _format_input,
|
|
27
|
+
)
|
|
28
|
+
from nightshift.adapters._process import (
|
|
29
|
+
stream_ndjson,
|
|
30
|
+
)
|
|
31
|
+
from nightshift.adapters._process import (
|
|
32
|
+
summarize as _summarize,
|
|
33
|
+
)
|
|
34
|
+
from nightshift.adapters.base import Availability, Event, OnEvent, RunResult
|
|
35
|
+
|
|
36
|
+
#: Tools Claude Code may use: inspect the repo, nothing else.
|
|
37
|
+
ALLOWED_TOOLS = ("Read", "Grep", "Glob", "NotebookRead")
|
|
38
|
+
|
|
39
|
+
#: Tools that can change a repo or reach the network. Belt and braces — the
|
|
40
|
+
#: allowlist above should already exclude these, but a denylist survives a
|
|
41
|
+
#: future release adding a new write-capable tool to the default set.
|
|
42
|
+
DISALLOWED_TOOLS = (
|
|
43
|
+
"Bash",
|
|
44
|
+
"Edit",
|
|
45
|
+
"MultiEdit",
|
|
46
|
+
"Write",
|
|
47
|
+
"NotebookEdit",
|
|
48
|
+
"WebFetch",
|
|
49
|
+
"WebSearch",
|
|
50
|
+
"Task",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: Where Claude Code records the user's own sessions; the newest mtime under
|
|
54
|
+
#: here is our proxy for "a human is using this right now".
|
|
55
|
+
CLAUDE_PROJECTS_DIR = Path("~/.claude/projects")
|
|
56
|
+
|
|
57
|
+
_READ_ONLY_PREAMBLE = (
|
|
58
|
+
"You are running unattended as part of an automated read-only review.\n"
|
|
59
|
+
"You have no write, edit, or shell tools — do not attempt to use them, and "
|
|
60
|
+
"do not ask questions. Inspect the repository and report findings only.\n\n"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _content(payload: dict) -> list[dict]:
|
|
65
|
+
message = payload.get("message")
|
|
66
|
+
if not isinstance(message, dict):
|
|
67
|
+
return []
|
|
68
|
+
blocks = message.get("content")
|
|
69
|
+
if not isinstance(blocks, list):
|
|
70
|
+
return []
|
|
71
|
+
return [b for b in blocks if isinstance(b, dict)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ClaudeCodeAdapter:
|
|
75
|
+
name = "claude_code"
|
|
76
|
+
|
|
77
|
+
def __init__(self, binary: str = "claude"):
|
|
78
|
+
self.binary = binary
|
|
79
|
+
|
|
80
|
+
# ---- availability -------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _which(self) -> str | None:
|
|
83
|
+
return shutil.which(self.binary)
|
|
84
|
+
|
|
85
|
+
def availability(self) -> Availability:
|
|
86
|
+
path = self._which()
|
|
87
|
+
if path is None:
|
|
88
|
+
return Availability(
|
|
89
|
+
ok=False,
|
|
90
|
+
reason=f"`{self.binary}` is not on PATH — install Claude Code, see "
|
|
91
|
+
f"https://claude.com/claude-code",
|
|
92
|
+
)
|
|
93
|
+
try:
|
|
94
|
+
proc = subprocess.run(
|
|
95
|
+
[self.binary, "--version"],
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
timeout=20,
|
|
99
|
+
check=False,
|
|
100
|
+
)
|
|
101
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
102
|
+
return Availability(ok=False, reason=f"`{self.binary} --version` failed: {exc}")
|
|
103
|
+
if proc.returncode != 0:
|
|
104
|
+
detail = (proc.stderr or proc.stdout or "").strip().splitlines()
|
|
105
|
+
first = detail[0] if detail else f"exit {proc.returncode}"
|
|
106
|
+
return Availability(ok=False, reason=f"`{self.binary} --version` failed: {first}")
|
|
107
|
+
return Availability(ok=True, reason=(proc.stdout or "").strip())
|
|
108
|
+
|
|
109
|
+
def available(self) -> bool:
|
|
110
|
+
return self.availability().ok
|
|
111
|
+
|
|
112
|
+
# ---- idle detection -----------------------------------------------
|
|
113
|
+
|
|
114
|
+
def _projects_dir(self) -> Path:
|
|
115
|
+
return Path(os.path.expanduser(str(CLAUDE_PROJECTS_DIR)))
|
|
116
|
+
|
|
117
|
+
def last_human_use(self) -> datetime | None:
|
|
118
|
+
"""Newest mtime under ``~/.claude/projects`` that was not ours.
|
|
119
|
+
|
|
120
|
+
Returns ``None`` when the directory is absent — a fresh install has
|
|
121
|
+
simply never been used, which reads as idle rather than as busy.
|
|
122
|
+
|
|
123
|
+
Our own runs write transcripts here too, named for the session id the
|
|
124
|
+
CLI reported, so those are skipped: counting them would make every run
|
|
125
|
+
gate the next one and nightshift would never run twice in a night.
|
|
126
|
+
|
|
127
|
+
Only files are considered. A directory's mtime bumps whenever anything
|
|
128
|
+
inside it is written — including our own transcript — so it cannot be
|
|
129
|
+
attributed to a human once nightshift shares the directory, and
|
|
130
|
+
counting it would defeat the check above.
|
|
131
|
+
"""
|
|
132
|
+
root = self._projects_dir()
|
|
133
|
+
if not root.is_dir():
|
|
134
|
+
return None
|
|
135
|
+
mine = sessions.ours()
|
|
136
|
+
newest: float | None = None
|
|
137
|
+
try:
|
|
138
|
+
for path in root.rglob("*"):
|
|
139
|
+
if path.stem in mine:
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
if not path.is_file():
|
|
143
|
+
continue
|
|
144
|
+
mtime = path.stat().st_mtime
|
|
145
|
+
except OSError:
|
|
146
|
+
continue
|
|
147
|
+
if newest is None or mtime > newest:
|
|
148
|
+
newest = mtime
|
|
149
|
+
except OSError:
|
|
150
|
+
return None
|
|
151
|
+
if newest is None:
|
|
152
|
+
return None
|
|
153
|
+
return datetime.fromtimestamp(newest)
|
|
154
|
+
|
|
155
|
+
# ---- running ------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def command(self, prompt: str, stream: bool = False) -> list[str]:
|
|
158
|
+
# stream-json is newline-delimited events rather than one envelope;
|
|
159
|
+
# the CLI only emits it under --print when --verbose is also set.
|
|
160
|
+
fmt = ["--output-format", "json"]
|
|
161
|
+
if stream:
|
|
162
|
+
fmt = ["--output-format", "stream-json", "--verbose"]
|
|
163
|
+
return [
|
|
164
|
+
self.binary,
|
|
165
|
+
"--print",
|
|
166
|
+
_READ_ONLY_PREAMBLE + prompt,
|
|
167
|
+
*fmt,
|
|
168
|
+
"--allowed-tools",
|
|
169
|
+
*ALLOWED_TOOLS,
|
|
170
|
+
"--disallowed-tools",
|
|
171
|
+
*DISALLOWED_TOOLS,
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
def run(
|
|
175
|
+
self,
|
|
176
|
+
prompt: str,
|
|
177
|
+
project_dir: Path,
|
|
178
|
+
timeout_s: int,
|
|
179
|
+
on_event: OnEvent | None = None,
|
|
180
|
+
) -> RunResult:
|
|
181
|
+
started = datetime.now()
|
|
182
|
+
|
|
183
|
+
def finish(status: str, findings_md: str, detail: str = "") -> RunResult:
|
|
184
|
+
return RunResult(
|
|
185
|
+
provider=self.name,
|
|
186
|
+
project=project_dir.name,
|
|
187
|
+
task="",
|
|
188
|
+
status=status, # type: ignore[arg-type]
|
|
189
|
+
findings_md=findings_md,
|
|
190
|
+
started_at=started,
|
|
191
|
+
duration_s=(datetime.now() - started).total_seconds(),
|
|
192
|
+
detail=detail,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if not project_dir.is_dir():
|
|
196
|
+
return finish("failed", "", f"project path does not exist: {project_dir}")
|
|
197
|
+
|
|
198
|
+
if on_event is not None:
|
|
199
|
+
return self._run_streaming(prompt, project_dir, timeout_s, on_event, finish)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
proc = subprocess.run(
|
|
203
|
+
self.command(prompt),
|
|
204
|
+
cwd=str(project_dir),
|
|
205
|
+
capture_output=True,
|
|
206
|
+
text=True,
|
|
207
|
+
timeout=timeout_s,
|
|
208
|
+
check=False,
|
|
209
|
+
stdin=subprocess.DEVNULL,
|
|
210
|
+
start_new_session=True,
|
|
211
|
+
)
|
|
212
|
+
except subprocess.TimeoutExpired:
|
|
213
|
+
# This comment used to claim subprocess.run's kill takes the whole
|
|
214
|
+
# process group with it. It does not: on timeout it calls
|
|
215
|
+
# Popen.kill(), which is os.kill(pid) on the direct child alone —
|
|
216
|
+
# start_new_session buys us nothing here. Any grandchild survives
|
|
217
|
+
# as an orphan still holding provider quota.
|
|
218
|
+
#
|
|
219
|
+
# Nothing gates on this path today: the scheduler always passes an
|
|
220
|
+
# event sink, so every real run streams. It stays for callers that
|
|
221
|
+
# want no events, and it is honest about what it does not do.
|
|
222
|
+
return finish("timeout", "", f"no output after {timeout_s}s")
|
|
223
|
+
except FileNotFoundError:
|
|
224
|
+
return finish("failed", "", f"`{self.binary}` is not on PATH")
|
|
225
|
+
except OSError as exc:
|
|
226
|
+
return finish("failed", "", f"could not start `{self.binary}`: {exc}")
|
|
227
|
+
|
|
228
|
+
stdout = (proc.stdout or "").strip()
|
|
229
|
+
stderr = (proc.stderr or "").strip()
|
|
230
|
+
|
|
231
|
+
# Claim the session before anything can return: a transcript we do not
|
|
232
|
+
# claim is one we will later mistake for a human's.
|
|
233
|
+
self._claim_session(stdout)
|
|
234
|
+
|
|
235
|
+
if proc.returncode != 0:
|
|
236
|
+
detail = stderr.splitlines()[0] if stderr else f"exit {proc.returncode}"
|
|
237
|
+
# A non-zero exit that still produced output is worth keeping.
|
|
238
|
+
return finish("failed", stdout, detail)
|
|
239
|
+
|
|
240
|
+
if not stdout:
|
|
241
|
+
return finish("failed", "", "no output")
|
|
242
|
+
|
|
243
|
+
return finish("ok", self._extract(stdout))
|
|
244
|
+
|
|
245
|
+
# ---- streaming ----------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def _run_streaming(
|
|
248
|
+
self,
|
|
249
|
+
prompt: str,
|
|
250
|
+
project_dir: Path,
|
|
251
|
+
timeout_s: int,
|
|
252
|
+
on_event: OnEvent,
|
|
253
|
+
finish,
|
|
254
|
+
) -> RunResult:
|
|
255
|
+
"""Same run, reported as it happens.
|
|
256
|
+
|
|
257
|
+
The buffered path above gets its timeout and its process-group kill
|
|
258
|
+
from ``subprocess.run``; reading the stream ourselves means we have to
|
|
259
|
+
reproduce both, which is what ``stream_ndjson`` is for — a timeout here
|
|
260
|
+
still takes the whole tree with it rather than leaving orphans holding
|
|
261
|
+
the quota.
|
|
262
|
+
"""
|
|
263
|
+
state = {"result_text": "", "claimed": False}
|
|
264
|
+
prose: list[str] = []
|
|
265
|
+
|
|
266
|
+
def on_line(payload: dict) -> None:
|
|
267
|
+
if not state["claimed"] and payload.get("session_id"):
|
|
268
|
+
# Claim on sight rather than at the end: a run killed at the
|
|
269
|
+
# deadline still wrote a transcript, and an unclaimed transcript
|
|
270
|
+
# is one we later read as a human's.
|
|
271
|
+
sessions.record(str(payload["session_id"]))
|
|
272
|
+
state["claimed"] = True
|
|
273
|
+
if payload.get("type") == "result" and not payload.get("is_error"):
|
|
274
|
+
value = payload.get("result")
|
|
275
|
+
if isinstance(value, str):
|
|
276
|
+
state["result_text"] = value.strip()
|
|
277
|
+
for event in self._events_for(payload):
|
|
278
|
+
if event.kind == "text":
|
|
279
|
+
prose.append(event.text)
|
|
280
|
+
_emit(on_event, event)
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
outcome = stream_ndjson(
|
|
284
|
+
self.command(prompt, stream=True), project_dir, timeout_s, on_line
|
|
285
|
+
)
|
|
286
|
+
except FileNotFoundError:
|
|
287
|
+
return finish("failed", "", f"`{self.binary}` is not on PATH")
|
|
288
|
+
except OSError as exc:
|
|
289
|
+
return finish("failed", "", f"could not start `{self.binary}`: {exc}")
|
|
290
|
+
|
|
291
|
+
# Whatever the CLI managed to say before the deadline is still worth
|
|
292
|
+
# keeping — the run was billed either way.
|
|
293
|
+
findings = state["result_text"] or "\n\n".join(prose).strip()
|
|
294
|
+
|
|
295
|
+
if outcome.timed_out:
|
|
296
|
+
return finish("timeout", findings, f"no output after {timeout_s}s")
|
|
297
|
+
|
|
298
|
+
if outcome.returncode != 0:
|
|
299
|
+
return finish("failed", findings, outcome.stderr_head)
|
|
300
|
+
|
|
301
|
+
if not findings:
|
|
302
|
+
return finish("failed", "", "no output")
|
|
303
|
+
|
|
304
|
+
return finish("ok", findings)
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def _events_for(cls, payload: dict):
|
|
308
|
+
"""Translate one stream-json line into zero or more events."""
|
|
309
|
+
kind = payload.get("type")
|
|
310
|
+
|
|
311
|
+
if kind == "system" and payload.get("subtype") == "init":
|
|
312
|
+
yield Event("start", text=str(payload.get("cwd") or ""))
|
|
313
|
+
|
|
314
|
+
elif kind == "assistant":
|
|
315
|
+
for block in _content(payload):
|
|
316
|
+
btype = block.get("type")
|
|
317
|
+
if btype == "text":
|
|
318
|
+
text = (block.get("text") or "").strip()
|
|
319
|
+
if text:
|
|
320
|
+
yield Event("text", text=text)
|
|
321
|
+
elif btype == "tool_use":
|
|
322
|
+
yield Event(
|
|
323
|
+
"tool",
|
|
324
|
+
tool=str(block.get("name") or "?"),
|
|
325
|
+
detail=_format_input(block.get("input")),
|
|
326
|
+
)
|
|
327
|
+
elif btype == "thinking":
|
|
328
|
+
yield Event("thinking")
|
|
329
|
+
|
|
330
|
+
elif kind == "user":
|
|
331
|
+
for block in _content(payload):
|
|
332
|
+
if block.get("type") == "tool_result":
|
|
333
|
+
yield Event("tool_result", text=_summarize(block.get("content")))
|
|
334
|
+
|
|
335
|
+
elif kind == "result":
|
|
336
|
+
if payload.get("is_error"):
|
|
337
|
+
detail = payload.get("result")
|
|
338
|
+
yield Event("error", text=str(detail or "the run reported an error"))
|
|
339
|
+
else:
|
|
340
|
+
yield Event("result")
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _claim_session(stdout: str) -> None:
|
|
344
|
+
"""Record the session id out of ``--output-format json``."""
|
|
345
|
+
try:
|
|
346
|
+
payload = json.loads(stdout)
|
|
347
|
+
except (json.JSONDecodeError, TypeError):
|
|
348
|
+
return
|
|
349
|
+
if isinstance(payload, dict):
|
|
350
|
+
sessions.record(str(payload.get("session_id") or ""))
|
|
351
|
+
|
|
352
|
+
@staticmethod
|
|
353
|
+
def _extract(stdout: str) -> str:
|
|
354
|
+
"""Pull the result text out of ``--output-format json``.
|
|
355
|
+
|
|
356
|
+
Never discards a completed run: if the envelope isn't the shape we
|
|
357
|
+
expect, the raw stdout *is* the findings. A run that cost quota must
|
|
358
|
+
always leave something in the digest.
|
|
359
|
+
"""
|
|
360
|
+
try:
|
|
361
|
+
payload = json.loads(stdout)
|
|
362
|
+
except json.JSONDecodeError:
|
|
363
|
+
return stdout
|
|
364
|
+
|
|
365
|
+
if isinstance(payload, dict):
|
|
366
|
+
for key in ("result", "text", "content", "output"):
|
|
367
|
+
value = payload.get(key)
|
|
368
|
+
if isinstance(value, str) and value.strip():
|
|
369
|
+
return value.strip()
|
|
370
|
+
if payload.get("is_error") and isinstance(payload.get("error"), str):
|
|
371
|
+
return payload["error"].strip()
|
|
372
|
+
return stdout
|