Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

150 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

@hasna/accounts

Manage and switch between multiple AI coding tool profiles/accounts on one machine — Claude Code, Takumi, Codex CLI, Codex App, Codewith, Gemini CLI, opencode, Cursor Agent, Pi Coding Agent, Hermes, Kimi Code, Grok Build, and custom tools.

accounts is a local-first CLI. Each profile is an isolated config directory. Switch in the terminal with CLAUDE_CONFIG_DIR, or in Cursor / VS Code with accounts apply (syncs auth to live ~/.claude paths).

  • Isolated account state — separate config dirs and credentials, with explicitly shared Claude capabilities such as skills, agents, and merged session history.
  • Apply mode — sync OAuth / credentials to live paths for IDEs (Claude-only today).
  • Remembers the email — auto-detected from .claude.json when possible.
  • Multi-tool — first-class built-ins for Claude, Takumi, Codex CLI, Codex App, Codewith, Gemini, opencode, Cursor Agent, Pi, Hermes, Kimi Code, and Grok Build; custom tools via accounts tools add.
  • Tool-specific profiles — each profile belongs to one tool, and older duplicate names must pass --tool on bare commands.
  • Local-first — the registry defaults to ~/.hasna/accounts/; network access is opt-in for API storage and usage refreshes. No telemetry is sent by default.
  • Open source — source, docs, and contribution guidelines live in this repository.

Install

bun install -g @hasna/accounts
accounts --help

Requires Node ≥ 18 (or Bun ≥ 1.0).

Quick start (two Claude subscriptions)

# 1. Import your current install (optional)
accounts import main --dir ~/.claude

# 2. Create profiles
accounts add work --email work@company.com
accounts add personal --email me@gmail.com

# 3. Log in once per profile (isolated dir)
accounts login work --tool claude        # or omit --tool and choose Claude in the prompt
accounts login personal --tool claude    # login, exit; it becomes the live/default account

# 4. Switch
accounts apply work                 # Cursor / VS Code — live ~/.claude auth
accounts apply personal

# Or terminal-only (parallel sessions OK):
accounts launch work
eval "$(accounts env personal)"      # other terminal

# Or supervised: lets MCP switch/restart this Claude process automatically
accounts use work
accounts run claude --resume
accounts switch personal --supervisor   # from another terminal

After accounts login <name>, accounts snapshots the auth Claude wrote, updates the detected email, and applies that profile to live ~/.claude paths automatically. accounts apply still refuses profiles without auth so live OAuth is not wiped.

Codex App profiles on macOS

Codex CLI profiles use --tool codex. The macOS desktop app needs its own tool because it also needs an isolated Electron user data directory:

# Create/sign into a desktop app profile. Quit Codex.app after login finishes.
accounts login personal --tool codex-app
accounts login work --tool codex-app

# Switch by launching the desired app profile.
accounts launch personal --tool codex-app
accounts launch work --tool codex-app

# Or print/launch the exact handoff command.
accounts switch work --tool codex-app
accounts switch work --tool codex-app --launch

# Or run a native macOS menu-bar switcher.
accounts codex-app menubar

codex and codex-app are separate tool ids. If a registry created by an older version contains the same account name for both, pass --tool codex or --tool codex-app to choose explicitly; bare profile commands reject the ambiguous name even when login has stored a tool selection. The same rule applies to registered variants such as a custom claude-app or claude-cowork: each tool id gets its own profile directory.

Each codex-app profile gets its own CODEX_HOME and --user-data-dir=<profile>/electron-user-data. Before login, launch, switch, or shell commands, accounts ensures the profile root config.toml has cli_auth_credentials_store = "file" so ChatGPT auth stays in that profile directory instead of sharing one macOS Keychain credential.

The menu-bar switcher lists codex-app profiles, marks the active profile, and switches with a button click. A switch marks the selected profile active, asks a running Codex.app to quit, waits briefly, and relaunches Codex.app with the selected profile's isolated CODEX_HOME and Electron user data directory.

Three pointers (active, applied, isolated)

Pointer / mode Set by Meaning
Active accounts use, interactive launch/shell, pick Registry current — which profile you intend (terminal + hook)
Applied accounts apply, pick (default) Registry applied — auth on live ~/.claude (what Cursor sees)
Isolated env, launch, shell Per-process CLAUDE_CONFIG_DIR; does not change live disk
Mode Command Best for
Isolated accounts launch, accounts env, accounts shell Terminal, two accounts at once
Apply accounts apply <name> Cursor, VS Code, single global auth
Picker accounts pick Interactive choose; default applies to live paths
In-place accounts switch-account <name> Change the RUNNING Claude session's account mid-conversation, no restart

In-place switching works because Claude Code re-reads <configDir>/.credentials.json from disk on every API request (measured on 2.1.220): switch-account snapshots the dir's outgoing credentials back to their owning profile, installs the target profile's credentials + oauthAccount into the session's config dir, and the session's next message runs as the new account with the conversation intact. Every live session sharing that config dir switches together, so the command refuses multi-session dirs without --yes. If a target profile's auth is expired it fails loudly before touching anything.

accounts use alone does not change Cursor — run accounts apply for IDE auth.

A child process cannot change your parent shell — from a POSIX shell (sh/bash/zsh), use eval "$(accounts env …)", or use the shell hook (terminal claude only, not IDE extensions). Other shells should use accounts launch; fish/nushell users may also use accounts shell when SHELL identifies that shell. Accounts does not currently emit fish, nushell, or PowerShell assignment syntax.

Request-debug environment policy

Credential-bearing launches, probes, subshells, and generated handoffs intentionally remove exactly three request-dump controls: BUN_CONFIG_VERBOSE_FETCH, NODE_DEBUG, and NODE_DEBUG_NATIVE. accounts env and accounts pick --env print an unset before their exports; non-launch accounts switch commands and the optional claude() shell hook use env -u for the same keys. The hook changes only the provider child, not the parent shell. This also prevents a custom tool's profile environment from restoring those controls.

Controlled launch and prelaunch errors also redact complete Authorization, Proxy-Authorization, Cookie, and Set-Cookie records, including folded quoted values, token fragments, arbitrary extension parameters, and cookie attributes. Generic credential fields such as x-api-key, client-secret, and token fields use the same folded-record boundary instead of redacting only their first token. Folded input is scanned linearly and fails closed until a clear record boundary, so blank folds, escaped quotes, and malformed or unfamiliar credential syntax are not emitted merely because a parameter name resembles a diagnostic or serialized field. Only structurally quoted serialized siblings are retained. Captured stderr and stdout are separated as distinct records before bounded redaction, preventing a missing trailing newline from fusing a diagnostic prefix with a credential-bearing header. Credential keys are classified through one separator/camel-case-aware policy, so dot-, space-, separator-, and camel-case forms such as oauth.key, oauth key, oauth_token, consumerSecret, sessionKey, and webhookCredential, plus stemmed forms such as credentials, secret-key, service-account-key, auth-header, service-auth, and bearer, receive the same treatment. Any distinct normalized key token is sensitive, including encryption-key, client-key, and access-key-id, without matching unsplit words such as keyboard, keynote, or monkey. Valid JSON is redacted recursively after decoding escaped keys, while malformed serialized fragments remain fail-closed. Supervisor child arguments stay raw only for the immediate spawn; persisted state, restart responses, legacy state reads, and text/JSON status surfaces store or return argument-aware redacted command arrays. Long options using =, :, or a following value and the supported short -k forms share that policy. Combined short clusters ending in k and compatibility-normalized Unicode dash/letter forms are treated as credential options too. If a syntactically bare credential option appears while a prior option is still awaiting a value, the new option is redacted as syntax and becomes the pending option instead of being consumed as the earlier value. Credential-shaped fragments inside opaque or non-option dash-leading argv items remain bound values. An exact -- marker ends argv option interpretation in ordinary or pending state. Later positional arguments remain positional values, but each is independently passed through generic text redaction before the public command or rendered handoff is emitted. Attached credential fields are redacted in place. Empty-value credential fields redact their next non-empty positional value without consuming empty padding. Because authorization schemes may span multiple argv items, Authorization and Proxy-Authorization fail closed by redacting every later non-empty positional item; empty items remain empty. Wrapper-bound separate options such as env=--api-key preserve their wrapper syntax and carry exactly one pending positional value across empty padding. Structured URL, URN, email, and drive-path values remain data even when an interior segment is named authorization, so later safe tokens remain visible. Other benign values remain unchanged. The same bounded, quote-aware option scanner redacts command-shaped values embedded in captured stdout, stderr, and error strings. A separate credential option keeps one pending value across LF, CRLF, or bare CR and consumes the next syntactic value; quoting, escaping, or opaque non-bare syntax keeps dash-leading text bound as that value. Only a complete bare option or a complete sensitive attached form replaces pending state. Bare option syntax is exactly one or two compatibility-normalized leading dashes, an alphanumeric body start, then only alphanumerics, dots, underscores, or dashes; exact -- is reserved for the end-of-options marker. Compatibility normalization applies to option names, not this control token: only one complete raw, unquoted, unescaped, standalone ASCII -- token ends interpretation. Unicode dash pairs, quoted or escaped markers, wrappers, and punctuation-adjacent fragments remain value or text data, so they cannot expose a later credential option. Three-or-more dash runs and dot- or underscore-leading bodies, including malformed attached credential-looking forms, remain one opaque bound value. Attached values that resemble options, including --api-key=--client-key and its colon form, are redacted in place without consuming the next safe token. While a separate value is pending, the complete physical token is classified before embedded punctuation. Unless the whole token is proven option syntax, it is redacted as one bound value and the next whitespace-separated token remains unchanged. An open quote or odd trailing backslash activates one carried logical-value scanner across later physical fragments. While active, it redacts through quote closure and every adjacent non-whitespace suffix without treating punctuation, complete bare options, sensitive-looking options, or exact -- text as fresh syntax. Option classification resumes only after that logical token reaches whitespace. A blank physical line or an explicit status, message, stack, or detail record ends the pending command record, including an active quote or backslash continuation; this explicit record boundary takes precedence over incomplete shell syntax so sensitive state cannot carry into independent diagnostics. When no separate value is pending or active, options may begin after the package's safe punctuation boundaries (:, =, |, /, <, >, brackets, parentheses, commas, and semicolons), while embedded word, URL, email, and arithmetic forms remain ordinary text. Balanced punctuation inside structured URL/email values stays data, while a closing outer wrapper or quote resumes option parsing. The scanner still retains later whitespace-separated options and explicit unquoted standalone ASCII -- boundaries, and its line, token, and quoted-segment passes remain forward-only for bounded linear work. Captured stderr and stdout are bounded and redacted as separate process records, so a missing value at the end of one stream never consumes the first token from the other stream.

