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/__init__.py +3 -0
- opencode_swap/__main__.py +4 -0
- opencode_swap/atomic.py +95 -0
- opencode_swap/backup.py +128 -0
- opencode_swap/cli.py +799 -0
- opencode_swap/exceptions.py +60 -0
- opencode_swap/locking.py +70 -0
- opencode_swap/macos_keychain.py +136 -0
- opencode_swap/models.py +190 -0
- opencode_swap/oauth_jwt.py +66 -0
- opencode_swap/oauth_refresh.py +156 -0
- opencode_swap/opencode_auth.py +54 -0
- opencode_swap/paths.py +83 -0
- opencode_swap/process_detection.py +29 -0
- opencode_swap/providers/__init__.py +26 -0
- opencode_swap/providers/api.py +50 -0
- opencode_swap/providers/base.py +91 -0
- opencode_swap/providers/common.py +144 -0
- opencode_swap/providers/github_copilot.py +46 -0
- opencode_swap/providers/openai.py +186 -0
- opencode_swap/providers/poe.py +64 -0
- opencode_swap/providers/xai.py +72 -0
- opencode_swap/providers/zai.py +31 -0
- opencode_swap/sealed.py +83 -0
- opencode_swap/store.py +551 -0
- opencode_swap/switcher.py +952 -0
- opencode_swap/transfer.py +149 -0
- opencode_swap/usage.py +304 -0
- opencode_swap-0.4.0.dist-info/METADATA +307 -0
- opencode_swap-0.4.0.dist-info/RECORD +33 -0
- opencode_swap-0.4.0.dist-info/WHEEL +4 -0
- opencode_swap-0.4.0.dist-info/entry_points.txt +3 -0
- opencode_swap-0.4.0.dist-info/licenses/LICENSE +21 -0
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())
|