pi-profile-switch 0.3.1 → 0.4.2

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.
Files changed (54) hide show
  1. package/README.md +31 -36
  2. package/README.zh-CN.md +31 -36
  3. package/bin/pi-profile.js +11 -0
  4. package/bin/pi-profile.ts +65 -0
  5. package/bin/postinstall.d.ts +13 -0
  6. package/bin/postinstall.js +88 -0
  7. package/defaults/profiles.json +18 -0
  8. package/examples/profiles.json +31 -5
  9. package/extensions/pi-profile/index.ts +451 -0
  10. package/package.json +10 -13
  11. package/schemas/profiles.schema.json +22 -24
  12. package/src/extension-discovery.ts +347 -0
  13. package/src/json-file.ts +1 -21
  14. package/src/launcher/args.ts +57 -0
  15. package/src/launcher/discovery.ts +64 -0
  16. package/src/launcher/initial-profile.ts +179 -0
  17. package/src/launcher/model-check.ts +52 -0
  18. package/src/launcher/runtime-cleanup.ts +85 -0
  19. package/src/launcher/spawn.ts +82 -0
  20. package/src/mcp-config.ts +37 -153
  21. package/src/mcp-coordination.ts +29 -10
  22. package/src/profile-catalog-store.ts +31 -12
  23. package/src/profile-catalog.ts +45 -93
  24. package/src/profile-resolver.ts +239 -245
  25. package/src/project-trust.ts +82 -0
  26. package/src/runtime-state-store.ts +25 -41
  27. package/src/settings-generator.ts +541 -0
  28. package/src/skill-registry.ts +94 -0
  29. package/src/switching/apply-plan.ts +197 -0
  30. package/src/switching/customize.ts +62 -33
  31. package/src/switching/list-profiles.ts +9 -6
  32. package/src/switching/mcp-toggle.ts +14 -26
  33. package/src/switching/profile-crud.ts +31 -24
  34. package/src/switching/profile-wizard.ts +21 -49
  35. package/src/switching/status.ts +142 -72
  36. package/src/switching/switch-profile.ts +219 -0
  37. package/src/switching/tool-references.ts +40 -0
  38. package/src/workspace.ts +57 -0
  39. package/LICENSE +0 -21
  40. package/examples/profiles.example.json +0 -74
  41. package/extensions/pi-profile-switch/index.ts +0 -778
  42. package/src/adapter-presence.ts +0 -75
  43. package/src/default-profiles.ts +0 -59
  44. package/src/mcp-overlay-file.ts +0 -35
  45. package/src/mcp-overlay.ts +0 -122
  46. package/src/model-selection.ts +0 -64
  47. package/src/name-matching.ts +0 -50
  48. package/src/profile-badge.ts +0 -142
  49. package/src/profile-presets.ts +0 -61
  50. package/src/skill-selection.ts +0 -81
  51. package/src/startup-mcp-scope.ts +0 -271
  52. package/src/startup-selection.ts +0 -201
  53. package/src/switching/activate-profile.ts +0 -144
  54. package/src/switching/apply-profile.ts +0 -131
