Skip to content

Support multiple authenticated contexts with a persistent active selection #28

@weppos

Description

@weppos

Description

Today the CLI's authentication is implicitly anchored to a single environment per invocation: production by default, sandbox only when --sandbox is passed. Even though credentials.yml can store credentials for both hosts simultaneously, there is no notion of an active selection — every command that targets sandbox needs the flag re-typed, and a user with multiple accounts in the same environment cannot keep them authenticated at the same time.

We want a model where the user can be authenticated with multiple distinct credentials (e.g. production-personal, production-work, sandbox-test) and select one active context at a time. Subsequent commands should target the active context without requiring --sandbox or --account on every invocation. At the same time, agents and parallel shells must be able to bypass the active context entirely with per-invocation overrides.

Why this matters

  • Switching environments mid-session is high-friction: --sandbox must be re-typed for every command.
  • A user with access to more than one account in a single environment cannot keep both authenticated at once today; logging into a second account replaces the first.
  • The mental model people bring from gh, kubectl, and aws --profile is "named context, active one at a time". The CLI doesn't match.
  • Agents and automation running multiple invocations in parallel need to address specific stored credentials without mutating shared state.

Current state

credentials.yml is keyed by host:

hosts:
  api.dnsimple.com:
    token: ...
    account_id: "981"
  api.sandbox.dnsimple.com:
    token: ...
    account_id: "24"

Limitations:

  • At most one token per host.
  • No persistent "active" pointer — production is hard-coded as the default in Config.Sandbox.
  • --sandbox is the only way to target sandbox, on every command.
  • auth switch <id> only changes the default account for whichever host the per-invocation --sandbox flag selects.

Expected Behavior or Outcome

Rework credentials.yml into a list of named contexts, each carrying its own host, token, and default account:

contexts:
  - name: personal
    host: api.dnsimple.com
    token: tok_a
    account_id: "981"
  - name: work
    host: api.dnsimple.com
    token: tok_c
    account_id: "550"
  - name: sandbox
    host: api.sandbox.dnsimple.com
    token: tok_b
    account_id: "24"
active_context: sandbox

The CLI supports two operating modes that compose cleanly: stateful (use the active context) and stateless (per-invocation overrides).

Stateful mode (interactive default)

With no overrides, every command uses active_context. The user manages contexts with auth login, auth list, auth switch, auth status, auth logout. This is the path for an interactive shell session.

Stateless mode (per-invocation overrides)

For agents, automation, and parallel shells where the active context cannot be assumed or mutated, three overrides are available, in increasing rawness:

  • --context <name> — picks a stored context by name for this invocation only. Does not change active_context. This is the agent-friendly handle: "give me a pointer to a stored credential".
  • --sandbox — host override. Tells the CLI to talk to the sandbox API for this invocation. Composes with the other overrides. When passed standalone and multiple sandbox contexts exist in storage, the CLI errors with the list of candidates and asks the user to disambiguate via --context.
  • --token / --account — raw credential overrides. Highest precedence. Used when the agent has credentials from outside credentials.yml (env vars, secret manager, vault).

Resolution chain

For each of (token, host, account):

1. Explicit raw flag      (--token / --sandbox / --account)
2. Environment variable   (DNSIMPLE_TOKEN / DNSIMPLE_ACCOUNT)
3. --context <name>       (loads named context from credentials.yml)
4. Active context         (credentials.yml.active_context)
5. Error: not authenticated

--sandbox and --context are independent and compose. You can use either, both, or neither.

Command surface

  • auth login [--sandbox] [--name <name>]
    • Authenticates and creates a new context. --name is optional; auto-derived if omitted.
    • The newly created (or refreshed) context becomes active.
    • The local --sandbox flag here means "this login is for the sandbox host".
  • auth logout [--name <name>]
    • Removes a context. Defaults to the active one. If the active context is removed, no context is active until the user picks one.
  • auth list
    • Lists all stored contexts, highlighting the active one. Read-only; does not require network or a valid token.
  • auth switch [<name-or-account-id>]
    • With an argument: switches the active context to the matching one (by name or by account ID; errors if ambiguous).
    • Without an argument: opens an interactive picker.
    • Switching is a local operation only — no API call. auth switch no longer touches accounts; changing the account inside a context is done by re-running auth login.
  • auth status

There is no separate auth useauth switch subsumes that role.

