pi-profile-switch 0.4.2 → 0.4.3

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/bin/pi-profile.ts CHANGED
@@ -29,7 +29,7 @@ try {
29
29
  const agentDir = getAgentDir();
30
30
  // Fails before spawning when the profile is unknown or cannot activate.
31
31
  // --approve/--no-approve are consumed here as a one-run trust input.
32
- const { plan, discovery, projectSettings, warnings } = await resolveInitialProfile(args.profile, {
32
+ const { plan, discovery, projectSettings, projectDir, warnings } = await resolveInitialProfile(args.profile, {
33
33
  agentDir,
34
34
  cwd: process.cwd(),
35
35
  trustOverride: args.trustOverride,
@@ -41,7 +41,7 @@ try {
41
41
  // window) are swept before this launch materializes its own. Best-effort:
42
42
  // sweep errors never block the launch.
43
43
  await sweepStaleRuntimeDirs(agentDir);
44
- const generated = await generateRuntimeDir(plan, { agentDir, discovery, projectSettings });
44
+ const generated = await generateRuntimeDir(plan, { agentDir, discovery, projectSettings, projectDir });
45
45
  process.exitCode = await spawnPi({
46
46
  generated,
47
47
  piArgs: args.piArgs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -56,6 +56,8 @@ export interface InitialProfile {
56
56
  /** The trusted project's `.pi/settings.json` content, when trusted and
57
57
  * present. The generator merges it into the base for selection plans. */
58
58
  projectSettings?: Record<string, unknown>;
59
+ /** The trusted project directory, when trusted. */
60
+ projectDir?: string;
59
61
  /** Non-fatal notices for the user (e.g. a dangling restored profile that
60
62
  * fell back to default). The launcher prints them. */
61
63
  warnings: string[];
@@ -154,7 +156,7 @@ export async function resolveInitialProfile(
154
156
  overlay,
155
157
  });
156
158
  warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
157
- return { plan, discovery, projectSettings, warnings };
159
+ return { plan, discovery, projectSettings, projectDir, warnings };
158
160
  }
159
161
 
160
162
  const discovery = await discoverLauncherResources({ ...context, projectTrusted });
@@ -175,5 +177,5 @@ export async function resolveInitialProfile(
175
177
  // silently do nothing or leak through unfiltered.
176
178
  throw new MissingMcpAdapterError(plan.profile);
177
179
  }
178
- return { plan, discovery, projectSettings, warnings };
180
+ return { plan, discovery, projectSettings, projectDir, warnings };
179
181
  }
package/src/mcp-config.ts CHANGED
@@ -1,25 +1,26 @@
1
1
  /**
2
- * AdapterConfigDiscovery: reads the MCP server NAMES pi-mcp-adapter would
3
- * discover from its pi-native config files, without ever managing them.
2
+ * AdapterConfigDiscovery: reads MCP server names and configurations
3
+ * pi-mcp-adapter would discover from its standard configuration files,
4
+ * without ever managing them.
4
5
  *
5
6
  * pi-profile never stores MCP connection parameters or credentials
6
- * (ADR-0002); this module reads only the `mcpServers` key names so the
7
- * launcher can validate a profile's `mcp` references before spawn.
7
+ * (ADR-0002); this module reads config definitions to validate references
8
+ * before spawn and filter instance mcp.json files.
8
9
  *
9
- * Discovery scope (documented limitation): the pi-native files only — the
10
- * global `<agentDir>/mcp.json` and, when trusted, the project's
10
+ * Discovery scope: standard user-global configs (~/.config/mcp/mcp.json,
11
+ * ~/.agents/mcp.json, ~/.agents/mcp/mcp.json), the Pi-global
12
+ * `<agentDir>/mcp.json`, and, when trusted, the project's `.mcp.json` and
11
13
  * `.pi/mcp.json`. Servers defined solely in the adapter's editor-specific
12
- * legacy locations (~/.claude/mcp.json et al.) are invisible here; profiles
13
- * referencing them fail launch validation. The pi-native files are the
14
- * adapter's documented default, and no in-session re-validation exists (the
15
- * adapter's status snapshots arrive too late and only post-init), so the
16
- * launch check is the only name validation — keep configs in the pi-native
17
- * files.
14
+ * legacy locations (~/.claude/mcp.json et al.) are invisible here unless
15
+ * imported.
18
16
  *
19
17
  * Malformed config files fail loudly — a broken mcp.json must not silently
20
18
  * read as "no servers" and reject every reference.
21
19
  */
22
20
 
21
+ import { homedir } from "node:os";
22
+ import path from "node:path";
23
+
23
24
  import { isRecord, readJsonFile } from "./json-file.ts";
24
25
 
25
26
  export class McpConfigError extends Error {
@@ -32,35 +33,104 @@ export class McpConfigError extends Error {
32
33
  }
33
34
  }
34
35
 
35
- async function readServerNames(filePath: string): Promise<string[]> {
36
- const result = await readJsonFile(filePath);
37
- if (!result.ok) {
38
- if (result.reason === "missing") {
39
- return [];
40
- }
41
- throw new McpConfigError(`MCP config is not valid JSON: ${filePath}`, filePath);
42
- }
43
- if (!isRecord(result.value)) {
44
- throw new McpConfigError(`MCP config must be a JSON object: ${filePath}`, filePath);
45
- }
46
- if (result.value.mcpServers === undefined) {
47
- return [];
48
- }
49
- if (!isRecord(result.value.mcpServers)) {
50
- throw new McpConfigError(`"mcpServers" must be a JSON object: ${filePath}`, filePath);
51
- }
52
- return Object.keys(result.value.mcpServers);
36
+ export interface McpDiscoveryOptions {
37
+ homeDir?: string;
53
38
  }
54
39
 
55
- /** Server names the adapter would discover: global agentDir config plus the
56
- * trusted project's config. Pass `projectDir` only when the trust check
57
- * passed — an untrusted project's config is never read. */
58
- export async function discoverAdapterServerNames(agentDir: string, projectDir?: string): Promise<string[]> {
59
- const names = new Set(await readServerNames(`${agentDir}/mcp.json`));
40
+ export interface MergedMcpResult {
41
+ servers: Record<string, Record<string, unknown>>;
42
+ sharedServers: Set<string>;
43
+ baseConfig?: Record<string, unknown>;
44
+ }
45
+
46
+ export interface McpConfigSource {
47
+ path: string;
48
+ isShared: boolean;
49
+ isAgentDir?: boolean;
50
+ }
51
+
52
+ /**
53
+ * Standard MCP configuration sources recognized by pi-mcp-adapter in precedence order:
54
+ * 1. ~/.config/mcp/mcp.json (user-global standard MCP)
55
+ * 2. ~/.agents/mcp.json (user-global .agents MCP)
56
+ * 3. ~/.agents/mcp/mcp.json (user-global .agents nested MCP)
57
+ * 4. <agentDir>/mcp.json (Pi global override)
58
+ * 5. <projectDir>/.mcp.json (project standard MCP, when project is trusted)
59
+ * 6. <projectDir>/.pi/mcp.json (project Pi override, when project is trusted)
60
+ */
61
+ export function getStandardMcpConfigSources(
62
+ agentDir: string,
63
+ projectDir?: string,
64
+ options?: McpDiscoveryOptions,
65
+ ): McpConfigSource[] {
66
+ const home = options?.homeDir ?? process.env.HOME ?? homedir();
67
+ const sources: McpConfigSource[] = [
68
+ { path: path.join(home, ".config", "mcp", "mcp.json"), isShared: true },
69
+ { path: path.join(home, ".agents", "mcp.json"), isShared: true },
70
+ { path: path.join(home, ".agents", "mcp", "mcp.json"), isShared: true },
71
+ { path: path.join(agentDir, "mcp.json"), isShared: false, isAgentDir: true },
72
+ ];
60
73
  if (projectDir !== undefined) {
61
- for (const name of await readServerNames(`${projectDir}/.pi/mcp.json`)) {
62
- names.add(name);
74
+ sources.push({ path: path.join(projectDir, ".mcp.json"), isShared: true });
75
+ sources.push({ path: path.join(projectDir, ".pi", "mcp.json"), isShared: false });
76
+ }
77
+ return sources;
78
+ }
79
+
80
+ export async function loadMergedMcpServers(
81
+ agentDir: string,
82
+ projectDir?: string,
83
+ options?: McpDiscoveryOptions,
84
+ ): Promise<MergedMcpResult> {
85
+ const sources = getStandardMcpConfigSources(agentDir, projectDir, options);
86
+ const seenPaths = new Set<string>();
87
+ const servers: Record<string, Record<string, unknown>> = {};
88
+ const sharedServers = new Set<string>();
89
+ let baseConfig: Record<string, unknown> | undefined;
90
+
91
+ for (const source of sources) {
92
+ const resolvedPath = path.resolve(source.path);
93
+ if (seenPaths.has(resolvedPath)) continue;
94
+ seenPaths.add(resolvedPath);
95
+
96
+ const result = await readJsonFile(resolvedPath);
97
+ if (!result.ok) {
98
+ if (result.reason === "missing") continue;
99
+ throw new McpConfigError(`MCP config is not valid JSON: ${resolvedPath}`, resolvedPath);
100
+ }
101
+ if (!isRecord(result.value)) {
102
+ throw new McpConfigError(`MCP config must be a JSON object: ${resolvedPath}`, resolvedPath);
103
+ }
104
+ if (source.isAgentDir) {
105
+ baseConfig = result.value;
106
+ }
107
+ if (result.value.mcpServers === undefined) continue;
108
+ if (!isRecord(result.value.mcpServers)) {
109
+ throw new McpConfigError(`"mcpServers" must be a JSON object: ${resolvedPath}`, resolvedPath);
110
+ }
111
+ for (const [name, def] of Object.entries(result.value.mcpServers)) {
112
+ if (source.isShared) {
113
+ sharedServers.add(name);
114
+ }
115
+ if (isRecord(def)) {
116
+ servers[name] = { ...(servers[name] ?? {}), ...def };
117
+ } else {
118
+ servers[name] = { ...(servers[name] ?? {}) };
119
+ }
63
120
  }
64
121
  }
65
- return [...names].sort();
122
+
123
+ return { servers, sharedServers, baseConfig };
124
+ }
125
+
126
+ /** Server names the adapter would discover: standard global MCP configs,
127
+ * global agentDir config, plus the trusted project's configs. Pass `projectDir`
128
+ * only when the trust check passed — an untrusted project's config is never read. */
129
+ export async function discoverAdapterServerNames(
130
+ agentDir: string,
131
+ projectDir?: string,
132
+ options?: McpDiscoveryOptions,
133
+ ): Promise<string[]> {
134
+ const { servers } = await loadMergedMcpServers(agentDir, projectDir, options);
135
+ return Object.keys(servers).sort();
66
136
  }
@@ -45,6 +45,7 @@ import path from "node:path";
45
45
  import type { ActivationPlan } from "./profile-resolver.ts";
46
46
  import { getInstancesRootDir } from "./workspace.ts";
47
47
  import { isRecord } from "./json-file.ts";
48
+ import { loadMergedMcpServers } from "./mcp-config.ts";
48
49
  import type { SkillEntry } from "./skill-registry.ts";
49
50
 
50
51
  /** A configured global package and its resolved install/local root. */
@@ -66,6 +67,10 @@ export interface DiscoveryContext {
66
67
  export interface GenerateOptions {
67
68
  /** The user's real agent dir (e.g. ~/.pi/agent). */
68
69
  agentDir: string;
70
+ /** Optional home dir override (useful for testing). */
71
+ homeDir?: string;
72
+ /** Optional trusted project dir. */
73
+ projectDir?: string;
69
74
  /** Required for selection plans; unused for the default profile. */
70
75
  discovery?: DiscoveryContext;
71
76
  /** The trusted project's `.pi/settings.json` content (already parsed).
@@ -276,6 +281,10 @@ function buildSelectionSettings(
276
281
  export interface RuntimeFileOptions {
277
282
  /** The user's real agent dir (e.g. ~/.pi/agent). */
278
283
  agentDir: string;
284
+ /** Optional home dir override (useful for testing). */
285
+ homeDir?: string;
286
+ /** Optional trusted project dir. */
287
+ projectDir?: string;
279
288
  /** Required for selection plans; unused for the default profile. */
280
289
  discovery?: DiscoveryContext;
281
290
  /** The trusted project's `.pi/settings.json` content (already parsed). */
@@ -418,29 +427,34 @@ export async function writeRuntimeFiles(
418
427
  } else {
419
428
  // Filter MCP servers
420
429
  try { await rm(mcpInstancePath); } catch {}
421
- if (await exists(mcpTarget)) {
422
- try {
423
- const mcpContent = await readFile(mcpTarget, "utf8");
424
- let mcpParsed = JSON.parse(mcpContent);
425
- if (isRecord(mcpParsed) && isRecord(mcpParsed.mcpServers)) {
426
- const filteredServers: Record<string, unknown> = {};
427
- for (const serverName of plan.mcps) {
428
- if (mcpParsed.mcpServers[serverName] !== undefined) {
429
- filteredServers[serverName] = mcpParsed.mcpServers[serverName];
430
- }
431
- }
432
- mcpParsed.mcpServers = filteredServers;
433
- await writeFile(mcpInstancePath, JSON.stringify(mcpParsed, null, 2));
434
- } else {
435
- // Malformed or empty, write empty
436
- await writeFile(mcpInstancePath, JSON.stringify({ mcpServers: {} }, null, 2));
437
- }
438
- } catch {
439
- await writeFile(mcpInstancePath, JSON.stringify({ mcpServers: {} }, null, 2));
430
+ const { servers, sharedServers, baseConfig } = await loadMergedMcpServers(
431
+ options.agentDir,
432
+ options.projectDir,
433
+ options.homeDir !== undefined ? { homeDir: options.homeDir } : undefined,
434
+ );
435
+
436
+ const allowedSet = new Set(plan.mcps);
437
+ const filteredServers: Record<string, unknown> = {};
438
+
439
+ for (const serverName of plan.mcps) {
440
+ if (servers[serverName] !== undefined) {
441
+ const def = { ...servers[serverName] };
442
+ delete def.disabled;
443
+ filteredServers[serverName] = def;
444
+ }
445
+ }
446
+
447
+ for (const sharedName of sharedServers) {
448
+ if (!allowedSet.has(sharedName)) {
449
+ filteredServers[sharedName] = { disabled: true };
440
450
  }
441
- } else {
442
- await writeFile(mcpInstancePath, JSON.stringify({ mcpServers: {} }, null, 2));
443
451
  }
452
+
453
+ const outputConfig: Record<string, unknown> = isRecord(baseConfig)
454
+ ? { ...baseConfig, mcpServers: filteredServers }
455
+ : { mcpServers: filteredServers };
456
+
457
+ await writeFile(mcpInstancePath, JSON.stringify(outputConfig, null, 2));
444
458
  }
445
459
 
446
460
  // Instructions generation (Ticket 04)
@@ -164,6 +164,7 @@ export async function switchProfile(
164
164
  : undefined;
165
165
  await writeRuntimeFiles(deps.runtimeDir, resolved.plan, {
166
166
  agentDir: deps.realAgentDir,
167
+ projectDir: resolved.projectDir,
167
168
  discovery: resolved.discovery,
168
169
  projectSettings: resolved.projectSettings,
169
170
  planExtras: {