auto-model-router 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.10.0",
10
+ "version": "0.11.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.10.0",
17
+ "version": "0.11.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -1340,6 +1340,21 @@ sends the name itself, which the router does not accept as a scope (a scope is a
1340
1340
  lowercase slug) and falls back to its default. `--scope <slug>` pins one project for the
1341
1341
  whole machine instead; a refresh keeps a pin, and `connect --scope ""` removes it.
1342
1342
 
1343
+ ### One file to install
1344
+
1345
+ A remote that serves members can hand them this package as a single executable: the CLI
1346
+ compiled by `bun build --compile` for their operating system (`buildExecutable` in the
1347
+ library, one of `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`,
1348
+ `windows-x64`), with the package's own source files embedded. Nothing else is installed —
1349
+ no bun, no npm. omp still loads the extensions from disk and Hermes still copies its
1350
+ plugin, so the first `connect` from the executable writes the embedded files out under
1351
+ `<router home>/package/<version>/` and points every harness there; the executable itself
1352
+ becomes Claude Code's key helper and, with `--profile`, goes on PATH. `connect
1353
+ --setup-token <token>` trades a one-time onboarding token at the remote's
1354
+ `/setup/exchange` for the credential, so the install command carries no key at all.
1355
+ `remote.json` records the executable, and a refresh from inside omp keeps the helper
1356
+ pointed at it.
1357
+
1343
1358
  ## Multiple coding harnesses, one router
1344
1359
 
1345
1360
  **One router process for everything.** omp's embed extension binds a private
@@ -36,6 +36,12 @@ export interface RemoteRouter {
36
36
  refreshExpiresAtMs?: number;
37
37
  /** What the remote calls this machine. */
38
38
  device?: string;
39
+ /**
40
+ * The compiled executable that ran `connect`, when one did. A refresh from
41
+ * inside omp re-writes every harness config and must point Claude Code's key
42
+ * helper at it, not at a `bun run` of the extracted source.
43
+ */
44
+ executable?: string;
39
45
  }
40
46
 
41
47
  export function remoteFilePath(routerHome: string): string {
@@ -59,6 +65,7 @@ export function parseRemoteRouter(text: string): RemoteRouter | null {
59
65
  ...(typeof raw.keyExpiresAtMs === "number" ? { keyExpiresAtMs: raw.keyExpiresAtMs } : {}),
60
66
  ...(typeof raw.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: raw.refreshExpiresAtMs } : {}),
61
67
  ...(typeof raw.device === "string" && raw.device !== "" ? { device: raw.device } : {}),
68
+ ...(typeof raw.executable === "string" && raw.executable !== "" ? { executable: raw.executable } : {}),
62
69
  };
63
70
  } catch {
64
71
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Builds the single-file member install: this package compiled by
3
+ * `bun build --compile` for one operating system, with the package's own source
4
+ * files embedded so `connect` can write out what omp and Hermes load from disk
5
+ * (see `embedded.ts`). A team server calls this once per router version and
6
+ * target and serves the result; nothing here runs on a member's machine.
7
+ *
8
+ * Cross-compiling needs bun's runtime for the target, which bun downloads on
9
+ * first use and caches; a host without network access to bun's releases can
10
+ * only build its own platform.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+ import { EMBEDDED_GLOBAL, type EmbeddedPackage } from "./embedded.ts";
17
+
18
+ export const EXECUTABLE_TARGETS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "windows-x64"] as const;
19
+ export type ExecutableTarget = (typeof EXECUTABLE_TARGETS)[number];
20
+
21
+ export function isExecutableTarget(value: string): value is ExecutableTarget {
22
+ return (EXECUTABLE_TARGETS as readonly string[]).includes(value);
23
+ }
24
+
25
+ /** The target of the machine this process runs on, or null when bun has no build for it. */
26
+ export function hostTarget(platform = process.platform, arch = process.arch): ExecutableTarget | null {
27
+ const cpu = arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : null;
28
+ if (cpu === null) return null;
29
+ if (platform === "linux") return `linux-${cpu}`;
30
+ if (platform === "darwin") return `darwin-${cpu}`;
31
+ if (platform === "win32" && cpu === "x64") return "windows-x64";
32
+ return null;
33
+ }
34
+
35
+ /** The file name a build for `target` is served under. */
36
+ export function executableFileName(target: ExecutableTarget): string {
37
+ return `auto-model-router-${target}${target.startsWith("windows") ? ".exe" : ""}`;
38
+ }
39
+
40
+ /** The package entries the install needs: the CLI, the harness integrations, and the two runtime deps. */
41
+ const PACKAGE_ENTRIES = ["package.json", "README.md", "src", "omp-extension", "hermes-plugin", "opencode-plugin"] as const;
42
+ const RUNTIME_DEPS = ["yaml", "zod"] as const;
43
+
44
+ function walk(root: string, rel: string, keep: (rel: string) => boolean, out: Record<string, string>): void {
45
+ const abs = join(root, ...rel.split("/").filter((s) => s !== ""));
46
+ const st = statSync(abs);
47
+ if (st.isDirectory()) {
48
+ for (const name of readdirSync(abs)) walk(root, rel === "" ? name : `${rel}/${name}`, keep, out);
49
+ return;
50
+ }
51
+ if (!keep(rel)) return;
52
+ // Everything the filters let through is text (ts, py, yaml, js, json, md). No byte-level
53
+ // sniffing: src/util/hash.ts holds a NUL inside a string literal and was silently dropped
54
+ // by one, which broke the extension on a member machine and nowhere else.
55
+ out[rel] = readFileSync(abs, "utf8");
56
+ }
57
+
58
+ const keepPackageFile = (rel: string): boolean => !rel.includes("__pycache__") && !rel.endsWith(".pyc") && !rel.endsWith(".test.ts");
59
+ const keepDepFile = (rel: string): boolean => !/(^|\/)(tests?|__tests__)\//.test(rel) && !/\.(d\.[cm]?ts|map)$/.test(rel) && !/\.test\.[cm]?[jt]sx?$/.test(rel);
60
+
61
+ /**
62
+ * Every file the executable embeds, keyed by path relative to the package
63
+ * root. The runtime dependencies are resolved from the package's own location,
64
+ * so a `bun link`ed package finds them too.
65
+ */
66
+ export function collectPackageFiles(packageDir: string): EmbeddedPackage {
67
+ const files: Record<string, string> = {};
68
+ for (const entry of PACKAGE_ENTRIES) {
69
+ if (existsSync(join(packageDir, entry))) walk(packageDir, entry, keepPackageFile, files);
70
+ }
71
+ for (const dep of RUNTIME_DEPS) {
72
+ let depDir: string;
73
+ try {
74
+ depDir = dirname(Bun.resolveSync(`${dep}/package.json`, packageDir));
75
+ } catch {
76
+ continue;
77
+ }
78
+ const depFiles: Record<string, string> = {};
79
+ walk(depDir, "", keepDepFile, depFiles);
80
+ for (const [rel, content] of Object.entries(depFiles)) files[`node_modules/${dep}/${rel}`] = content;
81
+ }
82
+ const version = (JSON.parse(files["package.json"] ?? "{}") as { version?: string }).version ?? "0.0.0";
83
+ return { version, files };
84
+ }
85
+
86
+ export interface BuildExecutableOptions {
87
+ packageDir: string;
88
+ target: ExecutableTarget;
89
+ /** Where the executable goes; created or replaced. */
90
+ outFile: string;
91
+ /** The bun binary to build with; this process's own when it is bun. */
92
+ bun?: string;
93
+ /** A directory for the manifest and entry module; a temp dir when absent, removed afterwards. */
94
+ stageDir?: string;
95
+ }
96
+
97
+ export type BuildExecutableResult = { ok: true; path: string; bytes: number } | { ok: false; reason: string };
98
+
99
+ function bunBinary(explicit: string | undefined): string | null {
100
+ if (explicit !== undefined) return explicit;
101
+ if (/(^|[\\/])bun(\.exe)?$/i.test(process.execPath)) return process.execPath;
102
+ return Bun.which("bun");
103
+ }
104
+
105
+ /**
106
+ * Compiles the package at `packageDir` into `outFile` for `target`. The entry
107
+ * module sets the global `embedded.ts` reads and then loads the CLI, so the CLI
108
+ * itself never knows at build time whether it will be compiled.
109
+ */
110
+ export async function buildExecutable(opts: BuildExecutableOptions): Promise<BuildExecutableResult> {
111
+ const bun = bunBinary(opts.bun);
112
+ if (bun === null) return { ok: false, reason: "bun is not available to build with" };
113
+ const stage = opts.stageDir ?? mkdtempSync(join(tmpdir(), "amr-exe-"));
114
+ mkdirSync(stage, { recursive: true });
115
+ try {
116
+ const pkg = collectPackageFiles(opts.packageDir);
117
+ if (pkg.files["src/index.ts"] === undefined) return { ok: false, reason: `${opts.packageDir} does not hold the router package (no src/index.ts)` };
118
+ writeFileSync(join(stage, "manifest.json"), JSON.stringify(pkg), "utf8");
119
+ const entry = join(opts.packageDir, "src", "index.ts").replaceAll("\\", "/");
120
+ writeFileSync(
121
+ join(stage, "entry.ts"),
122
+ [
123
+ `import manifest from "./manifest.json" with { type: "file" };`,
124
+ `(globalThis as Record<string, unknown>)[${JSON.stringify(EMBEDDED_GLOBAL)}] = { manifestPath: manifest };`,
125
+ `await import(${JSON.stringify(entry)});`,
126
+ "",
127
+ ].join("\n"),
128
+ "utf8",
129
+ );
130
+ mkdirSync(dirname(opts.outFile), { recursive: true });
131
+ const proc = Bun.spawn([bun, "build", "--compile", `--target=bun-${opts.target}`, join(stage, "entry.ts"), "--outfile", opts.outFile], { stdout: "pipe", stderr: "pipe", cwd: opts.packageDir });
132
+ const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
133
+ if (exitCode !== 0 || !existsSync(opts.outFile)) {
134
+ const tail = `${stderr}\n${stdout}`.trim().split("\n").slice(-6).join("\n");
135
+ return { ok: false, reason: `bun build --compile for ${opts.target} failed (exit ${exitCode}): ${tail}` };
136
+ }
137
+ return { ok: true, path: opts.outFile, bytes: statSync(opts.outFile).size };
138
+ } finally {
139
+ if (opts.stageDir === undefined) rmSync(stage, { recursive: true, force: true });
140
+ }
141
+ }
142
+
@@ -25,11 +25,12 @@
25
25
  */
26
26
 
27
27
  import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
28
- import { homedir } from "node:os";
28
+ import { homedir, hostname } from "node:os";
29
29
  import { dirname, join, resolve } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { refreshAccountOf, remoteFilePath } from "../../omp-extension/remote-logic.ts";
32
32
  import { SCOPE_ENV } from "../context/scope.ts";
33
+ import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
33
34
  import { pickStore, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
34
35
  import { flagString, type CliArgs } from "./args.ts";
35
36
 
@@ -58,6 +59,12 @@ export interface ConnectOptions {
58
59
  keyExpiresAtMs?: number;
59
60
  refreshExpiresAtMs?: number;
60
61
  device?: string;
62
+ /**
63
+ * The compiled executable running this, when one is (see embedded.ts). It
64
+ * becomes Claude Code's key helper and goes on PATH with --profile, and
65
+ * remote.json records it so a refresh from omp keeps pointing at it.
66
+ */
67
+ exePath?: string;
61
68
  platform: string;
62
69
  pathHas: (bin: string) => boolean;
63
70
  }
@@ -257,6 +264,7 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
257
264
  ...(o.keyExpiresAtMs !== undefined ? { keyExpiresAtMs: o.keyExpiresAtMs } : {}),
258
265
  ...(o.refreshExpiresAtMs !== undefined ? { refreshExpiresAtMs: o.refreshExpiresAtMs } : {}),
259
266
  ...(o.device !== undefined && o.device !== "" ? { device: o.device } : {}),
267
+ ...(o.exePath !== undefined && o.exePath !== "" ? { executable: o.exePath } : {}),
260
268
  },
261
269
  null,
262
270
  2,
@@ -339,8 +347,9 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
339
347
  const env: Record<string, unknown> = { ...((settings.env as Record<string, unknown> | undefined) ?? {}), ANTHROPIC_BASE_URL: o.url };
340
348
  // Never leave a stale key beside the helper: the helper is the source now.
341
349
  delete env.ANTHROPIC_API_KEY;
342
- const entry = resolve(o.packageDir, "src", "index.ts").replaceAll("\\", "/");
343
- const next = { ...settings, env, apiKeyHelper: `bun run "${entry}" token` };
350
+ // The executable is its own helper; under bun the source entry is.
351
+ const helper = o.exePath !== undefined && o.exePath !== "" ? `"${o.exePath.replaceAll("\\", "/")}" token` : `bun run "${resolve(o.packageDir, "src", "index.ts").replaceAll("\\", "/")}" token`;
352
+ const next = { ...settings, env, apiKeyHelper: helper };
344
353
  const after = `${JSON.stringify(next, null, 2)}\n`;
345
354
  if (after !== before) {
346
355
  if (before !== "" && !o.dryRun) writeFileSync(`${settingsPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, before, "utf8");
