pi-session-mcp 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.pi-session-mcp-cli/config.js +47 -7
  2. package/.pi-session-mcp-cli/doctor.js +75 -2
  3. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  4. package/.pi-session-mcp-cli/override-lock.js +125 -0
  5. package/.pi-session-mcp-cli/setup-platform.js +195 -0
  6. package/.pi-session-mcp-cli/setup-result.js +8 -0
  7. package/.pi-session-mcp-cli/skill-catalog.js +35 -0
  8. package/.pi-session-mcp-cli/skill-customize.js +268 -0
  9. package/.pi-session-mcp-cli/skill-library.js +188 -0
  10. package/CHANGELOG.md +176 -0
  11. package/README.md +15 -3
  12. package/dist/capabilities.d.ts +1 -0
  13. package/dist/capabilities.js +2 -1
  14. package/dist/client-adapters/mcp-verifier.js +6 -2
  15. package/dist/config.d.ts +4 -0
  16. package/dist/config.js +47 -7
  17. package/dist/doctor.d.ts +7 -1
  18. package/dist/doctor.js +75 -2
  19. package/dist/execution-profile.d.ts +4 -0
  20. package/dist/execution-profile.js +1 -0
  21. package/dist/override-lock.d.ts +34 -0
  22. package/dist/override-lock.js +125 -0
  23. package/dist/pi-adapter.d.ts +7 -5
  24. package/dist/pi-adapter.js +1 -0
  25. package/dist/pi-skills.d.ts +17 -0
  26. package/dist/pi-skills.js +64 -0
  27. package/dist/sdk-pi-adapter.d.ts +7 -3
  28. package/dist/sdk-pi-adapter.js +82 -16
  29. package/dist/server.js +1 -0
  30. package/dist/session-registry.d.ts +8 -6
  31. package/dist/session-registry.js +3 -2
  32. package/dist/setup-cli.d.ts +2 -0
  33. package/dist/setup-cli.js +16 -0
  34. package/dist/setup-main.d.ts +1 -1
  35. package/dist/setup-main.js +18 -3
  36. package/dist/setup-ownership.d.ts +9 -0
  37. package/dist/setup-ownership.js +32 -5
  38. package/dist/setup-skills.d.ts +76 -0
  39. package/dist/setup-skills.js +586 -0
  40. package/dist/setup.d.ts +7 -0
  41. package/dist/setup.js +49 -6
  42. package/dist/skill-catalog.d.ts +26 -0
  43. package/dist/skill-catalog.js +35 -0
  44. package/dist/skill-customize.d.ts +41 -0
  45. package/dist/skill-customize.js +268 -0
  46. package/dist/skill-library.d.ts +106 -0
  47. package/dist/skill-library.js +188 -0
  48. package/docs/acceptance.md +593 -1
  49. package/docs/adr/0006-pi-extension-allowlist.md +3 -0
  50. package/docs/adr/0007-profile-skill-selection.md +228 -0
  51. package/docs/architecture.md +10 -6
  52. package/docs/client-setup-design.md +219 -1
  53. package/docs/client-setup.md +86 -3
  54. package/docs/doctor-design.md +9 -1
  55. package/docs/installation.md +128 -5
  56. package/docs/pi-extensions-design.md +28 -4
  57. package/docs/releasing.md +18 -9
  58. package/docs/session-mcp.md +7 -2
  59. package/docs/skills.md +616 -0
  60. package/docs/threat-model.md +19 -2
  61. package/docs/tool-contracts.md +9 -5
  62. package/package.json +2 -1
  63. package/pi-session-mcp.example.json +9 -3
  64. package/skills/pi-session-implementation/SKILL.md +42 -0
  65. package/skills/pi-session-orchestration/SKILL.md +94 -0
  66. package/skills/pi-session-review/SKILL.md +58 -0
