auto-model-router 0.10.0 → 0.12.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.12.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.12.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -1340,6 +1340,31 @@ 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
+
1358
+ ### The remote's skills come along
1359
+
1360
+ A remote that serves a skills bundle (`GET <url>/setup/skills` with the member key: a
1361
+ version and a list of `{ name, files }`, each with a `SKILL.md`) has it installed by
1362
+ `connect`, and by every refresh, into the harnesses it configured that read user-level
1363
+ skills: Claude Code's `~/.claude/skills/<name>/` and omp's `~/.omp/agent/skills/<name>/`.
1364
+ `<router home>/skills-installed.json` records what was placed, so an update removes what
1365
+ the bundle no longer carries, and a skill of the same name the member wrote themselves is
1366
+ left alone with a note. A remote without skills answers 404 and nothing happens.
1367
+
1343
1368
  ## Multiple coding harnesses, one router
1344
1369
 
1345
1370
  **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.12.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,13 @@
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";
34
+ import { fetchSkills, installSkills, type SkillsBundle, type SkillsInstallReport, type SkillsTarget } from "./skills.ts";
33
35
  import { pickStore, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
34
36
  import { flagString, type CliArgs } from "./args.ts";
35
37
 
@@ -58,6 +60,14 @@ export interface ConnectOptions {
58
60
  keyExpiresAtMs?: number;
59
61
  refreshExpiresAtMs?: number;
60
62
  device?: string;
63
+ /**
64
+ * The compiled executable running this, when one is (see embedded.ts). It
65
+ * becomes Claude Code's key helper and goes on PATH with --profile, and
66
+ * remote.json records it so a refresh from omp keeps pointing at it.
67
+ */
68
+ exePath?: string;
69
+ /** The remote's skills bundle, installed into every configured harness that reads user-level skills. */
70
+ skills?: SkillsBundle;
61
71
  platform: string;
62
72
  pathHas: (bin: string) => boolean;
63
73
  }
@@ -68,6 +78,8 @@ export interface ConnectReport {
68
78
  skipped: string[];
69
79
  envLines: string[];
70
80
  notes: string[];
81
+ /** What the remote's skills bundle did, when there was one. */
82
+ skills?: SkillsInstallReport;
71
83
  }
72
84
 
73
85
  const expand = (raw: string, home: string): string => (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(home, raw.slice(1)) : raw);
@@ -257,6 +269,7 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
257
269
  ...(o.keyExpiresAtMs !== undefined ? { keyExpiresAtMs: o.keyExpiresAtMs } : {}),
258
270
  ...(o.refreshExpiresAtMs !== undefined ? { refreshExpiresAtMs: o.refreshExpiresAtMs } : {}),
259
271
  ...(o.device !== undefined && o.device !== "" ? { device: o.device } : {}),
272
+ ...(o.exePath !== undefined && o.exePath !== "" ? { executable: o.exePath } : {}),
260
273
  },
261
274
  null,
262
275
  2,
@@ -339,8 +352,9 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
339
352
  const env: Record<string, unknown> = { ...((settings.env as Record<string, unknown> | undefined) ?? {}), ANTHROPIC_BASE_URL: o.url };
340
353
  // Never leave a stale key beside the helper: the helper is the source now.
341
354
  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` };
355
+ // The executable is its own helper; under bun the source entry is.
356
+ const helper = o.exePath !== undefined && o.exePath !== "" ? `"${o.exePath.replaceAll("\\", "/")}" token` : `bun run "${resolve(o.packageDir, "src", "index.ts").replaceAll("\\", "/")}" token`;
357
+ const next = { ...settings, env, apiKeyHelper: helper };
344
358
  const after = `${JSON.stringify(next, null, 2)}\n`;
345
359
  if (after !== before) {
346
360
  if (before !== "" && !o.dryRun) writeFileSync(`${settingsPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, before, "utf8");
@@ -351,6 +365,17 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
351
365
  report.envLines.unshift(`AUTO_MODEL_ROUTER_URL=${o.url}`, `AUTO_MODEL_ROUTER_API_KEY=${o.key}`);
352
366
  report.envLines = [...new Set(report.envLines)];
353
367
 