Acceptance Criteria

  • credentials.yml uses the new contexts: + active_context: schema; the legacy hosts: map is migrated automatically on first load.
  • The pre-migration file is preserved as credentials.yml.bak so users have a manual rollback path.
  • auth login creates a new context, auto-deriving its name when --name is omitted (per the algorithm in Notes); when --name is provided, the name is honored or rejected per the same rules.
  • Re-logging in with a token that already exists in storage mutates the existing context in place rather than creating a duplicate.
  • auth list lists all stored contexts, highlights the active one, and works with no network and no valid token.
  • auth switch [<name-or-account-id>] updates active_context locally with no API call; without arguments it opens an interactive picker.
  • auth logout [--name] removes a context and updates the active selection if needed.
  • auth status reports the active context's name, host/environment, and account, including the Warning row from auth switch silently accepts invalid accounts and auth status ignores stored default #25.
  • Global flag --context <name> is added and integrates with the resolution chain; passing it does not modify active_context.
  • --sandbox continues to work as a per-invocation host override and composes with --context, --token, and --account.
  • --sandbox standalone with multiple sandbox contexts stored returns an actionable error listing the candidates and pointing to --context.
  • --token and --account continue to behave as raw overrides at the highest precedence and bypass credentials.yml entirely when both are supplied.
  • The four concurrent-usage patterns in Notes all work end-to-end without interfering with each other.
  • README and dnsimple ai document both the stateful and stateless modes, with the same patterns shown in Notes.
  • Existing unit and integration tests are updated; new tests cover login naming, switching, listing, the picker, the resolution chain, the migration, and the multi-context error path for --sandbox.

Resources/References

Notes

Concurrent and multi-context usage

The two-mode model is designed to support both interactive single-user shells and concurrent automation in the same CLI binary. The patterns below all run side by side without interfering with each other, because the only shared state (active_context) is touched exclusively by auth switch.

Pattern 1 — Interactive user, occasional one-shot to a different context

A developer working primarily in production occasionally needs to peek at sandbox without losing their place.

$ dnsimple auth status
Context  personal
Account  alice@example.com (981)

$ dnsimple zones list                    # uses active context (personal / production)
$ dnsimple --context sandbox zones list  # one-shot sandbox; active context untouched
$ dnsimple zones list                    # back to active (still personal / production)

Pattern 2 — Multiple terminals each pinned to a different context

Two tmux panes (or two terminal windows) work in parallel, each effectively "logged in" to a different context for the duration of the session, without ever calling auth switch.

# Pane A
$ alias d-prod='dnsimple --context personal'
$ d-prod zones list
$ d-prod records list example.com

# Pane B (concurrent, independent)
$ alias d-sbx='dnsimple --context sandbox'
$ d-sbx zones list

active_context is never mutated by either pane, so neither pane's auth status is affected by the other's work.

Pattern 3 — Agent fanning out across stored contexts

An automation agent runs the same command across N stored contexts in parallel.

$ for ctx in personal work sandbox; do
    dnsimple --context "$ctx" zones list --json &
  done
$ wait

Each invocation reads its own context from credentials.yml. None of them touch active_context. The user's interactive session keeps whatever context was active before and after.

Pattern 4 — Agent with raw credentials from a secret manager

An agent holds tokens for accounts that aren't (and shouldn't be) stored in credentials.yml — typically because they come from a vault or environment.

$ dnsimple --token "$PROD_TOKEN" --account 981 zones list
$ dnsimple --sandbox --token "$SBX_TOKEN" --account 24 zones list

credentials.yml is never read. The CLI is fully stateless for this invocation.

Combining overrides

The override flags compose field-by-field, in the precedence order documented above. A few useful combinations:

# Use the 'work' context's token+host, but target a different account the token can see
$ dnsimple --context work --account 12345 zones list

# Use a stored context but inject a token from elsewhere (e.g. just-rotated, not yet saved)
$ dnsimple --context work --token "$NEW_TOKEN" zones list

--context provides the baseline; raw overrides win where present.

Interactive picker

Example shape of auth switch with no argument:

$ dnsimple auth switch
  [1] personal     production  alice@example.com (981)
  [2] work         production  bob@example.com   (550)
  [3] sandbox      sandbox     carol@example.com (24)
Select: _

Naming algorithm

Auto-derived names when --name is omitted, evaluated in order:

  1. Re-login detection. If any existing context has the same (host, token) as the new login, mutate it in place. The context name is whatever it already is.
  2. Bare environment name. Try production or sandbox. If free, use it.
  3. Account-suffixed. Try <env>-<account_id> (e.g. production-981). If free, use it.
  4. Numeric suffix. Try <env>-<account_id>-2, then -3, ... Used only when two distinct tokens grant access to the same account in the same environment.

When --name foo is passed explicitly:

  • Free → create.
  • Exists with the same (host, token) → mutate (refresh metadata).
  • Exists with a different (host, token)reject: context "foo" already exists; pick a different name or run 'dnsimple auth logout --name foo' first. We never silently overwrite a token under a user-supplied name.

Note: account IDs in DNSimple are not unique across environments — account 24 in sandbox is unrelated to account 24 in production. The <env>- prefix is therefore mandatory in the auto-derived form.

Migration

Existing credentials.yml files using the hosts: map need a one-time, in-place migration on first read:

  • Each host entry becomes a context named after the environment (production, sandbox).
  • If both exist, production is set as active_context to preserve current default behavior.
  • Old hosts: key is removed after a successful write.
  • The pre-migration file is preserved as credentials.yml.bak for one release cycle so users have a manual rollback path.

The migration is silent on success and idempotent.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions