@byokit/accounts 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.14.0 (2026-10-01)
6
+
7
+ - Dependency update: pins @byokit/usage 0.4.0.
8
+
9
+ - SECURITY: Add a Node-only managed CLI account boundary for subscription sign-in: only app-owned folders and explicitly passed absolute binaries, no default login access, no credential-file reads, no token output, and launch environment credential shedding.
10
+ - Add managed CLI account creation, marker-gated sign-in, status, rename, cancellation, history links and removal, with legacy roster and terms compatibility.
11
+ - Share the portable chooser's AccountLike type and accept normalized subscription usage with millisecond reset times.
12
+ - Bound native status deadlines even when a passed CLI ignores termination; stdout is capped and only the owned child is terminated.
13
+ - Offer every subscription catalogue row by default on supported platforms, adding Kimi, Meta, Qwen and MiniMax labels; preserve Claude Pro/Max sign-in and remove terms and visibility gates. API key (billed per use) rows remain opt-in. Simplify the ChatGPT plan-use error words.
14
+
15
+ ## 0.13.0 (2026-10-01)
16
+
17
+
18
+
19
+ - Add portable, choose-once multi-account selection with most-room ordering, unknown room above exhausted, and secret-free candidate explanations. Auto and default fallback use subscription accounts only; an API key (billed per use) must be explicitly selected.
20
+ - Export roomOf and roomWords, shared Auto conformance fixtures, and descriptive multi-account terms data.
21
+
5
22
  ## 0.12.0 (2026-09-30)
6
23
 
7
24
 
package/README.md CHANGED
@@ -8,8 +8,8 @@
8
8
  </p>
9
9
 
10
10
  <p align="center"><strong>Sign in with the AI plan you already pay for, inside your own app.</strong><br/>
11
- ChatGPT and Claude Pro/Max on every platform (Claude needs Web Crypto); OpenRouter on computers when an app offers it (API billing, never by default); Grok and
12
- GitHub Copilot hidden by default. Anthropic uses an app-passed API key (billed per use), explicitly opted in. Sign-ins go into your app's own store: on a computer (Node, Electron), in a browser
11
+ ChatGPT and Claude Pro/Max on every platform (Claude needs Web Crypto); Grok, GitHub Copilot, Kimi and Meta on computers.
12
+ OpenRouter and Anthropic API keys are billed per use and require explicit app opt-in. Sign-ins go into your app's own store: on a computer (Node, Electron), in a browser
13
13
  (a PWA, Electron's renderer) and on a phone (React Native and Expo, iOS and Android). One import; your bundler picks
14
14
  the platform's side (`package.json`'s `react-native` and `browser` conditions).</p>
15
15
 
@@ -75,16 +75,18 @@ On a computer it uses Pi's [`@earendil-works/pi-ai`](https://www.npmjs.com/packa
75
75
  flows, pinned exactly:
76
76
 
77
77
  ```ts
78
+ import type { SafeStorageLike } from '@byokit/accounts';
78
79
  import { isolate } from '@byokit/accounts/isolate'; // first, before any Pi import
79
80
  isolate('/path/to/app/engine'); // scrub inherited Pi settings and provider keys
80
- const { Accounts, fileStore } = await import('@byokit/accounts');
81
- const { app, safeStorage } = await import('electron');
82
- await app.whenReady();
83
-
84
- const accounts = new Accounts({ store: (member) => fileStore(`/path/to/app/people/${member}/auth.json`, safeStorage) });
85
- const shown = await accounts.login(1, 'chatgpt', { via: 'code' }); // { state: 'waiting', code, url }
86
- // show shown.code and shown.url; the sign-in finishes by itself
87
- (await accounts.status(1, 'chatgpt')).words; // "ChatGPT is connected."
81
+
82
+ // Your Electron main process waits for app.whenReady(), then passes its safeStorage here.
83
+ export async function connect(safeStorage: SafeStorageLike) {
84
+ const { Accounts, fileStore } = await import('@byokit/accounts');
85
+ const accounts = new Accounts({ store: (member) => fileStore(`/path/to/app/people/${member}/auth.json`, safeStorage) });
86
+ const shown = await accounts.login(1, 'chatgpt', { via: 'code' }); // { state: 'waiting', code, url }
87
+ // show shown.code and shown.url; the sign-in finishes by itself
88
+ return { shown, status: await accounts.status(1, 'chatgpt') };
89
+ }
88
90
  ```
89
91
 
90
92
  ### On a phone or in a browser
@@ -119,7 +121,7 @@ and [`examples/pwa`](../../examples/pwa) (browser sign-in).
119
121
  | `Accounts` | Sign-in, status, sign-out, asking and limits for each member: `login`, `finished`, `status`, `plan`, `logout`, `respond`, `failed`, `ladder`, `keepFresh` |
120
122
  | `portable`, `computer`, `loopback` | The platform `Accounts` runs on: device code with `fetch` alone, or (Node entry only) Pi's flows and the loopback listener |
121
123
  | `memoryStore`, `fileStore`, `secureStore`, `browserStore`, `recordStore` | One store per person: in memory, a sealed 0600 file (Node entry only), Keychain/Keystore, IndexedDB, or your own load and save |
122
- | `offered`, `provider`, `PROVIDERS` | The catalogue: each provider's billing, terms status, reason and source |
124
+ | `offered`, `provider`, `PROVIDERS` | The catalogue: each provider's billing, models and source |
123
125
  | `billingWords`, `say`, `WORDS`, `signInError`, `failure`, `clock`, `callbackPage` | The plain sentences every app shows the same way (`words.json`), a time in words, and the page a browser sees after a sign-in |
124
126
  | `respond`, `ResponseError`, `IncompleteError`, `sseReader`, `limitResponse`, `isFunctionCall` | Ask ChatGPT's answers endpoint with a sign-in, with tools, pictures, thinking effort and an answer shape; the error with the words to show and the kind acted on |
125
127
  | `classifyFailure`, `classify`, `REST_MS` | An error's kind (limit, overload, plan without this use, lapsed sign-in, network) and default rest times |
@@ -138,7 +140,7 @@ and [`examples/pwa`](../../examples/pwa) (browser sign-in).
138
140
  | Claude Pro/Max (subscription) | Provider page, paste its code back | Same PKCE flow; token endpoint CORS required | Same PKCE flow; app supplies Web Crypto |
139
141
  | Anthropic (API key, billed per use) | App passes its own key, explicitly | Same fetch-only Messages provider | Same fetch-only Messages provider |
