pi-skill-router 0.2.1 → 0.3.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-skill-router
2
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.
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 registers every routed skill as a codemode tool the model searches and calls.
4
4
 
5
5
  **Package version:** see [`package.json`](package.json).
6
6
 
@@ -30,7 +30,10 @@ pi install npm:pi-skill-router
30
30
  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.
31
31
  2. **Describe and materialize**: after selecting skills, the active AI model synthesizes the selected skills' descriptions into a whole-collection capability summary and the router saves it directly, without manual editing or confirmation. If no authenticated model is available or generation fails or returns invalid output, Add collection reports an installation error and leaves the collection uninstalled; it never substitutes a skill-name-based summary. 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.
32
32
  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 `/`.
33
- 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.
33
+ 4. **Search and call**: each routed skill is registered as one codemode tool, named `skill_router__<collection>__<skill>` with every character outside `[A-Za-z0-9_]` replaced by `_`, as MCP tool names are. All of a collection's skill tools share one namespace named `skill_router__<collection>`, aliased to the collection id. The model searches codemode's tools and calls the matched one, which returns that skill's instructions together with the directory its references and scripts resolve against. The collection's capability summary is the namespace description and the gateway body is its `instructions`, so one lookup answers what a collection contains. Codemode is inactive by default and the router never activates it.
34
+ 5. **Fall back without codemode**: while codemode is not active — or while an allowlist has removed the skill tools, or the registered `codemode` declares that it reaches no tool — `before_agent_start` appends a focused suggestion naming the exact sub-skill `SKILL.md` path for the agent to read instead. Your message is never rewritten. The gateway is the sole slash command; sub-skills are never slash commands.
35
+
36
+ To run the tools channel without an MCP server, declare codemode in your settings with `"defaultTools": ["+codemode"]`. Passing `--tools` names an allowlist that also removes extension tools: `--tools 'read,codemode'` hides every skill tool and leaves only the fallback, while `--tools 'read,codemode,skill_router__*'` keeps them callable from a script. An allowlist match cannot declare a `codemode`-exposure tool, so a direct call needs `tool_search` to load it first. The router decides per skill, so an allowlist that kept one skill's tool and removed another still falls back for the removed one.
34
37
 
35
38
  ## Managing Collections
36
39
 
@@ -38,7 +41,7 @@ Registry changes wait asynchronously for competing operations, for up to 10 seco
38
41
 
39
42
  Everything runs through the `/skill-router` menu:
40
43
 
41
- - **Add collection** — clone a repo, pick skills, install.
44
+ - **Add collection** — clone a repo, pick skills, install. Every menu change — add, update, routed skills, capability summary, remove, enable/disable — refreshes the skill tools in place, so the tools channel follows the registry in the same session and a removed or disabled collection has its tools withdrawn. `/reload` is needed only to refresh the gateway command and the prompt's skill list. A collection installed by another process, or a hand-edited registry, stays on the routing suggestion until then.
42
45
  - **Update collection** — pull upstream and re-materialize the preserved selection. New upstream skills are reported but stay unrouted until you explicitly select them.
43
46
  - **Change routed skills** — add currently available upstream skills to an installed collection's explicit selection.
44
47
  - **Edit capability summary** — revise the gateway description for an installed collection, then re-materialize its gateway.
