@groeponline/pi-wishcraft 0.25.0 → 0.26.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/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.26.0] - 2026-08-20
6
+
7
+ ### Added
8
+ - `/skills new <name> [template]` writes a SKILL.md from standard, browser-workflow, CLI-workflow, or review-checklist. `ctrl+n` opens the template picker.
9
+
5
10
  ## [0.25.0] - 2026-08-20
6
11
 
7
12
  ## [0.24.0] - 2026-08-20
package/docs/index.md CHANGED
@@ -8,7 +8,7 @@ The README is the public landing page (`banner.png` only). Everything below live
8
8
  - [Configuration](./configuration.md) — custom items, hooks, repairs, token budget, labels, templates, layout, cost alert, and display formats.
9
9
  - [Bash mode](./bash-mode.md) — sticky shell, ghost suggestions, and shell config.
10
10
  - [Stash & shortcuts](./stash-and-shortcuts.md) — editor stash, prompt history, clipboard/navigation shortcuts, and shortcut config.
11
- - [Skill manager](./skill-manager.md) — browsing and inserting installed skills, `/skills doctor` health table.
11
+ - [Skill manager](./skill-manager.md) — browsing, inserting, `/skills doctor`, and `/skills new` templates.
12
12
  - [Working vibes](./working-vibes.md) — themed loading messages, modes, and configuration.
13
13
  - [Segments & theming](./segments.md) — segment reference, separators, thinking/path/git options, and theme overrides.
14
14
 
