@hanamorilabs/tab 0.1.3 → 0.1.5

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
@@ -45,8 +45,8 @@ kill switch stay on flocktab.com. It is one small Rust binary. `tab up` asks
45
45
  for the keys once (typed without echo, saved owner-only to
46
46
  `~/.flocktab/proxy.env`), fetches `flocktab-proxy` for this machine into
47
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 Rust, no checkout: the
48
+ `tab down` stops it; `tab update` installs the newest tab, which brings the
49
+ newest proxy, and restarts it; `tab claude` starts it when it is down. No Docker, no Rust, no checkout: the
50
50
  binary comes with `tab` as the `@hanamorilabs/flocktab-proxy-<platform>`
51
51
  package for your machine. Then
52
52
  `tab login`, pick **2 Self-hosted**: the proxy says which console it settles
@@ -64,11 +64,34 @@ tab alias remove <name>... undo that; `tab alias list` shows them
64
64
  tab status flock, folder Agent, proxy health, provider key state
65
65
  tab version tab and proxy versions
66
66
  tab up | down self-hosted: start or stop the local proxy
67
- tab update self-hosted: newest proxy (npm i -g @hanamorilabs/tab@latest)
67
+ tab update the newest tab and proxy from npm; restarts a running local proxy
68
68
  tab logout forget the session and keys on this machine
69
69
  tab help this list
70
70
  ```
71
71
 
72
+ ## The console from the terminal
73
+
74
+ Everything the console does to a tab, `tab` does too, with the machine
75
+ session from `tab login`. An Agent is named by slug or name; left out, it is
76
+ this folder's Agent. Every read takes `--json`.
77
+
78
+ ```
79
+ tab list every Agent: state, spent of cap, window, project
80
+ tab close my-project tab open my-project the kill switch (402 tab_closed on the next call)
81
+ tab cap my-project 25 --window week cap in dollars, and the window
82
+ tab rename my-project "My project (nightly)" the slug stays
83
+ tab policy my-project --velocity 60 --models gpt-5,claude-sonnet-5 --allow email.send
84
+ tab project my-project Foley --create attribute to a project (--repo owner/name; none clears)
85
+ tab projects add Foley projects
86
+ tab key rotate my-project [--show] new key, kept on this machine; --show prints it once
87
+ tab archive my-project closes for good, leaves the bill (asks first; -y skips)
88
+ tab ledger [my-project] --limit 20 --blocked one row per call: settled of held, status, reason
89
+ tab spend [agent|project|day] --since 7d meter and outside spend, the console's numbers
90
+ tab outside connections, per-project outside spend, unattributed
91
+ tab live --watch 4 who is working now, refreshed every 4 seconds
92
+ tab web [my-project] the Agent's console page in the browser
93
+ ```
94
+
72
95
  ## Aliases: plain `codex` on the tab
73
96
 
74
97
  ```
package/dist/cli.js CHANGED
@@ -11,7 +11,8 @@
11
11
  * tab alias remove codex undo that; `tab alias list` shows them
12
12
  * tab status which flock, which Agent here, is the proxy up
13
13
  * tab version tab and proxy versions
14
- * tab up | down | update self-hosted: start, stop, or fetch the newest proxy
14
+ * tab up | down self-hosted: start or stop the local proxy
15
+ * tab update the newest tab and proxy from npm
15
16
  * tab logout forget the session and keys
16
17
  *
17
18
  * Login is per machine. The Agent is per folder: the first `tab claude` in
@@ -31,6 +32,7 @@ import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } fr
31
32
  import { clearConfig, configDir, configPath, HOSTED_PROXY, isLocalProxy, loadConfig, LOCAL_PROXY, normalizeProxyUrl, presentedKey, saveConfig, } from "./config.js";
32
33
  import { reportFolder } from "./folder.js";
33
34
  import { listAliases, pathLine, pathWithoutShims, removeAlias, shimDir, shimDirOnPath, validAliasName, writeAlias } from "./alias.js";
35
+ import * as manage from "./manage.js";
34
36
  import { agentNameFor, projectRoot, readProject, writeProject } from "./project.js";
35
37
  import { downloadProxy, hasProxyEnv, packagedBinPath, proxyBinPath, proxyEnvPath, proxyInstalled, proxyLogPath, PROXY_VERSION, startProxy, stopProxy, tailProxyLog, writeProxyEnv, } from "./proxy-bin.js";
