pi-session-mcp 0.7.0 → 0.8.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.
Files changed (66) hide show
  1. package/.pi-session-mcp-cli/config.js +47 -7
  2. package/.pi-session-mcp-cli/doctor.js +68 -2
  3. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  4. package/.pi-session-mcp-cli/extension-spec.js +7 -2
  5. package/.pi-session-mcp-cli/setup-result.js +8 -0
  6. package/.pi-session-mcp-cli/skill-catalog.js +35 -0
  7. package/.pi-session-mcp-cli/skill-customize.js +183 -0
  8. package/.pi-session-mcp-cli/skill-library.js +120 -0
  9. package/CHANGELOG.md +110 -0
  10. package/README.md +15 -3
  11. package/dist/capabilities.d.ts +1 -0
  12. package/dist/capabilities.js +2 -1
  13. package/dist/client-adapters/mcp-verifier.js +6 -2
  14. package/dist/config.d.ts +4 -0
  15. package/dist/config.js +47 -7
  16. package/dist/doctor.d.ts +4 -1
  17. package/dist/doctor.js +68 -2
  18. package/dist/execution-profile.d.ts +4 -0
  19. package/dist/execution-profile.js +1 -0
  20. package/dist/extension-spec.js +7 -2
  21. package/dist/pi-adapter.d.ts +1 -1
  22. package/dist/pi-adapter.js +1 -0
  23. package/dist/pi-skills.d.ts +17 -0
  24. package/dist/pi-skills.js +64 -0
  25. package/dist/sdk-pi-adapter.d.ts +7 -3
  26. package/dist/sdk-pi-adapter.js +57 -11
  27. package/dist/server.js +1 -0
  28. package/dist/session-tools.d.ts +1 -1
  29. package/dist/session-tools.js +20 -4
  30. package/dist/setup-cli.d.ts +2 -0
  31. package/dist/setup-cli.js +16 -0
  32. package/dist/setup-main.d.ts +1 -1
  33. package/dist/setup-main.js +18 -3
  34. package/dist/setup-ownership.d.ts +6 -0
  35. package/dist/setup-ownership.js +29 -5
  36. package/dist/setup-skills.d.ts +37 -0
  37. package/dist/setup-skills.js +353 -0
  38. package/dist/setup.d.ts +7 -0
  39. package/dist/setup.js +49 -6
  40. package/dist/skill-catalog.d.ts +26 -0
  41. package/dist/skill-catalog.js +35 -0
  42. package/dist/skill-customize.d.ts +23 -0
  43. package/dist/skill-customize.js +183 -0
  44. package/dist/skill-library.d.ts +61 -0
  45. package/dist/skill-library.js +120 -0
  46. package/docs/acceptance.md +372 -3
  47. package/docs/adr/0006-pi-extension-allowlist.md +5 -2
  48. package/docs/adr/0007-profile-skill-selection.md +217 -0
  49. package/docs/architecture.md +9 -5
  50. package/docs/client-setup-design.md +133 -1
  51. package/docs/client-setup.md +74 -3
  52. package/docs/doctor-design.md +9 -1
  53. package/docs/installation.md +129 -9
  54. package/docs/pi-extensions-design.md +41 -12
  55. package/docs/releasing.md +15 -6
  56. package/docs/session-mcp.md +7 -2
  57. package/docs/skills.md +487 -0
  58. package/docs/threat-model.md +21 -4
  59. package/docs/tool-contracts.md +9 -5
  60. package/package.json +3 -1
  61. package/pi-session-mcp.example.json +9 -3
  62. package/skills/pi-session-implementation/SKILL.md +42 -0
  63. package/skills/pi-session-orchestration/SKILL.md +93 -0
  64. package/skills/pi-session-review/SKILL.md +58 -0
  65. package/docs/superpowers/plans/2026-10-10-macos-setup.md +0 -1725
  66. package/docs/superpowers/plans/2026-10-11-pi-extensions.md +0 -2450
@@ -1,4 +1,4 @@
1
- import { type AgentSessionEvent, type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, DefaultResourceLoader, ModelRuntime, type PromptOptions, SettingsManager } from "@earendil-works/pi-coding-agent";
1
+ import { type AgentSessionEvent, type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, DefaultResourceLoader, ModelRuntime, type PromptOptions, type Skill, SettingsManager } from "@earendil-works/pi-coding-agent";
2
2
  import { type ExtensionSpec } from "./extension-spec.js";
