@kmiyh/pi-skills-menu 1.1.0 → 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
@@ -67,7 +67,7 @@ After that, the skill is inserted into the editor so it will be used by Pi when
67
67
 
68
68
  Press:
69
69
 
70
- - `x` — toggle the selected skill between enabled and disabled
70
+ - `Ctrl+X` — toggle the selected skill between enabled and disabled
71
71
 
72
72
  This also works for skills that come from installed libraries/packages.
73
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kmiyh/pi-skills-menu",
3
- "version": "1.1.0",
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,30 +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
- enabled: true,
1018
- };
1072
+ return await saveCreatedSkill(ctx, answers, draft);
1019
1073
  }
1020
1074
 
1021
1075
  export async function createNewSkill(
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
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";
@@ -7,8 +7,7 @@ import { loadSkillRegistry } from "./skill-registry.js";
7
7
  import { ensureSkillCommandsHidden } from "./settings-toggle.js";
8
8
  import { setSkillEnabled } from "./skill-enabled-toggle.js";
9
9
  import type { ExtensionInstallScope, SkillRegistry } from "./types.js";
10
- import { showSkillPreview } from "./ui/skill-preview.js";
11
- import { showSkillsSelector } from "./ui/skills-selector.js";
10
+ import { showSkillsManager } from "./ui/skills-manager.js";
12
11
 
13
12
  const EMPTY_REGISTRY: SkillRegistry = {
14
13
  skills: [],
@@ -107,81 +106,42 @@ export default function skillsMenuExtension(pi: ExtensionAPI) {
107
106
  return;
108
107
  }
109
108
 
110
- let selectorIndex = 0;
111
- let selectorQuery = "";
112
- while (true) {
113
- try {
114
- await refreshRegistry(ctx.cwd);
115
- } catch (error) {
116
- console.error("skills-menu: failed to refresh skills registry", error);
117
- ctx.ui.notify("Failed to load skills list", "error");
118
- return;
119
- }
120
-
121
- const selection = await showSkillsSelector(ctx, registry, selectorIndex, selectorQuery);
122
- if (!selection) {
123
- return;
124
- }
125
- selectorIndex = selection.selectedIndex;
126
- selectorQuery = selection.query;
127
-
128
- if (selection.type === "create") {
129
- const createdSkill = await createSkillFromAnswers(ctx, selection.answers, {
130
- thinkingLevel: pi.getThinkingLevel(),
131
- });
132
- if (!createdSkill) {
133
- continue;
134
- }
135
- await refreshRegistry(ctx.cwd);
136
- const createdSkillIndex = registry.allSkills.findIndex((skill) => skill.path === createdSkill.path);
137
- selectorIndex = createdSkillIndex >= 0 ? createdSkillIndex + 1 : 0;
138
- selectorQuery = "";
139
- continue;
140
- }
141
-
142
- if (selection.type === "toggle") {
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) => {
143
123
  try {
144
- await setSkillEnabled(ctx.cwd, selection.skill, !selection.skill.enabled);
145
- await refreshRegistry(ctx.cwd);
146
- ctx.ui.notify(
147
- selection.skill.enabled
148
- ? `Disabled ${selection.skill.name}. Run /reload to fully apply the change.`
149
- : `Enabled ${selection.skill.name}. Run /reload to fully apply the change.`,
150
- "info",
151
- );
124
+ return await deleteSkill(ctx, skill);
152
125
  } catch (error) {
153
- console.error("skills-menu: failed to toggle skill", error);
154
- ctx.ui.notify(error instanceof Error ? error.message : "Failed to update skill visibility", "error");
155
- }
156
- continue;
157
- }
158
-
159
- if (selection.type === "preview") {
160
- await showSkillPreview(ctx, selection.skill);
161
- continue;
162
- }
163
-
164
- if (selection.type === "delete") {
165
- const confirmed = await ctx.ui.confirm(
166
- "Delete skill",
167
- `Delete ${selection.skill.name}? This removes the skill from disk and cannot be undone.`,
168
- );
169
- if (!confirmed) {
170
- continue;
126
+ console.error("skills-menu: failed to delete skill", error);
127
+ ctx.ui.notify("Failed to delete skill", "error");
128
+ return false;
171
129
  }
130
+ },
131
+ onToggle: async (skill, enabled) => {
172
132
  try {
173
- await deleteSkill(ctx, selection.skill);
174
- selectorIndex = Math.max(0, selectorIndex - 1);
133
+ await setSkillEnabled(ctx.cwd, skill, enabled);
175
134
  } catch (error) {
176
- console.error("skills-menu: failed to delete skill", error);
177
- ctx.ui.notify("Failed to delete skill", "error");
135
+ console.error("skills-menu: failed to toggle skill", error);
136
+ throw error instanceof Error ? error : new Error("Failed to update skill visibility");
178
137
  }
179
- continue;
180
- }
181
-
182
- insertSkillMarker(ctx, selection.skill);
138
+ },
139
+ onRefresh: async () => await refreshRegistry(ctx.cwd),
140
+ });
141
+ if (!selection) {
183
142
  return;
184
143
  }
144
+ insertSkillMarker(ctx, selection);
185
145
  },
186
146
  });
187
147
 
@@ -207,7 +167,7 @@ export default function skillsMenuExtension(pi: ExtensionAPI) {
207
167
  return { action: "continue" };
208
168
  }
209
169
 
210
- if (!currentCwd || currentCwd !== ctx.cwd || registry.skills.length === 0) {
170
+ if (!currentCwd || currentCwd !== ctx.cwd || registry.allSkills.length === 0) {
211
171
  try {
212
172
  await refreshRegistry(ctx.cwd);
213
173
  } catch (error) {
@@ -3,10 +3,6 @@ import { parseSkillFile } from "./skill-parser.js";
3
3
  import type { SkillEntry, SkillRegistry } from "./types.js";
4
4
 
5
5
  function compareSkills(a: SkillEntry, b: SkillEntry): number {
6
- if (a.enabled !== b.enabled) {
7
- return a.enabled ? -1 : 1;
8
- }
9
-
10
6
  const scopeRank = (scope: SkillEntry["scope"]) => {
11
7
  switch (scope) {
12
8
  case "project":
@@ -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
  );