Switch output is an explicit hasna.accounts.switch-output/v1 DTO. It exposes only bounded profile/tool identifiers, status booleans, a redacted command and handoff line, and the user-facing message; internal profile metadata, tool configuration, environment maps, and export scripts do not cross that output boundary. Built-in labels come from the package-owned registry; caller-defined tool labels are represented as the opaque Custom tool label on switch and supervisor output. Raw arguments and environment values remain available only to the immediate provider spawn. Legacy prelaunch state is projected through its explicit schema rather than recursively copying unknown fields, and public recursive redaction uses null-prototype objects populated only from own enumerable data descriptors. Accessors, inherited fields, pollution keys, proxies, and non-plain objects are not evaluated or copied.

Unix supervisor directories, state files, and sockets are owner-only (0700, 0600, and 0600). Accounts refuses symlinked ACCOUNTS_HOME or supervisor components, refuses non-socket control-path replacement, snapshots directory identity, and revalidates the boundary before state writes, unlinks, chmods, listens, provider spawn, and post-prelaunch persistence.

Printed POSIX handoffs validate every environment-variable name, single-quote every value and command word without expansion, and place an explicit env -- option boundary before assignments. Profile directories and custom extraEnv values therefore retain spaces, quotes, newlines, backslashes, dollars, backticks, and leading hyphens as data rather than shell syntax. Public restart commands replace non-empty credential-named environment assignments with env -u NAME; empty credential variables remain explicit empty assignments.

Accounts otherwise preserves the caller's same-binding environment, including PATH, proxy and TLS settings, Bedrock/Vertex selection, and AWS/Google SDK configuration. Those settings, provider-specific flags/config files, and any other logging controls remain caller-trusted: Accounts does not claim to sanitize arbitrary provider configuration or a command the caller edits after generation.

Implementation details: docs/IMPLEMENT.md. The additive v2 migration preflight and durability contract is documented in docs/V2_MIGRATION_SIDECAR.md.

What is isolated, and what is shared

Account identity, credentials, and live process state are per-profile. Capabilities belong to the person at the machine, so every Claude profile reads the same explicitly configured corpus:

