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.
- package/.pi-session-mcp-cli/config.js +47 -7
- package/.pi-session-mcp-cli/doctor.js +68 -2
- package/.pi-session-mcp-cli/execution-profile.js +1 -0
- package/.pi-session-mcp-cli/extension-spec.js +7 -2
- package/.pi-session-mcp-cli/setup-result.js +8 -0
- package/.pi-session-mcp-cli/skill-catalog.js +35 -0
- package/.pi-session-mcp-cli/skill-customize.js +183 -0
- package/.pi-session-mcp-cli/skill-library.js +120 -0
- package/CHANGELOG.md +110 -0
- package/README.md +15 -3
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +2 -1
- package/dist/client-adapters/mcp-verifier.js +6 -2
- package/dist/config.d.ts +4 -0
- package/dist/config.js +47 -7
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +68 -2
- package/dist/execution-profile.d.ts +4 -0
- package/dist/execution-profile.js +1 -0
- package/dist/extension-spec.js +7 -2
- package/dist/pi-adapter.d.ts +1 -1
- package/dist/pi-adapter.js +1 -0
- package/dist/pi-skills.d.ts +17 -0
- package/dist/pi-skills.js +64 -0
- package/dist/sdk-pi-adapter.d.ts +7 -3
- package/dist/sdk-pi-adapter.js +57 -11
- package/dist/server.js +1 -0
- package/dist/session-tools.d.ts +1 -1
- package/dist/session-tools.js +20 -4
- package/dist/setup-cli.d.ts +2 -0
- package/dist/setup-cli.js +16 -0
- package/dist/setup-main.d.ts +1 -1
- package/dist/setup-main.js +18 -3
- package/dist/setup-ownership.d.ts +6 -0
- package/dist/setup-ownership.js +29 -5
- package/dist/setup-skills.d.ts +37 -0
- package/dist/setup-skills.js +353 -0
- package/dist/setup.d.ts +7 -0
- package/dist/setup.js +49 -6
- package/dist/skill-catalog.d.ts +26 -0
- package/dist/skill-catalog.js +35 -0
- package/dist/skill-customize.d.ts +23 -0
- package/dist/skill-customize.js +183 -0
- package/dist/skill-library.d.ts +61 -0
- package/dist/skill-library.js +120 -0
- package/docs/acceptance.md +372 -3
- package/docs/adr/0006-pi-extension-allowlist.md +5 -2
- package/docs/adr/0007-profile-skill-selection.md +217 -0
- package/docs/architecture.md +9 -5
- package/docs/client-setup-design.md +133 -1
- package/docs/client-setup.md +74 -3
- package/docs/doctor-design.md +9 -1
- package/docs/installation.md +129 -9
- package/docs/pi-extensions-design.md +41 -12
- package/docs/releasing.md +15 -6
- package/docs/session-mcp.md +7 -2
- package/docs/skills.md +487 -0
- package/docs/threat-model.md +21 -4
- package/docs/tool-contracts.md +9 -5
- package/package.json +3 -1
- package/pi-session-mcp.example.json +9 -3
- package/skills/pi-session-implementation/SKILL.md +42 -0
- package/skills/pi-session-orchestration/SKILL.md +93 -0
- package/skills/pi-session-review/SKILL.md +58 -0
- package/docs/superpowers/plans/2026-10-10-macos-setup.md +0 -1725
- package/docs/superpowers/plans/2026-10-11-pi-extensions.md +0 -2450
package/dist/sdk-pi-adapter.d.ts
CHANGED
|
@@ -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;
|
package/dist/sdk-pi-adapter.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
package/dist/session-tools.d.ts
CHANGED
|
@@ -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;
|
package/dist/session-tools.js
CHANGED
|
@@ -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
|
-
/**
|
|
6
|
-
|
|
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 (!
|
|
34
|
+
if (!isAcceptableExtensionToolName(name) || reserved.has(name))
|
|
19
35
|
return undefined;
|
|
20
36
|
reserved.add(name);
|
|
21
37
|
extensionTools.push(name);
|
package/dist/setup-cli.d.ts
CHANGED
|
@@ -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) {
|
package/dist/setup-main.d.ts
CHANGED
|
@@ -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;
|
package/dist/setup-main.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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(
|
|
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;
|
package/dist/setup-ownership.js
CHANGED
|
@@ -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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
+
}
|