@@ -358,11 +367,14 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
358
367
  const [k, ...v] = line.split("=");
359
368
  Bun.spawnSync(["setx", k!, v.join("=")], { stdout: "ignore", stderr: "ignore" });
360
369
  }
370
+ // Not setx for PATH: it truncates at 1024 characters and would eat the rest.
371
+ if (o.exePath !== undefined && o.exePath !== "") addToUserPathWindows(dirname(o.exePath));
361
372
  report.notes.push("user environment variables set with setx; open a new terminal");
362
373
  } else {
363
374
  const shell = o.env.SHELL ?? "";
364
375
  const rc = shell.includes("zsh") ? join(o.home, ".zshrc") : join(o.home, ".bashrc");
365
- const block = `\n# auto-model-router remote (added by \`auto-model-router connect\`)\n${report.envLines.map((l) => `export ${l}`).join("\n")}\n`;
376
+ const pathLine = o.exePath !== undefined && o.exePath !== "" ? [`export PATH="${dirname(o.exePath)}:$PATH"`] : [];
377
+ const block = `\n# auto-model-router remote (added by \`auto-model-router connect\`)\n${[...report.envLines.map((l) => `export ${l}`), ...pathLine].join("\n")}\n`;
366
378
  const before = existsSync(rc) ? readFileSync(rc, "utf8") : "";
367
379
  if (!before.includes("# auto-model-router remote") && !before.includes("# auto-model-router team")) appendFileSync(rc, block, "utf8");
368
380
  else write(rc, before.replace(/\n# auto-model-router (?:remote|team)[^\n]*\n(?:export [^\n]*\n)*/, block));
@@ -374,16 +386,84 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
374
386
  return report;
375
387
  }
376
388
 
389
+ /** What a team's one-time setup token is traded for. */
390
+ export interface IssuedCredential {
391
+ key: string;
392
+ refreshToken: string;
393
+ keyExpiresAtMs?: number;
394
+ refreshExpiresAtMs?: number;
395
+ userId: string;
396
+ name: string;
397
+ }
398
+
399
+ /**
400
+ * Trades a one-time setup token for this machine's credential at the team's
401
+ * exchange route, so the install needs nothing on the machine but this
402
+ * program: the token is the only secret in the install command and dies on use.
403
+ */
404
+ export async function exchangeSetupToken(url: string, token: string, device: string, fetchImpl: typeof fetch = fetch): Promise<IssuedCredential> {
405
+ const res = await fetchImpl(`${url}/setup/exchange`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, device }), signal: AbortSignal.timeout(15_000) });
406
+ if (res.status === 401) throw new Error("the setup token was refused (expired or already used); get a new one from the team's portal");
407
+ if (!res.ok) throw new Error(`the team's setup exchange answered ${res.status}`);
408
+ const body = (await res.json()) as Record<string, unknown>;
409
+ if (typeof body.key !== "string" || body.key === "") throw new Error("the team's setup exchange returned no key");
410
+ return {
411
+ key: body.key,
412
+ refreshToken: typeof body.refreshToken === "string" ? body.refreshToken : "",
413
+ ...(typeof body.keyExpiresAtMs === "number" ? { keyExpiresAtMs: body.keyExpiresAtMs } : {}),
414
+ ...(typeof body.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: body.refreshExpiresAtMs } : {}),
415
+ userId: typeof body.userId === "string" ? body.userId : "",
416
+ name: typeof body.name === "string" ? body.name : "",
417
+ };
418
+ }
419
+
420
+ /** Adds `dir` to the user's PATH on Windows, once, through the registry-backed API rather than setx. */
421
+ function addToUserPathWindows(dir: string): void {
422
+ const quoted = `'${dir.replaceAll("'", "''")}'`;
423
+ const script = `$d=${quoted}; $p=[Environment]::GetEnvironmentVariable('Path','User'); if ($null -eq $p) { $p='' }; if (($p -split ';') -notcontains $d) { [Environment]::SetEnvironmentVariable('Path', (($p.TrimEnd(';') + ';' + $d).TrimStart(';')), 'User') }`;
424
+ Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], { stdout: "ignore", stderr: "ignore" });
425
+ }
426
+
427
+ /**
428
+ * Where the harness integrations are read from. Under bun that is this
429
+ * package; in the compiled executable it is the copy written out from the
430
+ * executable's own embedded files.
431
+ */
432
+ async function resolvePackageDir(): Promise<string> {
433
+ const embedded = await readEmbeddedPackage();
434
+ if (embedded === null) return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
435
+ const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
436
+ return materializePackage(expand(raw, homedir()), embedded);
437
+ }
438
+
377
439
  export async function connectCommand(args: CliArgs): Promise<void> {
378
440
  const url = (flagString(args, "url") ?? process.env.AUTO_MODEL_ROUTER_URL ?? "").replace(/\/+$/, "");
379
- const key = flagString(args, "key") ?? process.env.AUTO_MODEL_ROUTER_API_KEY ?? "";
380
- if (url === "" || key === "") throw new Error("connect needs --url <remote router> and --key <its key>");
441
+ let key = flagString(args, "key") ?? process.env.AUTO_MODEL_ROUTER_API_KEY ?? "";
442
+ const setupToken = flagString(args, "setup-token") ?? "";
443
+ if (url === "" || (key === "" && setupToken === "")) throw new Error("connect needs --url <remote router> and either --key <its key> or --setup-token <one-time token from the team>");
381
444
  const only = (flagString(args, "harness") ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => s !== "");
382
445
  const pathHas = (bin: string): boolean => Bun.which(bin) !== null;
383
- const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
384
- // Verify the key against the route every router serves before touching anything.
446
+ const packageDir = await resolvePackageDir();
447
+ const exePath = executablePath();
385
448
  let name = flagString(args, "name") ?? "";
386
449
  let userId = flagString(args, "user-id") ?? "";
450
+ let device = flagString(args, "device") ?? "";
451
+ // A remote that issues short-lived keys hands these over beside the key.
452
+ let refreshToken = flagString(args, "refresh-token") ?? "";
453
+ let keyExpires = Number.parseInt(flagString(args, "key-expires") ?? "", 10);
454
+ let refreshExpires = Number.parseInt(flagString(args, "refresh-expires") ?? "", 10);
455
+ if (setupToken !== "") {
456
+ if (device === "") device = hostname();
457
+ const issued = await exchangeSetupToken(url, setupToken, device);
458
+ key = issued.key;
459
+ refreshToken = issued.refreshToken;
460
+ keyExpires = issued.keyExpiresAtMs ?? Number.NaN;
461
+ refreshExpires = issued.refreshExpiresAtMs ?? Number.NaN;
462
+ if (issued.userId !== "") userId = issued.userId;
463
+ if (issued.name !== "") name = issued.name;
464
+ console.log(`credential issued for ${name === "" ? userId : name} (device ${device})`);
465
+ }
466
+ // Verify the key against the route every router serves before touching anything.
387
467
  try {
388
468
  const res = await fetch(`${url}/v1/models`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
389
469
  if (res.status === 401) throw new Error("the remote router rejected this key");
@@ -396,11 +476,6 @@ export async function connectCommand(args: CliArgs): Promise<void> {
396
476
  // A single-project machine can label every request; a machine with several
397
477
  // repos should leave it off and let the extensions send the workspace's own.
398
478
  const scopeFlag = flagString(args, "scope");
399
- // A remote that issues short-lived keys hands these over beside the key.
400
- const refreshToken = flagString(args, "refresh-token") ?? "";
401
- const keyExpires = Number.parseInt(flagString(args, "key-expires") ?? "", 10);
402
- const refreshExpires = Number.parseInt(flagString(args, "refresh-expires") ?? "", 10);
403
- const device = flagString(args, "device") ?? "";
404
479
  const report = connectRemote({
405
480
  url,
406
481
  key,
@@ -419,7 +494,9 @@ export async function connectCommand(args: CliArgs): Promise<void> {
419
494
  ...(Number.isFinite(keyExpires) ? { keyExpiresAtMs: keyExpires } : {}),
420
495
  ...(Number.isFinite(refreshExpires) ? { refreshExpiresAtMs: refreshExpires } : {}),
421
496
  ...(device === "" ? {} : { device }),
497
+ ...(exePath === null ? {} : { exePath }),
422
498
  });
499
+ if (exePath !== null) console.log(`executable ${exePath}; package files under ${packageDir}`);
423
500
  console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
424
501
  for (const c of report.configured) console.log(` configured ${c}`);
425
502
  for (const s of report.skipped) console.log(` skipped ${s}`);
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The router package carried inside a compiled executable.
3
+ *
4
+ * `bun build --compile` turns this CLI into one file per operating system, and
5
+ * that is what a team member downloads: no bun, no npm, nothing else. But the
6
+ * harness integrations are not the CLI — omp loads `omp-extension/*.ts` with
7
+ * its own runtime and Hermes copies `hermes-plugin/` — so the executable also
8
+ * carries the package's source files and writes them out under the router home
9
+ * the first time `connect` runs. The build side (`build-executable.ts`) puts a
10
+ * JSON manifest of those files into the executable and sets a global that names
11
+ * it; this side reads it.
12
+ */
13
+
14
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+
17
+ export interface EmbeddedPackage {
18
+ /** The router package's version, which also names the extracted directory. */
19
+ version: string;
20
+ /** Relative path (forward slashes) → file content. Text only: the package has no binaries. */
21
+ files: Record<string, string>;
22
+ }
23
+
24
+ /** What the build's entry module sets before the CLI loads. */
25
+ export interface EmbeddedHandle {
26
+ /** Path of the manifest inside the executable's embedded filesystem. */
27
+ manifestPath: string;
28
+ }
29
+
30
+ export const EMBEDDED_GLOBAL = "AUTO_MODEL_ROUTER_EMBEDDED";
31
+
32
+ export function embeddedHandle(): EmbeddedHandle | null {
33
+ const h = (globalThis as Record<string, unknown>)[EMBEDDED_GLOBAL];
34
+ return typeof h === "object" && h !== null && typeof (h as EmbeddedHandle).manifestPath === "string" ? (h as EmbeddedHandle) : null;
35
+ }
36
+
37
+ /** True when this process is the compiled executable rather than `bun run src/index.ts`. */
38
+ export function isCompiled(): boolean {
39
+ return embeddedHandle() !== null;
40
+ }
41
+
42
+ /** The executable's own path when compiled, for key helpers and PATH; null under bun. */
43
+ export function executablePath(): string | null {
44
+ return isCompiled() ? process.execPath : null;
45
+ }
46
+
47
+ export async function readEmbeddedPackage(): Promise<EmbeddedPackage | null> {
48
+ const h = embeddedHandle();
49
+ if (h === null) return null;
50
+ const parsed = JSON.parse(await Bun.file(h.manifestPath).text()) as EmbeddedPackage;
51
+ return typeof parsed.version === "string" && typeof parsed.files === "object" && parsed.files !== null ? parsed : null;
52
+ }
53
+
54
+ const MARKER = ".materialized";
55
+
56
+ /**
57
+ * Writes the embedded package under `<routerHome>/package/<version>/` and
58
+ * returns that directory, the `packageDir` every harness config then points
59
+ * at. Idempotent: a marker records the manifest's hash, so an unchanged
60
+ * executable writes nothing on later runs and a rebuilt one of the same
61
+ * version refreshes the files.
62
+ */
63
+ export function materializePackage(routerHome: string, pkg: EmbeddedPackage): string {
64
+ const dir = join(routerHome, "package", pkg.version);
65
+ const digest = Bun.hash(JSON.stringify(pkg.files)).toString(16);
66
+ try {
67
+ if (readFileSync(join(dir, MARKER), "utf8").trim() === digest) return dir;
68
+ } catch {
69
+ /* not written yet */
70
+ }
71
+ for (const [rel, content] of Object.entries(pkg.files)) {
72
+ const target = join(dir, ...rel.split("/"));
73
+ mkdirSync(dirname(target), { recursive: true });
74
+ writeFileSync(target, content, "utf8");
75
+ }
76
+ writeFileSync(join(dir, MARKER), `${digest}\n`, "utf8");
77
+ return dir;
78
+ }
@@ -13,6 +13,7 @@
13
13
  * its own expiry, so a session still holding it is never cut.
14
14
  */
15
15
 
16
+ import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
16
17
  import { homedir } from "node:os";
17
18
  import { dirname, resolve } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
@@ -89,7 +90,12 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
89
90
  const rh = opts.routerHome ?? routerHome();
90
91
  const fresh = await refreshCredential(opts.remote, opts.fetchImpl ?? fetch, rh, opts.storeDeps ?? {});
91
92
  const home = opts.home ?? (process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir());
92
- const packageDir = opts.packageDir ?? resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
93
+ // The compiled executable rewrites from its own extracted package and keeps
94
+ // itself as the key helper; the omp extension, running from that extracted
95
+ // package, learns the executable from remote.json.
96
+ const embedded = opts.packageDir === undefined ? await readEmbeddedPackage() : null;
97
+ const packageDir = opts.packageDir ?? (embedded === null ? resolve(dirname(fileURLToPath(import.meta.url)), "..", "..") : materializePackage(rh, embedded));
98
+ const exePath = opts.remote.executable ?? executablePath() ?? undefined;
93
99
  connectRemote({
94
100
  url: opts.remote.url,
95
101
  key: fresh.key,
@@ -110,6 +116,7 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
110
116
  // The store that already holds it keeps it; a machine never silently changes store.
111
117
  ...(opts.remote.refreshTokenStore !== undefined ? { store: opts.remote.refreshTokenStore } : {}),
112
118
  ...(opts.storeDeps !== undefined ? { storeDeps: opts.storeDeps } : {}),
119
+ ...(exePath !== undefined ? { exePath } : {}),
113
120
  // undefined keeps whatever scope the managed models.yml block already carries.
114
121
  });
115
122
  return fresh;
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import { configCommand } from "./cli/config-cmd.ts";
13
13
  import { explainCommand } from "./cli/explain.ts";
14
14
  import { exportCommand } from "./cli/export.ts";
15
15
  import { connectCommand } from "./cli/connect.ts";
16
+ import { isCompiled, readEmbeddedPackage } from "./cli/embedded.ts";
16
17
  import { refreshCommand, tokenCommand } from "./cli/refresh.ts";
17
18
  import { modelsCommand } from "./cli/models.ts";
18
19
  import { reportCommand } from "./cli/report.ts";
@@ -27,7 +28,7 @@ Usage: auto-model-router <command> [options]
27
28
  stats Show routed spend, per-model share, and escalation rates
28
29
  report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
29
30
  export One row per day, harness and model as CSV (--json for rows)
30
- connect Point this machine at a remote router (--url, --key[, --refresh-token]; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment)
31
+ connect Point this machine at a remote router (--url with --key[, --refresh-token] or --setup-token <one-time token from a team>; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment and, from the compiled executable, PATH)
31
32
  refresh Trade the refresh token for a new access key and re-write every harness config (--force: even when not near expiry)
32
33
  token Print an access key that is good right now, refreshing first if needed (for a harness key-helper)
33
34
  models Show what each complexity tier would consider, and why
@@ -56,7 +57,9 @@ async function main(): Promise<number> {
56
57
  const args = parseArgv(process.argv.slice(2));
57
58
 
58
59
  if (args.flags.has("version")) {
59
- const pkg: unknown = await Bun.file(join(import.meta.dir, "..", "package.json")).json();
60
+ // The compiled executable has no package.json beside it; its embedded copy answers.
61
+ const embedded = await readEmbeddedPackage();
62
+ const pkg: unknown = embedded ?? (await Bun.file(join(import.meta.dir, "..", "package.json")).json());
60
63
  const value =
61
64
  typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string"
62
65
  ? pkg.version
@@ -108,7 +111,8 @@ async function main(): Promise<number> {
108
111
  }
109
112
  }
110
113
 
111
- if (import.meta.main) {
114
+ // The compiled executable loads this module from its entry, so it is never import.meta.main there.
115
+ if (import.meta.main || isCompiled()) {
112
116
  try {
113
117
  const code = await main();
114
118
  if (code !== 0) process.exit(code);
package/src/lib.ts CHANGED
@@ -23,5 +23,6 @@ export { openDb } from "./util/sqlite.ts";
23
23
  export { spendUsdSince, feedbackView, exportRows, exportCsv, harnessScopeParam, type HarnessScope, type ExportRow, type FeedbackRow, type FeedbackByModel, type FeedbackView } from "./cost/views.ts";
24
24
  export { createLedger } from "./cost/ledger.ts";
25
25
  export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./cost/feedback.ts";
26
+ export { buildExecutable, collectPackageFiles, executableFileName, hostTarget, isExecutableTarget, EXECUTABLE_TARGETS, type ExecutableTarget, type BuildExecutableResult } from "./cli/build-executable.ts";
26
27
  export type { RequestPolicy } from "./wire/types.ts";
27
28
  export type { Ledger, LedgerEntry } from "./cost/types.ts";
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { buildExecutable, collectPackageFiles, executableFileName, hostTarget, isExecutableTarget } from "../src/cli/build-executable.ts";
6
+ import { connectRemote, exchangeSetupToken } from "../src/cli/connect.ts";
7
+ import { EMBEDDED_GLOBAL, embeddedHandle, isCompiled, materializePackage, readEmbeddedPackage } from "../src/cli/embedded.ts";
8
+ import { parseRemoteRouter } from "../omp-extension/remote-logic.ts";
9
+
10
+ const NL = String.fromCharCode(10);
11
+
12
+ describe("the single-file member install", () => {
13
+ test("the embedded package is the install: CLI, harness integrations, runtime deps; no tests, no caches", () => {
14
+ const pkg = collectPackageFiles(process.cwd());
15
+ expect(pkg.version).toBe((JSON.parse(readFileSync("package.json", "utf8")) as { version: string }).version);
16
+ expect(pkg.files["src/index.ts"]).toContain("connect");
17
+ expect(pkg.files["omp-extension/router-embed.ts"]).toBeDefined();
18
+ expect(pkg.files["hermes-plugin/native/__init__.py"] ?? pkg.files[Object.keys(pkg.files).find((k) => k.startsWith("hermes-plugin/")) ?? ""]).toBeDefined();
19
+ expect(pkg.files["node_modules/zod/package.json"]).toBeDefined();
20
+ expect(pkg.files["node_modules/yaml/package.json"]).toBeDefined();
21
+ const names = Object.keys(pkg.files);
22
+ // Every non-test source file, none missing: a dropped one only fails on a member machine, when the extension imports it.
23
+ const onDisk = readdirSync("src", { recursive: true }).map(String).filter((f) => /\.(ts|py|yaml)$/.test(f) && !f.endsWith(".test.ts")).map((f) => `src/${f.replaceAll("\\", "/")}`);
24
+ expect(onDisk.length).toBeGreaterThan(50);
25
+ for (const f of onDisk) expect(names).toContain(f);
26
+ expect(names.some((n) => n.endsWith(".test.ts"))).toBe(false);
27
+ expect(names.some((n) => n.includes("__pycache__") || n.endsWith(".pyc"))).toBe(false);
28
+ expect(names.some((n) => /\.d\.ts$/.test(n) && n.startsWith("node_modules/"))).toBe(false);
29
+ });
30
+
31
+ test("targets and file names", () => {
32
+ expect(isExecutableTarget("linux-x64")).toBe(true);
33
+ expect(isExecutableTarget("linux-x86")).toBe(false);
34
+ expect(executableFileName("windows-x64")).toBe("auto-model-router-windows-x64.exe");
35
+ expect(executableFileName("darwin-arm64")).toBe("auto-model-router-darwin-arm64");
36
+ expect(hostTarget("win32", "x64")).toBe("windows-x64");
37
+ expect(hostTarget("darwin", "arm64")).toBe("darwin-arm64");
38
+ expect(hostTarget("win32", "arm64")).toBeNull();
39
+ });
40
+
41
+ test("under bun there is no embedded package", async () => {
42
+ expect(isCompiled()).toBe(false);
43
+ expect(embeddedHandle()).toBeNull();
44
+ expect(await readEmbeddedPackage()).toBeNull();
45
+ });
46
+
47
+ test("materializing writes the files once, keyed by content, under the router home", () => {
48
+ const home = mkdtempSync(join(tmpdir(), "amr-mat-"));
49
+ try {
50
+ const pkg = { version: "9.9.9", files: { "package.json": `{"version":"9.9.9"}${NL}`, "src/index.ts": `console.log(1)${NL}`, "omp-extension/x.ts": "export {}" } };
51
+ const dir = materializePackage(home, pkg);
52
+ expect(dir).toBe(join(home, "package", "9.9.9"));
53
+ expect(readFileSync(join(dir, "src", "index.ts"), "utf8")).toBe(`console.log(1)${NL}`);
54
+ // Unchanged: a second call leaves a hand-edited file alone (nothing is rewritten).
55
+ writeFileSync(join(dir, "src", "index.ts"), "edited", "utf8");
56
+ expect(materializePackage(home, pkg)).toBe(dir);
57
+ expect(readFileSync(join(dir, "src", "index.ts"), "utf8")).toBe("edited");
58
+ // A rebuilt executable of the same version with different content refreshes it.
59
+ expect(materializePackage(home, { ...pkg, files: { ...pkg.files, "src/index.ts": "v2" } })).toBe(dir);
60
+ expect(readFileSync(join(dir, "src", "index.ts"), "utf8")).toBe("v2");
61
+ } finally {
62
+ rmSync(home, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ test("connect from the executable: it is Claude Code's key helper, goes on PATH, and remote.json names it", () => {
67
+ const home = mkdtempSync(join(tmpdir(), "amr-exe-connect-"));
68
+ const claude = join(home, ".claude");
69
+ mkdirSync(claude, { recursive: true });
70
+ const rh = join(home, ".auto-model-router");
71
+ const env = { HOME: home, PI_CODING_AGENT_DIR: join(home, "no-omp"), AUTO_MODEL_ROUTER_HOME: rh, HERMES_HOME: join(home, "no-hermes"), SHELL: "/bin/zsh" };
72
+ const exe = join(rh, "bin", "auto-model-router");
73
+ try {
74
+ const r = connectRemote({ url: "https://team.example", key: "amrt_k", userId: "u_ada", name: "Ada", profile: true, dryRun: false, only: ["claude"], env, home, packageDir: join(rh, "package", "1.0.0"), exePath: exe, platform: "linux", pathHas: () => false });
75
+ expect(r.configured.some((c) => c.startsWith("Claude Code ("))).toBe(true);
76
+ const s = JSON.parse(readFileSync(join(claude, "settings.json"), "utf8")) as { apiKeyHelper: string };
77
+ expect(s.apiKeyHelper).toBe(`"${exe.replaceAll("\\", "/")}" token`);
78
+ expect(parseRemoteRouter(readFileSync(join(rh, "remote.json"), "utf8"))?.executable).toBe(exe);
79
+ const rc = readFileSync(join(home, ".zshrc"), "utf8");
80
+ expect(rc).toContain(`export PATH="${join(rh, "bin")}:$PATH"`);
81
+ expect(rc).toContain("export AUTO_MODEL_ROUTER_URL=https://team.example");
82
+ // Re-running keeps one block.
83
+ connectRemote({ url: "https://team.example", key: "amrt_k2", userId: "u_ada", name: "Ada", profile: true, dryRun: false, only: ["claude"], env, home, packageDir: join(rh, "package", "1.0.0"), exePath: exe, platform: "linux", pathHas: () => false });
84
+ expect(readFileSync(join(home, ".zshrc"), "utf8").split("# auto-model-router remote").length).toBe(2);
85
+ } finally {
86
+ rmSync(home, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ test("a setup token is traded for the credential; a refused token says so", async () => {
91
+ const seen: { url: string; body: string }[] = [];
92
+ const ok = (async (url: string | URL | Request, init?: RequestInit) => {
93
+ seen.push({ url: String(url), body: String(init?.body) });
94
+ return Response.json({ key: "amrt_new", refreshToken: "amrr_new", keyExpiresAtMs: 10, refreshExpiresAtMs: 20, userId: "u_ada", name: "Ada", teamUrl: "https://team.example" });
95
+ }) as unknown as typeof fetch;
96
+ const issued = await exchangeSetupToken("https://team.example", "amrs_t", "laptop", ok);
97
+ expect(issued).toEqual({ key: "amrt_new", refreshToken: "amrr_new", keyExpiresAtMs: 10, refreshExpiresAtMs: 20, userId: "u_ada", name: "Ada" });
98
+ expect(seen[0]?.url).toBe("https://team.example/setup/exchange");
99
+ expect(JSON.parse(seen[0]?.body ?? "{}")).toEqual({ token: "amrs_t", device: "laptop" });
100
+ const refused = (async () => Response.json({ error: "invalid_token" }, { status: 401 })) as unknown as typeof fetch;
101
+ await expect(exchangeSetupToken("https://team.example", "amrs_old", "laptop", refused)).rejects.toThrow("refused");
102
+ });
103
+
104
+ test("the executable builds for this host and knows its version from the embedded package", async () => {
105
+ const target = hostTarget();
106
+ if (target === null) return;
107
+ const dir = mkdtempSync(join(tmpdir(), "amr-build-"));
108
+ try {
109
+ const out = join(dir, executableFileName(target));
110
+ const r = await buildExecutable({ packageDir: process.cwd(), target, outFile: out });
111
+ if (!r.ok) throw new Error(r.reason);
112
+ expect(existsSync(out)).toBe(true);
113
+ expect(r.bytes).toBeGreaterThan(10_000_000);
114
+ const version = Bun.spawnSync([out, "--version"], { stdout: "pipe", stderr: "pipe" }).stdout.toString().trim();
115
+ expect(version).toBe((JSON.parse(readFileSync("package.json", "utf8")) as { version: string }).version);
116
+ } finally {
117
+ rmSync(dir, { recursive: true, force: true });
118
+ }
119
+ }, 120_000);
120
+
121
+ test("the global handle is what marks a compiled process", () => {
122
+ const g = globalThis as Record<string, unknown>;
123
+ g[EMBEDDED_GLOBAL] = { manifestPath: "/$bunfs/root/manifest.json" };
124
+ try {
125
+ expect(isCompiled()).toBe(true);
126
+ expect(embeddedHandle()?.manifestPath).toBe("/$bunfs/root/manifest.json");
127
+ } finally {
128
+ delete g[EMBEDDED_GLOBAL];
129
+ }
130
+ expect(isCompiled()).toBe(false);
131
+ });
132
+ });