@@ -52,9 +55,17 @@ Routing terms are derived from skill names and stored in `~/.pi/agent/skill-rout
52
55
 
53
56
  - Gateway names are unique across collections; duplicate upstream skill names inside one collection fail the install instead of colliding.
54
57
  - 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.
58
+ - Invalid registry entries fail closed: they are ignored for exposure, routing, and tool registration.
59
+ - Only skills you selected are ever exposed, and a disabled collection registers no tools.
60
+ - A skill tool re-resolves its collection on every call and reads only a regular file inside `exposed/collections`. A removed collection, a tampered leaf, or a symlinked instruction file is refused instead of served.
61
+ - A call returns at most 20000 characters of instructions, followed by a note naming the file that holds the rest.
62
+ - A registered skill tool can be filtered out of the session entirely by a `--tools` or `--exclude-tools` allowlist. The router detects that for the matched skill, and the routing suggestion takes over for it.
63
+ - Removing or disabling a collection while the session is live withdraws its skill tools: Pi cannot unregister a tool, so they are re-registered hidden and stay unreachable.
57
64
 
58
65
  ## Security
59
66
 
60
67
  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.
68
+
69
+ ## License
70
+
71
+ MIT
package/index.ts CHANGED
@@ -1,29 +1,52 @@
1
1
  import type { BeforeAgentStartEvent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { routerRoot } from "./src/paths";
2
+ import { routerRoot, skillToolName } from "./src/paths";
3
3
  import { loadCollections } from "./src/registry";
4
4
  import { isExplicitSkillInvocation, routePrompt, routingGuidance } from "./src/router";
5
+ import { codemodeReachesSkillTool, refreshSkillTools } from "./src/skills";
5
6
  import { exposedSkillPaths } from "./src/sync";
6
7
  import { showSkillRouterMenu } from "./src/menu";
7
8
 
9
+ /**
10
+ * Two channels reach a leaf skill.
11
+ *
12
+ * The tools channel registers one codemode tool per routed leaf skill, so the
13
+ * model searches codemode's tools and calls the matched one. The fallback
14
+ * channel keeps the routing suggestion whenever that skill's own tool cannot be
15
+ * called: a session that does not declare codemode, an allowlist that removed
16
+ * the tool, or a collection added after this extension loaded. The gateway stays
17
+ * exposed either way, as the human `/skill:<gateway>` entry point.
18
+ */
8
19
  export default function skillRouter(pi: ExtensionAPI): void {
20
+ refreshSkillTools(pi, routerRoot());
21
+
9
22
  pi.on("resources_discover", () => ({
10
23
  skillPaths: exposedSkillPaths(routerRoot()),
11
24
  }));
12
25
 
13
26
  pi.on("before_agent_start", (event: BeforeAgentStartEvent) => {
14
- if (isExplicitSkillInvocation(event.prompt)) return;
27
+ const options = event.systemPromptOptions;
28
+ if (!("sections" in options) || !options.sections || typeof options.sections !== "object" || Array.isArray(options.sections)) {
29
+ throw new Error("This extension requires Pi structured prompt sections.");
30
+ }
31
+ const sections = options.sections;
32
+ Reflect.deleteProperty(sections, "skill_router");
33
+ if (!("prompt" in event) || typeof event.prompt !== "string" || isExplicitSkillInvocation(event.prompt)) return;
15
34
 
16
35
  const skills = event.systemPromptOptions.skills ?? [];
17
36
  const match = routePrompt(event.prompt, loadCollections(routerRoot()), new Map(skills.map((skill) => [skill.name, skill])));
18
37
  if (!match) return;
38
+ // The tools channel wins only for this skill's own tool. A surviving sibling
39
+ // tool, or a collection that registered nothing before the next reload, must
40
+ // not suppress the suggestion that is the only working path to this skill.
41
+ if (codemodeReachesSkillTool(pi, skillToolName(match.collectionId, match.skillName))) return;
19
42
 
20
- return { systemPrompt: `${event.systemPrompt}\n\n${routingGuidance(match)}` };
43
+ Object.assign(sections, { skill_router: routingGuidance(match) });
21
44
  });
22
45
 
23
46
  pi.registerCommand("skill-router", {
24
47
  description: "Manage externally hosted skill collections (add, update, remove, toggle, list)",
25
48
  handler: async (_args, ctx) => {
26
- await showSkillRouterMenu(ctx);
49
+ await showSkillRouterMenu(ctx, { refreshTools: () => refreshSkillTools(pi, routerRoot()) });
27
50
  },
28
51
  });
29
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skill-router",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
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
5
  "keywords": [
6
6
  "pi-package",
@@ -10,6 +10,7 @@
10
10
  ],
11
11
  "author": "Frad LEE <fradser@gmail.com>",
12
12
  "license": "MIT",
13
+ "type": "module",
13
14
  "repository": {
14
15
  "type": "git",
15
16
  "url": "https://github.com/FradSer/pi-packages.git"
@@ -26,7 +27,8 @@
26
27
  "peerDependencies": {
27
28
  "@earendil-works/pi-ai": "*",
28
29
  "@earendil-works/pi-coding-agent": "*",
29
- "@earendil-works/pi-tui": "*"
30
+ "@earendil-works/pi-tui": "*",
31
+ "typebox": "*"
30
32
  },
31
33
  "pi": {
32
34
  "extensions": [
@@ -34,6 +36,6 @@
34
36
  ]
35
37
  },
36
38
  "dependencies": {
37
- "@fradser/pi-kit": "0.5.0"
39
+ "@fradser/pi-kit": "0.6.0"
38
40
  }
39
- }
41
+ }
package/src/menu.ts CHANGED
@@ -19,7 +19,15 @@ import {
19
19
  type UpstreamSkill,
20
20
  } from "./sync";
21
21
 
22
- const RELOAD_HINT = "Run /reload (or restart pi) to apply skill changes.";
22
+ /** Collection changes apply to the tools channel at once; the gateway command and
23
+ * the prompt's skill list come from Pi's resource discovery, which only runs on
24
+ * startup and reload. */
25
+ const RELOAD_HINT = "Skill tools updated; run /reload (or restart pi) to refresh the gateway command.";
26
+
27
+ /** What the menu needs from the extension to keep the tools channel current. */
28
+ export interface SkillRouterHooks {
29
+ refreshTools: () => void;
30
+ }
23
31
 
24
32
  function reportOperationError(ctx: ExtensionCommandContext, label: string, error: unknown): void {
25
33
  if (error instanceof Error && error.name === "AbortError") {
@@ -114,7 +122,7 @@ async function pickSkills(ctx: ExtensionCommandContext, skills: UpstreamSkill[])
114
122
  return selected.length > 0 ? selected : undefined;
115
123
  }
116
124
 
117
- async function addFlow(ctx: ExtensionCommandContext): Promise<void> {
125
+ async function addFlow(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
118
126
  const root = routerRoot();
119
127
  const repo = await ctx.ui.input("Add collection", "owner/repo[@ref], GitHub URL, or local path");
120
128
  if (!repo) return;
@@ -166,6 +174,7 @@ async function addFlow(ctx: ExtensionCommandContext): Promise<void> {
166
174
  `Installing ${repo}...`,
167
175
  (signal) => addCollection(root, { repo, id, description, skills: selection, summaries }, signal),
168
176
  );
177
+ hooks.refreshTools();
169
178
  notifyPi(ctx.ui, `Installed "${result.id}" with ${result.skills.length} skills. ${RELOAD_HINT}`, "info");
170
179
  } catch (error) {
171
180
  reportOperationError(ctx, "Failed to install collection", error);
@@ -186,7 +195,7 @@ async function pickCollection(ctx: ExtensionCommandContext, action: string) {
186
195
  return collections.find((collection) => choice.startsWith(`${collection.id} (`));
187
196
  }
188
197
 
189
- async function selectionFlow(ctx: ExtensionCommandContext): Promise<void> {
198
+ async function selectionFlow(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
190
199
  const collection = await pickCollection(ctx, "Change routed skills");
191
200
  if (!collection) return;
192
201
  try {
@@ -209,13 +218,14 @@ async function selectionFlow(ctx: ExtensionCommandContext): Promise<void> {
209
218
  if (selected.size === collection.routes.length) return;
210
219
  const result = await runWithLoading(ctx, `Changing ${collection.id} skills...`,
211
220
  (signal) => updateCollectionSelection(routerRoot(), collection.id, [...selected], signal));
221
+ hooks.refreshTools();
212
222
  notifyPi(ctx.ui, `Updated "${result.id}" with ${result.selected.length} routed skills. ${RELOAD_HINT}`, "info");
213
223
  } catch (error) {
214
224
  reportOperationError(ctx, "Selection update failed", error);
215
225
  }
216
226
  }
217
227
 
218
- async function descriptionFlow(ctx: ExtensionCommandContext): Promise<void> {
228
+ async function descriptionFlow(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
219
229
  const collection = await pickCollection(ctx, "Edit capability summary");
220
230
  if (!collection) return;
221
231
  const description = await ctx.ui.input("Collection capability summary", collection.description);
@@ -223,13 +233,14 @@ async function descriptionFlow(ctx: ExtensionCommandContext): Promise<void> {
223
233
  try {
224
234
  await runWithLoading(ctx, `Updating ${collection.id} summary...`,
225
235
  (signal) => updateCollectionDescription(routerRoot(), collection.id, description, signal));
236
+ hooks.refreshTools();
226
237
  notifyPi(ctx.ui, `Updated "${collection.id}" capability summary. ${RELOAD_HINT}`, "info");
227
238
  } catch (error) {
228
239
  reportOperationError(ctx, "Capability summary update failed", error);
229
240
  }
230
241
  }
231
242
 
232
- async function updateFlow(ctx: ExtensionCommandContext): Promise<void> {
243
+ async function updateFlow(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
233
244
  const collection = await pickCollection(ctx, "Update collection");
234
245
  if (!collection) return;
235
246
  try {
@@ -238,6 +249,7 @@ async function updateFlow(ctx: ExtensionCommandContext): Promise<void> {
238
249
  `Updating ${collection.id}...`,
239
250
  (signal) => updateCollection(routerRoot(), collection.id, signal),
240
251
  );
252
+ hooks.refreshTools();
241
253
  const notes = [`Updated "${result.id}": ${result.kept.length} skills re-materialized.`];
242
254
  if (result.dropped.length > 0) notes.push(`Removed upstream: ${result.dropped.join(", ")}.`);
243
255
  if (result.newUpstream.length > 0) notes.push(`New upstream skills not routed: ${result.newUpstream.join(", ")}.`);
@@ -247,7 +259,7 @@ async function updateFlow(ctx: ExtensionCommandContext): Promise<void> {
247
259
  }
248
260
  }
249
261
 
250
- async function removeFlow(ctx: ExtensionCommandContext): Promise<void> {
262
+ async function removeFlow(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
251
263
  const collection = await pickCollection(ctx, "Remove collection");
252
264
  if (!collection) return;
253
265
  const confirmed = await ctx.ui.confirm("Remove collection", `Remove "${collection.id}" and its exposed skills? The cached clone is kept.`);
@@ -255,18 +267,20 @@ async function removeFlow(ctx: ExtensionCommandContext): Promise<void> {
255
267
  try {
256
268
  await runWithLoading(ctx, `Removing ${collection.id}...`,
257
269
  (signal) => removeCollection(routerRoot(), collection.id, signal));
270
+ hooks.refreshTools();
258
271
  notifyPi(ctx.ui, `Removed "${collection.id}". ${RELOAD_HINT}`, "info");
259
272
  } catch (error) {
260
273
  reportOperationError(ctx, "Remove failed", error);
261
274
  }
262
275
  }
263
276
 
264
- async function toggleFlow(ctx: ExtensionCommandContext): Promise<void> {
277
+ async function toggleFlow(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
265
278
  const collection = await pickCollection(ctx, "Enable/disable collection");
266
279
  if (!collection) return;
267
280
  try {
268
281
  const updated = await runWithLoading(ctx, `Updating ${collection.id}...`,
269
282
  (signal) => setCollectionEnabled(routerRoot(), collection.id, !collection.enabled, signal));
283
+ hooks.refreshTools();
270
284
  notifyPi(ctx.ui, `"${updated.id}" is now ${updated.enabled ? "enabled" : "disabled"}. ${RELOAD_HINT}`, "info");
271
285
  } catch (error) {
272
286
  reportOperationError(ctx, "Enable/disable failed", error);
@@ -288,7 +302,7 @@ async function listFlow(ctx: ExtensionCommandContext): Promise<void> {
288
302
  );
289
303
  }
290
304
 
291
- export async function showSkillRouterMenu(ctx: ExtensionCommandContext): Promise<void> {
305
+ export async function showSkillRouterMenu(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
292
306
  for (;;) {
293
307
  const action = await ctx.ui.select("Skill Router", [
294
308
  "Add collection",
@@ -300,12 +314,12 @@ export async function showSkillRouterMenu(ctx: ExtensionCommandContext): Promise
300
314
  "List collections",
301
315
  ]);
302
316
  if (action === undefined) return;
303
- if (action === "Add collection") await addFlow(ctx);
304
- else if (action === "Update collection") await updateFlow(ctx);
305
- else if (action === "Change routed skills") await selectionFlow(ctx);
306
- else if (action === "Edit capability summary") await descriptionFlow(ctx);
307
- else if (action === "Remove collection") await removeFlow(ctx);
308
- else if (action === "Enable/disable collection") await toggleFlow(ctx);
317
+ if (action === "Add collection") await addFlow(ctx, hooks);
318
+ else if (action === "Update collection") await updateFlow(ctx, hooks);
319
+ else if (action === "Change routed skills") await selectionFlow(ctx, hooks);
320
+ else if (action === "Edit capability summary") await descriptionFlow(ctx, hooks);
321
+ else if (action === "Remove collection") await removeFlow(ctx, hooks);
322
+ else if (action === "Enable/disable collection") await toggleFlow(ctx, hooks);
309
323
  else await listFlow(ctx);
310
324
  }
311
325
  }
package/src/paths.ts CHANGED
@@ -17,8 +17,12 @@ export function cacheDir(root: string, cacheKey: string): string {
17
17
  return join(root, "cache", cacheKey);
18
18
  }
19
19
 
20
+ export function exposedCollectionsDir(root: string): string {
21
+ return join(root, "exposed", "collections");
22
+ }
23
+
20
24
  export function exposedDir(root: string, collectionId: string): string {
21
- return join(root, "exposed", "collections", collectionId);
25
+ return join(exposedCollectionsDir(root), collectionId);
22
26
  }
23
27
 
24
28
  export function legacyExposedDir(root: string, collectionId: string): string {
@@ -40,3 +44,19 @@ export function leafSkillDir(root: string, collectionId: string, skillName: stri
40
44
  export function leafSkillFile(root: string, collectionId: string, skillName: string): string {
41
45
  return join(leafSkillDir(root, collectionId, skillName), "SKILL.md");
42
46
  }
47
+
48
+ /** Every character a codemode script identifier cannot carry becomes `_`, as it
49
+ * does for MCP tool names, so the registered name and the script identifier agree. */
50
+ export function codemodeIdentifier(value: string): string {
51
+ return value.replace(/[^A-Za-z0-9_]/g, "_");
52
+ }
53
+
54
+ /** The search namespace of one collection: the router prefix and the collection. */
55
+ export function collectionNamespaceName(collectionId: string): string {
56
+ return `skill_router__${codemodeIdentifier(collectionId)}`;
57
+ }
58
+
59
+ /** The tool name standing for one leaf skill: the namespace and the skill. */
60
+ export function skillToolName(collectionId: string, skillName: string): string {
61
+ return `${collectionNamespaceName(collectionId)}__${codemodeIdentifier(skillName)}`;
62
+ }
package/src/skills.ts ADDED
@@ -0,0 +1,360 @@
1
+ /**
2
+ * The tools channel: one codemode-callable tool per routed leaf skill.
3
+ *
4
+ * A leaf skill is prose, so the tool exists to be found and called: its name and
5
+ * description are the skill's searchable identity, its namespace is the
6
+ * collection, and its call returns the wrapped instructions with the directory
7
+ * that resolves the skill's references and scripts.
8
+ *
9
+ * Registration reads the registry once, at extension load. A call re-resolves the
10
+ * collection, so a tool whose collection was removed fails instead of serving a
11
+ * path captured at registration. Nothing here activates codemode: a session that
12
+ * does not declare it keeps the routing-suggestion fallback.
13
+ */
14
+
15
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, type Stats } from "node:fs";
16
+ import { join, sep } from "node:path";
17
+ import { Type, type Static } from "typebox";
18
+ import { defineTool, keyHint, type AgentToolResult, type ExtensionAPI, type ToolNamespace } from "@earendil-works/pi-coding-agent";
19
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
20
+ import { bindLifecycleRenderers, emptyToolCall } from "@fradser/pi-kit";
21
+ import { cacheDir, collectionNamespaceName, exposedCollectionsDir, gatewayDir, leafSkillDir, leafSkillFile, skillToolName } from "./paths";
22
+ import { loadCollections, type RegistryCollection, type RegistryRoute } from "./registry";
23
+ import { scanSkills } from "./sync";
24
+
25
+ /** Instructions one call returns. A longer file is cut at this bound, and the
26
+ * returned body names the file that keeps all of it. */
27
+ const MAX_SKILL_BODY_CHARS = 20000;
28
+
29
+ const TOOL_TAG = "skill-router";
30
+ const CODEMODE_TOOL_NAME = "codemode";
31
+ /** `O_NOFOLLOW` is absent on platforms that cannot express it, where it contributes nothing. */
32
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
33
+
34
+ const SKILL_TOOL_PARAMS = Type.Object({}, { additionalProperties: false });
35
+
36
+ const SKILL_TOOL_OUTPUT = Type.Object({
37
+ ok: Type.Boolean(),
38
+ collection: Type.String(),
39
+ skill: Type.String(),
40
+ tool: Type.String(),
41
+ directory: Type.Optional(Type.String()),
42
+ skillFile: Type.Optional(Type.String()),
43
+ body: Type.Optional(Type.String()),
44
+ truncated: Type.Optional(Type.Boolean()),
45
+ error: Type.Optional(Type.String()),
46
+ }, { additionalProperties: false });
47
+
48
+ type SkillToolOutput = Static<typeof SKILL_TOOL_OUTPUT>;
49
+
50
+ /** What the transcript row reads back from a completed call. */
51
+ interface SkillToolDetails {
52
+ subject?: string;
53
+ summary?: string;
54
+ label?: string;
55
+ details?: readonly string[];
56
+ tone?: "success" | "warning" | "error";
57
+ }
58
+
59
+ type SkillToolResult = AgentToolResult<SkillToolDetails> & { structuredContent: SkillToolOutput };
60
+
61
+ /** A script that searches: `reachesTools` must answer it for every tool a script
62
+ * can call, so what it names is what the session can actually reach. */
63
+ const DISCOVERY_SCRIPT = { code: "await searchTools('')" };
64
+
65
+ /** Whether one skill's tool is reachable from a codemode script: codemode is
66
+ * active, answers a discovery script by naming that exact tool, and the tool
67
+ * survived the session's tool allowlist. A session allowlist (`--tools`,
68
+ * `--exclude-tools`) filters an extension tool out of the registry entirely, so
69
+ * codemode can be active while every call fails. The check is per skill, not per
70
+ * collection: a surviving sibling tool must not stand in for the one the prompt
71
+ * matched, and a collection added after load has no tool at all until a refresh.
72
+ *
73
+ * A `codemode` that declares `reachesTools` but names nothing keeps the fallback:
74
+ * Pi's contract lets it answer per script, and the suggestion is then the only
75
+ * working path. */
76
+ export function codemodeReachesSkillTool(
77
+ pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">,
78
+ toolName: string,
79
+ ): boolean {
80
+ if (!pi.getActiveTools().includes(CODEMODE_TOOL_NAME)) return false;
81
+ const tools = pi.getAllTools();
82
+ const codemode = tools.find((tool) => tool.name === CODEMODE_TOOL_NAME);
83
+ if (typeof codemode?.reachesTools !== "function") return false;
84
+ if (!tools.some((tool) => tool.name === toolName)) return false;
85
+ // A word refers to every tool or namespace whose name contains it.
86
+ let reach: "all" | readonly string[] | undefined;
87
+ try {
88
+ reach = codemode.reachesTools(DISCOVERY_SCRIPT);
89
+ } catch {
90
+ // A `codemode` that cannot answer keeps the fallback: this is the conservative
91
+ // direction, and an exception must not cost the turn its only working path.
92
+ return false;
93
+ }
94
+ return reach === "all" || (Array.isArray(reach) && reach.some((word) => typeof word === "string" && toolName.includes(word)));
95
+ }
96
+
97
+ /** Skill tools one loaded extension instance has registered. Pi cannot
98
+ * unregister a tool, so a refresh withdraws the names a registry change removed
99
+ * by re-registering them hidden. */
100
+ const registeredSkillTools = new WeakMap<ExtensionAPI, string[]>();
101
+
102
+ /** Register every routed leaf skill of every enabled collection, and withdraw the
103
+ * tools of a collection that is gone or disabled since the last refresh. The
104
+ * extension calls this when it loads and after every `/skill-router` mutation, so
105
+ * the tools channel follows the registry without a reload. */
106
+ export function refreshSkillTools(pi: ExtensionAPI, root: string): void {
107
+ const names = new Set<string>();
108
+ const definitions = loadCollections(root)
109
+ .filter((collection) => collection.enabled)
110
+ .flatMap((collection) => {
111
+ const namespace = collectionNamespace(root, collection);
112
+ const upstream = upstreamDescriptions(root, collection);
113
+ return collection.routes.map((route) => {
114
+ const tool = skillTool(root, collection, route, searchableSkillText(route, upstream.get(route.skill) ?? ""), namespace);
115
+ names.add(tool.name);
116
+ return tool;
117
+ });
118
+ });
119
+
120
+ for (const tool of definitions) pi.registerTool(tool);
121
+ for (const name of registeredSkillTools.get(pi) ?? []) {
122
+ if (!names.has(name)) pi.registerTool(withdrawnSkillTool(name));
123
+ }
124
+ registeredSkillTools.set(pi, [...names]);
125
+ }
126
+
127
+ /** A withdrawn skill tool: unreachable, and kept only because Pi cannot
128
+ * unregister a name. */
129
+ function withdrawnSkillTool(name: string) {
130
+ return defineTool<typeof SKILL_TOOL_PARAMS, SkillToolDetails>({
131
+ name,
132
+ label: name,
133
+ description: "Withdrawn skill tool.",
134
+ parameters: SKILL_TOOL_PARAMS,
135
+ outputSchema: SKILL_TOOL_OUTPUT,
136
+ exposure: "hidden",
137
+ renderShell: "self",
138
+ renderCall: () => emptyToolCall(),
139
+ renderResult: skillResultBand,
140
+ async execute(): Promise<SkillToolResult> {
141
+ return refuse(name, "", "", `Skill tool "${name}" is withdrawn: its collection is no longer routed.`);
142
+ },
143
+ });
144
+ }
145
+
146
+ /** Read a regular file, refusing a symlink that appears after the caller's own
147
+ * checks and before this open. */
148
+ function readRegularFile(path: string, validated: Stats): string {
149
+ const descriptor = openSync(path, constants.O_RDONLY | NO_FOLLOW);
150
+ try {
151
+ const opened = fstatSync(descriptor);
152
+ if (!opened.isFile()) throw new Error(`Not a regular file: ${path}`);
153
+ // The descriptor must be the very file that was validated: a directory swapped
154
+ // between the checks and this open would otherwise redirect the read.
155
+ if (opened.dev !== validated.dev || opened.ino !== validated.ino) {
156
+ throw new Error(`File changed between validation and read: ${path}`);
157
+ }
158
+ return readFileSync(descriptor, "utf8");
159
+ } finally {
160
+ closeSync(descriptor);
161
+ }
162
+ }
163
+
164
+ /** The searchable text of one route: the workflow summary and the skill author's
165
+ * own description, without repeating one inside the other. The summary judges
166
+ * relevance; the upstream description carries the words a request uses. */
167
+ function searchableSkillText(route: RegistryRoute, upstreamDescription: string): string {
168
+ const oneLine = (value: string) => value.replace(/\s+/g, " ").trim();
169
+ const summary = oneLine(route.summary ?? "");
170
+ const upstream = oneLine(upstreamDescription);
171
+ if (!upstream || upstream.startsWith(summary.replace(/\.$/, ""))) return upstream || summary;
172
+ return summary ? `${summary} ${upstream}` : upstream;
173
+ }
174
+
175
+ /** One namespace per collection: its capability summary and gateway index answer
176
+ * "what is in this collection" through one lookup. */
177
+ function collectionNamespace(root: string, collection: RegistryCollection): ToolNamespace {
178
+ const gateway = managedFile(root, join(gatewayDir(root, collection.id, collection.gateway), "SKILL.md"));
179
+ return {
180
+ name: collectionNamespaceName(collection.id),
181
+ aliases: [collection.id],
182
+ description: collection.description,
183
+ ...(gateway === undefined ? {} : { instructions: gateway }),
184
+ };
185
+ }
186
+
187
+ /** Upstream descriptions by skill name, from the collection's clone. A missing or
188
+ * unreadable clone leaves the workflow summaries as the searchable text. */
189
+ function upstreamDescriptions(root: string, collection: RegistryCollection): Map<string, string> {
190
+ try {
191
+ const skills = scanSkills(cacheDir(root, collection.source.cacheKey));
192
+ return new Map(skills.map((skill) => [skill.name, skill.description]));
193
+ } catch {
194
+ return new Map();
195
+ }
196
+ }
197
+
198
+ function skillTool(
199
+ root: string,
200
+ collection: RegistryCollection,
201
+ route: RegistryRoute,
202
+ searchable: string,
203
+ namespace: ToolNamespace,
204
+ ) {
205
+ const name = skillToolName(collection.id, route.skill);
206
+ const subject = `${collection.id}/${route.skill}`;
207
+ return defineTool<typeof SKILL_TOOL_PARAMS, SkillToolDetails>({
208
+ name,
209
+ label: route.skill,
210
+ description: searchable,
211
+ promptGuidelines: [
212
+ `Loads the \`${route.skill}\` workflow of the \`${collection.id}\` skill collection. Follow the returned instructions and use the returned directory for its files.`,
213
+ "Call the workflow these instructions load; do not act on the collection's other workflows.",
214
+ ],
215
+ parameters: SKILL_TOOL_PARAMS,
216
+ outputSchema: SKILL_TOOL_OUTPUT,
217
+ exposure: "codemode",
218
+ namespace,
219
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
220
+ renderShell: "self",
221
+ renderCall: () => emptyToolCall(),
222
+ renderResult: skillResultBand,
223
+ async execute(_toolCallId, _params, signal): Promise<SkillToolResult> {
224
+ signal?.throwIfAborted();
225
+ const current = loadCollections(root).find((entry) => entry.id === collection.id && entry.enabled);
226
+ const currentRoute = current?.routes.find((candidate) => candidate.skill === route.skill);
227
+ if (!current || !currentRoute) {
228
+ return refuse(
229
+ name,
230
+ collection.id,
231
+ route.skill,
232
+ `Collection "${collection.id}" is no longer installed and enabled, so "${route.skill}" is unavailable. Run /reload after changing collections.`,
233
+ );
234
+ }
235
+ try {
236
+ const leaf = readLeafSkill(root, current, currentRoute);
237
+ const { body, truncated } = boundSkillBody(leaf.content, leaf.skillFile);
238
+ return {
239
+ content: [{ type: "text", text: body }],
240
+ details: {
241
+ subject,
242
+ label: "loaded",
243
+ details: [leaf.skillFile],
244
+ ...(truncated ? { summary: `The body is cut at ${MAX_SKILL_BODY_CHARS} characters; the file above holds all of it.` } : {}),
245
+ },
246
+ structuredContent: {
247
+ ok: true,
248
+ collection: collection.id,
249
+ skill: route.skill,
250
+ tool: name,
251
+ directory: leaf.directory,
252
+ skillFile: leaf.skillFile,
253
+ body,
254
+ truncated,
255
+ },
256
+ };
257
+ } catch (error) {
258
+ return refuse(name, collection.id, route.skill, error instanceof Error ? error.message : String(error));
259
+ }
260
+ },
261
+ });
262
+ }
263
+
264
+ function refuse(tool: string, collection: string, skill: string, error: string): SkillToolResult {
265
+ return {
266
+ content: [{ type: "text", text: `REFUSED · ${error}` }],
267
+ details: { subject: `${collection}/${skill}`, label: "refused", summary: error, tone: "error" },
268
+ structuredContent: { ok: false, collection, skill, tool, error },
269
+ isError: true,
270
+ };
271
+ }
272
+
273
+ interface LeafSkill {
274
+ directory: string;
275
+ skillFile: string;
276
+ content: string;
277
+ }
278
+
279
+ /** Read one materialized leaf skill, refusing anything that is not a regular file
280
+ * inside the router's exposed collection tree. Containment is anchored on the
281
+ * router root, because a symlinked `exposed` would otherwise move the base that
282
+ * the leaf's own resolution is compared against. */
283
+ function readLeafSkill(root: string, collection: RegistryCollection, route: RegistryRoute): LeafSkill {
284
+ const exposedTree = join(root, "exposed");
285
+ const exposed = exposedCollectionsDir(root);
286
+ if (!isRealDirectory(exposedTree) || !isRealDirectory(exposed)) {
287
+ throw new Error(`Skill Router's exposed collections are unavailable at ${exposed}`);
288
+ }
289
+ const directory = leafSkillDir(root, collection.id, route.skill);
290
+ if (!isRealDirectory(directory)) {
291
+ throw new Error(`Skill "${route.skill}" of collection "${collection.id}" is not materialized at ${directory}`);
292
+ }
293
+ const skillFile = leafSkillFile(root, collection.id, route.skill);
294
+ let stats: Stats;
295
+ try {
296
+ stats = lstatSync(skillFile);
297
+ } catch {
298
+ throw new Error(`Skill "${route.skill}" of collection "${collection.id}" has no instruction file at ${skillFile}`);
299
+ }
300
+ if (!stats.isFile()) {
301
+ throw new Error(`Skill "${route.skill}" of collection "${collection.id}" has an unusable instruction file at ${skillFile}`);
302
+ }
303
+ const resolved = realpathSync(skillFile);
304
+ if (!resolved.startsWith(realpathSync(exposed) + sep) || !resolved.startsWith(realpathSync(root) + sep)) {
305
+ throw new Error(`Skill "${route.skill}" of collection "${collection.id}" resolves outside the exposed collections`);
306
+ }
307
+ return { directory, skillFile, content: readRegularFile(skillFile, stats) };
308
+ }
309
+
310
+ /** Cut a body at the bound and name the file that keeps the rest. */
311
+ function boundSkillBody(content: string, skillFile: string): { body: string; truncated: boolean } {
312
+ if (content.length <= MAX_SKILL_BODY_CHARS) return { body: content, truncated: false };
313
+ let end = MAX_SKILL_BODY_CHARS;
314
+ // Never split a UTF-16 surrogate pair at the cut.
315
+ if (/[\uD800-\uDBFF]/.test(content[end - 1] ?? "")) end -= 1;
316
+ const omitted = content.length - end;
317
+ return { body: `${content.slice(0, end)}\n\n…${omitted} chars truncated…\nComplete instructions: ${skillFile}`, truncated: true };
318
+ }
319
+
320
+ function isRealDirectory(path: string): boolean {
321
+ try {
322
+ const stats = lstatSync(path);
323
+ return stats.isDirectory() && !stats.isSymbolicLink();
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+
329
+ /** Read a managed file only when it is a regular file inside the router root. */
330
+ function managedFile(root: string, path: string): string | undefined {
331
+ try {
332
+ const stats = lstatSync(path);
333
+ if (!stats.isFile()) return undefined;
334
+ if (!realpathSync(path).startsWith(realpathSync(root) + sep)) return undefined;
335
+ return readRegularFile(path, stats);
336
+ } catch {
337
+ return undefined;
338
+ }
339
+ }
340
+
341
+ /** One lifecycle geometry for every skill row, owned by pi-kit. */
342
+ const rows = bindLifecycleRenderers({
343
+ fit: truncateToWidth,
344
+ visibleWidth,
345
+ wrapDetail: (line, width) => wrapTextWithAnsi(line, Math.max(1, width)),
346
+ expandHint: () => keyHint("app.tools.expand", "to expand"),
347
+ });
348
+
349
+ const skillResultBand = rows.result<AgentToolResult<SkillToolDetails>>((value) => {
350
+ const details = (value.details ?? {}) as SkillToolDetails;
351
+ return {
352
+ tool: TOOL_TAG,
353
+ kind: "event" as const,
354
+ label: details.label,
355
+ subject: details.subject ?? "",
356
+ ...(details.summary ? { summary: [details.summary] } : {}),
357
+ ...(details.details ? { details: details.details } : {}),
358
+ ...(details.tone ? { tone: details.tone } : {}),
359
+ };
360
+ });
package/src/sync.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  } from "node:fs";
18
18
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
19
19
  import { setTimeout as delay } from "node:timers/promises";
20
- import { cacheDir, exposedDir, gatewayDir, leafSkillFile, legacyExposedDir } from "./paths";
20
+ import { cacheDir, exposedDir, gatewayDir, leafSkillFile, legacyExposedDir, skillToolName } from "./paths";
21
21
  import { isCollectionId, isSafeGitRef, isSlug, loadCollections, saveCollections, type RegistryCollection, type RegistryRoute } from "./registry";
22
22
 
23
23
  export interface RepoSpec {
@@ -343,7 +343,7 @@ function gatewayContent(root: string, collection: RegistryCollection, leaves: Up
343
343
  const gateway = gatewayDir(root, collection.id, collection.gateway);
344
344
  const workflows = leaves.map((skill) => [
345
345
  `- \`${skill.name}\` — ${collection.routes.find((route) => route.skill === skill.name)?.summary ?? fallbackWorkflowSummary(skill.description)}`,
346
- ` Read \`${relative(gateway, leafSkillFile(root, collection.id, skill.name))}\` before applying this workflow.`,
346
+ ` Tool \`${skillToolName(collection.id, skill.name)}\`, file \`${relative(gateway, leafSkillFile(root, collection.id, skill.name))}\`.`,
347
347
  ]).flat();
348
348
 
349
349
  return [
@@ -354,7 +354,7 @@ function gatewayContent(root: string, collection: RegistryCollection, leaves: Up
354
354
  "",
355
355
  "## Available workflows",
356
356
  "",
357
- "Select the one workflow that best fits the user's request, then read its SKILL.md before proceeding.",
357
+ "Select the one workflow that best fits the user's request, call its tool, and follow the returned instructions. The listed file holds the same instructions and resolves the workflow's own files.",
358
358
  "",
359
359
  ...workflows,
360
360
  "",