pi-extmgr 0.1.28 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,25 +18,29 @@ pi install npm:pi-extmgr
18
18
 
19
19
  If Pi is already running, use `/reload`.
20
20
 
21
- Requires Node.js `>=22`.
21
+ Requires Node.js `>=22.20.0`.
22
22
 
23
23
  ## Features
24
24
 
25
25
  - **Unified manager UI**
26
26
  - Local extensions (`~/.pi/agent/extensions`, `.pi/extensions`) and installed packages in one list
27
- - Scope indicators (global/project), status indicators, update badges
27
+ - Grouped sections for local extensions vs installed packages
28
+ - Compact rows with selected-item details below the list, so large extension sets stay scannable
29
+ - Built-in search and filter shortcuts for large extension sets
30
+ - Scope indicators (global/project), status indicators, update badges, and package sizes when known
28
31
  - **Package extension configuration panel**
29
32
  - Configure individual extension entrypoints inside an installed package (`c` on package row)
30
33
  - Works with manifest-declared entrypoints and conventional `extensions/` package layouts
31
34
  - Persists to package filters in `settings.json` (no manual JSON editing)
32
35
  - **Safe staged local extension toggles**
33
- - Toggle with `Space/Enter`, apply with `S`
36
+ - Toggle with `Space`, apply with `S`
34
37
  - Unsaved-change guard when leaving (save/discard/stay)
35
38
  - **Package management**
36
39
  - Install, update, remove from UI and command line
37
40
  - Quick actions (`A`, `u`, `X`) and bulk update (`U`)
38
41
  - **Remote discovery and install**
39
- - npm search/browse with pagination
42
+ - npm search/browse with pagination, inline browse search, and keyboard page navigation
43
+ - Path- and git-like queries are handled explicitly instead of surfacing unrelated npm results
40
44
  - Install by source (`npm:`, `git:`, `https://`, `ssh://`, `git@...`, local path)
41
45
  - Supports direct GitHub `.ts` installs and standalone local install for self-contained packages
42
46
  - Long-running discovery/detail screens now show dedicated loading UI, and cancellable reads can be aborted with `Esc`
@@ -66,20 +70,26 @@ Open the manager:
66
70
  | Key | Action |
67
71
  | ------------- | ----------------------------------------------------- |
68
72
  | `↑↓` | Navigate |
69
- | `Space/Enter` | Toggle local extension on/off |
73
+ | `PageUp/Down` | Jump through longer lists |
74
+ | `Home/End` | Jump to top or bottom |
75
+ | `Space` | Toggle selected local extension on/off |
70
76
  | `S` | Save local extension changes |
71
- | `Enter` / `A` | Actions on selected package (configure/update/remove) |
77
+ | `Enter` / `A` | Actions on selected item |
78
+ | `/` / `Ctrl+F`| Search visible items |
79
+ | `Tab` / `Shift+Tab` | Cycle filters |
80
+ | `1-5` | Filters: All / Local / Packages / Updates / Disabled |
72
81
  | `c` | Configure selected package extensions |
73
82
  | `u` | Update selected package directly |
83
+ | `V` | View full details for selected item |
74
84
  | `X` | Remove selected item (package/local extension) |
75
85
  | `i` | Quick install by source |
76
- | `f` | Quick search |
86
+ | `f` | Remote package search |
77
87
  | `U` | Update all packages |
78
88
  | `t` | Auto-update wizard |
79
89
  | `P` / `M` | Quick actions palette |
80
90
  | `R` | Browse remote packages |
81
91
  | `?` / `H` | Help |
82
- | `Esc` | Exit |
92
+ | `Esc` | Clear search or exit |
83
93
 
84
94
  ### Commands
85
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.28",
3
+ "version": "0.2.0",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -27,6 +27,12 @@
27
27
  "smoke-test": "node --import=tsx ./scripts/smoke-test.mjs",
28
28
  "test": "node --import=tsx --test ./test/*.test.ts",
29
29
  "check": "tsc --noEmit -p tsconfig.json && node --import=tsx ./scripts/smoke-test.mjs && node --import=tsx --test ./test/*.test.ts && pnpm run lint && pnpm run format:check",
30
+ "release": "release-it",
31
+ "release:patch": "release-it patch",
32
+ "release:minor": "release-it minor",
33
+ "release:major": "release-it major",
34
+ "release:first": "release-it --first-release",
35
+ "release:ci": "node --import=tsx ./scripts/release-ci.ts",
30
36
  "prepublishOnly": "pnpm run check",
31
37
  "prepare": "husky"
32
38
  },
