hausfold

scruff

SDKs

Five thin clients over one binary, in TypeScript, Python, Rust, Go and Swift. They share a wire format and a version number, on purpose.

installsource
TypeScriptbun add @hausfold/scruffsdk/ts
Pythonuv add hausfold-scruffsdk/python
Rustcargo add hausfold-scruffsdk/rust
Gogo get github.com/hausfold/scruff/sdk/gosdk/go
Swifthausfold/scruff-swiftsdk/swift

All five wrap the same CLI: list, watch as a native async stream, child/spawn, park/unpark/reap/reship, and occupancy leases. scruff itself has to be on PATH, or you pass its path in.

scruff stays a binary

There is no daemon, no port, no auth, no supervisor and no socket semantics to pin before a single consumer exists. The SDKs shell out, and the frozen contract is the --json envelope, not the registry file. Adding a scruff serve later is purely additive, because the protocol would be the same either way.

import { ScruffClient } from "@hausfold/scruff";

const scruff = new ScruffClient();

for (const lane of (await scruff.list()).lanes) {
  // occupied/dirty are `boolean | null`. null means "not determined".
  console.log(lane.name, lane.state, lane.occupied);
}

Two shapes of usage

Programmatic, for a web backend or an orchestrator. Every method except the two ending in Interactive captures the child's stdout and returns, so it is safe from a server with many concurrent sessions.

// Create a lane WITHOUT attaching an agent to it: the orchestrator primitive.
const dir = await scruff.child("/path/to/some-repo", "task-42");
// ...now launch your own agent process against `dir`.

Interactive, for a real terminal app. newInteractive and resumeInteractive inherit the calling process's stdio, so scruff execs the client and takes over the terminal; control returns when that session ends.

Don't call the Interactive pair from a server

They exec the client unconditionally, without checking for a TTY, so piped stdio blocks forever. Use resume() instead: it detects a piped stdout and prints the reopen command as text rather than exec'ing.

Live updates instead of polling

for await (const line of scruff.watch()) {
  if (line.kind === "created") notifyUI(line.lane);
}

// …or scoped to the one lane this session holds: no hello/ready framing,
// and nothing about anybody else's lanes.
for await (const event of scruff.watchLane(dir)) {
  if (event.kind === "reaped") endSession();
}

Leases: telling scruff a session is live

There is no callback for "is this lane still in use?", because there is no daemon to call back into. The client reports; scruff consumes. A lease is a file, every language can write one, and a lease naming a live pid maintains itself.

const lease = scruff.lease(laneDir);  // refreshes inside the 90s TTL
// ... serve the session ...
await lease.release();

A lease can only ever save a lane from the sweep, never condemn one. Reach for one whenever the thing holding a lane is not visible as a cwd: a server's session is a connection, and only the server can see it.

A whole machine that can answer for everyone at once wants the other shape, an occupied seam. Leases are per session, at connection speed; a seam replaces lsof outright.

Five clients, one number

They all carry the same version, and it is forced rather than chosen: npm, PyPI and crates.io are immutable registries that already hold published numbers, so the version is a compatibility contract read by people who pinned against it, not a date. Five clients agreeing about one wire format is the invariant the whole arrangement exists to protect, and five drifting version lines would hide a divergence instead of surfacing it. A breaking change in the Rust client alone bumps all five.

Go's module path has moved twice

github.com/nebelhaus/holt through v0.2.8, github.com/hausfold/holt through v0.5.0, and github.com/hausfold/scruff from v1.0.0. Go's proxy is immutable, so each old path stays resolvable at the tags published under it and at nothing after. An importer on an old path either edits its import line or pins that path's last tag.

The Swift package ships from a generated mirror, hausfold/scruff-swift, because SwiftPM requires Package.swift at a repository root. Send changes to sdk/swift in the main repo; the mirror is overwritten wholesale on the next sync.

On this page