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,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExtensionDiscovery: implicit, read-only discovery of selectable extensions
|
|
3
|
+
* (ADR-0006), so profiles can reference extensions without registering them
|
|
4
|
+
* in resources.json first.
|
|
5
|
+
*
|
|
6
|
+
* Two implicit sources, both Pi-native and side-effect free:
|
|
7
|
+
* - Configured user packages: each package's `package.json#pi.extensions`
|
|
8
|
+
* declares its extension entry files; the package name (or an alias like
|
|
9
|
+
* the `npm:` source string) is the profile-facing reference.
|
|
10
|
+
* - Loose extension files: `<agentDir>/extensions/*.{ts,js}` and, for
|
|
11
|
+
* trusted projects, `<projectDir>/.pi/extensions/*.{ts,js}`, referenced by
|
|
12
|
+
* filename stem.
|
|
13
|
+
*
|
|
14
|
+
* Discovery never executes extension code and never installs anything: a
|
|
15
|
+
* package contributes entries only for declared files that exist on disk.
|
|
16
|
+
* Explicit resources.json entries merge over this discovery result in
|
|
17
|
+
* ResourceRegistry.load (registration remains the override, never the
|
|
18
|
+
* prerequisite).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
import { minimatch } from "minimatch";
|
|
25
|
+
|
|
26
|
+
import { isRecord } from "./json-file.ts";
|
|
27
|
+
import type { ConfiguredPackageRoot } from "./settings-generator.ts";
|
|
28
|
+
|
|
29
|
+
export interface DiscoveredPackage {
|
|
30
|
+
/** Selectable package name: package.json "name", or the source minus its
|
|
31
|
+
* npm:/git:/github: prefix (and any version spec) when unreadable. */
|
|
32
|
+
name: string;
|
|
33
|
+
/** The source string exactly as configured in the user's settings
|
|
34
|
+
* (e.g. "npm:pi-mcp-adapter"); accepted as a reference alias. */
|
|
35
|
+
source: string;
|
|
36
|
+
/** Absolute install/local root of the package. */
|
|
37
|
+
root: string;
|
|
38
|
+
/** Absolute paths of declared `pi.extensions` entries that exist on disk. */
|
|
39
|
+
entries: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DiscoveredLocalExtension {
|
|
43
|
+
/** Selectable ID: the filename stem ("conventions" for conventions.ts). */
|
|
44
|
+
id: string;
|
|
45
|
+
entry: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ImplicitExtensionDiscovery {
|
|
49
|
+
packages: DiscoveredPackage[];
|
|
50
|
+
/** Loose files, project entries already merged over same-ID global ones. */
|
|
51
|
+
local: DiscoveredLocalExtension[];
|
|
52
|
+
/** Non-fatal notices (skipped packages, shadowed duplicates). */
|
|
53
|
+
warnings: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class ExtensionError extends Error {
|
|
57
|
+
constructor(message: string) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "ExtensionError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SelectedExtension {
|
|
64
|
+
id: string;
|
|
65
|
+
entry: string;
|
|
66
|
+
origin?: "package" | "local" | "path";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SelectExtensionsResult {
|
|
70
|
+
entries: SelectedExtension[];
|
|
71
|
+
unmatched: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toPosix(filePath: string): string {
|
|
75
|
+
return filePath.split(path.sep).join("/");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function exists(filePath: string): Promise<boolean> {
|
|
79
|
+
try {
|
|
80
|
+
return (await stat(filePath)).isFile();
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function looksLikePath(reference: string): boolean {
|
|
87
|
+
return (
|
|
88
|
+
reference.startsWith("/") ||
|
|
89
|
+
reference.startsWith("~/") ||
|
|
90
|
+
reference.startsWith("./") ||
|
|
91
|
+
reference.startsWith("../") ||
|
|
92
|
+
/\.(ts|js)$/.test(reference)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class DiscoveredExtensions {
|
|
97
|
+
readonly #packages: readonly DiscoveredPackage[];
|
|
98
|
+
readonly #local: readonly DiscoveredLocalExtension[];
|
|
99
|
+
readonly #warnings: string[];
|
|
100
|
+
readonly #entries: ReadonlyMap<string, { id: string; entry: string; packageName?: string; origin: "package" | "local" }>;
|
|
101
|
+
|
|
102
|
+
constructor(packages: readonly DiscoveredPackage[], local: readonly DiscoveredLocalExtension[], warnings: string[]) {
|
|
103
|
+
this.#packages = packages;
|
|
104
|
+
this.#local = local;
|
|
105
|
+
this.#warnings = [...warnings];
|
|
106
|
+
|
|
107
|
+
const entries = new Map<string, { id: string; entry: string; packageName?: string; origin: "package" | "local" }>();
|
|
108
|
+
for (const loc of local) {
|
|
109
|
+
entries.set(loc.id, { id: loc.id, entry: loc.entry, origin: "local" });
|
|
110
|
+
}
|
|
111
|
+
for (const pkg of packages) {
|
|
112
|
+
for (const entryPath of pkg.entries) {
|
|
113
|
+
const id = pkg.entries.length === 1 ? pkg.name : `${pkg.name}:${toPosix(path.relative(pkg.root, entryPath))}`;
|
|
114
|
+
if (entries.has(id)) {
|
|
115
|
+
this.#warnings.push(
|
|
116
|
+
`extension id "${id}" is provided by both a local file and package "${pkg.source}"; the local file wins — reference the package as "${pkg.source}"`,
|
|
117
|
+
);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
entries.set(id, { id, entry: entryPath, packageName: pkg.name, origin: "package" });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this.#entries = entries;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get(id: string): { id: string; entry: string; origin: "package" | "local" } | undefined {
|
|
127
|
+
return this.#entries.get(id);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
list(): SelectedExtension[] {
|
|
131
|
+
return [...this.#entries.values()].map((e) => ({ id: e.id, entry: e.entry, origin: e.origin }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
warnings(): string[] {
|
|
135
|
+
return [...this.#warnings];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
selectableNames(): string[] {
|
|
139
|
+
const names = new Set(this.#entries.keys());
|
|
140
|
+
for (const pkg of this.#packages) names.add(pkg.name);
|
|
141
|
+
return [...names].sort();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#packageByNameOrAlias(reference: string): DiscoveredPackage | undefined {
|
|
145
|
+
return this.#packages.find((pkg) => pkg.name === reference || pkg.source === reference);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#packageEntries(pkg: DiscoveredPackage): SelectedExtension[] {
|
|
149
|
+
return pkg.entries.map((entryPath) => {
|
|
150
|
+
const id = pkg.entries.length === 1 ? pkg.name : `${pkg.name}:${toPosix(path.relative(pkg.root, entryPath))}`;
|
|
151
|
+
const existing = this.#entries.get(id);
|
|
152
|
+
if (existing !== undefined && existing.entry === entryPath) return existing;
|
|
153
|
+
return { id, entry: entryPath, origin: "package" };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#unknownMessage(reference: string, names: string[]): string {
|
|
158
|
+
const lines = [
|
|
159
|
+
`unknown extension: "${reference}" — no installed package, extension file, or path matches it.`,
|
|
160
|
+
];
|
|
161
|
+
if (names.length > 0) {
|
|
162
|
+
const shown = names.slice(0, 10);
|
|
163
|
+
lines.push(`discovered: ${shown.join(", ")}${names.length > shown.length ? ` (+${names.length - shown.length} more)` : ""}`);
|
|
164
|
+
}
|
|
165
|
+
const suggestion = names.find(
|
|
166
|
+
(name) =>
|
|
167
|
+
name.toLowerCase().includes(reference.toLowerCase()) || reference.toLowerCase().includes(name.toLowerCase()),
|
|
168
|
+
);
|
|
169
|
+
if (suggestion !== undefined) {
|
|
170
|
+
lines.push(`did you mean "${suggestion}"?`);
|
|
171
|
+
}
|
|
172
|
+
return lines.join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async select(references: string[]): Promise<SelectExtensionsResult> {
|
|
176
|
+
const byPath = new Map<string, SelectedExtension>();
|
|
177
|
+
const unmatched: string[] = [];
|
|
178
|
+
const names = this.selectableNames();
|
|
179
|
+
|
|
180
|
+
const add = (entry: SelectedExtension): void => {
|
|
181
|
+
if (!byPath.has(entry.entry)) byPath.set(entry.entry, entry);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
for (const reference of references) {
|
|
185
|
+
if (reference.includes("*") || reference.includes("?")) {
|
|
186
|
+
let matched = 0;
|
|
187
|
+
for (const name of names) {
|
|
188
|
+
if (!minimatch(name, reference)) continue;
|
|
189
|
+
matched += 1;
|
|
190
|
+
const pkg = this.#packageByNameOrAlias(name);
|
|
191
|
+
const entry = this.#entries.get(name);
|
|
192
|
+
if (pkg !== undefined && pkg.name === name) {
|
|
193
|
+
for (const pkgEntry of this.#packageEntries(pkg)) add(pkgEntry);
|
|
194
|
+
}
|
|
195
|
+
if (entry !== undefined) add(entry);
|
|
196
|
+
}
|
|
197
|
+
if (matched === 0) unmatched.push(reference);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const entry = this.#entries.get(reference);
|
|
202
|
+
if (entry !== undefined) {
|
|
203
|
+
add(entry);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const pkg = this.#packageByNameOrAlias(reference);
|
|
207
|
+
if (pkg !== undefined) {
|
|
208
|
+
const pkgEntries = this.#packageEntries(pkg);
|
|
209
|
+
if (pkgEntries.length === 0) {
|
|
210
|
+
throw new ExtensionError(
|
|
211
|
+
`package "${reference}" declares no extension entries (its pi.extensions files are missing or shadowed by local files)`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
for (const pkgEntry of pkgEntries) add(pkgEntry);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (looksLikePath(reference)) {
|
|
218
|
+
if (reference.startsWith("./") || reference.startsWith("../")) {
|
|
219
|
+
throw new ExtensionError(
|
|
220
|
+
`extension reference "${reference}" is a relative path; profiles take absolute paths (or ~/...) — catalogs live in both global and project scope, so a relative base would be ambiguous`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const resolved = reference.startsWith("~/")
|
|
224
|
+
? path.join(process.env.HOME ?? "", reference.slice(1))
|
|
225
|
+
: path.resolve(reference);
|
|
226
|
+
if (!(await exists(resolved))) {
|
|
227
|
+
throw new ExtensionError(`extension path not found: ${resolved}`);
|
|
228
|
+
}
|
|
229
|
+
add({ id: resolved, entry: resolved, origin: "path" });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
throw new ExtensionError(this.#unknownMessage(reference, names));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { entries: [...byPath.values()], unmatched };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const LOOSE_FILE_PATTERN = /\.(ts|js)$/;
|
|
240
|
+
|
|
241
|
+
/** Derives a package name from its source string when package.json is
|
|
242
|
+
* unreadable: strips the npm:/git:/github: prefix and any version spec
|
|
243
|
+
* (scoped names keep their "@scope/" prefix). */
|
|
244
|
+
export function packageNameFromSource(source: string): string {
|
|
245
|
+
let name = source.replace(/^(npm|git|github):/, "");
|
|
246
|
+
if (name.startsWith("@")) {
|
|
247
|
+
// @scope/pkg[@version] — cut a version spec only after the scope path.
|
|
248
|
+
const slash = name.indexOf("/");
|
|
249
|
+
const versionAt = slash === -1 ? -1 : name.indexOf("@", slash);
|
|
250
|
+
if (versionAt !== -1) name = name.slice(0, versionAt);
|
|
251
|
+
} else {
|
|
252
|
+
const versionAt = name.indexOf("@");
|
|
253
|
+
if (versionAt !== -1) name = name.slice(0, versionAt);
|
|
254
|
+
}
|
|
255
|
+
return name;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Reads one installed package's declared extension entries. Packages
|
|
259
|
+
* without a readable package.json or without `pi.extensions` contribute
|
|
260
|
+
* nothing (skills-only packages are the common case). Declared entries
|
|
261
|
+
* missing on disk are skipped — the spawned pi reports load errors itself. */
|
|
262
|
+
async function readPackageExtensions(pkg: ConfiguredPackageRoot): Promise<DiscoveredPackage | undefined> {
|
|
263
|
+
if (pkg.root === undefined) return undefined;
|
|
264
|
+
let manifest: unknown;
|
|
265
|
+
try {
|
|
266
|
+
manifest = JSON.parse(await readFile(path.join(pkg.root, "package.json"), "utf8"));
|
|
267
|
+
} catch {
|
|
268
|
+
return undefined; // not installed yet or unreadable — nothing selectable
|
|
269
|
+
}
|
|
270
|
+
if (!isRecord(manifest)) return undefined;
|
|
271
|
+
const pi = manifest.pi;
|
|
272
|
+
const declared =
|
|
273
|
+
isRecord(pi) && Array.isArray(pi.extensions) ? pi.extensions.filter((e): e is string => typeof e === "string") : [];
|
|
274
|
+
if (declared.length === 0) return undefined;
|
|
275
|
+
const entries: string[] = [];
|
|
276
|
+
for (const rel of declared) {
|
|
277
|
+
const entry = path.resolve(pkg.root, rel);
|
|
278
|
+
if (await exists(entry)) entries.push(entry);
|
|
279
|
+
}
|
|
280
|
+
if (entries.length === 0) return undefined;
|
|
281
|
+
const name = typeof manifest.name === "string" && manifest.name.length > 0 ? manifest.name : packageNameFromSource(pkg.source);
|
|
282
|
+
return { name, source: pkg.source, root: pkg.root, entries };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Lists loose extension files in one directory; missing dir → empty.
|
|
286
|
+
* A `.ts`/`.js` stem pair resolves to the `.ts` file (Pi's convention:
|
|
287
|
+
* TypeScript sources are the canonical form) and is reported, not silent. */
|
|
288
|
+
async function scanLooseDir(dir: string, warnings: string[]): Promise<DiscoveredLocalExtension[]> {
|
|
289
|
+
let files: string[];
|
|
290
|
+
try {
|
|
291
|
+
files = (await readdir(dir)).filter((name) => LOOSE_FILE_PATTERN.test(name));
|
|
292
|
+
} catch {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
const byStem = new Map<string, string>();
|
|
296
|
+
for (const name of files.sort()) {
|
|
297
|
+
const stem = name.replace(LOOSE_FILE_PATTERN, "");
|
|
298
|
+
const full = path.join(dir, name);
|
|
299
|
+
const existing = byStem.get(stem);
|
|
300
|
+
if (existing !== undefined) {
|
|
301
|
+
if (!existing.endsWith(".ts") && name.endsWith(".ts")) {
|
|
302
|
+
warnings.push(`extension "${stem}" exists as both .ts and .js in ${dir}; the .ts file is used`);
|
|
303
|
+
byStem.set(stem, full);
|
|
304
|
+
}
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
byStem.set(stem, full);
|
|
308
|
+
}
|
|
309
|
+
return [...byStem.entries()].map(([id, entry]) => ({ id, entry }));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function discoverImplicitExtensions(options: {
|
|
313
|
+
agentDir: string;
|
|
314
|
+
/** Configured user-scope packages with resolved roots (launcher discovery). */
|
|
315
|
+
packages: ConfiguredPackageRoot[];
|
|
316
|
+
/** Trusted project dir; untrusted projects are never scanned. */
|
|
317
|
+
projectDir?: string;
|
|
318
|
+
}): Promise<ImplicitExtensionDiscovery> {
|
|
319
|
+
const warnings: string[] = [];
|
|
320
|
+
|
|
321
|
+
const packages: DiscoveredPackage[] = [];
|
|
322
|
+
for (const pkg of options.packages) {
|
|
323
|
+
const discovered = await readPackageExtensions(pkg);
|
|
324
|
+
if (discovered !== undefined) packages.push(discovered);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const globalLocal = await scanLooseDir(path.join(options.agentDir, "extensions"), warnings);
|
|
328
|
+
const merged = new Map<string, DiscoveredLocalExtension>(globalLocal.map((entry) => [entry.id, entry]));
|
|
329
|
+
if (options.projectDir !== undefined) {
|
|
330
|
+
// Project loose files override same-ID global ones, mirroring the
|
|
331
|
+
// catalog/registry override convention.
|
|
332
|
+
for (const entry of await scanLooseDir(path.join(options.projectDir, ".pi", "extensions"), warnings)) {
|
|
333
|
+
merged.set(entry.id, entry);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { packages, local: [...merged.values()], warnings };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function discoverExtensions(options: {
|
|
341
|
+
agentDir: string;
|
|
342
|
+
packages: ConfiguredPackageRoot[];
|
|
343
|
+
projectDir?: string;
|
|
344
|
+
}): Promise<DiscoveredExtensions> {
|
|
345
|
+
const raw = await discoverImplicitExtensions(options);
|
|
346
|
+
return new DiscoveredExtensions(raw.packages, raw.local, raw.warnings);
|
|
347
|
+
}
|
package/src/json-file.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared JSON-file reading for pi-profile
|
|
2
|
+
* Shared JSON-file reading for pi-profile's file-backed stores (catalog,
|
|
3
3
|
* resource registry, runtime state). Each store maps read failures to its
|
|
4
4
|
* own error policy (loud CatalogError/RegistryError vs. quiet state
|
|
5
5
|
* fallback); this helper only classifies the outcome.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync } from "node:fs";
|
|
9
8
|
import { readFile } from "node:fs/promises";
|
|
10
9
|
|
|
11
10
|
export type JsonFileResult =
|
|
@@ -31,25 +30,6 @@ export async function readJsonFile(filePath: string): Promise<JsonFileResult> {
|
|
|
31
30
|
}
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
/** Synchronous twin of `readJsonFile`, for callers that must finish before
|
|
35
|
-
* an event Pi is about to emit (extension loading). Same classification. */
|
|
36
|
-
export function readJsonFileSync(filePath: string): JsonFileResult {
|
|
37
|
-
let raw: string;
|
|
38
|
-
try {
|
|
39
|
-
raw = readFileSync(filePath, "utf8");
|
|
40
|
-
} catch (error) {
|
|
41
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
42
|
-
return { ok: false, reason: "missing" };
|
|
43
|
-
}
|
|
44
|
-
throw error;
|
|
45
|
-
}
|
|
46
|
-
try {
|
|
47
|
-
return { ok: true, value: JSON.parse(raw) };
|
|
48
|
-
} catch {
|
|
49
|
-
return { ok: false, reason: "invalid" };
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
33
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
54
34
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
35
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launcher argument parsing for the `pi-profile` binary.
|
|
3
|
+
*
|
|
4
|
+
* Grammar: `pi-profile [profile] [--] <pi args>...`
|
|
5
|
+
* - The launcher consumes exactly two things: an optional leading positional
|
|
6
|
+
* profile name, and at most one `--` separator. Positional arguments after
|
|
7
|
+
* the profile name belong to pi (they are pi's message arguments).
|
|
8
|
+
* - Everything else is passed through to Pi verbatim (ADR-0005): any pi flag,
|
|
9
|
+
* known or unknown, reaches the real pi binary unchanged.
|
|
10
|
+
* - `--approve` / `-a` / `--no-approve` / `-na` are recognized and recorded as
|
|
11
|
+
* a trust override input; the caller decides whether to re-apply them
|
|
12
|
+
* (default profile: native passthrough) or feed them to the resolver
|
|
13
|
+
* (non-default profiles: one-run trust input, never forwarded, so Pi never
|
|
14
|
+
* auto-discovers unfiltered project resources). Contradictory repeats are
|
|
15
|
+
* last-wins, matching CLI convention.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface LauncherArgs {
|
|
19
|
+
/** Positional profile name; undefined means "use the default or saved profile". */
|
|
20
|
+
profile: string | undefined;
|
|
21
|
+
/** Arguments forwarded verbatim to the spawned pi process. */
|
|
22
|
+
piArgs: string[];
|
|
23
|
+
/** Trust override recorded from --approve/-a (true) or --no-approve/-na (false). */
|
|
24
|
+
trustOverride: boolean | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const APPROVE_FLAGS = new Set(["--approve", "-a"]);
|
|
28
|
+
const NO_APPROVE_FLAGS = new Set(["--no-approve", "-na"]);
|
|
29
|
+
|
|
30
|
+
export function parseLauncherArgs(argv: string[]): LauncherArgs {
|
|
31
|
+
const rest = [...argv];
|
|
32
|
+
|
|
33
|
+
let profile: string | undefined;
|
|
34
|
+
if (rest[0] !== undefined && !rest[0].startsWith("-")) {
|
|
35
|
+
profile = rest.shift();
|
|
36
|
+
}
|
|
37
|
+
// Consume at most one separator (pi-profile's own); later `--` belong to pi.
|
|
38
|
+
if (rest[0] === "--") {
|
|
39
|
+
rest.shift();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let trustOverride: boolean | undefined;
|
|
43
|
+
const piArgs: string[] = [];
|
|
44
|
+
for (const arg of rest) {
|
|
45
|
+
if (APPROVE_FLAGS.has(arg)) {
|
|
46
|
+
trustOverride = true; // last-wins, like pi's own flag handling
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (NO_APPROVE_FLAGS.has(arg)) {
|
|
50
|
+
trustOverride = false;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
piArgs.push(arg);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { profile, piArgs, trustOverride };
|
|
57
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launcher-side discovery: the full skill registry plus the configured
|
|
3
|
+
* global package roots, both read-only, feeding the resolver and the
|
|
4
|
+
* settings generator.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
DiscoveredExtensions,
|
|
13
|
+
discoverExtensions,
|
|
14
|
+
} from "../extension-discovery.ts";
|
|
15
|
+
import type { ConfiguredPackageRoot } from "../settings-generator.ts";
|
|
16
|
+
import { discoverSkills, type SkillEntry } from "../skill-registry.ts";
|
|
17
|
+
import type { LauncherContext } from "./initial-profile.ts";
|
|
18
|
+
|
|
19
|
+
export interface LauncherDiscovery {
|
|
20
|
+
skills: SkillEntry[];
|
|
21
|
+
packages: ConfiguredPackageRoot[];
|
|
22
|
+
/** Discovered selectable extensions: package entries + loose files. */
|
|
23
|
+
extensions: DiscoveredExtensions;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolves a configured package's local root without network access:
|
|
27
|
+
* the manager's installed path for npm/git sources, or the local source
|
|
28
|
+
* path resolved against the agent dir (Pi's base for user-scope entries). */
|
|
29
|
+
function resolvePackageRoot(source: string, installedPath: string | undefined, agentDir: string): string | undefined {
|
|
30
|
+
if (installedPath !== undefined) return installedPath;
|
|
31
|
+
if (source.startsWith("npm:") || source.startsWith("git:") || source.startsWith("github:")) return undefined;
|
|
32
|
+
const expanded = source.startsWith("~")
|
|
33
|
+
? path.join(process.env.HOME ?? "", source.slice(1))
|
|
34
|
+
: source;
|
|
35
|
+
return path.resolve(agentDir, expanded);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function discoverLauncherResources(
|
|
39
|
+
context: LauncherContext & { projectTrusted: boolean },
|
|
40
|
+
): Promise<LauncherDiscovery> {
|
|
41
|
+
const settingsManager = SettingsManager.create(context.cwd, context.agentDir, {
|
|
42
|
+
projectTrusted: context.projectTrusted,
|
|
43
|
+
});
|
|
44
|
+
const packageManager = new DefaultPackageManager({
|
|
45
|
+
cwd: context.cwd,
|
|
46
|
+
agentDir: context.agentDir,
|
|
47
|
+
settingsManager,
|
|
48
|
+
});
|
|
49
|
+
// User-scope packages only: project-scope packages install under the
|
|
50
|
+
// project's .pi/npm, which generated global-scope settings cannot reference.
|
|
51
|
+
const configured = packageManager
|
|
52
|
+
.listConfiguredPackages()
|
|
53
|
+
.filter((pkg) => pkg.scope === "user")
|
|
54
|
+
.map((pkg) => ({ source: pkg.source, root: resolvePackageRoot(pkg.source, pkg.installedPath, context.agentDir) }));
|
|
55
|
+
const [skills, extensions] = await Promise.all([
|
|
56
|
+
discoverSkills(context),
|
|
57
|
+
discoverExtensions({
|
|
58
|
+
agentDir: context.agentDir,
|
|
59
|
+
packages: configured,
|
|
60
|
+
projectDir: context.projectTrusted ? context.cwd : undefined,
|
|
61
|
+
}),
|
|
62
|
+
]);
|
|
63
|
+
return { skills, packages: configured, extensions };
|
|
64
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initial profile resolution for the launcher.
|
|
3
|
+
*
|
|
4
|
+
* Flow: trust check (gatekeeper for everything project-scoped) → positional
|
|
5
|
+
* name or saved state (project state wins when trusted) → catalog lookup →
|
|
6
|
+
* skill discovery + resource registry → resolver (glob expansion, dependency
|
|
7
|
+
* closure, alwaysOn, model validation) → ActivationPlan + full discovery
|
|
8
|
+
* results for the settings generator. Unknown profiles, malformed
|
|
9
|
+
* catalogs/registries, and unresolvable resources all fail before Pi spawns.
|
|
10
|
+
*
|
|
11
|
+
* The CLI's initial selection is transient: no runtime state is written here.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
import { isRecord, readJsonFile } from "../json-file.ts";
|
|
17
|
+
import { discoverAdapterServerNames } from "../mcp-config.ts";
|
|
18
|
+
import { isAdapterExtension, MissingMcpAdapterError } from "../mcp-coordination.ts";
|
|
19
|
+
import { ProfileCatalog, type ResolvedProfile } from "../profile-catalog.ts";
|
|
20
|
+
import { ActivationError, defaultPlan, resolveProfile, type ActivationPlan } from "../profile-resolver.ts";
|
|
21
|
+
import { resolveProjectTrust } from "../project-trust.ts";
|
|
22
|
+
import { RuntimeStateStore, type RuntimeOverlay } from "../runtime-state-store.ts";
|
|
23
|
+
import { getGlobalStateDir } from "../workspace.ts";
|
|
24
|
+
import { discoverLauncherResources, type LauncherDiscovery } from "./discovery.ts";
|
|
25
|
+
import { checkDeclaredModel } from "./model-check.ts";
|
|
26
|
+
|
|
27
|
+
export class UnknownProfileError extends Error {
|
|
28
|
+
constructor(name: string) {
|
|
29
|
+
super(`unknown profile: ${name}`);
|
|
30
|
+
this.name = "UnknownProfileError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Zero-match glob references surface as launch warnings (ADR-0006):
|
|
35
|
+
* visible, but never blocking — globs re-expand on every resolution. */
|
|
36
|
+
function unmatchedWarnings(plan: ActivationPlan): string[] {
|
|
37
|
+
return (plan.unmatched ?? []).map(
|
|
38
|
+
(reference) => `profile "${plan.profile}": "${reference}" matched nothing this resolution`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LauncherContext {
|
|
43
|
+
/** The user's real agent dir (e.g. ~/.pi/agent). */
|
|
44
|
+
agentDir: string;
|
|
45
|
+
/** The project working directory Pi will run in. */
|
|
46
|
+
cwd: string;
|
|
47
|
+
/** One-run trust input from --approve / --no-approve (never forwarded to Pi). */
|
|
48
|
+
trustOverride?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface InitialProfile {
|
|
52
|
+
plan: ActivationPlan;
|
|
53
|
+
/** Full discovery results for the settings generator. Undefined for the
|
|
54
|
+
* default profile (which applies no filtering). */
|
|
55
|
+
discovery?: LauncherDiscovery;
|
|
56
|
+
/** The trusted project's `.pi/settings.json` content, when trusted and
|
|
57
|
+
* present. The generator merges it into the base for selection plans. */
|
|
58
|
+
projectSettings?: Record<string, unknown>;
|
|
59
|
+
/** Non-fatal notices for the user (e.g. a dangling restored profile that
|
|
60
|
+
* fell back to default). The launcher prints them. */
|
|
61
|
+
warnings: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Reads the real global `defaultProjectTrust` setting (a trust input) and,
|
|
65
|
+
* when trusted, the project's `.pi/settings.json`. Exported for the
|
|
66
|
+
* session-side surfaces (selector/list/status) that need the same trust
|
|
67
|
+
* gate the launcher uses. */
|
|
68
|
+
export async function readTrustInputs(context: LauncherContext): Promise<{
|
|
69
|
+
projectTrusted: boolean;
|
|
70
|
+
projectSettings?: Record<string, unknown>;
|
|
71
|
+
}> {
|
|
72
|
+
const globalSettingsPath = path.join(context.agentDir, "settings.json");
|
|
73
|
+
const globalSettings = await readJsonFile(globalSettingsPath);
|
|
74
|
+
const userDefaultProjectTrust =
|
|
75
|
+
globalSettings.ok && isRecord(globalSettings.value) && typeof globalSettings.value.defaultProjectTrust === "string"
|
|
76
|
+
? globalSettings.value.defaultProjectTrust
|
|
77
|
+
: undefined;
|
|
78
|
+
const projectTrusted = resolveProjectTrust({
|
|
79
|
+
cwd: context.cwd,
|
|
80
|
+
agentDir: context.agentDir,
|
|
81
|
+
trustOverride: context.trustOverride,
|
|
82
|
+
userDefaultProjectTrust,
|
|
83
|
+
});
|
|
84
|
+
if (!projectTrusted) {
|
|
85
|
+
return { projectTrusted };
|
|
86
|
+
}
|
|
87
|
+
const projectSettingsResult = await readJsonFile(path.join(context.cwd, ".pi", "settings.json"));
|
|
88
|
+
const projectSettings =
|
|
89
|
+
projectSettingsResult.ok && isRecord(projectSettingsResult.value) ? projectSettingsResult.value : undefined;
|
|
90
|
+
return { projectTrusted, projectSettings };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function resolveInitialProfile(
|
|
94
|
+
name: string | undefined,
|
|
95
|
+
context: LauncherContext,
|
|
96
|
+
options?: { overlay?: RuntimeOverlay },
|
|
97
|
+
): Promise<InitialProfile> {
|
|
98
|
+
const { projectTrusted, projectSettings } = await readTrustInputs(context);
|
|
99
|
+
const projectDir = projectTrusted ? context.cwd : undefined;
|
|
100
|
+
const catalog = await ProfileCatalog.load(context.agentDir, { projectDir });
|
|
101
|
+
|
|
102
|
+
let selected = name;
|
|
103
|
+
const warnings: string[] = [];
|
|
104
|
+
if (selected === undefined) {
|
|
105
|
+
// No positional name: the trusted project's saved selection is the more
|
|
106
|
+
// specific one and wins; otherwise the global state, then default.
|
|
107
|
+
if (projectTrusted) {
|
|
108
|
+
selected = (await new RuntimeStateStore(path.join(context.cwd, ".pi")).read()).activeProfile;
|
|
109
|
+
}
|
|
110
|
+
selected ??= (await new RuntimeStateStore(getGlobalStateDir(context.agentDir)).read()).activeProfile ?? "default";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const profile = catalog.resolve(selected);
|
|
114
|
+
if (profile === undefined) {
|
|
115
|
+
// Explicit positional selection fails loudly; a restored selection that
|
|
116
|
+
// no longer exists falls back to default with a warning instead of
|
|
117
|
+
// blocking the launch (restore is a convenience, not a commitment).
|
|
118
|
+
if (name !== undefined) {
|
|
119
|
+
throw new UnknownProfileError(selected);
|
|
120
|
+
}
|
|
121
|
+
warnings.push(`saved profile "${selected}" no longer exists; starting the default profile`);
|
|
122
|
+
return { plan: defaultPlan(), warnings };
|
|
123
|
+
}
|
|
124
|
+
if (profile.source === "builtin") {
|
|
125
|
+
// The default profile is normally unfiltered. With an overlay it becomes
|
|
126
|
+
// a synthetic "everything minus disabled" selection (PRD: overlays may
|
|
127
|
+
// temporarily narrow default's scope). MCP narrowing on default is
|
|
128
|
+
// rejected — the adapter's own /mcp commands own that surface natively.
|
|
129
|
+
const overlay = options?.overlay;
|
|
130
|
+
const narrowed =
|
|
131
|
+
overlay !== undefined &&
|
|
132
|
+
((overlay.disabledSkills?.length ?? 0) > 0 ||
|
|
133
|
+
(overlay.disabledExtensions?.length ?? 0) > 0 ||
|
|
134
|
+
(overlay.disabledMcps?.length ?? 0) > 0 ||
|
|
135
|
+
overlay.tools !== undefined);
|
|
136
|
+
if (!narrowed) {
|
|
137
|
+
return { plan: defaultPlan(), warnings };
|
|
138
|
+
}
|
|
139
|
+
if ((overlay.disabledMcps?.length ?? 0) > 0) {
|
|
140
|
+
throw new ActivationError(
|
|
141
|
+
"the default profile has no MCP allowlist to narrow — use the adapter's own /mcp commands instead",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const synthetic: ResolvedProfile = {
|
|
145
|
+
name: "default",
|
|
146
|
+
source: "builtin",
|
|
147
|
+
definition: { skills: ["*"], extensions: ["*"] },
|
|
148
|
+
};
|
|
149
|
+
const discovery = await discoverLauncherResources({ ...context, projectTrusted });
|
|
150
|
+
const plan = await resolveProfile({
|
|
151
|
+
profile: synthetic,
|
|
152
|
+
skills: discovery.skills,
|
|
153
|
+
extensions: discovery.extensions,
|
|
154
|
+
overlay,
|
|
155
|
+
});
|
|
156
|
+
warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
|
|
157
|
+
return { plan, discovery, projectSettings, warnings };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const discovery = await discoverLauncherResources({ ...context, projectTrusted });
|
|
161
|
+
const plan = await resolveProfile({
|
|
162
|
+
profile,
|
|
163
|
+
skills: discovery.skills,
|
|
164
|
+
extensions: discovery.extensions,
|
|
165
|
+
validateModel: (model) => checkDeclaredModel(context.agentDir, model),
|
|
166
|
+
discoveredMcpServers: profile.definition.mcps?.length
|
|
167
|
+
? await discoverAdapterServerNames(context.agentDir, projectDir)
|
|
168
|
+
: undefined,
|
|
169
|
+
overlay: options?.overlay,
|
|
170
|
+
});
|
|
171
|
+
warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
|
|
172
|
+
if (plan.mcps !== undefined && !plan.extensions.some(isAdapterExtension)) {
|
|
173
|
+
// Fail before spawn: without the adapter in the active extension set
|
|
174
|
+
// nobody applies the allowlist, and the declared servers would either
|
|
175
|
+
// silently do nothing or leak through unfiltered.
|
|
176
|
+
throw new MissingMcpAdapterError(plan.profile);
|
|
177
|
+
}
|
|
178
|
+
return { plan, discovery, projectSettings, warnings };
|
|
179
|
+
}
|