pi-profile-switch 0.1.0 → 0.3.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 +10 -36
- package/README.zh-CN.md +10 -36
- package/examples/profiles.example.json +74 -0
- package/examples/profiles.json +6 -28
- package/extensions/pi-profile-switch/index.ts +213 -18
- package/package.json +1 -1
- package/schemas/profiles.schema.json +2 -8
- package/src/adapter-presence.ts +75 -0
- package/src/json-file.ts +20 -0
- package/src/mcp-config.ts +153 -34
- package/src/mcp-overlay-file.ts +35 -0
- package/src/mcp-overlay.ts +122 -0
- package/src/profile-badge.ts +142 -0
- package/src/profile-catalog-store.ts +4 -5
- package/src/profile-catalog.ts +17 -43
- package/src/profile-presets.ts +61 -0
- package/src/profile-resolver.ts +51 -18
- package/src/runtime-state-store.ts +13 -0
- package/src/startup-mcp-scope.ts +262 -0
- package/src/startup-selection.ts +1 -4
- package/src/switching/activate-profile.ts +8 -6
- package/src/switching/list-profiles.ts +8 -11
- package/src/switching/profile-wizard.ts +39 -6
package/src/mcp-config.ts
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AdapterConfigDiscovery: reads
|
|
3
|
-
*
|
|
2
|
+
* AdapterConfigDiscovery: reads what pi-mcp-adapter would load from its
|
|
3
|
+
* pi-native FILE sources — server names for reference validation, and the
|
|
4
|
+
* "Pi global override" slot document for the generated overlay.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Discovery mirrors the adapter's own source order (later sources override
|
|
7
|
+
* earlier ones): shared global MCP config, the two `.agents` globals, the Pi
|
|
8
|
+
* global override slot (`--mcp-config`, else `<agentDir>/mcp.json`), then the
|
|
9
|
+
* project's `.mcp.json` and `.pi/mcp.json` (project sources only when Pi
|
|
10
|
+
* reports the project trusted — an untrusted project's config is never read).
|
|
8
11
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Not covered (documented limitation): the adapter's opt-in host discovery
|
|
13
|
+
* (`~/.claude.json`, `~/.cursor/mcp.json`, …), package manifests (`pi.mcp`)
|
|
14
|
+
* and agent/Claude plugin sources. Their servers are namespaced and cannot be
|
|
15
|
+
* referenced by a profile today; the overlay still disables them when a name
|
|
16
|
+
* happens to match one it knows.
|
|
17
|
+
*
|
|
18
|
+
* pi-profile-switch never writes these files and never exposes connection
|
|
19
|
+
* parameters; the slot document is read only to be carried into the generated
|
|
20
|
+
* overlay, because that file replaces the slot.
|
|
15
21
|
*
|
|
16
22
|
* Malformed config files fail loudly — a broken mcp.json must not silently
|
|
17
23
|
* read as "no servers" and reject every reference.
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
|
-
import {
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
import { isRecord, readJsonFileSync } from "./json-file.ts";
|
|
21
30
|
|
|
22
31
|
export class McpConfigError extends Error {
|
|
23
32
|
readonly filePath: string;
|
|
@@ -29,35 +38,145 @@ export class McpConfigError extends Error {
|
|
|
29
38
|
}
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
export interface AdapterMcpSource {
|
|
42
|
+
label: string;
|
|
43
|
+
filePath: string;
|
|
44
|
+
scope: "global" | "project";
|
|
45
|
+
/** True for the slot `--mcp-config` replaces. */
|
|
46
|
+
slot: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AdapterMcpDiscoveryInput {
|
|
50
|
+
agentDir: string;
|
|
51
|
+
cwd: string;
|
|
52
|
+
projectTrusted: boolean;
|
|
53
|
+
/** The effective `--mcp-config` value, when one is in play. */
|
|
54
|
+
overridePath?: string;
|
|
55
|
+
/** Home directory override; tests point this at their fixture. */
|
|
56
|
+
homeDir?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The adapter's file sources, in its own precedence order. Paths are
|
|
60
|
+
* de-duplicated: the adapter skips a source whose read path equals the slot. */
|
|
61
|
+
export function adapterMcpSources(input: AdapterMcpDiscoveryInput): AdapterMcpSource[] {
|
|
62
|
+
const home = input.homeDir ?? homedir();
|
|
63
|
+
const slotPath = path.resolve(input.overridePath ?? path.join(input.agentDir, "mcp.json"));
|
|
64
|
+
const candidates: AdapterMcpSource[] = [
|
|
65
|
+
{ label: "shared global MCP config", filePath: path.join(home, ".config", "mcp", "mcp.json"), scope: "global", slot: false },
|
|
66
|
+
{ label: ".agents MCP config", filePath: path.join(home, ".agents", "mcp.json"), scope: "global", slot: false },
|
|
67
|
+
{ label: ".agents/mcp MCP config", filePath: path.join(home, ".agents", "mcp", "mcp.json"), scope: "global", slot: false },
|
|
68
|
+
{ label: "Pi global MCP override", filePath: slotPath, scope: "global", slot: true },
|
|
69
|
+
];
|
|
70
|
+
if (input.projectTrusted) {
|
|
71
|
+
candidates.push(
|
|
72
|
+
{ label: "project MCP config", filePath: path.resolve(input.cwd, ".mcp.json"), scope: "project", slot: false },
|
|
73
|
+
{ label: "project Pi MCP override", filePath: path.resolve(input.cwd, ".pi", "mcp.json"), scope: "project", slot: false },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const seen = new Set<string>();
|
|
77
|
+
return candidates.filter((source) => {
|
|
78
|
+
if (source.slot) return true; // the slot is always the read path
|
|
79
|
+
if (seen.has(source.filePath)) return false;
|
|
80
|
+
seen.add(source.filePath);
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface AdapterMcpView {
|
|
86
|
+
/** Read path of the slot `--mcp-config` replaces. */
|
|
87
|
+
slotPath: string;
|
|
88
|
+
/** Parsed slot document (verbatim), when the file exists. */
|
|
89
|
+
slotDocument?: Record<string, unknown>;
|
|
90
|
+
slotNames: string[];
|
|
91
|
+
/** Server names from the other file sources. */
|
|
92
|
+
otherNames: string[];
|
|
93
|
+
/** Union of slot and other names, sorted. */
|
|
94
|
+
serverNames: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Synchronous read: the extension-load pass must finish before the adapter's
|
|
98
|
+
* session initialization, and the files are tiny. */
|
|
99
|
+
export function readAdapterMcpViewSync(input: AdapterMcpDiscoveryInput): AdapterMcpView {
|
|
100
|
+
const sources = adapterMcpSources(input);
|
|
101
|
+
const slot = sources.find((source) => source.slot);
|
|
102
|
+
const slotPath = slot?.filePath ?? path.join(input.agentDir, "mcp.json");
|
|
103
|
+
let slotDocument: Record<string, unknown> | undefined;
|
|
104
|
+
const slotNames: string[] = [];
|
|
105
|
+
const otherNames = new Set<string>();
|
|
106
|
+
for (const source of sources) {
|
|
107
|
+
if (source.slot) {
|
|
108
|
+
const document = readMcpDocumentSync(source.filePath);
|
|
109
|
+
if (document === undefined) continue;
|
|
110
|
+
slotDocument = document;
|
|
111
|
+
slotNames.push(...serverNames(document, source.filePath));
|
|
112
|
+
continue;
|
|
37
113
|
}
|
|
114
|
+
const document = readMcpDocumentSync(source.filePath);
|
|
115
|
+
if (document === undefined) continue;
|
|
116
|
+
for (const name of serverNames(document, source.filePath)) otherNames.add(name);
|
|
117
|
+
}
|
|
118
|
+
for (const name of slotNames) otherNames.delete(name);
|
|
119
|
+
return {
|
|
120
|
+
slotPath,
|
|
121
|
+
...(slotDocument === undefined ? {} : { slotDocument }),
|
|
122
|
+
slotNames,
|
|
123
|
+
otherNames: [...otherNames].sort(),
|
|
124
|
+
serverNames: [...new Set([...slotNames, ...otherNames])].sort(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Async twin for the runtime paths (selection, status, toggles). */
|
|
129
|
+
export async function readAdapterMcpView(input: AdapterMcpDiscoveryInput): Promise<AdapterMcpView> {
|
|
130
|
+
return readAdapterMcpViewSync(input);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Server names from every source EXCEPT the slot file. The slot is generated
|
|
134
|
+
* by pi-profile-switch, so it must not feed back into the next generation —
|
|
135
|
+
* otherwise a stub would look like a source server and vanish on the next
|
|
136
|
+
* write. */
|
|
137
|
+
export function readAdapterOtherServerNamesSync(input: AdapterMcpDiscoveryInput): string[] {
|
|
138
|
+
const names = new Set<string>();
|
|
139
|
+
for (const source of adapterMcpSources(input)) {
|
|
140
|
+
if (source.slot) continue;
|
|
141
|
+
const document = readMcpDocumentSync(source.filePath);
|
|
142
|
+
if (document === undefined) continue;
|
|
143
|
+
for (const name of serverNames(document, source.filePath)) names.add(name);
|
|
144
|
+
}
|
|
145
|
+
return [...names].sort();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Server names the adapter would discover. `projectDir` is passed only when
|
|
149
|
+
* the trust check passed. */
|
|
150
|
+
export async function discoverAdapterServerNames(
|
|
151
|
+
agentDir: string,
|
|
152
|
+
projectDir?: string,
|
|
153
|
+
homeDir?: string,
|
|
154
|
+
): Promise<string[]> {
|
|
155
|
+
return readAdapterMcpViewSync({
|
|
156
|
+
agentDir,
|
|
157
|
+
cwd: projectDir ?? process.cwd(),
|
|
158
|
+
projectTrusted: projectDir !== undefined,
|
|
159
|
+
...(homeDir === undefined ? {} : { homeDir }),
|
|
160
|
+
}).serverNames;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Missing file → undefined; malformed → McpConfigError. */
|
|
164
|
+
export function readMcpDocumentSync(filePath: string): Record<string, unknown> | undefined {
|
|
165
|
+
const result = readJsonFileSync(filePath);
|
|
166
|
+
if (!result.ok) {
|
|
167
|
+
if (result.reason === "missing") return undefined;
|
|
38
168
|
throw new McpConfigError(`MCP config is not valid JSON: ${filePath}`, filePath);
|
|
39
169
|
}
|
|
40
170
|
if (!isRecord(result.value)) {
|
|
41
171
|
throw new McpConfigError(`MCP config must be a JSON object: ${filePath}`, filePath);
|
|
42
172
|
}
|
|
43
|
-
|
|
44
|
-
return [];
|
|
45
|
-
}
|
|
46
|
-
if (!isRecord(result.value.mcpServers)) {
|
|
47
|
-
throw new McpConfigError(`"mcpServers" must be a JSON object: ${filePath}`, filePath);
|
|
48
|
-
}
|
|
49
|
-
return Object.keys(result.value.mcpServers);
|
|
173
|
+
return result.value;
|
|
50
174
|
}
|
|
51
175
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const names = new Set(await readServerNames(`${agentDir}/mcp.json`));
|
|
57
|
-
if (projectDir !== undefined) {
|
|
58
|
-
for (const name of await readServerNames(`${projectDir}/.pi/mcp.json`)) {
|
|
59
|
-
names.add(name);
|
|
60
|
-
}
|
|
176
|
+
function serverNames(document: Record<string, unknown>, filePath: string): string[] {
|
|
177
|
+
if (document.mcpServers === undefined) return [];
|
|
178
|
+
if (!isRecord(document.mcpServers)) {
|
|
179
|
+
throw new McpConfigError(`"mcpServers" must be a JSON object: ${filePath}`, filePath);
|
|
61
180
|
}
|
|
62
|
-
return
|
|
181
|
+
return Object.keys(document.mcpServers);
|
|
63
182
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* McpOverlayFile: the only writer of the generated MCP overlay.
|
|
3
|
+
*
|
|
4
|
+
* Invariants:
|
|
5
|
+
* - Atomic (temporary file + rename) so a reader never sees a half-written
|
|
6
|
+
* document.
|
|
7
|
+
* - Written only when the bytes change: a profile switch that does not move
|
|
8
|
+
* the MCP selection produces no write and therefore no reload.
|
|
9
|
+
* - Mode 0600: the overlay carries the Pi-global slot's definitions verbatim
|
|
10
|
+
* when the user keeps servers there, and those definitions may embed
|
|
11
|
+
* credentials even though the overlay itself never introduces any.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
/** Current bytes, or undefined when the file is missing/unreadable. */
|
|
18
|
+
export function readMcpOverlaySync(overlayPath: string): string | undefined {
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(overlayPath, "utf8");
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Writes `content` when it differs from what is on disk. Returns true when
|
|
27
|
+
* the file changed. */
|
|
28
|
+
export function writeMcpOverlayIfChangedSync(overlayPath: string, content: string): boolean {
|
|
29
|
+
if (readMcpOverlaySync(overlayPath) === content) return false;
|
|
30
|
+
mkdirSync(path.dirname(overlayPath), { recursive: true });
|
|
31
|
+
const temporary = `${overlayPath}.tmp-${process.pid}`;
|
|
32
|
+
writeFileSync(temporary, content, { mode: 0o600 });
|
|
33
|
+
renameSync(temporary, overlayPath);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* McpOverlay: the PURE half of the profile-scoped MCP filter.
|
|
3
|
+
*
|
|
4
|
+
* `pi-mcp-adapter` has no runtime allowlist channel (ADR-0002 amendment), so
|
|
5
|
+
* the profile's `mcp` declaration is enforced by a generated config file the
|
|
6
|
+
* adapter reads as its "Pi global override" slot (the slot `--mcp-config`
|
|
7
|
+
* replaces). The file is a DISABLE OVERLAY: entries carry no connection
|
|
8
|
+
* parameters and no credentials — `{ "<server>": { "disabled": true } }` is
|
|
9
|
+
* the adapter's own idiom for `/mcp disable`, and its config merge is
|
|
10
|
+
* per-field, so a stub merges onto the definition owned by the user's own
|
|
11
|
+
* file.
|
|
12
|
+
*
|
|
13
|
+
* One exception is structural: because the overlay file REPLACES the Pi
|
|
14
|
+
* global slot, the servers the user keeps in that slot are carried over
|
|
15
|
+
* verbatim from the sidecar (`mcp.user.json`); a hand-written slot file is
|
|
16
|
+
* adopted into the sidecar before the first overwrite.
|
|
17
|
+
*
|
|
18
|
+
* `allowed` semantics:
|
|
19
|
+
* - `"all"` — the profile declares no `mcp`: nothing is disabled.
|
|
20
|
+
* - `[]` — every discovered server is disabled.
|
|
21
|
+
* - `["github", …]` — everything outside the list is disabled.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
import { isRecord } from "./json-file.ts";
|
|
27
|
+
import { matchesReference } from "./name-matching.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The generated overlay REPLACES the adapter's Pi-global slot, so it lives at
|
|
31
|
+
* the slot's default path. The user's own Pi-global servers move to a sidecar
|
|
32
|
+
* that this package never writes except when adopting a hand-written slot.
|
|
33
|
+
*/
|
|
34
|
+
export const MCP_SLOT_FILE_NAME = "mcp.json";
|
|
35
|
+
export const MCP_SOURCE_FILE_NAME = "mcp.user.json";
|
|
36
|
+
/** Top-level key marking a file as generated. The adapter ignores unknown
|
|
37
|
+
* top-level keys, so the marker never reaches it as configuration. */
|
|
38
|
+
export const MCP_GENERATED_MARKER = "piProfileSwitch";
|
|
39
|
+
|
|
40
|
+
/** The adapter's Pi-global slot: the generated overlay. */
|
|
41
|
+
export function mcpSlotPath(agentDir: string): string {
|
|
42
|
+
return path.join(agentDir, MCP_SLOT_FILE_NAME);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The user-owned sidecar holding the Pi-global servers verbatim. */
|
|
46
|
+
export function mcpSourcePath(agentDir: string): string {
|
|
47
|
+
return path.join(agentDir, MCP_SOURCE_FILE_NAME);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True when the document was produced by pi-profile-switch. */
|
|
51
|
+
export function isGeneratedOverlay(value: unknown): boolean {
|
|
52
|
+
if (!isRecord(value)) return false;
|
|
53
|
+
const marker = value[MCP_GENERATED_MARKER];
|
|
54
|
+
return isRecord(marker) && marker.generated === true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True for the credential-free stubs the overlay adds for servers defined in
|
|
58
|
+
* the adapter's other sources. */
|
|
59
|
+
export function isDisabledStub(value: unknown): boolean {
|
|
60
|
+
return isRecord(value) && Object.keys(value).length === 1 && value.disabled === true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Server names a profile's `mcp` references resolve to. `undefined` (the
|
|
64
|
+
* profile declares nothing) means "no filtering" and is reported as the
|
|
65
|
+
* literal `"all"`. Glob references follow the same rules as every other
|
|
66
|
+
* profile reference. */
|
|
67
|
+
export function resolveAllowedServers(
|
|
68
|
+
refs: readonly string[] | undefined,
|
|
69
|
+
discovered: readonly string[],
|
|
70
|
+
): readonly string[] | "all" {
|
|
71
|
+
if (refs === undefined) return "all";
|
|
72
|
+
const allowed = new Set<string>();
|
|
73
|
+
for (const ref of refs) {
|
|
74
|
+
for (const name of discovered) {
|
|
75
|
+
if (matchesReference(ref, name)) allowed.add(name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return [...allowed];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface McpOverlayInput {
|
|
82
|
+
/** Parsed Pi-global slot document, when the user has one. */
|
|
83
|
+
slotDocument?: Record<string, unknown>;
|
|
84
|
+
/** Server names defined in the adapter's other file sources. */
|
|
85
|
+
otherServerNames: readonly string[];
|
|
86
|
+
allowed: readonly string[] | "all";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Builds the overlay document. Server order is sorted so the serialized
|
|
90
|
+
* bytes are stable and a rewrite only happens on a real change. */
|
|
91
|
+
export function buildMcpOverlay(input: McpOverlayInput): Record<string, unknown> {
|
|
92
|
+
const allowed = input.allowed === "all" ? undefined : new Set(input.allowed);
|
|
93
|
+
const slotServers = isRecord(input.slotDocument?.mcpServers) ? input.slotDocument.mcpServers : {};
|
|
94
|
+
const servers: Record<string, unknown> = {};
|
|
95
|
+
for (const [name, definition] of Object.entries(slotServers)) {
|
|
96
|
+
if (allowed === undefined || allowed.has(name) || !isRecord(definition)) {
|
|
97
|
+
servers[name] = definition;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
servers[name] = { ...definition, disabled: true };
|
|
101
|
+
}
|
|
102
|
+
for (const name of input.otherServerNames) {
|
|
103
|
+
if (name in servers) continue; // the slot's definition owns the name
|
|
104
|
+
if (allowed === undefined || allowed.has(name)) continue;
|
|
105
|
+
servers[name] = { disabled: true };
|
|
106
|
+
}
|
|
107
|
+
const document: Record<string, unknown> = {};
|
|
108
|
+
for (const [key, value] of Object.entries(input.slotDocument ?? {})) {
|
|
109
|
+
if (key !== "mcpServers" && key !== MCP_GENERATED_MARKER) document[key] = value;
|
|
110
|
+
}
|
|
111
|
+
document[MCP_GENERATED_MARKER] = { generated: true, version: 1 };
|
|
112
|
+
document.mcpServers = Object.fromEntries(
|
|
113
|
+
Object.entries(servers).sort(([left], [right]) => left.localeCompare(right)),
|
|
114
|
+
);
|
|
115
|
+
return document;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Stable serialization: two runs with the same semantics produce the same
|
|
119
|
+
* bytes, so the writer can skip no-op writes. */
|
|
120
|
+
export function serializeMcpOverlay(document: Record<string, unknown>): string {
|
|
121
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
122
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile badge: the persistent, one-line answer to "which profile is this
|
|
3
|
+
* session running?".
|
|
4
|
+
*
|
|
5
|
+
* Presentation only — no state, no I/O, no activation. The extension decides
|
|
6
|
+
* when a badge is written (after an activation that succeeded, via
|
|
7
|
+
* `ctx.ui.setStatus(PROFILE_STATUS_KEY, …)`), so the badge can never claim a
|
|
8
|
+
* selection that was not applied.
|
|
9
|
+
*
|
|
10
|
+
* One canonical rendering, shared with the `/profile status` heading
|
|
11
|
+
* (`profile: <name>`), plus `*` when a runtime overlay is in effect — the only
|
|
12
|
+
* runtime difference the catalog does not show.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
import { DEFAULT_PROFILE_NAME } from "./profile-catalog.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Status key in Pi's footer. Pi joins all extension statuses into one line,
|
|
21
|
+
* orders them by key, and truncates that line from the right — so an earlier
|
|
22
|
+
* key keeps its text visible on a narrow terminal. `active-profile` sorts
|
|
23
|
+
* before the keys it shares the line with (`mcp`, `pi-…`, `thinking`).
|
|
24
|
+
*/
|
|
25
|
+
export const PROFILE_STATUS_KEY = "active-profile";
|
|
26
|
+
|
|
27
|
+
/** The label prefix, matching the `/profile status` heading. */
|
|
28
|
+
export const PROFILE_BADGE_LABEL = "profile";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Display columns reserved for the name before it is elided. The footer is a
|
|
32
|
+
* shared, fixed-width line: profile names are unbounded user input, so an
|
|
33
|
+
* unelided name would evict the statuses of other extensions.
|
|
34
|
+
*/
|
|
35
|
+
export const PROFILE_BADGE_NAME_COLUMNS = 16;
|
|
36
|
+
|
|
37
|
+
const ELLIPSIS = "…";
|
|
38
|
+
|
|
39
|
+
export interface ProfileBadge {
|
|
40
|
+
/** The name `/profile use` accepts (never the display `label`). */
|
|
41
|
+
name: string;
|
|
42
|
+
/** A runtime overlay is in effect: this runtime differs from the catalog. */
|
|
43
|
+
overlay: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The minimum theme surface the badge needs; `ctx.ui.theme` satisfies it. */
|
|
47
|
+
export interface BadgeTheme {
|
|
48
|
+
fg(color: ThemeColor, text: string): string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BadgeOptions {
|
|
52
|
+
overlay: boolean;
|
|
53
|
+
/** Override the name budget (tests, future width awareness). */
|
|
54
|
+
nameColumns?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Builds the badge for a resolved profile, or `undefined` when there must be
|
|
59
|
+
* no badge at all.
|
|
60
|
+
*
|
|
61
|
+
* `default` is Pi's native baseline — it declares nothing — so it must not
|
|
62
|
+
* change the footer either: a plain Pi session shows no badge, and the footer
|
|
63
|
+
* status line only exists while some extension status is set.
|
|
64
|
+
*/
|
|
65
|
+
export function buildProfileBadge(name: string, options: BadgeOptions): ProfileBadge | undefined {
|
|
66
|
+
if (name === DEFAULT_PROFILE_NAME) return undefined;
|
|
67
|
+
return {
|
|
68
|
+
name: truncateToColumns(name, options.nameColumns ?? PROFILE_BADGE_NAME_COLUMNS),
|
|
69
|
+
overlay: options.overlay,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Renders the badge. Colors come from the caller's theme at render time: Pi
|
|
75
|
+
* stores footer statuses as finished strings, so the extension re-renders on
|
|
76
|
+
* profile changes and on each turn (there is no extension-visible theme-change
|
|
77
|
+
* event).
|
|
78
|
+
*/
|
|
79
|
+
export function renderProfileBadge(badge: ProfileBadge, theme: BadgeTheme): string {
|
|
80
|
+
const label = theme.fg("dim", `${PROFILE_BADGE_LABEL}: `);
|
|
81
|
+
const name = theme.fg("dim", badge.name);
|
|
82
|
+
return badge.overlay ? `${label}${name}${theme.fg("warning", "*")}` : `${label}${name}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* -------------------------------------------------------------------------- */
|
|
86
|
+
/* Display width */
|
|
87
|
+
/* -------------------------------------------------------------------------- */
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Width of one code point in terminal columns: 2 for East Asian wide and
|
|
91
|
+
* fullwidth code points, 1 otherwise. Zero-width joiners and combining marks
|
|
92
|
+
* are counted as 1 — an approximation that only over-reserves space for
|
|
93
|
+
* exotic names.
|
|
94
|
+
*/
|
|
95
|
+
export function codePointWidth(codePoint: number): number {
|
|
96
|
+
return isWide(codePoint) ? 2 : 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Display width of `text` in terminal columns. */
|
|
100
|
+
export function displayWidth(text: string): number {
|
|
101
|
+
let width = 0;
|
|
102
|
+
for (const character of text) width += codePointWidth(character.codePointAt(0)!);
|
|
103
|
+
return width;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Truncates `text` to at most `columns` terminal columns, appending `…` when
|
|
108
|
+
* something was dropped. The ellipsis is part of the budget, so the result
|
|
109
|
+
* never exceeds `columns`.
|
|
110
|
+
*/
|
|
111
|
+
export function truncateToColumns(text: string, columns: number, ellipsis = ELLIPSIS): string {
|
|
112
|
+
if (columns <= 0) return "";
|
|
113
|
+
if (displayWidth(text) <= columns) return text;
|
|
114
|
+
const budget = columns - displayWidth(ellipsis);
|
|
115
|
+
let result = "";
|
|
116
|
+
let width = 0;
|
|
117
|
+
for (const character of text) {
|
|
118
|
+
const next = width + codePointWidth(character.codePointAt(0)!);
|
|
119
|
+
if (next > budget) break;
|
|
120
|
+
result += character;
|
|
121
|
+
width = next;
|
|
122
|
+
}
|
|
123
|
+
return budget < 0 ? "" : `${result}${ellipsis}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isWide(codePoint: number): boolean {
|
|
127
|
+
return (
|
|
128
|
+
(codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
|
|
129
|
+
(codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK radicals, Kangxi, CJK symbols
|
|
130
|
+
(codePoint >= 0x3041 && codePoint <= 0x33ff) || // kana, CJK compatibility, CJK punctuation
|
|
131
|
+
(codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK unified ideographs extension A
|
|
132
|
+
(codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK unified ideographs
|
|
133
|
+
(codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi syllables
|
|
134
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul syllables
|
|
135
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK compatibility ideographs
|
|
136
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) || // CJK compatibility forms
|
|
137
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) || // fullwidth forms
|
|
138
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) || // fullwidth signs
|
|
139
|
+
(codePoint >= 0x1f300 && codePoint <= 0x1faff) || // emoji, pictographs
|
|
140
|
+
(codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK unified ideographs extension B+
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
* separate from the read-only ProfileCatalog.
|
|
4
4
|
*
|
|
5
5
|
* Invariants:
|
|
6
|
-
* - Whole-file overwrites (pretty-printed, `schemaVersion
|
|
7
|
-
*
|
|
8
|
-
* rewritten as version 2 on the next save; wizard saves never block on
|
|
6
|
+
* - Whole-file overwrites (pretty-printed, `schemaVersion 1` envelope, the
|
|
7
|
+
* constant owned by profile-catalog.ts). Wizard saves never block on
|
|
9
8
|
* concurrent edits — re-read at write time, same-name conflicts resolve
|
|
10
9
|
* last-write-wins.
|
|
11
10
|
* - Definitions are complete and self-contained: no inheritance fields
|
|
@@ -38,7 +37,7 @@ export class ProfileCatalogStore {
|
|
|
38
37
|
|
|
39
38
|
/** Validated definitions: missing file → empty; malformed → CatalogError
|
|
40
39
|
* (catalog errors never pass silently, even on the write path).
|
|
41
|
-
*
|
|
40
|
+
* Unknown fields (an `extensions` key left over from v0.1.0) are dropped. */
|
|
42
41
|
async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
|
|
43
42
|
const result = await readJsonFile(this.#filePath);
|
|
44
43
|
if (!result.ok) {
|
|
@@ -48,7 +47,7 @@ export class ProfileCatalogStore {
|
|
|
48
47
|
if (!isRecord(result.value)) {
|
|
49
48
|
throw new CatalogError(`${this.#filePath}: catalog must be an object`);
|
|
50
49
|
}
|
|
51
|
-
return parseCatalogDocument(result.value, this.#filePath)
|
|
50
|
+
return parseCatalogDocument(result.value, this.#filePath);
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
/** Overwrites the file with the given definitions (last write wins). */
|