Concern Where it lives Shared?
OAuth account, credentials, keychain snapshot <profile>/.claude.json, <profile>/.credentials.json, <profile>/.accounts-auth/ No — per profile
Live per-process session state <profile>/sessions No — per profile
Transcripts (sessions, subagents, workflows) <profile>/projects~/.claude/projects Yes — symlink, after accounts sessions merge
Prompt history <profile>/history.jsonl~/.claude/history.jsonl Yes — symlink, after accounts sessions merge
Skills <profile>/skills~/.claude/skills Yes — symlink
Subagents <profile>/agents~/.claude/agents Yes — symlink
MCP servers mcpServers merged into <profile>/.claude.json Yes — merged (the profile's own entries always win)

The links are plain symlinks, so this is write-through: creating or deleting a skill from inside any profile changes the one shared corpus for all of them. That cuts both ways — one rm -rf through a link empties the corpus for every profile at once — so accounts records the size of each corpus when it links it and accounts doctor fails if the corpus later shrinks. Once you have confirmed a deletion was intended, accept it with:

accounts doctor --accept-capability-baseline

MCP servers cannot be linked (the file that holds them is rewritten in place), so they are merged member-by-member instead — a profile gains anything new in the shared set and never loses its own entries. Servers are unioned across the tool's declared sources with the first definition of a name winning, so rendered config takes precedence over templated config; a server whose command still contains an unsubstituted {{PLACEHOLDER}} is dropped rather than shipped broken, and the secrets server is excluded outright — a vault-retrieval tool in another identity's tool list is the closest thing to sharing tokens without sharing them. If the profile's own config file exists but does not parse, the merge is refused and reported: it is never rebuilt from scratch over whatever the file still held.

Sessions: merge first, then link

Sessions are the one capability that cannot simply be linked. A profile created before session sharing already owns a real, populated projects/ directory, and the linker deliberately refuses to replace a real directory a profile owns — that guard is what stops a link from swallowing real data. Listing sessions as a shared entry on its own would therefore do nothing at all while reporting success. The contents have to be unioned into the shared home first:

accounts sessions merge --dry-run      # report only, writes nothing
accounts sessions merge                # union every profile's sessions into the shared home
accounts sessions merge --link         # …and then point each registered profile at it

What the merge guarantees:

  • Nothing is deleted, ever. Source files are only read. When --link replaces a profile's own directory, the original is renamed to <profile>/.accounts-session-migration/<timestamp>/, never removed, and if the swap fails at any point the rename is undone and that profile is left exactly as it was.
  • Files are merged with link(2), not copied. The shared home gets the same inode, so a transcript that is still being appended to arrives whole instead of as a torn prefix of itself, mtimes are preserved (the tool prunes sessions by age — a copy that reset them would stop pruning entirely), the migration costs no disk, and a re-run is a no-op by construction: the same inode means the file is already merged. Copying is the fallback for a source on another filesystem, and it refuses a JSONL body that does not end in a newline.
  • Files are keyed by their full path under projects/, never by name. The tree is nested — subagent and workflow transcripts sit several directories down, and journal.jsonl alone occurs at hundreds of distinct paths — so a name-keyed merge would collapse unrelated files into one.
  • A conflict never overwrites. Same path, identical bytes: nothing to do. Same path, and the shared copy is a proven byte prefix of the incoming one: the longer one replaces it atomically, which cannot lose a byte. Anything else is a genuine fork, and both copies are kept — the second under a deterministic <name>.from-<source>.<hash>.jsonl, so a re-run finds its own copy instead of making another.
  • Sources are enumerated from the filesystem, not the registry. Profile directories that Accounts has forgotten still hold real transcripts, and they are merged too. They are never linked, because only registered profiles are visited by the launch-time repair that keeps a link pointing at the right place; they are listed in the report so you can accounts import them and merge again.
  • --from <dir> merges extra read-only trees, such as a backup taken before the migration, so a transcript deleted from the live tree since the backup is restored rather than lost.
  • It verifies before and after it links. Transcript counts are taken recursively on the shared side — adding the sources back in would double-count every hardlink — and the shared tree must have grown by exactly the transcripts the run placed. The count is re-taken after the link step, because that is the only part of the run that can destroy anything. A profile is linked only when everything under it is demonstrably in the shared home, and anything written to it during the run is merged out of the retained tree before the swap is accepted.
  • Symlinks inside the tree are reproduced, not discarded. Claude Code creates them for forked and resumed subagent transcripts. They are rewritten relative so the shared corpus stays portable; a link pointing out of the tree is refused with guidance rather than followed.
  • A dry run reports bounds, not exact counts. It places nothing, so each source is compared against a shared tree that never received the earlier ones: merge counts are an upper bound and collision counts a lower bound.

<profile>/sessions is deliberately not shared: it holds one file per running process, with a status heartbeat, and each instance reaps entries whose process it believes to be dead — two profiles sharing it would reap each other's live sessions. Nothing is lost by keeping it per-profile, because a session's durable record is its transcript under projects/.

Because the shared corpus is write-through, accounts doctor records a floor for it and fails if it shrinks. For projects/ that floor is counted recursively: its top level is one directory per project, so a top-level count would not move even if every transcript inside were deleted.

Instruction files (CLAUDE.md, rules/) are not handled here, and the status quo is not by design: Claude Code discovers memory by walking the working directory's ancestors, not the config dir, so a copy inside a profile would never be read. They load today only when the working directory happens to sit under the home directory that holds them — a session started outside it loses them silently.

Sharing is materialized when a profile is created and re-checked on every launch, so profiles created by older versions are repaired the next time they are used; accounts doctor reports any profile that is not actually sharing. Which entries and config keys are shared is per-tool data (sharedEntries / sharedConfig on a tool definition), so a custom tool registered with accounts tools add can opt in. The shared home defaults to the tool's default config dir and can be overridden per machine with ACCOUNTS_SHARED_HOME_<TOOL_ID> (e.g. ACCOUNTS_SHARED_HOME_CLAUDE).

Switching modes (summary)

  • accounts active — prints active profile (store.current); scripting.
  • accounts applied — prints applied profile (store.applied); scripting.
  • accounts current — human-readable active (+ applied hint) per tool.
  • accounts list active, applied, ●◉ when both are the same profile.

Commands

The table below covers common workflows. The complete, option-for-option command tree is in CLI reference and is also available from accounts <command> --help.

Command Description
accounts add <name> Create a profile. --tool, --email, --display-name, --identity, --card-last4, --metadata key=value, --dir, --description.
accounts import [name] Import existing config dir (default ~/.claude). --copy for managed copy.
accounts login <name> Choose a tool when needed, lock the profile name to that tool, then launch that tool's login flow in an isolated profile dir. Use --tool to bypass or change the chooser.
accounts apply <name> Apply profile auth to live Claude paths (requires snapshot; Claude-only).
accounts pick Interactive picker; default applies. --env, --no-act, or non-interactive usage-aware selection with --healthiest.
accounts switch <name> Switch profile and print a restart/resume command. Add --resume, --launch, or --permissions <preset>. Use --tool only when ambiguous.
accounts switch <name> --supervisor Ask a running accounts run <tool> supervisor to restart under that profile. Supports --permissions <preset>.
accounts switch-account [name] Switch the CURRENT session's account in place — no restart, conversation intact (Claude-only; the running session re-reads .credentials.json on its next request). Picker when no name; --dir, --yes, --json.
accounts use <name> Mark profile active; prints apply/env hints.
accounts list (ls) List profiles ( active, applied, ●◉ both).
accounts show <name> --tool <tool> Profile details including active/applied flags.
accounts current Active profile per tool (with applied hint).
accounts active [tool] Print active profile name (scripting).
accounts applied [tool] Print applied profile name (scripting).
accounts env [name] Print POSIX sh/bash/zsh export ... lines for the profile. Use --tool only when ambiguous or when no name is passed.
accounts launch <name> Launch tool once with profile env. Supports --permissions <preset>.
accounts run <target> [args...] Run a tool or profile under the supervisor so MCP/CLI can switch and restart it. A tool target accepts --profile; a profile target accepts --tool. Supports --resume and --permissions. Claude --headless/--background runs bypass the supervisor.
accounts supervisor status [tool] Show running supervisors.
accounts supervisor switch <name> Switch a running supervisor to another profile. Use --tool only when ambiguous.
accounts supervisor stop <tool> Stop a running supervisor and its child process.
accounts shell <name> Subshell with profile env.
accounts hook install Install claude() wrapper — see docs/hook.md.
accounts hook uninstall Remove hook script.
accounts hook path Print hook script path.
accounts agents List Claude agent sessions across all profiles, the default ~/.claude dir, and untracked processes (claude agents only shows the current account). Provider records and process command lines are recursively projected through the public redaction boundary before human or JSON output. Live process attribution requires the kernel-reported PID executable to match the configured direct executable, its exact current PATH target/real path, or the native version root. Node/Bun wrapper rows fail closed because the kernel executable proves only the mutable interpreter, not the child script; missing PID identity also fails closed. --background, --profile <name>, --json.
accounts sessions (sessions list) Read-only catalog of root Claude sessions owned by registered local profiles. --profile, --project, --uuid, --json.
accounts sessions merge Union Claude transcripts/history into the shared home; --link opts registered profiles into shared history. Supports --dry-run, --profile, --from, and --json.
accounts usage One cached, cross-tool profile view: Claude session/weekly headroom where measured, readiness proxy otherwise, plus last switch, process occupancy, active/applied state, and conservative launchability. --tool filters; --refresh explicitly refreshes supported provider usage. See usage-aware switching.
accounts usage-hook Fail-open Claude UserPromptSubmit hook for cached usage-aware switching; not installed automatically.
`accounts auth status migrate
accounts sessions resume <catalogRef> Same-binding native Claude resume. Requires --account <name> and --session-id <uuid>; rejects cross-binding transfer and supports --dry-run --json.
accounts health (readiness) Print the sanitized account/provider readiness contract. Use --json for automation.
accounts detect <name> Re-detect email from config dir.
accounts doctor Check registry and dirs (exits 1 on errors).
accounts-serve Start the Bun HTTP API for the cloud storage mode. Supports --port, --host, public probes, and authenticated /v1 account routes.
accounts-migrate Check or apply the cloud Postgres schema migrations. Use --dry-run to print the pending migration plan without mutating the database.

See the CLI reference for set, rename, remove, tools, codex-app, storage, contracts, events, and webhooks.

Claude session catalog

accounts sessions and accounts sessions list scan only projects/<encoded-project>/<uuid>.jsonl under verified local Claude profiles. The live ~/.claude directory is included only when an Accounts profile explicitly represents it. Foreign or missing profile paths and symlinks are rejected with a warning. A renamed managed profile remains represented through its stored canonical config dir, provided that dir is still a direct child of the Accounts-managed Claude profiles root. Traversal and nested roots are rejected. Session files with multiple hard links are also rejected.

The default table shows owner, project, UUID, update time, size, and a bounded session-ID check. BOUNDED-MISMATCH and NOT-OBSERVED remain visible instead of looking like healthy entries. --json also returns an opaque catalogRef, the source profile identity, canonical profile and source paths, the encoded project key, and the bounded sessionIdCheck result needed to distinguish collisions or report a filename/metadata mismatch. The v2 reference includes the canonical profile root, encoded project key, UUID, and source path, but no mutable account name or profile identity. Multiple account records representing the same canonical source storage are emitted as one entry with sorted representations; the deterministic primary representation remains in the flat compatibility fields and the table shows every represented owner. catalogRefAliases contains the sorted, deduplicated v1 references emitted by the landed catalog, and the catalog resolver accepts either a canonical ref or one of those explicit aliases. Unknown development refs fail closed. A continuation journal must canonicalize a resolved alias before creating a new transaction rather than treating an alias change as a second request. The bounded metadata scan is discovery only and does not assert that the whole transcript is valid; continuation brokers must validate the complete source strictly.

accounts sessions resume <catalogRef> --account <name> --session-id <uuid> is deliberately narrower than cross-account transfer. It validates that the current catalog reference still resolves, that the explicit UUID matches the resolved session and its bounded metadata check, and that the target account is a current representation of that exact same local Claude profile root. If those same-binding gates pass, Accounts launches native claude --resume <uuid> in the cataloged project cwd using the target profile's normal launch environment. It creates no copied transcript, continuation transaction, or target seed, and it rejects attempts to resume into another profile binding. Use --dry-run --json to inspect the validated launch plan without starting Claude.

--uuid requires canonical hexadecimal 8-4-4-4-12 UUID syntax. A valid UUID with no match returns an empty result successfully; malformed syntax exits nonzero with a validation error.

Catalog reads never change transcript content and prompts/messages are never emitted. On platforms that support it, Accounts requests O_NOATIME; when the flag is unavailable or not permitted, the read-only fallback may update filesystem access-time metadata.

Scanning a machine that is actively writing sessions never truncates the catalog silently. A path that keeps changing is retried, and anything still unreadable is listed as a warning: on stderr, so a consumer can tell "no such session" from "not observed on this pass". Registered Claude roots that are missing or outside the trusted direct-child/default-root boundary are also reported instead of disappearing silently. A represented profile with malformed UTF-16 identity metadata is skipped and reported by reason and canonical source path only; the identity text is not emitted. stdout stays a clean stream: closing the pipe early — accounts sessions --json | head — exits 0 without a stack trace.

Cloud Runtime Entrypoints

The published package also includes two operator entrypoints for the Postgres-backed API runtime. They are separate from the local-first accounts CLI and are intended for service deployments, one-shot migration jobs, and local ops against the same cloud storage mode.

Start the HTTP service with:

HASNA_ACCOUNTS_STORAGE_MODE=cloud \
HASNA_ACCOUNTS_DATABASE_URL=postgres://... \
HASNA_ACCOUNTS_API_SIGNING_KEY=... \
accounts-serve --port 8080 --host 0.0.0.0

accounts-serve runs on Bun. It reads PORT or ACCOUNTS_SERVE_PORT when --port is omitted, defaults to port 8080, and defaults to host 0.0.0.0. It requires HASNA_ACCOUNTS_STORAGE_MODE=cloud, HASNA_ACCOUNTS_DATABASE_URL, and an API signing key from HASNA_ACCOUNTS_API_SIGNING_KEY or the shared HASNA_API_SIGNING_KEY fallback.

The public probes are:

  • GET /health — database reachability and package version.
  • GET /ready — database reachability plus migration ledger status.
  • GET /version — package version.

Authenticated /v1 account routes require API keys with the accounts:read or accounts:write scopes.

Run migrations before serving, or as a deployment one-shot:

HASNA_ACCOUNTS_STORAGE_MODE=cloud \
HASNA_ACCOUNTS_DATABASE_URL=postgres://... \
HASNA_ACCOUNTS_RUNTIME_ROLE=accounts_app \
accounts-migrate --dry-run

accounts-migrate is idempotent and uses the checksum-guarded migration ledger for the accounts schema. --dry-run prints the current plan as JSON without DDL. Without --dry-run, it applies pending migrations and prints a JSON summary; when the ledger is already current it exits successfully with a migrate_noop event.

Account Metadata

Profiles can carry non-secret ownership metadata alongside their isolated config directory:

accounts add account001 \
  --email owner@example.com \
  --display-name "Owner Name" \
  --identity agent:owner-name \
  --card-last4 4242 \
  --metadata machine=spark02

accounts set account001 --identity identity_abc123 --metadata source=spark01
accounts show account001 --json

cardLast4 is validated as exactly four digits. metadata accepts repeated key=value pairs with string, finite number, boolean, or null values. Metadata keys may use letters, digits, _, ., :, and -; object prototype keys such as __proto__, prototype, and constructor are rejected. Do not store secrets, tokens, full card numbers, or billing addresses in profile metadata.

A registry rename can be recorded, not just performed: accounts set <name> --native-name <tool-native-name> records the identifier the tool itself still uses on disk, and accounts set <name> --alias <old-name> (repeatable) records a former registry name — appended to the profile's alias history, never replacing it. accounts show <old-name> then resolves the exact-name match as before AND prints a disambiguation line for any OTHER profile whose aliases record <old-name> as one of its former names, so an old name never silently answers "no such profile" for something that was only renamed.

Agent / MCP Switching

accounts ships a stdio MCP server:

accounts-mcp

Add it to Claude/Codex/opencode/Cursor MCP config as a command server named accounts. It exposes:

  • list_tools
  • list_profiles
  • current_profile
  • supervisor_status
  • switch_profile

For automatic agent restarts, start the agent through accounts run:

accounts use account001
accounts run claude --resume

When switch_profile is called from that Claude session, accounts-mcp contacts the supervisor. The supervisor applies/switches the profile, closes the current Claude process, and restarts it with the selected profile. Claude uses claude --continue; Codex uses codex resume --last; opencode uses opencode --continue; custom tools can define resumeArgs.

If the agent was not started through accounts run, MCP falls back to the safe handoff behavior and returns a command such as: env -u BUN_CONFIG_VERBOSE_FETCH -u NODE_DEBUG -u NODE_DEBUG_NATIVE CLAUDE_CONFIG_DIR=... claude --continue.

Human equivalent:

accounts switch account001 --resume
accounts switch account001 --resume --launch
accounts switch account001 --resume --permissions dangerous
accounts switch account001 --supervisor
accounts switch codex-work --tool codex --resume
accounts switch ops --tool opencode --resume

launch, run, and switch --launch run a configs prelaunch step by default for supported tools (claude, codex, codewith, opencode, cursor). The prelaunch call applies into the profile's locked tool and isolated config dir:

accounts launch account001
accounts run account001
accounts switch account001 --launch

Use --configs-dry-run or --configs plan to preview without writing, --skip-configs for legacy/no-configs runs, and --allow-configs-failure only when intentionally bypassing a failed prelaunch check. If the account profile has identity: agent:marcus, accounts exports that OpenIdentities overlay into the profile home and passes it to configs session apply as --identity-export. Repeat --identity-export <path> to add prebuilt global/tool/account exports.

accounts list, accounts show, and accounts supervisor status --json include redacted prelaunch diagnostics: last run mode/result, audited skip/bypass reason, OpenConfigs manifest path/hash, generated timestamp, source ids/counts, and missing/stale/drift status.

--permissions <preset> maps a permission mode to the tool's own flags. For example, --permissions dangerous launches Claude/Takumi with --dangerously-skip-permissions, Codex with --dangerously-bypass-approvals-and-sandbox, and Gemini/Hermes/Kimi with their YOLO mode flags. Unsupported tools fail with a list of configured presets.

Shell hook (optional)

accounts hook install
# Add to ~/.zshrc or ~/.bashrc:
source "$(accounts hook path)"

The wrapper runs accounts apply when the active profile differs from applied, then invokes the real claude binary. Full behavior and footguns: docs/hook.md.

Storage layout

~/.hasna/accounts/
  accounts.json              # registry: profiles, current, applied (mode 600)
  claude-hook.sh             # optional shell wrapper
  supervisors/
    claude.sock              # local control socket for `accounts run claude`
    claude.json              # supervisor pid/profile/command metadata
  profiles/
    claude/<name>/           # managed config dir
    claude/<name>/.accounts-auth/   # auth snapshots for apply mode
      oauth-account.json
      credentials.json       # Linux / file-based auth
      keychain.json          # macOS keychain payload

Overrides: ACCOUNTS_HOME, ACCOUNTS_STORE_PATH.

Profile directories are validated before API registry writes. They must be absolute, persistent, home-anchored paths under the managed profiles root or a built-in tool home; temp paths, worktrees, scratchpads, and caches are refused. Custom tool homes submitted to the API use the same persistence/home checks. See Profile directory policy.

Registry access is selected through AccountsStore:

  • local uses the atomic on-machine JSON registry.
  • self_hosted and cloud use the authenticated Accounts HTTP API.
  • ACCOUNTS_HOME selects an isolated local registry even when API URL/key are inherited; set an explicit self_hosted/cloud mode to override it.
  • Explicit self_hosted/cloud modes fail closed unless both the API URL and key are configured.
  • Retired remote, hybrid, and s3 aliases are ignored for migration safety; any other unknown mode is rejected.
import { resolveStore } from "@hasna/accounts";

const store = resolveStore();
console.log(store.transport);
console.log(await store.listProfiles());

Configure API mode with:

  • HASNA_ACCOUNTS_STORAGE_MODE=local|self_hosted|cloud
  • HASNA_ACCOUNTS_API_URL=https://accounts.example.com
  • HASNA_ACCOUNTS_API_KEY from the service operator

The @hasna/accounts/storage entry point and accounts storage command group retain deprecated source/CLI compatibility shims. Local status and snapshot helpers continue to work. push, pull, and sync fail explicitly because the retired provider-backed transport is not present. Their legacy optional environment arguments remain accepted, and the retired CLI commands still parse --json before returning the same deterministic retirement diagnostic.

For server compatibility, an older client may create an account using a previously local custom tool id without first registering a Tool definition. The server distinguishes that unseen id from an explicitly removed id using a durable PostgreSQL tombstone; only an explicit tools registration reactivates a removed id.

Production PostgreSQL uses separate identities: an object-owning migration role for accounts-migrate, and a DML-only LOGIN NOINHERIT role for accounts-serve. accounts-migrate requires HASNA_ACCOUNTS_RUNTIME_ROLE to name the server role so it validates and reapplies the least-privilege grants. The server role must never own the schema or run migrations. See Accounts Storage Stabilization for the exact grants and rollout order.

Supported tools

Tool id Env var Default dir
Claude Code claude CLAUDE_CONFIG_DIR, TELEGRAM_STATE_DIR ~/.claude
Takumi takumi TAKUMI_CONFIG_DIR ~/.takumi
Codex CLI codex CODEX_HOME ~/.codex
Codex App codex-app CODEX_HOME + --user-data-dir ~/.codex
Codewith codewith CODEWITH_HOME, CODEX_HOME ~/.codewith
Gemini CLI gemini GEMINI_CONFIG_DIR ~/.gemini
opencode opencode OPENCODE_CONFIG_DIR, XDG_CONFIG_HOME, XDG_DATA_HOME ~/.config/opencode
Cursor Agent cursor CURSOR_CONFIG_DIR ~/.cursor
Pi Coding Agent pi PI_CODING_AGENT_HOME ~/.pi
Hermes hermes HERMES_HOME ~/.hermes
Kimi Code kimi KIMI_CODE_HOME ~/.kimi-code
Grok Build grok HOME (process-scoped) ~/.grok

apply is Claude-only today. Use launch / env for other tools. For Grok Build, prefer accounts launch or accounts shell; exporting HOME globally is intentionally not recommended.

Custom tools can join supervised resume switching with accounts tools add ... --resume-arg <arg>. They can also define permission presets with --permission-arg preset=--flag. Use --launch-arg for app-level arguments that should be prepended to every login/launch/run command; templates support {profileDir}, {profileName}, and {toolId}.

accounts login <name> builds its chooser from this registry, including custom tools. Installed tools are listed first; tools whose binary or required app install is missing are marked as requiring installation. In non-interactive shells, accounts does not prompt and instead prints explicit --tool commands to run.

Library

import { addProfile, applyProfile, importProfile, resolveStore } from "@hasna/accounts";

The package root exposes local profile, switching, auth, readiness, supervisor, session, and contract helpers. @hasna/accounts/storage retains deprecated storage compatibility exports, while @hasna/accounts/sdk is the generated HTTP client for accounts-serve. See HTTP API and SDK.

License

Apache-2.0 © Andrei Hasna

About

Local-first CLI for managing and switching multiple AI coding tool profiles

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages