pi-profile-switch 0.4.0 → 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/README.md +0 -1
- package/README.zh-CN.md +0 -1
- package/bin/pi-profile.ts +2 -2
- package/package.json +6 -2
- package/schemas/profiles.schema.json +1 -1
- package/src/launcher/initial-profile.ts +4 -2
- package/src/mcp-config.ts +108 -38
- package/src/profile-catalog.ts +2 -2
- package/src/settings-generator.ts +35 -21
- package/src/switching/switch-profile.ts +1 -0
package/README.md
CHANGED
|
@@ -58,7 +58,6 @@ In the TUI, the `/profile` command family manages everything in-session:
|
|
|
58
58
|
| `/profile list` / `/profile status` | Show profiles / active profile details |
|
|
59
59
|
| `/profile use <name>` / `/profile reload` | Switch / reload without restarting (rollback on failure) |
|
|
60
60
|
| `/profile create\|edit\|delete\|duplicate` | Guided profile CRUD (TUI only) |
|
|
61
|
-
| `/profile resource create\|edit\|delete` | Guided resource overrides |
|
|
62
61
|
| `/profile customize` / `/profile reset` | Narrow the active profile for this session only |
|
|
63
62
|
| `/mcp enable\|disable <server>` | Toggle MCP servers in the active profile |
|
|
64
63
|
|
package/README.zh-CN.md
CHANGED
|
@@ -58,7 +58,6 @@ Profile 只**引用**资源,从不复制资源。已安装的包和标准目
|
|
|
58
58
|
| `/profile list` / `/profile status` | 列出 profile / 查看活动 profile 详情 |
|
|
59
59
|
| `/profile use <name>` / `/profile reload` | 会话内切换 / 重载(失败自动回滚) |
|
|
60
60
|
| `/profile create\|edit\|delete\|duplicate` | 向导式 profile 增删改(仅 TUI) |
|
|
61
|
-
| `/profile resource create\|edit\|delete` | 向导式资源覆盖管理 |
|
|
62
61
|
| `/profile customize` / `/profile reset` | 仅本次会话收窄活动 profile |
|
|
63
62
|
| `/mcp enable\|disable <server>` | 在活动 profile 中开关 MCP server |
|
|
64
63
|
|
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.
|
|
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": [
|
|
@@ -11,8 +11,12 @@
|
|
|
11
11
|
"pi"
|
|
12
12
|
],
|
|
13
13
|
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/VincentFF/pi-profile-switch.git"
|
|
17
|
+
},
|
|
14
18
|
"bin": {
|
|
15
|
-
"pi-profile": "
|
|
19
|
+
"pi-profile": "bin/pi-profile.js"
|
|
16
20
|
},
|
|
17
21
|
"pi": {
|
|
18
22
|
"extensions": [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"title": "pi-profile-switch profile catalog",
|
|
4
|
-
"description": "Named profile definitions (global ~/.pi/agent/profiles.json or project .pi/profiles.json). Definitions are complete and self-contained: there is no inheritance field, and resource references are names/globs, never copies. The built-in \"default\" profile must not be defined here.",
|
|
4
|
+
"description": "Named profile definitions (global ~/.pi-profile-switch/profiles.json, legacy fallback ~/.pi/agent/profiles.json; or project .pi/profiles.json). Definitions are complete and self-contained: there is no inheritance field, and resource references are names/globs, never copies. The built-in \"default\" profile must not be defined here.",
|
|
5
5
|
"type": "object",
|
|
6
6
|
"required": [
|
|
7
7
|
"schemaVersion",
|
|
@@ -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
|
|
3
|
-
* discover from its
|
|
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
|
|
7
|
-
*
|
|
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
|
|
10
|
-
*
|
|
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
|
|
13
|
-
*
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/profile-catalog.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ProfileCatalog: reads profile definitions from the global catalog
|
|
3
|
-
* (`<agentDir>/profiles.json`)
|
|
4
|
-
* catalog (`<projectDir>/.pi/profiles.json`).
|
|
3
|
+
* (`~/.pi-profile-switch/profiles.json`, legacy fallback `<agentDir>/profiles.json`)
|
|
4
|
+
* and, for trusted projects, the project catalog (`<projectDir>/.pi/profiles.json`).
|
|
5
5
|
*
|
|
6
6
|
* Invariants:
|
|
7
7
|
* - The built-in `default` profile never exists in either file and cannot be
|
|
@@ -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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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: {
|