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/docs/config.md ADDED
@@ -0,0 +1,117 @@
1
+ # Configuration reference
2
+
3
+ Everything lives in one file:
4
+
5
+ ```
6
+ ~/.pi/agent/simple-skills-manager.json
7
+ ```
8
+
9
+ - The file is **JSONC**: `//` line comments and `/* */` block comments are
10
+ allowed when you edit by hand. Saving from `/skills-manager` rewrites the
11
+ file and strips comments.
12
+ - On first run the extension creates a commented starter file, seeded with
13
+ the built-in **store** root, and creates the skill store directory
14
+ `~/.pi/agent/managed-skills`.
15
+ - A file that exists but is unparseable or structurally invalid fails loudly:
16
+ no skills are registered and the reason is surfaced at startup and in
17
+ `/skills-manager`.
18
+ - Each `registrar[]` entry is validated **independently**. A malformed entry
19
+ is kept in the file, flagged with issues (visible in the tree), and skipped
20
+ — it never prevents other entries from loading.
21
+ - Every save is atomic (temp file + rename, mode 0600).
22
+
23
+ **Vocabulary.** The *library* is the corpus of all skills known to the
24
+ manager. The *registrar* is the corpus of directory roots the library is
25
+ scanned from — in the TUI all roots sit flat next to each other. The
26
+ *store* is the built-in root where skills added through the TUI
27
+ accumulate. *Native* roots are pi's own discovery locations.
28
+
29
+ ## Top level
30
+
31
+ | Field | Type | Default | Description |
32
+ |---|---|---|---|
33
+ | `version` | number | `1` | Config format version. Only `1` is supported. |
34
+ | `settings` | object | `{}` | Extension-level tunables (below). All optional. |
35
+ | `registrar` | array | `[]` | Library roots. May be empty; the starter seeds the store root. |
36
+
37
+ ## `settings`
38
+
39
+ | Field | Type | Default | Range | Description |
40
+ |---|---|---|---|---|
41
+ | `maxResultBytes` | int | `51200` | 1024–10485760 | Per-invocation skill text output cap, in bytes. |
42
+ | `maxResultLines` | int | `2000` | 1–1000000 | Per-invocation skill text output line cap. |
43
+ | `maxSkills` | int | `200` | 1–1000 | Max skills one root's approved manifest may hold. |
44
+ | `toolNameLimit` | int | `64` | 16–128 | Max length of registered `skill_<root>__<skill>` tool names. |
45
+ | `maxSkillBytes` | int | `131072` | 1024–1048576 | Max size of one skill instruction file. |
46
+ | `maxScanFiles` | int | `4096` | 10–100000 | Max filesystem entries one scan may visit. |
47
+
48
+ Invalid values fall back to their defaults and are reported as settings issues
49
+ (they are tunables, not security boundaries — the hard bounds above always
50
+ apply regardless of what is configured).
51
+
52
+ ## `registrar[]`
53
+
54
+ Every entry requires `name`, `enabled`, `transport`, and `path`.
55
+
56
+ | Field | Type | Required | Description |
57
+ |---|---|---|---|
58
+ | `name` | string | yes | 1–48 chars: lowercase letters, numbers, single hyphens. Must be unique. |
59
+ | `enabled` | boolean | yes | Whether this root's approved skills may be invoked. |
60
+ | `transport` | `"skills-dir"` \| `"single-skill"` | yes | `skills-dir` scans recursively for skill directories (and root-level `.md` skill files); `single-skill` expects exactly one `SKILL.md` at the root. |
61
+ | `path` | string | yes | The directory to scan. `~`, `${workspace}`, and `${home}` are expanded; `${...}` variables resolve from the launching environment and fail closed when missing. Must be a regular directory (symlinked roots are rejected). |
62
+ | `store` | boolean | — | Marks the built-in store root. At most one allowed; its path is pinned to `~/.pi/agent/managed-skills`. Set automatically by the starter. |
63
+ | `native` | boolean | — | Marks a native pi discovery location (`~/.pi/agent/skills`, `~/.agents/skills`). Set automatically whenever the path matches. **Auto-adoption:** native locations that exist on disk but are not in the registrar are seeded automatically on load — disabled, unapproved, flagged "Auto-detected" in the tree; *Scan or refresh* adopts them. Persisted to the file only once a save blesses them. Native exposure is mirrored, not gated (see README). |
64
+ | `connection` | `"lazy"` | — | Only `lazy` is supported; may be omitted. |
65
+ | `skills` | object | — | `{ "mode": "all" \| "selected", "include": [names] }`. `include` must be empty when mode is `all`. |
66
+ | `manifest` | object | — | Approved skill manifest (below). Written by *Scan or refresh* and the add/edit flows; never write it by hand. |
67
+
68
+ ### `manifest`
69
+
70
+ The approved snapshot of a root's scan:
71
+
72
+ | Field | Type | Description |
73
+ |---|---|---|
74
+ | `version` | number | `1`. |
75
+ | `fingerprint` | string | SHA-256 over the root identity (`name`, `transport`, `path`). Changing any of those invalidates the manifest — this is what binds an approval to an exact directory. |
76
+ | `scannedAt` | string | ISO timestamp of the inspection. |
77
+ | `skills` | array | Approved skill snapshots (below). |
78
+ | `rejected` | array | Skills found but rejected, with `dirPath` and `reason` — kept for TUI visibility. |
79
+
80
+ ### `manifest.skills[]`
81
+
82
+ | Field | Type | Description |
83
+ |---|---|---|
84
+ | `name` | string | Frontmatter name. 1–64 chars, lowercase letters, numbers, single hyphens (Agent Skills standard). Unique within the root. |
85
+ | `description` | string | Frontmatter description. Required, ≤ 1024 chars, sanitized. |
86
+ | `dirPath` | string | Skill directory relative to the root (`.` for single-skill roots and root-level `.md` files). No `..`, no absolute paths. |
87
+ | `file` | string | Instruction file name inside the skill directory. Defaults to `SKILL.md`; set for root-level `.md` skill files. |
88
+ | `contentHash` | string | SHA-256 of the instruction file's bytes. **The drift guarantee:** every invocation re-hashes the bytes it delivers against this. |
89
+ | `fileCount` | number | Files in the skill directory (informational). |
90
+ | `executableScripts` | array | Shebang files inside the skill directory — flagged because the model may be instructed to execute them. |
91
+ | `hidden` | boolean | Frontmatter `disable-model-invocation: true`: no tool, only the `skill:<name>` command. |
92
+
93
+ ## Importing skills
94
+
95
+ `/skills-manager` → *Add a skill to the store* offers three methods:
96
+
97
+ - **Write in the editor** — guided name/group/description, scaffolded SKILL.md.
98
+ - **Import an .md file** — copied into `store/[group/]<name>/SKILL.md`
99
+ (the original is untouched); frontmatter is derived or prompted.
100
+ - **Import a directory** — the whole directory is copied wholesale. If it has
101
+ no `SKILL.md` at its root, the TUI prints its tree (all depths up to 4)
102
+ and asks you to type the full path of the intended `SKILL.md`.
103
+
104
+ Copies enforce the same hygiene as scans: symlinks are skipped and
105
+ reported, per-file size and total file-count bounds apply, destination
106
+ collisions are refused. Writing through the manager is an approval act —
107
+ the snapshot is refreshed automatically. Hand edits outside the manager
108
+ change the content hash and fail closed at the next invocation until you
109
+ *Scan or refresh*.
110
+
111
+ ## Migrating
112
+
113
+ On-disk state is the one config file plus the plain-file store. To reset
114
+ everything: delete the config file (a starter with an empty store root is
115
+ recreated on the next run) and, if you like, the `managed-skills`
116
+ directory. Skills are ordinary Markdown directories — they can be moved,
117
+ copied, and version-controlled freely; re-scan after moving a root.
@@ -0,0 +1,93 @@
1
+ # The scan surface — what is read from disk
2
+
3
+ The scanner is the filesystem equivalent of a protocol client: a small,
4
+ auditable, fail-closed subset of what *could* be read. This page documents
5
+ exactly what crosses the "wire".
6
+
7
+ ## Discovery rules
8
+
9
+ - A root path must be a **regular directory** — symlinked roots are
10
+ rejected, and symlinks inside the tree are skipped with a warning, never
11
+ followed.
12
+ - `skills-dir` transport: any directory containing `SKILL.md` is a skill;
13
+ group directories (no `SKILL.md`) are recursed into, up to depth 8.
14
+ Directories that are skills are **not** recursed further.
15
+ - Root-level `*.md` files with valid skill frontmatter are skills too
16
+ (mirroring pi's own native discovery of `~/.pi/agent/skills`); `.md`
17
+ files without skill frontmatter are ignored silently.
18
+ - `single-skill` transport: exactly one `SKILL.md` at the root.
19
+ - One scan may visit at most `settings.maxScanFiles` filesystem entries and
20
+ hold at most `settings.maxSkills` skills — both fail closed with
21
+ readable errors.
22
+
23
+ ## Frontmatter subset
24
+
25
+ A deliberately minimal YAML parser: `---` fences around `key: value`
26
+ lines. Supported: quoted values, `#` comments, unknown keys (ignored).
27
+ **Not** supported — they reject the skill with a readable reason:
28
+ block scalars (`|`, `>`), nested structures, multi-line values, empty
29
+ values. Required keys: `name` (1–64 chars, lowercase letters, numbers,
30
+ single hyphens) and `description` (non-empty, ≤ 1024 chars).
31
+ `disable-model-invocation: true` hides the skill from the model.
32
+
33
+ ## What is recorded per skill
34
+
35
+ - `name`, `description` (sanitized of control characters),
36
+ - `dirPath` (+ `file` for root-level `.md` skills),
37
+ - `contentHash` — SHA-256 of the instruction file's bytes **at scan time**,
38
+ - `fileCount` — the number of files in the skill directory,
39
+ - `executableScripts` — files starting with a shebang (`#!`), flagged
40
+ because the model may be instructed to execute them,
41
+ - `hidden` — from `disable-model-invocation`.
42
+
43
+ Duplicate skill names within a root: the first wins, later ones are
44
+ rejected with a reason (kept in the manifest's `rejected` list so the tree
45
+ shows exactly where the manager's strictness disagrees with pi's leniency).
46
+
47
+ ## What is never done
48
+
49
+ - No file is ever **executed**, parsed as code, or written during a scan —
50
+ scans only read.
51
+ - Nothing outside the root path is read.
52
+ - The manager never writes into roots that are not the store; external and
53
+ native roots are read-only to it.
54
+ - Store skills are written only through the add/edit flows (atomic
55
+ temp-file writes), and removals are refused outside `managed-skills/`.
56
+
57
+ ## Trust model
58
+
59
+ Skill text is **untrusted display data**: sanitized, size-bounded
60
+ (`settings.maxResultBytes` / `maxResultLines`), and delivered with a
61
+ content hash comparison on every invocation. The security boundary is your
62
+ inspection during *Scan or refresh* (or your authorship through the
63
+ add/edit flows), bound two ways:
64
+
65
+ - the **manifest fingerprint** binds the approval to the exact root
66
+ (`name`, `transport`, `path`) — move or rename the root and the approval
67
+ is void;
68
+ - the **content hash** binds it to the exact `SKILL.md` bytes — edit the
69
+ file outside the manager and every invocation fails closed until you
70
+ refresh.
71
+
72
+ What is *not* bound: helper scripts and reference files are inventoried
73
+ (and shebang files flagged) at scan time but not hash-checked. They may
74
+ change after approval, exactly as an MCP server's code may change while
75
+ only its schemas are bound. Re-scan before trusting; review executable
76
+ scripts; use a container or VM when you need real isolation.
77
+
78
+ ## Native roots, precisely
79
+
80
+ For roots marked `native` (pi's `~/.pi/agent/skills`, `~/.agents/skills`):
81
+
82
+ - While pi's native discovery is **active**, every skill the scan approves
83
+ whose instruction file appears in the session's loaded-skills list is
84
+ exposed as a **mirror**: registered through the manager's drift-checked
85
+ surfaces (tool + shadowed `skill:<name>` command), shown red and locked
86
+ in the tree. The manager cannot revoke pi's own listing — that requires
87
+ removing the files or launching pi with `--no-skills`.
88
+ - In **library-only mode** (`pi --no-skills`), nothing is natively
89
+ exposed, the mirror rule finds nothing, and the standard gate
90
+ (approved + selected + enabled) applies in full.
91
+ - Skills pi is exposing that the manager rejected or has not yet scanned
92
+ are shown in the tree as unapproved-but-live (red), so the library view
93
+ is always the complete truth of what the model can see.
package/index.ts ADDED
@@ -0,0 +1,419 @@
1
+ /**
2
+ * simple-skills-manager — a pi extension for exposing reviewed skills to
3
+ * the model and to the user.
4
+ *
5
+ * Everything lives in ONE config file, ~/.pi/agent/simple-skills-manager.json:
6
+ * extension settings, every library root (the registrar), and each root's
7
+ * approved skill manifest. Skills themselves are plain files: the built-in
8
+ * store accumulates them at ~/.pi/agent/managed-skills, external roots
9
+ * point at directories you already have, and native pi locations
10
+ * (~/.pi/agent/skills, ~/.agents/skills) are adopted as mirrors of the
11
+ * exposure pi itself already creates. The file is created with commented
12
+ * defaults on first run, is fully manageable from the /skills-manager TUI
13
+ * (see menu.ts), and may also be edited by hand (JSONC comments allowed;
14
+ * TUI saves strip them).
15
+ *
16
+ * Three decisions are kept separate on purpose:
17
+ * 1. Configuration — where a root is and how the library is scanned.
18
+ * 2. Manifest approval — which skill names, descriptions, and content
19
+ * hashes a user inspected, fingerprinted to the exact root.
20
+ * 3. Invocation — whether the model may load a particular skill now.
21
+ *
22
+ * Loading is lazy: a root's directory is never scanned merely because pi
23
+ * starts. The first approved invocation opens the scan.
24
+ *
25
+ * Exposure surfaces, per approved skill:
26
+ * - a namespaced tool `skill_<root>__<skill>` the model calls to load
27
+ * the instructions (bounded, sanitized, drift-checked on delivery);
28
+ * - a `skill:<skill>` command the user calls, shadowing pi's native
29
+ * skill-command namespace (extension commands dispatch first), which
30
+ * loads through the same drift-checked path and injects the content
31
+ * as a user message, exactly like native /skill:name.
32
+ *
33
+ * This module is the core: output bounding, tool naming, the scan cache,
34
+ * the invocation loader, tool and command registration, and the extension
35
+ * entry point. The interactive library management lives in menu.ts.
36
+ */
37
+
38
+ import { createHash } from "node:crypto";
39
+ import { lstat, readFile } from "node:fs/promises";
40
+ import { join } from "node:path";
41
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
42
+ import { Type } from "typebox";
43
+ import {
44
+ CONFIG_PATH,
45
+ DEFAULT_SETTINGS,
46
+ STORE_DIR,
47
+ computeExposedSkills,
48
+ fingerprint,
49
+ loadManagerConfig,
50
+ resolvedRootPath,
51
+ skillGroup,
52
+ type ManagedRoot,
53
+ type ManagerConfig,
54
+ type ManagerSettings,
55
+ type RootConfig,
56
+ type SkillSnapshot,
57
+ } from "./config.ts";
58
+ import { scanRoot } from "./scan.ts";
59
+ import { runSkillsManagerMenu } from "./menu.ts";
60
+ import { errText, sanitizeExternalText } from "./util.ts";
61
+
62
+ // ─── Output bounding (settings-driven) ──────────────────────────────────────
63
+
64
+ function truncateText(input: string, settings: ManagerSettings): string {
65
+ const lines = sanitizeExternalText(input).split("\n");
66
+ let output = lines.slice(0, settings.maxResultLines).join("\n");
67
+ const marker = "\n\n[Skill content truncated; omitted content was not persisted.]";
68
+ let truncated = lines.length > settings.maxResultLines;
69
+ // The marker must land inside the byte budget too, so the delivered text
70
+ // never exceeds maxResultBytes even when truncated.
71
+ while (output.length > 0 && Buffer.byteLength(output) + marker.length > settings.maxResultBytes) {
72
+ output = output.slice(0, Math.floor(output.length * 0.9));
73
+ truncated = true;
74
+ }
75
+ // Do not leave a split UTF-16 surrogate pair at the cut.
76
+ output = output.replace(/[\ud800-\udbff]$/, "");
77
+ return truncated ? `${output}${marker}` : output;
78
+ }
79
+
80
+ /** Assemble the bounded text a skill invocation delivers: the skill's
81
+ * absolute directory (so relative script and reference paths resolve),
82
+ * the sanitized instructions, and an optional user request. */
83
+ export function skillLoadText(name: string, rootName: string, skillDir: string, content: string, request: string | undefined, settings: ManagerSettings): string {
84
+ const header = `Skill '${name}' from the '${rootName}' root of the skills library.\nSkill directory: ${skillDir}\n\n`;
85
+ const tail = request
86
+ ? `\n\nUser: ${sanitizeExternalText(request).slice(0, 4_096)}`
87
+ : `\n\n[Skill instructions end here. Use relative paths from the skill directory.]`;
88
+ return truncateText(`${header}${sanitizeExternalText(content)}${tail}`, settings);
89
+ }
90
+
91
+ // ─── Tool naming ────────────────────────────────────────────────────────────
92
+
93
+ function skillSlug(value: string): string {
94
+ // Skill names are already lowercase a-z0-9 with single hyphens (validated
95
+ // at approval), so hyphens are preserved — the tool name mirrors the
96
+ // skill name. Anything else collapses to underscores.
97
+ return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^[\-_]+|[\-_]+$/g, "") || "skill";
98
+ }
99
+
100
+ /**
101
+ * Build the Pi tool name for a skill: `skill_<root>__<skill>`.
102
+ * The skill_ prefix, normalized root name, and normalized skill name prevent
103
+ * a skill called "read" from replacing Pi's built-in tools. The result never
104
+ * exceeds settings.toolNameLimit; truncation is disambiguated with a hash
105
+ * suffix whenever the remaining budget allows one.
106
+ */
107
+ export function piSkillToolName(root: string, skill: string, nameLimit: number = DEFAULT_SETTINGS.toolNameLimit): string {
108
+ const hash = (seed: string, length: number): string => createHash("sha256").update(seed).digest("hex").slice(0, length);
109
+ /** Fit `part` into `budget`, suffixing a hash of `seed` for disambiguation
110
+ * when the budget has room for one. */
111
+ const fit = (part: string, budget: number, seed: string, hashLength: number): string => {
112
+ if (part.length <= budget) return part;
113
+ if (budget <= hashLength) return part.slice(0, budget);
114
+ return `${part.slice(0, budget - hashLength - 1)}_${hash(seed, hashLength)}`;
115
+ };
116
+ const rootPart = fit(skillSlug(root), Math.max(4, Math.floor(nameLimit / 3)), root, 6);
117
+ const prefix = `skill_${rootPart}__`;
118
+ const suffix = fit(skillSlug(skill), Math.max(1, nameLimit - prefix.length), skill, 8);
119
+ return `${prefix}${suffix}`;
120
+ }
121
+
122
+ // ─── Scan cache ────────────────────────────────────────────────────────────
123
+
124
+ type LiveSkill = { dirPath: string; file?: string; hidden: boolean };
125
+ type ScanEntry = { rootPath: string; liveSkills: Map<string, LiveSkill> };
126
+
127
+ /** Scan cache: one lazy directory scan per (root, fingerprint), reused
128
+ * across invocations, evicted when a read fails, cleared on shutdown.
129
+ * Exported so the exact production cache/invocation path is testable. */
130
+ export function createScanCache(settings: ManagerSettings = DEFAULT_SETTINGS) {
131
+ const scans = new Map<string, Promise<ScanEntry>>();
132
+
133
+ function cacheKey(root: RootConfig): string {
134
+ return `${root.name}:${fingerprint(root)}`;
135
+ }
136
+
137
+ function evictScan(root: RootConfig): void {
138
+ scans.delete(cacheKey(root));
139
+ }
140
+
141
+ async function getScan(root: RootConfig, workspace: string, signal?: AbortSignal): Promise<ScanEntry> {
142
+ const key = cacheKey(root);
143
+ let pending = scans.get(key);
144
+ if (!pending) {
145
+ pending = (async () => {
146
+ const outcome = await scanRoot(root, workspace, settings, signal);
147
+ return {
148
+ rootPath: resolvedRootPath(root, workspace),
149
+ liveSkills: new Map(outcome.skills.map((skill) => [skill.name, {
150
+ dirPath: skill.dirPath,
151
+ file: skill.file,
152
+ hidden: skill.hidden === true,
153
+ } as LiveSkill])),
154
+ };
155
+ })();
156
+ scans.set(key, pending);
157
+ pending.catch(() => scans.delete(key));
158
+ }
159
+ return pending;
160
+ }
161
+
162
+ async function shutdown(): Promise<void> {
163
+ scans.clear();
164
+ }
165
+
166
+ return { getScan, evictScan, shutdown };
167
+ }
168
+
169
+ // ─── Invocation loader ──────────────────────────────────────────────────────
170
+
171
+ /** Load one approved skill through the production path: lazily scan the
172
+ * root (cached per session), then re-hash the bytes actually read against
173
+ * the approved snapshot — drift fails closed, and the drift check runs on
174
+ * the content being delivered, not on a stale copy. Exported for tests. */
175
+ export async function loadSkillForInvocation(
176
+ root: RootConfig,
177
+ snapshot: SkillSnapshot,
178
+ settings: ManagerSettings,
179
+ getScan: (root: RootConfig, workspace: string, signal?: AbortSignal) => Promise<ScanEntry>,
180
+ evictScan: (root: RootConfig) => void,
181
+ workspace: string,
182
+ signal: AbortSignal | undefined,
183
+ notify: (message: string, level: "info" | "warning" | "error") => void,
184
+ request: string | undefined,
185
+ ): Promise<{ text: string; skillDir: string; skillPath: string }> {
186
+ const scan = await getScan(root, workspace, signal);
187
+ const live = scan.liveSkills.get(snapshot.name);
188
+ if (!live || live.hidden !== (snapshot.hidden === true)) {
189
+ // The directory changed since the scan; do not let the stale scan
190
+ // poison the cache — let the next call re-scan.
191
+ evictScan(root);
192
+ notify(`${root.name}/${snapshot.name} changed since approval. Refresh it through /skills-manager before use.`, "warning");
193
+ throw new Error("Skill manifest drift detected; refresh and approve the root before retrying.");
194
+ }
195
+ const skillPath = join(scan.rootPath, live.dirPath === "." ? "" : live.dirPath, live.file ?? "SKILL.md");
196
+ const skillDir = join(scan.rootPath, live.dirPath === "." ? "" : live.dirPath);
197
+ try {
198
+ const info = await lstat(skillPath);
199
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("The skill's instruction file must be a regular file, not a symbolic link.");
200
+ if (info.size > settings.maxSkillBytes) throw new Error(`The skill's instruction file exceeds settings.maxSkillBytes (${settings.maxSkillBytes} bytes).`);
201
+ const bytes = await readFile(skillPath);
202
+ const liveHash = createHash("sha256").update(bytes).digest("hex");
203
+ if (liveHash !== snapshot.contentHash) {
204
+ notify(`${root.name}/${snapshot.name} changed since approval. Refresh it through /skills-manager before use.`, "warning");
205
+ throw new Error("Skill content drift detected; refresh and approve the root before retrying.");
206
+ }
207
+ const text = skillLoadText(snapshot.name, root.name, skillDir, bytes.toString("utf8"), request, settings);
208
+ return { text, skillDir, skillPath };
209
+ } catch (error) {
210
+ // A changed directory must not poison the cache; let the next call re-scan.
211
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") evictScan(root);
212
+ throw error;
213
+ }
214
+ }
215
+
216
+ export type ManagedSkillExecutor = {
217
+ name: string;
218
+ execute: (
219
+ toolCallId: string,
220
+ params: Record<string, unknown>,
221
+ signal: AbortSignal | undefined,
222
+ onUpdate: ((partial: AgentToolResult<unknown>) => void) | undefined,
223
+ ctx: ExtensionContext,
224
+ ) => Promise<AgentToolResult<unknown>>;
225
+ };
226
+
227
+ /** Build the execute wrapper for one approved skill. Exported for tests. */
228
+ export function buildManagedSkillExecutor(
229
+ root: RootConfig,
230
+ snapshot: SkillSnapshot,
231
+ settings: ManagerSettings,
232
+ getScan: (root: RootConfig, workspace: string, signal?: AbortSignal) => Promise<ScanEntry>,
233
+ evictScan: (root: RootConfig) => void,
234
+ ): ManagedSkillExecutor {
235
+ return {
236
+ name: piSkillToolName(root.name, snapshot.name, settings.toolNameLimit),
237
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
238
+ onUpdate?.({ content: [{ type: "text", text: `Scanning library root ${root.name}...` }], details: {} });
239
+ const { text, skillDir, skillPath } = await loadSkillForInvocation(
240
+ root, snapshot, settings, getScan, evictScan,
241
+ ctx.cwd, signal, ctx.ui.notify,
242
+ typeof params.request === "string" && params.request.trim() ? params.request.trim() : undefined,
243
+ );
244
+ onUpdate?.({ content: [{ type: "text", text: `Loading skill ${root.name}/${snapshot.name}...` }], details: {} });
245
+ return {
246
+ content: [{ type: "text", text }],
247
+ details: { root: root.name, skill: snapshot.name, skillDir, skillPath },
248
+ };
249
+ },
250
+ };
251
+ }
252
+
253
+ // ─── Registration ──────────────────────────────────────────────────────────
254
+
255
+ function registerManagedSkill(
256
+ pi: ExtensionAPI,
257
+ root: RootConfig,
258
+ snapshot: SkillSnapshot,
259
+ settings: ManagerSettings,
260
+ guidelines: string[] | undefined,
261
+ getScan: (root: RootConfig, workspace: string, signal?: AbortSignal) => Promise<ScanEntry>,
262
+ evictScan: (root: RootConfig) => void,
263
+ ): void {
264
+ const group = skillGroup(snapshot.dirPath);
265
+ // The tool: how the model loads the skill. Hidden skills stay command-only,
266
+ // mirroring pi's native disable-model-invocation semantics.
267
+ if (!snapshot.hidden) {
268
+ const executor = buildManagedSkillExecutor(root, snapshot, settings, getScan, evictScan);
269
+ pi.registerTool({
270
+ name: executor.name,
271
+ label: `${root.name}: ${snapshot.name}`,
272
+ description: `[Skill library ${root.name}] ${(snapshot.description).slice(0, 8_192)}`,
273
+ promptSnippet: `[skill] library/${root.name}/${group}/${snapshot.name} — ${snapshot.description.replace(/\s+/g, " ").slice(0, 100)}`,
274
+ promptGuidelines: guidelines,
275
+ parameters: Type.Unsafe<{ request?: string }>({
276
+ type: "object",
277
+ properties: {
278
+ request: { type: "string", description: "Optional user request, appended to the skill instructions" },
279
+ },
280
+ additionalProperties: false,
281
+ }),
282
+ execute: executor.execute,
283
+ });
284
+ }
285
+ // The command: how the user loads the skill. Extension commands dispatch
286
+ // before pi's native skill expansion, so `skill:<name>` is ours.
287
+ pi.registerCommand(`skill:${snapshot.name}`, {
288
+ description: `${group}/${snapshot.name} — ${snapshot.description.replace(/\s+/g, " ").slice(0, 120)}`,
289
+ handler: async (args, ctx) => {
290
+ try {
291
+ const { text } = await loadSkillForInvocation(
292
+ root, snapshot, settings, getScan, evictScan,
293
+ ctx.cwd, undefined, ctx.ui.notify,
294
+ args?.trim() ? args.trim() : undefined,
295
+ );
296
+ pi.sendUserMessage(text, { deliverAs: "steer" });
297
+ } catch (error) {
298
+ ctx.ui.notify(`skill:${snapshot.name}: ${errText(error)}`, "error");
299
+ }
300
+ },
301
+ });
302
+ }
303
+
304
+ // ─── Extension entry point ─────────────────────────────────────────────────
305
+
306
+ export default function skillsManagerExtension(pi: ExtensionAPI): void {
307
+ const registeredToolNames = new Map<string, string>();
308
+ const registeredCommandNames = new Map<string, string>();
309
+ let cache: ReturnType<typeof createScanCache> | undefined;
310
+ let config: ManagerConfig | undefined;
311
+ let nativeRegistered = false;
312
+
313
+ function registerRoots(roots: ManagedRoot[], nativeActivePaths: Set<string>, ctx: ExtensionContext): void {
314
+ if (!cache || !config) return;
315
+ const usableRoots = config.roots.filter((root) => root.normalized && !root.issues.length && root.config.enabled && root.config.manifest);
316
+ const registerable = roots.filter((root) => root.normalized && !root.issues.length && root.config.enabled && root.config.manifest);
317
+ // Harness-level self-awareness: these lines are merged into the
318
+ // Guidelines section of every chat's system prompt, so even a virgin
319
+ // chat knows where its skill machinery lives. They are attached to
320
+ // the FIRST registered skill tool only — pi appends promptGuidelines
321
+ // flat per tool, so repeating them on every tool would duplicate the
322
+ // lines once per exposed skill.
323
+ const awareness = [
324
+ "Managed skills: all library roots and approved skill manifests live in ~/.pi/agent/simple-skills-manager.json; manage them interactively with the /skills-manager command (add, import, scan, approve, expose, edit, remove).",
325
+ `Skills currently exposed: ${
326
+ usableRoots.map((root) => {
327
+ const names = computeExposedSkills(root.config, ctx.cwd, nativeActivePaths).map((skill) => skill.name);
328
+ const list = names.length
329
+ ? names.slice(0, 60).join(", ") + (names.length > 60 ? ", …" : "")
330
+ : "no skills exposed";
331
+ return `${root.config.name} (${list})`;
332
+ }).join("; ")
333
+ || "(none configured — add one via /skills-manager)"
334
+ }. Hidden skills load via skill:<name> commands only.`,
335
+ ];
336
+ let awarenessAttached = false;
337
+ for (const root of registerable) {
338
+ for (const skill of computeExposedSkills(root.config, ctx.cwd, nativeActivePaths)) {
339
+ const toolName = piSkillToolName(root.config.name, skill.name, config.settings.toolNameLimit);
340
+ const owner = `${root.config.name}/${skill.name}`;
341
+ const priorTool = registeredToolNames.get(toolName);
342
+ if (priorTool && priorTool !== owner) {
343
+ ctx.ui.notify(`Skill tool name collision: ${priorTool} and ${owner}; ${owner} was not registered as a tool.`, "error");
344
+ continue;
345
+ }
346
+ const priorCommand = registeredCommandNames.get(`skill:${skill.name}`);
347
+ if (priorCommand && priorCommand !== owner) {
348
+ ctx.ui.notify(`Skill command collision: skill:${skill.name} already serves ${priorCommand}; ${owner} was not registered as a command.`, "error");
349
+ continue;
350
+ }
351
+ registeredToolNames.set(toolName, owner);
352
+ registeredCommandNames.set(`skill:${skill.name}`, owner);
353
+ const group = skillGroup(skill.dirPath);
354
+ // Per-tool guidelines always name their tool (pi renders them
355
+ // flat, with no tool prefix); the shared awareness lines ride
356
+ // along only on the first registered tool.
357
+ const perTool = [
358
+ `Use ${toolName} to load the ${root.config.name}/${group}/${skill.name} skill instructions when that skill is relevant to the task.`,
359
+ ];
360
+ // Hidden skills register a command only; the shared awareness
361
+ // lines must ride on the first *tool*.
362
+ const guidelines = skill.hidden ? [] : awarenessAttached ? perTool : [...awareness, ...perTool];
363
+ if (!skill.hidden) awarenessAttached = true;
364
+ registerManagedSkill(pi, root.config, skill, config.settings, guidelines, cache.getScan, cache.evictScan);
365
+ }
366
+ }
367
+ }
368
+
369
+ pi.on("session_start", async (_event, ctx) => {
370
+ try {
371
+ config = await loadManagerConfig();
372
+ } catch (error) {
373
+ ctx.ui.notify(`simple-skills-manager: ${errText(error)}`, "error");
374
+ return;
375
+ }
376
+ cache = createScanCache(config.settings);
377
+ nativeRegistered = false;
378
+ if (config.created) {
379
+ ctx.ui.notify(
380
+ `simple-skills-manager: created a starter config at ${CONFIG_PATH} and a skill store at ${STORE_DIR}. Run /skills-manager to add your first skill.`,
381
+ "info",
382
+ );
383
+ }
384
+ if (config.settingsIssues.length) {
385
+ ctx.ui.notify(`simple-skills-manager settings issue(s): ${config.settingsIssues[0]}`, "warning");
386
+ }
387
+ // Standard roots register immediately; native roots wait for the
388
+ // first before_agent_start, where pi's own loaded-skills list is
389
+ // available for live mirror detection.
390
+ registerRoots((config?.roots ?? []).filter((root) => !root.config.native), new Set(), ctx);
391
+ });
392
+
393
+ pi.on("before_agent_start", async (event, ctx) => {
394
+ if (nativeRegistered || !config || !cache) return;
395
+ nativeRegistered = true;
396
+ const nativeRoots = config.roots.filter((root) => root.config.native);
397
+ if (!nativeRoots.length) return;
398
+ // Live native-exposure detection: whatever pi's own discovery loaded
399
+ // into this session is mirrored (never gated) for native roots.
400
+ const nativeActivePaths = new Set<string>(
401
+ ((event.systemPromptOptions?.skills ?? []) as { filePath?: string }[]).map((skill) => skill.filePath ?? ""),
402
+ );
403
+ registerRoots(nativeRoots, nativeActivePaths, ctx);
404
+ });
405
+
406
+ pi.on("session_shutdown", async () => {
407
+ await cache?.shutdown();
408
+ cache = undefined;
409
+ config = undefined;
410
+ });
411
+
412
+ pi.registerCommand("skills-manager", {
413
+ description: "Browse, add, import, scan, approve, expose, edit, or remove library skills and roots, or edit extension settings",
414
+ handler: async (_args, ctx) => {
415
+ if (ctx.mode !== "tui") return void ctx.ui.notify("/skills-manager requires TUI mode", "error");
416
+ await runSkillsManagerMenu(ctx);
417
+ },
418
+ });
419
+ }