@@ -4,5 +4,6 @@ Browse and insert your installed skills (`SKILL.md` files and `*.md`/`*.txt` pro
4
4
 
5
5
  - **`/skills`** — open the skill manager. Filter with plain typing, `↑↓` to move, `enter` to open a skill's detail body, `↑↓` in the detail to scroll, `enter`/`tab` to insert the skill content into your prompt, `esc` to go back/close.
6
6
  - **`/skills doctor`** — health table (not an essay): broken or missing frontmatter, descriptions over 240 characters, the same name in global and project, unused skills (usage ledger count 0). `↑↓` navigate, `enter` copies a row, `esc` closes.
7
+ - **`/skills new <name> [template]`** — write `~/.pi/agent/skills/<name>/SKILL.md` from a template (`standard`, `browser-workflow`, `CLI-workflow`, `review-checklist`), then drop an `$EDITOR` command in the prompt. `/skills new` or `ctrl+n` in the manager opens the template picker. Names reject empty values, `..`, and path separators. No GitHub/npm install.
7
8
 
8
9
  The manager reuses the same skill discovery as inline `/command`/`$skill` triggers, so anything you can inline you can also browse and insert manually.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Wishcraft cockpit for the pi coding agent — powerline status, vibes, idea inbox, bash mode, and full customization.",
5
5
  "type": "module",
6
6
  "files": [
@@ -14,10 +14,8 @@
14
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
15
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
16
  import { rmSync } from "node:fs";
17
- import { join } from "node:path";
18
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
18
  import type { RuntimeState } from "../core/types.ts";
20
- import { getAgentPath } from "../../paths/agent-dirs.ts";
21
19
  import {
22
20
  applySkillFilter,
23
21
  getSkillUsage,
@@ -29,6 +27,7 @@ import {
29
27
  type SkillEntry,
30
28
  } from "./skill-registry.ts";
31
29
  import { runSkillDoctor } from "./skill-doctor.ts";
30
+ import { runSkillsNew } from "./skill-templates.ts";
32
31
 
33
32
  const CATEGORY_LABELS: Record<SkillCategory | "all", string> = {
34
33
  all: "alles",
@@ -90,21 +89,20 @@ function editorCommand(path: string): string {
90
89
  return `!${ed} ${shellQuote(path)}`;
91
90
  }
92
91
 
93
- export async function showSkillManager(ctx: any): Promise<void> {
92
+ export async function showSkillManager(ctx: any): Promise<"new" | null> {
94
93
  invalidateSkillCache();
95
94
  let entries = loadSkillCatalog(ctx.cwd ?? process.cwd());
96
95
  const usage = getSkillUsage();
97
96
  if (entries.length === 0) {
98
97
  ctx.ui.notify("No skills found", "info");
99
- return;
100
98
  }
101
99
 
102
- await ctx.ui.custom(
100
+ return ctx.ui.custom(
103
101
  (
104
102
  tui: any,
105
103
  theme: Theme,
106
104
  _keybindings: any,
107
- done: (result: null) => void,
105
+ done: (result: "new" | null) => void,
108
106
  ) => {
109
107
  const border = (text: string) => theme.fg("dim", text);
110
108
  const wrapRow = (text: string, innerWidth: number): string =>
@@ -183,11 +181,12 @@ export async function showSkillManager(ctx: any): Promise<void> {
183
181
  lines.push(border(`├${"─".repeat(innerWidth)}┤`));
184
182
 
185
183
  if (f.length === 0) {
184
+ const emptyMsg =
185
+ entries.length === 0 && !query
186
+ ? "No skills installed — ctrl+n to create one"
187
+ : `Geen skills voor "${query}"`;
186
188
  lines.push(
187
- wrapRow(
188
- theme.fg("warning", `Geen skills voor "${query}"`),
189
- innerWidth,
190
- ),
189
+ wrapRow(theme.fg("warning", emptyMsg), innerWidth),
191
190
  );
192
191
  } else {
193
192
  // scroll-window rondom de selectie
@@ -396,13 +395,8 @@ export async function showSkillManager(ctx: any): Promise<void> {
396
395
  return;
397
396
  }
398
397
  } else if (data === "\x0e") {
399
- // ctrl+n = nieuwe skill
400
- appendToEditor(
401
- ctx,
402
- `!mkdir -p ${shellQuote(join(getAgentPath("skills"), "<naam>"))} && ${editorCommand(join(getAgentPath("skills"), "<naam>", "SKILL.md")).slice(1)}`,
403
- "Nieuwe skill: vervang <naam>, enter draait 'm",
404
- );
405
- close();
398
+ // ctrl+n = new skill from a template
399
+ done("new");
406
400
  return;
407
401
  } else if (data === "\x04") {
408
402
  // ctrl+d = verwijderen (met confirm)
@@ -482,7 +476,7 @@ export function registerSkillManagerCommand(
482
476
  ): void {
483
477
  const runDoctor = deps.runDoctor ?? runSkillDoctor;
484
478
  pi.registerCommand("skills", {
485
- description: "Browse installed skills, or `doctor` for a health table",
479
+ description: "Browse installed skills, or `doctor` / `new [template]`",
486
480
  handler: async (args: string, ctx: any) => {
487
481
  if (!rt.enabled || !ctx.hasUI) {
488
482
  ctx.ui.notify("Powerline UI is disabled", "info");
@@ -493,7 +487,12 @@ export function registerSkillManagerCommand(
493
487
  await runDoctor(ctx);
494
488
  return;
495
489
  }
496
- await showSkillManager(ctx);
490
+ if (sub === "new") {
491
+ await runSkillsNew(ctx, args);
492
+ return;
493
+ }
494
+ const result = await showSkillManager(ctx);
495
+ if (result === "new") await runSkillsNew(ctx, "");
497
496
  },
498
497
  });
499
498
  }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * `/skills new` templates. No marketplace, no GitHub/npm install.
3
+ * Writes `~/.pi/agent/skills/<name>/SKILL.md` then opens $EDITOR.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import type { SelectItem } from "@earendil-works/pi-tui";
9
+
10
+ import { getAgentPath } from "../../paths/agent-dirs.ts";
11
+ import { showSelectOverlay } from "../ui/overlay-chrome.ts";
12
+ import { invalidateSkillCache } from "./skill-registry.ts";
13
+
14
+ export const SKILL_TEMPLATE_IDS = [
15
+ "standard",
16
+ "browser-workflow",
17
+ "cli-workflow",
18
+ "review-checklist",
19
+ ] as const;
20
+
21
+ export type SkillTemplateId = (typeof SKILL_TEMPLATE_IDS)[number];
22
+
23
+ export class SkillNameError extends Error {
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = "SkillNameError";
27
+ }
28
+ }
29
+
30
+ const TEMPLATE_META: Record<
31
+ SkillTemplateId,
32
+ { label: string; description: string }
33
+ > = {
34
+ standard: {
35
+ label: "standard",
36
+ description: "Name, description, and a short body",
37
+ },
38
+ "browser-workflow": {
39
+ label: "browser-workflow",
40
+ description: "UI verify with screenshots, no profile wipe",
41
+ },
42
+ "cli-workflow": {
43
+ label: "CLI-workflow",
44
+ description: "Command, flags, and expected output",
45
+ },
46
+ "review-checklist": {
47
+ label: "review-checklist",
48
+ description: "Diff review gates before merge",
49
+ },
50
+ };
51
+
52
+ export function isSkillTemplateId(value: string): value is SkillTemplateId {
53
+ return (SKILL_TEMPLATE_IDS as readonly string[]).includes(value);
54
+ }
55
+
56
+ export function sanitizeSkillName(raw: string): string {
57
+ const trimmed = raw.trim().toLowerCase().replace(/_/g, "-");
58
+ if (!trimmed) {
59
+ throw new SkillNameError("Skill name is required");
60
+ }
61
+ if (trimmed === "." || trimmed === ".." || trimmed.includes("..")) {
62
+ throw new SkillNameError("Skill name cannot contain path traversal");
63
+ }
64
+ if (trimmed.includes("/") || trimmed.includes("\\")) {
65
+ throw new SkillNameError("Skill name cannot contain path separators");
66
+ }
67
+ if (trimmed.length > 64) {
68
+ throw new SkillNameError("Skill name must be 64 characters or fewer");
69
+ }
70
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(trimmed)) {
71
+ throw new SkillNameError(
72
+ "Skill name must be lowercase letters, digits, and hyphens",
73
+ );
74
+ }
75
+ return trimmed;
76
+ }
77
+
78
+ export function parseSkillsNewArgs(args: string): {
79
+ name?: string;
80
+ template: SkillTemplateId;
81
+ } {
82
+ const parts = args.trim().split(/\s+/).filter(Boolean);
83
+ if (parts[0]?.toLowerCase() === "new") parts.shift();
84
+ if (parts.length === 0) return { template: "standard" };
85
+ if (parts.length === 1) {
86
+ const singleRaw = parts[0]!.toLowerCase();
87
+ if (isSkillTemplateId(singleRaw)) return { template: singleRaw };
88
+ return { name: parts[0], template: "standard" };
89
+ }
90
+ const templateRaw = parts[1]!.toLowerCase();
91
+ if (!isSkillTemplateId(templateRaw)) {
92
+ throw new SkillNameError(
93
+ `Unknown template '${parts[1]}'. Use: ${SKILL_TEMPLATE_IDS.join(", ")}`,
94
+ );
95
+ }
96
+ return { name: parts[0], template: templateRaw };
97
+ }
98
+
99
+ export function renderSkillTemplate(
100
+ id: SkillTemplateId,
101
+ name: string,
102
+ ): string {
103
+ const title = name;
104
+ switch (id) {
105
+ case "standard":
106
+ return `---
107
+ name: ${title}
108
+ description: Short skill for ${title}. State when to use it in one sentence.
109
+ ---
110
+
111
+ # ${title}
112
+
113
+ Use this skill when the operator asks for ${title}.
114
+
115
+ ## Steps
116
+
117
+ 1. Restate the goal in one line.
118
+ 2. Do the work with the tools already in session.
119
+ 3. Report what changed and how to verify it.
120
+ `;
121
+ case "browser-workflow":
122
+ return `---
123
+ name: ${title}
124
+ description: Browser UI verify for ${title}. Screenshot evidence, no profile wipe.
125
+ ---
126
+
127
+ # ${title}
128
+
129
+ Use for visual checks in a real browser. Do not author layout here.
130
+
131
+ ## Steps
132
+
133
+ 1. Open the target URL in the existing session.
134
+ 2. Snapshot the page, then act on stable refs.
135
+ 3. Take a screenshot of the result.
136
+ 4. Do not wipe profiles, cookies, or login state unless the operator asked.
137
+ `;
138
+ case "cli-workflow":
139
+ return `---
140
+ name: ${title}
141
+ description: CLI workflow for ${title}. Command, flags, and expected output.
142
+ ---
143
+
144
+ # ${title}
145
+
146
+ Use when the work is a command-line tool or script.
147
+
148
+ ## Steps
149
+
150
+ 1. Name the binary and the exact invocation.
151
+ 2. List required flags and inputs.
152
+ 3. Run the command and capture exit code plus stdout/stderr.
153
+ 4. State the expected output and how to rerun it.
154
+ `;
155
+ case "review-checklist":
156
+ return `---
157
+ name: ${title}
158
+ description: Review checklist for ${title}. Gates before merge, no rubber-stamp.
159
+ ---
160
+
161
+ # ${title}
162
+
163
+ Use before marking a change merge-ready.
164
+
165
+ ## Checklist
166
+
167
+ - [ ] Scope matches the request; no drive-by edits
168
+ - [ ] Tests cover the changed behavior
169
+ - [ ] No secrets in the diff
170
+ - [ ] Docs match the new surface
171
+ - [ ] Independent review is present; do not self-approve
172
+ `;
173
+ }
174
+ }
175
+
176
+ export function writeSkillFromTemplate(
177
+ name: string,
178
+ template: SkillTemplateId,
179
+ skillsRoot: string = getAgentPath("skills"),
180
+ ): { filePath: string } {
181
+ const safe = sanitizeSkillName(name);
182
+ const dir = join(skillsRoot, safe);
183
+ const filePath = join(dir, "SKILL.md");
184
+ if (existsSync(filePath)) {
185
+ throw new SkillNameError(`Skill already exists: ${safe}`);
186
+ }
187
+ mkdirSync(dir, { recursive: true });
188
+ writeFileSync(filePath, renderSkillTemplate(template, safe), "utf8");
189
+ invalidateSkillCache();
190
+ return { filePath };
191
+ }
192
+
193
+ export function buildSkillTemplateItems(): SelectItem[] {
194
+ return SKILL_TEMPLATE_IDS.map((id) => ({
195
+ value: id,
196
+ label: TEMPLATE_META[id].label,
197
+ description: TEMPLATE_META[id].description,
198
+ }));
199
+ }
200
+
201
+ function shellQuote(value: string): string {
202
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
203
+ }
204
+
205
+ export function editorCommandFor(path: string): string {
206
+ const ed = process.env.EDITOR?.trim() || "nvim";
207
+ return `!${ed} ${shellQuote(path)}`;
208
+ }
209
+
210
+ function appendEditorText(ctx: any, text: string): void {
211
+ const current = ctx.ui.getEditorText?.() ?? "";
212
+ const separator = current && !current.endsWith("\n") ? "\n" : "";
213
+ ctx.ui.setEditorText(`${current}${separator}${text}\n`);
214
+ }
215
+
216
+ export async function pickSkillTemplate(
217
+ ctx: any,
218
+ ): Promise<SkillTemplateId | null> {
219
+ const selected = await showSelectOverlay(
220
+ ctx,
221
+ "New skill template",
222
+ "↑↓ navigate • enter choose • esc cancel",
223
+ buildSkillTemplateItems(),
224
+ SKILL_TEMPLATE_IDS.length,
225
+ );
226
+ return selected && isSkillTemplateId(selected.value)
227
+ ? selected.value
228
+ : null;
229
+ }
230
+
231
+ export async function runSkillsNew(ctx: any, args: string): Promise<void> {
232
+ let parsed: { name?: string; template: SkillTemplateId };
233
+ try {
234
+ parsed = parseSkillsNewArgs(args);
235
+ } catch (error) {
236
+ ctx.ui.notify(
237
+ error instanceof Error ? error.message : String(error),
238
+ "error",
239
+ );
240
+ return;
241
+ }
242
+
243
+ let template = parsed.template;
244
+ let name = parsed.name;
245
+ if (!name) {
246
+ const picked = await pickSkillTemplate(ctx);
247
+ if (!picked) return;
248
+ template = picked;
249
+ appendEditorText(ctx, `/skills new <name> ${template}`);
250
+ ctx.ui.notify(
251
+ `Replace <name> and run to create a ${template} skill.`,
252
+ "info",
253
+ );
254
+ return;
255
+ }
256
+
257
+ try {
258
+ const { filePath } = writeSkillFromTemplate(name, template);
259
+ appendEditorText(ctx, editorCommandFor(filePath));
260
+ ctx.ui.notify(`Created ${name} (${template}). Enter runs the editor.`, "info");
261
+ } catch (error) {
262
+ ctx.ui.notify(
263
+ error instanceof Error ? error.message : String(error),
264
+ "error",
265
+ );
266
+ }
267
+ }