Repository navigation
claude owns its accounts - #76
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-claudeholds 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:
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/loginlands 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
RemoteAccountsis 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.Accountis spelled out at every use, because OrreryCore still has anAccount— the one carryingtool,displayNameandworkspace. Two types with one name is a transition state, not a design; the host's copy goes when theToolenum does. One existing test needed the same qualification.orrery listnow asks the pluginThe branch deciding where accounts come from lives in
AccountListingand nowhere else. Writingif this tool has a plugininto 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-claudecould be deleted and every path would keep working exactly as before, which is indistinguishable from never having asked it. Verified the test catches it: addingif !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,removeanduseare unchanged.orrery useturned 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,removeandpingo through the pluginThe mapping turned out cleaner than expected, because orrery already keeps the two facts apart:
_pin-current— what the shell function'suse)case calls; a global current, deliberately independent of workspacesaccount.makeCurrent()orrery pin --workspace— binds an account to oneaccount.pin(to:)Neither command needed its meaning changed, only its destination.
UseCommandis untouched:orrery-bin use --claudealready throws, because claude's selection is handled by theorrery()shell function, which calls_pin-current. Routing a command that refuses to run for claude would have been theatre.orrery addsays out loud that it cannot log in for a plugin-owned tool.AccountLoginFlowworks on orrery's ownAccount— 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
awaitthrough 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:
{"id":…,"name":"work","workspace":"client-x"}workspacekey — "not pinned" is genuinely absent, not a defaultcurrentworkspace.json?workspace.jsonhas no accounts keyremoveThe 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 Sendableor@preconcurrency.A trap worth recording
Found while writing the registration test.
PluginDiscovery.locatesearches an env var, then$ORRERY_HOME/tools, thenPATH— so a test supplying onlyPATHexercises whatever is installed on the machine.Here that was a plugin from weeks earlier advertising
tool/describealone, 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