hausfold-scruff 1.0.0__tar.gz

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,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .venv/
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.5
2
+ Name: hausfold-scruff
3
+ Version: 1.0.0
4
+ Summary: Python SDK for scruff, the worktree-lifecycle substrate. Shells out to the scruff binary; async-first so it drops into a FastAPI/asyncio backend or a script equally.
5
+ Project-URL: Repository, https://github.com/hausfold/scruff
6
+ Author: hausfold
7
+ License: Apache-2.0
8
+ Classifier: Typing :: Typed
9
+ Requires-Python: >=3.10
10
+ Provides-Extra: dev
11
+ Requires-Dist: mypy>=1.11; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
13
+ Requires-Dist: pytest>=8; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # hausfold-scruff (Python SDK)
17
+
18
+ A thin Python client over the [`scruff`](../../README.md) binary — the
19
+ worktree-lifecycle substrate for parallel coding agents. scruff has no daemon,
20
+ so this SDK shells out to it (`asyncio.create_subprocess_exec` + `--json`,
21
+ `watch --json` for a live NDJSON stream).
22
+
23
+ Async-first: `watch()` is naturally a stream. A sync script can still call
24
+ every method via `asyncio.run(...)`.
25
+
26
+ Import name is `scruff`; the package on PyPI is `hausfold-scruff`.
27
+
28
+ ## Install
29
+
30
+ ```
31
+ pip install hausfold-scruff
32
+ # or: uv add hausfold-scruff
33
+ ```
34
+
35
+ For local development against this repo instead: `pip install -e sdk/python`.
36
+
37
+ `scruff` itself must be on `PATH`, or pass `ScruffClientOptions(bin="/path/to/scruff")`.
38
+
39
+ ## Two shapes of usage
40
+
41
+ **Programmatic.** Every `ScruffClient` method except the two ending in
42
+ `_interactive` captures the child's stdout and returns — safe to call from
43
+ a server with many concurrent sessions.
44
+
45
+ ```python
46
+ import asyncio
47
+ from scruff import ScruffClient
48
+
49
+ async def main() -> None:
50
+ scruff = ScruffClient()
51
+
52
+ envelope = await scruff.list()
53
+ for lane in envelope.lanes:
54
+ # occupied/dirty are `bool | None`: None means "not determined",
55
+ # never coerce it to False.
56
+ print(lane.name, lane.state, lane.occupied)
57
+
58
+ # Create a lane WITHOUT attaching an agent to it — the primitive an
59
+ # orchestrator wants. child/spawn only ever print the new path.
60
+ lane_dir = await scruff.child("/path/to/some-repo", "task-42")
61
+ # ...now launch YOUR OWN agent process against lane_dir.
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ ```python
67
+ # Live updates instead of polling — created/parked/resumed/reaped/changed.
68
+ async for line in scruff.watch():
69
+ if line.kind == "created" and line.lane is not None:
70
+ notify_ui(line.lane)
71
+
72
+ # Or scoped to the one lane this session holds — no hello/ready framing,
73
+ # and nothing about anybody else's lanes.
74
+ async for event in scruff.watch_lane(lane_dir):
75
+ if event.kind == "reaped":
76
+ end_session()
77
+ ```
78
+
79
+ **Interactive.** `new_interactive` / `resume_interactive` inherit the
80
+ calling process's stdio, so when scruff execs the configured agent client
81
+ (`claude`, `codex`, `opencode`, `pi`) it takes over the real terminal — same as
82
+ running `scruff new` by hand — and control returns to you when that session
83
+ ends.
84
+
85
+ ```python
86
+ # A terminal app, run in an actual TTY:
87
+ await scruff.new_interactive("task-42")
88
+ # ... the agent owned the screen; you're back here when it exits.
89
+ ```
90
+
91
+ **Do not call `new_interactive` from a server** — `scruff new` execs the
92
+ agent client unconditionally, without checking for a TTY, so piped stdio
93
+ blocks forever. Use `resume()` instead: it detects piped stdout and prints
94
+ the reopen command as text rather than exec'ing.
95
+
96
+ ## Holding a session open: leases
97
+
98
+ scruff's sweep (`reap`) needs to know a checkout is in use. On a human's
99
+ machine, `lsof` answers that; a server has no pane or shell cwd'd anywhere,
100
+ so it says so itself with a lease:
101
+
102
+ ```python
103
+ lease = await scruff.lease(lane_dir) # refreshes on an interval, < the 90s TTL
104
+ # ... serve the session ...
105
+ await lease.release()
106
+ ```
107
+
108
+ Pass `pid=` instead when the lease should track a real local process — the
109
+ OS then drops it the instant that pid dies, no refresh loop needed.
110
+
111
+ A lease can only **save** a lane from `reap`, never condemn one — "nobody
112
+ leased it" isn't proof nobody's there.
113
+
114
+ `scruff.lease(...)` is a coroutine, so it can await the first heartbeat before
115
+ returning: a failure to take the lease raises immediately instead of
116
+ surfacing on the next refresh or release call.
117
+
118
+ ## `watch()` cleanup
119
+
120
+ `watch()` returns an async generator; stop consuming (`break`, or
121
+ `.aclose()`) to kill the underlying process. Async generators aren't
122
+ guaranteed to close promptly when they go out of scope — wrap long-lived use
123
+ in `contextlib.aclosing()` to tear down the subprocess deterministically:
124
+
125
+ ```python
126
+ from contextlib import aclosing
127
+
128
+ async with aclosing(scruff.watch()) as stream:
129
+ async for line in stream:
130
+ ...
131
+ ```
132
+
133
+ ## Types for a frontend
134
+
135
+ `scruff.types` has no runtime dependencies beyond the standard library.
136
+ Import just the dataclasses if you're modeling the same wire shape
137
+ elsewhere:
138
+
139
+ ```python
140
+ from scruff import ScruffLane, WatchEvent
141
+ ```
142
+
143
+ ## What's NOT here yet
144
+
145
+ `hook create`/`hook remove` have no wrapper — shell out via `run()` if you
146
+ need them. Types are hand-ported from the Go structs, not generated; if
147
+ scruff's JSON shape drifts from this file, that's a bug here.
148
+
149
+ ## Testing
150
+
151
+ `tests/fake-scruff.sh` stands in for the real binary so tests don't need a Go
152
+ build.
153
+
154
+ ```
155
+ python -m venv .venv && source .venv/bin/activate
156
+ pip install -e ".[dev]"
157
+ pytest
158
+ mypy src
159
+ ```
@@ -0,0 +1,144 @@
1
+ # hausfold-scruff (Python SDK)
2
+
3
+ A thin Python client over the [`scruff`](../../README.md) binary — the
4
+ worktree-lifecycle substrate for parallel coding agents. scruff has no daemon,
5
+ so this SDK shells out to it (`asyncio.create_subprocess_exec` + `--json`,
6
+ `watch --json` for a live NDJSON stream).
7
+
8
+ Async-first: `watch()` is naturally a stream. A sync script can still call
9
+ every method via `asyncio.run(...)`.
10
+
11
+ Import name is `scruff`; the package on PyPI is `hausfold-scruff`.
12
+
13
+ ## Install
14
+
15
+ ```
16
+ pip install hausfold-scruff
17
+ # or: uv add hausfold-scruff
18
+ ```
19
+
20
+ For local development against this repo instead: `pip install -e sdk/python`.
21
+
22
+ `scruff` itself must be on `PATH`, or pass `ScruffClientOptions(bin="/path/to/scruff")`.
23
+
24
+ ## Two shapes of usage
25
+
26
+ **Programmatic.** Every `ScruffClient` method except the two ending in
27
+ `_interactive` captures the child's stdout and returns — safe to call from
28
+ a server with many concurrent sessions.
29
+
30
+ ```python
31
+ import asyncio
32
+ from scruff import ScruffClient
33
+
34
+ async def main() -> None:
35
+ scruff = ScruffClient()
36
+
37
+ envelope = await scruff.list()
38
+ for lane in envelope.lanes:
39
+ # occupied/dirty are `bool | None`: None means "not determined",
40
+ # never coerce it to False.
41
+ print(lane.name, lane.state, lane.occupied)
42
+
43
+ # Create a lane WITHOUT attaching an agent to it — the primitive an
44
+ # orchestrator wants. child/spawn only ever print the new path.
45
+ lane_dir = await scruff.child("/path/to/some-repo", "task-42")
46
+ # ...now launch YOUR OWN agent process against lane_dir.
47
+
48
+ asyncio.run(main())
49
+ ```
50
+
51
+ ```python
52
+ # Live updates instead of polling — created/parked/resumed/reaped/changed.
53
+ async for line in scruff.watch():
54
+ if line.kind == "created" and line.lane is not None:
55
+ notify_ui(line.lane)
56
+
57
+ # Or scoped to the one lane this session holds — no hello/ready framing,
58
+ # and nothing about anybody else's lanes.
59
+ async for event in scruff.watch_lane(lane_dir):
60
+ if event.kind == "reaped":
61
+ end_session()
62
+ ```
63
+
64
+ **Interactive.** `new_interactive` / `resume_interactive` inherit the
65
+ calling process's stdio, so when scruff execs the configured agent client
66
+ (`claude`, `codex`, `opencode`, `pi`) it takes over the real terminal — same as
67
+ running `scruff new` by hand — and control returns to you when that session
68
+ ends.
69
+
70
+ ```python
71
+ # A terminal app, run in an actual TTY:
72
+ await scruff.new_interactive("task-42")
73
+ # ... the agent owned the screen; you're back here when it exits.
74
+ ```
75
+
76
+ **Do not call `new_interactive` from a server** — `scruff new` execs the
77
+ agent client unconditionally, without checking for a TTY, so piped stdio
78
+ blocks forever. Use `resume()` instead: it detects piped stdout and prints
79
+ the reopen command as text rather than exec'ing.
80
+
81
+ ## Holding a session open: leases
82
+
83
+ scruff's sweep (`reap`) needs to know a checkout is in use. On a human's
84
+ machine, `lsof` answers that; a server has no pane or shell cwd'd anywhere,
85
+ so it says so itself with a lease:
86
+
87
+ ```python
88
+ lease = await scruff.lease(lane_dir) # refreshes on an interval, < the 90s TTL
89
+ # ... serve the session ...
90
+ await lease.release()
91
+ ```
92
+
93
+ Pass `pid=` instead when the lease should track a real local process — the
94
+ OS then drops it the instant that pid dies, no refresh loop needed.
95
+
96
+ A lease can only **save** a lane from `reap`, never condemn one — "nobody
97
+ leased it" isn't proof nobody's there.
98
+
99
+ `scruff.lease(...)` is a coroutine, so it can await the first heartbeat before
100
+ returning: a failure to take the lease raises immediately instead of
101
+ surfacing on the next refresh or release call.
102
+
103
+ ## `watch()` cleanup
104
+
105
+ `watch()` returns an async generator; stop consuming (`break`, or
106
+ `.aclose()`) to kill the underlying process. Async generators aren't
107
+ guaranteed to close promptly when they go out of scope — wrap long-lived use
108
+ in `contextlib.aclosing()` to tear down the subprocess deterministically:
109
+
110
+ ```python
111
+ from contextlib import aclosing
112
+
113
+ async with aclosing(scruff.watch()) as stream:
114
+ async for line in stream:
115
+ ...
116
+ ```
117
+
118
+ ## Types for a frontend
119
+
120
+ `scruff.types` has no runtime dependencies beyond the standard library.
121
+ Import just the dataclasses if you're modeling the same wire shape
122
+ elsewhere:
123
+
124
+ ```python
125
+ from scruff import ScruffLane, WatchEvent
126
+ ```
127
+
128
+ ## What's NOT here yet
129
+
130
+ `hook create`/`hook remove` have no wrapper — shell out via `run()` if you
131
+ need them. Types are hand-ported from the Go structs, not generated; if
132
+ scruff's JSON shape drifts from this file, that's a bug here.
133
+
134
+ ## Testing
135
+
136
+ `tests/fake-scruff.sh` stands in for the real binary so tests don't need a Go
137
+ build.
138
+
139
+ ```
140
+ python -m venv .venv && source .venv/bin/activate
141
+ pip install -e ".[dev]"
142
+ pytest
143
+ mypy src
144
+ ```
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "hausfold-scruff"
3
+ version = "1.0.0"
4
+ description = "Python SDK for scruff, the worktree-lifecycle substrate. Shells out to the scruff binary; async-first so it drops into a FastAPI/asyncio backend or a script equally."
5
+ readme = "README.md"
6
+ license = { text = "Apache-2.0" }
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "hausfold" }]
9
+ classifiers = [
10
+ "Typing :: Typed",
11
+ ]
12
+
13
+ [project.urls]
14
+ Repository = "https://github.com/hausfold/scruff"
15
+
16
+ [project.optional-dependencies]
17
+ dev = ["pytest>=8", "pytest-asyncio>=0.24", "mypy>=1.11"]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/scruff"]
25
+
26
+ [tool.mypy]
27
+ strict = true
28
+ python_version = "3.10"
29
+
30
+ [tool.pytest.ini_options]
31
+ asyncio_mode = "auto"
32
+ testpaths = ["tests"]
@@ -0,0 +1,47 @@
1
+ from .client import ScruffClient, ScruffClientOptions, Lease
2
+ from .errors import ScruffError
3
+ from .exec import RunOptions, RunResult, run, run_json
4
+ from .types import (
5
+ ScruffEnvelope,
6
+ ScruffExitCode,
7
+ ScruffLane,
8
+ LandedInfo,
9
+ LandedVerdict,
10
+ LandedVia,
11
+ LaneState,
12
+ PostMergeAhead,
13
+ WatchEvent,
14
+ WatchEventKind,
15
+ WatchHello,
16
+ WatchLine,
17
+ is_watch_hello,
18
+ parse_watch_line,
19
+ )
20
+ from .watch import watch_all, watch_lane
21
+
22
+ __all__ = [
23
+ "ScruffClient",
24
+ "ScruffClientOptions",
25
+ "Lease",
26
+ "ScruffError",
27
+ "RunOptions",
28
+ "RunResult",
29
+ "run",
30
+ "run_json",
31
+ "ScruffEnvelope",
32
+ "ScruffExitCode",
33
+ "ScruffLane",
34
+ "LandedInfo",
35
+ "LandedVerdict",
36
+ "LandedVia",
37
+ "LaneState",
38
+ "PostMergeAhead",
39
+ "WatchEvent",
40
+ "WatchEventKind",
41
+ "WatchHello",
42
+ "WatchLine",
43
+ "is_watch_hello",
44
+ "parse_watch_line",
45
+ "watch_all",
46
+ "watch_lane",
47
+ ]
@@ -0,0 +1,269 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from typing import Any, AsyncGenerator, Optional
6
+
7
+ from .errors import ScruffError
8
+ from .exec import RunOptions, merged_env, run, run_json
9
+ from .types import ScruffEnvelope, WatchEvent, WatchLine
10
+ from .watch import watch_all, watch_lane
11
+
12
+
13
+ @dataclass
14
+ class ScruffClientOptions:
15
+ # Path to the scruff binary, or a bare name resolved on PATH. Defaults to
16
+ # "scruff".
17
+ bin: Optional[str] = None
18
+ # Working directory every command runs from — most of scruff's commands
19
+ # are cwd-sensitive (`new`, `park`, a bare `scruff <name>`). Defaults to
20
+ # this process's own cwd.
21
+ cwd: Optional[str] = None
22
+ # Extra environment variables, merged over the current process's env.
23
+ # Useful for SCRUFF_AGENT, SCRUFF_OCCUPANCY=lease.
24
+ env: Optional[dict[str, Optional[str]]] = None
25
+
26
+
27
+ class ScruffClient:
28
+ """A thin client over the `scruff` binary. Every method shells out —
29
+ there is no daemon, no port, no socket (SPEC.md §14.1) — so this class
30
+ holds nothing but the options each call needs, and is cheap to
31
+ construct as often as you like.
32
+
33
+ Two methods (`new_interactive`, `resume_interactive`) inherit the
34
+ calling process's stdio and can hand off the terminal to a coding
35
+ agent; every other method captures output and returns. Mixing them up
36
+ matters: see each method's docstring.
37
+ """
38
+
39
+ def __init__(self, options: Optional[ScruffClientOptions] = None) -> None:
40
+ options = options or ScruffClientOptions()
41
+ self._opts = RunOptions(bin=options.bin, cwd=options.cwd, env=options.env)
42
+
43
+ async def list(self) -> ScruffEnvelope:
44
+ """`scruff --json` / `scruff list --json` — byte-identical (SPEC.md
45
+ §2.2). The full snapshot: every live/parked lane, across every repo
46
+ scruff knows about. Poll this for landedness and PR state; use
47
+ {watch} for everything else, since it's push rather than poll."""
48
+ data = await run_json(["--json"], self._opts)
49
+ return ScruffEnvelope._from_json(data)
50
+
51
+ def watch(self) -> AsyncGenerator[WatchLine, None]:
52
+ """`scruff watch --json` as an async iterator of typed lines — a
53
+ `hello`, then a `sync` burst for every lane already alive, `ready`,
54
+ then live changes for as long as you keep iterating. Stop
55
+ iterating (`break`, or `.aclose()`) to kill the underlying
56
+ process.
57
+
58
+ This is the primitive onOpen/onParked/... callback-style APIs are
59
+ built from (SPEC.md §14.2) — see {watch_lane} for a version scoped
60
+ to one lane's `path`.
61
+
62
+ ```python
63
+ async for line in scruff.watch():
64
+ if line.kind == "created":
65
+ print("new lane:", line.lane.name if line.lane else None)
66
+ ```
67
+ """
68
+ return watch_all(self._opts)
69
+
70
+ def watch_lane(self, path: str) -> AsyncGenerator[WatchEvent, None]:
71
+ """{watch}, filtered to events about ONE lane (`event.lane.path`)
72
+ and stripped of the `hello`/`ready` framing that names no lane —
73
+ the shape an embedder holding one session per lane usually wants:
74
+ "tell me when THIS lane's state changes." A `sync` event for the
75
+ lane still passes through: it's how a caller that started watching
76
+ after the lane went live learns it exists at all.
77
+
78
+ Compare full paths, not names: names aren't unique across repos,
79
+ but a checkout path is the registry's own primary key (SPEC.md
80
+ §2.1).
81
+
82
+ The module-level `scruff.watch_lane` does the same thing but takes
83
+ its own `RunOptions`; this one carries the client's bin/cwd/env.
84
+ """
85
+ return watch_lane(path, self._opts)
86
+
87
+ async def child(self, repo_path: str, name: Optional[str] = None) -> str:
88
+ """`scruff child <repo> [name]` — a lane on ANOTHER repo, registered
89
+ as a child of cwd. Prints only the new checkout's path on stdout
90
+ (SPEC.md §2.3's "only the path" discipline extends here too) and
91
+ never execs a client, which is what makes it the right primitive
92
+ for an orchestrator: create the lane, then run your OWN agent
93
+ process against the path it returns."""
94
+ args = ["child", repo_path, *([name] if name else [])]
95
+ result = await run(args, self._opts)
96
+ return result.stdout.strip()
97
+
98
+ async def spawn(self, repo_path: str, name: str, agent: Optional[str] = None) -> str:
99
+ """`scruff spawn <repo> <name> [agent]` — a named lane for a caller
100
+ with no pane of its own (a scheduler, a web backend). Like
101
+ {child}, only ever creates the lane and prints its path; never
102
+ execs."""
103
+ args = ["spawn", repo_path, name, *([agent] if agent else [])]
104
+ result = await run(args, self._opts)
105
+ return result.stdout.strip()
106
+
107
+ async def resume(self, name: str) -> str:
108
+ """`scruff <name>` / `scruff resume <name>` with stdout captured
109
+ rather than a terminal — which means the Go binary's own TTY check
110
+ (`ui.IsTTY`) sees a pipe and, by design, never execs a client. It
111
+ rebuilds the checkout if needed and returns the human-readable
112
+ result: either confirmation it's ready, or the exact command to
113
+ reopen the agent's chat by hand. Safe to call from a server
114
+ process. For a TUI that wants to actually hand off the terminal,
115
+ use {resume_interactive} instead."""
116
+ result = await run(["resume", name], self._opts)
117
+ return result.stdout
118
+
119
+ async def park(self, label: Optional[str] = None) -> None:
120
+ """`scruff park [label]` — commits the working tree as one `wip:`
121
+ commit on the current branch. Never touches the shared stash stack
122
+ (README's "park, not git stash" section) — this is the one safe
123
+ way for concurrent lanes to set work aside."""
124
+ await run(["park", *([label] if label else [])], self._opts)
125
+
126
+ async def unpark(self) -> None:
127
+ """`scruff unpark` — reverses the most recent `park`, putting its
128
+ changes back uncommitted. Raises {ScruffError} with `.refused ==
129
+ True` if that commit is already pushed (scruff will not rewrite
130
+ published history) or HEAD isn't a parked commit."""
131
+ await run(["unpark"], self._opts)
132
+
133
+ async def reap(self) -> None:
134
+ """`scruff reap` — sweeps every LANDED lane nobody is standing in
135
+ (occupied, per {heartbeat}/`lsof`, always wins). Never removes the
136
+ checkout scruff is being run from, and never removes a stray."""
137
+ await run(["reap"], self._opts)
138
+
139
+ async def reship(self, name: Optional[str] = None) -> None:
140
+ """`scruff reship [name]` — pushes a branch that outran its already-
141
+ merged PR, and opens the follow-up. Raises with `.degraded ==
142
+ True` if `gh` itself is unavailable."""
143
+ await run(["reship", *([name] if name else [])], self._opts)
144
+
145
+ async def heartbeat(self, path: Optional[str] = None, *, pid: Optional[int] = None) -> None:
146
+ """`scruff heartbeat [path] [--pid N | --release]` — takes or
147
+ refreshes the occupancy lease on a checkout (SPEC.md §9.1, §14.2).
148
+ This is the seam built for exactly this SDK: a program embedding
149
+ scruff has no pane and no shell cwd'd anywhere, so the lease is the
150
+ only way `reap` learns a checkout is in use. A lease can only SAVE
151
+ a lane from the sweep, never condemn one — see {lease} for a
152
+ self-refreshing wrapper instead of calling this on a timer
153
+ yourself."""
154
+ args = ["heartbeat", *([path] if path else [])]
155
+ if pid is not None:
156
+ args += ["--pid", str(pid)]
157
+ await run(args, self._opts)
158
+
159
+ async def release_heartbeat(self, path: Optional[str] = None) -> None:
160
+ """Drops the lease taken by {heartbeat}."""
161
+ await run(["heartbeat", *([path] if path else []), "--release"], self._opts)
162
+
163
+ async def lease(
164
+ self,
165
+ path: str,
166
+ *,
167
+ pid: Optional[int] = None,
168
+ refresh_seconds: float = 60.0,
169
+ ) -> "Lease":
170
+ """Takes an occupancy lease and holds it for as long as the
171
+ returned handle is open, refreshing on an interval comfortably
172
+ under the 90s TTL (`internal/occupancy.TTL`) that applies when
173
+ there's no pid to watch. This is the primitive an embedder's
174
+ "session" (a connection, not a cwd — SPEC.md §14.2) should hold
175
+ from connect to disconnect:
176
+
177
+ ```python
178
+ lease = await scruff.lease(lane_dir)
179
+ # ... serve the session ...
180
+ await lease.release()
181
+ ```
182
+
183
+ Pass `pid=` instead when the lease should track a real local
184
+ process — the kernel then releases it the instant that pid dies,
185
+ with no refresh loop needed at all, and `refresh_seconds` is
186
+ ignored.
187
+
188
+ Unlike the TS SDK's constructor-based `lease()`, this is a
189
+ coroutine: Python can await the first heartbeat before returning,
190
+ so a failure to take the lease raises here rather than silently
191
+ surfacing on the next refresh or release.
192
+ """
193
+ kwargs: dict[str, Any] = {"pid": pid} if pid is not None else {}
194
+ await self.heartbeat(path, **kwargs)
195
+ return Lease(self, path, pid=pid, refresh_seconds=refresh_seconds)
196
+
197
+ async def new_interactive(self, name: Optional[str] = None, agent: Optional[str] = None) -> None:
198
+ """`scruff new [name] --open [agent]` with stdio INHERITED from the calling
199
+ process. scruff execs the configured agent client unconditionally
200
+ here (unlike `resume`, `new` doesn't check for a TTY) —
201
+ appropriate for a real terminal app (a TUI) that wants to hand off
202
+ the screen and get control back when the agent session ends, and
203
+ WRONG for a server: it will block until the agent process exits,
204
+ with your stdio attached to whatever the agent expects."""
205
+ # --open is explicit: bare `scruff new` only prints the lane's path.
206
+ args = ["new", *([name] if name else []), "--open", *([agent] if agent else [])]
207
+ await _run_interactive(args, self._opts)
208
+
209
+ async def resume_interactive(self, name: str) -> None:
210
+ """`scruff resume <name>` / `scruff <name>` with stdio INHERITED, so a
211
+ real terminal's TTY check passes and scruff hands off the screen to
212
+ the agent client. Same caveat as {new_interactive}: blocks until
213
+ that session ends."""
214
+ await _run_interactive(["resume", name], self._opts)
215
+
216
+
217
+ async def _run_interactive(args: list[str], opts: RunOptions) -> None:
218
+ bin_ = opts.bin or "scruff"
219
+ proc = await asyncio.create_subprocess_exec(
220
+ bin_,
221
+ *args,
222
+ cwd=opts.cwd,
223
+ env=merged_env(opts.env),
224
+ # stdin/stdout/stderr default to None, i.e. inherited from this
225
+ # process — the async equivalent of Node's stdio: "inherit".
226
+ )
227
+ code = await proc.wait()
228
+ if code != 0:
229
+ raise ScruffError(code, "", [bin_, *args])
230
+
231
+
232
+ class Lease:
233
+ """A held occupancy lease. See {ScruffClient.lease}."""
234
+
235
+ def __init__(
236
+ self,
237
+ client: ScruffClient,
238
+ path: str,
239
+ *,
240
+ pid: Optional[int],
241
+ refresh_seconds: float,
242
+ ) -> None:
243
+ self._client = client
244
+ self._path = path
245
+ self._released = False
246
+ self._task: Optional["asyncio.Task[None]"] = None
247
+ if pid is None:
248
+ self._task = asyncio.create_task(self._refresh_loop(refresh_seconds))
249
+
250
+ async def _refresh_loop(self, refresh_seconds: float) -> None:
251
+ try:
252
+ while True:
253
+ await asyncio.sleep(refresh_seconds)
254
+ try:
255
+ await self._client.heartbeat(self._path)
256
+ except ScruffError:
257
+ pass # best-effort refresh; a miss self-heals on the next tick
258
+ except asyncio.CancelledError:
259
+ pass
260
+
261
+ async def release(self) -> None:
262
+ """Drops the lease and stops refreshing it. Safe to call more than
263
+ once."""
264
+ if self._released:
265
+ return
266
+ self._released = True
267
+ if self._task is not None:
268
+ self._task.cancel()
269
+ await self._client.release_heartbeat(self._path)
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from .types import ScruffExitCode
4
+
5
+ _LABELS: dict[int, str] = {
6
+ ScruffExitCode.USAGE: "usage",
7
+ ScruffExitCode.REFUSED: "refused",
8
+ ScruffExitCode.DEGRADED: "degraded",
9
+ ScruffExitCode.CONFLICT: "conflict",
10
+ ScruffExitCode.LOCKED: "locked",
11
+ }
12
+
13
+
14
+ class ScruffError(Exception):
15
+ """Raised by every SDK call that shells out and gets back a non-zero
16
+ exit. Carries scruff's actual exit code (SPEC.md §2.4) rather than
17
+ collapsing it to a generic failure — `err.refused` is how a caller tells
18
+ "scruff declined to destroy something" from "you asked wrong" (`USAGE`) or
19
+ "registry locked" (`LOCKED`), and each deserves different handling
20
+ (retry, surface to a human, or just don't retry).
21
+ """
22
+
23
+ def __init__(self, code: int, stderr: str, command: list[str]) -> None:
24
+ label = _LABELS.get(code, f"exit {code}")
25
+ suffix = f" — {stderr.strip()}" if stderr else ""
26
+ super().__init__(f"scruff {' '.join(command)}: {label}{suffix}")
27
+ self.code = code
28
+ self.stderr = stderr
29
+ self.command = command
30
+
31
+ @property
32
+ def refused(self) -> bool:
33
+ """`True` when scruff declined for safety (occupied, dirty, or not
34
+ provably landed) rather than because the call itself was wrong."""
35
+ return self.code == ScruffExitCode.REFUSED
36
+
37
+ @property
38
+ def degraded(self) -> bool:
39
+ """`True` when the operation completed but a signal was unavailable
40
+ (forge down, no `lsof`) — check `warnings` on the envelope for
41
+ why."""
42
+ return self.code == ScruffExitCode.DEGRADED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Any, Optional
8
+
9
+ from .errors import ScruffError
10
+
11
+
12
+ @dataclass
13
+ class RunOptions:
14
+ """Options threaded through to every subprocess call."""
15
+
16
+ # Path to the scruff binary, or a bare name resolved on PATH. Defaults to
17
+ # "scruff".
18
+ bin: Optional[str] = None
19
+ # Working directory to run scruff from — most commands are cwd-sensitive
20
+ # (`scruff new`, `scruff park`, a bare `scruff <name>`).
21
+ cwd: Optional[str] = None
22
+ # Extra environment variables, merged over the current process's env —
23
+ # e.g. SCRUFF_AGENT, SCRUFF_OCCUPANCY. A value of None unsets the key rather
24
+ # than passing the literal string "None" to the child.
25
+ env: Optional[dict[str, Optional[str]]] = None
26
+ # Piped to the child's stdin, then the stream is closed. Used by
27
+ # `scruff hook create`/`remove`, which read JSON off stdin (SPEC.md §2.3).
28
+ stdin: Optional[str] = None
29
+
30
+
31
+ @dataclass
32
+ class RunResult:
33
+ stdout: str
34
+ stderr: str
35
+ code: int
36
+
37
+
38
+ def merged_env(env: Optional[dict[str, Optional[str]]]) -> Optional[dict[str, str]]:
39
+ if env is None:
40
+ return None
41
+ merged = dict(os.environ)
42
+ for key, value in env.items():
43
+ if value is None:
44
+ merged.pop(key, None)
45
+ else:
46
+ merged[key] = value
47
+ return merged
48
+
49
+
50
+ async def run(args: list[str], opts: Optional[RunOptions] = None) -> RunResult:
51
+ """Runs one scruff invocation to completion and collects its output. Every
52
+ non-`--json` scruff command writes human text to stdout on success — this
53
+ is the primitive `list()`/`watch()` build their typed parsing on top of,
54
+ and the one lifecycle commands (`new`, `park`, `reap`, ...) use directly,
55
+ surfacing stdout as a plain string.
56
+
57
+ Raises {ScruffError} on a non-zero exit, carrying scruff's exit code
58
+ (SPEC.md §2.4) rather than collapsing every failure into one shape.
59
+ """
60
+ opts = opts or RunOptions()
61
+ bin_ = opts.bin or "scruff"
62
+ proc = await asyncio.create_subprocess_exec(
63
+ bin_,
64
+ *args,
65
+ cwd=opts.cwd,
66
+ env=merged_env(opts.env),
67
+ stdin=asyncio.subprocess.PIPE,
68
+ stdout=asyncio.subprocess.PIPE,
69
+ stderr=asyncio.subprocess.PIPE,
70
+ )
71
+ stdin_bytes = opts.stdin.encode() if opts.stdin is not None else b""
72
+ stdout_bytes, stderr_bytes = await proc.communicate(stdin_bytes)
73
+ stdout = stdout_bytes.decode()
74
+ stderr = stderr_bytes.decode()
75
+ code = proc.returncode if proc.returncode is not None else 1
76
+
77
+ if code != 0:
78
+ raise ScruffError(code, stderr, [bin_, *args])
79
+ return RunResult(stdout=stdout, stderr=stderr, code=code)
80
+
81
+
82
+ async def run_json(args: list[str], opts: Optional[RunOptions] = None) -> Any:
83
+ """Same as {run}, but parses stdout as JSON — for `--json` commands
84
+ only. scruff's own contract (README, internal/ui) is "stdout carries the
85
+ payload, every diagnostic goes to stderr", so this never has to guess
86
+ which lines are data."""
87
+ result = await run(args, opts)
88
+ return json.loads(result.stdout)
File without changes
@@ -0,0 +1,216 @@
1
+ # Wire types for scruff's frozen public contracts — SPEC.md §2.2 (`--json`) and
2
+ # §14.3 step 2 (`watch --json`). Hand-ported from the Go source of truth
3
+ # (internal/commands/json.go, internal/commands/watch.go) rather than
4
+ # generated, because scruff has no `go generate` step yet for this — see the
5
+ # SDK README for the drift risk that implies.
6
+ #
7
+ # schema 1 is what's actually implemented today. SPEC.md §2.2's example
8
+ # envelope also shows `pr`, `overlap`, `ahead`, `behind` — those are §0.2/
9
+ # later milestones (`overlap`, forge polling) and are NOT on the wire yet.
10
+ # Do not add them here until json.go does; a field that exists in the type
11
+ # but never arrives on the wire is worse than one that's simply missing.
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from enum import IntEnum
17
+ from typing import Any, Literal, Optional, Union
18
+
19
+
20
+ class ScruffExitCode(IntEnum):
21
+ """scruff's exit-code contract (SPEC.md §2.4). REFUSED vs USAGE is the one
22
+ that matters to a caller: "you asked wrong" vs "I declined to destroy
23
+ something"."""
24
+
25
+ OK = 0
26
+ USAGE = 1
27
+ REFUSED = 2
28
+ DEGRADED = 3
29
+ CONFLICT = 4
30
+ LOCKED = 5
31
+
32
+
33
+ # Closed set — SPEC.md §2.2: "additions are minor, removals major." Treat an
34
+ # unknown value as opaque, not an error.
35
+ LaneState = Literal["live", "parked", "stray"]
36
+
37
+ LandedVerdict = Literal["yes", "no", "fresh", "contained"]
38
+
39
+ LandedVia = Optional[
40
+ Literal[
41
+ "never-diverged",
42
+ "ancestry",
43
+ "pr-head-oid",
44
+ "patch-equivalence",
45
+ "merge-tree-empty",
46
+ ]
47
+ ]
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class LandedInfo:
52
+ verdict: LandedVerdict
53
+ via: LandedVia
54
+ confidence: str
55
+
56
+ @classmethod
57
+ def _from_json(cls, d: dict[str, Any]) -> "LandedInfo":
58
+ return cls(verdict=d["verdict"], via=d.get("via"), confidence=d["confidence"])
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class PostMergeAhead:
63
+ commits: int
64
+ # PR number, or 0 when there isn't one — scruff doesn't null this field
65
+ # today (internal/commands/json.go's jsonPostMerge), unlike `pr` at the
66
+ # envelope level. Treat 0 as "none" here, not as PR #0.
67
+ pr: int
68
+
69
+ @classmethod
70
+ def _from_json(cls, d: dict[str, Any]) -> "PostMergeAhead":
71
+ return cls(commits=d["commits"], pr=d["pr"])
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class ScruffLane:
76
+ """One lane, in the exact shape `--json` uses for `lanes[]` — the same
77
+ shape `watch --json` puts on `event.lane`. One schema whether you're
78
+ reading a snapshot or a stream (SPEC.md §14.1).
79
+
80
+ `occupied` and `dirty` are three-state on purpose: `None` means "not
81
+ determined" (no lsof, no forge, cache miss), which is categorically
82
+ different from `False`. Every consumer bug in scruff's bash-era statusline
83
+ came from collapsing that `None` into `False` — do not do that here
84
+ either.
85
+ """
86
+
87
+ name: str
88
+ repo: str
89
+ main: str
90
+ branch: str
91
+ path: str
92
+ parent: str
93
+ # The client this lane opens (claude | codex | opencode | pi, or whatever
94
+ # adapters are configured) — never the lane's own identity.
95
+ agent: str
96
+ state: LaneState
97
+ occupied: Optional[bool]
98
+ dirty: Optional[bool]
99
+ landed: LandedInfo
100
+ post_merge_ahead: PostMergeAhead
101
+ last_commit: str
102
+
103
+ @classmethod
104
+ def _from_json(cls, d: dict[str, Any]) -> "ScruffLane":
105
+ return cls(
106
+ name=d["name"],
107
+ repo=d["repo"],
108
+ main=d["main"],
109
+ branch=d["branch"],
110
+ path=d["path"],
111
+ parent=d["parent"],
112
+ agent=d["agent"],
113
+ state=d["state"],
114
+ occupied=d["occupied"],
115
+ dirty=d["dirty"],
116
+ landed=LandedInfo._from_json(d["landed"]),
117
+ post_merge_ahead=PostMergeAhead._from_json(d["post_merge_ahead"]),
118
+ last_commit=d["last_commit"],
119
+ )
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class ScruffEnvelope:
124
+ """The `scruff --json` / `scruff list --json` envelope — byte-identical
125
+ between the two spellings (SPEC.md §2.2)."""
126
+
127
+ scruff: str
128
+ schema: int
129
+ lanes: list[ScruffLane]
130
+ warnings: list[str]
131
+
132
+ @classmethod
133
+ def _from_json(cls, d: dict[str, Any]) -> "ScruffEnvelope":
134
+ return cls(
135
+ scruff=d["scruff"],
136
+ schema=d["schema"],
137
+ lanes=[ScruffLane._from_json(lane) for lane in d["lanes"]],
138
+ warnings=list(d["warnings"]),
139
+ )
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # `scruff watch --json` — SPEC.md §14.3 step 2, §14.4.
144
+
145
+ # Closed set, same discipline as LaneState/LandedVerdict: additions are
146
+ # minor, removals major. An unknown kind is noise to ignore, not an error to
147
+ # raise on — that's what lets scruff add `landed`/`source: "forge"` later
148
+ # without breaking every SDK pinned to v1 (SPEC.md §14.4).
149
+ WatchEventKind = Literal[
150
+ "sync", "ready", "created", "parked", "resumed", "reaped", "changed", "warning"
151
+ ]
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class WatchHello:
156
+ """First line of every `watch` stream. A version header, not an event —
157
+ see `capabilities` for why it carries more than `{scruff, schema}`."""
158
+
159
+ kind: Literal["hello"]
160
+ seq: int
161
+ scruff: str
162
+ schema: int
163
+ # What families of event this scruff build can ever send on this stream.
164
+ # v1 always sends exactly ["registry"]; a future "forge" entry is how a
165
+ # consumer learns a landed/post_merge_ahead event kind might show up
166
+ # without guessing from which kinds happen to have arrived yet.
167
+ capabilities: list[str]
168
+
169
+
170
+ @dataclass(frozen=True, slots=True)
171
+ class WatchEvent:
172
+ """Every line after `hello`. One line, at most one lane — never a
173
+ batch."""
174
+
175
+ kind: WatchEventKind
176
+ # Monotonic across the WHOLE stream, hello included — lets a consumer
177
+ # fanning this out over its own transport (e.g. a websocket) detect a
178
+ # dropped line without scruff knowing anything about that transport.
179
+ seq: int
180
+ # RFC3339 UTC. When THIS scruff process observed the change, not
181
+ # necessarily when it happened at the source. Absent on `hello`.
182
+ ts: Optional[str] = None
183
+ # Which provider produced the event. v1 only ever writes "registry";
184
+ # absent on `ready`, which names no lane and no provider.
185
+ source: Optional[str] = None
186
+ # Present on every kind except `ready` and `warning`.
187
+ lane: Optional[ScruffLane] = None
188
+ # Present only on `warning` — the same text `warnings[]` carries under
189
+ # `--json`, pushed here because a stream reader has no envelope to poll.
190
+ message: Optional[str] = None
191
+
192
+
193
+ WatchLine = Union[WatchHello, WatchEvent]
194
+
195
+
196
+ def parse_watch_line(d: dict[str, Any]) -> WatchLine:
197
+ if d["kind"] == "hello":
198
+ return WatchHello(
199
+ kind="hello",
200
+ seq=d["seq"],
201
+ scruff=d["scruff"],
202
+ schema=d["schema"],
203
+ capabilities=list(d["capabilities"]),
204
+ )
205
+ return WatchEvent(
206
+ kind=d["kind"],
207
+ seq=d["seq"],
208
+ ts=d.get("ts"),
209
+ source=d.get("source"),
210
+ lane=ScruffLane._from_json(d["lane"]) if d.get("lane") is not None else None,
211
+ message=d.get("message"),
212
+ )
213
+
214
+
215
+ def is_watch_hello(line: WatchLine) -> bool:
216
+ return isinstance(line, WatchHello)
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import AsyncGenerator, Optional
6
+
7
+ from .exec import RunOptions, merged_env
8
+ from .types import WatchEvent, WatchLine, parse_watch_line
9
+
10
+
11
+ async def watch_all(opts: Optional[RunOptions] = None) -> AsyncGenerator[WatchLine, None]:
12
+ """`scruff watch --json` as an async iterator of typed lines. One object
13
+ per NDJSON line on stdout, in order: `hello`, a `sync` burst for every
14
+ lane already alive, `ready`, then live changes for as long as the
15
+ process runs (SPEC.md §14.3 step 2).
16
+
17
+ The child process is killed when you stop consuming — `break` out of an
18
+ `async for`, or call `.aclose()` on the generator. Async generators are
19
+ not guaranteed to be finalized promptly by garbage collection the way
20
+ CPython's refcounting closes sync generators, so wrap long-lived use in
21
+ `contextlib.aclosing()` if you want the subprocess torn down
22
+ deterministically rather than on the next GC pass:
23
+
24
+ ```python
25
+ from contextlib import aclosing
26
+
27
+ async with aclosing(scruff.watch()) as stream:
28
+ async for line in stream:
29
+ ...
30
+ ```
31
+
32
+ There is no other way to stop it short: `watch` has no built-in end
33
+ condition, by design (SPEC.md §14).
34
+ """
35
+ opts = opts or RunOptions()
36
+ bin_ = opts.bin or "scruff"
37
+ proc = await asyncio.create_subprocess_exec(
38
+ bin_,
39
+ "watch",
40
+ "--json",
41
+ cwd=opts.cwd,
42
+ env=merged_env(opts.env),
43
+ stdin=asyncio.subprocess.DEVNULL,
44
+ stdout=asyncio.subprocess.PIPE,
45
+ stderr=asyncio.subprocess.PIPE,
46
+ )
47
+ assert proc.stdout is not None
48
+ try:
49
+ while True:
50
+ raw = await proc.stdout.readline()
51
+ if not raw:
52
+ break
53
+ line = raw.decode().strip()
54
+ if not line:
55
+ continue
56
+ yield parse_watch_line(json.loads(line))
57
+ finally:
58
+ if proc.returncode is None:
59
+ proc.kill()
60
+ await proc.wait()
61
+
62
+
63
+ async def watch_lane(path: str, opts: Optional[RunOptions] = None) -> AsyncGenerator[WatchEvent, None]:
64
+ """{watch_all}, filtered to events about one lane (`event.lane.path`)
65
+ and stripped of the `hello`/`ready` framing that names no lane — the
66
+ shape an embedder holding one session per lane usually wants: "tell me
67
+ when THIS lane's state changes."
68
+
69
+ A `sync` event for the lane still passes through — it is NOT framing.
70
+ It's how a caller that started watching after the lane went live learns
71
+ the lane exists at all, so a match over `event.kind` needs a `sync` arm.
72
+
73
+ Compare full paths, not names: names aren't unique across repos, but a
74
+ checkout path is the registry's own primary key (SPEC.md §2.1).
75
+ """
76
+ async for line in watch_all(opts):
77
+ if isinstance(line, WatchEvent) and line.lane is not None and line.lane.path == path:
78
+ yield line
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bash
2
+ # A stand-in for the real `scruff` binary, used only by this SDK's own tests.
3
+ # Emits fixed --json / watch --json payloads so the SDK's parsing, process
4
+ # lifecycle and error mapping can be exercised without a Go build.
5
+ #
6
+ # Kept in sync by hand with sdk/ts/test/fake-scruff.sh — same fixture data, so
7
+ # a wire-shape bug shows up identically in both SDKs' test suites.
8
+ set -euo pipefail
9
+
10
+ case "${1:-}" in
11
+ --json|list)
12
+ cat <<'JSON'
13
+ {
14
+ "scruff": "0.1.0-dev",
15
+ "schema": 2,
16
+ "lanes": [
17
+ {
18
+ "name": "sparkle",
19
+ "repo": "hausfold/haus",
20
+ "main": "/repo/haus",
21
+ "branch": "worktree-sparkle",
22
+ "path": "/repo/.scruff/haus/sparkle",
23
+ "parent": "/repo/haus",
24
+ "agent": "claude",
25
+ "state": "live",
26
+ "occupied": true,
27
+ "dirty": false,
28
+ "landed": { "verdict": "no", "via": null, "confidence": "certain" },
29
+ "post_merge_ahead": { "commits": 0, "pr": 0 },
30
+ "last_commit": "spec: SDK smoke test fixture"
31
+ },
32
+ {
33
+ "name": "frost",
34
+ "repo": "hausfold/haus",
35
+ "main": "/repo/haus",
36
+ "branch": "worktree-frost",
37
+ "path": "/repo/.scruff/haus/frost",
38
+ "parent": "/repo/haus",
39
+ "agent": "codex",
40
+ "state": "parked",
41
+ "occupied": null,
42
+ "dirty": null,
43
+ "landed": { "verdict": "contained", "via": "merge-tree-empty", "confidence": "advisory" },
44
+ "post_merge_ahead": { "commits": 0, "pr": 0 },
45
+ "last_commit": "park: set aside"
46
+ }
47
+ ],
48
+ "warnings": []
49
+ }
50
+ JSON
51
+ ;;
52
+
53
+ watch)
54
+ echo '{"kind":"hello","seq":0,"scruff":"0.1.0-dev","schema":2,"capabilities":["registry"]}'
55
+ echo '{"kind":"sync","seq":1,"ts":"2026-08-07T02:11:04Z","source":"registry","lane":{"name":"sparkle","repo":"hausfold/haus","main":"/repo/haus","branch":"worktree-sparkle","path":"/repo/.scruff/haus/sparkle","parent":"/repo/haus","agent":"claude","state":"live","occupied":true,"dirty":false,"landed":{"verdict":"no","via":null,"confidence":"certain"},"post_merge_ahead":{"commits":0,"pr":0},"last_commit":"c1"}}'
56
+ echo '{"kind":"ready","seq":2,"ts":"2026-08-07T02:11:04Z"}'
57
+ sleep 0.05
58
+ echo '{"kind":"created","seq":3,"ts":"2026-08-07T02:11:05Z","source":"registry","lane":{"name":"fresh","repo":"hausfold/haus","main":"/repo/haus","branch":"worktree-fresh","path":"/repo/.scruff/haus/fresh","parent":"/repo/haus","agent":"claude","state":"live","occupied":true,"dirty":true,"landed":{"verdict":"no","via":null,"confidence":"certain"},"post_merge_ahead":{"commits":0,"pr":0},"last_commit":"c2"}}'
59
+ # Stay alive until killed, same as the real `watch`.
60
+ trap 'exit 0' TERM INT
61
+ while true; do sleep 0.05; done
62
+ ;;
63
+
64
+ child)
65
+ echo "/repo/.scruff/other/new-lane"
66
+ ;;
67
+
68
+ park)
69
+ exit 0
70
+ ;;
71
+
72
+ heartbeat)
73
+ if [[ " $* " == *" --release "* ]]; then
74
+ echo "released $2" >&2
75
+ fi
76
+ exit 0
77
+ ;;
78
+
79
+ reap-refused)
80
+ echo "refused: occupied" >&2
81
+ exit 2
82
+ ;;
83
+
84
+ resume)
85
+ # Mirrors the real resume's non-TTY behaviour: print the reopen command
86
+ # instead of exec'ing anything, since stdout is a pipe under test.
87
+ echo "checkout ready. Reopen the claude chat with:"
88
+ echo " cd /repo/.scruff/haus/sparkle && claude --resume"
89
+ ;;
90
+
91
+ *)
92
+ echo "fake-scruff: unhandled args: $*" >&2
93
+ exit 1
94
+ ;;
95
+ esac
@@ -0,0 +1,95 @@
1
+ import os
2
+ from contextlib import aclosing
3
+
4
+ import pytest
5
+
6
+ from scruff import ScruffClient, ScruffClientOptions, ScruffError
7
+ from scruff.exec import RunOptions, run
8
+ from scruff.watch import watch_lane
9
+
10
+ FAKE_SCRUFF = os.path.join(os.path.dirname(__file__), "fake-scruff.sh")
11
+
12
+
13
+ def client() -> ScruffClient:
14
+ return ScruffClient(ScruffClientOptions(bin=FAKE_SCRUFF))
15
+
16
+
17
+ async def test_list_parses_the_json_envelope_with_nullable_discipline_intact() -> None:
18
+ envelope = await client().list()
19
+ assert envelope.schema == 2
20
+ assert len(envelope.lanes) == 2
21
+
22
+ sparkle = envelope.lanes[0]
23
+ assert sparkle.occupied is True # True, not falsy-coerced
24
+ assert sparkle.dirty is False # False, distinct from None
25
+
26
+ frost = envelope.lanes[1]
27
+ assert frost.occupied is None # None means "not determined"
28
+ assert frost.dirty is None
29
+ assert frost.landed.verdict == "contained"
30
+
31
+
32
+ async def test_watch_yields_hello_sync_ready_then_live_changes_and_stops_on_break() -> None:
33
+ kinds = []
34
+ async with aclosing(client().watch()) as stream:
35
+ async for line in stream:
36
+ kinds.append(line.kind)
37
+ if line.kind == "created":
38
+ break
39
+ assert kinds == ["hello", "sync", "ready", "created"]
40
+
41
+
42
+ async def test_watch_lane_filters_to_one_lanes_events_only() -> None:
43
+ seen = []
44
+ async with aclosing(watch_lane("/repo/.scruff/haus/fresh", RunOptions(bin=FAKE_SCRUFF))) as stream:
45
+ async for ev in stream:
46
+ seen.append(ev.kind)
47
+ break
48
+ assert seen == ["created"]
49
+
50
+
51
+ async def test_client_watch_lane_filters_the_same_way_on_its_own_options() -> None:
52
+ seen = []
53
+ async with aclosing(client().watch_lane("/repo/.scruff/haus/fresh")) as stream:
54
+ async for ev in stream:
55
+ seen.append(ev.kind)
56
+ break
57
+ assert seen == ["created"]
58
+
59
+
60
+ # `sync` names a lane, so it is data, not framing — it's the only way a caller
61
+ # that attached AFTER the lane went live learns the lane exists. Pinned because
62
+ # three docstrings used to claim the opposite.
63
+ async def test_watch_lane_passes_a_lanes_sync_through() -> None:
64
+ seen = []
65
+ async with aclosing(client().watch_lane("/repo/.scruff/haus/sparkle")) as stream:
66
+ async for ev in stream:
67
+ seen.append(ev.kind)
68
+ break
69
+ assert seen == ["sync"]
70
+
71
+
72
+ async def test_child_returns_only_the_new_checkout_path() -> None:
73
+ directory = await client().child("/repo/other")
74
+ assert directory == "/repo/.scruff/other/new-lane"
75
+
76
+
77
+ async def test_resume_captured_stdout_never_execs() -> None:
78
+ out = await client().resume("sparkle")
79
+ assert "claude --resume" in out
80
+
81
+
82
+ async def test_error_mapping_nonzero_exit_raises_scruff_error_carrying_the_real_exit_code() -> None:
83
+ with pytest.raises(ScruffError) as exc_info:
84
+ await run(["reap-refused"], RunOptions(bin=FAKE_SCRUFF))
85
+ err = exc_info.value
86
+ assert err.code == 2
87
+ assert err.refused is True
88
+ assert "occupied" in err.stderr
89
+
90
+
91
+ async def test_lease_release_calls_heartbeat_release() -> None:
92
+ c = client()
93
+ lease = await c.lease("/repo/.scruff/haus/sparkle", pid=12345)
94
+ await lease.release()
95
+ # No raise: fake-scruff's heartbeat branch accepts --release silently.