pi-session-mcp 0.7.1 → 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 (59) 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/setup-result.js +8 -0
  5. package/.pi-session-mcp-cli/skill-catalog.js +35 -0
  6. package/.pi-session-mcp-cli/skill-customize.js +183 -0
  7. package/.pi-session-mcp-cli/skill-library.js +120 -0
  8. package/CHANGELOG.md +85 -0
  9. package/README.md +15 -3
  10. package/dist/capabilities.d.ts +1 -0
  11. package/dist/capabilities.js +2 -1
  12. package/dist/client-adapters/mcp-verifier.js +6 -2
  13. package/dist/config.d.ts +4 -0
  14. package/dist/config.js +47 -7
  15. package/dist/doctor.d.ts +4 -1
  16. package/dist/doctor.js +68 -2
  17. package/dist/execution-profile.d.ts +4 -0
  18. package/dist/execution-profile.js +1 -0
  19. package/dist/pi-adapter.d.ts +1 -1
  20. package/dist/pi-adapter.js +1 -0
  21. package/dist/pi-skills.d.ts +17 -0
  22. package/dist/pi-skills.js +64 -0
  23. package/dist/sdk-pi-adapter.d.ts +7 -3
  24. package/dist/sdk-pi-adapter.js +54 -11
  25. package/dist/server.js +1 -0
  26. package/dist/setup-cli.d.ts +2 -0
  27. package/dist/setup-cli.js +16 -0
  28. package/dist/setup-main.d.ts +1 -1
  29. package/dist/setup-main.js +18 -3
  30. package/dist/setup-ownership.d.ts +6 -0
  31. package/dist/setup-ownership.js +29 -5
  32. package/dist/setup-skills.d.ts +37 -0
  33. package/dist/setup-skills.js +353 -0
  34. package/dist/setup.d.ts +7 -0
  35. package/dist/setup.js +49 -6
  36. package/dist/skill-catalog.d.ts +26 -0
  37. package/dist/skill-catalog.js +35 -0
  38. package/dist/skill-customize.d.ts +23 -0
  39. package/dist/skill-customize.js +183 -0
  40. package/dist/skill-library.d.ts +61 -0
  41. package/dist/skill-library.js +120 -0
  42. package/docs/acceptance.md +243 -1
  43. package/docs/adr/0006-pi-extension-allowlist.md +3 -0
  44. package/docs/adr/0007-profile-skill-selection.md +217 -0
  45. package/docs/architecture.md +9 -5
  46. package/docs/client-setup-design.md +133 -1
  47. package/docs/client-setup.md +74 -3
  48. package/docs/doctor-design.md +9 -1
  49. package/docs/installation.md +122 -5
  50. package/docs/pi-extensions-design.md +17 -4
  51. package/docs/session-mcp.md +7 -2
  52. package/docs/skills.md +487 -0
  53. package/docs/threat-model.md +19 -2
  54. package/docs/tool-contracts.md +8 -4
  55. package/package.json +2 -1
  56. package/pi-session-mcp.example.json +9 -3
  57. package/skills/pi-session-implementation/SKILL.md +42 -0
  58. package/skills/pi-session-orchestration/SKILL.md +93 -0
  59. package/skills/pi-session-review/SKILL.md +58 -0
@@ -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;
@@ -269,8 +270,10 @@ function isTextBlock(block) {
269
270
  const candidate = block;
270
271
  return candidate.type === "text" && typeof candidate.text === "string";
271
272
  }
272
- /** Resource-loader options for every embedded session: no discovery, skills, templates or themes. */
273
- 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 } }));
274
277
  return {
275
278
  noExtensions: true,
276
279
  noSkills: true,
@@ -281,11 +284,14 @@ function phase0ResourceLoaderOptions() {
281
284
  appendSystemPrompt: [],
282
285
  systemPromptOverride: () => undefined,
283
286
  appendSystemPromptOverride: () => [],
287
+ skillsOverride: () => ({ skills: selected.map((skill) => ({ ...skill, sourceInfo: { ...skill.sourceInfo } })), diagnostics: [] }),
288
+ promptsOverride: () => ({ prompts: [], diagnostics: [] }),
289
+ themesOverride: () => ({ themes: [], diagnostics: [] }),
284
290
  };
285
291
  }
286
- export async function createPhase0ResourceLoader(cwd) {
292
+ export async function createPhase0ResourceLoader(cwd, skills = []) {
287
293
  const settingsManager = SettingsManager.inMemory(undefined, { projectTrusted: false });
288
- const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions() });
294
+ const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions(skills) });
289
295
  await resourceLoader.reload();
290
296
  return { resourceLoader, settingsManager };
291
297
  }
