auto-model-router 0.11.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 +10 -0
- package/package.json +1 -1
- package/src/cli/connect.ts +21 -0
- package/src/cli/refresh.ts +4 -0
- package/src/cli/skills.ts +145 -0
- package/src/index.ts +1 -1
- package/src/lib.ts +1 -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
|
@@ -1355,6 +1355,16 @@ becomes Claude Code's key helper and, with `--profile`, goes on PATH. `connect
|
|
|
1355
1355
|
`remote.json` records the executable, and a refresh from inside omp keeps the helper
|
|
1356
1356
|
pointed at it.
|
|
1357
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
|
+
|
|
1358
1368
|
## Multiple coding harnesses, one router
|
|
1359
1369
|
|
|
1360
1370
|
**One router process for everything.** omp's embed extension binds a private
|
package/package.json
CHANGED
package/src/cli/connect.ts
CHANGED
|
@@ -31,6 +31,7 @@ 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
33
|
import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
|
|
34
|
+
import { fetchSkills, installSkills, type SkillsBundle, type SkillsInstallReport, type SkillsTarget } from "./skills.ts";
|
|
34
35
|
import { pickStore, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
|
|
35
36
|
import { flagString, type CliArgs } from "./args.ts";
|
|
36
37
|
|
|
@@ -65,6 +66,8 @@ export interface ConnectOptions {
|
|
|
65
66
|
* remote.json records it so a refresh from omp keeps pointing at it.
|
|
66
67
|
*/
|
|
67
68
|
exePath?: string;
|
|
69
|
+
/** The remote's skills bundle, installed into every configured harness that reads user-level skills. */
|
|
70
|
+
skills?: SkillsBundle;
|
|
68
71
|
platform: string;
|
|
69
72
|
pathHas: (bin: string) => boolean;
|
|
70
73
|
}
|
|
@@ -75,6 +78,8 @@ export interface ConnectReport {
|
|
|
75
78
|
skipped: string[];
|
|
76
79
|
envLines: string[];
|
|
77
80
|
notes: string[];
|
|
81
|
+
/** What the remote's skills bundle did, when there was one. */
|
|
82
|
+
skills?: SkillsInstallReport;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
const expand = (raw: string, home: string): string => (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(home, raw.slice(1)) : raw);
|
|
@@ -360,6 +365,17 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
360
365
|
report.envLines.unshift(`AUTO_MODEL_ROUTER_URL=${o.url}`, `AUTO_MODEL_ROUTER_API_KEY=${o.key}`);
|
|
361
366
|
report.envLines = [...new Set(report.envLines)];
|
|
362
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
|
+
|
|
363
379
|
// 7. Persist the environment.
|
|
364
380
|
if (o.profile && !o.dryRun) {
|
|
365
381
|
if (o.platform === "win32") {
|
|
@@ -445,6 +461,7 @@ export async function connectCommand(args: CliArgs): Promise<void> {
|
|
|
445
461
|
const pathHas = (bin: string): boolean => Bun.which(bin) !== null;
|
|
446
462
|
const packageDir = await resolvePackageDir();
|
|
447
463
|
const exePath = executablePath();
|
|
464
|
+
const fetchImpl = fetch;
|
|
448
465
|
let name = flagString(args, "name") ?? "";
|
|
449
466
|
let userId = flagString(args, "user-id") ?? "";
|
|
450
467
|
let device = flagString(args, "device") ?? "";
|
|
@@ -476,6 +493,8 @@ export async function connectCommand(args: CliArgs): Promise<void> {
|
|
|
476
493
|
// A single-project machine can label every request; a machine with several
|
|
477
494
|
// repos should leave it off and let the extensions send the workspace's own.
|
|
478
495
|
const scopeFlag = flagString(args, "scope");
|
|
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);
|
|
479
498
|
const report = connectRemote({
|
|
480
499
|
url,
|
|
481
500
|
key,
|
|
@@ -495,7 +514,9 @@ export async function connectCommand(args: CliArgs): Promise<void> {
|
|
|
495
514
|
...(Number.isFinite(refreshExpires) ? { refreshExpiresAtMs: refreshExpires } : {}),
|
|
496
515
|
...(device === "" ? {} : { device }),
|
|
497
516
|
...(exePath === null ? {} : { exePath }),
|
|
517
|
+
...(skills.bundle === null ? {} : { skills: skills.bundle }),
|
|
498
518
|
});
|
|
519
|
+
if (skills.note !== undefined) report.notes.push(skills.note);
|
|
499
520
|
if (exePath !== null) console.log(`executable ${exePath}; package files under ${packageDir}`);
|
|
500
521
|
console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
|
|
501
522
|
for (const c of report.configured) console.log(` configured ${c}`);
|
package/src/cli/refresh.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
|
|
17
|
+
import { fetchSkills } from "./skills.ts";
|
|
17
18
|
import { homedir } from "node:os";
|
|
18
19
|
import { dirname, resolve } from "node:path";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
@@ -96,6 +97,8 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
|
|
|
96
97
|
const embedded = opts.packageDir === undefined ? await readEmbeddedPackage() : null;
|
|
97
98
|
const packageDir = opts.packageDir ?? (embedded === null ? resolve(dirname(fileURLToPath(import.meta.url)), "..", "..") : materializePackage(rh, embedded));
|
|
98
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);
|
|
99
102
|
connectRemote({
|
|
100
103
|
url: opts.remote.url,
|
|
101
104
|
key: fresh.key,
|
|
@@ -117,6 +120,7 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
|
|
|
117
120
|
...(opts.remote.refreshTokenStore !== undefined ? { store: opts.remote.refreshTokenStore } : {}),
|
|
118
121
|
...(opts.storeDeps !== undefined ? { storeDeps: opts.storeDeps } : {}),
|
|
119
122
|
...(exePath !== undefined ? { exePath } : {}),
|
|
123
|
+
...(skills.bundle === null ? {} : { skills: skills.bundle }),
|
|
120
124
|
// undefined keeps whatever scope the managed models.yml block already carries.
|
|
121
125
|
});
|
|
122
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
|
@@ -28,7 +28,7 @@ Usage: auto-model-router <command> [options]
|
|
|
28
28
|
stats Show routed spend, per-model share, and escalation rates
|
|
29
29
|
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
30
30
|
export One row per day, harness and model as CSV (--json for rows)
|
|
31
|
-
connect Point this machine at a remote router (--url with --key[, --refresh-token] or --setup-token <one-time token from a team>; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment and, from the compiled executable, PATH)
|
|
31
|
+
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)
|
|
32
32
|
refresh Trade the refresh token for a new access key and re-write every harness config (--force: even when not near expiry)
|
|
33
33
|
token Print an access key that is good right now, refreshing first if needed (for a harness key-helper)
|
|
34
34
|
models Show what each complexity tier would consider, and why
|
package/src/lib.ts
CHANGED
|
@@ -24,5 +24,6 @@ export { spendUsdSince, feedbackView, exportRows, exportCsv, harnessScopeParam,
|
|
|
24
24
|
export { createLedger } from "./cost/ledger.ts";
|
|
25
25
|
export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./cost/feedback.ts";
|
|
26
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";
|
|
27
28
|
export type { RequestPolicy } from "./wire/types.ts";
|
|
28
29
|
export type { Ledger, LedgerEntry } from "./cost/types.ts";
|
|
@@ -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
|
+
});
|