@@ -0,0 +1,64 @@
1
+ import { constants } from "node:fs";
2
+ import { access, realpath, stat } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { loadSkills } from "@earendil-works/pi-coding-agent";
5
+ import { PiSessionCreationError } from "./pi-adapter.js";
6
+ import { MAX_SKILL_FILE_BYTES } from "./skill-catalog.js";
7
+ export class PiSkillError extends PiSessionCreationError {
8
+ alias;
9
+ constructor(alias) {
10
+ super("skill_load_failed");
11
+ this.alias = alias;
12
+ this.name = "PiSkillError";
13
+ }
14
+ }
15
+ /** Load only the configured files, independently of package discovery and SDK deduplication. */
16
+ export async function loadSelectedSkills(selection, cwd, dependencies = {}) {
17
+ if (selection.length === 0)
18
+ return [];
19
+ const fs = { stat, access, realpath, loadSkills, ...dependencies };
20
+ const paths = [];
21
+ const canonical = new Set();
22
+ try {
23
+ for (const selected of selection) {
24
+ try {
25
+ const path = resolve(selected.path);
26
+ const info = await fs.stat(path);
27
+ if (!info.isFile() || info.size > MAX_SKILL_FILE_BYTES)
28
+ throw new PiSkillError(selected.alias);
29
+ await fs.access(path, constants.R_OK);
30
+ const real = await fs.realpath(path);
31
+ if (canonical.has(real))
32
+ throw new PiSkillError(selected.alias);
33
+ canonical.add(real);
34
+ paths.push(path);
35
+ }
36
+ catch {
37
+ throw new PiSkillError(selected.alias);
38
+ }
39
+ }
40
+ const result = fs.loadSkills({ cwd, agentDir: cwd, skillPaths: paths, includeDefaults: false });
41
+ if (result.diagnostics.length > 0) {
42
+ const aliases = new Set(result.diagnostics.map((diagnostic) => {
43
+ const index = paths.indexOf(diagnostic.path ?? "");
44
+ return index < 0 ? undefined : selection[index].alias;
45
+ }));
46
+ throw new PiSkillError(aliases.size === 1 ? [...aliases][0] : undefined);
47
+ }
48
+ if (result.skills.length !== paths.length) {
49
+ const missing = selection.filter((_selected, index) => !result.skills.some((skill) => skill.filePath === paths[index]));
50
+ throw new PiSkillError(missing.length === 1 ? missing[0].alias : undefined);
51
+ }
52
+ return paths.map((path, index) => {
53
+ const matches = result.skills.filter((skill) => skill.filePath === path);
54
+ if (matches.length !== 1 || matches[0].disableModelInvocation === true)
55
+ throw new PiSkillError(selection[index].alias);
56
+ return matches[0];
57
+ });
58
+ }
59
+ catch (error) {
60
+ if (error instanceof PiSkillError)
61
+ throw error;
62
+ throw new PiSkillError();
63
+ }
64
+ }
@@ -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;
@@ -191,12 +192,31 @@ class SdkHandle {
191
192
  }
192
193
  catch { /* Liveness reporting is best effort and content-free. */ }
193
194
  };
