simple-skills-manager 1.0.1
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/CHANGELOG.md +100 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/config.ts +736 -0
- package/docs/config.md +117 -0
- package/docs/discovery.md +93 -0
- package/index.ts +419 -0
- package/menu.ts +1292 -0
- package/package.json +31 -0
- package/scan.ts +397 -0
- package/simple-skills-manager.example.json +114 -0
- package/tests/fixtures/bad/malformed/SKILL.md +5 -0
- package/tests/fixtures/bad/unparseable/SKILL.md +7 -0
- package/tests/fixtures/single-skill/SKILL.md +8 -0
- package/tests/fixtures/single-skill/references/notes.md +1 -0
- package/tests/fixtures/skills-dir/dupe-a/SKILL.md +6 -0
- package/tests/fixtures/skills-dir/dupe-b/SKILL.md +6 -0
- package/tests/fixtures/skills-dir/grouped/doc/SKILL.md +8 -0
- package/tests/fixtures/skills-dir/grouped/scripted/SKILL.md +8 -0
- package/tests/fixtures/skills-dir/grouped/scripted/scripts/run.sh +2 -0
- package/tests/fixtures/skills-dir/hidden/SKILL.md +9 -0
- package/tests/fixtures/skills-dir/plain/SKILL.md +8 -0
- package/tests/smoke-extension.ts +577 -0
- package/tests/smoke.sh +247 -0
- package/tsconfig.json +20 -0
- package/util.ts +74 -0
package/config.ts
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* simple-skills-manager configuration.
|
|
3
|
+
*
|
|
4
|
+
* Everything — extension settings, every library root ("the registrar"),
|
|
5
|
+
* and each root's approved skill manifest — lives in ONE file:
|
|
6
|
+
*
|
|
7
|
+
* ~/.pi/agent/simple-skills-manager.json
|
|
8
|
+
*
|
|
9
|
+
* Vocabulary (see README):
|
|
10
|
+
* - The *library* is the corpus of all skills known to the manager.
|
|
11
|
+
* - The *registrar* is the corpus of directory roots the library is
|
|
12
|
+
* scanned from; in the TUI all roots sit flat next to each other.
|
|
13
|
+
* - The *store* is the built-in root where skills added through the
|
|
14
|
+
* TUI accumulate: ~/.pi/agent/managed-skills.
|
|
15
|
+
* - *Native* roots are pi's own discovery locations
|
|
16
|
+
* (~/.pi/agent/skills, ~/.agents/skills). pi exposes skills found
|
|
17
|
+
* there by itself; the manager mirrors that exposure (shown in red
|
|
18
|
+
* in the TUI) instead of pretending to gate it.
|
|
19
|
+
*
|
|
20
|
+
* The config file is JSONC (comments allowed when edited by hand; saving
|
|
21
|
+
* from /skills-manager rewrites it and strips comments). It is created
|
|
22
|
+
* automatically with commented defaults on first run, is editable at
|
|
23
|
+
* runtime from the /skills-manager TUI, and is written atomically on
|
|
24
|
+
* every save.
|
|
25
|
+
*
|
|
26
|
+
* Per-entry isolation: each registrar[] entry is validated independently.
|
|
27
|
+
* A malformed entry is kept in the file, flagged with issues, and skipped —
|
|
28
|
+
* it never prevents other entries from loading.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
35
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
36
|
+
import { errText, isRecord, stripJsonComments, writeFileAtomic } from "./util.ts";
|
|
37
|
+
|
|
38
|
+
export const CONFIG_FILE = "simple-skills-manager.json";
|
|
39
|
+
export const CONFIG_PATH = join(getAgentDir(), CONFIG_FILE);
|
|
40
|
+
export const STORE_DIR = join(getAgentDir(), "managed-skills");
|
|
41
|
+
/** pi's own global skill discovery locations — recognized and mirrored, never overridden. */
|
|
42
|
+
export const NATIVE_DIRS = [join(getAgentDir(), "skills"), join(homedir(), ".agents", "skills")];
|
|
43
|
+
export const MAX_CONFIG_BYTES = 1024 * 1024;
|
|
44
|
+
export const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
45
|
+
export const MAX_SKILL_NAME_CHARS = 64;
|
|
46
|
+
export const MAX_SKILL_DESC_CHARS = 1024;
|
|
47
|
+
export const MAX_DIR_PATH_CHARS = 512;
|
|
48
|
+
export const MAX_PATH_CHARS = 2048;
|
|
49
|
+
export const MAX_ROOT_NAME_CHARS = 48;
|
|
50
|
+
|
|
51
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export type Transport = "skills-dir" | "single-skill";
|
|
54
|
+
export type SkillMode = "all" | "selected";
|
|
55
|
+
|
|
56
|
+
/** The approved snapshot of one skill, bound to the exact root it was
|
|
57
|
+
* scanned under (via the manifest fingerprint) and to the exact content
|
|
58
|
+
* that was inspected (via contentHash). */
|
|
59
|
+
export type SkillSnapshot = {
|
|
60
|
+
name: string;
|
|
61
|
+
description: string;
|
|
62
|
+
/** Skill directory, relative to the root path ("." for single-skill roots and root-level .md files). */
|
|
63
|
+
dirPath: string;
|
|
64
|
+
/** The skill instruction file inside the skill directory. Defaults to "SKILL.md"; root-level .md skills set their own file name. */
|
|
65
|
+
file?: string;
|
|
66
|
+
/** SHA-256 of the skill instruction file's bytes. The drift check on every invocation compares against this. */
|
|
67
|
+
contentHash: string;
|
|
68
|
+
/** Number of files in the skill directory (informational). */
|
|
69
|
+
fileCount: number;
|
|
70
|
+
/** Files inside the skill directory that start with a shebang — the model may be instructed to execute these. */
|
|
71
|
+
executableScripts: string[];
|
|
72
|
+
/** Frontmatter declared disable-model-invocation: hidden from the model, only invocable via /skill:<name>. */
|
|
73
|
+
hidden?: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** A skill that was found on disk but failed validation. Kept in the
|
|
77
|
+
* manifest for TUI visibility (native pi is lenient; the manager is
|
|
78
|
+
* strict — the tree shows exactly where the two disagree). */
|
|
79
|
+
export type RejectedSkill = {
|
|
80
|
+
dirPath: string;
|
|
81
|
+
reason: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** The approved snapshot of a root's scan, bound to the exact root
|
|
85
|
+
* configuration it was inspected under (via fingerprint). */
|
|
86
|
+
export type SkillManifest = {
|
|
87
|
+
version: 1;
|
|
88
|
+
fingerprint: string;
|
|
89
|
+
scannedAt: string;
|
|
90
|
+
skills: SkillSnapshot[];
|
|
91
|
+
rejected?: RejectedSkill[];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type RootConfig = {
|
|
95
|
+
name: string;
|
|
96
|
+
enabled: boolean;
|
|
97
|
+
transport: Transport;
|
|
98
|
+
path: string;
|
|
99
|
+
/** Marks the built-in store root (exactly one allowed; path pinned to STORE_DIR). */
|
|
100
|
+
store?: boolean;
|
|
101
|
+
/** Marks a native pi discovery location — exposure is mirrored, not gated. */
|
|
102
|
+
native?: boolean;
|
|
103
|
+
connection: "lazy";
|
|
104
|
+
skills: { mode: SkillMode; include: string[] };
|
|
105
|
+
/** Approved manifest from "Scan or refresh"; required to enable a root. */
|
|
106
|
+
manifest?: SkillManifest;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Extension-level tunables. Every limit that was once a hardcoded constant
|
|
110
|
+
* lives here so users can adjust it without touching code. */
|
|
111
|
+
export type ManagerSettings = {
|
|
112
|
+
maxResultBytes: number;
|
|
113
|
+
maxResultLines: number;
|
|
114
|
+
maxSkills: number;
|
|
115
|
+
toolNameLimit: number;
|
|
116
|
+
maxSkillBytes: number;
|
|
117
|
+
maxScanFiles: number;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const DEFAULT_SETTINGS: ManagerSettings = {
|
|
121
|
+
maxResultBytes: 50 * 1024,
|
|
122
|
+
maxResultLines: 2_000,
|
|
123
|
+
maxSkills: 200,
|
|
124
|
+
toolNameLimit: 64,
|
|
125
|
+
maxSkillBytes: 128 * 1024,
|
|
126
|
+
maxScanFiles: 4_096,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** One registrar[] entry as the extension sees it: the normalized config (an
|
|
130
|
+
* inert placeholder when the raw entry could not be parsed), its validation
|
|
131
|
+
* issues, and the raw entry itself so unparseable data round-trips safely
|
|
132
|
+
* through saves instead of being clobbered. `auto` marks roots the loader
|
|
133
|
+
* seeded automatically because the directory is a native pi location that
|
|
134
|
+
* exists on disk — persisted to the file only once a save blesses them. */
|
|
135
|
+
export type ManagedRoot = {
|
|
136
|
+
config: RootConfig;
|
|
137
|
+
issues: string[];
|
|
138
|
+
normalized: boolean;
|
|
139
|
+
raw: Record<string, unknown>;
|
|
140
|
+
auto?: boolean;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export type ManagerConfig = {
|
|
144
|
+
version: 1;
|
|
145
|
+
settings: ManagerSettings;
|
|
146
|
+
settingsIssues: string[];
|
|
147
|
+
roots: ManagedRoot[];
|
|
148
|
+
/** True when the starter config file was created by this load. */
|
|
149
|
+
created: boolean;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/** Agent Skills standard: 1-64 chars, lowercase letters, numbers, single hyphens. */
|
|
155
|
+
export const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
156
|
+
const CONTENT_HASH_RE = /^[0-9a-f]{64}$/;
|
|
157
|
+
const DIR_SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
158
|
+
const TEMPLATE_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
159
|
+
const MUTABLE_SEGMENTS = ["node_modules", "/tmp/", "temp/", ".cache/", ".npm/", ".local/share/Trash"];
|
|
160
|
+
|
|
161
|
+
// ─── Loading ───────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/** Create a minimal, commented starter config if none exists yet, seeded
|
|
164
|
+
* with the built-in store root. Returns true when the file was created
|
|
165
|
+
* (first run). A fresh config has no approved manifest yet; /skills-manager
|
|
166
|
+
* guides the first addition. Only the default config path also creates the
|
|
167
|
+
* store directory, so test loads with custom paths have no side effects. */
|
|
168
|
+
async function ensureConfigFile(path: string = CONFIG_PATH): Promise<boolean> {
|
|
169
|
+
const info = await lstat(path).catch(() => undefined);
|
|
170
|
+
if (info) return false;
|
|
171
|
+
const starter = `{
|
|
172
|
+
// simple-skills-manager configuration — created automatically on first run.
|
|
173
|
+
// Manage everything interactively with the /skills-manager command (add or
|
|
174
|
+
// import skills, scan and approve roots, expose, edit, remove), or edit this
|
|
175
|
+
// file by hand and restart pi (or run /reload).
|
|
176
|
+
// JSONC comments are allowed here; note that saving from /skills-manager
|
|
177
|
+
// rewrites the file and strips comments.
|
|
178
|
+
// Full field reference: simple-skills-manager.example.json beside the
|
|
179
|
+
// extension, and docs/ in the package.
|
|
180
|
+
|
|
181
|
+
"version": 1,
|
|
182
|
+
|
|
183
|
+
// Extension-level settings (all optional; defaults shown).
|
|
184
|
+
"settings": {
|
|
185
|
+
// "maxResultBytes": 51200, // per-invocation skill text output cap
|
|
186
|
+
// "maxResultLines": 2000, // per-invocation skill text output line cap
|
|
187
|
+
// "maxSkills": 200, // max skills a root manifest may hold
|
|
188
|
+
// "toolNameLimit": 64, // max length of registered skill_* tool names
|
|
189
|
+
// "maxSkillBytes": 131072, // max size of one SKILL.md (bytes)
|
|
190
|
+
// "maxScanFiles": 4096 // max filesystem entries a scan may visit
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
// The registrar: every library root sits flat next to the others. The
|
|
194
|
+
// built-in "store" root is where skills added through /skills-manager
|
|
195
|
+
// accumulate; group skills by nesting them in subdirectories of the
|
|
196
|
+
// store (subdirectories are displayed as groups in the TUI). Each other
|
|
197
|
+
// root needs one "Scan or refresh" pass (in /skills-manager) to record
|
|
198
|
+
// its approved skill manifest before it can be enabled.
|
|
199
|
+
"registrar": [
|
|
200
|
+
{
|
|
201
|
+
"name": "store",
|
|
202
|
+
"store": true,
|
|
203
|
+
"enabled": false,
|
|
204
|
+
"transport": "skills-dir",
|
|
205
|
+
"path": ${JSON.stringify(STORE_DIR)},
|
|
206
|
+
"connection": "lazy",
|
|
207
|
+
"skills": { "mode": "selected", "include": [] }
|
|
208
|
+
}
|
|
209
|
+
]
|
|
210
|
+
}
|
|
211
|
+
`;
|
|
212
|
+
try {
|
|
213
|
+
await writeFile(path, starter, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// Another first load won the race; its file is as good as ours.
|
|
216
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
if (path === CONFIG_PATH) {
|
|
220
|
+
await mkdir(STORE_DIR, { recursive: true }).catch(() => undefined);
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function stringArray(value: unknown): string[] | undefined {
|
|
226
|
+
if (value === undefined) return undefined;
|
|
227
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return undefined;
|
|
228
|
+
return value as string[];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function numberSetting(
|
|
232
|
+
raw: Record<string, unknown>,
|
|
233
|
+
key: keyof ManagerSettings,
|
|
234
|
+
def: number,
|
|
235
|
+
min: number,
|
|
236
|
+
max: number,
|
|
237
|
+
issues: string[],
|
|
238
|
+
): number {
|
|
239
|
+
const value = raw[key];
|
|
240
|
+
if (value === undefined) return def;
|
|
241
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
|
|
242
|
+
issues.push(`settings.${key} must be an integer between ${min} and ${max}; using the default (${def}).`);
|
|
243
|
+
return def;
|
|
244
|
+
}
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function normalizeSettings(raw: unknown): { settings: ManagerSettings; issues: string[] } {
|
|
249
|
+
const issues: string[] = [];
|
|
250
|
+
if (raw === undefined) return { settings: { ...DEFAULT_SETTINGS }, issues };
|
|
251
|
+
if (!isRecord(raw)) {
|
|
252
|
+
issues.push("settings must be a JSON object; using defaults.");
|
|
253
|
+
return { settings: { ...DEFAULT_SETTINGS }, issues };
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
settings: {
|
|
257
|
+
maxResultBytes: numberSetting(raw, "maxResultBytes", DEFAULT_SETTINGS.maxResultBytes, 1024, 10 * 1024 * 1024, issues),
|
|
258
|
+
maxResultLines: numberSetting(raw, "maxResultLines", DEFAULT_SETTINGS.maxResultLines, 1, 1_000_000, issues),
|
|
259
|
+
maxSkills: numberSetting(raw, "maxSkills", DEFAULT_SETTINGS.maxSkills, 1, 1_000, issues),
|
|
260
|
+
toolNameLimit: numberSetting(raw, "toolNameLimit", DEFAULT_SETTINGS.toolNameLimit, 16, 128, issues),
|
|
261
|
+
maxSkillBytes: numberSetting(raw, "maxSkillBytes", DEFAULT_SETTINGS.maxSkillBytes, 1024, 1_048_576, issues),
|
|
262
|
+
maxScanFiles: numberSetting(raw, "maxScanFiles", DEFAULT_SETTINGS.maxScanFiles, 10, 100_000, issues),
|
|
263
|
+
},
|
|
264
|
+
issues,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Normalize one raw registrar[] entry. Returns the normalized config when
|
|
269
|
+
* the entry has the required shape (validation issues may still apply), or
|
|
270
|
+
* an inert placeholder when it could not be parsed at all. Paths that point
|
|
271
|
+
* at pi's native discovery locations are automatically flagged native. */
|
|
272
|
+
function normalizeRoot(raw: Record<string, unknown>, settings: ManagerSettings): { config: RootConfig; normalized: boolean; issues: string[] } {
|
|
273
|
+
const issues: string[] = [];
|
|
274
|
+
if (typeof raw.name !== "string" || !raw.name || typeof raw.enabled !== "boolean"
|
|
275
|
+
|| (raw.transport !== "skills-dir" && raw.transport !== "single-skill")
|
|
276
|
+
|| typeof raw.path !== "string" || !raw.path) {
|
|
277
|
+
return {
|
|
278
|
+
config: inertRoot(typeof raw.name === "string" && raw.name ? raw.name : "unnamed"),
|
|
279
|
+
normalized: false,
|
|
280
|
+
issues: ["Malformed root entry: name, enabled, transport (skills-dir | single-skill), and path are required."],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (raw.connection !== undefined && raw.connection !== "lazy") {
|
|
284
|
+
issues.push("Only lazy skill loading is supported.");
|
|
285
|
+
}
|
|
286
|
+
if (raw.skills !== undefined && !isRecord(raw.skills)) {
|
|
287
|
+
issues.push("skills must be a JSON object with mode and include.");
|
|
288
|
+
}
|
|
289
|
+
const skills = isRecord(raw.skills) ? raw.skills : {};
|
|
290
|
+
if (skills.mode !== undefined && skills.mode !== "all" && skills.mode !== "selected") {
|
|
291
|
+
issues.push("skills.mode must be \"all\" or \"selected\".");
|
|
292
|
+
}
|
|
293
|
+
const include = stringArray(skills.include);
|
|
294
|
+
if (skills.include !== undefined && !include) {
|
|
295
|
+
issues.push("skills.include must be an array of skill names.");
|
|
296
|
+
}
|
|
297
|
+
// Auto-recognize native pi discovery locations, whatever the file says.
|
|
298
|
+
const native = raw.native === true || isNativePath(raw.path);
|
|
299
|
+
const config: RootConfig = {
|
|
300
|
+
name: raw.name,
|
|
301
|
+
enabled: raw.enabled,
|
|
302
|
+
transport: raw.transport,
|
|
303
|
+
path: raw.path,
|
|
304
|
+
store: raw.store === true ? true : undefined,
|
|
305
|
+
native: native ? true : undefined,
|
|
306
|
+
connection: "lazy",
|
|
307
|
+
skills: {
|
|
308
|
+
mode: skills.mode === "all" ? "all" : "selected",
|
|
309
|
+
include: include ?? [],
|
|
310
|
+
},
|
|
311
|
+
manifest: isRecord(raw.manifest) ? raw.manifest as unknown as SkillManifest : undefined,
|
|
312
|
+
};
|
|
313
|
+
issues.push(...validateRoot(config, settings));
|
|
314
|
+
if (config.manifest) {
|
|
315
|
+
const checked = validateManifest(config.manifest, config, settings);
|
|
316
|
+
if (!checked.manifest) issues.push(...checked.issues);
|
|
317
|
+
else config.manifest = checked.manifest;
|
|
318
|
+
} else {
|
|
319
|
+
issues.push("No approved skill manifest; scan the root before enabling it.");
|
|
320
|
+
}
|
|
321
|
+
return { config, normalized: true, issues };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function inertRoot(name: string): RootConfig {
|
|
325
|
+
return {
|
|
326
|
+
name,
|
|
327
|
+
enabled: false,
|
|
328
|
+
transport: "skills-dir",
|
|
329
|
+
path: "",
|
|
330
|
+
connection: "lazy",
|
|
331
|
+
skills: { mode: "selected", include: [] },
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Load the single config file. Throws with a readable reason when the file
|
|
336
|
+
* exists but is unparseable or structurally invalid — never a silent
|
|
337
|
+
* fallback. A missing file is auto-created as a starter config. */
|
|
338
|
+
export async function loadManagerConfig(path: string = CONFIG_PATH): Promise<ManagerConfig> {
|
|
339
|
+
const created = await ensureConfigFile(path);
|
|
340
|
+
const info = await lstat(path).catch(() => undefined);
|
|
341
|
+
if (!info?.isFile() || info.isSymbolicLink()) {
|
|
342
|
+
throw new Error(`${path} must be a regular file, not a symbolic link.`);
|
|
343
|
+
}
|
|
344
|
+
if (info.size > MAX_CONFIG_BYTES) {
|
|
345
|
+
throw new Error(`${path} exceeds ${MAX_CONFIG_BYTES} bytes.`);
|
|
346
|
+
}
|
|
347
|
+
let parsed: unknown;
|
|
348
|
+
try {
|
|
349
|
+
parsed = JSON.parse(stripJsonComments(await readFile(path, "utf8")));
|
|
350
|
+
} catch (error) {
|
|
351
|
+
throw new Error(`${path} is not valid JSON: ${errText(error)}`);
|
|
352
|
+
}
|
|
353
|
+
if (!isRecord(parsed)) throw new Error(`${path} must contain a JSON object.`);
|
|
354
|
+
if (parsed.version !== undefined && parsed.version !== 1) {
|
|
355
|
+
throw new Error(`${path}: unsupported config version ${String(parsed.version)}; expected 1.`);
|
|
356
|
+
}
|
|
357
|
+
if (parsed.registrar !== undefined && !Array.isArray(parsed.registrar)) {
|
|
358
|
+
throw new Error(`${path}: "registrar" must be an array (it may be empty).`);
|
|
359
|
+
}
|
|
360
|
+
const { settings, issues: settingsIssues } = normalizeSettings(parsed.settings);
|
|
361
|
+
const roots: ManagedRoot[] = [];
|
|
362
|
+
const seen = new Set<string>();
|
|
363
|
+
let storeSeen = false;
|
|
364
|
+
for (const raw of (parsed.registrar ?? []) as unknown[]) {
|
|
365
|
+
if (!isRecord(raw)) {
|
|
366
|
+
roots.push({ config: inertRoot("unnamed"), issues: ["Root entry must be a JSON object."], normalized: false, raw: {} });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const { config, normalized, issues } = normalizeRoot(raw, settings);
|
|
370
|
+
if (config.store) {
|
|
371
|
+
if (storeSeen) issues.push("Duplicate store root (earlier definition wins).");
|
|
372
|
+
else storeSeen = true;
|
|
373
|
+
}
|
|
374
|
+
if (seen.has(config.name)) issues.push(`Duplicate root name: ${config.name} (earlier definition wins).`);
|
|
375
|
+
else seen.add(config.name);
|
|
376
|
+
roots.push({ config, issues, normalized, raw });
|
|
377
|
+
}
|
|
378
|
+
// Auto-adoption: native pi skill locations that exist on disk but are not
|
|
379
|
+
// in the registrar appear automatically — disabled, unapproved, flagged.
|
|
380
|
+
// The manager never enables or scans them by itself; the tree shows them
|
|
381
|
+
// for visibility and "Scan or refresh" is the adoption ceremony.
|
|
382
|
+
for (const [index, dir] of NATIVE_DIRS.entries()) {
|
|
383
|
+
const info = await lstat(dir).catch(() => undefined);
|
|
384
|
+
if (!info?.isDirectory()) continue;
|
|
385
|
+
const resolved = resolve(dir);
|
|
386
|
+
const registered = roots.some((root) => {
|
|
387
|
+
const rawPath = typeof root.raw.path === "string" ? root.raw.path : root.config.path;
|
|
388
|
+
try {
|
|
389
|
+
return typeof rawPath === "string" && rawPath && expandUserPath(rawPath, process.cwd()) === resolved;
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
if (registered) continue;
|
|
395
|
+
const base = index === 0 ? "pi-skills" : "agents-skills";
|
|
396
|
+
let name = base;
|
|
397
|
+
let suffix = 2;
|
|
398
|
+
while (roots.some((root) => root.config.name === name)) name = `${base}-${suffix++}`;
|
|
399
|
+
const config: RootConfig = {
|
|
400
|
+
name, enabled: false, transport: "skills-dir", path: dir,
|
|
401
|
+
native: true, connection: "lazy", skills: { mode: "selected", include: [] },
|
|
402
|
+
};
|
|
403
|
+
roots.push({
|
|
404
|
+
config,
|
|
405
|
+
issues: [
|
|
406
|
+
"Auto-detected native pi location — pi exposes skills here through its own discovery, regardless of this extension.",
|
|
407
|
+
"No approved skill manifest; Scan or refresh (in /skills-manager) to adopt it into the library.",
|
|
408
|
+
],
|
|
409
|
+
normalized: true,
|
|
410
|
+
raw: {},
|
|
411
|
+
auto: true,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return { version: 1, settings, settingsIssues, roots, created };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ─── Saving ────────────────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
/** Atomically write raw config text (mode 0600) with the global size bound
|
|
420
|
+
* enforced. Used both by saveManagerConfig and by the TUI's hand-edit path,
|
|
421
|
+
* so every write of the file is atomic. */
|
|
422
|
+
export async function writeConfigText(text: string, path: string = CONFIG_PATH): Promise<void> {
|
|
423
|
+
if (Buffer.byteLength(text) > MAX_CONFIG_BYTES) {
|
|
424
|
+
throw new Error(`Refusing to write: the config would exceed ${MAX_CONFIG_BYTES} bytes.`);
|
|
425
|
+
}
|
|
426
|
+
await writeFileAtomic(path, text);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Atomically write the whole config file. Normalized entries are written
|
|
430
|
+
* from their canonical config; unparseable entries are written back raw so
|
|
431
|
+
* hand-edited data is never silently destroyed. Saving strips comments. */
|
|
432
|
+
export async function saveManagerConfig(config: ManagerConfig, path: string = CONFIG_PATH): Promise<void> {
|
|
433
|
+
const payload = {
|
|
434
|
+
version: 1,
|
|
435
|
+
settings: config.settings,
|
|
436
|
+
registrar: config.roots.map((root) => root.normalized ? root.config : root.raw),
|
|
437
|
+
};
|
|
438
|
+
await writeConfigText(`${JSON.stringify(payload, null, 2)}\n`, path);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ─── Entry helpers ──────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
export function findRoot(config: ManagerConfig, name: string): ManagedRoot | undefined {
|
|
444
|
+
return config.roots.find((root) => root.config.name === name);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function findStoreRoot(config: ManagerConfig): ManagedRoot | undefined {
|
|
448
|
+
return config.roots.find((root) => root.config.store && root.normalized);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Insert or replace a registrar entry by name, refusing entries with
|
|
452
|
+
* validation issues (hand-repair those first through the TUI or the file). */
|
|
453
|
+
export function upsertRoot(config: ManagerConfig, root: RootConfig): void {
|
|
454
|
+
const issues = validateRoot(root, config.settings);
|
|
455
|
+
if (issues.length) throw new Error(`Refusing to save invalid library root definition:\n${issues.join("\n")}`);
|
|
456
|
+
if (root.manifest) {
|
|
457
|
+
const checked = validateManifest(root.manifest, root, config.settings);
|
|
458
|
+
if (!checked.manifest) throw new Error(`Refusing to save invalid skill manifest:\n${checked.issues.join("\n")}`);
|
|
459
|
+
}
|
|
460
|
+
const entry: ManagedRoot = { config: root, issues: [], normalized: true, raw: root as unknown as Record<string, unknown> };
|
|
461
|
+
const index = config.roots.findIndex((item) => item.config.name === root.name);
|
|
462
|
+
if (index >= 0) config.roots[index] = entry;
|
|
463
|
+
else config.roots.push(entry);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function removeRootEntry(config: ManagerConfig, name: string): boolean {
|
|
467
|
+
const index = config.roots.findIndex((item) => item.config.name === name);
|
|
468
|
+
if (index < 0) return false;
|
|
469
|
+
config.roots.splice(index, 1);
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ─── Validation ────────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
export function validateRoot(config: RootConfig, _settings: ManagerSettings = DEFAULT_SETTINGS): string[] {
|
|
476
|
+
const issues: string[] = [];
|
|
477
|
+
if (!SKILL_NAME_RE.test(config.name) || config.name.length > MAX_ROOT_NAME_CHARS) {
|
|
478
|
+
issues.push("Name must be 1-48 lowercase letters, numbers, or single hyphens.");
|
|
479
|
+
}
|
|
480
|
+
if (config.connection !== "lazy") issues.push("Only lazy skill loading is supported.");
|
|
481
|
+
if (config.transport !== "skills-dir" && config.transport !== "single-skill") {
|
|
482
|
+
issues.push("Transport must be \"skills-dir\" or \"single-skill\".");
|
|
483
|
+
}
|
|
484
|
+
if (typeof config.path !== "string" || !config.path || config.path.length > MAX_PATH_CHARS || /[\0\r\n]/.test(config.path)) {
|
|
485
|
+
issues.push("A root path is required (no NUL or newline characters).");
|
|
486
|
+
} else {
|
|
487
|
+
try {
|
|
488
|
+
const resolved = expandUserPath(resolveTemplate(config.path), process.cwd());
|
|
489
|
+
if (resolved === homedir() || resolved === "/") issues.push("The root path may not be the home or filesystem root.");
|
|
490
|
+
if (config.store && resolved !== resolve(STORE_DIR)) {
|
|
491
|
+
issues.push("A store root's path must be the built-in store directory.");
|
|
492
|
+
}
|
|
493
|
+
if (config.native && !isNativePath(config.path)) {
|
|
494
|
+
issues.push("A native root's path must be one of pi's native skill discovery locations.");
|
|
495
|
+
}
|
|
496
|
+
} catch {
|
|
497
|
+
// A template reference (${...}) that cannot be resolved yet is fine;
|
|
498
|
+
// it fails closed at scan time with a readable error.
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (config.store && config.native) issues.push("A root may not be both the store and a native location.");
|
|
502
|
+
if (new Set(config.skills.include).size !== config.skills.include.length) issues.push("The selected skill list contains duplicates.");
|
|
503
|
+
if (config.skills.mode === "all" && (config.skills.include?.length ?? 0) > 0) {
|
|
504
|
+
issues.push("skills.include must be empty when skills.mode is \"all\".");
|
|
505
|
+
}
|
|
506
|
+
return issues;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The identity of a root: everything that determines what directory the
|
|
511
|
+
* library is scanned from. The approved skill manifest is bound to this
|
|
512
|
+
* identity, so changing any of these fields invalidates the manifest.
|
|
513
|
+
* Deliberately excludes enabled, native, store, the skills selection, and
|
|
514
|
+
* the manifest itself.
|
|
515
|
+
*/
|
|
516
|
+
function rootIdentity(config: RootConfig): unknown {
|
|
517
|
+
return { name: config.name, transport: config.transport, path: config.path };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function fingerprint(config: RootConfig): string {
|
|
521
|
+
return createHash("sha256").update(JSON.stringify(rootIdentity(config))).digest("hex");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function validateManifest(
|
|
525
|
+
manifest: unknown,
|
|
526
|
+
config: RootConfig,
|
|
527
|
+
settings: ManagerSettings = DEFAULT_SETTINGS,
|
|
528
|
+
): { manifest?: SkillManifest; issues: string[] } {
|
|
529
|
+
const issues: string[] = [];
|
|
530
|
+
if (!isRecord(manifest) || manifest.version !== 1 || !Array.isArray(manifest.skills)) {
|
|
531
|
+
return { issues: ["Missing or malformed approved skill manifest."] };
|
|
532
|
+
}
|
|
533
|
+
if (Buffer.byteLength(JSON.stringify(manifest)) > MAX_MANIFEST_BYTES) issues.push(`The approved skill manifest exceeds ${MAX_MANIFEST_BYTES} bytes.`);
|
|
534
|
+
if (String(manifest.fingerprint) !== fingerprint(config)) issues.push("The approved skill manifest does not match the current root configuration; refresh it.");
|
|
535
|
+
if (manifest.skills.length > settings.maxSkills) issues.push(`The root contains more than ${settings.maxSkills} skills.`);
|
|
536
|
+
const names = new Set<string>();
|
|
537
|
+
const skills: SkillSnapshot[] = [];
|
|
538
|
+
for (const raw of manifest.skills) {
|
|
539
|
+
if (!isRecord(raw)) {
|
|
540
|
+
issues.push("The skill manifest contains a malformed skill.");
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const name = typeof raw.name === "string" ? raw.name : "";
|
|
544
|
+
if (!SKILL_NAME_RE.test(name) || name.length > MAX_SKILL_NAME_CHARS || /[\0\r\n]/.test(name)) {
|
|
545
|
+
issues.push(`Skill name is invalid (1-64 chars, lowercase letters, numbers, single hyphens): ${name.slice(0, 80) || "(empty)"}`);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (typeof raw.description !== "string" || !raw.description.trim() || raw.description.length > MAX_SKILL_DESC_CHARS) {
|
|
549
|
+
issues.push(`Skill ${name} must have a non-empty description of at most ${MAX_SKILL_DESC_CHARS} characters.`);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const dirPath = typeof raw.dirPath === "string" ? raw.dirPath : "";
|
|
553
|
+
if (!validDirPath(dirPath)) {
|
|
554
|
+
issues.push(`Skill ${name} has an invalid directory path: ${dirPath.slice(0, 80) || "(empty)"}`);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (typeof raw.contentHash !== "string" || !CONTENT_HASH_RE.test(raw.contentHash)) {
|
|
558
|
+
issues.push(`Skill ${name} must carry a SHA-256 content hash.`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const file = typeof raw.file === "string" ? raw.file : "SKILL.md";
|
|
562
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/.test(file) || file.length > 128) {
|
|
563
|
+
issues.push(`Skill ${name} has an invalid instruction file name: ${file.slice(0, 80)}`);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const executableScripts = stringArray(raw.executableScripts) ?? [];
|
|
567
|
+
if (raw.executableScripts !== undefined && !executableScripts) {
|
|
568
|
+
issues.push(`Skill ${name}: executableScripts must be an array of file paths.`);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (executableScripts.some((script) => !validDirPath(script) || script.length > MAX_DIR_PATH_CHARS)) {
|
|
572
|
+
issues.push(`Skill ${name} has an invalid executable script path.`);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (names.has(name)) issues.push(`Duplicate skill name in manifest: ${name}`);
|
|
576
|
+
names.add(name);
|
|
577
|
+
skills.push({
|
|
578
|
+
name,
|
|
579
|
+
description: raw.description.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ").slice(0, MAX_SKILL_DESC_CHARS),
|
|
580
|
+
dirPath,
|
|
581
|
+
file: file === "SKILL.md" ? undefined : file,
|
|
582
|
+
contentHash: raw.contentHash,
|
|
583
|
+
fileCount: typeof raw.fileCount === "number" && Number.isInteger(raw.fileCount) && raw.fileCount >= 1 ? raw.fileCount : 1,
|
|
584
|
+
executableScripts,
|
|
585
|
+
hidden: raw.hidden === true ? true : undefined,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
const rejected: RejectedSkill[] = [];
|
|
589
|
+
if (manifest.rejected !== undefined) {
|
|
590
|
+
if (!Array.isArray(manifest.rejected)) issues.push("manifest.rejected must be an array.");
|
|
591
|
+
else for (const raw of manifest.rejected) {
|
|
592
|
+
if (isRecord(raw) && typeof raw.dirPath === "string" && validDirPath(raw.dirPath) && typeof raw.reason === "string" && raw.reason.length <= 512) {
|
|
593
|
+
rejected.push({ dirPath: raw.dirPath, reason: raw.reason.slice(0, 512) });
|
|
594
|
+
} else {
|
|
595
|
+
issues.push("manifest.rejected contains a malformed entry.");
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (config.skills.mode === "selected") {
|
|
600
|
+
const manifestNames = new Set(skills.map((skill) => skill.name));
|
|
601
|
+
for (const name of config.skills.include) {
|
|
602
|
+
if (!manifestNames.has(name)) issues.push(`Selected skill is absent from the approved manifest: ${name}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
manifest: issues.length ? undefined : {
|
|
607
|
+
version: 1,
|
|
608
|
+
fingerprint: String(manifest.fingerprint),
|
|
609
|
+
scannedAt: typeof manifest.scannedAt === "string" ? manifest.scannedAt : "unknown",
|
|
610
|
+
skills,
|
|
611
|
+
rejected: rejected.length ? rejected : undefined,
|
|
612
|
+
},
|
|
613
|
+
issues,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** A relative path with sane segments, no "..", no absolute prefix, no NUL/newline. */
|
|
618
|
+
export function validDirPath(value: string): boolean {
|
|
619
|
+
if (!value || value.length > MAX_DIR_PATH_CHARS || /[\0\r\n]/.test(value)) return false;
|
|
620
|
+
if (value === ".") return true;
|
|
621
|
+
if (value.startsWith("/") || value.includes("\\")) return false;
|
|
622
|
+
return value.split("/").every((segment) => DIR_SEGMENT_RE.test(segment) && segment !== "..");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ─── Path plumbing ──────────────────────────────────────────────────────────
|
|
626
|
+
|
|
627
|
+
export function expandUserPath(input: string, cwd: string): string {
|
|
628
|
+
const trimmed = input.trim();
|
|
629
|
+
const expanded = trimmed === "~"
|
|
630
|
+
? homedir()
|
|
631
|
+
: trimmed.startsWith(`~${sep}`) || trimmed.startsWith("~/")
|
|
632
|
+
? join(homedir(), trimmed.slice(2))
|
|
633
|
+
: trimmed;
|
|
634
|
+
return resolve(isAbsolute(expanded) ? expanded : join(cwd, expanded));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Resolve `${NAME}` templates against extras and the launching environment. Missing variables fail closed. */
|
|
638
|
+
export function resolveTemplate(value: string, extras: Record<string, string> = {}): string {
|
|
639
|
+
return value.replace(TEMPLATE_REF_RE, (_match, name: string) => {
|
|
640
|
+
const resolved = extras[name] ?? process.env[name];
|
|
641
|
+
if (resolved === undefined) throw new Error(`Required environment variable is not set: ${name}`);
|
|
642
|
+
return resolved;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** The root path with ~, ${workspace}, and ${home} expanded. */
|
|
647
|
+
export function resolvedRootPath(config: RootConfig, workspace: string): string {
|
|
648
|
+
return expandUserPath(resolveTemplate(config.path, { workspace, home: homedir() }), workspace);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** True when the path is one of pi's native global skill discovery locations. */
|
|
652
|
+
export function isNativePath(path: string): boolean {
|
|
653
|
+
try {
|
|
654
|
+
const resolved = expandUserPath(path, process.cwd());
|
|
655
|
+
return NATIVE_DIRS.some((dir) => resolve(dir) === resolved);
|
|
656
|
+
} catch {
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Absolute path of a skill's instruction file. */
|
|
662
|
+
export function skillFilePath(config: RootConfig, snapshot: { dirPath: string; file?: string }, workspace: string): string {
|
|
663
|
+
const rootPath = resolvedRootPath(config, workspace);
|
|
664
|
+
return join(rootPath, snapshot.dirPath === "." ? "" : snapshot.dirPath, snapshot.file ?? "SKILL.md");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** The display group of a skill: the first path segment under its root
|
|
668
|
+
* when the skill is nested in a subdirectory, or "ungrouped" at the root
|
|
669
|
+
* level (including root-level .md files and single-skill roots). */
|
|
670
|
+
export function skillGroup(dirPath: string): string {
|
|
671
|
+
if (dirPath === "." || !dirPath.includes("/")) return "ungrouped";
|
|
672
|
+
return dirPath.split("/")[0]!;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Warn when a root lives somewhere its content may change without notice. */
|
|
676
|
+
export function mutablePathWarning(path: string): string | undefined {
|
|
677
|
+
const normalized = path.replaceAll("\\", "/");
|
|
678
|
+
for (const segment of MUTABLE_SEGMENTS) {
|
|
679
|
+
if (normalized === segment || normalized.includes(`/${segment}`) || normalized.startsWith(segment)) {
|
|
680
|
+
return "This root lives in a mutable or cache location; its content may change without notice. Re-scan before trusting it.";
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Warn when adopting a directory pi already discovers natively. */
|
|
687
|
+
export function nativeDiscoveryWarning(path: string): string | undefined {
|
|
688
|
+
if (!isNativePath(path)) return undefined;
|
|
689
|
+
return "pi already discovers this location natively: skills here will be listed by pi itself as well. " +
|
|
690
|
+
"The manager mirrors that exposure (shown in red in the tree) and cannot revoke it; launch pi with --no-skills for full library-only control.";
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export function scanSummary(config: RootConfig, workspace: string): string {
|
|
694
|
+
return [
|
|
695
|
+
`Transport: ${config.transport}`,
|
|
696
|
+
`Path: ${resolvedRootPath(config, workspace)}`,
|
|
697
|
+
`Store root: ${config.store ? "yes (skills added via /skills-manager accumulate here)" : "no"}`,
|
|
698
|
+
`Native pi location: ${config.native ? "yes — exposure is mirrored, not gated" : "no"}`,
|
|
699
|
+
].join("\n");
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ─── Reusable selection logic ───────────────────────────────────────────────
|
|
703
|
+
|
|
704
|
+
/** The skills of a root's approved manifest, defensively. A manifest that
|
|
705
|
+
* failed validation is deliberately retained raw in the entry (so hand
|
|
706
|
+
* edits are not destroyed on save), so its `skills` can be anything —
|
|
707
|
+
* never assume it is an array. */
|
|
708
|
+
export function manifestSkills(root: RootConfig): SkillSnapshot[] {
|
|
709
|
+
const skills = root.manifest?.skills;
|
|
710
|
+
return Array.isArray(skills) ? skills : [];
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function selectedSkills(root: RootConfig): SkillSnapshot[] {
|
|
714
|
+
const skills = manifestSkills(root);
|
|
715
|
+
if (root.skills.mode === "all") return skills;
|
|
716
|
+
const included = new Set(root.skills.include);
|
|
717
|
+
return skills.filter((skill) => included.has(skill.name));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* The skills exposed to the model for one root, live. Standard roots expose
|
|
722
|
+
* their selected skills. Native roots additionally mirror whatever pi's
|
|
723
|
+
* native discovery is exposing right now (each skill's instruction file
|
|
724
|
+
* path present in the system prompt's loaded-skills list): pi exposes those
|
|
725
|
+
* skills whether the manager likes it or not, so mirroring adds the
|
|
726
|
+
* drift-checked surface without pretending to gate the native listing.
|
|
727
|
+
*/
|
|
728
|
+
export function computeExposedSkills(root: RootConfig, workspace: string, nativeActivePaths: Set<string>): SkillSnapshot[] {
|
|
729
|
+
if (!root.enabled) return [];
|
|
730
|
+
const standard = selectedSkills(root);
|
|
731
|
+
if (!root.native) return standard;
|
|
732
|
+
const standardNames = new Set(standard.map((skill) => skill.name));
|
|
733
|
+
return manifestSkills(root).filter((skill) =>
|
|
734
|
+
standardNames.has(skill.name) || nativeActivePaths.has(skillFilePath(root, skill, workspace)),
|
|
735
|
+
);
|
|
736
|
+
}
|