@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 +35 -13
- package/dist/cli.js +99 -34
- package/dist/clients.js +25 -1
- package/dist/codex-home.js +57 -4
- package/dist/manage.js +45 -1
- package/dist/proxy-bin.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -6
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`
|
|
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
|
|
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
|
|
81
|
-
tab cap
|
|
82
|
-
tab rename
|
|
83
|
-
tab policy
|
|
84
|
-
tab project
|
|
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
|
|
87
|
-
tab archive
|
|
88
|
-
tab ledger [
|
|
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 [
|
|
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
|
|
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", "
|
|
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
|
-
|
|
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
|
-
/**
|
|
591
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/codex-home.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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: [
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
26
|
-
"@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.
|
|
27
|
-
"@hanamorilabs/flocktab-proxy-linux-x64": "0.1.
|
|
28
|
-
"@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.
|
|
29
|
-
"@hanamorilabs/flocktab-proxy-win-x64": "0.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",
|