@@ -0,0 +1,94 @@
1
+ /**
2
+ * SkillRegistry: mirrors Pi's native skill discovery result as a
3
+ * name → final SKILL.md mapping.
4
+ *
5
+ * Implemented on top of the Pi SDK's DefaultResourceLoader pointed at the
6
+ * real agent dir and cwd, so discovery rules and same-name priority stay
7
+ * Pi's own — this module never re-implements directory scanning.
8
+ *
9
+ * Extensions are NOT loaded during discovery (`noExtensions`): extension code
10
+ * must never execute as a side effect of resolving a profile. Skills that
11
+ * extensions contribute at runtime are therefore absent here — they load with
12
+ * their extension instead of being referenced by profiles.
13
+ *
14
+ * Discovery re-runs on every call, so newly added/removed skills are
15
+ * reflected immediately (glob references re-expand at every start).
16
+ */
17
+
18
+ import { DefaultResourceLoader, SettingsManager } from "@earendil-works/pi-coding-agent";
19
+
20
+ export interface SkillEntry {
21
+ /** Pi skill name (the profile-facing identity). */
22
+ name: string;
23
+ /** Absolute path of the winning SKILL.md (or lone .md) file. */
24
+ filePath: string;
25
+ /** Discovery source, e.g. "auto", "local", or a package source string. */
26
+ source: string;
27
+ /** "user" | "project" | "temporary" (Pi's SourceScope). */
28
+ scope: string;
29
+ /** "package" when contributed by an installed package, else "top-level". */
30
+ origin: string;
31
+ /** Package install root for package-origin skills (patterns are relative to it). */
32
+ baseDir?: string;
33
+ }
34
+
35
+ export interface DiscoverSkillsOptions {
36
+ cwd: string;
37
+ agentDir: string;
38
+ /**
39
+ * Whether the project at `cwd` is trusted (the launcher's trust check).
40
+ * Untrusted projects are never scanned: no project skills, no project
41
+ * settings packages. Defaults to false.
42
+ */
43
+ projectTrusted?: boolean;
44
+ }
45
+
46
+ export async function discoverSkills(options: DiscoverSkillsOptions): Promise<SkillEntry[]> {
47
+ // Project trust comes from the caller's trust check; generated settings
48
+ // carry defaultProjectTrust: "never", so discovery is the only place
49
+ // project resources can enter a plan.
50
+ const settingsManager = SettingsManager.create(options.cwd, options.agentDir, {
51
+ projectTrusted: options.projectTrusted ?? false,
52
+ });
53
+ const loader = new DefaultResourceLoader({
54
+ cwd: options.cwd,
55
+ agentDir: options.agentDir,
56
+ settingsManager,
57
+ noExtensions: true,
58
+ noPromptTemplates: true,
59
+ noThemes: true,
60
+ noContextFiles: true,
61
+ });
62
+ // Pi installs missing configured packages during resolve(); discovery must
63
+ // stay read-only (no network, no mutations), so offline mode is forced for
64
+ // the duration of the load. The spawned pi decides on installs itself at
65
+ // startup, with its own progress UI. Known limitation: skills of a
66
+ // not-yet-installed package cannot be referenced until after a reload.
67
+ const savedOffline = process.env.PI_OFFLINE;
68
+ process.env.PI_OFFLINE = "1";
69
+ try {
70
+ await loader.reload();
71
+ } finally {
72
+ if (savedOffline === undefined) delete process.env.PI_OFFLINE;
73
+ else process.env.PI_OFFLINE = savedOffline;
74
+ }
75
+ return loader.getSkills().skills.flatMap((skill) => {
76
+ // Project-scoped package skills are excluded: their packages install
77
+ // under the project's .pi/npm, which generated global-scope settings
78
+ // cannot reference. Project .pi/skills and ancestor .agents/skills are
79
+ // unaffected. (Limitation documented in ticket 03's comments.)
80
+ if (skill.sourceInfo.origin === "package" && skill.sourceInfo.scope === "project") {
81
+ return [];
82
+ }
83
+ return [
84
+ {
85
+ name: skill.name,
86
+ filePath: skill.filePath,
87
+ source: skill.sourceInfo.source,
88
+ scope: skill.sourceInfo.scope,
89
+ origin: skill.sourceInfo.origin,
90
+ baseDir: skill.sourceInfo.baseDir,
91
+ },
92
+ ];
93
+ });
94
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * ApplyLaunchPlan: applies the launch plan inside a running Pi after every
3
+ * session start (startup, reload, new/resume/fork) — the post-reload half of
4
+ * switching (ticket 05).
5
+ *
6
+ * The freshly re-executed pi-profile extension calls this from its
7
+ * `session_start` handler. It is dependency-injected against a narrow pi
8
+ * surface so unit tests never need a real Pi.
9
+ *
10
+ * Steps:
11
+ * 1. tools: re-expand the profile's raw tool references against Pi's LIVE
12
+ * tool registry (includes extension-provided tools the pre-spawn
13
+ * expansion cannot know) and setActiveTools. Literals that no tool
14
+ * provides are dropped with a warning — Pi silently ignores unknown
15
+ * names, so the warning is the only signal.
16
+ * 2. model: setModel + setThinkingLevel when declared.
17
+ * 3. mcp: probe the adapter and publish the runtime allowlist (ticket 04).
18
+ * 4. persistence: when the plan is marked `persistSelection` and this is a
19
+ * reload, save the selection and the rollback anchor
20
+ * (activeProfile = lastVerifiedProfile = plan.profile) to the
21
+ * profile's scope state file. Launch-transient selections never write.
22
+ * 5. change summary: a `switchedFrom` marker produces a one-shot summary
23
+ * for the next agent turn and is cleared from the plan file.
24
+ *
25
+ * Pi's reload re-executes extension modules, so no stale handler or command
26
+ * context survives; this module is the only place post-reload state is
27
+ * established.
28
+ */
29
+
30
+ import { writeFile } from "node:fs/promises";
31
+ import path from "node:path";
32
+
33
+ import { isRecord, readJsonFile } from "../json-file.ts";
34
+ import {
35
+ MCP_ALLOWLIST_EVENT,
36
+ MCP_ALLOWLIST_VERSION,
37
+ MissingMcpAdapterError,
38
+ probeAdapterPresence,
39
+ } from "../mcp-coordination.ts";
40
+ import { RuntimeStateStore } from "../runtime-state-store.ts";
41
+ import { getGlobalStateDir } from "../workspace.ts";
42
+ import { expandToolReferences } from "./tool-references.ts";
43
+
44
+ export interface LaunchPlanFile {
45
+ profile: string;
46
+ source: string;
47
+ agentDir?: string;
48
+ instructions?: string;
49
+ model?: { provider: string; id: string; thinkingLevel?: string };
50
+ tools?: string[];
51
+ toolReferences?: string[];
52
+ mcps?: string[];
53
+ switchedFrom?: string;
54
+ persistSelection?: boolean;
55
+ clearOverlay?: boolean;
56
+ resolved?: {
57
+ skills: Array<{ name: string; filePath: string }>;
58
+ extensions: Array<{ id: string; entry: string }>;
59
+ };
60
+ /** Glob references that matched nothing at resolution (ADR-0006). */
61
+ unmatched?: string[];
62
+ previousResolved?: {
63
+ skills: string[];
64
+ extensions: string[];
65
+ tools?: string[];
66
+ mcps?: string[];
67
+ };
68
+ }
69
+
70
+ /** The narrow slice of ExtensionAPI/Context the application needs. */
71
+ export interface PlanApplicationSurface {
72
+ getAllTools(): Array<{ name: string }>;
73
+ setActiveTools(names: string[]): void;
74
+ modelRegistry: { find(provider: string, id: string): unknown | undefined };
75
+ setModel(model: unknown): Promise<boolean>;
76
+ setThinkingLevel(level: unknown): void;
77
+ events: { emit(channel: string, data: unknown): void };
78
+ notify?(message: string, level: "info" | "warning" | "error"): void;
79
+ }
80
+
81
+ export interface ApplyResult {
82
+ /** One-shot profile-change summary for the next agent turn, if any. */
83
+ summary?: string;
84
+ warnings: string[];
85
+ }
86
+
87
+ export async function readLaunchPlanFile(runtimeDir: string): Promise<LaunchPlanFile | undefined> {
88
+ const result = await readJsonFile(path.join(runtimeDir, "pi-profile.json"));
89
+ if (!result.ok || !isRecord(result.value) || typeof result.value.profile !== "string") {
90
+ return undefined;
91
+ }
92
+ return result.value as unknown as LaunchPlanFile;
93
+ }
94
+
95
+ /** Applies the plan carried by the runtime dir's pi-profile.json. */
96
+ export async function applyLaunchPlan(input: {
97
+ runtimeDir: string;
98
+ cwd: string;
99
+ /** The session_start reason ("startup" | "reload" | "new" | ...). */
100
+ reason: string;
101
+ surface: PlanApplicationSurface;
102
+ }): Promise<ApplyResult> {
103
+ const { surface } = input;
104
+ const plan = await readLaunchPlanFile(input.runtimeDir);
105
+ if (plan === undefined) {
106
+ return { warnings: [] };
107
+ }
108
+ const warnings: string[] = [];
109
+
110
+ // --- tools ---
111
+ if (plan.toolReferences !== undefined) {
112
+ const liveNames = surface.getAllTools().map((tool) => tool.name);
113
+ const { expanded, droppedLiterals } = expandToolReferences(plan.toolReferences, liveNames);
114
+ if (droppedLiterals.length > 0) {
115
+ warnings.push(
116
+ `profile "${plan.profile}": tools ${droppedLiterals.map((name) => JSON.stringify(name)).join(", ")} match nothing in Pi's live registry`,
117
+ );
118
+ }
119
+ surface.setActiveTools(expanded);
120
+ }
121
+
122
+ // --- model ---
123
+ if (plan.model !== undefined) {
124
+ const found = surface.modelRegistry.find(plan.model.provider, plan.model.id);
125
+ if (found === undefined) {
126
+ warnings.push(`profile "${plan.profile}": declared model ${plan.model.provider}/${plan.model.id} not found`);
127
+ } else {
128
+ const applied = await surface.setModel(found);
129
+ if (!applied) {
130
+ warnings.push(
131
+ `profile "${plan.profile}": model ${plan.model.provider}/${plan.model.id} has no configured auth`,
132
+ );
133
+ }
134
+ }
135
+ if (plan.model.thinkingLevel !== undefined) {
136
+ surface.setThinkingLevel(plan.model.thinkingLevel);
137
+ }
138
+ }
139
+
140
+ // --- mcp coordination (ticket 04 contract) ---
141
+ if (plan.mcps !== undefined && plan.mcps.length > 0) {
142
+ if (!probeAdapterPresence(surface.events)) {
143
+ const error = new MissingMcpAdapterError(plan.profile);
144
+ surface.notify?.(error.message, "error");
145
+ throw error;
146
+ }
147
+ surface.events.emit(MCP_ALLOWLIST_EVENT, {
148
+ version: MCP_ALLOWLIST_VERSION,
149
+ profile: plan.profile,
150
+ servers: plan.mcps,
151
+ });
152
+ }
153
+
154
+ // --- persistence + rollback anchor (post-reload only) ---
155
+ if (plan.persistSelection === true && input.reason === "reload" && plan.agentDir !== undefined) {
156
+ const stateDir = plan.source === "project" ? path.join(input.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
157
+ // Merge: the overlay belongs to customize/reset, not to this write.
158
+ // A switch (clearOverlay) explicitly drops it.
159
+ await new RuntimeStateStore(stateDir).update({
160
+ activeProfile: plan.profile,
161
+ lastVerifiedProfile: plan.profile,
162
+ ...(plan.clearOverlay === true ? { overlay: undefined } : {}),
163
+ });
164
+ }
165
+
166
+ // --- one-shot change summary ---
167
+ let summary: string | undefined;
168
+ if (typeof plan.switchedFrom === "string" && plan.switchedFrom.length > 0) {
169
+ summary = buildSwitchSummary(plan);
170
+ surface.notify?.(summary, "info");
171
+ await clearSwitchMarker(input.runtimeDir);
172
+ }
173
+
174
+ for (const warning of warnings) {
175
+ surface.notify?.(warning, "warning");
176
+ }
177
+ return { summary, warnings };
178
+ }
179
+
180
+ function buildSwitchSummary(plan: LaunchPlanFile): string {
181
+ const parts = [
182
+ `profile switched: ${plan.switchedFrom} → ${plan.profile}`,
183
+ plan.tools !== undefined ? `tools: [${plan.tools.join(", ")}]` : undefined,
184
+ plan.mcps !== undefined && plan.mcps.length > 0 ? `mcp: [${plan.mcps.join(", ")}]` : undefined,
185
+ plan.model !== undefined ? `model: ${plan.model.provider}/${plan.model.id}` : undefined,
186
+ ].filter((part): part is string => part !== undefined);
187
+ return parts.join("; ");
188
+ }
189
+
190
+ /** Clears the one-shot marker so the summary fires exactly once, even
191
+ * across later reloads. */
192
+ async function clearSwitchMarker(runtimeDir: string): Promise<void> {
193
+ const plan = await readLaunchPlanFile(runtimeDir);
194
+ if (plan === undefined) return;
195
+ delete plan.switchedFrom;
196
+ await writeFile(path.join(runtimeDir, "pi-profile.json"), `${JSON.stringify(plan, null, 2)}\n`);
197
+ }
@@ -1,61 +1,90 @@
1
1
  /**
2
- * Overlay customize/reset orchestration.
2
+ * Overlay customize/reset orchestration (ticket 06).
3
3
  *
4
4
  * The overlay narrows the ACTIVE profile for this runtime only. It is
5
- * written to the scope state file (never a catalog) as persistence and the
6
- * status surface; the runtime effect flows through re-resolution with the
7
- * overlay, exactly like a switch. Startup activation ignores stored
8
- * overlays, so an overlay never outlives its runtime.
5
+ * written to the scope state file (never a catalog) purely as the
6
+ * persistence/status surface the runtime effect flows through
7
+ * re-resolution with the overlay, exactly like a switch. The launcher
8
+ * ignores stored overlays, so an overlay never outlives its runtime.
9
9
  *
10
10
  * Ordering invariants:
11
- * - customize: activate with the candidate overlay FIRST (validation:
12
- * unknown references fail here, before anything is written), then persist
13
- * the overlay to state. A failed activation leaves the stored overlay
14
- * untouched.
15
- * - reset: activate without the overlay first, then delete it from state.
11
+ * - customize: re-resolve with the candidate overlay FIRST (validation:
12
+ * unknown references and alwaysOn protection fail here, before anything
13
+ * is written), then switch+reload, then persist the overlay to state. A
14
+ * failed switch leaves the stored overlay untouched, consistent with the
15
+ * rolled-back runtime.
16
+ * - reset: switch+reload WITHOUT the overlay first, then delete it from
17
+ * state. If the switch rolls back, the stored overlay still matches the
18
+ * restored runtime.
16
19
  */
17
20
 
21
+ import path from "node:path";
18
22
 
19
- import type { ProfileSource } from "../profile-catalog.ts";
20
- import { RuntimeStateStore, stateDirFor, type RuntimeOverlay } from "../runtime-state-store.ts";
21
- import { activateProfile, ActivationError, type ActivationDeps, type ActivationResult } from "./activate-profile.ts";
23
+ import { RuntimeStateStore, type RuntimeOverlay, type RuntimeState } from "../runtime-state-store.ts";
24
+ import { getGlobalStateDir } from "../workspace.ts";
25
+ import { readLaunchPlanFile } from "./apply-plan.ts";
26
+ import { SwitchError, switchProfile, type SwitchDeps, type SwitchResult } from "./switch-profile.ts";
22
27
 
23
- export interface OverlayTarget {
24
- profile: { name: string; source: ProfileSource };
28
+ /** The scope state file for the currently active profile. */
29
+ async function currentStateTarget(
30
+ deps: SwitchDeps,
31
+ ): Promise<{ profile: string; store: RuntimeStateStore; state: RuntimeState }> {
32
+ const plan = await readLaunchPlanFile(deps.runtimeDir);
33
+ if (plan === undefined) {
34
+ throw new SwitchError("no active profile — nothing to customize");
35
+ }
36
+ const stateDir = plan.source === "project" ? path.join(deps.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
37
+ if (stateDir === undefined) {
38
+ throw new SwitchError("the launch plan carries no real agent dir — cannot locate the state file");
39
+ }
40
+ const store = new RuntimeStateStore(stateDir);
41
+ return { profile: plan.profile, store, state: await store.read() };
25
42
  }
26
43
 
27
44
  /** Applies a mutation to the active profile's overlay and re-activates. */
28
45
  export async function customizeOverlay(
29
- deps: ActivationDeps & OverlayTarget,
46
+ deps: SwitchDeps,
30
47
  mutate: (overlay: RuntimeOverlay) => RuntimeOverlay,
31
- ): Promise<ActivationResult> {
32
- const stateDir = stateDirFor(deps.profile.source, deps);
33
- const stored = (await new RuntimeStateStore(stateDir).read()).overlay ?? {};
34
- const candidate = mutate(stored);
35
- // activateProfile re-resolves with the candidate and persists it
36
- // (overlay in the state update) — validation fails before any write.
37
- return activateProfile(deps.profile.name, deps, { overlay: candidate, persist: true });
48
+ ): Promise<SwitchResult> {
49
+ const { profile, store, state } = await currentStateTarget(deps);
50
+ const candidate = mutate(state.overlay ?? {});
51
+
52
+ // switchProfile re-resolves with the candidate overlay; resolution-time
53
+ // validation (unknown references, alwaysOn protection) fails before any
54
+ // write. persistSelection is preserved by the reload-current path.
55
+ const result = await switchProfile(profile, deps, { reloadCurrent: true, overlay: candidate });
56
+
57
+ await store.update({ overlay: candidate });
58
+ return result;
38
59
  }
39
60
 
40
61
  /** Discards the overlay and reactivates the profile exactly as declared. */
41
- export async function resetOverlay(deps: ActivationDeps & OverlayTarget): Promise<ActivationResult> {
42
- return activateProfile(deps.profile.name, deps, { overlay: null, persist: true });
62
+ export async function resetOverlay(deps: SwitchDeps): Promise<SwitchResult> {
63
+ const { profile, store } = await currentStateTarget(deps);
64
+
65
+ // overlay: null — explicit "none"; without it the reload path would
66
+ // re-apply the stored overlay we're discarding.
67
+ const result = await switchProfile(profile, deps, { reloadCurrent: true, overlay: null });
68
+
69
+ await store.update({ overlay: undefined });
70
+ return result;
43
71
  }
44
72
 
45
73
  export const CUSTOMIZE_USAGE =
46
- "/profile customize disable|enable skill|mcp <name> · /profile customize tools [ref...]" as const;
74
+ "/profile customize disable|enable skill|extension|mcp <name> · /profile customize tools [ref...]" as const;
47
75
 
48
76
  const DISABLED_FIELDS = {
49
77
  skill: "disabledSkills",
50
- mcp: "disabledMcp",
78
+ extension: "disabledExtensions",
79
+ mcp: "disabledMcps",
51
80
  } as const;
52
81
 
53
82
  /** Parses `/profile customize` arguments into an overlay mutation.
54
83
  * Grammar:
55
- * customize disable skill|mcp <name>
56
- * customize enable skill|mcp <name> (un-disable)
57
- * customize tools <ref>... (replace tool refs)
58
- * customize tools (clear the tools override)
84
+ * customize disable skill|extension|mcp <name>
85
+ * customize enable skill|extension|mcp <name> (un-disable)
86
+ * customize tools <ref>... (replace tool refs)
87
+ * customize tools (clear the tools override)
59
88
  */
60
89
  export function parseCustomizeArgs(args: string): (overlay: RuntimeOverlay) => RuntimeOverlay {
61
90
  const [action, kind, ...rest] = args.trim().split(/\s+/).filter(Boolean);
@@ -71,8 +100,8 @@ export function parseCustomizeArgs(args: string): (overlay: RuntimeOverlay) => R
71
100
  }
72
101
 
73
102
  const field = DISABLED_FIELDS[kind as keyof typeof DISABLED_FIELDS];
74
- if (field === undefined || (action !== "disable" && action !== "enable") || rest.length !== 1) {
75
- throw new ActivationError(`usage: ${CUSTOMIZE_USAGE}`);
103
+ if ((action !== "disable" && action !== "enable") || field === undefined || rest.length !== 1) {
104
+ throw new SwitchError(`usage: ${CUSTOMIZE_USAGE}`);
76
105
  }
77
106
  const [name] = rest;
78
107
  return (overlay) => {
@@ -1,12 +1,14 @@
1
1
  /**
2
- * ProfileListing: the `/profile list` and `/profile` selector data surface.
2
+ * ProfileListing: the `/profile list` and `/profile` selector data surface
3
+ * (ticket 07).
3
4
  *
4
5
  * Trust-gated exactly like activation: an untrusted project's profiles are
5
6
  * invisible. The listing reports each visible profile with the source of
6
- * the WINNING definition (a same-name project definition fully replaces the
7
- * global one — the shadowed global entry is reported as such).
7
+ * the WINNING definition (a same-name project definition fully replaces
8
+ * the global one — the shadowed global entry is reported as such).
8
9
  */
9
10
 
11
+ import { readTrustInputs } from "../launcher/initial-profile.ts";
10
12
  import { ProfileCatalog, type ProfileSource } from "../profile-catalog.ts";
11
13
 
12
14
  export interface ProfileListEntry {
@@ -23,17 +25,18 @@ export interface ProfileListEntry {
23
25
  export async function listProfiles(input: {
24
26
  realAgentDir: string;
25
27
  cwd: string;
26
- projectTrusted: boolean;
27
28
  }): Promise<ProfileListEntry[]> {
29
+ const { projectTrusted } = await readTrustInputs({ agentDir: input.realAgentDir, cwd: input.cwd });
28
30
  const catalog = await ProfileCatalog.load(input.realAgentDir, {
29
- projectDir: input.projectTrusted ? input.cwd : undefined,
31
+ projectDir: projectTrusted ? input.cwd : undefined,
30
32
  });
33
+ const globalOnly = projectTrusted ? await ProfileCatalog.load(input.realAgentDir) : catalog;
31
34
  return catalog.list().map((profile) => ({
32
35
  name: profile.name,
33
36
  source: profile.source,
34
37
  ...(typeof profile.definition.label === "string" ? { label: profile.definition.label } : {}),
35
38
  ...(typeof profile.definition.description === "string" ? { description: profile.definition.description } : {}),
36
- shadowsGlobal: profile.source === "project" && catalog.shadowsGlobal(profile.name),
39
+ shadowsGlobal: profile.source === "project" && globalOnly.resolve(profile.name) !== undefined,
37
40
  }));
38
41
  }
39
42
 
@@ -4,9 +4,9 @@
4
4
  * The profile's `mcps` array in its OWNING catalog is the profile-scoped
5
5
  * state store (ticket 04 established that pi-mcp-adapter@2.33.0 has no
6
6
  * allowlist/profile-state API — ADR-0002's assumed store does not exist;
7
- * pi-profile-switch owns the contract). The runtime effect is the caller's
8
- * re-activation of the active profile, which republishes the allowlist over
9
- * the coordination channel.
7
+ * pi-profile owns the contract). Runtime effect flows through the standard
8
+ * rewrite-settings-and-reload path: the post-reload session_start
9
+ * republishes the allowlist over the coordination channel.
10
10
  *
11
11
  * Invariants:
12
12
  * - Enable accepts only adapter-discovered names (fail fast on typos);
@@ -20,40 +20,30 @@
20
20
  */
21
21
 
22
22
  import { discoverAdapterServerNames } from "../mcp-config.ts";
23
- import {
24
- CatalogError,
25
- DEFAULT_PROFILE_NAME,
26
- LEGACY_FIELD_ALIASES,
27
- type ProfileDefinition,
28
- type ProfileSource,
29
- } from "../profile-catalog.ts";
23
+ import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition } from "../profile-catalog.ts";
24
+ import { readTrustInputs as readTrust } from "../launcher/initial-profile.ts";
30
25
  import { catalogStore, readCatalogScope, type CatalogScope } from "./profile-crud.ts";
31
26
 
32
27
  export async function setMcpServerEnabled(
33
- input: {
34
- realAgentDir: string;
35
- cwd: string;
36
- projectTrusted: boolean;
37
- profile: { name: string; source: ProfileSource };
38
- },
28
+ input: { realAgentDir: string; cwd: string; profile: { name: string; source: string } },
39
29
  server: string,
40
30
  enabled: boolean,
41
31
  ): Promise<{ mcps: string[]; changed: boolean }> {
42
- const { name, source } = input.profile;
43
- if (name === DEFAULT_PROFILE_NAME || source === "builtin") {
32
+ if (input.profile.name === DEFAULT_PROFILE_NAME || input.profile.source === "builtin") {
44
33
  throw new CatalogError(
45
34
  `the built-in default profile has no catalog entry — create a named profile (/profile create) to toggle MCP servers`,
46
35
  );
47
36
  }
48
- if (source !== "global" && source !== "project") {
49
- throw new CatalogError(`profile "${name}" has no writable owning catalog (source: ${source})`);
37
+ const scope = input.profile.source as CatalogScope;
38
+ if (scope !== "global" && scope !== "project") {
39
+ throw new CatalogError(`profile "${input.profile.name}" has no writable owning catalog (source: ${input.profile.source})`);
50
40
  }
51
- const scope: CatalogScope = source;
52
41
 
53
- if (scope === "project" && !input.projectTrusted) {
42
+ const { projectTrusted } = await readTrust({ agentDir: input.realAgentDir, cwd: input.cwd });
43
+ if (scope === "project" && !projectTrusted) {
54
44
  throw new CatalogError(`project catalog is unavailable: ${input.cwd} is not trusted`);
55
45
  }
56
- const discovered = await discoverAdapterServerNames(input.realAgentDir, input.projectTrusted ? input.cwd : undefined);
46
+ const discovered = await discoverAdapterServerNames(input.realAgentDir, projectTrusted ? input.cwd : undefined);
57
47
  if (enabled && !discovered.includes(server)) {
58
48
  throw new CatalogError(
59
49
  `unknown MCP server "${server}" — adapter discovered: [${discovered.join(", ") || "(none)"}]`,
@@ -72,12 +62,10 @@ export async function setMcpServerEnabled(
72
62
  }
73
63
  const next = enabled ? [...current, server] : current.filter((name) => name !== server);
74
64
  // Drop the key entirely when empty (exactOptionalPropertyTypes; a
75
- // written `mcps: undefined` would also misrepresent the definition) and
76
- // drop the legacy alias, so one save migrates the file to `mcps`.
65
+ // written `mcps: undefined` would also misrepresent the definition).
77
66
  const rest = { ...definition };
78
67
  delete rest.mcps;
79
68
  const updated: ProfileDefinition = next.length > 0 ? { ...rest, mcps: next } : rest;
80
- delete (updated as Record<string, unknown>)[LEGACY_FIELD_ALIASES.mcps];
81
69
  await catalogStore(input, scope).upsert(input.profile.name, updated);
82
70
  return { mcps: next, changed: true };
83
71
  }
@@ -16,30 +16,34 @@
16
16
 
17
17
  import path from "node:path";
18
18
 
19
+ import { getGlobalProfilesPath } from "../workspace.ts";
20
+ import { readTrustInputs } from "../launcher/initial-profile.ts";
19
21
  import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition } from "../profile-catalog.ts";
20
22
  import { ProfileCatalogStore } from "../profile-catalog-store.ts";
21
23
 
22
24
  export type CatalogScope = "global" | "project";
23
25
 
24
- /** The caller's trust decision (`ctx.isProjectTrusted()`) plus the two
25
- * directories scope files live in. */
26
- export interface CatalogInput {
27
- realAgentDir: string;
28
- cwd: string;
29
- projectTrusted: boolean;
30
- }
31
-
32
26
  /** The store for one scope's catalog file — the ONLY place scope-file
33
27
  * paths are constructed. Callers must still trust-gate project access
34
28
  * (`requireScope` / `readCatalogScope`). */
35
- export function catalogStore(input: CatalogInput, scope: CatalogScope): ProfileCatalogStore {
36
- return new ProfileCatalogStore(
37
- scope === "global" ? path.join(input.realAgentDir, "profiles.json") : path.join(input.cwd, ".pi", "profiles.json"),
38
- );
29
+ export function catalogStore(
30
+ input: { realAgentDir: string; cwd: string },
31
+ scope: CatalogScope,
32
+ ): ProfileCatalogStore {
33
+ if (scope === "global") {
34
+ return new ProfileCatalogStore(
35
+ getGlobalProfilesPath(),
36
+ path.join(input.realAgentDir, "profiles.json")
37
+ );
38
+ }
39
+ return new ProfileCatalogStore(path.join(input.cwd, ".pi", "profiles.json"));
39
40
  }
40
41
 
41
- function requireScope(input: CatalogInput, scope: CatalogScope): void {
42
- if (scope === "project" && !input.projectTrusted) {
42
+ async function requireScope(input: { realAgentDir: string; cwd: string }, scope: CatalogScope): Promise<void> {
43
+ if (
44
+ scope === "project" &&
45
+ !(await readTrustInputs({ agentDir: input.realAgentDir, cwd: input.cwd })).projectTrusted
46
+ ) {
43
47
  throw new CatalogError(`project catalog is unavailable: ${input.cwd} is not trusted`);
44
48
  }
45
49
  }
@@ -47,10 +51,13 @@ function requireScope(input: CatalogInput, scope: CatalogScope): void {
47
51
  /** Reads one scope's catalog with the trust gate applied — project reads
48
52
  * return empty when untrusted (never touching the file). */
49
53
  export async function readCatalogScope(
50
- input: CatalogInput,
54
+ input: { realAgentDir: string; cwd: string },
51
55
  scope: CatalogScope,
52
56
  ): Promise<Map<string, ProfileDefinition>> {
53
- if (scope === "project" && !input.projectTrusted) {
57
+ if (
58
+ scope === "project" &&
59
+ !(await readTrustInputs({ agentDir: input.realAgentDir, cwd: input.cwd })).projectTrusted
60
+ ) {
54
61
  return new Map();
55
62
  }
56
63
  return catalogStore(input, scope).readDefinitions();
@@ -58,12 +65,12 @@ export async function readCatalogScope(
58
65
 
59
66
  /** Creates a complete definition in the chosen scope. */
60
67
  export async function createProfile(
61
- input: CatalogInput,
68
+ input: { realAgentDir: string; cwd: string },
62
69
  scope: CatalogScope,
63
70
  name: string,
64
71
  definition: ProfileDefinition,
65
72
  ): Promise<void> {
66
- requireScope(input, scope);
73
+ await requireScope(input, scope);
67
74
  const store = catalogStore(input, scope);
68
75
  if ((await store.readDefinitions()).has(name)) {
69
76
  throw new CatalogError(`profile "${name}" already exists in the ${scope} catalog`);
@@ -73,12 +80,12 @@ export async function createProfile(
73
80
 
74
81
  /** Replaces a complete definition; the caller reloads iff it is active. */
75
82
  export async function editProfile(
76
- input: CatalogInput,
83
+ input: { realAgentDir: string; cwd: string },
77
84
  scope: CatalogScope,
78
85
  name: string,
79
86
  definition: ProfileDefinition,
80
87
  ): Promise<void> {
81
- requireScope(input, scope);
88
+ await requireScope(input, scope);
82
89
  if (name === DEFAULT_PROFILE_NAME) {
83
90
  throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be edited`);
84
91
  }
@@ -96,12 +103,12 @@ export async function editProfile(
96
103
  * deleted.
97
104
  */
98
105
  export async function deleteProfile(
99
- input: CatalogInput,
106
+ input: { realAgentDir: string; cwd: string },
100
107
  scope: CatalogScope,
101
108
  name: string,
102
109
  options: { activeProfile?: string; replacement?: string },
103
110
  ): Promise<void> {
104
- requireScope(input, scope);
111
+ await requireScope(input, scope);
105
112
  if (name === DEFAULT_PROFILE_NAME) {
106
113
  throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be deleted`);
107
114
  }
@@ -113,12 +120,12 @@ export async function deleteProfile(
113
120
 
114
121
  /** Copies a complete definition under a new, unused name. */
115
122
  export async function duplicateProfile(
116
- input: CatalogInput,
123
+ input: { realAgentDir: string; cwd: string },
117
124
  scope: CatalogScope,
118
125
  sourceName: string,
119
126
  newName: string,
120
127
  ): Promise<void> {
121
- requireScope(input, scope);
128
+ await requireScope(input, scope);
122
129
  const store = catalogStore(input, scope);
123
130
  const definitions = await store.readDefinitions();
124
131
  const source = definitions.get(sourceName);