@hanamorilabs/tab 0.1.4 → 0.1.6

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.
package/README.md CHANGED
@@ -38,6 +38,25 @@ is keyed, and keeps it in the config file so `tab claude` is one command.
38
38
  Set `FLOCKTAB_PROXY_URL`, `FLOCKTAB_KEY` and `FLOCKTAB_UNLOCK` instead when
39
39
  you would rather not have a file or a folder, for example in CI.
40
40
 
41
+ ## On a subscription (Claude Max, ChatGPT)
42
+
43
+ ```
44
+ tab use --subscription --plan "Claude Max 5x" --price 100
45
+ tab claude # your own Claude login through the tab
46
+ tab codex # your own ChatGPT login (codex login once, inside tab codex)
47
+ tab quota # 5h / 7d windows the vendor reported on the last call
48
+ tab use --api-key # back to the metered tab
49
+ ```
50
+
51
+ The plan pays for the tokens; the tab still gates every call (kill switch,
52
+ velocity, model and tool policy) and records it at list price as a
53
+ `SUBSCRIPTION` row that never touches the cap. Claude Code gets
54
+ `ANTHROPIC_BASE_URL=<proxy>/t/<key>/anthropic` and no API key; Codex gets a
55
+ home whose `chatgpt_base_url` is `<proxy>/t/<key>/openai/backend-api/`, with
56
+ your existing `~/.codex/auth.json` copied in once (never written back). The
57
+ mode is the Agent's, read on every launch, so the console's **Runs on** panel
58
+ and `tab use` agree. `docs/subscriptions.md` has the wire.
59
+
41
60
  ## Self-hosted
42
61
 
43
62
  The proxy runs on your box and holds the provider keys; the ledger, tabs and
@@ -45,8 +64,8 @@ kill switch stay on flocktab.com. It is one small Rust binary. `tab up` asks
45
64
  for the keys once (typed without echo, saved owner-only to
46
65
  `~/.flocktab/proxy.env`), fetches `flocktab-proxy` for this machine into
47
66
  `~/.flocktab/bin`, and starts it in the background (log: `~/.flocktab/proxy.log`).
48
- `tab down` stops it; `tab update` fetches the newest proxy and restarts;
49
- `tab claude` starts it when it is down. No Docker, no Rust, no checkout: the
67
+ `tab down` stops it; `tab update` installs the newest tab, which brings the
68
+ newest proxy, and restarts it; `tab claude` starts it when it is down. No Docker, no Rust, no checkout: the
50
69
  binary comes with `tab` as the `@hanamorilabs/flocktab-proxy-<platform>`
51
70
  package for your machine. Then
52
71
  `tab login`, pick **2 Self-hosted**: the proxy says which console it settles
@@ -59,12 +78,14 @@ Codex shows `FlockTab - Self-hosted` as its provider.
59
78
  tab login approve this machine in the console, once
60
79
  tab <agent> [args] claude, codex, grok, kimi, gemini, or any command, on the tab
61
80
  tab use pick or change the Agent this folder runs as (.flocktab)
81
+ tab use --subscription | --api-key this Agent on your Claude Max / ChatGPT plan, or back on the meter
82
+ tab quota [agent] what the subscription has left: 5h and 7d windows
62
83
  tab alias setup <name>... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
63
84
  tab alias remove <name>... undo that; `tab alias list` shows them
64
85
  tab status flock, folder Agent, proxy health, provider key state
65
86
  tab version tab and proxy versions
66
87
  tab up | down self-hosted: start or stop the local proxy
67
- tab update self-hosted: newest proxy (npm i -g @hanamorilabs/tab@latest)
88
+ tab update the newest tab and proxy from npm; restarts a running local proxy
68
89
  tab logout forget the session and keys on this machine
69
90
  tab help this list
70
91
  ```
@@ -76,20 +97,20 @@ session from `tab login`. An Agent is named by slug or name; left out, it is
76
97
  this folder's Agent. Every read takes `--json`.
77
98
 
78
99
  ```
