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.
opencode_swap/cli.py ADDED
@@ -0,0 +1,799 @@
1
+ """Command-line interface for opencode-swap.
2
+
3
+ Output rules: never print a secret (access/refresh/api key/token). Account
4
+ ids are shown truncated even though they aren't secret themselves, matching
5
+ the plan's stated CLI redaction policy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import getpass
12
+ import json
13
+ import math
14
+ import sys
15
+ import time
16
+ from collections.abc import Iterable
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+
20
+ from opencode_swap import __version__, backup, opencode_auth, paths, process_detection
21
+ from opencode_swap.exceptions import AuthFileError, BackupError, OpenCodeSwapError, SchemaError
22
+ from opencode_swap.models import ImportConflictAction, JsonObject, Validity
23
+ from opencode_swap.providers import get_provider
24
+ from opencode_swap.providers.common import is_json_number
25
+ from opencode_swap.store import RecordLocation
26
+ from opencode_swap.switcher import AccountRefreshResult, RefreshOutcome, Switcher
27
+ from opencode_swap.usage import UsageSnapshot, UsageWindow
28
+
29
+
30
+ def _build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(
32
+ prog="opencode-swap",
33
+ description="Multi-account switcher for OpenCode",
34
+ )
35
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
36
+
37
+ subparsers = parser.add_subparsers(dest="command")
38
+
39
+ add_p = subparsers.add_parser("add", help="import a provider's active account into secure storage")
40
+ add_p.add_argument("provider", help="OpenCode provider id")
41
+ add_p.add_argument("name", help="name to save the account under")
42
+
43
+ list_p = subparsers.add_parser("list", help="list saved accounts")
44
+ list_p.add_argument("provider", nargs="?", help="optional provider id filter")
45
+ list_p.add_argument(
46
+ "--usage",
47
+ action="store_true",
48
+ help="fetch live usage where supported (OpenAI OAuth, Z.AI GLM Coding Plan; network calls; off by default)",
49
+ )
50
+ current_p = subparsers.add_parser("current", help="show which managed accounts are active")
51
+ current_p.add_argument("provider", nargs="?", help="optional provider id filter")
52
+
53
+ status_p = subparsers.add_parser("status", help="show integration status")
54
+ status_p.add_argument("provider", nargs="?", help="optional provider id filter")
55
+ status_p.add_argument("--json", action="store_true", help="emit versioned machine-readable status")
56
+ status_p.add_argument(
57
+ "--usage",
58
+ action="store_true",
59
+ help="fetch usage for active managed accounts where supported (network calls; off by default)",
60
+ )
61
+
62
+ use_p = subparsers.add_parser("use", help="switch a provider's active account")
63
+ use_p.add_argument("provider", help="OpenCode provider id")
64
+ use_p.add_argument("name", help="saved account name to activate")
65
+ use_p.add_argument("-y", "--yes", action="store_true", help="don't prompt for confirmation")
66
+
67
+ switch_p = subparsers.add_parser("switch", help="switch to the next saved account for a provider")
68
+ switch_p.add_argument("provider", help="OpenCode provider id")
69
+ switch_p.add_argument("-y", "--yes", action="store_true", help="don't prompt for confirmation")
70
+
71
+ refresh_p = subparsers.add_parser("refresh", help="ensure a saved account's OAuth token is valid, refreshing over the network if it's expired")
72
+ refresh_p.add_argument("provider", help="OpenCode provider id")
73
+ refresh_p.add_argument("name", nargs="?", help="saved account name (every saved account for this provider if omitted)")
74
+
75
+ remove_p = subparsers.add_parser("remove", help="remove a saved account")
76
+ remove_p.add_argument("provider", help="OpenCode provider id")
77
+ remove_p.add_argument("name", help="saved account name to remove")
78
+ remove_p.add_argument("-y", "--yes", action="store_true", help="don't prompt for confirmation")
79
+
80
+ rename_p = subparsers.add_parser("rename", help="rename a saved account")
81
+ rename_p.add_argument("provider", help="OpenCode provider id")
82
+ rename_p.add_argument("old", help="current account name")
83
+ rename_p.add_argument("new", help="new account name")
84
+
85
+ export_p = subparsers.add_parser("export", help="export saved accounts to a password-encrypted archive")
86
+ export_p.add_argument("path", help="new archive path (must not already exist)")
87
+
88
+ import_p = subparsers.add_parser("import", help="import saved accounts from a password-encrypted archive")
89
+ import_p.add_argument("path", help="archive path to import")
90
+
91
+ restore_p = subparsers.add_parser("restore", help="restore OpenCode's auth.json from a backup snapshot")
92
+ restore_p.add_argument(
93
+ "--pristine",
94
+ action="store_true",
95
+ help="restore the original pristine snapshot instead of the most recent pre-switch backup",
96
+ )
97
+ restore_p.add_argument(
98
+ "--discard-pending",
99
+ action="store_true",
100
+ help=("archive a retained failed-restore recovery snapshot under backups/ and proceed, instead of refusing to restore"),
101
+ )
102
+ restore_p.add_argument("-y", "--yes", action="store_true", help="don't prompt for confirmation")
103
+
104
+ subparsers.add_parser("doctor", help="check environment and compatibility")
105
+
106
+ return parser
107
+
108
+
109
+ def _redact_account_id(account_id: str | None) -> str:
110
+ if not account_id:
111
+ return "?"
112
+ return f"...{account_id[-4:]}" if len(account_id) > 4 else account_id
113
+
114
+
115
+ def _confirm(prompt: str, assume_yes: bool) -> bool:
116
+ if assume_yes:
117
+ return True
118
+ if not sys.stdin.isatty():
119
+ print(
120
+ f"opencode-swap: refusing to prompt on a non-interactive terminal; rerun with --yes to confirm: {prompt}",
121
+ file=sys.stderr,
122
+ )
123
+ return False
124
+ answer = input(f"{prompt} [y/N] ").strip().lower()
125
+ return answer in ("y", "yes")
126
+
127
+
128
+ def _prompt_archive_password(*, confirm: bool) -> str:
129
+ if not sys.stdin.isatty():
130
+ raise OpenCodeSwapError("archive password requires an interactive terminal")
131
+ try:
132
+ password = getpass.getpass("Archive password: ")
133
+ if not password:
134
+ raise OpenCodeSwapError("archive password cannot be empty")
135
+ if confirm and getpass.getpass("Confirm archive password: ") != password:
136
+ raise OpenCodeSwapError("archive passwords do not match")
137
+ return password
138
+ except EOFError as exc:
139
+ raise OpenCodeSwapError("could not read archive password") from exc
140
+
141
+
142
+ def _prompt_archive_extension(path: Path) -> Path:
143
+ """Offer the conventional archive extension only when none was supplied."""
144
+ if path.suffix:
145
+ return path
146
+ while True:
147
+ try:
148
+ answer = input(f"Export path '{path}' has no extension. Add .ocs? [Y/n] ").strip().lower()
149
+ except EOFError as exc:
150
+ raise OpenCodeSwapError("could not read archive extension choice") from exc
151
+ if answer in ("", "y", "yes"):
152
+ return path.with_name(f"{path.name}.ocs")
153
+ if answer in ("n", "no"):
154
+ return path
155
+ print("Enter yes or no.", file=sys.stderr)
156
+
157
+
158
+ def cmd_add(switcher: Switcher, args: argparse.Namespace) -> int:
159
+ meta = switcher.add_account(args.name, provider_id=args.provider)
160
+ print(f"Added '{meta.provider}:{meta.name}' ({meta.email or 'no email'}, account {_redact_account_id(meta.account_id)}).")
161
+ return 0
162
+
163
+
164
+ def cmd_list(switcher: Switcher, args: argparse.Namespace) -> int:
165
+ accounts = switcher.registry.scoped_accounts(args.provider)
166
+ if not accounts:
167
+ print("No accounts saved. Run `opencode-swap add <provider> <name>` after logging into OpenCode.")
168
+ return 0
169
+
170
+ active_by_provider = {provider_id: switcher.current(provider_id)[0] for provider_id in {meta.provider for meta in accounts.values()}}
171
+ validity_tag = {Validity.OK: "", Validity.EXPIRED: " (expired)", Validity.INVALID: " (invalid!)"}
172
+
173
+ rows = []
174
+ for provider_id, name in sorted(accounts):
175
+ meta = accounts[(provider_id, name)]
176
+ active = active_by_provider[provider_id]
177
+ marker = "*" if active is not None and name == active.name else " "
178
+ # Fetch usage (which may refresh and persist a rotated token) before
179
+ # reading validity, so a successful --usage refresh is reflected in
180
+ # the validity tag on the same line rather than showing a stale
181
+ # "(expired)" next to freshly-fetched numbers.
182
+ snapshot = switcher.fetch_usage(name, provider_id=provider_id) if args.usage else None
183
+ validity, desc = switcher.account_status(name, provider_id=provider_id)
184
+ # A static-key provider has no account id; describe() fills the slot
185
+ # with a last-4 key hint instead. Prefer the live value over whatever
186
+ # the registry captured at add time.
187
+ account_id = _redact_account_id((desc.account_id if desc else None) or meta.account_id)
188
+ email = meta.email or "-"
189
+ rows.append((marker, provider_id, name, account_id, email, validity, snapshot))
190
+
191
+ # Column widths float up to fit whatever is actually being printed
192
+ # (provider ids, account names, and emails are arbitrary-length user
193
+ # data, unlike the redacted account-id column, which _redact_account_id
194
+ # already bounds) -- floored at today's defaults so a short list renders
195
+ # exactly as before.
196
+ provider_w = max(22, *(len(provider_id) for _, provider_id, *_ in rows))
197
+ name_w = max(20, *(len(name) for _, _, name, *_ in rows))
198
+ email_w = max(28, *(len(email) for *_, email, _, _ in rows))
199
+
200
+ # Column-align the usage block across rows: pad the validity tag to a
201
+ # common width so every "usage:" starts in the same place, and pass the
202
+ # per-window field widths to _format_usage.
203
+ widths = _usage_column_widths(snapshot for *_, snapshot in rows) if args.usage else None
204
+ tag_width = max((len(validity_tag[validity]) for *_, validity, _ in rows), default=0) if args.usage else 0
205
+
206
+ for marker, provider_id, name, account_id, email, validity, snapshot in rows:
207
+ tag = f"{validity_tag[validity]:<{tag_width}}" if tag_width else validity_tag[validity]
208
+ usage_suffix = _format_usage(snapshot, widths) if args.usage else ""
209
+ line = f"{marker} {provider_id:<{provider_w}} {name:<{name_w}} {account_id:<8} {email:<{email_w}}{tag}{usage_suffix}"
210
+ print(line)
211
+ return 0
212
+
213
+
214
+ def _window_label(window_seconds: object) -> str | None:
215
+ """Derive a short duration label ("5h", "7d", ...) straight from the
216
+ window's own length -- no table of known OpenAI windows, so a changed or
217
+ unfamiliar window length still gets a sensible label (see usage.py's
218
+ key-agnostic window discovery)."""
219
+ if not isinstance(window_seconds, (int, float)) or isinstance(window_seconds, bool):
220
+ return None
221
+ try:
222
+ valid = math.isfinite(window_seconds) and window_seconds > 0
223
+ except OverflowError:
224
+ valid = False
225
+ if not valid:
226
+ return None
227
+ unit_seconds, unit_suffix = next((s, suffix) for s, suffix in ((86_400, "d"), (3600, "h"), (60, "m"), (1, "s")) if window_seconds >= s)
228
+ return f"{round(window_seconds / unit_seconds)}{unit_suffix}"
229
+
230
+
231
+ def _format_reset(reset_at: object) -> str | None:
232
+ if not isinstance(reset_at, (int, float)) or isinstance(reset_at, bool):
233
+ return None
234
+ try:
235
+ if not math.isfinite(reset_at):
236
+ return None
237
+ except OverflowError:
238
+ return None
239
+ try:
240
+ # Aware-UTC-then-astimezone(), not fromtimestamp(reset_at, tz=...): the
241
+ # reset time is shown in the user's local clock, same as time.time()
242
+ # below it, so the timestamp must convert to local, not stay UTC.
243
+ reset_time = datetime.fromtimestamp(reset_at / 1000, tz=UTC).astimezone()
244
+ except (OverflowError, OSError, ValueError):
245
+ return None
246
+ if 0 <= reset_at - time.time() * 1000 < 86_400_000:
247
+ return f"{reset_time:%H:%M}"
248
+ return f"{reset_time:%b} {reset_time.day}, {reset_time:%H:%M}"
249
+
250
+
251
+ def _window_parts(window: UsageWindow) -> tuple[str, str, str] | None:
252
+ """Raw `(label, percent, reset)` pieces for one window, or None when the
253
+ percent is unusable. `label` and `reset` may be empty strings; `reset`
254
+ keeps its leading ``@``."""
255
+ used_percent = window.used_percent
256
+ try:
257
+ valid_percent = isinstance(used_percent, (int, float)) and not isinstance(used_percent, bool) and math.isfinite(used_percent)
258
+ except OverflowError:
259
+ valid_percent = False
260
+ if not valid_percent:
261
+ return None
262
+ label = _window_label(window.window_seconds) or ""
263
+ reset = _format_reset(window.reset_at)
264
+ return (label, f"{used_percent:.0f}%", f"@{reset}" if reset else "")
265
+
266
+
267
+ def _format_window(window: UsageWindow, widths: tuple[int, int, int] | None = None) -> str | None:
268
+ parts = _window_parts(window)
269
+ if parts is None:
270
+ return None
271
+ label, percent, reset = parts
272
+ if widths is None:
273
+ text = f"{label} {percent}" if label else percent
274
+ return f"{text} {reset}" if reset else text
275
+ label_w, percent_w, reset_w = widths
276
+ text = f"{label:>{label_w}} {percent:>{percent_w}}" if label_w else f"{percent:>{percent_w}}"
277
+ return f"{text} {reset:<{reset_w}}" if reset_w else text
278
+
279
+
280
+ def _usage_column_widths(snapshots: Iterable[UsageSnapshot | None]) -> dict[int, tuple[int, int, int]]:
281
+ """Per-window-position max widths of `(label, percent, reset)` across
282
+ every snapshot being listed, so `list --usage` can column-align them."""
283
+ widths: dict[int, tuple[int, int, int]] = {}
284
+ for snapshot in snapshots:
285
+ if snapshot is None or not snapshot.available:
286
+ continue
287
+ for index, window in enumerate(snapshot.windows):
288
+ parts = _window_parts(window)
289
+ if parts is None:
290
+ continue
291
+ have = widths.get(index, (0, 0, 0))
292
+ widths[index] = tuple(max(a, len(b)) for a, b in zip(have, parts, strict=True)) # type: ignore[assignment]
293
+ return widths
294
+
295
+
296
+ def _format_usage(snapshot: UsageSnapshot | None, widths: dict[int, tuple[int, int, int]] | None = None) -> str:
297
+ if snapshot is None:
298
+ return " usage: n/a"
299
+ if not snapshot.available:
300
+ return f" usage: unavailable ({snapshot.message})"
301
+ parts = [text for index, window in enumerate(snapshot.windows) if (text := _format_window(window, (widths or {}).get(index))) is not None]
302
+ if not parts:
303
+ return " usage: n/a"
304
+ # Inner windows keep their trailing pad so the " | " separators line up;
305
+ # only the last segment's pad is trimmed before the plan name / EOL.
306
+ windows_text = " | ".join(parts).rstrip()
307
+ if snapshot.plan_name:
308
+ windows_text = f"{windows_text}, {snapshot.plan_name}"
309
+ return " usage: " + windows_text
310
+
311
+
312
+ def _status_provider_ids(switcher: Switcher, provider_id: str | None, live_auth: dict[str, object]) -> list[str]:
313
+ if provider_id:
314
+ return [provider_id]
315
+
316
+ provider_ids = {meta.provider for meta in switcher.registry.scoped_accounts().values()}
317
+ provider_ids.update(live_auth)
318
+ return sorted(provider_ids)
319
+
320
+
321
+ def _status_usage_window(window: UsageWindow) -> dict[str, object] | None:
322
+ try:
323
+ valid_percent = (
324
+ isinstance(window.used_percent, (int, float)) and not isinstance(window.used_percent, bool) and math.isfinite(window.used_percent)
325
+ )
326
+ valid_reset = isinstance(window.reset_at, (int, float)) and not isinstance(window.reset_at, bool) and math.isfinite(window.reset_at)
327
+ valid_window = (
328
+ isinstance(window.window_seconds, (int, float))
329
+ and not isinstance(window.window_seconds, bool)
330
+ and math.isfinite(window.window_seconds)
331
+ and window.window_seconds > 0
332
+ )
333
+ except OverflowError:
334
+ valid_percent = False
335
+ valid_reset = False
336
+ valid_window = False
337
+ if not valid_percent:
338
+ return None
339
+ result: dict[str, object] = {"used_percent": window.used_percent}
340
+ if valid_reset:
341
+ result["reset_at"] = window.reset_at
342
+ if valid_window:
343
+ result["window_seconds"] = window.window_seconds
344
+ return result
345
+
346
+
347
+ def _status_usage(snapshot: UsageSnapshot | None) -> dict[str, object]:
348
+ if snapshot is None:
349
+ return {"applicable": False}
350
+
351
+ result: dict[str, object] = {
352
+ "applicable": True,
353
+ "available": snapshot.available,
354
+ }
355
+ if snapshot.available:
356
+ windows = [entry for window in snapshot.windows if (entry := _status_usage_window(window)) is not None]
357
+ if windows:
358
+ result["windows"] = windows
359
+ if snapshot.plan_name:
360
+ result["plan_name"] = snapshot.plan_name
361
+ return result
362
+
363
+
364
+ def _incompatible_reason(provider_id: str, live_auth: dict[str, object], exc: Exception) -> str:
365
+ """`current_from_auth`'s SchemaError can come from two unrelated places:
366
+ OpenCode's own live record for this provider not matching a known shape
367
+ (a real compatibility gap), or opencode-swap's *own* stored secret for a
368
+ managed account failing to parse while resolving which account owns the
369
+ live record (a local secret-store problem with a different remedy: re-run
370
+ `add`, not wait for a schema fix). Re-run just the live-record parse,
371
+ which has no side effects, to tell the two apart for the reported reason.
372
+ """
373
+ try:
374
+ get_provider(provider_id).extract(live_auth)
375
+ except (SchemaError, ValueError):
376
+ return str(exc)
377
+ return f"cannot determine active account: {exc}"
378
+
379
+
380
+ def _status_payload(switcher: Switcher, provider_id: str | None, include_usage: bool) -> dict[str, object]:
381
+ """Build the `status --json` payload.
382
+
383
+ Compatibility contract for `schema_version`: it is bumped only for a
384
+ breaking change to this shape (a field removed, renamed, or repurposed).
385
+ Adding a new `active.state` value (as "incompatible" was added here) or a
386
+ new optional field is *not* a bump -- consumers, including the bundled
387
+ TUI plugin (a separately-versioned npm package that can trail the CLI),
388
+ must tolerate unknown `state` values and unknown fields rather than
389
+ assuming the set present at the time they were written is exhaustive.
390
+
391
+ `schema_version` went 1 -> 2 when OpenAI added a second (5h) rate-limit
392
+ window: the flat `usage.used_percent`/`reset_at`/`window_seconds` fields
393
+ were replaced by `usage.windows` (a list of the same three fields, one
394
+ entry per window) since a single flat field can no longer represent both
395
+ windows -- an actual removal/repurposing, not an addition.
396
+ """
397
+ accounts = switcher.registry.scoped_accounts(provider_id)
398
+ live_auth = opencode_auth.read_auth(switcher.opencode_auth_path) if switcher.opencode_auth_path.exists() else {}
399
+ providers: list[dict[str, object]] = []
400
+ for current_provider_id in _status_provider_ids(switcher, provider_id, live_auth):
401
+ provider_accounts = [
402
+ {"name": meta.name, "type": meta.type}
403
+ for (stored_provider_id, _), meta in sorted(accounts.items())
404
+ if stored_provider_id == current_provider_id
405
+ ]
406
+ active: dict[str, object]
407
+ try:
408
+ current, desc = switcher.current_from_auth(live_auth, current_provider_id)
409
+ except (SchemaError, ValueError) as exc:
410
+ current = None
411
+ active = {"state": "incompatible", "reason": _incompatible_reason(current_provider_id, live_auth, exc)}
412
+ else:
413
+ if current is not None:
414
+ active = {"state": "managed", "name": current.name}
415
+ elif desc is not None:
416
+ active = {"state": "unmanaged"}
417
+ else:
418
+ active = {"state": "none"}
419
+
420
+ entry: dict[str, object] = {
421
+ "id": current_provider_id,
422
+ "accounts": provider_accounts,
423
+ "active": active,
424
+ }
425
+ if include_usage and current is not None:
426
+ entry["usage"] = _status_usage(switcher.fetch_usage(current.name, provider_id=current_provider_id))
427
+ providers.append(entry)
428
+ return {"schema_version": 2, "providers": providers}
429
+
430
+
431
+ def _format_status_usage(usage_snapshot: dict[str, object]) -> str:
432
+ """Render the `usage` block of a `status --json` payload entry as text,
433
+ for `status`'s non-JSON output. Takes the already-built JSON dict (not a
434
+ `UsageSnapshot`) since `cmd_status` only has the payload dict in hand;
435
+ reuses `_window_label`/`_format_reset` so the two commands' text output
436
+ stays in sync."""
437
+ if not usage_snapshot.get("applicable"):
438
+ return " usage: n/a"
439
+ if not usage_snapshot.get("available"):
440
+ return " usage: unavailable"
441
+ windows = usage_snapshot.get("windows")
442
+ parts: list[str] = []
443
+ if isinstance(windows, list):
444
+ for window in windows:
445
+ if not isinstance(window, dict):
446
+ continue
447
+ used_percent = window.get("used_percent")
448
+ if not isinstance(used_percent, (int, float)) or isinstance(used_percent, bool):
449
+ continue
450
+ text = f"{used_percent:.0f}%"
451
+ label = _window_label(window.get("window_seconds"))
452
+ if label:
453
+ text = f"{label} {text}"
454
+ reset = _format_reset(window.get("reset_at"))
455
+ if reset:
456
+ text = f"{text} @{reset}"
457
+ parts.append(text)
458
+ if not parts:
459
+ return " usage: n/a"
460
+ windows_text = " | ".join(parts)
461
+ plan_name = usage_snapshot.get("plan_name")
462
+ if isinstance(plan_name, str) and plan_name:
463
+ windows_text = f"{windows_text}, {plan_name}"
464
+ return " usage: " + windows_text
465
+
466
+
467
+ def cmd_status(switcher: Switcher, args: argparse.Namespace) -> int:
468
+ payload = _status_payload(switcher, args.provider, args.usage)
469
+ if args.json:
470
+ print(json.dumps(payload, separators=(",", ":"), allow_nan=False))
471
+ return 0
472
+
473
+ providers = payload["providers"]
474
+ assert isinstance(providers, list)
475
+ for provider in providers:
476
+ assert isinstance(provider, dict)
477
+ active = provider["active"]
478
+ assert isinstance(active, dict)
479
+ provider_id = provider["id"]
480
+ state = active["state"]
481
+ if state == "managed":
482
+ line = f"{provider_id}: {active['name']}"
483
+ elif state == "unmanaged":
484
+ line = f"{provider_id}: active account opencode-swap doesn't manage"
485
+ elif state == "incompatible":
486
+ line = f"{provider_id}: {active.get('reason')}"
487
+ else:
488
+ line = f"{provider_id}: no active account"
489
+ usage_snapshot = provider.get("usage")
490
+ if isinstance(usage_snapshot, dict):
491
+ line += _format_status_usage(usage_snapshot)
492
+ print(line)
493
+ return 0
494
+
495
+
496
+ def cmd_current(switcher: Switcher, args: argparse.Namespace) -> int:
497
+ provider_ids = [args.provider] if args.provider else sorted({meta.provider for meta in switcher.registry.scoped_accounts().values()})
498
+ if not args.provider and switcher.opencode_auth_path.exists():
499
+ try:
500
+ live_provider_ids = set(opencode_auth.read_auth(switcher.opencode_auth_path))
501
+ except AuthFileError:
502
+ live_provider_ids = set()
503
+ provider_ids = sorted(set(provider_ids) | live_provider_ids)
504
+ if not provider_ids:
505
+ print("No active provider accounts in OpenCode.")
506
+ return 0
507
+ incompatible = False
508
+ for provider_id in provider_ids:
509
+ try:
510
+ meta, desc = switcher.current(provider_id)
511
+ except SchemaError as exc:
512
+ if args.provider:
513
+ raise
514
+ print(f"{provider_id}: unsupported/incompatible ({exc})")
515
+ incompatible = True
516
+ continue
517
+ if desc is None:
518
+ print(f"{provider_id}: no active account")
519
+ elif meta is None:
520
+ print(f"{provider_id}: active account opencode-swap doesn't manage ({_redact_account_id(desc.account_id)})")
521
+ else:
522
+ print(f"{provider_id}: {meta.name} ({meta.email or 'no email'}, account {_redact_account_id(desc.account_id or meta.account_id)})")
523
+ return 1 if incompatible else 0
524
+
525
+
526
+ def _can_switch(assume_yes: bool) -> bool:
527
+ prompt = "Switch OpenCode's active account?"
528
+ if process_detection.is_opencode_running():
529
+ prompt = "OpenCode appears to be running; switching now could race an in-flight token refresh. Switch anyway?"
530
+ if not _confirm(prompt, assume_yes):
531
+ print("Aborted.", file=sys.stderr)
532
+ return False
533
+ return True
534
+
535
+
536
+ def cmd_use(switcher: Switcher, args: argparse.Namespace) -> int:
537
+ if not _can_switch(args.yes):
538
+ return 1
539
+ meta = switcher.use_account(args.name, provider_id=args.provider)
540
+ print(f"Switched to '{meta.provider}:{meta.name}'.")
541
+ return 0
542
+
543
+
544
+ def cmd_switch(switcher: Switcher, args: argparse.Namespace) -> int:
545
+ if not _can_switch(args.yes):
546
+ return 1
547
+ next_meta = switcher.next_account(args.provider)
548
+ meta = switcher.use_account(next_meta.name, provider_id=args.provider)
549
+ print(f"Switched to '{meta.provider}:{meta.name}'.")
550
+ return 0
551
+
552
+
553
+ def _refresh_result_tag(result: AccountRefreshResult) -> tuple[str, bool]:
554
+ """(message, is_error). EXPIRED collapses three distinct reasons that
555
+ call for different messages: this account's provider/type has no
556
+ standalone refresh at all (`NO_SUPPORT`, expected and not an error) vs.
557
+ this one genuinely supports refresh but it was deliberately skipped --
558
+ either because OpenCode itself owns this account's refresh (`LIVE`) or
559
+ because live account state couldn't be safely verified this time
560
+ (`AMBIGUOUS`, worth retrying, reported as an error)."""
561
+ if result.validity == Validity.INVALID:
562
+ return "invalid", True
563
+ if result.validity == Validity.OK:
564
+ return "ok", False
565
+ if result.outcome is RefreshOutcome.NO_SUPPORT:
566
+ return "still expired (no standalone refresh available for this account type)", False
567
+ if result.outcome is RefreshOutcome.LIVE:
568
+ return "still expired (OpenCode refreshes this account on its next request)", False
569
+ if result.outcome is RefreshOutcome.AMBIGUOUS:
570
+ return "still expired (live account state could not be confirmed; refresh skipped, try again)", True
571
+ return "still expired", True
572
+
573
+
574
+ def cmd_refresh(switcher: Switcher, args: argparse.Namespace) -> int:
575
+ if args.name:
576
+ names = [args.name]
577
+ else:
578
+ accounts = switcher.registry.scoped_accounts(args.provider)
579
+ names = sorted(name for (provider_id, name) in accounts if provider_id == args.provider)
580
+ if not names:
581
+ print(f"No saved accounts for provider '{args.provider}'.")
582
+ return 0
583
+
584
+ exit_code = 0
585
+ for name in names:
586
+ try:
587
+ result = switcher.refresh_account(name, provider_id=args.provider)
588
+ except OpenCodeSwapError as exc:
589
+ print(f"{args.provider:<22} {name:<20} {exc}")
590
+ exit_code = 1
591
+ continue
592
+ tag, is_error = _refresh_result_tag(result)
593
+ if is_error:
594
+ exit_code = 1
595
+ print(f"{args.provider:<22} {name:<20} {tag}")
596
+ return exit_code
597
+
598
+
599
+ def cmd_remove(switcher: Switcher, args: argparse.Namespace) -> int:
600
+ if not _confirm(f"Remove saved account '{args.provider}:{args.name}'?", args.yes):
601
+ print("Aborted.", file=sys.stderr)
602
+ return 1
603
+ switcher.remove_account(args.name, provider_id=args.provider)
604
+ print(f"Removed '{args.provider}:{args.name}'.")
605
+ return 0
606
+
607
+
608
+ def cmd_rename(switcher: Switcher, args: argparse.Namespace) -> int:
609
+ switcher.rename_account(args.old, args.new, provider_id=args.provider)
610
+ print(f"Renamed '{args.provider}:{args.old}' to '{args.provider}:{args.new}'.")
611
+ return 0
612
+
613
+
614
+ def cmd_export(switcher: Switcher, args: argparse.Namespace) -> int:
615
+ path = Path(args.path).expanduser()
616
+ path = _prompt_archive_extension(path)
617
+ count = switcher.export_accounts(path, _prompt_archive_password(confirm=True))
618
+ print(f"Exported {count} account{'s' if count != 1 else ''} to {path}.")
619
+ return 0
620
+
621
+
622
+ def cmd_import(switcher: Switcher, args: argparse.Namespace) -> int:
623
+ path = Path(args.path).expanduser()
624
+ apply_to_all: ImportConflictAction | None = None
625
+
626
+ def resolve_conflict(name: str) -> ImportConflictAction:
627
+ nonlocal apply_to_all
628
+ if apply_to_all is not None:
629
+ return apply_to_all
630
+ choices = {
631
+ "s": ImportConflictAction.SKIP,
632
+ "skip": ImportConflictAction.SKIP,
633
+ "sa": ImportConflictAction.SKIP,
634
+ "skip-all": ImportConflictAction.SKIP,
635
+ "o": ImportConflictAction.OVERWRITE,
636
+ "overwrite": ImportConflictAction.OVERWRITE,
637
+ "oa": ImportConflictAction.OVERWRITE,
638
+ "overwrite-all": ImportConflictAction.OVERWRITE,
639
+ "a": ImportConflictAction.ABORT,
640
+ "abort": ImportConflictAction.ABORT,
641
+ }
642
+ while True:
643
+ try:
644
+ answer = (
645
+ input(f"Account '{name}' already exists. Choose [s/sa/o/oa/a] (skip/skip-all/overwrite/overwrite-all/abort): ").strip().lower()
646
+ )
647
+ except EOFError as exc:
648
+ raise OpenCodeSwapError("could not read import conflict choice") from exc
649
+ action = choices.get(answer)
650
+ if action is not None:
651
+ if answer in ("sa", "skip-all", "oa", "overwrite-all"):
652
+ apply_to_all = action
653
+ return action
654
+ print("Enter s, sa, o, oa, or a (full names also accepted).", file=sys.stderr)
655
+
656
+ count = switcher.import_accounts(path, _prompt_archive_password(confirm=False), resolve_conflict)
657
+ print(f"Imported {count} account{'s' if count != 1 else ''}. Run `opencode-swap use <provider> <name>` to activate one.")
658
+ return 0
659
+
660
+
661
+ def cmd_restore(switcher: Switcher, args: argparse.Namespace) -> int:
662
+ source = "pristine" if args.pristine else "bak"
663
+ which = "original pristine" if args.pristine else "most recent pre-switch"
664
+ prompt = f"Restore OpenCode's auth.json from the {which} backup? This overwrites the current live auth.json."
665
+ if args.discard_pending:
666
+ prompt += " This also archives a retained failed-restore recovery snapshot under backups/ before proceeding."
667
+ if not _confirm(prompt, args.yes):
668
+ print("Aborted.", file=sys.stderr)
669
+ return 1
670
+
671
+ metas = switcher.restore(source=source, discard_pending=args.discard_pending)
672
+ if metas:
673
+ accounts = ", ".join(f"{meta.provider}:{meta.name}" for meta in sorted(metas, key=lambda item: (item.provider, item.name)))
674
+ print(f"Restored. Active managed accounts: {accounts}.")
675
+ else:
676
+ print("Restored. No restored provider account could be matched to a managed account.")
677
+ return 0
678
+
679
+
680
+ def _fractional_expiry_warning(auth: JsonObject, provider_id: str) -> str | None:
681
+ """Report an `expires` OpenCode's schema would reject.
682
+
683
+ Checked against the raw auth.json value rather than an extracted
684
+ AuthRecord: `extract` reads verbatim and normalization only happens at
685
+ the publication boundary (`providers.common.published_raw`, used by
686
+ `Provider.splice`), so by extraction time the problem is invisible.
687
+ OpenCode types `expires` as `NonNegativeInt` and drops entries that
688
+ fail to decode without any error, so a fractional value looks exactly
689
+ like "this provider was never logged in".
690
+ """
691
+ raw = auth.get(provider_id)
692
+ if not isinstance(raw, dict) or raw.get("type") != "oauth":
693
+ return None
694
+ expires = raw.get("expires")
695
+ if not is_json_number(expires) or float(expires).is_integer():
696
+ return None
697
+ return (
698
+ f" provider {provider_id!r}: 'expires' is not an integer — OpenCode silently ignores this entry; "
699
+ f"run `opencode-swap use {provider_id} <name>` to repair it"
700
+ )
701
+
702
+
703
+ def cmd_doctor(switcher: Switcher, args: argparse.Namespace) -> int:
704
+ print(f"OpenCode auth file: {switcher.opencode_auth_path}")
705
+ print(f" exists: {'yes' if switcher.opencode_auth_path.exists() else 'no'}")
706
+ if paths.opencode_auth_content_override_active():
707
+ print(" WARNING: OPENCODE_AUTH_CONTENT is set — OpenCode ignores auth.json entirely while this is set, so switches would have no effect.")
708
+
709
+ provider_statuses: list[str] = []
710
+ try:
711
+ if switcher.opencode_auth_path.exists():
712
+ auth = opencode_auth.read_auth(switcher.opencode_auth_path)
713
+ managed_provider_ids = {meta.provider for meta in switcher.registry.scoped_accounts().values()}
714
+ for provider_id in sorted(set(auth) | managed_provider_ids):
715
+ try:
716
+ get_provider(provider_id).extract(auth)
717
+ except (SchemaError, ValueError) as exc:
718
+ provider_statuses.append(f" provider {provider_id!r}: UNSUPPORTED/INCOMPATIBLE: {exc}")
719
+ continue
720
+ fractional_expiry = _fractional_expiry_warning(auth, provider_id)
721
+ if fractional_expiry is not None:
722
+ provider_statuses.append(fractional_expiry)
723
+ schema_status = "OK"
724
+ except AuthFileError as exc:
725
+ schema_status = f"UNREADABLE: {exc}"
726
+ except SchemaError as exc:
727
+ schema_status = f"INCOMPATIBLE: {exc}"
728
+ print(f" schema check: {schema_status}")
729
+ for status in provider_statuses:
730
+ print(status)
731
+
732
+ print(f"opencode-swap data dir: {switcher.data_root}")
733
+ accounts = switcher.registry.scoped_accounts()
734
+ locations = [switcher.secrets.record_location(f"{provider}:{name}") for provider, name in accounts]
735
+ sealed_count = locations.count(RecordLocation.SEALED)
736
+ fallback_count = locations.count(RecordLocation.FILE_FALLBACK)
737
+ missing_count = locations.count(RecordLocation.MISSING)
738
+ backend_detail = f"{sealed_count} sealed, {fallback_count} plaintext-fallback, {missing_count} unreadable"
739
+ print(f" secret backend: {switcher.secrets.backend_name} ({backend_detail})")
740
+ print(f" managed accounts: {len(accounts)}")
741
+ active = [
742
+ f"{provider}:{name}" for provider in sorted({meta.provider for meta in accounts.values()}) if (name := switcher.registry.get_active(provider))
743
+ ]
744
+ print(f" active (per registry): {', '.join(active) if active else 'none'}")
745
+ print(f" .bak present: {'yes' if backup.read_bak(switcher.data_root) is not None else 'no'}")
746
+ print(f" .pristine present: {'yes' if backup.read_pristine(switcher.data_root) is not None else 'no'}")
747
+ try:
748
+ pending_restore = backup.read_restore_snapshot(switcher.data_root) is not None
749
+ restore_status = "yes" if pending_restore else "no"
750
+ except BackupError:
751
+ restore_status = "unreadable"
752
+ if restore_status != "no":
753
+ print(f" .restore pending: {restore_status} (run `opencode-swap restore --discard-pending` to clear it)")
754
+ else:
755
+ print(f" .restore pending: {restore_status}")
756
+
757
+ print(f"OpenCode process detected: {'yes' if process_detection.is_opencode_running() else 'no'}")
758
+ return 0
759
+
760
+
761
+ _HANDLERS = {
762
+ "add": cmd_add,
763
+ "list": cmd_list,
764
+ "current": cmd_current,
765
+ "status": cmd_status,
766
+ "use": cmd_use,
767
+ "switch": cmd_switch,
768
+ "refresh": cmd_refresh,
769
+ "remove": cmd_remove,
770
+ "rename": cmd_rename,
771
+ "export": cmd_export,
772
+ "import": cmd_import,
773
+ "restore": cmd_restore,
774
+ "doctor": cmd_doctor,
775
+ }
776
+
777
+
778
+ def main(argv: list[str] | None = None) -> int:
779
+ parser = _build_parser()
780
+ args = parser.parse_args(argv)
781
+
782
+ if args.command is None:
783
+ parser.print_help()
784
+ return 0
785
+
786
+ handler = _HANDLERS.get(args.command)
787
+ if handler is None:
788
+ parser.error(f"unknown command: {args.command}")
789
+
790
+ try:
791
+ switcher = Switcher.default()
792
+ return handler(switcher, args)
793
+ except (OpenCodeSwapError, ValueError) as exc:
794
+ print(f"opencode-swap: {exc}", file=sys.stderr)
795
+ return 1
796
+
797
+
798
+ if __name__ == "__main__":
799
+ sys.exit(main())