@hanamorilabs/tab 0.1.6 → 0.1.8

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,24 +38,34 @@ 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)
41
+ ## On a subscription (Claude Max, ChatGPT, SuperGrok, Kimi)
42
+
43
+ An Agent is **api** (the flock's provider key, metered) or **subscription**
44
+ (the harnesses' own logins), chosen when it is made: the first `tab claude`
45
+ in a folder asks, or
42
46
 
43
47
  ```
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
48
+ tab agent create "me@joseairosa.com" --subscription
49
+ tab use # this folder runs as it
50
+ tab claude # your own Claude login through the tab
51
+ tab codex # your own ChatGPT login (codex login once, inside tab codex)
52
+ tab grok # your SuperGrok login
53
+ tab kimi # your Kimi Code login
54
+ tab accounts # accounts seen: plan, seat price, 30 days at list, what is left
55
+ tab agent kind me@joseairosa.com api # back to the meter
49
56
  ```
50
57
 
51
58
  The plan pays for the tokens; the tab still gates every call (kill switch,
52
59
  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.
60
+ `SUBSCRIPTION` row that never touches the cap. The account is not declared:
61
+ the vendor names it on every reply (Anthropic's organization id, ChatGPT's
62
+ account id, the login token for xAI and Kimi), FlockTab files the call under
63
+ it, learns the email and plan tier, and prices the seat from the tier. Four
64
+ Max accounts are four accounts; `claude /login` to another one mid-session
65
+ and the next call lands on it. Claude Code gets
66
+ `ANTHROPIC_BASE_URL=<proxy>/t/<key>/anthropic` and no API key; Codex a home
67
+ whose `chatgpt_base_url` is the passthrough; Grok Build `GROK_XAI_API_BASE_URL`;
68
+ Kimi Code `KIMI_BASE_URL`. `docs/subscriptions.md` has the wire.
59
69
 
60
70
  ## Self-hosted
61
71
 
@@ -78,8 +88,9 @@ Codex shows `FlockTab - Self-hosted` as its provider.
78
88
  tab login approve this machine in the console, once
79
89
  tab <agent> [args] claude, codex, grok, kimi, gemini, or any command, on the tab
80
90
  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
91
+ tab agent create <name> --subscription|--api a new Agent of that kind (--cap 50)
92
+ tab agent kind <agent> api|subscription change it
93
+ tab accounts accounts your subscription Agents were seen on: plan, price, quota left
83
94
  tab alias setup <name>... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
84
95
  tab alias remove <name>... undo that; `tab alias list` shows them
85
96
  tab status flock, folder Agent, proxy health, provider key state
@@ -97,7 +108,7 @@ session from `tab login`. An Agent is named by slug or name; left out, it is
97
108
  this folder's Agent. Every read takes `--json`.
98
109
 
99
110
  ```
100
- tab list every Agent: state, mode (meter | plan), spent of cap, window, project
111
+ tab list every Agent: kind, state, spent of cap, window, project
101
112
  tab close my-project tab open my-project the kill switch (402 tab_closed on the next call)
102
113
  tab cap my-project 25 --window week cap in dollars, and the window
103
114
  tab rename my-project "My project (nightly)" the slug stays
@@ -142,6 +153,6 @@ proxy http://127.0.0.1:8787 (self-hosted) up
142
153
  flock Hanamori Labs
143
154
  folder flocktab-codex
144
155
  plan team
145
- mode metered (the flock's provider key, charged to the tab)
156
+ kind api (the flock's provider key, metered on the tab)
146
157
  byok provider keys on this box
147
158
  ```
package/dist/cli.js CHANGED
@@ -7,8 +7,8 @@
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
+ * tab agent create <name> --subscription an Agent whose harnesses use your own logins
11
+ * tab accounts the accounts your subscription Agents were seen on, with quota left
12
12
  * tab alias setup codex make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
13
13
  * tab alias remove codex undo that; `tab alias list` shows them
14
14
  * tab status which flock, which Agent here, is the proxy up
@@ -27,7 +27,8 @@ import { hostname } from "node:os";
27
27
  import { createInterface } from "node:readline/promises";
28
28
  import { stdin, stdout } from "node:process";
29
29
  import { bold, box, cyan, dim, green, heading, line, rows, underline, yellow } from "./ui.js";
30
- import { envFor, knownClients } from "./clients.js";
30
+ import { envFor, knownClients, subscriptionVendorFor } from "./clients.js";
31
+ import { describeLogin, localLogin } from "./logins.js";
31
32
  import { prepareCodexHome } from "./codex-home.js";
32
33
  import { ConsoleApiError, createAgent, issueAgentKey, listAgents } from "./console-api.js";
33
34
  import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } from "./device-login.js";
@@ -55,9 +56,9 @@ function usage() {
55
56
  cmd(others.join(" | tab "), "the same, for those agents"),
56
57
  cmd("<command> [args]", "any other agent, both APIs pointed at the tab"),
57
58
  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"),
59
+ cmd("agent create <name>", "a new Agent: --subscription (your own logins) or --api (the flock's key, metered)"),
60
+ cmd("agent kind <agent> api|subscription", "change it"),
61
+ cmd("accounts", "accounts your subscription Agents were seen on: plan, price, quota left"),
61
62
  cmd("alias setup <name>...", "make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)"),
62
63
  cmd("alias remove <name>...", "undo that; `tab alias list` shows them"),
63
64
  cmd("status", "which flock, which Agent here, is the proxy up"),
@@ -397,14 +398,19 @@ async function resolveAgent(config, opts = {}) {
397
398
  say(rows([
398
399
  ...options.map((a, i) => [
399
400
  `${bold(String(i + 1))} ${a.name}`,
400
- `${a.state === "open" ? green("open") : yellow(a.state)} ${dim(`$${(Number(a.capCents) / 100).toFixed(2)} cap`)}${agents[a.slug] ? dim(" keyed here") : a.hasKey ? dim(" keyed elsewhere") : ""}`,
401
+ `${a.state === "open" ? green("open") : yellow(a.state)} ${a.kind === "subscription" ? yellow("subscription") : dim("api")} ${dim(`$${(Number(a.capCents) / 100).toFixed(2)} cap`)}${agents[a.slug] ? dim(" keyed here") : a.hasKey ? dim(" keyed elsewhere") : ""}`,
401
402
  ]),
402
403
  [`${bold("n")} New Agent`, dim(`named ${suggested}, $50.00 cap`)],
403
404
  ]));
404
405
  const answer = await ask(`Choose 1-${options.length} or ${bold("n")} ${dim("[n]")}: `);
405
406
  if (answer === "" || answer.toLowerCase() === "n") {
406
407
  const name = (await ask(`${cyan("?")} Agent name ${dim(`[${suggested}]`)}: `)) || suggested;
407
- created = { name };
408
+ say(rows([
409
+ [`${bold("1")} API key`, dim("the flock's provider key; every call metered against the cap")],
410
+ [`${bold("2")} Subscription`, dim("your own Claude Max / ChatGPT / SuperGrok / Kimi logins; gated and recorded, never charged")],
411
+ ]));
412
+ const which = await ask(`Which kind? ${bold("1")} or ${bold("2")} ${dim("[1]")}: `);
413
+ created = { name, kind: which.trim() === "2" ? "subscription" : "api" };
408
414
  }
409
415
  else {
410
416
  const index = Number.parseInt(answer, 10);
@@ -418,8 +424,8 @@ async function resolveAgent(config, opts = {}) {
418
424
  let issued;
419
425
  try {
420
426
  if (created) {
421
- issued = await createAgent(config.consoleUrl, config.token, { name: created.name });
422
- ok(`Created Agent ${bold(issued.agent.name)} with a $50.00 cap. Change it in the console.`);
427
+ issued = await createAgent(config.consoleUrl, config.token, { name: created.name, kind: created.kind });
428
+ ok(`Created ${created.kind === "subscription" ? "subscription" : "API-key"} Agent ${bold(issued.agent.name)} with a $50.00 cap. Change it in the console.`);
423
429
  }
424
430
  else if (chosen) {
425
431
  if (chosen.hasKey && !agents[chosen.slug]) {
@@ -485,7 +491,7 @@ async function status() {
485
491
  }
486
492
  else {
487
493
  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)")}`]);
494
+ pairs.push(["kind", me.kind === "subscription" ? `${bold("subscription")} ${dim("(your own logins; gated and recorded, never charged)")}` : `api ${dim("(the flock's provider key, metered on the tab)")}`]);
489
495
  const byok = !me.byok
490
496
  ? dim("no provider key on this flock")
491
497
  : me.unlock === "none"
@@ -512,20 +518,26 @@ async function runAgent(name, args) {
512
518
  const agent = await resolveAgent(config);
513
519
  if (!agent)
514
520
  return 1;
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.
521
+ // The Agent's kind decides: a subscription Agent's harness brings its own
522
+ // login (Claude Code its Claude one, Codex ChatGPT, Grok SuperGrok, Kimi
523
+ // Code its Kimi plan), read fresh on every launch so a change in the
524
+ // console applies to the next run. An api Agent, or a harness with no
525
+ // consumer plan, is metered with the flock's key.
526
+ const vendor = subscriptionVendorFor(name);
517
527
  const me = await whoami(config.proxyUrl, { key: agent.key, unlock: config.unlock });
518
- const auth = !("error" in me) && me.authMode === "subscription" ? "subscription" : "key";
528
+ const kind = !("error" in me) && me.kind === "subscription" ? "subscription" : "api";
529
+ const auth = kind === "subscription" && vendor ? "subscription" : "key";
519
530
  // On a subscription there is no provider key to unlock; the bare key rides in the URL.
520
531
  const key = auth === "subscription" ? agent.key : presentedKey({ key: agent.key, unlock: config.unlock });
521
532
  reportFolder(config.proxyUrl, key);
522
533
  const { spec, env } = envFor({ name, proxyUrl: config.proxyUrl, presentedKey: key, auth });
523
534
  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.`));
535
+ const login = describeLogin(await localLogin(vendor));
536
+ const vendorName = vendor === "openai" ? "ChatGPT" : vendor === "xai" ? "SuperGrok" : vendor === "kimi" ? "Kimi" : "Claude";
537
+ say(dim(`${spec.label} on your own ${vendorName} login${login ? `: ${login}` : ""}. FlockTab keeps the record and the kill switch; the account is read off each reply.`));
538
+ }
539
+ else if (kind === "subscription") {
540
+ say(dim(`${spec.label} has no consumer plan FlockTab can pass through, so it runs metered on the flock's key.`));
529
541
  }
530
542
  // An alias shim named like the agent must not be what we spawn.
531
543
  env.PATH = pathWithoutShims();
@@ -607,39 +619,15 @@ function pathHint() {
607
619
  say(` ${bold(pathLine())}`);
608
620
  return 0;
609
621
  }
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) {
622
+ /** `tab use`: pick or change the Agent for this folder. */
623
+ async function use() {
616
624
  const config = await ensureLogin();
617
625
  if (!config)
618
626
  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
- }
626
627
  if (!(await ensureProxy(config)))
627
628
  return 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
- }
629
+ const agent = await resolveAgent(config, { pick: true });
630
+ return agent ? 0 : 1;
643
631
  }
644
632
  /**
645
633
  * First `tab up` on a box: the provider keys the proxy will spend, typed
@@ -763,8 +751,11 @@ async function managed(command, rest) {
763
751
  return await manage.outside(config, flags);
764
752
  case "live":
765
753
  return await manage.live(config, flags);
754
+ case "accounts":
766
755
  case "quota":
767
- return await manage.quota(config, flags);
756
+ return await manage.accounts(config, flags);
757
+ case "agent":
758
+ return await manage.agent(config, flags);
768
759
  default:
769
760
  return await manage.web(config, flags);
770
761
  }
@@ -790,7 +781,7 @@ async function main(argv) {
790
781
  ok("Forgot the session and keys on this machine.");
791
782
  return 0;
792
783
  case "use":
793
- return use(rest);
784
+ return use();
794
785
  case "alias":
795
786
  return alias(rest);
796
787
  case "unalias":
@@ -815,6 +806,8 @@ async function main(argv) {
815
806
  case "outside":
816
807
  case "live":
817
808
  case "quota":
809
+ case "accounts":
810
+ case "agent":
818
811
  case "web":
819
812
  return managed(command, rest);
820
813
  case "status":
package/dist/clients.js CHANGED
@@ -76,6 +76,19 @@ export function openaiBase(proxyUrl, provider) {
76
76
  export function passthroughBase(proxyUrl, key, provider) {
77
77
  return `${proxyUrl}/t/${key}/${provider}`;
78
78
  }
79
+ /** Which vendor's login a harness carries: Claude Code its Claude one, Codex ChatGPT, Grok SuperGrok, Kimi Code a Kimi plan. */
80
+ export function subscriptionVendorFor(name) {
81
+ const spec = clientFor(name);
82
+ if (spec.shape === "anthropic")
83
+ return "anthropic";
84
+ if (spec.isolatedHome === "codex")
85
+ return "openai";
86
+ if (spec.provider === "xai")
87
+ return "xai";
88
+ if (spec.provider === "moonshot")
89
+ return "kimi";
90
+ return undefined;
91
+ }
79
92
  export function envFor(input) {
80
93
  const base = clientFor(input.name);
81
94
  const known = input.name in KNOWN;
@@ -101,10 +114,19 @@ export function envFor(input) {
101
114
  };
102
115
  const setOpenAI = () => {
103
116
  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.
117
+ // Each harness carries its own login; a plain OpenAI key would bypass
118
+ // the plan, so none is set. Codex's own home points at the passthrough;
119
+ // Grok Build and Kimi Code read a base URL of their own.
106
120
  delete env.OPENAI_BASE_URL;
107
121
  delete env.OPENAI_API_KEY;
122
+ if (spec.provider === "xai") {
123
+ env.GROK_XAI_API_BASE_URL = `${passthroughBase(input.proxyUrl, input.presentedKey, "xai")}/v1`;
124
+ delete env.XAI_API_KEY;
125
+ }
126
+ if (spec.provider === "moonshot") {
127
+ env.KIMI_BASE_URL = `${passthroughBase(input.proxyUrl, input.presentedKey, "kimi")}/coding/v1`;
128
+ delete env.KIMI_API_KEY;
129
+ }
108
130
  return;
109
131
  }
110
132
  env.OPENAI_BASE_URL = openaiBase(input.proxyUrl, spec.provider);
package/dist/logins.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Which account each harness is signed in with on this machine, read from
3
+ * the files the harnesses keep themselves. Used for one thing: telling the
4
+ * person, before a subscription Agent launches, which login is about to be
5
+ * used. Nothing here is sent anywhere; tokens are never read, only the
6
+ * account fields beside them.
7
+ */
8
+ import { readFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import path from "node:path";
11
+ async function jsonFile(file) {
12
+ try {
13
+ const parsed = JSON.parse(await readFile(file, "utf8"));
14
+ return parsed && typeof parsed === "object" ? parsed : undefined;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ function str(value) {
21
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
22
+ }
23
+ /** The claims of a JWT, unverified; only the account fields are looked at. */
24
+ function jwtClaims(token) {
25
+ if (typeof token !== "string")
26
+ return undefined;
27
+ const parts = token.split(".");
28
+ if (parts.length !== 3)
29
+ return undefined;
30
+ try {
31
+ const parsed = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
32
+ return parsed && typeof parsed === "object" ? parsed : undefined;
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ /**
39
+ * Claude Code keeps the signed-in account (not the token) in `~/.claude.json`
40
+ * under `oauthAccount`; Codex keeps its ChatGPT login in `auth.json` under
41
+ * the home it runs with. Grok Build and Kimi Code are asked nothing: their
42
+ * accounts are read off the wire by the proxy.
43
+ */
44
+ export async function localLogin(vendor, opts = {}) {
45
+ const home = opts.home ?? homedir();
46
+ if (vendor === "anthropic") {
47
+ const account = (await jsonFile(path.join(home, ".claude.json")))?.oauthAccount;
48
+ if (!account)
49
+ return undefined;
50
+ const email = str(account.emailAddress);
51
+ const plan = str(account.organizationRateLimitTier) ?? str(account.organizationType);
52
+ const accountId = str(account.organizationUuid);
53
+ return { ...(email ? { email } : {}), ...(plan ? { plan } : {}), ...(accountId ? { accountId } : {}) };
54
+ }
55
+ if (vendor === "openai") {
56
+ const auth = await jsonFile(path.join(opts.codexHome ?? path.join(home, ".codex"), "auth.json"));
57
+ const tokens = auth?.tokens;
58
+ if (!tokens)
59
+ return undefined;
60
+ const claims = jwtClaims(tokens.id_token);
61
+ const authClaims = claims?.["https://api.openai.com/auth"];
62
+ const email = str(claims?.email);
63
+ const plan = str(authClaims?.chatgpt_plan_type);
64
+ const accountId = str(tokens.account_id) ?? str(authClaims?.chatgpt_account_id);
65
+ return { ...(email ? { email } : {}), ...(plan ? { plan } : {}), ...(accountId ? { accountId } : {}) };
66
+ }
67
+ return undefined;
68
+ }
69
+ /** "me@x.com (Claude Max 20x)" or nothing worth saying. */
70
+ export function describeLogin(login) {
71
+ if (!login)
72
+ return undefined;
73
+ const plan = login.plan?.replace(/^default_/, "").replaceAll("_", " ");
74
+ if (login.email && plan)
75
+ return `${login.email} (${plan})`;
76
+ return login.email ?? plan ?? (login.accountId ? `account ${login.accountId}` : undefined);
77
+ }
package/dist/manage.js CHANGED
@@ -59,7 +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
+ const kindOf = (t) => (t.kind === "subscription" ? yellow("subscription") : "api");
63
63
  async function tabsOf(call) {
64
64
  return (await call("GET", "/api/cli/tabs")).tabs;
65
65
  }
@@ -93,7 +93,7 @@ export async function list(config, flags) {
93
93
  const tabs = await tabsOf(api(config));
94
94
  emit(flags.json, { tabs }, () => tabs.length === 0
95
95
  ? dim("No Agents yet. Run tab claude or tab codex in a project folder.")
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
+ : table(["agent", "kind", "state", "spent", "cap", "used", "window", "project"], tabs.map((t) => [t.name === t.slug ? t.slug : `${t.name} ${dim(t.slug)}`, kindOf(t), state(t.state), money(t.spentCents), money(t.capCents), pct(t.spentCents, t.capCents), t.window, t.projectName ?? dim("-")]), { right: [3, 4, 5] }));
97
97
  return 0;
98
98
  }
99
99
  export async function setState(config, flags, next) {
@@ -141,45 +141,70 @@ export async function archive(config, flags, ask) {
141
141
  return 0;
142
142
  }
143
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.
144
+ * `tab agent create <name> [--subscription|--api] [--cap 50]` makes an Agent
145
+ * of one of the two kinds; `tab agent kind <agent> api|subscription` changes
146
+ * it. A subscription Agent's harnesses (Claude Code, Codex, Grok, Kimi Code)
147
+ * bring their own logins: every call is still gated by the tab and recorded
148
+ * at list price, filed under the account the vendor names, never charged.
149
149
  */
150
- export async function setAuthMode(config, flags, given, authMode) {
150
+ export async function agent(config, flags) {
151
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;
152
+ const [verb, ...rest] = flags.args;
153
+ if (verb === "create" || verb === "new") {
154
+ const name = rest.join(" ").trim();
155
+ if (!name)
156
+ throw new ManageError('tab agent create <name> [--subscription|--api] [--cap 50]');
157
+ const kind = flags.opts.subscription !== undefined ? "subscription" : "api";
158
+ const body = { name, kind };
159
+ if (flags.opts.cap)
160
+ body.capDollars = flags.opts.cap.replace(/^\$/, "");
161
+ const made = await call("POST", "/api/cli/agents", body);
162
+ // The key comes back once; it is this machine's, so keep it like tab use would.
163
+ const agents = { ...(config.agents ?? {}), [made.agent.slug]: { key: made.key, agentId: made.agent.id, agentName: made.agent.name } };
164
+ await saveConfig({ ...config, agents });
165
+ emit(flags.json, { agent: made.agent, kind }, () => `Created ${kind === "subscription" ? bold("subscription") : bold("API-key")} Agent ${bold(made.agent.name)} ${dim(`(${made.agent.slug})`)}; its key is saved on this machine.\n` +
166
+ dim(kind === "subscription"
167
+ ? "Its harnesses use your own logins: tab claude your Claude one, tab codex ChatGPT, tab grok SuperGrok, tab kimi Kimi. Gated and recorded, never charged. tab use picks it for a folder."
168
+ : "Its calls use the flock's provider key and are metered against the cap. tab use picks it for a folder."));
169
+ return 0;
170
+ }
171
+ if (verb === "kind" || verb === "type") {
172
+ const [given, wanted] = rest;
173
+ if (!given || !wanted)
174
+ throw new ManageError("tab agent kind <agent> api|subscription");
175
+ const slug = await resolveSlug(call, given);
176
+ const { tab } = await call("PATCH", `/api/cli/tabs/${encodeURIComponent(slug)}`, { kind: wanted });
177
+ emit(flags.json, { tab }, () => `${bold(tab.name)} is now ${tab.kind === "subscription" ? bold("a subscription Agent") + dim(": its harnesses use your own logins from the next run") : bold("an API-key Agent") + dim(": metered with the flock's key from the next run")}.`);
178
+ return 0;
179
+ }
180
+ throw new ManageError("tab agent create <name> [--subscription|--api] | tab agent kind <agent> api|subscription");
164
181
  }
165
- /** `tab quota [agent]`: what the vendor said the plan has left, on the last call. */
166
- export async function quota(config, flags) {
182
+ /**
183
+ * `tab accounts` (also `tab quota`): every account the flock's subscription
184
+ * Agents were seen on, with the plan the vendor reported, its seat price,
185
+ * what the last 30 days would have cost at list, and what the plan has left.
186
+ */
187
+ export async function accounts(config, flags) {
167
188
  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.`;
189
+ const { accounts: list } = await call("GET", "/api/cli/accounts");
190
+ emit(flags.json, { accounts: list }, () => {
191
+ if (list.length === 0)
192
+ return dim("No accounts seen yet. Run tab claude or tab codex on a subscription Agent; the account shows after the first call.");
175
193
  const left = (w) => {
176
194
  const pctLeft = Math.max(0, 100 - w.usedPct);
177
195
  const paint = pctLeft <= 10 ? red : pctLeft <= 30 ? yellow : green;
178
- return paint(`${pctLeft}% left`);
196
+ return paint(`${pctLeft}%`);
179
197
  };
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] }));
198
+ return table(["vendor", "account", "plan", "/month", "30d at list", "calls", "left", "agents"], list.map((a) => [
199
+ a.vendor,
200
+ a.label ?? a.email ?? dim(a.id),
201
+ a.planLabel ?? dim("unknown"),
202
+ a.monthlyCents ? money(a.monthlyCents) : dim("-"),
203
+ money(a.listCents),
204
+ String(a.calls),
205
+ a.quota.length === 0 ? dim("-") : a.quota.map((q) => `${q.window} ${left(q)}`).join(" "),
206
+ a.agents.join(", "),
207
+ ]), { right: [3, 4, 5] }) + `\n${dim("Set a price or label in the console, Control, Accounts.")}`;
183
208
  });
184
209
  return 0;
185
210
  }
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.3";
16
+ export const PROXY_VERSION = "0.1.5";
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.6";
2
+ export const TAB_VERSION = "0.1.8";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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.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"
25
+ "@hanamorilabs/flocktab-proxy-darwin-arm64": "0.1.5",
26
+ "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.5",
27
+ "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.5",
28
+ "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.5",
29
+ "@hanamorilabs/flocktab-proxy-win-x64": "0.1.5"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.18.6",