36
38
  import { proxyHealth, waitHealthy } from "./selfhost.js";
@@ -55,9 +57,24 @@ function usage() {
55
57
  cmd("alias remove <name>...", "undo that; `tab alias list` shows them"),
56
58
  cmd("status", "which flock, which Agent here, is the proxy up"),
57
59
  cmd("version", "tab and proxy versions"),
60
+ cmd("list", "every Agent: state, spent of cap, window, project"),
61
+ cmd("close | open [agent]", "the kill switch; omit the Agent for this folder's"),
62
+ cmd("cap [agent] <dollars>", "set the cap (--window day|week|month|run|hour|lifetime)"),
63
+ cmd("rename <agent> <name>", "rename it (the slug stays)"),
64
+ cmd("policy [agent]", "show or set: --velocity N|none --models a,b|none --allow tool|none"),
65
+ cmd("project [agent] <name>", "attribute to a project (--create, --repo owner/name, none)"),
66
+ cmd("projects [add <name>]", "list or add projects"),
67
+ cmd("key rotate [agent]", "new key, kept on this machine (--show prints it once)"),
68
+ cmd("archive [agent]", "close for good and leave the bill"),
69
+ cmd("ledger [agent]", "one row per call (--limit N --blocked)"),
70
+ cmd("spend [agent|project|day]", "meter and outside spend (--since 7d --agent x)"),
71
+ cmd("outside", "outside-spend connections and per-project totals"),
72
+ cmd("live", "who is working now (--watch 4)"),
73
+ cmd("web [agent]", "open the Agent's console page"),
74
+ cmd("... --json", "every read command as JSON, for scripts and agents"),
58
75
  cmd("up", "self-hosted: start the local proxy (fetches it the first time)"),
59
76
  cmd("down", "self-hosted: stop the local proxy"),
60
- cmd("update", "self-hosted: fetch the newest proxy and restart it"),
77
+ cmd("update", "the newest tab (and proxy) from npm; restarts a running local proxy"),
61
78
  cmd("logout", "forget the session and keys"),
62
79
  ].map(([k, v]) => [cyan(k), v])));
