This site is being rebuilt and some pages are out of date. For current details, write to reach@inorbit.hr. This notice goes away when the rebuild is done.

No analytics unless you allow it, no tracking. This site keeps in your browser the language you pick, the theme, its colour, which site you chose, the currency on the pricing page and that you closed this notice; signing in adds session cookies. The legal page has the details.

Sign in

← Back to Platform

RFC 0019decided2026-10-02

The iohr command line

One Rust binary, iohr, installed with apt, brew, winget or a one-line installer, that signs a developer in, keeps several accounts side by side in the system keychain, and generates SDKs cut to what each of those accounts may call.

Problem

The API has a reference with a working playground and two credentials, tokens and keys (RFC 0016). What it does not have is a tool on the developer's own machine. Every platform developers live in ships one: gh, stripe, fly, gcloud, supabase. They sign you in once through the browser, remember who you are, let you switch between work and personal accounts, and do the repetitive parts for you.

Ours has a specific first job. The API a caller may reach is not the same for every caller: the OpenAPI document the gateway serves depends on who asks (RFC 0020). An SDK generated from the public document offers operations a given account cannot call, and misses the ones its plan adds. The command line is where a developer signs in as the account they build for, and where the SDK for that account is generated.

The code is public, under the SDK repository's Apache 2.0 licence, and it is Rust. It will be read by people who know Rust well. It has to hold up to that reading: small dependency set, no unwrap on a user's input, errors that say what to do, generics where they remove a class of bug and nowhere else.

Proposal

What it is

A single binary, iohr, for Linux (x86-64, arm64), macOS (Apple silicon, Intel) and Windows (x86-64, arm64), all three in the first release. It is installed from our own APT repository, our Homebrew tap, winget, or a one-line installer for each platform (RFC 0021). The name is the platform's short name, already used on the wire (the iohr-api audience, the iohr.<service>.v1 packages), short to type, and free on every registry it ships to (checked 2026-10-02: crates.io, Homebrew, Debian, Ubuntu, winget, Scoop, npm and PyPI). It talks only to the public API and the public sign-in pages, the same two hosts the SDKs talk to, and sends no telemetry.

Commands, first release

iohr login                     sign in (browser, or a device code); add a profile
iohr login --with-token        read an API token from stdin (CI, scripts)
iohr logout [--profile P]      forget a profile and revoke what it holds
iohr profile list|use|show     the profiles on this machine; the default one
iohr whoami                    who the active profile is, its account, plan, scopes
iohr accounts list             the accounts the signed-in person belongs to
iohr token create|list|revoke  API tokens for an account (RFC 0016)
iohr api <METHOD> <PATH>       one authenticated call, JSON out (like `gh api`)
iohr openapi pull              the OpenAPI document this profile sees, to a file
iohr sdk generate              an SDK cut to one or more profiles (RFC 0020)
iohr sdk check                 fail when the generated SDK no longer matches
iohr completion <shell>        shell completions

Every command takes --profile, reads IOHR_PROFILE, and prints JSON with --json. Exit codes are documented and stable: 0 success, 1 a failed call, 2 a usage error, 3 not signed in, 4 forbidden by scope or plan.

Signing in

iohr login offers both standard grants for a command line, and both are first class:

  • In a browser on : the authorization code grant with PKCE on a loopback redirect (RFC 8252). The command opens the system browser, the developer signs in or is already signed in, and the terminal receives the tokens on a local port it listens on for that one request.
  • With a device code: the OAuth 2.0 device authorization grant (RFC 8628). The terminal prints a short code and a link, the developer approves in any browser on any device, and the terminal receives the tokens. It works over SSH, in a container and on a headless server, where a loopback redirect cannot.

The command picks for the developer: the browser when it can open one on this machine, the device code otherwise (an SSH session, no display on Linux, a container, a CI runner). --web and --device force either. Our identity provider supports both. The device grant needs one new page in our sign-in app, where the code is typed and approved. It runs on the identity provider's current open-source release. A later release makes a device code strictly single use even when two requests redeem it at the same moment ( changelog); it is not yet published as open source, and we upgrade when it is.

A diagram is drawn here in the RFCs product; this page does not show diagrams yet.

--with-token takes an API token instead, for CI and for people who prefer to create one in the console. A key (client id and secret) is never stored by the command line: servers use keys through the SDK, not through a developer's keychain.

Profiles and several accounts

A profile is a name for one way of calling the API: who signs in, and which account the calls count against. A person can belong to their personal account and to several teams, and can hold API tokens for any of them, holds several profiles:

# the config file in the platform's config directory
# (XDG on Linux, Application Support on macOS, %APPDATA% on Windows)
default = "personal"

[profiles.personal]
kind = "person"           # signed in with `iohr login`
account = "acc_…"

[profiles.acme-ci]
kind = "token"            # an API token for the Acme team
account = "acc_…"

The file holds names, account ids and settings, never a secret. Secrets go to the operating system's credential store (macOS Keychain, Windows Credential Manager, the GNOME Keyring or KWallet on Linux) through the keyring crate, one entry per profile keyed by profile and account. gh learned the hard way that an entry keyed by service alone returns an arbitrary token once two accounts share a host (cli/cli#12885). A machine without a credential store (a CI runner) uses IOHR_TOKEN and nothing is written to disk.

The active profile is chosen per invocation (--profile, then IOHR_PROFILE, then default). There is a profile use to change the default, but nothing depends on a global switch: two terminals building for two accounts at the same time must not race each other.

A diagram is drawn here in the RFCs product; this page does not show diagrams yet.

How the code is laid out

A Cargo workspace in the SDK repository, beside the SDKs, so the command line uses the Rust SDK for its own calls and every conformance case the SDKs pass covers it too:

