@groeponline/pi-wishcraft 0.24.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 +8 -0
- package/docs/index.md +1 -1
- package/docs/skill-manager.md +2 -0
- package/package.json +1 -1
- package/src/extension/skills/skill-doctor.ts +263 -0
- package/src/extension/skills/skill-manager.ts +31 -20
- package/src/extension/skills/skill-registry.ts +104 -5
- package/src/extension/skills/skill-templates.ts +267 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,18 @@
|
|
|
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
|
+
|
|
10
|
+
## [0.25.0] - 2026-08-20
|
|
11
|
+
|
|
5
12
|
## [0.24.0] - 2026-08-20
|
|
6
13
|
|
|
7
14
|
### Added
|
|
8
15
|
- Declarative policy engine (`wishcraft.policy`): in-process deny/inject rules in global settings, evaluated before command hooks. No process spawn. `policyEnabled: false` is the kill-switch.
|
|
16
|
+
- `/skills doctor` health table: broken frontmatter, descriptions over 240 chars, global/project duplicates, unused skills.
|
|
9
17
|
|
|
10
18
|
## [0.23.4] - 2026-08-20
|
|
11
19
|
|
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
|
|
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
|
|
package/docs/skill-manager.md
CHANGED
|
@@ -3,5 +3,7 @@
|
|
|
3
3
|
Browse and insert your installed skills (`SKILL.md` files and `*.md`/`*.txt` prompts) from an interactive TUI overlay:
|
|
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
|
+
- **`/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.
|
|
6
8
|
|
|
7
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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill health table for `/skills doctor`.
|
|
3
|
+
* Rows only — no essay. Overlay chrome matches `/powerline doctor`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
import { copyToClipboard } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
import { showSelectOverlay } from "../ui/overlay-chrome.ts";
|
|
12
|
+
import {
|
|
13
|
+
getSkillUsage,
|
|
14
|
+
invalidateSkillCache,
|
|
15
|
+
loadSkillCatalog,
|
|
16
|
+
type SkillEntry,
|
|
17
|
+
type SkillUsage,
|
|
18
|
+
} from "./skill-registry.ts";
|
|
19
|
+
|
|
20
|
+
/** Prompt-budget cap for skill descriptions (stricter than core's 1024). */
|
|
21
|
+
export const SKILL_DESCRIPTION_MAX_CHARS = 240;
|
|
22
|
+
|
|
23
|
+
export type SkillDoctorStatus = "ok" | "warn" | "fail";
|
|
24
|
+
|
|
25
|
+
export type SkillDoctorIssue =
|
|
26
|
+
| "unclosed-frontmatter"
|
|
27
|
+
| "missing-frontmatter"
|
|
28
|
+
| "missing-description"
|
|
29
|
+
| "description-budget"
|
|
30
|
+
| "duplicate-global-project"
|
|
31
|
+
| "unused"
|
|
32
|
+
| "warning"
|
|
33
|
+
| "none";
|
|
34
|
+
|
|
35
|
+
export interface SkillDoctorRow {
|
|
36
|
+
status: SkillDoctorStatus;
|
|
37
|
+
skill: string;
|
|
38
|
+
issue: SkillDoctorIssue;
|
|
39
|
+
detail: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ISSUE_LABEL: Record<SkillDoctorIssue, string> = {
|
|
43
|
+
"unclosed-frontmatter": "unclosed frontmatter",
|
|
44
|
+
"missing-frontmatter": "missing frontmatter",
|
|
45
|
+
"missing-description": "missing description",
|
|
46
|
+
"description-budget": "description over budget",
|
|
47
|
+
"duplicate-global-project": "duplicate global/project",
|
|
48
|
+
unused: "unused",
|
|
49
|
+
warning: "warning",
|
|
50
|
+
none: "no issues",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function hasUnclosedFrontmatter(content: string): boolean {
|
|
54
|
+
const lines = content.split("\n");
|
|
55
|
+
if (lines[0]?.trim() !== "---") return false;
|
|
56
|
+
for (let i = 1; i < lines.length; i++) {
|
|
57
|
+
if (lines[i]?.trim() === "---") return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function hasClosedFrontmatter(content: string): boolean {
|
|
63
|
+
const lines = content.split("\n");
|
|
64
|
+
if (lines[0]?.trim() !== "---") return false;
|
|
65
|
+
for (let i = 1; i < lines.length; i++) {
|
|
66
|
+
if (lines[i]?.trim() === "---") return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isLoosePromptOrExtraFile(entry: SkillEntry): boolean {
|
|
72
|
+
return (
|
|
73
|
+
(entry.category === "prompts" || entry.category === "extra") &&
|
|
74
|
+
basename(entry.filePath) !== "SKILL.md" &&
|
|
75
|
+
!entry.isDirectorySkill
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pushRow(
|
|
80
|
+
rows: SkillDoctorRow[],
|
|
81
|
+
skill: string,
|
|
82
|
+
status: SkillDoctorStatus,
|
|
83
|
+
issue: SkillDoctorIssue,
|
|
84
|
+
detail: string,
|
|
85
|
+
): void {
|
|
86
|
+
rows.push({ status, skill, issue, detail });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Pure table builder. `contents` is optional file text keyed by `filePath`
|
|
91
|
+
* so unclosed-frontmatter can be detected without the filesystem.
|
|
92
|
+
*/
|
|
93
|
+
export function diagnoseSkills(
|
|
94
|
+
entries: readonly SkillEntry[],
|
|
95
|
+
usage: ReadonlyMap<string, SkillUsage>,
|
|
96
|
+
contents: ReadonlyMap<string, string> = new Map(),
|
|
97
|
+
): SkillDoctorRow[] {
|
|
98
|
+
const rows: SkillDoctorRow[] = [];
|
|
99
|
+
|
|
100
|
+
const byName = new Map<string, SkillEntry[]>();
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
const key = entry.name.toLowerCase();
|
|
103
|
+
const list = byName.get(key) ?? [];
|
|
104
|
+
list.push(entry);
|
|
105
|
+
byName.set(key, list);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const duplicateNames = new Set<string>();
|
|
109
|
+
for (const [name, group] of byName) {
|
|
110
|
+
const cats = new Set(group.map((e) => e.category));
|
|
111
|
+
if (cats.has("global") && cats.has("project")) duplicateNames.add(name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const content = contents.get(entry.filePath);
|
|
116
|
+
const description = entry.description.trim();
|
|
117
|
+
let hadFail = false;
|
|
118
|
+
if (content !== undefined && hasUnclosedFrontmatter(content)) {
|
|
119
|
+
hadFail = true;
|
|
120
|
+
pushRow(
|
|
121
|
+
rows,
|
|
122
|
+
entry.name,
|
|
123
|
+
"fail",
|
|
124
|
+
"unclosed-frontmatter",
|
|
125
|
+
`${entry.category} · ${entry.filePath}`,
|
|
126
|
+
);
|
|
127
|
+
} else if (
|
|
128
|
+
content !== undefined &&
|
|
129
|
+
!hasClosedFrontmatter(content) &&
|
|
130
|
+
!isLoosePromptOrExtraFile(entry)
|
|
131
|
+
) {
|
|
132
|
+
hadFail = true;
|
|
133
|
+
pushRow(
|
|
134
|
+
rows,
|
|
135
|
+
entry.name,
|
|
136
|
+
"fail",
|
|
137
|
+
"missing-frontmatter",
|
|
138
|
+
`${entry.category} · ${entry.filePath}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!hadFail && !description) {
|
|
143
|
+
hadFail = true;
|
|
144
|
+
pushRow(
|
|
145
|
+
rows,
|
|
146
|
+
entry.name,
|
|
147
|
+
"fail",
|
|
148
|
+
"missing-description",
|
|
149
|
+
`${entry.category} · model will not see this skill`,
|
|
150
|
+
);
|
|
151
|
+
} else if (!hadFail && description.length > SKILL_DESCRIPTION_MAX_CHARS) {
|
|
152
|
+
pushRow(
|
|
153
|
+
rows,
|
|
154
|
+
entry.name,
|
|
155
|
+
"warn",
|
|
156
|
+
"description-budget",
|
|
157
|
+
`${description.length}/${SKILL_DESCRIPTION_MAX_CHARS} chars`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (duplicateNames.has(entry.name.toLowerCase())) {
|
|
162
|
+
pushRow(
|
|
163
|
+
rows,
|
|
164
|
+
entry.name,
|
|
165
|
+
"warn",
|
|
166
|
+
"duplicate-global-project",
|
|
167
|
+
"same name in global and project",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const count = usage.get(entry.name)?.count ?? 0;
|
|
172
|
+
if (count === 0) {
|
|
173
|
+
pushRow(rows, entry.name, "warn", "unused", "usage ledger count 0");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (entry.warning && !hadFail) {
|
|
177
|
+
pushRow(rows, entry.name, "warn", "warning", entry.warning);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const rank: Record<SkillDoctorStatus, number> = { fail: 0, warn: 1, ok: 2 };
|
|
182
|
+
rows.sort((a, b) => {
|
|
183
|
+
const s = rank[a.status] - rank[b.status];
|
|
184
|
+
if (s !== 0) return s;
|
|
185
|
+
const n = a.skill.localeCompare(b.skill);
|
|
186
|
+
if (n !== 0) return n;
|
|
187
|
+
return a.issue.localeCompare(b.issue);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (rows.length === 0) {
|
|
191
|
+
return [
|
|
192
|
+
{
|
|
193
|
+
status: "ok",
|
|
194
|
+
skill: "catalog",
|
|
195
|
+
issue: "none",
|
|
196
|
+
detail: "no issues",
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
return rows;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function formatSkillDoctorRow(row: SkillDoctorRow): string {
|
|
204
|
+
const tag =
|
|
205
|
+
row.status === "ok" ? "[ok] " : row.status === "warn" ? "[warn]" : "[fail]";
|
|
206
|
+
if (row.issue === "none") {
|
|
207
|
+
return `${tag} catalog · no issues`;
|
|
208
|
+
}
|
|
209
|
+
return `${tag} ${row.skill} · ${ISSUE_LABEL[row.issue]}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function skillDoctorRowsToSelectItems(
|
|
213
|
+
rows: readonly SkillDoctorRow[],
|
|
214
|
+
): SelectItem[] {
|
|
215
|
+
return rows.map((row) => ({
|
|
216
|
+
label: formatSkillDoctorRow(row),
|
|
217
|
+
value: `${row.skill}: ${row.detail}`,
|
|
218
|
+
description: row.detail,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Build doctor rows and file contents for a cwd (testable without overlay). */
|
|
223
|
+
export function collectSkillDoctorInputs(cwd: string = process.cwd()): {
|
|
224
|
+
entries: SkillEntry[];
|
|
225
|
+
usage: Map<string, SkillUsage>;
|
|
226
|
+
contents: Map<string, string>;
|
|
227
|
+
} {
|
|
228
|
+
invalidateSkillCache();
|
|
229
|
+
const entries = loadSkillCatalog(cwd);
|
|
230
|
+
const usage = getSkillUsage();
|
|
231
|
+
const contents = new Map<string, string>();
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
try {
|
|
234
|
+
contents.set(entry.filePath, readFileSync(entry.filePath, "utf8"));
|
|
235
|
+
} catch {
|
|
236
|
+
// unreadables already surface as registry warnings
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { entries, usage, contents };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Overlay table. Enter copies the selected line. */
|
|
243
|
+
export async function runSkillDoctor(ctx: any): Promise<void> {
|
|
244
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
245
|
+
const { entries, usage, contents } = collectSkillDoctorInputs(cwd);
|
|
246
|
+
const items = skillDoctorRowsToSelectItems(
|
|
247
|
+
diagnoseSkills(entries, usage, contents),
|
|
248
|
+
);
|
|
249
|
+
const picked = await showSelectOverlay(
|
|
250
|
+
ctx,
|
|
251
|
+
"Skills doctor",
|
|
252
|
+
"↑↓ navigate · enter copy · esc close",
|
|
253
|
+
items,
|
|
254
|
+
Math.min(Math.max(items.length, 1), 20),
|
|
255
|
+
);
|
|
256
|
+
if (!picked) return;
|
|
257
|
+
try {
|
|
258
|
+
await copyToClipboard(picked.value);
|
|
259
|
+
ctx.ui.notify("Skill doctor row copied to clipboard", "info");
|
|
260
|
+
} catch {
|
|
261
|
+
ctx.ui.notify("Could not copy skill doctor row to clipboard", "warning");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -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,
|
|
@@ -28,6 +26,8 @@ import {
|
|
|
28
26
|
type SkillCategory,
|
|
29
27
|
type SkillEntry,
|
|
30
28
|
} from "./skill-registry.ts";
|
|
29
|
+
import { runSkillDoctor } from "./skill-doctor.ts";
|
|
30
|
+
import { runSkillsNew } from "./skill-templates.ts";
|
|
31
31
|
|
|
32
32
|
const CATEGORY_LABELS: Record<SkillCategory | "all", string> = {
|
|
33
33
|
all: "alles",
|
|
@@ -89,21 +89,20 @@ function editorCommand(path: string): string {
|
|
|
89
89
|
return `!${ed} ${shellQuote(path)}`;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export async function showSkillManager(ctx: any): Promise<
|
|
92
|
+
export async function showSkillManager(ctx: any): Promise<"new" | null> {
|
|
93
93
|
invalidateSkillCache();
|
|
94
94
|
let entries = loadSkillCatalog(ctx.cwd ?? process.cwd());
|
|
95
95
|
const usage = getSkillUsage();
|
|
96
96
|
if (entries.length === 0) {
|
|
97
97
|
ctx.ui.notify("No skills found", "info");
|
|
98
|
-
return;
|
|
99
98
|
}
|
|
100
99
|
|
|
101
|
-
|
|
100
|
+
return ctx.ui.custom(
|
|
102
101
|
(
|
|
103
102
|
tui: any,
|
|
104
103
|
theme: Theme,
|
|
105
104
|
_keybindings: any,
|
|
106
|
-
done: (result: null) => void,
|
|
105
|
+
done: (result: "new" | null) => void,
|
|
107
106
|
) => {
|
|
108
107
|
const border = (text: string) => theme.fg("dim", text);
|
|
109
108
|
const wrapRow = (text: string, innerWidth: number): string =>
|
|
@@ -182,11 +181,12 @@ export async function showSkillManager(ctx: any): Promise<void> {
|
|
|
182
181
|
lines.push(border(`├${"─".repeat(innerWidth)}┤`));
|
|
183
182
|
|
|
184
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}"`;
|
|
185
188
|
lines.push(
|
|
186
|
-
wrapRow(
|
|
187
|
-
theme.fg("warning", `Geen skills voor "${query}"`),
|
|
188
|
-
innerWidth,
|
|
189
|
-
),
|
|
189
|
+
wrapRow(theme.fg("warning", emptyMsg), innerWidth),
|
|
190
190
|
);
|
|
191
191
|
} else {
|
|
192
192
|
// scroll-window rondom de selectie
|
|
@@ -395,13 +395,8 @@ export async function showSkillManager(ctx: any): Promise<void> {
|
|
|
395
395
|
return;
|
|
396
396
|
}
|
|
397
397
|
} else if (data === "\x0e") {
|
|
398
|
-
// ctrl+n =
|
|
399
|
-
|
|
400
|
-
ctx,
|
|
401
|
-
`!mkdir -p ${shellQuote(join(getAgentPath("skills"), "<naam>"))} && ${editorCommand(join(getAgentPath("skills"), "<naam>", "SKILL.md")).slice(1)}`,
|
|
402
|
-
"Nieuwe skill: vervang <naam>, enter draait 'm",
|
|
403
|
-
);
|
|
404
|
-
close();
|
|
398
|
+
// ctrl+n = new skill from a template
|
|
399
|
+
done("new");
|
|
405
400
|
return;
|
|
406
401
|
} else if (data === "\x04") {
|
|
407
402
|
// ctrl+d = verwijderen (met confirm)
|
|
@@ -470,18 +465,34 @@ export async function showSkillManager(ctx: any): Promise<void> {
|
|
|
470
465
|
}
|
|
471
466
|
|
|
472
467
|
/** Registreer de `/skills` command. */
|
|
468
|
+
export type SkillManagerCommandDeps = {
|
|
469
|
+
runDoctor?: (ctx: any) => Promise<void>;
|
|
470
|
+
};
|
|
471
|
+
|
|
473
472
|
export function registerSkillManagerCommand(
|
|
474
473
|
pi: ExtensionAPI,
|
|
475
474
|
rt: RuntimeState,
|
|
475
|
+
deps: SkillManagerCommandDeps = {},
|
|
476
476
|
): void {
|
|
477
|
+
const runDoctor = deps.runDoctor ?? runSkillDoctor;
|
|
477
478
|
pi.registerCommand("skills", {
|
|
478
|
-
description: "Browse installed skills
|
|
479
|
-
handler: async (
|
|
479
|
+
description: "Browse installed skills, or `doctor` / `new [template]`",
|
|
480
|
+
handler: async (args: string, ctx: any) => {
|
|
480
481
|
if (!rt.enabled || !ctx.hasUI) {
|
|
481
482
|
ctx.ui.notify("Powerline UI is disabled", "info");
|
|
482
483
|
return;
|
|
483
484
|
}
|
|
484
|
-
|
|
485
|
+
const sub = args?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
486
|
+
if (sub === "doctor") {
|
|
487
|
+
await runDoctor(ctx);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
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, "");
|
|
485
496
|
},
|
|
486
497
|
});
|
|
487
498
|
}
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
import { loadSkills } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
|
14
|
-
import { dirname, join, relative } from "node:path";
|
|
14
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
15
15
|
import { getAgentDir, getAgentPath } from "../../paths/agent-dirs.ts";
|
|
16
|
-
import { stripFrontmatter } from "../../core/frontmatter.ts";
|
|
16
|
+
import { parseSkillFrontmatter, stripFrontmatter } from "../../core/frontmatter.ts";
|
|
17
17
|
|
|
18
18
|
/** Categorie: waar de skill vandaan komt. */
|
|
19
19
|
export type SkillCategory = "global" | "project" | "prompts" | "extra";
|
|
@@ -158,6 +158,92 @@ function parseFrontmatterKeys(content: string): string[] {
|
|
|
158
158
|
return keys;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/** Walk canonical skill trees the same way pi core discovers nested SKILL.md. */
|
|
162
|
+
function walkCanonicalSkillMdFiles(dir: string, visit: (filePath: string) => void): void {
|
|
163
|
+
if (!existsSync(dir)) return;
|
|
164
|
+
let entries;
|
|
165
|
+
try {
|
|
166
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
167
|
+
} catch {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const skillMd = entries.find((entry) => entry.isFile() && entry.name === "SKILL.md");
|
|
171
|
+
if (skillMd) {
|
|
172
|
+
visit(join(dir, skillMd.name));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
walkCanonicalSkillMdFiles(join(dir, entry.name), visit);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Core rejects these paths (skill: null) but still emits diagnostics — surface them for doctor/manager. */
|
|
184
|
+
function buildRejectedSkillEntries(
|
|
185
|
+
diagnostics: { message: string; path?: string }[],
|
|
186
|
+
knownPaths: Set<string>,
|
|
187
|
+
cwd: string,
|
|
188
|
+
extras: { path: string; category: SkillCategory }[],
|
|
189
|
+
): SkillEntry[] {
|
|
190
|
+
const byPath = new Map<string, string>();
|
|
191
|
+
for (const d of diagnostics) {
|
|
192
|
+
if (!d.path || knownPaths.has(d.path)) continue;
|
|
193
|
+
if (!byPath.has(d.path)) byPath.set(d.path, d.message);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const agent = getAgentDir();
|
|
197
|
+
for (const root of [join(agent, "skills"), join(cwd, ".pi", "skills"), join(cwd, "skills")]) {
|
|
198
|
+
walkCanonicalSkillMdFiles(root, (filePath) => {
|
|
199
|
+
if (!knownPaths.has(filePath) && !byPath.has(filePath)) {
|
|
200
|
+
byPath.set(filePath, "skill file not loaded by catalog");
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const out: SkillEntry[] = [];
|
|
206
|
+
for (const [filePath, message] of byPath) {
|
|
207
|
+
let content = "";
|
|
208
|
+
let sizeBytes = 0;
|
|
209
|
+
let lineCount = 0;
|
|
210
|
+
let mtimeMs = 0;
|
|
211
|
+
try {
|
|
212
|
+
content = readFileSync(filePath, "utf8");
|
|
213
|
+
sizeBytes = Buffer.byteLength(content, "utf8");
|
|
214
|
+
lineCount = content.split("\n").length;
|
|
215
|
+
mtimeMs = statSync(filePath).mtimeMs;
|
|
216
|
+
} catch {
|
|
217
|
+
// include unreadable paths so doctor can still report them
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const fm = parseSkillFrontmatter(content);
|
|
221
|
+
const base = basename(filePath);
|
|
222
|
+
const name =
|
|
223
|
+
fm.name ??
|
|
224
|
+
(base === "SKILL.md"
|
|
225
|
+
? basename(dirname(filePath))
|
|
226
|
+
: base.replace(/\.(md|txt)$/, ""));
|
|
227
|
+
|
|
228
|
+
out.push({
|
|
229
|
+
name,
|
|
230
|
+
description: fm.description ?? "",
|
|
231
|
+
filePath,
|
|
232
|
+
baseDir: dirname(filePath),
|
|
233
|
+
isDirectorySkill: base === "SKILL.md",
|
|
234
|
+
category: categorize(filePath, cwd, extras),
|
|
235
|
+
disableModelInvocation: false,
|
|
236
|
+
sizeBytes,
|
|
237
|
+
lineCount,
|
|
238
|
+
mtimeMs,
|
|
239
|
+
frontmatterKeys: parseFrontmatterKeys(content),
|
|
240
|
+
warning: message,
|
|
241
|
+
});
|
|
242
|
+
knownPaths.add(filePath);
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
161
247
|
/** Bouw de volledige skill-catalogus (gecached, TTL 30s). */
|
|
162
248
|
export function loadSkillCatalog(cwd: string = process.cwd()): SkillEntry[] {
|
|
163
249
|
const now = Date.now();
|
|
@@ -215,10 +301,23 @@ export function loadSkillCatalog(cwd: string = process.cwd()): SkillEntry[] {
|
|
|
215
301
|
|
|
216
302
|
// loose entries achteraan; bij naam-collisie wint core
|
|
217
303
|
const looseNames = new Set(loose.map((e) => e.name));
|
|
304
|
+
const catalogPaths = new Set([
|
|
305
|
+
...entries.map((e) => e.filePath),
|
|
306
|
+
...loose.map((e) => e.filePath),
|
|
307
|
+
]);
|
|
308
|
+
const rejected = buildRejectedSkillEntries(
|
|
309
|
+
result.diagnostics,
|
|
310
|
+
catalogPaths,
|
|
311
|
+
cwd,
|
|
312
|
+
extras,
|
|
313
|
+
);
|
|
218
314
|
cachedAt = now;
|
|
219
|
-
|
|
220
|
-
cachedEntries = [
|
|
221
|
-
.
|
|
315
|
+
cachedCwd = cwd;
|
|
316
|
+
cachedEntries = [
|
|
317
|
+
...entries.filter((e) => !looseNames.has(e.name)),
|
|
318
|
+
...loose,
|
|
319
|
+
...rejected,
|
|
320
|
+
].sort((a, b) => a.name.localeCompare(b.name));
|
|
222
321
|
cachedPathMap = new Map(cachedEntries.map((e) => [e.name, e.filePath] as const));
|
|
223
322
|
return cachedEntries;
|
|
224
323
|
}
|
|
@@ -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
|
+
}
|