63
80
  say(`
@@ -605,6 +622,48 @@ async function collectProviderKeys() {
605
622
  ok(`Saved to ${dim(file)} ${dim("(owner-only)")}`);
606
623
  return true;
607
624
  }
625
+ /**
626
+ * `tab update`: the newest tab from npm, which carries the newest proxy as
627
+ * its platform package. A self-hosted proxy that was running is restarted
628
+ * on the new binary; a hosted machine just gets the new tab.
629
+ */
630
+ async function update() {
631
+ const config = await loadConfig();
632
+ const wasUp = config?.mode === "self-hosted" && (await proxyHealth(config.proxyUrl)).ok;
633
+ say(dim(`tab ${TAB_VERSION} now; asking npm for the newest.`));
634
+ const code = await new Promise((resolve) => {
635
+ const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["i", "-g", "@hanamorilabs/tab@latest"], {
636
+ stdio: ["ignore", "ignore", "inherit"],
637
+ shell: process.platform === "win32",
638
+ });
639
+ child.on("error", () => resolve(1));
640
+ child.on("close", (c) => resolve(c ?? 1));
641
+ });
642
+ if (code !== 0) {
643
+ fail(`npm could not update tab. Try: ${bold("npm i -g @hanamorilabs/tab@latest")}`);
644
+ return 1;
645
+ }
646
+ // The version now on PATH is the new install's, not this process's.
647
+ const installed = await new Promise((resolve) => {
648
+ const child = spawn(process.platform === "win32" ? "tab.cmd" : "tab", ["version"], { shell: process.platform === "win32" });
649
+ let text = "";
650
+ child.stdout?.on("data", (d) => (text += d.toString()));
651
+ child.stderr?.on("data", (d) => (text += d.toString()));
652
+ child.on("close", () => resolve(text.trim()));
653
+ child.on("error", () => resolve(""));
654
+ });
655
+ ok(installed ? `Updated.
656
+ ${installed}` : "Updated.");
657
+ if (!wasUp) {
658
+ if (!packagedBinPath() && config?.mode === "self-hosted") {
659
+ const fetched = await downloadProxy((t) => say(dim(t)));
660
+ (fetched.ok ? ok : fail)(fetched.ok ? `Fetched flocktab-proxy ${PROXY_VERSION}.` : fetched.message);
661
+ }
662
+ return 0;
663
+ }
664
+ await stopProxy();
665
+ return (await bringUp(config.proxyUrl)) ? 0 : 1;
666
+ }
608
667
  async function selfHosted(action) {
609
668
  const config = await loadConfig();
610
669
  const proxyUrl = config?.mode === "self-hosted" ? config.proxyUrl : LOCAL_PROXY;
@@ -613,30 +672,6 @@ async function selfHosted(action) {
613
672
  (stopped ? ok : warn)(stopped ? "Proxy stopped." : "Proxy was not running.");
614
673
  return 0;
615
674
  }
616
- if (action === "update") {
617
- await stopProxy();
618
- if (packagedBinPath()) {
619
- // The binary is part of the npm install; the newest one comes with the newest tab.
620
- say(`The proxy ships with tab. Updating tab updates it: ${bold("npm i -g @hanamorilabs/tab@latest")}`);
621
- const code = await new Promise((resolve) => {
622
- const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["i", "-g", "@hanamorilabs/tab@latest"], { stdio: "inherit" });
623
- child.on("error", () => resolve(1));
624
- child.on("close", (c) => resolve(c ?? 1));
625
- });
626
- if (code !== 0) {
627
- fail("npm could not update tab.");
628
- return 1;
629
- }
630
- }
631
- else {
632
- const fetched = await downloadProxy((t) => say(dim(t)));
633
- if (!fetched.ok) {
634
- fail(fetched.message);
635
- return 1;
636
- }
637
- ok(`Fetched flocktab-proxy ${PROXY_VERSION}.`);
638
- }
639
- }
640
675
  if (!(await hasProxyEnv())) {
641
676
  const written = await collectProviderKeys();
642
677
  if (!written)
@@ -648,6 +683,52 @@ async function selfHosted(action) {
648
683
  say(`Next: ${bold("tab login")} and pick 2 Self-hosted to approve this machine.`);
649
684
  return 0;
650
685
  }
686
+ /** Console management from the terminal; every command needs the machine session. */
687
+ async function managed(command, rest) {
688
+ const config = await ensureLogin();
689
+ if (!config)
690
+ return 2;
691
+ const flags = manage.parseFlags(rest);
692
+ try {
693
+ switch (command) {
694
+ case "list":
695
+ case "ls":
696
+ return await manage.list(config, flags);
697
+ case "close":
698
+ return await manage.setState(config, flags, "closed");
699
+ case "open":
700
+ return await manage.setState(config, flags, "open");
701
+ case "cap":
702
+ return await manage.cap(config, flags);
703
+ case "rename":
704
+ return await manage.rename(config, flags);
705
+ case "policy":
706
+ return await manage.policy(config, flags);
707
+ case "project":
708
+ return await manage.project(config, flags);
709
+ case "projects":
710
+ return await manage.projects(config, flags);
711
+ case "key":
712
+ return await manage.key(config, flags);
713
+ case "archive":
714
+ return await manage.archive(config, flags, ask);
715
+ case "ledger":
716
+ return await manage.ledger(config, flags);
717
+ case "spend":
718
+ return await manage.spend(config, flags);
719
+ case "outside":
720
+ return await manage.outside(config, flags);
721
+ case "live":
722
+ return await manage.live(config, flags);
723
+ default:
724
+ return await manage.web(config, flags);
725
+ }
726
+ }
727
+ catch (err) {
728
+ fail(err instanceof Error ? err.message : String(err));
729
+ return 1;
730
+ }
731
+ }
651
732
  async function main(argv) {
652
733
  const [command, ...rest] = argv;
653
734
  switch (command) {
@@ -673,6 +754,23 @@ async function main(argv) {
673
754
  case "--version":
674
755
  case "-v":
675
756
  return version();
757
+ case "list":
758
+ case "ls":
759
+ case "close":
760
+ case "open":
761
+ case "cap":
762
+ case "rename":
763
+ case "policy":
764
+ case "project":
765
+ case "projects":
766
+ case "key":
767
+ case "archive":
768
+ case "ledger":
769
+ case "spend":
770
+ case "outside":
771
+ case "live":
772
+ case "web":
773
+ return managed(command, rest);
676
774
  case "status":
677
775
  return status();
678
776
  case "up":
@@ -680,7 +778,7 @@ async function main(argv) {
680
778
  case "down":
681
779
  return selfHosted("down");
682
780
  case "update":
683
- return selfHosted("update");
781
+ return update();
684
782
  default:
685
783
  return runAgent(command, rest);
686
784
  }
package/dist/manage.js ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * The console from the terminal: tabs, caps, policies, keys, projects, the
3
+ * ledger and spend, all through the machine session from `tab login`. Every
4
+ * read takes `--json` for scripts and other agents. An Agent argument is a
5
+ * slug or a name; omitted, it is this folder's Agent from `.flocktab`.
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import { readProject } from "./project.js";
9
+ import { saveConfig } from "./config.js";
10
+ import { ConsoleApiError } from "./console-api.js";
11
+ import { bold, dim, green, money, red, rows, table, yellow } from "./ui.js";
12
+ export class ManageError extends Error {
13
+ }
14
+ /** One place to talk to `/api/cli/*` with the session. */
15
+ export function api(config, fetchImpl = fetch) {
16
+ const base = config.consoleUrl;
17
+ const token = config.token;
18
+ if (!base || !token)
19
+ throw new ManageError("This machine is not logged in. Run tab login.");
20
+ return async (method, path, body) => {
21
+ const res = await fetchImpl(`${base}${path}`, {
22
+ method,
23
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
24
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
25
+ });
26
+ const json = (await res.json().catch(() => ({})));
27
+ if (!res.ok) {
28
+ const detail = json.error ?? `console answered ${res.status}`;
29
+ throw new ConsoleApiError(res.status === 401 ? "Session expired. Run tab login." : json.hint ? `${detail} (${json.hint})` : detail, res.status);
30
+ }
31
+ return json;
32
+ };
33
+ }
34
+ /** `--json`, `--yes`, `--name value` or `--name=value`; the rest are positional. */
35
+ export function parseFlags(argv) {
36
+ const flags = { json: false, yes: false, opts: {}, args: [] };
37
+ for (let i = 0; i < argv.length; i += 1) {
38
+ const a = argv[i];
39
+ if (a === "--json")
40
+ flags.json = true;
41
+ else if (a === "--yes" || a === "-y")
42
+ flags.yes = true;
43
+ else if (a.startsWith("--")) {
44
+ const eq = a.indexOf("=");
45
+ if (eq > 0)
46
+ flags.opts[a.slice(2, eq)] = a.slice(eq + 1);
47
+ else {
48
+ const next = argv[i + 1];
49
+ if (next !== undefined && !next.startsWith("--")) {
50
+ flags.opts[a.slice(2)] = next;
51
+ i += 1;
52
+ }
53
+ else
54
+ flags.opts[a.slice(2)] = "";
55
+ }
56
+ }
57
+ else
58
+ flags.args.push(a);
59
+ }
60
+ return flags;
61
+ }
62
+ async function tabsOf(call) {
63
+ return (await call("GET", "/api/cli/tabs")).tabs;
64
+ }
65
+ /** Slug or name (case-insensitive); omitted means this folder's Agent. */
66
+ export async function resolveSlug(call, given, cwd = process.cwd()) {
67
+ const tabs = await tabsOf(call);
68
+ if (!given) {
69
+ const project = await readProject(cwd);
70
+ if (!project)
71
+ throw new ManageError("No Agent named and this folder has none yet (tab use), so say which: tab <command> <agent>.");
72
+ return project.agent;
73
+ }
74
+ const want = given.trim().toLowerCase();
75
+ const hit = tabs.find((t) => t.slug.toLowerCase() === want) ?? tabs.find((t) => t.name.toLowerCase() === want);
76
+ if (!hit)
77
+ throw new ManageError(`No Agent called ${given}. tab list shows them.`);
78
+ return hit.slug;
79
+ }
80
+ const state = (s) => (s === "open" ? green("open") : red(s));
81
+ const pct = (spent, cap) => {
82
+ const c = BigInt(cap || "0");
83
+ return c === 0n ? "" : `${Number((BigInt(spent) * 100n) / c)}%`;
84
+ };
85
+ function out(text) {
86
+ console.log(text);
87
+ }
88
+ function emit(json, value, text) {
89
+ out(json ? JSON.stringify(value, null, 2) : text());
90
+ }
91
+ export async function list(config, flags) {
92
+ const tabs = await tabsOf(api(config));
93
+ emit(flags.json, { tabs }, () => tabs.length === 0
94
+ ? dim("No Agents yet. Run tab claude or tab codex in a project folder.")
95
+ : table(["agent", "state", "spent", "cap", "used", "window", "project"], tabs.map((t) => [t.name === t.slug ? t.slug : `${t.name} ${dim(t.slug)}`, state(t.state), money(t.spentCents), money(t.capCents), pct(t.spentCents, t.capCents), t.window, t.projectName ?? dim("-")]), { right: [2, 3, 4] }));
96
+ return 0;
97
+ }
98
+ export async function setState(config, flags, next) {
99
+ const call = api(config);
100
+ const slug = await resolveSlug(call, flags.args[0]);
101
+ const { tab } = await call("POST", `/api/cli/tabs/${encodeURIComponent(slug)}/state`, { state: next });
102
+ emit(flags.json, { tab }, () => `${bold(tab.name)} is ${state(tab.state)}${next === "closed" ? dim(" (the next call answers 402 tab_closed)") : ""}`);
103
+ return 0;
104
+ }
105
+ export async function cap(config, flags) {
106
+ const call = api(config);
107
+ const [given, dollars] = flags.args.length >= 2 ? [flags.args[0], flags.args[1]] : [undefined, flags.args[0]];
108
+ if (!dollars && !flags.opts.window)
109
+ throw new ManageError("tab cap [agent] <dollars> [--window day|week|month|run|hour|lifetime]");
110
+ const slug = await resolveSlug(call, given);
111
+ const body = {};
112
+ if (dollars)
113
+ body.capDollars = dollars.replace(/^\$/, "");
114
+ if (flags.opts.window)
115
+ body.window = flags.opts.window;
116
+ const { tab } = await call("PATCH", `/api/cli/tabs/${encodeURIComponent(slug)}`, body);
117
+ emit(flags.json, { tab }, () => `${bold(tab.name)}: cap ${money(tab.capCents)} per ${tab.window}, ${money(tab.spentCents)} spent`);
118
+ return 0;
119
+ }
120
+ export async function rename(config, flags) {
121
+ const call = api(config);
122
+ if (flags.args.length < 2)
123
+ throw new ManageError('tab rename <agent> "new name"');
124
+ const slug = await resolveSlug(call, flags.args[0]);
125
+ const name = flags.args.slice(1).join(" ");
126
+ const { tab } = await call("PATCH", `/api/cli/tabs/${encodeURIComponent(slug)}`, { name });
127
+ emit(flags.json, { tab }, () => `Renamed to ${bold(tab.name)} ${dim(`(slug stays ${tab.slug})`)}`);
128
+ return 0;
129
+ }
130
+ export async function archive(config, flags, ask) {
131
+ const call = api(config);
132
+ const slug = await resolveSlug(call, flags.args[0]);
133
+ if (!flags.yes) {
134
+ const go = await ask(`Archive ${bold(slug)}? Its tab closes for good and it leaves the bill. [y/N]: `);
135
+ if (!/^y(es)?$/i.test(go))
136
+ return 1;
137
+ }
138
+ const { tab } = await call("DELETE", `/api/cli/tabs/${encodeURIComponent(slug)}`);
139
+ emit(flags.json, { tab, archived: true }, () => `${bold(tab.name)} archived.`);
140
+ return 0;
141
+ }
142
+ function listOpt(raw) {
143
+ if (raw === undefined)
144
+ return undefined;
145
+ if (raw.trim().toLowerCase() === "none" || raw.trim() === "")
146
+ return [];
147
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
148
+ }
149
+ export async function policy(config, flags) {
150
+ const call = api(config);
151
+ const slug = await resolveSlug(call, flags.args[0]);
152
+ const patch = {};
153
+ if (flags.opts.velocity !== undefined) {
154
+ patch.velocityMaxCalls = flags.opts.velocity.toLowerCase() === "none" ? null : Number(flags.opts.velocity);
155
+ }
156
+ const models = listOpt(flags.opts.models);
157
+ if (models)
158
+ patch.models = models;
159
+ const allow = listOpt(flags.opts.allow);
160
+ if (allow)
161
+ patch.irreversibleTools = allow;
162
+ const path = `/api/cli/tabs/${encodeURIComponent(slug)}`;
163
+ const { tab, policy } = Object.keys(patch).length > 0
164
+ ? await call("PATCH", path, { policy: patch })
165
+ : await call("GET", path);
166
+ emit(flags.json, { tab, policy }, () => `${bold(tab.name)}\n` +
167
+ rows([
168
+ ["cap", `${money(tab.capCents)} per ${tab.window}`],
169
+ ["velocity", policy?.velocityMaxCalls ? `${policy.velocityMaxCalls} calls/min` : dim("ledger default")],
170
+ ["models", policy?.models.length ? policy.models.join(", ") : dim("any")],
171
+ ["irreversible", policy?.irreversibleTools.length ? policy.irreversibleTools.join(", ") : dim("none allowed")],
172
+ ]) +
173
+ `\n${dim("Change with --velocity N|none --models a,b|none --allow tool,tool|none")}`);
174
+ return 0;
175
+ }
176
+ export async function projects(config, flags) {
177
+ const call = api(config);
178
+ if (flags.args[0] === "add") {
179
+ const name = flags.args.slice(1).join(" ");
180
+ if (!name)
181
+ throw new ManageError("tab projects add <name>");
182
+ const { project } = await call("POST", "/api/cli/projects", { name });
183
+ emit(flags.json, { project }, () => `Created project ${bold(project.name)}`);
184
+ return 0;
185
+ }
186
+ const { projects } = await call("GET", "/api/cli/projects");
187
+ emit(flags.json, { projects }, () => (projects.length === 0 ? dim("No projects. tab projects add <name>") : projects.map((p) => ` ${p.name}`).join("\n")));
188
+ return 0;
189
+ }
190
+ /** `tab project [agent] <project name|none> [--repo owner/name]` */
191
+ export async function project(config, flags) {
192
+ const call = api(config);
193
+ const [given, ...rest] = flags.args.length >= 2 ? [flags.args[0], ...flags.args.slice(1)] : [undefined, ...flags.args];
194
+ const target = rest.join(" ").trim();
195
+ if (!target && flags.opts.repo === undefined)
196
+ throw new ManageError("tab project [agent] <project name|none> [--repo owner/name]");
197
+ const slug = await resolveSlug(call, given);
198
+ const body = {};
199
+ if (target) {
200
+ if (target.toLowerCase() === "none")
201
+ body.projectId = null;
202
+ else {
203
+ const { projects } = await call("GET", "/api/cli/projects");
204
+ let hit = projects.find((p) => p.name.toLowerCase() === target.toLowerCase());
205
+ if (!hit) {
206
+ if (!flags.opts.create && flags.opts.create !== "")
207
+ throw new ManageError(`No project called ${target}. Add --create to make it.`);
208
+ hit = (await call("POST", "/api/cli/projects", { name: target })).project;
209
+ }
210
+ body.projectId = hit.id;
211
+ }
212
+ }
213
+ if (flags.opts.repo !== undefined)
214
+ body.githubRepo = flags.opts.repo || null;
215
+ const { tab } = await call("PATCH", `/api/cli/tabs/${encodeURIComponent(slug)}`, body);
216
+ emit(flags.json, { tab }, () => `${bold(tab.name)} → project ${tab.projectName ?? dim("none")}${tab.githubRepo ? dim(` repo ${tab.githubRepo}`) : ""}`);
217
+ return 0;
218
+ }
219
+ /** `tab key rotate [agent]`: a fresh key; this machine keeps using the Agent without a new login. */
220
+ export async function key(config, flags) {
221
+ const call = api(config);
222
+ if (flags.args[0] !== "rotate")
223
+ throw new ManageError("tab key rotate [agent] [--show]");
224
+ const slug = await resolveSlug(call, flags.args[1]);
225
+ const issued = await call("POST", `/api/cli/tabs/${encodeURIComponent(slug)}/key`);
226
+ const agents = { ...(config.agents ?? {}), [issued.agent.slug]: { key: issued.key, agentId: issued.agent.id, agentName: issued.agent.name } };
227
+ await saveConfig({ ...config, agents });
228
+ emit(flags.json, { agent: issued.agent, rotated: issued.rotated, ...(flags.opts.show !== undefined ? { key: issued.key } : {}) }, () => `${bold(issued.agent.name)}: new key saved on this machine${issued.rotated ? "; anything using the old one is refused from now on" : ""}.` +
229
+ (flags.opts.show !== undefined ? `\n${issued.key}` : `\n${dim("Add --show to print it once.")}`));
230
+ return 0;
231
+ }
232
+ export async function ledger(config, flags) {
233
+ const call = api(config);
234
+ const params = new URLSearchParams();
235
+ if (flags.args[0])
236
+ params.set("agent", await resolveSlug(call, flags.args[0]));
237
+ if (flags.opts.limit)
238
+ params.set("limit", flags.opts.limit);
239
+ if (flags.opts.blocked !== undefined)
240
+ params.set("blocked", "1");
241
+ const { rows: list, timezone } = await call("GET", `/api/cli/ledger?${params}`);
242
+ emit(flags.json, { timezone, rows: list }, () => list.length === 0
243
+ ? dim("No decisions yet.")
244
+ : table(["time", "agent", "action", "amount", "status", "reason"], list.map((r) => {
245
+ const status = (r.status ?? r.decision).toUpperCase();
246
+ const paint = status === "BLOCKED" || status === "CLOSED" ? red : status === "PENDING" ? yellow : green;
247
+ const held = r.reservedCents && r.reservedCents !== r.cents ? dim(` of ${money(r.reservedCents)}`) : "";
248
+ return [r.at, r.agent, r.action.length > 28 ? `${r.action.slice(0, 27)}…` : r.action, `${money(r.cents)}${held}`, paint(status), r.reason];
249
+ }), { right: [3] }) + `\n${dim(`times in ${timezone}`)}`);
250
+ return 0;
251
+ }
252
+ export async function spend(config, flags) {
253
+ const call = api(config);
254
+ const by = flags.opts.by ?? (flags.args[0] === "day" || flags.args[0] === "project" || flags.args[0] === "agent" ? flags.args[0] : "agent");
255
+ const params = new URLSearchParams({ by });
256
+ if (flags.opts.since)
257
+ params.set("since", flags.opts.since);
258
+ if (flags.opts.agent)
259
+ params.set("agent", await resolveSlug(call, flags.opts.agent));
260
+ const data = await call("GET", `/api/cli/spend?${params}`);
261
+ emit(flags.json, data, () => {
262
+ if (by === "day") {
263
+ const days = data.days;
264
+ return days.length === 0
265
+ ? dim("Nothing settled in the window.")
266
+ : table(["day", "calls", "settled", "in", "out"], days.map((d) => [d.day, String(d.calls), money(d.settledCents), d.promptTokens, d.completionTokens]), { right: [1, 2, 3, 4] });
267
+ }
268
+ if (by === "project") {
269
+ const ps = data.projects;
270
+ const lines = table(["project", "agents", "meter", "outside", "total"], ps.map((p) => [p.name, String(p.agents), money(p.meterCents), money(p.outsideCents), money(BigInt(p.meterCents) + BigInt(p.outsideCents))]), { right: [1, 2, 3, 4] });
271
+ const un = data.unattributedOutsideCents;
272
+ return `${lines}\n${dim(`unattributed outside spend ${money(un)}`)}`;
273
+ }
274
+ const as = data.agents;
275
+ return table(["agent", "state", "spent", "cap", "window", "in", "out", "project"], as.map((a) => [a.slug, state(a.state), money(a.spentCents), money(a.capCents), a.window, a.promptTokens, a.completionTokens, a.project ?? dim("-")]), { right: [2, 3, 5, 6] });
276
+ });
277
+ return 0;
278
+ }
279
+ export async function outside(config, flags) {
280
+ const call = api(config);
281
+ const data = await call("GET", "/api/cli/outside");
282
+ emit(flags.json, data, () => {
283
+ const conns = data.connections.length === 0
284
+ ? dim("No connections. Console → Outside spend → Connect.")
285
+ : table(["connection", "30 days", "status", "note"], data.connections.map((c) => [c.label ? `${c.provider} (${c.label})` : c.provider, money(c.windowCents), c.lastError ? red("error") : c.status, c.notice ?? c.lastError ?? ""]), { right: [1] });
286
+ const projects = data.byProject.length === 0 ? "" : `\n\n${table(["project", "outside", "events"], data.byProject.map((p) => [p.projectName, money(p.cents), String(p.events)]), { right: [1, 2] })}`;
287
+ return `${conns}${projects}\n${dim(`unattributed ${money(data.unattributedCents)}`)}`;
288
+ });
289
+ return 0;
290
+ }
291
+ export async function live(config, flags) {
292
+ const call = api(config);
293
+ const once = async () => {
294
+ const feed = await call("GET", "/api/cli/live");
295
+ emit(flags.json, feed, () => `${dim("active")} ${feed.totals.active} ${dim("calls/min")} ${feed.totals.callsPerMinute} ${dim("blocked 5m")} ${feed.totals.blocked} ${dim("spend 5m")} ${money(feed.totals.windowCents)}\n` +
296
+ table(["agent", "pulse", "calls/min", "blocked", "5m", "last"], feed.agents.map((a) => [a.slug, a.pulse, String(a.callsPerMinute), String(a.blocked), money(a.windowCents), a.lastDecision ? `${a.lastDecision.toLowerCase()} ${dim(a.lastReason ?? "")}` : dim("-")]), { right: [2, 3, 4] }));
297
+ };
298
+ await once();
299
+ if (flags.opts.watch === undefined)
300
+ return 0;
301
+ const every = Math.max(2, Number(flags.opts.watch) || 4) * 1000;
302
+ await new Promise((resolve) => {
303
+ const timer = setInterval(() => {
304
+ out("");
305
+ once().catch((err) => out(red(String(err instanceof Error ? err.message : err))));
306
+ }, every);
307
+ process.on("SIGINT", () => {
308
+ clearInterval(timer);
309
+ resolve();
310
+ });
311
+ });
312
+ return 0;
313
+ }
314
+ /** `tab web [agent]`: this folder's Agent in the console. */
315
+ export async function web(config, flags) {
316
+ const call = api(config);
317
+ const slug = flags.args[0] || (await readProject(process.cwd()))?.agent;
318
+ const url = slug ? `${config.consoleUrl}/console/tabs/${encodeURIComponent(await resolveSlug(call, slug))}` : `${config.consoleUrl}/console`;
319
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
320
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
321
+ try {
322
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
323
+ }
324
+ catch {
325
+ // The URL is printed either way.
326
+ }
327
+ out(url);
328
+ return 0;
329
+ }
package/dist/ui.js CHANGED
@@ -97,3 +97,26 @@ export function rows(pairs, indent = 2) {
97
97
  export function heading(text) {
98
98
  return bold(text);
99
99
  }
100
+ /**
101
+ * A plain table: header dimmed, columns padded to their widest cell, numbers
102
+ * right-aligned when the column says so. Visible width, so painted cells
103
+ * line up.
104
+ */
105
+ export function table(header, body, options = {}) {
106
+ const right = new Set(options.right ?? []);
107
+ const cols = header.length;
108
+ const widths = header.map((h, i) => Math.max(width(h), ...body.map((r) => width(r[i] ?? ""))));
109
+ const cell = (text, i) => {
110
+ const pad = " ".repeat(Math.max(0, widths[i] - width(text)));
111
+ return right.has(i) ? `${pad}${text}` : `${text}${pad}`;
112
+ };
113
+ const line = (r) => `${" ".repeat(options.indent ?? 2)}${r.map((c, i) => cell(c ?? "", i)).join(" ").trimEnd()}`;
114
+ return [line(header.map((h) => dim(h))), ...body.map((r) => line(r.slice(0, cols)))].join("\n");
115
+ }
116
+ /** "$0.14" from a cents string or bigint. */
117
+ export function money(cents) {
118
+ const n = typeof cents === "bigint" ? cents : BigInt(String(cents).replace(/[^0-9-]/g, "") || "0");
119
+ const negative = n < 0n;
120
+ const abs = negative ? -n : n;
121
+ return `${negative ? "-" : ""}$${abs / 100n}.${(abs % 100n).toString().padStart(2, "0")}`;
122
+ }
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.3";
2
+ export const TAB_VERSION = "0.1.5";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",