@firstpick/pi-extension-setup-skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/index.ts +347 -0
  4. package/package.json +26 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Firstpick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # pi-extension-setup-skills
2
+
3
+ Adds `/setup-skills`, an interactive Pi UI for enabling/disabling skills.
4
+
5
+ ## Usage
6
+
7
+ ```text
8
+ /setup-skills
9
+ ```
10
+
11
+ Controls:
12
+
13
+ - `↑` / `↓`: navigate
14
+ - `Enter` / `Space`: toggle selected skill
15
+ - Type: search/filter
16
+ - `Esc` or `q`: cancel
17
+ - `Ctrl+S`: save
18
+
19
+ The command updates Pi settings and prompts for `/reload` after changes.
20
+
21
+ ## What it manages
22
+
23
+ The extension discovers skills from Pi's standard local locations and configured Pi packages:
24
+
25
+ - `~/.pi/agent/skills`
26
+ - `~/.agents/skills`
27
+ - project `.pi/skills`
28
+ - project `.agents/skills`
29
+ - skills exposed by entries in `settings.json` `packages`
30
+
31
+ For local skill selection it writes explicit `skills` filters. For package-bundled skills it preserves the package entry and updates its `skills` filter.
package/index.ts ADDED
@@ -0,0 +1,347 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
+ import { DynamicBorder, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
6
+ import { Container, getKeybindings, Key, matchesKey, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
7
+
8
+ type PackageEntry = string | { source?: string; skills?: string[]; extensions?: string[]; prompts?: string[]; [key: string]: unknown };
9
+ type SettingsShape = { packages?: PackageEntry[]; skills?: string[]; [key: string]: unknown };
10
+
11
+ function getAgentSettingsPath(): string {
12
+ return join(getAgentDir(), "settings.json");
13
+ }
14
+
15
+ type SkillCandidate = {
16
+ name: string;
17
+ description: string;
18
+ skillPath: string;
19
+ enableKind: "settings-skill" | "package" | "package-skill";
20
+ enablePath: string;
21
+ packageSource?: string;
22
+ packageSkillName?: string;
23
+ };
24
+
25
+ function readJson(path: string): SettingsShape {
26
+ if (!existsSync(path)) return {};
27
+ return JSON.parse(readFileSync(path, "utf8")) as SettingsShape;
28
+ }
29
+
30
+ function writeJson(path: string, data: SettingsShape): void {
31
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
32
+ }
33
+
34
+ function walkSkillFiles(root: string): string[] {
35
+ if (!existsSync(root)) return [];
36
+ const out: string[] = [];
37
+ const visit = (dir: string) => {
38
+ for (const entry of readdirSync(dir)) {
39
+ const path = join(dir, entry);
40
+ let st;
41
+ try {
42
+ st = statSync(path);
43
+ } catch {
44
+ continue;
45
+ }
46
+ if (st.isDirectory()) visit(path);
47
+ else if (entry === "SKILL.md") out.push(path);
48
+ }
49
+ };
50
+ visit(root);
51
+ return out;
52
+ }
53
+
54
+ function parseSkill(path: string): { name: string; description: string } | undefined {
55
+ const text = readFileSync(path, "utf8");
56
+ const frontmatter = text.match(/^---\s*\n([\s\S]*?)\n---/);
57
+ if (!frontmatter) return undefined;
58
+ const name = frontmatter[1].match(/^name:\s*(.+)$/m)?.[1]?.trim().replace(/^['"]|['"]$/g, "");
59
+ const description = frontmatter[1].match(/^description:\s*(.+)$/m)?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
60
+ if (!name) return undefined;
61
+ return { name, description };
62
+ }
63
+
64
+ function packageSource(entry: PackageEntry): string | undefined {
65
+ return typeof entry === "string" ? entry : entry.source;
66
+ }
67
+
68
+ function stripNpmVersion(spec: string): string {
69
+ if (spec.startsWith("@")) {
70
+ const at = spec.lastIndexOf("@");
71
+ return at > 0 ? spec.slice(0, at) : spec;
72
+ }
73
+ const at = spec.lastIndexOf("@");
74
+ return at > 0 ? spec.slice(0, at) : spec;
75
+ }
76
+
77
+ function expandTilde(path: string): string {
78
+ if (path === "~") return homedir();
79
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
80
+ return path;
81
+ }
82
+
83
+ function resolveConfiguredPath(path: string, baseDir: string): string {
84
+ const expanded = expandTilde(path);
85
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(baseDir, expanded);
86
+ }
87
+
88
+ function resolvePackageInstallDir(source: string, settingsDir: string): string | undefined {
89
+ if (source.startsWith("npm:")) {
90
+ const name = stripNpmVersion(source.slice(4));
91
+ return join(getAgentDir(), "npm", "node_modules", ...name.split("/"));
92
+ }
93
+ if (source.startsWith("git:") || source.startsWith("http://") || source.startsWith("https://") || source.startsWith("ssh://")) {
94
+ return undefined;
95
+ }
96
+ return resolveConfiguredPath(source, settingsDir);
97
+ }
98
+
99
+ function discoverPackageSkills(packageDir: string, source: string, candidates: Map<string, SkillCandidate>): void {
100
+ if (!existsSync(packageDir)) return;
101
+ for (const skillPath of walkSkillFiles(join(packageDir, "skills"))) {
102
+ const parsed = parseSkill(skillPath);
103
+ if (!parsed) continue;
104
+ candidates.set(parsed.name, {
105
+ ...parsed,
106
+ skillPath,
107
+ enableKind: "package-skill",
108
+ enablePath: packageDir,
109
+ packageSource: source,
110
+ packageSkillName: parsed.name,
111
+ });
112
+ }
113
+ }
114
+
115
+ function addLocalSkills(root: string, candidates: Map<string, SkillCandidate>): void {
116
+ for (const skillPath of walkSkillFiles(root)) {
117
+ const parsed = parseSkill(skillPath);
118
+ if (!parsed) continue;
119
+ candidates.set(parsed.name, {
120
+ ...parsed,
121
+ skillPath,
122
+ enableKind: "settings-skill",
123
+ enablePath: skillPath,
124
+ });
125
+ }
126
+ }
127
+
128
+ function discoverProjectSkillRoots(cwd: string): string[] {
129
+ const roots: string[] = [];
130
+ let current = resolve(cwd);
131
+ while (true) {
132
+ roots.push(join(current, ".pi", "skills"), join(current, ".agents", "skills"));
133
+ if (existsSync(join(current, ".git"))) break;
134
+ const parent = dirname(current);
135
+ if (parent === current) break;
136
+ current = parent;
137
+ }
138
+ return roots;
139
+ }
140
+
141
+ function discoverCandidates(settings: SettingsShape, cwd: string): SkillCandidate[] {
142
+ const candidates = new Map<string, SkillCandidate>();
143
+
144
+ for (const root of [join(getAgentDir(), "skills"), join(homedir(), ".agents", "skills"), ...discoverProjectSkillRoots(cwd)]) {
145
+ addLocalSkills(root, candidates);
146
+ }
147
+
148
+ const settingsDir = dirname(getAgentSettingsPath());
149
+ for (const entry of settings.packages ?? []) {
150
+ const source = packageSource(entry);
151
+ if (!source) continue;
152
+ const packageDir = resolvePackageInstallDir(source, settingsDir);
153
+ if (!packageDir) continue;
154
+ discoverPackageSkills(packageDir, source, candidates);
155
+ }
156
+
157
+ return [...candidates.values()].sort((a, b) => {
158
+ const byName = a.name.localeCompare(b.name);
159
+ return byName || (a.packageSource ?? a.enablePath).localeCompare(b.packageSource ?? b.enablePath);
160
+ });
161
+ }
162
+
163
+ function normalizePath(path: string): string {
164
+ return resolve(path);
165
+ }
166
+
167
+ function isEnabled(candidate: SkillCandidate, settings: SettingsShape): boolean {
168
+ if (candidate.enableKind === "package-skill") {
169
+ const source = candidate.packageSource;
170
+ const skillName = candidate.packageSkillName;
171
+ if (!source || !skillName) return false;
172
+ const entry = (settings.packages ?? []).find((pkg) => packageSource(pkg) === source);
173
+ if (!entry) return false;
174
+ if (typeof entry === "string" || entry.skills === undefined) return true;
175
+ return entry.skills.includes(skillName);
176
+ }
177
+
178
+ if (candidate.enableKind === "package") {
179
+ const target = normalizePath(candidate.enablePath);
180
+ return (settings.packages ?? []).some((entry) => {
181
+ const source = packageSource(entry);
182
+ return source ? normalizePath(source) === target : false;
183
+ });
184
+ }
185
+
186
+ const skillSettings = settings.skills ?? [];
187
+ if (skillSettings.length === 0) return true;
188
+
189
+ const direct = normalizePath(candidate.enablePath);
190
+ const plusDirect = `+${direct}`;
191
+ return skillSettings.some((entry) => {
192
+ if (entry === plusDirect) return true;
193
+ if (entry.startsWith("!") || entry.startsWith("-")) return false;
194
+ const raw = entry.startsWith("+") ? entry.slice(1) : entry;
195
+ return normalizePath(raw) === direct || normalizePath(raw) === normalizePath(dirname(direct));
196
+ });
197
+ }
198
+
199
+ function applySelection(settings: SettingsShape, candidates: SkillCandidate[], selected: boolean[]): SettingsShape {
200
+ const next: SettingsShape = { ...settings };
201
+ const packageTargets = new Set(candidates.filter((c) => c.enableKind === "package").map((c) => normalizePath(c.enablePath)));
202
+ const skillTargets = new Set(candidates.filter((c) => c.enableKind === "settings-skill").map((c) => normalizePath(c.enablePath)));
203
+ const managedPackageSources = new Set(
204
+ candidates.filter((c) => c.enableKind === "package-skill" && c.packageSource).map((c) => c.packageSource!),
205
+ );
206
+
207
+ const selectedPackageSkills = new Map<string, string[]>();
208
+ for (let i = 0; i < candidates.length; i++) {
209
+ const candidate = candidates[i];
210
+ if (candidate.enableKind !== "package-skill" || !candidate.packageSource || !candidate.packageSkillName || !selected[i]) continue;
211
+ const list = selectedPackageSkills.get(candidate.packageSource) ?? [];
212
+ list.push(candidate.packageSkillName);
213
+ selectedPackageSkills.set(candidate.packageSource, list);
214
+ }
215
+
216
+ next.packages = (next.packages ?? [])
217
+ .filter((entry) => {
218
+ const source = packageSource(entry);
219
+ return !source || !packageTargets.has(normalizePath(source));
220
+ })
221
+ .map((entry) => {
222
+ const source = packageSource(entry);
223
+ if (!source || !managedPackageSources.has(source)) return entry;
224
+ const selectedSkills = selectedPackageSkills.get(source) ?? [];
225
+ const base = typeof entry === "string" ? { source: entry } : { ...entry, source };
226
+ return { ...base, skills: selectedSkills.sort() };
227
+ });
228
+
229
+ const existingSkillFilters = (next.skills ?? []).filter((entry) => {
230
+ if (entry === "!**") return false;
231
+ const raw = entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
232
+ return !skillTargets.has(normalizePath(raw));
233
+ });
234
+ next.skills = ["!**", ...existingSkillFilters];
235
+
236
+ for (let i = 0; i < candidates.length; i++) {
237
+ if (!selected[i]) continue;
238
+ const candidate = candidates[i];
239
+ if (candidate.enableKind === "package") next.packages.push(candidate.enablePath);
240
+ else if (candidate.enableKind === "settings-skill") next.skills.push(`+${candidate.enablePath}`);
241
+ }
242
+
243
+ return next;
244
+ }
245
+
246
+ async function selectSkills(
247
+ ctx: ExtensionCommandContext,
248
+ candidates: SkillCandidate[],
249
+ initialSelected: boolean[],
250
+ ): Promise<boolean[] | undefined> {
251
+ if (!ctx.hasUI) return initialSelected;
252
+
253
+ return await ctx.ui.custom<boolean[] | undefined>((tui, theme, _kb, done) => {
254
+ const selected = [...initialSelected];
255
+ const items: SettingItem[] = candidates.map((candidate, index) => ({
256
+ id: String(index),
257
+ label: candidate.name,
258
+ description: `${candidate.packageSource ?? (candidate.enableKind === "package" ? "package" : "local")}: ${candidate.description}`,
259
+ currentValue: selected[index] ? "enabled" : "disabled",
260
+ values: ["enabled", "disabled"],
261
+ }));
262
+
263
+ const container = new Container();
264
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
265
+ container.addChild(new Text(theme.fg("accent", theme.bold("Setup Skills")), 0, 0));
266
+
267
+ const settingsList = new SettingsList(
268
+ items,
269
+ 12,
270
+ getSettingsListTheme(),
271
+ (id, newValue) => {
272
+ selected[Number(id)] = newValue === "enabled";
273
+ },
274
+ () => done(undefined),
275
+ { enableSearch: true },
276
+ );
277
+
278
+ container.addChild(settingsList);
279
+ container.addChild(new Text(theme.fg("dim", " Ctrl+S save • q cancel"), 0, 0));
280
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
281
+
282
+ return {
283
+ render(width: number) {
284
+ return container.render(width);
285
+ },
286
+ invalidate() {
287
+ container.invalidate();
288
+ },
289
+ handleInput(data: string) {
290
+ if (data === "q") {
291
+ done(undefined);
292
+ return;
293
+ }
294
+ const kb = getKeybindings();
295
+ if (kb.matches(data, "app.models.save") || matchesKey(data, Key.ctrl("s")) || data === "\x13") {
296
+ done(selected);
297
+ return;
298
+ }
299
+ settingsList.handleInput(data);
300
+ tui.requestRender();
301
+ },
302
+ };
303
+ });
304
+ }
305
+
306
+ export default function setupSkillsExtension(pi: ExtensionAPI): void {
307
+ pi.registerCommand("setup-skills", {
308
+ description: "Enable/disable local Pi skills with a multi-selection list",
309
+ handler: async (_args, ctx) => {
310
+ const settingsPath = getAgentSettingsPath();
311
+ let settings: SettingsShape;
312
+ try {
313
+ settings = readJson(settingsPath);
314
+ } catch (error) {
315
+ ctx.ui.notify(`Could not read ${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
316
+ return;
317
+ }
318
+
319
+ const candidates = discoverCandidates(settings, ctx.cwd);
320
+ if (candidates.length === 0) {
321
+ ctx.ui.notify("No skills found.", "warning");
322
+ return;
323
+ }
324
+
325
+ const initial = candidates.map((candidate) => isEnabled(candidate, settings));
326
+ const selected = await selectSkills(ctx, candidates, initial);
327
+ if (!selected) {
328
+ ctx.ui.notify("Skill setup cancelled.", "info");
329
+ return;
330
+ }
331
+
332
+ try {
333
+ writeJson(settingsPath, applySelection(settings, candidates, selected));
334
+ } catch (error) {
335
+ ctx.ui.notify(`Could not write ${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
336
+ return;
337
+ }
338
+
339
+ const changed = candidates.filter((_, i) => initial[i] !== selected[i]).length;
340
+ ctx.ui.notify(`Skill setup saved (${changed} changed).`, "info");
341
+ if (changed > 0 && ctx.hasUI) {
342
+ const reload = await ctx.ui.select("Reload Pi now to apply skill changes?", ["Yes", "No"]);
343
+ if (reload === "Yes") await ctx.reload();
344
+ }
345
+ },
346
+ });
347
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@firstpick/pi-extension-setup-skills",
3
+ "version": "0.1.0",
4
+ "description": "Interactive Pi command to enable or disable local skills from a multi-selection list.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-coding-agent",
10
+ "extension",
11
+ "skills"
12
+ ],
13
+ "pi": {
14
+ "extensions": [
15
+ "./index.ts"
16
+ ]
17
+ },
18
+ "peerDependencies": {
19
+ "@earendil-works/pi-coding-agent": "*"
20
+ },
21
+ "files": [
22
+ "index.ts",
23
+ "README.md",
24
+ "LICENSE"
25
+ ]
26
+ }