Skip to content

claude owns its accounts - #76

Merged
gradyzhuo merged 5 commits into
mainfrom
feat/claude-owns-accounts
Sep 30, 2026
Merged

gradyzhuo merged 5 commits into
mainfrom
feat/claude-owns-accounts

Conversation

@gradyzhuo

@gradyzhuo gradyzhuo commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

Depends on AIToolKit 0.0.1-dev.10, which this branch now pins. The 661 tests were built from what the pin resolves to, not from the local checkout the code was written against.

The first time a plugin owns something instead of answering questions about directories orrery prepared. orrery-claude holds the pool: where accounts live, what is in them, and which one is pinned.

orrery hands over a root and stops there

Each plugin is spawned with $ORRERY_HOME/plugins/<id> in its environment, and everything beneath it is the plugin's to arrange.

The root comes from the host because a plugin must never derive a home — that seam is what keeps an isolated run out of the developer's real config. The host stops at the root because one that knew the layout would be back to owning the pool and asking the plugin to look things up in it.

The layout itself is inherited unchanged: a directory per account id holding metadata.json. A layout that moves for no reason is a migration nobody asked for. What changed is who may know it.

Two behaviours carried over deliberately

Both are about not making a whole command useless:

  • An account whose metadata cannot be read is skipped with a warning to stderr, not thrown. A listing is worth having with one row missing; refusing to list anything would take away the command someone needs in order to notice and fix it.
  • A pin naming an account that no longer exists reads as nothing pinned. The account is gone; reporting that as a failure asks every caller to handle a state it cannot fix.

Cheap listing, fresh detail

list() reads files only and leaves the credential store alone. current() is a detail view and spends one spawn on it, because an in-session /login lands there before anywhere else.

The host now asks one question and gets whole accounts back — it no longer fetches identities separately and stitches them onto rows.

Host side

RemoteAccounts is all five methods or none. A plugin that could list accounts but not delete one would leave a pool that can be filled and never emptied, and finding that out at the first delete is worse than the capability being absent.

Unlike a listing of identities, an undecodable account is not skipped: a short listing looks complete, and orrery would go on offering a pool quietly missing rows.

AIToolKit.Account is spelled out at every use, because OrreryCore still has an Account — the one carrying tool, displayName and workspace. Two types with one name is a transition state, not a design; the host's copy goes when the Tool enum does. One existing test needed the same qualification.

orrery list now asks the plugin

The branch deciding where accounts come from lives in AccountListing and nowhere else. Writing if this tool has a plugin into each command that lists or shows accounts would spread a transition across fourteen files and then require finding all fourteen again to remove it.

The fork is temporary in a specific way: codex and gemini are not being migrated into the plugin model, they are being replaced by it — codex gets reimplemented against the framework, gemini is dropped for now and returns as a plugin. Nobody has to maintain this bridge.

No fallback, and that is the point. A plugin-owned tool with an empty pool lists nothing, even when orrery's own records still hold accounts for it. A fallback would make the plugin unobservable — orrery-claude could be deleted and every path would keep working exactly as before, which is indistinguishable from never having asked it. Verified the test catches it: adding if !rows.isEmpty { return rows }, which reads entirely reasonable, fails the case immediately.

An error from the plugin propagates rather than degrading to an empty list. It owns the accounts, so "I cannot say" and "there are none" are different answers, and rendering the first as the second tells someone their accounts are gone.

Rows are flattened on the way out: the plugin path returns accounts already carrying email and plan, while the pool path fetches identity separately. A caller that could see the difference would have to care about it.

Still on the store

add, remove and use are unchanged. orrery use turned out not to set a global current at all — it pins an account into a workspace (env.setAccount(acct.id, for: tool)), so the real pin is (workspace, tool) → account. AIToolAccounts.setCurrent(id:) has no workspace dimension and cannot express it. That shape is an open design question, not something to guess at here.

add, remove and pin go through the plugin

The mapping turned out cleaner than expected, because orrery already keeps the two facts apart:

orrery already had reaches
_pin-current — what the shell function's use) case calls; a global current, deliberately independent of workspaces account.makeCurrent()
orrery pin --workspace — binds an account to one account.pin(to:)

Neither command needed its meaning changed, only its destination.

UseCommand is untouched: orrery-bin use --claude already throws, because claude's selection is handled by the orrery() shell function, which calls _pin-current. Routing a command that refuses to run for claude would have been theatre.