79
- tab list every Agent: state, spent of cap, window, project
80
- tab close crawler tab open crawler the kill switch (402 tab_closed on the next call)
81
- tab cap crawler 25 --window week cap in dollars, and the window
82
- tab rename crawler "Nightly crawler" the slug stays
83
- tab policy crawler --velocity 60 --models gpt-5,claude-sonnet-5 --allow email.send
84
- tab project crawler Foley --create attribute to a project (--repo owner/name; none clears)
100
+ tab list every Agent: state, mode (meter | plan), spent of cap, window, project
101
+ tab close my-project tab open my-project the kill switch (402 tab_closed on the next call)
102
+ tab cap my-project 25 --window week cap in dollars, and the window
103
+ tab rename my-project "My project (nightly)" the slug stays
104
+ tab policy my-project --velocity 60 --models gpt-5,claude-sonnet-5 --allow email.send
105
+ tab project my-project Foley --create attribute to a project (--repo owner/name; none clears)
85
106
  tab projects add Foley projects
86
- tab key rotate crawler [--show] new key, kept on this machine; --show prints it once
87
- tab archive crawler closes for good, leaves the bill (asks first; -y skips)
88
- tab ledger [crawler] --limit 20 --blocked one row per call: settled of held, status, reason
107
+ tab key rotate my-project [--show] new key, kept on this machine; --show prints it once
108
+ tab archive my-project closes for good, leaves the bill (asks first; -y skips)
109
+ tab ledger [my-project] --limit 20 --blocked one row per call: settled of held, status, reason
89
110
  tab spend [agent|project|day] --since 7d meter and outside spend, the console's numbers
90
111
  tab outside connections, per-project outside spend, unattributed
91
112
  tab live --watch 4 who is working now, refreshed every 4 seconds
92
- tab web [crawler] the Agent's console page in the browser
113
+ tab web [my-project] the Agent's console page in the browser
93
114
  ```
94
115
 
95
116
  ## Aliases: plain `codex` on the tab
@@ -121,5 +142,6 @@ proxy http://127.0.0.1:8787 (self-hosted) up
121
142
  flock Hanamori Labs
122
143
  folder flocktab-codex
123
144
  plan team
145
+ mode metered (the flock's provider key, charged to the tab)
124
146
  byok provider keys on this box
125
147
  ```
package/dist/cli.js CHANGED
@@ -7,11 +7,14 @@
7
7
  * tab codex [...args] Codex on the tab
8
8
  * tab <cmd> [...args] anything else, both APIs pointed at the tab
9
9
  * tab use pick (or change) the Agent this folder runs as
10
+ * tab use --subscription this folder's Agent runs on your Claude Max / ChatGPT plan
11
+ * tab quota what the plan has left (5h / 7d windows) for this Agent
10
12
  * tab alias setup codex make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
11
13
  * tab alias remove codex undo that; `tab alias list` shows them
12
14
  * tab status which flock, which Agent here, is the proxy up
13
15
  * tab version tab and proxy versions
14
- * tab up | down | update self-hosted: start, stop, or fetch the newest proxy
16
+ * tab up | down self-hosted: start or stop the local proxy
17
+ * tab update the newest tab and proxy from npm
15
18
  * tab logout forget the session and keys
16
19
  *
17
20
  * Login is per machine. The Agent is per folder: the first `tab claude` in
@@ -52,6 +55,9 @@ function usage() {
52
55
  cmd(others.join(" | tab "), "the same, for those agents"),
53
56
  cmd("<command> [args]", "any other agent, both APIs pointed at the tab"),
54
57
  cmd("use", "pick or change the Agent this folder runs as (.flocktab)"),
58
+ cmd("use --subscription", "this Agent runs on your Claude Max / ChatGPT plan (--plan \"Max 5x\" --price 100)"),
59
+ cmd("use --api-key", "back to the metered tab with the flock's provider key"),
60
+ cmd("quota [agent]", "what the subscription has left: 5h and 7d windows"),
55
61
  cmd("alias setup <name>...", "make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)"),
56
62
  cmd("alias remove <name>...", "undo that; `tab alias list` shows them"),
57
63
  cmd("status", "which flock, which Agent here, is the proxy up"),
@@ -73,7 +79,7 @@ function usage() {
73
79
  cmd("... --json", "every read command as JSON, for scripts and agents"),
74
80
  cmd("up", "self-hosted: start the local proxy (fetches it the first time)"),
75
81
  cmd("down", "self-hosted: stop the local proxy"),
76
- cmd("update", "self-hosted: fetch the newest proxy and restart it"),
82
+ cmd("update", "the newest tab (and proxy) from npm; restarts a running local proxy"),
77
83
  cmd("logout", "forget the session and keys"),
78
84
  ].map(([k, v]) => [cyan(k), v])));
