vibemaxxing 0.2.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.
- vibemaxxing/__init__.py +7 -0
- vibemaxxing/__main__.py +6 -0
- vibemaxxing/cli.py +475 -0
- vibemaxxing/credentials.py +239 -0
- vibemaxxing/envelope.py +298 -0
- vibemaxxing/errors.py +55 -0
- vibemaxxing/fsutil.py +46 -0
- vibemaxxing/history.py +77 -0
- vibemaxxing/httpclient.py +124 -0
- vibemaxxing/index.html +551 -0
- vibemaxxing/keychain.py +144 -0
- vibemaxxing/models.py +20 -0
- vibemaxxing/oauth.py +276 -0
- vibemaxxing/poll.py +89 -0
- vibemaxxing/pool.py +56 -0
- vibemaxxing/redact.py +133 -0
- vibemaxxing/store.py +362 -0
- vibemaxxing/tui.py +249 -0
- vibemaxxing/usage.py +160 -0
- vibemaxxing/web.py +174 -0
- vibemaxxing-0.2.0.dist-info/METADATA +188 -0
- vibemaxxing-0.2.0.dist-info/RECORD +25 -0
- vibemaxxing-0.2.0.dist-info/WHEEL +4 -0
- vibemaxxing-0.2.0.dist-info/entry_points.txt +3 -0
- vibemaxxing-0.2.0.dist-info/licenses/LICENSE +21 -0
vibemaxxing/__init__.py
ADDED
vibemaxxing/__main__.py
ADDED
vibemaxxing/cli.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Argument parsing and the human renderer. Every command funnels its output
|
|
2
|
+
through redact.out / redact.err, so no path prints an unscrubbed byte."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import webbrowser
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from contextlib import closing
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import timedelta
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Final
|
|
18
|
+
|
|
19
|
+
from vibemaxxing import __version__, credentials, envelope, history, oauth, store, tui, web
|
|
20
|
+
from vibemaxxing.credentials import Credential
|
|
21
|
+
from vibemaxxing.envelope import AccountView
|
|
22
|
+
from vibemaxxing.errors import NeedsLoginError, NetworkError, UsageError, VibeError
|
|
23
|
+
from vibemaxxing.httpclient import HttpClient, UrllibClient
|
|
24
|
+
from vibemaxxing.keychain import KeychainPort, default_port
|
|
25
|
+
from vibemaxxing.models import AccountState
|
|
26
|
+
from vibemaxxing.pool import dry_in
|
|
27
|
+
from vibemaxxing.redact import err, install_excepthook, out
|
|
28
|
+
from vibemaxxing.web import LOOPBACK
|
|
29
|
+
|
|
30
|
+
DEFAULT_PORT: Final = 8787
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Context:
|
|
35
|
+
root: Path
|
|
36
|
+
client: HttpClient
|
|
37
|
+
port: KeychainPort
|
|
38
|
+
now_s: float
|
|
39
|
+
# A long-lived surface needs a clock, not a snapshot: the dashboard reads this
|
|
40
|
+
# every cycle. A one-shot command reads now_s and never calls it.
|
|
41
|
+
clock: Callable[[], float] = time.time
|
|
42
|
+
prompt: Callable[[str], str] = input
|
|
43
|
+
browser: Callable[[str], bool] = webbrowser.open
|
|
44
|
+
argv0: str = field(default="vibe")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def default_context() -> Context:
|
|
48
|
+
return Context(
|
|
49
|
+
root=store.store_root(),
|
|
50
|
+
client=UrllibClient(),
|
|
51
|
+
port=default_port(),
|
|
52
|
+
now_s=time.time(),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- rendering ---------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _render_accounts(views: Sequence[AccountView], *, now_s: float, dry: float | None) -> str:
|
|
60
|
+
if not views:
|
|
61
|
+
return "no accounts yet — run: vibe add\n"
|
|
62
|
+
lines: list[str] = []
|
|
63
|
+
for view in views:
|
|
64
|
+
marker = "*" if view.active else " "
|
|
65
|
+
who = view.identity.email or view.identity.display_name or "—"
|
|
66
|
+
plan = view.plan or "—"
|
|
67
|
+
lines.append(f"{marker} {view.alias} {who} {plan}")
|
|
68
|
+
if view.message:
|
|
69
|
+
lines.append(f" {view.message}")
|
|
70
|
+
summary = view.summary
|
|
71
|
+
if summary is not None and not summary.rows:
|
|
72
|
+
lines.append(" no limits reported")
|
|
73
|
+
for row in summary.rows if summary else ():
|
|
74
|
+
percent = "—" if row.percent is None else f"{row.percent}%"
|
|
75
|
+
flag = "" if row.severity in (None, "normal") else f" ({row.severity})"
|
|
76
|
+
lines.append(f" {row.label:<24}{percent:>5}{flag}")
|
|
77
|
+
lines.append("")
|
|
78
|
+
plural = "" if len(views) == 1 else "s"
|
|
79
|
+
tail = f"pool {envelope.pool_weeks(views)} account-weeks across {len(views)} account{plural}"
|
|
80
|
+
if dry is not None:
|
|
81
|
+
tail += f" · dry in {dry / 3600:.1f}h"
|
|
82
|
+
lines.append(tail)
|
|
83
|
+
return "\n".join(lines) + "\n"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _emit(payload: dict[str, object], *, as_json: bool, human: str) -> None:
|
|
87
|
+
out(envelope.dumps(payload) + "\n" if as_json else human)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _action(action: str, **fields: object) -> dict[str, object]:
|
|
91
|
+
return {"schema": envelope.ENVELOPE_SCHEMA, "ok": True, "action": action, **fields}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --- commands ----------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _slug(text: str) -> str:
|
|
98
|
+
# Lower case and strip the leading punctuation store.valid_alias rejects, so
|
|
99
|
+
# an email local part like "_Jan.S" cannot make a bare `vibe add` abort.
|
|
100
|
+
kept = [c if (c.isalnum() or c in "._-") else "-" for c in text.lower()]
|
|
101
|
+
slug = "".join(kept).strip("-._")
|
|
102
|
+
return slug[:64] or "account"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _free_alias(root: Path, wanted: str) -> str:
|
|
106
|
+
taken = set(store.list_aliases(root))
|
|
107
|
+
if wanted not in taken:
|
|
108
|
+
return wanted
|
|
109
|
+
for n in range(2, 100):
|
|
110
|
+
candidate = f"{wanted}-{n}"
|
|
111
|
+
if candidate not in taken:
|
|
112
|
+
return candidate
|
|
113
|
+
raise UsageError(f'too many accounts named like "{wanted}"', "vibe list")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _already_stored(root: Path, credential: Credential) -> str | None:
|
|
117
|
+
wanted = credential.refresh_token
|
|
118
|
+
for alias in store.list_aliases(root):
|
|
119
|
+
try:
|
|
120
|
+
if store.read_account(root, alias).credential.refresh_token == wanted:
|
|
121
|
+
return alias
|
|
122
|
+
except VibeError:
|
|
123
|
+
continue # a broken account file must not block an adopt
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _adopt(ctx: Context, as_json: bool) -> int:
|
|
128
|
+
blob = ctx.port.read()
|
|
129
|
+
if blob is None:
|
|
130
|
+
raise NeedsLoginError(
|
|
131
|
+
"Claude Code has no login on this machine to adopt",
|
|
132
|
+
"claude /login",
|
|
133
|
+
)
|
|
134
|
+
credential = credentials.parse_blob(blob)
|
|
135
|
+
existing = _already_stored(ctx.root, credential)
|
|
136
|
+
if existing is not None:
|
|
137
|
+
# Adopting twice would put one refresh token in two account files, and
|
|
138
|
+
# the first rotation under either alias silently kills the other.
|
|
139
|
+
_emit(
|
|
140
|
+
_action("add", alias=existing, adopted=True),
|
|
141
|
+
as_json=as_json,
|
|
142
|
+
human=f'that login is already stored as "{existing}"\n',
|
|
143
|
+
)
|
|
144
|
+
return 0
|
|
145
|
+
identity = credentials.read_claude_identity(Path.home() / ".claude.json")
|
|
146
|
+
base = identity.email or identity.display_name or "account"
|
|
147
|
+
alias = _free_alias(ctx.root, _slug(base.split("@", 1)[0]))
|
|
148
|
+
store.write_account(
|
|
149
|
+
ctx.root,
|
|
150
|
+
store.Account(
|
|
151
|
+
alias=alias,
|
|
152
|
+
credential=credential,
|
|
153
|
+
identity=identity,
|
|
154
|
+
state=AccountState.OK,
|
|
155
|
+
message=None,
|
|
156
|
+
added_at=ctx.now_s,
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
store.write_active(ctx.root, alias)
|
|
160
|
+
_emit(
|
|
161
|
+
_action("add", alias=alias, adopted=True),
|
|
162
|
+
as_json=as_json,
|
|
163
|
+
human=f'adopted the login already in Claude Code as "{alias}"\n',
|
|
164
|
+
)
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _login(ctx: Context, alias: str, as_json: bool) -> int:
|
|
169
|
+
if not store.valid_alias(alias):
|
|
170
|
+
raise UsageError(
|
|
171
|
+
f'"{alias}" is not a usable alias: letters, digits, dot, dash, underscore',
|
|
172
|
+
"vibe add work",
|
|
173
|
+
)
|
|
174
|
+
verifier, state = oauth.new_verifier(), oauth.new_state()
|
|
175
|
+
url = oauth.build_authorize_url(verifier, state)
|
|
176
|
+
# URL first, then the browser. On Linux, CPython registers text-mode console
|
|
177
|
+
# browsers whenever TERM is set and GenericBrowser.open waits for the child,
|
|
178
|
+
# so a headless login hands the terminal to lynx and blocks there -- with the
|
|
179
|
+
# URL never printed, because printing came after the call.
|
|
180
|
+
err(f"open this to log in:\n\n{url}\n\n")
|
|
181
|
+
if sys.platform == "darwin" or os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
|
|
182
|
+
ctx.browser(url)
|
|
183
|
+
# The prompt goes to stderr: with --json, stdout must be the envelope alone.
|
|
184
|
+
err("paste the code shown in the browser: ")
|
|
185
|
+
paste = ctx.prompt("").strip()
|
|
186
|
+
code, _ = oauth.parse_pasted_code(paste, state)
|
|
187
|
+
credential, identity = oauth.exchange_code(
|
|
188
|
+
ctx.client, code=code, verifier=verifier, state=state
|
|
189
|
+
)
|
|
190
|
+
# A stash from the previous lineage would be consumed as a successor by the
|
|
191
|
+
# next refresh, destroying the credential this login just issued.
|
|
192
|
+
store.delete_stash(ctx.root, alias)
|
|
193
|
+
store.write_account(
|
|
194
|
+
ctx.root,
|
|
195
|
+
store.Account(
|
|
196
|
+
alias=alias,
|
|
197
|
+
credential=credential,
|
|
198
|
+
identity=identity,
|
|
199
|
+
state=AccountState.OK,
|
|
200
|
+
message=None,
|
|
201
|
+
added_at=ctx.now_s,
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
_emit(
|
|
205
|
+
_action("add", alias=alias, adopted=False),
|
|
206
|
+
as_json=as_json,
|
|
207
|
+
human=f'added "{alias}" — run: vibe switch {alias}\n',
|
|
208
|
+
)
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_add(args: argparse.Namespace, ctx: Context) -> int:
|
|
213
|
+
if args.alias is None:
|
|
214
|
+
return _adopt(ctx, args.json)
|
|
215
|
+
return _login(ctx, args.alias, args.json)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _collect_and_record(ctx: Context) -> tuple[list[AccountView], float | None]:
|
|
219
|
+
views = envelope.collect(ctx.root, client=ctx.client, now_s=ctx.now_s)
|
|
220
|
+
with closing(history.connect(store.history_path(ctx.root))) as conn:
|
|
221
|
+
history.record(conn, at_s=ctx.now_s, pool=envelope.pool_weeks(views))
|
|
222
|
+
window = history.samples(conn, since_s=ctx.now_s - history.RETENTION_DAYS * 86_400.0)
|
|
223
|
+
forecast = dry_in(window)
|
|
224
|
+
return views, None if forecast is None else forecast.total_seconds()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def cmd_list(args: argparse.Namespace, ctx: Context) -> int:
|
|
228
|
+
views, dry = _collect_and_record(ctx)
|
|
229
|
+
_emit(
|
|
230
|
+
envelope.build(
|
|
231
|
+
views,
|
|
232
|
+
now_s=ctx.now_s,
|
|
233
|
+
dry_in=None if dry is None else timedelta(seconds=dry),
|
|
234
|
+
),
|
|
235
|
+
as_json=args.json,
|
|
236
|
+
human=_render_accounts(views, now_s=ctx.now_s, dry=dry),
|
|
237
|
+
)
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def check_loopback(host: str) -> None:
|
|
242
|
+
if host != LOOPBACK:
|
|
243
|
+
raise UsageError(
|
|
244
|
+
f"--host {host} is refused: the dashboard binds loopback ({LOOPBACK}) only, "
|
|
245
|
+
"because it has no authentication and no TLS",
|
|
246
|
+
f"vibe usage web --host {LOOPBACK}",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cmd_dashboard(args: argparse.Namespace, ctx: Context) -> int:
|
|
251
|
+
if not args.json and not sys.stdin.isatty():
|
|
252
|
+
# Textual would take the alt screen and wait forever for input that is
|
|
253
|
+
# never coming: ssh without -t, cron, a pipeline, a CI step.
|
|
254
|
+
raise UsageError(
|
|
255
|
+
"the dashboard needs a terminal, and stdin is not one",
|
|
256
|
+
"vibe list",
|
|
257
|
+
)
|
|
258
|
+
# Bare `vibe` opens the dashboard; `vibe --json` stays machine-readable.
|
|
259
|
+
return cmd_list(args, ctx) if args.json else tui.run(ctx)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def cmd_usage(args: argparse.Namespace, ctx: Context) -> int:
|
|
263
|
+
if args.mode == "web":
|
|
264
|
+
if args.once:
|
|
265
|
+
raise UsageError(
|
|
266
|
+
"vibe usage web serves continuously; --once fetches and exits",
|
|
267
|
+
"vibe usage --once",
|
|
268
|
+
)
|
|
269
|
+
check_loopback(args.host)
|
|
270
|
+
return web.serve(ctx, port=args.port)
|
|
271
|
+
return cmd_list(args, ctx)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def cmd_switch(args: argparse.Namespace, ctx: Context) -> int:
|
|
275
|
+
store.switch(ctx.root, args.alias, ctx.port)
|
|
276
|
+
_emit(
|
|
277
|
+
_action("switch", alias=args.alias),
|
|
278
|
+
as_json=args.json,
|
|
279
|
+
human=f'Claude Code now uses "{args.alias}"\n',
|
|
280
|
+
)
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def cmd_remove(args: argparse.Namespace, ctx: Context) -> int:
|
|
285
|
+
store.delete_account(ctx.root, args.alias)
|
|
286
|
+
_emit(
|
|
287
|
+
_action("remove", alias=args.alias),
|
|
288
|
+
as_json=args.json,
|
|
289
|
+
human=f'removed "{args.alias}"\n',
|
|
290
|
+
)
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def cmd_alias(args: argparse.Namespace, ctx: Context) -> int:
|
|
295
|
+
store.rename_account(ctx.root, args.old, args.new)
|
|
296
|
+
_emit(
|
|
297
|
+
_action("alias", old=args.old, new=args.new),
|
|
298
|
+
as_json=args.json,
|
|
299
|
+
human=f'"{args.old}" is now "{args.new}"\n',
|
|
300
|
+
)
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _fresh_token(ctx: Context, alias: str) -> Credential:
|
|
305
|
+
account = store.read_account(ctx.root, alias)
|
|
306
|
+
now_ms = int(ctx.now_s * 1000)
|
|
307
|
+
if credentials.login_lapsed(account.credential, now_ms=now_ms):
|
|
308
|
+
raise NeedsLoginError(f'the login for "{alias}" has lapsed', f"vibe add {alias}")
|
|
309
|
+
if not credentials.is_expired(account.credential, now_ms=now_ms):
|
|
310
|
+
return account.credential
|
|
311
|
+
outcome = oauth.refresh(ctx.root, alias, account.credential, ctx.client, now_ms=now_ms)
|
|
312
|
+
if outcome.credential is not None:
|
|
313
|
+
return outcome.credential
|
|
314
|
+
if outcome.error in ("invalid_grant", "no_refresh_token"):
|
|
315
|
+
raise NeedsLoginError(f'the refresh token for "{alias}" is dead', f"vibe add {alias}")
|
|
316
|
+
# transient, busy, invalid_client: the login is fine, the attempt was not, so
|
|
317
|
+
# sending the user to a browser login here would be wrong advice.
|
|
318
|
+
raise NetworkError(
|
|
319
|
+
f'could not refresh "{alias}" right now ({outcome.error})',
|
|
320
|
+
f"vibe run {alias} -- ...",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def cmd_run(args: argparse.Namespace, ctx: Context) -> int:
|
|
325
|
+
# Only the leading separator is ours; a `--` the child itself needs must
|
|
326
|
+
# survive into its argv.
|
|
327
|
+
command = args.command[1:] if args.command[:1] == ["--"] else list(args.command)
|
|
328
|
+
if not command:
|
|
329
|
+
raise UsageError("vibe run needs a command after --", "vibe run work -- claude")
|
|
330
|
+
credential = _fresh_token(ctx, args.alias)
|
|
331
|
+
# The fifth and last permitted reveal() site: the child's environment. The
|
|
332
|
+
# global Keychain credential is untouched, so other shells keep their account.
|
|
333
|
+
child_env = {**os.environ, "CLAUDE_CODE_OAUTH_TOKEN": credential.access_token.reveal()}
|
|
334
|
+
try:
|
|
335
|
+
completed = subprocess.run(command, env=child_env, check=False)
|
|
336
|
+
except KeyboardInterrupt:
|
|
337
|
+
# The child got the same SIGINT and is already shutting down. Report
|
|
338
|
+
# the conventional 130 rather than a traceback from the wrapper.
|
|
339
|
+
return 130
|
|
340
|
+
if args.json:
|
|
341
|
+
out(envelope.dumps(_action("run", alias=args.alias, exit_code=completed.returncode)) + "\n")
|
|
342
|
+
return completed.returncode
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# --- parser ------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
HELP: Final = """vibe — several Claude Code accounts, one pooled view of the plan limits.
|
|
348
|
+
|
|
349
|
+
Getting started
|
|
350
|
+
vibe add adopt the login Claude Code already has here (no browser)
|
|
351
|
+
vibe add work log in to another account; opens a browser, you paste a code
|
|
352
|
+
vibe switch work point Claude Code at that account, then run `claude` normally
|
|
353
|
+
|
|
354
|
+
Every day
|
|
355
|
+
vibe the dashboard, in the terminal
|
|
356
|
+
vibe list each account's limits and the pooled headroom
|
|
357
|
+
vibe run work -- claude one command on one account; the active login is untouched
|
|
358
|
+
vibe usage web the same dashboard in a browser, http://127.0.0.1:8787
|
|
359
|
+
|
|
360
|
+
Housekeeping
|
|
361
|
+
vibe alias old new rename an account
|
|
362
|
+
vibe remove work forget an account (the login itself is not revoked)
|
|
363
|
+
vibe usage --once fetch the numbers once and print them
|
|
364
|
+
vibe --version the installed version
|
|
365
|
+
|
|
366
|
+
switch or run?
|
|
367
|
+
`switch` is global and lasts: every shell, until you switch again. It swaps the
|
|
368
|
+
credential Claude Code itself reads, so a session started afterwards is on the new
|
|
369
|
+
account -- one already running is not.
|
|
370
|
+
`run` is one command only, on a token handed to that child process alone. Use it to
|
|
371
|
+
borrow headroom from another account without disturbing what you are logged in as.
|
|
372
|
+
|
|
373
|
+
Good to know
|
|
374
|
+
Limits are per account. `vibe list` shows session, weekly-all-models and weekly-Fable,
|
|
375
|
+
plus how many account-weeks the pool has left and roughly when it runs dry.
|
|
376
|
+
Accounts share one ~/.claude history and one MCP config -- only the credential is
|
|
377
|
+
swapped, exactly as if you had logged out and in by hand.
|
|
378
|
+
`--json` works on every command and emits the envelope documented in docs/CONTRACT.md.
|
|
379
|
+
The web dashboard refetches at most every 3 minutes; `vibe list` always fetches now."""
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _add_json(parser: argparse.ArgumentParser, *, root: bool = False) -> None:
|
|
383
|
+
# Only the root parser carries a default. A subparser default would overwrite
|
|
384
|
+
# `vibe --json list` back to False after the root had already set it, so the
|
|
385
|
+
# caller asked for JSON and silently got human text.
|
|
386
|
+
parser.add_argument(
|
|
387
|
+
"--json",
|
|
388
|
+
action="store_true",
|
|
389
|
+
default=False if root else argparse.SUPPRESS,
|
|
390
|
+
help="emit the machine-readable envelope",
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def cmd_help(args: argparse.Namespace, ctx: Context) -> int:
|
|
395
|
+
out(HELP + "\n")
|
|
396
|
+
return 0
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
400
|
+
parser = argparse.ArgumentParser(
|
|
401
|
+
prog="vibe",
|
|
402
|
+
description="Manage several Claude Code accounts and see pooled plan usage.",
|
|
403
|
+
epilog=HELP,
|
|
404
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
405
|
+
)
|
|
406
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
407
|
+
_add_json(parser, root=True)
|
|
408
|
+
parser.set_defaults(handler=cmd_dashboard, alias=None, mode=None)
|
|
409
|
+
subs = parser.add_subparsers(dest="command_name")
|
|
410
|
+
|
|
411
|
+
add = subs.add_parser("add", help="adopt the current Claude Code login, or log in fresh")
|
|
412
|
+
add.add_argument("alias", nargs="?", help="omit to adopt the login already in Claude Code")
|
|
413
|
+
_add_json(add)
|
|
414
|
+
add.set_defaults(handler=cmd_add)
|
|
415
|
+
|
|
416
|
+
listing = subs.add_parser("list", help="show every account and the pooled headroom")
|
|
417
|
+
_add_json(listing)
|
|
418
|
+
listing.set_defaults(handler=cmd_list)
|
|
419
|
+
|
|
420
|
+
switch = subs.add_parser("switch", help="make one account the active Claude Code login")
|
|
421
|
+
switch.add_argument("alias")
|
|
422
|
+
_add_json(switch)
|
|
423
|
+
switch.set_defaults(handler=cmd_switch)
|
|
424
|
+
|
|
425
|
+
run = subs.add_parser("run", help="run one command pinned to one account")
|
|
426
|
+
run.add_argument("alias")
|
|
427
|
+
run.add_argument("command", nargs=argparse.REMAINDER)
|
|
428
|
+
_add_json(run)
|
|
429
|
+
run.set_defaults(handler=cmd_run)
|
|
430
|
+
|
|
431
|
+
remove = subs.add_parser("remove", help="forget an account")
|
|
432
|
+
remove.add_argument("alias")
|
|
433
|
+
_add_json(remove)
|
|
434
|
+
remove.set_defaults(handler=cmd_remove)
|
|
435
|
+
|
|
436
|
+
rename = subs.add_parser("alias", help="rename an account")
|
|
437
|
+
rename.add_argument("old")
|
|
438
|
+
rename.add_argument("new")
|
|
439
|
+
_add_json(rename)
|
|
440
|
+
rename.set_defaults(handler=cmd_alias)
|
|
441
|
+
|
|
442
|
+
use = subs.add_parser("usage", help="fetch usage once, or serve the web dashboard")
|
|
443
|
+
use.add_argument(
|
|
444
|
+
"mode", nargs="?", choices=["web"], help="omit to fetch once; 'web' serves the dashboard"
|
|
445
|
+
)
|
|
446
|
+
use.add_argument(
|
|
447
|
+
"--once", action="store_true", help="fetch once and print (the default without 'web')"
|
|
448
|
+
)
|
|
449
|
+
use.add_argument("--host", default=LOOPBACK)
|
|
450
|
+
use.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
451
|
+
_add_json(use)
|
|
452
|
+
use.set_defaults(handler=cmd_usage)
|
|
453
|
+
|
|
454
|
+
# `vibe help` is what people type; without it argparse answers an unhelpful
|
|
455
|
+
# "invalid choice: 'help'" and lists the commands it just refused to explain.
|
|
456
|
+
helping = subs.add_parser("help", help="what each command is for, and when to use it")
|
|
457
|
+
_add_json(helping)
|
|
458
|
+
helping.set_defaults(handler=cmd_help)
|
|
459
|
+
|
|
460
|
+
return parser
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def main(argv: Sequence[str] | None = None, *, context: Context | None = None) -> int:
|
|
464
|
+
install_excepthook()
|
|
465
|
+
args = build_parser().parse_args(argv)
|
|
466
|
+
try:
|
|
467
|
+
ctx = context if context is not None else default_context()
|
|
468
|
+
handler: Callable[[argparse.Namespace, Context], int] = args.handler
|
|
469
|
+
return handler(args, ctx)
|
|
470
|
+
except VibeError as exc:
|
|
471
|
+
if args.json:
|
|
472
|
+
out(envelope.dumps(envelope.error_payload(exc)) + "\n")
|
|
473
|
+
else:
|
|
474
|
+
err(exc.render() + "\n")
|
|
475
|
+
return exc.exit_code
|