opencode-swap 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,952 @@
1
+ """Orchestrates opencode-swap's account operations.
2
+
3
+ Ties together paths.py (where OpenCode's and our own state live),
4
+ opencode_auth.py (safe auth.json I/O), the Provider seam (record
5
+ interpretation), and store.py (SecretStore + Registry) into the actual
6
+ add/remove/rename/current commands. `use` (the risky part — swapping the
7
+ live account, with sync-back and atomic replace) lands separately.
8
+
9
+ Every mutating operation holds the same FileLock, serializing opencode-swap's
10
+ own concurrent invocations (see locking.py). This lock has no relationship
11
+ to OpenCode's own process.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from collections.abc import Callable
18
+ from contextlib import suppress
19
+ from dataclasses import dataclass, field, replace
20
+ from datetime import UTC, datetime
21
+ from enum import Enum, auto
22
+ from pathlib import Path
23
+
24
+ from opencode_swap import backup, opencode_auth, paths, transfer, usage
25
+ from opencode_swap.exceptions import AccountExistsError, AuthFileError, OpenCodeSwapError, RefreshError, SchemaError
26
+ from opencode_swap.locking import FileLock
27
+ from opencode_swap.models import (
28
+ AccountDesc,
29
+ AccountMeta,
30
+ AuthRecord,
31
+ ImportConflictAction,
32
+ JsonObject,
33
+ Platform,
34
+ SwitchTransaction,
35
+ Validity,
36
+ normalize_account_name,
37
+ normalize_provider_id,
38
+ )
39
+ from opencode_swap.providers import get_provider
40
+ from opencode_swap.providers.base import Provider
41
+ from opencode_swap.store import Registry, SecretStore
42
+
43
+
44
+ def _secret_key(provider_id: str, name: str) -> str:
45
+ return f"{provider_id}:{name}"
46
+
47
+
48
+ def _now_iso() -> str:
49
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
50
+
51
+
52
+ def _transfer_added(value: str) -> str:
53
+ # %z (not a literal "Z" in the format string) is what makes this an aware
54
+ # parse: Python's strptime treats a literal "Z" in the input as UTC under
55
+ # %z, same as _now_iso()'s producer side, and re-formatting with a
56
+ # literal "Z" here round-trips to the identical string for any input this
57
+ # produced. The point of the round trip is strict-canonical-format
58
+ # validation, not timezone conversion.
59
+ normalized = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z").strftime("%Y-%m-%dT%H:%M:%SZ")
60
+ if normalized != value:
61
+ raise ValueError("timestamp is not canonical")
62
+ return normalized
63
+
64
+
65
+ def _without_archive_credential(value: str | None, credentials: set[str]) -> str | None:
66
+ if value is None or any(secret in value for secret in credentials):
67
+ return None
68
+ return value
69
+
70
+
71
+ def _same_orphan_record(provider: Provider, stored: AuthRecord, incoming: AuthRecord) -> bool:
72
+ if stored.raw == incoming.raw:
73
+ return True
74
+ stored_identity = provider.identity(stored)
75
+ return stored_identity == provider.identity(incoming) and provider.identity_is_stable(stored)
76
+
77
+
78
+ class RefreshOutcome(Enum):
79
+ """Why `Switcher._ensure_refreshed` did or didn't perform a standalone
80
+ refresh, for callers (currently `refresh_account`/the `refresh` CLI
81
+ command) that need to report an accurate reason rather than let a
82
+ caller-still-EXPIRED `Validity` alone imply the wrong one -- EXPIRED
83
+ from an ambiguous live state and EXPIRED from "this provider/type has
84
+ no standalone refresh at all" are different situations that call for
85
+ different messages.
86
+ """
87
+
88
+ LIVE = auto() # resolved via live auth.json; OpenCode owns its own refresh, never attempted here
89
+ AMBIGUOUS = auto() # live-ownership couldn't be verified; refresh skipped despite being supported
90
+ NO_SUPPORT = auto() # this provider/record type has no standalone refresh at all (Provider.refresh returned None)
91
+ RESOLVED = auto() # resolved from the stored record via normal means: already valid, or freshly refreshed
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class AccountRefreshResult:
96
+ """`Switcher.refresh_account`'s result: the account's resulting
97
+ validity, plus why (see `RefreshOutcome`) -- so a caller reporting
98
+ "still expired" can say whether that's because this provider/type has
99
+ no standalone refresh at all, or because the live account state
100
+ couldn't be safely verified this time (worth retrying), rather than
101
+ conflating the two under one misleading message.
102
+ """
103
+
104
+ validity: Validity
105
+ outcome: RefreshOutcome
106
+
107
+
108
+ @dataclass
109
+ class Switcher:
110
+ opencode_auth_path: Path
111
+ data_root: Path
112
+ platform: Platform | None = None # override for tests; None = auto-detect
113
+ registry: Registry = field(init=False)
114
+ secrets: SecretStore = field(init=False)
115
+ lock: FileLock = field(init=False)
116
+
117
+ def __post_init__(self) -> None:
118
+ self.data_root.mkdir(parents=True, exist_ok=True)
119
+ self.data_root.chmod(0o700)
120
+ self.registry = Registry(self.data_root / "registry.json")
121
+ self.secrets = SecretStore(self.data_root / "secrets", platform=self.platform)
122
+ self.lock = FileLock(self.data_root / ".lock")
123
+ with self.lock:
124
+ self.registry.migrate()
125
+
126
+ @classmethod
127
+ def default(cls) -> Switcher:
128
+ return cls(opencode_auth_path=paths.get_opencode_auth_path(), data_root=paths.get_data_root())
129
+
130
+ @staticmethod
131
+ def _provider(provider_id: str) -> Provider:
132
+ return get_provider(normalize_provider_id(provider_id))
133
+
134
+ def _sweep_secret_upgrades(self) -> None:
135
+ """Best-effort: reseal any account still on an older secret-store
136
+ format into the current v3 envelope (SecretStore.upgrade). Called
137
+ under `self.lock` at the start of every mutating operation so a
138
+ record that isn't otherwise rewritten by the operation itself
139
+ still gets upgraded, without two concurrent invocations racing to
140
+ upgrade the same key."""
141
+ for provider_id, name in self.registry.scoped_accounts():
142
+ self.secrets.upgrade(_secret_key(provider_id, name))
143
+
144
+ def _load_record(self, provider_id: str, name: str, *, confirmed: bool = False) -> AuthRecord | None:
145
+ key = _secret_key(provider_id, name)
146
+ stored = self.secrets.get_confirmed(key) if confirmed else self.secrets.get(key)
147
+ if stored is None:
148
+ return None
149
+ return self._parse_stored_record(provider_id, name, stored)
150
+
151
+ def _parse_stored_record(self, provider_id: str, name: str, stored: str) -> AuthRecord:
152
+ meta = self.registry.scoped_accounts().get((provider_id, name))
153
+ try:
154
+ raw = json.loads(stored)
155
+ except json.JSONDecodeError as exc:
156
+ raise SchemaError(f"stored credentials for '{name}' are not valid JSON") from exc
157
+ record = self._provider(provider_id).extract({provider_id: raw})
158
+ if record is None: # extract only returns None for an absent provider key
159
+ raise SchemaError(f"stored credentials for '{name}' are missing")
160
+ if meta is not None and record.type != meta.type:
161
+ raise SchemaError(f"stored credentials for '{name}' do not match registry type")
162
+ return record
163
+
164
+ def _find_by_identity(self, provider_id: str, identity: str, *, confirmed: bool = False) -> str | None:
165
+ provider = self._provider(provider_id)
166
+ for _stored_provider, name in self.registry.scoped_accounts(provider_id):
167
+ record = self._load_record(provider_id, name, confirmed=confirmed)
168
+ if record is None:
169
+ continue
170
+ if provider.identity(record) == identity:
171
+ return name
172
+ return None
173
+
174
+ def _live_attribution(self, provider_id: str, name: str) -> tuple[AuthRecord | None, bool]:
175
+ """Attempt to attribute the live OpenCode-auth.json record to the
176
+ saved account `name`. Returns `(live_record, ambiguous)`:
177
+
178
+ - `(record, False)`: attribution succeeded -- `record` is `name`'s
179
+ current live credential, more current than whatever
180
+ opencode-swap last captured into the secret store (OpenCode
181
+ rewrites auth.json in place on every token refresh; see
182
+ docs/opencode-auth.md#loading-and-refresh).
183
+ - `(None, False)`: there is definitely no live record to attribute
184
+ -- auth.json doesn't exist, or this provider has no live entry.
185
+ Safe to treat `name` as not currently live.
186
+ - `(None, True)`: attribution is ambiguous. Two distinct cases collapse
187
+ to this, both meaning "do not treat `name` as safe to
188
+ standalone-refresh":
189
+ 1. auth.json exists but can't be read or parsed. A present-but-
190
+ unreadable file might still hold `name`'s live credential --
191
+ unlike a genuinely absent file, this is not proof of absence.
192
+ 2. A live record exists whose identity matches no saved
193
+ account, `name` is this provider's registry-active account,
194
+ shares the live record's type, and `name`'s own stored
195
+ identity is unstable. This is the shape of an in-flight
196
+ unstable-to-stable identity transition (OpenCode rotated
197
+ `name`'s token and the new one happens to carry an
198
+ `accountId` claim the old one lacked) -- or an unrelated
199
+ foreign login. Either way, `name`'s stored refresh token may
200
+ already have been invalidated by whatever this rotation was.
201
+
202
+ Never accepts attribution through the registry's active-name hint
203
+ alone (case 2 above refuses, it never accepts): `use_account` only
204
+ ever uses that same hint to decide whether to *refuse* an ambiguous
205
+ live credential (stash it as unclaimed and raise, see its
206
+ ambiguous-identity branch) -- never to *accept* it as belonging to
207
+ the active account. A foreign OpenCode login with no stable
208
+ identity would otherwise be silently attributed to whatever account
209
+ the registry last recorded as active, and a caller that then syncs
210
+ it back (see `_ensure_refreshed`) would overwrite that account's
211
+ real stored credentials with the foreign one.
212
+ """
213
+ if not self.opencode_auth_path.exists():
214
+ return None, False
215
+ try:
216
+ live_record = self._read_live_record(provider_id)
217
+ except AuthFileError:
218
+ return None, True
219
+ if live_record is None:
220
+ return None, False
221
+
222
+ provider = self._provider(provider_id)
223
+ identity = provider.identity(live_record)
224
+ owner = self._find_by_identity(provider_id, identity)
225
+ if owner == name:
226
+ return live_record, False
227
+
228
+ if self.registry.get_active(provider_id) == name:
229
+ meta = self.registry.scoped_accounts().get((provider_id, name))
230
+ if meta is not None and meta.type == live_record.type:
231
+ stored_record = self._load_record(provider_id, name)
232
+ if stored_record is not None and not provider.identity_is_stable(stored_record):
233
+ return None, True
234
+
235
+ return None, False
236
+
237
+ def _ensure_refreshed(self, provider_id: str, name: str, *, allow_refresh: bool) -> tuple[AuthRecord, RefreshOutcome] | None:
238
+ """The most current OAuth record attributable to saved account
239
+ `name`, as `(record, outcome)`. None if there's no live-owned or
240
+ stored record to resolve at all.
241
+
242
+ Live-attribution, sync-back, and any standalone refresh all happen
243
+ inside a single `self.lock` acquisition, and everything they act on
244
+ (live auth.json, the stored secret) is re-read fresh *after* the
245
+ lock is acquired rather than trusted from a value a caller computed
246
+ before waiting for the lock. Both properties matter for the same
247
+ reason: `use_account` (which also takes this lock) can splice a
248
+ rotated live credential into the secret store as part of its own
249
+ sync-back at any moment. Without re-checking under the lock, a
250
+ delayed caller here could either (a) attribute a *stale* cached live
251
+ snapshot to `name` and overwrite a newer sync-back `use_account` just
252
+ performed, permanently losing the freshest token, or (b) spend a
253
+ standalone refresh on `name`'s stored refresh token after `name`
254
+ became the live-active account, invalidating the very token
255
+ OpenCode itself is about to try to refresh with on its next
256
+ request.
257
+
258
+ Never triggers a standalone refresh for the account currently live
259
+ in OpenCode, regardless of `allow_refresh`: OpenCode owns that
260
+ refresh on its own next request (docs/opencode-auth.md#loading-and-refresh) --
261
+ reported as `RefreshOutcome.LIVE`. Also never refreshes when
262
+ `_live_attribution` reports the live state as ambiguous (auth.json
263
+ exists but couldn't be read, or `name` is the registry-active
264
+ account mid an unstable-to-stable identity transition) -- reported
265
+ as `RefreshOutcome.AMBIGUOUS`, distinct from `RefreshOutcome.NO_SUPPORT`
266
+ (this provider/record type has no standalone refresh at all): in
267
+ both ambiguous and live cases `name`'s stored refresh token might
268
+ already have been invalidated by whatever is currently live, so a
269
+ standalone refresh attempt here could either spend a token that's
270
+ about to be superseded anyway, or fail and misreport a perfectly
271
+ healthy, refreshable account as needing re-login. The stored record
272
+ (however stale it looks) is returned as-is instead. Callers must
273
+ not assume EXPIRED plus a lack of refresh means "unsupported" --
274
+ check `outcome`.
275
+
276
+ When `allow_refresh` is True, attribution isn't ambiguous, and the
277
+ resolved record is a stored (not live) copy that's expired,
278
+ refreshes it and persists the rotated token before returning
279
+ (`RefreshOutcome.RESOLVED`) -- serialized under the same lock so
280
+ two concurrent callers can never spend the same single-use refresh
281
+ token (OpenAI issues a new one on every grant and invalidates the
282
+ old one; see oauth_refresh.py): the stored record is re-read once
283
+ more immediately before refreshing, so a refresh a concurrent caller
284
+ just completed is picked up instead of spending a second,
285
+ already-superseded token. Raises RefreshError if a refresh was
286
+ attempted and the grant was rejected or the request failed.
287
+ """
288
+ provider = self._provider(provider_id)
289
+ with self.lock:
290
+ live_record, ambiguous = self._live_attribution(provider_id, name)
291
+ if live_record is not None:
292
+ key = _secret_key(provider_id, name)
293
+ stored = self.secrets.get(key)
294
+ if stored is None or json.loads(stored) != live_record.raw:
295
+ self.secrets.put(key, json.dumps(live_record.raw))
296
+ return live_record, RefreshOutcome.LIVE
297
+
298
+ record = self._load_record(provider_id, name)
299
+ if record is None:
300
+ return None
301
+ if ambiguous:
302
+ return record, RefreshOutcome.AMBIGUOUS
303
+ if not allow_refresh or provider.validate(record) == Validity.OK:
304
+ return record, RefreshOutcome.RESOLVED
305
+ refreshed = provider.refresh(record)
306
+ if refreshed is None:
307
+ return record, RefreshOutcome.NO_SUPPORT
308
+ self.secrets.put(_secret_key(provider_id, name), json.dumps(refreshed.raw))
309
+ meta = self.registry.scoped_accounts().get((provider_id, name))
310
+ account_id = refreshed.raw.get("accountId")
311
+ if meta is not None and isinstance(account_id, str) and account_id and meta.account_id != account_id:
312
+ self.registry.upsert_account(replace(meta, account_id=account_id))
313
+ return refreshed, RefreshOutcome.RESOLVED
314
+
315
+ def _read_live_record(self, provider_id: str) -> AuthRecord | None:
316
+ """Read + extract the live provider record.
317
+
318
+ Raises AuthFileError if auth.json is missing/unreadable/malformed,
319
+ SchemaError if the provider's entry doesn't match a known shape.
320
+ Deliberately unwrapped: add_account/use_account want a friendly
321
+ "run opencode auth login" message for AuthFileError but must let
322
+ SchemaError propagate untouched (fail-safe — never silently
323
+ guessed at); current() treats AuthFileError as "nothing active"
324
+ but must *also* let SchemaError propagate, so an incompatible
325
+ schema is reported rather than masqueraded as "no active account".
326
+ """
327
+ auth = opencode_auth.read_auth(self.opencode_auth_path)
328
+ return self._provider(provider_id).extract(auth)
329
+
330
+ def add_account(self, name: str, provider_id: str = "openai") -> AccountMeta:
331
+ provider_id = normalize_provider_id(provider_id)
332
+ name = normalize_account_name(name)
333
+ provider = self._provider(provider_id)
334
+
335
+ with self.lock:
336
+ self._sweep_secret_upgrades()
337
+ try:
338
+ record = self._read_live_record(provider_id)
339
+ except AuthFileError as exc:
340
+ raise OpenCodeSwapError(f"could not read OpenCode's auth file ({exc}); run `opencode auth login` first") from exc
341
+ if record is None:
342
+ raise OpenCodeSwapError(f"no active {provider_id} account in OpenCode; run `opencode auth login` first")
343
+
344
+ identity = provider.identity(record)
345
+ existing_owner = self._find_by_identity(provider_id, identity, confirmed=True)
346
+
347
+ if existing_owner is not None and existing_owner != name:
348
+ raise AccountExistsError(
349
+ f"this account is already saved as '{existing_owner}' "
350
+ f"(use `opencode-swap use {provider_id} {existing_owner}` or "
351
+ f"`opencode-swap rename {provider_id} {existing_owner} {name}`)"
352
+ )
353
+
354
+ existing_meta = self.registry.scoped_accounts().get((provider_id, name))
355
+ if existing_meta is not None and existing_owner is None:
356
+ raise AccountExistsError(f"account name '{name}' is already used by a different account; remove it first or choose another name")
357
+ if existing_meta is None:
358
+ destination_secret = self.secrets.get_confirmed(_secret_key(provider_id, name))
359
+ destination_record = self._parse_stored_record(provider_id, name, destination_secret) if destination_secret is not None else None
360
+ if destination_record is not None and not _same_orphan_record(provider, destination_record, record):
361
+ raise AccountExistsError(
362
+ f"account name '{name}' has unregistered stored credentials; recover it with the matching account or remove it first"
363
+ )
364
+
365
+ desc = provider.describe(record)
366
+ added = existing_meta.added if existing_meta is not None else _now_iso()
367
+ meta = AccountMeta(
368
+ name=name,
369
+ provider=provider_id,
370
+ type=record.type,
371
+ account_id=desc.account_id,
372
+ email=desc.email,
373
+ added=added,
374
+ )
375
+
376
+ self.secrets.put(_secret_key(provider_id, name), json.dumps(record.raw))
377
+ self.registry.upsert_account(meta)
378
+ self.registry.set_active(name, provider_id)
379
+
380
+ return meta
381
+
382
+ def _sync_live_for_export(self, accounts: dict[tuple[str, str], AccountMeta]) -> None:
383
+ if not self.opencode_auth_path.exists():
384
+ return
385
+ auth = opencode_auth.read_auth(self.opencode_auth_path)
386
+ for provider_id in {meta.provider for meta in accounts.values()}:
387
+ provider = self._provider(provider_id)
388
+ live_record = provider.extract(auth)
389
+ if live_record is None:
390
+ continue
391
+ live_identity = provider.identity(live_record)
392
+ owner_name = self._find_by_identity(provider_id, live_identity, confirmed=True)
393
+ if owner_name is not None:
394
+ stored = self.secrets.get_confirmed(_secret_key(provider_id, owner_name))
395
+ if stored is None or json.loads(stored) != live_record.raw:
396
+ self.secrets.put(_secret_key(provider_id, owner_name), json.dumps(live_record.raw))
397
+ continue
398
+ active_name = self.registry.get_active(provider_id)
399
+ active_meta = accounts.get((provider_id, active_name)) if active_name is not None else None
400
+ if active_meta is None or active_meta.type != live_record.type:
401
+ continue
402
+ raise OpenCodeSwapError(
403
+ "live credential does not match the registry-active account; cannot safely determine which saved account to refresh before export"
404
+ )
405
+
406
+ def export_accounts(self, path: Path, password: str) -> int:
407
+ """Export every managed account into a password-encrypted archive."""
408
+ with self.lock:
409
+ accounts = self.registry.scoped_accounts()
410
+ if not accounts:
411
+ raise OpenCodeSwapError("no saved accounts to export")
412
+ self._sync_live_for_export(accounts)
413
+
414
+ entries: list[transfer.TransferEntry] = []
415
+ for provider_id, name in sorted(accounts):
416
+ meta = accounts[(provider_id, name)]
417
+ record = self._load_record(meta.provider, name, confirmed=True)
418
+ if record is None:
419
+ raise OpenCodeSwapError(f"no stored credentials for '{name}' (secret store may be unavailable)")
420
+ try:
421
+ _transfer_added(meta.added)
422
+ except ValueError:
423
+ meta = replace(meta, added=_now_iso())
424
+ entries.append(transfer.TransferEntry(meta=meta, record=record.raw))
425
+ transfer.write_archive(path, entries, password)
426
+ return len(entries)
427
+
428
+ def import_accounts( # noqa: PLR0912, PLR0915
429
+ self,
430
+ path: Path,
431
+ password: str,
432
+ resolve_conflict: Callable[[str], ImportConflictAction] | None = None,
433
+ ) -> int:
434
+ """Import an archive, optionally resolving existing-name conflicts."""
435
+ entries = transfer.read_archive(path, password)
436
+ if not entries:
437
+ raise OpenCodeSwapError("account archive contains no accounts")
438
+
439
+ with self.lock:
440
+ self._sweep_secret_upgrades()
441
+ validated: list[tuple[transfer.TransferEntry, Provider, AuthRecord]] = []
442
+ for entry in entries:
443
+ try:
444
+ provider = self._provider(entry.meta.provider)
445
+ record = provider.extract({entry.meta.provider: entry.record})
446
+ except (SchemaError, ValueError):
447
+ raise SchemaError("account archive contains invalid credentials") from None
448
+ if record is None:
449
+ raise SchemaError("account archive contains missing credentials")
450
+ if record.type != entry.meta.type:
451
+ raise SchemaError("account archive credential type does not match metadata")
452
+ validated.append((entry, provider, record))
453
+
454
+ incoming: list[tuple[AccountMeta, AuthRecord]] = []
455
+ incoming_identities: set[tuple[str, str]] = set()
456
+ archive_credentials = {value for _entry, provider, record in validated for value in provider.credential_values(record)}
457
+ if any(
458
+ secret in value
459
+ for entry in entries
460
+ for value in (entry.meta.provider, entry.meta.name, entry.meta.type, entry.meta.added)
461
+ for secret in archive_credentials
462
+ ):
463
+ raise SchemaError("account archive contains credential data in non-secret metadata")
464
+ for entry, provider, record in validated:
465
+ try:
466
+ added = _transfer_added(entry.meta.added)
467
+ except ValueError as exc:
468
+ raise SchemaError(f"imported metadata for '{entry.meta.name}' has an invalid added timestamp") from exc
469
+ desc = provider.describe(record)
470
+ meta = AccountMeta(
471
+ name=entry.meta.name,
472
+ provider=entry.meta.provider,
473
+ type=record.type,
474
+ account_id=_without_archive_credential(desc.account_id, archive_credentials),
475
+ email=_without_archive_credential(desc.email, archive_credentials),
476
+ added=added,
477
+ )
478
+ identity_key = (entry.meta.provider, provider.identity(record))
479
+ if identity_key in incoming_identities:
480
+ raise AccountExistsError("account archive contains duplicate account identities")
481
+ incoming_identities.add(identity_key)
482
+ incoming.append((meta, record))
483
+
484
+ existing_accounts = self.registry.scoped_accounts()
485
+ selected: list[tuple[AccountMeta, AuthRecord]] = []
486
+ overwritten_keys: set[tuple[str, str]] = set()
487
+ for meta, record in incoming:
488
+ account_key = (meta.provider, meta.name)
489
+ if account_key in existing_accounts:
490
+ if resolve_conflict is None:
491
+ raise AccountExistsError(f"account '{meta.provider}:{meta.name}' already exists; import made no changes")
492
+ action = resolve_conflict(f"{meta.provider}:{meta.name}")
493
+ if action is ImportConflictAction.ABORT:
494
+ raise OpenCodeSwapError("import aborted; no changes made")
495
+ if action is ImportConflictAction.SKIP:
496
+ continue
497
+ if action is not ImportConflictAction.OVERWRITE:
498
+ raise ValueError("invalid import conflict action")
499
+ overwritten_keys.add(account_key)
500
+ selected.append((meta, record))
501
+
502
+ if not selected:
503
+ return 0
504
+
505
+ existing_identities: set[tuple[str, str]] = set()
506
+ original_secrets: dict[str, str | None] = {}
507
+ for (provider_id, name), meta in existing_accounts.items():
508
+ key = _secret_key(meta.provider, name)
509
+ stored = self.secrets.get_confirmed(key)
510
+ if (provider_id, name) in overwritten_keys:
511
+ original_secrets[key] = stored
512
+ continue
513
+ if stored is None:
514
+ raise OpenCodeSwapError(f"no stored credentials for existing account '{name}'; import made no changes")
515
+ record = self._parse_stored_record(meta.provider, name, stored)
516
+ existing_identities.add((meta.provider, self._provider(meta.provider).identity(record)))
517
+ selected_identities = {(meta.provider, self._provider(meta.provider).identity(record)) for meta, record in selected}
518
+ if selected_identities & existing_identities:
519
+ raise AccountExistsError("an imported account identity is already saved under another name; import made no changes")
520
+
521
+ for meta, _record in selected:
522
+ key = _secret_key(meta.provider, meta.name)
523
+ if (meta.provider, meta.name) not in overwritten_keys and self.secrets.get_confirmed(key) is not None:
524
+ raise AccountExistsError(f"account name '{meta.name}' has unregistered stored credentials; import made no changes")
525
+ original_secrets.setdefault(key, None)
526
+
527
+ attempted_keys: list[str] = []
528
+ try:
529
+ for meta, record in selected:
530
+ key = _secret_key(meta.provider, meta.name)
531
+ attempted_keys.append(key)
532
+ self.secrets.put(key, json.dumps(record.raw))
533
+ self.registry.upsert_accounts([meta for meta, _record in selected])
534
+ except BaseException as exc:
535
+ cleanup_failed = False
536
+ for key in reversed(attempted_keys):
537
+ try:
538
+ original = original_secrets[key]
539
+ if original is None:
540
+ self.secrets.delete(key)
541
+ else:
542
+ self.secrets.put(key, original)
543
+ except BaseException: # noqa: BLE001
544
+ # Deliberately swallowed, not narrowed to Exception:
545
+ # this is a best-effort rollback loop restoring every
546
+ # attempted key, so one key's restore failure (of any
547
+ # kind, including KeyboardInterrupt) must not stop the
548
+ # rest from being attempted. The aggregate failure is
549
+ # reported below via cleanup_failed, chained from the
550
+ # original exc, not discarded.
551
+ cleanup_failed = True
552
+ if cleanup_failed:
553
+ raise OpenCodeSwapError("import failed and cleanup could not restore all previous credentials; registry was not changed") from exc
554
+ raise
555
+ return len(selected)
556
+
557
+ def use_account(self, name: str, provider_id: str = "openai") -> AccountMeta: # noqa: PLR0912, PLR0915
558
+ """Switch OpenCode's active account to the saved account `name`.
559
+
560
+ Algorithm (see plan §8 for the full rationale):
561
+ 1. Sync-back: if the currently-live record belongs to a *different*
562
+ managed account than `name`, capture its (possibly rotated)
563
+ tokens before it's overwritten. If it belongs to no managed
564
+ account at all, stash it under backups/ instead of losing it.
565
+ 2. Snapshot the live auth.json to backups/auth.json.bak (and, the
566
+ very first time ever, to backups/auth.json.pristine).
567
+ 3. Atomically replace the target provider's entry in auth.json.
568
+ 4. Record `name` as the active account in the registry.
569
+
570
+ Only step 3 mutates OpenCode's live state, and atomic_write_auth
571
+ guarantees it's all-or-nothing — so a failure anywhere in this
572
+ method either leaves auth.json completely untouched, or (if it fails
573
+ after step 3 succeeded, e.g. in step 4) gets rolled back by
574
+ restoring the pre-switch auth.json content captured in step 2.
575
+ """
576
+ provider_id = normalize_provider_id(provider_id)
577
+ name = normalize_account_name(name)
578
+ with self.lock:
579
+ self._sweep_secret_upgrades()
580
+ target_meta = self.registry.scoped_accounts().get((provider_id, name))
581
+ if target_meta is None:
582
+ raise OpenCodeSwapError(f"no such account: {name}")
583
+ provider = self._provider(provider_id)
584
+
585
+ target_record = self._load_record(provider_id, name, confirmed=True)
586
+ if target_record is None:
587
+ raise OpenCodeSwapError(
588
+ f"no stored credentials for '{name}' (secret store may be out of sync); "
589
+ "try `opencode-swap add <provider> <name>` again while that account is active in OpenCode"
590
+ )
591
+
592
+ auth = opencode_auth.read_auth(self.opencode_auth_path) if self.opencode_auth_path.exists() else {}
593
+
594
+ backup.write_pristine_if_absent(self.data_root, auth)
595
+ transaction = SwitchTransaction(original_auth=auth)
596
+
597
+ try:
598
+ live_record = provider.extract(auth)
599
+ if live_record is not None:
600
+ live_identity = provider.identity(live_record)
601
+ owner_name = self._find_by_identity(provider_id, live_identity, confirmed=True)
602
+ if owner_name is None and not provider.identity_is_stable(live_record):
603
+ active_name = self.registry.get_active(provider_id)
604
+ if active_name is not None:
605
+ active_meta = self.registry.scoped_accounts().get((provider_id, active_name))
606
+ if active_meta is not None and active_meta.type == live_record.type:
607
+ active_record = self._load_record(provider_id, active_name, confirmed=True)
608
+ if active_record is not None and not provider.identity_is_stable(active_record):
609
+ backup.write_unclaimed(self.data_root, provider_id, live_record.raw)
610
+ transaction.record_step("unclaimed_stashed")
611
+ raise OpenCodeSwapError(
612
+ f"live {provider_id} credential changed without a stable account identity; "
613
+ "preserved it as an unclaimed backup and refused to overwrite it"
614
+ )
615
+ if owner_name is not None:
616
+ stored = self.secrets.get_confirmed(_secret_key(provider_id, owner_name))
617
+ if stored is None or json.loads(stored) != live_record.raw:
618
+ self.secrets.put(_secret_key(provider_id, owner_name), json.dumps(live_record.raw))
619
+ transaction.record_step("sync_captured")
620
+ if owner_name == name:
621
+ # A self-switch may have just captured rotated tokens;
622
+ # never splice the pre-sync cached record back over them.
623
+ target_record = self._load_record(provider_id, name, confirmed=True)
624
+ if target_record is None:
625
+ raise OpenCodeSwapError(f"stored credentials for '{name}' disappeared during switch")
626
+ else:
627
+ backup.write_unclaimed(self.data_root, provider_id, live_record.raw)
628
+ transaction.record_step("unclaimed_stashed")
629
+
630
+ backup.write_bak(self.data_root, auth)
631
+ transaction.record_step("bak_written")
632
+
633
+ new_auth = provider.splice(auth, target_record)
634
+ opencode_auth.atomic_write_auth(self.opencode_auth_path, new_auth)
635
+ transaction.record_step("auth_written")
636
+
637
+ self.registry.set_active(name, provider_id)
638
+ transaction.record_step("registry_written")
639
+ except BaseException:
640
+ self._rollback(transaction)
641
+ raise
642
+
643
+ return target_meta
644
+
645
+ def _rollback(self, transaction: SwitchTransaction) -> None:
646
+ if "auth_written" not in transaction.completed_steps:
647
+ return # live auth.json was never touched; nothing to undo
648
+ try:
649
+ opencode_auth.atomic_write_auth(self.opencode_auth_path, transaction.original_auth)
650
+ except OSError:
651
+ raise OpenCodeSwapError(
652
+ "switch failed partway and automatic rollback of auth.json also failed; "
653
+ f"restore manually from {self.data_root / 'backups' / 'auth.json.bak'}"
654
+ ) from None
655
+
656
+ def remove_account(self, name: str, provider_id: str = "openai") -> None:
657
+ provider_id = normalize_provider_id(provider_id)
658
+ name = normalize_account_name(name)
659
+ with self.lock:
660
+ self._sweep_secret_upgrades()
661
+ meta = self.registry.scoped_accounts().get((provider_id, name))
662
+ if meta is None:
663
+ raise OpenCodeSwapError(f"no such account: {name}")
664
+ original_active = self.registry.get_active(provider_id)
665
+ secret_key = _secret_key(meta.provider, name)
666
+ secret = self.secrets.get(secret_key)
667
+ self.registry.remove_account(name, provider_id)
668
+ try:
669
+ self.secrets.delete(secret_key)
670
+ except BaseException:
671
+ # OS deletion errors are ambiguous: it might have completed
672
+ # before timing out. Preserve captured credentials in the
673
+ # pinned file fallback before re-registering this account.
674
+ if secret is not None:
675
+ self.secrets.put(secret_key, secret)
676
+ self.registry.upsert_account(meta)
677
+ self.registry.set_active(original_active, provider_id)
678
+ raise
679
+
680
+ def rename_account(self, old: str, new: str, provider_id: str = "openai") -> None:
681
+ provider_id = normalize_provider_id(provider_id)
682
+ old = normalize_account_name(old)
683
+ new = normalize_account_name(new)
684
+ with self.lock:
685
+ self._sweep_secret_upgrades()
686
+ meta = self.registry.scoped_accounts().get((provider_id, old))
687
+ if meta is None:
688
+ raise OpenCodeSwapError(f"no such account: {old}")
689
+ if self.registry.scoped_accounts().get((provider_id, new)) is not None:
690
+ raise AccountExistsError(f"account already exists: {new}")
691
+
692
+ secret = self.secrets.get(_secret_key(meta.provider, old))
693
+ if secret is None:
694
+ raise OpenCodeSwapError(f"no stored credentials for '{old}' (secret store may be unavailable)")
695
+ provider = self._provider(meta.provider)
696
+ source_record = self._load_record(meta.provider, old)
697
+ if source_record is None:
698
+ raise OpenCodeSwapError(f"no stored credentials for '{old}' (secret store may be unavailable)")
699
+ old_key = _secret_key(meta.provider, old)
700
+ new_key = _secret_key(meta.provider, new)
701
+ previous_new_secret = self.secrets.get_confirmed(new_key)
702
+ if previous_new_secret is not None:
703
+ destination_record = self._parse_stored_record(meta.provider, new, previous_new_secret)
704
+ if destination_record is not None and not _same_orphan_record(provider, destination_record, source_record):
705
+ raise AccountExistsError(
706
+ f"account name '{new}' has unregistered stored credentials; recover it with the matching account or remove it first"
707
+ )
708
+ self.secrets.put(new_key, secret)
709
+ registry_renamed = False
710
+ try:
711
+ self.registry.rename_account(old, new, provider_id)
712
+ registry_renamed = True
713
+ self.secrets.delete(old_key)
714
+ except BaseException:
715
+ if registry_renamed:
716
+ # Keep the new registry mapping if recovery fails: it
717
+ # still names the pre-copied usable credential.
718
+ self.secrets.put(old_key, secret)
719
+ self.registry.rename_account(new, old, provider_id)
720
+ if previous_new_secret is None:
721
+ with suppress(OpenCodeSwapError, OSError):
722
+ self.secrets.delete(new_key)
723
+ else:
724
+ self.secrets.put(new_key, previous_new_secret)
725
+ raise
726
+
727
+ def _finish_restore(self, data: JsonObject) -> list[AccountMeta]:
728
+ """Best-effort bookkeeping after a restore has committed live state."""
729
+ active: list[AccountMeta] = []
730
+ provider_ids = {meta.provider for meta in self.registry.scoped_accounts().values()}
731
+ for provider_id in provider_ids:
732
+ try:
733
+ provider = self._provider(provider_id)
734
+ record = provider.extract(data)
735
+ owner_name = self._find_by_identity(provider_id, provider.identity(record)) if record else None
736
+ except OpenCodeSwapError:
737
+ owner_name = None
738
+ with suppress(OpenCodeSwapError):
739
+ self.registry.set_active(owner_name, provider_id)
740
+ meta = self.registry.scoped_accounts().get((provider_id, owner_name)) if owner_name else None
741
+ if meta is not None:
742
+ active.append(meta)
743
+ return active
744
+
745
+ def restore(self, source: str = "bak", *, discard_pending: bool = False) -> list[AccountMeta]:
746
+ """Restore OpenCode's auth.json from a backup snapshot.
747
+
748
+ `source` is "bak" (most recent pre-switch state) or "pristine" (the
749
+ very first live state opencode-swap ever saw). The current live
750
+ content is itself chained into .bak first (if readable) so a
751
+ restore is always undoable by restoring again.
752
+
753
+ Returns the managed account that now matches the restored live
754
+ state, or None if it doesn't match any saved account (including
755
+ when the restored data's provider entry can't even be interpreted —
756
+ the restore itself still succeeds; only identification is skipped).
757
+ """
758
+ with self.lock:
759
+ recovery = backup.read_restore_snapshot(self.data_root)
760
+ if recovery is not None:
761
+ try:
762
+ live = opencode_auth.read_auth(self.opencode_auth_path)
763
+ except AuthFileError:
764
+ live = None
765
+ if live == recovery:
766
+ backup.remove_restore_snapshot(self.data_root)
767
+ return self._finish_restore(recovery)
768
+ if discard_pending:
769
+ # The pending snapshot may be the only surviving copy of
770
+ # whatever .bak held before this restore attempt started
771
+ # (chaining below overwrites .bak with current live state
772
+ # every time) -- archive it before dropping the marker.
773
+ backup.write_discarded_restore(self.data_root, recovery)
774
+ backup.remove_restore_snapshot(self.data_root)
775
+ else:
776
+ raise OpenCodeSwapError(
777
+ "a previous restore failed and its recovery source is retained at "
778
+ f"{self.data_root / 'backups' / backup.RESTORE_SNAPSHOT_FILENAME}; "
779
+ "rerun with --discard-pending to drop it, or delete that file manually before restoring again"
780
+ )
781
+ if source == "bak":
782
+ data = backup.read_bak(self.data_root)
783
+ if data is None:
784
+ raise OpenCodeSwapError("no .bak snapshot found; nothing to restore")
785
+ elif source == "pristine":
786
+ data = backup.read_pristine(self.data_root)
787
+ if data is None:
788
+ raise OpenCodeSwapError("no pristine snapshot found; nothing to restore")
789
+ else:
790
+ raise ValueError(f"unknown restore source: {source!r}")
791
+
792
+ # A restore from .bak must not erase its only source before the
793
+ # new live auth.json has landed. Keep this durable until chaining
794
+ # current live state into .bak and the live replacement both pass.
795
+ backup.write_restore_snapshot(self.data_root, data)
796
+ if self.opencode_auth_path.exists():
797
+ try:
798
+ current_live = opencode_auth.read_auth(self.opencode_auth_path)
799
+ backup.write_bak(self.data_root, current_live)
800
+ except AuthFileError:
801
+ pass # current file is unreadable -- exactly the case restore exists for
802
+
803
+ opencode_auth.atomic_write_auth(self.opencode_auth_path, data)
804
+ backup.remove_restore_snapshot(self.data_root)
805
+ return self._finish_restore(data)
806
+
807
+ def fetch_usage(self, name: str, provider_id: str = "openai") -> usage.UsageSnapshot | None:
808
+ """Live usage lookup for a saved account, for providers that have a
809
+ usage source (OpenAI OAuth, Z.AI GLM Coding Plan). None when the
810
+ provider has no usage source, the saved record's type isn't one it
811
+ can look up, or the secret store is out of sync -- a distinct "not
812
+ applicable" case from usage.UsageSnapshot's own available=False
813
+ ("looked it up, the request failed").
814
+
815
+ Prefers OpenCode's own live auth.json over opencode-swap's stored
816
+ snapshot when the live record can be positively attributed to
817
+ `name` (see `_ensure_refreshed`) -- for whichever account is
818
+ currently active in OpenCode, the live copy is authoritative and
819
+ the stored copy is stale by construction. A drifted live record is
820
+ captured back into the secret store as a side effect. That account
821
+ is never refreshed standalone, live or expired: OpenCode owns that.
822
+
823
+ For any other saved account, refreshes its stored OAuth token on
824
+ demand when it's expired -- this is the one thing besides the
825
+ explicit `refresh` command that spends network quota and a
826
+ single-use refresh token, and only because the caller already
827
+ opted in with `--usage`. (Static API-key providers never refresh;
828
+ the whole block below is a no-op for them.)
829
+ """
830
+ provider = self._provider(provider_id)
831
+ meta = self.registry.scoped_accounts().get((provider_id, name))
832
+ if meta is None or meta.type not in provider.usage_record_types:
833
+ return None
834
+
835
+ try:
836
+ result = self._ensure_refreshed(provider_id, name, allow_refresh=True)
837
+ except RefreshError as exc:
838
+ return usage.UsageSnapshot(available=False, message=str(exc))
839
+ if result is None:
840
+ return None
841
+ record, outcome = result
842
+
843
+ if provider.validate(record) != Validity.OK:
844
+ if outcome is RefreshOutcome.LIVE:
845
+ message = "expired; OpenCode refreshes on next request"
846
+ elif outcome is RefreshOutcome.AMBIGUOUS:
847
+ message = "expired; live account state could not be confirmed, refresh skipped"
848
+ else:
849
+ message = "expired and no standalone refresh available for this account type"
850
+ return usage.UsageSnapshot(available=False, message=message)
851
+
852
+ return provider.fetch_usage(record)
853
+
854
+ def account_validity(self, name: str, provider_id: str = "openai") -> Validity:
855
+ """Validity of a saved account's most current record. See
856
+ `account_status` for the full semantics."""
857
+ return self.account_status(name, provider_id)[0]
858
+
859
+ def account_status(self, name: str, provider_id: str = "openai") -> tuple[Validity, AccountDesc | None]:
860
+ """`(validity, description)` of a saved account's most current record.
861
+
862
+ `validity` is OK/EXPIRED/INVALID; INVALID means the secret is missing
863
+ or unreadable (registry entry orphaned), in which case `description`
864
+ is None.
865
+
866
+ Prefers the live auth.json record when it can be positively
867
+ attributed to `name` (see `_ensure_refreshed`) -- OpenCode rotates
868
+ tokens in `auth.json` in place, so the account currently active in
869
+ OpenCode may look expired in opencode-swap's stored snapshot while
870
+ the live copy is still perfectly valid. Never triggers a network
871
+ refresh: this is called unconditionally by `list`, with no separate
872
+ opt-in for a network call.
873
+ """
874
+ meta = self.registry.scoped_accounts().get((provider_id, name))
875
+ if meta is None:
876
+ raise OpenCodeSwapError(f"no such account: {name}")
877
+ result = self._ensure_refreshed(provider_id, name, allow_refresh=False)
878
+ if result is None:
879
+ return Validity.INVALID, None
880
+ record, _outcome = result
881
+ provider = self._provider(meta.provider)
882
+ return provider.validate(record), provider.describe(record)
883
+
884
+ def refresh_account(self, name: str, provider_id: str = "openai") -> AccountRefreshResult:
885
+ """Ensure a saved account's OAuth token is valid, refreshing it over
886
+ the network and persisting the rotated token if it's expired.
887
+
888
+ A no-op beyond the existence check when the token is already valid
889
+ -- refreshing a still-valid token would needlessly spend its
890
+ single-use refresh token for no benefit, which could break another
891
+ holder of the same account (e.g. a second OpenCode install). Never
892
+ refreshes the account currently active in OpenCode: that account's
893
+ validity is reported from its live `auth.json` record instead (see
894
+ `_ensure_refreshed`), since OpenCode owns its refresh on its own
895
+ next request.
896
+
897
+ Raises OpenCodeSwapError if the account doesn't exist or has no
898
+ stored credentials. Raises RefreshError if a refresh was attempted
899
+ and the grant was rejected or the request failed. Returns an
900
+ `AccountRefreshResult` — the resulting Validity (OK if the token is
901
+ now valid) alongside the `RefreshOutcome` explaining why, if it
902
+ isn't: `NO_SUPPORT` (this provider/record type has no standalone
903
+ refresh) is a different, unactionable-here situation from `LIVE`
904
+ or `AMBIGUOUS` (this account genuinely supports refresh, but it was
905
+ deliberately skipped this time) — callers must not collapse those
906
+ into the same "no refresh available" message.
907
+ """
908
+ provider_id = normalize_provider_id(provider_id)
909
+ name = normalize_account_name(name)
910
+ with self.lock:
911
+ self._sweep_secret_upgrades()
912
+ if self.registry.scoped_accounts().get((provider_id, name)) is None:
913
+ raise OpenCodeSwapError(f"no such account: {name}")
914
+ result = self._ensure_refreshed(provider_id, name, allow_refresh=True)
915
+ if result is None:
916
+ raise OpenCodeSwapError(f"no stored credentials for '{name}' (secret store may be out of sync)")
917
+ record, outcome = result
918
+ return AccountRefreshResult(validity=self._provider(provider_id).validate(record), outcome=outcome)
919
+
920
+ def next_account(self, provider_id: str = "openai") -> AccountMeta:
921
+ """Return next saved account after the live managed account, wrapping around."""
922
+ current, _ = self.current(provider_id)
923
+ if current is None:
924
+ raise OpenCodeSwapError("current OpenCode account is not managed; use `opencode-swap use <provider> <name>` first")
925
+ accounts = self.registry.scoped_accounts()
926
+ names = sorted(name for (stored_provider, name) in accounts if stored_provider == provider_id)
927
+ return accounts[(provider_id, names[(names.index(current.name) + 1) % len(names)])]
928
+
929
+ def current(self, provider_id: str = "openai") -> tuple[AccountMeta | None, AccountDesc | None]:
930
+ """Return (managed account meta if recognized, live account description).
931
+
932
+ (None, None) if OpenCode has no active account for this provider.
933
+ A non-None description with a None meta means OpenCode is logged
934
+ into an account opencode-swap hasn't been told to manage.
935
+ """
936
+ try:
937
+ auth = opencode_auth.read_auth(self.opencode_auth_path)
938
+ except AuthFileError:
939
+ return None, None
940
+ return self.current_from_auth(auth, provider_id)
941
+
942
+ def current_from_auth(self, auth: JsonObject, provider_id: str = "openai") -> tuple[AccountMeta | None, AccountDesc | None]:
943
+ """Identify current provider account from already-validated auth data."""
944
+ record = self._provider(provider_id).extract(auth)
945
+ if record is None:
946
+ return None, None
947
+
948
+ provider = self._provider(provider_id)
949
+ identity = provider.identity(record)
950
+ owner = self._find_by_identity(provider_id, identity)
951
+ meta = self.registry.scoped_accounts().get((provider_id, owner)) if owner else None
952
+ return meta, provider.describe(record)