@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/index.cjs +3595 -772
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +954 -5
- package/dist/index.d.ts +6 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/readers/claude-code.d.ts +21 -2
- package/dist/readers/claude-code.js +31 -1
- package/dist/readers/cursor.d.ts +23 -2
- package/dist/readers/cursor.js +33 -1
- package/dist/skill-index.d.ts +110 -0
- package/dist/skill-index.js +470 -0
- package/dist/skill-proposals.d.ts +212 -0
- package/dist/skill-proposals.js +757 -0
- package/dist/skill-shares.d.ts +128 -0
- package/dist/skill-shares.js +366 -0
- package/dist/skill-store.d.ts +186 -0
- package/dist/skill-store.js +750 -0
- package/dist/skills.d.ts +295 -0
- package/dist/skills.js +999 -0
- package/package.json +5 -4
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
|
+
}
|