140
142
  | OpenRouter (API billing) | Its own page, back to this computer (Pi's flow), when an app offers it (never by default) | Not yet | Not yet |
141
- | Grok, Copilot (hidden) | Pi's flows | No | No |
143
+ | Grok, Copilot, Kimi, Meta | Pi's flows | No | No |
142
144
  | Where sign-ins are kept | `fileStore(path, safeStorage)`, sealing required | `browserStore(name)` (IndexedDB) | `secureStore(SecureStore, name)` (Keychain, Keystore) |
143
145
 
144
146
  Device code works everywhere: OpenAI's sign-in endpoints answer any web page. The page-straight-back sign-in needs a
@@ -148,13 +150,16 @@ doesn't answer other web pages), so a PWA's model calls go through the app's own
148
150
 
149
151
  ## Catalogue and billing
150
152
 
151
- `catalogue.json` holds each provider with its billing (`subscription`, `api`) and terms status (`allowed`, `grey`,
152
- `partner`), a one-line reason and a source. The kit labels; your app decides what to offer
153
- (`new Accounts({ offer: ['chatgpt'] })`). Without an explicit `offer`, only subscription sign-ins supported on this
154
- platform are shown: OpenRouter is API-billed and never offered by default. An explicit list is not platform-filtered,
155
- so choose from the table above. Show `billingWords(p)` next to every provider you list.
153
+ `catalogue.json` holds each provider's billing (`subscription`, `api`), models and source.
154
+ All subscription rows are offered by default on platforms that support their sign-in. API-billed rows
155
+ are offered only when the app names them. An explicit `offer` list is not platform-filtered.
156
+ The `Provider` shape no longer has `terms`, `hidden` or `why`, and `Terms` is no longer exported.
157
+ Qwen and MiniMax have subscription catalogue rows; their paste and portal sign-in flows follow in later work packages.
158
+ Each provider's own terms apply to how you use your plan.
159
+ Anthropic Messages uses an app-passed API key (billed per use); authentication is separate from the Messages request.
156
160
 
157
- Anthropic Messages uses an app-passed API key (billed per use), with explicit opt-in; its authentication is separate from the Messages request.
161
+ Native Claude CLI sign-in uses the managed-folder entry.
162
+ Approved exception: the `./cli` entry reads and runs only app-managed per-account folders under `stateDir` and the absolute CLI binaries the app passes; it never touches the person's default login; tokens never leave the device and are never logged.
158
163
 
159
164
  ```ts
160
165
  import { Accounts, billingWords, offered } from '@byokit/accounts';
@@ -164,7 +169,7 @@ for (const p of offered(['chatgpt', 'openrouter'])) console.log(`${p.name}: ${bi
164
169
  ```
165
170
 
166
171
  ```text
167
- [ 'chatgpt', 'claude' ]
172
+ [ 'chatgpt', 'grok', 'copilot', 'claude', 'kimi', 'meta', 'qwen', 'minimax' ]
168
173
  ChatGPT: Uses your ChatGPT plan.
169
174
  OpenRouter: Charged per use to your OpenRouter account, not a plan.
170
175
  ```
@@ -438,8 +443,6 @@ fetch such as Expo's. `betas` explicitly opts into native beta headers.
438
443
 
439
444
  ## Claude Pro/Max subscription
440
445
 
441
- Anthropic's [developer guidance](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) prohibits third-party Claude.ai login without approval; BYOKit has no approval, and this route may stop working or lead to account restrictions (the separately billed API-key route is the documented alternative).
442
-
443
446
  Claude is available by default. Open its page, then paste the returned `code#state` into the app:
444
447
 
445
448
  ```ts
@@ -489,3 +492,100 @@ The common route follows Hermes's platform token host, three scopes and `axios/1
489
492
  with inference `claude-code/2.1.74 (external, cli)`, `x-app: cli`, bearer authorization, Messages version
490
493
  `2023-06-01` and betas `claude-code-20250219,oauth-2025-04-20`. The implementation is independent;
491
494
  [NOTICE](NOTICE) records the MIT protocol references.
495
+
496
+ ## Choosing between accounts
497
+
498
+ The portable entry exports `chooseAccount`, `resolveSelection`, `roomOf`, `roomWords` and the structural
499
+ `AccountLike`, `AccountPick`, `Room`, `Considered`, `RunSelection` and `Defaults` types. The host supplies its own
500
+ account records and readings; these functions never read credentials, sign in, refresh, start a run or switch a
501
+ running conversation. Resolve once before starting and keep the selected account for the whole run.
502
+
503
+ ```ts
504
+ import { resolveSelection, roomOf, type AccountLike, type Defaults } from '@byokit/accounts';
505
+
506
+ // Host-provided account, model and measurement accessors:
507
+ declare const accounts: readonly AccountLike[];
508
+ declare const defaults: Defaults;
509
+ declare function windowsFor(account: AccountLike, demand: readonly string[]):
510
+ { usedPercent: number; kind: string; resetsAt?: number }[];
511
+ declare function measuredAt(account: AccountLike): number | undefined;
512
+ declare function modelsFor(account: AccountLike): { id: string; available: boolean }[];
513
+ declare function startRunWith(account: AccountLike, model: string): void;
514
+
515
+ const pick = resolveSelection(accounts, defaults, { account: 'auto', needs: ['provider/model'] },
516
+ (account, demand) => roomOf(windowsFor(account, demand), measuredAt(account), 'milliseconds'), Date.now(),
517
+ (account) => modelsFor(account));
518
+ if (pick.ok) startRunWith(pick.account, pick.model);
519
+ ```
520
+
521
+ Auto uses ready subscription accounts (including a rest whose deadline has elapsed). It ranks usable readings
522
+ by most room, earlier refill, then list order; unknown readings follow; exhausted accounts refill first. A reading
523
+ older than 24 hours counts as unknown. A numeric reading without a measurement time retains its room tier,
524
+ with unknown age/confidence. Explicit account ids bypass state and billing filtering: the host must verify the
525
+ selected account is ready before starting, and a failed explicit selection never falls back to another account.
526
+ API key accounts (billed per use) are used only when explicitly selected by id or a ready default.
527
+
528
+ `sel.model` and deduplicated `sel.needs` form the demand passed to the room reader. Supply `models` to verify
529
+ every demanded model is available. Without it, the host owns model eligibility; `model` is the explicit or default
530
+ model, or an empty string when neither is provided. With a model list, no available model returns `not_included`.
531
+ Without a demand, selection stays within the default account's provider, or the first provider in list order.
532
+
533
+ Each pick includes every account's `considered` row in list order. It holds only ids, exclusion/ranking codes,
534
+ room figures, measurement age and confidence (`known`, `stale`, `unknown`); no names, emails, credential fields
535
+ or engine messages are copied. The winning row's `reason` is the pick's `why`; other candidate reasons describe
536
+ the deterministic comparison to the Auto winner. Excluded rows carry their first exclusion code. Account ids
537
+ and model ids supplied by the host must themselves be secret-free. The generic returned `account` is the original
538
+ host record: keep credentials outside that record before exposing the whole pick to UI or logs.
539
+
540
+ `AccountLike.until`, `nowMs`, `Room.at`, `Room.resetsAt` and `Considered.age` use **milliseconds**.
541
+ `roomOf(windows, at, resetUnit?)` accepts structural windows. Legacy reset **epoch seconds** are the default,
542
+ converted by 1000 exactly once. For normalized `@byokit/usage` 0.2.0+ windows, pass `'milliseconds'` as the
543
+ third argument; reset times then stay unchanged. A normalized usage `Room` also passes directly to the chooser.
544
+ The optional `at` is always the original measurement time in epoch milliseconds.
545
+ The current structural input contains `usedPercent`, `kind`, and optional `resetsAt`; hard-limit/model-scope and
546
+ poll-health ingestion is a follow-up to the pending usage extension. Hosts must supply demand-filtered windows
547
+ and authoritative eligibility rather than interpreting an unavailable quota reading as a fresh successful read.
548
+
549
+ `Provider.multiAccount` gives a terms assessment, reason and source for multiple accounts of that service.
550
+ A grey assessment records missing explicit documentation; it does not gate selection. `auto.terms`, `auto.*`,
551
+ `room.*` and `pick.*` words are exported in `WORDS`.
552
+
553
+ Identity and re-authentication stay with the host's canonical device store or engine. A provider account id
554
+ scoped by member/provider proves identity; names and emails do not. The TypeScript identity fixture records
555
+ wrong-account, duplicate identity, changed-email, absent-identity, removal/refresh and extension-field boundaries
556
+ for runtime integration; the chooser consumes host-validated state and never adopts credentials itself.
557
+
558
+ ## Managed CLI accounts (Node only)
559
+
560
+ `@byokit/accounts/cli` exports `cliAccounts`, `CliAccountError`, `CliProvider`, `CliAccount`, `CliOptions` and `SignInCommand`. Accounts use subscription billing. The portable entries and the `Accounts` class retain their existing sign-in flows.
561
+
562
+ `CliAccount` extends the portable chooser's `AccountLike`. Pass normalized usage through `@byokit/usage`'s `roomOf(reading, nowMs)` when selecting an account, preserving millisecond reset times and the original measurement time.
563
+
564
+ ```ts
565
+ import { cliAccounts } from '@byokit/accounts/cli';
566
+ const accounts = cliAccounts({
567
+ stateDir: '/app/state/plans',
568
+ bins: { claude: '/app/bin/claude', codex: '/app/bin/codex' },
569
+ env: { HOME: '/app/home', PATH: '/app/bin:/usr/bin:/bin' },
570
+ historyFrom: { claude: '/app/history/projects', codex: '/app/history/sessions' },
571
+ prepare: async (folder, provider) => { /* app-owned setup, such as installing hooks */ },
572
+ });
573
+ const { account, signIn } = await accounts.add('claude');
574
+ // Run signIn.shell in the app's sign-in tab, or run signIn.argv with signIn.env
575
+ // and create signIn.completion privately only after that command succeeds.
576
+ const current = await accounts.status(account.id);
577
+ const { set, unset } = accounts.launchEnv(account.id);
578
+ // Apply unset to the launch environment, then apply set, before starting the agent.
579
+ ```
580
+
581
+ `stateDir` must be an absolute app-owned directory with an existing parent. The kit creates it at 0700 and creates private `<provider>/<hex>` account folders below it. `bins` are absolute paths; PATH is never used to find the CLI. Status and login use an environment built from nothing plus the app's `env`, after removing provider credential overrides and adding the managed-folder variable. Pass proxy or temporary-directory settings explicitly if needed. `launchEnv` returns the folder variable in `set` and the provider overrides in `unset`; hosts must apply both so a stray API key (billed per use) cannot override the chosen subscription.
582
+
583
+ `add` returns a `signing` account and the native login command. `status` stays `signing` until the completion marker exists, including after a host restart, and does not run a second native client during pending sign-in. The shell uses a private per-folder lock and marks completion only after successful login. Stop the app's sign-in tab before `cancel` or `remove`; the kit does not supervise that tab. `signInAgain` reuses a pending command or starts a new completion cycle; if an account operation is already in flight it throws `prepare-failed`, so await that operation before retrying. Native CLIs own their refresh transactions; the kit neither copies credentials nor refreshes grants. A crashed sign-in shell can leave its lock; the host should stop that process and remove only that managed lock before retrying.
584
+
585
+ `list` probes managed folders concurrently; `status`, `rename`, `remove` and `cancel` serialize operations per account. Only signed-in state (`ready` or `signed_out`), email and plan come from native status output; no credential file is opened and raw CLI errors and output are discarded. `rename` accepts a trimmed name of 1–64 characters. `cancel` removes only folders added by this instance; for existing accounts it ends the pending completion cycle without deleting the account. `remove` deletes only a validated managed folder. `historyFrom` creates a history symlink inside that folder; it never creates or writes the target, even when it is missing. A throwing `prepare` rolls back the new folder.
586
+
587
+ Native status reads resolve within 15 seconds. Claude stdout is capped at 256 KB and identity JSON at 64 KB; the shared Codex client caps stdout at 64 KB. Timeout or excess output terminates only that owned child, with SIGTERM followed by SIGKILL after one second if it is still alive.
588
+
589
+ The existing `accounts-v1.json` `{version:1,accounts:[{id,provider,name,folder,found}]}` and `auto-terms-v1.json` `{acknowledged:true}` encodings remain unchanged, with 0600 files and atomic replacement. The kit preserves but excludes `found-*` and `found:true` rows, which belong to the host's default-login adapter. Legacy managed rows without kit completion sidecars retain their native signed-in status; new or re-signing rows require the completion marker. Symlinked account folders and records outside the provider/hex layout are refused.
590
+
591
+ `usageSource(id)` returns a Codex Source for `@byokit/usage`; Claude returns `undefined`, and its usage Source is `{provider:'claude', folder:set.CLAUDE_CONFIG_DIR, headers}` in a usage reader whose `stateDir` is the same managed root. `kinds` serves only the matching native agent (`claude` or `codex`); Pi is excluded until its folder mapping is verified. `resumeArgs` accepts an `id` conversation reference for these kinds. `termsAcknowledged` and `acknowledgeTerms` keep the host's existing terms bit; they do not gate sign-in. `suggestName` uses the first part of an email, falling back to the provider's name.
package/SECURITY.md CHANGED
@@ -66,7 +66,7 @@ Deleting local files alone does not revoke tokens, and deletion cannot erase old
66
66
 
67
67
  Tests use fake providers, temporary homes, filesystem canaries and Node permissions;
68
68
  `npm test` blocks outbound networking and checks the owner's existing setup byte for byte.
69
- The kit never invokes the owner's installed tools or borrows environment API keys.
69
+ The kit never borrows environment API keys. Its Node-only `./cli` exception invokes only app-passed absolute CLI binaries against app-managed folders; it never opens credential files or the person's default login. Native CLI credentials remain on the device under the CLI's own storage policy; they do not pass through `fileStore` sealing.
70
70
 
71
71
  ## Review record
72
72
 
@@ -55,7 +55,7 @@ export type AnthropicAccountAsk = AnthropicAsk & {
55
55
  key: string;
56
56
  };
57
57
  export type AccountsOptions<M extends Member = Member> = {
58
- /** The accounts this app offers, in order. Default: supported subscription providers not hidden. */
58
+ /** The accounts this app offers, in order. Default: every subscription provider supported on this platform. */
59
59
  offer?: readonly string[];
60
60
  /** Each member's own store. Default: in memory. */
61
61
  store?: (member: M) => CredentialStore;
@@ -112,7 +112,7 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
112
112
  onExpired?: (member: M, key: string) => void;
113
113
  onSignOutError?: (member: M, key: string, error: Error) => void;
114
114
  private platform;
115
- /** Offered: the providers named in `offer`, else every provider not hidden that this platform can sign in to. */
115
+ /** Offered: the providers named in `offer`, else every subscription provider that this platform can sign in to. */
116
116
  constructor(opts?: AccountsOptions<M>, platform?: Platform);
117
117
  /** A member's own store. */
118
118
  protected store(member: M): EndingStore;
package/dist/accounts.js CHANGED
@@ -51,7 +51,7 @@ export class Accounts {
51
51
  onExpired;
52
52
  onSignOutError;
53
53
  platform;
54
- /** Offered: the providers named in `offer`, else every provider not hidden that this platform can sign in to. */
54
+ /** Offered: the providers named in `offer`, else every subscription provider that this platform can sign in to. */
55
55
  constructor(opts = {}, platform = portable) {
56
56
  this.opts = opts;
57
57
  this.platform = platform;
@@ -1,6 +1,11 @@
1
- export type Terms = 'allowed' | 'grey' | 'partner' | 'forbidden';
2
1
  /** How the person pays: their plan (`subscription`), or per-use charges to their account (`api`, never offered by default). */
3
2
  export type Billing = 'subscription' | 'api';
3
+ /** Terms assessment for choosing between several accounts; descriptive, never an eligibility gate. */
4
+ export type MultiAccountTerms = {
5
+ terms: 'allowed' | 'grey' | 'partner' | 'forbidden';
6
+ why: string;
7
+ source: string;
8
+ };
4
9
  /** `callbackPort`: where the provider sends the browser back after its own sign-in page, fixed for the client Pi signs in as.
5
10
  * `revoke`: where signing out ends the sign-in on the provider's side too, for the client `clientId`. */
6
11
  export type Provider = {
@@ -19,13 +24,11 @@ export type Provider = {
19
24
  auth?: 'api-key' | 'oauth';
20
25
  label?: string;
21
26
  offer?: boolean;
22
- terms: Terms;
23
- hidden: boolean;
24
- why: string;
25
27
  source: string;
28
+ multiAccount: MultiAccountTerms;
26
29
  };
27
30
  export declare const PROVIDERS: Record<string, Provider>;
28
31
  export declare function provider(key: string): Provider;
29
- /** What an app offers: the keys it names, in its order, or every subscription provider not hidden by default.
32
+ /** What an app offers: the keys it names, in its order, or every subscription provider by default.
30
33
  * API-billed rows are never in the default: an app offers them only by naming them. */
31
34
  export declare const offered: (keys?: readonly string[]) => Provider[];
package/dist/catalogue.js CHANGED
@@ -1,7 +1,3 @@
1
- // The AI accounts a person can bring, by the name they know, with each provider's billing and terms status as data
2
- // (catalogue.json, readable from Kotlin too). The kit labels; the app decides what to offer. API-billed rows are never
3
- // offered by default; an app names them explicitly. Anthropic here is the app-passed API-key route; authentication
4
- // is separate from Messages, allowing additional opt-in auth routes to be implemented independently.
5
1
  import CATALOGUE from './catalogue.json' with { type: 'json' };
6
2
  export const PROVIDERS = Object.fromEntries(Object.entries(CATALOGUE).map(([key, p]) => [key, { key, ...p }]));
7
3
  export function provider(key) {
@@ -10,6 +6,6 @@ export function provider(key) {
10
6
  throw Object.assign(new Error('no such AI account'), { status: 404 });
11
7
  return p;
12
8
  }
13
- /** What an app offers: the keys it names, in its order, or every subscription provider not hidden by default.
9
+ /** What an app offers: the keys it names, in its order, or every subscription provider by default.
14
10
  * API-billed rows are never in the default: an app offers them only by naming them. */
15
- export const offered = (keys) => keys ? keys.map(provider) : Object.values(PROVIDERS).filter((p) => !p.hidden && p.offer !== false && p.billing === 'subscription');
11
+ export const offered = (keys) => keys ? keys.map(provider) : Object.values(PROVIDERS).filter((p) => p.billing === 'subscription');
@@ -11,10 +11,12 @@
11
11
  "clientId": "app_EMoamEEZ73f0CkXaXp7hrann",
12
12
  "revoke": "https://auth.openai.com/oauth/revoke",
13
13
  "billing": "subscription",
14
- "terms": "grey",
15
- "hidden": false,
16
- "why": "Signs in through Codex's own sign-in. OpenAI documents it for Codex, not for other apps, and has endorsed one other app using it.",
17
- "source": "https://developers.openai.com/codex/auth"
14
+ "source": "https://developers.openai.com/codex/auth",
15
+ "multiAccount": {
16
+ "terms": "grey",
17
+ "why": "This source does not explicitly document choosing between several accounts of this service in another app.",
18
+ "source": "https://developers.openai.com/codex/auth"
19
+ }
18
20
  },
19
21
  "openrouter": {
20
22
  "pi": "openrouter",
@@ -24,10 +26,12 @@
24
26
  "strong": "moonshotai/kimi-k2.6"
25
27
  },
26
28
  "billing": "api",
27
- "terms": "allowed",
28
- "hidden": false,
29
- "why": "Documented sign-in for any app, no registration. Pay-as-you-go credits, not a subscription.",
30
- "source": "https://openrouter.ai/docs/guides/overview/auth/oauth"
29
+ "source": "https://openrouter.ai/docs/guides/overview/auth/oauth",
30
+ "multiAccount": {
31
+ "terms": "grey",
32
+ "why": "This source does not explicitly document choosing between several accounts of this service in another app.",
33
+ "source": "https://openrouter.ai/docs/guides/overview/auth/oauth"
34
+ }
31
35
  },
32
36
  "grok": {
33
37
  "pi": "xai",
@@ -37,10 +41,12 @@
37
41
  "strong": "grok-4.7"
38
42
  },
39
43
  "billing": "subscription",
40
- "terms": "partner",
41
- "hidden": true,
42
- "why": "xAI allows plan sign-in only in apps it has partnered with.",
43
- "source": "https://x.ai/news/grok-opencode"
44
+ "source": "https://x.ai/news/grok-opencode",
45
+ "multiAccount": {
46
+ "terms": "grey",
47
+ "why": "This source does not explicitly document choosing between several accounts of this service in another app.",
48
+ "source": "https://x.ai/news/grok-opencode"
49
+ }
44
50
  },
45
51
  "copilot": {
46
52
  "pi": "github-copilot",
@@ -50,10 +56,12 @@
50
56
  "strong": "gpt-5.4"
51
57
  },
52
58
  "billing": "subscription",
53
- "terms": "partner",
54
- "hidden": true,
55
- "why": "GitHub allows Copilot sign-in only in apps it has partnered with; this uses VS Code's client.",
56
- "source": "https://github.blog/changelog/2026-01-16-github-copilot-now-supports-opencode"
59
+ "source": "https://github.blog/changelog/2026-01-16-github-copilot-now-supports-opencode",
60
+ "multiAccount": {
61
+ "terms": "grey",
62
+ "why": "This source does not explicitly document choosing between several accounts of this service in another app.",
63
+ "source": "https://github.blog/changelog/2026-01-16-github-copilot-now-supports-opencode"
64
+ }
57
65
  },
58
66
  "anthropic": {
59
67
  "pi": "anthropic",
@@ -63,13 +71,15 @@
63
71
  "strong": "claude-opus-5-5"
64
72
  },
65
73
  "billing": "api",
66
- "terms": "allowed",
67
- "hidden": false,
68
74
  "auth": "api-key",
69
75
  "label": "API key (billed per use)",
70
76
  "offer": false,
71
- "why": "The app passes an Anthropic API key explicitly. API key (billed per use), never a default or subscription fallback.",
72
- "source": "https://platform.claude.com/docs/en/api/overview"
77
+ "source": "https://platform.claude.com/docs/en/api/overview",
78
+ "multiAccount": {
79
+ "terms": "grey",
80
+ "why": "This source does not explicitly document choosing between several accounts of this service in another app.",
81
+ "source": "https://platform.claude.com/docs/en/api/overview"
82
+ }
73
83
  },
74
84
  "claude": {
75
85
  "pi": "byokit-claude-plan",
@@ -82,9 +92,73 @@
82
92
  "clientId": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
83
93
  "billing": "subscription",
84
94
  "auth": "oauth",
85
- "terms": "grey",
86
- "hidden": false,
87
- "why": "Anthropic prohibits third-party Claude.ai sign-in without approval; this route may stop working or lead to account restrictions.",
88
- "source": "https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use"
95
+ "source": "https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use",
96
+ "multiAccount": {
97
+ "terms": "grey",
98
+ "why": "This source does not explicitly document choosing between several accounts of this service in another app.",
99
+ "source": "https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use"
100
+ }
101
+ },
102
+ "kimi": {
103
+ "pi": "kimi-coding",
104
+ "name": "Kimi",
105
+ "company": "Moonshot AI",
106
+ "models": {
107
+ "strong": "k3",
108
+ "fast": "kimi-for-coding-highspeed"
109
+ },
110
+ "billing": "subscription",
111
+ "source": "https://www.kimi.com",
112
+ "multiAccount": {
113
+ "terms": "grey",
114
+ "why": "This source does not explicitly document choosing between several accounts of this service in another app.",
115
+ "source": "https://www.kimi.com"
116
+ }
117
+ },
118
+ "meta": {
119
+ "pi": "meta",
120
+ "name": "Meta",
121
+ "company": "Meta",
122
+ "models": {
123
+ "strong": "muse-spark-1.3"
124
+ },
125
+ "billing": "subscription",
126
+ "source": "https://www.meta.ai",
127
+ "multiAccount": {
128
+ "terms": "grey",
129
+ "why": "This source does not explicitly document choosing between several accounts of this service in another app.",
130
+ "source": "https://www.meta.ai"
131
+ }
132
+ },
133
+ "qwen": {
134
+ "pi": "qwen-portal",
135
+ "name": "Qwen",
136
+ "company": "Alibaba",
137
+ "models": {
138
+ "strong": "qwen3-coder-plus"
139
+ },
140
+ "billing": "subscription",
141
+ "source": "https://chat.qwen.ai",
142
+ "multiAccount": {
143
+ "terms": "grey",
144
+ "why": "This source does not explicitly document choosing between several accounts of this service in another app.",
145
+ "source": "https://chat.qwen.ai"
146
+ }
147
+ },
148
+ "minimax": {
149
+ "pi": "minimax",
150
+ "name": "MiniMax",
151
+ "company": "MiniMax",
152
+ "models": {
153
+ "strong": "MiniMax-M3",
154
+ "fast": "MiniMax-M2.7-highspeed"
155
+ },
156
+ "billing": "subscription",
157
+ "source": "https://www.minimax.io",
158
+ "multiAccount": {
159
+ "terms": "grey",
160
+ "why": "This source does not explicitly document choosing between several accounts of this service in another app.",
161
+ "source": "https://www.minimax.io"
162
+ }
89
163
  }
90
164
  }
@@ -12,7 +12,7 @@ export function chatgptPlan(o) {
12
12
  const session = await o.session(signal);
13
13
  if (!session || !Array.isArray(session.scopes) || !session.scopes.includes('chatgpt.tokens.use.direct') ||
14
14
  !session.scopes.includes('resource.invoke') || typeof session.accessToken !== 'string' || !session.accessToken.trim()) {
15
- throw new UnsupportedAccountError('ChatGPT plan usage needs a consented token-sharing session.');
15
+ throw new UnsupportedAccountError('This needs a ChatGPT sign-in that allows plan use.');
16
16
  }
17
17
  return session.accessToken;
18
18
  },
package/dist/cli.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { AccountLike } from './multi.ts';
2
+ export type CliProvider = 'claude' | 'codex';
3
+ /** Shared selection surface; the CLI entry remains Node-only. */
4
+ export type CliAccount = AccountLike & {
5
+ provider: CliProvider;
6
+ billing: 'subscription';
7
+ email?: string;
8
+ plan?: string;
9
+ };
10
+ export type CliOptions = {
11
+ stateDir: string;
12
+ bins: Partial<Record<CliProvider, string>>;
13
+ env: {
14
+ PATH: string;
15
+ HOME: string;
16
+ } & Record<string, string>;
17
+ historyFrom?: Partial<Record<CliProvider, string>>;
18
+ prepare?: (folder: string, provider: CliProvider) => Promise<void>;
19
+ };
20
+ export type SignInCommand = {
21
+ argv: string[];
22
+ env: Record<string, string>;
23
+ completion: string;
24
+ shell: string;
25
+ };
26
+ export declare class CliAccountError extends Error {
27
+ name: string;
28
+ readonly code: 'unknown-account' | 'invalid-name' | 'kind-mismatch' | 'prepare-failed' | 'bad-option';
29
+ constructor(code: CliAccountError['code']);
30
+ }
31
+ /** Only app-managed folders and explicitly supplied absolute CLI binaries. */
32
+ export declare function cliAccounts(options: CliOptions): {
33
+ list: () => Promise<CliAccount[]>;
34
+ add(provider: CliProvider): Promise<{
35
+ account: CliAccount;
36
+ signIn: SignInCommand;
37
+ }>;
38
+ signInAgain: (id: string) => SignInCommand;
39
+ status: (id: string) => Promise<CliAccount>;
40
+ cancel(id: string): Promise<{
41
+ removed: boolean;
42
+ }>;
43
+ rename(id: string, name: string): Promise<CliAccount>;
44
+ remove: (id: string) => Promise<void>;
45
+ launchEnv: (id: string) => {
46
+ set: {
47
+ [x: string]: string;
48
+ };
49
+ unset: string[];
50
+ };
51
+ kinds: (provider: CliProvider) => string[];
52
+ resumeArgs(kind: string, ref: {
53
+ kind: "id" | "path";
54
+ value: string;
55
+ }): string[];
56
+ usageSource(id: string): {
57
+ provider: "codex";
58
+ bin: string;
59
+ home: string;
60
+ env: Record<string, string>;
61
+ } | undefined;
62
+ termsAcknowledged: () => boolean;
63
+ acknowledgeTerms: () => void;
64
+ suggestName: (email: string | undefined, provider: CliProvider) => string;
65
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,337 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes, randomUUID } from 'node:crypto';
3
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import { identity } from '@byokit/usage';
6
+ import { say } from "./words.js";
7
+ export class CliAccountError extends Error {
8
+ name = 'CliAccountError';
9
+ code;
10
+ constructor(code) {
11
+ super(say({ 'unknown-account': 'cli.unknownAccount', 'invalid-name': 'cli.invalidName', 'kind-mismatch': 'cli.kindMismatch', 'prepare-failed': 'cli.prepareFailed', 'bad-option': 'cli.badOption' }[code]));
12
+ this.code = code;
13
+ }
14
+ }
15
+ const record = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
16
+ const text = (v) => typeof v === 'string' && !v.includes('\0');
17
+ const providers = ['claude', 'codex'];
18
+ const shed = {
19
+ claude: ['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_ANTHROPIC_AWS', 'CLAUDE_CODE_USE_MANTLE', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_PROFILE', 'ANTHROPIC_FEDERATION_RULE_ID'],
20
+ codex: ['OPENAI_API_KEY', 'CODEX_API_KEY'],
21
+ };
22
+ const folderVar = (p) => p === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
23
+ const quote = (v) => `'${v.replaceAll("'", "'\\''")}'`;
24
+ const complete = (r) => join(r.folder, '.byokit-signin-complete');
25
+ const pending = (r) => join(r.folder, '.byokit-signin-pending');
26
+ function marker(file) {
27
+ try {
28
+ const stat = lstatSync(file);
29
+ if (!stat.isFile() || stat.isSymbolicLink())
30
+ throw new CliAccountError('bad-option');
31
+ return true;
32
+ }
33
+ catch (error) {
34
+ if (error.code === 'ENOENT')
35
+ return false;
36
+ throw error;
37
+ }
38
+ }
39
+ function json(file) {
40
+ let fd;
41
+ try {
42
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
43
+ const stat = fstatSync(fd);
44
+ if (!stat.isFile() || stat.size > 256 * 1024)
45
+ return undefined;
46
+ const data = Buffer.alloc(256 * 1024 + 1);
47
+ let n = 0;
48
+ while (n < data.length) {
49
+ const count = readSync(fd, data, n, data.length - n, null);
50
+ if (!count)
51
+ break;
52
+ n += count;
53
+ }
54
+ return n < data.length ? JSON.parse(data.subarray(0, n).toString('utf8')) : undefined;
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ finally {
60
+ if (fd !== undefined)
61
+ closeSync(fd);
62
+ }
63
+ }
64
+ function directory(path, create = false) {
65
+ try {
66
+ const s = lstatSync(path);
67
+ return s.isDirectory() && !s.isSymbolicLink();
68
+ }
69
+ catch (error) {
70
+ if (create && error.code === 'ENOENT') {
71
+ mkdirSync(path, { mode: 0o700 });
72
+ return true;
73
+ }
74
+ return false;
75
+ }
76
+ }
77
+ /** Only app-managed folders and explicitly supplied absolute CLI binaries. */
78
+ export function cliAccounts(options) {
79
+ if (!record(options) || !text(options.stateDir) || !isAbsolute(options.stateDir) || !record(options.bins) || !record(options.env) || !text(options.env.PATH) || !text(options.env.HOME) || !isAbsolute(options.env.HOME))
80
+ throw new CliAccountError('bad-option');
81
+ if (Object.entries(options.env).some(([k, v]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(k) || !text(v)))
82
+ throw new CliAccountError('bad-option');
83
+ if (Object.entries(options.bins).some(([p, bin]) => !providers.includes(p) || !text(bin) || !isAbsolute(bin)))
84
+ throw new CliAccountError('bad-option');
85
+ if (options.historyFrom && (!record(options.historyFrom) || Object.entries(options.historyFrom).some(([p, path]) => !providers.includes(p) || !text(path) || !isAbsolute(path))))
86
+ throw new CliAccountError('bad-option');
87
+ const stateDir = resolve(options.stateDir);
88
+ const env = { ...options.env };
89
+ const bins = { ...options.bins };
90
+ const history = { ...options.historyFrom };
91
+ for (const path of ['.claude', '.codex', '.pi']) {
92
+ const own = join(resolve(env.HOME), path);
93
+ if (stateDir === own || stateDir.startsWith(own + '/'))
94
+ throw new CliAccountError('bad-option');
95
+ }
96
+ // Parents must already be app-owned. Never follow a state/provider/folder symlink.
97
+ try {
98
+ if (realpathSync(dirname(stateDir)) !== dirname(stateDir))
99
+ throw new CliAccountError('bad-option');
100
+ }
101
+ catch {
102
+ throw new CliAccountError('bad-option');
103
+ }
104
+ if (!directory(stateDir, true))
105
+ throw new CliAccountError('bad-option');
106
+ chmodSync(stateDir, 0o700);
107
+ const file = join(stateDir, 'accounts-v1.json');
108
+ const created = new Set();
109
+ const operations = new Map();
110
+ function safe(r) {
111
+ const parent = join(stateDir, r.provider);
112
+ return resolve(r.folder) === r.folder && r.folder.startsWith(parent + '/') && /^[a-f0-9]+$/.test(r.folder.slice(parent.length + 1)) && directory(stateDir) && realpathSync(stateDir) === stateDir && directory(parent) && directory(r.folder);
113
+ }
114
+ function load() {
115
+ if (!directory(stateDir) || realpathSync(stateDir) !== stateDir)
116
+ throw new CliAccountError('bad-option');
117
+ const saved = json(file);
118
+ if (!record(saved) || !Array.isArray(saved.accounts))
119
+ return [];
120
+ const seen = new Set();
121
+ const rows = [];
122
+ for (const candidate of saved.accounts) {
123
+ if (!record(candidate) || !text(candidate.id) || !candidate.id || candidate.id.length > 128 || seen.has(candidate.id) || !providers.includes(candidate.provider) || !text(candidate.name) || candidate.name.length > 64 || !text(candidate.folder) || typeof candidate.found !== 'boolean')
124
+ continue;
125
+ const r = { id: candidate.id, provider: candidate.provider, name: candidate.name, folder: candidate.folder, found: candidate.found };
126
+ // Host-owned rows remain byte-compatible in the roster, without touching their folder.
127
+ if (!r.found && !r.id.startsWith('found-') && !safe(r))
128
+ continue;
129
+ seen.add(r.id);
130
+ rows.push(r);
131
+ }
132
+ return rows;
133
+ }
134
+ function atomic(path, value) {
135
+ if (!directory(stateDir) || realpathSync(stateDir) !== stateDir)
136
+ throw new CliAccountError('bad-option');
137
+ const tmp = `${path}.${randomUUID()}.tmp`;
138
+ try {
139
+ writeFileSync(tmp, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
140
+ renameSync(tmp, path);
141
+ }
142
+ catch {
143
+ throw new CliAccountError('prepare-failed');
144
+ }
145
+ finally {
146
+ rmSync(tmp, { force: true });
147
+ }
148
+ }
149
+ const save = (rows) => atomic(file, { version: 1, accounts: rows });
150
+ function row(id) {
151
+ const r = load().find((r) => r.id === id && !r.found && !r.id.startsWith('found-'));
152
+ if (!r)
153
+ throw new CliAccountError('unknown-account');
154
+ return r;
155
+ }
156
+ function binary(p) { const bin = bins[p]; if (!bin)
157
+ throw new CliAccountError('bad-option'); return bin; }
158
+ function launch(r) { return { set: { [folderVar(r.provider)]: r.folder }, unset: [...shed[r.provider]] }; }
159
+ function spawnEnv(r) {
160
+ const out = { ...env };
161
+ for (const key of shed[r.provider])
162
+ delete out[key];
163
+ delete out.CLAUDE_CONFIG_DIR;
164
+ delete out.CODEX_HOME;
165
+ return { ...out, ...launch(r).set };
166
+ }
167
+ function command(r) {
168
+ const argv = r.provider === 'claude' ? [binary(r.provider), 'auth', 'login', '--claudeai'] : [binary(r.provider), 'login', '--device-auth'];
169
+ const passed = spawnEnv(r);
170
+ const completion = complete(r);
171
+ const lock = join(r.folder, '.byokit-signin-lock');
172
+ const login = ['/usr/bin/env', '-i', ...Object.entries(passed).map(([k, v]) => `${k}=${v}`), ...argv].map(quote).join(' ');
173
+ // Cross-process guard: a native CLI owns any refresh transaction; the kit never copies or refreshes its grants.
174
+ const shell = `(umask 077; /bin/mkdir ${quote(lock)} || exit 1; trap ${quote(`/bin/rmdir ${quote(lock)}`)} EXIT; ${login} && (set -C; printf complete > ${quote(completion)}))`;
175
+ return { argv, env: passed, completion, shell };
176
+ }
177
+ function begin(r) {
178
+ const result = command(r);
179
+ // Refuse overlapping sign-in starts; reuse the pending command until completion or cancel.
180
+ if (!marker(pending(r)) || marker(complete(r))) {
181
+ rmSync(complete(r), { force: true });
182
+ if (!marker(pending(r)))
183
+ writeFileSync(pending(r), '', { mode: 0o600, flag: 'wx' });
184
+ }
185
+ return result;
186
+ }
187
+ function serial(id, action) {
188
+ const task = (operations.get(id) ?? Promise.resolve()).then(action, action);
189
+ operations.set(id, task);
190
+ return task.finally(() => { if (operations.get(id) === task)
191
+ operations.delete(id); });
192
+ }
193
+ async function readIdentity(r) {
194
+ if (r.provider === 'codex')
195
+ return identity({ provider: 'codex', bin: binary('codex'), home: r.folder, env: spawnEnv(r) });
196
+ return new Promise((accept) => {
197
+ const child = spawn(binary('claude'), ['auth', 'status'], { env: spawnEnv(r), stdio: ['ignore', 'pipe', 'ignore'] });
198
+ let stdout = '';
199
+ let bytes = 0;
200
+ let settled = false;
201
+ let escalation;
202
+ const finish = (answer) => {
203
+ if (settled)
204
+ return;
205
+ settled = true;
206
+ clearTimeout(timer);
207
+ if (child.exitCode === null && child.signalCode === null) {
208
+ child.kill('SIGTERM');
209
+ escalation = setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
210
+ child.kill('SIGKILL'); }, 1000);
211
+ }
212
+ accept(answer);
213
+ };
214
+ const timer = setTimeout(() => finish({ signedIn: false }), 15_000);
215
+ child.once('error', () => finish({ signedIn: false }));
216
+ child.stdout.setEncoding('utf8');
217
+ child.stdout.on('data', (chunk) => {
218
+ if (settled)
219
+ return;
220
+ bytes += Buffer.byteLength(chunk);
221
+ if (bytes > 256 * 1024) {
222
+ finish({ signedIn: false });
223
+ return;
224
+ }
225
+ stdout += chunk;
226
+ });
227
+ child.once('close', () => {
228
+ clearTimeout(escalation);
229
+ if (settled)
230
+ return;
231
+ if (bytes > 64 * 1024) {
232
+ finish({ signedIn: false });
233
+ return;
234
+ }
235
+ let raw;
236
+ try {
237
+ raw = JSON.parse(stdout);
238
+ }
239
+ catch {
240
+ finish({ signedIn: false });
241
+ return;
242
+ }
243
+ if (!record(raw) || raw.loggedIn !== true) {
244
+ finish({ signedIn: false });
245
+ return;
246
+ }
247
+ const email = typeof raw.email === 'string' && raw.email.length <= 320 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw.email) ? raw.email : undefined;
248
+ const plan = [raw.subscriptionType, raw.plan, raw.planName, raw.tier].find((v) => typeof v === 'string' && /^[a-zA-Z][a-zA-Z0-9 _+-]{0,63}$/.test(v));
249
+ finish({ signedIn: true, ...(email ? { email } : {}), ...(typeof plan === 'string' ? { plan } : {}) });
250
+ });
251
+ });
252
+ }
253
+ function suggestName(email, provider) {
254
+ const first = email?.split('@')[0]?.split(/[._-]+/).find(Boolean);
255
+ return first ? (first[0].toUpperCase() + first.slice(1).toLowerCase()).slice(0, 64) : provider === 'claude' ? 'Claude' : 'Codex';
256
+ }
257
+ async function status(id) {
258
+ return serial(id, async () => {
259
+ const r = row(id);
260
+ const signing = marker(pending(r)) && !marker(complete(r));
261
+ if (signing)
262
+ return { id: r.id, provider: r.provider, name: r.name.trim() || suggestName(undefined, r.provider), billing: 'subscription', state: 'signing' };
263
+ const info = await readIdentity(r);
264
+ if (marker(pending(r)))
265
+ rmSync(pending(r), { force: true });
266
+ // Pre-existing roster rows have no kit marker; their native status remains authoritative.
267
+ return { id: r.id, provider: r.provider, name: r.name.trim() || suggestName(info.email, r.provider), billing: 'subscription', state: info.signedIn ? 'ready' : 'signed_out', ...(info.email ? { email: info.email } : {}), ...(info.plan ? { plan: info.plan } : {}) };
268
+ });
269
+ }
270
+ async function remove(id) {
271
+ return serial(id, async () => { const r = row(id); rmSync(r.folder, { recursive: true, force: true }); save(load().filter((v) => v.id !== id)); created.delete(id); });
272
+ }
273
+ return {
274
+ list: async () => Promise.all(load().filter((r) => !r.found && !r.id.startsWith('found-')).map((r) => status(r.id))),
275
+ async add(provider) {
276
+ if (!providers.includes(provider))
277
+ throw new CliAccountError('bad-option');
278
+ binary(provider);
279
+ const parent = join(stateDir, provider);
280
+ if (!directory(stateDir) || !directory(parent, true))
281
+ throw new CliAccountError('bad-option');
282
+ chmodSync(parent, 0o700);
283
+ const r = { id: `pa_${randomBytes(9).toString('hex')}`, provider, name: '', folder: join(parent, randomBytes(8).toString('hex')), found: false };
284
+ mkdirSync(r.folder, { mode: 0o700 });
285
+ try {
286
+ if (history[provider])
287
+ symlinkSync(history[provider], join(r.folder, provider === 'claude' ? 'projects' : 'sessions'), 'dir');
288
+ const signIn = begin(r);
289
+ await options.prepare?.(r.folder, provider);
290
+ if (!safe(r))
291
+ throw new CliAccountError('prepare-failed');
292
+ save([...load(), r]);
293
+ created.add(r.id);
294
+ return { account: { id: r.id, provider, name: suggestName(undefined, provider), billing: 'subscription', state: 'signing' }, signIn };
295
+ }
296
+ catch {
297
+ rmSync(r.folder, { recursive: true, force: true });
298
+ throw new CliAccountError('prepare-failed');
299
+ }
300
+ },
301
+ signInAgain: (id) => {
302
+ if (operations.has(id))
303
+ throw new CliAccountError('prepare-failed');
304
+ return begin(row(id));
305
+ },
306
+ status,
307
+ async cancel(id) {
308
+ if (created.has(id)) {
309
+ await remove(id);
310
+ return { removed: true };
311
+ }
312
+ return serial(id, async () => { const r = row(id); rmSync(pending(r), { force: true }); return { removed: false }; });
313
+ },
314
+ async rename(id, name) {
315
+ if (!text(name) || !name.trim() || name.trim().length > 64 || /[\x00-\x1f\x7f]/.test(name))
316
+ throw new CliAccountError('invalid-name');
317
+ await serial(id, async () => { row(id); const rows = load(); rows.find((r) => r.id === id).name = name.trim(); save(rows); });
318
+ return status(id);
319
+ },
320
+ remove,
321
+ launchEnv: (id) => launch(row(id)),
322
+ kinds: (provider) => providers.includes(provider) ? [provider] : [],
323
+ resumeArgs(kind, ref) {
324
+ if (!text(ref.value) || !ref.value || (kind !== 'claude' && kind !== 'codex') || (kind === 'claude' && ref.kind !== 'id') || (kind === 'codex' && ref.kind !== 'id'))
325
+ throw new CliAccountError('kind-mismatch');
326
+ return kind === 'claude' ? ['--resume', ref.value] : ['resume', ref.value];
327
+ },
328
+ usageSource(id) {
329
+ const r = row(id);
330
+ return r.provider === 'codex' ? { provider: 'codex', bin: binary('codex'), home: r.folder, env: spawnEnv(r) } : undefined;
331
+ },
332
+ termsAcknowledged: () => { if (!directory(stateDir))
333
+ throw new CliAccountError('bad-option'); const saved = json(join(stateDir, 'auto-terms-v1.json')); return record(saved) && saved.acknowledged === true; },
334
+ acknowledgeTerms: () => atomic(join(stateDir, 'auto-terms-v1.json'), { acknowledged: true }),
335
+ suggestName,
336
+ };
337
+ }
@@ -0,0 +1,77 @@
1
+ export type SignInState = 'ready' | 'signing' | 'resting' | 'signed_out' | 'needs_again' | 'not_included';
2
+ export type AccountLike = {
3
+ id: string;
4
+ provider: string;
5
+ name: string;
6
+ state: SignInState;
7
+ billing: 'subscription' | 'api';
8
+ until?: number;
9
+ };
10
+ export type RoomSpan = 'session' | 'week' | 'month' | 'tightest';
11
+ /** Every time here is epoch milliseconds; `at` is the source measurement time, never its receipt time. */
12
+ export type Room = {
13
+ left: number;
14
+ span: RoomSpan;
15
+ resetsAt?: number;
16
+ at?: number;
17
+ } | {
18
+ left: 'unknown';
19
+ at?: number;
20
+ };
21
+ export type RunSelection = {
22
+ account: string | 'default' | 'auto';
23
+ model?: string;
24
+ needs?: string[];
25
+ };
26
+ export type Defaults = {
27
+ account?: string;
28
+ model?: string;
29
+ auto?: boolean;
30
+ };
31
+ export type PickWhy = 'chosen' | 'default' | 'first_ready' | 'only' | 'most_room' | 'earlier_reset' | 'list_order' | 'no_reading' | 'refills_first';
32
+ export type Considered = {
33
+ id: string;
34
+ out?: 'state' | 'resting' | 'billing' | 'model' | 'provider';
35
+ until?: number;
36
+ missing?: string;
37
+ tier?: 'room' | 'unknown' | 'exhausted';
38
+ left: number | 'unknown';
39
+ span?: RoomSpan;
40
+ resetsAt?: number;
41
+ age: number | 'unknown';
42
+ confidence: 'known' | 'stale' | 'unknown';
43
+ reason: PickWhy | 'state' | 'resting' | 'billing' | 'model' | 'provider';
44
+ };
45
+ export type AccountPick<A extends AccountLike = AccountLike> = {
46
+ ok: true;
47
+ account: A;
48
+ model: string;
49
+ how: 'chosen' | 'default' | 'auto';
50
+ why: PickWhy;
51
+ reason: string;
52
+ considered: Considered[];
53
+ } | {
54
+ ok: false;
55
+ code: 'none' | 'not_included' | 'unknown_account';
56
+ reason: string;
57
+ considered: Considered[];
58
+ };
59
+ type Models<A> = (a: A) => readonly {
60
+ id: string;
61
+ available: boolean;
62
+ }[];
63
+ /** Structural windows: legacy reset seconds by default; usage 0.2.0+ must pass milliseconds explicitly. */
64
+ export declare function roomOf(windows: readonly {
65
+ usedPercent: number;
66
+ kind: string;
67
+ resetsAt?: number;
68
+ }[], at?: number, resetUnit?: 'seconds' | 'milliseconds'): Room;
69
+ export declare function roomWords(room: Room): string;
70
+ /** Auto's winner only. Pure and portable; no timers, reads, refreshes or mid-run switching. */
71
+ export declare function chooseAccount<A extends AccountLike>(accounts: readonly A[], room: (a: A) => Room, nowMs: number): {
72
+ account: A;
73
+ why: PickWhy;
74
+ } | undefined;
75
+ /** Resolve once before starting. Without `models`, membership is host-validated and a missing model is ''. */
76
+ export declare function resolveSelection<A extends AccountLike>(accounts: readonly A[], defaults: Defaults, sel: RunSelection, room: (a: A, demand: readonly string[]) => Room, nowMs: number, models?: Models<A>): AccountPick<A>;
77
+ export {};
package/dist/multi.js ADDED
@@ -0,0 +1,134 @@
1
+ // Pure start-of-run selection. Callers own identities and credentials and keep the returned account for the run.
2
+ import { clock, say } from "./words.js";
3
+ const DAY_MS = 24 * 60 * 60 * 1000;
4
+ const finite = (n) => typeof n === 'number' && Number.isFinite(n);
5
+ const reset = (r) => r.resetsAt ?? Infinity;
6
+ /** Structural windows: legacy reset seconds by default; usage 0.2.0+ must pass milliseconds explicitly. */
7
+ export function roomOf(windows, at, resetUnit = 'seconds') {
8
+ const known = windows.filter((w) => finite(w.usedPercent));
9
+ if (!known.length)
10
+ return { left: 'unknown', ...(finite(at) ? { at } : {}) };
11
+ const w = known.reduce((a, b) => b.usedPercent > a.usedPercent ? b : a);
12
+ const span = w.kind === 'session' ? 'session' : w.kind === 'weekly' ? 'week' : w.kind === 'monthly' ? 'month' : 'tightest';
13
+ return { left: Math.max(0, Math.min(100, 100 - w.usedPercent)), span,
14
+ ...(finite(w.resetsAt) ? { resetsAt: w.resetsAt * (resetUnit === 'seconds' ? 1000 : 1) } : {}), ...(finite(at) ? { at } : {}) };
15
+ }
16
+ export function roomWords(room) {
17
+ return room.left === 'unknown' ? say('room.unknown') : say(`room.${room.span}`, { left: `${Math.round(room.left)}%` });
18
+ }
19
+ // One shared eligibility predicate for direct picks, Auto and all explanation rows. Room is read once per row.
20
+ function consider(accounts, room, nowMs, demand = [], models, provider, named) {
21
+ return accounts.map((account) => {
22
+ const r = room(account);
23
+ const age = finite(r.at) ? Math.max(0, nowMs - r.at) : 'unknown';
24
+ const stale = typeof age === 'number' && age > DAY_MS;
25
+ const left = r.left === 'unknown' || !finite(r.left) || stale ? 'unknown' : Math.max(0, Math.min(100, r.left));
26
+ const row = { id: account.id, left, age,
27
+ confidence: stale ? 'stale' : left === 'unknown' || age === 'unknown' ? 'unknown' : 'known', reason: 'no_reading' };
28
+ if (r.left !== 'unknown') {
29
+ row.span = r.span;
30
+ if (finite(r.resetsAt))
31
+ row.resetsAt = r.resetsAt;
32
+ }
33
+ // Explicit ids bypass state and billing: the host verifies readiness before starting, never falls back.
34
+ if (account.id !== named) {
35
+ if (account.state === 'resting' && finite(account.until) && account.until > nowMs) {
36
+ row.out = 'resting';
37
+ row.until = account.until;
38
+ }
39
+ else if (account.state !== 'ready' && !(account.state === 'resting' && finite(account.until) && account.until <= nowMs))
40
+ row.out = 'state';
41
+ else if (account.billing !== 'subscription')
42
+ row.out = 'billing';
43
+ }
44
+ if (!row.out && demand.length && models) {
45
+ const available = models(account);
46
+ const missing = demand.find((id) => !available.some((m) => m.id === id && m.available));
47
+ if (missing !== undefined) {
48
+ row.out = 'model';
49
+ row.missing = missing;
50
+ }
51
+ }
52
+ if (!row.out && !demand.length && provider !== undefined && account.provider !== provider && account.id !== named)
53
+ row.out = 'provider';
54
+ if (row.out)
55
+ row.reason = row.out;
56
+ else
57
+ row.tier = left === 'unknown' ? 'unknown' : left > 0 || reset(row) <= nowMs ? 'room' : 'exhausted';
58
+ return { account, row };
59
+ });
60
+ }
61
+ const tierOrder = { room: 0, unknown: 1, exhausted: 2 };
62
+ function compare(a, b) {
63
+ const tier = tierOrder[a.row.tier] - tierOrder[b.row.tier];
64
+ if (tier)
65
+ return tier;
66
+ if (a.row.tier === 'unknown')
67
+ return 0;
68
+ if (a.row.tier === 'room' && a.row.left !== b.row.left)
69
+ return Number(b.row.left) - Number(a.row.left);
70
+ // Avoid Infinity - Infinity (NaN); stable sort preserves list order for ties.
71
+ return reset(a.row) === reset(b.row) ? 0 : reset(a.row) < reset(b.row) ? -1 : 1;
72
+ }
73
+ function rankingReason(winner, next) {
74
+ if (winner.row.tier === 'unknown')
75
+ return 'no_reading';
76
+ if (winner.row.tier === 'exhausted')
77
+ return 'refills_first';
78
+ if (!next || next.row.tier !== 'room' || winner.row.left !== next.row.left)
79
+ return 'most_room';
80
+ return reset(winner.row) < reset(next.row) ? 'earlier_reset' : 'list_order';
81
+ }
82
+ function rank(rows) {
83
+ const ranked = rows.filter((c) => !c.row.out).sort(compare);
84
+ const winner = ranked[0];
85
+ if (!winner)
86
+ return undefined;
87
+ const why = ranked.length === 1 ? 'only' : rankingReason(winner, ranked[1]);
88
+ for (const c of ranked)
89
+ c.row.reason = c === winner ? why : rankingReason(winner, c);
90
+ return { account: winner.account, why, row: winner.row };
91
+ }
92
+ /** Auto's winner only. Pure and portable; no timers, reads, refreshes or mid-run switching. */
93
+ export function chooseAccount(accounts, room, nowMs) {
94
+ const pick = rank(consider(accounts, room, nowMs));
95
+ return pick && { account: pick.account, why: pick.why };
96
+ }
97
+ /** Resolve once before starting. Without `models`, membership is host-validated and a missing model is ''. */
98
+ export function resolveSelection(accounts, defaults, sel, room, nowMs, models) {
99
+ const demand = [...new Set([...(sel.model ? [sel.model] : []), ...(sel.needs ?? [])])];
100
+ const defaultAccount = accounts.find((a) => a.id === defaults.account);
101
+ const named = sel.account === 'default' ? defaultAccount?.state === 'ready' ? defaultAccount.id : undefined : sel.account === 'auto' ? undefined : sel.account;
102
+ const provider = defaultAccount?.provider ?? accounts[0]?.provider;
103
+ const rows = consider(accounts, (a) => room(a, demand), nowMs, demand, models, provider, named);
104
+ const considered = rows.map((c) => c.row);
105
+ const auto = rank(rows); // also fills explanations for every eligible row, even on explicit selection
106
+ if (named !== undefined) {
107
+ const chosen = rows.find((c) => c.account.id === named);
108
+ if (!chosen)
109
+ return { ok: false, code: 'unknown_account', reason: say('pick.unknownAccount'), considered };
110
+ if (chosen.row.out)
111
+ return { ok: false, code: 'not_included', reason: say('pick.out.model', { model: chosen.row.missing ?? '' }), considered };
112
+ return finish(chosen.account, chosen.row, sel.account === 'default' ? 'default' : 'chosen', sel.account === 'default' ? 'default' : 'chosen');
113
+ }
114
+ if (sel.account === 'default' && defaults.auto === false) {
115
+ const first = rows.find((c) => !c.row.out);
116
+ if (first)
117
+ return finish(first.account, first.row, 'default', 'first_ready');
118
+ }
119
+ else if (auto)
120
+ return finish(auto.account, auto.row, 'auto', auto.why);
121
+ return { ok: false, code: 'none', reason: say('auto.none', { provider: provider ?? '' }), considered };
122
+ function finish(account, row, how, why) {
123
+ const available = models?.(account).filter((m) => m.available);
124
+ const model = sel.model ?? (defaults.model && (!available || available.some((m) => m.id === defaults.model)) ? defaults.model : available?.[0]?.id) ?? '';
125
+ if (models && !model)
126
+ return { ok: false, code: 'not_included', reason: say('pick.noModel'), considered };
127
+ row.reason = why;
128
+ const slots = { name: account.name, provider: account.provider };
129
+ const reason = how !== 'auto' ? say(`pick.why.${why}`, slots) : row.tier === 'unknown' ? say('auto.unknown', slots) : row.tier === 'exhausted' ?
130
+ say(finite(row.resetsAt) ? 'auto.refills' : 'auto.refillsNoTime', { ...slots, time: finite(row.resetsAt) ? clock(row.resetsAt) : '' }) :
131
+ say('auto.room', { ...slots, room: roomWords({ left: Number(row.left), span: row.span ?? 'tightest' }) });
132
+ return { ok: true, account, model, how, why, reason, considered };
133
+ }
134
+ }
@@ -1,5 +1,5 @@
1
1
  export { Accounts, planOf, portable, type AccountsOptions, type ClaudePlanAsk, type AnthropicAccountAsk, type AuthHost, type Loopback, type Member, type Platform, type SignIn, type Status } from './accounts.ts';
2
- export { PROVIDERS, offered, provider, type Billing, type Provider, type Terms } from './catalogue.ts';
2
+ export { PROVIDERS, offered, provider, type Billing, type MultiAccountTerms, type Provider } from './catalogue.ts';
3
3
  export { PORTABLE, claims, credentialOf, devicePoll, deviceStart, portableEngine, type EngineOptions, type Poll } from './engine.ts';
4
4
  export { REST_MS, classify, classifyFailure, type Failure, type Kind } from './limits.ts';
5
5
  export { IncompleteError, ResponseError, isFunctionCall, limitResponse, respond, sseReader, type Ask, type ResponseFunctionCall, type ResponseInputItem, type ResponseOutputItem, type ResponseOutputMessage, type ResponseReasoning, type ResponseResult, type ResponseStreamEvent, type ResponseText, type ResponseTextFormat, type ResponseTool, type ResponseToolChoice } from './responses.ts';
@@ -8,3 +8,4 @@ export { WORDS, billingWords, callbackPage, clock, failure, say, signInError, ty
8
8
  export { chatgptPlan, UnsupportedAccountError, type ChatGPTPlanAccount, type ChatGPTPlanSession } from './chatgpt-plan.ts';
9
9
  export { anthropic, anthropicSseReader, AnthropicIncompleteError, type AnthropicAsk, type AnthropicCacheControl, type AnthropicContent, type AnthropicImage, type AnthropicMessage, type AnthropicOptions, type AnthropicRequest, type AnthropicResponse, type AnthropicResult, type AnthropicStreamEvent, type AnthropicText, type AnthropicThinking, type AnthropicTool, type AnthropicToolChoice, type AnthropicToolUse, type AnthropicUsage } from './anthropic.ts';
10
10
  export { ClaudePlanExpiredError, ClaudePlanPlatformError, claudeAuthorization, claudeCode, type ClaudePlanOptions } from './claude-plan.ts';
11
+ export { chooseAccount, resolveSelection, roomOf, roomWords, type AccountLike, type AccountPick, type Considered, type Defaults, type PickWhy, type Room, type RoomSpan, type RunSelection, type SignInState } from './multi.ts';
package/dist/portable.js CHANGED
@@ -10,3 +10,4 @@ export { WORDS, billingWords, callbackPage, clock, failure, say, signInError } f
10
10
  export { chatgptPlan, UnsupportedAccountError } from "./chatgpt-plan.js";
11
11
  export { anthropic, anthropicSseReader, AnthropicIncompleteError } from "./anthropic.js";
12
12
  export { ClaudePlanExpiredError, ClaudePlanPlatformError, claudeAuthorization, claudeCode } from "./claude-plan.js";
13
+ export { chooseAccount, resolveSelection, roomOf, roomWords } from "./multi.js";
package/dist/words.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "signIn.opening": "Opening {name}…",
2
+ "signIn.opening": "Opening {name}\u2026",
3
3
  "signIn.waitingUrl": "Sign in on the {name} page that just opened.",
4
4
  "signIn.waitingCode": "On the {name} page, type this code: {code}",
5
5
  "signIn.pasteHint": "Having trouble? Copy the address from the browser and paste it here.",
@@ -12,7 +12,7 @@
12
12
  "signIn.busy": "Something else on this computer is signing in to {name}. Try again in a minute.",
13
13
  "signIn.failed": "{name} didn't finish the sign-in. Tap Sign in with {name} to try again.",
14
14
  "status.ready": "{name} is connected.",
15
- "status.signing": "Signing in to {name}…",
15
+ "status.signing": "Signing in to {name}\u2026",
16
16
  "status.resting": "{name} is resting until {until}.",
17
17
  "status.busy": "{name} is busy right now.",
18
18
  "status.signedOut": "{name} isn't signed in yet.",
@@ -25,5 +25,42 @@
25
25
  "callback.declined": "No problem. Nothing was changed. You can go back to {app}.",
26
26
  "callback.failed": "{error} Go back to {app}.",
27
27
  "callback.nearly": "Nearly there. Go back to {app} to finish.",
28
- "callback.outOfDate": "This sign-in page is out of date. Go back to {app} and tap Sign in with {name} again."
28
+ "callback.outOfDate": "This sign-in page is out of date. Go back to {app} and tap Sign in with {name} again.",
29
+ "auto.terms": "Auto may use either of a provider's accounts.",
30
+ "auto.room": "Right now that's {name}: {room}",
31
+ "auto.unknown": "Right now that's {name} (no recent reading)",
32
+ "auto.refills": "All {provider} accounts are out of room until {time}. {name} refills first.",
33
+ "auto.refillsNoTime": "All {provider} accounts are out of room. {name} refills first.",
34
+ "auto.none": "No signed-in {provider} account.",
35
+ "room.unknown": "Room left unknown",
36
+ "room.session": "{left} left this session",
37
+ "room.week": "{left} left this week",
38
+ "room.month": "{left} left this month",
39
+ "room.tightest": "{left} left for now",
40
+ "pick.out.state": "Not signed in right now",
41
+ "pick.out.resting": "Taking a break until {time}",
42
+ "pick.out.billing": "Billed per use, so used only when you choose it",
43
+ "pick.out.model": "Doesn't include {model}",
44
+ "pick.out.provider": "A different service",
45
+ "pick.out.bound": "This conversation uses another account",
46
+ "pick.unknownAccount": "That account is no longer in your list.",
47
+ "pick.noModel": "That account doesn't include a model for this run.",
48
+ "pick.why.chosen": "You chose {name}.",
49
+ "pick.why.default": "{name} is your default.",
50
+ "pick.why.first_ready": "Your default isn't ready, so {name}, the first ready account.",
51
+ "pick.why.only": "{name} is the only account that can take this.",
52
+ "pick.why.most_room": "{name} has the most room left.",
53
+ "pick.why.earlier_reset": "{name} has as much room left and refills sooner.",
54
+ "pick.why.list_order": "{name} is tied for room and comes first in your list.",
55
+ "pick.why.no_reading": "No account has a recent reading, so {name}, first in your list.",
56
+ "pick.why.refills_first": "All accounts are out of room; {name} refills first.",
57
+ "pick.age": "Read {ago} ago",
58
+ "pick.ageUnknown": "Reading time unknown",
59
+ "ago.minutes": "{n} minutes",
60
+ "ago.hours": "{n} hours",
61
+ "cli.unknownAccount": "Choose an account from the list.",
62
+ "cli.invalidName": "Give the account a name up to 64 characters.",
63
+ "cli.kindMismatch": "That agent cannot use this account.",
64
+ "cli.prepareFailed": "Could not prepare the account. Try again.",
65
+ "cli.badOption": "The app supplied invalid account options."
29
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/accounts",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Sign in with the AI plan you already pay for, into your app's own store.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,6 +29,10 @@
29
29
  "types": "./dist/isolate.d.ts",
30
30
  "default": "./dist/isolate.js"
31
31
  },
32
+ "./cli": {
33
+ "types": "./dist/cli.d.ts",
34
+ "default": "./dist/cli.js"
35
+ },
32
36
  "./testing": {
33
37
  "types": "./dist/testing/index.d.ts",
34
38
  "default": "./dist/testing/index.js"
@@ -48,6 +52,7 @@
48
52
  "prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
49
53
  },
50
54
  "dependencies": {
55
+ "@byokit/usage": "0.4.0",
51
56
  "@earendil-works/pi-ai": "0.87.1"
52
57
  },
53
58
  "publishConfig": {