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.
@@ -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 tool references against Pi's LIVE
12
- * tool registry (including extension- and MCP-provided tools) and
13
- * call setActiveTools. This is the CURRENT strict-allowlist enforcement
14
- * ensuring non-builtin tools obey profile restrictions; settings
15
- * `defaultTools` provides only the boot baseline for built-ins.
16
- * Literals that no tool provides are dropped with a warning — Pi
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<{ name: string }>;
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
- if (plan.toolReferences !== undefined || plan.disabledTools !== undefined) {
107
- const liveNames = surface.getAllTools().map((tool) => tool.name);
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(plan.toolReferences, liveNames);
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
- active = expanded;
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 = [...liveNames];
211
+ active = allTools.map((tool) => tool.name);
120
212
  }
121
- if (plan.disabledTools !== undefined && plan.disabledTools.length > 0) {
213
+
214
+ if (hasOverlayDisables) {
215
+ const allLiveNames = allTools.map((tool) => tool.name);
122
216
  const { expanded: disabled, droppedLiterals: vanishedEntries } = expandToolReferences(
123
- plan.disabledTools,
124
- liveNames,
217
+ plan.disabledTools!,
218
+ allLiveNames,
219
+ mcpToolNames,
125
220
  );
126
221
  if (vanishedEntries.length > 0) {
127
222
  warnings.push(
@@ -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: string; source: string };
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
- const mcp = {
76
- enabled,
77
- disabled: input.discoveredMcpServers.filter((name) => !enabled.includes(name)),
78
- missing: enabled.filter((name) => !discovered.has(name)),
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 = info.source === "builtin" || extensionDirs.some((dir) => info.path.startsWith(dir));
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({ name: `tool:${toolName}`, expectedPath: "builtin or selected extension", winnerPath: info.path });
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(references: string[], liveToolNames: string[]): ToolExpansion {
21
- const live = new Set(liveToolNames);
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
- if (live.has(reference)) {
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
  }