368
+ // 6b. The remote's skills, into the harnesses configured above that read a
369
+ // user-level skills directory. Hermes, Codex and Aider have none we know of.
370
+ if (o.skills !== undefined) {
371
+ const targets: SkillsTarget[] = [];
372
+ if (report.configured.some((c) => c.startsWith("omp ("))) targets.push({ harness: "omp", dir: join(agentDir, "skills") });
373
+ if (report.configured.some((c) => c.startsWith("Claude Code ("))) targets.push({ harness: "Claude Code", dir: join(claudeDir, "skills") });
374
+ report.skills = installSkills(o.skills, targets, rh, o.dryRun);
375
+ if (report.skills.placed.length > 0) report.configured.push(`skills ${o.skills.version} (${report.skills.placed.join(", ")})`);
376
+ for (const s of report.skills.skipped) report.notes.push(`skill ${s}`);
377
+ }
378
+
354
379
  // 7. Persist the environment.
355
380
  if (o.profile && !o.dryRun) {
356
381
  if (o.platform === "win32") {
@@ -358,11 +383,14 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
358
383
  const [k, ...v] = line.split("=");
359
384
  Bun.spawnSync(["setx", k!, v.join("=")], { stdout: "ignore", stderr: "ignore" });
360
385
  }
386
+ // Not setx for PATH: it truncates at 1024 characters and would eat the rest.
387
+ if (o.exePath !== undefined && o.exePath !== "") addToUserPathWindows(dirname(o.exePath));
361
388
  report.notes.push("user environment variables set with setx; open a new terminal");
362
389
  } else {
363
390
  const shell = o.env.SHELL ?? "";
364
391
  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`;
392
+ const pathLine = o.exePath !== undefined && o.exePath !== "" ? [`export PATH="${dirname(o.exePath)}:$PATH"`] : [];
393
+ 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
394
  const before = existsSync(rc) ? readFileSync(rc, "utf8") : "";
367
395
  if (!before.includes("# auto-model-router remote") && !before.includes("# auto-model-router team")) appendFileSync(rc, block, "utf8");
368
396
  else write(rc, before.replace(/\n# auto-model-router (?:remote|team)[^\n]*\n(?:export [^\n]*\n)*/, block));
@@ -374,16 +402,85 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
374
402
  return report;
375
403
  }
376
404
 
405
+ /** What a team's one-time setup token is traded for. */
406
+ export interface IssuedCredential {
407
+ key: string;
408
+ refreshToken: string;
409
+ keyExpiresAtMs?: number;
410
+ refreshExpiresAtMs?: number;
411
+ userId: string;
412
+ name: string;
413
+ }
414
+
415
+ /**
416
+ * Trades a one-time setup token for this machine's credential at the team's
417
+ * exchange route, so the install needs nothing on the machine but this
418
+ * program: the token is the only secret in the install command and dies on use.
419
+ */
420
+ export async function exchangeSetupToken(url: string, token: string, device: string, fetchImpl: typeof fetch = fetch): Promise<IssuedCredential> {
421
+ const res = await fetchImpl(`${url}/setup/exchange`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, device }), signal: AbortSignal.timeout(15_000) });
422
+ 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");
423
+ if (!res.ok) throw new Error(`the team's setup exchange answered ${res.status}`);
424
+ const body = (await res.json()) as Record<string, unknown>;
425
+ if (typeof body.key !== "string" || body.key === "") throw new Error("the team's setup exchange returned no key");
426
+ return {
427
+ key: body.key,
428
+ refreshToken: typeof body.refreshToken === "string" ? body.refreshToken : "",
429
+ ...(typeof body.keyExpiresAtMs === "number" ? { keyExpiresAtMs: body.keyExpiresAtMs } : {}),
430
+ ...(typeof body.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: body.refreshExpiresAtMs } : {}),
431
+ userId: typeof body.userId === "string" ? body.userId : "",
432
+ name: typeof body.name === "string" ? body.name : "",
433
+ };
434
+ }
435
+
436
+ /** Adds `dir` to the user's PATH on Windows, once, through the registry-backed API rather than setx. */
437
+ function addToUserPathWindows(dir: string): void {
438
+ const quoted = `'${dir.replaceAll("'", "''")}'`;
439
+ 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') }`;
440
+ Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], { stdout: "ignore", stderr: "ignore" });
441
+ }
442
+
443
+ /**
444
+ * Where the harness integrations are read from. Under bun that is this
445
+ * package; in the compiled executable it is the copy written out from the
446
+ * executable's own embedded files.
447
+ */
448
+ async function resolvePackageDir(): Promise<string> {
449
+ const embedded = await readEmbeddedPackage();
450
+ if (embedded === null) return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
451
+ const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
452
+ return materializePackage(expand(raw, homedir()), embedded);
453
+ }
454
+
377
455
  export async function connectCommand(args: CliArgs): Promise<void> {
378
456
  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>");
457
+ let key = flagString(args, "key") ?? process.env.AUTO_MODEL_ROUTER_API_KEY ?? "";
458
+ const setupToken = flagString(args, "setup-token") ?? "";
459
+ 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
460
  const only = (flagString(args, "harness") ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => s !== "");
382
461
  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.
462
+ const packageDir = await resolvePackageDir();
463
+ const exePath = executablePath();
464
+ const fetchImpl = fetch;
385
465
  let name = flagString(args, "name") ?? "";
386
466
  let userId = flagString(args, "user-id") ?? "";
467
+ let device = flagString(args, "device") ?? "";
468
+ // A remote that issues short-lived keys hands these over beside the key.
469
+ let refreshToken = flagString(args, "refresh-token") ?? "";
470
+ let keyExpires = Number.parseInt(flagString(args, "key-expires") ?? "", 10);
471
+ let refreshExpires = Number.parseInt(flagString(args, "refresh-expires") ?? "", 10);
472
+ if (setupToken !== "") {
473
+ if (device === "") device = hostname();
474
+ const issued = await exchangeSetupToken(url, setupToken, device);
475
+ key = issued.key;
476
+ refreshToken = issued.refreshToken;
477
+ keyExpires = issued.keyExpiresAtMs ?? Number.NaN;
478
+ refreshExpires = issued.refreshExpiresAtMs ?? Number.NaN;
479
+ if (issued.userId !== "") userId = issued.userId;
480
+ if (issued.name !== "") name = issued.name;
481
+ console.log(`credential issued for ${name === "" ? userId : name} (device ${device})`);
482
+ }
483
+ // Verify the key against the route every router serves before touching anything.
387
484
  try {
388
485
  const res = await fetch(`${url}/v1/models`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
389
486
  if (res.status === 401) throw new Error("the remote router rejected this key");
@@ -396,11 +493,8 @@ export async function connectCommand(args: CliArgs): Promise<void> {
396
493
  // A single-project machine can label every request; a machine with several
397
494
  // repos should leave it off and let the extensions send the workspace's own.
398
495
  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") ?? "";
496
+ // The remote's skills for the agents on this machine; a remote without any serves 404.
497
+ const skills = await fetchSkills(url, key, fetchImpl);
404
498
  const report = connectRemote({
405
499
  url,
406
500
  key,
@@ -419,7 +513,11 @@ export async function connectCommand(args: CliArgs): Promise<void> {
419
513
  ...(Number.isFinite(keyExpires) ? { keyExpiresAtMs: keyExpires } : {}),
420
514
  ...(Number.isFinite(refreshExpires) ? { refreshExpiresAtMs: refreshExpires } : {}),
421
515
  ...(device === "" ? {} : { device }),
516
+ ...(exePath === null ? {} : { exePath }),
517
+ ...(skills.bundle === null ? {} : { skills: skills.bundle }),
422
518
  });
519
+ if (skills.note !== undefined) report.notes.push(skills.note);
520
+ if (exePath !== null) console.log(`executable ${exePath}; package files under ${packageDir}`);
423
521
  console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
424
522
  for (const c of report.configured) console.log(` configured ${c}`);
425
523
  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,8 @@
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";
17
+ import { fetchSkills } from "./skills.ts";
16
18
  import { homedir } from "node:os";
17
19
  import { dirname, resolve } from "node:path";
18
20
  import { fileURLToPath } from "node:url";
@@ -89,7 +91,14 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
89
91
  const rh = opts.routerHome ?? routerHome();
90
92
  const fresh = await refreshCredential(opts.remote, opts.fetchImpl ?? fetch, rh, opts.storeDeps ?? {});
91
93
  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)), "..", "..");
94
+ // The compiled executable rewrites from its own extracted package and keeps
95
+ // itself as the key helper; the omp extension, running from that extracted
96
+ // package, learns the executable from remote.json.
97
+ const embedded = opts.packageDir === undefined ? await readEmbeddedPackage() : null;
98
+ const packageDir = opts.packageDir ?? (embedded === null ? resolve(dirname(fileURLToPath(import.meta.url)), "..", "..") : materializePackage(rh, embedded));
99
+ const exePath = opts.remote.executable ?? executablePath() ?? undefined;
100
+ // A refresh is when the team's skills reach a machine that has not re-run connect.
101
+ const skills = await fetchSkills(opts.remote.url, fresh.key, opts.fetchImpl ?? fetch);
93
102
  connectRemote({
94
103
  url: opts.remote.url,
95
104
  key: fresh.key,
@@ -110,6 +119,8 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
110
119
  // The store that already holds it keeps it; a machine never silently changes store.
111
120
  ...(opts.remote.refreshTokenStore !== undefined ? { store: opts.remote.refreshTokenStore } : {}),
112
121
  ...(opts.storeDeps !== undefined ? { storeDeps: opts.storeDeps } : {}),
122
+ ...(exePath !== undefined ? { exePath } : {}),
123
+ ...(skills.bundle === null ? {} : { skills: skills.bundle }),
113
124
  // undefined keeps whatever scope the managed models.yml block already carries.
114
125
  });
115
126
  return fresh;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Skills served by a remote router, installed by `connect`.
3
+ *
4
+ * A coding agent is only as good as the instructions it carries, and a team
5
+ * has instructions it wants every member's agent to have: how its shared
6
+ * context works, how to use the router well. The remote serves them as one
7
+ * versioned bundle (`GET <url>/setup/skills`, with the member key), and
8
+ * `connect` writes them into every harness it configures that reads a
9
+ * user-level skills directory — Claude Code's `~/.claude/skills`, omp's
10
+ * `~/.omp/agent/skills`. A manifest under the router home records what was
11
+ * placed, so an update removes what the bundle no longer carries and a skill
12
+ * the member wrote themselves under the same name is never touched.
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+
18
+ export interface SkillsBundle {
19
+ /** Changes whenever any file changes; what `connect` reports and remembers. */
20
+ version: string;
21
+ skills: { name: string; files: Record<string, string> }[];
22
+ }
23
+
24
+ const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
25
+ const REL = /^(?!\.)(?!.*\/\.)[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
26
+
27
+ /** Parses a bundle defensively: a malformed one installs nothing rather than something odd. */
28
+ export function parseSkillsBundle(value: unknown): SkillsBundle | null {
29
+ if (typeof value !== "object" || value === null) return null;
30
+ const raw = value as Record<string, unknown>;
31
+ if (typeof raw.version !== "string" || raw.version === "" || !Array.isArray(raw.skills)) return null;
32
+ const skills: SkillsBundle["skills"] = [];
33
+ for (const s of raw.skills) {
34
+ if (typeof s !== "object" || s === null) return null;
35
+ const r = s as Record<string, unknown>;
36
+ if (typeof r.name !== "string" || !NAME.test(r.name) || typeof r.files !== "object" || r.files === null) return null;
37
+ const files: Record<string, string> = {};
38
+ for (const [rel, content] of Object.entries(r.files as Record<string, unknown>)) {
39
+ if (!REL.test(rel) || typeof content !== "string") return null;
40
+ files[rel] = content;
41
+ }
42
+ if (files["SKILL.md"] === undefined) return null;
43
+ skills.push({ name: r.name, files });
44
+ }
45
+ return { version: raw.version, skills };
46
+ }
47
+
48
+ /** The remote's bundle, or null when it serves none (404) or cannot be reached; never throws. */
49
+ export async function fetchSkills(url: string, key: string, fetchImpl: typeof fetch = fetch): Promise<{ bundle: SkillsBundle | null; note?: string }> {
50
+ try {
51
+ const res = await fetchImpl(`${url}/setup/skills`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(15_000) });
52
+ if (res.status === 404) return { bundle: null };
53
+ if (!res.ok) return { bundle: null, note: `skills: ${url}/setup/skills answered ${res.status}; nothing installed` };
54
+ const bundle = parseSkillsBundle(await res.json());
55
+ return bundle === null ? { bundle: null, note: "skills: the remote's bundle was not understood; nothing installed" } : { bundle };
56
+ } catch (err) {
57
+ return { bundle: null, note: `skills: could not fetch ${url}/setup/skills (${err instanceof Error ? err.message : String(err)}); nothing installed` };
58
+ }
59
+ }
60
+
61
+ export interface SkillsTarget {
62
+ /** Shown in the report: "Claude Code", "omp". */
63
+ harness: string;
64
+ /** The harness's user-level skills directory; each skill goes in `<dir>/<name>/`. */
65
+ dir: string;
66
+ }
67
+
68
+ export interface SkillsInstallReport {
69
+ version: string;
70
+ /** "<harness>: <name>" per skill written. */
71
+ placed: string[];
72
+ /** Files from an earlier install the bundle no longer carries. */
73
+ removed: string[];
74
+ /** Skills left alone because the member has their own of that name there. */
75
+ skipped: string[];
76
+ }
77
+
78
+ interface Manifest {
79
+ version: string;
80
+ files: string[];
81
+ }
82
+
83
+ export function skillsManifestPath(routerHome: string): string {
84
+ return join(routerHome, "skills-installed.json");
85
+ }
86
+
87
+ export function readSkillsManifest(routerHome: string): Manifest | null {
88
+ try {
89
+ const raw = JSON.parse(readFileSync(skillsManifestPath(routerHome), "utf8")) as Record<string, unknown>;
90
+ return typeof raw.version === "string" && Array.isArray(raw.files) ? { version: raw.version, files: raw.files.filter((f): f is string => typeof f === "string") } : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ const norm = (p: string): string => p.replaceAll("\\", "/");
97
+
98
+ /**
99
+ * Writes the bundle into each target, records what it placed, removes what a
100
+ * previous install placed that is gone now, and skips a skill directory it did
101
+ * not create. Pure over the file system: no network, so a test can drive it.
102
+ */
103
+ export function installSkills(bundle: SkillsBundle, targets: readonly SkillsTarget[], routerHome: string, dryRun = false): SkillsInstallReport {
104
+ const previous = readSkillsManifest(routerHome);
105
+ const ours = new Set((previous?.files ?? []).map(norm));
106
+ const report: SkillsInstallReport = { version: bundle.version, placed: [], removed: [], skipped: [] };
107
+ const placedFiles: string[] = [];
108
+ for (const t of targets) {
109
+ for (const skill of bundle.skills) {
110
+ const dir = join(t.dir, skill.name);
111
+ const foreign = existsSync(dir) && !readdirSync(dir).some((f) => ours.has(norm(join(dir, f))));
112
+ if (foreign) {
113
+ report.skipped.push(`${t.harness}: ${skill.name} (a skill of that name is already there and was not placed by connect; left alone)`);
114
+ continue;
115
+ }
116
+ for (const [rel, content] of Object.entries(skill.files)) {
117
+ const path = join(dir, ...rel.split("/"));
118
+ placedFiles.push(norm(path));
119
+ if (dryRun) continue;
120
+ mkdirSync(dirname(path), { recursive: true });
121
+ writeFileSync(path, content, "utf8");
122
+ }
123
+ report.placed.push(`${t.harness}: ${skill.name}`);
124
+ }
125
+ }
126
+ const keep = new Set(placedFiles);
127
+ for (const old of ours) {
128
+ if (keep.has(old)) continue;
129
+ report.removed.push(old);
130
+ if (dryRun) continue;
131
+ rmSync(old, { force: true });
132
+ // An emptied skill directory goes too, so the harness does not list a hollow skill.
133
+ const parent = dirname(old);
134
+ try {
135
+ if (readdirSync(parent).length === 0) rmSync(parent, { recursive: true, force: true });
136
+ } catch {
137
+ /* already gone */
138
+ }
139
+ }
140
+ if (!dryRun) {
141
+ mkdirSync(routerHome, { recursive: true });
142
+ writeFileSync(skillsManifestPath(routerHome), `${JSON.stringify({ version: bundle.version, files: placedFiles } satisfies Manifest, null, 2)}\n`, "utf8");
143
+ }
144
+ return report;
145
+ }
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; the remote's skills are installed for Claude Code and omp)
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,7 @@ 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";
27
+ export { parseSkillsBundle, type SkillsBundle } from "./cli/skills.ts";
26
28
  export type { RequestPolicy } from "./wire/types.ts";
27
29
  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
+ });
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { connectRemote } from "../src/cli/connect.ts";
6
+ import { fetchSkills, installSkills, parseSkillsBundle, readSkillsManifest, type SkillsBundle } from "../src/cli/skills.ts";
7
+
8
+ const NL = String.fromCharCode(10);
9
+ const bundle = (version: string, skills: Record<string, Record<string, string>>): SkillsBundle => ({ version, skills: Object.entries(skills).map(([name, files]) => ({ name, files })) });
10
+
11
+ describe("skills served by the remote, installed by connect", () => {
12
+ test("a bundle is parsed defensively", () => {
13
+ expect(parseSkillsBundle({ version: "v1", skills: [{ name: "team-context", files: { "SKILL.md": "# x" } }] })?.skills[0]?.name).toBe("team-context");
14
+ expect(parseSkillsBundle({ version: "v1", skills: [{ name: "Bad Name", files: { "SKILL.md": "# x" } }] })).toBeNull();
15
+ expect(parseSkillsBundle({ version: "v1", skills: [{ name: "ok", files: { "notes.md": "x" } }] })).toBeNull(); // no SKILL.md
16
+ expect(parseSkillsBundle({ version: "v1", skills: [{ name: "ok", files: { "../escape.md": "x", "SKILL.md": "y" } }] })).toBeNull();
17
+ expect(parseSkillsBundle({ version: "", skills: [] })).toBeNull();
18
+ expect(parseSkillsBundle("nope")).toBeNull();
19
+ });
20
+
21
+ test("install writes each skill into each target, updates in place, removes what is gone, and leaves a member's own skill alone", () => {
22
+ const home = mkdtempSync(join(tmpdir(), "amr-skills-"));
23
+ const rh = join(home, ".auto-model-router");
24
+ const claude = join(home, ".claude", "skills");
25
+ const omp = join(home, ".omp", "agent", "skills");
26
+ try {
27
+ const targets = [
28
+ { harness: "Claude Code", dir: claude },
29
+ { harness: "omp", dir: omp },
30
+ ];
31
+ // The member already has their own "router" skill in Claude Code.
32
+ mkdirSync(join(claude, "router"), { recursive: true });
33
+ writeFileSync(join(claude, "router", "SKILL.md"), "mine", "utf8");
34
+ const v1 = bundle("v1", { "team-context": { "SKILL.md": `# ctx v1${NL}`, "notes/extra.md": "extra" }, router: { "SKILL.md": "# router v1" } });
35
+ const r1 = installSkills(v1, targets, rh);
36
+ expect(r1.placed).toEqual(["Claude Code: team-context", "omp: team-context", "omp: router"]);
37
+ expect(r1.skipped).toHaveLength(1);
38
+ expect(r1.skipped[0]).toContain("Claude Code: router");
39
+ expect(readFileSync(join(claude, "router", "SKILL.md"), "utf8")).toBe("mine");
40
+ expect(readFileSync(join(omp, "router", "SKILL.md"), "utf8")).toBe("# router v1");
41
+ expect(readFileSync(join(claude, "team-context", "notes", "extra.md"), "utf8")).toBe("extra");
42
+ expect(readSkillsManifest(rh)?.version).toBe("v1");
43
+ expect(readSkillsManifest(rh)?.files).toHaveLength(5);
44
+
45
+ // v2 drops the extra file and the router skill; team-context changes.
46
+ const v2 = bundle("v2", { "team-context": { "SKILL.md": `# ctx v2${NL}` } });
47
+ const r2 = installSkills(v2, targets, rh);
48
+ expect(r2.placed).toEqual(["Claude Code: team-context", "omp: team-context"]);
49
+ expect(readFileSync(join(omp, "team-context", "SKILL.md"), "utf8")).toBe(`# ctx v2${NL}`);
50
+ expect(existsSync(join(claude, "team-context", "notes", "extra.md"))).toBe(false);
51
+ expect(existsSync(join(omp, "router"))).toBe(false); // ours, now gone, directory and all
52
+ expect(readFileSync(join(claude, "router", "SKILL.md"), "utf8")).toBe("mine"); // theirs, untouched
53
+ expect(r2.removed).toHaveLength(3);
54
+ expect(readSkillsManifest(rh)?.files).toHaveLength(2);
55
+
56
+ // Dry run touches nothing.
57
+ const r3 = installSkills(bundle("v3", { fresh: { "SKILL.md": "x" } }), targets, rh, true);
58
+ expect(r3.placed).toHaveLength(2);
59
+ expect(existsSync(join(omp, "fresh"))).toBe(false);
60
+ expect(readSkillsManifest(rh)?.version).toBe("v2");
61
+ } finally {
62
+ rmSync(home, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ test("fetchSkills: a remote without skills is silent, an unreachable one is a note, never an error", async () => {
67
+ const gone = (async () => new Response("", { status: 404 })) as unknown as typeof fetch;
68
+ expect(await fetchSkills("https://t", "k", gone)).toEqual({ bundle: null });
69
+ const seen: string[] = [];
70
+ const ok = (async (url: string | URL | Request, init?: RequestInit) => {
71
+ seen.push(`${String(url)} ${(init?.headers as Record<string, string>).authorization}`);
72
+ return Response.json({ version: "v9", skills: [{ name: "team-context", files: { "SKILL.md": "# hi" } }] });
73
+ }) as unknown as typeof fetch;
74
+ expect((await fetchSkills("https://t", "amrt_k", ok)).bundle?.version).toBe("v9");
75
+ expect(seen[0]).toBe("https://t/setup/skills Bearer amrt_k");
76
+ const down = (async () => {
77
+ throw new Error("connect ECONNREFUSED");
78
+ }) as unknown as typeof fetch;
79
+ const r = await fetchSkills("https://t", "k", down);
80
+ expect(r.bundle).toBeNull();
81
+ expect(r.note).toContain("ECONNREFUSED");
82
+ const odd = (async () => Response.json({ version: "v1", skills: "?" })) as unknown as typeof fetch;
83
+ expect((await fetchSkills("https://t", "k", odd)).note).toContain("not understood");
84
+ });
85
+
86
+ test("connect installs the bundle only into the harnesses it configured", () => {
87
+ const home = mkdtempSync(join(tmpdir(), "amr-skills-connect-"));
88
+ mkdirSync(join(home, ".claude"), { recursive: true });
89
+ const agent = join(home, ".omp", "agent");
90
+ mkdirSync(agent, { recursive: true });
91
+ writeFileSync(join(agent, "config.yml"), `extensions: []${NL}`, "utf8");
92
+ const rh = join(home, ".auto-model-router");
93
+ const env = { HOME: home, PI_CODING_AGENT_DIR: agent, AUTO_MODEL_ROUTER_HOME: rh, HERMES_HOME: join(home, "no-hermes") };
94
+ try {
95
+ const skills = bundle("s1", { "team-context": { "SKILL.md": "# team" } });
96
+ const r = connectRemote({ url: "https://team.example", key: "amrt_k", userId: "u", name: "Ada", profile: false, dryRun: false, only: ["omp", "claude"], env, home, packageDir: "/pkg", skills, platform: "linux", pathHas: () => false });
97
+ expect(r.skills?.placed).toEqual(["omp: team-context", "Claude Code: team-context"]);
98
+ expect(readFileSync(join(agent, "skills", "team-context", "SKILL.md"), "utf8")).toBe("# team");
99
+ expect(readFileSync(join(home, ".claude", "skills", "team-context", "SKILL.md"), "utf8")).toBe("# team");
100
+ expect(r.configured.some((c) => c.startsWith("skills s1 ("))).toBe(true);
101
+ // Only Claude Code asked for: omp's directory is not created.
102
+ rmSync(join(agent, "skills"), { recursive: true, force: true });
103
+ const r2 = connectRemote({ url: "https://team.example", key: "amrt_k", userId: "u", name: "Ada", profile: false, dryRun: false, only: ["claude"], env, home, packageDir: "/pkg", skills, platform: "linux", pathHas: () => false });
104
+ expect(r2.skills?.placed).toEqual(["Claude Code: team-context"]);
105
+ expect(existsSync(join(agent, "skills"))).toBe(false);
106
+ } finally {
107
+ rmSync(home, { recursive: true, force: true });
108
+ }
109
+ });
110
+ });