@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,184 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
assessBundleAvailability,
|
|
5
|
+
formatBundleCoverage,
|
|
6
|
+
} from "./bundle-status.ts";
|
|
7
|
+
import { expandSkillNames } from "./bundles.ts";
|
|
8
|
+
import { enrichSkill } from "./metadata.ts";
|
|
9
|
+
import type { SkillBundle, SkillInfo } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
function resolveSkillNamesForCompletion(
|
|
12
|
+
skillPart: string,
|
|
13
|
+
bundles: Map<string, SkillBundle>,
|
|
14
|
+
): string[] {
|
|
15
|
+
const raw = skillPart
|
|
16
|
+
.split(",")
|
|
17
|
+
.map((s) => s.trim())
|
|
18
|
+
.filter((s) => s.length > 0);
|
|
19
|
+
if (raw.length === 0) return [];
|
|
20
|
+
return expandSkillNames(raw, bundles).skills;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function collectEmbeddedCommands(
|
|
24
|
+
skillNames: string[],
|
|
25
|
+
allSkills: SkillInfo[],
|
|
26
|
+
): Map<string, string> {
|
|
27
|
+
const byName = new Map(allSkills.map((s) => [s.name, s]));
|
|
28
|
+
const commands = new Map<string, string>();
|
|
29
|
+
|
|
30
|
+
for (const name of skillNames) {
|
|
31
|
+
const skill = byName.get(name);
|
|
32
|
+
if (!skill) continue;
|
|
33
|
+
const enriched = enrichSkill(skill);
|
|
34
|
+
for (const cmd of enriched.metadata.commands) {
|
|
35
|
+
if (!commands.has(cmd)) {
|
|
36
|
+
commands.set(cmd, enriched.name);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return commands;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function completeSkillNames(
|
|
45
|
+
prefix: string,
|
|
46
|
+
allSkills: SkillInfo[],
|
|
47
|
+
bundles: Map<string, SkillBundle>,
|
|
48
|
+
): AutocompleteItem[] {
|
|
49
|
+
const parts = prefix.split(",");
|
|
50
|
+
const currentPart = parts[parts.length - 1].trim();
|
|
51
|
+
const alreadySelected = parts
|
|
52
|
+
.slice(0, -1)
|
|
53
|
+
.map((s) => s.trim())
|
|
54
|
+
.filter((s) => s.length > 0);
|
|
55
|
+
|
|
56
|
+
const items: AutocompleteItem[] = [];
|
|
57
|
+
|
|
58
|
+
if (currentPart.startsWith("@") || currentPart === "") {
|
|
59
|
+
const bundleQuery = currentPart.startsWith("@")
|
|
60
|
+
? currentPart.slice(1)
|
|
61
|
+
: "";
|
|
62
|
+
for (const [name, bundle] of bundles) {
|
|
63
|
+
if (bundleQuery && !name.startsWith(bundleQuery)) continue;
|
|
64
|
+
if (alreadySelected.includes(`@${name}`)) continue;
|
|
65
|
+
const status = assessBundleAvailability(name, bundle, allSkills);
|
|
66
|
+
const prefixValue =
|
|
67
|
+
alreadySelected.length > 0
|
|
68
|
+
? `${parts.slice(0, -1).join(",")},@${name}`
|
|
69
|
+
: `@${name}`;
|
|
70
|
+
const coverage = formatBundleCoverage(status);
|
|
71
|
+
const req =
|
|
72
|
+
status.coverage === 0 && bundle.requires
|
|
73
|
+
? ` · needs ${bundle.requires}`
|
|
74
|
+
: "";
|
|
75
|
+
items.push({
|
|
76
|
+
value: prefixValue,
|
|
77
|
+
label: `@${name}`,
|
|
78
|
+
description: `${bundle.description} (${coverage})${req}`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const remaining = allSkills.filter(
|
|
84
|
+
(s) => !alreadySelected.includes(s.name),
|
|
85
|
+
);
|
|
86
|
+
let candidates: SkillInfo[] = [];
|
|
87
|
+
if (currentPart && !currentPart.startsWith("@")) {
|
|
88
|
+
candidates = remaining.filter((s) => s.name.startsWith(currentPart));
|
|
89
|
+
} else if (!currentPart.startsWith("@")) {
|
|
90
|
+
candidates = remaining;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const needsComma = alreadySelected.length > 0;
|
|
94
|
+
for (const s of candidates) {
|
|
95
|
+
items.push({
|
|
96
|
+
value: needsComma
|
|
97
|
+
? `${parts.slice(0, -1).join(",")},${s.name}`
|
|
98
|
+
: s.name,
|
|
99
|
+
label: alreadySelected.length > 0 ? ` ${s.name}` : s.name,
|
|
100
|
+
description: s.description || undefined,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return items.sort((a, b) => {
|
|
105
|
+
const aReady = a.description?.includes("0/") ? 1 : 0;
|
|
106
|
+
const bReady = b.description?.includes("0/") ? 1 : 0;
|
|
107
|
+
if (aReady !== bReady) return aReady - bReady;
|
|
108
|
+
return a.label.localeCompare(b.label);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function completeEmbeddedCommands(
|
|
113
|
+
skillPart: string,
|
|
114
|
+
commandPrefix: string,
|
|
115
|
+
allSkills: SkillInfo[],
|
|
116
|
+
bundles: Map<string, SkillBundle>,
|
|
117
|
+
pi: ExtensionAPI,
|
|
118
|
+
): AutocompleteItem[] {
|
|
119
|
+
const skillNames = resolveSkillNamesForCompletion(skillPart, bundles);
|
|
120
|
+
const fromSkills = collectEmbeddedCommands(skillNames, allSkills);
|
|
121
|
+
const query = commandPrefix.trim();
|
|
122
|
+
const normalizedQuery = query.startsWith("/") ? query : `/${query}`;
|
|
123
|
+
|
|
124
|
+
const items: AutocompleteItem[] = [];
|
|
125
|
+
const seen = new Set<string>();
|
|
126
|
+
|
|
127
|
+
for (const [cmd, sourceSkill] of fromSkills) {
|
|
128
|
+
if (normalizedQuery && !cmd.startsWith(normalizedQuery)) continue;
|
|
129
|
+
if (seen.has(cmd)) continue;
|
|
130
|
+
seen.add(cmd);
|
|
131
|
+
items.push({
|
|
132
|
+
value: `${skillPart} ${cmd}`,
|
|
133
|
+
label: cmd,
|
|
134
|
+
description: `from ${sourceSkill}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const cmd of pi.getCommands()) {
|
|
139
|
+
const slashName = cmd.name.startsWith("skill:")
|
|
140
|
+
? null
|
|
141
|
+
: `/${cmd.name}`;
|
|
142
|
+
if (!slashName) continue;
|
|
143
|
+
if (normalizedQuery && !slashName.startsWith(normalizedQuery)) continue;
|
|
144
|
+
if (seen.has(slashName)) continue;
|
|
145
|
+
seen.add(slashName);
|
|
146
|
+
items.push({
|
|
147
|
+
value: `${skillPart} ${slashName}`,
|
|
148
|
+
label: slashName,
|
|
149
|
+
description: cmd.description || cmd.source,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return items.sort((a, b) => a.label.localeCompare(b.label));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function getSkillsArgumentCompletions(
|
|
157
|
+
prefix: string,
|
|
158
|
+
allSkills: SkillInfo[],
|
|
159
|
+
bundles: Map<string, SkillBundle>,
|
|
160
|
+
pi: ExtensionAPI,
|
|
161
|
+
): AutocompleteItem[] | null {
|
|
162
|
+
const spaceIndex = prefix.indexOf(" ");
|
|
163
|
+
if (spaceIndex === -1) {
|
|
164
|
+
const items = completeSkillNames(prefix, allSkills, bundles);
|
|
165
|
+
return items.length > 0 ? items : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const skillPart = prefix.slice(0, spaceIndex);
|
|
169
|
+
const rest = prefix.slice(spaceIndex + 1);
|
|
170
|
+
|
|
171
|
+
// Embedded command phase: "/skills skill /command args"
|
|
172
|
+
if (rest.startsWith("/") || rest.length === 0) {
|
|
173
|
+
const items = completeEmbeddedCommands(
|
|
174
|
+
skillPart,
|
|
175
|
+
rest,
|
|
176
|
+
allSkills,
|
|
177
|
+
bundles,
|
|
178
|
+
pi,
|
|
179
|
+
);
|
|
180
|
+
return items.length > 0 ? items : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
}
|
package/src/conflicts.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { EnrichedSkillInfo } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Drop lower-priority skills that conflict with an earlier skill in the sorted list. */
|
|
4
|
+
export function resolveSkillConflicts(
|
|
5
|
+
skills: EnrichedSkillInfo[],
|
|
6
|
+
): { skills: EnrichedSkillInfo[]; warnings: string[] } {
|
|
7
|
+
const kept: EnrichedSkillInfo[] = [];
|
|
8
|
+
const warnings: string[] = [];
|
|
9
|
+
const keptNames = new Set<string>();
|
|
10
|
+
|
|
11
|
+
for (const skill of skills) {
|
|
12
|
+
const conflictsWithKept = skill.metadata.conflictsWith.filter((name) =>
|
|
13
|
+
keptNames.has(name),
|
|
14
|
+
);
|
|
15
|
+
if (conflictsWithKept.length > 0) {
|
|
16
|
+
warnings.push(
|
|
17
|
+
`Skipped ${skill.name} (conflicts with ${conflictsWithKept.join(", ")})`,
|
|
18
|
+
);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
kept.push(skill);
|
|
22
|
+
keptNames.add(skill.name);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { skills: kept, warnings };
|
|
26
|
+
}
|
package/src/discover.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
getAgentDir,
|
|
6
|
+
loadSkills,
|
|
7
|
+
loadSkillsFromDir,
|
|
8
|
+
type ExtensionAPI,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { truncateDescription } from "./metadata.ts";
|
|
11
|
+
import type { SkillInfo } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
function readSettingsSkillPaths(): string[] {
|
|
14
|
+
try {
|
|
15
|
+
const raw = readFileSync(join(getAgentDir(), "settings.json"), "utf-8");
|
|
16
|
+
const parsed = JSON.parse(raw) as { skills?: string[] };
|
|
17
|
+
return Array.isArray(parsed.skills) ? parsed.skills : [];
|
|
18
|
+
} catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fromPiCommands(pi: ExtensionAPI): Map<string, SkillInfo> {
|
|
24
|
+
const map = new Map<string, SkillInfo>();
|
|
25
|
+
for (const cmd of pi.getCommands()) {
|
|
26
|
+
if (cmd.source !== "skill") continue;
|
|
27
|
+
const name = cmd.name.startsWith("skill:")
|
|
28
|
+
? cmd.name.slice(6)
|
|
29
|
+
: cmd.name;
|
|
30
|
+
map.set(name, {
|
|
31
|
+
name,
|
|
32
|
+
description: truncateDescription(cmd.description || ""),
|
|
33
|
+
filePath: cmd.sourceInfo.path,
|
|
34
|
+
baseDir: cmd.sourceInfo.baseDir || dirname(cmd.sourceInfo.path),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return map;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Claude Code plugin cache — superpowers and other plugin skills not in pi settings. */
|
|
41
|
+
function discoverClaudePluginSkills(): SkillInfo[] {
|
|
42
|
+
const cacheRoot = join(homedir(), ".claude", "plugins", "cache");
|
|
43
|
+
if (!existsSync(cacheRoot)) return [];
|
|
44
|
+
|
|
45
|
+
const byName = new Map<string, SkillInfo>();
|
|
46
|
+
try {
|
|
47
|
+
for (const pluginEntry of readdirSync(cacheRoot, { withFileTypes: true })) {
|
|
48
|
+
if (!pluginEntry.isDirectory()) continue;
|
|
49
|
+
const pluginRoot = join(cacheRoot, pluginEntry.name);
|
|
50
|
+
for (const versionEntry of readdirSync(pluginRoot, {
|
|
51
|
+
withFileTypes: true,
|
|
52
|
+
})) {
|
|
53
|
+
if (!versionEntry.isDirectory()) continue;
|
|
54
|
+
const skillsDir = join(pluginRoot, versionEntry.name, "skills");
|
|
55
|
+
if (!existsSync(skillsDir)) continue;
|
|
56
|
+
const result = loadSkillsFromDir({
|
|
57
|
+
dir: skillsDir,
|
|
58
|
+
source: "claude-plugin",
|
|
59
|
+
});
|
|
60
|
+
for (const skill of result.skills) {
|
|
61
|
+
byName.set(skill.name, {
|
|
62
|
+
name: skill.name,
|
|
63
|
+
description: truncateDescription(skill.description || ""),
|
|
64
|
+
filePath: skill.filePath,
|
|
65
|
+
baseDir: skill.baseDir,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Non-fatal
|
|
72
|
+
}
|
|
73
|
+
return [...byName.values()];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function discoverCursorSkills(): SkillInfo[] {
|
|
77
|
+
const dir = join(homedir(), ".cursor", "skills-cursor");
|
|
78
|
+
if (!existsSync(dir)) return [];
|
|
79
|
+
try {
|
|
80
|
+
return loadSkillsFromDir({ dir, source: "cursor" }).skills.map((skill) => ({
|
|
81
|
+
name: skill.name,
|
|
82
|
+
description: truncateDescription(skill.description || ""),
|
|
83
|
+
filePath: skill.filePath,
|
|
84
|
+
baseDir: skill.baseDir,
|
|
85
|
+
}));
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Discover skills from every source pi and Claude Code use:
|
|
93
|
+
* getCommands(), settings skill paths, defaults, plugin cache, cursor skills.
|
|
94
|
+
*/
|
|
95
|
+
export function discoverAllSkills(
|
|
96
|
+
pi: ExtensionAPI,
|
|
97
|
+
cwd: string,
|
|
98
|
+
): SkillInfo[] {
|
|
99
|
+
const map = fromPiCommands(pi);
|
|
100
|
+
|
|
101
|
+
const loaded = loadSkills({
|
|
102
|
+
cwd,
|
|
103
|
+
agentDir: getAgentDir(),
|
|
104
|
+
skillPaths: readSettingsSkillPaths(),
|
|
105
|
+
includeDefaults: true,
|
|
106
|
+
});
|
|
107
|
+
for (const skill of loaded.skills) {
|
|
108
|
+
if (!map.has(skill.name)) {
|
|
109
|
+
map.set(skill.name, {
|
|
110
|
+
name: skill.name,
|
|
111
|
+
description: truncateDescription(skill.description || ""),
|
|
112
|
+
filePath: skill.filePath,
|
|
113
|
+
baseDir: skill.baseDir,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const skill of [
|
|
119
|
+
...discoverClaudePluginSkills(),
|
|
120
|
+
...discoverCursorSkills(),
|
|
121
|
+
]) {
|
|
122
|
+
if (!map.has(skill.name)) map.set(skill.name, skill);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Resolve a skill by name when it was requested but missing from the primary index. */
|
|
129
|
+
export function resolveSkillByName(
|
|
130
|
+
name: string,
|
|
131
|
+
known: Map<string, SkillInfo>,
|
|
132
|
+
): SkillInfo | undefined {
|
|
133
|
+
if (known.has(name)) return known.get(name);
|
|
134
|
+
|
|
135
|
+
for (const skill of discoverClaudePluginSkills()) {
|
|
136
|
+
if (skill.name === name) return skill;
|
|
137
|
+
}
|
|
138
|
+
for (const skill of discoverCursorSkills()) {
|
|
139
|
+
if (skill.name === name) return skill;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const dirs = [
|
|
143
|
+
join(getAgentDir(), "skills"),
|
|
144
|
+
join(cwd, ".pi", "skills"),
|
|
145
|
+
];
|
|
146
|
+
for (const dir of dirs) {
|
|
147
|
+
for (const candidate of [
|
|
148
|
+
join(dir, name, "SKILL.md"),
|
|
149
|
+
join(dir, `${name}.md`),
|
|
150
|
+
]) {
|
|
151
|
+
if (!existsSync(candidate)) continue;
|
|
152
|
+
return {
|
|
153
|
+
name,
|
|
154
|
+
description: "",
|
|
155
|
+
filePath: candidate,
|
|
156
|
+
baseDir: dirname(candidate),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|