pi-extmgr 0.1.27 → 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.
Files changed (41) hide show
  1. package/README.md +21 -10
  2. package/package.json +21 -16
  3. package/src/commands/auto-update.ts +5 -5
  4. package/src/commands/cache.ts +1 -1
  5. package/src/commands/history.ts +5 -34
  6. package/src/commands/install.ts +2 -2
  7. package/src/commands/registry.ts +7 -7
  8. package/src/commands/types.ts +1 -1
  9. package/src/constants.ts +0 -8
  10. package/src/extensions/discovery.ts +125 -42
  11. package/src/index.ts +15 -15
  12. package/src/packages/catalog.ts +9 -8
  13. package/src/packages/discovery.ts +56 -19
  14. package/src/packages/extensions.ts +65 -103
  15. package/src/packages/install.ts +104 -74
  16. package/src/packages/management.ts +78 -65
  17. package/src/types/index.ts +20 -11
  18. package/src/ui/async-task.ts +101 -65
  19. package/src/ui/footer.ts +47 -31
  20. package/src/ui/help.ts +17 -13
  21. package/src/ui/package-config.ts +36 -48
  22. package/src/ui/remote.ts +714 -119
  23. package/src/ui/theme.ts +2 -2
  24. package/src/ui/unified.ts +964 -371
  25. package/src/utils/auto-update.ts +44 -39
  26. package/src/utils/cache.ts +208 -37
  27. package/src/utils/command.ts +1 -1
  28. package/src/utils/duration.ts +132 -0
  29. package/src/utils/format.ts +4 -33
  30. package/src/utils/fs.ts +8 -4
  31. package/src/utils/history.ts +47 -9
  32. package/src/utils/mode.ts +2 -2
  33. package/src/utils/notify.ts +1 -15
  34. package/src/utils/npm-exec.ts +1 -1
  35. package/src/utils/package-source.ts +35 -7
  36. package/src/utils/path-identity.ts +7 -0
  37. package/src/utils/relative-path-selection.ts +100 -0
  38. package/src/utils/settings.ts +11 -61
  39. package/src/utils/status.ts +12 -10
  40. package/src/utils/ui-helpers.ts +2 -2
  41. package/src/utils/retry.ts +0 -49
@@ -1,30 +1,31 @@
1
1
  /**
2
2
  * Package installation logic
3
3
  */
4
- import { mkdir, rm, writeFile, cp } from "node:fs/promises";
5
- import { join } from "node:path";
4
+ import { cp, mkdir, rm, writeFile } from "node:fs/promises";
6
5
  import { homedir } from "node:os";
7
- import type {
8
- ExtensionAPI,
9
- ExtensionCommandContext,
10
- ProgressEvent,
6
+ import { join } from "node:path";
7
+ import {
8
+ type ExtensionAPI,
9
+ type ExtensionCommandContext,
10
+ type ProgressEvent,
11
11
  } from "@mariozechner/pi-coding-agent";
12
+ import { TIMEOUTS } from "../constants.js";
13
+ import { runTaskWithLoader } from "../ui/async-task.js";
14
+ import { parseChoiceByLabel } from "../utils/command.js";
12
15
  import { normalizePackageSource } from "../utils/format.js";
13
16
  import { fileExists } from "../utils/fs.js";
14
- import { clearSearchCache } from "./discovery.js";
15
- import { getPackageCatalog } from "./catalog.js";
16
- import { discoverPackageExtensionEntrypoints, readPackageManifest } from "./extensions.js";
17
17
  import { logPackageInstall } from "../utils/history.js";
18
- import { clearUpdatesAvailable } from "../utils/settings.js";
19
- import { notify, error as notifyError, success } from "../utils/notify.js";
20
- import { confirmAction, confirmReload, showProgress } from "../utils/ui-helpers.js";
21
18
  import { tryOperation } from "../utils/mode.js";
22
- import { runTaskWithLoader } from "../ui/async-task.js";
23
- import { updateExtmgrStatus } from "../utils/status.js";
19
+ import { fetchWithTimeout } from "../utils/network.js";
20
+ import { notify, error as notifyError, success } from "../utils/notify.js";
24
21
  import { execNpm } from "../utils/npm-exec.js";
25
22
  import { normalizePackageIdentity } from "../utils/package-source.js";
26
- import { fetchWithTimeout } from "../utils/network.js";
27
- import { TIMEOUTS } from "../constants.js";
23
+ import { clearUpdatesAvailable } from "../utils/settings.js";
24
+ import { updateExtmgrStatus } from "../utils/status.js";
25
+ import { confirmAction, confirmReload, showProgress } from "../utils/ui-helpers.js";
26
+ import { getPackageCatalog } from "./catalog.js";
27
+ import { clearSearchCache } from "./discovery.js";
28
+ import { discoverPackageExtensionEntrypoints, readPackageManifest } from "./extensions.js";
28
29
 
29
30
  export type InstallScope = "global" | "project";
30
31
 
@@ -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
- /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/(.+\.ts)$/
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);
@@ -211,6 +198,7 @@ export async function installPackage(
211
198
  title: "Install Package",
212
199
  message: `Installing ${normalized}...`,
213
200
  cancellable: false,
201
+ fallbackWithoutLoader: true,
214
202
  },
