@hanamorilabs/tab 0.1.0

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 ADDED
@@ -0,0 +1,67 @@
1
+ # tab
2
+
3
+ Run any AI agent on a FlockTab tab.
4
+
5
+ ```
6
+ npm i -g @hanamorilabs/tab
7
+ tab login # once per machine: opens the console, approve this machine
8
+ cd my-project
9
+ tab claude # first run here asks which Agent this folder runs as (or makes one)
10
+ tab codex # same Agent: it is in ./.flocktab
11
+ tab grok # Grok, routed to xAI through the tab
12
+ tab kimi # Kimi, routed to Moonshot through the tab
13
+ tab aider --model gpt-4.1 # anything else: both APIs pointed at the tab
14
+ ```
15
+
16
+ Login is per machine, the Agent is per folder. `tab login` approves the
17
+ machine for your flock and stores a session in `~/.flocktab/config.json`
18
+ (owner-only). The first `tab claude` or `tab codex` in a folder lists your
19
+ Agents, lets you pick one or create one named after the folder ($50 cap,
20
+ change it in the console), mints that Agent's key for this machine, and
21
+ writes `.flocktab` with the Agent's name. No secrets in `.flocktab`, so
22
+ commit it if you like; a teammate's first run mints their own key. `tab use`
23
+ changes the folder's Agent.
24
+
25
+ `tab <agent>` sets the variables that agent reads and runs it with your
26
+ terminal attached. Claude Code reads `ANTHROPIC_BASE_URL`; everything else
27
+ reads `OPENAI_BASE_URL`. Codex is special: a ChatGPT login or a custom
28
+ provider in `~/.codex` would beat the environment, so `tab codex` gives it
29
+ its own home under `~/.flocktab/codex` pinned to the tab, and never touches
30
+ `~/.codex`. The proxy serves the OpenAI chat and Responses APIs and the
31
+ Anthropic Messages API, so the same tab, cap and kill switch apply whichever
32
+ one the agent speaks.
33
+
34
+ The key the agent presents is `ft_live_<secret>.<unlock>`. On a hosted flock
35
+ the unlock decrypts your provider key on the proxy for that one request and
36
+ is never stored on the server; `tab` asks for it once, when the first Agent
37
+ is keyed, and keeps it in the config file so `tab claude` is one command.
38
+ Set `FLOCKTAB_PROXY_URL`, `FLOCKTAB_KEY` and `FLOCKTAB_UNLOCK` instead when
39
+ you would rather not have a file or a folder, for example in CI.
40
+
41
+ ## Self-hosted
42
+
43
+ The proxy runs on your box and holds the provider keys; the ledger, tabs and
44
+ kill switch stay on flocktab.com. It is one small Rust binary. `tab up` asks
45
+ for the keys once (typed without echo, saved owner-only to
46
+ `~/.flocktab/proxy.env`), fetches `flocktab-proxy` for this machine into
47
+ `~/.flocktab/bin`, and starts it in the background (log: `~/.flocktab/proxy.log`).
48
+ `tab down` stops it; `tab update` fetches the newest proxy and restarts;
49
+ `tab claude` starts it when it is down. No Docker, no checkout. Then
50
+ `tab login`, pick **2 Self-hosted**: the proxy says which console it settles
51
+ through, so the device code lands on flocktab.com. No unlock in this mode;
52
+ Codex shows `FlockTab - Self-hosted` as its provider.
53
+
54
+ ## Secrets and colour
55
+
56
+ The unlock and provider keys are typed with echo off (raw mode); the unlock is checked against the proxy before saving; a wrong unlock is refused, and `tab status` says `unlock ok` or `unlock is WRONG`. The check is `GET /v1/whoami` with the unlock, which decrypts the envelope in memory and answers `ok`/`wrong`, never the key. Output is coloured on a terminal; `NO_COLOR` turns it off, `FORCE_COLOR` turns it on for pipes.
57
+
58
+ ## Status
59
+
60
+ ```
61
+ tab status
62
+ proxy http://127.0.0.1:8787 (self-hosted) up
63
+ flock Hanamori Labs
64
+ folder flocktab-codex
65
+ plan team
66
+ byok provider keys on this box
67
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,611 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tab: run any AI agent on a FlockTab tab.
4
+ *
5
+ * tab login approve this machine in the console (once per machine)
6
+ * tab claude [...args] Claude Code on the tab
7
+ * tab codex [...args] Codex on the tab
8
+ * tab <cmd> [...args] anything else, both APIs pointed at the tab
9
+ * tab use pick (or change) the Agent this folder runs as
10
+ * tab status which flock, which Agent here, is the proxy up
11
+ * tab up | down | update self-hosted: start, stop, or fetch the newest proxy
12
+ * tab logout forget the session and keys
13
+ *
14
+ * Login is per machine. The Agent is per folder: the first `tab claude` in
15
+ * a folder asks which Agent it runs as (or makes one) and writes `.flocktab`
16
+ * there. The child process gets the tab's environment and inherits the
17
+ * terminal, so an interactive agent behaves exactly as it does without `tab`.
18
+ */
19
+ import { spawn } from "node:child_process";
20
+ import { hostname } from "node:os";
21
+ import { createInterface } from "node:readline/promises";
22
+ import { stdin, stdout } from "node:process";
23
+ import { bold, box, cyan, dim, green, heading, line, rows, underline, yellow } from "./ui.js";
24
+ import { envFor, knownClients } from "./clients.js";
25
+ import { prepareCodexHome } from "./codex-home.js";
26
+ import { ConsoleApiError, createAgent, issueAgentKey, listAgents } from "./console-api.js";
27
+ import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } from "./device-login.js";
28
+ import { clearConfig, configDir, configPath, HOSTED_PROXY, isLocalProxy, loadConfig, LOCAL_PROXY, normalizeProxyUrl, presentedKey, saveConfig, } from "./config.js";
29
+ import { reportFolder } from "./folder.js";
30
+ import { agentNameFor, projectRoot, readProject, writeProject } from "./project.js";
31
+ import { downloadProxy, hasProxyEnv, packagedBinPath, proxyBinPath, proxyEnvPath, proxyLogPath, PROXY_VERSION, startProxy, stopProxy, tailProxyLog, writeProxyEnv, } from "./proxy-bin.js";
32
+ import { proxyHealth, waitHealthy } from "./selfhost.js";
33
+ const say = (text) => console.error(text);
34
+ const ok = (text) => say(line("ok", text));
35
+ const warn = (text) => say(line("warn", text));
36
+ const fail = (text) => say(line("fail", text));
37
+ function usage() {
38
+ const others = knownClients().filter((n) => n !== "claude" && n !== "codex");
39
+ const cmd = (name, what) => [`tab ${name}`, what];
40
+ say(`${heading("tab")} ${dim("- run any AI agent on a FlockTab tab")}
41
+ `);
42
+ say(rows([
43
+ cmd("login", "approve this machine in the console, once"),
44
+ cmd("claude [args]", "Claude Code on the tab"),
45
+ cmd("codex [args]", "Codex on the tab"),
46
+ cmd(others.join(" | tab "), "the same, for those agents"),
47
+ cmd("<command> [args]", "any other agent, both APIs pointed at the tab"),
48
+ cmd("use", "pick or change the Agent this folder runs as (.flocktab)"),
49
+ cmd("status", "which flock, which Agent here, is the proxy up"),
50
+ cmd("up", "self-hosted: start the local proxy (fetches it the first time)"),
51
+ cmd("down", "self-hosted: stop the local proxy"),
52
+ cmd("update", "self-hosted: fetch the newest proxy and restart it"),
53
+ cmd("logout", "forget the session and keys"),
54
+ ].map(([k, v]) => [cyan(k), v])));
55
+ say(`
56
+ ${dim("Config")} ${configPath()} ${dim("or FLOCKTAB_PROXY_URL, FLOCKTAB_KEY, FLOCKTAB_UNLOCK")}`);
57
+ }
58
+ async function whoami(proxyUrl, login) {
59
+ try {
60
+ const response = await fetch(`${proxyUrl}/v1/whoami`, {
61
+ headers: { authorization: `Bearer ${presentedKey(login)}` },
62
+ });
63
+ const body = (await response.json().catch(() => ({})));
64
+ if (!response.ok) {
65
+ const message = body.error?.message;
66
+ return { error: message ?? `whoami returned ${response.status}` };
67
+ }
68
+ return body;
69
+ }
70
+ catch {
71
+ return { error: "proxy unreachable" };
72
+ }
73
+ }
74
+ /**
75
+ * Without a terminal (CI, a script, `printf ... | tab up`) every answer
76
+ * comes from stdin, read once and handed out line by line; closing a
77
+ * readline interface ends stdin, so one interface per question cannot work.
78
+ */
79
+ let piped;
80
+ async function pipedLine() {
81
+ if (!piped) {
82
+ let text = "";
83
+ for await (const chunk of stdin)
84
+ text += chunk;
85
+ piped = text.split(/\r?\n/);
86
+ }
87
+ return (piped.shift() ?? "").trim();
88
+ }
89
+ async function ask(question) {
90
+ if (!stdin.isTTY) {
91
+ stdout.write(question);
92
+ const answer = await pipedLine();
93
+ stdout.write("\n");
94
+ return answer;
95
+ }
96
+ const rl = createInterface({ input: stdin, output: stdout, terminal: true });
97
+ try {
98
+ return (await rl.question(question)).trim();
99
+ }
100
+ finally {
101
+ rl.close();
102
+ }
103
+ }
104
+ /**
105
+ * A secret is typed with echo off: raw mode, nothing printed per key, so
106
+ * neither the terminal nor its scrollback ever shows it. Without a TTY
107
+ * (piped input) it falls back to a plain line read, which is not echoed
108
+ * by the terminal either.
109
+ */
110
+ async function askSecret(question) {
111
+ if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
112
+ stdout.write(question);
113
+ const answer = await pipedLine();
114
+ stdout.write("\n");
115
+ return answer;
116
+ }
117
+ stdout.write(question);
118
+ stdin.setRawMode(true);
119
+ stdin.resume();
120
+ stdin.setEncoding("utf8");
121
+ let value = "";
122
+ try {
123
+ return await new Promise((resolve, reject) => {
124
+ const onData = (chunk) => {
125
+ for (const ch of chunk) {
126
+ if (ch === "\r" || ch === "\n") {
127
+ stdin.off("data", onData);
128
+ stdout.write("\n");
129
+ resolve(value.trim());
130
+ return;
131
+ }
132
+ if (ch === "\u0003") {
133
+ stdin.off("data", onData);
134
+ stdout.write("\n");
135
+ reject(new Error("interrupted"));
136
+ return;
137
+ }
138
+ if (ch === "\u007f" || ch === "\b") {
139
+ value = value.slice(0, -1);
140
+ continue;
141
+ }
142
+ if (ch >= " ")
143
+ value += ch;
144
+ }
145
+ };
146
+ stdin.on("data", onData);
147
+ });
148
+ }
149
+ finally {
150
+ stdin.setRawMode(false);
151
+ stdin.pause();
152
+ }
153
+ }
154
+ function openBrowser(url) {
155
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
156
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
157
+ try {
158
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
159
+ child.on("error", () => { });
160
+ child.unref();
161
+ }
162
+ catch {
163
+ // The URL is printed either way.
164
+ }
165
+ }
166
+ async function chooseProxy(current) {
167
+ const isCurrent = (url) => current !== undefined && normalizeProxyUrl(current) === url;
168
+ say(heading("Where is your FlockTab proxy?"));
169
+ say(rows([
170
+ [`${bold("1")} Hosted`, `${dim(HOSTED_PROXY)}${isCurrent(HOSTED_PROXY) ? green(" current") : ""}`],
171
+ [`${bold("2")} Self-hosted`, `${dim(LOCAL_PROXY)}${isCurrent(LOCAL_PROXY) ? green(" current") : ""}`],
172
+ ]));
173
+ const fallback = current && !isCurrent(HOSTED_PROXY) && !isCurrent(LOCAL_PROXY) ? current : isCurrent(LOCAL_PROXY) ? "2" : "1";
174
+ const choice = await ask(`Choose ${bold("1")} or ${bold("2")}, or paste a URL ${dim(`[${fallback}]`)}: `);
175
+ const picked = choice === "" ? fallback : choice;
176
+ return normalizeProxyUrl(picked === "1" ? HOSTED_PROXY : picked === "2" ? LOCAL_PROXY : picked);
177
+ }
178
+ const UNLOCK_TRIES = 3;
179
+ /**
180
+ * Ask for the flock unlock only when a hosted flock has a provider key and
181
+ * none is saved yet, and check it against the proxy before saving: whoami
182
+ * decrypts the envelope in memory and answers ok or wrong, never the key.
183
+ * Self-hosted keys sit on the box in the clear, so there is no unlock.
184
+ */
185
+ async function unlockIfNeeded(config, key) {
186
+ if (config.unlock)
187
+ return config.unlock;
188
+ const me = await whoami(config.proxyUrl, { key });
189
+ if ("error" in me || !me.byok || me.unlock === "none")
190
+ return undefined;
191
+ say("");
192
+ say(`This flock has a provider key saved. Your ${bold("unlock")} decrypts it for each call; it is typed without echo and never sent to the console.`);
193
+ for (let attempt = 1; attempt <= UNLOCK_TRIES; attempt += 1) {
194
+ const unlock = await askSecret(`${cyan("?")} Unlock ${dim("(blank to skip)")}: `);
195
+ if (!unlock) {
196
+ warn("No unlock saved. Calls will be blocked until you run tab login again.");
197
+ return undefined;
198
+ }
199
+ const check = await whoami(config.proxyUrl, { key, unlock });
200
+ if ("error" in check) {
201
+ warn(`Could not check the unlock (${check.error}). Saving it as typed.`);
202
+ return unlock;
203
+ }
204
+ if (check.unlock === "ok" || check.unlock === "none") {
205
+ ok("Unlock opens the provider key.");
206
+ return unlock;
207
+ }
208
+ fail(attempt < UNLOCK_TRIES
209
+ ? `That unlock does not open this flock's provider key. Try again (${UNLOCK_TRIES - attempt} left).`
210
+ : "That unlock does not open this flock's provider key. Not saved; run tab login when you have it.");
211
+ }
212
+ return undefined;
213
+ }
214
+ /**
215
+ * `tab login`: get a code, open the console, wait for approval. The machine
216
+ * session arrives over the poll. Nothing about Agents happens here.
217
+ */
218
+ async function login(args, preferredProxy) {
219
+ if (args.includes("--paste") || args.includes("--as")) {
220
+ fail("tab login no longer takes --paste or --as: login is per machine, and each folder picks its Agent with tab use.");
221
+ return 2;
222
+ }
223
+ const existing = await loadConfig();
224
+ // An explicit proxy in the environment never gets a prompt (CI, scripts);
225
+ // otherwise `tab login` always asks, with the current one as the default,
226
+ // so switching between hosted and self-hosted is one keystroke.
227
+ const envProxy = process.env.FLOCKTAB_PROXY_URL?.trim();
228
+ const proxyUrl = envProxy
229
+ ? normalizeProxyUrl(envProxy)
230
+ : (preferredProxy ?? (await chooseProxy(existing?.proxyUrl)));
231
+ const mode = isLocalProxy(proxyUrl) ? "self-hosted" : "hosted";
232
+ const consoleUrl = await consoleUrlFor(proxyUrl);
233
+ let started;
234
+ try {
235
+ started = await startDeviceLogin(consoleUrl);
236
+ }
237
+ catch (err) {
238
+ fail(err instanceof Error ? err.message : String(err));
239
+ say(`Could not reach ${consoleUrl}.${mode === "self-hosted" ? ` Is the local proxy up? ${bold("tab up")}` : ""}`);
240
+ return 1;
241
+ }
242
+ say("");
243
+ say(box([
244
+ `${dim("Code")} ${bold(yellow(started.userCode))}`,
245
+ `${dim("Approve")} ${underline(cyan(started.verifyUrl))}`,
246
+ ], { title: "Approve this machine" }));
247
+ say("");
248
+ say(dim(`Opening the console in your browser. Sign in, check the code, approve this machine (${hostname()}).`));
249
+ openBrowser(started.verifyUrl);
250
+ let result;
251
+ try {
252
+ result = await waitForApproval({
253
+ consoleUrl,
254
+ deviceSecret: started.deviceSecret,
255
+ intervalMs: started.intervalMs,
256
+ deadline: Date.now() + 10 * 60_000,
257
+ });
258
+ }
259
+ catch (err) {
260
+ fail(err instanceof DeviceLoginError ? err.message : String(err));
261
+ return 1;
262
+ }
263
+ ok(`Approved for ${bold(result.flock.name)}`);
264
+ // Keys minted for another flock or proxy are not this login's; drop them.
265
+ const sameFlock = existing?.flockName === result.flock.name && existing?.proxyUrl === proxyUrl;
266
+ const config = {
267
+ proxyUrl,
268
+ mode,
269
+ token: result.token,
270
+ consoleUrl,
271
+ flockName: result.flock.name,
272
+ ...(sameFlock && existing?.unlock ? { unlock: existing.unlock } : {}),
273
+ agents: sameFlock ? (existing?.agents ?? {}) : {},
274
+ };
275
+ const file = await saveConfig(config);
276
+ ok(`Saved to ${dim(file)} ${dim("(owner-only)")}`);
277
+ say("");
278
+ say(`Now run ${bold("tab claude")} or ${bold("tab codex")} in a project folder: the first time, tab asks which Agent that folder runs as.`);
279
+ return 0;
280
+ }
281
+ /** No saved login: run the browser flow instead of failing. */
282
+ async function ensureLogin() {
283
+ const config = await loadConfig();
284
+ if (config)
285
+ return config;
286
+ say(dim("Not logged in yet. Starting tab login."));
287
+ const code = await login([]);
288
+ if (code !== 0)
289
+ return undefined;
290
+ return loadConfig();
291
+ }
292
+ async function ensureProxy(config) {
293
+ const health = await proxyHealth(config.proxyUrl);
294
+ if (health.ok)
295
+ return true;
296
+ if (config.mode !== "self-hosted") {
297
+ fail(`FlockTab proxy at ${config.proxyUrl} is ${health.reason}.`);
298
+ return false;
299
+ }
300
+ warn(`Local proxy is ${health.reason}. Starting it.`);
301
+ if (!(await hasProxyEnv())) {
302
+ const written = await collectProviderKeys();
303
+ if (!written)
304
+ return false;
305
+ }
306
+ return bringUp(config.proxyUrl);
307
+ }
308
+ /** Start the binary and wait for its health line; on failure, show the log's tail. */
309
+ async function bringUp(proxyUrl) {
310
+ const started = await startProxy((t) => say(dim(t)));
311
+ if (!started.ok) {
312
+ fail(started.message);
313
+ return false;
314
+ }
315
+ const ready = await waitHealthy(proxyUrl, 20_000);
316
+ if (!ready.ok) {
317
+ fail(`Proxy started but is not healthy: ${ready.reason}.`);
318
+ for (const line of await tailProxyLog())
319
+ say(dim(` ${line}`));
320
+ say(dim(`Log: ${proxyLogPath()}`));
321
+ return false;
322
+ }
323
+ ok(`Proxy is up at ${proxyUrl} ${dim(`(flocktab-proxy ${PROXY_VERSION}, ${proxyBinPath()})`)}`);
324
+ return true;
325
+ }
326
+ /**
327
+ * The Agent this folder runs as. `.flocktab` names it; the machine config
328
+ * holds its key. Missing either: ask (or, with `.flocktab` present, mint the
329
+ * key silently for this machine), save both, carry on.
330
+ */
331
+ async function resolveAgent(config, opts = {}) {
332
+ const agents = config.agents ?? {};
333
+ // CI and scripts: FLOCKTAB_KEY is the Agent, no folder involved.
334
+ if (agents.env && !opts.pick)
335
+ return agents.env;
336
+ const cwd = process.cwd();
337
+ const project = opts.pick ? undefined : await readProject(cwd);
338
+ if (project && agents[project.agent])
339
+ return agents[project.agent];
340
+ if (!config.token || !config.consoleUrl) {
341
+ fail(`This machine is not logged in. Run ${bold("tab login")}.`);
342
+ return undefined;
343
+ }
344
+ let listed;
345
+ try {
346
+ listed = await listAgents(config.consoleUrl, config.token);
347
+ }
348
+ catch (err) {
349
+ fail(err instanceof ConsoleApiError && err.status === 401 ? `Session expired. Run ${bold("tab login")}.` : String(err instanceof Error ? err.message : err));
350
+ return undefined;
351
+ }
352
+ const byName = new Map(listed.agents.map((a) => [a.slug, a]));
353
+ let chosen;
354
+ let created;
355
+ if (project) {
356
+ chosen = byName.get(project.agent);
357
+ if (!chosen) {
358
+ warn(`${project.file} names Agent ${bold(project.agent)}, which ${listed.flock.name} no longer has.`);
359
+ }
360
+ }
361
+ if (!chosen) {
362
+ const root = await projectRoot(cwd);
363
+ const suggested = agentNameFor(root);
364
+ say(heading(`Which Agent does this folder run as?`));
365
+ say(dim(`${root} · flock ${listed.flock.name}`));
366
+ const options = listed.agents;
367
+ say(rows([
368
+ ...options.map((a, i) => [
369
+ `${bold(String(i + 1))} ${a.name}`,
370
+ `${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") : ""}`,
371
+ ]),
372
+ [`${bold("n")} New Agent`, dim(`named ${suggested}, $50.00 cap`)],
373
+ ]));
374
+ const answer = await ask(`Choose 1-${options.length} or ${bold("n")} ${dim("[n]")}: `);
375
+ if (answer === "" || answer.toLowerCase() === "n") {
376
+ const name = (await ask(`${cyan("?")} Agent name ${dim(`[${suggested}]`)}: `)) || suggested;
377
+ created = { name };
378
+ }
379
+ else {
380
+ const index = Number.parseInt(answer, 10);
381
+ chosen = options[index - 1];
382
+ if (!chosen) {
383
+ fail("That is not one of the options.");
384
+ return undefined;
385
+ }
386
+ }
387
+ }
388
+ let issued;
389
+ try {
390
+ if (created) {
391
+ issued = await createAgent(config.consoleUrl, config.token, { name: created.name });
392
+ ok(`Created Agent ${bold(issued.agent.name)} with a $50.00 cap. Change it in the console.`);
393
+ }
394
+ else if (chosen) {
395
+ if (chosen.hasKey && !agents[chosen.slug]) {
396
+ warn(`${chosen.name} already has a key on another machine. Continuing replaces it there.`);
397
+ const go = await ask(`Replace it? ${dim("[y/N]")}: `);
398
+ if (!/^y(es)?$/i.test(go))
399
+ return undefined;
400
+ }
401
+ issued = await issueAgentKey(config.consoleUrl, config.token, chosen.id);
402
+ }
403
+ else {
404
+ return undefined;
405
+ }
406
+ }
407
+ catch (err) {
408
+ fail(err instanceof Error ? err.message : String(err));
409
+ return undefined;
410
+ }
411
+ const login = { key: issued.key, agentId: issued.agent.id, agentName: issued.agent.name };
412
+ const next = { ...config, agents: { ...agents, [issued.agent.slug]: login } };
413
+ if (listed.flock.byok && config.mode === "hosted") {
414
+ const unlock = await unlockIfNeeded(next, issued.key);
415
+ if (unlock)
416
+ next.unlock = unlock;
417
+ }
418
+ await saveConfig(next);
419
+ config.agents = next.agents ?? {};
420
+ if (next.unlock)
421
+ config.unlock = next.unlock;
422
+ const file = await writeProject(await projectRoot(cwd), { agent: issued.agent.slug });
423
+ ok(`This folder runs as ${bold(issued.agent.name)} ${dim(`(${file})`)}`);
424
+ return login;
425
+ }
426
+ async function status() {
427
+ const config = await loadConfig();
428
+ if (!config) {
429
+ fail(`Not logged in. Run ${bold("tab login")}.`);
430
+ return 2;
431
+ }
432
+ const health = await proxyHealth(config.proxyUrl);
433
+ const proxyLine = `${config.proxyUrl} ${dim(`(${config.mode})`)} ${health.ok ? green("up") : yellow(health.reason)}`;
434
+ const pairs = [["proxy", proxyLine]];
435
+ if (config.flockName)
436
+ pairs.push(["flock", bold(config.flockName)]);
437
+ const project = await readProject(process.cwd());
438
+ const here = project ? config.agents?.[project.agent] : undefined;
439
+ pairs.push(["folder", project ? (here ? bold(here.agentName) : yellow(`${project.agent} (no key on this machine; run tab use)`)) : dim("no Agent yet; tab claude asks, or tab use")]);
440
+ let tone = health.ok ? "ok" : "warn";
441
+ if (health.ok && here) {
442
+ const me = await whoami(config.proxyUrl, { key: here.key, unlock: config.unlock });
443
+ if ("error" in me) {
444
+ pairs.push(["key", yellow(me.error)]);
445
+ tone = "warn";
446
+ }
447
+ else {
448
+ pairs.push(["plan", me.flock.plan]);
449
+ const byok = !me.byok
450
+ ? dim("no provider key on this flock")
451
+ : me.unlock === "none"
452
+ ? green(me.mode === "self-hosted" ? "provider keys on this box" : "provider key saved")
453
+ : me.unlock === "wrong"
454
+ ? yellow("provider key saved, unlock is WRONG: run tab use")
455
+ : me.unlock === "missing"
456
+ ? yellow("provider key saved, NO unlock: run tab use")
457
+ : green("provider key saved, unlock ok");
458
+ pairs.push(["byok", byok]);
459
+ if (me.byok && (me.unlock === "wrong" || me.unlock === "missing"))
460
+ tone = "warn";
461
+ }
462
+ }
463
+ say(box(rows(pairs, 0).split("\n"), { title: "FlockTab", tone }));
464
+ return tone === "ok" ? 0 : 1;
465
+ }
466
+ async function runAgent(name, args) {
467
+ const config = await ensureLogin();
468
+ if (!config)
469
+ return 2;
470
+ if (!(await ensureProxy(config)))
471
+ return 1;
472
+ const agent = await resolveAgent(config);
473
+ if (!agent)
474
+ return 1;
475
+ const key = presentedKey({ key: agent.key, unlock: config.unlock });
476
+ reportFolder(config.proxyUrl, key);
477
+ const { spec, env } = envFor({ name, proxyUrl: config.proxyUrl, presentedKey: key });
478
+ if (spec.isolatedHome === "codex") {
479
+ env.CODEX_HOME = await prepareCodexHome({
480
+ baseDir: configDir(),
481
+ proxyUrl: config.proxyUrl,
482
+ presentedKey: key,
483
+ mode: config.mode,
484
+ });
485
+ }
486
+ const child = spawn(spec.command, [...(spec.args ?? []), ...args], { stdio: "inherit", env });
487
+ return new Promise((resolve) => {
488
+ child.on("error", (err) => {
489
+ if (err.code === "ENOENT") {
490
+ fail(`${spec.label} is not installed (${spec.command} not on PATH). ${dim(spec.install)}`);
491
+ resolve(127);
492
+ return;
493
+ }
494
+ fail(err.message);
495
+ resolve(1);
496
+ });
497
+ child.on("close", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
498
+ });
499
+ }
500
+ /** `tab use`: pick or change the Agent for this folder. */
501
+ async function use() {
502
+ const config = await ensureLogin();
503
+ if (!config)
504
+ return 2;
505
+ if (!(await ensureProxy(config)))
506
+ return 1;
507
+ const agent = await resolveAgent(config, { pick: true });
508
+ return agent ? 0 : 1;
509
+ }
510
+ /**
511
+ * First `tab up` on a box: the provider keys the proxy will spend, typed
512
+ * without echo into ~/.flocktab/proxy.env (owner-only). They never go
513
+ * anywhere else; flocktab.com only ever sees reserve and settle.
514
+ */
515
+ async function collectProviderKeys() {
516
+ say(heading("Provider keys for this machine"));
517
+ say(`They stay in ${dim(proxyEnvPath())} and are used by the proxy here. flocktab.com never sees them. Leave one blank to skip it; add more later in that file as ${dim("FLOCKTAB_PROVIDER_KEY_<PROVIDER>")}.`);
518
+ const keys = {};
519
+ for (const [id, label] of [
520
+ ["openai", "OpenAI"],
521
+ ["anthropic", "Anthropic"],
522
+ ["xai", "xAI"],
523
+ ]) {
524
+ keys[id] = await askSecret(`${cyan("?")} ${label} key ${dim("(blank to skip)")}: `);
525
+ }
526
+ if (!Object.values(keys).some((v) => v.trim())) {
527
+ fail("No keys typed. The proxy has nothing to spend; run tab up again when you have one.");
528
+ return false;
529
+ }
530
+ const file = await writeProxyEnv({ control: HOSTED_PROXY, keys });
531
+ ok(`Saved to ${dim(file)} ${dim("(owner-only)")}`);
532
+ return true;
533
+ }
534
+ async function selfHosted(action) {
535
+ const config = await loadConfig();
536
+ const proxyUrl = config?.mode === "self-hosted" ? config.proxyUrl : LOCAL_PROXY;
537
+ if (action === "down") {
538
+ const stopped = await stopProxy();
539
+ (stopped ? ok : warn)(stopped ? "Proxy stopped." : "Proxy was not running.");
540
+ return 0;
541
+ }
542
+ if (action === "update") {
543
+ await stopProxy();
544
+ if (packagedBinPath()) {
545
+ // The binary is part of the npm install; the newest one comes with the newest tab.
546
+ say(`The proxy ships with tab. Updating tab updates it: ${bold("npm i -g @hanamorilabs/tab@latest")}`);
547
+ const code = await new Promise((resolve) => {
548
+ const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["i", "-g", "@hanamorilabs/tab@latest"], { stdio: "inherit" });
549
+ child.on("error", () => resolve(1));
550
+ child.on("close", (c) => resolve(c ?? 1));
551
+ });
552
+ if (code !== 0) {
553
+ fail("npm could not update tab.");
554
+ return 1;
555
+ }
556
+ }
557
+ else {
558
+ const fetched = await downloadProxy((t) => say(dim(t)));
559
+ if (!fetched.ok) {
560
+ fail(fetched.message);
561
+ return 1;
562
+ }
563
+ ok(`Fetched flocktab-proxy ${PROXY_VERSION}.`);
564
+ }
565
+ }
566
+ if (!(await hasProxyEnv())) {
567
+ const written = await collectProviderKeys();
568
+ if (!written)
569
+ return 2;
570
+ }
571
+ if (!(await bringUp(proxyUrl)))
572
+ return 1;
573
+ if (!config)
574
+ say(`Next: ${bold("tab login")} and pick 2 Self-hosted to approve this machine.`);
575
+ return 0;
576
+ }
577
+ async function main(argv) {
578
+ const [command, ...rest] = argv;
579
+ switch (command) {
580
+ case undefined:
581
+ case "-h":
582
+ case "--help":
583
+ case "help":
584
+ usage();
585
+ return command === undefined ? 2 : 0;
586
+ case "login":
587
+ return login(rest);
588
+ case "logout":
589
+ await clearConfig();
590
+ ok("Forgot the session and keys on this machine.");
591
+ return 0;
592
+ case "use":
593
+ return use();
594
+ case "status":
595
+ return status();
596
+ case "up":
597
+ return selfHosted("up");
598
+ case "down":
599
+ return selfHosted("down");
600
+ case "update":
601
+ return selfHosted("update");
602
+ default:
603
+ return runAgent(command, rest);
604
+ }
605
+ }
606
+ main(process.argv.slice(2))
607
+ .then((code) => process.exit(code))
608
+ .catch((err) => {
609
+ fail(err instanceof Error ? err.message : String(err));
610
+ process.exit(1);
611
+ });