@@ -45,8 +51,10 @@
45
51
  "@biomejs/biome": "^2.4.9",
46
52
  "@mariozechner/pi-coding-agent": "^0.63.1",
47
53
  "@mariozechner/pi-tui": "^0.63.1",
54
+ "@release-it/conventional-changelog": "^10.0.5",
48
55
  "@types/node": "^22.19.10",
49
56
  "husky": "^9.1.7",
57
+ "release-it": "^19.2.4",
50
58
  "tsx": "^4.21.0",
51
59
  "typescript": "^5.9.3"
52
60
  },
@@ -54,7 +62,7 @@
54
62
  "license": "MIT",
55
63
  "packageManager": "pnpm@10.33.0",
56
64
  "engines": {
57
- "node": ">=22"
65
+ "node": ">=22.20.0"
58
66
  },
59
67
  "repository": {
60
68
  "type": "git",
@@ -54,7 +54,7 @@ export async function handleAutoUpdateSubcommand(
54
54
  " 3d - Check every 3 days",
55
55
  " 1w - Check weekly",
56
56
  " 2w - Check every 2 weeks",
57
- " 1mo - Check monthly (1m also works)",
57
+ " 1mo - Check monthly",
58
58
  " daily - Check daily (alias)",
59
59
  " weekly - Check weekly (alias)",
60
60
  ];
@@ -6,6 +6,7 @@ import {
6
6
  queryGlobalHistory,
7
7
  querySessionChanges,
8
8
  } from "../utils/history.js";
9
+ import { parseLookbackDuration } from "../utils/duration.js";
9
10
  import { notify } from "../utils/notify.js";
10
11
  import { formatListOutput } from "../utils/ui-helpers.js";
11
12
 
@@ -37,36 +38,6 @@ type HistoryOptionHandler = (tokens: string[], index: number, state: HistoryPars
37
38
 
38
39
  const HISTORY_ACTION_SET = new Set<ChangeAction>(HISTORY_ACTIONS);
39
40
 
40
- function parseHistorySinceDuration(input: string): number | undefined {
41
- const normalized = input.toLowerCase().trim();
42
- const match = normalized.match(
43
- /^(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|wk|wks|week|weeks|mo|mos|month|months)$/
44
- );
45
- if (!match) return undefined;
46
-
47
- const value = Number.parseInt(match[1] ?? "", 10);
48
- if (!Number.isFinite(value) || value <= 0) return undefined;
49
-
50
- const unit = match[2] ?? "";
51
- if (unit.startsWith("m") && !unit.startsWith("mo")) {
52
- return value * 60 * 1000;
53
- }
54
- if (unit.startsWith("h")) {
55
- return value * 60 * 60 * 1000;
56
- }
57
- if (unit.startsWith("d")) {
58
- return value * 24 * 60 * 60 * 1000;
59
- }
60
- if (unit.startsWith("w")) {
61
- return value * 7 * 24 * 60 * 60 * 1000;
62
- }
63
- if (unit.startsWith("mo")) {
64
- return value * 30 * 24 * 60 * 60 * 1000;
65
- }
66
-
67
- return undefined;
68
- }
69
-
70
41
  const HISTORY_OPTION_HANDLERS: Record<string, HistoryOptionHandler> = {
71
42
  "--help": (_tokens, _index, state) => {
72
43
  state.showHelp = true;
@@ -142,7 +113,7 @@ const HISTORY_OPTION_HANDLERS: Record<string, HistoryOptionHandler> = {
142
113
  return 0;
143
114
  }
144
115
 
145
- const ms = parseHistorySinceDuration(value);
116
+ const ms = parseLookbackDuration(value);
146
117
  if (!ms) {
147
118
  state.errors.push(`Invalid --since duration: ${value}`);
148
119
  } else {
package/src/constants.ts CHANGED
@@ -26,14 +26,6 @@ export const TIMEOUTS = {
26
26
  npmView: 10_000,
27
27
  /** Full package installation timeout (3 minutes) */
28
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
29
  /** Package metadata fetch timeout */
38
30
  fetchPackageInfo: 30_000,
39
31
  /** Package extraction timeout */
@@ -10,8 +10,13 @@ import { readdir, rename, rm } from "node:fs/promises";
10
10
  import { homedir } from "node:os";
11
11
  import { basename, dirname, join, relative } from "node:path";
12
12
  import { DISABLED_SUFFIX } from "../constants.js";
13
+ import { readPackageManifest } from "../packages/extensions.js";
13
14
  import { type ExtensionEntry, type Scope, type State } from "../types/index.js";
14
15
  import { fileExists, readSummary } from "../utils/fs.js";
16
+ import {
17
+ normalizeRelativePath,
18
+ resolveRelativePathSelection,
19
+ } from "../utils/relative-path-selection.js";
15
20
 
16
21
  interface RootConfig {
17
22
  root: string;
@@ -94,8 +99,7 @@ async function discoverInRoot(
94
99
  }
95
100
 
96
101
  if (item.isDirectory()) {
97
- const entry = await parseDirectoryIndex(root, label, scope, name);
98
- if (entry) found.push(entry);
102
+ found.push(...(await parseDirectoryExtensions(root, label, scope, name)));
99
103
  }
100
104
  }
101
105
 
@@ -141,53 +145,131 @@ async function parseTopLevelFile(
141
145
  };
142
146
  }
143
147
 
148
+ function stripDisabledSuffix(path: string): string {
149
+ return path.replace(/\.(ts|js)\.disabled$/i, ".$1");
150
+ }
151
+
152
+ function isExtensionEntrypointPath(path: string): boolean {
153
+ return /\.(ts|js)$/i.test(path);
154
+ }
155
+
156
+ function isLocalExtensionFile(path: string): boolean {
157
+ return /\.(ts|js)(?:\.disabled)?$/i.test(path);
158
+ }
159
+
160
+ async function collectLocalExtensionFiles(rootDir: string, startDir: string): Promise<string[]> {
161
+ const collected: string[] = [];
162
+
163
+ let entries: Dirent[];
164
+ try {
165
+ entries = await readdir(startDir, { withFileTypes: true });
166
+ } catch {
167
+ return collected;
168
+ }
169
+
170
+ for (const entry of entries) {
171
+ if (entry.name.startsWith(".")) {
172
+ continue;
173
+ }
174
+
175
+ const absolutePath = join(startDir, entry.name);
176
+ if (entry.isDirectory()) {
177
+ collected.push(...(await collectLocalExtensionFiles(rootDir, absolutePath)));
178
+ continue;
179
+ }
180
+
181
+ if (!entry.isFile()) {
182
+ continue;
183
+ }
184
+
185
+ const relativePath = normalizeRelativePath(relative(rootDir, absolutePath));
186
+ if (isLocalExtensionFile(relativePath)) {
187
+ collected.push(stripDisabledSuffix(relativePath));
188
+ }
189
+ }
190
+
191
+ return collected;
192
+ }
193
+
194
+ async function resolveManifestLocalEntrypoints(dir: string): Promise<string[] | undefined> {
195
+ const manifest = await readPackageManifest(dir);
196
+ const extensions = manifest?.pi?.extensions;
197
+ if (!Array.isArray(extensions)) {
198
+ return undefined;
199
+ }
200
+
201
+ const entries = extensions.filter((value): value is string => typeof value === "string");
202
+ const allFiles = await collectLocalExtensionFiles(dir, dir);
203
+ return resolveRelativePathSelection(
204
+ allFiles,
205
+ entries,
206
+ (path, files) => isExtensionEntrypointPath(path) && files.includes(path)
207
+ );
208
+ }
209
+
210
+ async function toDirectoryExtensionEntry(
211
+ root: string,
212
+ label: string,
213
+ scope: Scope,
214
+ dir: string,
215
+ extensionPath: string
216
+ ): Promise<ExtensionEntry | undefined> {
217
+ const normalizedPath = normalizeRelativePath(extensionPath);
218
+ const activePath = join(dir, normalizedPath);
219
+ const disabledPath = `${activePath}${DISABLED_SUFFIX}`;
220
+
221
+ let state: State;
222
+ let summaryPath: string;
223
+ if (await fileExists(activePath)) {
224
+ state = "enabled";
225
+ summaryPath = activePath;
226
+ } else if (await fileExists(disabledPath)) {
227
+ state = "disabled";
228
+ summaryPath = disabledPath;
229
+ } else {
230
+ return undefined;
231
+ }
232
+
233
+ return {
234
+ id: `${scope}:${activePath}`,
235
+ scope,
236
+ state,
237
+ activePath,
238
+ disabledPath,
239
+ displayName: `${label}/${normalizeRelativePath(relative(root, activePath))}`,
240
+ summary: await readSummary(summaryPath),
241
+ };
242
+ }
243
+
144
244
  /**
145
- * Parse a directory containing an index.ts/js file as an extension entry.
146
- *
147
- * @param root - Root directory path
148
- * @param label - Display label for the root
149
- * @param scope - "global" or "project"
150
- * @param dirName - Name of the directory to parse
151
- * @returns ExtensionEntry if index file found, undefined otherwise
245
+ * Parse a directory containing a manifest-declared entrypoint or index.ts/js file as one or more
246
+ * extension entries.
152
247
  */
153
- async function parseDirectoryIndex(
248
+ async function parseDirectoryExtensions(
154
249
  root: string,
155
250
  label: string,
156
251
  scope: Scope,
157
252
  dirName: string
158
- ): Promise<ExtensionEntry | undefined> {
253
+ ): Promise<ExtensionEntry[]> {
159
254
  const dir = join(root, dirName);
255
+ const manifestEntrypoints = await resolveManifestLocalEntrypoints(dir);
160
256
 
161
- for (const ext of [".ts", ".js"]) {
162
- const activePath = join(dir, `index${ext}`);
163
- const disabledPath = `${activePath}${DISABLED_SUFFIX}`;
164
-
165
- if (await fileExists(activePath)) {
166
- return {
167
- id: `${scope}:${activePath}`,
168
- scope,
169
- state: "enabled",
170
- activePath,
171
- disabledPath,
172
- displayName: `${label}/${dirName}/index${ext}`,
173
- summary: await readSummary(activePath),
174
- };
175
- }
176
-
177
- if (await fileExists(disabledPath)) {
178
- return {
179
- id: `${scope}:${activePath}`,
180
- scope,
181
- state: "disabled",
182
- activePath,
183
- disabledPath,
184
- displayName: `${label}/${dirName}/index${ext}`,
185
- summary: await readSummary(disabledPath),
186
- };
187
- }
257
+ if (manifestEntrypoints !== undefined) {
258
+ const entries = await Promise.all(
259
+ manifestEntrypoints.map((extensionPath) =>
260
+ toDirectoryExtensionEntry(root, label, scope, dir, extensionPath)
261
+ )
262
+ );
263
+ return entries.filter((entry): entry is ExtensionEntry => Boolean(entry));
188
264
  }
189
265
 
190
- return undefined;
266
+ const fallbackEntries = await Promise.all(
267
+ ["index.ts", "index.js"].map((extensionPath) =>
268
+ toDirectoryExtensionEntry(root, label, scope, dir, extensionPath)
269
+ )
270
+ );
271
+
272
+ return fallbackEntries.filter((entry): entry is ExtensionEntry => Boolean(entry)).slice(0, 1);
191
273
  }
192
274
 
193
275
  /**
@@ -28,6 +28,14 @@ interface NpmSearchResultObject {
28
28
  description?: string;
29
29
  keywords?: string[];
30
30
  date?: string;
31
+ publisher?: {
32
+ username?: string;
33
+ email?: string;
34
+ };
35
+ maintainers?: Array<{
36
+ username?: string;
37
+ email?: string;
38
+ }>;
31
39
  };
32
40
  }
33
41
 
@@ -79,6 +87,31 @@ import {
79
87
  setCachedSearch,
80
88
  } from "../utils/cache.js";
81
89
 
90
+ function getNpmPackageAuthor(
91
+ pkg: NonNullable<NpmSearchResultObject["package"]>
92
+ ): string | undefined {
93
+ const publisher = pkg.publisher;
94
+ if (publisher?.username?.trim()) {
95
+ return publisher.username.trim();
96
+ }
97
+
98
+ if (publisher?.email?.trim()) {
99
+ return publisher.email.trim();
100
+ }
101
+
102
+ const maintainerWithUsername = pkg.maintainers?.find((entry) => entry.username?.trim());
103
+ if (maintainerWithUsername?.username?.trim()) {
104
+ return maintainerWithUsername.username.trim();
105
+ }
106
+
107
+ const maintainerWithEmail = pkg.maintainers?.find((entry) => entry.email?.trim());
108
+ if (maintainerWithEmail?.email?.trim()) {
109
+ return maintainerWithEmail.email.trim();
110
+ }
111
+
112
+ return undefined;
113
+ }
114
+
82
115
  function toNpmPackage(entry: NpmSearchResultObject): NpmPackage | undefined {
83
116
  const pkg = entry.package;
84
117
  if (!pkg) return undefined;
@@ -90,6 +123,7 @@ function toNpmPackage(entry: NpmSearchResultObject): NpmPackage | undefined {
90
123
  name,
91
124
  version: pkg.version,
92
125
  description: pkg.description,
126
+ author: getNpmPackageAuthor(pkg),
93
127
  keywords: Array.isArray(pkg.keywords) ? pkg.keywords : undefined,
94
128
  date: pkg.date,
95
129
  };
@@ -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}`,