215
203
  async ({ setMessage }) => {
216
204
  await getPackageCatalog(ctx.cwd).install(normalized, scope, (event) => {
@@ -225,18 +213,38 @@ export async function installPackage(
225
213
  logPackageInstall(pi, normalized, normalized, undefined, scope, false, errorMsg);
226
214
  notifyError(ctx, errorMsg);
227
215
  void updateExtmgrStatus(ctx, pi);
228
- return;
216
+ return { installed: false, reloaded: false };
229
217
  }
230
218
 
231
219
  clearSearchCache();
232
220
  logPackageInstall(pi, normalized, normalized, undefined, scope, true);
233
221
  success(ctx, `Installed ${normalized} (${scope})`);
234
- clearUpdatesAvailable(pi, ctx, [normalizePackageIdentity(normalized)]);
222
+ clearUpdatesAvailable(pi, ctx, [normalizePackageIdentity(normalized, { cwd: ctx.cwd })]);
235
223
 
236
224
  const reloaded = await confirmReload(ctx, "Package installed.");
237
225
  if (!reloaded) {
238
226
  void updateExtmgrStatus(ctx, pi);
239
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);
240
248
  }
241
249
 
242
250
  export async function installFromUrl(
@@ -245,11 +253,11 @@ export async function installFromUrl(
245
253
  ctx: ExtensionCommandContext,
246
254
  pi: ExtensionAPI,
247
255
  options?: InstallOptions
248
- ): Promise<void> {
256
+ ): Promise<InstallOutcome> {
249
257
  const scope = await resolveInstallScope(ctx, options?.scope);
250
258
  if (!scope) {
251
259
  notify(ctx, "Installation cancelled.", "info");
252
- return;
260
+ return { installed: false, reloaded: false };
253
261
  }
254
262
 
255
263
  const extensionDir = getExtensionInstallDir(ctx, scope);
@@ -262,7 +270,7 @@ export async function installFromUrl(
262
270
  );
263
271
  if (!confirmed) {
264
272
  notify(ctx, "Installation cancelled.", "info");
265
- return;
273
+ return { installed: false, reloaded: false };
266
274
  }
267
275
 
268
276
  const result = await tryOperation(
@@ -288,7 +296,7 @@ export async function installFromUrl(
288
296
  if (!result) {
289
297
  logPackageInstall(pi, url, fileName, undefined, scope, false, "Installation failed");
290
298
  void updateExtmgrStatus(ctx, pi);
291
- return;
299
+ return { installed: false, reloaded: false };
292
300
  }
293
301
 
294
302
  const { fileName: name, destPath } = result;
@@ -299,6 +307,8 @@ export async function installFromUrl(
299
307
  if (!reloaded) {
300
308
  void updateExtmgrStatus(ctx, pi);
301
309
  }
310
+
311
+ return { installed: true, reloaded };
302
312
  }
303
313
 
304
314
  /**
@@ -323,16 +333,16 @@ function parsePackageInfo(viewOutput: string): { version: string; tarballUrl: st
323
333
  }
324
334
  }
325
335
 
326
- export async function installPackageLocally(
336
+ async function installPackageLocallyInternal(
327
337
  packageName: string,
328
338
  ctx: ExtensionCommandContext,
329
339
  pi: ExtensionAPI,
330
340
  options?: InstallOptions
331
- ): Promise<void> {
341
+ ): Promise<InstallOutcome> {
332
342
  const scope = await resolveInstallScope(ctx, options?.scope);
333
343
  if (!scope) {
334
344
  notify(ctx, "Installation cancelled.", "info");
335
- return;
345
+ return { installed: false, reloaded: false };
336
346
  }
337
347
 
338
348
  const extensionDir = getExtensionInstallDir(ctx, scope);
@@ -345,7 +355,7 @@ export async function installPackageLocally(
345
355
  );
346
356
  if (!confirmed) {
347
357
  notify(ctx, "Installation cancelled.", "info");
348
- return;
358
+ return { installed: false, reloaded: false };
349
359
  }
350
360
 
351
361
  const result = await tryOperation(
@@ -383,7 +393,7 @@ export async function installPackageLocally(
383
393
  "Failed to fetch package info"
384
394
  );
385
395
  void updateExtmgrStatus(ctx, pi);
386
- return;
396
+ return { installed: false, reloaded: false };
387
397
  }
388
398
  const { version, tarballUrl } = result;
389
399
 
@@ -400,7 +410,7 @@ export async function installPackageLocally(
400
410
  tarAvailability.error
401
411
  );
402
412
  void updateExtmgrStatus(ctx, pi);
403
- return;
413
+ return { installed: false, reloaded: false };
404
414
  }
405
415
 
406
416
  // Download and extract
@@ -438,7 +448,7 @@ export async function installPackageLocally(
438
448
  "Download failed"
439
449
  );
440
450
  void updateExtmgrStatus(ctx, pi);
441
- return;
451
+ return { installed: false, reloaded: false };
442
452
  }
443
453
  const { tarballPath } = extractResult;
444
454
 
@@ -495,7 +505,7 @@ export async function installPackageLocally(
495
505
  "Extraction failed"
496
506
  );
497
507
  void updateExtmgrStatus(ctx, pi);
498
- return;
508
+ return { installed: false, reloaded: false };
499
509
  }
500
510
 
501
511
  // Copy to extensions dir
@@ -526,7 +536,7 @@ export async function installPackageLocally(
526
536
  "Failed to copy extension"
527
537
  );
528
538
  void updateExtmgrStatus(ctx, pi);
529
- return;
539
+ return { installed: false, reloaded: false };
530
540
  }
531
541
 
532
542
  clearSearchCache();
@@ -537,4 +547,24 @@ export async function installPackageLocally(
537
547
  if (!reloaded) {
538
548
  void updateExtmgrStatus(ctx, pi);
539
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);
540
570
  }
@@ -1,49 +1,47 @@
1
1
  /**
2
2
  * Package management (update, remove)
3
3
  */
4
- import type {
5
- ExtensionAPI,
6
- ExtensionCommandContext,
7
- ProgressEvent,
8
- } from "@mariozechner/pi-coding-agent";
9
- import type { InstalledPackage } from "../types/index.js";
10
4
  import {
11
- getInstalledPackages,
12
- getInstalledPackagesAllScopes,
13
- clearSearchCache,
14
- } from "./discovery.js";
15
- import { getPackageCatalog } from "./catalog.js";
5
+ type ExtensionAPI,
6
+ type ExtensionCommandContext,
7
+ getAgentDir,
8
+ type ProgressEvent,
9
+ } from "@mariozechner/pi-coding-agent";
10
+ import { UI } from "../constants.js";
11
+ import { type InstalledPackage } from "../types/index.js";
12
+ import { runTaskWithLoader } from "../ui/async-task.js";
13
+ import { parseChoiceByLabel } from "../utils/command.js";
16
14
  import { formatInstalledPackageLabel } from "../utils/format.js";
15
+ import { logPackageRemove, logPackageUpdate } from "../utils/history.js";
16
+ import { requireUI } from "../utils/mode.js";
17
+ import { notify, error as notifyError, success } from "../utils/notify.js";
17
18
  import { normalizePackageIdentity } from "../utils/package-source.js";
18
- import { logPackageUpdate, logPackageRemove } from "../utils/history.js";
19
19
  import { clearUpdatesAvailable } from "../utils/settings.js";
20
- import { notify, error as notifyError, success } from "../utils/notify.js";
20
+ import { updateExtmgrStatus } from "../utils/status.js";
21
21
  import {
22
22
  confirmAction,
23
23
  confirmReload,
24
- showProgress,
25
24
  formatListOutput,
25
+ showProgress,
26
26
  } from "../utils/ui-helpers.js";
27
- import { requireUI } from "../utils/mode.js";
28
- import { runTaskWithLoader } from "../ui/async-task.js";
29
- import { updateExtmgrStatus } from "../utils/status.js";
30
- import { UI } from "../constants.js";
27
+ import { getPackageCatalog } from "./catalog.js";
28
+ import {
29
+ clearSearchCache,
30
+ getInstalledPackages,
31
+ getInstalledPackagesAllScopes,
32
+ } from "./discovery.js";
31
33
 
32
34
  export interface PackageMutationOutcome {
33
35
  reloaded: boolean;
34
36
  }
35
37
 
36
- const NO_PACKAGE_MUTATION_OUTCOME: PackageMutationOutcome = {
37
- reloaded: false,
38
- };
39
-
40
38
  const BULK_UPDATE_LABEL = "all packages";
41
-
42
- function packageMutationOutcome(
43
- overrides: Partial<PackageMutationOutcome>
44
- ): PackageMutationOutcome {
45
- return { ...NO_PACKAGE_MUTATION_OUTCOME, ...overrides };
46
- }
39
+ const REMOVAL_SCOPE_CHOICES = {
40
+ both: "Both global + project",
41
+ global: "Global only",
42
+ project: "Project only",
43
+ cancel: "Cancel",
44
+ } as const;
47
45
 
48
46
  function getProgressMessage(event: ProgressEvent, fallback: string): string {
49
47
  return event.message?.trim() || fallback;
@@ -56,7 +54,7 @@ async function updatePackageInternal(
56
54
  ): Promise<PackageMutationOutcome> {
57
55
  showProgress(ctx, "Updating", source);
58
56
 
59
- const updateIdentity = normalizePackageIdentity(source);
57
+ const updateIdentity = normalizePackageIdentity(source, { cwd: ctx.cwd });
60
58
 
61
59
  try {
62
60
  const updates = await getPackageCatalog(ctx.cwd).checkForAvailableUpdates();
@@ -69,7 +67,7 @@ async function updatePackageInternal(
69
67
  logPackageUpdate(pi, source, source, undefined, true);
70
68
  clearUpdatesAvailable(pi, ctx, [updateIdentity]);
71
69
  void updateExtmgrStatus(ctx, pi);
72
- return NO_PACKAGE_MUTATION_OUTCOME;
70
+ return { reloaded: false };
73
71
  }
74
72
 
75
73
  await runTaskWithLoader(
@@ -78,6 +76,7 @@ async function updatePackageInternal(
78
76
  title: "Update Package",
79
77
  message: `Updating ${source}...`,
80
78
  cancellable: false,
79
+ fallbackWithoutLoader: true,
81
80
  },
82
81
  async ({ setMessage }) => {
83
82
  await getPackageCatalog(ctx.cwd).update(source, (event) => {
@@ -92,7 +91,7 @@ async function updatePackageInternal(
92
91
  logPackageUpdate(pi, source, source, undefined, false, errorMsg);
93
92
  notifyError(ctx, errorMsg);
94
93
  void updateExtmgrStatus(ctx, pi);
95
- return NO_PACKAGE_MUTATION_OUTCOME;
94
+ return { reloaded: false };
96
95
  }
97
96
 
98
97
  logPackageUpdate(pi, source, source, undefined, true);
@@ -103,7 +102,7 @@ async function updatePackageInternal(
103
102
  if (!reloaded) {
104
103
  void updateExtmgrStatus(ctx, pi);
105
104
  }
106
- return packageMutationOutcome({ reloaded });
105
+ return { reloaded };
107
106
  }
108
107
 
109
108
  async function updatePackagesInternal(
@@ -119,7 +118,7 @@ async function updatePackagesInternal(
119
118
  logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true);
120
119
  clearUpdatesAvailable(pi, ctx);
121
120
  void updateExtmgrStatus(ctx, pi);
122
- return NO_PACKAGE_MUTATION_OUTCOME;
121
+ return { reloaded: false };
123
122
  }
124
123
 
125
124
  await runTaskWithLoader(
@@ -128,6 +127,7 @@ async function updatePackagesInternal(
128
127
  title: "Update Packages",
129
128
  message: "Updating all packages...",
130
129
  cancellable: false,
130
+ fallbackWithoutLoader: true,
131
131
  },
132
132
  async ({ setMessage }) => {
133
133
  await getPackageCatalog(ctx.cwd).update(undefined, (event) => {
@@ -142,7 +142,7 @@ async function updatePackagesInternal(
142
142
  logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, false, errorMsg);
143
143
  notifyError(ctx, errorMsg);
144
144
  void updateExtmgrStatus(ctx, pi);
145
- return NO_PACKAGE_MUTATION_OUTCOME;
145
+ return { reloaded: false };
146
146
  }
147
147
 
148
148
  logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true);
@@ -153,7 +153,7 @@ async function updatePackagesInternal(
153
153
  if (!reloaded) {
154
154
  void updateExtmgrStatus(ctx, pi);
155
155
  }
156
- return packageMutationOutcome({ reloaded });
156
+ return { reloaded };
157
157
  }
158
158
 
159
159
  export async function updatePackage(
@@ -186,8 +186,31 @@ export async function updatePackagesWithOutcome(
186
186
  return updatePackagesInternal(ctx, pi);
187
187
  }
188
188
 
189
- function packageIdentity(source: string): string {
190
- return normalizePackageIdentity(source);
189
+ function packageIdentity(
190
+ source: string,
191
+ options?: { resolvedPath?: string; cwd?: string }
192
+ ): string {
193
+ return normalizePackageIdentity(source, options);
194
+ }
195
+
196
+ function packageSourceIdentities(source: string, ctx: ExtensionCommandContext): Set<string> {
197
+ return new Set([
198
+ packageIdentity(source, { cwd: ctx.cwd }),
199
+ packageIdentity(source, { cwd: getAgentDir() }),
200
+ ]);
201
+ }
202
+
203
+ function installedPackageMatchesSource(
204
+ pkg: InstalledPackage,
205
+ identities: Set<string>,
206
+ ctx: ExtensionCommandContext
207
+ ): boolean {
208
+ return identities.has(
209
+ packageIdentity(pkg.source, {
210
+ ...(pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : {}),
211
+ cwd: pkg.scope === "project" ? ctx.cwd : getAgentDir(),
212
+ })
213
+ );
191
214
  }
192
215
 
193
216
  async function getInstalledPackagesAllScopesForRemoval(
@@ -204,25 +227,15 @@ interface RemovalTarget {
204
227
  name: string;
205
228
  }
206
229
 
207
- function scopeChoiceFromLabel(choice: string | undefined): RemovalScopeChoice {
208
- if (!choice || choice === "Cancel") return "cancel";
209
- if (choice.includes("Both")) return "both";
210
- if (choice.includes("Global")) return "global";
211
- if (choice.includes("Project")) return "project";
212
- return "cancel";
213
- }
214
-
215
230
  async function selectRemovalScope(ctx: ExtensionCommandContext): Promise<RemovalScopeChoice> {
216
231
  if (!ctx.hasUI) return "global";
217
232
 
218
- const choice = await ctx.ui.select("Remove scope", [
219
- "Both global + project",
220
- "Global only",
221
- "Project only",
222
- "Cancel",
223
- ]);
224
-
225
- return scopeChoiceFromLabel(choice);
233
+ return (
234
+ parseChoiceByLabel(
235
+ REMOVAL_SCOPE_CHOICES,
236
+ await ctx.ui.select("Remove scope", Object.values(REMOVAL_SCOPE_CHOICES))
237
+ ) ?? "cancel"
238
+ );
226
239
  }
227
240
 
228
241
  function buildRemovalTargets(
@@ -244,7 +257,6 @@ function buildRemovalTargets(
244
257
  return addTarget("global");
245
258
  case "project":
246
259
  return addTarget("project");
247
- case "cancel":
248
260
  default:
249
261
  return [];
250
262
  }
@@ -285,6 +297,7 @@ async function executeRemovalTargets(
285
297
  title: "Remove Package",
286
298
  message: `Removing ${target.source}...`,
287
299
  cancellable: false,
300
+ fallbackWithoutLoader: true,
288
301
  },
289
302
  async ({ setMessage }) => {
290
303
  await getPackageCatalog(ctx.cwd).remove(target.source, target.scope, (event) => {
@@ -338,8 +351,8 @@ async function removePackageInternal(
338
351
  pi: ExtensionAPI
339
352
  ): Promise<PackageMutationOutcome> {
340
353
  const installed = await getInstalledPackagesAllScopesForRemoval(ctx);
341
- const identity = packageIdentity(source);
342
- const matching = installed.filter((p) => packageIdentity(p.source) === identity);
354
+ const identities = packageSourceIdentities(source, ctx);
355
+ const matching = installed.filter((pkg) => installedPackageMatchesSource(pkg, identities, ctx));
343
356
 
344
357
  const hasBothScopes =
345
358
  matching.some((pkg) => pkg.scope === "global") &&
@@ -348,18 +361,18 @@ async function removePackageInternal(
348
361
 
349
362
  if (scopeChoice === "cancel") {
350
363
  notify(ctx, "Removal cancelled.", "info");
351
- return NO_PACKAGE_MUTATION_OUTCOME;
364
+ return { reloaded: false };
352
365
  }
353
366
 
354
367
  if (matching.length === 0) {
355
368
  notify(ctx, `${source} is not installed.`, "info");
356
- return NO_PACKAGE_MUTATION_OUTCOME;
369
+ return { reloaded: false };
357
370
  }
358
371
 
359
372
  const targets = buildRemovalTargets(matching, ctx.hasUI, scopeChoice);
360
373
  if (targets.length === 0) {
361
374
  notify(ctx, "Nothing to remove.", "info");
362
- return NO_PACKAGE_MUTATION_OUTCOME;
375
+ return { reloaded: false };
363
376
  }
364
377
 
365
378
  const confirmed = await confirmAction(
@@ -370,7 +383,7 @@ async function removePackageInternal(
370
383
  );
371
384
  if (!confirmed) {
372
385
  notify(ctx, "Removal cancelled.", "info");
373
- return NO_PACKAGE_MUTATION_OUTCOME;
386
+ return { reloaded: false };
374
387
  }
375
388
 
376
389
  const results = await executeRemovalTargets(targets, ctx, pi);
@@ -385,20 +398,20 @@ async function removePackageInternal(
385
398
  .filter((result) => result.success)
386
399
  .map((result) => result.target);
387
400
 
388
- const remaining = (await getInstalledPackagesAllScopesForRemoval(ctx)).filter(
389
- (p) => packageIdentity(p.source) === identity
401
+ const remaining = (await getInstalledPackagesAllScopesForRemoval(ctx)).filter((pkg) =>
402
+ installedPackageMatchesSource(pkg, identities, ctx)
390
403
  );
391
404
  notifyRemovalSummary(source, remaining, failures, ctx);
392
405
 
393
406
  if (failures.length === 0 && remaining.length === 0) {
394
- clearUpdatesAvailable(pi, ctx, [identity]);
407
+ clearUpdatesAvailable(pi, ctx, identities);
395
408
  }
396
409
 
397
410
  const successfulRemovalCount = successfulTargets.length;
398
411
 
399
412
  if (successfulRemovalCount === 0) {
400
413
  void updateExtmgrStatus(ctx, pi);
401
- return NO_PACKAGE_MUTATION_OUTCOME;
414
+ return { reloaded: false };
402
415
  }
403
416
 
404
417
  const reloaded = await confirmReload(ctx, "Removal complete.");
@@ -406,7 +419,7 @@ async function removePackageInternal(
406
419
  void updateExtmgrStatus(ctx, pi);
407
420
  }
408
421
 
409
- return packageMutationOutcome({ reloaded });
422
+ return { reloaded };
410
423
  }
411
424
 
412
425
  export async function removePackage(