@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.
@@ -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
- const agent = await install(entry);
458
- await profileFor(id)?.prepare?.(agent);
459
- return agent;
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
- const npm = process.platform === "win32" ? "npm.cmd" : "npm";
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.0" : "0.0.0-dev";
657
+ var VERSION = true ? "0.1.2" : "0.0.0-dev";
601
658
 
602
659
  // src/auth.ts
603
- import { spawn as spawn2 } from "child_process";
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 = spawn(this.options.command, this.options.args, {
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
- shell: process.platform === "win32",
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
- async function checkSignIn(id, onProgress) {
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
- const link = options.onUrl && !options.terminal ? catchBrowserLinks(options.onUrl) : null;
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, session, env, sandbox } = opened;
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, run()]);
1184
+ return await Promise.race([cancelled, run2()]);
1104
1185
  } finally {
1105
1186
  close();
1106
1187
  }
1107
- async function run() {
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 = spawn2(agent.command, [...agent.launchArgs, ...method.args ?? []], {
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
- const auth = await session.authenticate(method.id);
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
- /** Create or replace the origin's grant; returns the new token. Keeps the site's AI and model choice; `needs` replaces what it declared. */
1228
- create(origin, app, needs) {
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 createHash3, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
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) => createHash3("sha256").update(nonce).digest();
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
- request.token = this.options.grants.create(request.origin, request.app, request.needs);
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 tmpdir3 } from "os";
1942
- import { join as join6 } from "path";
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 = join6(tmpdir3(), `leuria-${sessionId}`);
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
- /** The page tools a session's browser has declared. */
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 ? Array.from(channel.tools.values()) : [];
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: Array.from(channel.tools.values()).map((t) => ({
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
- if (await handleSessionRequest(req, res, pathname, sessions, requester)) return;
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,