orrery add says out loud that it cannot log in for a plugin-owned tool. AccountLoginFlow works on orrery's own Account — staging dirs, a Keychain item name — and none of that exists for an account the plugin owns. Moving credentials across the boundary is its own piece of work; printing nothing would leave someone holding an account that looks finished and cannot be used.

Four commands became async, which pulled await through their test call sites. No test changed what it asserts.

Checked on a real installation, not only in tests

Built for Linux x86_64 with the release toolchain and installed in a clean OrbStack machine, because what matters here is where bytes land — which unit tests cannot show:

Where accounts landed orrery's pool 0, plugin store 2
Where the workspace landed the account's own metadata: {"id":…,"name":"work","workspace":"client-x"}
An unpinned account no workspace key — "not pinned" is genuinely absent, not a default
current the plugin's own file, holding an id
A second process reads what the first wrote
Does claude still reach workspace.json? no — origin's workspace.json has no accounts key
remove plugin store 2 → 1
codex still orrery's pool (1), plugin store 0

The last row is the point: claude and codex coexist in one orrery list, one asked and one read, and the output does not betray which. The transition fork works rather than half-working.

Verification

14 tests through the shipped binary, not an in-process conformer — including that a pin survives the process that recorded it. Full suite 667 green. No nonisolated(unsafe), @unchecked Sendable or @preconcurrency.

A trap worth recording

Found while writing the registration test. PluginDiscovery.locate searches an env var, then $ORRERY_HOME/tools, then PATH — so a test supplying only PATH exercises whatever is installed on the machine.

Here that was a plugin from weeks earlier advertising tool/describe alone, so the capability under test was simply absent and the failure pointed somewhere else entirely. The suite now pins the binary by env var.

The other plugin suites drive the transport directly and never reach locate, which is why none of them had ever caught it.

🤖 Generated with Claude Code

The first time a plugin owns something instead of answering questions about
directories orrery prepared. `orrery-claude` holds the pool: where accounts live,
what is in them, and which one is pinned.

orrery hands over a root and does not look inside it. Each plugin gets
`$ORRERY_HOME/plugins/<id>`, supplied in the environment at spawn, and everything
beneath it is the plugin's to arrange. The root comes from the host because a
plugin must never derive a home — that seam is what keeps an isolated run out of
the developer's real config — and the host stops there because one that knew the
layout would be back to owning the pool.

The pool's layout is inherited unchanged: a directory per account id holding
`metadata.json`. A layout that moves for no reason is a migration nobody asked
for. What changed is who may know it.

Two behaviours carried over deliberately, both of them about not making a whole
command useless:

  - An account whose metadata cannot be read is skipped with a warning to stderr,
    not thrown. A listing is worth having with one row missing; refusing to list
    anything would take away the command someone needs in order to notice.
  - A pin naming an account that no longer exists reads as nothing pinned. The
    account is gone; reporting that as a failure asks every caller to handle a
    state it cannot fix.

`list()` returns accounts already carrying email and plan, from files only, with
the credential store left alone — so the listing stays cheap. `current()` is a
detail view and spends one spawn on the credential store, because an in-session
`/login` lands there first. The host asks one question and gets whole accounts;
it no longer fetches identities separately and stitches them onto rows.

Host side, `RemoteAccounts` is all five methods or none. A plugin that could list
accounts but not delete one would leave a pool that can be filled and never
emptied, and finding that out at the first delete is worse than the capability
being absent. Unlike a listing of identities, an undecodable account is *not*
skipped: a short listing looks complete, and orrery would go on offering a pool
quietly missing rows.

`AIToolKit.Account` is spelled out at every use. OrreryCore still has an
`Account` — the one with `tool`, `displayName` and `workspace` — and two types
with one name is a transition state, not a design. One existing test needed the
same qualification.

Verified against the shipped binary, 14 tests through a real process, including
that a pin survives the process that recorded it.

A trap worth recording, found while writing the registration test.
`PluginDiscovery.locate` searches an env var, then `$ORRERY_HOME/tools`, then
`PATH` — so supplying only `PATH` tests whatever is *installed* on the machine.
Here that was a plugin from weeks earlier advertising `tool/describe` alone, so
the capability under test was absent and the failure pointed somewhere else
entirely. The suite now pins the binary by env var. The other plugin suites drive
the transport directly and never reach `locate`, which is why none of them ever
caught it.
dev.9 has neither `AIToolAccounts` nor `PluginState`, so the branch that uses
them resolved and then failed to compile. dev.10 is the merge that added both.

