pi-profile-switch 0.4.8 → 0.4.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
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": [
@@ -1,27 +1,34 @@
1
1
  /**
2
2
  * ExtensionDiscovery: implicit, read-only discovery of selectable extensions
3
- * (ADR-0006), so profiles can reference extensions without any
3
+ * (ADR-0006/0007), so profiles can reference extensions without any
4
4
  * registration step.
5
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.
6
+ * Two implicit sources, both resolved by Pi itself:
7
+ * - Configured packages: `DefaultPackageManager.resolve()` expands each
8
+ * package's `package.json#pi.extensions` (files, directories, globs, the
9
+ * package's own `+`/`-`/`!` filters, `.gitignore` rules) exactly like the
10
+ * spawned pi does at startup. The package name — or an alias such as the
11
+ * `npm:` source string — is the profile-facing reference.
12
+ * - Loose extension files: `<agentDir>/extensions/**` and, for trusted
13
+ * projects, `<projectDir>/.pi/extensions/**`, referenced by a path-derived
14
+ * ID ("conventions", "guards/review").
13
15
  *
14
- * Discovery never executes extension code and never installs anything: a
15
- * package contributes entries only for declared files that exist on disk.
16
+ * This module owns naming, merging, selection, and glob expansion only. Entry
17
+ * enumeration is Pi's own, so a new Pi convention (or a fix to one) cannot
18
+ * silently diverge from what the spawned pi actually loads.
19
+ *
20
+ * Discovery stays side-effect free: `resolve()` is called with
21
+ * `onMissing: "skip"`, which never installs and never touches the network,
22
+ * and extension code is never imported.
16
23
  */
17
24
 
18
- import { readdir, readFile, stat } from "node:fs/promises";
25
+ import { readFile, stat } from "node:fs/promises";
19
26
  import path from "node:path";
20
27
 
28
+ import { DefaultPackageManager, SettingsManager, type PackageManager } from "@earendil-works/pi-coding-agent";
21
29
  import { minimatch } from "minimatch";
22
30
 
23
31
  import { isRecord } from "./json-file.ts";
24
- import type { ConfiguredPackageRoot } from "./settings-generator.ts";
25
32
 
26
33
  export interface DiscoveredPackage {
27
34
  /** Selectable package name: package.json "name", or the source minus its
@@ -32,12 +39,14 @@ export interface DiscoveredPackage {
32
39
  source: string;
33
40
  /** Absolute install/local root of the package. */
34
41
  root: string;
35
- /** Absolute paths of declared `pi.extensions` entries that exist on disk. */
42
+ /** Absolute paths of the package's enabled extension entries. */
36
43
  entries: string[];
37
44
  }
38
45
 
39
46
  export interface DiscoveredLocalExtension {
40
- /** Selectable ID: the filename stem ("conventions" for conventions.ts). */
47
+ /** Selectable ID: the path under the extensions dir, without the file
48
+ * extension and with a trailing "/index" collapsed ("conventions",
49
+ * "guards/review"). */
41
50
  id: string;
42
51
  entry: string;
43
52
  }
@@ -72,7 +81,7 @@ function toPosix(filePath: string): string {
72
81
  return filePath.split(path.sep).join("/");
73
82
  }
74
83
 
75
- async function exists(filePath: string): Promise<boolean> {
84
+ async function isFile(filePath: string): Promise<boolean> {
76
85
  try {
77
86
  return (await stat(filePath)).isFile();
78
87
  } catch {
@@ -205,7 +214,7 @@ export class DiscoveredExtensions {
205
214
  const pkgEntries = this.#packageEntries(pkg);
206
215
  if (pkgEntries.length === 0) {
207
216
  throw new ExtensionError(
208
- `package "${reference}" declares no extension entries (its pi.extensions files are missing or shadowed by local files)`,
217
+ `package "${reference}" declares no extension entries (missing on disk, filtered out by its settings package entry, or shadowed by local files)`,
209
218
  );
210
219
  }
211
220
  for (const pkgEntry of pkgEntries) add(pkgEntry);
@@ -220,7 +229,7 @@ export class DiscoveredExtensions {
220
229
  const resolved = reference.startsWith("~/")
221
230
  ? path.join(process.env.HOME ?? "", reference.slice(1))
222
231
  : path.resolve(reference);
223
- if (!(await exists(resolved))) {
232
+ if (!(await isFile(resolved))) {
224
233
  throw new ExtensionError(`extension path not found: ${resolved}`);
225
234
  }
226
235
  add({ id: resolved, entry: resolved, origin: "path" });
@@ -233,8 +242,6 @@ export class DiscoveredExtensions {
233
242
  }
234
243
  }
235
244
 
236
- const LOOSE_FILE_PATTERN = /\.(ts|js)$/;
237
-
238
245
  /** Derives a package name from its source string when package.json is
239
246
  * unreadable: strips the npm:/git:/github: prefix and any version spec
240
247
  * (scoped names keep their "@scope/" prefix). */
@@ -252,93 +259,141 @@ export function packageNameFromSource(source: string): string {
252
259
  return name;
253
260
  }
254
261
 
255
- /** Reads one installed package's declared extension entries. Packages
256
- * without a readable package.json or without `pi.extensions` contribute
257
- * nothing (skills-only packages are the common case). Declared entries
258
- * missing on disk are skipped — the spawned pi reports load errors itself. */
259
- async function readPackageExtensions(pkg: ConfiguredPackageRoot): Promise<DiscoveredPackage | undefined> {
260
- if (pkg.root === undefined) return undefined;
261
- let manifest: unknown;
262
- try {
263
- manifest = JSON.parse(await readFile(path.join(pkg.root, "package.json"), "utf8"));
264
- } catch {
265
- return undefined; // not installed yet or unreadable — nothing selectable
266
- }
267
- if (!isRecord(manifest)) return undefined;
268
- const pi = manifest.pi;
269
- const declared =
270
- isRecord(pi) && Array.isArray(pi.extensions) ? pi.extensions.filter((e): e is string => typeof e === "string") : [];
271
- if (declared.length === 0) return undefined;
272
- const entries: string[] = [];
273
- for (const rel of declared) {
274
- const entry = path.resolve(pkg.root, rel);
275
- if (await exists(entry)) entries.push(entry);
276
- }
277
- if (entries.length === 0) return undefined;
278
- const name = typeof manifest.name === "string" && manifest.name.length > 0 ? manifest.name : packageNameFromSource(pkg.source);
279
- return { name, source: pkg.source, root: pkg.root, entries };
262
+ /** Selectable ID for a loose extension file: its path under the extensions
263
+ * dir without the file extension, with a trailing "/index" collapsed so a
264
+ * directory-style extension is referenced by its directory name. */
265
+ function looseId(filePath: string, extensionsDir: string): string {
266
+ const rel = toPosix(path.relative(extensionsDir, filePath));
267
+ const collapsed = rel.replace(/(?:^|\/)index\.(ts|js)$/, "");
268
+ const base = collapsed === "" ? rel : collapsed;
269
+ return base.replace(/\.(ts|js)$/, "");
280
270
  }
281
271
 
282
- /** Lists loose extension files in one directory; missing dir → empty.
283
- * A `.ts`/`.js` stem pair resolves to the `.ts` file (Pi's convention:
284
- * TypeScript sources are the canonical form) and is reported, not silent. */
285
- async function scanLooseDir(dir: string, warnings: string[]): Promise<DiscoveredLocalExtension[]> {
286
- let files: string[];
272
+ /** Reads a package's display name from its manifest, falling back to the
273
+ * source string when the manifest is missing or has no name. */
274
+ async function readPackageName(root: string, source: string): Promise<string> {
287
275
  try {
288
- files = (await readdir(dir)).filter((name) => LOOSE_FILE_PATTERN.test(name));
276
+ const manifest: unknown = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
277
+ if (isRecord(manifest) && typeof manifest.name === "string" && manifest.name.length > 0) return manifest.name;
289
278
  } catch {
290
- return [];
291
- }
292
- const byStem = new Map<string, string>();
293
- for (const name of files.sort()) {
294
- const stem = name.replace(LOOSE_FILE_PATTERN, "");
295
- const full = path.join(dir, name);
296
- const existing = byStem.get(stem);
297
- if (existing !== undefined) {
298
- if (!existing.endsWith(".ts") && name.endsWith(".ts")) {
299
- warnings.push(`extension "${stem}" exists as both .ts and .js in ${dir}; the .ts file is used`);
300
- byStem.set(stem, full);
301
- }
302
- continue;
303
- }
304
- byStem.set(stem, full);
279
+ // Not installed (a skipped source) or unreadable — derive from source.
305
280
  }
306
- return [...byStem.entries()].map(([id, entry]) => ({ id, entry }));
281
+ return packageNameFromSource(source);
307
282
  }
308
283
 
309
- export async function discoverImplicitExtensions(options: {
284
+ export interface DiscoverExtensionsOptions {
285
+ /** Project working directory (Pi's cwd). */
286
+ cwd: string;
287
+ /** The user's real agent dir (e.g. ~/.pi/agent). */
310
288
  agentDir: string;
311
- /** Configured user-scope packages with resolved roots (launcher discovery). */
312
- packages: ConfiguredPackageRoot[];
313
- /** Trusted project dir; untrusted projects are never scanned. */
314
- projectDir?: string;
315
- }): Promise<ImplicitExtensionDiscovery> {
289
+ /**
290
+ * Whether the project at `cwd` is trusted (the launcher's trust check).
291
+ * Untrusted projects contribute nothing: no project settings packages, no
292
+ * `.pi/extensions` files. Defaults to false.
293
+ */
294
+ projectTrusted?: boolean;
295
+ }
296
+
297
+ /** Builds the Pi package manager that owns discovery for one cwd/agentDir. */
298
+ function createPackageManager(options: DiscoverExtensionsOptions): PackageManager {
299
+ const settingsManager = SettingsManager.create(options.cwd, options.agentDir, {
300
+ projectTrusted: options.projectTrusted ?? false,
301
+ });
302
+ return new DefaultPackageManager({
303
+ cwd: options.cwd,
304
+ agentDir: options.agentDir,
305
+ settingsManager,
306
+ });
307
+ }
308
+
309
+ interface PackageGroup {
310
+ source: string;
311
+ root: string;
312
+ entries: string[];
313
+ /** Some declared entry exists on disk, even if all are disabled — keeps
314
+ * "package is filtered out" distinguishable from "package has no
315
+ * extensions". */
316
+ known: boolean;
317
+ }
318
+
319
+ /** Runs Pi's own resolution once and classifies the result into pi-profile's
320
+ * selectable model. */
321
+ async function resolveImplicit(packageManager: PackageManager, agentDir: string): Promise<ImplicitExtensionDiscovery> {
316
322
  const warnings: string[] = [];
323
+ // `skip` is the API's read-only mode: missing sources are reported as
324
+ // absent instead of triggering an install (no network, no mutation).
325
+ const resolved = await packageManager.resolve(async () => "skip");
326
+ // Sort for deterministic IDs and ordering: Pi preserves directory read
327
+ // order, which varies by filesystem.
328
+ const resources = [...resolved.extensions].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
329
+
330
+ const groups = new Map<string, PackageGroup>();
331
+ const userLoose = new Map<string, DiscoveredLocalExtension>();
332
+ const projectLoose = new Map<string, DiscoveredLocalExtension>();
333
+
334
+ for (const resource of resources) {
335
+ const { source, scope, origin, baseDir } = resource.metadata;
336
+ if (origin === "package") {
337
+ // Project-scope packages install under the project's .pi/npm, which
338
+ // generated global-scope settings cannot reference.
339
+ if (scope === "project") continue;
340
+ if (baseDir === undefined) continue;
341
+ const group = groups.get(source) ?? { source, root: baseDir, entries: [], known: false };
342
+ // A local source with no manifest and no convention dir resolves to
343
+ // the directory itself; the loader imports paths verbatim, so it is
344
+ // not selectable and must not be advertised.
345
+ if (await isFile(resource.path)) {
346
+ group.known = true;
347
+ if (resource.enabled && !group.entries.includes(resource.path)) group.entries.push(resource.path);
348
+ }
349
+ groups.set(source, group);
350
+ continue;
351
+ }
352
+ // Settings-declared paths (`extensions: [...]`) are already concrete
353
+ // files, so profiles reference them directly instead of through a
354
+ // derived ID; only auto-discovered directory contents become IDs.
355
+ if (!resource.enabled || source !== "auto") continue;
356
+ const extensionsDir = path.join(baseDir ?? agentDir, "extensions");
357
+ const id = looseId(resource.path, extensionsDir);
358
+ const target = scope === "project" ? projectLoose : userLoose;
359
+ const existing = target.get(id);
360
+ if (existing === undefined) {
361
+ target.set(id, { id, entry: resource.path });
362
+ continue;
363
+ }
364
+ // Pi reports a `.ts`/`.js` stem pair as two entries; keep its
365
+ // TypeScript-first convention, but say so instead of silently
366
+ // shadowing the file the loader would pick.
367
+ if (!existing.entry.endsWith(".ts") && resource.path.endsWith(".ts")) {
368
+ warnings.push(`extension "${id}" exists as both .ts and .js in ${extensionsDir}; the .ts file is used`);
369
+ target.set(id, { id, entry: resource.path });
370
+ }
371
+ }
317
372
 
318
373
  const packages: DiscoveredPackage[] = [];
319
- for (const pkg of options.packages) {
320
- const discovered = await readPackageExtensions(pkg);
321
- if (discovered !== undefined) packages.push(discovered);
374
+ for (const group of groups.values()) {
375
+ if (!group.known) continue;
376
+ packages.push({
377
+ name: await readPackageName(group.root, group.source),
378
+ source: group.source,
379
+ root: group.root,
380
+ entries: group.entries,
381
+ });
322
382
  }
323
383
 
324
- const globalLocal = await scanLooseDir(path.join(options.agentDir, "extensions"), warnings);
325
- const merged = new Map<string, DiscoveredLocalExtension>(globalLocal.map((entry) => [entry.id, entry]));
326
- if (options.projectDir !== undefined) {
327
- // Project loose files override same-ID global ones, mirroring the
328
- // project-over-global catalog override convention.
329
- for (const entry of await scanLooseDir(path.join(options.projectDir, ".pi", "extensions"), warnings)) {
330
- merged.set(entry.id, entry);
331
- }
332
- }
384
+ // Project loose files override same-ID global ones, mirroring the
385
+ // project-over-global catalog override convention.
386
+ const merged = new Map(userLoose);
387
+ for (const [id, entry] of projectLoose) merged.set(id, entry);
333
388
 
334
389
  return { packages, local: [...merged.values()], warnings };
335
390
  }
336
391
 
337
- export async function discoverExtensions(options: {
338
- agentDir: string;
339
- packages: ConfiguredPackageRoot[];
340
- projectDir?: string;
341
- }): Promise<DiscoveredExtensions> {
392
+ export async function discoverImplicitExtensions(options: DiscoverExtensionsOptions): Promise<ImplicitExtensionDiscovery> {
393
+ return await resolveImplicit(createPackageManager(options), options.agentDir);
394
+ }
395
+
396
+ export async function discoverExtensions(options: DiscoverExtensionsOptions): Promise<DiscoveredExtensions> {
342
397
  const raw = await discoverImplicitExtensions(options);
343
398
  return new DiscoveredExtensions(raw.packages, raw.local, raw.warnings);
344
399
  }
@@ -54,10 +54,12 @@ export async function discoverLauncherResources(
54
54
  .map((pkg) => ({ source: pkg.source, root: resolvePackageRoot(pkg.source, pkg.installedPath, context.agentDir) }));
55
55
  const [skills, extensions] = await Promise.all([
56
56
  discoverSkills(context),
57
+ // Extension discovery resolves through Pi's own package manager, so it
58
+ // only needs the same cwd/agentDir/trust inputs as skill discovery.
57
59
  discoverExtensions({
60
+ cwd: context.cwd,
58
61
  agentDir: context.agentDir,
59
- packages: configured,
60
- projectDir: context.projectTrusted ? context.cwd : undefined,
62
+ projectTrusted: context.projectTrusted,
61
63
  }),
62
64
  ]);
63
65
  return { skills, packages: configured, extensions };