Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 99 additions & 38 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,44 +8,43 @@ mirrors these commands. On any command / flag change, run the protocol below —
the concrete website-regen steps live in "Keeping the Website Docs in Sync" near
the end of this file. This repo is one of five in the product mesh.

### Repo map

| repo | path | role | public? | changes that ripple across the mesh |
|---|---|---|---|---|
| **fc** | `../fc` | control-plane — **source of truth** | 🔒 private | HTTP API, wire/JSON fields, error shapes, lifecycle/state, limits/quotas, behavior |
| **fc-sdk** | `../fc-sdk` | TypeScript SDK **+ `examples/`** | 🌐 public | public SDK methods, wire types, example apps |
| **createos-cli** | `../createos-cli` | Go CLI | 🌐 public | commands, flags, help/UX text |
| **website-04** | `../website-04` (`content/docs/Sandbox`) | public docs | 🌐 public | REST / SDK / CLI reference + concept pages |
| **createos-plugin** | `../createos-plugin` | Claude Code plugin over the `createos` CLI | 🌐 public | skills, slash commands, hooks |

### What counts as a shared surface

HTTP endpoint or method · wire or JSON field · error shape · sandbox
lifecycle/state · limit or quota · CLI command or flag · public SDK method ·
documented behavior. A change confined to internals — refactor, comment,
private helper, test-only — is **not** a shared surface, so skip the mesh for it.

### Protocol — run before finalizing a shared-surface change

1. **Classify origin.** `fc` is the source of truth; SDK / CLI / docs / plugin
are downstream consumers. A downstream change that implies a backend change
(new field, new endpoint) → surface it to the user; never invent server
behavior inside a client.
2. **Search every sibling** for the touched symbol / endpoint / flag —
`semble search` first, then `rg`.
3. **Build a status matrix** per sibling: `already-present` ·
`missing-needs-update` · `n/a`. **Flag the already-present ones to the user**
("already exists in fc-sdk + docs"). Never silently duplicate a change that is
already there — that is the whole point of this check.
4. **`fc` → any public repo is a leak-guard boundary (security).** Strip private
implementation, security internals, infra, threat-model notes, and
internal-only tooling (`fcctl`, host filesystem paths, mTLS/CA internals)
before anything lands in a public repo. Respect each public repo's own wording
rules (e.g. `fc-sdk/AGENTS.md` forbids the word "VM"). Report the proposed diff
and **ask for approval before landing any public edit** — never auto-write
across the boundary.
5. **Use the `sync-docs` skill** to execute SDK / CLI / website reconciliation
against upstream `fc` where it applies.
| repo | role | public? |
|---|---|---|
| **fc** | control-plane — source of truth | 🔒 private |
| **fc-sdk** | TypeScript SDK + `examples/` | 🌐 public |
| **createos-cli** | Go CLI | 🌐 public |
| **website-04** (`content/docs/Sandbox`) | public docs | 🌐 public |
| **createos-plugin** | Claude Code plugin over the `createos` CLI | 🌐 public |

**Shared surface** = HTTP endpoint or method · wire or JSON field · error shape ·
sandbox lifecycle/state · limit or quota · CLI command or flag · public SDK
method · documented behavior. Internals — refactor, private helper, test-only —
are not, so skip the mesh for them.

On a shared-surface change: search every sibling for the touched symbol
(`semble search` first, then `rg`), report a per-sibling matrix
(`already-present` / `missing-needs-update` / `n/a`) before finalizing, and never
silently duplicate what is already there. Origin matters — `fc` is upstream; a
downstream change implying new server behavior goes to the user, never invented
inside a client. **`fc` → any public repo is a leak-guard boundary:** strip
private implementation, security internals, infra, threat-model notes, and
internal-only tooling (`fcctl`, host filesystem paths, mTLS/CA internals),
respect each public repo's own wording rules (e.g. `fc-sdk/AGENTS.md` forbids
the word "VM"), and get approval before landing any public edit. The `sync-docs`
skill executes SDK / CLI / website reconciliation against upstream `fc`.

### Downstream reference implementations (not mesh-protocol members)

Two public repos shell out to this CLI and are worth checking before a
command/flag/help-text change ships, even though neither owns shared
surface and both sit outside the formal 5-repo protocol above:

- **createos-plugin** (`../createos-plugin/createos-sandbox`) — the primary
CLI wrapper; most exposed, since it parses `createos` stdout.
- **createos-sandbox-ghar** (`../createos-sandbox-ghar`) — its
`.github/workflows/bump-runner.yml` daily job shells out to this CLI to
rebuild the `ghar-runner` rootfs template; a flag/output change here can
break that job silently.