3
3
  import type { ResolvedExecutionProfile, ThinkingLevel } from "./execution-profile.js";
4
4
  import { type McpConnectionFactory } from "./mcp-session-tools.js";
@@ -11,7 +11,7 @@ export declare const SESSION_CREATION_TIMEOUT_MS = 10000;
11
11
  export interface SdkPiAdapterDiagnostic {
12
12
  readonly level: "error";
13
13
  readonly event: "pi_session_creation_failed";
14
- readonly stage: "runtime" | "provider" | "model" | "check_auth" | "get_auth" | "availability" | "resource_loader" | "mcp_tools" | "create_session" | "postcheck" | "extension_resolve" | "extension_load" | "extension_bind";
14
+ readonly stage: "runtime" | "provider" | "model" | "check_auth" | "get_auth" | "availability" | "resource_loader" | "mcp_tools" | "create_session" | "postcheck" | "extension_resolve" | "extension_load" | "extension_bind" | "skill_load";
15
15
  readonly code: PiSessionCreationErrorCode;
16
16
  readonly executionProfile: string;
17
17
  readonly provider: string;
@@ -22,6 +22,8 @@ export interface SdkPiAdapterDiagnostic {
22
22
  readonly discoveredToolCount?: number;
23
23
  /** `<name>@<version>` of the extension that failed to resolve; never a path. */
24
24
  readonly extension?: string;
25
+ /** The failing configured catalog alias, never a skill-file name or path. */
26
+ readonly skill?: string;
25
27
  }
26
28
  /** The Pi SDK session-lifecycle API used for profiles with extensions; injectable for tests. */
27
29
  export interface ExtensionSdk {
@@ -64,7 +66,7 @@ export type PromptDisposition = Parameters<NonNullable<PromptOptions["preflightR
64
66
  * typecheck (and therefore the Pi SDK canary) instead of being mapped silently.
65
67
  */
66
68
  export declare function admitsTurn(disposition: PromptDisposition): boolean;
67
- export declare function createPhase0ResourceLoader(cwd: string): Promise<{
69
+ export declare function createPhase0ResourceLoader(cwd: string, skills?: readonly Skill[]): Promise<{
68
70
  resourceLoader: DefaultResourceLoader;
69
71
  settingsManager: SettingsManager;
70
72
  }>;
@@ -83,6 +85,8 @@ export declare class SdkPiSessionAdapter implements PiSessionAdapter {
83
85
  signal?: AbortSignal;
84
86
  }): Promise<PiSessionHandle>;
85
87
  private createWithExtensions;
88
+ private loadSkills;
89
+ private checkResources;
86
90
  /** Provider, model, authentication and availability, exactly as configured. */
87
91
  private resolveVerifiedModel;
88
92
  private connectMcpTools;
@@ -4,6 +4,7 @@ import { classifyMcpStartupError, connectSessionMcpTools, } from "./mcp-session-
4
4
  import { createStdioMcpConnection } from "./mcp-stdio-client.js";
5
5
  import { PiSessionCreationError, } from "./pi-adapter.js";
6
6
  import { resolvePiExtension } from "./pi-extensions.js";
7
+ import { loadSelectedSkills, PiSkillError } from "./pi-skills.js";
7
8
  import { composeSessionTools, PROFILE_TOOLS } from "./session-tools.js";
8
9
  export { PROFILE_TOOLS } from "./session-tools.js";
9
10
  export const AUTH_OPERATION_TIMEOUT_MS = 5_000;
@@ -223,6 +224,9 @@ class SdkHandle {
223
224
  promptResult = this.session.prompt(text, {
224
225
  // Client text is content, never a command: no extension command, skill or template dispatch.
225
226
  expandPromptTemplates: false,
227
+ // Pi's own RPC mode uses `rpc` for input from a program over stdio, which matches an MCP
228
+ // client. `interactive`, the SDK default, means a human at the terminal or the CLI's print mode.
229
+ source: "rpc",
226
230
  preflightResult: (disposition) => signalPreflight(admitsTurn(disposition)),
227
231
  });
228
232
  }
@@ -266,8 +270,10 @@ function isTextBlock(block) {
266
270
  const candidate = block;
267
271
  return candidate.type === "text" && typeof candidate.text === "string";
268
272
  }
269
- /** Resource-loader options for every embedded session: no discovery, skills, templates or themes. */
270
- function phase0ResourceLoaderOptions() {
273
+ /** Independently validated skills replace package and extension discovery on every reload. */
274
+ function phase0ResourceLoaderOptions(skills) {
275
+ // Keep the closure's snapshot private: the SDK may annotate or mutate returned objects.
276
+ const selected = skills.map((skill) => ({ ...skill, sourceInfo: { ...skill.sourceInfo } }));
271
277
  return {
272
278
  noExtensions: true,
273
279
  noSkills: true,
@@ -278,11 +284,14 @@ function phase0ResourceLoaderOptions() {
278
284
  appendSystemPrompt: [],
279
285
  systemPromptOverride: () => undefined,
280
286
  appendSystemPromptOverride: () => [],
287
+ skillsOverride: () => ({ skills: selected.map((skill) => ({ ...skill, sourceInfo: { ...skill.sourceInfo } })), diagnostics: [] }),
288
+ promptsOverride: () => ({ prompts: [], diagnostics: [] }),
289
+ themesOverride: () => ({ themes: [], diagnostics: [] }),
281
290
  };
282
291
  }
283
- export async function createPhase0ResourceLoader(cwd) {
292
+ export async function createPhase0ResourceLoader(cwd, skills = []) {
284
293
  const settingsManager = SettingsManager.inMemory(undefined, { projectTrusted: false });
285
- const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions() });
294
+ const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions(skills) });
286
295
  await resourceLoader.reload();
287
296
  return { resourceLoader, settingsManager };
288
297
  }
@@ -307,6 +316,7 @@ export class SdkPiSessionAdapter {
307
316
  if (extensions.length > 0)
308
317
  return this.createWithExtensions(input, extensions);
309
318
  const { executionProfile } = input;
319
+ const skills = await this.loadSkills(input);
310
320
  let runtime;
311
321
  try {
312
322
  runtime = await this.getRuntime();
@@ -318,7 +328,7 @@ export class SdkPiSessionAdapter {
318
328
  let resourceLoader;
319
329
  let settingsManager;
320
330
  try {
321
- ({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd));
331
+ ({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd, skills));
322
332
  }
323
333
  catch {
324
334
  throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
@@ -344,10 +354,11 @@ export class SdkPiSessionAdapter {
344
354
  await mcpTools.close();
345
355
  throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
346
356
  }
347
- return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined);
357
+ return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined, "exact", skills);
348
358
  }
349
359
  async createWithExtensions(input, extensions) {
350
360
  const { cwd, executionProfile } = input;
361
+ const skills = await this.loadSkills(input);
351
362
  const directories = [];
352
363
  for (const extension of extensions) {
353
364
  try {
@@ -374,15 +385,18 @@ export class SdkPiSessionAdapter {
374
385
  agentDir: cwd,
375
386
  modelRuntime: runtime,
376
387
  settingsManager: SettingsManager.inMemory(undefined, { projectTrusted: false }),
377
- resourceLoaderOptions: { ...phase0ResourceLoaderOptions(), additionalExtensionPaths: directories },
388
+ resourceLoaderOptions: { ...phase0ResourceLoaderOptions(skills), additionalExtensionPaths: directories },
378
389
  }));
390
+ this.checkResources(services.resourceLoader, skills, executionProfile, "extension_load");
379
391
  const loaded = services.resourceLoader.getExtensions();
380
392
  if (loaded.errors.length > 0 || services.diagnostics.some((diagnostic) => diagnostic.type === "error")) {
381
393
  throw new Error("extension load reported errors");
382
394
  }
383
395
  extensionTools = registeredExtensionTools(services);
384
396
  }
385
- catch {
397
+ catch (error) {
398
+ if (error instanceof PiSessionCreationError)
399
+ throw error;
386
400
  throw this.failure(executionProfile, "extension_load", "extension_load_failed");
387
401
  }
388
402
  const model = await this.resolveVerifiedModel(services.modelRuntime, executionProfile);
@@ -431,6 +445,7 @@ export class SdkPiSessionAdapter {
431
445
  bound = true;
432
446
  if (bindErrors > 0)
433
447
  throw new Error("extension hook reported errors while binding");
448
+ this.checkResources(sessionRuntime.session.resourceLoader, skills, executionProfile, "extension_bind");
434
449
  // A `session_start` handler may register a tool that replaces a built-in or granted tool.
435
450
  // Tools an extension registers later, during a turn, are not re-checked: allowlisted
436
451
  // extensions are trusted code.
@@ -438,15 +453,44 @@ export class SdkPiSessionAdapter {
438
453
  if (!recheck || recheck.length !== tools.length || recheck.some((name, index) => name !== tools[index]))
439
454
  conflict = true;
440
455
  }
441
- catch {
456
+ catch (error) {
442
457
  await cleanUp();
458
+ if (error instanceof PiSessionCreationError)
459
+ throw error;
443
460
  throw this.failure(executionProfile, "extension_bind", "extension_load_failed");
444
461
  }
445
462
  if (conflict) {
446
463
  await cleanUp();
447
464
  throw this.failure(executionProfile, "extension_bind", "extension_tool_conflict");
448
465
  }
449
- return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset");
466
+ return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset", skills);
467
+ }
468
+ async loadSkills(input) {
469
+ try {
470
+ return await loadSelectedSkills(input.executionProfile.skills ?? [], input.cwd);
471
+ }
472
+ catch (error) {
473
+ throw this.failure(input.executionProfile, "skill_load", "skill_load_failed", error instanceof PiSkillError && error.alias !== undefined ? { skill: error.alias } : undefined);
474
+ }
475
+ }
476
+ checkResources(loader, skills, executionProfile, stage) {
477
+ let actual;
478
+ try {
479
+ actual = loader.getSkills().skills;
480
+ }
481
+ catch {
482
+ throw this.failure(executionProfile, stage, "skill_load_failed");
483
+ }
484
+ // Compare a multiset so duplicate entries cannot satisfy a selected file twice.
485
+ const identity = (skill) => JSON.stringify([skill.filePath, skill.name]);
486
+ const expected = skills.map(identity).sort();
487
+ const received = actual.map(identity).sort();
488
+ if (expected.length !== received.length || expected.some((entry, index) => entry !== received[index])) {
489
+ throw this.failure(executionProfile, stage, "skill_load_failed");
490
+ }
491
+ if (loader.getPrompts().prompts.length > 0 || loader.getThemes().themes.length > 0) {
492
+ throw this.failure(executionProfile, stage, "pi_session_creation_failed");
493
+ }
450
494
  }
451
495
  /** Provider, model, authentication and availability, exactly as configured. */
452
496
  async resolveVerifiedModel(runtime, executionProfile) {
@@ -510,8 +554,9 @@ export class SdkPiSessionAdapter {
510
554
  /** Post-verifies a created session and wraps it; a mismatch disposes it before reporting. */
511
555
  async admit(session, executionProfile, tools, mcpTools, disposeSession, toolMismatch,
512
556
  // `subset` lets an extension narrow its own active tools; nothing outside the allowlist may be active.
513
- toolCheck = "exact") {
557
+ toolCheck = "exact", skills = []) {
514
558
  try {
559
+ this.checkResources(session.resourceLoader, skills, executionProfile, "postcheck");
515
560
  if (session.model?.provider !== executionProfile.provider || session.model.id !== executionProfile.model) {
516
561
  throw this.failure(executionProfile, "postcheck", "execution_selection_mismatch");
517
562
  }
@@ -567,6 +612,7 @@ export class SdkPiSessionAdapter {
567
612
  ...(metadata?.toolAlias === undefined ? {} : { toolAlias: metadata.toolAlias }),
568
613
  ...(metadata?.discoveredToolCount === undefined ? {} : { discoveredToolCount: metadata.discoveredToolCount }),
569
614
  ...(metadata?.extension === undefined ? {} : { extension: metadata.extension }),
615
+ ...(metadata?.skill === undefined ? {} : { skill: metadata.skill }),
570
616
  });
571
617
  }
572
618
  catch {
package/dist/server.js CHANGED
@@ -42,6 +42,7 @@ const capabilitiesSchema = z.object({
42
42
  model: z.string(),
43
43
  thinkingLevel: thinkingLevelSchema,
44
44
  extensions: z.array(z.string()),
45
+ skills: z.array(aliasSchema),
45
46
  }).strict()),
46
47
  }).strict();
47
48
  function result(value, isError = false) {
@@ -4,6 +4,6 @@ export declare const PROFILE_TOOLS: Readonly<Record<PermissionProfile, readonly
4
4
  * The tool allowlist of a session, or undefined when an extension tool name conflicts.
5
5
  * Extension tools are active only in coding profiles, but a conflict fails closed in both,
6
6
  * so an extension can never shadow a built-in or granted tool. A name outside the safe
7
- * character set also fails closed.
7
+ * character set, or one the SDK treats as an MCP tool, also fails closed.
8
8
  */
9
9
  export declare function composeSessionTools(permissionProfile: PermissionProfile, mcpToolNames: readonly string[], extensionToolNames: readonly (readonly string[])[]): string[] | undefined;
@@ -2,20 +2,36 @@ export const PROFILE_TOOLS = {
2
2
  "read-only": ["read", "grep", "find", "ls"],
3
3
  coding: ["read", "bash", "edit", "write", "grep", "find", "ls"],
4
4
  };
5
- /** The SDK reads `*` in an allowlist as a glob and a leading `+` or `-` as a modifier, so those names are rejected: the first character may not be `-`. */
6
- const EXTENSION_TOOL_NAME = /^[A-Za-z0-9_.:][A-Za-z0-9_.:-]*$/;
5
+ /**
6
+ * Letters, digits, `_` and `-`, at most 64 characters, which fits the common provider
7
+ * function-name limit `^[a-zA-Z0-9_-]{1,64}$`. The SDK reads `*` in an allowlist as a glob
8
+ * and a leading `+` or `-` as a modifier, so the first character may not be `-`.
9
+ */
10
+ const EXTENSION_TOOL_NAME = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$/;
11
+ /**
12
+ * These mirror `isMcpToolName` of Pi SDK 1.1.0 (core/mcp-servers.js), which is not a root
13
+ * export. When the allowlist names no `mcp__` entry, the SDK's `_isAllowedTool` keeps such
14
+ * tools outside the allowlist for codemode and tool_search, so an extension tool with one
15
+ * of these names could be active without being granted. This server's own MCP bridge
16
+ * tools are named `mcp_<server>_<tool>` with a single underscore, so they never collide.
17
+ */
18
+ const MCP_RESERVED_PREFIX = "mcp__";
19
+ const MCP_RESERVED_NAMES = new Set(["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]);
20
+ function isAcceptableExtensionToolName(name) {
21
+ return EXTENSION_TOOL_NAME.test(name) && !name.startsWith(MCP_RESERVED_PREFIX) && !MCP_RESERVED_NAMES.has(name);
22
+ }
7
23
  /**
8
24
  * The tool allowlist of a session, or undefined when an extension tool name conflicts.
9
25
  * Extension tools are active only in coding profiles, but a conflict fails closed in both,
10
26
  * so an extension can never shadow a built-in or granted tool. A name outside the safe
11
- * character set also fails closed.
27
+ * character set, or one the SDK treats as an MCP tool, also fails closed.
12
28
  */
13
29
  export function composeSessionTools(permissionProfile, mcpToolNames, extensionToolNames) {
14
30
  const reserved = new Set([...PROFILE_TOOLS.coding, ...PROFILE_TOOLS["read-only"], ...mcpToolNames]);
15
31
  const extensionTools = [];
16
32
  for (const names of extensionToolNames) {
17
33
  for (const name of names) {
18
- if (!EXTENSION_TOOL_NAME.test(name) || reserved.has(name))
34
+ if (!isAcceptableExtensionToolName(name) || reserved.has(name))
19
35
  return undefined;
20
36
  reserved.add(name);
21
37
  extensionTools.push(name);
@@ -3,6 +3,8 @@ import type { SetupResult } from "./setup-result.js";
3
3
  /** Small argv adapter; command execution remains injected in SetupClientAdapter. */
4
4
  export interface SetupCliRequest extends SetupRequest {
5
5
  readonly json: boolean;
6
+ /** `--customize-skill <name>`: copies a shipped skill into the override directory instead of touching clients. */
7
+ readonly customizeSkill?: string;
6
8
  }
7
9
  export declare function parseSetupArgs(argv: readonly string[]): SetupCliRequest;
8
10
  export declare function runSetupCli(argv: readonly string[], orchestrator: SetupOrchestrator): Promise<SetupResult>;
package/dist/setup-cli.js CHANGED
@@ -2,6 +2,7 @@ export function parseSetupArgs(argv) {
2
2
  let operation;
3
3
  let json = false;
4
4
  const targets = [];
5
+ let customizeSkill;
5
6
  for (let index = 0; index < argv.length; index += 1) {
6
7
  const argument = argv[index];
7
8
  if (argument === "--json") {
@@ -24,8 +25,23 @@ export function parseSetupArgs(argv) {
24
25
  targets.push({ client: parts[0], scope: parts[1], alias: parts[2] });
25
26
  continue;
26
27
  }
28
+ if (argument === "--customize-skill") {
29
+ if (customizeSkill !== undefined)
30
+ throw new Error("customize_skill_repeated");
31
+ const value = argv[++index];
32
+ if (value === undefined || value === "" || value.startsWith("-"))
33
+ throw new Error("customize_skill_invalid");
34
+ customizeSkill = value;
35
+ continue;
36
+ }
27
37
  throw new Error("argument_unsupported");
28
38
  }
39
+ if (customizeSkill !== undefined) {
40
+ // Skill customization is a dry-run or an explicit apply; it never targets a client.
41
+ if (targets.length > 0 || (operation !== undefined && operation !== "dry-run" && operation !== "apply"))
42
+ throw new Error("customize_skill_combination");
43
+ return operation ? { operation, targets, json, customizeSkill } : { targets, json, customizeSkill };
44
+ }
29
45
  return operation ? { operation, targets, json } : { targets, json };
30
46
  }
31
47
  export async function runSetupCli(argv, orchestrator) {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createProductionSetup } from "./setup.js";
3
- /** Executable entry point: `node dist/setup-main.js [operation] --target client:scope:alias`. */
3
+ /** Executable entry point: `node dist/setup-main.js [operation] --target client:scope:alias` or `[--apply] --customize-skill <name>`. */
4
4
  export interface SetupMainDependencies {
5
5
  readonly createSetup?: typeof createProductionSetup;
6
6
  readonly packageRoot?: string;
@@ -5,6 +5,8 @@ import { isEntryPoint } from "./entry-point.js";
5
5
  import { createProcessRunner } from "./setup-process.js";
6
6
  import { currentSetupPlatform } from "./setup-platform.js";
7
7
  import { createProductionSetup, isSupportedTarget } from "./setup.js";
8
+ import { customizeSkill } from "./skill-customize.js";
9
+ import { SKILL_NAME_PATTERN } from "./skill-library.js";
8
10
  import { formatSetupHuman, formatSetupResult, parseSetupArgs } from "./setup-cli.js";
9
11
  import { result } from "./setup-result.js";
10
12
  export async function main(argv = process.argv.slice(2), dependencies = {}) {
@@ -18,11 +20,21 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
18
20
  return write(cliFailure("dry-run", [], "usage_invalid", 64), json);
19
21
  }
20
22
  const operation = request.operation ?? "dry-run";
23
+ const failureTargets = request.customizeSkill === undefined ? request.targets : [skillTarget(request.customizeSkill)];
21
24
  if (!currentSetupPlatform())
22
- return write(cliFailure(operation, request.targets, "platform_unsupported", 1), request.json);
25
+ return write(cliFailure(operation, failureTargets, "platform_unsupported", 1), request.json);
23
26
  const config = process.env.PI_SESSION_MCP_CONFIG;
24
27
  if (!config)
25
- return write(cliFailure(operation, request.targets, "config_required", 1), request.json);
28
+ return write(cliFailure(operation, failureTargets, "config_required", 1), request.json);
29
+ if (request.customizeSkill !== undefined) {
30
+ const name = request.customizeSkill;
31
+ try {
32
+ return write(await customizeSkill({ name, configPath: resolve(config), apply: operation === "apply", ...(dependencies.packageRoot === undefined ? {} : { packageRoot: dependencies.packageRoot }) }), request.json);
33
+ }
34
+ catch {
35
+ return write(cliFailure(operation, [skillTarget(name)], "skill_customize_failed", 1), request.json);
36
+ }
37
+ }
26
38
  const root = dependencies.packageRoot ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
39
  const controller = new AbortController();
28
40
  let interrupted;
@@ -51,8 +63,11 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
51
63
  process.removeListener("SIGTERM", onTerminate);
52
64
  }
53
65
  }
66
+ /** The skill name is echoed only when it is a valid name; arbitrary input never reaches a report. */
67
+ function skillTarget(name) { return { client: "skills", scope: "override", alias: SKILL_NAME_PATTERN.test(name) ? name : "invalid" }; }
68
+ function isReportableTarget(target) { return isSupportedTarget(target) || (target.client === "skills" && target.scope === "override" && (target.alias === "invalid" || SKILL_NAME_PATTERN.test(target.alias))); }
54
69
  function cliFailure(operation, targets, code, exitCode) {
55
- const safeTargets = targets.filter(isSupportedTarget);
70
+ const safeTargets = targets.filter(isReportableTarget);
56
71
  const reported = safeTargets.length === targets.length && safeTargets.length > 0 ? safeTargets : [{ client: "invalid", scope: "invalid", alias: "invalid" }];
57
72
  return result(operation, reported.map((target) => ({ target, status: "failed", code })), "failed", exitCode);
58
73
  }
@@ -6,6 +6,11 @@ export interface OwnershipRecord {
6
6
  readonly previous: "absent" | "equivalent";
7
7
  readonly phase: "pending" | "owned";
8
8
  readonly transactionId: string;
9
+ /**
10
+ * Skill records only, while pending: the fingerprint of setup's own copy that the pending
11
+ * update replaces, so an interrupted update can still be completed or removed.
12
+ */
13
+ readonly replaces?: string;
9
14
  }
10
15
  export interface RegistrationSnapshot {
11
16
  /** Opaque adapter-owned snapshot; implementations must not persist secrets. */
@@ -51,3 +56,4 @@ export declare class DurableOwnershipStore implements OwnershipStore {
51
56
  private withLock;
52
57
  private write;
53
58
  }
59
+ export declare function isRecord(value: unknown): value is OwnershipRecord;
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { dirname } from "node:path";
5
5
  import { acquireSetupGuardian } from "./setup-process.js";
6
6
  import { assertSupportedFilesystem, currentSetupPlatform, openLockedDarwin } from "./setup-platform.js";
7
+ import { SKILL_NAME_PATTERN } from "./skill-library.js";
7
8
  /** Safe default for one process; callers needing recovery inject a durable store. */
8
9
  export class MemoryOwnershipStore {
9
10
  entries = new Map();
@@ -209,10 +210,33 @@ async function syncDirectory(path) {
209
210
  await handle.close();
210
211
  }
211
212
  }
212
- function isRecord(value) { if (!value || typeof value !== "object")
213
- return false; const x = value; if (!exactKeys(x, ["target", "fingerprint", "previous", "phase", "transactionId"]))
214
- return false; const t = x.target; if (!t || typeof t !== "object")
215
- return false; const target = t; if (!exactKeys(target, ["client", "scope", "alias"]))
216
- return false; const client = target.client; const scope = target.scope; return (client === "codex" || client === "claude-code") && typeof scope === "string" && (client === "codex" ? scope === "user" : ["user", "project", "local"].includes(scope)) && target.alias === "pi-session-mcp" && typeof x.fingerprint === "string" && /^[a-f0-9]{64}$/.test(x.fingerprint) && (x.phase === "pending" || x.phase === "owned") && x.previous === "absent" && typeof x.transactionId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(x.transactionId); }
213
+ /** `skill:<name>` for a home slot; `skill:<name>@<16 hex>` for a project slot bound to an opaque project identity. */
214
+ const SKILL_RECORD_ALIAS = /^skill:([^@]+)(?:@([0-9a-f]{16}))?$/;
215
+ export function isRecord(value) {
216
+ if (!value || typeof value !== "object")
217
+ return false;
218
+ const x = value;
219
+ const keys = ["target", "fingerprint", "previous", "phase", "transactionId"];
220
+ if (!exactKeys(x, keys) && !exactKeys(x, [...keys, "replaces"]))
221
+ return false;
222
+ if (!x.target || typeof x.target !== "object")
223
+ return false;
224
+ const target = x.target;
225
+ if (!exactKeys(target, ["client", "scope", "alias"]))
226
+ return false;
227
+ const { client, scope, alias } = target;
228
+ const registration = alias === "pi-session-mcp" &&
229
+ (client === "codex" ? scope === "user" : client === "claude-code" && typeof scope === "string" && ["user", "project", "local"].includes(scope));
230
+ const skillAlias = typeof alias === "string" ? SKILL_RECORD_ALIAS.exec(alias) : null;
231
+ const skill = skillAlias !== null && SKILL_NAME_PATTERN.test(skillAlias[1]) &&
232
+ (client === "codex" ? scope === "user" && skillAlias[2] === undefined
233
+ : client === "claude-code" && ((scope === "user" && skillAlias[2] === undefined) || (scope === "project" && skillAlias[2] !== undefined)));
234
+ // `replaces` belongs only to a pending skill record.
235
+ if ("replaces" in x && !(skill && x.phase === "pending" && typeof x.replaces === "string" && /^[a-f0-9]{64}$/.test(x.replaces)))
236
+ return false;
237
+ return (registration || skill) && typeof x.fingerprint === "string" && /^[a-f0-9]{64}$/.test(x.fingerprint) &&
238
+ (x.phase === "pending" || x.phase === "owned") && x.previous === "absent" &&
239
+ typeof x.transactionId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(x.transactionId);
240
+ }
217
241
  function exactKeys(value, expected) { const keys = Object.keys(value).sort(); return keys.length === expected.length && [...expected].sort().every((key, index) => keys[index] === key); }
218
242
  const key = (target) => `${target.client}\u0000${target.scope}\u0000${target.alias}`;
@@ -0,0 +1,37 @@
1
+ import type { OwnershipAccess } from "./setup-ownership.js";
2
+ import type { SetupFinding, SetupOperation, SetupTarget } from "./setup-result.js";
3
+ export interface SetupSkillInstaller {
4
+ /** Called under the registration's ownership transaction for every mutation. */
5
+ run(operation: SetupOperation, target: SetupTarget, ownership: OwnershipAccess): Promise<readonly SetupFinding[]>;
6
+ /**
7
+ * Whether setup still owns an installed skill of exactly this registration target, so that a
8
+ * remove may finish the skill cleanup after the registration is already gone.
9
+ */
10
+ owns(target: SetupTarget, ownership: OwnershipAccess): Promise<boolean>;
11
+ }
12
+ export interface SetupSkillRoots {
13
+ readonly packageRoot: string;
14
+ readonly configPath: string;
15
+ readonly home: string;
16
+ readonly cwd: string;
17
+ }
18
+ /** Paths remain internal. Ownership identifies a client slot, never its location. */
19
+ export declare class FileSetupSkillInstaller implements SetupSkillInstaller {
20
+ private readonly roots;
21
+ constructor(roots: SetupSkillRoots);
22
+ run(operation: SetupOperation, target: SetupTarget, ownership: OwnershipAccess): Promise<readonly SetupFinding[]>;
23
+ owns(target: SetupTarget, ownership: OwnershipAccess): Promise<boolean>;
24
+ /**
25
+ * The slot path and its ownership key. A project may be untrusted: only its root is
26
+ * canonicalized, and a link below it (for example `.claude` pointing elsewhere) makes the
27
+ * slot divergent, so setup never writes outside the project. In the operator's home the
28
+ * client's skills directory may be the operator's own link, for example into a dotfiles
29
+ * checkout, so it is followed. The skill entry itself is never followed in either place.
30
+ * A project slot's ownership is bound to an opaque, path-free identity of the project, so a
31
+ * remove in another project never touches this project's copy.
32
+ */
33
+ private location;
34
+ private library;
35
+ private skill;
36
+ private replace;
37
+ }