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/tests/smoke.sh ADDED
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env bash
2
+ # simple-skills-manager smoke test.
3
+ #
4
+ # Exercises, with no external model and no untrusted network calls:
5
+ # 1. both transports end-to-end: discovery, grouping, script flagging,
6
+ # hidden skills, strict rejections, symlink fail-closed; frontmatter
7
+ # subset; validation invariants; config round-trips (starter with the
8
+ # seeded store root, upsert/remove, settings fallback, loud failure on
9
+ # a corrupt file); the registered-tool execute path (lazy scan at first
10
+ # call, success, scan reuse, fail-closed content drift); scan budget
11
+ # and maxSkills enforcement; native mirror computation
12
+ # 2. managed discovery through Pi's normal extension loading, from the
13
+ # single config file at $PI_CODING_AGENT_DIR/simple-skills-manager.json:
14
+ # tool + command registration, per-entry isolation, native-tool
15
+ # preservation, and lazy registration (a tool registers while its root
16
+ # directory does not exist)
17
+ # 3. first run: a fresh agent dir gets a starter config (seeded with the
18
+ # store root) and the store directory auto-created, and registers no
19
+ # skill tools
20
+ set -euo pipefail
21
+
22
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
23
+ extension_root="$(cd "$here/.." && pwd -P)"
24
+ pi_bin="${PI_BIN:-$(command -v pi)}"
25
+ node_bin="${NODE_BIN:-$(command -v node)}"
26
+ [[ -n "$pi_bin" ]] || { echo "pi binary not found" >&2; exit 1; }
27
+ [[ -n "$node_bin" ]] || { echo "node binary not found" >&2; exit 1; }
28
+
29
+ temp="$(mktemp -d)"
30
+ cleanup() { rm -rf "$temp"; }
31
+ trap cleanup EXIT
32
+
33
+ wait_for_file() {
34
+ local file="$1" label="$2"
35
+ for _ in $(seq 1 300); do
36
+ [[ -s "$file" ]] && return 0
37
+ sleep 0.1
38
+ done
39
+ echo "Timed out waiting for $label" >&2
40
+ return 1
41
+ }
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Phase 1: the client/scanner bridge, driven by an extension via -e.
45
+ # ---------------------------------------------------------------------------
46
+ result="$temp/client-result.json"
47
+ ( cd "$temp" && printf '%s\n' '{"type":"get_commands"}' && sleep 4 ) | env \
48
+ PI_CODING_AGENT_DIR="$temp/client-agent" \
49
+ PI_SKILL_FIXTURES="$here/fixtures" \
50
+ PI_SKILL_RESULT="$result" \
51
+ "$pi_bin" -e "$here/smoke-extension.ts" --mode rpc --session "$temp/client-session.jsonl" \
52
+ >"$temp/client-rpc.out" 2>"$temp/client-rpc.err"
53
+ wait_for_file "$result" "client smoke result"
54
+
55
+ "$node_bin" -e '
56
+ const fs = require("fs");
57
+ const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
58
+ const fail = () => { console.error(value); process.exit(1); };
59
+ if (!value.ok) fail();
60
+ for (const key of ["skillsDir", "singleSkill", "strictRejections", "symlinkRoots", "snapshotFile", "copyBudget"]) {
61
+ if (value.scans?.[key] !== true) fail();
62
+ }
63
+ for (const key of ["roundTrip", "storeSeed", "settingsFallback", "loudFailure"]) {
64
+ if (value.roundtrip?.[key] !== true) fail();
65
+ }
66
+ const exec = value.executor;
67
+ if (!exec || exec.lazyRejected !== true || exec.callOk !== true || exec.driftRejected !== true) fail();
68
+ if (value.autoAdoption !== true) fail();
69
+ for (const key of ["frontmatter", "validation", "nameInvariants", "malformedManifestSafety", "cacheMaxSkillsHonored", "nativeMirror", "treeBuilder"]) {
70
+ if (value.regressions?.[key] !== true) fail();
71
+ }
72
+ ' "$result"
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Phase 2: managed discovery through Pi's normal resource path, from the
76
+ # single-file config. A malformed entry must not block a valid one, and a
77
+ # root that does not exist yet must still register its tool (laziness).
78
+ # ---------------------------------------------------------------------------
79
+ agent="$temp/managed-agent"
80
+ mkdir -p "$agent/extensions"
81
+ cp -a "$extension_root" "$agent/extensions/simple-skills-manager"
82
+
83
+ # The store root lives at $agent/managed-skills; copy one fixture skill in.
84
+ mkdir -p "$agent/managed-skills"
85
+ cp -a "$here/fixtures/skills-dir/plain" "$agent/managed-skills/plain"
86
+
87
+ # The manifest fingerprint must equal fingerprint() in config.ts:
88
+ # sha256 over { name, transport, path }.
89
+ fingerprint_store="$("$node_bin" -e '
90
+ const crypto = require("crypto");
91
+ const identity = { name: "store", transport: "skills-dir", path: process.argv[1] };
92
+ process.stdout.write(crypto.createHash("sha256").update(JSON.stringify(identity)).digest("hex"));
93
+ ' "$agent/managed-skills")"
94
+ content_hash="$("$node_bin" -e '
95
+ const crypto = require("crypto");
96
+ const bytes = require("fs").readFileSync(process.argv[1]);
97
+ process.stdout.write(crypto.createHash("sha256").update(bytes).digest("hex"));
98
+ ' "$agent/managed-skills/plain/SKILL.md")"
99
+ ghost_path="$temp/ghost-root"
100
+ ghost_fingerprint="$("$node_bin" -e '
101
+ const crypto = require("crypto");
102
+ const identity = { name: "ghost", transport: "skills-dir", path: process.argv[1] };
103
+ process.stdout.write(crypto.createHash("sha256").update(JSON.stringify(identity)).digest("hex"));
104
+ ' "$ghost_path")"
105
+
106
+ cat >"$agent/simple-skills-manager.json" <<EOF
107
+ {
108
+ "version": 1,
109
+ "settings": {"maxSkills": 50},
110
+ "registrar": [
111
+ {
112
+ "name": "store",
113
+ "store": true,
114
+ "enabled": true,
115
+ "transport": "skills-dir",
116
+ "path": "$agent/managed-skills",
117
+ "connection": "lazy",
118
+ "skills": {"mode": "selected", "include": ["plain-skill"]},
119
+ "manifest": {
120
+ "version": 1,
121
+ "fingerprint": "$fingerprint_store",
122
+ "scannedAt": "fixture",
123
+ "skills": [{
124
+ "name": "plain-skill",
125
+ "description": "A plain fixture skill with no extras. Use when testing skill scanning.",
126
+ "dirPath": "plain",
127
+ "contentHash": "$content_hash",
128
+ "fileCount": 1,
129
+ "executableScripts": []
130
+ }]
131
+ }
132
+ },
133
+ {
134
+ "name": "ghost",
135
+ "enabled": true,
136
+ "transport": "skills-dir",
137
+ "path": "$ghost_path",
138
+ "connection": "lazy",
139
+ "skills": {"mode": "selected", "include": ["phantom"]},
140
+ "manifest": {
141
+ "version": 1,
142
+ "fingerprint": "$ghost_fingerprint",
143
+ "scannedAt": "fixture",
144
+ "skills": [{
145
+ "name": "phantom",
146
+ "description": "A ghost skill whose root directory does not exist yet.",
147
+ "dirPath": "phantom",
148
+ "contentHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
149
+ "fileCount": 1,
150
+ "executableScripts": []
151
+ }]
152
+ }
153
+ },
154
+ {"name": "broken", "enabled": true, "transport": "skills-dir"}
155
+ ]
156
+ }
157
+ EOF
158
+
159
+ # The probe cannot rely on session_start handler ordering across extensions,
160
+ # so it polls in the background instead of blocking startup.
161
+ cat >"$agent/extensions/zz-skills-smoke-probe.ts" <<'EOF'
162
+ import { writeFile } from "node:fs/promises";
163
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
164
+
165
+ export default function probe(pi: ExtensionAPI): void {
166
+ pi.on("session_start", async () => {
167
+ const output = process.env.PI_SKILL_TOOL_RESULT;
168
+ if (!output) return;
169
+ const deadline = Date.now() + 10_000;
170
+ void (async () => {
171
+ while (Date.now() < deadline) {
172
+ const names = pi.getAllTools().map((tool) => tool.name);
173
+ if (names.includes("skill_store__plain-skill") && names.includes("skill_ghost__phantom")) {
174
+ const commands = pi.getCommands().map((command) => ({ name: command.name, source: command.source }));
175
+ await writeFile(output, JSON.stringify({ tools: names, commands }));
176
+ return;
177
+ }
178
+ await new Promise((resolve) => setTimeout(resolve, 50));
179
+ }
180
+ const commands = pi.getCommands().map((command) => ({ name: command.name, source: command.source }));
181
+ await writeFile(output, JSON.stringify({ tools: pi.getAllTools().map((tool) => tool.name), commands }));
182
+ })();
183
+ });
184
+ }
185
+ EOF
186
+
187
+ tools_result="$temp/tools-result.json"
188
+ ( cd "$temp" && printf '%s\n' '{"type":"get_commands"}' && sleep 3 ) | env \
189
+ PI_CODING_AGENT_DIR="$agent" \
190
+ PI_SKILL_TOOL_RESULT="$tools_result" \
191
+ "$pi_bin" --mode rpc --session "$temp/managed-session.jsonl" \
192
+ >"$temp/managed-rpc.out" 2>"$temp/managed-rpc.err"
193
+ wait_for_file "$tools_result" "managed tool registration result"
194
+
195
+ "$node_bin" -e '
196
+ const fs = require("fs");
197
+ const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
198
+ const fail = (why) => { console.error(why, value); process.exit(1); };
199
+ for (const name of ["read", "bash", "edit", "write", "grep", "find", "ls", "skill_store__plain-skill", "skill_ghost__phantom"]) {
200
+ if (!value.tools.includes(name)) fail(`missing tool: ${name}`);
201
+ }
202
+ if (value.tools.some((name) => name.startsWith("skill_broken__"))) fail("malformed entry should not register tools");
203
+ // Our skill:<name> commands are registered as extension commands and shadow
204
+ // the native skill namespace (extension commands dispatch first).
205
+ for (const name of ["skill:plain-skill", "skill:phantom", "skills-manager"]) {
206
+ const command = value.commands.find((entry) => entry.name === name);
207
+ if (!command) fail(`missing command: ${name}`);
208
+ if (command.source !== "extension") fail(`command ${name} has source ${command.source}`);
209
+ }
210
+ ' "$tools_result"
211
+
212
+ # Lazy registration: the ghost root directory never existed, yet its tool was
213
+ # registered — proving nothing is scanned merely because pi starts.
214
+ [[ ! -e "$ghost_path" ]] || { echo "ghost root unexpectedly exists" >&2; exit 1; }
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Phase 3: first run — a fresh agent dir with no config file must get a
218
+ # starter config (seeded with the store root) and the store directory,
219
+ # register no skill tools, and not crash.
220
+ # ---------------------------------------------------------------------------
221
+ fresh="$temp/fresh-agent"
222
+ mkdir -p "$fresh/extensions"
223
+ cp -a "$extension_root" "$fresh/extensions/simple-skills-manager"
224
+
225
+ ( cd "$temp" && printf '%s\n' '{"type":"get_commands"}' && sleep 3 ) | env \
226
+ PI_CODING_AGENT_DIR="$fresh" \
227
+ "$pi_bin" --mode rpc --session "$temp/fresh-session.jsonl" \
228
+ >"$temp/fresh-rpc.out" 2>"$temp/fresh-rpc.err"
229
+
230
+ [[ -s "$fresh/simple-skills-manager.json" ]] || { echo "first run did not create the starter config" >&2; exit 1; }
231
+ [[ -d "$fresh/managed-skills" ]] || { echo "first run did not create the store directory" >&2; exit 1; }
232
+ "$node_bin" -e '
233
+ const fs = require("fs");
234
+ const raw = fs.readFileSync(process.argv[1], "utf8");
235
+ const parsed = JSON.parse(raw.replace(/(^|\s)\/\/.*$/gm, "$1").replace(/\/\*[\s\S]*?\*\//g, ""));
236
+ if (parsed.version !== 1 || !Array.isArray(parsed.registrar) || parsed.registrar.length !== 1) {
237
+ console.error("starter config has wrong shape", parsed);
238
+ process.exit(1);
239
+ }
240
+ const store = parsed.registrar[0];
241
+ if (store.name !== "store" || store.store !== true || store.enabled !== false || store.transport !== "skills-dir") {
242
+ console.error("starter store root has wrong shape", store);
243
+ process.exit(1);
244
+ }
245
+ ' "$fresh/simple-skills-manager.json"
246
+
247
+ echo "Skills smoke test passed: both transports, strict rejections, config round-trips, fail-closed drift, native mirroring, single-file discovery, per-entry isolation, command shadowing, lazy registration, native-tool preservation, and first-run starter creation."
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "allowImportingTsExtensions": true,
7
+ "noEmit": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "allowSyntheticDefaultImports": true,
11
+ "esModuleInterop": true,
12
+ "types": ["node"]
13
+ },
14
+ "include": ["*.ts", "tests/*.ts"],
15
+ "paths": {
16
+ "@earendil-works/pi-coding-agent": ["/home/msav/.local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.d.ts"],
17
+ "@earendil-works/pi-tui": ["/home/msav/.local/lib/node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui/dist/index.d.ts"],
18
+ "typebox": ["/home/msav/.local/lib/node_modules/@earendil-works/pi-coding-agent/node_modules/typebox/dist/index.d.ts"]
19
+ }
20
+ }
package/util.ts ADDED
@@ -0,0 +1,74 @@
1
+ /** Shared tiny helpers used across the extension's modules. */
2
+
3
+ import { rename, writeFile } from "node:fs/promises";
4
+
5
+ export function isRecord(value: unknown): value is Record<string, unknown> {
6
+ return !!value && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+
9
+ /** Strip JSONC comments — // line comments and block comments — so the
10
+ * config file can be annotated by hand. String-aware: // inside quoted
11
+ * values (URLs like http://...) is preserved. */
12
+ export function stripJsonComments(text: string): string {
13
+ let out = "";
14
+ let inString = false;
15
+ let i = 0;
16
+ while (i < text.length) {
17
+ const c = text[i];
18
+ if (inString) {
19
+ out += c;
20
+ if (c === "\\" && i + 1 < text.length) {
21
+ out += text[i + 1];
22
+ i += 2;
23
+ continue;
24
+ }
25
+ if (c === '"') inString = false;
26
+ i++;
27
+ continue;
28
+ }
29
+ if (c === '"') {
30
+ inString = true;
31
+ out += c;
32
+ i++;
33
+ continue;
34
+ }
35
+ if (c === "/" && text[i + 1] === "/") {
36
+ while (i < text.length && text[i] !== "\n") i++;
37
+ continue;
38
+ }
39
+ if (c === "/" && text[i + 1] === "*") {
40
+ i += 2;
41
+ while (i + 1 < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
42
+ i += 2;
43
+ continue;
44
+ }
45
+ out += c;
46
+ i++;
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /** Remove control characters (except \n and \t) from untrusted external text
52
+ * before it is shown to the user or persisted into tool output. */
53
+ export function sanitizeExternalText(input: string): string {
54
+ return input.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
55
+ }
56
+
57
+ /** A readable message from any thrown value. */
58
+ export function errText(error: unknown): string {
59
+ return error instanceof Error ? error.message : String(error);
60
+ }
61
+
62
+ /** Render validation issues as markdown bullets. */
63
+ export function bulletList(items: string[]): string {
64
+ return items.map((item) => `- ${item}`).join("\n");
65
+ }
66
+
67
+ /** Atomically write a text file (temp file + rename, mode 0600). Used for
68
+ * the config and for skill files edited through the TUI, so every write is
69
+ * a crash-safe replacement. */
70
+ export async function writeFileAtomic(path: string, text: string): Promise<void> {
71
+ const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
72
+ await writeFile(temp, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
73
+ await rename(temp, path);
74
+ }