@getbourdon/participants 0.5.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/skills.js ADDED
@@ -0,0 +1,999 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ /**
3
+ * Skill readers: advertise the local skills an agent can see in its L5
4
+ * manifest (plan 005, discovery side). Port of `participants/_skills.py`.
5
+ *
6
+ * A participant that knows where its agent keeps skills (a folder per skill
7
+ * holding a `SKILL.md` with `name` / `description` front matter) lists them as
8
+ * `SkillRef` rows: name, description and a content hash. Nothing is shared or
9
+ * promoted here; the body never enters the manifest.
10
+ *
11
+ * Two kinds of skill directory:
12
+ * - **The agent's own** (`~/.claude/skills`, `$CODEX_HOME/skills`,
13
+ * `~/.cursor/skills` and their project forms).
14
+ * - **The cross-agent store**, `~/.agents/skills` (and `<project>/.agents/skills`):
15
+ * a vendor-neutral directory no single agent owns. The cross-agent skills
16
+ * installer CLI keeps its installs there and records each one in
17
+ * `~/.agents/.skill-lock.json`; people also write skills there by hand. Refs
18
+ * from it carry `store: cross_agent`. Every skill-reading participant reads
19
+ * it with its own directories; a reader of several manifests collapses the
20
+ * copies (`collectAdvertisedSkills` in @getbourdon/federation).
21
+ *
22
+ * One skill, one ref per manifest: when an agent's own folder holds a skill
23
+ * that is also in the cross-agent store, the agent-folder ref is kept and
24
+ * records the store copy in `also_in` (`via: copy` for a copy with the same
25
+ * `content_sha256`, `via: link` for a link into the store); the store's own ref
26
+ * is then left out. A link counts only when it resolves to a direct child
27
+ * folder of the store at the same scope, and the skill is then read through
28
+ * the store's own path, never through the link. Any other link is skipped.
29
+ *
30
+ * Provenance (user-level store only): a skill with an entry in the installer
31
+ * lock carries `origin: {kind: installed, ...}`; one without carries
32
+ * `origin: {kind: authored}`. Nothing else is read from the lock, and a
33
+ * missing, linked, oversized or malformed lock never fails a scan.
34
+ *
35
+ * Scope: one skill at a time. By default a participant lists only the skills
36
+ * the person approved one by one with `bourdon skills share` (stored locally
37
+ * in `<library>/.skill-shares.json`, see skill-shares.ts), and only while the
38
+ * folder still hashes to the approved content; no other skill folder is read.
39
+ * `BOURDON_SKILLS_ADVERTISE` set to an on value lists every skill; an off
40
+ * value or an unrecognised one lists none, shared or not. A caller that builds
41
+ * a participant with explicit `skillSources` lists every skill in them
42
+ * ({@link skillsListingMode}). The directories: user-level skill directories,
43
+ * and a project's directories
44
+ * only when the project is named in `BOURDON_SKILL_PROJECTS` (absolute paths
45
+ * separated by the platform's path delimiter). Disallowed files are skipped
46
+ * and flagged, never fatal; no link is followed; no absolute path reaches the
47
+ * manifest (`path_hint` starts at a symbolic root); refs are emitted as
48
+ * `team`.
49
+ */
50
+ import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
51
+ import { homedir } from "node:os";
52
+ import { basename, delimiter, dirname, isAbsolute, join, normalize } from "node:path";
53
+ import { Visibility, makeSkillRef, } from "@getbourdon/l5";
54
+ import { containsSecret } from "@getbourdon/redaction";
55
+ import { SkillContentError, codePointLength, codePointSlice, compareUtf8, isPathLinkOrReparse, pyStrip, scanLocalSkill, } from "./skill-index.js";
56
+ import { loadShares, shareAppliesTo } from "./skill-shares.js";
57
+ /** Opt-in list of project roots whose skill directories may be indexed. */
58
+ export const SKILL_PROJECTS_ENV = "BOURDON_SKILL_PROJECTS";
59
+ /**
60
+ * The listing switch for every participant. Skill descriptions are free text
61
+ * and can name private projects, clients or infrastructure, so by default
62
+ * (unset or empty) only the skills approved one by one are listed. `1` /
63
+ * `true` / `yes` / `on` list every skill; `0` / `false` / `no` / `off` list
64
+ * none, approved or not, and so does any other value (fail closed): a typo
65
+ * never lists anything.
66
+ */
67
+ export const SKILLS_ADVERTISE_ENV = "BOURDON_SKILLS_ADVERTISE";
68
+ /**
69
+ * Turns named participants off in every mode: agent ids separated by commas
70
+ * (or whitespace), e.g. `codex,cursor`. Matching ignores case.
71
+ */
72
+ export const SKILLS_NO_ADVERTISE_ENV = "BOURDON_SKILLS_NO_ADVERTISE";
73
+ /** {@link skillsListingMode} results. */
74
+ export const LIST_ALL = "all";
75
+ export const LIST_APPROVED = "approved";
76
+ export const LIST_OFF = "off";
77
+ const ADVERTISE_ON = new Set(["1", "true", "yes", "on"]);
78
+ const ADVERTISE_OFF = new Set(["0", "false", "no", "off"]);
79
+ // A run of str.split() whitespace.
80
+ // eslint-disable-next-line no-control-regex
81
+ const PY_WHITESPACE_RUN = /[\t\n\v\f\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/;
82
+ /** Every advertised ref carries this visibility. */
83
+ export const SKILL_REF_VISIBILITY = Visibility.TEAM;
84
+ /** The L5 schema bounds (`skills.maxItems` and the `SkillRef` fields). */
85
+ export const MAX_SKILL_REFS = 512;
86
+ export const MAX_SKIPPED_FILES = 32;
87
+ export const MAX_PATH_HINT_CHARS = 255;
88
+ export const MAX_PROJECT_NAME_CHARS = 128;
89
+ export const MAX_ORIGIN_SOURCE_CHARS = 255;
90
+ export const MAX_ORIGIN_URL_CHARS = 512;
91
+ export const MAX_ORIGIN_SOURCE_TYPE_CHARS = 32;
92
+ /** An installer lock larger than this is not read (origin is then omitted). */
93
+ export const MAX_LOCK_BYTES = 4 * 1024 * 1024;
94
+ export const SCOPE_USER = "user";
95
+ export const SCOPE_PROJECT = "project";
96
+ /** Whose directory a skill folder sits in; `agent` is the default and never written. */
97
+ export const STORE_AGENT = "agent";
98
+ export const STORE_CROSS_AGENT = "cross_agent";
99
+ export const VIA_COPY = "copy";
100
+ export const VIA_LINK = "link";
101
+ export const ORIGIN_INSTALLED = "installed";
102
+ export const ORIGIN_AUTHORED = "authored";
103
+ /** The cross-agent installer's lock file, beside the user-level store. */
104
+ export const CROSS_AGENT_LOCK_NAME = ".skill-lock.json";
105
+ // Kept in step with $defs/SkillRef.origin in the L5 schema.
106
+ const ORIGIN_SOURCE_RE = /^[A-Za-z0-9][A-Za-z0-9._~/:+-]*$/;
107
+ const ORIGIN_SOURCE_TYPE_RE = /^[a-z0-9][a-z0-9_-]*$/;
108
+ const ORIGIN_URL_RE = /^https?:\/\/[^\s/?#@]+(\/[^\s?#]*)?$/;
109
+ function warn(message) {
110
+ console.warn(`[bourdon.participants] ${message}`);
111
+ }
112
+ /** The home directory user-level skill directories are found under. */
113
+ export function userHome() {
114
+ return homedir();
115
+ }
116
+ // -- sources ------------------------------------------------------------------
117
+ function expandUser(path) {
118
+ if (path === "~")
119
+ return userHome();
120
+ if (path.startsWith("~/") || path.startsWith("~\\"))
121
+ return join(userHome(), path.slice(2));
122
+ return path;
123
+ }
124
+ function isAbsoluteProjectPath(path) {
125
+ if (process.platform === "win32") {
126
+ // A Windows path is absolute only with a drive and a root, or as UNC.
127
+ return /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]{2}[^\\/]/.test(path);
128
+ }
129
+ return isAbsolute(path);
130
+ }
131
+ function pathKey(path) {
132
+ const normalized = normalize(path);
133
+ const trimmed = normalized.replace(/[\\/]+$/, "");
134
+ return trimmed === "" || /^[A-Za-z]:$/.test(trimmed) ? normalized : trimmed;
135
+ }
136
+ /**
137
+ * Project roots opted in through {@link SKILL_PROJECTS_ENV}, in order. Only
138
+ * absolute paths count: a relative entry is ignored (and logged) rather than
139
+ * resolved against whatever directory the export happens to run in.
140
+ */
141
+ export function skillProjects() {
142
+ const raw = process.env[SKILL_PROJECTS_ENV] ?? "";
143
+ const projects = [];
144
+ const seen = new Set();
145
+ for (const entry of raw.split(delimiter)) {
146
+ const text = pyStrip(entry);
147
+ if (!text)
148
+ continue;
149
+ const path = expandUser(text);
150
+ if (!isAbsoluteProjectPath(path)) {
151
+ warn(`${SKILL_PROJECTS_ENV}: ignoring relative entry (use an absolute path)`);
152
+ continue;
153
+ }
154
+ const key = pathKey(path);
155
+ if (seen.has(key))
156
+ continue;
157
+ seen.add(key);
158
+ projects.push(key);
159
+ }
160
+ return projects;
161
+ }
162
+ function projectName(project) {
163
+ const name = basename(pathKey(project)) || "project";
164
+ return codePointSlice(name, MAX_PROJECT_NAME_CHARS);
165
+ }
166
+ function projectSources(projects, relative, store = STORE_AGENT) {
167
+ const chosen = projects === undefined ? skillProjects() : [...projects];
168
+ return chosen.map((project) => ({
169
+ root: join(project, ...relative.split("/")),
170
+ scope: SCOPE_PROJECT,
171
+ label: relative,
172
+ project: projectName(project),
173
+ store,
174
+ lockFile: null,
175
+ }));
176
+ }
177
+ function homeOverride(...names) {
178
+ for (const name of names) {
179
+ const value = process.env[name];
180
+ if (value)
181
+ return [expandUser(value), `$${name}`];
182
+ }
183
+ return null;
184
+ }
185
+ /**
186
+ * Claude Code: `<config dir>/skills` and, on opt-in, `<project>/.claude/skills`.
187
+ * The config directory is `CLAUDE_CONFIG_DIR` (Claude Code's own override),
188
+ * else `CLAUDE_HOME`, else `~/.claude`.
189
+ */
190
+ export function claudeCodeSkillSources(options = {}) {
191
+ const override = homeOverride("CLAUDE_CONFIG_DIR", "CLAUDE_HOME");
192
+ let user;
193
+ if (override !== null) {
194
+ const [configDir, label] = override;
195
+ user = { root: join(configDir, "skills"), scope: SCOPE_USER, label: `${label}/skills` };
196
+ }
197
+ else {
198
+ const base = options.home ?? userHome();
199
+ user = { root: join(base, ".claude", "skills"), scope: SCOPE_USER, label: "~/.claude/skills" };
200
+ }
201
+ return [user, ...projectSources(options.projects, ".claude/skills")];
202
+ }
203
+ /**
204
+ * Codex: `$CODEX_HOME/skills`. `codexHome` is an explicit Codex home, if one
205
+ * was given; otherwise `CODEX_HOME`, else `~/.codex`. Codex keeps its bundled
206
+ * skills in a hidden `.system` folder there, which is not indexed. The mirror
207
+ * has no Codex participant yet; this is the source a Codex reader would use.
208
+ */
209
+ export function codexSkillSources(options = {}) {
210
+ if (options.codexHome != null) {
211
+ return [
212
+ { root: join(options.codexHome, "skills"), scope: SCOPE_USER, label: "$CODEX_HOME/skills" },
213
+ ];
214
+ }
215
+ const override = homeOverride("CODEX_HOME");
216
+ if (override !== null) {
217
+ const [root, label] = override;
218
+ return [{ root: join(root, "skills"), scope: SCOPE_USER, label: `${label}/skills` }];
219
+ }
220
+ const base = options.home ?? userHome();
221
+ return [{ root: join(base, ".codex", "skills"), scope: SCOPE_USER, label: "~/.codex/skills" }];
222
+ }
223
+ /**
224
+ * Cursor: `~/.cursor/skills` and, on opt-in, `<project>/.cursor/skills`.
225
+ * `CURSOR_HOME` overrides `~/.cursor`. `~/.cursor/skills-cursor` holds
226
+ * Cursor's own built-in skills and is not indexed.
227
+ */
228
+ export function cursorSkillSources(options = {}) {
229
+ const override = homeOverride("CURSOR_HOME");
230
+ let user;
231
+ if (override !== null) {
232
+ const [root, label] = override;
233
+ user = { root: join(root, "skills"), scope: SCOPE_USER, label: `${label}/skills` };
234
+ }
235
+ else {
236
+ const base = options.home ?? userHome();
237
+ user = { root: join(base, ".cursor", "skills"), scope: SCOPE_USER, label: "~/.cursor/skills" };
238
+ }
239
+ return [user, ...projectSources(options.projects, ".cursor/skills")];
240
+ }
241
+ /**
242
+ * The vendor-neutral store: `~/.agents/skills` and, on opt-in,
243
+ * `<project>/.agents/skills`. The user-level store comes with the installer
244
+ * lock for provenance; no lock is read for a project store.
245
+ */
246
+ export function crossAgentSkillSources(options = {}) {
247
+ const base = options.home ?? userHome();
248
+ const agentsDir = join(base, ".agents");
249
+ const user = {
250
+ root: join(agentsDir, "skills"),
251
+ scope: SCOPE_USER,
252
+ label: "~/.agents/skills",
253
+ store: STORE_CROSS_AGENT,
254
+ lockFile: join(agentsDir, CROSS_AGENT_LOCK_NAME),
255
+ };
256
+ return [user, ...projectSources(options.projects, ".agents/skills", STORE_CROSS_AGENT)];
257
+ }
258
+ function isDict(value) {
259
+ return value !== null && typeof value === "object" && !Array.isArray(value);
260
+ }
261
+ const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
262
+ /**
263
+ * The `skills` table of the installer lock, keyed by skill name. An empty map
264
+ * when there is no lock (every store skill is then `authored`); `null` when a
265
+ * lock exists but cannot be trusted or parsed (a link, not a regular file, too
266
+ * large, not JSON, or without a `skills` mapping), in which case origin is left
267
+ * off rather than guessed.
268
+ */
269
+ export function readInstallLock(path) {
270
+ let info;
271
+ try {
272
+ info = lstatSync(path);
273
+ }
274
+ catch (err) {
275
+ return err.code === "ENOENT" ? new Map() : null;
276
+ }
277
+ let data;
278
+ try {
279
+ if (isPathLinkOrReparse(path, info) || !info.isFile())
280
+ return null;
281
+ if (info.size > MAX_LOCK_BYTES)
282
+ return null;
283
+ data = JSON.parse(STRICT_UTF8.decode(readFileSync(path)));
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ if (!isDict(data))
289
+ return null;
290
+ const skills = data.skills;
291
+ if (!isDict(skills))
292
+ return null;
293
+ const table = new Map();
294
+ for (const [name, entry] of Object.entries(skills)) {
295
+ if (isDict(entry))
296
+ table.set(name, entry);
297
+ }
298
+ return table;
299
+ }
300
+ function cleanSource(value) {
301
+ if (typeof value !== "string")
302
+ return null;
303
+ const text = pyStrip(value);
304
+ if (!text || codePointLength(text) > MAX_ORIGIN_SOURCE_CHARS)
305
+ return null;
306
+ if (!ORIGIN_SOURCE_RE.test(text) || containsSecret(text))
307
+ return null;
308
+ return text;
309
+ }
310
+ function cleanSourceType(value) {
311
+ if (typeof value !== "string")
312
+ return null;
313
+ const text = pyStrip(value);
314
+ if (!text || codePointLength(text) > MAX_ORIGIN_SOURCE_TYPE_CHARS)
315
+ return null;
316
+ return ORIGIN_SOURCE_TYPE_RE.test(text) ? text : null;
317
+ }
318
+ // urllib.parse.urlsplit: leading C0 control/space stripped, tab/CR/LF removed.
319
+ // eslint-disable-next-line no-control-regex
320
+ const URL_LEADING_JUNK_RE = /^[\x00-\x20]+/;
321
+ const URL_UNSAFE_RE = /[\t\r\n]/g;
322
+ const URL_SCHEME_RE = /^([A-Za-z][A-Za-z0-9+.-]*):/;
323
+ /** The scheme, netloc and path of `value` as Python's `urlsplit` reads them, or null on its ValueError. */
324
+ function urlSplit(value) {
325
+ let url = value.replace(URL_LEADING_JUNK_RE, "").replace(URL_UNSAFE_RE, "");
326
+ let scheme = "";
327
+ const schemeMatch = URL_SCHEME_RE.exec(url);
328
+ if (schemeMatch) {
329
+ scheme = (schemeMatch[1] ?? "").toLowerCase();
330
+ url = url.slice(schemeMatch[0].length);
331
+ }
332
+ let netloc = "";
333
+ if (url.startsWith("//")) {
334
+ const rest = url.slice(2);
335
+ const delim = rest.search(/[/?#]/);
336
+ netloc = delim === -1 ? rest : rest.slice(0, delim);
337
+ url = delim === -1 ? "" : rest.slice(delim);
338
+ if (netloc.includes("[") !== netloc.includes("]"))
339
+ return null;
340
+ }
341
+ const hash = url.indexOf("#");
342
+ if (hash !== -1)
343
+ url = url.slice(0, hash);
344
+ const query = url.indexOf("?");
345
+ if (query !== -1)
346
+ url = url.slice(0, query);
347
+ return { scheme, netloc, path: url };
348
+ }
349
+ function urlHostname(netloc) {
350
+ const hostinfo = netloc.slice(netloc.lastIndexOf("@") + 1);
351
+ let host;
352
+ if (hostinfo.startsWith("[")) {
353
+ const close = hostinfo.indexOf("]");
354
+ host = close === -1 ? "" : hostinfo.slice(1, close);
355
+ }
356
+ else {
357
+ const colon = hostinfo.indexOf(":");
358
+ host = colon === -1 ? hostinfo : hostinfo.slice(0, colon);
359
+ }
360
+ return host ? host.toLowerCase() : null;
361
+ }
362
+ /** An http(s) URL without credentials; query string and fragment dropped. */
363
+ function cleanSourceUrl(value) {
364
+ if (typeof value !== "string")
365
+ return null;
366
+ const parts = urlSplit(pyStrip(value));
367
+ if (parts === null)
368
+ return null;
369
+ if ((parts.scheme !== "http" && parts.scheme !== "https") || !urlHostname(parts.netloc)) {
370
+ return null;
371
+ }
372
+ // Credentials in the URL: drop it rather than try to repair it.
373
+ if (parts.netloc.includes("@"))
374
+ return null;
375
+ const url = `${parts.scheme}://${parts.netloc}${parts.path}`;
376
+ if (codePointLength(url) > MAX_ORIGIN_URL_CHARS || !ORIGIN_URL_RE.test(url))
377
+ return null;
378
+ return containsSecret(url) ? null : url;
379
+ }
380
+ // datetime.fromisoformat (3.11+): extended or basic date, any one-character
381
+ // separator, then HH[:MM[:SS[.f+]]] or the basic forms, then an optional offset.
382
+ const ISO_DATE_RE = /^(\d{4})-?(\d{2})-?(\d{2})/;
383
+ const ISO_TIME_RE = /^(\d{2})(?::?(\d{2})(?::?(\d{2})(?:[.,](\d+))?)?)?/;
384
+ const ISO_OFFSET_RE = /^([+-])(\d{2})(?::?(\d{2})(?::?(\d{2})(?:\.(\d+))?)?)?$/;
385
+ function daysInMonth(year, month) {
386
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
387
+ return [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0;
388
+ }
389
+ function pad(value, width) {
390
+ return String(value).padStart(width, "0");
391
+ }
392
+ function cleanTimestamp(value) {
393
+ if (typeof value !== "string" || codePointLength(value) > 64)
394
+ return null;
395
+ let text = pyStrip(value);
396
+ if (text.endsWith("Z") || text.endsWith("z"))
397
+ text = `${text.slice(0, -1)}+00:00`;
398
+ const date = ISO_DATE_RE.exec(text);
399
+ if (!date)
400
+ return null;
401
+ const extendedDate = date[0].includes("-");
402
+ if (extendedDate && date[0].length !== 10)
403
+ return null;
404
+ if (!extendedDate && date[0].length !== 8)
405
+ return null;
406
+ const [year, month, day] = [Number(date[1]), Number(date[2]), Number(date[3])];
407
+ if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
408
+ return null;
409
+ }
410
+ let rest = text.slice(date[0].length);
411
+ let [hour, minute, second] = [0, 0, 0];
412
+ let offsetSeconds = 0;
413
+ if (rest.length > 0) {
414
+ const separator = Array.from(rest)[0] ?? "";
415
+ rest = rest.slice(separator.length);
416
+ const time = ISO_TIME_RE.exec(rest);
417
+ if (!time)
418
+ return null;
419
+ hour = Number(time[1]);
420
+ minute = time[2] === undefined ? 0 : Number(time[2]);
421
+ second = time[3] === undefined ? 0 : Number(time[3]);
422
+ if (hour > 23 || minute > 59 || second > 59)
423
+ return null;
424
+ rest = rest.slice(time[0].length);
425
+ if (rest.length > 0) {
426
+ const offset = ISO_OFFSET_RE.exec(rest);
427
+ if (!offset)
428
+ return null;
429
+ const oh = Number(offset[2]);
430
+ const om = offset[3] === undefined ? 0 : Number(offset[3]);
431
+ const os = offset[4] === undefined ? 0 : Number(offset[4]);
432
+ if (oh > 23 || om > 59 || os > 59)
433
+ return null;
434
+ offsetSeconds = (offset[1] === "-" ? -1 : 1) * (oh * 3600 + om * 60 + os);
435
+ }
436
+ }
437
+ const moment = new Date(0);
438
+ moment.setUTCFullYear(year, month - 1, day);
439
+ moment.setUTCHours(hour, minute, second, 0);
440
+ const utc = new Date(moment.getTime() - offsetSeconds * 1000);
441
+ const utcYear = utc.getUTCFullYear();
442
+ if (utcYear < 1 || utcYear > 9999)
443
+ return null;
444
+ return (`${pad(utcYear, 4)}-${pad(utc.getUTCMonth() + 1, 2)}-${pad(utc.getUTCDate(), 2)}` +
445
+ `T${pad(utc.getUTCHours(), 2)}:${pad(utc.getUTCMinutes(), 2)}:${pad(utc.getUTCSeconds(), 2)}Z`);
446
+ }
447
+ /**
448
+ * `installed` with the lock entry's sanitised source fields, else `authored`.
449
+ * Reads only `source`, `sourceType`, `sourceUrl` and `updatedAt`; a field
450
+ * that is missing, of the wrong type or fails its check is left out.
451
+ */
452
+ export function originFromLockEntry(entry) {
453
+ if (entry == null)
454
+ return { kind: ORIGIN_AUTHORED };
455
+ return {
456
+ kind: ORIGIN_INSTALLED,
457
+ source: cleanSource(entry.source),
458
+ source_type: cleanSourceType(entry.sourceType),
459
+ source_url: cleanSourceUrl(entry.sourceUrl),
460
+ updated_at: cleanTimestamp(entry.updatedAt),
461
+ };
462
+ }
463
+ function sourceStore(source) {
464
+ return source.store ?? STORE_AGENT;
465
+ }
466
+ function skip(report, source, hint, reason) {
467
+ report.skipped.push({
468
+ pathHint: hint,
469
+ scope: source.scope,
470
+ reason,
471
+ project: source.project ?? null,
472
+ });
473
+ }
474
+ function listSource(source, report) {
475
+ const root = source.root;
476
+ let info;
477
+ try {
478
+ info = lstatSync(root);
479
+ }
480
+ catch {
481
+ return null; // no such directory: the agent simply has no skills here
482
+ }
483
+ if (isPathLinkOrReparse(root, info)) {
484
+ skip(report, source, source.label, "skills directory is a link; links are not followed");
485
+ return null;
486
+ }
487
+ if (!info.isDirectory())
488
+ return null;
489
+ try {
490
+ return readdirSync(root, { withFileTypes: true }).sort((a, b) => compareUtf8(a.name, b.name));
491
+ }
492
+ catch {
493
+ skip(report, source, source.label, "skills directory cannot be listed");
494
+ return null;
495
+ }
496
+ }
497
+ function makeRef(source, hint, folder, report) {
498
+ if (codePointLength(hint) > MAX_PATH_HINT_CHARS) {
499
+ skip(report, source, codePointSlice(hint, MAX_PATH_HINT_CHARS), "path is too long");
500
+ return null;
501
+ }
502
+ let scan;
503
+ try {
504
+ scan = scanLocalSkill(folder);
505
+ }
506
+ catch (err) {
507
+ if (err instanceof SkillContentError) {
508
+ skip(report, source, hint, err.problems.join("; "));
509
+ }
510
+ else {
511
+ skip(report, source, hint, "skill folder cannot be read");
512
+ }
513
+ return null;
514
+ }
515
+ return makeSkillRef({
516
+ name: scan.frontMatter.name,
517
+ description: scan.frontMatter.description,
518
+ content_sha256: scan.contentSha256,
519
+ scope: source.scope,
520
+ path_hint: hint,
521
+ project: source.project ?? null,
522
+ skipped_files: scan.skipped.slice(0, MAX_SKIPPED_FILES).map((item) => ({
523
+ path: codePointSlice(item.path, MAX_PATH_HINT_CHARS),
524
+ reason: item.reason,
525
+ })),
526
+ visibility: SKILL_REF_VISIBILITY,
527
+ store: sourceStore(source) === STORE_CROSS_AGENT ? STORE_CROSS_AGENT : null,
528
+ });
529
+ }
530
+ function realPath(path) {
531
+ try {
532
+ return realpathSync(path);
533
+ }
534
+ catch {
535
+ return null;
536
+ }
537
+ }
538
+ function normCase(path) {
539
+ const normalized = normalize(path);
540
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
541
+ }
542
+ /**
543
+ * The store folder a linked agent skill folder resolves to, if any. Only the
544
+ * link's target path is resolved; nothing behind it is read. It counts only
545
+ * when it is a direct child of the store root and that child is itself a real
546
+ * folder, so the skill is then read through the store's own path.
547
+ */
548
+ function storeMember(link, store) {
549
+ if (store === null || store.realRoot === null)
550
+ return null;
551
+ const target = realPath(link);
552
+ if (target === null)
553
+ return null;
554
+ const name = basename(target);
555
+ if (!name || normCase(dirname(target)) !== normCase(store.realRoot))
556
+ return null;
557
+ const member = join(store.source.root, name);
558
+ let info;
559
+ try {
560
+ info = lstatSync(member);
561
+ }
562
+ catch {
563
+ return null;
564
+ }
565
+ if (isPathLinkOrReparse(member, info) || !info.isDirectory())
566
+ return null;
567
+ return name;
568
+ }
569
+ function scanStore(source, report, wanted = null) {
570
+ const store = { source, realRoot: null, byFolder: new Map(), byHash: new Map() };
571
+ const found = [];
572
+ const children = listSource(source, report);
573
+ if (children === null)
574
+ return [store, found];
575
+ store.realRoot = realPath(source.root);
576
+ const lock = source.lockFile ? readInstallLock(source.lockFile) : null;
577
+ for (const entry of children) {
578
+ if (entry.name.startsWith("."))
579
+ continue; // tool-managed (a .git checkout), not a skill
580
+ if (wanted !== null && !wanted.has(entry.name))
581
+ continue; // not asked for: not read
582
+ const hint = `${source.label}/${entry.name}`;
583
+ const path = join(source.root, entry.name);
584
+ try {
585
+ if (isPathLinkOrReparse(path)) {
586
+ skip(report, source, hint, "skill folder is a link; links are not followed");
587
+ continue;
588
+ }
589
+ if (!entry.isDirectory())
590
+ continue;
591
+ }
592
+ catch {
593
+ skip(report, source, hint, "skill folder cannot be read");
594
+ continue;
595
+ }
596
+ const ref = makeRef(source, hint, path, report);
597
+ if (ref === null)
598
+ continue;
599
+ if (lock !== null) {
600
+ const lockEntry = lock.get(entry.name) ?? lock.get(ref.name);
601
+ ref.origin = originFromLockEntry(lockEntry);
602
+ }
603
+ const item = { source, folder: entry.name, ref, represents: null, represented: false };
604
+ found.push(item);
605
+ store.byFolder.set(entry.name, item);
606
+ if (!store.byHash.has(ref.content_sha256))
607
+ store.byHash.set(ref.content_sha256, item);
608
+ }
609
+ return [store, found];
610
+ }
611
+ function scanAgentDir(source, report, store, include = null) {
612
+ const found = [];
613
+ const children = listSource(source, report);
614
+ if (children === null)
615
+ return found;
616
+ for (const entry of children) {
617
+ if (entry.name.startsWith("."))
618
+ continue; // agent-managed (bundled) skills, not the user's
619
+ if (include !== null && !include(source, entry.name))
620
+ continue; // not asked for: not read
621
+ const hint = `${source.label}/${entry.name}`;
622
+ const path = join(source.root, entry.name);
623
+ let isLink;
624
+ try {
625
+ isLink = isPathLinkOrReparse(path);
626
+ if (!isLink && !entry.isDirectory())
627
+ continue; // a loose file beside the skill folders
628
+ }
629
+ catch {
630
+ skip(report, source, hint, "skill folder cannot be read");
631
+ continue;
632
+ }
633
+ if (isLink) {
634
+ const member = storeMember(path, store);
635
+ if (store === null || member === null) {
636
+ skip(report, source, hint, "skill folder is a link; links are not followed");
637
+ continue;
638
+ }
639
+ const shared = store.byFolder.get(member);
640
+ if (shared === undefined) {
641
+ skip(report, source, hint, "skill folder links to a cross-agent skill that is not advertised");
642
+ continue;
643
+ }
644
+ if (codePointLength(hint) > MAX_PATH_HINT_CHARS) {
645
+ skip(report, source, codePointSlice(hint, MAX_PATH_HINT_CHARS), "path is too long");
646
+ continue;
647
+ }
648
+ const linked = makeSkillRef({
649
+ ...shared.ref,
650
+ scope: source.scope,
651
+ project: source.project ?? null,
652
+ path_hint: hint,
653
+ store: null,
654
+ also_in: { path_hint: shared.ref.path_hint, via: VIA_LINK },
655
+ });
656
+ found.push({ source, folder: entry.name, ref: linked, represents: shared, represented: false });
657
+ continue;
658
+ }
659
+ const ref = makeRef(source, hint, path, report);
660
+ if (ref === null)
661
+ continue;
662
+ const copy = store !== null ? (store.byHash.get(ref.content_sha256) ?? null) : null;
663
+ let advertised = ref;
664
+ if (copy !== null) {
665
+ advertised = makeSkillRef({
666
+ ...ref,
667
+ also_in: { path_hint: copy.ref.path_hint, via: VIA_COPY },
668
+ origin: copy.ref.origin ?? null,
669
+ });
670
+ }
671
+ found.push({ source, folder: entry.name, ref: advertised, represents: copy, represented: false });
672
+ }
673
+ return found;
674
+ }
675
+ function childNames(root) {
676
+ try {
677
+ return readdirSync(root);
678
+ }
679
+ catch {
680
+ return [];
681
+ }
682
+ }
683
+ /**
684
+ * Store folders that an included agent folder links to. Only link targets are
685
+ * resolved (no skill file is read), so a link the caller asked for is still
686
+ * read through the store's own folder.
687
+ */
688
+ function linkedStoreMembers(storeSource, agentSources, include) {
689
+ const members = new Set();
690
+ const realRoot = realPath(storeSource.root);
691
+ if (realRoot === null)
692
+ return members;
693
+ for (const source of agentSources) {
694
+ if (source.scope !== storeSource.scope ||
695
+ (source.project ?? null) !== (storeSource.project ?? null)) {
696
+ continue;
697
+ }
698
+ for (const name of childNames(source.root)) {
699
+ if (name.startsWith(".") || !include(source, name))
700
+ continue;
701
+ const path = join(source.root, name);
702
+ let target;
703
+ try {
704
+ if (!isPathLinkOrReparse(path))
705
+ continue;
706
+ target = realPath(path);
707
+ }
708
+ catch {
709
+ continue;
710
+ }
711
+ if (target === null)
712
+ continue;
713
+ const member = basename(target);
714
+ if (member && normCase(dirname(target)) === normCase(realRoot))
715
+ members.add(member);
716
+ }
717
+ }
718
+ return members;
719
+ }
720
+ /**
721
+ * Index every source; one ref per skill, first occurrence of a name wins per
722
+ * store and scope. Cross-agent stores are scanned first so the agent's own
723
+ * folders can be matched against them (`also_in`). Refs come back with the
724
+ * agent's own folders first, in source order, then the cross-agent store
725
+ * skills that no advertised agent folder already represents.
726
+ */
727
+ export function scanSkillSources(sources, options = {}) {
728
+ const include = options.include ?? null;
729
+ const ordered = [...sources];
730
+ const report = { refs: [], skipped: [] };
731
+ const stores = new Map();
732
+ const perSource = new Map();
733
+ const storeKey = (source) => JSON.stringify([source.scope, source.project ?? null]);
734
+ const agentSources = ordered.filter((source) => sourceStore(source) !== STORE_CROSS_AGENT);
735
+ ordered.forEach((source, index) => {
736
+ if (sourceStore(source) !== STORE_CROSS_AGENT)
737
+ return;
738
+ const key = storeKey(source);
739
+ if (stores.has(key))
740
+ return; // one store per scope; a repeat would only duplicate it
741
+ let wanted = null;
742
+ if (include !== null) {
743
+ wanted = linkedStoreMembers(source, agentSources, include);
744
+ for (const name of childNames(source.root)) {
745
+ if (include(source, name))
746
+ wanted.add(name);
747
+ }
748
+ }
749
+ const [store, found] = scanStore(source, report, wanted);
750
+ stores.set(key, store);
751
+ perSource.set(index, found);
752
+ });
753
+ ordered.forEach((source, index) => {
754
+ if (sourceStore(source) === STORE_CROSS_AGENT)
755
+ return;
756
+ perSource.set(index, scanAgentDir(source, report, stores.get(storeKey(source)) ?? null, include));
757
+ });
758
+ const seen = new Map();
759
+ const indexed = ordered.map((source, index) => [index, source]);
760
+ const agentFirst = [
761
+ ...indexed.filter(([, source]) => sourceStore(source) !== STORE_CROSS_AGENT),
762
+ ...indexed.filter(([, source]) => sourceStore(source) === STORE_CROSS_AGENT),
763
+ ];
764
+ for (const [index, source] of agentFirst) {
765
+ for (const item of perSource.get(index) ?? []) {
766
+ if (item.represented)
767
+ continue;
768
+ const ref = item.ref;
769
+ const nameKey = JSON.stringify([
770
+ ref.store ?? STORE_AGENT,
771
+ ref.scope,
772
+ ref.project ?? null,
773
+ ref.name,
774
+ ]);
775
+ const advertisedAt = seen.get(nameKey);
776
+ if (advertisedAt !== undefined) {
777
+ skip(report, source, ref.path_hint, `duplicate skill name '${ref.name}' (advertised: ${advertisedAt})`);
778
+ continue;
779
+ }
780
+ if (report.refs.length >= MAX_SKILL_REFS) {
781
+ skip(report, source, ref.path_hint, `more than ${MAX_SKILL_REFS} skills`);
782
+ continue;
783
+ }
784
+ seen.set(nameKey, ref.path_hint);
785
+ report.refs.push(ref);
786
+ if (item.represents !== null)
787
+ item.represents.represented = true;
788
+ }
789
+ }
790
+ return report;
791
+ }
792
+ function perAgentOff(agentId) {
793
+ const quiet = process.env[SKILLS_NO_ADVERTISE_ENV] ?? "";
794
+ const names = new Set(pyStrip(quiet.replaceAll(",", " "))
795
+ .split(PY_WHITESPACE_RUN)
796
+ .map((item) => pyStrip(item).toLowerCase())
797
+ .filter((item) => item !== ""));
798
+ return names.has(pyStrip(agentId).toLowerCase());
799
+ }
800
+ /**
801
+ * Which skills the participant `agentId` may list in its manifest.
802
+ *
803
+ * - {@link LIST_APPROVED}: {@link SKILLS_ADVERTISE_ENV} unset or empty: only
804
+ * the skills approved one by one (`bourdon skills share`) whose folder still
805
+ * hashes to the approved content.
806
+ * - {@link LIST_ALL}: an on value; or, while the variable is unset, a
807
+ * participant built with explicit `skillSources` (the caller chose what to
808
+ * index).
809
+ * - {@link LIST_OFF}: an off value, or any unrecognised one (fail closed):
810
+ * nothing is listed, approved or not.
811
+ *
812
+ * {@link SKILLS_NO_ADVERTISE_ENV} turns named participants off in every mode.
813
+ */
814
+ export function skillsListingMode(agentId, options = {}) {
815
+ const raw = process.env[SKILLS_ADVERTISE_ENV];
816
+ const value = pyStrip(raw ?? "").toLowerCase();
817
+ let mode;
818
+ if (ADVERTISE_ON.has(value)) {
819
+ mode = LIST_ALL;
820
+ }
821
+ else if (value === "") {
822
+ mode = options.explicitSources ? LIST_ALL : LIST_APPROVED;
823
+ }
824
+ else {
825
+ if (!ADVERTISE_OFF.has(value)) {
826
+ warn(`${SKILLS_ADVERTISE_ENV}=${JSON.stringify(raw)} is not a recognised on/off value; ` +
827
+ "not listing skills");
828
+ }
829
+ return LIST_OFF;
830
+ }
831
+ return perAgentOff(agentId) ? LIST_OFF : mode;
832
+ }
833
+ /**
834
+ * Whether the participant `agentId` lists every skill it can see: true only
835
+ * in {@link LIST_ALL} mode ({@link skillsListingMode}). In the default mode a
836
+ * participant lists only the skills shared one by one.
837
+ */
838
+ export function skillsAdvertisingEnabled(agentId, options = {}) {
839
+ return skillsListingMode(agentId, options) === LIST_ALL;
840
+ }
841
+ /**
842
+ * Approved-only mode: the participant's skills that hold a current approval.
843
+ * Reads only the approved folders (and a store folder an approved link points
844
+ * at); with nothing approved, nothing is read.
845
+ */
846
+ function sharedRefs(participant, agentId) {
847
+ const shares = loadShares().filter((share) => shareAppliesTo(share, agentId));
848
+ if (shares.length === 0)
849
+ return [];
850
+ const approved = new Set(shares.map((s) => JSON.stringify([s.scope, s.project, s.pathHint, s.contentSha256])));
851
+ const hints = new Set(shares.map((s) => JSON.stringify([s.scope, s.project, s.pathHint])));
852
+ let refs;
853
+ if (typeof participant.skillSources === "function") {
854
+ const include = (source, name) => hints.has(JSON.stringify([source.scope, source.project ?? null, `${source.label}/${name}`]));
855
+ refs = scanSkillSources(participant.skillSources(), { include }).refs;
856
+ }
857
+ else {
858
+ // A reader without skill sources: its own scan, approved refs kept.
859
+ refs = [...participant.exportSkills()];
860
+ }
861
+ return refs.filter((ref) => approved.has(JSON.stringify([ref.scope, ref.project ?? null, ref.path_hint, ref.content_sha256])));
862
+ }
863
+ /**
864
+ * The refs `participant`'s manifest carries, for `exportL5`.
865
+ *
866
+ * Follows {@link skillsListingMode}: nothing, and no skill directory read,
867
+ * when off; by default only the skills with a current approval, reading only
868
+ * their folders; every skill the reader sees when listing all. A participant
869
+ * built with explicit `skillSources` lists all unless turned off. A failure
870
+ * here never fails an export: skills are an addition to the manifest, not a
871
+ * reason to lose its memory rows. Private refs are dropped, as private
872
+ * entities are.
873
+ */
874
+ export function advertisedSkills(participant) {
875
+ const agentId = String(participant.agentId ?? "");
876
+ const explicitSources = participant.hasExplicitSkillSources?.() === true;
877
+ const mode = skillsListingMode(agentId, { explicitSources });
878
+ if (mode === LIST_OFF)
879
+ return [];
880
+ let refs;
881
+ try {
882
+ refs = mode === LIST_APPROVED ? sharedRefs(participant, agentId) : [...participant.exportSkills()];
883
+ }
884
+ catch (err) {
885
+ warn(`skill readers: ${agentId} skipped skill export: ${String(err)}`);
886
+ return [];
887
+ }
888
+ return refs.filter((ref) => ref.visibility !== Visibility.PRIVATE);
889
+ }
890
+ // -- proposal side: back from an advertised ref to its folder -----------------
891
+ /**
892
+ * The default skill directories of a built-in skill-reading participant.
893
+ *
894
+ * The same directories the participant's own export reads (its user-level
895
+ * directory honouring its home override, the opted-in projects, and the
896
+ * cross-agent store). `null` for an agent with no skill reader here, so a skill
897
+ * it advertises can be listed but not proposed from this machine.
898
+ */
899
+ export function skillSourcesForAgent(agentId) {
900
+ const builders = {
901
+ "claude-code": () => claudeCodeSkillSources(),
902
+ codex: () => codexSkillSources(),
903
+ cursor: () => cursorSkillSources(),
904
+ };
905
+ const build = Object.hasOwn(builders, agentId) ? builders[agentId] : undefined;
906
+ if (build === undefined)
907
+ return null;
908
+ return [...build(), ...crossAgentSkillSources()];
909
+ }
910
+ function folderForHint(sources, pathHint, scope, project) {
911
+ for (const source of sources) {
912
+ if (source.scope !== scope || (source.project ?? null) !== (project ?? null))
913
+ continue;
914
+ const prefix = `${source.label}/`;
915
+ if (!pathHint.startsWith(prefix))
916
+ continue;
917
+ const folder = pathHint.slice(prefix.length);
918
+ if (!folder ||
919
+ folder === "." ||
920
+ folder === ".." ||
921
+ folder.includes("/") ||
922
+ folder.includes("\\")) {
923
+ return null;
924
+ }
925
+ return join(source.root, folder);
926
+ }
927
+ return null;
928
+ }
929
+ /**
930
+ * The folder an advertised ref was scanned from, found through `sources` only.
931
+ *
932
+ * `ref` is a SkillRef as it sits in a manifest. Its `path_hint` is matched
933
+ * against each source's symbolic label (same scope and project), so the result
934
+ * is always one direct child of a directory the reader indexes; a path from
935
+ * anywhere else cannot be produced. A ref that is a link into the cross-agent
936
+ * store resolves to the store's own folder, as the reader read it. `null` when
937
+ * nothing matches.
938
+ */
939
+ export function resolveAdvertisedFolder(sources, ref) {
940
+ const hint = ref.path_hint;
941
+ if (typeof hint !== "string")
942
+ return null;
943
+ const scope = ref.scope;
944
+ const project = ref.project;
945
+ const alsoIn = ref.also_in;
946
+ const isCrossAgent = (s) => s.store === STORE_CROSS_AGENT;
947
+ if (isDict(alsoIn) && alsoIn.via === VIA_LINK) {
948
+ const storeHint = alsoIn.path_hint;
949
+ if (typeof storeHint !== "string")
950
+ return null;
951
+ return folderForHint(sources.filter(isCrossAgent), storeHint, scope, project);
952
+ }
953
+ const chosen = ref.store === STORE_CROSS_AGENT
954
+ ? sources.filter(isCrossAgent)
955
+ : sources.filter((s) => !isCrossAgent(s));
956
+ return folderForHint(chosen, hint, scope, project);
957
+ }
958
+ // -- the local share commands: what this machine's readers can see -------------
959
+ /** The built-in skill readers, in listing order. */
960
+ export const SKILL_READERS = ["claude-code", "codex", "cursor"];
961
+ /**
962
+ * Every skill folder the built-in readers see on this machine (each one is
963
+ * read). The same directories the readers' exports use: user-level, opted-in
964
+ * projects and the cross-agent store. One row per folder; a store folder seen
965
+ * by several readers is one row naming them all. `agent` keeps the folders
966
+ * that one reader sees.
967
+ */
968
+ export function localSkills(agent = null) {
969
+ const rows = new Map();
970
+ for (const reader of SKILL_READERS) {
971
+ if (agent !== null && reader !== agent)
972
+ continue;
973
+ const sources = skillSourcesForAgent(reader) ?? [];
974
+ for (const ref of scanSkillSources(sources).refs) {
975
+ const owner = ref.store === STORE_CROSS_AGENT ? null : reader;
976
+ const store = ref.store || STORE_AGENT;
977
+ const key = JSON.stringify([owner, store, ref.scope, ref.project ?? null, ref.path_hint]);
978
+ const row = rows.get(key);
979
+ if (row !== undefined) {
980
+ if (!row.agents.includes(reader))
981
+ row.agents.push(reader);
982
+ continue;
983
+ }
984
+ const alsoIn = ref.also_in
985
+ ? { path_hint: ref.also_in.path_hint, via: ref.also_in.via }
986
+ : null;
987
+ const folder = resolveAdvertisedFolder(sources, {
988
+ path_hint: ref.path_hint,
989
+ scope: ref.scope,
990
+ project: ref.project ?? null,
991
+ store: ref.store ?? null,
992
+ also_in: alsoIn,
993
+ });
994
+ if (folder !== null)
995
+ rows.set(key, { ref, agent: owner, agents: [reader], folder, store });
996
+ }
997
+ }
998
+ return [...rows.values()];
999
+ }