@hanamorilabs/tab 0.1.2 → 0.1.4
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 +33 -8
- package/dist/cli.js +98 -5
- 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
|
@@ -59,8 +59,8 @@ Codex shows `FlockTab - Self-hosted` as its provider.
|
|
|
59
59
|
tab login approve this machine in the console, once
|
|
60
60
|
tab <agent> [args] claude, codex, grok, kimi, gemini, or any command, on the tab
|
|
61
61
|
tab use pick or change the Agent this folder runs as (.flocktab)
|
|
62
|
-
tab alias <name>...
|
|
63
|
-
tab
|
|
62
|
+
tab alias setup <name>... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
|
|
63
|
+
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
|
|
@@ -69,19 +69,44 @@ 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 crawler tab open crawler the kill switch (402 tab_closed on the next call)
|
|
81
|
+
tab cap crawler 25 --window week cap in dollars, and the window
|
|
82
|
+
tab rename crawler "Nightly crawler" the slug stays
|
|
83
|
+
tab policy crawler --velocity 60 --models gpt-5,claude-sonnet-5 --allow email.send
|
|
84
|
+
tab project crawler Foley --create attribute to a project (--repo owner/name; none clears)
|
|
85
|
+
tab projects add Foley projects
|
|
86
|
+
tab key rotate crawler [--show] new key, kept on this machine; --show prints it once
|
|
87
|
+
tab archive crawler closes for good, leaves the bill (asks first; -y skips)
|
|
88
|
+
tab ledger [crawler] --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 [crawler] the Agent's console page in the browser
|
|
93
|
+
```
|
|
94
|
+
|
|
72
95
|
## Aliases: plain `codex` on the tab
|
|
73
96
|
|
|
74
97
|
```
|
|
75
|
-
tab alias codex claude
|
|
98
|
+
tab alias setup codex claude
|
|
76
99
|
export PATH="$HOME/.flocktab/bin:$PATH" # once, in your shell rc
|
|
77
100
|
codex # now runs: tab codex
|
|
101
|
+
tab alias remove codex # back to the plain codex
|
|
78
102
|
```
|
|
79
103
|
|
|
80
|
-
`tab alias` writes a shim named after the command into
|
|
81
|
-
(`codex.cmd` on Windows). With that folder first on
|
|
82
|
-
command runs on the tab. When `tab` spawns the real agent
|
|
83
|
-
own shim folder from the child's `PATH`, so nothing loops.
|
|
84
|
-
|
|
104
|
+
`tab alias setup` writes a shim named after the command into
|
|
105
|
+
`~/.flocktab/bin` (`codex.cmd` on Windows). With that folder first on
|
|
106
|
+
`PATH`, the plain command runs on the tab. When `tab` spawns the real agent
|
|
107
|
+
it removes its own shim folder from the child's `PATH`, so nothing loops.
|
|
108
|
+
`tab alias list` shows them; `tab alias remove <name>` deletes the shim, and
|
|
109
|
+
the plain command is the real one again. Prefer a shell alias?
|
|
85
110
|
`alias codex='tab codex'` in your rc does the same for that shell only.
|
|
86
111
|
|
|
87
112
|
## Secrets and colour
|
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* tab codex [...args] Codex on the tab
|
|
8
8
|
* tab <cmd> [...args] anything else, both APIs pointed at the tab
|
|
9
9
|
* tab use pick (or change) the Agent this folder runs as
|
|
10
|
-
* tab alias codex
|
|
10
|
+
* tab alias setup codex make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
|
|
11
|
+
* tab alias remove codex undo that; `tab alias list` shows them
|
|
11
12
|
* tab status which flock, which Agent here, is the proxy up
|
|
12
13
|
* tab version tab and proxy versions
|
|
13
14
|
* tab up | down | update self-hosted: start, stop, or fetch the newest proxy
|
|
@@ -30,6 +31,7 @@ import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } fr
|
|
|
30
31
|
import { clearConfig, configDir, configPath, HOSTED_PROXY, isLocalProxy, loadConfig, LOCAL_PROXY, normalizeProxyUrl, presentedKey, saveConfig, } from "./config.js";
|
|
31
32
|
import { reportFolder } from "./folder.js";
|
|
32
33
|
import { listAliases, pathLine, pathWithoutShims, removeAlias, shimDir, shimDirOnPath, validAliasName, writeAlias } from "./alias.js";
|
|
34
|
+
import * as manage from "./manage.js";
|
|
33
35
|
import { agentNameFor, projectRoot, readProject, writeProject } from "./project.js";
|
|
34
36
|
import { downloadProxy, hasProxyEnv, packagedBinPath, proxyBinPath, proxyEnvPath, proxyInstalled, proxyLogPath, PROXY_VERSION, startProxy, stopProxy, tailProxyLog, writeProxyEnv, } from "./proxy-bin.js";
|
|
35
37
|
import { proxyHealth, waitHealthy } from "./selfhost.js";
|
|
@@ -50,10 +52,25 @@ function usage() {
|
|
|
50
52
|
cmd(others.join(" | tab "), "the same, for those agents"),
|
|
51
53
|
cmd("<command> [args]", "any other agent, both APIs pointed at the tab"),
|
|
52
54
|
cmd("use", "pick or change the Agent this folder runs as (.flocktab)"),
|
|
53
|
-
cmd("alias <name>...", "make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)"),
|
|
54
|
-
cmd("
|
|
55
|
+
cmd("alias setup <name>...", "make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)"),
|
|
56
|
+
cmd("alias remove <name>...", "undo that; `tab alias list` shows them"),
|
|
55
57
|
cmd("status", "which flock, which Agent here, is the proxy up"),
|
|
56
58
|
cmd("version", "tab and proxy versions"),
|
|
59
|
+
cmd("list", "every Agent: state, spent of cap, window, project"),
|
|
60
|
+
cmd("close | open [agent]", "the kill switch; omit the Agent for this folder's"),
|
|
61
|
+
cmd("cap [agent] <dollars>", "set the cap (--window day|week|month|run|hour|lifetime)"),
|
|
62
|
+
cmd("rename <agent> <name>", "rename it (the slug stays)"),
|
|
63
|
+
cmd("policy [agent]", "show or set: --velocity N|none --models a,b|none --allow tool|none"),
|
|
64
|
+
cmd("project [agent] <name>", "attribute to a project (--create, --repo owner/name, none)"),
|
|
65
|
+
cmd("projects [add <name>]", "list or add projects"),
|
|
66
|
+
cmd("key rotate [agent]", "new key, kept on this machine (--show prints it once)"),
|
|
67
|
+
cmd("archive [agent]", "close for good and leave the bill"),
|
|
68
|
+
cmd("ledger [agent]", "one row per call (--limit N --blocked)"),
|
|
69
|
+
cmd("spend [agent|project|day]", "meter and outside spend (--since 7d --agent x)"),
|
|
70
|
+
cmd("outside", "outside-spend connections and per-project totals"),
|
|
71
|
+
cmd("live", "who is working now (--watch 4)"),
|
|
72
|
+
cmd("web [agent]", "open the Agent's console page"),
|
|
73
|
+
cmd("... --json", "every read command as JSON, for scripts and agents"),
|
|
57
74
|
cmd("up", "self-hosted: start the local proxy (fetches it the first time)"),
|
|
58
75
|
cmd("down", "self-hosted: stop the local proxy"),
|
|
59
76
|
cmd("update", "self-hosted: fetch the newest proxy and restart it"),
|
|
@@ -521,7 +538,20 @@ async function runAgent(name, args) {
|
|
|
521
538
|
* `tab alias codex claude`: shims so the plain commands run on the tab.
|
|
522
539
|
* `tab alias` alone lists them; `tab unalias codex` removes one.
|
|
523
540
|
*/
|
|
524
|
-
async function alias(
|
|
541
|
+
async function alias(args, removeFlag = false) {
|
|
542
|
+
// `tab alias setup codex`, `tab alias remove codex`, `tab alias list`;
|
|
543
|
+
// the bare `tab alias codex` and `tab unalias codex` mean the same.
|
|
544
|
+
const [first, ...rest] = args;
|
|
545
|
+
let names = args;
|
|
546
|
+
let remove = removeFlag;
|
|
547
|
+
if (first === "setup" || first === "add")
|
|
548
|
+
names = rest;
|
|
549
|
+
else if (first === "remove" || first === "rm") {
|
|
550
|
+
names = rest;
|
|
551
|
+
remove = true;
|
|
552
|
+
}
|
|
553
|
+
else if (first === "list" || first === "ls")
|
|
554
|
+
names = [];
|
|
525
555
|
if (names.length === 0 && !remove) {
|
|
526
556
|
const existing = await listAliases();
|
|
527
557
|
if (existing.length === 0)
|
|
@@ -531,7 +561,7 @@ async function alias(names, remove = false) {
|
|
|
531
561
|
return pathHint();
|
|
532
562
|
}
|
|
533
563
|
if (names.length === 0) {
|
|
534
|
-
fail("tab
|
|
564
|
+
fail("tab alias remove needs a name, e.g. tab alias remove codex");
|
|
535
565
|
return 2;
|
|
536
566
|
}
|
|
537
567
|
for (const name of names) {
|
|
@@ -634,6 +664,52 @@ async function selfHosted(action) {
|
|
|
634
664
|
say(`Next: ${bold("tab login")} and pick 2 Self-hosted to approve this machine.`);
|
|
635
665
|
return 0;
|
|
636
666
|
}
|
|
667
|
+
/** Console management from the terminal; every command needs the machine session. */
|
|
668
|
+
async function managed(command, rest) {
|
|
669
|
+
const config = await ensureLogin();
|
|
670
|
+
if (!config)
|
|
671
|
+
return 2;
|
|
672
|
+
const flags = manage.parseFlags(rest);
|
|
673
|
+
try {
|
|
674
|
+
switch (command) {
|
|
675
|
+
case "list":
|
|
676
|
+
case "ls":
|
|
677
|
+
return await manage.list(config, flags);
|
|
678
|
+
case "close":
|
|
679
|
+
return await manage.setState(config, flags, "closed");
|
|
680
|
+
case "open":
|
|
681
|
+
return await manage.setState(config, flags, "open");
|
|
682
|
+
case "cap":
|
|
683
|
+
return await manage.cap(config, flags);
|
|
684
|
+
case "rename":
|
|
685
|
+
return await manage.rename(config, flags);
|
|
686
|
+
case "policy":
|
|
687
|
+
return await manage.policy(config, flags);
|
|
688
|
+
case "project":
|
|
689
|
+
return await manage.project(config, flags);
|
|
690
|
+
case "projects":
|
|
691
|
+
return await manage.projects(config, flags);
|
|
692
|
+
case "key":
|
|
693
|
+
return await manage.key(config, flags);
|
|
694
|
+
case "archive":
|
|
695
|
+
return await manage.archive(config, flags, ask);
|
|
696
|
+
case "ledger":
|
|
697
|
+
return await manage.ledger(config, flags);
|
|
698
|
+
case "spend":
|
|
699
|
+
return await manage.spend(config, flags);
|
|
700
|
+
case "outside":
|
|
701
|
+
return await manage.outside(config, flags);
|
|
702
|
+
case "live":
|
|
703
|
+
return await manage.live(config, flags);
|
|
704
|
+
default:
|
|
705
|
+
return await manage.web(config, flags);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
catch (err) {
|
|
709
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
710
|
+
return 1;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
637
713
|
async function main(argv) {
|
|
638
714
|
const [command, ...rest] = argv;
|
|
639
715
|
switch (command) {
|
|
@@ -659,6 +735,23 @@ async function main(argv) {
|
|
|
659
735
|
case "--version":
|
|
660
736
|
case "-v":
|
|
661
737
|
return version();
|
|
738
|
+
case "list":
|
|
739
|
+
case "ls":
|
|
740
|
+
case "close":
|
|
741
|
+
case "open":
|
|
742
|
+
case "cap":
|
|
743
|
+
case "rename":
|
|
744
|
+
case "policy":
|
|
745
|
+
case "project":
|
|
746
|
+
case "projects":
|
|
747
|
+
case "key":
|
|
748
|
+
case "archive":
|
|
749
|
+
case "ledger":
|
|
750
|
+
case "spend":
|
|
751
|
+
case "outside":
|
|
752
|
+
case "live":
|
|
753
|
+
case "web":
|
|
754
|
+
return managed(command, rest);
|
|
662
755
|
case "status":
|
|
663
756
|
return status();
|
|
664
757
|
case "up":
|
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.4";
|