pi-profile-switch 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -22
- package/README.zh-CN.md +14 -22
- package/examples/example.json +8 -4
- package/extensions/pi-profile/index.ts +76 -6
- package/package.json +2 -1
- package/schemas/profiles.schema.json +15 -1
- package/skills/profile-config/SKILL.md +16 -7
- package/src/launcher/initial-profile.ts +22 -9
- package/src/mcp-config.ts +24 -12
- package/src/profile-catalog.ts +47 -0
- package/src/profile-resolver.ts +190 -0
- package/src/settings-generator.ts +19 -31
- package/src/startup-notifier.ts +636 -0
- package/src/switching/apply-plan.ts +113 -18
- package/src/switching/status.ts +66 -9
- package/src/switching/tool-references.ts +26 -4
|
@@ -8,13 +8,12 @@
|
|
|
8
8
|
* surface so unit tests never need a real Pi.
|
|
9
9
|
*
|
|
10
10
|
* Steps:
|
|
11
|
-
* 1. tools: re-expand the profile's raw
|
|
12
|
-
* tool registry
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* silently ignores unknown names, so the warning is the only signal.
|
|
11
|
+
* 1. tools: re-expand the profile's raw references against Pi's LIVE
|
|
12
|
+
* non-MCP tool registry and call setActiveTools while retaining
|
|
13
|
+
* adapter-owned registrations. Settings `defaultTools` provides only
|
|
14
|
+
* the boot baseline for built-ins. Literals that no Pi tool provides
|
|
15
|
+
* are dropped with a warning — Pi silently ignores unknown names, so
|
|
16
|
+
* the warning is the only signal.
|
|
18
17
|
* When the plan carries overlay disabled tool entries, the active set
|
|
19
18
|
* is the base expansion — the profile references, or the whole live
|
|
20
19
|
* registry when the profile declares none — minus the entries' live
|
|
@@ -31,7 +30,7 @@
|
|
|
31
30
|
* established.
|
|
32
31
|
*/
|
|
33
32
|
|
|
34
|
-
import { writeFile } from "node:fs/promises";
|
|
33
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
35
34
|
import path from "node:path";
|
|
36
35
|
|
|
37
36
|
import { isRecord, readJsonFile } from "../json-file.ts";
|
|
@@ -49,12 +48,13 @@ export interface LaunchPlanFile {
|
|
|
49
48
|
* from the base expansion at session start. */
|
|
50
49
|
disabledTools?: string[];
|
|
51
50
|
mcps?: string[];
|
|
51
|
+
mcpTools?: Record<string, string[]>;
|
|
52
52
|
switchedFrom?: string;
|
|
53
53
|
persistSelection?: boolean;
|
|
54
54
|
clearOverlay?: boolean;
|
|
55
55
|
resolved?: {
|
|
56
56
|
skills: Array<{ name: string; filePath: string }>;
|
|
57
|
-
extensions: Array<{ id: string; entry: string }>;
|
|
57
|
+
extensions: Array<{ id: string; entry: string; origin?: "package" | "local" | "path" }>;
|
|
58
58
|
};
|
|
59
59
|
/** Glob references that matched nothing at resolution (ADR-0009). */
|
|
60
60
|
unmatched?: string[];
|
|
@@ -68,7 +68,10 @@ export interface LaunchPlanFile {
|
|
|
68
68
|
|
|
69
69
|
/** The narrow slice of ExtensionAPI/Context the application needs. */
|
|
70
70
|
export interface PlanApplicationSurface {
|
|
71
|
-
getAllTools(): Array<{
|
|
71
|
+
getAllTools(): Array<{
|
|
72
|
+
name: string;
|
|
73
|
+
sourceInfo?: { path?: string; source?: string };
|
|
74
|
+
}>;
|
|
72
75
|
setActiveTools(names: string[]): void;
|
|
73
76
|
notify?(message: string, level: "info" | "warning" | "error"): void;
|
|
74
77
|
}
|
|
@@ -87,6 +90,70 @@ export async function readLaunchPlanFile(runtimeDir: string): Promise<LaunchPlan
|
|
|
87
90
|
return result.value as unknown as LaunchPlanFile;
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
interface AdapterAttribution {
|
|
94
|
+
/** Exact adapter entry paths (loose adapter files). */
|
|
95
|
+
exactEntries: string[];
|
|
96
|
+
/** Verified pi-mcp-adapter npm package roots. */
|
|
97
|
+
packageRoots: string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function resolveAdapterPackageRoot(entryPath: string): Promise<string | undefined> {
|
|
101
|
+
let current = path.resolve(entryPath);
|
|
102
|
+
while (true) {
|
|
103
|
+
const parent = path.dirname(current);
|
|
104
|
+
if (parent === current) return undefined;
|
|
105
|
+
current = parent;
|
|
106
|
+
try {
|
|
107
|
+
const manifest: unknown = JSON.parse(await readFile(path.join(current, "package.json"), "utf8"));
|
|
108
|
+
if (isRecord(manifest) && manifest.name === "pi-mcp-adapter") {
|
|
109
|
+
return current;
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Continue walking toward the filesystem root.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function getAdapterAttribution(plan: LaunchPlanFile): Promise<AdapterAttribution> {
|
|
118
|
+
const exactEntries: string[] = [];
|
|
119
|
+
const packageRoots: string[] = [];
|
|
120
|
+
for (const ext of plan.resolved?.extensions ?? []) {
|
|
121
|
+
if (ext.origin === "package") {
|
|
122
|
+
const packageName = ext.id.split(":", 1)[0]!;
|
|
123
|
+
if (packageName === "pi-mcp-adapter") {
|
|
124
|
+
const root = await resolveAdapterPackageRoot(ext.entry);
|
|
125
|
+
if (root !== undefined) {
|
|
126
|
+
packageRoots.push(root);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} else if (ext.id === "pi-mcp-adapter") {
|
|
130
|
+
// Loose adapter files (and plan entries without an origin marker)
|
|
131
|
+
// own only their exact entry; siblings such as
|
|
132
|
+
// "pi-mcp-adapter/linter" do not become adapter-owned.
|
|
133
|
+
exactEntries.push(ext.entry);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { exactEntries, packageRoots };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isMcpOwnedTool(
|
|
140
|
+
tool: { name: string; sourceInfo?: { path?: string; source?: string } },
|
|
141
|
+
attribution?: AdapterAttribution,
|
|
142
|
+
): boolean {
|
|
143
|
+
const info = tool.sourceInfo;
|
|
144
|
+
if (!info) return false;
|
|
145
|
+
if (info.source === "pi-mcp-adapter" || info.source === "mcp") return true;
|
|
146
|
+
if (typeof info.path !== "string" || attribution === undefined) return false;
|
|
147
|
+
if (attribution.exactEntries.includes(info.path)) return true;
|
|
148
|
+
for (const root of attribution.packageRoots) {
|
|
149
|
+
const rel = path.relative(root, info.path);
|
|
150
|
+
if (rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
90
157
|
/** Applies the plan carried by the runtime dir's pi-profile.json. */
|
|
91
158
|
export async function applyLaunchPlan(input: {
|
|
92
159
|
runtimeDir: string;
|
|
@@ -103,25 +170,53 @@ export async function applyLaunchPlan(input: {
|
|
|
103
170
|
const warnings: string[] = [];
|
|
104
171
|
|
|
105
172
|
// --- tools ---
|
|
106
|
-
|
|
107
|
-
|
|
173
|
+
const hasOverlayDisables = plan.disabledTools !== undefined && plan.disabledTools.length > 0;
|
|
174
|
+
if (plan.toolReferences !== undefined || hasOverlayDisables) {
|
|
175
|
+
const allTools = surface.getAllTools();
|
|
176
|
+
const attribution = await getAdapterAttribution(plan);
|
|
177
|
+
const mcpToolNames: string[] = [];
|
|
178
|
+
const nonMcpToolNames: string[] = [];
|
|
179
|
+
|
|
180
|
+
for (const tool of allTools) {
|
|
181
|
+
if (isMcpOwnedTool(tool, attribution)) {
|
|
182
|
+
mcpToolNames.push(tool.name);
|
|
183
|
+
} else {
|
|
184
|
+
nonMcpToolNames.push(tool.name);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
108
188
|
let active: string[];
|
|
109
189
|
if (plan.toolReferences !== undefined) {
|
|
110
|
-
const { expanded, droppedLiterals } = expandToolReferences(
|
|
190
|
+
const { expanded, droppedLiterals, legacyMcpReferences } = expandToolReferences(
|
|
191
|
+
plan.toolReferences,
|
|
192
|
+
nonMcpToolNames,
|
|
193
|
+
mcpToolNames,
|
|
194
|
+
);
|
|
111
195
|
if (droppedLiterals.length > 0) {
|
|
112
196
|
warnings.push(
|
|
113
197
|
`profile "${plan.profile}": tools ${droppedLiterals.map((name) => JSON.stringify(name)).join(", ")} match nothing in Pi's live registry`,
|
|
114
198
|
);
|
|
115
199
|
}
|
|
116
|
-
|
|
200
|
+
if (legacyMcpReferences.length > 0) {
|
|
201
|
+
for (const ref of legacyMcpReferences) {
|
|
202
|
+
warnings.push(
|
|
203
|
+
`profile "${plan.profile}": tool reference ${JSON.stringify(ref)} matched only MCP tools; migrate MCP tool configuration to "mcp_tools"`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Retain MCP-owned tools in the live registry alongside selected Pi tools
|
|
208
|
+
active = [...expanded, ...mcpToolNames];
|
|
117
209
|
} else {
|
|
118
210
|
// No declared tools: the base is the whole live registry.
|
|
119
|
-
active =
|
|
211
|
+
active = allTools.map((tool) => tool.name);
|
|
120
212
|
}
|
|
121
|
-
|
|
213
|
+
|
|
214
|
+
if (hasOverlayDisables) {
|
|
215
|
+
const allLiveNames = allTools.map((tool) => tool.name);
|
|
122
216
|
const { expanded: disabled, droppedLiterals: vanishedEntries } = expandToolReferences(
|
|
123
|
-
plan.disabledTools
|
|
124
|
-
|
|
217
|
+
plan.disabledTools!,
|
|
218
|
+
allLiveNames,
|
|
219
|
+
mcpToolNames,
|
|
125
220
|
);
|
|
126
221
|
if (vanishedEntries.length > 0) {
|
|
127
222
|
warnings.push(
|
package/src/switching/status.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface StatusConflict {
|
|
|
26
26
|
winnerPath: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
export interface McpServerToolStatus {
|
|
30
|
+
server: string;
|
|
31
|
+
policy: "unrestricted" | "restricted" | "none";
|
|
32
|
+
tools?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
export interface StatusReport {
|
|
30
36
|
profile: string;
|
|
31
37
|
source: string;
|
|
@@ -34,6 +40,7 @@ export interface StatusReport {
|
|
|
34
40
|
extensions: Array<{ id: string; entry: string; origin?: string }>;
|
|
35
41
|
tools?: string[];
|
|
36
42
|
mcp: { enabled: string[]; disabled: string[]; missing: string[] };
|
|
43
|
+
mcpTools?: McpServerToolStatus[];
|
|
37
44
|
/** Glob delta versus the previous activation (prefixed names). */
|
|
38
45
|
delta?: { added: string[]; removed: string[] };
|
|
39
46
|
/** Glob references that matched nothing at resolution (ADR-0009). */
|
|
@@ -48,7 +55,7 @@ interface RegisteredCommand {
|
|
|
48
55
|
|
|
49
56
|
interface RegisteredTool {
|
|
50
57
|
name: string;
|
|
51
|
-
sourceInfo?: { path
|
|
58
|
+
sourceInfo?: { path?: string; source?: string };
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
function currentNames(plan: LaunchPlanFile): string[] {
|
|
@@ -65,18 +72,49 @@ export function buildStatusReport(input: {
|
|
|
65
72
|
plan: LaunchPlanFile;
|
|
66
73
|
overlay?: RuntimeOverlay;
|
|
67
74
|
discoveredMcpServers: string[];
|
|
75
|
+
disabledMcpServers: string[];
|
|
68
76
|
commands: RegisteredCommand[];
|
|
69
77
|
tools: RegisteredTool[];
|
|
70
78
|
}): StatusReport {
|
|
71
79
|
const { plan } = input;
|
|
72
80
|
|
|
73
|
-
const enabled = plan.mcps ?? [];
|
|
74
81
|
const discovered = new Set(input.discoveredMcpServers);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
82
|
+
let enabled: string[];
|
|
83
|
+
let disabled: string[];
|
|
84
|
+
let missing: string[];
|
|
85
|
+
|
|
86
|
+
if (plan.mcps === undefined) {
|
|
87
|
+
const adapterDisabled = new Set(input.disabledMcpServers);
|
|
88
|
+
enabled = input.discoveredMcpServers.filter((name) => !adapterDisabled.has(name));
|
|
89
|
+
disabled = input.discoveredMcpServers.filter((name) => adapterDisabled.has(name));
|
|
90
|
+
missing = [];
|
|
91
|
+
} else {
|
|
92
|
+
enabled = plan.mcps;
|
|
93
|
+
disabled = input.discoveredMcpServers.filter((name) => !enabled.includes(name));
|
|
94
|
+
missing = enabled.filter((name) => !discovered.has(name));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const mcp = { enabled, disabled, missing };
|
|
98
|
+
|
|
99
|
+
let mcpTools: McpServerToolStatus[] | undefined;
|
|
100
|
+
const policyMap = plan.mcpTools;
|
|
101
|
+
if (policyMap !== undefined || enabled.length > 0) {
|
|
102
|
+
mcpTools = [];
|
|
103
|
+
const allServers = Array.from(new Set([...enabled, ...Object.keys(policyMap ?? {})]));
|
|
104
|
+
for (const server of allServers) {
|
|
105
|
+
const hasPolicy = policyMap !== undefined && Object.prototype.hasOwnProperty.call(policyMap, server);
|
|
106
|
+
if (!hasPolicy) {
|
|
107
|
+
mcpTools.push({ server, policy: "unrestricted" });
|
|
108
|
+
} else {
|
|
109
|
+
const declaredTools = policyMap![server] ?? [];
|
|
110
|
+
if (declaredTools.length === 0) {
|
|
111
|
+
mcpTools.push({ server, policy: "none", tools: [] });
|
|
112
|
+
} else {
|
|
113
|
+
mcpTools.push({ server, policy: "restricted", tools: declaredTools });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
80
118
|
|
|
81
119
|
let delta: StatusReport["delta"];
|
|
82
120
|
if (plan.previousResolved !== undefined) {
|
|
@@ -117,9 +155,15 @@ export function buildStatusReport(input: {
|
|
|
117
155
|
const winner = input.tools.find((entry) => entry.name === toolName);
|
|
118
156
|
const info = winner?.sourceInfo;
|
|
119
157
|
if (info === undefined) continue; // unknown names are dropped by pi.setActiveTools
|
|
120
|
-
const expected =
|
|
158
|
+
const expected =
|
|
159
|
+
info.source === "builtin" ||
|
|
160
|
+
(typeof info.path === "string" && extensionDirs.some((dir) => info.path!.startsWith(dir)));
|
|
121
161
|
if (!expected) {
|
|
122
|
-
conflicts.push({
|
|
162
|
+
conflicts.push({
|
|
163
|
+
name: `tool:${toolName}`,
|
|
164
|
+
expectedPath: "builtin or selected extension",
|
|
165
|
+
winnerPath: info.path ?? "unknown",
|
|
166
|
+
});
|
|
123
167
|
}
|
|
124
168
|
}
|
|
125
169
|
|
|
@@ -131,6 +175,7 @@ export function buildStatusReport(input: {
|
|
|
131
175
|
extensions: plan.resolved?.extensions ?? [],
|
|
132
176
|
...(plan.tools !== undefined ? { tools: plan.tools } : {}),
|
|
133
177
|
mcp,
|
|
178
|
+
...(mcpTools !== undefined ? { mcpTools } : {}),
|
|
134
179
|
...(delta !== undefined ? { delta } : {}),
|
|
135
180
|
...(plan.unmatched !== undefined && plan.unmatched.length > 0 ? { unmatched: plan.unmatched } : {}),
|
|
136
181
|
conflicts,
|
|
@@ -168,6 +213,18 @@ export function formatStatusMarkdown(report: StatusReport): string {
|
|
|
168
213
|
lines.push(
|
|
169
214
|
`mcp: enabled=[${report.mcp.enabled.join(", ")}] disabled=[${report.mcp.disabled.join(", ")}] missing=[${report.mcp.missing.join(", ")}]`,
|
|
170
215
|
);
|
|
216
|
+
if (report.mcpTools !== undefined && report.mcpTools.length > 0) {
|
|
217
|
+
lines.push("mcp tools:");
|
|
218
|
+
for (const item of report.mcpTools) {
|
|
219
|
+
if (item.policy === "unrestricted") {
|
|
220
|
+
lines.push(` ${item.server}: unrestricted`);
|
|
221
|
+
} else if (item.policy === "none") {
|
|
222
|
+
lines.push(` ${item.server}: no enabled MCP tools`);
|
|
223
|
+
} else {
|
|
224
|
+
lines.push(` ${item.server}: [${(item.tools ?? []).join(", ")}]`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
171
228
|
if (report.delta !== undefined) {
|
|
172
229
|
lines.push(`delta: +[${report.delta.added.join(", ")}] -[${report.delta.removed.join(", ")}]`);
|
|
173
230
|
}
|
|
@@ -15,26 +15,48 @@ export interface ToolExpansion {
|
|
|
15
15
|
expanded: string[];
|
|
16
16
|
/** Literal references no live tool provides. */
|
|
17
17
|
droppedLiterals: string[];
|
|
18
|
+
/** References that matched only MCP-owned tools in the live registry. */
|
|
19
|
+
legacyMcpReferences: string[];
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
export function expandToolReferences(
|
|
21
|
-
|
|
22
|
+
export function expandToolReferences(
|
|
23
|
+
references: string[],
|
|
24
|
+
liveToolNames: string[],
|
|
25
|
+
mcpToolNames: string[] = [],
|
|
26
|
+
): ToolExpansion {
|
|
27
|
+
const liveNonMcp = new Set(liveToolNames);
|
|
28
|
+
const liveMcp = new Set(mcpToolNames);
|
|
22
29
|
const expanded = new Set<string>();
|
|
23
30
|
const droppedLiterals: string[] = [];
|
|
31
|
+
const legacyMcpReferences: string[] = [];
|
|
32
|
+
|
|
24
33
|
for (const reference of references) {
|
|
25
34
|
if (reference.includes("*") || reference.includes("?")) {
|
|
35
|
+
let matchedNonMcp = false;
|
|
26
36
|
for (const name of liveToolNames) {
|
|
27
37
|
if (minimatch(name, reference)) {
|
|
28
38
|
expanded.add(name);
|
|
39
|
+
matchedNonMcp = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!matchedNonMcp && mcpToolNames.length > 0) {
|
|
43
|
+
for (const name of mcpToolNames) {
|
|
44
|
+
if (minimatch(name, reference)) {
|
|
45
|
+
legacyMcpReferences.push(reference);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
29
48
|
}
|
|
30
49
|
}
|
|
31
50
|
continue;
|
|
32
51
|
}
|
|
33
|
-
|
|
52
|
+
|
|
53
|
+
if (liveNonMcp.has(reference)) {
|
|
34
54
|
expanded.add(reference);
|
|
55
|
+
} else if (liveMcp.has(reference)) {
|
|
56
|
+
legacyMcpReferences.push(reference);
|
|
35
57
|
} else {
|
|
36
58
|
droppedLiterals.push(reference);
|
|
37
59
|
}
|
|
38
60
|
}
|
|
39
|
-
return { expanded: [...expanded], droppedLiterals };
|
|
61
|
+
return { expanded: [...expanded], droppedLiterals, legacyMcpReferences };
|
|
40
62
|
}
|