Skip to content

feat(sandbox): add JSON output, stderr errors, exec stdin - #70

Open
pratikbin wants to merge 2 commits into
mainfrom
feat/sandbox-json-output
Open

feat(sandbox): add JSON output, stderr errors, exec stdin#70
pratikbin wants to merge 2 commits into
mainfrom
feat/sandbox-json-output

Conversation

@pratikbin

Copy link
Copy Markdown
Contributor

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:

  1. Errors went to stdout. main.go printed them with pterm.Error, whose default writer is stdout. createos … > out.json captured the error text as data, and 2>/dev/null hid nothing.
  2. No mutation emitted JSON. output.Render was called only from read commands. create gave you no way to learn the id it just made. output.RenderError was written and had zero call sites repo-wide.
  3. Global flags only parsed before the subcommand. createos sandbox create --output json failed with flag provided but not defined: -output.

Plus two smaller gaps: api.SandboxExecReq.Stdin existed but the CLI never filled it, so you could not pipe anything into exec; and nothing anywhere honoured NO_COLOR, so ANSI escapes reached CI logs.

What changed

JSON on every sandbox subcommand

A shared renderResult helper (cmd/sandbox/jsonout.go) wraps each mutation. The human renderer goes in a closure and runs only in table mode.

$ createos sandbox create --output json
{
  "action": "created",
  "id": "sb-01k...",
  "name": "quiet-lake-4821",
  "shape": "s-2vcpu-4gb",
  "rootfs": "devbox",
  "ip": "10.0.4.19",
  "ingress_url": "https://<port>-sb-01k....sb.createos.sh",
  "shell_command": "createos sandbox shell sb-01k..."
}

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 action field naming what happened, and field names are shared with the read commands so a caller can diff what create returned against what get returns 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

$ createos sandbox get nope --output json    # stdout
{
  "error": { "code": "not_found", "message": "no sandbox named nope" }
}

$ createos sandbox get nope                  # stderr, stdout empty
ERROR: no sandbox named nope

code comes from a new api.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.Hoist rewrites argv before app.Run, moving known global flags (and their values) in front of the subcommand.

createos --output json sandbox create
createos sandbox create --output json   # now identical

Tokens after a bare -- are never touched, so sandbox exec box -- ./ci.sh --debug still passes --debug to your script. 11 table-driven tests cover inline values, aliases, passthrough, and the trailing-value-less case.

exec stdin

echo "hello" | createos sandbox exec my-box -- cat
cat script.sh | createos sandbox exec my-box -- bash
createos sandbox exec my-box --stdin ./setup.sh -- bash

On a TTY with no --stdin, nothing is read, so the command does not block on the keyboard.

Colour and stream hygiene

  • ANSI styling off when stdout is not a terminal, or when NO_COLOR is set — one pterm.DisableStyling() in the root Before hook.
  • In JSON mode, 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. exec gates JSON on explicit --output json, not the non-TTY auto-detect. output.DetectFormat auto-selects JSON when stdout is not a TTY, which is right for most commands. For exec it is actively wrong: stdout is the payload, so createos sandbox exec box -- cat data.csv > out would have written a JSON envelope instead of the file. New helper output.IsJSONExplicit marks 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.Before hook that consumes them runs before subcommand parsing, so sandbox create --api-key X would have silently ignored the key. Silently-wrong is worse than the error it replaces. Verified no subcommand declares any hoisted name or uses -o / -d as an alias.

⚠️ Maintenance note: adding or renaming a global flag in root.go now also means adding it to globalStringFlags / globalBoolFlags in internal/cliargs/hoist.go. A missing entry silently reintroduces the parse error. This is called out in CLAUDE.md.

3. Five commands stay text-onlyshell, sync, editor, exec --stream, template logs. They are live interactive streams with no single result to report; framing them would break live piping. tunnel and vpn up block 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

Check Result
go build ./... pass
go vet ./... pass
go test ./... pass (11 new tests in internal/cliargs)
golangci-lint run ./... (v2.12.2) 0 issues
gosec ./... 14 issues, all pre-existing in editor.go / vpn.go / devices.go / main.go; none in new code
pre-commit detect-secrets + go checks pass

Behaviour confirmed against the built binary:

$ CREATEOS_API_KEY=bogus createos sandbox list --output json   # stdout, exit 1
{ "error": { "code": "unauthorized", "message": "invalid api key" } }

$ CREATEOS_API_KEY=bogus createos sandbox list --output table  # stdout empty
ERROR: invalid api key                                          # stderr, no ANSI

The --output json after 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 json before the subcommand (unaffected), parses stdout JSON from read commands (unchanged), and merges both streams with >log 2>&1 on create (errors still captured). No breakage. Its --name-tagging workaround and strip_ansi are now redundant, since create returns the id directly and NO_COLOR is honoured natively — worth a follow-up in that repo, not touched here.
  • createos-sandbox-ghar (bump-runner.yml) — runs template rm / template submit and ignores their stdout. No breakage.

Docs

  • README.md — JSON output for mutations, error envelope, flag placement, NO_COLOR, exec stdin.
  • CLAUDE.md — conventions for the next contributor: when to use renderResult, IsJSON vs IsJSONExplicit, 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-04 edits (Commands.md + the two regenerated lib/docs/*.ts) are written but uncommitted, to be raised as a separate PR per the precedent set by #59.

Deliberately not done

Item Why
--wait on create / get Real CI value, but callers can poll get. Next tier.
Cancel exits 130 Correct convention; wants the ~52 scattered cancel strings unified in one coherent pass.
--quiet on mutations Superseded for machine callers by JSON. Still nice for shell users.
Shape price hints in the picker api.Shape carries no pricing — that is an fc change, not a CLI change.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants