@leuria/cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/{chunk-BXSAYWR3.js → chunk-JOCM56MT.js} +569 -45
- package/dist/cli.js +64 -8
- package/dist/index.d.ts +255 -122
- package/dist/index.js +1 -1
- package/package.json +9 -2
|
@@ -41,6 +41,14 @@ async function getRegistryEntry(id) {
|
|
|
41
41
|
}
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
|
+
function runnableHere(entry) {
|
|
45
|
+
const d = entry.distribution;
|
|
46
|
+
return Boolean(d.npx || d.uvx || d.binary?.[platformKey()]);
|
|
47
|
+
}
|
|
48
|
+
function clearRegistryCache() {
|
|
49
|
+
cache = null;
|
|
50
|
+
failedAt = 0;
|
|
51
|
+
}
|
|
44
52
|
|
|
45
53
|
// src/home.ts
|
|
46
54
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
@@ -335,6 +343,7 @@ import { createHash } from "crypto";
|
|
|
335
343
|
import { chmodSync as chmodSync2, existsSync as existsSync3, mkdirSync as mkdirSync4, readdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync3 } from "fs";
|
|
336
344
|
import { dirname, join as join3 } from "path";
|
|
337
345
|
import { promisify as promisify2 } from "util";
|
|
346
|
+
import crossSpawn from "cross-spawn";
|
|
338
347
|
|
|
339
348
|
// src/codex.ts
|
|
340
349
|
import { execFile } from "child_process";
|
|
@@ -366,7 +375,11 @@ async function prepareCodexHome(agent) {
|
|
|
366
375
|
"",
|
|
367
376
|
"[features]",
|
|
368
377
|
...features.map((name) => `${name} = ${ENABLED_FEATURES.has(name)}`),
|
|
369
|
-
""
|
|
378
|
+
"",
|
|
379
|
+
// Windows: the sandbox that needs no administrator rights (a restricted token).
|
|
380
|
+
// The other one asks for them the first time: out of the blue, and a dead end
|
|
381
|
+
// on a work computer without them.
|
|
382
|
+
...process.platform === "win32" ? ["[windows]", 'sandbox = "unelevated"', ""] : []
|
|
370
383
|
];
|
|
371
384
|
writeFileSync2(join2(home, "config.toml"), lines.join("\n"), { mode: 384 });
|
|
372
385
|
writeFileSync2(stamp, agent.version);
|
|
@@ -454,9 +467,33 @@ async function ensureAgentOnce(id, options) {
|
|
|
454
467
|
}
|
|
455
468
|
if (installed?.version === entry.version) return installed;
|
|
456
469
|
options.onProgress?.(`Installing ${entry.name} ${entry.version} from the ACP registry\u2026`);
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
470
|
+
return install(entry);
|
|
471
|
+
}
|
|
472
|
+
function installedIds() {
|
|
473
|
+
const root = agentsDir();
|
|
474
|
+
if (!existsSync3(root)) return [];
|
|
475
|
+
const ids = readdirSync(root).filter((name) => name.includes("@") && existsSync3(join3(root, name, MANIFEST))).map((name) => name.slice(0, name.lastIndexOf("@")));
|
|
476
|
+
return [...new Set(ids)];
|
|
477
|
+
}
|
|
478
|
+
function pruneAgentVersions() {
|
|
479
|
+
const root = agentsDir();
|
|
480
|
+
for (const id of installedIds()) {
|
|
481
|
+
const versions = readdirSync(root).filter((name) => name.startsWith(`${id}@`) && existsSync3(join3(root, name, MANIFEST))).sort(compareVersions);
|
|
482
|
+
for (const old of versions.slice(0, -1)) rmSync(join3(root, old), { recursive: true, force: true });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async function updateAgents(onUpdated) {
|
|
486
|
+
clearRegistryCache();
|
|
487
|
+
for (const id of installedIds()) {
|
|
488
|
+
const installed = installedAgent(id);
|
|
489
|
+
const entry = await getRegistryEntry(id);
|
|
490
|
+
if (!installed || !entry || !runnableHere(entry) || compareVersions(entry.version, installed.version) <= 0) continue;
|
|
491
|
+
try {
|
|
492
|
+
const agent = await ensureAgent(id, { update: true });
|
|
493
|
+
await onUpdated?.(agent, installed.version);
|
|
494
|
+
} catch {
|
|
495
|
+
}
|
|
496
|
+
}
|
|
460
497
|
}
|
|
461
498
|
async function resolveAgentCommand(id, model) {
|
|
462
499
|
if (isLlmId(id)) {
|
|
@@ -529,6 +566,12 @@ async function install(entry) {
|
|
|
529
566
|
throw error;
|
|
530
567
|
}
|
|
531
568
|
const agent = { id: entry.id, name: entry.name, version: entry.version, dir, ...launch };
|
|
569
|
+
try {
|
|
570
|
+
await profileFor(entry.id)?.prepare?.(agent);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
rmSync(dir, { recursive: true, force: true });
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
532
575
|
writeFileSync3(join3(dir, MANIFEST), `${JSON.stringify(agent, null, 2)}
|
|
533
576
|
`);
|
|
534
577
|
return agent;
|
|
@@ -538,12 +581,7 @@ async function installPackage(dir, spec, args, env) {
|
|
|
538
581
|
if (process.versions.bun) {
|
|
539
582
|
await execFileAsync2(process.execPath, ["add", spec], { cwd: dir, env: { ...process.env, BUN_BE_BUN: "1" }, timeout: 5 * 6e4 });
|
|
540
583
|
} else {
|
|
541
|
-
|
|
542
|
-
await execFileAsync2(npm, ["install", "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", spec], {
|
|
543
|
-
cwd: dir,
|
|
544
|
-
timeout: 5 * 6e4,
|
|
545
|
-
shell: process.platform === "win32"
|
|
546
|
-
});
|
|
584
|
+
await run("npm", ["install", "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", spec], { cwd: dir, timeout: 5 * 6e4 });
|
|
547
585
|
}
|
|
548
586
|
const name = spec.lastIndexOf("@") > 0 ? spec.slice(0, spec.lastIndexOf("@")) : spec;
|
|
549
587
|
const pkgDir = join3(dir, "node_modules", ...name.split("/"));
|
|
@@ -555,6 +593,25 @@ async function installPackage(dir, spec, args, env) {
|
|
|
555
593
|
const isScript = /\.[cm]?js$/.test(entry) || /^#!.*\b(node|bun)\b/.test(readFileSync3(entry, "utf-8").slice(0, 100));
|
|
556
594
|
return isScript ? { command: process.execPath, launchArgs: [entry], args, env } : { command: entry, launchArgs: [], args, env };
|
|
557
595
|
}
|
|
596
|
+
function run(command, args, options) {
|
|
597
|
+
return new Promise((resolve, reject) => {
|
|
598
|
+
const child = crossSpawn(command, args, { cwd: options.cwd, stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
|
|
599
|
+
let stderr = "";
|
|
600
|
+
child.stderr?.on("data", (chunk) => {
|
|
601
|
+
stderr += chunk.toString("utf-8");
|
|
602
|
+
});
|
|
603
|
+
const timer = setTimeout(() => child.kill(), options.timeout);
|
|
604
|
+
child.on("error", (error) => {
|
|
605
|
+
clearTimeout(timer);
|
|
606
|
+
reject(error);
|
|
607
|
+
});
|
|
608
|
+
child.on("close", (code) => {
|
|
609
|
+
clearTimeout(timer);
|
|
610
|
+
if (code === 0) resolve();
|
|
611
|
+
else reject(new Error(`${command} ${args.join(" ")} failed (${code ?? "stopped"})${stderr.trim() ? `: ${stderr.trim()}` : ""}`));
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
}
|
|
558
615
|
async function installBinary(dir, target) {
|
|
559
616
|
const res = await fetch(target.archive, { signal: AbortSignal.timeout(5 * 6e4) });
|
|
560
617
|
if (!res.ok) throw new Error(`Download failed (${res.status}): ${target.archive}`);
|
|
@@ -597,16 +654,17 @@ function compareVersions(a, b) {
|
|
|
597
654
|
}
|
|
598
655
|
|
|
599
656
|
// src/version.ts
|
|
600
|
-
var VERSION = true ? "0.1.
|
|
657
|
+
var VERSION = true ? "0.1.2" : "0.0.0-dev";
|
|
601
658
|
|
|
602
659
|
// src/auth.ts
|
|
603
|
-
import
|
|
660
|
+
import crossSpawn3 from "cross-spawn";
|
|
604
661
|
import { mkdtempSync as mkdtempSync2, rmSync as rmSync3 } from "fs";
|
|
605
662
|
import { tmpdir as tmpdir2 } from "os";
|
|
606
663
|
import { join as join5 } from "path";
|
|
607
664
|
|
|
608
665
|
// src/acp/acp-client.ts
|
|
609
666
|
import { spawn } from "child_process";
|
|
667
|
+
import crossSpawn2 from "cross-spawn";
|
|
610
668
|
import { Readable, Writable } from "stream";
|
|
611
669
|
var rawParams = {
|
|
612
670
|
// biome-ignore lint/suspicious/noExplicitAny: ACP payloads are untyped here
|
|
@@ -669,11 +727,11 @@ var AcpLiveSession = class {
|
|
|
669
727
|
const env = { ...process.env, ...this.options.envVars };
|
|
670
728
|
for (const key of REMOVED_ENV_VARS) delete env[key];
|
|
671
729
|
if (process.versions.bun) env.BUN_BE_BUN = "1";
|
|
672
|
-
const child =
|
|
730
|
+
const child = crossSpawn2(this.options.command, this.options.args, {
|
|
673
731
|
stdio: ["pipe", "pipe", "pipe"],
|
|
674
732
|
env,
|
|
675
733
|
cwd: this.options.cwd,
|
|
676
|
-
|
|
734
|
+
windowsHide: true,
|
|
677
735
|
// Its own process group, so close() also stops what the agent starts
|
|
678
736
|
// (Gemini relaunches itself as a child process).
|
|
679
737
|
detached: process.platform !== "win32"
|
|
@@ -877,7 +935,7 @@ ${file.text}
|
|
|
877
935
|
function killTree(child) {
|
|
878
936
|
try {
|
|
879
937
|
if (process.platform === "win32" && child.pid) {
|
|
880
|
-
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
938
|
+
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
881
939
|
} else if (child.pid) {
|
|
882
940
|
process.kill(-child.pid, "SIGTERM");
|
|
883
941
|
} else {
|
|
@@ -1050,7 +1108,11 @@ function checkTimeoutMs() {
|
|
|
1050
1108
|
const value = Number(process.env.LEURIA_CHECK_TIMEOUT_MS);
|
|
1051
1109
|
return Number.isFinite(value) && value > 0 ? value : 15e3;
|
|
1052
1110
|
}
|
|
1053
|
-
|
|
1111
|
+
function signInStallMs() {
|
|
1112
|
+
const value = Number(process.env.LEURIA_SIGNIN_STALL_MS);
|
|
1113
|
+
return Number.isFinite(value) && value > 0 ? value : 1e4;
|
|
1114
|
+
}
|
|
1115
|
+
async function checkSignIn(id, onProgress, options = {}) {
|
|
1054
1116
|
if (isLlmId(id)) return checkLlm(id);
|
|
1055
1117
|
const { session, close } = await open(id, onProgress);
|
|
1056
1118
|
let timer;
|
|
@@ -1061,7 +1123,7 @@ async function checkSignIn(id, onProgress) {
|
|
|
1061
1123
|
});
|
|
1062
1124
|
try {
|
|
1063
1125
|
const status = await Promise.race([timedOut, check()]);
|
|
1064
|
-
rememberReady(id, status.ok);
|
|
1126
|
+
if (!options.quiet) rememberReady(id, status.ok);
|
|
1065
1127
|
return status;
|
|
1066
1128
|
} finally {
|
|
1067
1129
|
clearTimeout(timer);
|
|
@@ -1078,7 +1140,11 @@ async function checkSignIn(id, onProgress) {
|
|
|
1078
1140
|
}
|
|
1079
1141
|
async function signIn(id, options = {}) {
|
|
1080
1142
|
if (isLlmId(id)) return checkLlm(id);
|
|
1081
|
-
|
|
1143
|
+
let pageOpened = false;
|
|
1144
|
+
const link = options.onUrl && !options.terminal ? catchBrowserLinks((url) => {
|
|
1145
|
+
pageOpened = true;
|
|
1146
|
+
options.onUrl?.(url);
|
|
1147
|
+
}) : null;
|
|
1082
1148
|
let opened;
|
|
1083
1149
|
try {
|
|
1084
1150
|
opened = await open(id, options.onProgress, options.terminal, link?.env);
|
|
@@ -1086,11 +1152,26 @@ async function signIn(id, options = {}) {
|
|
|
1086
1152
|
link?.stop();
|
|
1087
1153
|
throw error;
|
|
1088
1154
|
}
|
|
1089
|
-
const { agent,
|
|
1155
|
+
const { agent, env, sandbox } = opened;
|
|
1090
1156
|
const close = () => {
|
|
1091
1157
|
opened.close();
|
|
1092
1158
|
link?.stop();
|
|
1093
1159
|
};
|
|
1160
|
+
async function authenticateOrStall(session, methodId) {
|
|
1161
|
+
const auth = session.authenticate(methodId);
|
|
1162
|
+
if (!link) return auth;
|
|
1163
|
+
let timer;
|
|
1164
|
+
const stalled = new Promise((resolve) => {
|
|
1165
|
+
timer = setTimeout(() => {
|
|
1166
|
+
if (!pageOpened) resolve("stalled");
|
|
1167
|
+
}, signInStallMs());
|
|
1168
|
+
});
|
|
1169
|
+
try {
|
|
1170
|
+
return await Promise.race([auth, stalled]);
|
|
1171
|
+
} finally {
|
|
1172
|
+
clearTimeout(timer);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1094
1175
|
const cancelled = new Promise((resolve) => {
|
|
1095
1176
|
const onAbort = () => {
|
|
1096
1177
|
resolve({ ok: false, detail: "cancelled", methods: [] });
|
|
@@ -1100,11 +1181,12 @@ async function signIn(id, options = {}) {
|
|
|
1100
1181
|
else options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1101
1182
|
});
|
|
1102
1183
|
try {
|
|
1103
|
-
return await Promise.race([cancelled,
|
|
1184
|
+
return await Promise.race([cancelled, run2()]);
|
|
1104
1185
|
} finally {
|
|
1105
1186
|
close();
|
|
1106
1187
|
}
|
|
1107
|
-
async function
|
|
1188
|
+
async function run2() {
|
|
1189
|
+
let session = opened.session;
|
|
1108
1190
|
const connected = await session.connect();
|
|
1109
1191
|
if (connected.error) return { ok: false, detail: `the agent did not start: ${connected.error}`, methods: [] };
|
|
1110
1192
|
const all = session.info?.authMethods ?? [];
|
|
@@ -1118,7 +1200,7 @@ async function signIn(id, options = {}) {
|
|
|
1118
1200
|
if (isTerminal(method)) {
|
|
1119
1201
|
session.close();
|
|
1120
1202
|
const code = await new Promise((resolve, reject) => {
|
|
1121
|
-
const child =
|
|
1203
|
+
const child = crossSpawn3(agent.command, [...agent.launchArgs, ...method.args ?? []], {
|
|
1122
1204
|
stdio: "inherit",
|
|
1123
1205
|
cwd: sandbox,
|
|
1124
1206
|
env: { ...process.env, ...env, ...method.env ?? {} }
|
|
@@ -1129,7 +1211,16 @@ async function signIn(id, options = {}) {
|
|
|
1129
1211
|
if (code !== 0) return { ok: false, detail: `the sign-in ended with code ${code}`, methods };
|
|
1130
1212
|
return checkSignIn(id, options.onProgress);
|
|
1131
1213
|
}
|
|
1132
|
-
|
|
1214
|
+
let auth = await authenticateOrStall(session, method.id);
|
|
1215
|
+
if (auth === "stalled") {
|
|
1216
|
+
opened.close();
|
|
1217
|
+
if (options.signal?.aborted) return { ok: false, detail: "cancelled", methods };
|
|
1218
|
+
opened = await open(id, options.onProgress, options.terminal, link?.env);
|
|
1219
|
+
session = opened.session;
|
|
1220
|
+
const again = await session.connect();
|
|
1221
|
+
if (again.error) return { ok: false, detail: `the agent did not start: ${again.error}`, methods };
|
|
1222
|
+
auth = await session.authenticate(method.id);
|
|
1223
|
+
}
|
|
1133
1224
|
if (auth.error) return { ok: false, detail: auth.error, methods };
|
|
1134
1225
|
const created = await session.newSession();
|
|
1135
1226
|
if (created.error) return { ok: false, detail: created.error, methods };
|
|
@@ -1224,8 +1315,18 @@ var GrantStore = class {
|
|
|
1224
1315
|
for (const listener of this.changedListeners) listener(grant.origin);
|
|
1225
1316
|
return true;
|
|
1226
1317
|
}
|
|
1227
|
-
/**
|
|
1228
|
-
|
|
1318
|
+
/** Replace the site's skills (it changed its list). Its open conversations keep the skills they started with. */
|
|
1319
|
+
setSkills(origin, skills) {
|
|
1320
|
+
this.reload();
|
|
1321
|
+
const grant = this.grants.find((g) => g.origin === normalizeOrigin(origin));
|
|
1322
|
+
if (!grant) return false;
|
|
1323
|
+
if (skills) grant.skills = skills;
|
|
1324
|
+
else delete grant.skills;
|
|
1325
|
+
this.save();
|
|
1326
|
+
return true;
|
|
1327
|
+
}
|
|
1328
|
+
/** Create or replace the origin's grant; returns the new token. Keeps the site's AI and model choice; `needs` and `skills` replace what it declared. */
|
|
1329
|
+
create(origin, app, needs, skills) {
|
|
1229
1330
|
this.reload();
|
|
1230
1331
|
const normalized = normalizeOrigin(origin);
|
|
1231
1332
|
const token = randomBytes(32).toString("base64url");
|
|
@@ -1238,7 +1339,8 @@ var GrantStore = class {
|
|
|
1238
1339
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1239
1340
|
...previous?.agent ? { agent: previous.agent } : {},
|
|
1240
1341
|
...previous?.model ? { model: previous.model } : {},
|
|
1241
|
-
...needs ? { needs } : {}
|
|
1342
|
+
...needs ? { needs } : {},
|
|
1343
|
+
...skills ? { skills } : {}
|
|
1242
1344
|
});
|
|
1243
1345
|
this.save();
|
|
1244
1346
|
return token;
|
|
@@ -1456,7 +1558,7 @@ function parseEmbedBody(body) {
|
|
|
1456
1558
|
}
|
|
1457
1559
|
|
|
1458
1560
|
// src/pairing.ts
|
|
1459
|
-
import { createHash as
|
|
1561
|
+
import { createHash as createHash5, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1460
1562
|
|
|
1461
1563
|
// src/needs.ts
|
|
1462
1564
|
var EFFORTS = ["light", "standard", "deep"];
|
|
@@ -1473,13 +1575,363 @@ function parseNeeds(input) {
|
|
|
1473
1575
|
return Object.keys(needs).length ? needs : void 0;
|
|
1474
1576
|
}
|
|
1475
1577
|
|
|
1578
|
+
// src/skills.ts
|
|
1579
|
+
import { createHash as createHash4 } from "crypto";
|
|
1580
|
+
import cacache from "cacache";
|
|
1581
|
+
|
|
1582
|
+
// src/skill-sources.ts
|
|
1583
|
+
import { createHash as createHash3 } from "crypto";
|
|
1584
|
+
import { lookup } from "dns/promises";
|
|
1585
|
+
import { mkdtemp, readdir, readFile, rm, stat } from "fs/promises";
|
|
1586
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
1587
|
+
import { extname, join as join6, relative, sep } from "path";
|
|
1588
|
+
import { downloadTemplate } from "giget";
|
|
1589
|
+
import ipaddr from "ipaddr.js";
|
|
1590
|
+
import { parse as parseYaml } from "yaml";
|
|
1591
|
+
var TEXT_FILES = /* @__PURE__ */ new Set([".md", ".markdown", ".txt", ".json", ".yaml", ".yml", ".csv"]);
|
|
1592
|
+
var MAX_SKILL_BYTES = 256 * 1024;
|
|
1593
|
+
var MAX_FILES = 50;
|
|
1594
|
+
var MAX_TOTAL_BYTES = 1024 * 1024;
|
|
1595
|
+
var MAX_DESCRIPTION = 1024;
|
|
1596
|
+
var FETCH_TIMEOUT_MS = 15e3;
|
|
1597
|
+
var MAX_DEPTH = 5;
|
|
1598
|
+
var WELL_KNOWN = [".well-known/agent-skills", ".well-known/skills"];
|
|
1599
|
+
var DISCOVERY_V2 = "https://schemas.agentskills.io/discovery/0.2.0/schema.json";
|
|
1600
|
+
var SKILL_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
1601
|
+
var REPO_PART = /^[A-Za-z0-9_.-]+$/;
|
|
1602
|
+
function parseSkillSource(input, origin) {
|
|
1603
|
+
let ref = input.trim();
|
|
1604
|
+
if (ref.startsWith("/") || /^https?:\/\//i.test(ref)) {
|
|
1605
|
+
const url = new URL(ref, origin);
|
|
1606
|
+
if (url.hostname === "github.com") return parseGithubUrl(url);
|
|
1607
|
+
if (url.origin !== origin) throw new Error(`${input}: skills come from GitHub or from the site's own origin (${origin})`);
|
|
1608
|
+
url.hash = "";
|
|
1609
|
+
return { kind: "site", url: url.toString() };
|
|
1610
|
+
}
|
|
1611
|
+
ref = ref.replace(/^github:/, "");
|
|
1612
|
+
let pin;
|
|
1613
|
+
let skill;
|
|
1614
|
+
const hash = ref.indexOf("#");
|
|
1615
|
+
if (hash >= 0) {
|
|
1616
|
+
const fragment = ref.slice(hash + 1);
|
|
1617
|
+
ref = ref.slice(0, hash);
|
|
1618
|
+
const at2 = fragment.indexOf("@");
|
|
1619
|
+
pin = (at2 >= 0 ? fragment.slice(0, at2) : fragment) || void 0;
|
|
1620
|
+
skill = at2 >= 0 ? fragment.slice(at2 + 1) || void 0 : void 0;
|
|
1621
|
+
}
|
|
1622
|
+
const at = ref.match(/^([^/]+)\/([^/@]+)@(.+)$/);
|
|
1623
|
+
if (at) return github(input, `${at[1]}/${at[2]}`, pin, void 0, skill ?? at[3]);
|
|
1624
|
+
const parts = ref.split("/").filter(Boolean);
|
|
1625
|
+
if (parts.length < 2) throw new Error(`${input}: expected owner/repo, owner/repo@skill, a GitHub URL, or a URL on the site`);
|
|
1626
|
+
return github(input, `${parts[0]}/${parts[1]}`, pin, parts.slice(2).join("/") || void 0, skill);
|
|
1627
|
+
}
|
|
1628
|
+
function parseGithubUrl(url) {
|
|
1629
|
+
const [owner, repo, marker, pin, ...rest] = url.pathname.split("/").filter(Boolean);
|
|
1630
|
+
if (!owner || !repo) throw new Error(`${url}: not a GitHub repository`);
|
|
1631
|
+
const tree = marker === "tree" && pin;
|
|
1632
|
+
return github(url.toString(), `${owner}/${repo.replace(/\.git$/, "")}`, tree ? pin : void 0, tree ? rest.join("/") || void 0 : void 0, void 0);
|
|
1633
|
+
}
|
|
1634
|
+
function github(input, repo, ref, subpath, skill) {
|
|
1635
|
+
const [owner, name] = repo.split("/");
|
|
1636
|
+
if (![owner, name].every((part) => part && REPO_PART.test(part) && !/^\.+$/.test(part))) throw new Error(`${input}: not a GitHub repository`);
|
|
1637
|
+
if (subpath && subpath.split("/").some((p) => p === ".." || p === "." || !p)) throw new Error(`${input}: bad folder`);
|
|
1638
|
+
if (ref && !/^[\w./-]+$/.test(ref)) throw new Error(`${input}: bad ref`);
|
|
1639
|
+
return { kind: "github", repo, ...ref ? { ref } : {}, ...subpath ? { subpath } : {}, ...skill ? { skill } : {} };
|
|
1640
|
+
}
|
|
1641
|
+
function sourceLabel(source) {
|
|
1642
|
+
return source.kind === "github" ? source.repo : new URL(source.url).host;
|
|
1643
|
+
}
|
|
1644
|
+
async function fetchSkills(source) {
|
|
1645
|
+
const skills = source.kind === "github" ? await fromGithub(source) : await fromSite(source.url);
|
|
1646
|
+
if (!skills.length) throw new Error("no skill found");
|
|
1647
|
+
return skills;
|
|
1648
|
+
}
|
|
1649
|
+
function parseSkillMd(text, files = {}) {
|
|
1650
|
+
if (text.length > MAX_SKILL_BYTES) throw new Error("SKILL.md is too long");
|
|
1651
|
+
const match = text.replace(/^/, "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
1652
|
+
if (!match) throw new Error("SKILL.md has no front matter");
|
|
1653
|
+
const meta = parseYaml(match[1]);
|
|
1654
|
+
const name = typeof meta?.name === "string" ? meta.name.trim() : "";
|
|
1655
|
+
const description = typeof meta?.description === "string" ? meta.description.trim().replace(/\s+/g, " ") : "";
|
|
1656
|
+
if (!SKILL_NAME.test(name) || name.length > 64) throw new Error(`SKILL.md name "${name}" is not a skill name`);
|
|
1657
|
+
if (!description) throw new Error(`SKILL.md of ${name} has no description`);
|
|
1658
|
+
return { name, description: description.slice(0, MAX_DESCRIPTION), body: match[2].trim(), files };
|
|
1659
|
+
}
|
|
1660
|
+
async function fromGithub(source) {
|
|
1661
|
+
const dir = await mkdtemp(join6(tmpdir3(), "leuria-skills-"));
|
|
1662
|
+
try {
|
|
1663
|
+
const path2 = source.subpath ? `/${source.subpath}` : "";
|
|
1664
|
+
await downloadTemplate(`gh:${source.repo}${path2}#${source.ref ?? "HEAD"}`, {
|
|
1665
|
+
dir,
|
|
1666
|
+
force: true,
|
|
1667
|
+
forceClean: true,
|
|
1668
|
+
registry: false,
|
|
1669
|
+
silent: true,
|
|
1670
|
+
ignore: (file) => !TEXT_FILES.has(extname(file).toLowerCase())
|
|
1671
|
+
});
|
|
1672
|
+
const found = [];
|
|
1673
|
+
for (const skillFile of await findSkillFiles(dir, 0)) {
|
|
1674
|
+
let skill;
|
|
1675
|
+
try {
|
|
1676
|
+
skill = parseSkillMd(await readFile(skillFile, "utf-8"));
|
|
1677
|
+
} catch {
|
|
1678
|
+
continue;
|
|
1679
|
+
}
|
|
1680
|
+
if (source.skill && skill.name !== source.skill) continue;
|
|
1681
|
+
skill.files = await readTextFiles(join6(skillFile, ".."));
|
|
1682
|
+
found.push(skill);
|
|
1683
|
+
}
|
|
1684
|
+
return found;
|
|
1685
|
+
} finally {
|
|
1686
|
+
await rm(dir, { recursive: true, force: true });
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
async function findSkillFiles(dir, depth) {
|
|
1690
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1691
|
+
const own = entries.find((e) => e.isFile() && e.name.toLowerCase() === "skill.md");
|
|
1692
|
+
if (own) return [join6(dir, own.name)];
|
|
1693
|
+
if (depth >= MAX_DEPTH) return [];
|
|
1694
|
+
const nested = await Promise.all(
|
|
1695
|
+
entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => findSkillFiles(join6(dir, e.name), depth + 1))
|
|
1696
|
+
);
|
|
1697
|
+
return nested.flat();
|
|
1698
|
+
}
|
|
1699
|
+
async function readTextFiles(dir) {
|
|
1700
|
+
const files = {};
|
|
1701
|
+
let total = 0;
|
|
1702
|
+
const walk = async (at, depth) => {
|
|
1703
|
+
for (const entry of await readdir(at, { withFileTypes: true })) {
|
|
1704
|
+
const full = join6(at, entry.name);
|
|
1705
|
+
if (entry.isDirectory() && depth < MAX_DEPTH) await walk(full, depth + 1);
|
|
1706
|
+
if (!entry.isFile() || !TEXT_FILES.has(extname(entry.name).toLowerCase())) continue;
|
|
1707
|
+
const path2 = relative(dir, full).split(sep).join("/");
|
|
1708
|
+
if (path2.toLowerCase() === "skill.md" || Object.keys(files).length >= MAX_FILES) continue;
|
|
1709
|
+
const size = (await stat(full)).size;
|
|
1710
|
+
if (size > MAX_SKILL_BYTES || total + size > MAX_TOTAL_BYTES) continue;
|
|
1711
|
+
total += size;
|
|
1712
|
+
files[path2] = await readFile(full, "utf-8");
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
await walk(dir, 0);
|
|
1716
|
+
return files;
|
|
1717
|
+
}
|
|
1718
|
+
async function fromSite(url) {
|
|
1719
|
+
const parsed = new URL(url);
|
|
1720
|
+
if (/\/skill\.md$/i.test(parsed.pathname)) return [parseSkillMd(await siteText(url))];
|
|
1721
|
+
const named2 = parsed.pathname.match(/\/\.well-known\/(?:agent-skills|skills)\/([^/]+)\/?$/)?.[1];
|
|
1722
|
+
const base = parsed.pathname.replace(/\/\.well-known\/.*$/, "").replace(/\/$/, "");
|
|
1723
|
+
for (const wellKnown of WELL_KNOWN) {
|
|
1724
|
+
const indexUrl = `${parsed.origin}${base}/${wellKnown}/index.json`;
|
|
1725
|
+
let index;
|
|
1726
|
+
try {
|
|
1727
|
+
index = JSON.parse(await siteText(indexUrl));
|
|
1728
|
+
} catch {
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
const entries = indexEntries(index).filter((e) => !named2 || e.name === named2);
|
|
1732
|
+
const skills = [];
|
|
1733
|
+
for (const entry of entries) {
|
|
1734
|
+
try {
|
|
1735
|
+
skills.push(await fromIndexEntry(entry, indexUrl, `${parsed.origin}${base}/${wellKnown}/${entry.name}`));
|
|
1736
|
+
} catch {
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return skills;
|
|
1740
|
+
}
|
|
1741
|
+
throw new Error("the site has no .well-known/agent-skills/index.json");
|
|
1742
|
+
}
|
|
1743
|
+
function indexEntries(index) {
|
|
1744
|
+
const record = index;
|
|
1745
|
+
if (!record || !Array.isArray(record.skills)) return [];
|
|
1746
|
+
const v2 = record.$schema === DISCOVERY_V2;
|
|
1747
|
+
return record.skills.flatMap((raw) => {
|
|
1748
|
+
const e = raw;
|
|
1749
|
+
if (typeof e.name !== "string" || !SKILL_NAME.test(e.name)) return [];
|
|
1750
|
+
if (v2) {
|
|
1751
|
+
if (e.type !== "skill-md" || typeof e.url !== "string" || typeof e.digest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(e.digest)) return [];
|
|
1752
|
+
return [{ name: e.name, url: e.url, digest: e.digest }];
|
|
1753
|
+
}
|
|
1754
|
+
const files = Array.isArray(e.files) ? e.files.filter((f) => typeof f === "string" && !f.startsWith("/") && !f.includes("..")) : [];
|
|
1755
|
+
return files.some((f) => f.toLowerCase() === "skill.md") ? [{ name: e.name, files }] : [];
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
async function fromIndexEntry(entry, indexUrl, folder) {
|
|
1759
|
+
if (entry.url) {
|
|
1760
|
+
const text = await siteText(new URL(entry.url, indexUrl).toString());
|
|
1761
|
+
const digest = `sha256:${createHash3("sha256").update(text).digest("hex")}`;
|
|
1762
|
+
if (digest !== entry.digest) throw new Error(`${entry.name}: SKILL.md doesn't match its digest`);
|
|
1763
|
+
return named(parseSkillMd(text), entry.name);
|
|
1764
|
+
}
|
|
1765
|
+
const files = {};
|
|
1766
|
+
let skillMd = "";
|
|
1767
|
+
for (const file of (entry.files ?? []).slice(0, MAX_FILES)) {
|
|
1768
|
+
const text = await siteText(`${folder}/${file}`);
|
|
1769
|
+
if (file.toLowerCase() === "skill.md") skillMd = text;
|
|
1770
|
+
else if (TEXT_FILES.has(extname(file).toLowerCase())) files[file] = text;
|
|
1771
|
+
}
|
|
1772
|
+
return named(parseSkillMd(skillMd, files), entry.name);
|
|
1773
|
+
}
|
|
1774
|
+
function named(skill, name) {
|
|
1775
|
+
if (skill.name !== name) throw new Error(`the index names ${name}, its SKILL.md ${skill.name}`);
|
|
1776
|
+
return skill;
|
|
1777
|
+
}
|
|
1778
|
+
async function siteText(url) {
|
|
1779
|
+
const { hostname } = new URL(url);
|
|
1780
|
+
const host = hostname.replace(/^\[|\]$/g, "");
|
|
1781
|
+
if (!isLoopbackName(host)) {
|
|
1782
|
+
const addresses = ipaddr.isValid(host) ? [{ address: host }] : await lookup(host, { all: true });
|
|
1783
|
+
for (const { address } of addresses) {
|
|
1784
|
+
const range = ipaddr.process(address).range();
|
|
1785
|
+
if (range !== "unicast") throw new Error(`${hostname} resolves to a ${range} address`);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
const res = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
1789
|
+
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
|
1790
|
+
if (Number(res.headers.get("content-length")) > MAX_SKILL_BYTES) throw new Error(`${url} is too large`);
|
|
1791
|
+
const text = await res.text();
|
|
1792
|
+
if (text.length > MAX_SKILL_BYTES) throw new Error(`${url} is too large`);
|
|
1793
|
+
return text;
|
|
1794
|
+
}
|
|
1795
|
+
function isLoopbackName(host) {
|
|
1796
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
1797
|
+
return ipaddr.isValid(host) && ipaddr.process(host).range() === "loopback";
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// src/skills.ts
|
|
1801
|
+
var READ_SKILL = "read_skill";
|
|
1802
|
+
var MAX_REFS = 16;
|
|
1803
|
+
var MAX_SKILLS = 16;
|
|
1804
|
+
var MAX_REF_LENGTH = 300;
|
|
1805
|
+
function parseSkillRefs(input) {
|
|
1806
|
+
const raw = typeof input === "string" ? [input] : Array.isArray(input) ? input : [];
|
|
1807
|
+
const refs = [...new Set(raw.filter((r) => typeof r === "string").map((r) => r.trim()).filter((r) => r && r.length <= MAX_REF_LENGTH))];
|
|
1808
|
+
return refs.length ? refs.slice(0, MAX_REFS) : void 0;
|
|
1809
|
+
}
|
|
1810
|
+
var sameRefs = (a, b) => (a ?? []).join("\n") === (b ?? []).join("\n");
|
|
1811
|
+
var SkillService = class {
|
|
1812
|
+
constructor(options) {
|
|
1813
|
+
this.options = options;
|
|
1814
|
+
this.cache = options.cache === void 0 ? homePath("skills") : options.cache;
|
|
1815
|
+
}
|
|
1816
|
+
options;
|
|
1817
|
+
cache;
|
|
1818
|
+
memory = /* @__PURE__ */ new Map();
|
|
1819
|
+
refreshing = /* @__PURE__ */ new Map();
|
|
1820
|
+
/**
|
|
1821
|
+
* Fetch and cache the skills `refs` name for `origin`. A ref that fails
|
|
1822
|
+
* is logged and skipped; the first skill of a name wins.
|
|
1823
|
+
*/
|
|
1824
|
+
async resolve(origin, refs) {
|
|
1825
|
+
const list = [];
|
|
1826
|
+
for (const ref of refs) {
|
|
1827
|
+
let found;
|
|
1828
|
+
try {
|
|
1829
|
+
found = await (this.options.fetch ?? fetchRef)(ref, origin);
|
|
1830
|
+
} catch (error) {
|
|
1831
|
+
this.options.logger.warn("skill source failed", { origin, ref, error: error instanceof Error ? error.message : String(error) });
|
|
1832
|
+
continue;
|
|
1833
|
+
}
|
|
1834
|
+
for (const skill of found.skills) {
|
|
1835
|
+
if (list.length >= MAX_SKILLS || list.some((s) => s.name === skill.name)) continue;
|
|
1836
|
+
list.push({ name: skill.name, description: skill.description, source: found.source, shared: found.shared, integrity: await this.put(skill) });
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
return list;
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* A connected site sent its skill refs with a session: when they changed,
|
|
1843
|
+
* fetch them in the background and update its grant. Sessions starting
|
|
1844
|
+
* meanwhile keep the skills it had.
|
|
1845
|
+
*/
|
|
1846
|
+
refresh(origin, refs) {
|
|
1847
|
+
const grant = this.options.grants.get(origin);
|
|
1848
|
+
if (!grant || sameRefs(grant.skills?.refs, refs) || this.refreshing.has(origin)) return;
|
|
1849
|
+
const done = (async () => {
|
|
1850
|
+
const list = refs ? await this.resolve(origin, refs) : [];
|
|
1851
|
+
const before = new Set((this.options.grants.get(origin)?.skills?.list ?? []).map((s) => s.name));
|
|
1852
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1853
|
+
const marked = list.map((s) => before.has(s.name) ? s : { ...s, addedAt: now });
|
|
1854
|
+
this.options.grants.setSkills(origin, refs ? { refs, list: marked } : void 0);
|
|
1855
|
+
})().catch((error) => this.options.logger.warn("skills refresh failed", { origin, error: String(error) })).finally(() => this.refreshing.delete(origin));
|
|
1856
|
+
this.refreshing.set(origin, done);
|
|
1857
|
+
}
|
|
1858
|
+
/** The skills a session of `origin` gets, from the cache. */
|
|
1859
|
+
async forSession(origin) {
|
|
1860
|
+
const list = this.options.grants.get(origin)?.skills?.list ?? [];
|
|
1861
|
+
const loaded = await Promise.all(list.map((s) => this.get(s.integrity).catch(() => null)));
|
|
1862
|
+
return loaded.filter((s) => s !== null);
|
|
1863
|
+
}
|
|
1864
|
+
/** Wait for background refreshes (tests). */
|
|
1865
|
+
async settled() {
|
|
1866
|
+
await Promise.all(this.refreshing.values());
|
|
1867
|
+
}
|
|
1868
|
+
async put(skill) {
|
|
1869
|
+
const data = JSON.stringify(skill);
|
|
1870
|
+
if (this.cache === null) {
|
|
1871
|
+
const integrity = `sha256-${createHash4("sha256").update(data).digest("base64")}`;
|
|
1872
|
+
this.memory.set(integrity, skill);
|
|
1873
|
+
return integrity;
|
|
1874
|
+
}
|
|
1875
|
+
return String(await cacache.put(this.cache, `skill:${skill.name}`, data, { algorithms: ["sha256"] }));
|
|
1876
|
+
}
|
|
1877
|
+
async get(integrity) {
|
|
1878
|
+
if (this.cache === null) {
|
|
1879
|
+
const skill = this.memory.get(integrity);
|
|
1880
|
+
if (!skill) throw new Error("not cached");
|
|
1881
|
+
return skill;
|
|
1882
|
+
}
|
|
1883
|
+
return JSON.parse((await cacache.get.byDigest(this.cache, integrity)).toString("utf-8"));
|
|
1884
|
+
}
|
|
1885
|
+
};
|
|
1886
|
+
async function fetchRef(ref, origin) {
|
|
1887
|
+
const source = parseSkillSource(ref, origin);
|
|
1888
|
+
return { skills: await fetchSkills(source), source: sourceLabel(source), shared: source.kind === "github" };
|
|
1889
|
+
}
|
|
1890
|
+
function skillsPrompt(skills) {
|
|
1891
|
+
return [
|
|
1892
|
+
"This site gives you skills: instructions for tasks on this site.",
|
|
1893
|
+
`When a request matches a skill, call \`${READ_SKILL}\` with its name first and follow what it says.`,
|
|
1894
|
+
"",
|
|
1895
|
+
...skills.map((s) => `- ${s.name}: ${s.description}`)
|
|
1896
|
+
].join("\n");
|
|
1897
|
+
}
|
|
1898
|
+
function readSkillTool(skills) {
|
|
1899
|
+
return {
|
|
1900
|
+
name: READ_SKILL,
|
|
1901
|
+
description: "Read one of this site's skills: its instructions, or one of its files.",
|
|
1902
|
+
inputSchema: {
|
|
1903
|
+
type: "object",
|
|
1904
|
+
properties: {
|
|
1905
|
+
name: { type: "string", enum: skills.map((s) => s.name), description: "The skill's name." },
|
|
1906
|
+
file: { type: "string", description: "A file the skill mentions, by its path. Leave out for the skill's instructions." }
|
|
1907
|
+
},
|
|
1908
|
+
required: ["name"]
|
|
1909
|
+
},
|
|
1910
|
+
annotations: { readOnlyHint: true }
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
function readSkill(skills, args) {
|
|
1914
|
+
const skill = skills.find((s) => s.name === args.name);
|
|
1915
|
+
if (!skill) throw new Error(`No skill named ${String(args.name)}. Skills: ${skills.map((s) => s.name).join(", ")}`);
|
|
1916
|
+
if (typeof args.file === "string" && args.file) {
|
|
1917
|
+
const file = skill.files[args.file.replace(/^\.\//, "")];
|
|
1918
|
+
if (file === void 0) throw new Error(`${skill.name} has no file ${args.file}`);
|
|
1919
|
+
return file;
|
|
1920
|
+
}
|
|
1921
|
+
const files = Object.keys(skill.files);
|
|
1922
|
+
return files.length ? `${skill.body}
|
|
1923
|
+
|
|
1924
|
+
---
|
|
1925
|
+
Files of this skill (read one with \`file\`): ${files.join(", ")}` : skill.body;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1476
1928
|
// src/pairing.ts
|
|
1477
1929
|
var REQUEST_TTL_MS = 5 * 6e4;
|
|
1478
1930
|
var MAX_PENDING = 20;
|
|
1479
1931
|
var WAIT_MS = 25e3;
|
|
1480
1932
|
var DENIED_COOLDOWN_MS = 3e4;
|
|
1481
1933
|
var NONCE = /^[A-Za-z0-9_-]{22,128}$/;
|
|
1482
|
-
var hashNonce = (nonce) =>
|
|
1934
|
+
var hashNonce = (nonce) => createHash5("sha256").update(nonce).digest();
|
|
1483
1935
|
var Pairing = class {
|
|
1484
1936
|
constructor(options) {
|
|
1485
1937
|
this.options = options;
|
|
@@ -1508,7 +1960,7 @@ var Pairing = class {
|
|
|
1508
1960
|
if (this.options.selfOrigins.has(origin)) return { error: "The link doesn't name a website." };
|
|
1509
1961
|
if (typeof input.nonce !== "string" || !NONCE.test(input.nonce)) return { error: "The link is incomplete." };
|
|
1510
1962
|
const app = typeof input.app === "string" && input.app.trim() ? input.app.trim().slice(0, 80) : void 0;
|
|
1511
|
-
const request = this.open(origin, app, input.nonce, parseNeeds(input.needs));
|
|
1963
|
+
const request = this.open(origin, app, input.nonce, parseNeeds(input.needs), parseSkillRefs(input.skills));
|
|
1512
1964
|
return "error" in request ? request : { requestId: request.id };
|
|
1513
1965
|
}
|
|
1514
1966
|
/** Returns true when the request was a pairing route. */
|
|
@@ -1534,7 +1986,7 @@ var Pairing = class {
|
|
|
1534
1986
|
sendJson(res, 403, { error: "Forbidden" });
|
|
1535
1987
|
return true;
|
|
1536
1988
|
}
|
|
1537
|
-
sendJson(res, 200, { decision: request?.decision ?? "expired" });
|
|
1989
|
+
sendJson(res, 200, { decision: request?.decision ?? "expired", ...request?.skills && !request.skills.list ? { skillsLoading: true } : {} });
|
|
1538
1990
|
return true;
|
|
1539
1991
|
}
|
|
1540
1992
|
if (req.method === "POST" && action === "decide") {
|
|
@@ -1579,7 +2031,7 @@ var Pairing = class {
|
|
|
1579
2031
|
return;
|
|
1580
2032
|
}
|
|
1581
2033
|
const app = typeof body.app === "string" && body.app.trim() ? body.app.trim().slice(0, 80) : void 0;
|
|
1582
|
-
const opened = this.open(origin, app, nonce, parseNeeds(body.needs));
|
|
2034
|
+
const opened = this.open(origin, app, nonce, parseNeeds(body.needs), parseSkillRefs(body.skills));
|
|
1583
2035
|
if ("error" in opened) {
|
|
1584
2036
|
sendJson(res, opened.status, { error: opened.error });
|
|
1585
2037
|
return;
|
|
@@ -1604,7 +2056,7 @@ var Pairing = class {
|
|
|
1604
2056
|
}
|
|
1605
2057
|
}
|
|
1606
2058
|
/** Ask the visitor about `origin`, or update the question already asked. */
|
|
1607
|
-
open(origin, app, nonce, needs) {
|
|
2059
|
+
open(origin, app, nonce, needs, skillRefs) {
|
|
1608
2060
|
const denied = this.deniedAt.get(origin);
|
|
1609
2061
|
if (denied && Date.now() - denied < DENIED_COOLDOWN_MS) return { error: "The visitor just said no to this site", status: 429 };
|
|
1610
2062
|
const existing = [...this.requests.values()].find((r) => r.origin === origin && r.decision === "pending");
|
|
@@ -1612,6 +2064,7 @@ var Pairing = class {
|
|
|
1612
2064
|
existing.nonce = hashNonce(nonce);
|
|
1613
2065
|
if (app) existing.app = app;
|
|
1614
2066
|
if (needs) existing.needs = needs;
|
|
2067
|
+
if (skillRefs && !sameRefs(existing.skills?.refs, skillRefs)) this.fetchSkills(existing, skillRefs);
|
|
1615
2068
|
this.options.onRequest?.(this.info(existing));
|
|
1616
2069
|
return existing;
|
|
1617
2070
|
}
|
|
@@ -1629,16 +2082,35 @@ var Pairing = class {
|
|
|
1629
2082
|
waiters: /* @__PURE__ */ new Set()
|
|
1630
2083
|
};
|
|
1631
2084
|
this.requests.set(request.id, request);
|
|
2085
|
+
if (skillRefs) this.fetchSkills(request, skillRefs);
|
|
1632
2086
|
this.options.logger.info("pairing requested", { origin, app });
|
|
1633
2087
|
this.options.onRequest?.(this.info(request));
|
|
1634
2088
|
return request;
|
|
1635
2089
|
}
|
|
2090
|
+
/**
|
|
2091
|
+
* Fetch the skills the site names, then show them: the question is asked
|
|
2092
|
+
* again with them. Answered meanwhile: they go to the site's grant.
|
|
2093
|
+
*/
|
|
2094
|
+
fetchSkills(request, refs) {
|
|
2095
|
+
const { resolveSkills } = this.options;
|
|
2096
|
+
if (!resolveSkills) return;
|
|
2097
|
+
const skills = { refs };
|
|
2098
|
+
request.skills = skills;
|
|
2099
|
+
void resolveSkills(request.origin, refs).catch(() => []).then((list) => {
|
|
2100
|
+
if (request.skills !== skills) return;
|
|
2101
|
+
skills.list = list;
|
|
2102
|
+
if (request.decision === "pending") this.options.onRequest?.(this.info(request));
|
|
2103
|
+
else if (request.decision === "allowed") this.options.grants.setSkills(request.origin, { refs, list });
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
1636
2106
|
info(request) {
|
|
2107
|
+
const skills = request.skills;
|
|
1637
2108
|
return {
|
|
1638
2109
|
requestId: request.id,
|
|
1639
2110
|
origin: request.origin,
|
|
1640
2111
|
app: request.app,
|
|
1641
2112
|
...request.needs ? { needs: request.needs } : {},
|
|
2113
|
+
...skills ? { skills: { loading: !skills.list, list: (skills.list ?? []).map(({ name, description, source, shared }) => ({ name, description, source, shared })) } } : {},
|
|
1642
2114
|
approveUrl: `http://127.0.0.1:${this.options.port}/connect/${request.id}`
|
|
1643
2115
|
};
|
|
1644
2116
|
}
|
|
@@ -1661,7 +2133,8 @@ var Pairing = class {
|
|
|
1661
2133
|
}
|
|
1662
2134
|
decide(request, allow, granted) {
|
|
1663
2135
|
if (allow) {
|
|
1664
|
-
|
|
2136
|
+
const skills = request.skills ? { refs: request.skills.refs, list: request.skills.list ?? [] } : void 0;
|
|
2137
|
+
request.token = this.options.grants.create(request.origin, request.app, request.needs, skills);
|
|
1665
2138
|
granted?.(request.origin);
|
|
1666
2139
|
request.decision = "allowed";
|
|
1667
2140
|
} else {
|
|
@@ -1750,6 +2223,15 @@ button.ink:hover { background: var(--ink-hover); }
|
|
|
1750
2223
|
button:disabled { opacity: 0.5; cursor: default; }
|
|
1751
2224
|
button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
1752
2225
|
.small { font-size: 12px; }
|
|
2226
|
+
.skills { border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; font-size: 14px; }
|
|
2227
|
+
.skills summary { cursor: pointer; display: flex; justify-content: space-between; gap: 8px; list-style: none; }
|
|
2228
|
+
.skills summary::-webkit-details-marker { display: none; }
|
|
2229
|
+
.skills summary span:last-child { color: var(--muted); white-space: nowrap; }
|
|
2230
|
+
.skills[open] summary span:last-child::after { content: " \\25B4"; }
|
|
2231
|
+
.skills:not([open]) summary span:last-child::after { content: " \\25BE"; }
|
|
2232
|
+
.skills ul { margin-top: 10px; gap: 10px; }
|
|
2233
|
+
.skills li { display: block; }
|
|
2234
|
+
.skills li p { font-size: 13px; }
|
|
1753
2235
|
`;
|
|
1754
2236
|
var MARK = "M181 61 C184 74 191 82 206 86 C191 90 184 98 181 111 C178 98 171 90 156 86 C171 82 178 74 181 61 Z M59 92 C60 109 67 128 79 143 C91 158 105 168 124 174 C149 182 164 193 171 207 C177 219 177 239 177 255 C182 255 182 240 184 226 C188 199 201 187 223 179 C247 171 270 158 284 142 C298 127 303 109 303 92 C298 91 292 101 284 108 C265 125 245 130 226 126 C217 124 211 114 205 112 C199 110 199 116 199 121 C198 127 191 130 181 130 C171 130 165 127 163 121 C162 116 165 110 159 111 C151 112 148 122 137 126 C118 131 98 125 78 109 C69 102 65 91 59 92 Z";
|
|
1755
2237
|
var LOGO = `<span class="logo" role="img" aria-label="Leuria"><svg width="22" height="22" viewBox="53 30 256 256" aria-hidden="true"><path d="${MARK}" fill="currentColor"/></svg>leuria</span>`;
|
|
@@ -1774,6 +2256,7 @@ function approvePage(request, agentName2) {
|
|
|
1774
2256
|
<p>It will talk to ${agent}, on this computer.</p>
|
|
1775
2257
|
<div><div class="eyebrow">It can</div><ul class="can"><li>${CHECK}Ask your AI to answer you</li><li>${CHECK}Let your AI use its own page's tools</li></ul></div>
|
|
1776
2258
|
<div><div class="eyebrow">It can't</div><ul class="cant"><li>${CROSS}See your files or run programs</li><li>${CROSS}See what you do on other websites</li></ul></div>
|
|
2259
|
+
${skillsSection(request)}
|
|
1777
2260
|
${insecure ? `<p class="warn">This site doesn't use a secure connection.</p>` : ""}
|
|
1778
2261
|
<p class="small">You can disconnect it at any time with <code>leuria sites revoke ${escapeHtml(request.origin)}</code>.</p>
|
|
1779
2262
|
<div class="actions"><button type="button" class="soft" id="deny">Not now</button><button type="button" class="ink" id="allow">Allow</button></div>`;
|
|
@@ -1794,14 +2277,25 @@ async function decide(allow) {
|
|
|
1794
2277
|
}
|
|
1795
2278
|
document.getElementById("allow").addEventListener("click", () => decide(true));
|
|
1796
2279
|
document.getElementById("deny").addEventListener("click", () => decide(false));
|
|
1797
|
-
// Answered in the Leuria app instead: close this window too.
|
|
2280
|
+
// Answered in the Leuria app instead: close this window too. The site's skills fetched: show them.
|
|
2281
|
+
const skillsLoading = ${request.skills && !request.skills.list ? "true" : "false"};
|
|
1798
2282
|
setInterval(async () => {
|
|
1799
2283
|
const res = await fetch(location.pathname + "/state").catch(() => null);
|
|
1800
2284
|
const state = res && res.ok ? await res.json() : null;
|
|
1801
2285
|
if (state && state.decision !== "pending") done("Answered in Leuria", "You can close this window.");
|
|
2286
|
+
else if (state && skillsLoading && !state.skillsLoading) location.reload();
|
|
1802
2287
|
}, 1500);`;
|
|
1803
2288
|
return page("Connect your AI", body, script);
|
|
1804
2289
|
}
|
|
2290
|
+
function skillsSection(request) {
|
|
2291
|
+
const skills = request.skills;
|
|
2292
|
+
if (!skills) return "";
|
|
2293
|
+
if (!skills.list) return `<p>Checking the skills this site uses to guide your AI\u2026</p>`;
|
|
2294
|
+
if (!skills.list.length) return "";
|
|
2295
|
+
const count = skills.list.length === 1 ? "1 skill" : `${skills.list.length} skills`;
|
|
2296
|
+
const items = skills.list.map((s) => `<li><strong>${escapeHtml(s.name)}</strong><p>${escapeHtml(s.description)}</p><p class="small">${s.shared ? `Shared skill \xB7 ${escapeHtml(s.source)}` : `From ${escapeHtml(s.source)}`}</p></li>`).join("");
|
|
2297
|
+
return `<details class="skills"><summary><span>This site uses ${count} to guide your AI.</span><span>See details</span></summary><ul>${items}</ul></details>`;
|
|
2298
|
+
}
|
|
1805
2299
|
function messagePage(title, text) {
|
|
1806
2300
|
return page(title, `<h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>`);
|
|
1807
2301
|
}
|
|
@@ -1832,12 +2326,12 @@ var POST_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
1832
2326
|
"cancel",
|
|
1833
2327
|
"close"
|
|
1834
2328
|
]);
|
|
1835
|
-
async function handleSessionRequest(req, res, pathname, sm, requester) {
|
|
2329
|
+
async function handleSessionRequest(req, res, pathname, sm, requester, onSkills) {
|
|
1836
2330
|
const segments = pathname.split("/").filter(Boolean);
|
|
1837
2331
|
if (segments[0] !== "session") return false;
|
|
1838
2332
|
try {
|
|
1839
2333
|
if (req.method === "POST" && segments.length === 2 && segments[1] === "prepare") {
|
|
1840
|
-
await handlePrepare(req, res, sm, requester);
|
|
2334
|
+
await handlePrepare(req, res, sm, requester, onSkills);
|
|
1841
2335
|
return true;
|
|
1842
2336
|
}
|
|
1843
2337
|
const sessionId = segments[1];
|
|
@@ -1886,8 +2380,9 @@ async function runAction(req, sm, sessionId, action) {
|
|
|
1886
2380
|
return sm.close(sessionId);
|
|
1887
2381
|
}
|
|
1888
2382
|
}
|
|
1889
|
-
async function handlePrepare(req, res, sm, origin) {
|
|
2383
|
+
async function handlePrepare(req, res, sm, origin, onSkills) {
|
|
1890
2384
|
const body = await parseBody(req);
|
|
2385
|
+
if ("skills" in body) onSkills?.(origin, parseSkillRefs(body.skills));
|
|
1891
2386
|
if (body.prompt !== void 0 && (typeof body.prompt !== "string" || !body.prompt.trim())) {
|
|
1892
2387
|
sendJson(res, 400, { error: "prompt must be a non-empty string when given" });
|
|
1893
2388
|
return;
|
|
@@ -1938,8 +2433,8 @@ function handleStream(req, res, sm, sessionId) {
|
|
|
1938
2433
|
// src/session-manager.ts
|
|
1939
2434
|
import { randomUUID } from "crypto";
|
|
1940
2435
|
import { mkdirSync as mkdirSync6, rmSync as rmSync4 } from "fs";
|
|
1941
|
-
import { tmpdir as
|
|
1942
|
-
import { join as
|
|
2436
|
+
import { tmpdir as tmpdir4 } from "os";
|
|
2437
|
+
import { join as join7 } from "path";
|
|
1943
2438
|
|
|
1944
2439
|
// src/llm/llm-session.ts
|
|
1945
2440
|
var LlmSession = class {
|
|
@@ -2260,6 +2755,12 @@ var SessionManager = class {
|
|
|
2260
2755
|
return;
|
|
2261
2756
|
}
|
|
2262
2757
|
if (TERMINAL.has(session.status)) return;
|
|
2758
|
+
const skills = await this.options.loadSkills?.(session.params.origin).catch(() => []) ?? [];
|
|
2759
|
+
if (skills.length) {
|
|
2760
|
+
this.options.webMcpServer.setSkills(session.id, skills);
|
|
2761
|
+
session.params.systemPrompt = [session.params.systemPrompt, skillsPrompt(skills)].filter(Boolean).join("\n\n");
|
|
2762
|
+
}
|
|
2763
|
+
if (TERMINAL.has(session.status)) return;
|
|
2263
2764
|
const live = launch.llm ? this.llmSession(session, launch.llm) : this.acpSession(session, launch);
|
|
2264
2765
|
session.live = live;
|
|
2265
2766
|
const timeoutMs = this.options.startTimeoutMs ?? 6e4;
|
|
@@ -2418,7 +2919,7 @@ var SessionManager = class {
|
|
|
2418
2919
|
* files (CLAUDE.md, repo) to act on.
|
|
2419
2920
|
*/
|
|
2420
2921
|
allocateSandbox(sessionId) {
|
|
2421
|
-
const dir =
|
|
2922
|
+
const dir = join7(tmpdir4(), `leuria-${sessionId}`);
|
|
2422
2923
|
mkdirSync6(dir, { recursive: true });
|
|
2423
2924
|
return dir;
|
|
2424
2925
|
}
|
|
@@ -2509,6 +3010,7 @@ var WebMcpServer = class {
|
|
|
2509
3010
|
tools: /* @__PURE__ */ new Map(),
|
|
2510
3011
|
resources: /* @__PURE__ */ new Map(),
|
|
2511
3012
|
prompts: /* @__PURE__ */ new Map(),
|
|
3013
|
+
skills: [],
|
|
2512
3014
|
pendingRequests: /* @__PURE__ */ new Map(),
|
|
2513
3015
|
pingTimer: null
|
|
2514
3016
|
};
|
|
@@ -2617,20 +3119,33 @@ var WebMcpServer = class {
|
|
|
2617
3119
|
this.logger.info("WebMCP channel removed", { sessionId, channelId });
|
|
2618
3120
|
}
|
|
2619
3121
|
// ── In-process access (the engine's own agent loop for LLM providers) ──
|
|
2620
|
-
/**
|
|
3122
|
+
/** Give a session its site's skills: the agent then also gets `read_skill`. */
|
|
3123
|
+
setSkills(sessionId, skills) {
|
|
3124
|
+
const channelId = this.sessionChannels.get(sessionId);
|
|
3125
|
+
const channel = channelId ? this.channels.get(channelId) : void 0;
|
|
3126
|
+
if (channel) channel.skills = skills;
|
|
3127
|
+
}
|
|
3128
|
+
/** The tools a session's agent gets: the page's, and `read_skill` when the site has skills. */
|
|
2621
3129
|
listTools(sessionId) {
|
|
2622
3130
|
const channelId = this.sessionChannels.get(sessionId);
|
|
2623
3131
|
const channel = channelId ? this.channels.get(channelId) : void 0;
|
|
2624
|
-
return channel ?
|
|
3132
|
+
return channel ? this.toolsOf(channel) : [];
|
|
2625
3133
|
}
|
|
2626
|
-
/** Run a page tool in the browser; resolves with its JSON result. Throws on tool errors. */
|
|
3134
|
+
/** Run a page tool in the browser (or `read_skill` here); resolves with its JSON result. Throws on tool errors. */
|
|
2627
3135
|
async callTool(sessionId, name, args) {
|
|
2628
3136
|
const channelId = this.sessionChannels.get(sessionId);
|
|
2629
3137
|
const channel = channelId ? this.channels.get(channelId) : void 0;
|
|
2630
3138
|
if (!channel) throw new Error("The page is not connected");
|
|
3139
|
+
if (name === READ_SKILL && channel.skills.length) return readSkill(channel.skills, args);
|
|
2631
3140
|
if (!channel.tools.has(name)) throw new Error(`Unknown tool: ${name}`);
|
|
2632
3141
|
return this.forwardToBrowser(channel, "callTool", { tool: name, arguments: args });
|
|
2633
3142
|
}
|
|
3143
|
+
/** The page's tools, and the engine's `read_skill` in place of a page tool of that name. */
|
|
3144
|
+
toolsOf(channel) {
|
|
3145
|
+
const page2 = Array.from(channel.tools.values());
|
|
3146
|
+
if (!channel.skills.length) return page2;
|
|
3147
|
+
return [...page2.filter((t) => t.name !== READ_SKILL), readSkillTool(channel.skills)];
|
|
3148
|
+
}
|
|
2634
3149
|
// ── HTTP / WS dispatch (called from daemon) ──────────────────────────
|
|
2635
3150
|
/** Returns true when the request path belongs to WebMCP. */
|
|
2636
3151
|
matches(pathname) {
|
|
@@ -3036,7 +3551,7 @@ var WebMcpServer = class {
|
|
|
3036
3551
|
}
|
|
3037
3552
|
case "tools/list":
|
|
3038
3553
|
return {
|
|
3039
|
-
tools:
|
|
3554
|
+
tools: this.toolsOf(channel).map((t) => ({
|
|
3040
3555
|
name: t.name,
|
|
3041
3556
|
description: t.description,
|
|
3042
3557
|
inputSchema: t.inputSchema,
|
|
@@ -3047,6 +3562,9 @@ var WebMcpServer = class {
|
|
|
3047
3562
|
const name = req.params?.name;
|
|
3048
3563
|
const args = req.params?.arguments;
|
|
3049
3564
|
if (!name) throw new Error("Missing tool name");
|
|
3565
|
+
if (name === READ_SKILL && channel.skills.length) {
|
|
3566
|
+
return { content: [{ type: "text", text: readSkill(channel.skills, args ?? {}) }] };
|
|
3567
|
+
}
|
|
3050
3568
|
if (!channel.tools.has(name)) {
|
|
3051
3569
|
throw new Error(`Unknown tool: ${name}`);
|
|
3052
3570
|
}
|
|
@@ -3175,12 +3693,14 @@ async function startEngine(options) {
|
|
|
3175
3693
|
const selfOrigins = /* @__PURE__ */ new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
|
|
3176
3694
|
const allowedHosts = /* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
|
|
3177
3695
|
const silent = options.silent ?? Boolean(options.admin);
|
|
3696
|
+
const skills = options.skills ?? new SkillService({ grants, logger });
|
|
3178
3697
|
const webMcpServer = new WebMcpServer(logger, port);
|
|
3179
3698
|
const sessions = new SessionManager({
|
|
3180
3699
|
webMcpServer,
|
|
3181
3700
|
port,
|
|
3182
3701
|
logger,
|
|
3183
3702
|
resolveAgent: options.resolveAgent,
|
|
3703
|
+
loadSkills: (origin) => skills.forSession(origin),
|
|
3184
3704
|
startTimeoutMs: options.startTimeoutMs,
|
|
3185
3705
|
stdioMcpCommand: options.stdioMcpCommand,
|
|
3186
3706
|
onAgentState: options.onAgentState
|
|
@@ -3194,6 +3714,7 @@ async function startEngine(options) {
|
|
|
3194
3714
|
port,
|
|
3195
3715
|
linksOnly: silent,
|
|
3196
3716
|
agentName: () => agentName2(),
|
|
3717
|
+
resolveSkills: (origin, refs) => skills.resolve(origin, refs),
|
|
3197
3718
|
onRequest: options.onPairingRequest,
|
|
3198
3719
|
onDecided: options.onPairingDecided
|
|
3199
3720
|
});
|
|
@@ -3328,7 +3849,8 @@ async function startEngine(options) {
|
|
|
3328
3849
|
return;
|
|
3329
3850
|
}
|
|
3330
3851
|
const requester = caller.kind === "site" ? caller.origin : "local";
|
|
3331
|
-
|
|
3852
|
+
const onSkills = caller.kind === "site" ? (origin, refs) => skills.refresh(origin, refs) : void 0;
|
|
3853
|
+
if (await handleSessionRequest(req, res, pathname, sessions, requester, onSkills)) return;
|
|
3332
3854
|
sendJson(res, 404, { error: "Not found" });
|
|
3333
3855
|
} catch (err) {
|
|
3334
3856
|
logger.error("request failed", {
|
|
@@ -3407,6 +3929,8 @@ export {
|
|
|
3407
3929
|
decidePermission,
|
|
3408
3930
|
installedAgent,
|
|
3409
3931
|
ensureAgent,
|
|
3932
|
+
pruneAgentVersions,
|
|
3933
|
+
updateAgents,
|
|
3410
3934
|
resolveAgentCommand,
|
|
3411
3935
|
resolveSiteAgent,
|
|
3412
3936
|
agentName,
|