## Project Structure

Expand Down Expand Up @@ -91,6 +90,68 @@ The API has two response shapes — use the right one:
2. Match field names exactly to the JSON response — use nullable pointers (`*string`) for fields that can be `null`
3. For errors, return `ParseAPIError(resp.StatusCode(), resp.Body())` — never `fmt.Errorf("API error %d: %s", ...)`

## Machine-readable Output

Decisions and rejected alternatives: `docs/decisions.md`.

### Every mutation must emit JSON

A `sandbox` command that creates, changes, or deletes something calls
`renderResult` (`cmd/sandbox/jsonout.go`), never a bare `pterm.Success`:

```go
renderResult(c, "created", map[string]any{
"id": resp.ID,
"name": str(resp.Name),
}, func() {
pterm.Success.Printfln("Created %s", resp.ID)
})
```

The human renderer goes in the closure; it runs only in table mode. Rules:

- `action` names what happened (past tense: `created`, `paused`, `disk_attached`).
- Field names match the read commands — a caller diffs `create` against `get`.
- Nullable API pointers go through `str()` so a key is always present.
- Wrap the fields in `withResponse(resp, …)` when the API returns a struct, so
callers also get everything the server sent. Curated keys win on collision.
- Never branch the API call on output format. One call path serves both; the
spinner already writes to stderr. Two paths drift — that is how a JSON-mode
`fork` once skipped its status check and reported a failed fork as success.
- Error strings in results go through `api.UserMessageVerbose(err)`, never
`err.Error()` — raw Go errors leak syscall detail and local paths into JSON
just as readily as into the terminal.
- Batch commands return a `results` array with one entry per ref, plus
`deleted` / `failed` counts. An exit code cannot express a partial batch.
- Interactive streams (`shell`, `sync`, `editor`, `exec --stream`,
`template logs`) stay text-only. Blocking commands that have a result
(`tunnel`, `vpn up`) emit it *before* they block.

### Two JSON checks — pick the right one

| Helper | Use for |
|---|---|
| `output.IsJSON` / `output.Render` | Normal commands. True when `--output json` **or** stdout is not a TTY. |
| `output.IsJSONExplicit` | Commands whose stdout **is** the payload (`exec`). Only true when the user typed `--output json`, so `exec … > file` still writes raw bytes. |

### Streams

- **stdout** — data only. In JSON mode it holds exactly one document; the
root `Before` hook redirects all pterm output to stderr to guarantee it.
- **stderr** — errors, progress, hints, spinners.
- Errors are formatted in `main.go`: a JSON envelope on stdout in JSON mode,
otherwise plain text on stderr. Add new status codes to
`api.APIError.Code()`, which produces the envelope's `code` slug.
- Colour is disabled automatically for non-TTY stdout and for `NO_COLOR`.

### Global flags

`internal/cliargs.Hoist` rewrites argv before `app.Run` so global flags work
after the subcommand. **When adding or renaming a global flag in
`root.go`, add it to `globalStringFlags` / `globalBoolFlags` too** — a
missing entry silently reintroduces the "flag provided but not defined"
error. Tokens after a bare `--` are never hoisted.

## Error Handling

### API errors
Expand Down
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,8 @@ createos sandbox list --status paused --quiet | xargs createos sandbox rm --forc
createos sandbox get <id>
createos sandbox exec my-box -- uname -a
createos sandbox exec my-box --stream -- pip install requests
echo "hello" | createos sandbox exec my-box -- cat
createos sandbox exec my-box --stdin ./setup.sh -- bash
createos sandbox shell my-box
createos sandbox shell my-box --ssh
createos sandbox push my-box ./script.py /root/script.py
Expand Down Expand Up @@ -595,6 +597,90 @@ createos projects get --project <id> --output json
createos environments list --project <id> -o json
```

### Global flags work anywhere on the line

`--output`, `--debug`, `--api-url`, `--api-key`, `--sandbox-api-url`, and
`--sandbox-gateway` can go before or after the subcommand — both of these
are the same command:

```bash
createos --output json sandbox create
createos sandbox create --output json
```

Anything after a bare `--` is passed through untouched, so
`createos sandbox exec my-box -- ./ci.sh --debug` still sends `--debug` to
your script rather than to the CLI.

### Sandbox commands that change something

Every `sandbox` subcommand that creates, changes, or deletes something also
reports its result as JSON. Each object carries an `action` naming what
happened, plus the ids needed to chain the next command:

```bash
$ 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..."
}

