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,451 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import path from "node:path";
4
+
5
+ import { readTrustInputs } from "../../src/launcher/initial-profile.ts";
6
+ import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
7
+ import { RuntimeStateStore } from "../../src/runtime-state-store.ts";
8
+ import { applyLaunchPlan, readLaunchPlanFile } from "../../src/switching/apply-plan.ts";
9
+ import { CUSTOMIZE_USAGE, customizeOverlay, parseCustomizeArgs, resetOverlay } from "../../src/switching/customize.ts";
10
+ import { formatProfileList, listProfiles } from "../../src/switching/list-profiles.ts";
11
+ import {
12
+ createProfile,
13
+ deleteProfile,
14
+ duplicateProfile,
15
+ editProfile,
16
+ readCatalogScope,
17
+ } from "../../src/switching/profile-crud.ts";
18
+ import type { ProfileDefinition } from "../../src/profile-catalog.ts";
19
+ import {
20
+ runProfileCreateWizard,
21
+ runProfileDuplicateWizard,
22
+ runProfileEditWizard,
23
+ } from "../../src/switching/profile-wizard.ts";
24
+ import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
25
+ import { setMcpServerEnabled } from "../../src/switching/mcp-toggle.ts";
26
+ import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/status.ts";
27
+ import { switchProfile, type SwitchDeps } from "../../src/switching/switch-profile.ts";
28
+ import { getGlobalStateDir } from "../../src/workspace.ts";
29
+
30
+ /**
31
+ * pi-profile extension entry.
32
+ *
33
+ * Loaded into the spawned pi via `-e`. Responsibilities:
34
+ * - Append the profile's declared instructions to Pi's fully built system
35
+ * prompt on every turn (`before_agent_start`), so the default prompt,
36
+ * AGENTS.md, and other extensions keep working.
37
+ * - After every session start (startup/reload/new/resume/fork), apply the
38
+ * launch plan: re-expand tool references against Pi's live registry,
39
+ * set the declared model/thinking, publish the MCP allowlist, persist the
40
+ * selection + rollback anchor after switches, and produce the one-shot
41
+ * change summary injected into the next turn.
42
+ * - `/profile use <name>` / `/profile reload`: in-session switching without
43
+ * restarting the Pi process (src/switching/switch-profile.ts).
44
+ * - `/profile customize` / `/profile reset`: runtime overlay (ticket 06).
45
+ * - `/profile` (selector), `/profile list`, `/profile status`:
46
+ * observability surface (ticket 07). Status combines the active launch
47
+ * plan, the stored overlay, fresh MCP discovery, and Pi's actual command
48
+ * registrations (the winner evidence for same-name conflicts).
49
+ * - `/profile create|edit|delete|duplicate`: catalog CRUD wizards (ticket 09).
50
+ * CRUD is TUI-only (ticket 11): gated on `ctx.mode === "tui"` with a
51
+ * mode-aware refusal. Mutations apply via the standard reload path;
52
+ * mutation success is notified BEFORE the reload — the command context
53
+ * is stale afterwards.
54
+ * - list/status ship structured `details` payloads (`{kind, profiles}` /
55
+ * `{kind, report}`) for RPC consumers (ticket 11).
56
+ * - `/mcp enable|disable <server>`: edit the active profile's mcp array in
57
+ * its owning catalog, then reload (ticket 10). Fails clearly with the
58
+ * adapter absent; notifies before the reload (stale context after).
59
+ *
60
+ * Pi re-executes this module on reload, so post-reload state is established
61
+ * exclusively through `session_start` — nothing stale survives.
62
+ */
63
+
64
+ function setProfileStatus(ui: unknown, profile: string | undefined): void {
65
+ if (profile && typeof (ui as { setStatus?: (k: string, v: string) => void })?.setStatus === "function") {
66
+ (ui as { setStatus: (k: string, v: string) => void }).setStatus("profile", `profile: ${profile}`);
67
+ }
68
+ }
69
+
70
+ export default function piProfileExtension(pi: ExtensionAPI): void {
71
+ const runtimeDir = process.env.PI_CODING_AGENT_DIR;
72
+ if (runtimeDir === undefined) return;
73
+
74
+ let pendingSummary: string | undefined;
75
+
76
+ pi.on("session_start", async (event, ctx) => {
77
+ const plan = await readLaunchPlanFile(runtimeDir);
78
+ setProfileStatus(ctx.ui, plan?.profile);
79
+ const result = await applyLaunchPlan({
80
+ runtimeDir,
81
+ cwd: ctx.cwd,
82
+ reason: event.reason,
83
+ surface: {
84
+ getAllTools: () => pi.getAllTools(),
85
+ setActiveTools: (names) => pi.setActiveTools(names),
86
+ modelRegistry: ctx.modelRegistry,
87
+ setModel: (model) => pi.setModel(model as Parameters<ExtensionAPI["setModel"]>[0]),
88
+ setThinkingLevel: (level) =>
89
+ pi.setThinkingLevel(level as Parameters<ExtensionAPI["setThinkingLevel"]>[0]),
90
+ events: pi.events,
91
+ notify: (message, level) => ctx.ui?.notify(message, level),
92
+ },
93
+ });
94
+ pendingSummary = result.summary;
95
+ });
96
+
97
+ pi.on("before_agent_start", async (event) => {
98
+ const plan = await readLaunchPlanFile(runtimeDir);
99
+ const instructions = plan?.instructions;
100
+ let systemPrompt = event.systemPrompt;
101
+ if (instructions !== undefined && instructions.length > 0 && !systemPrompt.includes(instructions)) {
102
+ systemPrompt = `${systemPrompt}\n\n${instructions}`;
103
+ }
104
+ if (pendingSummary !== undefined) {
105
+ systemPrompt = `${systemPrompt}\n\n[${pendingSummary}]`;
106
+ pendingSummary = undefined;
107
+ }
108
+ return { systemPrompt };
109
+ });
110
+
111
+ pi.registerCommand("profile", {
112
+ description: "pi-profile: /profile [use|reload|customize|reset|list|status|create|edit|delete|duplicate]",
113
+ handler: async (args, ctx) => {
114
+ const [subcommandRaw, ...rest] = args.trim().split(/\s+/).filter(Boolean);
115
+ const subcommand = subcommandRaw ?? ""; // bare /profile → selector
116
+ // Stale-tolerant: after a successful reload this command context is
117
+ // invalidated and property access throws. Post-reload feedback is
118
+ // the new instance's job (session_start summary), so swallowed
119
+ // stale-ctx failures lose nothing the user would otherwise see.
120
+ const notify = (message: string, level: "info" | "warning" | "error") => {
121
+ try {
122
+ ctx.ui?.notify(message, level);
123
+ } catch {
124
+ // stale context after reload — see above
125
+ }
126
+ };
127
+ const usage = `usage: /profile [use <name> | reload | ${CUSTOMIZE_USAGE} | reset | list | status | create | edit <name> | delete <name> | duplicate]`;
128
+ if (subcommand === "use" && rest.length === 0) {
129
+ notify("usage: /profile use <name>", "error");
130
+ return;
131
+ }
132
+ if (
133
+ subcommand !== "" &&
134
+ ![
135
+ "use",
136
+ "reload",
137
+ "customize",
138
+ "reset",
139
+ "list",
140
+ "status",
141
+ "create",
142
+ "edit",
143
+ "delete",
144
+ "duplicate",
145
+ ].includes(
146
+ subcommand,
147
+ )
148
+ ) {
149
+ notify(usage, "error");
150
+ return;
151
+ }
152
+ if (["edit", "delete"].includes(subcommand) && rest[0] === undefined) {
153
+ notify(`usage: /profile ${subcommand} <name>`, "error");
154
+ return;
155
+ }
156
+ try {
157
+ const plan = await readLaunchPlanFile(runtimeDir);
158
+ if (plan?.agentDir === undefined) {
159
+ // Without the real agent dir the switch cannot reach catalogs,
160
+ // trust state, or state files — fail loudly, never guess one.
161
+ notify("cannot switch: the launch plan carries no real agent dir", "error");
162
+ return;
163
+ }
164
+ const deps: SwitchDeps = {
165
+ runtimeDir,
166
+ realAgentDir: plan.agentDir,
167
+ cwd: ctx.cwd,
168
+ waitForIdle: () => ctx.waitForIdle(),
169
+ reload: () => ctx.reload(),
170
+ // A real reload invalidates this context (Pi re-executes
171
+ // extensions); property access then throws. Interactive Pi
172
+ // swallows reload refusals, so this probe is the switch's
173
+ // proof that the reload actually ran.
174
+ assertStale: () => {
175
+ void ctx.cwd;
176
+ },
177
+ };
178
+ if (subcommand === "use") {
179
+ const result = await switchProfile(rest[0], deps, { clearOverlay: true });
180
+ for (const warning of result.warnings) notify(warning, "warning");
181
+ setProfileStatus(ctx.ui, result.profile);
182
+ notify(`profile active: ${result.profile}`, "info");
183
+ return;
184
+ }
185
+ if (subcommand === "reload") {
186
+ const result = await switchProfile(undefined, deps, { reloadCurrent: true });
187
+ for (const warning of result.warnings) notify(warning, "warning");
188
+ setProfileStatus(ctx.ui, result.profile);
189
+ notify(`profile reloaded: ${result.profile}`, "info");
190
+ return;
191
+ }
192
+ if (subcommand === "customize") {
193
+ const result = await customizeOverlay(deps, parseCustomizeArgs(rest.join(" ")));
194
+ for (const warning of result.warnings) notify(warning, "warning");
195
+ notify(`overlay updated: ${result.profile}`, "info");
196
+ return;
197
+ }
198
+ if (subcommand === "reset") {
199
+ const result = await resetOverlay(deps);
200
+ for (const warning of result.warnings) notify(warning, "warning");
201
+ notify(`overlay cleared: ${result.profile}`, "info");
202
+ return;
203
+ }
204
+ // Profile catalog CRUD (ticket 09): TUI-only wizards; mutations
205
+ // land in the chosen scope file. Editing the active profile
206
+ // reloads immediately; deleting the active profile requires a
207
+ // replacement chosen up front, then switches to it.
208
+ if (["create", "edit", "delete", "duplicate"].includes(subcommand)) {
209
+ if (ctx.mode !== "tui") {
210
+ notify(`/profile ${subcommand} requires TUI mode (current mode: ${ctx.mode})`, "error");
211
+ return;
212
+ }
213
+ const scopeInput = { realAgentDir: plan.agentDir, cwd: ctx.cwd };
214
+ if (subcommand === "create") {
215
+ const { projectTrusted: canWriteProject } = await readTrustInputs({
216
+ agentDir: plan.agentDir,
217
+ cwd: ctx.cwd,
218
+ });
219
+ const wizard = await runProfileCreateWizard(ctx.ui, { projectTrusted: canWriteProject });
220
+ if (wizard === undefined) return;
221
+ await createProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
222
+ notify(`created profile "${wizard.name}" (${wizard.scope}) — activate with /profile use ${wizard.name}`, "info");
223
+ return;
224
+ }
225
+ if (subcommand === "duplicate") {
226
+ const entries = await listProfiles(scopeInput);
227
+ // Read each scope once (trust-gated — never reads an
228
+ // untrusted project's catalog).
229
+ const byScope = {
230
+ global: await readCatalogScope(scopeInput, "global"),
231
+ project: await readCatalogScope(scopeInput, "project"),
232
+ };
233
+ const candidates: Array<{ name: string; source: "global" | "project"; definition: ProfileDefinition }> = [];
234
+ for (const entry of entries) {
235
+ if (entry.source === "builtin") continue;
236
+ const definition = byScope[entry.source].get(entry.name);
237
+ if (definition !== undefined) {
238
+ candidates.push({ name: entry.name, source: entry.source, definition });
239
+ }
240
+ }
241
+ const wizard = await runProfileDuplicateWizard(ctx.ui, { candidates });
242
+ if (wizard === undefined) return;
243
+ await duplicateProfile(scopeInput, wizard.scope, wizard.sourceName, wizard.newName);
244
+ notify(
245
+ `duplicated "${wizard.sourceName}" → "${wizard.newName}" (${wizard.scope}) — the full definition was copied`,
246
+ "info",
247
+ );
248
+ return;
249
+ }
250
+ const name = rest[0] as string;
251
+ if (subcommand === "edit") {
252
+ // Edit the WINNING definition in its source scope.
253
+ const entries = await listProfiles(scopeInput);
254
+ const existing = entries.find((entry) => entry.name === name);
255
+ if (existing === undefined || existing.source === "builtin") {
256
+ notify(`profile "${name}" not found in a writable catalog`, "error");
257
+ return;
258
+ }
259
+ const definition = (await readCatalogScope(scopeInput, existing.source)).get(name);
260
+ if (definition === undefined) {
261
+ notify(`profile "${name}" not found in the ${existing.source} catalog`, "error");
262
+ return;
263
+ }
264
+ const wizard = await runProfileEditWizard(ctx.ui, {
265
+ existing: { name, source: existing.source, definition },
266
+ });
267
+ if (wizard === undefined) return;
268
+ await editProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
269
+ if (name === plan.profile) {
270
+ notify(`saved profile "${name}"; reloading`, "info");
271
+ await switchProfile(plan.profile, deps, { reloadCurrent: true });
272
+ } else {
273
+ notify(`saved profile "${name}" (inactive — runtime untouched)`, "info");
274
+ }
275
+ return;
276
+ }
277
+ // delete
278
+ const entries = await listProfiles(scopeInput);
279
+ const existing = entries.find((entry) => entry.name === name);
280
+ if (existing === undefined || existing.source === "builtin") {
281
+ notify(`profile "${name}" not found in a writable catalog`, "error");
282
+ return;
283
+ }
284
+ // Both scopes hold the name: choose which record to delete.
285
+ let scope = existing.source as "global" | "project";
286
+ const projectCatalog = await readCatalogScope(scopeInput, "project");
287
+ const globalCatalog = await readCatalogScope(scopeInput, "global");
288
+ if (projectCatalog.has(name) && globalCatalog.has(name)) {
289
+ const chosen = await ctx.ui.select(`delete "${name}" from which catalog?`, ["global", "project"]);
290
+ if (chosen === undefined) return;
291
+ scope = chosen as "global" | "project";
292
+ }
293
+ // If the OTHER scope keeps the name alive, deletion reveals
294
+ // it and the session can stay on the revealed same-name
295
+ // definition (symmetric: project→global and global→project).
296
+ const survivesElsewhere =
297
+ (scope === "project" && globalCatalog.has(name)) || (scope === "global" && projectCatalog.has(name));
298
+ let replacement: string | undefined;
299
+ if (name === plan.profile && !survivesElsewhere) {
300
+ const survivors = entries.filter((entry) => entry.name !== name);
301
+ const chosen = await ctx.ui.select(
302
+ `"${name}" is active — switch to which profile?`,
303
+ survivors.map((entry) => `${entry.name} [${entry.source}]`),
304
+ );
305
+ if (chosen === undefined) return;
306
+ replacement = chosen.split(" [")[0];
307
+ }
308
+ await deleteProfile(scopeInput, scope, name, { activeProfile: plan.profile, replacement });
309
+ if (name === plan.profile) {
310
+ if (replacement !== undefined) {
311
+ notify(`deleted "${name}" (${scope}); switching to ${replacement}`, "info");
312
+ await switchProfile(replacement, deps, { clearOverlay: true });
313
+ } else {
314
+ notify(`deleted the ${scope} record of "${name}"; reloading the revealed definition`, "info");
315
+ await switchProfile(plan.profile, deps, { reloadCurrent: true });
316
+ }
317
+ } else {
318
+ notify(`deleted profile "${name}" (${scope})`, "info");
319
+ }
320
+ return;
321
+ }
322
+ // Observability surface (ticket 07): bare /profile opens the
323
+ // selector; list/status render via a displayed custom message.
324
+ const entries = await listProfiles({ realAgentDir: plan.agentDir, cwd: ctx.cwd });
325
+ if (subcommand === "list") {
326
+ pi.sendMessage({
327
+ customType: "pi-profile",
328
+ content: formatProfileList(entries, plan.profile),
329
+ display: true,
330
+ details: { kind: "list", profiles: entries },
331
+ });
332
+ return;
333
+ }
334
+ if (subcommand === "status") {
335
+ const { projectTrusted } = await readTrustInputs({ agentDir: plan.agentDir, cwd: ctx.cwd });
336
+ const stateDir = plan.source === "project" ? path.join(ctx.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
337
+ const state = await new RuntimeStateStore(stateDir).read();
338
+ const report = buildStatusReport({
339
+ plan,
340
+ overlay: state.overlay,
341
+ discoveredMcpServers: await discoverAdapterServerNames(
342
+ plan.agentDir,
343
+ projectTrusted ? ctx.cwd : undefined,
344
+ ),
345
+ commands: pi.getCommands(),
346
+ tools: pi.getAllTools(),
347
+ });
348
+ pi.sendMessage({
349
+ customType: "pi-profile",
350
+ content: formatStatusMarkdown(report),
351
+ display: true,
352
+ // Structured form for RPC consumers (ticket 11): the message
353
+ // event carries the full report object in `details`.
354
+ details: { kind: "status", report },
355
+ });
356
+ return;
357
+ }
358
+ // Bare /profile: the interactive selector. Without dialog-capable
359
+ // UI (print mode), fall back to the list.
360
+ if (!ctx.hasUI) {
361
+ pi.sendMessage({
362
+ customType: "pi-profile",
363
+ content: formatProfileList(entries, plan.profile),
364
+ display: true,
365
+ });
366
+ return;
367
+ }
368
+ const choice = await ctx.ui.select(
369
+ "select a profile",
370
+ entries.map((entry) => {
371
+ const label = entry.label ?? entry.description;
372
+ return `${entry.name} [${entry.source}]${label !== undefined ? ` — ${label}` : ""}`;
373
+ }),
374
+ );
375
+ if (choice === undefined) return; // cancelled
376
+ const chosen = choice.split(" [")[0] ?? choice;
377
+ if (chosen === plan.profile) return;
378
+ const switched = await switchProfile(chosen, deps, { clearOverlay: true });
379
+ for (const warning of switched.warnings) notify(warning, "warning");
380
+ setProfileStatus(ctx.ui, switched.profile);
381
+ } catch (error) {
382
+ notify(error instanceof Error ? error.message : String(error), "error");
383
+ }
384
+ },
385
+ });
386
+
387
+ // Persistent profile-scoped MCP toggles (ticket 10): edit the active
388
+ // profile's mcp array in its owning catalog, then reload so the runtime
389
+ // and the republished allowlist match. The adapter's own configuration
390
+ // (mcp.json files) is never written.
391
+ pi.registerCommand("mcp", {
392
+ description: "pi-profile: /mcp enable <server> | /mcp disable <server>",
393
+ handler: async (args, ctx) => {
394
+ const notify = (message: string, level: "info" | "warning" | "error") => {
395
+ try {
396
+ ctx.ui?.notify(message, level);
397
+ } catch {
398
+ // stale context after reload — see /profile
399
+ }
400
+ };
401
+ const [action, server] = args.trim().split(/\s+/).filter(Boolean);
402
+ if (!["enable", "disable"].includes(action ?? "") || server === undefined) {
403
+ notify("usage: /mcp enable <server> | /mcp disable <server>", "error");
404
+ return;
405
+ }
406
+ try {
407
+ const plan = await readLaunchPlanFile(runtimeDir);
408
+ if (plan?.agentDir === undefined) {
409
+ notify("pi-profile: launch plan is missing agentDir — cannot toggle MCP servers", "error");
410
+ return;
411
+ }
412
+ if (!probeAdapterPresence(pi.events)) {
413
+ throw new Error(
414
+ "pi-mcp-adapter is not active in this session — /mcp enable|disable requires it " +
415
+ "(select the adapter in the profile's extensions).",
416
+ );
417
+ }
418
+ const result = await setMcpServerEnabled(
419
+ { realAgentDir: plan.agentDir, cwd: ctx.cwd, profile: { name: plan.profile, source: plan.source } },
420
+ server,
421
+ action === "enable",
422
+ );
423
+ if (!result.changed) {
424
+ notify(`MCP server "${server}" is already ${action}d in profile "${plan.profile}"`, "info");
425
+ return;
426
+ }
427
+ // Notify BEFORE the reload: this context is stale afterwards.
428
+ notify(
429
+ `${action}d MCP server "${server}" in profile "${plan.profile}" (mcps: [${result.mcps.join(", ")}]); reloading`,
430
+ "info",
431
+ );
432
+ const switched = await switchProfile(plan.profile, {
433
+ runtimeDir,
434
+ realAgentDir: plan.agentDir,
435
+ cwd: ctx.cwd,
436
+ waitForIdle: () => ctx.waitForIdle(),
437
+ reload: () => ctx.reload(),
438
+ // Same staleness probe as /profile: interactive Pi
439
+ // swallows reload refusals; a live context afterwards
440
+ // means the reload never ran.
441
+ assertStale: () => {
442
+ void ctx.cwd;
443
+ },
444
+ }, { reloadCurrent: true });
445
+ for (const warning of switched.warnings) notify(warning, "warning");
446
+ } catch (error) {
447
+ notify(error instanceof Error ? error.message : String(error), "error");
448
+ }
449
+ },
450
+ });
451
+ }
package/package.json CHANGED
@@ -1,29 +1,22 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.3.1",
4
- "description": "Named profiles for Pi: skills, MCP servers, tools, model, and instructions per workflow switched in place in the same session.",
3
+ "version": "0.4.2",
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": [
7
7
  "pi-package",
8
- "pi",
9
- "pi-profile-switch",
8
+ "pi agent",
10
9
  "pi-profile-switch",
11
10
  "profile",
12
- "profiles",
13
- "profile-switching",
14
- "skills",
15
- "mcp",
16
- "tools"
11
+ "pi"
17
12
  ],