Crate Kind Holds
iohr binary argument parsing (clap derive), output, exit codes; no logic of its own; cargo install iohr
iohr-auth library the grants, the credential store, profiles
iohr-openapi library fetching a document, normalising it, the generator's model of it
iohr-codegen library one target per language, rendering that model to files
inorbithr library the Rust SDK (already reserved on crates.io, matching the @inorbithr npm scope)

The libraries return typed errors (thiserror); only the binary turns them into a message and an exit code. This is the split the Rust CLI book and the API guidelines describe, and the one cargo, rustup and uv follow: thin binary crate, tested libraries.

Generics where they remove a bug:

  • A credential is a trait. trait Credential { async fn bearer(&self) -> Result<Bearer, AuthError>; } with three implementations (a person's refreshing session, a pasted token, a key's exchange). Every command is written once against impl Credential; a test swaps in a fixed one.
  • A sign-in is a typestate. A device authorization is Pending until approved and Granted after; only Granted can be turned into a stored profile, so a half finished sign-in cannot be saved.
  • A target is a trait. trait Target { type Options: clap::Args; fn render(&self, api: &Api, opts: &Self::Options) -> Result<Files, RenderError>; }; adding a language is one implementation, with its own flags, and nothing else changes.
  • A secret is a type. Redacted<T> prints <redacted>, is zeroed on drop (zeroize) and is never Serialize; the SDK's rule that secrets never print holds in the command line by construction.

What it does not do: no async-trait macro (native async traits since Rust 1.75), no trait objects where a generic is static, no builder macros, no global state, no unsafe. Edition 2024. MSRV is the SDK's (Rust 1.94), declared with rust-version and tested in CI; the MSRV-aware resolver keeps a dependency from raising it silently. Clippy pedantic with warnings denied, cargo-deny for licences and advisories, cargo-semver-checks on the libraries. The dependency list is short and every entry has a line in the README saying why: clap, tokio (current-thread), reqwest with rustls, serde, toml, thiserror, keyring, zeroize, minijinja (templates), and typify (Rust types from JSON Schema).

Tests

Unit tests in each library; the SDK repository's conformance cases (token refresh, retries, error mapping) run against the command line's client too; the device grant and the token commands run against a local copy of the identity provider in CI; sdk generate has snapshot tests per target and a compile test that builds what it generated. CI runs every test on Linux, macOS and Windows, including the credential store on each. A release is never cut from a red main.

Alternatives considered

  • inorbit as the name. Another company uses the InOrbit name for robotics software and owns the @inorbit npm scope (the SDK's ADR 0006 already moved npm to @inorbithr). iohr avoids the clash and matches the names already on the wire.
  • Go, like gh, flyctl and the stripe CLI. A fine choice, and the platform already has Go in its SDK set. Rust wins here because the generator shares its model and its Rust target with the Rust SDK, the platform itself is Rust, and the user asked for it.
  • One grant only. A loopback redirect alone fails over SSH and in containers; a device code alone makes the common case, a laptop with a browser, a step longer. Offering both, chosen automatically, costs one extra code path.
  • Tokens in a file, like gh once did. The keychain is the right home on a ; a file stays an option only behind an explicit flag, mode 0600, and is what CI never needs because it uses an environment variable.
  • Generating the SDK in the browser (console download). Fine as a second door later. The command line comes first because the SDK is regenerated in the developer's repository and checked in CI (sdk check), which a download cannot do.
  • Putting the command line in the platform repository. It is public code and belongs with the SDKs, their licence, their conformance cases and their release process.

Decision

Decided 2026-10-02:

  1. The binary is iohr; the crates are iohr, iohr-auth, iohr-openapi and iohr-codegen, the Homebrew formula and the Debian package iohr, the environment variables IOHR_TOKEN and IOHR_PROFILE. Every one of these names was free on 2026-10-02. The Rust SDK keeps the crate name inorbithr.
  2. Both sign-in grants ship in the first release: the browser when one can be opened here, the device code otherwise, --web and --device to force either.
  3. Windows is in the first release, beside Linux and macOS.

Publication

When it ships: the docs gain a "Command line" guide and the quickstart offers brew install / apt install next to "Create a test token"; the console's tokens page shows iohr login --with-token beside a new token; this RFC's status log records the release.

Status log

  • 2026-10-02: proposed with RFC 0020 (SDK generation) and RFC 0021 (packages).
  • 2026-10-02: decided with the user's answers: the name iohr, both sign-in grants chosen automatically, Windows in the first release.
  • 2026-10-02: the device grant ships on the identity provider's current open-source release instead of waiting for the next one. The difference is narrow: a code redeemed twice in sequence is already refused; only two redemptions at the same instant could both succeed, and that needs the device's own secret code, which never leaves the terminal that started the sign-in (the person only types the short code). We upgrade when the open-source release with strictly single-use codes is published. The approval page is never skipped, warns against codes sent by someone else, and code entry is rate limited.
  • 2026-10-02: phase one built, in the open-source SDK repository, not yet packaged. iohr signs a person in, in a browser on or with a device code, keeps several accounts as profiles with secrets in the operating system's credential store, refreshes and revokes its session, manages API tokens, and calls the API. Checked against the platform: both sign-ins, a token made, used, refused outside its scopes and revoked within two seconds, and a session revoked on sign-out. Packages follow RFC 0021.
  • 2026-10-02: first pre-release, 0.1.0-alpha.2, installable with Homebrew, APT and the installers (RFC 0021); the docs gain a Command line guide and the console shows iohr login --with-token beside a new token.
  • 2026-10-07: Checked: iohr ships from inorbithr/sdk; nothing since the last line changes the decision. Status unchanged.

← Back to Platform