pi-profile-switch 0.9.2 → 0.11.0
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 +6 -14
- package/README.zh-CN.md +6 -14
- package/extensions/pi-profile/index.ts +96 -189
- package/package.json +1 -1
- package/src/launcher/initial-profile.ts +4 -2
- package/src/profile-catalog.ts +2 -2
- package/src/profile-resolver.ts +114 -27
- package/src/runtime-state-store.ts +3 -4
- package/src/settings-generator.ts +1 -0
- package/src/startup-notifier.ts +636 -0
- package/src/switching/apply-plan.ts +36 -8
- package/src/switching/list-profiles.ts +2 -2
- package/src/switching/{customize.ts → overlay.ts} +67 -35
- package/src/switching/status.ts +1 -1
- package/src/switching/switch-profile.ts +7 -3
- package/src/profile-catalog-store.ts +0 -86
- package/src/switching/profile-crud.ts +0 -136
- package/src/switching/profile-wizard.ts +0 -163
package/README.md
CHANGED
|
@@ -97,27 +97,19 @@ How fields resolve:
|
|
|
97
97
|
|
|
98
98
|
The files in [`examples/`](examples/) mirror the two profiles above: `ask.json` is the seeded starter, `example.json` the full-field demo.
|
|
99
99
|
|
|
100
|
-
### Migrating from earlier versions
|
|
101
|
-
|
|
102
|
-
If you used an earlier version that stored all profiles in a single `profiles.json` (`schemaVersion: 1`), migrate manually by creating a file for each profile under the `profiles/` directory:
|
|
103
|
-
|
|
104
|
-
1. Create directory `~/.pi-profile-switch/profiles/` (or `<project>/.pi/profiles/`).
|
|
105
|
-
2. For each key `<name>` in your old `profiles.json`'s `profiles` object, save its value directly as `<name>.json`.
|
|
106
|
-
3. Drop the outer `schemaVersion` and `profiles` envelope.
|
|
107
|
-
|
|
108
100
|
## Commands
|
|
109
101
|
|
|
110
|
-
|
|
102
|
+
The `/profile` command family manages everything in-session:
|
|
111
103
|
|
|
112
104
|
| Command | What it does |
|
|
113
105
|
| --- | --- |
|
|
114
|
-
| `/profile` | Interactive profile picker |
|
|
115
|
-
| `/profile list` / `/profile status` | Show profiles / active profile details |
|
|
106
|
+
| `/profile` | Interactive profile picker; without interactive UI it prints the profile list instead |
|
|
116
107
|
| `/profile use <name>` / `/profile reload` | Switch / reload without restarting (rollback on failure) |
|
|
117
|
-
| `/profile
|
|
118
|
-
| `/profile
|
|
108
|
+
| `/profile status` | Active profile details: resolved resources and paths, stored overlay, MCP server tri-state |
|
|
109
|
+
| `/profile overlay disable\|enable skill\|extension\|mcp\|tool <name-or-glob>` | Narrow / un-narrow the active profile for this session only; `disable` entries accept names or globs |
|
|
110
|
+
| `/profile overlay clear` | Discard the overlay and reactivate the profile exactly as declared |
|
|
119
111
|
|
|
120
|
-
All
|
|
112
|
+
All forms work in every mode, including non-interactive ones (`--mode rpc|print|json`); the bare selector degrades to the profile list where no interactive UI exists. The overlay is a runtime-only narrowing: it is never written to a catalog file and never survives a restart. Tools follow the same disable/enable model as the other resource kinds: a tool `disable` entry narrows the profile's resolved tool references — or the runtime's full available tool set when the profile declares no `tools`.
|
|
121
113
|
|
|
122
114
|
## Docs
|
|
123
115
|
|
package/README.zh-CN.md
CHANGED
|
@@ -97,27 +97,19 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
|
|
|
97
97
|
|
|
98
98
|
[`examples/`](examples/) 中的两个文件与上面一一对应:`ask.json` 是播种的初始 profile,`example.json` 是全字段演示。
|
|
99
99
|
|
|
100
|
-
### 从旧版本迁移
|
|
101
|
-
|
|
102
|
-
如果你之前使用了把全部 profile 存在单个 `profiles.json`(含 `schemaVersion: 1`)的旧版本,请手动把每个 profile 拆分到 `profiles/` 目录:
|
|
103
|
-
|
|
104
|
-
1. 创建 `~/.pi-profile-switch/profiles/`(或 `<项目>/.pi/profiles/`)目录。
|
|
105
|
-
2. 将旧 `profiles.json` 中 `profiles` 下的每个 `<name>` 键值提取为独立的 `<name>.json` 文件。
|
|
106
|
-
3. 去除外层的 `schemaVersion` 与 `profiles` 信封,文件顶层即为裸 profile 定义。
|
|
107
|
-
|
|
108
100
|
## 命令
|
|
109
101
|
|
|
110
|
-
|
|
102
|
+
`/profile` 命令族完成所有会话内操作:
|
|
111
103
|
|
|
112
104
|
| 命令 | 作用 |
|
|
113
105
|
| --- | --- |
|
|
114
|
-
| `/profile` | 交互式选择 profile |
|
|
115
|
-
| `/profile list` / `/profile status` | 列出 profile / 查看活动 profile 详情 |
|
|
106
|
+
| `/profile` | 交互式选择 profile;无交互界面时打印 profile 列表 |
|
|
116
107
|
| `/profile use <name>` / `/profile reload` | 会话内切换 / 重载(失败自动回滚) |
|
|
117
|
-
| `/profile
|
|
118
|
-
| `/profile
|
|
108
|
+
| `/profile status` | 查看活动 profile 详情:解析后的资源与路径、已存 overlay、MCP server 三态 |
|
|
109
|
+
| `/profile overlay disable\|enable skill\|extension\|mcp\|tool <name-or-glob>` | 仅本次会话收窄 / 恢复活动 profile;`disable` 接受名称或 glob |
|
|
110
|
+
| `/profile overlay clear` | 丢弃 overlay,完全按定义重新激活 profile |
|
|
119
111
|
|
|
120
|
-
|
|
112
|
+
所有命令在非交互模式(`--mode rpc|print|json`)下同样生效;裸 `/profile` 在无交互界面时降级为 profile 列表。overlay 是会话级收窄:绝不写入 catalog 文件,也不会跨重启保留。tool 与其他资源类别使用相同的 disable/enable 模型:tool `disable` 条目收窄 profile 解析出的工具引用;profile 未声明 `tools` 时,则收窄运行时的全部可用工具集。
|
|
121
113
|
|
|
122
114
|
## 文档
|
|
123
115
|
|
|
@@ -1,29 +1,20 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
4
6
|
|
|
7
|
+
import { isRecord } from "../../src/json-file.ts";
|
|
5
8
|
import { readTrustInputs } from "../../src/launcher/initial-profile.ts";
|
|
6
9
|
import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
|
|
7
10
|
import { RuntimeStateStore } from "../../src/runtime-state-store.ts";
|
|
11
|
+
import { runStartupNotifications, type NoticeSurface } from "../../src/startup-notifier.ts";
|
|
8
12
|
import { applyLaunchPlan, readLaunchPlanFile } from "../../src/switching/apply-plan.ts";
|
|
9
|
-
import {
|
|
13
|
+
import { OVERLAY_USAGE, applyOverlayMutation, clearOverlay, parseOverlayArgs } from "../../src/switching/overlay.ts";
|
|
10
14
|
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
15
|
import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/status.ts";
|
|
25
16
|
import { switchProfile, type SwitchDeps } from "../../src/switching/switch-profile.ts";
|
|
26
|
-
import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
17
|
+
import { getGlobalStateDir, getProfileSwitchDir } from "../../src/workspace.ts";
|
|
27
18
|
|
|
28
19
|
/**
|
|
29
20
|
* pi-profile extension entry.
|
|
@@ -37,23 +28,73 @@ import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
|
37
28
|
* via `before_agent_start`.
|
|
38
29
|
* - `/profile use <name>` / `/profile reload`: in-session switching without
|
|
39
30
|
* restarting the Pi process (src/switching/switch-profile.ts).
|
|
40
|
-
* - `/profile
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* is stale afterwards.
|
|
50
|
-
* - list/status ship structured `details` payloads (`{kind, profiles}` /
|
|
51
|
-
* `{kind, report}`) for RPC consumers (ticket 11).
|
|
31
|
+
* - `/profile overlay ...`: runtime overlay (ticket 06; renamed with the
|
|
32
|
+
* command word — disable/enable/tools/clear).
|
|
33
|
+
* - `/profile` (selector; degrades to the list without dialog-capable UI)
|
|
34
|
+
* and `/profile status`: observability surface (ticket 07). Status
|
|
35
|
+
* combines the active launch plan, the stored overlay, fresh MCP
|
|
36
|
+
* discovery, and Pi's actual command registrations (the winner evidence
|
|
37
|
+
* for same-name conflicts).
|
|
38
|
+
* - The degraded list and status ship structured `details` payloads
|
|
39
|
+
* (`{kind, profiles}` / `{kind, report}`) for RPC consumers (ticket 11).
|
|
52
40
|
*
|
|
53
41
|
* Pi re-executes this module on reload, so post-reload state is established
|
|
54
42
|
* exclusively through `session_start` — nothing stale survives.
|
|
55
43
|
*/
|
|
56
44
|
|
|
45
|
+
/** The bundled package's own metadata: the notifier compares the RUNNING
|
|
46
|
+
* package version (never Pi's version or a repository checkout). */
|
|
47
|
+
const OWN_PACKAGE_JSON = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
48
|
+
|
|
49
|
+
async function readOwnVersion(): Promise<string | undefined> {
|
|
50
|
+
try {
|
|
51
|
+
const raw: unknown = JSON.parse(await readFile(OWN_PACKAGE_JSON, "utf8"));
|
|
52
|
+
if (isRecord(raw) && typeof raw.version === "string") return raw.version;
|
|
53
|
+
} catch {
|
|
54
|
+
// Unresolvable package metadata: startup notices are skipped quietly.
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Notice presentation surface: the TUI notification channel when available,
|
|
60
|
+
* stderr in every other mode (never stdout, never the agent's prompts). */
|
|
61
|
+
function createNoticeSurface(ctx: unknown): NoticeSurface {
|
|
62
|
+
const context = ctx as {
|
|
63
|
+
hasUI?: boolean;
|
|
64
|
+
mode?: string;
|
|
65
|
+
ui?: { notify?: (message: string, level: "info" | "warning" | "error") => void };
|
|
66
|
+
};
|
|
67
|
+
if (context.hasUI === true && context.mode === "tui" && typeof context.ui?.notify === "function") {
|
|
68
|
+
return { display: (message, level) => context.ui!.notify!(message, level) };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
display: (message) => {
|
|
72
|
+
process.stderr.write(`${message}\n`);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Startup notices run once per Pi process on the initial session_start,
|
|
78
|
+
* without awaiting remote IO, in an isolated failure domain: a notifier
|
|
79
|
+
* problem can never block profile activation, change Pi's exit code, or
|
|
80
|
+
* reach the activation/switch error path. Pi re-executes this module on
|
|
81
|
+
* reload, and reload/new/resume/fork never pass reason "startup", so no
|
|
82
|
+
* second check is possible within one process. */
|
|
83
|
+
async function startStartupNotices(surface: NoticeSurface): Promise<void> {
|
|
84
|
+
try {
|
|
85
|
+
const version = await readOwnVersion();
|
|
86
|
+
if (version === undefined) return;
|
|
87
|
+
await runStartupNotifications({
|
|
88
|
+
installedVersion: version,
|
|
89
|
+
workspaceDir: getProfileSwitchDir(),
|
|
90
|
+
offline: process.env.PI_OFFLINE === "1",
|
|
91
|
+
surface,
|
|
92
|
+
});
|
|
93
|
+
} catch {
|
|
94
|
+
// Best-effort by contract: swallow everything the notifier missed.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
57
98
|
function setProfileStatus(ui: unknown, profile: string | undefined): void {
|
|
58
99
|
if (profile && typeof (ui as { setStatus?: (k: string, v: string) => void })?.setStatus === "function") {
|
|
59
100
|
(ui as { setStatus: (k: string, v: string) => void }).setStatus("profile", `profile: ${profile}`);
|
|
@@ -67,6 +108,10 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
67
108
|
let pendingSummary: string | undefined;
|
|
68
109
|
|
|
69
110
|
pi.on("session_start", async (event, ctx) => {
|
|
111
|
+
// Capture the notice surface synchronously: pi may replace the session
|
|
112
|
+
// right after this handler (one-shot modes), after which a captured
|
|
113
|
+
// ctx throws on access. The surface freezes the channel now.
|
|
114
|
+
const noticeSurface = createNoticeSurface(ctx);
|
|
70
115
|
const plan = await readLaunchPlanFile(runtimeDir);
|
|
71
116
|
setProfileStatus(ctx.ui, plan?.profile);
|
|
72
117
|
const result = await applyLaunchPlan({
|
|
@@ -80,6 +125,9 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
80
125
|
},
|
|
81
126
|
});
|
|
82
127
|
pendingSummary = result.summary;
|
|
128
|
+
if (event.reason === "startup") {
|
|
129
|
+
void startStartupNotices(noticeSurface);
|
|
130
|
+
}
|
|
83
131
|
});
|
|
84
132
|
|
|
85
133
|
pi.on("before_agent_start", async (event) => {
|
|
@@ -92,7 +140,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
92
140
|
});
|
|
93
141
|
|
|
94
142
|
pi.registerCommand("profile", {
|
|
95
|
-
description: "pi-profile: /profile [use|reload|
|
|
143
|
+
description: "pi-profile: /profile [use|reload|status|overlay]",
|
|
96
144
|
handler: async (args, ctx) => {
|
|
97
145
|
const [subcommandRaw, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
98
146
|
const subcommand = subcommandRaw ?? ""; // bare /profile → selector
|
|
@@ -107,35 +155,15 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
107
155
|
// stale context after reload — see above
|
|
108
156
|
}
|
|
109
157
|
};
|
|
110
|
-
const usage = `usage: /profile [use <name> | reload |
|
|
158
|
+
const usage = `usage: /profile [use <name> | reload | status | ${OVERLAY_USAGE}]`;
|
|
111
159
|
if (subcommand === "use" && rest.length === 0) {
|
|
112
160
|
notify("usage: /profile use <name>", "error");
|
|
113
161
|
return;
|
|
114
162
|
}
|
|
115
|
-
if (
|
|
116
|
-
subcommand !== "" &&
|
|
117
|
-
![
|
|
118
|
-
"use",
|
|
119
|
-
"reload",
|
|
120
|
-
"customize",
|
|
121
|
-
"reset",
|
|
122
|
-
"list",
|
|
123
|
-
"status",
|
|
124
|
-
"create",
|
|
125
|
-
"edit",
|
|
126
|
-
"delete",
|
|
127
|
-
"duplicate",
|
|
128
|
-
].includes(
|
|
129
|
-
subcommand,
|
|
130
|
-
)
|
|
131
|
-
) {
|
|
163
|
+
if (subcommand !== "" && !["use", "reload", "status", "overlay"].includes(subcommand)) {
|
|
132
164
|
notify(usage, "error");
|
|
133
165
|
return;
|
|
134
166
|
}
|
|
135
|
-
if (["edit", "delete"].includes(subcommand) && rest[0] === undefined) {
|
|
136
|
-
notify(`usage: /profile ${subcommand} <name>`, "error");
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
167
|
try {
|
|
140
168
|
const plan = await readLaunchPlanFile(runtimeDir);
|
|
141
169
|
if (plan?.agentDir === undefined) {
|
|
@@ -148,6 +176,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
148
176
|
runtimeDir,
|
|
149
177
|
realAgentDir: plan.agentDir,
|
|
150
178
|
cwd: ctx.cwd,
|
|
179
|
+
getAllTools: () => pi.getAllTools(),
|
|
151
180
|
waitForIdle: () => ctx.waitForIdle(),
|
|
152
181
|
reload: () => ctx.reload(),
|
|
153
182
|
// A real reload invalidates this context (Pi re-executes
|
|
@@ -172,148 +201,22 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
172
201
|
notify(`profile reloaded: ${result.profile}`, "info");
|
|
173
202
|
return;
|
|
174
203
|
}
|
|
175
|
-
if (subcommand === "
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (subcommand === "reset") {
|
|
182
|
-
const result = await resetOverlay(deps);
|
|
183
|
-
for (const warning of result.warnings) notify(warning, "warning");
|
|
184
|
-
notify(`overlay cleared: ${result.profile}`, "info");
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
// Profile catalog CRUD (ticket 09): TUI-only wizards; mutations
|
|
188
|
-
// land in the chosen scope file. Editing the active profile
|
|
189
|
-
// reloads immediately; deleting the active profile requires a
|
|
190
|
-
// replacement chosen up front, then switches to it.
|
|
191
|
-
if (["create", "edit", "delete", "duplicate"].includes(subcommand)) {
|
|
192
|
-
if (ctx.mode !== "tui") {
|
|
193
|
-
notify(`/profile ${subcommand} requires TUI mode (current mode: ${ctx.mode})`, "error");
|
|
204
|
+
if (subcommand === "overlay") {
|
|
205
|
+
const command = parseOverlayArgs(rest.join(" "));
|
|
206
|
+
if (command.kind === "clear") {
|
|
207
|
+
const result = await clearOverlay(deps);
|
|
208
|
+
for (const warning of result.warnings) notify(warning, "warning");
|
|
209
|
+
notify(`overlay cleared: ${result.profile}`, "info");
|
|
194
210
|
return;
|
|
195
211
|
}
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
agentDir: plan.agentDir,
|
|
200
|
-
cwd: ctx.cwd,
|
|
201
|
-
});
|
|
202
|
-
const wizard = await runProfileCreateWizard(ctx.ui, { projectTrusted: canWriteProject });
|
|
203
|
-
if (wizard === undefined) return;
|
|
204
|
-
await createProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
|
|
205
|
-
notify(`created profile "${wizard.name}" (${wizard.scope}) — activate with /profile use ${wizard.name}`, "info");
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
if (subcommand === "duplicate") {
|
|
209
|
-
const entries = await listProfiles(scopeInput);
|
|
210
|
-
// Read each scope once (trust-gated — never reads an
|
|
211
|
-
// untrusted project's catalog).
|
|
212
|
-
const byScope = {
|
|
213
|
-
global: await readCatalogScope(scopeInput, "global"),
|
|
214
|
-
project: await readCatalogScope(scopeInput, "project"),
|
|
215
|
-
};
|
|
216
|
-
const candidates: Array<{ name: string; source: "global" | "project"; definition: ProfileDefinition }> = [];
|
|
217
|
-
for (const entry of entries) {
|
|
218
|
-
if (entry.source === "builtin") continue;
|
|
219
|
-
const definition = byScope[entry.source].get(entry.name);
|
|
220
|
-
if (definition !== undefined) {
|
|
221
|
-
candidates.push({ name: entry.name, source: entry.source, definition });
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
const wizard = await runProfileDuplicateWizard(ctx.ui, { candidates });
|
|
225
|
-
if (wizard === undefined) return;
|
|
226
|
-
await duplicateProfile(scopeInput, wizard.scope, wizard.sourceName, wizard.newName);
|
|
227
|
-
notify(
|
|
228
|
-
`duplicated "${wizard.sourceName}" → "${wizard.newName}" (${wizard.scope}) — the full definition was copied`,
|
|
229
|
-
"info",
|
|
230
|
-
);
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
const name = rest[0] as string;
|
|
234
|
-
if (subcommand === "edit") {
|
|
235
|
-
// Edit the WINNING definition in its source scope.
|
|
236
|
-
const entries = await listProfiles(scopeInput);
|
|
237
|
-
const existing = entries.find((entry) => entry.name === name);
|
|
238
|
-
if (existing === undefined || existing.source === "builtin") {
|
|
239
|
-
notify(`profile "${name}" not found in a writable catalog`, "error");
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
const definition = (await readCatalogScope(scopeInput, existing.source)).get(name);
|
|
243
|
-
if (definition === undefined) {
|
|
244
|
-
notify(`profile "${name}" not found in the ${existing.source} catalog`, "error");
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
const wizard = await runProfileEditWizard(ctx.ui, {
|
|
248
|
-
existing: { name, source: existing.source, definition },
|
|
249
|
-
});
|
|
250
|
-
if (wizard === undefined) return;
|
|
251
|
-
await editProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
|
|
252
|
-
if (name === plan.profile) {
|
|
253
|
-
notify(`saved profile "${name}"; reloading`, "info");
|
|
254
|
-
await switchProfile(plan.profile, deps, { reloadCurrent: true });
|
|
255
|
-
} else {
|
|
256
|
-
notify(`saved profile "${name}" (inactive — runtime untouched)`, "info");
|
|
257
|
-
}
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
// delete
|
|
261
|
-
const entries = await listProfiles(scopeInput);
|
|
262
|
-
const existing = entries.find((entry) => entry.name === name);
|
|
263
|
-
if (existing === undefined || existing.source === "builtin") {
|
|
264
|
-
notify(`profile "${name}" not found in a writable catalog`, "error");
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
// Both scopes hold the name: choose which record to delete.
|
|
268
|
-
let scope = existing.source as "global" | "project";
|
|
269
|
-
const projectCatalog = await readCatalogScope(scopeInput, "project");
|
|
270
|
-
const globalCatalog = await readCatalogScope(scopeInput, "global");
|
|
271
|
-
if (projectCatalog.has(name) && globalCatalog.has(name)) {
|
|
272
|
-
const chosen = await ctx.ui.select(`delete "${name}" from which catalog?`, ["global", "project"]);
|
|
273
|
-
if (chosen === undefined) return;
|
|
274
|
-
scope = chosen as "global" | "project";
|
|
275
|
-
}
|
|
276
|
-
// If the OTHER scope keeps the name alive, deletion reveals
|
|
277
|
-
// it and the session can stay on the revealed same-name
|
|
278
|
-
// definition (symmetric: project→global and global→project).
|
|
279
|
-
const survivesElsewhere =
|
|
280
|
-
(scope === "project" && globalCatalog.has(name)) || (scope === "global" && projectCatalog.has(name));
|
|
281
|
-
let replacement: string | undefined;
|
|
282
|
-
if (name === plan.profile && !survivesElsewhere) {
|
|
283
|
-
const survivors = entries.filter((entry) => entry.name !== name);
|
|
284
|
-
const chosen = await ctx.ui.select(
|
|
285
|
-
`"${name}" is active — switch to which profile?`,
|
|
286
|
-
survivors.map((entry) => `${entry.name} [${entry.source}]`),
|
|
287
|
-
);
|
|
288
|
-
if (chosen === undefined) return;
|
|
289
|
-
replacement = chosen.split(" [")[0];
|
|
290
|
-
}
|
|
291
|
-
await deleteProfile(scopeInput, scope, name, { activeProfile: plan.profile, replacement });
|
|
292
|
-
if (name === plan.profile) {
|
|
293
|
-
if (replacement !== undefined) {
|
|
294
|
-
notify(`deleted "${name}" (${scope}); switching to ${replacement}`, "info");
|
|
295
|
-
await switchProfile(replacement, deps, { clearOverlay: true });
|
|
296
|
-
} else {
|
|
297
|
-
notify(`deleted the ${scope} record of "${name}"; reloading the revealed definition`, "info");
|
|
298
|
-
await switchProfile(plan.profile, deps, { reloadCurrent: true });
|
|
299
|
-
}
|
|
300
|
-
} else {
|
|
301
|
-
notify(`deleted profile "${name}" (${scope})`, "info");
|
|
302
|
-
}
|
|
212
|
+
const result = await applyOverlayMutation(deps, command.mutate);
|
|
213
|
+
for (const warning of result.warnings) notify(warning, "warning");
|
|
214
|
+
notify(`overlay updated: ${result.profile}`, "info");
|
|
303
215
|
return;
|
|
304
216
|
}
|
|
305
217
|
// Observability surface (ticket 07): bare /profile opens the
|
|
306
|
-
// selector;
|
|
218
|
+
// selector; status renders via a displayed custom message.
|
|
307
219
|
const entries = await listProfiles({ realAgentDir: plan.agentDir, cwd: ctx.cwd });
|
|
308
|
-
if (subcommand === "list") {
|
|
309
|
-
pi.sendMessage({
|
|
310
|
-
customType: "pi-profile",
|
|
311
|
-
content: formatProfileList(entries, plan.profile),
|
|
312
|
-
display: true,
|
|
313
|
-
details: { kind: "list", profiles: entries },
|
|
314
|
-
});
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
317
220
|
if (subcommand === "status") {
|
|
318
221
|
const { projectTrusted } = await readTrustInputs({ agentDir: plan.agentDir, cwd: ctx.cwd });
|
|
319
222
|
const stateDir = plan.source === "project" ? path.join(ctx.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
|
|
@@ -338,13 +241,17 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
338
241
|
});
|
|
339
242
|
return;
|
|
340
243
|
}
|
|
341
|
-
// Bare /profile: the interactive selector.
|
|
342
|
-
//
|
|
343
|
-
|
|
244
|
+
// Bare /profile: the interactive selector. Degradation boundary
|
|
245
|
+
// (delta "Observability surface"): outside TUI mode the selector
|
|
246
|
+
// does not run — the bare invocation degrades to the trust-gated
|
|
247
|
+
// list, carrying the structured payload the removed `list`
|
|
248
|
+
// subcommand emitted.
|
|
249
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
344
250
|
pi.sendMessage({
|
|
345
251
|
customType: "pi-profile",
|
|
346
252
|
content: formatProfileList(entries, plan.profile),
|
|
347
253
|
display: true,
|
|
254
|
+
details: { kind: "list", profiles: entries },
|
|
348
255
|
});
|
|
349
256
|
return;
|
|
350
257
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-profile-switch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting. Install: npm install -g pi-profile-switch (not pi install).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -94,7 +94,7 @@ export async function readTrustInputs(context: LauncherContext): Promise<{
|
|
|
94
94
|
export async function resolveInitialProfile(
|
|
95
95
|
name: string | undefined,
|
|
96
96
|
context: LauncherContext,
|
|
97
|
-
options?: { overlay?: RuntimeOverlay },
|
|
97
|
+
options?: { overlay?: RuntimeOverlay; liveToolNames?: string[] },
|
|
98
98
|
): Promise<InitialProfile> {
|
|
99
99
|
const { projectTrusted } = await readTrustInputs(context);
|
|
100
100
|
const projectDir = projectTrusted ? context.cwd : undefined;
|
|
@@ -133,7 +133,7 @@ export async function resolveInitialProfile(
|
|
|
133
133
|
((overlay.disabledSkills?.length ?? 0) > 0 ||
|
|
134
134
|
(overlay.disabledExtensions?.length ?? 0) > 0 ||
|
|
135
135
|
(overlay.disabledMcps?.length ?? 0) > 0 ||
|
|
136
|
-
overlay.
|
|
136
|
+
(overlay.disabledTools?.length ?? 0) > 0);
|
|
137
137
|
if (!narrowed) {
|
|
138
138
|
return { plan: defaultPlan(), projectTrusted, warnings };
|
|
139
139
|
}
|
|
@@ -153,6 +153,7 @@ export async function resolveInitialProfile(
|
|
|
153
153
|
skills: discovery.skills,
|
|
154
154
|
extensions: discovery.extensions,
|
|
155
155
|
overlay,
|
|
156
|
+
liveToolNames: options?.liveToolNames,
|
|
156
157
|
});
|
|
157
158
|
warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
|
|
158
159
|
return { plan, discovery, projectDir, projectTrusted, warnings };
|
|
@@ -168,6 +169,7 @@ export async function resolveInitialProfile(
|
|
|
168
169
|
? await discoverAdapterServerNames(context.agentDir, projectDir)
|
|
169
170
|
: undefined,
|
|
170
171
|
overlay: options?.overlay,
|
|
172
|
+
liveToolNames: options?.liveToolNames,
|
|
171
173
|
});
|
|
172
174
|
warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
|
|
173
175
|
if (plan.mcps !== undefined && !plan.extensions.some(isAdapterExtension)) {
|
package/src/profile-catalog.ts
CHANGED
|
@@ -89,8 +89,8 @@ function readOptionalString(value: unknown, field: string, profileName: string,
|
|
|
89
89
|
return value;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/** Parses one raw profile definition;
|
|
93
|
-
*
|
|
92
|
+
/** Parses one raw profile definition; the single read-time validator so
|
|
93
|
+
* catalog files and any external writer stay loadable. */
|
|
94
94
|
export function parseProfileDefinition(name: string, raw: unknown, filePath?: string): ProfileDefinition {
|
|
95
95
|
if (!isRecord(raw)) {
|
|
96
96
|
const prefix = filePath ? `${filePath}: ` : "";
|