# chain it
ID=$(createos sandbox create -o json | jq -r .id)
createos sandbox exec "$ID" -- uname -a
createos sandbox rm "$ID" --force
```

Batch commands (`rm`, `disk rm`, `network rm`, `template rm`) return one
entry per reference you passed, so you can tell which ones actually went:

```json
{
"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
}
```

`sandbox shell`, `sandbox sync`, `sandbox editor`, and `sandbox exec --stream`
stay human-readable — they are interactive streams with no single result to
report. `sandbox tunnel` and `sandbox vpn up` print their JSON result when the
connection comes up, before they block.

### Errors

Errors go to **stderr**, so `2>/dev/null` and pipes behave. In JSON mode the
error is a machine-readable envelope on stdout:

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

Codes: `bad_request`, `unauthorized`, `forbidden`, `not_found`, `conflict`,
`rate_limited`, `server_error`, `api_error`, `error`. The exit code is 1 for
any failure.

### Colour

ANSI colour is switched off automatically when stdout is not a terminal, and
whenever `NO_COLOR` is set. CI logs stay greppable without any flag.

## Options

| Flag | Description |
Expand Down
20 changes: 20 additions & 0 deletions cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ package root
import (
"fmt"
"net/url"
"os"
"time"

"github.com/pterm/pterm"
"github.com/urfave/cli/v2"

"github.com/NodeOps-app/createos-cli/cmd/ask"
Expand Down Expand Up @@ -36,6 +38,7 @@ import (
internaloauth "github.com/NodeOps-app/createos-cli/internal/oauth"
"github.com/NodeOps-app/createos-cli/internal/output"
"github.com/NodeOps-app/createos-cli/internal/pkg/version"
"github.com/NodeOps-app/createos-cli/internal/terminal"
)

// NewApp creates and configures the root CLI application.
Expand Down Expand Up @@ -89,6 +92,23 @@ func NewApp() *cli.App {
Before: func(c *cli.Context) error {
// Store the output format in metadata
c.App.Metadata[output.FormatKey] = output.DetectFormat(c)
c.App.Metadata[output.FormatExplicitKey] = c.String("output") != ""

// One choke point for colour: a pipe, a CI log, or NO_COLOR
// must never receive ANSI escapes. pterm styles every helper
// through this global, so disabling it here covers every
// command at once.
if os.Getenv("NO_COLOR") != "" || !terminal.IsInteractive() {
pterm.DisableStyling()
}

// In JSON mode stdout carries one machine-readable document and
// nothing else. Sending every pterm print to stderr keeps that
// true even for commands that still narrate progress, so a
// consumer never has to strip prose out of the stream.
if output.IsJSON(c) {
pterm.SetDefaultOutput(os.Stderr)
}

// Skip auth for --help / -h on any command
for _, a := range c.Args().Slice() {
Expand Down
22 changes: 11 additions & 11 deletions cmd/sandbox/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"github.com/urfave/cli/v2"

"github.com/NodeOps-app/createos-cli/internal/api"
"github.com/NodeOps-app/createos-cli/internal/output"
)

func newCreateCommand() *cli.Command {
Expand Down Expand Up @@ -196,15 +195,8 @@ func runCreate(c *cli.Context) error {
req.AutoPauseAfterSeconds = autoPauseSecs
}

if output.IsJSON(c) {
jsonResp, jsonErr := client.CreateSandbox(c.Context, req)
if jsonErr != nil {
return jsonErr
}
output.Render(c, jsonResp, func() {})
return nil
}

// One call path for both formats: the spinner writes to stderr, so it
// cannot corrupt a JSON document on stdout.
spinner, _ := pterm.DefaultSpinner.Start("Creating sandbox…") //nolint:errcheck
resp, err := client.CreateSandbox(c.Context, req)
if err != nil {
Expand All @@ -213,7 +205,15 @@ func runCreate(c *cli.Context) error {
}
spinner.Success("Sandbox is ready")

printCreateResult(resp)
renderResult(c, "created", withResponse(resp, map[string]any{
"id": resp.ID,
"name": str(resp.Name),
"shape": resp.Shape,
"rootfs": str(resp.Rootfs),
"ip": resp.IP,
"ingress_url": resp.IngressURLTemplate,
"shell_command": fmt.Sprintf("createos sandbox shell %s", resp.ID),
}), func() { printCreateResult(resp) })
return nil
}

Expand Down
Loading