pi-skill-router 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/index.ts +31 -0
- package/package.json +37 -0
- package/src/menu.ts +286 -0
- package/src/paths.ts +42 -0
- package/src/registry.ts +167 -0
- package/src/router.ts +65 -0
- package/src/sync.ts +869 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Frad LEE
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Pi Skill Router
|
|
2
|
+
|
|
3
|
+
`pi-skill-router` routes to **externally hosted skill collections**. It clones skill repositories into a user-level managed directory, wraps the skills you select as hidden leaves behind a visible gateway, exposes them to Pi, and provides focused routing suggestions.
|
|
4
|
+
|
|
5
|
+
**Package version:** 0.1.0
|
|
6
|
+
|
|
7
|
+
The package ships no skill content itself, and routed collections are never npm packages — they are plain Git repositories you add through the `/skill-router` menu.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
This package has not yet been released to npm. Install it from a local checkout:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install /path/to/pi-packages/packages/skill-router
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## How It Works
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
~/.pi/agent/skill-router/
|
|
21
|
+
├── collections.json # registry: source, selection, routing terms
|
|
22
|
+
├── cache/ # raw git clones (upstream content, unmodified)
|
|
23
|
+
└── exposed/ # materialized router output
|
|
24
|
+
└── collections/
|
|
25
|
+
└── <collection>/
|
|
26
|
+
├── gateway/
|
|
27
|
+
│ └── SKILL.md # generated gateway (the only skill Pi sees in `/`)
|
|
28
|
+
└── leaves/ # private sub-skills (suggested by router, hidden from `/`)
|
|
29
|
+
└── <leaf>/SKILL.md
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
1. **Add**: `/skill-router` → *Add collection* → enter `owner/repo[@ref]`, a GitHub URL, or a local path. The repo is shallow-cloned into `cache/`, scanned for `SKILL.md` files, and you choose which skills to route (or all). No prefix is required.
|
|
33
|
+
2. **Describe and materialize**: after selecting skills, confirm a short capability summary for the whole collection. The suggested text is derived from the selected skill names; edit it to describe the collection's domain and tasks rather than its Git source. The summary becomes the gateway's frontmatter description. Selected skills are saved into `exposed/collections/<collection>/leaves/<name>/`, while the single visible gateway is created in `exposed/collections/<collection>/gateway/SKILL.md`. Keeping the gateway and leaves in one atomic collection tree prevents partial materialization. Its body indexes the selected workflows with descriptions and exact relative leaf paths. Sub-skills remain clean without synthetic name prefixes.
|
|
34
|
+
3. **Expose**: on session start and `/reload`, the extension answers Pi's `resources_discover` event with only the gateway directory, so only `/skill:<gateway>` appears in Pi's slash-command menu. Sub-skills do not clutter `/`.
|
|
35
|
+
4. **Route**: when a prompt matches a route, `before_agent_start` appends a focused suggestion naming the exact sub-skill `SKILL.md` path for the agent to read. Your message is never rewritten. The gateway is the sole slash command; sub-skills are never slash commands.
|
|
36
|
+
|
|
37
|
+
## Managing Collections
|
|
38
|
+
|
|
39
|
+
Everything runs through the `/skill-router` menu:
|
|
40
|
+
|
|
41
|
+
- **Add collection** — clone a repo, pick skills, install.
|
|
42
|
+
- **Update collection** — pull upstream and re-materialize the preserved selection. New upstream skills are reported but stay unrouted until you explicitly select them.
|
|
43
|
+
- **Change routed skills** — add currently available upstream skills to an installed collection's explicit selection.
|
|
44
|
+
- **Edit capability summary** — revise the gateway description for an installed collection, then re-materialize its gateway.
|
|
45
|
+
- **Remove collection** — delete the exposed skills and registry entry (the cached clone is kept).
|
|
46
|
+
- **Enable/disable collection** — toggle exposure and routing without removing anything.
|
|
47
|
+
- **List collections** — show installed collections.
|
|
48
|
+
|
|
49
|
+
Routing terms are derived from skill names and stored in `~/.pi/agent/skill-router/collections.json`; edit that file to refine terms or collection descriptions. A collection description should be a high-level account of the selected workflows' shared capability, not installation details such as the repository name or gateway mechanics.
|
|
50
|
+
|
|
51
|
+
## Guarantees
|
|
52
|
+
|
|
53
|
+
- Gateway names are unique across collections; duplicate upstream skill names inside one collection fail the install instead of colliding.
|
|
54
|
+
- Materialization is atomic (temporary directory + rename); a failed install leaves no partial exposed directory.
|
|
55
|
+
- Invalid registry entries fail closed: they are ignored for both exposure and routing.
|
|
56
|
+
- Only skills you selected are ever exposed.
|
|
57
|
+
|
|
58
|
+
## Security
|
|
59
|
+
|
|
60
|
+
Skills are instructions the model follows and may bundle scripts. Review a repository's skills before adding it — installing a collection means trusting its content at user level across all projects.
|
package/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { BeforeAgentStartEvent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { routerRoot } from "./src/paths";
|
|
3
|
+
import { loadCollections } from "./src/registry";
|
|
4
|
+
import { isExplicitSkillInvocation, routePrompt, routingGuidance } from "./src/router";
|
|
5
|
+
import { exposedSkillPaths } from "./src/sync";
|
|
6
|
+
import { showSkillRouterMenu } from "./src/menu";
|
|
7
|
+
|
|
8
|
+
export default function skillRouter(pi: ExtensionAPI): void {
|
|
9
|
+
pi.on("resources_discover", () => ({
|
|
10
|
+
skillPaths: exposedSkillPaths(routerRoot()),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
pi.on("before_agent_start", (event: BeforeAgentStartEvent) => {
|
|
14
|
+
if (isExplicitSkillInvocation(event.prompt)) return;
|
|
15
|
+
|
|
16
|
+
const skills = event.systemPromptOptions.skills ?? [];
|
|
17
|
+
const match = routePrompt(event.prompt, loadCollections(routerRoot()), new Map(skills.map((skill) => [skill.name, skill])));
|
|
18
|
+
if (!match) return;
|
|
19
|
+
|
|
20
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${routingGuidance(match)}` };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
pi.registerCommand("skill-router", {
|
|
24
|
+
description: "Manage externally hosted skill collections (add, update, remove, toggle, list)",
|
|
25
|
+
handler: async (_args, ctx) => {
|
|
26
|
+
await showSkillRouterMenu(ctx);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { routePrompt };
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-skill-router",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Route to externally hosted skill collections: clone GitHub skill repos, wrap them as hidden leaves behind visible gateways, and get focused suggestions",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"skills",
|
|
8
|
+
"skill-router",
|
|
9
|
+
"routing"
|
|
10
|
+
],
|
|
11
|
+
"author": "Frad LEE <fradser@gmail.com>",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/FradSer/pi-packages.git"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"index.ts",
|
|
22
|
+
"src",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
28
|
+
},
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./index.ts"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@fradser/pi-kit": "0.4.2"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/menu.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CancellableLoader, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { createPiThemeStyle, notifyPi, renderPiPanel } from "@fradser/pi-kit";
|
|
4
|
+
import { routerRoot } from "./paths";
|
|
5
|
+
import { loadCollections } from "./registry";
|
|
6
|
+
import {
|
|
7
|
+
addCollection,
|
|
8
|
+
collectionSkillNames,
|
|
9
|
+
fetchCollectionSkills,
|
|
10
|
+
parseRepoSpec,
|
|
11
|
+
removeCollection,
|
|
12
|
+
setCollectionEnabled,
|
|
13
|
+
updateCollection,
|
|
14
|
+
updateCollectionDescription,
|
|
15
|
+
updateCollectionSelection,
|
|
16
|
+
defaultCollectionId,
|
|
17
|
+
generateWorkflowSummaries,
|
|
18
|
+
suggestCollectionDescription,
|
|
19
|
+
type UpstreamSkill,
|
|
20
|
+
} from "./sync";
|
|
21
|
+
|
|
22
|
+
const RELOAD_HINT = "Run /reload (or restart pi) to apply skill changes.";
|
|
23
|
+
|
|
24
|
+
type LoadingOutcome<T> =
|
|
25
|
+
| { status: "success"; value: T }
|
|
26
|
+
| { status: "error"; error: unknown };
|
|
27
|
+
|
|
28
|
+
async function runWithLoading<T>(ctx: ExtensionCommandContext, message: string, action: () => T | Promise<T>): Promise<T> {
|
|
29
|
+
if (!ctx.hasUI) return action();
|
|
30
|
+
|
|
31
|
+
const outcome = await ctx.ui.custom<LoadingOutcome<T>>((tui, theme, _keybindings, done) => {
|
|
32
|
+
const style = createPiThemeStyle(theme);
|
|
33
|
+
const loader = new CancellableLoader(
|
|
34
|
+
tui,
|
|
35
|
+
style.accent,
|
|
36
|
+
style.muted,
|
|
37
|
+
message,
|
|
38
|
+
);
|
|
39
|
+
loader.start();
|
|
40
|
+
setTimeout(() => {
|
|
41
|
+
Promise.resolve()
|
|
42
|
+
.then(action)
|
|
43
|
+
.then(
|
|
44
|
+
(value) => {
|
|
45
|
+
loader.stop();
|
|
46
|
+
done({ status: "success", value });
|
|
47
|
+
},
|
|
48
|
+
(error) => {
|
|
49
|
+
loader.stop();
|
|
50
|
+
done({ status: "error", error });
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
}, 0);
|
|
54
|
+
return {
|
|
55
|
+
render: (width) => renderPiPanel({
|
|
56
|
+
width,
|
|
57
|
+
style,
|
|
58
|
+
fit: truncateToWidth,
|
|
59
|
+
title: "Skill Router",
|
|
60
|
+
body: loader.render(Math.max(1, width - 2)),
|
|
61
|
+
footer: "esc cancel",
|
|
62
|
+
}),
|
|
63
|
+
invalidate: () => {},
|
|
64
|
+
dispose: () => loader.stop(),
|
|
65
|
+
};
|
|
66
|
+
}, {
|
|
67
|
+
overlay: true,
|
|
68
|
+
overlayOptions: {
|
|
69
|
+
anchor: "bottom-center",
|
|
70
|
+
width: "100%",
|
|
71
|
+
margin: { bottom: 4 },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (outcome.status === "error") throw outcome.error;
|
|
76
|
+
return outcome.value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function pickSkills(ctx: ExtensionCommandContext, skills: UpstreamSkill[]): Promise<string[] | "all" | undefined> {
|
|
80
|
+
const all = await ctx.ui.confirm("Skill selection", `Route all ${skills.length} skills found in this repository?`);
|
|
81
|
+
if (all) return "all";
|
|
82
|
+
|
|
83
|
+
const remaining = new Map(skills.map((skill) => [skill.name, skill]));
|
|
84
|
+
const selected: string[] = [];
|
|
85
|
+
while (remaining.size > 0) {
|
|
86
|
+
const choice = await ctx.ui.select(
|
|
87
|
+
`Select skills to route (${selected.length} selected)`,
|
|
88
|
+
[...[...remaining.keys()], "Finish selection"],
|
|
89
|
+
);
|
|
90
|
+
if (choice === undefined || choice === "Finish selection") break;
|
|
91
|
+
remaining.delete(choice);
|
|
92
|
+
selected.push(choice);
|
|
93
|
+
}
|
|
94
|
+
return selected.length > 0 ? selected : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function addFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
98
|
+
const root = routerRoot();
|
|
99
|
+
const repo = await ctx.ui.input("Add collection", "owner/repo[@ref], GitHub URL, or local path");
|
|
100
|
+
if (!repo) return;
|
|
101
|
+
|
|
102
|
+
let fetched: { cache: string; ref: string; skills: UpstreamSkill[] };
|
|
103
|
+
try {
|
|
104
|
+
const spec = parseRepoSpec(repo);
|
|
105
|
+
fetched = await runWithLoading(
|
|
106
|
+
ctx,
|
|
107
|
+
`Cloning and scanning ${spec.repo}...`,
|
|
108
|
+
() => fetchCollectionSkills(root, spec),
|
|
109
|
+
);
|
|
110
|
+
if (fetched.skills.length === 0) {
|
|
111
|
+
notifyPi(ctx.ui, `No skills (SKILL.md with name and description) found in ${spec.repo}.`, "warning");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
notifyPi(ctx.ui, `Failed to fetch repository: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const defaultId = defaultCollectionId(parseRepoSpec(repo));
|
|
120
|
+
const customId = await ctx.ui.input(
|
|
121
|
+
`Collection skill name (default: ${defaultId})`,
|
|
122
|
+
defaultId,
|
|
123
|
+
);
|
|
124
|
+
if (customId === undefined) return;
|
|
125
|
+
const id = customId.trim() || defaultId;
|
|
126
|
+
|
|
127
|
+
const selection = await pickSkills(ctx, fetched.skills);
|
|
128
|
+
if (!selection) return;
|
|
129
|
+
|
|
130
|
+
const selectedSkills = selection === "all"
|
|
131
|
+
? fetched.skills
|
|
132
|
+
: fetched.skills.filter((skill) => selection.includes(skill.name));
|
|
133
|
+
const summaries = await runWithLoading(
|
|
134
|
+
ctx,
|
|
135
|
+
"Creating workflow navigation...",
|
|
136
|
+
() => generateWorkflowSummaries(ctx.modelRegistry, ctx.model, selectedSkills, ctx.signal),
|
|
137
|
+
);
|
|
138
|
+
const suggestedDescription = suggestCollectionDescription(selectedSkills);
|
|
139
|
+
const customDescription = await ctx.ui.input(
|
|
140
|
+
"Collection capability summary",
|
|
141
|
+
suggestedDescription,
|
|
142
|
+
);
|
|
143
|
+
if (customDescription === undefined) return;
|
|
144
|
+
const description = customDescription.trim() || suggestedDescription;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const result = await runWithLoading(
|
|
148
|
+
ctx,
|
|
149
|
+
`Installing ${repo}...`,
|
|
150
|
+
() => addCollection(root, { repo, id, description, skills: selection, summaries }),
|
|
151
|
+
);
|
|
152
|
+
notifyPi(ctx.ui, `Installed "${result.id}" with ${result.skills.length} skills. ${RELOAD_HINT}`, "info");
|
|
153
|
+
} catch (error) {
|
|
154
|
+
notifyPi(ctx.ui, `Failed to install collection: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function pickCollection(ctx: ExtensionCommandContext, action: string) {
|
|
159
|
+
const collections = loadCollections(routerRoot());
|
|
160
|
+
if (collections.length === 0) {
|
|
161
|
+
notifyPi(ctx.ui, "No collections installed. Use Add collection first.", "warning");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const choice = await ctx.ui.select(
|
|
165
|
+
`${action}: choose a collection`,
|
|
166
|
+
collections.map((collection) => `${collection.id} (${collection.routes.length} skills${collection.enabled ? "" : ", disabled"})`),
|
|
167
|
+
);
|
|
168
|
+
if (!choice) return;
|
|
169
|
+
return collections.find((collection) => choice.startsWith(`${collection.id} (`));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function selectionFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
173
|
+
const collection = await pickCollection(ctx, "Change routed skills");
|
|
174
|
+
if (!collection) return;
|
|
175
|
+
try {
|
|
176
|
+
const upstream = collectionSkillNames(routerRoot(), collection.id);
|
|
177
|
+
const selected = new Set(collection.routes.map((route) => route.skill));
|
|
178
|
+
const remaining = upstream.filter((name) => !selected.has(name));
|
|
179
|
+
if (remaining.length === 0) {
|
|
180
|
+
notifyPi(ctx.ui, "All upstream skills are already selected.", "info");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
while (remaining.length > 0) {
|
|
184
|
+
const choice = await ctx.ui.select(
|
|
185
|
+
`Add skills to ${collection.id} (${selected.size} selected)`,
|
|
186
|
+
[...remaining, "Finish selection"],
|
|
187
|
+
);
|
|
188
|
+
if (!choice || choice === "Finish selection") break;
|
|
189
|
+
selected.add(choice);
|
|
190
|
+
remaining.splice(remaining.indexOf(choice), 1);
|
|
191
|
+
}
|
|
192
|
+
if (selected.size === collection.routes.length) return;
|
|
193
|
+
const result = await updateCollectionSelection(routerRoot(), collection.id, [...selected]);
|
|
194
|
+
notifyPi(ctx.ui, `Updated "${result.id}" with ${result.selected.length} routed skills. ${RELOAD_HINT}`, "info");
|
|
195
|
+
} catch (error) {
|
|
196
|
+
notifyPi(ctx.ui, `Selection update failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function descriptionFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
201
|
+
const collection = await pickCollection(ctx, "Edit capability summary");
|
|
202
|
+
if (!collection) return;
|
|
203
|
+
const description = await ctx.ui.input("Collection capability summary", collection.description);
|
|
204
|
+
if (description === undefined) return;
|
|
205
|
+
try {
|
|
206
|
+
updateCollectionDescription(routerRoot(), collection.id, description);
|
|
207
|
+
notifyPi(ctx.ui, `Updated "${collection.id}" capability summary. ${RELOAD_HINT}`, "info");
|
|
208
|
+
} catch (error) {
|
|
209
|
+
notifyPi(ctx.ui, `Capability summary update failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function updateFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
214
|
+
const collection = await pickCollection(ctx, "Update collection");
|
|
215
|
+
if (!collection) return;
|
|
216
|
+
try {
|
|
217
|
+
const result = await runWithLoading(
|
|
218
|
+
ctx,
|
|
219
|
+
`Updating ${collection.id}...`,
|
|
220
|
+
() => updateCollection(routerRoot(), collection.id),
|
|
221
|
+
);
|
|
222
|
+
const notes = [`Updated "${result.id}": ${result.kept.length} skills re-materialized.`];
|
|
223
|
+
if (result.dropped.length > 0) notes.push(`Removed upstream: ${result.dropped.join(", ")}.`);
|
|
224
|
+
if (result.newUpstream.length > 0) notes.push(`New upstream skills not routed: ${result.newUpstream.join(", ")}.`);
|
|
225
|
+
notifyPi(ctx.ui, `${notes.join(" ")} ${RELOAD_HINT}`, "info");
|
|
226
|
+
} catch (error) {
|
|
227
|
+
notifyPi(ctx.ui, `Update failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function removeFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
232
|
+
const collection = await pickCollection(ctx, "Remove collection");
|
|
233
|
+
if (!collection) return;
|
|
234
|
+
const confirmed = await ctx.ui.confirm("Remove collection", `Remove "${collection.id}" and its exposed skills? The cached clone is kept.`);
|
|
235
|
+
if (!confirmed) return;
|
|
236
|
+
try {
|
|
237
|
+
removeCollection(routerRoot(), collection.id);
|
|
238
|
+
notifyPi(ctx.ui, `Removed "${collection.id}". ${RELOAD_HINT}`, "info");
|
|
239
|
+
} catch (error) {
|
|
240
|
+
notifyPi(ctx.ui, `Remove failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function toggleFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
245
|
+
const collection = await pickCollection(ctx, "Enable/disable collection");
|
|
246
|
+
if (!collection) return;
|
|
247
|
+
const updated = setCollectionEnabled(routerRoot(), collection.id, !collection.enabled);
|
|
248
|
+
notifyPi(ctx.ui, `"${updated.id}" is now ${updated.enabled ? "enabled" : "disabled"}. ${RELOAD_HINT}`, "info");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function listFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
252
|
+
const collections = loadCollections(routerRoot());
|
|
253
|
+
if (collections.length === 0) {
|
|
254
|
+
notifyPi(ctx.ui, "No collections installed.", "info");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
await ctx.ui.select(
|
|
258
|
+
"Installed collections (esc to go back)",
|
|
259
|
+
collections.map(
|
|
260
|
+
(collection) =>
|
|
261
|
+
`${collection.id} — ${collection.source.repo}@${collection.source.ref} — ${collection.routes.length} skills — ${collection.enabled ? "enabled" : "disabled"}`,
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function showSkillRouterMenu(ctx: ExtensionCommandContext): Promise<void> {
|
|
267
|
+
for (;;) {
|
|
268
|
+
const action = await ctx.ui.select("Skill Router", [
|
|
269
|
+
"Add collection",
|
|
270
|
+
"Update collection",
|
|
271
|
+
"Change routed skills",
|
|
272
|
+
"Edit capability summary",
|
|
273
|
+
"Remove collection",
|
|
274
|
+
"Enable/disable collection",
|
|
275
|
+
"List collections",
|
|
276
|
+
]);
|
|
277
|
+
if (action === undefined) return;
|
|
278
|
+
if (action === "Add collection") await addFlow(ctx);
|
|
279
|
+
else if (action === "Update collection") await updateFlow(ctx);
|
|
280
|
+
else if (action === "Change routed skills") await selectionFlow(ctx);
|
|
281
|
+
else if (action === "Edit capability summary") await descriptionFlow(ctx);
|
|
282
|
+
else if (action === "Remove collection") await removeFlow(ctx);
|
|
283
|
+
else if (action === "Enable/disable collection") await toggleFlow(ctx);
|
|
284
|
+
else await listFlow(ctx);
|
|
285
|
+
}
|
|
286
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function agentDir(): string {
|
|
5
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(os.homedir(), ".pi", "agent");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function routerRoot(): string {
|
|
9
|
+
return join(agentDir(), "skill-router");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registryPath(root: string): string {
|
|
13
|
+
return join(root, "collections.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function cacheDir(root: string, cacheKey: string): string {
|
|
17
|
+
return join(root, "cache", cacheKey);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function exposedDir(root: string, collectionId: string): string {
|
|
21
|
+
return join(root, "exposed", "collections", collectionId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function legacyExposedDir(root: string, collectionId: string): string {
|
|
25
|
+
return join(root, "exposed", collectionId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function gatewayDir(root: string, collectionId: string, _gateway: string): string {
|
|
29
|
+
return join(exposedDir(root, collectionId), "gateway");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function leafSkillsDir(root: string, collectionId: string): string {
|
|
33
|
+
return join(exposedDir(root, collectionId), "leaves");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function leafSkillDir(root: string, collectionId: string, skillName: string): string {
|
|
37
|
+
return join(leafSkillsDir(root, collectionId), skillName);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function leafSkillFile(root: string, collectionId: string, skillName: string): string {
|
|
41
|
+
return join(leafSkillDir(root, collectionId, skillName), "SKILL.md");
|
|
42
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { registryPath } from "./paths";
|
|
5
|
+
|
|
6
|
+
export interface RegistryRoute {
|
|
7
|
+
skill: string;
|
|
8
|
+
path: string;
|
|
9
|
+
terms: string[];
|
|
10
|
+
summary?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CollectionSource {
|
|
14
|
+
repo: string;
|
|
15
|
+
url: string;
|
|
16
|
+
ref: string;
|
|
17
|
+
cacheKey: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RegistryCollection {
|
|
21
|
+
id: string;
|
|
22
|
+
gateway: string;
|
|
23
|
+
mode: "suggest";
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
description: string;
|
|
26
|
+
source: CollectionSource;
|
|
27
|
+
routes: RegistryRoute[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type UnknownRecord = Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is UnknownRecord {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const RESERVED_COLLECTION_IDS = new Set(["collections"]);
|
|
37
|
+
|
|
38
|
+
export function isSlug(value: unknown): value is string {
|
|
39
|
+
return typeof value === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isCollectionId(value: unknown): value is string {
|
|
43
|
+
return isSlug(value) && !RESERVED_COLLECTION_IDS.has(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isSafeRelativePath(value: unknown): value is string {
|
|
47
|
+
return (
|
|
48
|
+
typeof value === "string" &&
|
|
49
|
+
value.length > 0 &&
|
|
50
|
+
!value.startsWith("/") &&
|
|
51
|
+
!value.includes("\\") &&
|
|
52
|
+
!value.split("/").includes("..")
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseRoute(value: unknown): RegistryRoute | undefined {
|
|
57
|
+
if (!isRecord(value) || !isSlug(value.skill) || !isSafeRelativePath(value.path)) return;
|
|
58
|
+
if (!Array.isArray(value.terms)) return;
|
|
59
|
+
const terms = value.terms.filter((term): term is string => typeof term === "string" && term.trim().length > 0);
|
|
60
|
+
if (typeof value.summary !== "undefined" && (typeof value.summary !== "string" || !value.summary.trim())) return;
|
|
61
|
+
return terms.length > 0 ? { skill: value.skill, path: value.path, terms, summary: value.summary?.trim() } : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isSafeGitRef(value: unknown): value is string {
|
|
65
|
+
return (
|
|
66
|
+
typeof value === "string" &&
|
|
67
|
+
/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value) &&
|
|
68
|
+
!value.includes("..") &&
|
|
69
|
+
!value.includes("//") &&
|
|
70
|
+
!value.endsWith(".") &&
|
|
71
|
+
!value.endsWith("/")
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isSafeSourceUrl(value: unknown): value is string {
|
|
76
|
+
if (typeof value !== "string" || value.length === 0 || value.includes("\0")) return false;
|
|
77
|
+
return /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/.test(value)
|
|
78
|
+
|| value.startsWith("/");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isSafeCacheKey(value: unknown): value is string {
|
|
82
|
+
return (
|
|
83
|
+
typeof value === "string" &&
|
|
84
|
+
/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(value) &&
|
|
85
|
+
!value.includes("..")
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseSource(value: unknown): CollectionSource | undefined {
|
|
90
|
+
if (!isRecord(value)) return;
|
|
91
|
+
const { repo, url, cacheKey } = value;
|
|
92
|
+
if (typeof repo !== "string" || !isSafeSourceUrl(url) || !isSafeGitRef(value.ref)) return;
|
|
93
|
+
if (!repo || !isSafeCacheKey(cacheKey)) return;
|
|
94
|
+
const ref = value.ref;
|
|
95
|
+
return { repo, url, ref, cacheKey };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseCollection(value: unknown): RegistryCollection | undefined {
|
|
99
|
+
if (!isRecord(value)) return;
|
|
100
|
+
if (!isCollectionId(value.id) || !isSlug(value.gateway) || value.mode !== "suggest") return;
|
|
101
|
+
if (typeof value.description !== "string" || !Array.isArray(value.routes)) return;
|
|
102
|
+
if (value.enabled !== undefined && typeof value.enabled !== "boolean") return;
|
|
103
|
+
const source = parseSource(value.source);
|
|
104
|
+
if (!source) return;
|
|
105
|
+
|
|
106
|
+
const parsedRoutes = value.routes.map(parseRoute);
|
|
107
|
+
if (parsedRoutes.some((route) => route === undefined)) return;
|
|
108
|
+
const routes = parsedRoutes as RegistryRoute[];
|
|
109
|
+
const routeSkillNames = routes.map((route) => route.skill);
|
|
110
|
+
if (new Set(routeSkillNames).size !== routeSkillNames.length) return;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
id: value.id,
|
|
114
|
+
gateway: value.gateway,
|
|
115
|
+
mode: "suggest",
|
|
116
|
+
enabled: value.enabled !== false,
|
|
117
|
+
description: value.description,
|
|
118
|
+
source,
|
|
119
|
+
routes,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Load the registry, dropping structurally invalid entries and duplicate gateways or ids. */
|
|
124
|
+
export function loadCollections(root: string): RegistryCollection[] {
|
|
125
|
+
const path = registryPath(root);
|
|
126
|
+
if (!existsSync(path)) return [];
|
|
127
|
+
let parsed: unknown;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
130
|
+
} catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.collections)) return [];
|
|
134
|
+
|
|
135
|
+
const collections = parsed.collections
|
|
136
|
+
.map(parseCollection)
|
|
137
|
+
.filter((collection): collection is RegistryCollection => collection !== undefined);
|
|
138
|
+
|
|
139
|
+
const idCounts = new Map<string, number>();
|
|
140
|
+
const gatewayCounts = new Map<string, number>();
|
|
141
|
+
const cacheCounts = new Map<string, number>();
|
|
142
|
+
const sourceCounts = new Map<string, number>();
|
|
143
|
+
for (const collection of collections) {
|
|
144
|
+
idCounts.set(collection.id, (idCounts.get(collection.id) ?? 0) + 1);
|
|
145
|
+
gatewayCounts.set(collection.gateway, (gatewayCounts.get(collection.gateway) ?? 0) + 1);
|
|
146
|
+
cacheCounts.set(collection.source.cacheKey, (cacheCounts.get(collection.source.cacheKey) ?? 0) + 1);
|
|
147
|
+
const sourceKey = `${collection.source.url}\0${collection.source.ref}`;
|
|
148
|
+
sourceCounts.set(sourceKey, (sourceCounts.get(sourceKey) ?? 0) + 1);
|
|
149
|
+
}
|
|
150
|
+
return collections.filter((collection) => {
|
|
151
|
+
const sourceKey = `${collection.source.url}\0${collection.source.ref}`;
|
|
152
|
+
return (
|
|
153
|
+
idCounts.get(collection.id) === 1 &&
|
|
154
|
+
gatewayCounts.get(collection.gateway) === 1 &&
|
|
155
|
+
cacheCounts.get(collection.source.cacheKey) === 1 &&
|
|
156
|
+
sourceCounts.get(sourceKey) === 1
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function saveCollections(root: string, collections: RegistryCollection[]): void {
|
|
162
|
+
const path = registryPath(root);
|
|
163
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
164
|
+
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
165
|
+
writeFileSync(temporary, `${JSON.stringify({ collections }, null, 2)}\n`, "utf8");
|
|
166
|
+
renameSync(temporary, path);
|
|
167
|
+
}
|