@zaganjade/pi-multi-skill 1.0.0 → 1.3.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.
- package/README.md +453 -17
- package/package.json +9 -3
- package/skill-bundles.example.json +17 -0
- package/skill-bundles.example.yaml +9 -0
- package/src/bmad-auto.ts +99 -0
- package/src/bmad-status.ts +125 -0
- package/src/build.ts +270 -0
- package/src/bundle-status.ts +178 -0
- package/src/bundles.ts +138 -0
- package/src/completions.ts +184 -0
- package/src/conflicts.ts +26 -0
- package/src/discover.ts +161 -0
- package/src/index.ts +287 -345
- package/src/metadata.ts +170 -0
- package/src/parse-args.ts +80 -0
- package/src/registry.ts +46 -0
- package/src/stats.ts +164 -0
- package/src/subagents.ts +75 -0
- package/src/suggestions.ts +70 -0
- package/src/types.ts +64 -0
- package/src/yaml-bundles.ts +97 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { bmadAutoHint, resolveBmadAutoSkills } from "./bmad-auto.ts";
|
|
4
|
+
|
|
5
|
+
const STATUS_COMMANDS = new Set([
|
|
6
|
+
"/workflow-status",
|
|
7
|
+
"/status",
|
|
8
|
+
"/workflow-init",
|
|
9
|
+
"/init",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function readText(path: string): string | null {
|
|
13
|
+
try {
|
|
14
|
+
return readFileSync(path, "utf-8");
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function workflowStatus(content: string, key: string): string | null {
|
|
21
|
+
const patterns = [
|
|
22
|
+
new RegExp(`${key}:\\s*\\n[\\s\\S]*?status:\\s*([\\w-]+)`, "i"),
|
|
23
|
+
new RegExp(`${key}:[\\s\\S]*?status:\\s*([\\w\\s-]+)`, "i"),
|
|
24
|
+
];
|
|
25
|
+
for (const re of patterns) {
|
|
26
|
+
const match = content.match(re);
|
|
27
|
+
if (match) return match[1].trim();
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function projectMeta(cwd: string): {
|
|
33
|
+
level: number;
|
|
34
|
+
name: string | null;
|
|
35
|
+
type: string | null;
|
|
36
|
+
} {
|
|
37
|
+
const content = readText(join(cwd, "bmad", "config.yaml"));
|
|
38
|
+
if (!content) return { level: 1, name: null, type: null };
|
|
39
|
+
const level = content.match(/project_level:\s*(\d)/i);
|
|
40
|
+
const name = content.match(/project_name:\s*["']?([^"'\n]+)/i);
|
|
41
|
+
const type = content.match(/project_type:\s*["']?([^"'\n]+)/i);
|
|
42
|
+
return {
|
|
43
|
+
level: level ? Number.parseInt(level[1], 10) : 1,
|
|
44
|
+
name: name?.[1]?.trim() ?? null,
|
|
45
|
+
type: type?.[1]?.trim() ?? null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const WORKFLOW_KEYS = [
|
|
50
|
+
"product-brief",
|
|
51
|
+
"brainstorm",
|
|
52
|
+
"research",
|
|
53
|
+
"prd",
|
|
54
|
+
"tech-spec",
|
|
55
|
+
"architecture",
|
|
56
|
+
"ux-design",
|
|
57
|
+
"sprint-planning",
|
|
58
|
+
"dev-story",
|
|
59
|
+
"create-story",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export function shouldInjectBmadStatus(options: {
|
|
63
|
+
embeddedCommand?: string;
|
|
64
|
+
auto?: boolean;
|
|
65
|
+
}): boolean {
|
|
66
|
+
if (options.auto) return true;
|
|
67
|
+
if (!options.embeddedCommand) return false;
|
|
68
|
+
const cmd = options.embeddedCommand.split(/\s+/)[0] ?? "";
|
|
69
|
+
return STATUS_COMMANDS.has(cmd);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildBmadStatusBlock(cwd: string): string | null {
|
|
73
|
+
const statusPath = join(cwd, "docs", "bmm-workflow-status.yaml");
|
|
74
|
+
const configPath = join(cwd, "bmad", "config.yaml");
|
|
75
|
+
const statusExists = existsSync(statusPath);
|
|
76
|
+
const configExists = existsSync(configPath);
|
|
77
|
+
|
|
78
|
+
if (!statusExists && !configExists) return null;
|
|
79
|
+
|
|
80
|
+
const hint = bmadAutoHint(cwd);
|
|
81
|
+
const phaseMatch = hint?.match(/phase:\s*(\w+)/);
|
|
82
|
+
const phase = phaseMatch?.[1] ?? "unknown";
|
|
83
|
+
const project = projectMeta(cwd);
|
|
84
|
+
const recommendedSkills = resolveBmadAutoSkills(cwd);
|
|
85
|
+
|
|
86
|
+
const lines: string[] = [
|
|
87
|
+
`<bmad_status phase="${phase}" project_level="${project.level}" status_file="${statusExists ? "docs/bmm-workflow-status.yaml" : "missing"}">`,
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
if (project.name) lines.push(`Project: ${project.name}`);
|
|
91
|
+
if (project.type) lines.push(`Type: ${project.type}`);
|
|
92
|
+
lines.push(`Detected phase: ${phase}`);
|
|
93
|
+
lines.push(`Recommended skills for this phase: ${recommendedSkills.join(", ")}`);
|
|
94
|
+
|
|
95
|
+
if (statusExists) {
|
|
96
|
+
const content = readText(statusPath) ?? "";
|
|
97
|
+
const incomplete: string[] = [];
|
|
98
|
+
const complete: string[] = [];
|
|
99
|
+
for (const key of WORKFLOW_KEYS) {
|
|
100
|
+
const status = workflowStatus(content, key);
|
|
101
|
+
if (!status) continue;
|
|
102
|
+
if (/complete|done|finished/i.test(status)) {
|
|
103
|
+
complete.push(`${key}: ${status}`);
|
|
104
|
+
} else {
|
|
105
|
+
incomplete.push(`${key}: ${status}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (incomplete.length > 0) {
|
|
109
|
+
lines.push("", "Incomplete / in-progress workflows:");
|
|
110
|
+
for (const item of incomplete.slice(0, 8)) lines.push(` • ${item}`);
|
|
111
|
+
}
|
|
112
|
+
if (complete.length > 0) {
|
|
113
|
+
lines.push("", "Completed workflows:");
|
|
114
|
+
for (const item of complete.slice(0, 5)) lines.push(` • ${item}`);
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
lines.push("", "BMAD not initialized in this project.");
|
|
118
|
+
lines.push("Run /workflow-init or /skills bmad-master /workflow-init to set up.");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
lines.push("", "Use this context when executing the embedded BMAD command.");
|
|
122
|
+
lines.push("</bmad_status>");
|
|
123
|
+
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import type { BuildOptions, EnrichedSkillInfo, LoadMode } from "./types.ts";
|
|
5
|
+
import { stripFrontmatter } from "./metadata.ts";
|
|
6
|
+
import { buildParallelDispatchBlock } from "./subagents.ts";
|
|
7
|
+
|
|
8
|
+
const SUBAGENT_STOP = "<SUBAGENT-STOP>";
|
|
9
|
+
const SKILL_CHECK_MARKERS = [
|
|
10
|
+
"If you think there is even a 1% chance a skill might apply",
|
|
11
|
+
"Invoke relevant or requested skills BEFORE any response",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function getAgentDir(): string {
|
|
15
|
+
return join(homedir(), ".pi", "agent");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readSkillFile(skill: EnrichedSkillInfo, cwd: string): string | null {
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(skill.filePath, "utf-8");
|
|
21
|
+
} catch {
|
|
22
|
+
// fall through
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const dirs = [join(getAgentDir(), "skills"), join(cwd, ".pi", "skills")];
|
|
26
|
+
for (const dir of dirs) {
|
|
27
|
+
for (const candidate of [
|
|
28
|
+
join(dir, skill.name, "SKILL.md"),
|
|
29
|
+
join(dir, `${skill.name}.md`),
|
|
30
|
+
]) {
|
|
31
|
+
try {
|
|
32
|
+
return readFileSync(candidate, "utf-8");
|
|
33
|
+
} catch {
|
|
34
|
+
// continue
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extractCommandsSection(body: string): string {
|
|
42
|
+
const match = body.match(
|
|
43
|
+
/## Available Commands[\s\S]*?(?=\n## |\n# |$)/i,
|
|
44
|
+
);
|
|
45
|
+
return match ? match[0].trim() : "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatMetaBlock(skill: EnrichedSkillInfo): string {
|
|
49
|
+
const commands = skill.metadata.commands.length
|
|
50
|
+
? `\n\nCommands: ${skill.metadata.commands.join(", ")}`
|
|
51
|
+
: "";
|
|
52
|
+
const commandsSection = extractCommandsSection(skill.body);
|
|
53
|
+
const sectionText = commandsSection ? `\n\n${commandsSection}` : "";
|
|
54
|
+
|
|
55
|
+
return `<skill name="${skill.name}" location="${skill.filePath}" mode="meta">
|
|
56
|
+
${skill.metadata.description}${commands}${sectionText}
|
|
57
|
+
</skill>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatLazyBlock(skill: EnrichedSkillInfo, body: string): string {
|
|
61
|
+
const baseDir = dirname(skill.filePath);
|
|
62
|
+
const intro = body
|
|
63
|
+
.split("\n")
|
|
64
|
+
.filter((l) => l.trim() && !l.startsWith("#"))
|
|
65
|
+
.slice(0, 8)
|
|
66
|
+
.join("\n");
|
|
67
|
+
|
|
68
|
+
return `<skill name="${skill.name}" location="${skill.filePath}" mode="lazy">
|
|
69
|
+
References are relative to ${baseDir}. Load reference files with \`read\` only when the active workflow step requires them.
|
|
70
|
+
|
|
71
|
+
${intro}
|
|
72
|
+
|
|
73
|
+
${extractCommandsSection(body)}
|
|
74
|
+
</skill>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatFullBlock(skill: EnrichedSkillInfo, body: string): string {
|
|
78
|
+
const baseDir = dirname(skill.filePath);
|
|
79
|
+
return `<skill name="${skill.name}" location="${skill.filePath}" mode="full">
|
|
80
|
+
References are relative to ${baseDir}.
|
|
81
|
+
|
|
82
|
+
${body}
|
|
83
|
+
</skill>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function dedupeBody(name: string, body: string, seen: Set<string>): string {
|
|
87
|
+
let result = body;
|
|
88
|
+
|
|
89
|
+
if (body.includes(SUBAGENT_STOP)) {
|
|
90
|
+
if (seen.has(SUBAGENT_STOP)) {
|
|
91
|
+
result = result.replace(
|
|
92
|
+
new RegExp(`${SUBAGENT_STOP}[\\s\\S]*?${SUBAGENT_STOP}`, "g"),
|
|
93
|
+
"",
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
seen.add(SUBAGENT_STOP);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (name !== "using-superpowers") {
|
|
101
|
+
for (const marker of SKILL_CHECK_MARKERS) {
|
|
102
|
+
if (result.includes(marker) && seen.has(marker)) {
|
|
103
|
+
result = result.replace(
|
|
104
|
+
new RegExp(`## Instruction Priority[\\s\\S]*?(?=\\n## |$)`, "i"),
|
|
105
|
+
"",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (result.includes(marker)) seen.add(marker);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return result.trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderSkillBlock(
|
|
116
|
+
skill: EnrichedSkillInfo,
|
|
117
|
+
cwd: string,
|
|
118
|
+
mode: LoadMode,
|
|
119
|
+
seenDedup: Set<string>,
|
|
120
|
+
): string | null {
|
|
121
|
+
const effectiveMode = skill.metadata.tokenBudget ?? mode;
|
|
122
|
+
const raw = readSkillFile(skill, cwd);
|
|
123
|
+
if (!raw) return null;
|
|
124
|
+
|
|
125
|
+
const body = dedupeBody(skill.name, stripFrontmatter(raw).trim(), seenDedup);
|
|
126
|
+
switch (effectiveMode) {
|
|
127
|
+
case "meta":
|
|
128
|
+
return formatMetaBlock(skill);
|
|
129
|
+
case "lazy":
|
|
130
|
+
return formatLazyBlock(skill, body);
|
|
131
|
+
default:
|
|
132
|
+
return formatFullBlock(skill, body);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function buildCombinedMessage(
|
|
137
|
+
skills: EnrichedSkillInfo[],
|
|
138
|
+
cwd: string,
|
|
139
|
+
options: BuildOptions,
|
|
140
|
+
): { message: string; notFound: string[]; skippedDuplicates: string[] } {
|
|
141
|
+
const expandedBlocks: string[] = [];
|
|
142
|
+
const notFound: string[] = [];
|
|
143
|
+
const seenDedup = new Set<string>();
|
|
144
|
+
const skippedDuplicates: string[] = [];
|
|
145
|
+
|
|
146
|
+
for (const skill of skills) {
|
|
147
|
+
const before = seenDedup.size;
|
|
148
|
+
const block = renderSkillBlock(skill, cwd, options.mode, seenDedup);
|
|
149
|
+
if (!block) {
|
|
150
|
+
notFound.push(skill.name);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (seenDedup.size > before && before > 0) {
|
|
154
|
+
skippedDuplicates.push(`${skill.name} (deduplicated sections)`);
|
|
155
|
+
}
|
|
156
|
+
expandedBlocks.push(block);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const skillNames = skills.map((s) => s.name).join(", ");
|
|
160
|
+
const bundleAttr =
|
|
161
|
+
options.bundles && options.bundles.length > 0
|
|
162
|
+
? ` bundles="${options.bundles.map((b) => `@${b}`).join(",")}"`
|
|
163
|
+
: "";
|
|
164
|
+
const parts: string[] = [
|
|
165
|
+
`<manually_attached_skills count="${skills.length}"${bundleAttr}>`,
|
|
166
|
+
"The user activated multiple skills. Follow ALL of them before responding.",
|
|
167
|
+
"Priority: process skills → planning → implementation. User instructions override conflicts.",
|
|
168
|
+
`Skills: ${skillNames}`,
|
|
169
|
+
`Load mode: ${options.mode}`,
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
if (options.parallel) {
|
|
173
|
+
const tasks =
|
|
174
|
+
options.parallelTasks && options.parallelTasks.length > 0
|
|
175
|
+
? options.parallelTasks
|
|
176
|
+
: options.instructions
|
|
177
|
+
? [options.instructions]
|
|
178
|
+
: [];
|
|
179
|
+
parts.push(
|
|
180
|
+
buildParallelDispatchBlock({
|
|
181
|
+
tasks,
|
|
182
|
+
subagentAvailable: options.subagentAvailable ?? false,
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
parts.push("");
|
|
188
|
+
parts.push(...expandedBlocks);
|
|
189
|
+
|
|
190
|
+
if (options.bmadStatusBlock) {
|
|
191
|
+
parts.push("");
|
|
192
|
+
parts.push(options.bmadStatusBlock);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (options.embeddedCommand) {
|
|
196
|
+
parts.push("");
|
|
197
|
+
parts.push(
|
|
198
|
+
`<embedded_command>${options.embeddedCommand}</embedded_command>`,
|
|
199
|
+
);
|
|
200
|
+
parts.push(
|
|
201
|
+
"Execute the embedded command workflow as part of fulfilling the user request.",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (options.instructions) {
|
|
206
|
+
parts.push("");
|
|
207
|
+
parts.push("<user_query>");
|
|
208
|
+
parts.push(options.instructions);
|
|
209
|
+
parts.push("</user_query>");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (notFound.length > 0) {
|
|
213
|
+
parts.push("");
|
|
214
|
+
parts.push(`> ⚠️ Skills not found: ${notFound.join(", ")}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (options.conflictWarnings?.length) {
|
|
218
|
+
parts.push("");
|
|
219
|
+
parts.push(`> ⚠️ Conflicts: ${options.conflictWarnings.join("; ")}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (options.skippedDuplicates?.length) {
|
|
223
|
+
parts.push("");
|
|
224
|
+
parts.push(
|
|
225
|
+
`> ℹ️ Deduplicated: ${options.skippedDuplicates.join(", ")}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
parts.push("</manually_attached_skills>");
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
message: parts.join("\n"),
|
|
233
|
+
notFound,
|
|
234
|
+
skippedDuplicates,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function resolveAndReadLegacySkill(
|
|
239
|
+
skillName: string,
|
|
240
|
+
filePath: string,
|
|
241
|
+
cwd: string,
|
|
242
|
+
): string | null {
|
|
243
|
+
if (existsSync(filePath)) {
|
|
244
|
+
try {
|
|
245
|
+
const content = readFileSync(filePath, "utf-8");
|
|
246
|
+
const body = stripFrontmatter(content).trim();
|
|
247
|
+
const baseDir = dirname(filePath);
|
|
248
|
+
return `<skill name="${skillName}" location="${filePath}">\nReferences are relative to ${baseDir}.\n\n${body}\n</skill>`;
|
|
249
|
+
} catch {
|
|
250
|
+
// fall through
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const dirs = [join(getAgentDir(), "skills"), join(cwd, ".pi", "skills")];
|
|
255
|
+
for (const dir of dirs) {
|
|
256
|
+
for (const candidate of [
|
|
257
|
+
join(dir, skillName, "SKILL.md"),
|
|
258
|
+
join(dir, `${skillName}.md`),
|
|
259
|
+
]) {
|
|
260
|
+
try {
|
|
261
|
+
const content = readFileSync(candidate, "utf-8");
|
|
262
|
+
const body = stripFrontmatter(content).trim();
|
|
263
|
+
return `<skill name="${skillName}" location="${candidate}">\nReferences are relative to ${dirname(candidate)}.\n\n${body}\n</skill>`;
|
|
264
|
+
} catch {
|
|
265
|
+
// continue
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { SkillBundle, SkillInfo } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export interface BundleAvailability {
|
|
4
|
+
name: string;
|
|
5
|
+
bundle: SkillBundle;
|
|
6
|
+
available: string[];
|
|
7
|
+
missing: string[];
|
|
8
|
+
coverage: number;
|
|
9
|
+
ready: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const SKILL_INSTALL_HINTS: Record<string, string> = {
|
|
13
|
+
"bmad-master": "BMAD core — clone to ~/.claude/skills/bmad or add to pi settings \"skills\"",
|
|
14
|
+
analyst: "BMAD analyst — part of ~/.claude/skills/bmad/bmm/",
|
|
15
|
+
pm: "BMAD pm — part of ~/.claude/skills/bmad/bmm/",
|
|
16
|
+
architect: "BMAD architect — part of ~/.claude/skills/bmad/bmm/",
|
|
17
|
+
"ux-designer": "BMAD ux-designer — part of ~/.claude/skills/bmad/bmm/",
|
|
18
|
+
developer: "BMAD developer — part of ~/.claude/skills/bmad/bmm/",
|
|
19
|
+
"scrum-master": "BMAD scrum-master — part of ~/.claude/skills/bmad/bmm/",
|
|
20
|
+
"using-superpowers": "Superpowers — Claude Code plugin cache or ~/.pi/agent/skills",
|
|
21
|
+
brainstorming: "Superpowers — Claude Code plugin cache or ~/.pi/agent/skills",
|
|
22
|
+
"writing-plans": "Superpowers — Claude Code plugin cache or ~/.pi/agent/skills",
|
|
23
|
+
"test-driven-development": "Superpowers — Claude Code plugin cache or ~/.pi/agent/skills",
|
|
24
|
+
"requesting-code-review": "Superpowers — Claude Code plugin cache or ~/.pi/agent/skills",
|
|
25
|
+
"systematic-debugging": "Superpowers — Claude Code plugin cache or ~/.pi/agent/skills",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function assessBundleAvailability(
|
|
29
|
+
name: string,
|
|
30
|
+
bundle: SkillBundle,
|
|
31
|
+
availableSkills: SkillInfo[],
|
|
32
|
+
): BundleAvailability {
|
|
33
|
+
const availableNames = new Set(availableSkills.map((s) => s.name));
|
|
34
|
+
const available = bundle.skills.filter((s) => availableNames.has(s));
|
|
35
|
+
const missing = bundle.skills.filter((s) => !availableNames.has(s));
|
|
36
|
+
const coverage =
|
|
37
|
+
bundle.skills.length === 0
|
|
38
|
+
? 0
|
|
39
|
+
: Math.round((available.length / bundle.skills.length) * 100);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
name,
|
|
43
|
+
bundle,
|
|
44
|
+
available,
|
|
45
|
+
missing,
|
|
46
|
+
coverage,
|
|
47
|
+
ready: available.length > 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function assessAllBundles(
|
|
52
|
+
bundles: Map<string, SkillBundle>,
|
|
53
|
+
availableSkills: SkillInfo[],
|
|
54
|
+
): BundleAvailability[] {
|
|
55
|
+
return [...bundles.entries()]
|
|
56
|
+
.map(([name, bundle]) => assessBundleAvailability(name, bundle, availableSkills))
|
|
57
|
+
.sort((a, b) => b.coverage - a.coverage || a.name.localeCompare(b.name));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatBundleCoverage(status: BundleAvailability): string {
|
|
61
|
+
if (status.coverage === 100) {
|
|
62
|
+
return `${status.available.length}/${status.bundle.skills.length} ready`;
|
|
63
|
+
}
|
|
64
|
+
if (status.coverage === 0) {
|
|
65
|
+
return `0/${status.bundle.skills.length} — install required`;
|
|
66
|
+
}
|
|
67
|
+
return `${status.available.length}/${status.bundle.skills.length} (${status.missing.length} missing)`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatBundleHelpLine(status: BundleAvailability): string {
|
|
71
|
+
const coverage = formatBundleCoverage(status);
|
|
72
|
+
const req = status.bundle.requires ? ` · ${status.bundle.requires}` : "";
|
|
73
|
+
return ` • @${status.name} — ${status.bundle.description} [${coverage}]${req}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function formatMissingSkillHints(missing: string[]): string {
|
|
77
|
+
const lines = missing.map((name) => {
|
|
78
|
+
const hint = SKILL_INSTALL_HINTS[name];
|
|
79
|
+
return hint ? ` • ${name} — ${hint}` : ` • ${name}`;
|
|
80
|
+
});
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatBundleFailureGuide(
|
|
85
|
+
bundleNames: string[],
|
|
86
|
+
bundles: Map<string, SkillBundle>,
|
|
87
|
+
availableSkills: SkillInfo[],
|
|
88
|
+
): string {
|
|
89
|
+
const lines: string[] = [
|
|
90
|
+
"Bundle skills not found on this machine.",
|
|
91
|
+
"",
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
for (const bundleName of bundleNames) {
|
|
95
|
+
const bundle = bundles.get(bundleName);
|
|
96
|
+
if (!bundle) continue;
|
|
97
|
+
const status = assessBundleAvailability(bundleName, bundle, availableSkills);
|
|
98
|
+
lines.push(`@${bundleName} — ${status.bundle.description}`);
|
|
99
|
+
if (status.bundle.install) {
|
|
100
|
+
lines.push(` Install: ${status.bundle.install}`);
|
|
101
|
+
}
|
|
102
|
+
if (status.missing.length > 0) {
|
|
103
|
+
lines.push(" Missing skills:");
|
|
104
|
+
lines.push(formatMissingSkillHints(status.missing));
|
|
105
|
+
}
|
|
106
|
+
lines.push("");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
lines.push(
|
|
110
|
+
"Options without installing presets:",
|
|
111
|
+
" 1. /skills skill1,skill2 — chain skills you already have",
|
|
112
|
+
" 2. Create ~/.pi/agent/skill-bundles.json with your own bundles",
|
|
113
|
+
" 3. /skills-setup — full install guide",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function formatSetupReport(
|
|
120
|
+
bundles: Map<string, SkillBundle>,
|
|
121
|
+
availableSkills: SkillInfo[],
|
|
122
|
+
): string {
|
|
123
|
+
const statuses = assessAllBundles(bundles, availableSkills);
|
|
124
|
+
const ready = statuses.filter((s) => s.coverage === 100);
|
|
125
|
+
const partial = statuses.filter((s) => s.ready && s.coverage < 100);
|
|
126
|
+
const unavailable = statuses.filter((s) => !s.ready);
|
|
127
|
+
|
|
128
|
+
const lines: string[] = [
|
|
129
|
+
"pi-multi-skill bundle setup",
|
|
130
|
+
"",
|
|
131
|
+
`Skills discovered: ${availableSkills.length}`,
|
|
132
|
+
`Bundles ready: ${ready.length}/${statuses.length}`,
|
|
133
|
+
"",
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
if (ready.length > 0) {
|
|
137
|
+
lines.push("Ready to use:");
|
|
138
|
+
for (const s of ready) {
|
|
139
|
+
lines.push(` @${s.name} — ${formatBundleCoverage(s)}`);
|
|
140
|
+
}
|
|
141
|
+
lines.push("");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (partial.length > 0) {
|
|
145
|
+
lines.push("Partial (loads available skills, warns about missing):");
|
|
146
|
+
for (const s of partial) {
|
|
147
|
+
lines.push(` @${s.name} — ${formatBundleCoverage(s)}`);
|
|
148
|
+
lines.push(` missing: ${s.missing.join(", ")}`);
|
|
149
|
+
}
|
|
150
|
+
lines.push("");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (unavailable.length > 0) {
|
|
154
|
+
lines.push("Not available (install required):");
|
|
155
|
+
for (const s of unavailable) {
|
|
156
|
+
lines.push(` @${s.name} — ${s.bundle.description}`);
|
|
157
|
+
if (s.bundle.install) lines.push(` ${s.bundle.install}`);
|
|
158
|
+
}
|
|
159
|
+
lines.push("");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
lines.push(
|
|
163
|
+
"Install BMAD (optional):",
|
|
164
|
+
" git clone https://github.com/bmad-code-org/BMAD-METHOD ~/.claude/skills/bmad",
|
|
165
|
+
' Add to ~/.pi/agent/settings.json → "skills": ["~/.claude/skills/bmad"]',
|
|
166
|
+
" /reload",
|
|
167
|
+
"",
|
|
168
|
+
"Install Superpowers (optional):",
|
|
169
|
+
" Use Claude Code Superpowers plugin, or copy skills to ~/.pi/agent/skills",
|
|
170
|
+
" pi-multi-skill also discovers ~/.claude/plugins/cache/**/skills/",
|
|
171
|
+
"",
|
|
172
|
+
"Custom bundles (no BMAD/Superpowers needed):",
|
|
173
|
+
" Copy skill-bundles.example.json → ~/.pi/agent/skill-bundles.json",
|
|
174
|
+
" List only skills you have installed.",
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return lines.join("\n");
|
|
178
|
+
}
|
package/src/bundles.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { SkillBundle } from "./types.ts";
|
|
5
|
+
import { parseYamlBundles } from "./yaml-bundles.ts";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_BUNDLES: Record<string, SkillBundle> = {
|
|
8
|
+
"bmad-planning": {
|
|
9
|
+
description: "BMAD Phase 1-2 — analysis & planning",
|
|
10
|
+
skills: ["bmad-master", "analyst", "pm"],
|
|
11
|
+
order: "process-first",
|
|
12
|
+
default_mode: "meta",
|
|
13
|
+
requires: "BMAD Method",
|
|
14
|
+
install:
|
|
15
|
+
"git clone BMAD-METHOD to ~/.claude/skills/bmad, add path in pi settings \"skills\"",
|
|
16
|
+
},
|
|
17
|
+
"bmad-solutioning": {
|
|
18
|
+
description: "BMAD Phase 3 — architecture & design",
|
|
19
|
+
skills: ["bmad-master", "architect", "ux-designer"],
|
|
20
|
+
order: "process-first",
|
|
21
|
+
default_mode: "meta",
|
|
22
|
+
requires: "BMAD Method",
|
|
23
|
+
install:
|
|
24
|
+
"git clone BMAD-METHOD to ~/.claude/skills/bmad, add path in pi settings \"skills\"",
|
|
25
|
+
},
|
|
26
|
+
"bmad-build": {
|
|
27
|
+
description: "BMAD Phase 4 — story implementation",
|
|
28
|
+
skills: ["bmad-master", "developer", "scrum-master"],
|
|
29
|
+
order: "process-first",
|
|
30
|
+
default_mode: "lazy",
|
|
31
|
+
requires: "BMAD Method",
|
|
32
|
+
install:
|
|
33
|
+
"git clone BMAD-METHOD to ~/.claude/skills/bmad, add path in pi settings \"skills\"",
|
|
34
|
+
},
|
|
35
|
+
"cc-feature": {
|
|
36
|
+
description: "Claude Code-style feature workflow",
|
|
37
|
+
skills: [
|
|
38
|
+
"using-superpowers",
|
|
39
|
+
"brainstorming",
|
|
40
|
+
"writing-plans",
|
|
41
|
+
"test-driven-development",
|
|
42
|
+
"requesting-code-review",
|
|
43
|
+
],
|
|
44
|
+
order: "process-first",
|
|
45
|
+
default_mode: "lazy",
|
|
46
|
+
requires: "Superpowers",
|
|
47
|
+
install:
|
|
48
|
+
"Install Claude Code Superpowers plugin, or copy skills to ~/.pi/agent/skills",
|
|
49
|
+
},
|
|
50
|
+
debug: {
|
|
51
|
+
description: "Systematic bug investigation",
|
|
52
|
+
skills: ["systematic-debugging", "test-driven-development"],
|
|
53
|
+
order: "process-first",
|
|
54
|
+
default_mode: "full",
|
|
55
|
+
requires: "Superpowers",
|
|
56
|
+
install:
|
|
57
|
+
"Install Claude Code Superpowers plugin, or copy skills to ~/.pi/agent/skills",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function getAgentDir(): string {
|
|
62
|
+
return join(homedir(), ".pi", "agent");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mergeBundleFile(
|
|
66
|
+
target: Map<string, SkillBundle>,
|
|
67
|
+
path: string,
|
|
68
|
+
): void {
|
|
69
|
+
try {
|
|
70
|
+
const raw = readFileSync(path, "utf-8");
|
|
71
|
+
const parsed =
|
|
72
|
+
path.endsWith(".yaml") || path.endsWith(".yml")
|
|
73
|
+
? { bundles: parseYamlBundles(raw) }
|
|
74
|
+
: (JSON.parse(raw) as { bundles?: Record<string, SkillBundle> });
|
|
75
|
+
if (!parsed.bundles) return;
|
|
76
|
+
for (const [name, bundle] of Object.entries(parsed.bundles)) {
|
|
77
|
+
if (Array.isArray(bundle.skills) && bundle.skills.length > 0) {
|
|
78
|
+
target.set(name, bundle);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Skip invalid bundle files
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function loadBundles(cwd: string): Map<string, SkillBundle> {
|
|
87
|
+
const bundles = new Map<string, SkillBundle>();
|
|
88
|
+
for (const [name, bundle] of Object.entries(DEFAULT_BUNDLES)) {
|
|
89
|
+
bundles.set(name, bundle);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const paths = [
|
|
93
|
+
join(getAgentDir(), "skill-bundles.json"),
|
|
94
|
+
join(getAgentDir(), "skill-bundles.yaml"),
|
|
95
|
+
join(getAgentDir(), "skill-bundles.yml"),
|
|
96
|
+
join(cwd, ".pi", "skill-bundles.json"),
|
|
97
|
+
join(cwd, ".pi", "skill-bundles.yaml"),
|
|
98
|
+
join(cwd, ".pi", "skill-bundles.yml"),
|
|
99
|
+
];
|
|
100
|
+
for (const path of paths) {
|
|
101
|
+
if (existsSync(path)) mergeBundleFile(bundles, path);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return bundles;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function expandSkillNames(
|
|
108
|
+
names: string[],
|
|
109
|
+
bundles: Map<string, SkillBundle>,
|
|
110
|
+
): { skills: string[]; modeHint?: SkillBundle["default_mode"] } {
|
|
111
|
+
const expanded: string[] = [];
|
|
112
|
+
let modeHint: SkillBundle["default_mode"] | undefined;
|
|
113
|
+
|
|
114
|
+
for (const name of names) {
|
|
115
|
+
const bundleName = name.startsWith("@") ? name.slice(1) : name;
|
|
116
|
+
const bundle = bundles.get(bundleName);
|
|
117
|
+
if (name.startsWith("@") && bundle) {
|
|
118
|
+
expanded.push(...bundle.skills);
|
|
119
|
+
modeHint = modeHint ?? bundle.default_mode;
|
|
120
|
+
} else {
|
|
121
|
+
expanded.push(name);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { skills: [...new Set(expanded)], modeHint };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function bundleOrderHint(
|
|
129
|
+
names: string[],
|
|
130
|
+
bundles: Map<string, SkillBundle>,
|
|
131
|
+
): SkillBundle["order"] | undefined {
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
if (!name.startsWith("@")) continue;
|
|
134
|
+
const bundle = bundles.get(name.slice(1));
|
|
135
|
+
if (bundle?.order) return bundle.order;
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|