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.
@@ -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