@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 +26 -3
- package/dist/cli.js +125 -27
- package/dist/manage.js +329 -0
- package/dist/ui.js +23 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
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`
|
|
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
|
|
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
|
|
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", "
|
|
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
|
|
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.
|
|
2
|
+
export const TAB_VERSION = "0.1.5";
|