195
+ // Activity is the public SDK stream of this prompt: assistant message start, update and end, and
196
+ // tool execution start, update and end. Only the event discriminant and the message role are
197
+ // inspected: delta text, thinking, tool arguments and partial results are never read, stored or
198
+ // forwarded. Streaming is activity, not evidence of finished work; only the last assistant
199
+ // `message_end` is the answer, and the terminal state comes from the completion paths below.
194
200
  const unsubscribe = this.session.subscribe((event) => {
195
- if (event.type === "tool_execution_end")
196
- reportActivity();
197
- else if (event.type === "message_end" && isAssistantMessage(event.message)) {
198
- finalAssistant = event.message;
199
- reportActivity();
201
+ switch (event.type) {
202
+ case "tool_execution_start":
203
+ case "tool_execution_update":
204
+ case "tool_execution_end":
205
+ reportActivity();
206
+ break;
207
+ case "message_start":
208
+ case "message_update":
209
+ if (hasAssistantRole(event.message))
210
+ reportActivity();
211
+ break;
212
+ case "message_end":
213
+ if (isAssistantMessage(event.message)) {
214
+ finalAssistant = event.message;
215
+ reportActivity();
216
+ }
217
+ break;
218
+ default:
219
+ break;
200
220
  }
201
221
  });
202
222
  let preflightSeen = false;
@@ -263,14 +283,20 @@ function isAssistantMessage(message) {
263
283
  const candidate = message;
264
284
  return candidate.role === "assistant" && Array.isArray(candidate.content);
265
285
  }
286
+ /** Role check only: streaming messages are never inspected for content. */
287
+ function hasAssistantRole(message) {
288
+ return typeof message === "object" && message !== null && message.role === "assistant";
289
+ }
266
290
  function isTextBlock(block) {
267
291
  if (!block || typeof block !== "object")
268
292
  return false;
269
293
  const candidate = block;
270
294
  return candidate.type === "text" && typeof candidate.text === "string";
271
295
  }
272
- /** Resource-loader options for every embedded session: no discovery, skills, templates or themes. */
273
- function phase0ResourceLoaderOptions() {
296
+ /** Independently validated skills replace package and extension discovery on every reload. */
297
+ function phase0ResourceLoaderOptions(skills) {
298
+ // Keep the closure's snapshot private: the SDK may annotate or mutate returned objects.
299
+ const selected = skills.map((skill) => ({ ...skill, sourceInfo: { ...skill.sourceInfo } }));
274
300
  return {
275
301
  noExtensions: true,
276
302
  noSkills: true,
@@ -281,11 +307,14 @@ function phase0ResourceLoaderOptions() {
281
307
  appendSystemPrompt: [],
282
308
  systemPromptOverride: () => undefined,
283
309
  appendSystemPromptOverride: () => [],
310
+ skillsOverride: () => ({ skills: selected.map((skill) => ({ ...skill, sourceInfo: { ...skill.sourceInfo } })), diagnostics: [] }),
311
+ promptsOverride: () => ({ prompts: [], diagnostics: [] }),
312
+ themesOverride: () => ({ themes: [], diagnostics: [] }),
284
313
  };
285
314
  }
286
- export async function createPhase0ResourceLoader(cwd) {
315
+ export async function createPhase0ResourceLoader(cwd, skills = []) {
287
316
  const settingsManager = SettingsManager.inMemory(undefined, { projectTrusted: false });
288
- const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions() });
317
+ const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions(skills) });
289
318
  await resourceLoader.reload();
290
319
  return { resourceLoader, settingsManager };
291
320
  }