18
13
  "license": "MIT",
19
- "author": "VincentFF",
20
14
  "repository": {
21
15
  "type": "git",
22
16
  "url": "git+https://github.com/VincentFF/pi-profile-switch.git"
23
17
  },
24
- "homepage": "https://github.com/VincentFF/pi-profile-switch#readme",
25
- "bugs": {
26
- "url": "https://github.com/VincentFF/pi-profile-switch/issues"
18
+ "bin": {
19
+ "pi-profile": "bin/pi-profile.js"
27
20
  },
28
21
  "pi": {
29
22
  "extensions": [
@@ -31,6 +24,7 @@
31
24
  ]
32
25
  },
33
26
  "scripts": {
27
+ "postinstall": "node bin/postinstall.js",
34
28
  "check": "tsc --noEmit",
35
29
  "test": "vitest run",
36
30
  "test:watch": "vitest"
@@ -46,9 +40,12 @@
46
40
  "vitest": "^3.0.0"
47
41
  },
48
42
  "dependencies": {
43
+ "jiti": "^2.7.0",
49
44
  "minimatch": "^10.2.6"
50
45
  },
51
46
  "files": [
47
+ "bin",
48
+ "defaults",
52
49
  "extensions",
53
50
  "src",
54
51
  "schemas",
@@ -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 references are names/globs, never copies. Extensions are not a profile resource (ADR-0007): every installed extension loads natively. 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",
@@ -10,7 +10,6 @@
10
10
  "additionalProperties": false,
11
11
  "properties": {
12
12
  "schemaVersion": {
13
- "description": "Catalog format version. Only 1 is accepted.",
14
13
  "const": 1
15
14
  },
16
15
  "profiles": {
@@ -42,41 +41,40 @@
42
41
  "items": {
43
42
  "type": "string"
44
43
  },
45
- "description": "Skill names or globs. Controls what the model sees in the system prompt's skills section; every loaded skill stays callable by the user through /skill:name."
44
+ "description": "Skill names or globs (e.g. \"review\", \"internal-*\") referencing discovered skills."
45
+ },
46
+ "extensions": {
47
+ "type": "array",
48
+ "items": {
49
+ "type": "string"
50
+ },
51
+ "description": "Extension names or globs. Package names, source aliases, loose-file stems, glob patterns, or direct file paths, resolved against discovered extensions."
46
52
  },
47
53
  "mcps": {
48
54
  "type": "array",
49
55
  "items": {
50
56
  "type": "string"
51
57
  },
52
- "description": "MCP server names or globs, discovered by pi-mcp-adapter configuration. Connection details stay in adapter-managed config. The legacy key \"mcp\" is still accepted on read and migrates to \"mcps\" on the next save."
58
+ "description": "MCP server names or globs, discovered by pi-mcp-adapter configuration. Connection details stay in adapter-managed config."
53
59
  },
54
60
  "tools": {
55
61
  "type": "array",
56
62
  "items": {
57
63
  "type": "string"
58
64
  },
59
- "description": "Tool names or globs expanded against Pi's live registry (including extension and MCP tools). Names that are not registered yet are retried each turn."
65
+ "description": "Tool names or globs expanded against Pi's live registry (including extension tools)."
60
66
  },
61
- "model": {
62
- "type": "object",
63
- "additionalProperties": false,
64
- "required": [
65
- "provider",
66
- "id"
67
- ],
68
- "properties": {
69
- "provider": {
70
- "type": "string"
71
- },
72
- "id": {
73
- "type": "string"
74
- },
75
- "thinkingLevel": {
76
- "type": "string"
77
- }
78
- },
79
- "description": "Session-start model preset. An explicit --model/--thinking flag or a model recorded in the session history wins; /profile use applies the preset."
67
+ "defaultProvider": {
68
+ "type": "string",
69
+ "description": "Startup provider for this profile (e.g. \"anthropic\", \"openai\"). Mirrors Pi's settings.defaultProvider."
70
+ },
71
+ "defaultModel": {
72
+ "type": "string",
73
+ "description": "Startup model ID for this profile. Mirrors Pi's settings.defaultModel."
74
+ },
75
+ "defaultThinkingLevel": {
76
+ "type": "string",
77
+ "description": "Startup thinking level for this profile. Mirrors Pi's settings.defaultThinkingLevel."
80
78
  },
81
79
  "instructions": {
82
80
  "type": "string",