79
85
  say(`
@@ -479,6 +485,7 @@ async function status() {
479
485
  }
480
486
  else {
481
487
  pairs.push(["plan", me.flock.plan]);
488
+ pairs.push(["mode", me.authMode === "subscription" ? `${bold("subscription")} ${dim("(your Claude Max / ChatGPT login; FlockTab keeps the record)")}` : `metered ${dim("(the flock's provider key, charged to the tab)")}`]);
482
489
  const byok = !me.byok
483
490
  ? dim("no provider key on this flock")
484
491
  : me.unlock === "none"
@@ -505,9 +512,21 @@ async function runAgent(name, args) {
505
512
  const agent = await resolveAgent(config);
506
513
  if (!agent)
507
514
  return 1;
508
- const key = presentedKey({ key: agent.key, unlock: config.unlock });
515
+ // The Agent's mode is the console's call, read fresh on every launch so a
516
+ // switch there (or `tab use --subscription`) applies to the next run.
517
+ const me = await whoami(config.proxyUrl, { key: agent.key, unlock: config.unlock });
518
+ const auth = !("error" in me) && me.authMode === "subscription" ? "subscription" : "key";
519
+ // On a subscription there is no provider key to unlock; the bare key rides in the URL.
520
+ const key = auth === "subscription" ? agent.key : presentedKey({ key: agent.key, unlock: config.unlock });
509
521
  reportFolder(config.proxyUrl, key);
510
- const { spec, env } = envFor({ name, proxyUrl: config.proxyUrl, presentedKey: key });
522
+ const { spec, env } = envFor({ name, proxyUrl: config.proxyUrl, presentedKey: key, auth });
523
+ if (auth === "subscription") {
524
+ if (spec.shape === "openai" && spec.isolatedHome !== "codex") {
525
+ fail(`${spec.label} has no subscription route. Only Claude Code and Codex can run on a plan; switch this Agent back with ${bold("tab use --api-key")}.`);
526
+ return 2;
527
+ }
528
+ say(dim(`${agent.agentName} runs on your own ${spec.isolatedHome === "codex" ? "ChatGPT" : "Claude"} login; FlockTab keeps the record and the kill switch.`));
529
+ }
511
530
  // An alias shim named like the agent must not be what we spawn.
512
531
  env.PATH = pathWithoutShims();
513
532
  if (spec.isolatedHome === "codex") {
@@ -516,6 +535,7 @@ async function runAgent(name, args) {
516
535
  proxyUrl: config.proxyUrl,
517
536
  presentedKey: key,
518
537
  mode: config.mode,
538
+ auth,
519
539
  });
520
540
  }
521
541
  const child = spawn(spec.command, [...(spec.args ?? []), ...args], { stdio: "inherit", env });
@@ -587,15 +607,39 @@ function pathHint() {
587
607
  say(` ${bold(pathLine())}`);
588
608
  return 0;
589
609
  }
590
- /** `tab use`: pick or change the Agent for this folder. */
591
- async function use() {
610
+ /**
611
+ * `tab use`: pick or change the Agent for this folder. With `--subscription`
612
+ * or `--api-key` the folder's Agent (picked first if there is none) is
613
+ * switched in the console; `--plan` and `--price` label the seat.
614
+ */
615
+ async function use(args) {
592
616
  const config = await ensureLogin();
593
617
  if (!config)
594
618
  return 2;
619
+ const flags = manage.parseFlags(args);
620
+ const wantsSubscription = flags.opts.subscription !== undefined;
621
+ const wantsKey = flags.opts["api-key"] !== undefined || flags.opts.apikey !== undefined;
622
+ if (wantsSubscription && wantsKey) {
623
+ fail("Pick one: tab use --subscription or tab use --api-key.");
624
+ return 2;
625
+ }
595
626
  if (!(await ensureProxy(config)))
596
627
  return 1;
597
- const agent = await resolveAgent(config, { pick: true });
598
- return agent ? 0 : 1;
628
+ if (!wantsSubscription && !wantsKey) {
629
+ const agent = await resolveAgent(config, { pick: true });
630
+ return agent ? 0 : 1;
631
+ }
632
+ const agent = await resolveAgent(config);
633
+ if (!agent)
634
+ return 1;
635
+ try {
636
+ const project = await readProject(process.cwd());
637
+ return await manage.setAuthMode(config, flags, project?.agent ?? agent.agentName, wantsSubscription ? "subscription" : "api_key");
638
+ }
639
+ catch (err) {
640
+ fail(err instanceof Error ? err.message : String(err));
641
+ return 1;
642
+ }
599
643
  }
600
644
  /**
601
645
  * First `tab up` on a box: the provider keys the proxy will spend, typed
@@ -621,6 +665,48 @@ async function collectProviderKeys() {
621
665
  ok(`Saved to ${dim(file)} ${dim("(owner-only)")}`);
622
666
  return true;
623
667
  }
668
+ /**
669
+ * `tab update`: the newest tab from npm, which carries the newest proxy as
670
+ * its platform package. A self-hosted proxy that was running is restarted
671
+ * on the new binary; a hosted machine just gets the new tab.
672
+ */
673
+ async function update() {
674
+ const config = await loadConfig();
675
+ const wasUp = config?.mode === "self-hosted" && (await proxyHealth(config.proxyUrl)).ok;
676
+ say(dim(`tab ${TAB_VERSION} now; asking npm for the newest.`));
677
+ const code = await new Promise((resolve) => {
678
+ const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["i", "-g", "@hanamorilabs/tab@latest"], {
679
+ stdio: ["ignore", "ignore", "inherit"],
680
+ shell: process.platform === "win32",
681
+ });
682
+ child.on("error", () => resolve(1));
683
+ child.on("close", (c) => resolve(c ?? 1));
684
+ });
685
+ if (code !== 0) {
686
+ fail(`npm could not update tab. Try: ${bold("npm i -g @hanamorilabs/tab@latest")}`);
687
+ return 1;
688
+ }
689
+ // The version now on PATH is the new install's, not this process's.
690
+ const installed = await new Promise((resolve) => {
691
+ const child = spawn(process.platform === "win32" ? "tab.cmd" : "tab", ["version"], { shell: process.platform === "win32" });
692
+ let text = "";
693
+ child.stdout?.on("data", (d) => (text += d.toString()));
694
+ child.stderr?.on("data", (d) => (text += d.toString()));
695
+ child.on("close", () => resolve(text.trim()));
696
+ child.on("error", () => resolve(""));
697
+ });
698
+ ok(installed ? `Updated.
699
+ ${installed}` : "Updated.");
700
+ if (!wasUp) {
701
+ if (!packagedBinPath() && config?.mode === "self-hosted") {
702
+ const fetched = await downloadProxy((t) => say(dim(t)));
703
+ (fetched.ok ? ok : fail)(fetched.ok ? `Fetched flocktab-proxy ${PROXY_VERSION}.` : fetched.message);
704
+ }
705
+ return 0;
706
+ }
707
+ await stopProxy();
708
+ return (await bringUp(config.proxyUrl)) ? 0 : 1;
709
+ }
624
710
  async function selfHosted(action) {
625
711
  const config = await loadConfig();
626
712
  const proxyUrl = config?.mode === "self-hosted" ? config.proxyUrl : LOCAL_PROXY;
@@ -629,30 +715,6 @@ async function selfHosted(action) {
629
715
  (stopped ? ok : warn)(stopped ? "Proxy stopped." : "Proxy was not running.");
630
716
  return 0;
631
717
  }
632
- if (action === "update") {
633
- await stopProxy();
634
- if (packagedBinPath()) {
635
- // The binary is part of the npm install; the newest one comes with the newest tab.
636
- say(`The proxy ships with tab. Updating tab updates it: ${bold("npm i -g @hanamorilabs/tab@latest")}`);
637
- const code = await new Promise((resolve) => {
638
- const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["i", "-g", "@hanamorilabs/tab@latest"], { stdio: "inherit" });
639
- child.on("error", () => resolve(1));
640
- child.on("close", (c) => resolve(c ?? 1));
641
- });
642
- if (code !== 0) {
643
- fail("npm could not update tab.");
644
- return 1;
645
- }
646
- }
647
- else {
648
- const fetched = await downloadProxy((t) => say(dim(t)));
649
- if (!fetched.ok) {
650
- fail(fetched.message);
651
- return 1;
652
- }
653
- ok(`Fetched flocktab-proxy ${PROXY_VERSION}.`);
654
- }
655
- }
656
718
  if (!(await hasProxyEnv())) {
657
719
  const written = await collectProviderKeys();
658
720
  if (!written)
@@ -701,6 +763,8 @@ async function managed(command, rest) {
701
763
  return await manage.outside(config, flags);
702
764
  case "live":
703
765
  return await manage.live(config, flags);
766
+ case "quota":
767
+ return await manage.quota(config, flags);
704
768
  default:
705
769
  return await manage.web(config, flags);
706
770
  }
@@ -726,7 +790,7 @@ async function main(argv) {
726
790
  ok("Forgot the session and keys on this machine.");
727
791
  return 0;
728
792
  case "use":
729
- return use();
793
+ return use(rest);
730
794
  case "alias":
731
795
  return alias(rest);
732
796
  case "unalias":
@@ -750,6 +814,7 @@ async function main(argv) {
750
814
  case "spend":
751
815
  case "outside":
752
816
  case "live":
817
+ case "quota":
753
818
  case "web":
754
819
  return managed(command, rest);
755
820
  case "status":
@@ -759,7 +824,7 @@ async function main(argv) {
759
824
  case "down":
760
825
  return selfHosted("down");
761
826
  case "update":
762
- return selfHosted("update");
827
+ return update();
763
828
  default:
764
829
  return runAgent(command, rest);
765
830
  }
package/dist/clients.js CHANGED
@@ -72,11 +72,27 @@ export function openaiBase(proxyUrl, provider) {
72
72
  * overridden on purpose: a stray `OPENAI_BASE_URL` from another tool would
73
73
  * otherwise route around the tab, which is the one thing this must not do.
74
74
  */
75
+ /** `<proxy>/t/<key>/<provider>`: the subscription passthrough base for a vendor. */
76
+ export function passthroughBase(proxyUrl, key, provider) {
77
+ return `${proxyUrl}/t/${key}/${provider}`;
78
+ }
75
79
  export function envFor(input) {
76
- const spec = clientFor(input.name);
80
+ const base = clientFor(input.name);
77
81
  const known = input.name in KNOWN;
82
+ const auth = input.auth ?? "key";
78
83
  const env = { ...(input.base ?? process.env) };
84
+ // Codex: pinned to the API key on a metered tab, to the ChatGPT login on a subscription.
85
+ const spec = base.isolatedHome === "codex"
86
+ ? { ...base, args: ["-c", auth === "subscription" ? 'preferred_auth_method="chatgpt"' : 'preferred_auth_method="apikey"'] }
87
+ : base;
79
88
  const setAnthropic = () => {
89
+ if (auth === "subscription") {
90
+ // Claude Code signs in with its own Claude account; FlockTab only moves the base URL.
91
+ env.ANTHROPIC_BASE_URL = passthroughBase(input.proxyUrl, input.presentedKey, "anthropic");
92
+ delete env.ANTHROPIC_API_KEY;
93
+ delete env.ANTHROPIC_AUTH_TOKEN;
94
+ return;
95
+ }
80
96
  env.ANTHROPIC_BASE_URL = input.proxyUrl;
81
97
  env.ANTHROPIC_API_KEY = input.presentedKey;
82
98
  // Claude Code prefers an auth token when one is set; make sure a stale
@@ -84,6 +100,13 @@ export function envFor(input) {
84
100
  delete env.ANTHROPIC_AUTH_TOKEN;
85
101
  };
86
102
  const setOpenAI = () => {
103
+ if (auth === "subscription") {
104
+ // Only Codex has a subscription route (its own home points at it); a
105
+ // plain OpenAI key would bypass the plan, so none is set.
106
+ delete env.OPENAI_BASE_URL;
107
+ delete env.OPENAI_API_KEY;
108
+ return;
109
+ }
87
110
  env.OPENAI_BASE_URL = openaiBase(input.proxyUrl, spec.provider);
88
111
  env.OPENAI_API_KEY = input.presentedKey;
89
112
  };
@@ -99,5 +122,6 @@ export function envFor(input) {
99
122
  }
100
123
  // So the child, and anything it spawns, can tell it is on a tab.
101
124
  env.FLOCKTAB_PROXY_URL = input.proxyUrl;
125
+ env.FLOCKTAB_AUTH_MODE = auth;
102
126
  return { spec, env };
103
127
  }
@@ -10,8 +10,10 @@
10
10
  *
11
11
  * The person's own `~/.codex` is never read or written.
12
12
  */
13
- import { chmod, mkdir, writeFile } from "node:fs/promises";
13
+ import { chmod, copyFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
14
+ import { homedir } from "node:os";
14
15
  import path from "node:path";
16
+ import { passthroughBase } from "./clients.js";
15
17
  export function codexAuthJson(presentedKey) {
16
18
  return `${JSON.stringify({ auth_mode: "apikey", OPENAI_API_KEY: presentedKey })}\n`;
17
19
  }
@@ -19,6 +21,20 @@ export function codexAuthJson(presentedKey) {
19
21
  export function codexProviderId(mode) {
20
22
  return mode === "self-hosted" ? "FlockTab - Self-hosted" : "FlockTab - Hosted";
21
23
  }
24
+ /**
25
+ * On a ChatGPT subscription Codex talks to `chatgpt_base_url` with its own
26
+ * login, so that is what moves: to the passthrough, where the tab keeps the
27
+ * record. No provider block; the default OpenAI provider is the ChatGPT one.
28
+ */
29
+ export function codexSubscriptionToml(proxyUrl, presentedKey, model) {
30
+ const lines = [
31
+ `preferred_auth_method = "chatgpt"`,
32
+ `chatgpt_base_url = ${JSON.stringify(`${passthroughBase(proxyUrl, presentedKey, "openai")}/backend-api/`)}`,
33
+ ...(model ? [`model = ${JSON.stringify(model)}`] : []),
34
+ ``,
35
+ ];
36
+ return lines.join("\n");
37
+ }
22
38
  export function codexConfigToml(proxyUrl, model, mode = "hosted") {
23
39
  const id = codexProviderId(mode);
24
40
  const lines = [
@@ -41,9 +57,46 @@ export async function prepareCodexHome(input) {
41
57
  await chmod(home, 0o700);
42
58
  const auth = path.join(home, "auth.json");
43
59
  const config = path.join(home, "config.toml");
44
- await writeFile(auth, codexAuthJson(input.presentedKey), { mode: 0o600 });
45
- await writeFile(config, codexConfigToml(input.proxyUrl, input.model, input.mode ?? "hosted"), { mode: 0o600 });
46
- await chmod(auth, 0o600);
60
+ if (input.auth === "subscription") {
61
+ await adoptChatGptLogin(auth, input.userCodexHome ?? path.join(homedir(), ".codex"));
62
+ await writeFile(config, codexSubscriptionToml(input.proxyUrl, input.presentedKey, input.model), { mode: 0o600 });
63
+ }
64
+ else {
65
+ await writeFile(auth, codexAuthJson(input.presentedKey), { mode: 0o600 });
66
+ await chmod(auth, 0o600);
67
+ await writeFile(config, codexConfigToml(input.proxyUrl, input.model, input.mode ?? "hosted"), { mode: 0o600 });
68
+ }
47
69
  await chmod(config, 0o600);
48
70
  return home;
49
71
  }
72
+ /**
73
+ * The ChatGPT login for the tab's Codex home. A login already there (from
74
+ * `codex login` run through `tab codex`) stays. Otherwise the person's own
75
+ * `~/.codex/auth.json` is copied, owner-only: same machine, same person,
76
+ * and `~/.codex` itself is still never written. A leftover API-key auth
77
+ * file from metered mode is replaced; with nothing to copy, Codex asks the
78
+ * person to sign in and keeps that login in the tab home.
79
+ */
80
+ async function adoptChatGptLogin(auth, userCodexHome) {
81
+ const isApiKey = async (file) => {
82
+ try {
83
+ const parsed = JSON.parse(await readFile(file, "utf8"));
84
+ return parsed.auth_mode === "apikey" || (typeof parsed.OPENAI_API_KEY === "string" && parsed.OPENAI_API_KEY.startsWith("ft_"));
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ };
90
+ const existing = await isApiKey(auth);
91
+ if (existing === false)
92
+ return "kept";
93
+ if (existing === true)
94
+ await unlink(auth).catch(() => undefined);
95
+ const theirs = path.join(userCodexHome, "auth.json");
96
+ if ((await isApiKey(theirs)) === false) {
97
+ await copyFile(theirs, auth);
98
+ await chmod(auth, 0o600);
99
+ return "copied";
100
+ }
101
+ return "none";
102
+ }
package/dist/manage.js CHANGED
@@ -59,6 +59,7 @@ export function parseFlags(argv) {
59
59
  }
60
60
  return flags;
61
61
  }
62
+ const mode = (t) => (t.authMode === "subscription" ? `${yellow("plan")}${t.subscriptionPlan ? dim(` ${t.subscriptionPlan}`) : ""}` : "meter");
62
63
  async function tabsOf(call) {
63
64
  return (await call("GET", "/api/cli/tabs")).tabs;
64
65
  }
@@ -92,7 +93,7 @@ export async function list(config, flags) {
92
93
  const tabs = await tabsOf(api(config));
93
94
  emit(flags.json, { tabs }, () => tabs.length === 0
94
95
  ? dim("No Agents yet. Run tab claude or tab codex in a project folder.")
95
- : table(["agent", "state", "spent", "cap", "used", "window", "project"], tabs.map((t) => [t.name === t.slug ? t.slug : `${t.name} ${dim(t.slug)}`, state(t.state), money(t.spentCents), money(t.capCents), pct(t.spentCents, t.capCents), t.window, t.projectName ?? dim("-")]), { right: [2, 3, 4] }));
96
+ : table(["agent", "state", "mode", "spent", "cap", "used", "window", "project"], tabs.map((t) => [t.name === t.slug ? t.slug : `${t.name} ${dim(t.slug)}`, state(t.state), mode(t), money(t.spentCents), money(t.capCents), pct(t.spentCents, t.capCents), t.window, t.projectName ?? dim("-")]), { right: [3, 4, 5] }));
96
97
  return 0;
97
98
  }
98
99
  export async function setState(config, flags, next) {
@@ -139,6 +140,49 @@ export async function archive(config, flags, ask) {
139
140
  emit(flags.json, { tab, archived: true }, () => `${bold(tab.name)} archived.`);
140
141
  return 0;
141
142
  }
143
+ /**
144
+ * `tab use --subscription [--plan "Claude Max 5x"] [--price 100]` and
145
+ * `tab use --api-key`: which login the Agent runs on. On a subscription the
146
+ * vendor's plan pays; the tab still gates every call and records it at list
147
+ * price as a shadow row, and the seat's monthly price sits beside the meter
148
+ * in the project rollup.
149
+ */
150
+ export async function setAuthMode(config, flags, given, authMode) {
151
+ const call = api(config);
152
+ const slug = await resolveSlug(call, given);
153
+ const body = { authMode };
154
+ if (flags.opts.plan !== undefined)
155
+ body.subscriptionPlan = flags.opts.plan;
156
+ if (flags.opts.price !== undefined)
157
+ body.subscriptionMonthlyDollars = flags.opts.price.replace(/^\$/, "");
158
+ const { tab } = await call("PATCH", `/api/cli/tabs/${encodeURIComponent(slug)}`, body);
159
+ emit(flags.json, { tab }, () => authMode === "subscription"
160
+ ? `${bold(tab.name)} runs on a subscription${tab.subscriptionPlan ? ` (${tab.subscriptionPlan}${tab.subscriptionMonthlyCents ? `, ${money(tab.subscriptionMonthlyCents)}/mo` : ""})` : ""}.\n` +
161
+ `${dim("tab claude uses your own Claude login, tab codex your ChatGPT login. The kill switch and policies still apply; calls land in the ledger at list price, never on the tab's spend.")}`
162
+ : `${bold(tab.name)} is back on the metered tab: the flock's provider key, charged to the cap.`);
163
+ return 0;
164
+ }
165
+ /** `tab quota [agent]`: what the vendor said the plan has left, on the last call. */
166
+ export async function quota(config, flags) {
167
+ const call = api(config);
168
+ const slug = await resolveSlug(call, flags.args[0]);
169
+ const { tab, quota: windows } = await call("GET", `/api/cli/tabs/${encodeURIComponent(slug)}/quota`);
170
+ emit(flags.json, { tab, quota: windows }, () => {
171
+ if (tab.authMode !== "subscription")
172
+ return `${bold(tab.name)} is on the metered tab; ${dim("tab use --subscription")} puts it on a plan.`;
173
+ if (windows.length === 0)
174
+ return `${bold(tab.name)}: no quota reported yet. It shows after the first call through tab claude or tab codex.`;
175
+ const left = (w) => {
176
+ const pctLeft = Math.max(0, 100 - w.usedPct);
177
+ const paint = pctLeft <= 10 ? red : pctLeft <= 30 ? yellow : green;
178
+ return paint(`${pctLeft}% left`);
179
+ };
180
+ const when = (iso) => (iso ? new Date(iso).toLocaleString() : dim("-"));
181
+ return (`${bold(tab.name)}${tab.subscriptionPlan ? dim(` ${tab.subscriptionPlan}`) : ""}\n` +
182
+ table(["provider", "window", "used", "left", "resets", "seen"], windows.map((w) => [w.provider, w.window, `${w.usedPct}%`, left(w), when(w.resetsAt), when(w.observedAt)]), { right: [2] }));
183
+ });
184
+ return 0;
185
+ }
142
186
  function listOpt(raw) {
143
187
  if (raw === undefined)
144
188
  return undefined;
package/dist/proxy-bin.js CHANGED
@@ -13,7 +13,7 @@ import { createRequire } from "node:module";
13
13
  import path from "node:path";
14
14
  import { configDir } from "./config.js";
15
15
  /** The proxy release `tab up` fetches. Bump with each proxy tag. */
16
- export const PROXY_VERSION = "0.1.1";
16
+ export const PROXY_VERSION = "0.1.3";
17
17
  export const RELEASES = "https://github.com/joseairosa/flocktab/releases/download";
18
18
  export function targetFor(platform = process.platform, arch = process.arch) {
19
19
  if (platform === "darwin")
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Written by scripts/write-version.mjs from package.json at build; `tab version` prints it. */
2
- export const TAB_VERSION = "0.1.4";
2
+ export const TAB_VERSION = "0.1.6";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Run any AI agent on a FlockTab tab: tab claude, tab codex, tab <command>.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,11 +22,11 @@
22
22
  "prepublishOnly": "pnpm build"
23
23
  },
24
24
  "optionalDependencies": {
25
- "@hanamorilabs/flocktab-proxy-darwin-arm64": "0.1.1",
26
- "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.1",
27
- "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.1",
28
- "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.1",
29
- "@hanamorilabs/flocktab-proxy-win-x64": "0.1.1"
25
+ "@hanamorilabs/flocktab-proxy-darwin-arm64": "0.1.3",
26
+ "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.3",
27
+ "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.3",
28
+ "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.3",
29
+ "@hanamorilabs/flocktab-proxy-win-x64": "0.1.3"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.18.6",