pi-extmgr 0.1.20 → 0.1.22

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 CHANGED
@@ -17,11 +17,16 @@ pi install npm:pi-extmgr
17
17
 
18
18
  Then reload Pi.
19
19
 
20
+ Requires Node.js `>=22.5.0`.
21
+
20
22
  ## Features
21
23
 
22
24
  - **Unified manager UI**
23
25
  - Local extensions (`~/.pi/agent/extensions`, `.pi/extensions`) and installed packages in one list
24
26
  - Scope indicators (global/project), status indicators, update badges
27
+ - **Package extension configuration panel**
28
+ - Configure individual extension entrypoints inside an installed package (`c` on package row)
29
+ - Persists to package filters in `settings.json` (no manual JSON editing)
25
30
  - **Safe staged local extension toggles**
26
31
  - Toggle with `Space/Enter`, apply with `S`
27
32
  - Unsaved-change guard when leaving (save/discard/stay)
@@ -54,22 +59,23 @@ Open the manager:
54
59
 
55
60
  ### In the manager
56
61
 
57
- | Key | Action |
58
- | ------------- | ------------------------------------------------ |
59
- | `↑↓` | Navigate |
60
- | `Space/Enter` | Toggle local/package extension on/off |
61
- | `S` | Save changes |
62
- | `Enter` / `A` | Actions on selected package (update/remove/view) |
63
- | `u` | Update selected package directly |
64
- | `X` | Remove selected item (package/local extension) |
65
- | `i` | Quick install by source |
66
- | `f` | Quick search |
67
- | `U` | Update all packages |
68
- | `t` | Auto-update wizard |
69
- | `P` / `M` | Quick actions palette |
70
- | `R` | Browse remote packages |
71
- | `?` / `H` | Help |
72
- | `Esc` | Exit |
62
+ | Key | Action |
63
+ | ------------- | ----------------------------------------------------- |
64
+ | `↑↓` | Navigate |
65
+ | `Space/Enter` | Toggle local extension on/off |
66
+ | `S` | Save local extension changes |
67
+ | `Enter` / `A` | Actions on selected package (configure/update/remove) |
68
+ | `c` | Configure selected package extensions |
69
+ | `u` | Update selected package directly |
70
+ | `X` | Remove selected item (package/local extension) |
71
+ | `i` | Quick install by source |
72
+ | `f` | Quick search |
73
+ | `U` | Update all packages |
74
+ | `t` | Auto-update wizard |
75
+ | `P` / `M` | Quick actions palette |
76
+ | `R` | Browse remote packages |
77
+ | `?` / `H` | Help |
78
+ | `Esc` | Exit |
73
79
 
74
80
  ### Commands
75
81
 
@@ -134,7 +140,9 @@ Examples:
134
140
 
135
141
  ## Tips
136
142
 
137
- - **Staged changes**: Toggle extensions on/off, then press `S` to apply all at once. A `*` shows pending changes.
143
+ - **Staged local changes**: Toggle local extensions on/off, then press `S` to apply all at once.
144
+ - **Package extension config**: Select a package and press `c` (or Enter/A → Configure) to enable/disable individual package entrypoints.
145
+ - After saving package extension config, restart pi to fully apply changes.
138
146
  - **Two install modes**:
139
147
  - **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache
140
148
  - **Local** (standalone): Copies to `~/.pi/agent/extensions/{package}/`, supports multi-file extensions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -57,7 +57,7 @@
57
57
  "author": "ayagmar",
58
58
  "license": "MIT",
59
59
  "engines": {
60
- "node": ">=22"
60
+ "node": ">=22.5.0"
61
61
  },
62
62
  "repository": {
63
63
  "type": "git",
@@ -132,16 +132,20 @@ const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
132
132
  },
133
133
  };
134
134
 
135
- const COMMAND_ALIAS_TO_ID: Record<string, CommandId> = Object.values(COMMAND_DEFINITIONS).reduce(
136
- (acc, def) => {
137
- acc[def.id] = def.id;
135
+ function buildCommandAliasMap(
136
+ definitions: Record<CommandId, CommandDefinition>
137
+ ): Record<string, CommandId> {
138
+ const map: Record<string, CommandId> = {};
139
+ for (const def of Object.values(definitions)) {
140
+ map[def.id] = def.id;
138
141
  for (const alias of def.aliases ?? []) {
139
- acc[alias] = def.id;
142
+ map[alias] = def.id;
140
143
  }
141
- return acc;
142
- },
143
- {} as Record<string, CommandId>
144
- );
144
+ }
145
+ return map;
146
+ }
147
+
148
+ const COMMAND_ALIAS_TO_ID: Record<string, CommandId> = buildCommandAliasMap(COMMAND_DEFINITIONS);
145
149
 
146
150
  export function resolveCommand(tokens: string[]): { id: CommandId; args: string[] } | undefined {
147
151
  if (tokens.length === 0) {
package/src/constants.ts CHANGED
@@ -1,48 +1,79 @@
1
1
  /**
2
2
  * Constants for pi-extmgr
3
+ *
4
+ * All time values are in milliseconds unless otherwise noted.
3
5
  */
4
6
 
7
+ /** File extension suffix used to disable extensions (e.g., `extension.ts.disabled`) */
5
8
  export const DISABLED_SUFFIX = ".disabled";
9
+
10
+ /** Number of items to display per page in paginated views */
6
11
  export const PAGE_SIZE = 20;
7
- export const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
12
+
13
+ /** Default cache time-to-live: 5 minutes */
14
+ export const CACHE_TTL = 5 * 60 * 1000;
8
15
 
9
16
  /**
10
17
  * Timeout values for various operations (in milliseconds)
18
+ *
19
+ * These values balance user experience with reliability.
20
+ * Network operations get shorter timeouts, file operations get longer ones.
11
21
  */
12
22
  export const TIMEOUTS = {
13
- npmSearch: 20000,
14
- npmView: 10000,
15
- packageInstall: 180000,
16
- packageUpdate: 120000,
17
- packageRemove: 60000,
18
- listPackages: 10000,
19
- fetchPackageInfo: 30000,
20
- extractPackage: 30000,
21
- weeklyDownloads: 5000,
23
+ /** npm registry search timeout */
24
+ npmSearch: 20_000,
25
+ /** npm package metadata lookup timeout */
26
+ npmView: 10_000,
27
+ /** Full package installation timeout (3 minutes) */
28
+ packageInstall: 180_000,
29
+ /** Package update timeout (2 minutes) */
30
+ packageUpdate: 120_000,
31
+ /** Bulk package update timeout (5 minutes) */
32
+ packageUpdateAll: 300_000,
33
+ /** Package removal timeout (1 minute) */
34
+ packageRemove: 60_000,
35
+ /** Package listing timeout */
36
+ listPackages: 10_000,
37
+ /** Package metadata fetch timeout */
38
+ fetchPackageInfo: 30_000,
39
+ /** Package extraction timeout */
40
+ extractPackage: 30_000,
41
+ /** Weekly download stats timeout */
42
+ weeklyDownloads: 5_000,
22
43
  } as const;
23
44
 
24
45
  export type TimeoutKey = keyof typeof TIMEOUTS;
25
46
 
26
47
  /**
27
- * Cache limits (in milliseconds or count)
48
+ * Cache limits and TTL values (in milliseconds or count)
28
49
  */
29
50
  export const CACHE_LIMITS = {
51
+ /** Maximum number of package info entries to cache */
30
52
  packageInfoMaxSize: 100,
31
- metadataTTL: 24 * 60 * 60 * 1000, // 24 hours
32
- searchTTL: 15 * 60 * 1000, // 15 minutes
33
- packageInfoTTL: 6 * 60 * 60 * 1000, // 6 hours
53
+ /** Metadata cache TTL: 24 hours */
54
+ metadataTTL: 24 * 60 * 60 * 1000,
55
+ /** Search results cache TTL: 15 minutes */
56
+ searchTTL: 15 * 60 * 1000,
57
+ /** Package info cache TTL: 6 hours */
58
+ packageInfoTTL: 6 * 60 * 60 * 1000,
34
59
  } as const;
35
60
 
36
61
  export type CacheLimitKey = keyof typeof CACHE_LIMITS;
37
62
 
38
63
  /**
39
64
  * UI Constants
65
+ *
66
+ * These values control the user interface behavior and appearance.
40
67
  */
41
68
  export const UI = {
69
+ /** Maximum height for scrollable lists in terminal rows */
42
70
  maxListHeight: 16,
43
- searchThreshold: 8, // Enable search when items exceed this
44
- confirmTimeout: 30000,
45
- longConfirmTimeout: 60000,
71
+ /** Minimum number of items before enabling search functionality */
72
+ searchThreshold: 8,
73
+ /** Default confirmation dialog timeout: 30 seconds */
74
+ confirmTimeout: 30_000,
75
+ /** Extended confirmation timeout for destructive operations: 1 minute */
76
+ longConfirmTimeout: 60_000,
46
77
  } as const;
47
78
 
48
79
  export type UIKey = keyof typeof UI;
@@ -1,9 +1,13 @@
1
- import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
2
- import { dirname, join, relative, resolve } from "node:path";
1
+ import { mkdir, readFile, writeFile, rename, rm, readdir } from "node:fs/promises";
2
+ import type { Dirent } from "node:fs";
3
+ import { dirname, join, matchesGlob, relative, resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { homedir } from "node:os";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
5
8
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
6
9
  import type { InstalledPackage, PackageExtensionEntry, Scope, State } from "../types/index.js";
10
+ import { parseNpmSource } from "../utils/format.js";
7
11
  import { fileExists, readSummary } from "../utils/fs.js";
8
12
 
9
13
  interface PackageSettingsObject {
@@ -15,6 +19,9 @@ interface SettingsFile {
15
19
  packages?: (string | PackageSettingsObject)[];
16
20
  }
17
21
 
22
+ const execFileAsync = promisify(execFile);
23
+ let globalNpmRootCache: string | null | undefined;
24
+
18
25
  function normalizeRelativePath(value: string): string {
19
26
  const normalized = value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
20
27
  return normalized;
@@ -37,11 +44,69 @@ function normalizePackageRootCandidate(candidate: string): string {
37
44
  return resolved;
38
45
  }
39
46
 
40
- function toPackageRoot(pkg: InstalledPackage, cwd: string): string | undefined {
47
+ async function getGlobalNpmRoot(): Promise<string | undefined> {
48
+ if (globalNpmRootCache !== undefined) {
49
+ return globalNpmRootCache ?? undefined;
50
+ }
51
+
52
+ try {
53
+ const { stdout } = await execFileAsync("npm", ["root", "-g"], {
54
+ timeout: 2_000,
55
+ windowsHide: true,
56
+ });
57
+ const root = stdout.trim();
58
+ globalNpmRootCache = root || null;
59
+ } catch {
60
+ globalNpmRootCache = null;
61
+ }
62
+
63
+ return globalNpmRootCache ?? undefined;
64
+ }
65
+
66
+ async function resolveNpmPackageRoot(
67
+ pkg: InstalledPackage,
68
+ cwd: string
69
+ ): Promise<string | undefined> {
70
+ const parsed = parseNpmSource(pkg.source);
71
+ if (!parsed?.name) {
72
+ return undefined;
73
+ }
74
+
75
+ const packageName = parsed.name;
76
+ const projectCandidates = [
77
+ join(cwd, ".pi", "npm", "node_modules", packageName),
78
+ join(cwd, "node_modules", packageName),
79
+ ];
80
+
81
+ const packageDir = process.env.PI_PACKAGE_DIR || join(homedir(), ".pi", "agent");
82
+ const globalCandidates = [join(packageDir, "npm", "node_modules", packageName)];
83
+
84
+ const npmGlobalRoot = await getGlobalNpmRoot();
85
+ if (npmGlobalRoot) {
86
+ globalCandidates.unshift(join(npmGlobalRoot, packageName));
87
+ }
88
+
89
+ const candidates =
90
+ pkg.scope === "project" ? projectCandidates : [...globalCandidates, ...projectCandidates];
91
+
92
+ for (const candidate of candidates) {
93
+ if (await fileExists(join(candidate, "package.json"))) {
94
+ return candidate;
95
+ }
96
+ }
97
+
98
+ return undefined;
99
+ }
100
+
101
+ async function toPackageRoot(pkg: InstalledPackage, cwd: string): Promise<string | undefined> {
41
102
  if (pkg.resolvedPath) {
42
103
  return normalizePackageRootCandidate(pkg.resolvedPath);
43
104
  }
44
105
 
106
+ if (pkg.source.startsWith("npm:")) {
107
+ return resolveNpmPackageRoot(pkg, cwd);
108
+ }
109
+
45
110
  if (pkg.source.startsWith("file://")) {
46
111
  try {
47
112
  return normalizePackageRootCandidate(fileURLToPath(pkg.source));
@@ -141,23 +206,79 @@ async function writeSettingsFile(path: string, settings: SettingsFile): Promise<
141
206
  }
142
207
  }
143
208
 
209
+ function safeMatchesGlob(targetPath: string, pattern: string): boolean {
210
+ try {
211
+ return matchesGlob(targetPath, pattern);
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+
217
+ function matchesFilterPattern(targetPath: string, pattern: string): boolean {
218
+ const normalizedPattern = normalizeRelativePath(pattern.trim());
219
+ if (!normalizedPattern) return false;
220
+ if (targetPath === normalizedPattern) return true;
221
+
222
+ return safeMatchesGlob(targetPath, normalizedPattern);
223
+ }
224
+
144
225
  function getPackageFilterState(filters: string[] | undefined, extensionPath: string): State {
145
- if (!filters || filters.length === 0) {
226
+ // Omitted key => all enabled (pi default).
227
+ if (filters === undefined) {
146
228
  return "enabled";
147
229
  }
148
230
 
231
+ // Explicit empty array => load none.
232
+ if (filters.length === 0) {
233
+ return "disabled";
234
+ }
235
+
149
236
  const normalizedTarget = normalizeRelativePath(extensionPath);
150
- let state: State = "enabled";
237
+ const includePatterns: string[] = [];
238
+ const excludePatterns: string[] = [];
239
+ let markerOverride: State | undefined;
240
+
241
+ for (const rawToken of filters) {
242
+ const token = rawToken.trim();
243
+ if (!token) continue;
244
+
245
+ const prefix = token[0];
246
+
247
+ if (prefix === "+" || prefix === "-") {
248
+ const markerPath = normalizeRelativePath(token.slice(1));
249
+ if (markerPath === normalizedTarget) {
250
+ markerOverride = prefix === "+" ? "enabled" : "disabled";
251
+ }
252
+ continue;
253
+ }
254
+
255
+ if (prefix === "!") {
256
+ const pattern = normalizeRelativePath(token.slice(1));
257
+ if (pattern) {
258
+ excludePatterns.push(pattern);
259
+ }
260
+ continue;
261
+ }
262
+
263
+ const include = normalizeRelativePath(token);
264
+ if (include) {
265
+ includePatterns.push(include);
266
+ }
267
+ }
268
+
269
+ let enabled =
270
+ includePatterns.length === 0 ||
271
+ includePatterns.some((p) => matchesFilterPattern(normalizedTarget, p));
272
+
273
+ if (enabled && excludePatterns.some((p) => matchesFilterPattern(normalizedTarget, p))) {
274
+ enabled = false;
275
+ }
151
276
 
152
- for (const token of filters) {
153
- if (!token || (token[0] !== "+" && token[0] !== "-")) continue;
154
- const sign = token[0];
155
- const path = normalizeRelativePath(token.slice(1));
156
- if (path !== normalizedTarget) continue;
157
- state = sign === "+" ? "enabled" : "disabled";
277
+ if (markerOverride !== undefined) {
278
+ enabled = markerOverride === "enabled";
158
279
  }
159
280
 
160
- return state;
281
+ return enabled ? "enabled" : "disabled";
161
282
  }
162
283
 
163
284
  async function getPackageExtensionState(
@@ -185,26 +306,131 @@ async function getPackageExtensionState(
185
306
  return getPackageFilterState(entry.extensions, extensionPath);
186
307
  }
187
308
 
188
- async function discoverEntrypoints(packageRoot: string): Promise<string[]> {
309
+ function isExtensionEntrypointPath(path: string): boolean {
310
+ return /\.(ts|js)$/i.test(path);
311
+ }
312
+
313
+ function hasGlobMagic(path: string): boolean {
314
+ return /[*?{}[\]]/.test(path);
315
+ }
316
+
317
+ function isSafeRelativePath(path: string): boolean {
318
+ return path !== "" && path !== ".." && !path.startsWith("../") && !path.includes("/../");
319
+ }
320
+
321
+ function selectDirectoryFiles(allFiles: string[], directoryPath: string): string[] {
322
+ const prefix = `${directoryPath}/`;
323
+ return allFiles.filter((file) => file.startsWith(prefix));
324
+ }
325
+
326
+ function applySelection(selected: Set<string>, files: Iterable<string>, exclude: boolean): void {
327
+ for (const file of files) {
328
+ if (exclude) {
329
+ selected.delete(file);
330
+ } else {
331
+ selected.add(file);
332
+ }
333
+ }
334
+ }
335
+
336
+ async function collectExtensionFilesFromDir(
337
+ packageRoot: string,
338
+ startDir: string
339
+ ): Promise<string[]> {
340
+ const collected: string[] = [];
341
+
342
+ let entries: Dirent[];
343
+ try {
344
+ entries = await readdir(startDir, { withFileTypes: true });
345
+ } catch {
346
+ return collected;
347
+ }
348
+
349
+ for (const entry of entries) {
350
+ const absolutePath = join(startDir, entry.name);
351
+
352
+ if (entry.isDirectory()) {
353
+ collected.push(...(await collectExtensionFilesFromDir(packageRoot, absolutePath)));
354
+ continue;
355
+ }
356
+
357
+ if (!entry.isFile()) {
358
+ continue;
359
+ }
360
+
361
+ const relativePath = normalizeRelativePath(relative(packageRoot, absolutePath));
362
+ if (isExtensionEntrypointPath(relativePath)) {
363
+ collected.push(relativePath);
364
+ }
365
+ }
366
+
367
+ return collected;
368
+ }
369
+
370
+ async function resolveManifestExtensionEntries(
371
+ packageRoot: string,
372
+ entries: string[]
373
+ ): Promise<string[]> {
374
+ const selected = new Set<string>();
375
+ const allFiles = await collectExtensionFilesFromDir(packageRoot, packageRoot);
376
+
377
+ for (const rawToken of entries) {
378
+ const token = rawToken.trim();
379
+ if (!token) continue;
380
+
381
+ const exclude = token.startsWith("!");
382
+ const normalizedToken = normalizeRelativePath(exclude ? token.slice(1) : token);
383
+ const pattern = normalizedToken.replace(/[\\/]+$/g, "");
384
+ if (!isSafeRelativePath(pattern)) {
385
+ continue;
386
+ }
387
+
388
+ if (hasGlobMagic(pattern)) {
389
+ const matchedFiles = allFiles.filter((file) => matchesFilterPattern(file, pattern));
390
+ applySelection(selected, matchedFiles, exclude);
391
+ continue;
392
+ }
393
+
394
+ const directoryFiles = selectDirectoryFiles(allFiles, pattern);
395
+ if (directoryFiles.length > 0) {
396
+ applySelection(selected, directoryFiles, exclude);
397
+ continue;
398
+ }
399
+
400
+ if (isExtensionEntrypointPath(pattern)) {
401
+ applySelection(selected, [pattern], exclude);
402
+ }
403
+ }
404
+
405
+ return Array.from(selected).sort((a, b) => a.localeCompare(b));
406
+ }
407
+
408
+ export async function resolveManifestExtensionEntrypoints(
409
+ packageRoot: string
410
+ ): Promise<string[] | undefined> {
189
411
  const packageJsonPath = join(packageRoot, "package.json");
190
- let manifestExtensions: string[] | undefined;
191
412
 
413
+ let parsed: { pi?: { extensions?: unknown } };
192
414
  try {
193
415
  const raw = await readFile(packageJsonPath, "utf8");
194
- const parsed = JSON.parse(raw) as { pi?: { extensions?: unknown } };
195
- const ext = parsed.pi?.extensions;
196
- if (Array.isArray(ext)) {
197
- const entries = ext.filter((value): value is string => typeof value === "string");
198
- if (entries.length > 0) {
199
- manifestExtensions = entries;
200
- }
201
- }
416
+ parsed = JSON.parse(raw) as { pi?: { extensions?: unknown } };
202
417
  } catch {
203
- // Ignore invalid/missing package.json and fall back.
418
+ return undefined;
419
+ }
420
+
421
+ const extensions = parsed.pi?.extensions;
422
+ if (!Array.isArray(extensions)) {
423
+ return undefined;
204
424
  }
205
425
 
206
- if (manifestExtensions && manifestExtensions.length > 0) {
207
- return manifestExtensions.map((entry) => normalizeRelativePath(entry));
426
+ const entries = extensions.filter((value): value is string => typeof value === "string");
427
+ return resolveManifestExtensionEntries(packageRoot, entries);
428
+ }
429
+
430
+ async function discoverEntrypoints(packageRoot: string): Promise<string[]> {
431
+ const manifestEntrypoints = await resolveManifestExtensionEntrypoints(packageRoot);
432
+ if (manifestEntrypoints !== undefined) {
433
+ return manifestEntrypoints;
208
434
  }
209
435
 
210
436
  const indexTs = join(packageRoot, "index.ts");
@@ -227,7 +453,7 @@ export async function discoverPackageExtensions(
227
453
  const entries: PackageExtensionEntry[] = [];
228
454
 
229
455
  for (const pkg of packages) {
230
- const packageRoot = toPackageRoot(pkg, cwd);
456
+ const packageRoot = await toPackageRoot(pkg, cwd);
231
457
  if (!packageRoot) continue;
232
458
 
233
459
  const extensionPaths = await discoverEntrypoints(packageRoot);
@@ -319,8 +545,3 @@ export async function setPackageExtensionState(
319
545
  };
320
546
  }
321
547
  }
322
-
323
- export function toProjectRelativePath(path: string, cwd: string): string {
324
- const rel = relative(cwd, path);
325
- return rel.startsWith("..") ? path : normalizeRelativePath(rel);
326
- }
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Package installation logic
3
3
  */
4
- import { mkdir, rm, writeFile, cp, readFile } from "node:fs/promises";
4
+ import { mkdir, rm, writeFile, cp } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
8
8
  import { normalizePackageSource } from "../utils/format.js";
9
9
  import { fileExists } from "../utils/fs.js";
10
10
  import { clearSearchCache, isSourceInstalled } from "./discovery.js";
11
+ import { resolveManifestExtensionEntrypoints } from "./extensions.js";
11
12
  import { waitForCondition } from "../utils/retry.js";
12
13
  import { logPackageInstall } from "../utils/history.js";
13
14
  import { clearUpdatesAvailable } from "../utils/settings.js";
@@ -48,18 +49,20 @@ function getExtensionInstallDir(ctx: ExtensionCommandContext, scope: InstallScop
48
49
  return join(homedir(), ".pi", "agent", "extensions");
49
50
  }
50
51
 
52
+ interface GithubUrlInfo {
53
+ owner: string;
54
+ repo: string;
55
+ branch: string;
56
+ filePath: string;
57
+ }
58
+
51
59
  /**
52
60
  * Safely extracts regex match groups with validation
53
61
  */
54
- function safeExtractGithubMatch(
55
- match: RegExpMatchArray | null
56
- ): { owner: string; repo: string; branch: string; filePath: string } | undefined {
62
+ function safeExtractGithubMatch(match: RegExpMatchArray | null): GithubUrlInfo | undefined {
57
63
  if (!match) return undefined;
58
64
 
59
- const owner = match[1];
60
- const repo = match[2];
61
- const branch = match[3];
62
- const filePath = match[4];
65
+ const [, owner, repo, branch, filePath] = match;
63
66
 
64
67
  if (!owner || !repo || !branch || !filePath) {
65
68
  return undefined;
@@ -68,29 +71,15 @@ function safeExtractGithubMatch(
68
71
  return { owner, repo, branch, filePath };
69
72
  }
70
73
 
71
- function normalizeRelativePath(value: string): string {
72
- return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
73
- }
74
-
75
74
  async function hasStandaloneEntrypoint(packageRoot: string): Promise<boolean> {
76
- try {
77
- const manifestPath = join(packageRoot, "package.json");
78
- const raw = await readFile(manifestPath, "utf8");
79
- const parsed = JSON.parse(raw) as { pi?: { extensions?: unknown } };
80
- const declared = parsed.pi?.extensions;
81
-
82
- if (Array.isArray(declared) && declared.length > 0) {
83
- for (const entry of declared) {
84
- if (typeof entry !== "string" || !entry.trim()) continue;
85
- const candidate = join(packageRoot, normalizeRelativePath(entry));
86
- if (await fileExists(candidate)) {
87
- return true;
88
- }
75
+ const declared = await resolveManifestExtensionEntrypoints(packageRoot);
76
+ if (declared !== undefined) {
77
+ for (const path of declared) {
78
+ if (await fileExists(join(packageRoot, path))) {
79
+ return true;
89
80
  }
90
- return false;
91
81
  }
92
- } catch {
93
- // Ignore invalid/missing manifest and fall back to conventional entrypoints.
82
+ return false;
94
83
  }
95
84
 
96
85
  return (