@kmiyh/pi-skills-menu 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.
@@ -0,0 +1,20 @@
1
+ import { rm } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+ import type { SkillEntry } from "./types.js";
5
+
6
+ export function isDeletableSkill(skill: SkillEntry): boolean {
7
+ return skill.origin === "top-level" && (skill.scope === "project" || skill.scope === "user");
8
+ }
9
+
10
+ export async function deleteSkill(ctx: ExtensionContext, skill: SkillEntry): Promise<boolean> {
11
+ if (!isDeletableSkill(skill)) {
12
+ ctx.ui.notify("Only your own project and global skills can be deleted", "warning");
13
+ return false;
14
+ }
15
+
16
+ const targetPath = dirname(skill.path);
17
+ await rm(targetPath, { recursive: true, force: true });
18
+ ctx.ui.notify(`Deleted skill: ${skill.name}`, "info");
19
+ return true;
20
+ }
@@ -0,0 +1,31 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { resolve, sep } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+ import type { ExtensionInstallScope } from "./types.js";
5
+
6
+ function normalizeDir(path: string): string {
7
+ const normalized = resolve(path);
8
+ return normalized.endsWith(sep) ? normalized : normalized + sep;
9
+ }
10
+
11
+ function isWithin(path: string, parent: string): boolean {
12
+ const normalizedPath = normalizeDir(path);
13
+ const normalizedParent = normalizeDir(parent);
14
+ return normalizedPath.startsWith(normalizedParent);
15
+ }
16
+
17
+ export function detectExtensionInstallScope(cwd: string): ExtensionInstallScope {
18
+ const extensionFile = fileURLToPath(import.meta.url);
19
+ const agentDir = getAgentDir();
20
+ const projectPiDir = resolve(cwd, ".pi");
21
+
22
+ if (isWithin(extensionFile, projectPiDir)) {
23
+ return "project";
24
+ }
25
+
26
+ if (isWithin(extensionFile, agentDir)) {
27
+ return "global";
28
+ }
29
+
30
+ return "global";
31
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
package/src/index.ts ADDED
@@ -0,0 +1,215 @@
1
+ import type { ExtensionAPI, ExtensionContext, InputEventResult } from "@mariozechner/pi-coding-agent";
2
+ import { createSkillFromAnswers } from "./create-skill.js";
3
+ import { deleteSkill } from "./delete-skill.js";
4
+ import { detectExtensionInstallScope } from "./extension-scope.js";
5
+ import { expandSkillMarkers, hasSkillMarker, insertSkillMarker, removeIncompleteSkillMarkerLines } from "./markers.js";
6
+ import { loadSkillRegistry } from "./skill-registry.js";
7
+ import { ensureSkillCommandsHidden } from "./settings-toggle.js";
8
+ import type { ExtensionInstallScope, SkillRegistry } from "./types.js";
9
+ import { showSkillPreview } from "./ui/skill-preview.js";
10
+ import { showSkillsSelector } from "./ui/skills-selector.js";
11
+
12
+ const EMPTY_REGISTRY: SkillRegistry = {
13
+ skills: [],
14
+ byName: new Map(),
15
+ };
16
+
17
+ export default function skillsMenuExtension(pi: ExtensionAPI) {
18
+ let registry: SkillRegistry = EMPTY_REGISTRY;
19
+ let currentCwd: string | undefined;
20
+ let installScope: ExtensionInstallScope | undefined;
21
+ let hideChecked = false;
22
+ let pendingReload = false;
23
+ let terminalInputUnsubscribe: (() => void) | undefined;
24
+ let cleanupTimer: ReturnType<typeof setTimeout> | undefined;
25
+
26
+ async function refreshRegistry(cwd: string): Promise<SkillRegistry> {
27
+ registry = await loadSkillRegistry(cwd);
28
+ currentCwd = cwd;
29
+ return registry;
30
+ }
31
+
32
+ async function maybeHideBuiltinSkillCommands(ctx: ExtensionContext): Promise<boolean> {
33
+ if (hideChecked && currentCwd === ctx.cwd) {
34
+ return false;
35
+ }
36
+
37
+ installScope = detectExtensionInstallScope(ctx.cwd);
38
+ hideChecked = true;
39
+ const result = await ensureSkillCommandsHidden(installScope, ctx.cwd);
40
+ if (!result.changed) {
41
+ return false;
42
+ }
43
+
44
+ pendingReload = true;
45
+ const reload = (ctx as ExtensionContext & { reload?: () => Promise<void> }).reload;
46
+ if (typeof reload === "function") {
47
+ await reload.call(ctx);
48
+ return true;
49
+ }
50
+
51
+ return false;
52
+ }
53
+
54
+ function scheduleIncompleteMarkerCleanup(ctx: ExtensionContext): void {
55
+ if (!ctx.hasUI) {
56
+ return;
57
+ }
58
+ if (cleanupTimer) {
59
+ clearTimeout(cleanupTimer);
60
+ }
61
+ cleanupTimer = setTimeout(() => {
62
+ const currentText = ctx.ui.getEditorText();
63
+ const sanitized = removeIncompleteSkillMarkerLines(currentText, registry);
64
+ if (sanitized.changed) {
65
+ ctx.ui.setEditorText(sanitized.text);
66
+ }
67
+ }, 0);
68
+ }
69
+
70
+ async function prepareSession(ctx: ExtensionContext): Promise<boolean> {
71
+ const reloaded = await maybeHideBuiltinSkillCommands(ctx);
72
+ if (reloaded) {
73
+ return true;
74
+ }
75
+
76
+ try {
77
+ await refreshRegistry(ctx.cwd);
78
+ } catch (error) {
79
+ registry = EMPTY_REGISTRY;
80
+ console.error("skills-menu: failed to load skills registry", error);
81
+ }
82
+
83
+ terminalInputUnsubscribe?.();
84
+ if (ctx.hasUI) {
85
+ terminalInputUnsubscribe = ctx.ui.onTerminalInput(() => {
86
+ scheduleIncompleteMarkerCleanup(ctx);
87
+ return undefined;
88
+ });
89
+ }
90
+
91
+ return false;
92
+ }
93
+
94
+ pi.registerCommand("skills", {
95
+ description: "Browse and insert available skills",
96
+ handler: async (_args, ctx) => {
97
+ if (pendingReload) {
98
+ pendingReload = false;
99
+ await ctx.reload();
100
+ return;
101
+ }
102
+
103
+ if (!ctx.hasUI) {
104
+ ctx.ui.notify("/skills requires interactive mode", "warning");
105
+ return;
106
+ }
107
+
108
+ let selectorIndex = 0;
109
+ let selectorQuery = "";
110
+ while (true) {
111
+ try {
112
+ await refreshRegistry(ctx.cwd);
113
+ } catch (error) {
114
+ console.error("skills-menu: failed to refresh skills registry", error);
115
+ ctx.ui.notify("Failed to load skills list", "error");
116
+ return;
117
+ }
118
+
119
+ const selection = await showSkillsSelector(ctx, registry, selectorIndex, selectorQuery);
120
+ if (!selection) {
121
+ return;
122
+ }
123
+ selectorIndex = selection.selectedIndex;
124
+ selectorQuery = selection.query;
125
+
126
+ if (selection.type === "create") {
127
+ const createdSkill = await createSkillFromAnswers(ctx, selection.answers, {
128
+ thinkingLevel: pi.getThinkingLevel(),
129
+ });
130
+ if (!createdSkill) {
131
+ continue;
132
+ }
133
+ await refreshRegistry(ctx.cwd);
134
+ const createdSkillIndex = registry.skills.findIndex((skill) => skill.path === createdSkill.path);
135
+ selectorIndex = createdSkillIndex >= 0 ? createdSkillIndex + 1 : 0;
136
+ selectorQuery = "";
137
+ continue;
138
+ }
139
+
140
+ if (selection.type === "preview") {
141
+ await showSkillPreview(ctx, selection.skill);
142
+ continue;
143
+ }
144
+
145
+ if (selection.type === "delete") {
146
+ const confirmed = await ctx.ui.confirm(
147
+ "Delete skill",
148
+ `Delete ${selection.skill.name}? This removes the skill from disk and cannot be undone.`,
149
+ );
150
+ if (!confirmed) {
151
+ continue;
152
+ }
153
+ try {
154
+ await deleteSkill(ctx, selection.skill);
155
+ selectorIndex = Math.max(0, selectorIndex - 1);
156
+ } catch (error) {
157
+ console.error("skills-menu: failed to delete skill", error);
158
+ ctx.ui.notify("Failed to delete skill", "error");
159
+ }
160
+ continue;
161
+ }
162
+
163
+ insertSkillMarker(ctx, selection.skill);
164
+ return;
165
+ }
166
+ },
167
+ });
168
+
169
+ pi.on("session_start", async (_event, ctx) => {
170
+ await prepareSession(ctx);
171
+ });
172
+
173
+ pi.on("session_shutdown", async () => {
174
+ terminalInputUnsubscribe?.();
175
+ terminalInputUnsubscribe = undefined;
176
+ if (cleanupTimer) {
177
+ clearTimeout(cleanupTimer);
178
+ cleanupTimer = undefined;
179
+ }
180
+ });
181
+
182
+ pi.on("input", async (event, ctx): Promise<InputEventResult | void> => {
183
+ if (event.source === "extension") {
184
+ return { action: "continue" };
185
+ }
186
+
187
+ if (!hasSkillMarker(event.text)) {
188
+ return { action: "continue" };
189
+ }
190
+
191
+ if (!currentCwd || currentCwd !== ctx.cwd || registry.skills.length === 0) {
192
+ try {
193
+ await refreshRegistry(ctx.cwd);
194
+ } catch (error) {
195
+ console.error("skills-menu: failed to refresh skills registry for input transform", error);
196
+ return { action: "continue" };
197
+ }
198
+ }
199
+
200
+ const expanded = expandSkillMarkers(event.text, registry);
201
+ if (!expanded.changed) {
202
+ return { action: "continue" };
203
+ }
204
+
205
+ if (!expanded.insertedSkill && expanded.text.trim().length === 0) {
206
+ ctx.ui.notify("Incomplete skill marker removed", "info");
207
+ return { action: "handled" };
208
+ }
209
+
210
+ return {
211
+ action: "transform",
212
+ text: expanded.text,
213
+ };
214
+ });
215
+ }
package/src/markers.ts ADDED
@@ -0,0 +1,123 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { SkillEntry, SkillRegistry } from "./types.js";
3
+
4
+ export const SKILL_MARKER_PREFIX = "[skill] ";
5
+ const SKILL_MARKER_FRAGMENTS = ["[skill]", "[skill", "[skil", "[ski", "[sk", "skill]"];
6
+
7
+ export function buildSkillMarker(skillName: string): string {
8
+ return `${SKILL_MARKER_PREFIX}${skillName}`;
9
+ }
10
+
11
+ export function insertSkillMarker(ctx: ExtensionContext, skill: SkillEntry): void {
12
+ ctx.ui.pasteToEditor(`${buildSkillMarker(skill.name)}\n`);
13
+ }
14
+
15
+ function getMarkedSkillName(line: string): string | null {
16
+ const trimmed = line.trim();
17
+ if (!trimmed.startsWith(SKILL_MARKER_PREFIX)) return null;
18
+ const name = trimmed.slice(SKILL_MARKER_PREFIX.length).trim();
19
+ return name.length > 0 ? name : null;
20
+ }
21
+
22
+ function isPotentialSkillMarkerLine(line: string): boolean {
23
+ const trimmed = line.trim().toLowerCase();
24
+ return SKILL_MARKER_FRAGMENTS.some((fragment) => trimmed.startsWith(fragment));
25
+ }
26
+
27
+ function isCompleteSkillMarkerLine(line: string, registry: SkillRegistry): boolean {
28
+ const trimmed = line.trim();
29
+ const skillName = getMarkedSkillName(trimmed);
30
+ if (!skillName) return false;
31
+ return trimmed === buildSkillMarker(skillName) && registry.byName.has(skillName);
32
+ }
33
+
34
+ export function removeIncompleteSkillMarkerLines(
35
+ text: string,
36
+ registry: SkillRegistry,
37
+ ): { changed: boolean; text: string } {
38
+ const lines = text.split(/\r?\n/);
39
+ let changed = false;
40
+ const keptLines = lines.filter((line) => {
41
+ if (isCompleteSkillMarkerLine(line, registry)) {
42
+ return true;
43
+ }
44
+ if (line.trim().startsWith(SKILL_MARKER_PREFIX) || isPotentialSkillMarkerLine(line)) {
45
+ changed = true;
46
+ return false;
47
+ }
48
+ return true;
49
+ });
50
+
51
+ return {
52
+ changed,
53
+ text: keptLines.join("\n"),
54
+ };
55
+ }
56
+
57
+ function buildSingleSkillBlock(skill: SkillEntry): string {
58
+ const relativeHint = skill.baseDir ? `References are relative to ${skill.baseDir}.\n\n` : "";
59
+ return `<skill name="${skill.name}" location="${skill.path}">\n${relativeHint}${skill.content}\n</skill>`;
60
+ }
61
+
62
+ function buildMultiSkillBlock(skills: SkillEntry[]): string {
63
+ const combinedName = skills.map((skill) => skill.name).join(", ");
64
+ const combinedContent = skills
65
+ .map((skill) => {
66
+ const relativeHint = skill.baseDir ? `References are relative to ${skill.baseDir}.\n\n` : "";
67
+ return `## ${skill.name}\n\n${relativeHint}${skill.content}`;
68
+ })
69
+ .join("\n\n---\n\n");
70
+ return `<skill name="${combinedName}" location="multiple">\n${combinedContent}\n</skill>`;
71
+ }
72
+
73
+ export function hasSkillMarker(text: string): boolean {
74
+ return text
75
+ .split(/\r?\n/)
76
+ .some((line) => line.includes(SKILL_MARKER_PREFIX) || isPotentialSkillMarkerLine(line));
77
+ }
78
+
79
+ export function expandSkillMarkers(
80
+ text: string,
81
+ registry: SkillRegistry,
82
+ ): { changed: boolean; text: string; insertedSkill: boolean } {
83
+ const lines = text.split(/\r?\n/);
84
+ const selectedSkills: SkillEntry[] = [];
85
+ const remainingLines: string[] = [];
86
+ let changed = false;
87
+
88
+ for (const line of lines) {
89
+ const skillName = getMarkedSkillName(line);
90
+ if (skillName) {
91
+ const skill = registry.byName.get(skillName);
92
+ changed = true;
93
+ if (skill) {
94
+ selectedSkills.push(skill);
95
+ }
96
+ continue;
97
+ }
98
+
99
+ if (isPotentialSkillMarkerLine(line)) {
100
+ changed = true;
101
+ continue;
102
+ }
103
+
104
+ remainingLines.push(line);
105
+ }
106
+
107
+ if (selectedSkills.length === 0) {
108
+ return {
109
+ changed,
110
+ text: changed ? remainingLines.join("\n").trim() : text,
111
+ insertedSkill: false,
112
+ };
113
+ }
114
+
115
+ const skillBlock = selectedSkills.length === 1 ? buildSingleSkillBlock(selectedSkills[0]!) : buildMultiSkillBlock(selectedSkills);
116
+ const userText = remainingLines.join("\n").trim();
117
+
118
+ return {
119
+ changed: true,
120
+ text: userText ? `${skillBlock}\n\n${userText}` : skillBlock,
121
+ insertedSkill: true,
122
+ };
123
+ }
@@ -0,0 +1,40 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+ import type { ExtensionInstallScope } from "./types.js";
5
+
6
+ export interface SkillCommandSettingResult {
7
+ changed: boolean;
8
+ path: string;
9
+ }
10
+
11
+ export function getSettingsPath(scope: ExtensionInstallScope, cwd: string): string {
12
+ return scope === "global" ? join(getAgentDir(), "settings.json") : resolve(cwd, ".pi", "settings.json");
13
+ }
14
+
15
+ async function readSettings(path: string): Promise<Record<string, unknown>> {
16
+ try {
17
+ const content = await readFile(path, "utf8");
18
+ const parsed = JSON.parse(content);
19
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
20
+ } catch (error) {
21
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
22
+ return {};
23
+ }
24
+ throw error;
25
+ }
26
+ }
27
+
28
+ export async function ensureSkillCommandsHidden(scope: ExtensionInstallScope, cwd: string): Promise<SkillCommandSettingResult> {
29
+ const path = getSettingsPath(scope, cwd);
30
+ const settings = await readSettings(path);
31
+
32
+ if (settings.enableSkillCommands === false) {
33
+ return { changed: false, path };
34
+ }
35
+
36
+ settings.enableSkillCommands = false;
37
+ await mkdir(dirname(path), { recursive: true });
38
+ await writeFile(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
39
+ return { changed: true, path };
40
+ }
@@ -0,0 +1,31 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseFrontmatter, stripFrontmatter } from "@mariozechner/pi-coding-agent";
3
+ import type { SkillEntry } from "./types.js";
4
+
5
+ interface SkillFrontmatter {
6
+ name?: unknown;
7
+ description?: unknown;
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ export function parseSkillFile(path: string): Pick<SkillEntry, "name" | "description" | "content" | "frontmatter"> | null {
12
+ try {
13
+ const raw = readFileSync(path, "utf8");
14
+ const { frontmatter } = parseFrontmatter<SkillFrontmatter>(raw);
15
+ const name = typeof frontmatter.name === "string" ? frontmatter.name.trim() : "";
16
+ const description = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
17
+
18
+ if (!name || !description) {
19
+ return null;
20
+ }
21
+
22
+ return {
23
+ name,
24
+ description,
25
+ content: stripFrontmatter(raw).trim(),
26
+ frontmatter: Object.fromEntries(Object.entries(frontmatter).filter(([, value]) => value !== undefined)),
27
+ };
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
@@ -0,0 +1,69 @@
1
+ import { DefaultPackageManager, getAgentDir, SettingsManager, type ResolvedResource } from "@mariozechner/pi-coding-agent";
2
+ import { parseSkillFile } from "./skill-parser.js";
3
+ import type { SkillEntry, SkillRegistry } from "./types.js";
4
+
5
+ function compareSkills(a: SkillEntry, b: SkillEntry): number {
6
+ const scopeRank = (scope: SkillEntry["scope"]) => {
7
+ switch (scope) {
8
+ case "project":
9
+ return 0;
10
+ case "user":
11
+ return 1;
12
+ default:
13
+ return 2;
14
+ }
15
+ };
16
+
17
+ const rankDiff = scopeRank(a.scope) - scopeRank(b.scope);
18
+ if (rankDiff !== 0) return rankDiff;
19
+
20
+ if (a.origin !== b.origin) {
21
+ return a.origin === "top-level" ? -1 : 1;
22
+ }
23
+
24
+ return a.name.localeCompare(b.name);
25
+ }
26
+
27
+ function toSkillEntry(resource: ResolvedResource): SkillEntry | null {
28
+ if (!resource.enabled) return null;
29
+
30
+ const parsed = parseSkillFile(resource.path);
31
+ if (!parsed) return null;
32
+
33
+ return {
34
+ name: parsed.name,
35
+ description: parsed.description,
36
+ content: parsed.content,
37
+ frontmatter: parsed.frontmatter,
38
+ path: resource.path,
39
+ scope: resource.metadata.scope,
40
+ origin: resource.metadata.origin,
41
+ source: resource.metadata.source,
42
+ baseDir: resource.metadata.baseDir,
43
+ };
44
+ }
45
+
46
+ export async function loadSkillRegistry(cwd: string): Promise<SkillRegistry> {
47
+ const settingsManager = SettingsManager.create(cwd, getAgentDir());
48
+ const packageManager = new DefaultPackageManager({
49
+ cwd,
50
+ agentDir: getAgentDir(),
51
+ settingsManager,
52
+ });
53
+ const resolved = await packageManager.resolve();
54
+
55
+ const byName = new Map<string, SkillEntry>();
56
+ for (const resource of resolved.skills) {
57
+ const entry = toSkillEntry(resource);
58
+ if (!entry) continue;
59
+ if (!byName.has(entry.name)) {
60
+ byName.set(entry.name, entry);
61
+ }
62
+ }
63
+
64
+ const skills = Array.from(byName.values()).sort(compareSkills);
65
+ return {
66
+ skills,
67
+ byName: new Map(skills.map((skill) => [skill.name, skill])),
68
+ };
69
+ }
package/src/types.ts ADDED
@@ -0,0 +1,18 @@
1
+ export type ExtensionInstallScope = "global" | "project";
2
+
3
+ export interface SkillEntry {
4
+ name: string;
5
+ description: string;
6
+ path: string;
7
+ content: string;
8
+ frontmatter?: Record<string, unknown>;
9
+ scope: "user" | "project" | "temporary";
10
+ origin: "package" | "top-level";
11
+ source: string;
12
+ baseDir?: string;
13
+ }
14
+
15
+ export interface SkillRegistry {
16
+ skills: SkillEntry[];
17
+ byName: Map<string, SkillEntry>;
18
+ }