Skip to content

Latest commit

 

History

History
91 lines (80 loc) · 26.7 KB

File metadata and controls

91 lines (80 loc) · 26.7 KB

devctl CLI contract

The JSON surface agents depend on. Every schema here is generated from the Codable types in Sources/DevCtlKit and locked by golden-file tests; changing a field is an API change and updates this document in the same commit. All timestamps are ISO-8601 UTC with milliseconds. Timeouts are seconds.

Envelope

Success: the command's result object on stdout. Failure with --json: {"ok": false, "error": {"code", "message", "hint"}} on stdout; hint is the literal remediation command when one exists.

Stable error.code values: already-exists, config-invalid, daemon-starting, daemon-unreachable, internal-error, not-found, not-trusted, port-drift, port-held, resource-locked, resource-mutated, spawn-failed, usage, version-mismatch. (Grows append-only.)

Exit codes: 0 ok · 1 operation failed (crash, timeout, conflict) · 2 usage · 3 daemon unreachable · 4 named server not found. Unnamed status in an unconfigured project exits 0 with {"servers": []}.

ServerStatus (the core schema)

{
  "declaredPort": 3000,          // committed / ad-hoc declaration
  "effectivePort": 3742,         // what this run binds (override, overlay, sibling rebind, or declared)
  "errorSummary": { "count": 3, "firstAt": "", "lastAt": "" },  // stderr tally for the last run; present once it turns crashed/failed/unhealthy
  "heads": { "admin": "http://admin.myproj.localhost:3742/" },    // multi-headed server: display name → URL (follows effective host/port)
  "healthcheck": "http",        // "http" | "tcp" | "none"
  "lastExit": { "at": "", "code": 1, "signal": null },   // crashed only
  "lastHealthAt": "",
  "locks": ["d1"],               // resources this server holds while running; `devctl lock <resource> -- …` gets exclusive access without stopping it
  "logPath": "~/Library/Logs/devctl/myproj-a1b2c3d4/web/current.log",
  "observedPort": 3742,          // from post-healthy listen scan; must match effectivePort or the server fails with port-drift
  "phase": "running",            // stopped|starting|running|unhealthy|stopping|crashed|failed
  "pid": 4242,
  "portConflict": {              // present when a conflict was handled or is latent / fatal
    "declaredPort": 3000,
    "effectivePort": 3742,
    "holder": "web@/Users/me/code/myproj",
    "message": "port 3000 held by sibling; rebound to 3742",
    "state": "rebound"           // "rebound" | "held" | "drift" | "foreign" | "shared"
  },
  "project": "/Users/me/code/myproj",
  "recentLogTail": [""],       // crashed/failed only
  "server": "web",
  "spawnError": { "errno": 2, "message": "" },            // failed only
  "specStale": false,
  "uptimeSec": 123,
  "url": "http://worktree-review.myproj.localhost:3742/"
}

Commands

