@hanamorilabs/tab 0.1.11 → 0.1.13
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 +19 -1
- package/dist/alias.js +4 -2
- package/dist/cli.js +137 -101
- package/dist/clients.js +5 -1
- package/dist/codex-home.js +46 -2
- package/dist/console-api.js +3 -3
- package/dist/folder.js +3 -2
- package/dist/help.js +105 -0
- package/dist/logins.js +27 -2
- package/dist/manage.js +71 -48
- package/dist/network.js +78 -0
- package/dist/pool-run.js +13 -7
- package/dist/pool-view.js +87 -0
- package/dist/pool.js +159 -22
- package/dist/project.js +10 -2
- package/dist/proxy-bin.js +1 -1
- package/dist/proxy-identity.js +32 -0
- package/dist/tab-docs.js +429 -0
- package/dist/version.js +1 -1
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -86,6 +86,8 @@ Codex shows `FlockTab - Self-hosted` as its provider.
|
|
|
86
86
|
|
|
87
87
|
## Commands
|
|
88
88
|
|
|
89
|
+
`tab help` lists every command by group. `tab help <command>` (or `tab <command> --help`) explains one in full: what it does, its options, examples. `tab help glossary` says what is what, `tab help errors` lists every refusal and what to do. The same reference, with how the system works, is in the console under Docs (`console.flocktab.com/docs`).
|
|
90
|
+
|
|
89
91
|
```
|
|
90
92
|
tab login approve this machine in the console, once
|
|
91
93
|
tab <agent> [args] claude, codex, grok, kimi, gemini, or any command, on the tab
|
|
@@ -93,7 +95,7 @@ tab use pick or change the Agent this folder runs as (.flocktab)
|
|
|
93
95
|
tab agent create <name> --subscription|--api a new Agent of that kind (--cap 50)
|
|
94
96
|
tab agent kind <agent> api|subscription change it
|
|
95
97
|
tab accounts accounts your subscription Agents were seen on: plan, price, quota used
|
|
96
|
-
tab pool several logins of one vendor: add claude|codex <name>, at <percent>, swap auto|launch
|
|
98
|
+
tab pool several logins of one vendor: add claude|codex|grok|kimi <name>, at [vendor] <percent>, swap auto|launch
|
|
97
99
|
tab alias setup <name>... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
|
|
98
100
|
tab alias remove <name>... undo that; `tab alias list` shows them
|
|
99
101
|
tab status flock, folder Agent, proxy health, provider key state
|
|
@@ -161,3 +163,19 @@ plan team
|
|
|
161
163
|
kind api (the flock's provider key, metered on the tab)
|
|
162
164
|
byok provider keys on this box
|
|
163
165
|
```
|
|
166
|
+
|
|
167
|
+
## Trying a change before it is released
|
|
168
|
+
|
|
169
|
+
`tabdev` is this checkout's CLI run from source, beside the `tab` npm
|
|
170
|
+
installed. It keeps everything of its own: `~/.flocktabdev` for the login,
|
|
171
|
+
keys, pool and proxy files, and `.flocktabdev` as the per-folder file. Nothing
|
|
172
|
+
it does touches the production `tab`. Log it in once with `tabdev login`, and
|
|
173
|
+
give its folders Agents of their own: choosing an Agent that production
|
|
174
|
+
already holds a key for would replace that key (it asks first).
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
node apps/tab/scripts/link-dev.mjs # writes ~/.local/bin/tabdev
|
|
178
|
+
tabdev version # "0.1.x + dev (this checkout's source)"
|
|
179
|
+
tabdev claude # exactly what the next release would do
|
|
180
|
+
node apps/tab/scripts/link-dev.mjs --remove
|
|
181
|
+
```
|
package/dist/alias.js
CHANGED
|
@@ -65,10 +65,12 @@ export function shimDirOnPath(env = process.env, platform = process.platform) {
|
|
|
65
65
|
/** PATH for the child agent: everything except the shim folder. */
|
|
66
66
|
export function pathWithoutShims(env = process.env, platform = process.platform) {
|
|
67
67
|
const sep = platform === "win32" ? ";" : ":";
|
|
68
|
-
|
|
68
|
+
// Also the default home's shims: a CLI run with FLOCKTAB_HOME elsewhere (tabdev)
|
|
69
|
+
// must not spawn a production `claude` shim that re-enters the other tab.
|
|
70
|
+
const dirs = new Set([path.resolve(shimDir(env)), path.resolve(shimDir({}))]);
|
|
69
71
|
return (env.PATH ?? "")
|
|
70
72
|
.split(sep)
|
|
71
|
-
.filter((p) => p && path.resolve(p)
|
|
73
|
+
.filter((p) => p && !dirs.has(path.resolve(p)))
|
|
72
74
|
.join(sep);
|
|
73
75
|
}
|
|
74
76
|
/** The line to add to a shell rc so the shims win. */
|
package/dist/cli.js
CHANGED
|
@@ -32,12 +32,16 @@ import { bold, box, cyan, dim, green, heading, line, rows, underline, yellow } f
|
|
|
32
32
|
import { envFor, knownClients, subscriptionVendorFor } from "./clients.js";
|
|
33
33
|
import { describeLogin, localLogin } from "./logins.js";
|
|
34
34
|
import { prepareCodexHome } from "./codex-home.js";
|
|
35
|
-
import { addMember, loadPool, memberEnv, poolVendorFor, prepareClaudeMember, removeMember, savePool,
|
|
35
|
+
import { addMember, loadPool, memberEnv, memberHome, POOL_VENDORS, poolVendorFor, prepareClaudeMember, prepareSharedSessions, limitsFor, removeMember, savePool, setGuard, setThreshold, thresholdFor, usage as quotaUsage } from "./pool.js";
|
|
36
36
|
import { runPooled } from "./pool-run.js";
|
|
37
|
+
import { renderPool } from "./pool-view.js";
|
|
38
|
+
import { renderHelp } from "./help.js";
|
|
39
|
+
import { findTabCommand } from "./tab-docs.js";
|
|
37
40
|
import { ConsoleApiError, createAgent, issueAgentKey, listAgents } from "./console-api.js";
|
|
38
41
|
import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } from "./device-login.js";
|
|
39
42
|
import { clearConfig, configDir, configPath, HOSTED_PROXY, isLocalProxy, loadConfig, LOCAL_PROXY, normalizeProxyUrl, presentedKey, saveConfig, } from "./config.js";
|
|
40
43
|
import { reportFolder } from "./folder.js";
|
|
44
|
+
import { whoami } from "./proxy-identity.js";
|
|
41
45
|
import { listAliases, pathLine, pathWithoutShims, removeAlias, shimDir, shimDirOnPath, validAliasName, writeAlias } from "./alias.js";
|
|
42
46
|
import * as manage from "./manage.js";
|
|
43
47
|
import { agentNameFor, projectRoot, readProject, writeProject } from "./project.js";
|
|
@@ -48,68 +52,15 @@ const say = (text) => console.error(text);
|
|
|
48
52
|
const ok = (text) => say(line("ok", text));
|
|
49
53
|
const warn = (text) => say(line("warn", text));
|
|
50
54
|
const fail = (text) => say(line("fail", text));
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
`);
|
|
56
|
-
|
|
57
|
-
cmd("login", "approve this machine in the console, once"),
|
|
58
|
-
cmd("claude [args]", "Claude Code on the tab"),
|
|
59
|
-
cmd("codex [args]", "Codex on the tab"),
|
|
60
|
-
cmd(others.join(" | tab "), "the same, for those agents"),
|
|
61
|
-
cmd("<command> [args]", "any other agent, both APIs pointed at the tab"),
|
|
62
|
-
cmd("use", "pick or change the Agent this folder runs as (.flocktab)"),
|
|
63
|
-
cmd("agent create <name>", "a new Agent: --subscription (your own logins) or --api (the flock's key, metered)"),
|
|
64
|
-
cmd("agent kind <agent> api|subscription", "change it"),
|
|
65
|
-
cmd("accounts", "accounts your subscription Agents were seen on: plan, price, quota used"),
|
|
66
|
-
cmd("pool", "several logins of one vendor; tab claude runs as the one with most room"),
|
|
67
|
-
cmd("pool add claude|codex <name>", "sign another login in through the browser (--email to prefill, --dir <path> to use a folder you have)"),
|
|
68
|
-
cmd("pool at <percent>", "move to another login once this much of a window is used (default 80)"),
|
|
69
|
-
cmd("alias setup <name>...", "make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)"),
|
|
70
|
-
cmd("alias remove <name>...", "undo that; `tab alias list` shows them"),
|
|
71
|
-
cmd("status", "which flock, which Agent here, is the proxy up"),
|
|
72
|
-
cmd("log [-f] [--all]", "everything through the tab on this machine's Agents, one line per call; -f follows"),
|
|
73
|
-
cmd("log --proxy [-f]", "self-hosted: the local proxy's own log (cached tokens, milliseconds; no keys, logins or prompts)"),
|
|
74
|
-
cmd("version", "tab and proxy versions"),
|
|
75
|
-
cmd("list", "every Agent: state, spent of cap, window, project"),
|
|
76
|
-
cmd("close | open [agent]", "the kill switch; omit the Agent for this folder's"),
|
|
77
|
-
cmd("cap [agent] <dollars>", "set the cap (--window day|week|month|run|hour|lifetime)"),
|
|
78
|
-
cmd("rename <agent> <name>", "rename it (the slug stays)"),
|
|
79
|
-
cmd("policy [agent]", "show or set: --velocity N|none --models a,b|none --allow tool|none"),
|
|
80
|
-
cmd("project [agent] <name>", "attribute to a project (--create, --repo owner/name, none)"),
|
|
81
|
-
cmd("projects [add <name>]", "list or add projects"),
|
|
82
|
-
cmd("key rotate [agent]", "new key, kept on this machine (--show prints it once)"),
|
|
83
|
-
cmd("archive [agent]", "close for good and leave the bill"),
|
|
84
|
-
cmd("ledger [agent]", "one row per call (--limit N --blocked)"),
|
|
85
|
-
cmd("spend [agent|project|day]", "meter and outside spend (--since 7d --agent x)"),
|
|
86
|
-
cmd("outside", "outside-spend connections and per-project totals"),
|
|
87
|
-
cmd("live", "who is working now (--watch 4)"),
|
|
88
|
-
cmd("web [agent]", "open the Agent's console page"),
|
|
89
|
-
cmd("... --json", "every read command as JSON, for scripts and agents"),
|
|
90
|
-
cmd("up", "self-hosted: start the local proxy (fetches it the first time)"),
|
|
91
|
-
cmd("down", "self-hosted: stop the local proxy"),
|
|
92
|
-
cmd("update", "the newest tab (and proxy) from npm; restarts a running local proxy"),
|
|
93
|
-
cmd("logout", "forget the session and keys"),
|
|
94
|
-
].map(([k, v]) => [cyan(k), v])));
|
|
95
|
-
say(`
|
|
96
|
-
${dim("Config")} ${configPath()} ${dim("or FLOCKTAB_PROXY_URL, FLOCKTAB_KEY, FLOCKTAB_UNLOCK")}`);
|
|
97
|
-
}
|
|
98
|
-
async function whoami(proxyUrl, login) {
|
|
99
|
-
try {
|
|
100
|
-
const response = await fetch(`${proxyUrl}/v1/whoami`, {
|
|
101
|
-
headers: { authorization: `Bearer ${presentedKey(login)}` },
|
|
102
|
-
});
|
|
103
|
-
const body = (await response.json().catch(() => ({})));
|
|
104
|
-
if (!response.ok) {
|
|
105
|
-
const message = body.error?.message;
|
|
106
|
-
return { error: message ?? `whoami returned ${response.status}` };
|
|
107
|
-
}
|
|
108
|
-
return body;
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
return { error: "proxy unreachable" };
|
|
55
|
+
/** `tab help`, `tab help <word>`: the overview, one command, the glossary, the errors. */
|
|
56
|
+
function usage(word) {
|
|
57
|
+
const text = renderHelp(word, { configPath: configPath() });
|
|
58
|
+
if (text === undefined) {
|
|
59
|
+
fail(`No help for "${word}". ${dim("tab help lists every command.")}`);
|
|
60
|
+
return 2;
|
|
112
61
|
}
|
|
62
|
+
say(text);
|
|
63
|
+
return 0;
|
|
113
64
|
}
|
|
114
65
|
/**
|
|
115
66
|
* Without a terminal (CI, a script, `printf ... | tab up`) every answer
|
|
@@ -474,7 +425,9 @@ async function version() {
|
|
|
474
425
|
const bin = proxyBinPath();
|
|
475
426
|
const installed = await proxyInstalled();
|
|
476
427
|
const where = packaged ? `packaged, ${packaged}` : installed ? `downloaded, ${bin}` : "not installed; tab up fetches it";
|
|
477
|
-
|
|
428
|
+
// `tabdev` (scripts/link-dev.mjs) runs this checkout's source, not the npm install.
|
|
429
|
+
const tabVersion = process.env.FLOCKTAB_DEV ? `${TAB_VERSION} + dev ${dim("(this checkout's source)")}` : TAB_VERSION;
|
|
430
|
+
say(rows([["tab", tabVersion], ["proxy", `${PROXY_VERSION} ${dim(`(${where})`)}`]], 0));
|
|
478
431
|
return 0;
|
|
479
432
|
}
|
|
480
433
|
async function status() {
|
|
@@ -539,17 +492,53 @@ function takePoolFlags(argv) {
|
|
|
539
492
|
}
|
|
540
493
|
return { args, noPool, asked: asked || pinned !== undefined, ...(pinned ? { pinned } : {}) };
|
|
541
494
|
}
|
|
495
|
+
/** The login a member's folder holds, read from the harness's own files (account fields only). */
|
|
496
|
+
function memberLogin(vendor, member) {
|
|
497
|
+
const home = memberHome(vendor, member);
|
|
498
|
+
return localLogin(vendor, vendor === "anthropic" ? { claudeConfigDir: home } : vendor === "openai" ? { codexHome: home } : vendor === "xai" ? { grokHome: home } : { kimiShareDir: home });
|
|
499
|
+
}
|
|
500
|
+
const VENDOR_NAMES = { anthropic: "Claude", openai: "ChatGPT (Codex)", xai: "Grok", kimi: "Kimi" };
|
|
501
|
+
const VENDOR_HARNESS = { anthropic: "claude", openai: "codex", xai: "grok", kimi: "kimi" };
|
|
542
502
|
async function flockAccounts(config) {
|
|
543
503
|
return (await manage.api(config)("GET", "/api/cli/accounts")).accounts;
|
|
544
504
|
}
|
|
505
|
+
/**
|
|
506
|
+
* Tell the console which logins this machine is signed in with, so a pool
|
|
507
|
+
* member shows there before its first call. Only what the harness's own
|
|
508
|
+
* files say about the account (the vendor's id, email, plan tier), never a
|
|
509
|
+
* login. Best effort: the pool works the same when the console is away.
|
|
510
|
+
*/
|
|
511
|
+
async function registerPoolLogins(config, pool) {
|
|
512
|
+
const accounts = [];
|
|
513
|
+
for (const vendor of POOL_VENDORS) {
|
|
514
|
+
for (const member of pool.members[vendor] ?? []) {
|
|
515
|
+
const login = await memberLogin(vendor, member);
|
|
516
|
+
if (!login?.accountId)
|
|
517
|
+
continue;
|
|
518
|
+
accounts.push({ provider: vendor, externalId: login.accountId, ...(login.email ? { email: login.email } : {}), ...(login.plan ? { plan: login.plan } : {}) });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (accounts.length === 0)
|
|
522
|
+
return undefined;
|
|
523
|
+
try {
|
|
524
|
+
return (await manage.api(config)("POST", "/api/cli/accounts", { accounts })).accounts;
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return undefined;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
545
530
|
/** Each member with the login its folder holds and how used the vendor last said that account is. */
|
|
546
|
-
async function poolStandings(config, vendor, members) {
|
|
547
|
-
const accounts = await flockAccounts(config).catch(() => []);
|
|
531
|
+
async function poolStandings(config, vendor, members, known) {
|
|
532
|
+
const accounts = known ?? (await flockAccounts(config).catch(() => []));
|
|
548
533
|
return Promise.all(members.map(async (member) => {
|
|
549
|
-
const login = await
|
|
534
|
+
const login = await memberLogin(vendor, member);
|
|
550
535
|
const email = login?.email;
|
|
551
|
-
|
|
552
|
-
|
|
536
|
+
// By email where the vendor names one, else by the account id the proxy files calls under.
|
|
537
|
+
const account = accounts.find((a) => a.provider === vendor && ((email && a.email?.toLowerCase() === email.toLowerCase()) || (login?.accountId && a.externalId === login.accountId)));
|
|
538
|
+
const shown = email ?? (login?.accountId ? `account ${login.accountId.slice(0, 12)}` : undefined);
|
|
539
|
+
// The vendor's plan name once seen on the wire; before that, what the harness's own file says.
|
|
540
|
+
const plan = account?.planLabel ?? login?.plan?.replace(/^default_/, "").replaceAll("_", " ");
|
|
541
|
+
return { member, used: undefined, ...(account ? quotaUsage(account.quota) : {}), ...(shown ? { email: shown } : {}), ...(plan ? { plan } : {}), ...(account ? { windows: account.quota } : {}) };
|
|
553
542
|
}));
|
|
554
543
|
}
|
|
555
544
|
/** Quiet: no call of this Agent in flight, and no reply from the vendor in the last 20 seconds. */
|
|
@@ -566,7 +555,8 @@ async function runAgent(name, argv) {
|
|
|
566
555
|
const config = await ensureLogin();
|
|
567
556
|
if (!config)
|
|
568
557
|
return 2;
|
|
569
|
-
|
|
558
|
+
// Hosted whoami below proves readiness and current Agent kind in one round trip.
|
|
559
|
+
if (config.mode === "self-hosted" && !(await ensureProxy(config)))
|
|
570
560
|
return 1;
|
|
571
561
|
const agent = await resolveAgent(config);
|
|
572
562
|
if (!agent)
|
|
@@ -578,6 +568,10 @@ async function runAgent(name, argv) {
|
|
|
578
568
|
// consumer plan, is metered with the flock's key.
|
|
579
569
|
const vendor = subscriptionVendorFor(name);
|
|
580
570
|
const me = await whoami(config.proxyUrl, { key: agent.key, unlock: config.unlock });
|
|
571
|
+
if (config.mode === "hosted" && "error" in me) {
|
|
572
|
+
fail(me.error);
|
|
573
|
+
return 1;
|
|
574
|
+
}
|
|
581
575
|
let kind = !("error" in me) && me.kind === "subscription" ? "subscription" : "api";
|
|
582
576
|
// A proxy older than 0.1.7 answers whoami without the kind. Guessing api
|
|
583
577
|
// would hand a subscription Agent's harness the tab key, so ask the console.
|
|
@@ -653,7 +647,11 @@ async function runAgent(name, argv) {
|
|
|
653
647
|
};
|
|
654
648
|
if (!pooled || !poolVendor || !pool)
|
|
655
649
|
return launch(args, env).done;
|
|
656
|
-
// Codex members each need their own home written before the first launch
|
|
650
|
+
// Codex members each need their own home written before the first launch;
|
|
651
|
+
// Codex, Grok and Kimi members share one conversations folder.
|
|
652
|
+
for (const member of members)
|
|
653
|
+
await prepareSharedSessions(poolVendor, member);
|
|
654
|
+
void registerPoolLogins(config, pool);
|
|
657
655
|
if (poolVendor === "openai") {
|
|
658
656
|
for (const member of members) {
|
|
659
657
|
await prepareCodexHome({ baseDir: member.dir, proxyUrl: config.proxyUrl, presentedKey: key, mode: config.mode, auth, userCodexHome: path.join(member.dir, "no-personal-login") });
|
|
@@ -661,7 +659,7 @@ async function runAgent(name, argv) {
|
|
|
661
659
|
}
|
|
662
660
|
return runPooled(args, {
|
|
663
661
|
harness: name,
|
|
664
|
-
|
|
662
|
+
limits: limitsFor(pool, poolVendor),
|
|
665
663
|
swap: pool.swap,
|
|
666
664
|
...(pinned ? { pinned } : {}),
|
|
667
665
|
standings: () => poolStandings(config, poolVendor, members),
|
|
@@ -686,14 +684,42 @@ async function poolCommand(argv) {
|
|
|
686
684
|
const [sub, ...rest] = argv;
|
|
687
685
|
const pool = await loadPool();
|
|
688
686
|
const vendorOf = (word) => poolVendorFor(word === "chatgpt" ? "codex" : (word ?? ""));
|
|
687
|
+
const HARNESSES = "claude|codex|grok|kimi";
|
|
689
688
|
if (sub === "at") {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
689
|
+
// `tab pool at 80` for everyone; `tab pool at claude 70` for one vendor; `tab pool at claude default` to clear it.
|
|
690
|
+
// A window word makes it the weekly guard: `tab pool at 7d 95`, `tab pool at claude weekly 90`.
|
|
691
|
+
const words = rest.filter((w) => !["7d", "weekly", "week", "long"].includes(w));
|
|
692
|
+
const guard = words.length !== rest.length;
|
|
693
|
+
// `5h` / `short` is the default meaning, accepted so both read the same way.
|
|
694
|
+
const plain = words.filter((w) => !["5h", "short"].includes(w));
|
|
695
|
+
const vendor = plain.length > 1 ? vendorOf(plain[0]) : undefined;
|
|
696
|
+
const value = plain.length > 1 ? plain[1] : plain[0];
|
|
697
|
+
const clear = vendor !== undefined && (value === "default" || value === "reset");
|
|
698
|
+
if ((plain.length > 1 && !vendor) || plain.length > 2 || (!clear && !/^\d{1,3}$/.test(value ?? ""))) {
|
|
699
|
+
fail(`tab pool at [claude|codex|grok|kimi] [7d] <percent>|default. ${dim("A percent is 1 to 100. Without 7d it is the short (5-hour) window; with it, the weekly guard.")}`);
|
|
693
700
|
return 2;
|
|
694
701
|
}
|
|
695
|
-
|
|
696
|
-
|
|
702
|
+
let next;
|
|
703
|
+
try {
|
|
704
|
+
next = (guard ? setGuard : setThreshold)(pool, clear ? undefined : Number(value), vendor);
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
fail(err.message);
|
|
708
|
+
return 2;
|
|
709
|
+
}
|
|
710
|
+
await savePool(next);
|
|
711
|
+
if (guard) {
|
|
712
|
+
const g = vendor ? limitsFor(next, vendor).guard : next.guard;
|
|
713
|
+
ok(`${vendor ? `${VENDOR_NAMES[vendor]} logins give` : "A login gives"} way once ${g}% of the long (weekly) window is used, whatever the short one says. ${dim("Below that the weekly window is ignored.")}`);
|
|
714
|
+
}
|
|
715
|
+
else if (!vendor) {
|
|
716
|
+
const own = POOL_VENDORS.filter((v) => next.atByVendor[v] !== undefined).map((v) => `${VENDOR_HARNESS[v]} ${next.atByVendor[v]}%`);
|
|
717
|
+
ok(`The pool moves to another login at ${next.at}% used.${own.length > 0 ? dim(` Still their own: ${own.join(", ")}.`) : ""}`);
|
|
718
|
+
}
|
|
719
|
+
else if (clear)
|
|
720
|
+
ok(`${VENDOR_NAMES[vendor]} logins move at the shared ${next.at}% again.`);
|
|
721
|
+
else
|
|
722
|
+
ok(`${VENDOR_NAMES[vendor]} logins move at ${thresholdFor(next, vendor)}% used. ${dim(`Everyone else at ${next.at}%.`)}`);
|
|
697
723
|
return 0;
|
|
698
724
|
}
|
|
699
725
|
if (sub === "swap") {
|
|
@@ -710,7 +736,7 @@ async function poolCommand(argv) {
|
|
|
710
736
|
const flags = manage.parseFlags(rest.slice(1));
|
|
711
737
|
const memberName = flags.args[0];
|
|
712
738
|
if (!vendor || !memberName) {
|
|
713
|
-
fail(`tab pool ${sub}
|
|
739
|
+
fail(`tab pool ${sub} ${HARNESSES} <name>${sub === "add" ? " [--dir <folder>] [--email <address>]" : ""}`);
|
|
714
740
|
return 2;
|
|
715
741
|
}
|
|
716
742
|
if (sub !== "add") {
|
|
@@ -727,27 +753,33 @@ async function poolCommand(argv) {
|
|
|
727
753
|
return 2;
|
|
728
754
|
}
|
|
729
755
|
const member = next.members[vendor].at(-1);
|
|
730
|
-
const loginOf = () =>
|
|
756
|
+
const loginOf = () => memberLogin(vendor, member);
|
|
757
|
+
const signedIn = async () => {
|
|
758
|
+
const l = await loginOf();
|
|
759
|
+
return Boolean(l?.email ?? l?.accountId);
|
|
760
|
+
};
|
|
731
761
|
if (!flags.opts.dir && vendor === "anthropic")
|
|
732
762
|
await prepareClaudeMember(member.dir);
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
763
|
+
await mkdir(memberHome(vendor, member), { recursive: true, mode: 0o700 });
|
|
764
|
+
if (!flags.opts.dir)
|
|
765
|
+
await prepareSharedSessions(vendor, member);
|
|
766
|
+
if (!(await signedIn())) {
|
|
767
|
+
const harness = VENDOR_HARNESS[vendor];
|
|
737
768
|
// `claude auth login` does the browser sign-in and exits: no session to open, no /login, no /exit.
|
|
738
769
|
const email = flags.opts.email ?? (memberName.includes("@") ? memberName : undefined);
|
|
739
|
-
say(dim(`Signing ${
|
|
770
|
+
say(dim(`Signing ${harness} in for ${memberName}. Your browser opens; pick the account for this login.`));
|
|
740
771
|
// Plain launch: no proxy, no tab key. This only creates the login, in the harness's own store.
|
|
741
772
|
const loginEnv = { ...process.env, ...memberEnv(vendor, member), PATH: pathWithoutShims() };
|
|
742
|
-
for (const k of ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_BASE_URL", "OPENAI_API_KEY"])
|
|
773
|
+
for (const k of ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_BASE_URL", "OPENAI_API_KEY", "XAI_API_KEY", "GROK_XAI_API_BASE_URL", "KIMI_API_KEY", "KIMI_BASE_URL", "KIMI_CODE_BASE_URL"])
|
|
743
774
|
delete loginEnv[k];
|
|
744
775
|
const run = (argv) => new Promise((resolve) => {
|
|
745
776
|
const child = spawn(harness, argv, { stdio: "inherit", env: loginEnv });
|
|
746
777
|
child.on("error", () => resolve(127));
|
|
747
778
|
child.on("close", (c) => resolve(c ?? 1));
|
|
748
779
|
});
|
|
780
|
+
// Every one of them has its own sign-in command that opens the browser and exits.
|
|
749
781
|
let code = await run(vendor === "anthropic" ? ["auth", "login", "--claudeai", ...(email ? ["--email", email] : [])] : ["login"]);
|
|
750
|
-
if (vendor === "anthropic" && code !== 0 && code !== 127 && !(await
|
|
782
|
+
if (vendor === "anthropic" && code !== 0 && code !== 127 && !(await signedIn())) {
|
|
751
783
|
// A Claude Code too old for `auth login`: sign in from inside it.
|
|
752
784
|
say(dim("That did not sign in. Opening Claude Code instead: /login as this account, then /exit."));
|
|
753
785
|
code = await run([]);
|
|
@@ -758,35 +790,32 @@ async function poolCommand(argv) {
|
|
|
758
790
|
}
|
|
759
791
|
}
|
|
760
792
|
const login = await loginOf();
|
|
761
|
-
if (!
|
|
793
|
+
if (!(await signedIn())) {
|
|
762
794
|
fail(`No login found in ${member.dir}. Nothing was added; run the same command again to retry.`);
|
|
763
795
|
return 1;
|
|
764
796
|
}
|
|
765
797
|
await savePool(next);
|
|
766
|
-
|
|
798
|
+
const session = await loadConfig();
|
|
799
|
+
const shown = session?.token ? await registerPoolLogins(session, next) : undefined;
|
|
800
|
+
ok(`${bold(memberName)} is in the pool as ${describeLogin(login)}.${shown ? dim(" It shows in the console under Subscriptions.") : ""}`);
|
|
767
801
|
return 0;
|
|
768
802
|
}
|
|
769
|
-
if (sub && sub !== "list" && sub !== "ls") {
|
|
803
|
+
if (sub && sub !== "list" && sub !== "ls" && sub !== "--paths") {
|
|
770
804
|
fail(`Unknown: tab pool ${sub}. ${dim("tab pool | add | remove | at | swap")}`);
|
|
771
805
|
return 2;
|
|
772
806
|
}
|
|
773
807
|
const config = await ensureLogin();
|
|
774
808
|
if (!config)
|
|
775
809
|
return 2;
|
|
776
|
-
const vendors =
|
|
810
|
+
const vendors = POOL_VENDORS.filter((v) => (pool.members[v] ?? []).length > 0);
|
|
777
811
|
if (vendors.length === 0) {
|
|
778
812
|
say(dim(`No logins in the pool. ${bold("tab pool add claude work")} signs one in; then ${bold("tab claude")} runs as whichever has most room.`));
|
|
779
813
|
return 0;
|
|
780
814
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
s.member.name,
|
|
786
|
-
`${s.email ?? yellow("not signed in")} ${s.used === undefined ? dim("not seen yet") : s.used >= pool.at ? yellow(`${Math.round(s.used)}% used`) : green(`${Math.round(s.used)}% used`)} ${dim(s.member.dir)}`,
|
|
787
|
-
]), 0));
|
|
788
|
-
}
|
|
789
|
-
say(dim(`Moves at ${pool.at}% used (${pool.swap === "auto" ? "also while running, when idle" : "at launch only"}). tab claude --as <name> pins one; --no-pool uses your usual login.`));
|
|
815
|
+
// One round trip: register what this machine is signed in with, and get every account back.
|
|
816
|
+
const accounts = (await registerPoolLogins(config, pool)) ?? (await flockAccounts(config).catch(() => []));
|
|
817
|
+
const sections = await Promise.all(vendors.map(async (vendor) => ({ vendor, title: VENDOR_NAMES[vendor], harness: VENDOR_HARNESS[vendor], limits: limitsFor(pool, vendor), standings: await poolStandings(config, vendor, pool.members[vendor], accounts) })));
|
|
818
|
+
say(renderPool(sections, { at: pool.at, swap: pool.swap, paths: argv.includes("--paths") }));
|
|
790
819
|
return 0;
|
|
791
820
|
}
|
|
792
821
|
/**
|
|
@@ -1015,13 +1044,20 @@ async function managed(command, rest) {
|
|
|
1015
1044
|
}
|
|
1016
1045
|
async function main(argv) {
|
|
1017
1046
|
const [command, ...rest] = argv;
|
|
1047
|
+
if (command && (rest.includes("--help") || rest.includes("-h"))) {
|
|
1048
|
+
const doc = findTabCommand(command);
|
|
1049
|
+
// `tab claude --help` is Claude's help: everything after an agent's name is the agent's.
|
|
1050
|
+
if (doc && doc.group !== "run")
|
|
1051
|
+
return usage(command);
|
|
1052
|
+
}
|
|
1018
1053
|
switch (command) {
|
|
1019
1054
|
case undefined:
|
|
1020
1055
|
case "-h":
|
|
1021
1056
|
case "--help":
|
|
1022
|
-
case "help":
|
|
1023
|
-
usage();
|
|
1024
|
-
return command === undefined ? 2 :
|
|
1057
|
+
case "help": {
|
|
1058
|
+
const code = usage(command === "help" ? rest[0] : undefined);
|
|
1059
|
+
return command === undefined ? 2 : code;
|
|
1060
|
+
}
|
|
1025
1061
|
case "login":
|
|
1026
1062
|
return login(rest);
|
|
1027
1063
|
case "logout":
|
package/dist/clients.js
CHANGED
|
@@ -128,7 +128,11 @@ export function envFor(input) {
|
|
|
128
128
|
delete env.XAI_API_KEY;
|
|
129
129
|
}
|
|
130
130
|
if (spec.provider === "moonshot") {
|
|
131
|
-
|
|
131
|
+
// Kimi Code (the `~/.kimi-code` CLI) reads its plan endpoint from KIMI_CODE_BASE_URL;
|
|
132
|
+
// the older kimi-cli reads KIMI_BASE_URL. Its sign-in host is separate and untouched.
|
|
133
|
+
const kimiBase = `${passthroughBase(input.proxyUrl, input.presentedKey, "kimi")}/coding/v1`;
|
|
134
|
+
env.KIMI_CODE_BASE_URL = kimiBase;
|
|
135
|
+
env.KIMI_BASE_URL = kimiBase;
|
|
132
136
|
delete env.KIMI_API_KEY;
|
|
133
137
|
}
|
|
134
138
|
return;
|
package/dist/codex-home.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* The person's own `~/.codex` is never read or written.
|
|
12
12
|
*/
|
|
13
|
-
import { chmod, copyFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
13
|
+
import { chmod, copyFile, lstat, mkdir, readdir, readFile, rename, rm, symlink, unlink, writeFile } from "node:fs/promises";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { passthroughBase } from "./clients.js";
|
|
@@ -67,13 +67,57 @@ export async function prepareCodexHome(input) {
|
|
|
67
67
|
await writeFile(config, codexConfigToml(input.proxyUrl, input.model, input.mode ?? "hosted"), { mode: 0o600 });
|
|
68
68
|
}
|
|
69
69
|
await chmod(config, 0o600);
|
|
70
|
+
await shareConversations(home, input.userCodexHome ?? path.join(homedir(), ".codex"));
|
|
70
71
|
return home;
|
|
71
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Codex keeps conversations as rollout files under `sessions`. The tab's
|
|
75
|
+
* home is its own folder (so its config and login never touch `~/.codex`),
|
|
76
|
+
* which hid every conversation the person already had: `tab codex resume
|
|
77
|
+
* <id>` answered "no saved session". `sessions` is therefore a link to the
|
|
78
|
+
* person's own, and anything already recorded under the tab is moved across
|
|
79
|
+
* first. With no `~/.codex` on the machine there is nothing to share.
|
|
80
|
+
*/
|
|
81
|
+
export async function shareConversations(home, userCodexHome) {
|
|
82
|
+
if (!(await lstat(userCodexHome).then((s) => s.isDirectory(), () => false)))
|
|
83
|
+
return "none";
|
|
84
|
+
const theirs = path.join(userCodexHome, "sessions");
|
|
85
|
+
const mine = path.join(home, "sessions");
|
|
86
|
+
if (path.resolve(theirs) === path.resolve(mine))
|
|
87
|
+
return "already";
|
|
88
|
+
const state = await lstat(mine).catch(() => undefined);
|
|
89
|
+
if (state?.isSymbolicLink())
|
|
90
|
+
return "already";
|
|
91
|
+
await mkdir(theirs, { recursive: true, mode: 0o700 });
|
|
92
|
+
if (state?.isDirectory()) {
|
|
93
|
+
await moveInto(mine, theirs);
|
|
94
|
+
await rm(mine, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
await symlink(theirs, mine);
|
|
97
|
+
return "linked";
|
|
98
|
+
}
|
|
99
|
+
/** Move every file under `from` to the same place under `to`; a file already there is kept. */
|
|
100
|
+
export async function moveInto(from, to) {
|
|
101
|
+
for (const entry of await readdir(from, { withFileTypes: true })) {
|
|
102
|
+
const src = path.join(from, entry.name);
|
|
103
|
+
const dest = path.join(to, entry.name);
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
await mkdir(dest, { recursive: true, mode: 0o700 });
|
|
106
|
+
await moveInto(src, dest);
|
|
107
|
+
}
|
|
108
|
+
else if (!(await lstat(dest).then(() => true, () => false))) {
|
|
109
|
+
await rename(src, dest).catch(async () => {
|
|
110
|
+
// Another volume: copy, then the caller removes the source tree.
|
|
111
|
+
await copyFile(src, dest);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
72
116
|
/**
|
|
73
117
|
* The ChatGPT login for the tab's Codex home. A login already there (from
|
|
74
118
|
* `codex login` run through `tab codex`) stays. Otherwise the person's own
|
|
75
119
|
* `~/.codex/auth.json` is copied, owner-only: same machine, same person,
|
|
76
|
-
* and `~/.codex`
|
|
120
|
+
* and the login and config in `~/.codex` are still never written. A leftover API-key auth
|
|
77
121
|
* file from metered mode is replaced; with nothing to copy, Codex asks the
|
|
78
122
|
* person to sign in and keeps that login in the tab home.
|
|
79
123
|
*/
|
package/dist/console-api.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* make one, key one. All three are `/api/cli/*` with `Bearer ft_cli_...`.
|
|
4
4
|
* Injectable fetch so the folder picker is testable without a console.
|
|
5
5
|
*/
|
|
6
|
+
import { requestJson } from "./network.js";
|
|
6
7
|
export class ConsoleApiError extends Error {
|
|
7
8
|
status;
|
|
8
9
|
constructor(message, status) {
|
|
@@ -12,11 +13,10 @@ export class ConsoleApiError extends Error {
|
|
|
12
13
|
}
|
|
13
14
|
}
|
|
14
15
|
async function call(consoleUrl, token, path, init, fetchImpl) {
|
|
15
|
-
const response = await
|
|
16
|
+
const { response, body } = await requestJson(`${consoleUrl}${path}`, {
|
|
16
17
|
...init,
|
|
17
18
|
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...(init.headers ?? {}) },
|
|
18
|
-
});
|
|
19
|
-
const body = (await response.json().catch(() => ({})));
|
|
19
|
+
}, fetchImpl);
|
|
20
20
|
if (!response.ok) {
|
|
21
21
|
const detail = body.error ?? `console answered ${response.status}`;
|
|
22
22
|
throw new ConsoleApiError(body.hint ? `${detail} (${body.hint})` : detail, response.status);
|
package/dist/folder.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { requestJson } from "./network.js";
|
|
2
3
|
function parseGitRepo(remote) {
|
|
3
4
|
const trimmed = remote.trim();
|
|
4
5
|
const ssh = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i.exec(trimmed);
|
|
@@ -34,14 +35,14 @@ export function gitRepoFromCwd(cwd) {
|
|
|
34
35
|
export function reportFolder(proxyUrl, presentedKey) {
|
|
35
36
|
const cwd = process.cwd();
|
|
36
37
|
const git = gitRepoFromCwd(cwd);
|
|
37
|
-
void
|
|
38
|
+
void requestJson(`${proxyUrl}/v1/here`, {
|
|
38
39
|
method: "POST",
|
|
39
40
|
headers: {
|
|
40
41
|
authorization: `Bearer ${presentedKey}`,
|
|
41
42
|
"content-type": "application/json",
|
|
42
43
|
},
|
|
43
44
|
body: JSON.stringify({ cwd, git }),
|
|
44
|
-
}).catch(() => {
|
|
45
|
+
}, fetch, 5_000).catch(() => {
|
|
45
46
|
// The Agent still runs if this misses.
|
|
46
47
|
});
|
|
47
48
|
}
|