Skip to content

feat(cli): minimal hosted-first v0 command surface - #65

Closed
jiashuoz wants to merge 1 commit into
mainfrom
feat/cli-v0-interface
Closed

feat(cli): minimal hosted-first v0 command surface#65
jiashuoz wants to merge 1 commit into
mainfrom
feat/cli-v0-interface

Conversation

@jiashuoz

@jiashuoz jiashuoz commented Sep 7, 2026

Copy link
Copy Markdown
Member

Why

rainier --help listed twenty-odd commands. Transfers, snapshots, secrets, environments, contexts, workspaces and the self-hosted credential vault sat beside the four things a hosted developer actually does, so a new developer could not read the product off one screen — which is the only test of a command surface that matters.

Hosted v0 serves one developer, one workspace, one Dedicated plan, and several persistent sessions. The CLI is for four jobs: signing in, checking readiness, authenticating Claude Code or Codex, and creating and managing sessions. Payment, compute selection, workspace management, GitHub connection and environment administration belong on the web.

docs/cli-v0-contract.md is the contract this PR implements.

Session, Process, Connection — three facts, kept apart

A session has three independent facts, and the CLI never collapses them. The control plane reports them separately because they are separate, and each fails on its own:

Dimension API field Answers
Session state Is the sandbox coming up, up, stopped, or broken?
Process child_exit_code Has the command it runs finished, and with what status?
Connection reachable Can the control plane talk to its runner right now?

Session lifecycle:

API state Displayed
queued, creating Starting
running Running — regardless of child_exit_code
suspended_warm, suspended_cold Stopped
failed, dead Failed
canceled Canceled
destroyed Deleted
anything else Unknown (<the server's own word>)

Process: Exited (N) when child_exit_code is present; Running when absent and the sandbox exists; - otherwise. Running means the child exists — never that it is doing anything rather than waiting at a prompt.

Connection: Available / Unavailable. Never used as a lifecycle state.

canceled and destroyed are terminal records: absent from the default list, present under --all, and labelled accurately when inspected — a session somebody cancelled and one somebody deleted are different events, and neither is "finished".

Unknown future states stay unknown, carry the server's own word through, and are never translated into another dimension's vocabulary. A reachable session in an unknown state is still reachable.

NAME   STATE                  PROCESS     CONNECTION   AGE
live   Running                Running     Available    2h8m
done   Running                Exited (0)  Available    2h8m
blip   Running                Running     Unavailable  2h8m
boot   Starting               -           Unavailable  2h8m
broke  Failed                 -           Available    2h8m
odd    Unknown (hibernating)  -           Available    2h8m

Action eligibility comes from raw API states

Never from the display label. Two states that share a label can differ at the endpoint, which is exactly why the label cannot decide this. Read off the control plane:

Action Raw states the API accepts Source
attach running; failed only while its runner is connected controlapp/attachments.go attachable
resume suspended_warm, suspended_cold ResumeSession
suspend (stop) running only SuspendSession
delete all except creating (409) and destroyed DeleteSession

Consequences:

  • Stop is not claimed for a Starting session. queued and creating share that label and SuspendSession refuses both.
  • A running session whose child exited stays attachable and stoppable. The server's rules are about the sandbox, not the process inside it. This is the bug the previous revision had.
  • attach is offered for queued/creating (the CLI waits) and the suspended states (the CLI resumes first) — each is a real API path the CLI drives.
  • A failed session's attach depends on reachable, the one place a connection fact legitimately decides an action, because the endpoint says so.
  • An unknown state yields unknown — no local refusal, no invented claim, the server decides.

JSON keeps the canonical facts

state is the server's own string and is never replaced by a derived word. reachable and child_exit_code are the boolean and the nullable integer the API sent, with the key always present. Derived fields are additive and separately named:

{
  "schema": "rainier.v0.session", "version": 1,
  "id": "sess_b", "name": "done",
  "state": "running", "reachable": true, "child_exit_code": 0,
  "lifecycle": "running", "process": "exited", "connection": "available",
  "actions": {"attach": "yes", "stop": "yes", "delete": "yes"}
}

actions is three-valued (yes/no/unknown) because an unknown state admits no honest boolean. ls --json and info --json emit the same entry shape from the same builder.

Command inventory

Before: login doctor new ls attach suspend resume snapshot rm diff push pull creds connection agent secret env context workspace version

After (default help — 23 lines, 78 columns):

rainier login / logout / status [--verbose] [--json]
rainier new [--name NAME] [--agent claude|codex] [--detach] [-- CMD ...]
rainier ls [--all] [--verbose] [--json]
rainier info <session> [--json]
rainier attach <session>
rainier stop <session>
rainier delete <session> [--yes]
rainier agent login|status|logout <claude|codex>
rainier help [command] / version

Aliases retained (hidden, documented under help all): doctorstatus --verbose, suspendstop, rmdelete (keeping its no-prompt behavior for scripts), agent lsagent status.

Removed completely: diff. Git inside the session is the source of truth; the e2e suite's assertion is now an in-session git diff --stat origin/BASE...HEAD.

Advanced (help all): resume snapshot push pull creds connection secret env context workspace, plus self-hosted login flags and --env/--image/--egress/--since.

Stop vs Delete, and billing

rainier stop is the cold suspend; rainier attach resumes a stopped session automatically. Its help now says plainly:

Stop preserves the session and releases the session resources it was holding — the runner slot, its memory — for other sessions to use. It does not change what you pay: the Dedicated subscription stays active and continues to bill monthly whether sessions are running or stopped. Cancelling the subscription is a separate action, on the web.

delete remains permanent and distinct: it asks on a terminal, requires --yes without one, and never blocks on an answer that cannot arrive.

Cloud API contract, corrected against rainier-cloud 81d1ad3

The previous revision's §5 was written before Cloud PR #53 merged and was stale. Corrected:

Contract Status
GET /v0/workspaces/{id}/compute live — forwarded to bearer clients; 200 needs_plan for an unenrolled workspace
workspace_not_ready (409) live — emitted from session create and resume
Onboarding destination missing — exists only as a relative path on the cookie-only /v0/web/bootstrap
Agent launch catalog missing

status now reads the workspace's own compute enrollment and uses the server's vocabulary (needs_plan, awaiting_payment, provisioning, ready, failed, cancelling, cancelled) with health alongside it. ready + health:"unavailable" is not ready — capacity that exists and cannot be reached; the entitlement is intact and a session will not start, so calling it ready would send somebody to rainier new to be refused. It gets its own sentence, since the recovery is nothing like selecting a plan.

The launch catalog now targets GET /v0/environments/{id}/agents — launch capability is a property of the environment's image, which is regional tenant state — instead of the stale proposal to put launch_cmd on /v0/agents. /v0/agents stays credential custody, account-scoped, with nowhere to put a credential and nothing to say about an image. argv is a structured array from a closed server catalog, never a shell string, and never carries an image tag, digest, package name or version.

Both missing contracts are isolated in cmd/rainier/readiness.go. status prints no Continue: line without one, and drops any destination that is not an absolute https URL — the browser bootstrap serves a relative path today, and a relative path in a "go here" message is worse than none.

Verification

Rebased cleanly onto origin/main @ 68ac6fe (includes #66 and #67 — no CLI overlap).

Command Result
./scripts/check-module-path.sh exit 0
./scripts/check-public-protocols.sh exit 0
./scripts/check-public-control.sh exit 0
go test ./... exit 0
bash scripts/build.sh -o bin/ ./cmd/... exit 0
go vet ./... exit 0
go test -race -count=2 ./cmd/rainier/ ./internal/cli/ exit 0
go test -race ./internal/e2e/ ./controlapp/ ./v0wire/ ./control/ exit 0
gofmt -l on every changed file clean

Root help reviewed manually: 23 lines, longest line 78 columns.

Regression tests, and proof they catch the old behavior

cmd/rainier/sessionstate_test.go covers running+exit-code, reachable=false while running, both suspended states, failed/dead, canceled/destroyed, an unknown future state, the human columns, JSON fidelity, and action eligibility.

The old behavior was reintroduced into the real code and the new suite run against it. It failed on exactly the defects:

--- FAIL: TestThreeDimensions/running_with_a_child_that_exited_cleanly
    lifecycle = "canceled", want "running"          # was "finished"
--- FAIL: TestThreeDimensions/an_unknown_future_state
    lifecycle = "failed", want "unknown"            # unknown → connection vocabulary
--- FAIL: TestActionEligibility/running_with_a_child_that_exited
    canAttach = "no", want "yes"                    # attachable session refused
--- FAIL: TestActionEligibility/queued
    canStop = "yes", want "no"                      # Stop claimed for Starting
--- FAIL: TestActionEligibility/creating
    canStop = "yes", want "no"

Remaining server dependencies

  1. GET /v0/web/onboarding (edge) — a bearer-reachable, absolute-URL onboarding map keyed by the compute vocabulary. Until it ships status prints no Continue: line. browserhttp.onboardingFor() and this route must be generated from one shared table or they will drift.
  2. GET /v0/environments/{id}/agents (cell) — the per-environment launch catalog. Until it ships rainier new --agent X fails with one message naming it.
  3. "default": true on v0wire.EnvironmentView — so the default environment is a server fact rather than the CLI's "there is only one" heuristic. Decoded forward-compatibly today.
  4. A repository field on v0wire.SessionViewinfo omits repository metadata rather than reconstructing it.

Not in this PR

No rainier github group. No server changes. Not merged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q3GPB683VghXL2XA4XWK7Y

The CLI had grown twenty-odd commands, and `rainier --help` listed all of
them: transfers, snapshots, secrets, environments, contexts, workspaces and
the self-hosted credential vault sat beside the four things a hosted
developer actually does. A new developer could not read the product off one
screen, which is the only test of a command surface that matters.

The public surface is now sign in, check readiness, authenticate a coding
agent, and create and manage sessions — 23 lines of default help. Everything
else still dispatches, under `rainier help all`, labeled Advanced.

A session has three independent facts and this CLI keeps them apart: the
sandbox's lifecycle (`state`), the process inside it (`child_exit_code`), and
whether the control plane can reach it (`reachable`). They fail separately, so
they are three columns. A session whose agent exited is still Running — it
holds its filesystem and it is still attachable — and a runner that dropped
its link makes a session Unavailable, not Failed. Action eligibility comes
from the raw API's own transition rules and never from the display word:
`SuspendSession` accepts `running` and nothing else, so Stop is not offered
for a queued session merely because queued and creating share the label
Starting.

docs/cli-v0-contract.md is the authority for what each command promises and
which Cloud APIs each promise depends on. Rainier Cloud 81d1ad3 shipped the
compute enrollment and `workspace_not_ready`, so `status` reads the
workspace's own compute state — both `status` and `health`, because ready
capacity nobody can reach is not ready. Two contracts are still outstanding
and both are isolated in cmd/rainier/readiness.go: a bearer-reachable
onboarding destination, and a per-environment agent launch catalog. Neither is
guessed at.

New: `logout`, `status`, `info`, `stop`, `delete`, the `current` selector,
and `--json` on everything a script reads — carrying the canonical API facts
verbatim beside the derived ones. `diff` is removed completely: git inside the
session is the source of truth. `doctor`, `suspend`, `rm` and `agent ls`
remain as hidden aliases for the commands that replaced them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3GPB683VghXL2XA4XWK7Y
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.

1 participant