Filled in per phase as each lands; golden tests reference the examples in this file.

  • devctl status [name] [--all] --json{servers: [ServerStatus], trusted?}. Named lookup that matches nothing exits 4 (not-found); unnamed always exits 0. --all is machine-wide (every registered project) and auto-prunes checkouts whose path is gone; trusted is present only on a scoped project query.

  • devctl register --name N --cmd word --cmd word … [--port P] [--cwd D] [--write [--force]] --json{server: ServerStatus}. --cmd repeats per argv word and accepts dash-prefixed values. The spec is validated the same way a committed one is (a name carrying ::, an empty command, an out-of-range port or healthcheck value fail config-invalid), so a spec config check would reject can no longer be registered. --write also appends the server to devservers.json, merging so every other entry survives; an existing entry of the same name needs --force and otherwise fails already-exists.

  • devctl start|stop <name> --json{server: ServerStatus}. start returns at spawn with phase: "starting"; health promotion to running follows (use wait/ensure to block). stop on an already-stopped server exits 0.

  • devctl ensure <name> [--port P] [--timeout 60] --json{reason?, server}. Optional --port overrides the committed port for this run (flows through the same effectivePort pipeline as sibling rebind and devctl.local.json). State matrix: healthy → no-op; starting → joins the in-flight attempt (single-flight, two concurrent ensures cannot double-spawn); unhealthy → no-op reporting the phase; stopped/crashed/failed → start and block until healthy. Fails fast the moment the phase turns crashed/failed. reason: "crashed"|"failed"|"stopped"|"timeout"; reason present ⇒ exit 1, with lastExit/spawnError/recentLogTail forensics in server. Sibling port conflict auto-rebinds and still returns ok with server.portConflict.state: "rebound".

  • devctl start <name> [--port P] --json / devctl up [--only a,b] [--port P] [--timeout 60] --json: same optional --port override as ensure (applied to each started server under up).

  • devctl wait <name> [--healthy|--stopped] [--timeout 60] --json{reason?, server}. --healthy (default) rides through non-terminal transitions (another session's restart) and fails fast on crashed/failed/stopped; --stopped resolves on stopped/crashed/failed.

  • Port pre-check on every start-shaped path (start, ensure, up, switch, a lock resume, boot recovery): resolves effectivePort first. Held by a managed sibling (same git common-dir) → auto-rebind to a free port and continue (status carries portConflict.state: "rebound"). Held by an unrelated managed server → port-held naming that server and project. Held by an unmanaged listener → port-held with pid + command when lsof can name it. After first-healthy, if observedPort != effectivePortport-drift (Vite silent bump is not healthy). The managed-holder scan sees both live supervisors and holders recorded in state whose pid is still alive. The listener probe tries both loopback families. Runs only when the target is not already up. Under up/switch an unhandled held port fails the whole rollout with port-held.

  • Ownership check after first-healthy: a passing healthcheck proves something answered, not that this server answered. Every pid listening on effectivePort is compared against the server's process tree. Listeners both inside and outside → portConflict.state: "shared", phase untouched, since this server is serving and only the port is ambiguous (a v4-only listener beside a v6 wildcard bind, for instance). All listeners outside the tree → portConflict.state: "foreign", and the phase changes to failed only when one of those pids is another server this daemon supervises, which is what makes theft provable. An unattributable outside listener is annotated and left running: a container-backed or daemonizing server has its socket held by a process devctl never parented, and that is indistinguishable from theft by lsof alone. No listeners reported → nothing is recorded, because lsof can be unavailable and an empty result is not evidence.

  • Latent port conflicts are annotated on every status read, including the machine-wide sweep and why, so a stopped server names the holder keeping it down instead of reporting only "not running".

  • devctl.local.json (project root, gitignored by convention): partial per-server overlay merged after committed devservers.json (port, portEnv, portSpan, ports, command, env, url, host, heads). Never committed. portSpan claims a consecutive block from the effective primary without naming each secondary; ports names secondaries as { "offset": N, "env"? } (moves with sibling rebind) or { "port": N, "env"? } (absolute, machine-singleton). Overlapping portSpan and named offsets is a config error.

  • errorSummary is devctl's own count of the last run's stderr lines with the first and last timestamps, captured when a server turns crashed, failed, or unhealthy. It is arithmetic over the log, never the lines themselves, so the session-context hook can surface that errors piled up without emitting attacker-influenceable child output; the agent reads the actual lines with devctl why.

  • terminalEvidence / recentLogTail on status: short out/err/sys lines captured at terminal transitions (and persisted across ensure truncate / daemon rehydrate) so why still sees a refusal after a retry.

  • Healthchecks: explicit healthcheck block wins; else a declared port implies a TCP probe; else healthy = alive past a 2s stabilization window. ServerStatus.healthcheck says which ("http"|"tcp"|"none") so agents know when running is unverified. unhealthyAfter applies only after first-healthy: a slow boot stays starting.

  • devctl logs <name> [--follow] [--tail N] [--since 5m|ISO] [--since-mark <id>] [--grep RE] [--stream out|err|sys|mark] --json → one {at, stream, text} object per line on stdout. --grep is the Swift Regex dialect. A pattern that will not compile, or one that nests an unbounded quantifier inside another (the (a+)+ catastrophic-backtracking family, which Swift's regex engine runs for minutes on a single line), fails usage rather than running. --follow polls incrementally (restart-safe). Structured lines are sanitized: ANSI/OSC escapes stripped, NULs removed, CR spinner rewrites collapsed to the final frame; timestamps are per-file monotonic.

  • devctl mark <name> <text> [--label L] --json (or --all <text>) → {marks: [{at, id, server}]}. The id feeds --since-mark on logs and events, so no clock agreement is needed. Marks flow through the same append path as process output; ordering is exact.

  • devctl events [--all] [--since …] [--since-mark <id>] [--tail N] --json{events: [{at, detail?, kind, project, server}]} with kinds started|stopped|crashed|failed|healthy|unhealthy|marked|registered|unregistered. Scoped to the current project unless --all. This is the "what happened while I was compacted" answer.

  • devctl why <name> --json{findings: [{server, phase, summary, evidence[]}], rootCause?}. Diagnoses over the merged project view (committed devservers.json plus ad-hoc registry), the same set as status / ensure. Walks dependsOn to the deepest broken dependency; evidence prefers recentLogTail / persisted terminalEvidence, else a structured-log window (out+err+sys). Exit 0 after a short life notes that controlled refusals are common. Agent session context never embeds raw child lines; it points here.

  • devctl up [--only a,b] [--timeout 60] --json{results: [{reason?, server}]}. Wave-parallel in dependency order (Kahn waves); --only pulls in transitive dependencies; a wave with a failure stops the rollout (later waves depend on it); any reason ⇒ exit 1. waitFor: "started" on a server launches it without blocking on health; the default blocks until healthy. devctl down --json stops in reverse waves, always exit 0.

  • devctl trust --json → approves the project's committed config. Trust is also recorded implicitly by an explicit start/ensure/up on a file-sourced server (the invocation is the approval); the SessionStart hook advertises only trusted projects. status --json carries trusted when project-scoped.

  • devctl open <name> [head] → opens the server's URL in the default browser, or a named head of a multi-headed server. URLs derive as http://<host>:<port>/ from the project host (default <slug>.localhost), overridable per server (host or url keys). A heads map (display name → URL) models one server fronting several surfaces (a Host-routing dev proxy); heads appear in ServerStatus.heads, the agent context block, and the app's row/detail menus. A head is written either as an absolute URL or as a root-relative path (/admin), which resolves against the server's own effective base so it follows a rebind or a worktree host swap; the same two shapes apply to healthcheck.url. Anything else, and a root-relative value on a server that declares neither port nor url, is a config check error.

  • devctl link <verb> <name> [head] [--json] → prints a devctl:// URL for the cwd project (devctl://ensure/<slug>/<name>, etc.). Verbs: open, ensure, stop, why. --json{url}. For Raycast/Shortcuts/docs; the menu bar app handles the same URLs via Launch Services.

  • devctl x-url <url> [--json] (hidden): dispatches a devctl:// URL through the same DeepLinkRunner the app uses (no Launch Services). Smoke/CI entry. Success → DeepLinkRunResult {verb, projectPath, detail?}; bad URL / unknown slug → usage/not-found.

  • devctl config check --json{errors, host, servers, warnings} from the daemon's own validator (cycles, unknown dependencies, and unresolvable heads / healthcheck.url values are errors; duplicate declared ports, unknown versions, an http healthcheck with no url, and bare-loopback hosts and heads are warnings); errors ⇒ exit 1. Name servers after the project (not a generic web) and give each a <project>.localhost host, not bare localhost: the subdomain keeps browser cookies/storage/service workers isolated per project.

  • devctl restart <name> | --all [--port P] [--timeout 60] --json{results: [{reason?, server}]}. Stops and re-ensures as one daemon-side transition, so no other session's ensure lands in between and a refusal (a held resource, a paused server, a config that no longer parses) arrives before anything stops rather than after. The stop is non-retiring, so resume-on-boot survives it. A bare restart with neither a name nor --all is a usage error; any reason ⇒ exit 1.

  • A server may declare watch: project-relative files it reads at boot and does not reload itself ("watch": ["vite.config.ts"]). A change to one restarts that server. A server whose framework already reloads its own config declares nothing and behaves exactly as before. Paths are literal, relative to the project root, and may not exist yet (their appearance is a change); a glob, an absolute path, a path outside the project, or a directory is a config check warning and is ignored. The baseline is taken after the server has been up for a settle window, so a config the server writes during its own boot does not bounce it, and a change must hold still for a quiet window, so one save touching several files is one restart. A restart is refused while a resource the server declares is held, and the pending change fires when that hold releases rather than being dropped. Repeated restarts inside a short window suspend the watch with a sys log line naming the paths, since devctl cannot tell a server rewriting its own file from a person saving repeatedly; an explicit devctl restart re-arms it. DEVCTL_NO_WATCH=1 disables the sweep for the whole daemon.

  • devctl config init [--dry-run] [--force] [--host H] [--name N --cmd word … [--port P]] --json{check: CheckResult, content, notRecovered?, path, written}. Writes devservers.json from the servers the daemon already knows, which is the way back from losing a gitignored file. Refuses an existing file with already-exists unless --force; --dry-run returns the content and writes nothing. The projection writes only what the file declares: an effective or rebound port, a worktree-derived host, a materialized url, an absolute icon path, and the port and host keys devctl injects into the environment are all dropped, so the file stays portable to another checkout on another machine. lifecycle has no runtime counterpart and cannot be recovered; it is named in notRecovered rather than silently lost. The written file is indented, unlike every wire frame.

  • devctl config check --json also reports effectiveHost and effectiveHostReason (linked-worktree, local-overlay, server-override) when a start from this directory would use a host other than the declared one, plus serverHosts for the servers that differ from the project. A linked worktree gets an ephemeral worktree-<label>.<preferred>.localhost, so any origin the app itself pins (an auth callback, a CORS allow list, an API key referrer) is wrong before anything starts. Not a warning: an ephemeral host is correct behavior, so a worktree checkout still reports config ok.

  • devctl doctor [--fix] --json{findings: [{detail, kind, severity}]}: daemon/launchd state, captured-PATH staleness, the host:port signature table with conflicts, cross-project port collisions (port-collision: two unrelated projects declaring one port, which no host:port signature can catch because the hostnames differ while the bind does not; sibling worktrees are excluded since they rebind by design), unmanaged listeners on managed ports, and registry entries whose project path no longer exists (the daemon auto-prunes those on boot and machine-wide status; --fix remains an idempotent force path for leftovers). Also reports harness-hook findings (a detected harness with no devctl hook, or a hook pointing at a path that no longer exists); an install-shadow finding when a second devctl copy shadows the Homebrew install (a make install CLI in ~/.local/bin ahead of brew's bin on PATH, or a foreign /Applications/devctl.app Homebrew could not overwrite, either of which lets a bare devctl or the daemon keep running an old version after an upgrade); and, when a newer release is available, an update finding read from the shared update-check cache (refreshed only when stale). All are report-only: doctor names the exact command to run and never edits a file the user owns.

  • devctl switch <branch> [--no-fetch] [--timeout 120] → clean-tree guard (refuses dirty; never stashes), fetch, group down, git switch (remote-tracking fallback), then the project's lifecycle.switch playbook (argv arrays run sequentially from the project root; failures stop with devctl up as the resume hint), then group up. The branch's devservers.json is validated after the checkout and before the playbook runs, so a config config check would reject (including an empty lifecycle command, which the validator now covers) fails config-invalid instead of feeding argv to the shell. Playbooks live in devservers.json lifecycle and are agent-configurable.

  • Config extras: project-level icon (project-relative path, per-server override) feeds Spotlight thumbnails; every server and head is indexed in Spotlight as <project> · <head> with subtitle devctl · <url> (best-effort; not a Top Hit launcher); heads and pins surface in the menu bar app.

  • devctl lock <resource> [--no-pause] [--acquire-timeout 300] [--timeout 120] -- <command…> → runs the command holding a project resource exclusively. By default the daemon pauses servers that declare the resource in their locks (devservers.json) and re-ensures them on release (even on command failure). --no-pause takes the mutex without stopping declarers (for harnesses that reuse the live server). ensure/start of a declaring server is refused (resource-locked, naming the holder pid) while a live holder owns it, regardless of --no-pause. Locks are path-scoped (canonicalPath::resource); they do not pause other checkouts. Locks persist across a daemon crash: a dead holder auto-releases and resumes the paused set; a still-live holder keeps them paused so the harness stays exclusive. Exit status is the command's. The -- is required and devctl's own options go before it; everything after -- is captured verbatim, so a nested --, a dash option, and an empty string all reach the command untouched. A missing terminator or an unknown option is rejected by the parser at exit 64 rather than being passed through. A contended acquire writes the holder's pid, how long it has been running, and what it paused or left running to stderr, then repeats a still-waiting line every 15s, so a wait is never silent; --acquire-timeout 0 makes exactly one attempt and fails immediately with resource-locked. All of lock's own output is stderr: stdout belongs to the guarded command.

    A locks entry is written either as a bare name ("d1") or as an object naming where the resource's state lives ({"name": "d1", "path": ".wrangler/state/v3/d1"}); both forms parse and the bare form re-encodes bare. With a path declared, lock fingerprints that state before and after the command (device and inode, size, and a SHA-256 over a bounded manifest) and reports a change: under the default paused mode it is a note on stderr, and under --no-pause with a declaring server still running it is a resource-mutated failure, because that server holds the old state open and can write its cached pages back over the change. Two servers declaring one resource with different paths is a config error rather than a guess. What the check cannot catch: it flags the risk window, not the damage, since the flush that corrupts can land after the command exits; it cannot see state outside the declared path (a sibling -wal file when path names only the .sqlite), divergence that never reaches disk, or a change that reverts to byte-identical state inside the window; a directory stops hashing file contents past a byte budget and reports exact: false when it did, so a change confined to a file past that budget is missed; and it never names which process wrote. A resource that is a single file is hashed whole at any size, so a middle-only rewrite that preserves size and mtime is caught.

  • devctl context: the harness-agnostic session context: a fenced <devctl-servers> plain-text block (server phases, effective URLs, log paths, latent/rebound port-conflict warnings, the ensure/wait/why/logs/lock cheat-sheet) for the cwd's project. Linked worktrees get a banner naming the preferred host. Silent (exit 0) when the project is unregistered or untrusted or the daemon is down; never bootstraps; never contains raw log lines or command strings.

  • devctl daemon status --json{daemon?, launchd, reachable}. reachable is whether the daemon answered over the socket, and it is the field to branch on: launchd reporting running only means a job is loaded, so a loaded-but-not-listening daemon prints a reassuring launchd line with reachable: false. daemon is present only when reachable. Exit stays 0 either way, because the launchd half is still a useful answer. daemon.restoring is present and true only while boot restore is running, and absent otherwise; a client waiting for a usable daemon wants reachable and no restoring, since the daemon accepts before restore finishes and declines work until it does.

  • Boot restore is a window, not an outage. From the moment the socket accepts, daemon.info and daemon.shutdown answer and every other method is refused with daemon-starting and the hint run: devctl daemon status, rather than the daemon being unreachable while it works. The distinction is what a client must branch on: daemon-unreachable means nothing answered and starting a daemon is the fix, daemon-starting means one is already coming up and starting another is wrong. The CLI waits out daemon-starting for up to 30 seconds, saying on stderr what it is waiting for, then fails with exit 3 if the daemon never finishes. The session-start hook does not wait: it talks to the socket directly and stays silent, because a session must not block on a daemon.

  • devctl uninstall [--agent-only] [--purge] --json{actions: [string], agentOnly, purged}. The one uninstall verb. Stops nothing that is running: it shuts the daemon down and its children survive it. Removes the background agent (and any legacy home LaunchAgent), then agent hooks from every harness, then the CLI and daemon binaries devctl itself installed at ~/.local/bin (a Homebrew install keeps its CLI in brew's bin, so those paths do not exist there and this is a no-op; the cask removes brew's symlink). Data and logs are kept unless --purge. --agent-only stops after the agent and never touches hooks, binaries, or data: it is what the Homebrew cask calls on every upgrade, so it must stay narrow. purged in the result is true only when data was actually removed (never under --agent-only).

  • devctl daemon uninstall [--purge] --json: deprecated alias for devctl uninstall --agent-only (with --purge mapping to data removal). Kept working because the CLI JSON contract is a public surface; the deprecation notice goes to stderr so --json stdout stays clean. Removal is a future major.

  • devctl daemon install|start|stop|restart|status: launchd lifecycle. stop drains and writes a deliberate-stop marker that auto-bootstrap honors; restart and install (upgrade) both capture running servers, bounce the daemon, and re-ensure them by name ("servers bounce, then come back"). The new daemon accepts before recoverAtStartup and refuses real work with daemon-starting until it returns, so that re-ensure waits the window out rather than racing a half-finished restore. install also stages-and-renames the daemon binary and captures the login-shell PATH into the agent plist. Reboot recovery: the LaunchAgent runs at load; starting a server records resume-on-boot; a machine shutdown drains without clearing it; recoverAtStartup resolves specs through the merged config+registry view (so committed devservers.json servers come back, not only ad-hoc register entries) and restores those servers one at a time so sibling port claims observe each other. Restore is autonomous, so it honors the trust gate: a committed server whose project was never approved (no explicit start/ensure/up recorded trust) is not spawned, which keeps a cloned repo's devservers.json from running itself after a reboot. A deliberate devctl stop/down clears the intent. Renamed or deleted servers leave orphan state rows that recover drops.

  • devctl hook install [--harness claude|cursor] [--devctl-path PATH] [--statusline]: idempotently wires a session-start hook into the harness's settings (claude: SessionStart with matcher startup|resume|clear|compact in ~/.claude/settings.json, emitting hookSpecificOutput.additionalContext; cursor: sessionStart in ~/.cursor/hooks.json, emitting {additional_context}). --devctl-path (absolute) overrides the path recorded in the hook command, which the menu bar app passes as the CLI owner's path so a Homebrew install records the stable shim in brew's bin rather than the internal bundle path this binary resolves to. After a successful install it also prints a one-bullet discovery tip for the project's CLAUDE.md/AGENTS.md (wired to the nearest devservers.json's first server when one exists); the tip is printed for a human to paste and is never auto-appended to those files. Adding a harness: CONTRIBUTING.md.

  • devctl hook uninstall [--harness claude|cursor] --json: idempotently removes devctl's session hook from a harness's settings, leaving everything else the file holds untouched (an entry that held only devctl's hook is dropped whole; a foreign hook alongside it is kept). Omitting --harness removes from every detected harness. --json success is {}, matching hook install.

  • devctl statusline: reads harness statusline stdin JSON (workspace.current_dir or cwd), prints myproj:3000 ok · api crashed for the project, empty otherwise.