@kmiyh/pi-skills-menu 1.0.5 → 1.2.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.
package/README.md CHANGED
@@ -59,10 +59,22 @@ Press:
59
59
 
60
60
  Press:
61
61
 
62
- - `Enter` — select a skill and insert it into the editor
62
+ - `Enter` — select an enabled skill and insert it into the editor
63
63
 
64
64
  After that, the skill is inserted into the editor so it will be used by Pi when the message is sent.
65
65
 
66
+ ### Enable or disable a skill
67
+
68
+ Press:
69
+
70
+ - `Ctrl+X` — toggle the selected skill between enabled and disabled
71
+
72
+ This also works for skills that come from installed libraries/packages.
73
+
74
+ Disabled skills stay visible in the list and are marked with:
75
+
76
+ - `[disabled]`
77
+
66
78
  ### Create a new skill
67
79
 
68
80
  The list also contains a dedicated entry for creating a new skill.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kmiyh/pi-skills-menu",
3
- "version": "1.0.5",
3
+ "version": "1.2.0",
4
4
  "description": "Pi extension that moves skills into a dedicated /skills menu with browsing, preview, editing, and AI-assisted creation.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -546,6 +546,7 @@ export type SkillCreationThinkingLevel = ThinkingLevel | "off";
546
546
 
547
547
  export interface SkillGenerationOptions {
548
548
  thinkingLevel?: SkillCreationThinkingLevel;
549
+ signal?: AbortSignal;
549
550
  }
550
551
 
