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.
- package/README.md +31 -36
- package/README.zh-CN.md +31 -36
- package/bin/pi-profile.js +11 -0
- package/bin/pi-profile.ts +65 -0
- package/bin/postinstall.d.ts +13 -0
- package/bin/postinstall.js +88 -0
- package/defaults/profiles.json +18 -0
- package/examples/profiles.json +31 -5
- package/extensions/pi-profile/index.ts +451 -0
- package/package.json +10 -13
- package/schemas/profiles.schema.json +22 -24
- package/src/extension-discovery.ts +347 -0
- package/src/json-file.ts +1 -21
- package/src/launcher/args.ts +57 -0
- package/src/launcher/discovery.ts +64 -0
- package/src/launcher/initial-profile.ts +179 -0
- package/src/launcher/model-check.ts +52 -0
- package/src/launcher/runtime-cleanup.ts +85 -0
- package/src/launcher/spawn.ts +82 -0
- package/src/mcp-config.ts +37 -153
- package/src/mcp-coordination.ts +29 -10
- package/src/profile-catalog-store.ts +31 -12
- package/src/profile-catalog.ts +45 -93
- package/src/profile-resolver.ts +239 -245
- package/src/project-trust.ts +82 -0
- package/src/runtime-state-store.ts +25 -41
- package/src/settings-generator.ts +541 -0
- package/src/skill-registry.ts +94 -0
- package/src/switching/apply-plan.ts +197 -0
- package/src/switching/customize.ts +62 -33
- package/src/switching/list-profiles.ts +9 -6
- package/src/switching/mcp-toggle.ts +14 -26
- package/src/switching/profile-crud.ts +31 -24
- package/src/switching/profile-wizard.ts +21 -49
- package/src/switching/status.ts +142 -72
- package/src/switching/switch-profile.ts +219 -0
- package/src/switching/tool-references.ts +40 -0
- package/src/workspace.ts +57 -0
- package/LICENSE +0 -21
- package/examples/profiles.example.json +0 -74
- package/extensions/pi-profile-switch/index.ts +0 -778
- package/src/adapter-presence.ts +0 -75
- package/src/default-profiles.ts +0 -59
- package/src/mcp-overlay-file.ts +0 -35
- package/src/mcp-overlay.ts +0 -122
- package/src/model-selection.ts +0 -64
- package/src/name-matching.ts +0 -50
- package/src/profile-badge.ts +0 -142
- package/src/profile-presets.ts +0 -61
- package/src/skill-selection.ts +0 -81
- package/src/startup-mcp-scope.ts +0 -271
- package/src/startup-selection.ts +0 -201
- package/src/switching/activate-profile.ts +0 -144
- 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
|
|
6
|
-
* status surface
|
|
7
|
-
* overlay, exactly like a switch.
|
|
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:
|
|
12
|
-
* unknown references fail here, before anything
|
|
13
|
-
* the overlay to state. A
|
|
14
|
-
* untouched
|
|
15
|
-
*
|
|
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
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
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
|
-
|
|
24
|
-
|
|
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:
|
|
46
|
+
deps: SwitchDeps,
|
|
30
47
|
mutate: (overlay: RuntimeOverlay) => RuntimeOverlay,
|
|
31
|
-
): Promise<
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
// (
|
|
37
|
-
|
|
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:
|
|
42
|
-
|
|
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
|
-
|
|
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>...
|
|
58
|
-
* customize tools
|
|
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 (
|
|
75
|
-
throw new
|
|
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
|
|
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:
|
|
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" &&
|
|
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
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|
|
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(
|
|
36
|
-
|
|
37
|
-
|
|
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:
|
|
42
|
-
if (
|
|
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:
|
|
54
|
+
input: { realAgentDir: string; cwd: string },
|
|
51
55
|
scope: CatalogScope,
|
|
52
56
|
): Promise<Map<string, ProfileDefinition>> {
|
|
53
|
-
if (
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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);
|