Verified against the tag rather than the local checkout it was written against:
the `swift package edit` override is removed, and the 661 tests here were built
from what the pin actually resolves to.
`orrery list` now asks claude's plugin for claude's accounts. The branch that
decides this lives in `AccountListing` and nowhere else.

The alternative was writing `if this tool has a plugin` into each command that
lists or shows accounts. That spreads a transition across fourteen files and then
requires finding all fourteen again to remove it. Here the fork is one type, and
when the last tool has moved the `store` half goes with it.

The fork is temporary in a specific way: codex and gemini are not being migrated
into the plugin model, they are being replaced by it. codex gets reimplemented
against the framework; gemini is dropped for now and returns as a plugin. So this
is not a bridge anyone has to maintain.

Rows are flattened on the way out. The plugin path returns accounts already
carrying email and plan — one question, whole answers — while the pool path has to
fetch identity separately through `AccountAuthInfo`. A caller that could see the
difference would have to care about it.

No fallback, and that is the point. A plugin-owned tool with an empty pool lists
nothing, even when orrery's own records still hold accounts for it. A fallback
would make the plugin unobservable: `orrery-claude` could be deleted and every
path would keep working exactly as before, which is indistinguishable from never
having asked it. Verified the test catches it — adding `if !rows.isEmpty { return
rows }`, which reads entirely reasonable, fails the case immediately.

An error from the plugin propagates rather than degrading to an empty list. It
owns the accounts, so "I cannot say" and "there are none" are different answers,
and rendering the first as the second tells someone their accounts are gone.
dev.11 moves the operations onto `Account`, so both sides needed a type that can
perform them. A decoded value cannot: it has no store and no connection, which is
why `AccountRecord` does not conform.

`RemoteAccount` is the host's — the decoded record plus the pipe, so `pin(to:)`,
`makeCurrent()` and `delete()` become calls on the account a caller is already
holding rather than `verb(id:)` on the tool. `ClaudeAccount` is the plugin's, a
view onto `ClaudeAccountStore` rather than a value pulled out of it. Both make the
same claim `RemoteAITool` makes one level up: a call site holding `any Account`
cannot tell which kind it has.

The store now deals in `AccountRecord` and gained `setWorkspace(_:for:)`, which
rewrites that account's own metadata. One workspace per account, so it goes on
the account, and deleting the account takes the relation with it rather than
leaving a table holding a name for something gone.

`AIToolKit.Account` is spelled out at every use in OrreryCore, because OrreryCore
still has an `Account` of its own — the one with `tool`, `displayName` and
`workspace`. Two types under one name is a transition state; the host's copy goes
when the `Tool` enum does.

Two end-to-end tests changed shape rather than being repaired, because what they
asserted stopped being reachable. Designating or deleting an account that does
not exist was an error a caller had to handle; now there is nothing to hold, so
the case cannot be expressed at all. The tests say that instead.

667 tests pass against the tag rather than a local checkout.
The commands that write now reach the account the way the listing already
reads it.

The mapping turned out cleaner than expected, because orrery already keeps the
two facts apart. `_pin-current` — what the shell function's `use)` case calls —
records a global current, deliberately independent of workspaces; `orrery pin`
binds an account to one. Those are exactly `makeCurrent()` and `pin(to:)`, so
neither command needed its meaning changed, only its destination.

`UseCommand` is untouched. `orrery-bin use --claude` already throws: claude's
selection is handled by the orrery() shell function, which calls `_pin-current`.
Routing a command that refuses to run for claude would have been theatre.

Lookups move to the listing as well. Finding an account by display name went
through orrery's pool, which for a plugin-owned tool holds nothing to find.

`orrery add` says out loud that it cannot log in. `AccountLoginFlow` works on
orrery's own `Account` — staging dirs, a Keychain item name — and none of that
exists for an account the plugin owns. Moving credentials across the boundary is
its own piece of work; printing nothing would have left someone holding an
account that looks finished and cannot be used.

Four commands became async, which pulled `await` through their test call sites.
No test changed what it asserts.

667 tests pass.
@gradyzhuo
gradyzhuo merged commit 7c27a97 into main Sep 30, 2026
1 check passed
@gradyzhuo
gradyzhuo deleted the feat/claude-owns-accounts branch September 30, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant