pi-extmgr 0.1.28 → 0.2.1

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.
@@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { type Dirent } from "node:fs";
3
3
  import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { dirname, join, matchesGlob, relative, resolve } from "node:path";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { promisify } from "node:util";
8
8
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
@@ -14,6 +14,11 @@ import {
14
14
  } from "../types/index.js";
15
15
  import { parseNpmSource } from "../utils/format.js";
16
16
  import { fileExists, readSummary } from "../utils/fs.js";
17
+ import {
18
+ matchesFilterPattern,
19
+ normalizeRelativePath,
20
+ resolveRelativePathSelection,
21
+ } from "../utils/relative-path-selection.js";
17
22
  import { resolveNpmCommand } from "../utils/npm-exec.js";
18
23
 
19
24
  interface PackageSettingsObject {
@@ -36,11 +41,6 @@ export interface PackageManifest {
36
41
  const execFileAsync = promisify(execFile);
37
42
  let globalNpmRootCache: string | null | undefined;
38
43
 
39
- function normalizeRelativePath(value: string): string {
40
- const normalized = value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
41
- return normalized;
42
- }
43
-
44
44
  function normalizeSource(source: string): string {
45
45
  return source
46
46
  .trim()
@@ -238,17 +238,17 @@ function toPackageSettingsObject(
238
238
  packageSource: string
239
239
  ): PackageSettingsObject {
240
240
  if (typeof existing === "string") {
241
- return { source: existing, extensions: [] };
241
+ return { source: existing };
242
242
  }
243
243
 
244
244
  if (existing && typeof existing.source === "string") {
245
245
  return {
246
246
  source: existing.source,
247
- extensions: Array.isArray(existing.extensions) ? [...existing.extensions] : [],
247
+ ...(Array.isArray(existing.extensions) ? { extensions: [...existing.extensions] } : {}),
248
248
  };
249
249
  }
250
250
 
251
- return { source: packageSource, extensions: [] };
251
+ return { source: packageSource };
252
252
  }
253
253
 
254
254
  function updateExtensionMarkers(
@@ -276,7 +276,16 @@ function updateExtensionMarkers(
276
276
  for (const [extensionPath, target] of Array.from(changes.entries()).sort((a, b) =>
277
277
  a[0].localeCompare(b[0])
278
278
  )) {
279
- nextTokens.push(`${target === "enabled" ? "+" : "-"}${extensionPath}`);
279
+ const baseFilters =
280
+ nextTokens.length > 0
281
+ ? nextTokens
282
+ : existingTokens && existingTokens.length === 0
283
+ ? []
284
+ : undefined;
285
+ const baseState = getPackageFilterState(baseFilters, extensionPath);
286
+ if (target !== baseState) {
287
+ nextTokens.push(`${target === "enabled" ? "+" : "-"}${extensionPath}`);
288
+ }
280
289
  }
281
290
 
282
291
  return nextTokens;
@@ -322,10 +331,13 @@ export async function applyPackageExtensionStateChanges(
322
331
 
323
332
  packageEntry.extensions = updateExtensionMarkers(packageEntry.extensions, normalizedChanges);
324
333
 
334
+ const normalizedPackageEntry =
335
+ packageEntry.extensions.length > 0 ? packageEntry : packageEntry.source;
336
+
325
337
  if (index === -1) {
326
- packages.push(packageEntry);
338
+ packages.push(normalizedPackageEntry);
327
339
  } else {
328
- packages[index] = packageEntry;
340
+ packages[index] = normalizedPackageEntry;
329
341
  }
330
342
 
331
343
  settings.packages = packages;
@@ -340,22 +352,6 @@ export async function applyPackageExtensionStateChanges(
340
352
  }
341
353
  }
342
354
 
343
- function safeMatchesGlob(targetPath: string, pattern: string): boolean {
344
- try {
345
- return matchesGlob(targetPath, pattern);
346
- } catch {
347
- return false;
348
- }
349
- }
350
-
351
- function matchesFilterPattern(targetPath: string, pattern: string): boolean {
352
- const normalizedPattern = normalizeRelativePath(pattern.trim());
353
- if (!normalizedPattern) return false;
354
- if (targetPath === normalizedPattern) return true;
355
-
356
- return safeMatchesGlob(targetPath, normalizedPattern);
357
- }
358
-
359
355
  function getPackageFilterState(filters: string[] | undefined, extensionPath: string): State {
360
356
  // Omitted key => all enabled (pi default).
361
357
  if (filters === undefined) {
@@ -415,58 +411,37 @@ function getPackageFilterState(filters: string[] | undefined, extensionPath: str
415
411
  return enabled ? "enabled" : "disabled";
416
412
  }
417
413
 
418
- async function getPackageExtensionState(
419
- packageSource: string,
420
- extensionPath: string,
414
+ async function readPackageFilterMap(
421
415
  scope: Scope,
422
416
  cwd: string
423
- ): Promise<State> {
424
- const settingsPath = getSettingsPath(scope, cwd);
425
- const settings = await readSettingsFile(settingsPath);
417
+ ): Promise<Map<string, string[] | undefined>> {
418
+ const settings = await readSettingsFile(getSettingsPath(scope, cwd));
426
419
  const packages = settings.packages ?? [];
427
- const normalizedSource = normalizeSource(packageSource);
420
+ const filterMap = new Map<string, string[] | undefined>();
428
421
 
429
- const entry = packages.find((pkg) => {
430
- if (typeof pkg === "string") {
431
- return normalizeSource(pkg) === normalizedSource;
422
+ for (const entry of packages) {
423
+ if (typeof entry === "string") {
424
+ filterMap.set(normalizeSource(entry), undefined);
425
+ continue;
432
426
  }
433
- return normalizeSource(pkg.source) === normalizedSource;
434
- });
435
427
 
436
- if (!entry || typeof entry === "string") {
437
- return "enabled";
428
+ if (typeof entry.source !== "string") {
429
+ continue;
430
+ }
431
+
432
+ filterMap.set(
433
+ normalizeSource(entry.source),
434
+ Array.isArray(entry.extensions) ? entry.extensions : undefined
435
+ );
438
436
  }
439
437
 
440
- return getPackageFilterState(entry.extensions, extensionPath);
438
+ return filterMap;
441
439
  }
442
440
 
443
441
  function isExtensionEntrypointPath(path: string): boolean {
444
442
  return /\.(ts|js)$/i.test(path);
445
443
  }
446
444
 
447
- function hasGlobMagic(path: string): boolean {
448
- return /[*?{}[\]]/.test(path);
449
- }
450
-
451
- function isSafeRelativePath(path: string): boolean {
452
- return path !== "" && path !== ".." && !path.startsWith("../") && !path.includes("/../");
453
- }
454
-
455
- function selectDirectoryFiles(allFiles: string[], directoryPath: string): string[] {
456
- const prefix = `${directoryPath}/`;
457
- return allFiles.filter((file) => file.startsWith(prefix));
458
- }
459
-
460
- function applySelection(selected: Set<string>, files: Iterable<string>, exclude: boolean): void {
461
- for (const file of files) {
462
- if (exclude) {
463
- selected.delete(file);
464
- } else {
465
- selected.add(file);
466
- }
467
- }
468
- }
469
-
470
445
  async function collectExtensionFilesFromDir(
471
446
  packageRoot: string,
472
447
  startDir: string
@@ -505,38 +480,12 @@ async function resolveManifestExtensionEntries(
505
480
  packageRoot: string,
506
481
  entries: string[]
507
482
  ): Promise<string[]> {
508
- const selected = new Set<string>();
509
483
  const allFiles = await collectExtensionFilesFromDir(packageRoot, packageRoot);
510
-
511
- for (const rawToken of entries) {
512
- const token = rawToken.trim();
513
- if (!token) continue;
514
-
515
- const exclude = token.startsWith("!");
516
- const normalizedToken = normalizeRelativePath(exclude ? token.slice(1) : token);
517
- const pattern = normalizedToken.replace(/[\\/]+$/g, "");
518
- if (!isSafeRelativePath(pattern)) {
519
- continue;
520
- }
521
-
522
- if (hasGlobMagic(pattern)) {
523
- const matchedFiles = allFiles.filter((file) => matchesFilterPattern(file, pattern));
524
- applySelection(selected, matchedFiles, exclude);
525
- continue;
526
- }
527
-
528
- const directoryFiles = selectDirectoryFiles(allFiles, pattern);
529
- if (directoryFiles.length > 0) {
530
- applySelection(selected, directoryFiles, exclude);
531
- continue;
532
- }
533
-
534
- if (isExtensionEntrypointPath(pattern)) {
535
- applySelection(selected, [pattern], exclude);
536
- }
537
- }
538
-
539
- return Array.from(selected).sort((a, b) => a.localeCompare(b));
484
+ return resolveRelativePathSelection(
485
+ allFiles,
486
+ entries,
487
+ (path, files) => isExtensionEntrypointPath(path) && files.includes(path)
488
+ );
540
489
  }
541
490
 
542
491
  export async function readPackageManifest(
@@ -617,11 +566,19 @@ export async function discoverPackageExtensions(
617
566
  cwd: string
618
567
  ): Promise<PackageExtensionEntry[]> {
619
568
  const entries: PackageExtensionEntry[] = [];
569
+ const [globalFilterMap, projectFilterMap] = await Promise.all([
570
+ readPackageFilterMap("global", cwd),
571
+ readPackageFilterMap("project", cwd),
572
+ ]);
620
573
 
621
574
  for (const pkg of packages) {
622
575
  const packageRoot = await toPackageRoot(pkg, cwd);
623
576
  if (!packageRoot) continue;
624
577
 
578
+ const packageFilters =
579
+ (pkg.scope === "global" ? globalFilterMap : projectFilterMap).get(
580
+ normalizeSource(pkg.source)
581
+ ) ?? undefined;
625
582
  const extensionPaths = await discoverPackageExtensionEntrypoints(packageRoot);
626
583
  for (const extensionPath of extensionPaths) {
627
584
  const normalizedPath = normalizeRelativePath(extensionPath);
@@ -629,7 +586,7 @@ export async function discoverPackageExtensions(
629
586
  const summary = (await fileExists(absolutePath))
630
587
  ? await readSummary(absolutePath)
631
588
  : "package extension";
632
- const state = await getPackageExtensionState(pkg.source, normalizedPath, pkg.scope, cwd);
589
+ const state = getPackageFilterState(packageFilters, normalizedPath);
633
590
 
634
591
  entries.push({
635
592
  id: `pkg-ext:${pkg.scope}:${pkg.source}:${normalizedPath}`,
@@ -11,6 +11,7 @@ import {
11
11
  } from "@mariozechner/pi-coding-agent";
12
12
  import { TIMEOUTS } from "../constants.js";
13
13
  import { runTaskWithLoader } from "../ui/async-task.js";
14
+ import { parseChoiceByLabel } from "../utils/command.js";
14
15
  import { normalizePackageSource } from "../utils/format.js";
15
16
  import { fileExists } from "../utils/fs.js";
16
17
  import { logPackageInstall } from "../utils/history.js";
@@ -32,6 +33,17 @@ export interface InstallOptions {
32
33
  scope?: InstallScope;
33
34
  }
34
35
 
36
+ export interface InstallOutcome {
37
+ installed: boolean;
38
+ reloaded: boolean;
39
+ }
40
+
41
+ const INSTALL_SCOPE_CHOICES = {
42
+ global: "Global (~/.pi/agent/settings.json)",
43
+ project: "Project (.pi/settings.json)",
44
+ cancel: "Cancel",
45
+ } as const;
46
+
35
47
  function getProgressMessage(event: ProgressEvent, fallback: string): string {
36
48
  return event.message?.trim() || fallback;
37
49
  }
@@ -44,14 +56,12 @@ async function resolveInstallScope(
44
56
 
45
57
  if (!ctx.hasUI) return "global";
46
58
 
47
- const choice = await ctx.ui.select("Install scope", [
48
- "Global (~/.pi/agent/settings.json)",
49
- "Project (.pi/settings.json)",
50
- "Cancel",
51
- ]);
59
+ const choice = parseChoiceByLabel(
60
+ INSTALL_SCOPE_CHOICES,
61
+ await ctx.ui.select("Install scope", Object.values(INSTALL_SCOPE_CHOICES))
62
+ );
52
63
 
53
- if (!choice || choice === "Cancel") return undefined;
54
- return choice.startsWith("Project") ? "project" : "global";
64
+ return choice === "cancel" ? undefined : choice;
55
65
  }
56
66
 
57
67
  function getExtensionInstallDir(ctx: ExtensionCommandContext, scope: InstallScope): string {
@@ -61,28 +71,6 @@ function getExtensionInstallDir(ctx: ExtensionCommandContext, scope: InstallScop
61
71
  return join(homedir(), ".pi", "agent", "extensions");
62
72
  }
63
73
 
64
- interface GithubUrlInfo {
65
- owner: string;
66
- repo: string;
67
- branch: string;
68
- filePath: string;
69
- }
70
-
71
- /**
72
- * Safely extracts regex match groups with validation
73
- */
74
- function safeExtractGithubMatch(match: RegExpMatchArray | null): GithubUrlInfo | undefined {
75
- if (!match) return undefined;
76
-
77
- const [, owner, repo, branch, filePath] = match;
78
-
79
- if (!owner || !repo || !branch || !filePath) {
80
- return undefined;
81
- }
82
-
83
- return { owner, repo, branch, filePath };
84
- }
85
-
86
74
  async function ensureTarAvailable(
87
75
  pi: ExtensionAPI,
88
76
  ctx: ExtensionCommandContext
@@ -157,36 +145,35 @@ async function cleanupStandaloneTempArtifacts(tempDir: string, extractDir?: stri
157
145
  );
158
146
  }
159
147
 
160
- export async function installPackage(
148
+ async function installPackageInternal(
161
149
  source: string,
162
150
  ctx: ExtensionCommandContext,
163
151
  pi: ExtensionAPI,
164
152
  options?: InstallOptions
165
- ): Promise<void> {
153
+ ): Promise<InstallOutcome> {
166
154
  const scope = await resolveInstallScope(ctx, options?.scope);
167
155
  if (!scope) {
168
156
  notify(ctx, "Installation cancelled.", "info");
169
- return;
157
+ return { installed: false, reloaded: false };
170
158
  }
171
159
 
172
160
  // Check if it's a GitHub URL to a .ts file - handle as direct download
173
161
  const githubTsMatch = source.match(
174
162
  /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+\.ts)$/
175
163
  );
176
- const githubInfo = safeExtractGithubMatch(githubTsMatch);
177
- if (githubInfo) {
178
- const rawUrl = `https://raw.githubusercontent.com/${githubInfo.owner}/${githubInfo.repo}/${githubInfo.branch}/${githubInfo.filePath}`;
179
- const fileName =
180
- githubInfo.filePath.split("/").pop() || `${githubInfo.owner}-${githubInfo.repo}.ts`;
181
- await installFromUrl(rawUrl, fileName, ctx, pi, { scope });
182
- return;
164
+ if (githubTsMatch) {
165
+ const [, owner, repo, branch, filePath] = githubTsMatch;
166
+ if (owner && repo && branch && filePath) {
167
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`;
168
+ const fileName = filePath.split("/").pop() || `${owner}-${repo}.ts`;
169
+ return await installFromUrl(rawUrl, fileName, ctx, pi, { scope });
170
+ }
183
171
  }
184
172
 
185
173
  // Check if it's already a raw URL to a .ts file
186
174
  if (source.match(/^https:\/\/raw\.githubusercontent\.com\/.*\.ts$/)) {
187
175
  const fileName = source.split("/").pop() || "extension.ts";
188
- await installFromUrl(source, fileName, ctx, pi, { scope });
189
- return;
176
+ return await installFromUrl(source, fileName, ctx, pi, { scope });
190
177
  }
191
178
 
192
179
  const normalized = normalizePackageSource(source);
@@ -199,7 +186,7 @@ export async function installPackage(
199
186
  );
200
187
  if (!confirmed) {
201
188
  notify(ctx, "Installation cancelled.", "info");
202
- return;
189
+ return { installed: false, reloaded: false };
203
190
  }
204
191
 
205
192
  showProgress(ctx, "Installing", normalized);
@@ -226,7 +213,7 @@ export async function installPackage(
226
213
  logPackageInstall(pi, normalized, normalized, undefined, scope, false, errorMsg);
227
214
  notifyError(ctx, errorMsg);
228
215
  void updateExtmgrStatus(ctx, pi);
229
- return;
216
+ return { installed: false, reloaded: false };
230
217
  }
231
218
 
232
219
  clearSearchCache();
@@ -238,6 +225,26 @@ export async function installPackage(
238
225
  if (!reloaded) {
239
226
  void updateExtmgrStatus(ctx, pi);
240
227
  }
228
+
229
+ return { installed: true, reloaded };
230
+ }
231
+
232
+ export async function installPackage(
233
+ source: string,
234
+ ctx: ExtensionCommandContext,
235
+ pi: ExtensionAPI,
236
+ options?: InstallOptions
237
+ ): Promise<void> {
238
+ await installPackageInternal(source, ctx, pi, options);
239
+ }
240
+
241
+ export async function installPackageWithOutcome(
242
+ source: string,
243
+ ctx: ExtensionCommandContext,
244
+ pi: ExtensionAPI,
245
+ options?: InstallOptions
246
+ ): Promise<InstallOutcome> {
247
+ return installPackageInternal(source, ctx, pi, options);
241
248
  }
242
249
 
243
250
  export async function installFromUrl(
@@ -246,11 +253,11 @@ export async function installFromUrl(
246
253
  ctx: ExtensionCommandContext,
247
254
  pi: ExtensionAPI,
248
255
  options?: InstallOptions
249
- ): Promise<void> {
256
+ ): Promise<InstallOutcome> {
250
257
  const scope = await resolveInstallScope(ctx, options?.scope);
251
258
  if (!scope) {
252
259
  notify(ctx, "Installation cancelled.", "info");
253
- return;
260
+ return { installed: false, reloaded: false };
254
261
  }
255
262
 
256
263
  const extensionDir = getExtensionInstallDir(ctx, scope);
@@ -263,7 +270,7 @@ export async function installFromUrl(
263
270
  );
264
271
  if (!confirmed) {
265
272
  notify(ctx, "Installation cancelled.", "info");
266
- return;
273
+ return { installed: false, reloaded: false };
267
274
  }
268
275
 
269
276
  const result = await tryOperation(
@@ -289,7 +296,7 @@ export async function installFromUrl(
289
296
  if (!result) {
290
297
  logPackageInstall(pi, url, fileName, undefined, scope, false, "Installation failed");
291
298
  void updateExtmgrStatus(ctx, pi);
292
- return;
299
+ return { installed: false, reloaded: false };
293
300
  }
294
301
 
295
302
  const { fileName: name, destPath } = result;
@@ -300,6 +307,8 @@ export async function installFromUrl(
300
307
  if (!reloaded) {
301
308
  void updateExtmgrStatus(ctx, pi);
302
309
  }
310
+
311
+ return { installed: true, reloaded };
303
312
  }
304
313
 
305
314
  /**
@@ -324,16 +333,16 @@ function parsePackageInfo(viewOutput: string): { version: string; tarballUrl: st
324
333
  }
325
334
  }
326
335
 
327
- export async function installPackageLocally(
336
+ async function installPackageLocallyInternal(
328
337
  packageName: string,
329
338
  ctx: ExtensionCommandContext,
330
339
  pi: ExtensionAPI,
331
340
  options?: InstallOptions
332
- ): Promise<void> {
341
+ ): Promise<InstallOutcome> {
333
342
  const scope = await resolveInstallScope(ctx, options?.scope);
334
343
  if (!scope) {
335
344
  notify(ctx, "Installation cancelled.", "info");
336
- return;
345
+ return { installed: false, reloaded: false };
337
346
  }
338
347
 
339
348
  const extensionDir = getExtensionInstallDir(ctx, scope);
@@ -346,7 +355,7 @@ export async function installPackageLocally(
346
355
  );
347
356
  if (!confirmed) {
348
357
  notify(ctx, "Installation cancelled.", "info");
349
- return;
358
+ return { installed: false, reloaded: false };
350
359
  }
351
360
 
352
361
  const result = await tryOperation(
@@ -384,7 +393,7 @@ export async function installPackageLocally(
384
393
  "Failed to fetch package info"
385
394
  );
386
395
  void updateExtmgrStatus(ctx, pi);
387
- return;
396
+ return { installed: false, reloaded: false };
388
397
  }
389
398
  const { version, tarballUrl } = result;
390
399
 
@@ -401,7 +410,7 @@ export async function installPackageLocally(
401
410
  tarAvailability.error
402
411
  );
403
412
  void updateExtmgrStatus(ctx, pi);
404
- return;
413
+ return { installed: false, reloaded: false };
405
414
  }
406
415
 
407
416
  // Download and extract
@@ -439,7 +448,7 @@ export async function installPackageLocally(
439
448
  "Download failed"
440
449
  );
441
450
  void updateExtmgrStatus(ctx, pi);
442
- return;
451
+ return { installed: false, reloaded: false };
443
452
  }
444
453
  const { tarballPath } = extractResult;
445
454
 
@@ -496,7 +505,7 @@ export async function installPackageLocally(
496
505
  "Extraction failed"
497
506
  );
498
507
  void updateExtmgrStatus(ctx, pi);
499
- return;
508
+ return { installed: false, reloaded: false };
500
509
  }
501
510
 
502
511
  // Copy to extensions dir
@@ -527,7 +536,7 @@ export async function installPackageLocally(
527
536
  "Failed to copy extension"
528
537
  );
529
538
  void updateExtmgrStatus(ctx, pi);
530
- return;
539
+ return { installed: false, reloaded: false };
531
540
  }
532
541
 
533
542
  clearSearchCache();
@@ -538,4 +547,24 @@ export async function installPackageLocally(
538
547
  if (!reloaded) {
539
548
  void updateExtmgrStatus(ctx, pi);
540
549
  }
550
+
551
+ return { installed: true, reloaded };
552
+ }
553
+
554
+ export async function installPackageLocally(
555
+ packageName: string,
556
+ ctx: ExtensionCommandContext,
557
+ pi: ExtensionAPI,
558
+ options?: InstallOptions
559
+ ): Promise<void> {
560
+ await installPackageLocallyInternal(packageName, ctx, pi, options);
561
+ }
562
+
563
+ export async function installPackageLocallyWithOutcome(
564
+ packageName: string,
565
+ ctx: ExtensionCommandContext,
566
+ pi: ExtensionAPI,
567
+ options?: InstallOptions
568
+ ): Promise<InstallOutcome> {
569
+ return installPackageLocallyInternal(packageName, ctx, pi, options);
541
570
  }