feat(sandbox): add JSON output, stderr errors, exec stdin - #70
Open
pratikbin wants to merge 2 commits into
Open
Conversation
Make the sandbox CLI usable from CI pipelines and AI agents without scraping human-readable text. Every sandbox subcommand that creates, changes, or deletes something now reports its result as JSON via a shared renderResult helper. Each object carries an "action" naming what happened plus the ids needed to chain the next call, using field names shared with the read commands. Errors now go to stderr instead of stdout, so pipes and 2>/dev/null behave. In JSON mode they become a machine-readable envelope on stdout, wiring up output.RenderError, which was written but never called. A new APIError.Code() maps HTTP status to a stable slug. Global flags (--output, --debug, --api-url, --api-key, --sandbox-api-url, --sandbox-gateway) now work after the subcommand. urfave/cli v2 stops parsing app-level flags at the first subcommand token, so argv is rewritten before app.Run rather than mirroring hidden flags onto each command, which would parse them but never read their values. Tokens after a bare -- are left alone. sandbox exec forwards piped stdin to the command and gains --stdin FILE. The API already carried a Stdin field that the CLI never populated. exec gates JSON on an explicit --output json rather than the non-TTY auto-detect, because its stdout is the command's own output. ANSI styling is disabled when stdout is not a terminal or NO_COLOR is set, and in JSON mode all pterm output is redirected to stderr so stdout holds exactly one document. Interactive streams (shell, sync, editor, exec --stream, template logs) stay human-readable. tunnel and vpn up emit their result before blocking. Options weighed and rejected are recorded in docs/decisions.md.
# Conflicts: # cmd/sandbox/disk.go # cmd/sandbox/network.go # cmd/sandbox/rm.go # cmd/sandbox/template.go # main.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The sandbox CLI could not be driven from a script or an AI agent without scraping human-readable text. Three things stood in the way, all verified against the code before this change:
main.goprinted them withpterm.Error, whose default writer is stdout.createos … > out.jsoncaptured the error text as data, and2>/dev/nullhid nothing.output.Renderwas called only from read commands.creategave you no way to learn the id it just made.output.RenderErrorwas written and had zero call sites repo-wide.createos sandbox create --output jsonfailed withflag provided but not defined: -output.Plus two smaller gaps:
api.SandboxExecReq.Stdinexisted but the CLI never filled it, so you could not pipe anything intoexec; and nothing anywhere honouredNO_COLOR, so ANSI escapes reached CI logs.What changed
JSON on every sandbox subcommand
A shared
renderResulthelper (cmd/sandbox/jsonout.go) wraps each mutation. The human renderer goes in a closure and runs only in table mode.Covered:
create,fork,edit(ingress / ssh-keys / auto-pause),pause,resume,rm,push,pull,firewall set|clear,disk create|rm|attach|detach,network create|rm|attach|detach,devices register|unregister|remove,template submit|rm,tunnel,vpn up.Every object has an
actionfield naming what happened, and field names are shared with the read commands so a caller can diff whatcreatereturned against whatgetreturns later.Batch commands (
rm,disk rm,network rm,template rm) return one entry per reference, because a single exit code cannot express a partial batch:{ "action": "deleted", "results": [ { "ref": "my-box", "id": "sb-01k...", "deleted": true }, { "ref": "typo-box", "deleted": false, "error": "no sandbox named typo-box" } ], "deleted": 1, "failed": 1 }Errors on stderr, with a JSON envelope
codecomes from a newapi.APIError.Code():bad_request,unauthorized,forbidden,not_found,conflict,rate_limited,server_error,api_error,error. Exit status is 1 for any failure.Global flags work anywhere on the line
internal/cliargs.Hoistrewrites argv beforeapp.Run, moving known global flags (and their values) in front of the subcommand.createos --output json sandbox create createos sandbox create --output json # now identicalTokens after a bare
--are never touched, sosandbox exec box -- ./ci.sh --debugstill passes--debugto your script. 11 table-driven tests cover inline values, aliases, passthrough, and the trailing-value-less case.exec stdin
On a TTY with no
--stdin, nothing is read, so the command does not block on the keyboard.Colour and stream hygiene
NO_COLORis set — onepterm.DisableStyling()in the rootBeforehook.pterm.SetDefaultOutput(os.Stderr)sends every pterm print to stderr, so stdout holds exactly one document even for commands not converted here.Three judgment calls worth reviewing
1.
execgates JSON on explicit--output json, not the non-TTY auto-detect.output.DetectFormatauto-selects JSON when stdout is not a TTY, which is right for most commands. Forexecit is actively wrong: stdout is the payload, socreateos sandbox exec box -- cat data.csv > outwould have written a JSON envelope instead of the file. New helperoutput.IsJSONExplicitmarks the distinction for any future command whose stdout is a payload rather than a report.2. Argv rewriting instead of mirroring hidden flags onto each command. Mirroring makes the flags parse but never read — the
App.Beforehook that consumes them runs before subcommand parsing, sosandbox create --api-key Xwould have silently ignored the key. Silently-wrong is worse than the error it replaces. Verified no subcommand declares any hoisted name or uses-o/-das an alias.3. Five commands stay text-only —
shell,sync,editor,exec --stream,template logs. They are live interactive streams with no single result to report; framing them would break live piping.tunnelandvpn upblock until Ctrl-C, so they emit their JSON when the connection comes up rather than on exit — a caller needs the bound address while the tunnel is alive.Verification
go build ./...go vet ./...go test ./...internal/cliargs)golangci-lint run ./...(v2.12.2)gosec ./...editor.go/vpn.go/devices.go/main.go; none in new codepre-commitBehaviour confirmed against the built binary:
The
--output jsonafter the subcommand is itself the proof that hoisting works — before this change that line failed to parse.Downstream check
Per the mesh protocol in
CLAUDE.md, both consumers that shell out to this CLI were checked:createos-plugin(scripts/cos) — passes-o jsonbefore the subcommand (unaffected), parses stdout JSON from read commands (unchanged), and merges both streams with>log 2>&1on create (errors still captured). No breakage. Its--name-tagging workaround andstrip_ansiare now redundant, sincecreatereturns the id directly andNO_COLORis honoured natively — worth a follow-up in that repo, not touched here.createos-sandbox-ghar(bump-runner.yml) — runstemplate rm/template submitand ignores their stdout. No breakage.Docs
README.md— JSON output for mutations, error envelope, flag placement,NO_COLOR,execstdin.CLAUDE.md— conventions for the next contributor: when to userenderResult,IsJSONvsIsJSONExplicit, the stream contract, and the hoist-list maintenance rule.docs/decisions.md— new. Each decision with the options weighed and why the alternatives lost.Not in this PR: the matching
../website-04edits (Commands.md+ the two regeneratedlib/docs/*.ts) are written but uncommitted, to be raised as a separate PR per the precedent set by #59.Deliberately not done
--waitoncreate/getget. Next tier.--quieton mutationsapi.Shapecarries no pricing — that is anfcchange, not a CLI change.