@@ -310,6 +316,7 @@ export class SdkPiSessionAdapter {
310
316
  if (extensions.length > 0)
311
317
  return this.createWithExtensions(input, extensions);
312
318
  const { executionProfile } = input;
319
+ const skills = await this.loadSkills(input);
313
320
  let runtime;
314
321
  try {
315
322
  runtime = await this.getRuntime();
@@ -321,7 +328,7 @@ export class SdkPiSessionAdapter {
321
328
  let resourceLoader;
322
329
  let settingsManager;
323
330
  try {
324
- ({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd));
331
+ ({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd, skills));
325
332
  }
326
333
  catch {
327
334
  throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
@@ -347,10 +354,11 @@ export class SdkPiSessionAdapter {
347
354
  await mcpTools.close();
348
355
  throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
349
356
  }
350
- 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);
351
358
  }
352
359
  async createWithExtensions(input, extensions) {
353
360
  const { cwd, executionProfile } = input;
361
+ const skills = await this.loadSkills(input);
354
362
  const directories = [];
355
363
  for (const extension of extensions) {
356
364
  try {
@@ -377,15 +385,18 @@ export class SdkPiSessionAdapter {
377
385
  agentDir: cwd,
378
386
  modelRuntime: runtime,
379
387
  settingsManager: SettingsManager.inMemory(undefined, { projectTrusted: false }),
380
- resourceLoaderOptions: { ...phase0ResourceLoaderOptions(), additionalExtensionPaths: directories },
388
+ resourceLoaderOptions: { ...phase0ResourceLoaderOptions(skills), additionalExtensionPaths: directories },
381
389
  }));
390
+ this.checkResources(services.resourceLoader, skills, executionProfile, "extension_load");
382
391
  const loaded = services.resourceLoader.getExtensions();
383
392
  if (loaded.errors.length > 0 || services.diagnostics.some((diagnostic) => diagnostic.type === "error")) {
384
393
  throw new Error("extension load reported errors");
385
394
  }
386
395
  extensionTools = registeredExtensionTools(services);
387
396
  }
388
- catch {
397
+ catch (error) {
398
+ if (error instanceof PiSessionCreationError)
399
+ throw error;
389
400
  throw this.failure(executionProfile, "extension_load", "extension_load_failed");
390
401
  }
391
402
  const model = await this.resolveVerifiedModel(services.modelRuntime, executionProfile);
@@ -434,6 +445,7 @@ export class SdkPiSessionAdapter {
434
445
  bound = true;
435
446
  if (bindErrors > 0)
436
447
  throw new Error("extension hook reported errors while binding");
448
+ this.checkResources(sessionRuntime.session.resourceLoader, skills, executionProfile, "extension_bind");
437
449
  // A `session_start` handler may register a tool that replaces a built-in or granted tool.
438
450
  // Tools an extension registers later, during a turn, are not re-checked: allowlisted
439
451
  // extensions are trusted code.
@@ -441,15 +453,44 @@ export class SdkPiSessionAdapter {
441
453
  if (!recheck || recheck.length !== tools.length || recheck.some((name, index) => name !== tools[index]))
442
454
  conflict = true;
443
455
  }
444
- catch {
456
+ catch (error) {
445
457
  await cleanUp();
458
+ if (error instanceof PiSessionCreationError)
459
+ throw error;
446
460
  throw this.failure(executionProfile, "extension_bind", "extension_load_failed");
447
461
  }
448
462
  if (conflict) {
449
463
  await cleanUp();
450
464
  throw this.failure(executionProfile, "extension_bind", "extension_tool_conflict");
451
465
  }
452
- 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
+ }
453
494
  }
454
495
  /** Provider, model, authentication and availability, exactly as configured. */
455
496
  async resolveVerifiedModel(runtime, executionProfile) {
@@ -513,8 +554,9 @@ export class SdkPiSessionAdapter {
513
554
  /** Post-verifies a created session and wraps it; a mismatch disposes it before reporting. */
514
555
  async admit(session, executionProfile, tools, mcpTools, disposeSession, toolMismatch,
515
556
  // `subset` lets an extension narrow its own active tools; nothing outside the allowlist may be active.
516
- toolCheck = "exact") {
557
+ toolCheck = "exact", skills = []) {
517
558
  try {
559
+ this.checkResources(session.resourceLoader, skills, executionProfile, "postcheck");
518
560
  if (session.model?.provider !== executionProfile.provider || session.model.id !== executionProfile.model) {
519
561
  throw this.failure(executionProfile, "postcheck", "execution_selection_mismatch");
520
562
  }
@@ -570,6 +612,7 @@ export class SdkPiSessionAdapter {
570
612
  ...(metadata?.toolAlias === undefined ? {} : { toolAlias: metadata.toolAlias }),
571
613
  ...(metadata?.discoveredToolCount === undefined ? {} : { discoveredToolCount: metadata.discoveredToolCount }),
572
614
  ...(metadata?.extension === undefined ? {} : { extension: metadata.extension }),
615
+ ...(metadata?.skill === undefined ? {} : { skill: metadata.skill }),
573
616
  });
574
617
  }
575
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) {
@@ -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
+ }