@hanamorilabs/tab 0.1.0 → 0.1.2

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
@@ -1,6 +1,6 @@
1
1
  # tab
2
2
 
3
- Run any AI agent on a FlockTab tab.
3
+ Run any AI agent on a FlockTab tab. Needs Node 22 or newer; nothing else.
4
4
 
5
5
  ```
6
6
  npm i -g @hanamorilabs/tab
@@ -46,11 +46,44 @@ 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
48
  `tab down` stops it; `tab update` fetches the newest proxy and restarts;
49
- `tab claude` starts it when it is down. No Docker, no checkout. Then
49
+ `tab claude` starts it when it is down. No Docker, no Rust, no checkout: the
50
+ binary comes with `tab` as the `@hanamorilabs/flocktab-proxy-<platform>`
51
+ package for your machine. Then
50
52
  `tab login`, pick **2 Self-hosted**: the proxy says which console it settles
51
53
  through, so the device code lands on flocktab.com. No unlock in this mode;
52
54
  Codex shows `FlockTab - Self-hosted` as its provider.
53
55
 
56
+ ## Commands
57
+
58
+ ```
59
+ tab login approve this machine in the console, once
60
+ tab <agent> [args] claude, codex, grok, kimi, gemini, or any command, on the tab
61
+ tab use pick or change the Agent this folder runs as (.flocktab)
62
+ tab alias <name>... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
63
+ tab unalias <name> remove such a shim
64
+ tab status flock, folder Agent, proxy health, provider key state
65
+ tab version tab and proxy versions
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)
68
+ tab logout forget the session and keys on this machine
69
+ tab help this list
70
+ ```
71
+
72
+ ## Aliases: plain `codex` on the tab
73
+
74
+ ```
75
+ tab alias codex claude
76
+ export PATH="$HOME/.flocktab/bin:$PATH" # once, in your shell rc
77
+ codex # now runs: tab codex
78
+ ```
79
+
80
+ `tab alias` writes a shim named after the command into `~/.flocktab/bin`
81
+ (`codex.cmd` on Windows). With that folder first on `PATH`, the plain
82
+ command runs on the tab. When `tab` spawns the real agent it removes its
83
+ own shim folder from the child's `PATH`, so nothing loops. `tab alias` with
84
+ no name lists them; `tab unalias codex` removes one. Prefer a shell alias?
85
+ `alias codex='tab codex'` in your rc does the same for that shell only.
86
+
54
87
  ## Secrets and colour
55
88
 
56
89
  The unlock and provider keys are typed with echo off (raw mode); the unlock is checked against the proxy before saving; a wrong unlock is refused, and `tab status` says `unlock ok` or `unlock is WRONG`. The check is `GET /v1/whoami` with the unlock, which decrypts the envelope in memory and answers `ok`/`wrong`, never the key. Output is coloured on a terminal; `NO_COLOR` turns it off, `FORCE_COLOR` turns it on for pipes.
package/dist/alias.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `tab alias codex`: a shim named `codex` in ~/.flocktab/bin that runs
3
+ * `tab codex "$@"`. With that folder first on PATH, the plain command is on
4
+ * the tab. No shell rc is edited here: `tab alias` prints the one PATH line
5
+ * and the person adds it where they like.
6
+ *
7
+ * The shim shadows the real binary by name, so when `tab` spawns the agent
8
+ * it removes ~/.flocktab/bin from the child's PATH (`pathWithoutShims`);
9
+ * otherwise `codex` would resolve to the shim and loop forever.
10
+ */
11
+ import { chmod, mkdir, readdir, rm, writeFile } from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { configDir } from "./config.js";
14
+ export function shimDir(env = process.env) {
15
+ return path.join(configDir(env), "bin");
16
+ }
17
+ const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
18
+ export function validAliasName(name) {
19
+ return NAME.test(name) && !["tab", "flocktab-proxy"].includes(name.toLowerCase());
20
+ }
21
+ function shimBody(name, platform = process.platform) {
22
+ if (platform === "win32") {
23
+ return { file: `${name}.cmd`, body: `@echo off\r\ntab ${name} %*\r\n` };
24
+ }
25
+ return { file: name, body: `#!/bin/sh\nexec tab ${name} "$@"\n` };
26
+ }
27
+ export async function writeAlias(name, env = process.env, platform = process.platform) {
28
+ const dir = shimDir(env);
29
+ await mkdir(dir, { recursive: true, mode: 0o700 });
30
+ const { file, body } = shimBody(name, platform);
31
+ const target = path.join(dir, file);
32
+ await writeFile(target, body, { mode: 0o755 });
33
+ await chmod(target, 0o755);
34
+ return target;
35
+ }
36
+ export async function removeAlias(name, env = process.env, platform = process.platform) {
37
+ const { file } = shimBody(name, platform);
38
+ const target = path.join(shimDir(env), file);
39
+ try {
40
+ await rm(target);
41
+ return true;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /** Alias names present in the shim folder (the proxy binary is not one). */
48
+ export async function listAliases(env = process.env) {
49
+ try {
50
+ return (await readdir(shimDir(env)))
51
+ .filter((f) => !f.startsWith("flocktab-proxy") && !f.endsWith(".part"))
52
+ .map((f) => f.replace(/\.cmd$/i, ""))
53
+ .sort();
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ }
59
+ /** Whether the shim folder is on PATH, so an alias would actually be found. */
60
+ export function shimDirOnPath(env = process.env, platform = process.platform) {
61
+ const sep = platform === "win32" ? ";" : ":";
62
+ const dir = path.resolve(shimDir(env));
63
+ return (env.PATH ?? "").split(sep).some((p) => p && path.resolve(p) === dir);
64
+ }
65
+ /** PATH for the child agent: everything except the shim folder. */
66
+ export function pathWithoutShims(env = process.env, platform = process.platform) {
67
+ const sep = platform === "win32" ? ";" : ":";
68
+ const dir = path.resolve(shimDir(env));
69
+ return (env.PATH ?? "")
70
+ .split(sep)
71
+ .filter((p) => p && path.resolve(p) !== dir)
72
+ .join(sep);
73
+ }
74
+ /** The line to add to a shell rc so the shims win. */
75
+ export function pathLine(platform = process.platform) {
76
+ return platform === "win32"
77
+ ? `setx PATH "%USERPROFILE%\\.flocktab\\bin;%PATH%"`
78
+ : `export PATH="$HOME/.flocktab/bin:$PATH"`;
79
+ }
package/dist/cli.js CHANGED
@@ -7,7 +7,9 @@
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 ... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
10
11
  * tab status which flock, which Agent here, is the proxy up
12
+ * tab version tab and proxy versions
11
13
  * tab up | down | update self-hosted: start, stop, or fetch the newest proxy
12
14
  * tab logout forget the session and keys
13
15
  *
@@ -27,9 +29,11 @@ import { ConsoleApiError, createAgent, issueAgentKey, listAgents } from "./conso
27
29
  import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } from "./device-login.js";
28
30
  import { clearConfig, configDir, configPath, HOSTED_PROXY, isLocalProxy, loadConfig, LOCAL_PROXY, normalizeProxyUrl, presentedKey, saveConfig, } from "./config.js";
29
31
  import { reportFolder } from "./folder.js";
32
+ import { listAliases, pathLine, pathWithoutShims, removeAlias, shimDir, shimDirOnPath, validAliasName, writeAlias } from "./alias.js";
30
33
  import { agentNameFor, projectRoot, readProject, writeProject } from "./project.js";
31
- import { downloadProxy, hasProxyEnv, packagedBinPath, proxyBinPath, proxyEnvPath, proxyLogPath, PROXY_VERSION, startProxy, stopProxy, tailProxyLog, writeProxyEnv, } from "./proxy-bin.js";
34
+ import { downloadProxy, hasProxyEnv, packagedBinPath, proxyBinPath, proxyEnvPath, proxyInstalled, proxyLogPath, PROXY_VERSION, startProxy, stopProxy, tailProxyLog, writeProxyEnv, } from "./proxy-bin.js";
32
35
  import { proxyHealth, waitHealthy } from "./selfhost.js";
36
+ import { TAB_VERSION } from "./version.js";
33
37
  const say = (text) => console.error(text);
34
38
  const ok = (text) => say(line("ok", text));
35
39
  const warn = (text) => say(line("warn", text));
@@ -46,7 +50,10 @@ function usage() {
46
50
  cmd(others.join(" | tab "), "the same, for those agents"),
47
51
  cmd("<command> [args]", "any other agent, both APIs pointed at the tab"),
48
52
  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("unalias <name>", "remove such a shim"),
49
55
  cmd("status", "which flock, which Agent here, is the proxy up"),
56
+ cmd("version", "tab and proxy versions"),
50
57
  cmd("up", "self-hosted: start the local proxy (fetches it the first time)"),
51
58
  cmd("down", "self-hosted: stop the local proxy"),
52
59
  cmd("update", "self-hosted: fetch the newest proxy and restart it"),
@@ -423,6 +430,15 @@ async function resolveAgent(config, opts = {}) {
423
430
  ok(`This folder runs as ${bold(issued.agent.name)} ${dim(`(${file})`)}`);
424
431
  return login;
425
432
  }
433
+ /** `tab version`: this CLI, and the proxy it would run (packaged, downloaded, or neither). */
434
+ async function version() {
435
+ const packaged = packagedBinPath();
436
+ const bin = proxyBinPath();
437
+ const installed = await proxyInstalled();
438
+ const where = packaged ? `packaged, ${packaged}` : installed ? `downloaded, ${bin}` : "not installed; tab up fetches it";
439
+ say(rows([["tab", TAB_VERSION], ["proxy", `${PROXY_VERSION} ${dim(`(${where})`)}`]], 0));
440
+ return 0;
441
+ }
426
442
  async function status() {
427
443
  const config = await loadConfig();
428
444
  if (!config) {
@@ -475,6 +491,8 @@ async function runAgent(name, args) {
475
491
  const key = presentedKey({ key: agent.key, unlock: config.unlock });
476
492
  reportFolder(config.proxyUrl, key);
477
493
  const { spec, env } = envFor({ name, proxyUrl: config.proxyUrl, presentedKey: key });
494
+ // An alias shim named like the agent must not be what we spawn.
495
+ env.PATH = pathWithoutShims();
478
496
  if (spec.isolatedHome === "codex") {
479
497
  env.CODEX_HOME = await prepareCodexHome({
480
498
  baseDir: configDir(),
@@ -488,6 +506,8 @@ async function runAgent(name, args) {
488
506
  child.on("error", (err) => {
489
507
  if (err.code === "ENOENT") {
490
508
  fail(`${spec.label} is not installed (${spec.command} not on PATH). ${dim(spec.install)}`);
509
+ if (!knownClients().includes(name))
510
+ say(dim(`Not a tab command either; see ${bold("tab help")}.`));
491
511
  resolve(127);
492
512
  return;
493
513
  }
@@ -497,6 +517,46 @@ async function runAgent(name, args) {
497
517
  child.on("close", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
498
518
  });
499
519
  }
520
+ /**
521
+ * `tab alias codex claude`: shims so the plain commands run on the tab.
522
+ * `tab alias` alone lists them; `tab unalias codex` removes one.
523
+ */
524
+ async function alias(names, remove = false) {
525
+ if (names.length === 0 && !remove) {
526
+ const existing = await listAliases();
527
+ if (existing.length === 0)
528
+ say(dim(`No aliases. Example: ${bold("tab alias codex claude")}`));
529
+ else
530
+ say(rows(existing.map((n) => [n, dim(`tab ${n}`)]), 0));
531
+ return pathHint();
532
+ }
533
+ if (names.length === 0) {
534
+ fail("tab unalias needs a name.");
535
+ return 2;
536
+ }
537
+ for (const name of names) {
538
+ if (!validAliasName(name)) {
539
+ fail(`${name} is not a usable alias name.`);
540
+ return 2;
541
+ }
542
+ if (remove) {
543
+ (await removeAlias(name)) ? ok(`Removed ${name}.`) : warn(`${name} was not an alias.`);
544
+ }
545
+ else {
546
+ const file = await writeAlias(name);
547
+ ok(`${bold(name)} now runs ${bold(`tab ${name}`)} ${dim(`(${file})`)}`);
548
+ }
549
+ }
550
+ return remove ? 0 : pathHint();
551
+ }
552
+ function pathHint() {
553
+ if (shimDirOnPath())
554
+ return 0;
555
+ say("");
556
+ say(`Put ${dim(shimDir())} first on your PATH so the aliases win. Add to your shell rc, then open a new terminal:`);
557
+ say(` ${bold(pathLine())}`);
558
+ return 0;
559
+ }
500
560
  /** `tab use`: pick or change the Agent for this folder. */
501
561
  async function use() {
502
562
  const config = await ensureLogin();
@@ -591,6 +651,14 @@ async function main(argv) {
591
651
  return 0;
592
652
  case "use":
593
653
  return use();
654
+ case "alias":
655
+ return alias(rest);
656
+ case "unalias":
657
+ return alias(rest, true);
658
+ case "version":
659
+ case "--version":
660
+ case "-v":
661
+ return version();
594
662
  case "status":
595
663
  return status();
596
664
  case "up":
@@ -0,0 +1,2 @@
1
+ /** Written by scripts/write-version.mjs from package.json at build; `tab version` prints it. */
2
+ export const TAB_VERSION = "0.1.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
@@ -15,7 +15,7 @@
15
15
  "node": ">=22"
16
16
  },
17
17
  "scripts": {
18
- "build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js",
18
+ "build": "node scripts/write-version.mjs && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
19
19
  "dev": "tsx src/cli.ts",
20
20
  "test": "vitest run",
21
21
  "typecheck": "tsc --noEmit",