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.
@@ -0,0 +1,7 @@
1
+ """Manage several Claude Code accounts and see pooled plan usage across all of them."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("vibemaxxing")
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from vibemaxxing.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
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