551
552
  class SingleLineText implements Component {
@@ -863,11 +864,21 @@ function getGenerationStatusLabel(
863
864
  : `Generating skill draft using ${modelLabel}...`;
864
865
  }
865
866
 
867
+ function isAbortError(error: unknown): boolean {
868
+ if (!error || typeof error !== "object") return false;
869
+ const name = "name" in error ? String((error as { name?: unknown }).name) : "";
870
+ const message = "message" in error ? String((error as { message?: unknown }).message) : "";
871
+ return name === "AbortError" || message.toLowerCase().includes("aborted");
872
+ }
873
+
866
874
  async function generateSkillDraft(
867
875
  ctx: ExtensionContext,
868
876
  answers: SkillCreationAnswers,
869
877
  options?: SkillGenerationOptions,
870
878
  ): Promise<string> {
879
+ if (options?.signal?.aborted) {
880
+ throw new Error("Generation aborted");
881
+ }
871
882
  if (!ctx.model) {
872
883
  return buildFallbackSkill(answers);
873
884
  }
@@ -910,9 +921,13 @@ async function generateSkillDraft(
910
921
  const response = await completeSimple(
911
922
  ctx.model,
912
923
  { systemPrompt: GENERATE_SKILL_SYSTEM_PROMPT, messages: [userMessage] },
913
- { apiKey: auth.apiKey, headers: auth.headers, ...(reasoning ? { reasoning } : {}) },
924
+ { apiKey: auth.apiKey, headers: auth.headers, ...(reasoning ? { reasoning } : {}), ...(options?.signal ? { signal: options.signal } : {}) },
914
925
  );
915
926
 
927
+ if (options?.signal?.aborted) {
928
+ throw new Error("Generation aborted");
929
+ }
930
+
916
931
  const generated = response.content
917
932
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
918
933
  .map((c) => c.text)
@@ -942,12 +957,45 @@ async function runDraftGeneration(
942
957
 
943
958
  generateSkillDraft(ctx, answers, options)
944
959
  .then(done)
945
- .catch(() => done(buildFallbackSkill(answers)));
960
+ .catch((error) => done(isAbortError(error) ? null : buildFallbackSkill(answers)));
946
961
 
947
962
  return loader;
948
963
  });
949
964
  }
950
965
 
966
+ async function saveCreatedSkill(
967
+ ctx: ExtensionContext,
968
+ answers: SkillCreationAnswers,
969
+ draft: string,
970
+ ): Promise<SkillEntry | null> {
971
+ let parsedSkill: ParsedSkillDraft;
972
+ try {
973
+ parsedSkill = parseSkillDraft(draft, answers.name);
974
+ } catch (error) {
975
+ ctx.ui.notify(error instanceof Error ? error.message : "Invalid generated SKILL.md", "error");
976
+ return null;
977
+ }
978
+
979
+ const targetDir = getTargetDir(ctx, answers.location, answers.name);
980
+ const targetPath = join(targetDir, "SKILL.md");
981
+ await mkdir(targetDir, { recursive: true });
982
+ await writeFile(targetPath, parsedSkill.raw, "utf8");
983
+
984
+ ctx.ui.notify(`Created skill: ${targetPath}`, "info");
985
+ return {
986
+ name: parsedSkill.name,
987
+ description: parsedSkill.description,
988
+ path: targetPath,
989
+ content: parsedSkill.content,
990
+ frontmatter: parsedSkill.frontmatter,
991
+ scope: answers.location === "global" ? "user" : "project",
992
+ origin: "top-level",
993
+ source: "auto",
994
+ baseDir: targetDir,
995
+ enabled: true,
996
+ };
997
+ }
998
+
951
999
  async function collectAnswers(ctx: ExtensionContext): Promise<SkillCreationAnswers | null> {
952
1000
  const answers = await ctx.ui.custom<SkillCreationAnswers | null>((tui, _theme, _kb, done) => {
953
1001
  const component = new SkillCreationWizard(ctx.ui.theme, done);
@@ -992,29 +1040,36 @@ export async function createSkillFromAnswers(
992
1040
  return null;
993
1041
  }
994
1042
 
995
- let parsedSkill: ParsedSkillDraft;
1043
+ return await saveCreatedSkill(ctx, answers, draft);
1044
+ }
1045
+
1046
+ export async function createSkillFromAnswersWithoutUI(
1047
+ ctx: ExtensionContext,
1048
+ answers: SkillCreationAnswers,
1049
+ options?: SkillGenerationOptions,
1050
+ ): Promise<SkillEntry | null> {
1051
+ const targetDir = getTargetDir(ctx, answers.location, answers.name);
1052
+ const targetPath = join(targetDir, "SKILL.md");
1053
+ if (existsSync(targetPath)) {
1054
+ ctx.ui.notify(`Skill already exists: ${targetPath}`, "error");
1055
+ return null;
1056
+ }
1057
+
1058
+ let draft: string;
996
1059
  try {
997
- parsedSkill = parseSkillDraft(draft, answers.name);
1060
+ draft = await generateSkillDraft(ctx, answers, options);
998
1061
  } catch (error) {
999
- ctx.ui.notify(error instanceof Error ? error.message : "Invalid generated SKILL.md", "error");
1000
- return null;
1062
+ if (isAbortError(error) || options?.signal?.aborted) {
1063
+ return null;
1064
+ }
1065
+ draft = buildFallbackSkill(answers);
1001
1066
  }
1002
1067
 
1003
- await mkdir(targetDir, { recursive: true });
1004
- await writeFile(targetPath, parsedSkill.raw, "utf8");
1068
+ if (options?.signal?.aborted) {
1069
+ return null;
1070
+ }
1005
1071
 
1006
- ctx.ui.notify(`Created skill: ${targetPath}`, "info");
1007
- return {
1008
- name: parsedSkill.name,
1009
- description: parsedSkill.description,
1010
- path: targetPath,
1011
- content: parsedSkill.content,
1012
- frontmatter: parsedSkill.frontmatter,
1013
- scope: answers.location === "global" ? "user" : "project",
1014
- origin: "top-level",
1015
- source: "auto",
1016
- baseDir: targetDir,
1017
- };
1072
+ return await saveCreatedSkill(ctx, answers, draft);
1018
1073
  }
1019
1074
 
1020
1075
  export async function createNewSkill(
package/src/index.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  import type { ExtensionAPI, ExtensionContext, InputEventResult } from "@mariozechner/pi-coding-agent";
2
- import { createSkillFromAnswers } from "./create-skill.js";
2
+ import { createSkillFromAnswersWithoutUI } from "./create-skill.js";
3
3
  import { deleteSkill } from "./delete-skill.js";
4
4
  import { detectExtensionInstallScope } from "./extension-scope.js";
5
5
  import { expandSkillMarkers, hasSkillMarker, insertSkillMarker, removeIncompleteSkillMarkerLines } from "./markers.js";
6
6
  import { loadSkillRegistry } from "./skill-registry.js";
7
7
  import { ensureSkillCommandsHidden } from "./settings-toggle.js";
8
+ import { setSkillEnabled } from "./skill-enabled-toggle.js";
8
9
  import type { ExtensionInstallScope, SkillRegistry } from "./types.js";
9
- import { showSkillPreview } from "./ui/skill-preview.js";
10
- import { showSkillsSelector } from "./ui/skills-selector.js";
10
+ import { showSkillsManager } from "./ui/skills-manager.js";
11
11
 
12
12
  const EMPTY_REGISTRY: SkillRegistry = {
13
13
  skills: [],
14
+ allSkills: [],
14
15
  byName: new Map(),
15
16
  };
16
17
 
@@ -105,64 +106,42 @@ export default function skillsMenuExtension(pi: ExtensionAPI) {
105
106
  return;
106
107
  }
107
108
 
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
- }
109
+ try {
110
+ await refreshRegistry(ctx.cwd);
111
+ } catch (error) {
112
+ console.error("skills-menu: failed to refresh skills registry", error);
113
+ ctx.ui.notify("Failed to load skills list", "error");
114
+ return;
115
+ }
116
+
117
+ const selection = await showSkillsManager(ctx, registry, {
118
+ onCreate: async (answers, signal) => await createSkillFromAnswersWithoutUI(ctx, answers, {
119
+ thinkingLevel: pi.getThinkingLevel(),
120
+ signal,
121
+ }),
122
+ onDelete: async (skill) => {
153
123
  try {
154
- await deleteSkill(ctx, selection.skill);
155
- selectorIndex = Math.max(0, selectorIndex - 1);
124
+ return await deleteSkill(ctx, skill);
156
125
  } catch (error) {
157
126
  console.error("skills-menu: failed to delete skill", error);
158
127
  ctx.ui.notify("Failed to delete skill", "error");
128
+ return false;
159
129
  }
160
- continue;
161
- }
162
-
163
- insertSkillMarker(ctx, selection.skill);
130
+ },
131
+ onToggle: async (skill, enabled) => {
132
+ try {
133
+ await setSkillEnabled(ctx.cwd, skill, enabled);
134
+ } catch (error) {
135
+ console.error("skills-menu: failed to toggle skill", error);
136
+ throw error instanceof Error ? error : new Error("Failed to update skill visibility");
137
+ }
138
+ },
139
+ onRefresh: async () => await refreshRegistry(ctx.cwd),
140
+ });
141
+ if (!selection) {
164
142
  return;
165
143
  }
144
+ insertSkillMarker(ctx, selection);
166
145
  },
167
146
  });