@@ -310,6 +339,7 @@ export class SdkPiSessionAdapter {
310
339
  if (extensions.length > 0)
311
340
  return this.createWithExtensions(input, extensions);
312
341
  const { executionProfile } = input;
342
+ const skills = await this.loadSkills(input);
313
343
  let runtime;
314
344
  try {
315
345
  runtime = await this.getRuntime();
@@ -321,7 +351,7 @@ export class SdkPiSessionAdapter {
321
351
  let resourceLoader;
322
352
  let settingsManager;
323
353
  try {
324
- ({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd));
354
+ ({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd, skills));
325
355
  }
326
356
  catch {
327
357
  throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
@@ -347,10 +377,11 @@ export class SdkPiSessionAdapter {
347
377
  await mcpTools.close();
348
378
  throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
349
379
  }
350
- return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined);
380
+ return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined, "exact", skills);
351
381
  }
352
382
  async createWithExtensions(input, extensions) {
353
383
  const { cwd, executionProfile } = input;
384
+ const skills = await this.loadSkills(input);
354
385
  const directories = [];
355
386
  for (const extension of extensions) {
356
387
  try {
@@ -377,15 +408,18 @@ export class SdkPiSessionAdapter {
377
408
  agentDir: cwd,
378
409
  modelRuntime: runtime,
379
410
  settingsManager: SettingsManager.inMemory(undefined, { projectTrusted: false }),
380
- resourceLoaderOptions: { ...phase0ResourceLoaderOptions(), additionalExtensionPaths: directories },
411
+ resourceLoaderOptions: { ...phase0ResourceLoaderOptions(skills), additionalExtensionPaths: directories },
381
412
  }));
413
+ this.checkResources(services.resourceLoader, skills, executionProfile, "extension_load");
382
414
  const loaded = services.resourceLoader.getExtensions();
383
415
  if (loaded.errors.length > 0 || services.diagnostics.some((diagnostic) => diagnostic.type === "error")) {
384
416
  throw new Error("extension load reported errors");
385
417
  }
386
418
  extensionTools = registeredExtensionTools(services);
387
419
  }
388
- catch {
420
+ catch (error) {
421
+ if (error instanceof PiSessionCreationError)
422
+ throw error;
389
423
  throw this.failure(executionProfile, "extension_load", "extension_load_failed");
390
424
  }
391
425
  const model = await this.resolveVerifiedModel(services.modelRuntime, executionProfile);
@@ -434,6 +468,7 @@ export class SdkPiSessionAdapter {
434
468
  bound = true;
435
469
  if (bindErrors > 0)
436
470
  throw new Error("extension hook reported errors while binding");
471
+ this.checkResources(sessionRuntime.session.resourceLoader, skills, executionProfile, "extension_bind");
437
472
  // A `session_start` handler may register a tool that replaces a built-in or granted tool.
438
473
  // Tools an extension registers later, during a turn, are not re-checked: allowlisted
439
474
  // extensions are trusted code.
@@ -441,15 +476,44 @@ export class SdkPiSessionAdapter {
441
476
  if (!recheck || recheck.length !== tools.length || recheck.some((name, index) => name !== tools[index]))
442
477
  conflict = true;
443
478
  }
444
- catch {
479
+ catch (error) {
445
480
  await cleanUp();
481
+ if (error instanceof PiSessionCreationError)
482
+ throw error;
446
483
  throw this.failure(executionProfile, "extension_bind", "extension_load_failed");
447
484
  }
448
485
  if (conflict) {
449
486
  await cleanUp();
450
487
  throw this.failure(executionProfile, "extension_bind", "extension_tool_conflict");
451
488
  }
452
- return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset");
489
+ return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset", skills);
490
+ }
491
+ async loadSkills(input) {
492
+ try {
493
+ return await loadSelectedSkills(input.executionProfile.skills ?? [], input.cwd);
494
+ }
495
+ catch (error) {
496
+ throw this.failure(input.executionProfile, "skill_load", "skill_load_failed", error instanceof PiSkillError && error.alias !== undefined ? { skill: error.alias } : undefined);
497
+ }
498
+ }
499
+ checkResources(loader, skills, executionProfile, stage) {
500
+ let actual;
501
+ try {
502
+ actual = loader.getSkills().skills;
503
+ }
504
+ catch {
505
+ throw this.failure(executionProfile, stage, "skill_load_failed");
506
+ }
507
+ // Compare a multiset so duplicate entries cannot satisfy a selected file twice.
508
+ const identity = (skill) => JSON.stringify([skill.filePath, skill.name]);
509
+ const expected = skills.map(identity).sort();
510
+ const received = actual.map(identity).sort();
511
+ if (expected.length !== received.length || expected.some((entry, index) => entry !== received[index])) {
512
+ throw this.failure(executionProfile, stage, "skill_load_failed");
513
+ }
514
+ if (loader.getPrompts().prompts.length > 0 || loader.getThemes().themes.length > 0) {
515
+ throw this.failure(executionProfile, stage, "pi_session_creation_failed");
516
+ }
453
517
  }
454
518
  /** Provider, model, authentication and availability, exactly as configured. */
455
519
  async resolveVerifiedModel(runtime, executionProfile) {
@@ -513,8 +577,9 @@ export class SdkPiSessionAdapter {
513
577
  /** Post-verifies a created session and wraps it; a mismatch disposes it before reporting. */
514
578
  async admit(session, executionProfile, tools, mcpTools, disposeSession, toolMismatch,
515
579
  // `subset` lets an extension narrow its own active tools; nothing outside the allowlist may be active.
516
- toolCheck = "exact") {
580
+ toolCheck = "exact", skills = []) {
517
581
  try {
582
+ this.checkResources(session.resourceLoader, skills, executionProfile, "postcheck");
518
583
  if (session.model?.provider !== executionProfile.provider || session.model.id !== executionProfile.model) {
519
584
  throw this.failure(executionProfile, "postcheck", "execution_selection_mismatch");
520
585
  }
@@ -570,6 +635,7 @@ export class SdkPiSessionAdapter {
570
635
  ...(metadata?.toolAlias === undefined ? {} : { toolAlias: metadata.toolAlias }),
571
636
  ...(metadata?.discoveredToolCount === undefined ? {} : { discoveredToolCount: metadata.discoveredToolCount }),
572
637
  ...(metadata?.extension === undefined ? {} : { extension: metadata.extension }),
638
+ ...(metadata?.skill === undefined ? {} : { skill: metadata.skill }),
573
639
  });
574
640
  }
575
641
  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) {
@@ -16,10 +16,11 @@ export interface SessionView {
16
16
  lastError?: string;
17
17
  }
18
18
  /**
19
- * `updatedAt` is the registry's own last-activity stamp: while a turn runs it advances on a
20
- * completed tool execution or completed assistant message, then freezes at the terminal
21
- * transition. It is a liveness lower bound, not a progress count, and does not prove that a
22
- * silent turn is healthy.
19
+ * `updatedAt` is the time of the last observed public SDK activity of the turn (assistant
20
+ * message start, update and end; tool execution start, update and end), then freezes at the
21
+ * terminal transition. Streaming is activity, not evidence of finished or useful work, and
22
+ * internal work without a public event stays invisible. It is a liveness lower bound, not a
23
+ * progress count, and does not prove that a silent turn is healthy.
23
24
  */
24
25
  export interface TurnView {
25
26
  turnId: string;
@@ -81,8 +82,9 @@ export declare class SessionRegistry {
81
82
  private completeTurn;
82
83
  private failTurn;
83
84
  /**
84
- * Liveness only. A completed tool execution or assistant message advances this turn,
85
- * never another turn or the session. Activity after accepted preflight but before
85
+ * Liveness only. Observed public SDK activity (assistant message start, update and end; tool
86
+ * execution start, update and end) advances this turn, never another turn or the session.
87
+ * Streaming is activity, not evidence of finished or useful work. Activity after accepted preflight but before
86
88
  * publication is stamped and applied only if the turn is published; callbacks before
87
89
  * acceptance or after terminal cleanup cannot publish or revive a turn.
88
90
  */
@@ -273,8 +273,9 @@ export class SessionRegistry {
273
273
  entry.turn = undefined; if (entry.turnPromise)
274
274
  entry.turnPromise = undefined; entry.state = "failed"; entry.updatedAt = turn.updatedAt; entry.lastError = "Pi turn failed"; }
275
275
  /**
276
- * Liveness only. A completed tool execution or assistant message advances this turn,
277
- * never another turn or the session. Activity after accepted preflight but before
276
+ * Liveness only. Observed public SDK activity (assistant message start, update and end; tool
277
+ * execution start, update and end) advances this turn, never another turn or the session.
278
+ * Streaming is activity, not evidence of finished or useful work. Activity after accepted preflight but before
278
279
  * publication is stamped and applied only if the turn is published; callbacks before
279
280
  * acceptance or after terminal cleanup cannot publish or revive a turn.
280
281
  */
@@ -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,14 @@ 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. The
12
+ * transaction id also names the update's intermediate directories, so recovery needs no path.
13
+ * Skill fingerprints are `v2:` values; a bare 64-hex value is a record that 0.8.0 wrote and
14
+ * names a tree only once a trusted reference proves it.
15
+ */
16
+ readonly replaces?: string;
9
17
  }
10
18
  export interface RegistrationSnapshot {
11
19
  /** Opaque adapter-owned snapshot; implementations must not persist secrets. */
@@ -51,3 +59,4 @@ export declare class DurableOwnershipStore implements OwnershipStore {
51
59
  private withLock;
52
60
  private write;
53
61
  }
62
+ 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 { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, SKILL_NAME_PATTERN, SKILL_TREE_FINGERPRINT_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,36 @@ 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
+ // A registration fingerprint is a snapshot hash; a skill fingerprint is a version 2 tree value or a 0.8.0 legacy one.
235
+ const validFingerprint = (value) => typeof value === "string" &&
236
+ (skill ? SKILL_TREE_FINGERPRINT_PATTERN.test(value) || LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) : /^[a-f0-9]{64}$/.test(value));
237
+ // `replaces` belongs only to a pending skill record.
238
+ if ("replaces" in x && !(skill && x.phase === "pending" && validFingerprint(x.replaces)))
239
+ return false;
240
+ return (registration || skill) && validFingerprint(x.fingerprint) &&
241
+ (x.phase === "pending" || x.phase === "owned") && x.previous === "absent" &&
242
+ 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);
243
+ }
217
244
  function exactKeys(value, expected) { const keys = Object.keys(value).sort(); return keys.length === expected.length && [...expected].sort().every((key, index) => keys[index] === key); }
218
245
  const key = (target) => `${target.client}\u0000${target.scope}\u0000${target.alias}`;
@@ -0,0 +1,76 @@
1
+ import { type ReleasedSkillTree } from "./skill-library.js";
2
+ import type { OwnershipAccess } from "./setup-ownership.js";
3
+ import type { SetupFinding, SetupOperation, SetupTarget } from "./setup-result.js";
4
+ export interface SetupSkillInstaller {
5
+ /** Called under the registration's ownership transaction for every mutation. */
6
+ run(operation: SetupOperation, target: SetupTarget, ownership: OwnershipAccess): Promise<readonly SetupFinding[]>;
7
+ /**
8
+ * Whether setup still owns an installed skill of exactly this registration target, so that a
9
+ * remove may finish the skill cleanup after the registration is already gone.
10
+ */
11
+ owns(target: SetupTarget, ownership: OwnershipAccess): Promise<boolean>;
12
+ }
13
+ export interface SetupSkillRoots {
14
+ readonly packageRoot: string;
15
+ readonly configPath: string;
16
+ readonly home: string;
17
+ readonly cwd: string;
18
+ }
19
+ /** The crash-relevant points of an update, in order. Only a test seam observes them. */
20
+ export type SkillCheckpoint = "staged" | "backup-renamed" | "installed" | "trash-renamed" | "trash-entry-removed" | "trash-deleted" | "before-ownership";
21
+ export interface SetupSkillDependencies {
22
+ /** Trusted released trees that prove 0.8.0 records; defaults to the shipped table. */
23
+ readonly released?: readonly ReleasedSkillTree[];
24
+ /** Test seam, called after each step in `SkillCheckpoint` completed; defaults to a no-op. */
25
+ readonly checkpoint?: (step: SkillCheckpoint) => void | Promise<void>;
26
+ }
27
+ /** Paths remain internal. Ownership identifies a client slot, never its location. */
28
+ export declare class FileSetupSkillInstaller implements SetupSkillInstaller {
29
+ private readonly roots;
30
+ private readonly dependencies;
31
+ constructor(roots: SetupSkillRoots, dependencies?: SetupSkillDependencies);
32
+ run(operation: SetupOperation, target: SetupTarget, ownership: OwnershipAccess): Promise<readonly SetupFinding[]>;
33
+ owns(target: SetupTarget, ownership: OwnershipAccess): Promise<boolean>;
34
+ private step;
35
+ /**
36
+ * The slot path and its ownership key. A project may be untrusted: only its root is
37
+ * canonicalized, and a link below it (for example `.claude` pointing elsewhere) makes the
38
+ * slot divergent, so setup never writes outside the project. In the operator's home the
39
+ * client's skills directory may be the operator's own link, for example into a dotfiles
40
+ * checkout, so it is followed. The skill entry itself is never followed in either place.
41
+ * A project slot's ownership is bound to an opaque, path-free identity of the project, so a
42
+ * remove in another project never touches this project's copy.
43
+ */
44
+ private location;
45
+ private library;
46
+ /**
47
+ * Whether the record names this installed tree: its version 2 value, or a 0.8.0 value that a
48
+ * trusted reference proves for exactly these bytes. A bare legacy value never proves identity.
49
+ */
50
+ private ownsTree;
51
+ private skill;
52
+ /**
53
+ * Installs the new tree through the transaction's own intermediates. A failure leaves them for
54
+ * recovery; nothing here deletes by guess.
55
+ */
56
+ private replace;
57
+ /**
58
+ * Deletes the backup only after it is verified to be the exact old copy: it is renamed to the
59
+ * transaction's trash name first, so the deletion never leaves an unverifiable partial tree
60
+ * under the backup name.
61
+ */
62
+ private discardBackup;
63
+ /** Deletes the transaction's trash entry by entry, so a crash leaves only a smaller partial tree. */
64
+ private discardTrash;
65
+ /**
66
+ * Settles an interrupted update of the pending record's transaction, inside the ownership
67
+ * transaction. Only directories with this transaction's exact names are examined, and only
68
+ * verified ones are deleted: the backup must equal the replaced copy, the staging directory
69
+ * the target or an interrupted write of it. Anything else stays and is reported.
70
+ *
71
+ * apply, slot absent: a complete staging directory is renamed into the slot (forward);
72
+ * otherwise a verified backup is moved back. remove and rollback do not restore, they delete
73
+ * what is verified and leave the slot to the caller.
74
+ */
75
+ private recover;
76
+ }