appstrate
v1.0.0-beta.6
Published
Official CLI for Appstrate — install, login, and manage your instance
Downloads
1,883
Maintainers
Readme
Appstrate CLI
appstrate is the official command-line tool for installing, configuring, and authenticating against an Appstrate instance. It is a single self-contained binary (Bun runtime embedded) — no Node.js, npm, or pre-installed dependencies required on the host.
Lives at apps/cli/ in the monorepo; versioned in lockstep with the platform (ADR-006).
Driving this CLI from an AI coding agent? Read
AGENTS.mdfirst — it distills this reference into a zero-to-first-run recipe, rules of engagement, and acurl→appstrate apicheat sheet sized for an agent's context window.
Install
One-liner (recommended)
curl -fsSL https://get.appstrate.dev | bashDetects your OS/arch, downloads the matching binary from GitHub Releases, drops it at /usr/local/bin/appstrate, and immediately execs appstrate install.
Supported: darwin-arm64, darwin-x64, linux-x64, linux-arm64. Windows is not a v1 target — run the one-liner inside WSL2 (which reuses the linux-x64 binary), or invoke bunx appstrate install natively if you already have Bun on Windows.
Alternate install paths
# Verified one-liner — fetches + minisign-verifies + runs
curl -fsSL https://get.appstrate.dev/verify.sh | bash
# Bun-native (if you already have Bun)
bunx appstrate installSee examples/self-hosting/README.md for signature verification details (minisign + SLSA build provenance).
Commands
| Command | Purpose |
| ----------------------- | ------------------------------------------------------------------------------- |
| appstrate install | Install Appstrate locally (Tier 0) or bring up a Docker stack (Tiers 1/2/3). |
| appstrate login | Sign into an instance via RFC 8628 device-flow. Tokens land in the OS keyring. |
| appstrate logout | Revoke the active session server-side and wipe local credentials. |
| appstrate whoami | Print the identity attached to the active profile. |
| appstrate token | Print metadata about the stored access + refresh tokens (debug). |
| appstrate org | List, switch, or create organizations pinned on the active profile. |
| appstrate app | List, switch, or create applications pinned on the active profile. |
| appstrate api | Authenticated HTTP passthrough to the Appstrate API. |
| appstrate openapi | Explore the active profile's OpenAPI schema without flooding stdout. |
| appstrate run | Execute an agent locally — by package id or from a .afps/.afps-bundle path. |
| appstrate connections | Manage connection profiles (default + alternates) on the active profile. |
All commands accept --profile <name> to target a specific profile (see Profiles).
appstrate install
Interactive installer for a local Appstrate instance. Prompts for a tier, writes a generated .env with cryptographic secrets, and brings the stack up — then opens http://localhost:3000 once the healthcheck passes.
appstrate install # interactive
appstrate install --tier 3 # skip the tier prompt
appstrate install --tier 0 --dir ~/demo-appstrateFlags
| Flag | Values | Description |
| -------------- | ------------ | ------------------------------------------- |
| -t, --tier | 0\|1\|2\|3 | Skip the interactive tier prompt. |
| -d, --dir | path | Install directory (default: ~/appstrate). |
Tiers
| Tier | Runtime deps | Services | Storage | Notes | | ---- | ------------ | -------------------------- | ---------- | ------------------------------------------------------ | | 0 | Bun | None (PGlite in-process) | Filesystem | Hobby / evaluation. CLI auto-installs Bun if missing. | | 1 | Docker | PostgreSQL | Filesystem | Low-traffic single-node. In-memory scheduler / pubsub. | | 2 | Docker | PostgreSQL + Redis | Filesystem | Adds Redis (BullMQ, distributed rate-limiter). | | 3 | Docker | PostgreSQL + Redis + MinIO | S3 | Full production stack (default self-host target). |
Tier 0 specifics: git clones the appstrate/appstrate monorepo at the CLI's release tag, runs bun install, writes .env, and bun run dev spawns the platform as a detached process. If Bun is absent, the CLI prompts to install it via the official installer into ~/.bun/bin (user-local, no sudo).
Tier 1/2/3 specifics: checks docker info, writes docker-compose.yml from an embedded template (examples/self-hosting/docker-compose.tier{1,2,3}.yml), writes .env, runs docker compose up -d, polls / for up to 120s.
appstrate login
Authenticate against a running Appstrate instance via RFC 8628 device-authorization grant. The CLI displays a short user-code + URL; the user visits the URL in a browser, signs in, and approves the device. The CLI polls the token endpoint until approved, stores the resulting session token in the OS keyring, and persists the profile in ~/.config/appstrate/config.toml.
appstrate login # interactive prompt for instance URL
appstrate login --instance http://localhost:3000 # skip prompt
appstrate login --profile prod --instance https://app.my.ioFlags
| Flag | Values | Description |
| ----------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| --instance | URL | Instance base URL. Skips the interactive prompt. |
| -p, --profile | name | Profile name to store credentials under (default: default). |
| --org | <id-or-slug> | After the token exchange, pin this organization on the profile non-interactively. Fails if the reference does not match any org. |
| --create-org | <name> | Create a new organization with this name and pin it. A default application + hello-world agent are provisioned server-side. Skips the prompt. |
| --no-org | — | Skip the post-login org-pinning step entirely. Subsequent calls must carry -H 'X-Org-Id: …', or pin later via appstrate org switch. |
| --app | <id> | After the org pin, pin this application on the profile non-interactively. Fails if the reference does not match any app. |
| --create-app | <name> | Create a new application with this name after login and pin it. Skips the cascade's default-app pick. |
| --no-app | — | Skip the post-login app-pinning step entirely. Subsequent calls must carry -H 'X-App-Id: …', or pin later via appstrate app switch. |
Org pinning after login (issue #209): on success, the CLI calls GET /api/orgs and branches:
- Exactly one org → auto-pin. The success banner names it:
Logged in as … to "Acme" (org_xxx). - Zero orgs (fresh signup, dashboard onboarding skipped) → offer inline creation (
POST /api/orgs) which also provisions a default application + hello-world agent server-side. - ≥2 orgs → interactive picker.
The pinned org id is written to config.toml and automatically sent as X-Org-Id on every subsequent appstrate api call, so appstrate api GET /api/me works immediately after a fresh login with no extra flags.
Application pinning after login (issue #217): after the org pin succeeds, the CLI cascades into GET /api/applications and branches:
- Exactly one app → auto-pin.
- ≥2 apps → auto-pin the one with
isDefault: true(the server provisions exactly one default per org). No interactive picker at login — useappstrate app switchafterwards for a different app. - No default among ≥2 apps (defensive — should never happen) → warn on stderr, leave unpinned.
On success, the banner names both: Logged in as … to "Acme" (org_xxx) / app "Default" (app_xxx). The pinned app id is sent as X-App-Id on every appstrate api call, so app-scoped routes (/api/agents, /api/runs, /api/schedules, …) work with no extra flags.
Flow (what happens on the wire):
POST /api/auth/device/code→ receivedevice_code,user_code,verification_uri_complete,expires_in(10 min),interval(5s).- CLI prints the code, opens the verification URI in the browser via the
openpackage (silent fallback on headless hosts — the URL is still displayed in the terminal). - User authenticates on the instance's
/activateSSR page and clicks "Autoriser". A realm guard on/device/approverejects cross-audience approval attempts (e.g. an application-level end-user trying to approve a CLI session) — see ADR-006 for rationale. - CLI polls
POST /api/auth/cli/tokeneveryintervalseconds (honoringslow_downbackoff) until approval. On success: receives anaccess_token(15-minute signed JWT, ES256) +refresh_token(30-day opaque rotating token) pair — see issue #165. - CLI decodes the JWT payload locally to extract
sub(user id) andemailfrom its claims. No second round-trip needed — the JWT is the authoritative identity source, and/api/auth/get-sessiondoes not understand Bearer JWTs (that endpoint is BA's cookie-based session reader). - Tokens are stored in the OS keyring; profile is written to
config.toml.
Session lifetime: 15-minute access token + 30-day rotating refresh token (RFC 6819 §5.2.2.3 reuse detection). The CLI transparently refreshes on 401; re-run appstrate login only when the refresh token family is revoked or the 30-day window elapses.
appstrate logout
Revokes the active session server-side (POST /api/auth/sign-out) and wipes the local keyring entry + profile from config.toml.
appstrate logout
appstrate logout --profile prod
appstrate logout --all # nukes every CLI session (every device)Flags
| Flag | Values | Description |
| ----------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| -p, --profile | name | Profile to log out from (default: default). |
| --all | — | Revokes every CLI refresh-token family server-side via POST /api/auth/cli/sessions/revoke-all. Use after suspecting key compromise. |
If the instance is unreachable, local credentials are still wiped (with a warning on stderr) so the CLI returns to a clean state even during outages.
The dashboard's Devices preferences page (and GET /api/auth/cli/sessions) lets you revoke individual sessions. Org admins can revoke any member's CLI sessions via GET/DELETE /api/orgs/:orgId/cli-sessions[/:familyId] (requires the cli-sessions:read|delete RBAC grant — owners + admins by default).
appstrate self-update
Channel-aware in-place upgrade. The binary stamps its install source at build time (__APPSTRATE_INSTALL_SOURCE__), so self-update knows whether it was installed via curl, Bun, or bunx and dispatches accordingly.
appstrate self-update # update to latest stable
appstrate self-update --release v1.2.3
appstrate self-update --force # bypass version-equality short-circuit| Flag | Values | Description |
| ----------------- | ------- | --------------------------------------------- |
| --release <tag> | git tag | Pin the upgrade to a specific release. |
| -f, --force | — | Re-install even if already on target version. |
- curl channel — downloads the new binary, verifies minisign + SHA-256, and atomically replaces
~/.local/bin/appstrate. - bun channel — refuses to overwrite, prints the matching
bun update -g @appstrate/cliinvocation. - unknown channel — emits diagnostic instructions.
Channel matrix and recipes: docs/cli/upgrades.md.
appstrate doctor
Diagnoses the local install: detects every appstrate on $PATH, deduplicates by realpath, prints version + channel + binary location for each. Use when which -a appstrate returns multiple results or when self-update reports an unexpected channel.
appstrate doctor # human-readable report
appstrate doctor --json # machine-readable (for scripts / CI)If a dual install is detected (e.g. curl-installed binary shadowed by a Bun-global one), the runtime warns once per realpath set; ack persisted at ~/.config/appstrate/dual-install-ack.json re-arms whenever the set changes. Override with APPSTRATE_FORCE_DUAL=1 to silence non-interactively.
A hidden __install-source subcommand exposes a stable JSON contract { version, source, schema: 1 } for installers that need to gate on channel.
appstrate whoami
Prints the identity attached to a profile. Verifies the stored JWT is still valid by calling GET /api/profile (a 401 surfaces as a clear "re-login" error); the email comes from the profile persisted at login.
appstrate whoami
appstrate whoami --profile prodOutput:
Profile: default
Instance: https://app.example.com
User: [email protected]
Name: Alice
Expires: 2026-04-25T00:36:40.285ZExits non-zero if the profile is missing, the session is revoked, or the instance is unreachable — useful in CI scripts that need to fail fast when auth drifts.
appstrate token
Prints metadata about the access + refresh tokens stored for a profile. Metadata only — the token plaintext is never written to stdout or stderr, so copy-pasting the output into a screen share, a CI log, or a bug report never leaks a bearer.
appstrate token
appstrate token --profile prodOutput:
Profile: default
Instance: https://app.example.com
Access token
Status: fresh
Expires: in 14m 32s
Expires at: 2026-04-19T16:23:45.000Z
Refresh token
Status: valid
Expires: in 29d 23h
Expires at: 2026-05-18T16:08:45.000Z
JWT claims
iss: https://app.example.com/api/auth
aud: https://app.example.com/api/auth
sub: usr_abc123
azp: appstrate-cli
actor_type: user
scope: cli
iat: 1713543325 (2026-04-19T16:08:45.000Z)
exp: 1713544225 (2026-04-19T16:23:45.000Z)
jti: ab12cd34…Status vocabulary:
- Access:
fresh(> 30s remaining) ·rotating-soon(< 30s —api.tswill rotate on the next call) ·expired(past TTL; claims still render for diagnostics) - Refresh:
valid(> 24h remaining) ·expiring-soon(< 24h) ·expired(re-runappstrate login) ·not stored(legacy 1.x credentials)
No network call — this command inspects local state only. A refresh token revoked server-side still looks valid here by design. Use whoami for a server-authoritative identity check.
If the JWT exp claim and the locally stored expiresAt diverge by more than 2 seconds, token flags the mismatch — api.ts's proactive-rotation logic keys off the stored value, so a skew between the two is worth surfacing before it causes unexpected 401s.
appstrate org
Manage the organization pinned on the active profile. login auto-pins an org where possible (see above); org switch / org create let you change the pin without re-running the device flow. The pinned org id is sent as X-Org-Id on every appstrate api call and every /api/* endpoint that requires org context.
appstrate org list # enumerate orgs the profile has access to; pinned row is marked *
appstrate org switch # interactive picker (current org pre-highlighted)
appstrate org switch acme # non-interactive — by slug or id
appstrate org current # print the pinned orgId (scripts / shell prompts)
appstrate org create # interactive (name + optional slug) → auto-pin
appstrate org create "Acme" # non-interactive → auto-pin
appstrate org create "Acme" --slug acme-prodAll four subcommands respect the global --profile <name> flag and talk to GET /api/orgs / POST /api/orgs. Creating an org server-side also provisions a default application + a hello-world agent, so the CLI lands on a fully-working setup with no extra steps.
Subcommands
| Subcommand | Purpose |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| org list | List the orgs the active profile belongs to. The pinned one is marked with *. |
| org switch [id\|slug] | Re-pin the active org on the profile. With no argument, show an interactive picker with the current one highlighted. |
| org current | Print the pinned org id to stdout. Exits 1 with a hint when no org is pinned — designed for if / shell prompts. |
| org create [name] | Create a new org and pin it. With no argument, prompt for name + optional slug. Use --slug <slug> for an explicit kebab-case override. |
Cascade — the app pin follows the org pin. Every org switch / org create clears the current appId and re-pins the new org's default application in the same atomic operation. This keeps appstrate api GET /api/agents working immediately after switching — without the cascade the next call would return 404 Application not found in this organization.
appstrate app
Manage the application pinned on the active profile. login auto-pins the default application in the pinned org (see above); app switch / app create let you change the pin without re-running the device flow. The pinned app id is sent as X-App-Id on every appstrate api call — required for app-scoped routes (agents, runs, schedules, webhooks, api-keys, notifications, packages, providers, connections, end-users, app-profiles).
appstrate app list # enumerate apps in the pinned org; pinned row is marked *, default row tagged [default]
appstrate app switch # interactive picker (current app pre-highlighted)
appstrate app switch app_xxx # non-interactive — by id
appstrate app current # print the pinned appId (scripts / shell prompts)
appstrate app create # interactive (name) → auto-pin
appstrate app create "Staging" # non-interactive → auto-pinAll four subcommands respect the global --profile <name> flag and talk to GET /api/applications / POST /api/applications. Applications are identified by id only (there is no slug column server-side).
Subcommands
| Subcommand | Purpose |
| ------------------- | -------------------------------------------------------------------------------------------------------------------- |
| app list | List the applications in the pinned org. The pinned one is marked with *; the org's default is tagged [default]. |
| app switch [id] | Re-pin the active app on the profile. With no argument, show an interactive picker with the current one highlighted. |
| app current | Print the pinned app id to stdout. Exits 1 with a hint when no app is pinned — designed for if / shell prompts. |
| app create [name] | Create a new app and pin it. With no argument, prompt for a name interactively. |
appstrate openapi
Explore the active profile's OpenAPI 3.1 schema without dumping the whole spec to stdout. The platform exposes ~258 endpoints — list, show, and export subcommands make that corpus explorable at human scale (and agent-ingestable with --json).
The schema is fetched once per profile and cached under ~/.cache/appstrate/openapi-<profile>.json (or $XDG_CACHE_HOME/appstrate/…). Each cached copy pairs with an ETag sibling — subsequent invocations send If-None-Match and short-circuit on a 304 response, so re-running list / show during exploration costs one conditional round-trip instead of re-downloading the full spec.
appstrate openapi list # all operations, one per line
appstrate openapi list --tag runs # filter by tag
appstrate openapi list --method post # filter by HTTP method
appstrate openapi list --path '/api/runs/*' # filter by path glob
appstrate openapi list --search "create run" # fuzzy match on id / summary / path
appstrate openapi list --json # machine-readable index
appstrate openapi show createRun # by operationId
appstrate openapi show GET /api/runs # by METHOD + path
appstrate openapi show createRun --json # full dereferenced object (agent input)
appstrate openapi export # dump raw schema to stdout
appstrate openapi export -o schema.json # dump to fileSubcommand flags
| Subcommand | Flag | Description |
| ---------- | ---------------- | -------------------------------------------------------------------------------- |
| list | --tag <t> | Filter by OpenAPI tag (case-insensitive exact match). |
| list | --method <m> | Filter by HTTP method (GET, POST, …). |
| list | --path <glob> | Filter by path. Supports * (single segment) and ** (any). Exact match else. |
| list | --search <q> | Case-insensitive substring across operationId, summary, description, path. |
| list | --json | Emit a minimal JSON array (method, path, operationId, summary, tags) for piping. |
| show | --json | Emit the full dereferenced operation as JSON instead of the text summary. |
| export | -o, --output | Write the schema to a file (default: stdout). |
Shared flags (all three subcommands)
| Flag | Description |
| ------------ | --------------------------------------------------------------------- |
| --refresh | Force a fresh download; still update the on-disk cache on success. |
| --no-cache | Fully ephemeral — skip both cache read and write for this invocation. |
list output — one colored line per operation:
GET /api/runs — List runs [runs]
POST /api/runs — Create a run [runs]
GET /api/runs/{id} — Get a run [runs]
DELETE /api/runs/{id} — Cancel a run [runs]
GET /api/deprecated — Legacy endpoint [legacy] [deprecated]Colors are suppressed when stdout is not a TTY, or when NO_COLOR is set in the environment (respects no-color.org).
show output — a human-readable operation summary. For --json, the response uses @apidevtools/swagger-parser to dereference every $ref in the operation tree, so nested request/response schemas inline fully — ideal for piping into an LLM prompt or a code generator.
export output — the raw schema JSON. Use -o schema.json for file output (mode 0600) or stdout for shell piping (appstrate openapi export | jq '.info'). Equivalent to calling appstrate api GET /api/openapi.json, but served from the local cache when possible.
appstrate api
Curl-like authenticated HTTP passthrough. Purpose-built so coding agents (Claude Code, Cursor, Aider, …) can call the Appstrate API in a shell-one-liner without ever seeing the raw bearer — the CLI injects Authorization: Bearer … + X-Org-Id + X-App-Id from the keyring-backed profile.
appstrate api GET /api/agents
appstrate api /api/agents # method inferred
appstrate api POST /api/agents/abc/run -d '@req.json'
appstrate api https://app.example.com/api/health # absolute URL ok if origin matches profilecurl → appstrate api mapping
Every row below is a direct drop-in: an agent can replace curl with appstrate api and strip the hostname. All flags work identically.
| curl | appstrate api | Notes |
| ------------------------------- | ------------------------------------- | ------------------------------------------------- |
| curl https://app/api/x | appstrate api /api/x | method defaults to GET |
| curl -X POST -d @body … | appstrate api POST /api/x -d @body | literal / @file / @- for stdin |
| curl -F '[email protected]' | appstrate api -F '[email protected]' | ;type=mime supported |
| curl -H 'X-Foo: bar' | appstrate api -H 'X-Foo: bar' | repeatable; wins over defaults |
| curl --data-urlencode 'k=v w' | same | repeatable; 5 curl forms incl. @file / @- |
| curl -G --data-urlencode … | appstrate api -G --data-urlencode … | -G projects values into the query string |
| curl -T file | appstrate api -T file /x | PUT by default; -T - for stdin |
| curl -i | appstrate api -i | status line + headers on stdout |
| curl -I | appstrate api -I | HEAD only |
| curl -L | appstrate api -L | cross-origin hops strip Authorization |
| curl -k | appstrate api -k | skip TLS verification (this request) |
| curl -o out | appstrate api -o out | body → file |
| curl -s / -sS | appstrate api -s / -sS | silence / silence-but-errors |
| curl -f / --fail-with-body | same | -f suppresses body; --fail-with-body keeps it |
| curl -v | appstrate api -v | Authorization always [REDACTED] |
| curl -w '%{http_code}\n' | appstrate api -w '%{http_code}\n' | see write-out vars below |
| curl --connect-timeout N | appstrate api --connect-timeout N | exit 28 on timeout |
| curl --max-time N | appstrate api --max-time N | exit 28 |
| curl --retry N | appstrate api --retry N | 408/429/5xx; exp. backoff; Retry-After honored |
| curl --retry-connrefused | same | off by default (matches curl) |
| curl --compressed | appstrate api --compressed | advertise gzip/deflate/br |
| curl -r 0-1023 | appstrate api -r 0-1023 | Range: bytes=… |
| curl -A 'UA' | appstrate api -A 'UA' | shortcut; -H still wins |
| curl -e https://ref | appstrate api -e https://ref | Referer shortcut |
| curl -b 'k=v' | appstrate api -b 'k=v' | literal only; cookie-jar files rejected |
Write-out variables (-w)
Subset of curl's format string. Unknown variables pass through verbatim; \n \r \t escapes are expanded.
| Variable | Meaning |
| ----------------------- | --------------------------------------------------------- |
| %{http_code} | Final response status (0 on connect failure) |
| %{http_version} | Hardcoded 1.1 — fetch() doesn't expose the real version |
| %{size_download} | Body bytes received |
| %{size_upload} | Body bytes sent (0 when unknown — FormData / stream) |
| %{time_total} | Total time in seconds, 6 decimals |
| %{time_starttransfer} | Time until first response byte |
| %{url_effective} | Final URL after redirects |
| %{num_redirects} | 1 if -L followed a redirect, else 0 |
| %{header_json} | Response headers as JSON |
| %{exitcode} | Our process exit code |
Exit codes (libcurl-aligned)
| Code | Meaning |
| ---- | -------------------------------------------------------- |
| 0 | Success |
| 1 | Generic / auth error |
| 2 | Usage error (foreign host, -G + -F, cookie-jar path) |
| 6 | DNS failure (ENOTFOUND / EAI_AGAIN) |
| 7 | Connection refused / unreachable |
| 22 | HTTP ≥ 400 under -f / --fail-with-body |
| 25 | HTTP ≥ 500 under -f / --fail-with-body |
| 28 | --max-time or --connect-timeout expired |
| 35 | TLS handshake failure |
| 130 | SIGINT |
Differences from curl (intentional)
- No
-u / --user: the whole point is that agents never see the bearer. Use-H Authorization: …if you really need to override (it's still[REDACTED]under-v). - Cross-origin
<url>refused: the bearer must not leave the profile's instance. Explicit exit 2 with a pointer at plaincurl. - Cookie jars rejected:
-b file.txtis refused (exit 2). An attacker-controlled path would otherwise silently end up in the Cookie header. - No default
Content-Type:-d/--data-urlencodedon't auto-setapplication/x-www-form-urlencodedthe way curl does. Add-H 'Content-Type: …'explicitly when the server expects it (avoids corrupting multipart / binary payloads elsewhere in the API).
Behavioral divergences worth knowing
%{http_version}always reports1.1: Web fetch doesn't expose the negotiated protocol. All other-wvariables are accurate.%{header_json}emits lowercase header names: WHATWG fetch normalizes response header casing; curl preserves the wire casing. Parsers that key on lowercase are unaffected; case-sensitive parsers need adjustment.--connect-timeoutis wall-clock, not per-attempt under--retry: the timer starts once at the first fetch and aborts the whole run if response headers haven't arrived. curl resets it per attempt. In practice this only differs when the first attempt partially succeeds then fails mid-body (rare); retries on DNS / network errors that never touch the socket are unaffected.--retrydisabled automatically on stdin bodies:-d @-,-T -,--data-urlencode @-can't be replayed after the stream is consumed. The CLI warns on stderr and falls back to a single attempt instead of silently replaying an empty body.Retry-Afterdelta-seconds values capped at 1 hour: server-suggested delays above 3600 seconds are ignored and fall back to exponential backoff. A hostile / misconfigured origin can't stall a CI job overnight.
appstrate run
Execute an agent locally via the same Pi runner the platform uses for cloud runs. Two argument forms:
- By package id —
@scope/agent[@spec]. The CLI callsGET /api/agents/{scope}/{name}/bundleon the pinned instance to download a deterministic.afps-bundle, verifies its SRI integrity in memory, and runs it. The bytes are never written to disk — every invocation re-fetches. - By file path — a local
.afpsor.afps-bundlefile. No network roundtrip.
# Run the latest version of an installed agent
appstrate run @scope/triage
# Pin an exact version
appstrate run @scope/[email protected]
# Resolve via dist-tag
appstrate run @scope/triage@beta
# Run a local bundle without hitting the instance
appstrate run ./out/triage-1.2.0.afps-bundle --providers local --creds-file ./creds.jsonRun-config inheritance (model, proxy, agent config, version pin) is fetched from /api/applications/{appId}/packages/{scope}/{name}/run-config and merged with flag/env overrides. Use --no-inherit to opt out (deterministic CI).
Selected flags
| Flag | Purpose |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| --connection-profile <ref> | Connection profile to use for credential-proxy calls (UUID or name). Overrides the sticky default pinned via appstrate connections profile switch. |
| --cp <ref> | Alias for --connection-profile. |
| --provider-profile <kv> | Per-provider override providerId=<id\|name>. Repeatable. |
| --proxy <id> | Proxy id to associate with the run (overrides the per-app inherited value). |
| --no-inherit | Skip per-application run-config inheritance — flags + env vars + defaults only. |
| --no-preflight | Skip the connections-readiness preflight (CI mode; fails fast on missing connections via the structured-error path). |
| --preflight-timeout <s> | Maximum seconds to poll for connections during the preflight. Default 300. |
| --json | Emit canonical RunEvents as JSONL on stdout. Forces non-interactive preflight. |
| -v, --verbose | Verbose tool-call output: pretty-print args + reveal full results (~2 KB). Honoured only in human mode (without --json). Env: APPSTRATE_VERBOSE=1. |
| -q, --quiet | Suppress per-tool output lines (name, args, result). Errors and final summary still print. Mutually exclusive with --verbose. |
Tool-call rendering
In human mode (no --json), each tool call surfaces as one to three lines:
→ tool: bash
args command: "ls -la /tmp", timeout: 5000
✓ result total 8 ↵ drwxr-xr-x 3 root ...Defaults match the dashboard log viewer: args truncated at 200 chars, result preview at 100 chars (newlines collapsed to ↵). Pass -v to pretty-print args as multi-line JSON and reveal the full ~2 KB result; pass -q to suppress tool lines entirely (errors + summary always print). The bridge truncates oversized results to ~2 KB before transport — a __truncated: true marker stays visible in either mode so silent data loss is impossible.
The full flag set is documented under appstrate run --help.
Preflight readiness
Before launching, the CLI calls GET /api/agents/{scope}/{name}/readiness to check that every required provider has a healthy connection under the resolved profile context. Missing or expired connections in an interactive terminal trigger a prompt to open ${instance}/preferences/connectors in a browser, then poll until ready or --preflight-timeout is reached. In --json or non-TTY contexts, the run aborts with exit code 1 and a structured error containing { code: "connections_missing", missing, connectUrl } on stdout.
appstrate connections
Manage connection profiles for the active CLI profile. Profiles let you keep parallel sets of provider credentials (e.g. "personal Gmail" vs "work Gmail") and switch between them per run.
appstrate connections list # List connections for the current profile
appstrate connections profile list # List all profiles owned by the user
appstrate connections profile current # Print the pinned default
appstrate connections profile switch work # Pin "work" as the default for credential-proxy calls
appstrate connections profile create "freelance" # Create a new non-default profileswitch writes the resolved profile UUID to connectionProfileId in ~/.config/appstrate/config.toml. appstrate run and any other credential-proxy callers honour it via the X-Connection-Profile-Id header until overridden by --connection-profile.
Profiles
Multiple Appstrate instances (dev / prod / a customer deploy / ...) can be kept side by side via named profiles. Resolution cascade (first match wins):
--profile <name>flagAPPSTRATE_PROFILEenvironment variabledefaultProfilekey inconfig.toml(set on the first successful login)- Literal
"default"
Each profile stores the instance URL + user identity in ~/.config/appstrate/config.toml (TOML, 0600 perms); the session token lives in the OS keyring entry (appstrate, <profile-name>).
# Sign into prod, pinned profile name
appstrate login --profile prod --instance https://app.example.com
# Make prod the default for future invocations
APPSTRATE_PROFILE=prod appstrate whoami
# → or edit defaultProfile in ~/.config/appstrate/config.tomlToken storage
Tokens are stored in the OS keyring when available, otherwise in a file fallback.
| Platform | Primary backend | Fallback |
| -------- | ------------------------------------------- | --------------------------------------------------- |
| macOS | Keychain (via @napi-rs/keyring) | ~/.config/appstrate/credentials.json (0600) |
| Linux | libsecret / DBus (via @napi-rs/keyring) | idem (triggers on stripped containers without DBus) |
| Windows | Credential Manager (via @napi-rs/keyring) | idem |
The fallback activates transparently when the keyring backend is missing (common in headless CI containers). A one-time stderr warning fires if the keyring backend reports a non-missing-backend error (corrupt DB, locked Keychain) — that way a legitimate misconfiguration doesn't silently degrade to plaintext storage.
Configuration layout
$XDG_CONFIG_HOME/appstrate/ (or ~/.config/appstrate/)
├── config.toml # profiles, default profile pointer
└── credentials.json # keyring fallback (only if keyring unavailable)Example config.toml:
defaultProfile = "prod"
[profile.prod]
instance = "https://app.example.com"
userId = "EWnC2cLyy88EpCGBa3WrIdS7uqI648BB"
email = "[email protected]"
orgId = "org_123abc"
appId = "app_abc123"
[profile.dev]
instance = "http://localhost:3000"
userId = "SVAA9PSXrmqQmg95A3RzyydtlravhhJR"
email = "[email protected]"orgId and appId are both optional — when set, every apiFetch request sends X-Org-Id: <orgId> (and X-App-Id: <appId>) so the instance scopes requests correctly. Unset orgId means the user's default org applies server-side; unset appId means app-scoped routes (/api/agents, /api/runs, …) return 400 Application context required and the caller must pass -H X-App-Id: … manually.
Troubleshooting
Unauthorized — your session may have been revoked
Session expired or was revoked server-side. Re-run appstrate login.
Application context required. Provide X-App-Id header or use an API key.
The pinned profile has no appId. Either the cascade at login skipped it (--no-app, zero apps in the org, or no default found), or a previous org switch cleared it and the re-pin fetch flaked. Run appstrate app switch to pick one, or pass -H 'X-App-Id: …' on the call.
Application '<id>' not found in this organization
The pinned appId belongs to a different org than the currently pinned orgId. Happens when something mutates config.toml between an org switch and the next API call, or after a manual hand-edit. Run appstrate app switch to re-pin a valid app under the current org.
This CLI is not registered on the target instance. The platform may be running an incompatible version.
The instance's appstrate-cli OAuth client is missing. Boot the platform — ensureCliClient() auto-provisions it on startup. If the instance is much older than the CLI (pre-Phase-1 device flow), the CLI binary is incompatible — downgrade via APPSTRATE_VERSION=<older-tag> curl get.appstrate.dev | bash.
Docker is required for this tier but was not found
appstrate install --tier {1,2,3} needs Docker. Install Docker Desktop (macOS) or the Docker engine (Linux) and re-run. On Windows, run inside WSL2 with the Docker engine installed in the WSL distro (Docker Desktop's WSL integration also works). Tier 0 doesn't need Docker.
Bun is not installed.
Tier 0 bootstrap couldn't find bun on PATH. The CLI offers to install it via the upstream curl https://bun.sh/install | bash (user-local, no sudo). Decline to install manually from bun.sh.
Keyring fallback warning
If you see OS keyring ... failed ... falling back to ~/.config/appstrate/credentials.json, the OS keyring is broken (libsecret unreachable on Linux, Keychain locked on macOS). The file fallback is 0600 but is plaintext — fix the keyring backend if you want secure-at-rest storage.
Source + contributing
Source at apps/cli/. Tests at apps/cli/test/ (unit tests, run with bun test from the CLI directory). E2E against a real instance: spin up an Appstrate Tier 0 with bun run dev, then bun run src/cli.ts login --instance http://localhost:3000.
Building locally
bun build --compile --target=bun-<host> produces a working standalone binary for the host platform — @napi-rs/keyring's native .node binding is resolved from node_modules at bundle time and embedded into the output.
Cross-compiling from a single host does not work. bun build --compile --target=bun-linux-x64 from a macOS machine (or any other mismatched combination) will compile successfully but replace every require("./keyring.<target>.node") with a throw new Error("Cannot require module …"), because only the host-matching @napi-rs/keyring-<platform> optional dependency is installed by bun install. The binary will start, print --help, and crash the moment any code path touches the keyring (login, logout, whoami).
The release pipeline (.github/workflows/release.yml) handles this by running one job per target on a native runner (macOS arm64, macOS x64, Linux x64, Linux arm64) — each job's bun install fetches the matching native binding. If you need a binary for a platform other than your host locally, run bun build --compile on that target's OS or wait for a GitHub Release.
Architectural decisions in ADR-006; implementation plan in docs/specs/CLI_IMPLEMENTATION_PLAN.md (local-only, gitignored); preflight results in docs/specs/cli-preflight-results.md (also gitignored).