168
147
 
@@ -188,7 +167,7 @@ export default function skillsMenuExtension(pi: ExtensionAPI) {
188
167
  return { action: "continue" };
189
168
  }
190
169
 
191
- if (!currentCwd || currentCwd !== ctx.cwd || registry.skills.length === 0) {
170
+ if (!currentCwd || currentCwd !== ctx.cwd || registry.allSkills.length === 0) {
192
171
  try {
193
172
  await refreshRegistry(ctx.cwd);
194
173
  } catch (error) {
@@ -0,0 +1,69 @@
1
+ import { dirname, join, relative } from "node:path";
2
+ import { getAgentDir, SettingsManager, type PackageSource } from "@mariozechner/pi-coding-agent";
3
+ import type { SkillEntry } from "./types.js";
4
+
5
+ function updatePatterns(current: string[], pattern: string, enabled: boolean): string[] {
6
+ const updated = current.filter((entry) => {
7
+ const stripped = entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
8
+ return stripped !== pattern;
9
+ });
10
+ updated.push(`${enabled ? "+" : "-"}${pattern}`);
11
+ return updated;
12
+ }
13
+
14
+ function getTopLevelPattern(skill: SkillEntry, cwd: string): string {
15
+ const baseDir = skill.scope === "project" ? join(cwd, ".pi") : getAgentDir();
16
+ return relative(baseDir, skill.path);
17
+ }
18
+
19
+ function getPackagePattern(skill: SkillEntry): string {
20
+ const baseDir = skill.baseDir ?? dirname(skill.path);
21
+ return relative(baseDir, skill.path);
22
+ }
23
+
24
+ function hasPackageFilters(pkg: Exclude<PackageSource, string>): boolean {
25
+ return pkg.extensions !== undefined || pkg.skills !== undefined || pkg.prompts !== undefined || pkg.themes !== undefined;
26
+ }
27
+
28
+ export async function setSkillEnabled(cwd: string, skill: SkillEntry, enabled: boolean): Promise<void> {
29
+ if (skill.scope === "temporary") {
30
+ throw new Error("Temporary skills cannot be toggled.");
31
+ }
32
+
33
+ const settingsManager = SettingsManager.create(cwd, getAgentDir());
34
+
35
+ if (skill.origin === "top-level") {
36
+ const settings = skill.scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
37
+ const current = [...(settings.skills ?? [])];
38
+ const updated = updatePatterns(current, getTopLevelPattern(skill, cwd), enabled);
39
+
40
+ if (skill.scope === "project") {
41
+ settingsManager.setProjectSkillPaths(updated);
42
+ } else {
43
+ settingsManager.setSkillPaths(updated);
44
+ }
45
+ await settingsManager.flush();
46
+ return;
47
+ }
48
+
49
+ const settings = skill.scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
50
+ const packages = [...(settings.packages ?? [])];
51
+ const packageIndex = packages.findIndex((pkg) => (typeof pkg === "string" ? pkg : pkg.source) === skill.source);
52
+ if (packageIndex === -1) {
53
+ throw new Error("Could not find the package settings entry for this skill.");
54
+ }
55
+
56
+ const packageEntry = packages[packageIndex];
57
+ const packageConfig = typeof packageEntry === "string" ? { source: packageEntry } : { ...packageEntry };
58
+ const current = [...(packageConfig.skills ?? [])];
59
+ const updated = updatePatterns(current, getPackagePattern(skill), enabled);
60
+ packageConfig.skills = updated.length > 0 ? updated : undefined;
61
+ packages[packageIndex] = hasPackageFilters(packageConfig) ? packageConfig : packageConfig.source;
62
+
63
+ if (skill.scope === "project") {
64
+ settingsManager.setProjectPackages(packages);
65
+ } else {
66
+ settingsManager.setPackages(packages);
67
+ }
68
+ await settingsManager.flush();
69
+ }
@@ -25,8 +25,6 @@ function compareSkills(a: SkillEntry, b: SkillEntry): number {
25
25
  }
26
26
 
27
27
  function toSkillEntry(resource: ResolvedResource): SkillEntry | null {
28
- if (!resource.enabled) return null;
29
-
30
28
  const parsed = parseSkillFile(resource.path);
31
29
  if (!parsed) return null;
32
30
 
@@ -40,9 +38,21 @@ function toSkillEntry(resource: ResolvedResource): SkillEntry | null {
40
38
  origin: resource.metadata.origin,
41
39
  source: resource.metadata.source,
42
40
  baseDir: resource.metadata.baseDir,
41
+ enabled: resource.enabled,
43
42
  };
44
43
  }
45
44
 
45
+ function dedupeByPath(skills: SkillEntry[]): SkillEntry[] {
46
+ const seen = new Set<string>();
47
+ const deduped: SkillEntry[] = [];
48
+ for (const skill of skills) {
49
+ if (seen.has(skill.path)) continue;
50
+ seen.add(skill.path);
51
+ deduped.push(skill);
52
+ }
53
+ return deduped;
54
+ }
55
+
46
56
  export async function loadSkillRegistry(cwd: string): Promise<SkillRegistry> {
47
57
  const settingsManager = SettingsManager.create(cwd, getAgentDir());
48
58
  const packageManager = new DefaultPackageManager({
@@ -52,10 +62,10 @@ export async function loadSkillRegistry(cwd: string): Promise<SkillRegistry> {
52
62
  });
53
63
  const resolved = await packageManager.resolve();
54
64
 
65
+ const allSkills = dedupeByPath(resolved.skills.map(toSkillEntry).filter((entry): entry is SkillEntry => entry !== null)).sort(compareSkills);
55
66
  const byName = new Map<string, SkillEntry>();
56
- for (const resource of resolved.skills) {
57
- const entry = toSkillEntry(resource);
58
- if (!entry) continue;
67
+ for (const entry of allSkills) {
68
+ if (!entry.enabled) continue;
59
69
  if (!byName.has(entry.name)) {
60
70
  byName.set(entry.name, entry);
61
71
  }
@@ -64,6 +74,7 @@ export async function loadSkillRegistry(cwd: string): Promise<SkillRegistry> {
64
74
  const skills = Array.from(byName.values()).sort(compareSkills);
65
75
  return {
66
76
  skills,
77
+ allSkills,
67
78
  byName: new Map(skills.map((skill) => [skill.name, skill])),
68
79
  };
69
80
  }
package/src/types.ts CHANGED
@@ -10,9 +10,11 @@ export interface SkillEntry {
10
10
  origin: "package" | "top-level";
11
11
  source: string;
12
12
  baseDir?: string;
13
+ enabled: boolean;
13
14
  }
14
15
 
15
16
  export interface SkillRegistry {
16
17
  skills: SkillEntry[];
18
+ allSkills: SkillEntry[];
17
19
  byName: Map<string, SkillEntry>;
18
20
  }
@@ -37,7 +37,7 @@ function getSkillLocation(skill: SkillEntry): string {
37
37
  }
38
38
 
39
39
  function getSkillLocationLabel(skill: SkillEntry): string {
40
- return skill.origin === "package" ? "package" : "path";
40
+ return skill.origin === "package" ? "package" : "";
41
41
  }
42
42
 
43
43
  function formatScalar(value: unknown): string {
@@ -271,7 +271,7 @@ class ScrollableSkillPreview implements Component {
271
271
  private buildContentLines(innerWidth: number): string[] {
272
272
  const content = new Container();
273
273
  content.addChild(new Text(this.theme.fg("accent", this.theme.bold(this.skill.name)), 0, 0));
274
- content.addChild(new Text(this.theme.fg("muted", `${getSkillLocationLabel(this.skill)}${getSkillLocation(this.skill)}`), 0, 0));
274
+ content.addChild(new Text(this.theme.fg("muted", `${getSkillLocationLabel(this.skill)}${getSkillLocation(this.skill)}`), 0, 0));
275
275
  content.addChild(new Spacer(1));
276
276
  content.addChild(new Text(this.theme.fg("muted", this.theme.bold("Metadata")), 0, 0));
277
277
  content.addChild(new Text(this.theme.fg("dim", buildFrontmatterBlock(this.skill)), 0, 0));
@@ -292,7 +292,7 @@ class ScrollableSkillPreview implements Component {
292
292
  : "";
293
293
  const editInfo = this.editable ? " • e edit • r rename" : "";
294
294
  return truncateToWidth(
295
- this.theme.fg("dim", `↑/↓ scroll • pgup/pgdn jump • home/end${editInfo} • esc back${scrollInfo}`),
295
+ this.theme.fg("dim", `↑/↓ scroll${editInfo} • esc back${scrollInfo}`),
296
296
  innerWidth,
297
297
  this.theme.fg("dim", "..."),
298
298
  );