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/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
|
+
}
|