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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +25 -0
- package/omp-extension/remote-logic.ts +7 -0
- package/package.json +1 -1
- package/src/cli/build-executable.ts +142 -0
- package/src/cli/connect.ts +111 -13
- package/src/cli/embedded.ts +78 -0
- package/src/cli/refresh.ts +12 -1
- package/src/cli/skills.ts +145 -0
- package/src/index.ts +7 -3
- package/src/lib.ts +2 -0
- package/test/executable.test.ts +132 -0
- package/test/skills.test.ts +110 -0
|
@@ -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
|
+
"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.
|
|
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
|
@@ -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
|
+
|
package/src/cli/connect.ts
CHANGED
|
@@ -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
|
-
|
|
343
|
-
const
|
|
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
|
|
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
|
-
|
|
380
|
-
|
|
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 =
|
|
384
|
-
|
|
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
|
-
//
|
|
400
|
-
const
|
|
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
|
+
}
|
package/src/cli/refresh.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|