pi-extmgr 0.1.26 → 0.1.28

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.
@@ -3,23 +3,20 @@
3
3
  */
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
- import type {
7
- ExtensionAPI,
8
- ExtensionCommandContext,
9
- ExtensionContext,
6
+ import {
7
+ type ExtensionAPI,
8
+ type ExtensionCommandContext,
9
+ type ExtensionContext,
10
+ getAgentDir,
10
11
  } from "@mariozechner/pi-coding-agent";
11
- import type { InstalledPackage, NpmPackage, SearchCache } from "../types/index.js";
12
12
  import { CACHE_TTL, TIMEOUTS } from "../constants.js";
13
- import { readSummary } from "../utils/fs.js";
13
+ import { type InstalledPackage, type NpmPackage, type SearchCache } from "../types/index.js";
14
14
  import { parseNpmSource } from "../utils/format.js";
15
- import {
16
- getPackageSourceKind,
17
- normalizePackageIdentity,
18
- splitGitRepoAndRef,
19
- stripGitSourcePrefix,
20
- } from "../utils/package-source.js";
21
- import { execNpm } from "../utils/npm-exec.js";
15
+ import { readSummary } from "../utils/fs.js";
22
16
  import { fetchWithTimeout } from "../utils/network.js";
17
+ import { execNpm } from "../utils/npm-exec.js";
18
+ import { normalizePackageIdentity } from "../utils/package-source.js";
19
+ import { getPackageCatalog } from "./catalog.js";
23
20
 
24
21
  const NPM_SEARCH_API = "https://registry.npmjs.org/-/v1/search";
25
22
  const NPM_SEARCH_PAGE_SIZE = 250;
@@ -41,6 +38,18 @@ interface NpmSearchResponse {
41
38
 
42
39
  let searchCache: SearchCache | null = null;
43
40
 
41
+ function createAbortError(): Error {
42
+ const error = new Error("Operation cancelled");
43
+ error.name = "AbortError";
44
+ return error;
45
+ }
46
+
47
+ function throwIfAborted(signal?: AbortSignal): void {
48
+ if (signal?.aborted) {
49
+ throw createAbortError();
50
+ }
51
+ }
52
+
44
53
  export function getSearchCache(): SearchCache | null {
45
54
  return searchCache;
46
55
  }
@@ -61,13 +70,13 @@ export function isCacheValid(query: string): boolean {
61
70
 
62
71
  // Import persistent cache
63
72
  import {
64
- getCachedSearch,
65
- setCachedSearch,
66
73
  getCachedPackage,
67
- setCachedPackage,
68
- getPackageDescriptions,
69
74
  getCachedPackageSize,
75
+ getCachedSearch,
76
+ getPackageDescriptions,
77
+ setCachedPackage,
70
78
  setCachedPackageSize,
79
+ setCachedSearch,
71
80
  } from "../utils/cache.js";
72
81
 
73
82
  function toNpmPackage(entry: NpmSearchResultObject): NpmPackage | undefined {
@@ -88,7 +97,8 @@ function toNpmPackage(entry: NpmSearchResultObject): NpmPackage | undefined {
88
97
 
89
98
  async function fetchNpmSearchPage(
90
99
  query: string,
91
- from: number
100
+ from: number,
101
+ signal?: AbortSignal
92
102
  ): Promise<{
93
103
  total: number;
94
104
  resultCount: number;
@@ -101,7 +111,8 @@ async function fetchNpmSearchPage(
101
111
  });
102
112
  const response = await fetchWithTimeout(
103
113
  `${NPM_SEARCH_API}?${params.toString()}`,
104
- TIMEOUTS.npmSearch
114
+ TIMEOUTS.npmSearch,
115
+ signal
105
116
  );
106
117
 
107
118
  if (!response.ok) {
@@ -120,13 +131,16 @@ async function fetchNpmSearchPage(
120
131
  };
121
132
  }
122
133
 
123
- export async function fetchNpmRegistrySearchResults(query: string): Promise<NpmPackage[]> {
134
+ export async function fetchNpmRegistrySearchResults(
135
+ query: string,
136
+ signal?: AbortSignal
137
+ ): Promise<NpmPackage[]> {
124
138
  const packagesByName = new Map<string, NpmPackage>();
125
139
  let from = 0;
126
140
  let total = Infinity;
127
141
 
128
142
  while (from < total) {
129
- const page = await fetchNpmSearchPage(query, from);
143
+ const page = await fetchNpmSearchPage(query, from, signal);
130
144
  total = page.total;
131
145
 
132
146
  if (page.resultCount === 0) {
@@ -148,11 +162,10 @@ export async function fetchNpmRegistrySearchResults(query: string): Promise<NpmP
148
162
  export async function searchNpmPackages(
149
163
  query: string,
150
164
  ctx: ExtensionCommandContext,
151
- _pi: ExtensionAPI
165
+ options?: { signal?: AbortSignal }
152
166
  ): Promise<NpmPackage[]> {
153
- // Check persistent cache first
154
167
  const cached = await getCachedSearch(query);
155
- if (cached && cached.length > 0) {
168
+ if (cached) {
156
169
  if (ctx.hasUI) {
157
170
  ctx.ui.notify(`Using ${cached.length} cached results`, "info");
158
171
  }
@@ -163,7 +176,7 @@ export async function searchNpmPackages(
163
176
  ctx.ui.notify(`Searching npm for "${query}"...`, "info");
164
177
  }
165
178
 
166
- const packages = await fetchNpmRegistrySearchResults(query);
179
+ const packages = await fetchNpmRegistrySearchResults(query, options?.signal);
167
180
 
168
181
  // Cache the results
169
182
  await setCachedSearch(query, packages);
@@ -174,256 +187,50 @@ export async function searchNpmPackages(
174
187
  export async function getInstalledPackages(
175
188
  ctx: ExtensionCommandContext | ExtensionContext,
176
189
  pi: ExtensionAPI,
177
- onProgress?: (current: number, total: number) => void
190
+ onProgress?: (current: number, total: number) => void,
191
+ signal?: AbortSignal
178
192
  ): Promise<InstalledPackage[]> {
179
- const res = await pi.exec("pi", ["list"], { timeout: TIMEOUTS.listPackages, cwd: ctx.cwd });
180
- if (res.code !== 0) return [];
193
+ throwIfAborted(signal);
181
194
 
182
- const text = res.stdout || "";
183
- if (!text.trim() || /No packages installed/i.test(text)) {
195
+ const packages = await getPackageCatalog(ctx.cwd).listInstalledPackages();
196
+ if (packages.length === 0) {
184
197
  return [];
185
198
  }
186
199
 
187
- const packages = parseInstalledPackagesOutput(text);
188
-
189
- // Fetch metadata (descriptions and sizes) for packages in parallel
190
- await addPackageMetadata(packages, ctx, pi, onProgress);
191
-
200
+ await addPackageMetadata(packages, ctx, pi, onProgress, signal);
201
+ throwIfAborted(signal);
192
202
  return packages;
193
203
  }
194
204
 
195
- function sanitizeListSourceSuffix(source: string): string {
196
- return source
197
- .trim()
198
- .replace(/\s+\((filtered|pinned)\)$/i, "")
199
- .trim();
200
- }
201
-
202
- function getInstalledPackageIdentity(pkg: InstalledPackage): string {
203
- return normalizePackageIdentity(
204
- pkg.source,
205
- pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : undefined
206
- );
207
- }
208
-
209
- function isScopeHeader(lowerTrimmed: string, scope: "global" | "project"): boolean {
210
- if (scope === "global") {
211
- return (
212
- lowerTrimmed === "global" ||
213
- lowerTrimmed === "user" ||
214
- lowerTrimmed.startsWith("global packages") ||
215
- lowerTrimmed.startsWith("global:") ||
216
- lowerTrimmed.startsWith("user packages") ||
217
- lowerTrimmed.startsWith("user:")
218
- );
219
- }
220
-
221
- return (
222
- lowerTrimmed === "project" ||
223
- lowerTrimmed === "local" ||
224
- lowerTrimmed.startsWith("project packages") ||
225
- lowerTrimmed.startsWith("project:") ||
226
- lowerTrimmed.startsWith("local packages") ||
227
- lowerTrimmed.startsWith("local:")
228
- );
229
- }
230
-
231
- function looksLikePackageSource(source: string): boolean {
232
- return getPackageSourceKind(source) !== "unknown";
233
- }
234
-
235
- function parseResolvedPathLine(line: string): string | undefined {
236
- const resolvedMatch = line.match(/^resolved\s*:\s*(.+)$/i);
237
- if (resolvedMatch?.[1]) {
238
- return resolvedMatch[1].trim();
239
- }
240
-
241
- if (
242
- line.startsWith("/") ||
243
- line.startsWith("./") ||
244
- line.startsWith("../") ||
245
- line.startsWith(".\\") ||
246
- line.startsWith("..\\") ||
247
- line.startsWith("~/") ||
248
- line.startsWith("file://") ||
249
- /^[a-zA-Z]:[\\/]/.test(line) ||
250
- line.startsWith("\\\\")
251
- ) {
252
- return line;
253
- }
254
-
255
- return undefined;
256
- }
257
-
258
- function parseInstalledPackagesOutputInternal(text: string): InstalledPackage[] {
259
- const packages: InstalledPackage[] = [];
260
-
261
- const lines = text.split("\n");
262
- let currentScope: "global" | "project" = "global";
263
- let currentPackage: InstalledPackage | undefined;
264
-
265
- for (const rawLine of lines) {
266
- if (!rawLine.trim()) continue;
267
-
268
- const isIndented = /^(?:\t+|\s{4,})/.test(rawLine);
269
- const trimmed = rawLine.trim();
270
-
271
- if (isIndented && currentPackage) {
272
- const resolved = parseResolvedPathLine(trimmed);
273
- if (resolved) {
274
- currentPackage.resolvedPath = resolved;
275
- }
276
- continue;
277
- }
278
-
279
- const lowerTrimmed = trimmed.toLowerCase();
280
- if (isScopeHeader(lowerTrimmed, "global")) {
281
- currentScope = "global";
282
- currentPackage = undefined;
283
- continue;
284
- }
285
- if (isScopeHeader(lowerTrimmed, "project")) {
286
- currentScope = "project";
287
- currentPackage = undefined;
288
- continue;
289
- }
290
-
291
- const candidate = trimmed.replace(/^[-•]?\s*/, "").trim();
292
- if (!looksLikePackageSource(candidate)) continue;
293
-
294
- const source = sanitizeListSourceSuffix(candidate);
295
- const { name, version } = parsePackageNameAndVersion(source);
205
+ function getInstalledPackageIdentity(pkg: InstalledPackage, options?: { cwd?: string }): string {
206
+ const baseCwd = pkg.scope === "project" ? options?.cwd : getAgentDir();
296
207
 
297
- const pkg: InstalledPackage = { source, name, scope: currentScope };
298
- if (version !== undefined) {
299
- pkg.version = version;
300
- }
301
- packages.push(pkg);
302
- currentPackage = pkg;
303
- }
304
-
305
- return packages;
306
- }
307
-
308
- function shouldReplaceInstalledPackage(
309
- current: InstalledPackage | undefined,
310
- candidate: InstalledPackage
311
- ): boolean {
312
- if (!current) {
313
- return true;
314
- }
315
-
316
- if (current.scope !== candidate.scope) {
317
- return candidate.scope === "project";
318
- }
319
-
320
- return false;
321
- }
322
-
323
- export function parseInstalledPackagesOutput(text: string): InstalledPackage[] {
324
- const parsed = parseInstalledPackagesOutputInternal(text);
325
- const deduped = new Map<string, InstalledPackage>();
326
-
327
- for (const pkg of parsed) {
328
- const identity = getInstalledPackageIdentity(pkg);
329
- const current = deduped.get(identity);
330
- if (shouldReplaceInstalledPackage(current, pkg)) {
331
- deduped.set(identity, pkg);
332
- }
333
- }
334
-
335
- return Array.from(deduped.values());
208
+ return normalizePackageIdentity(pkg.source, {
209
+ ...(pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : {}),
210
+ ...(baseCwd ? { cwd: baseCwd } : {}),
211
+ });
336
212
  }
337
213
 
338
- /**
339
- * Check whether a specific package source is installed.
340
- * Matches on normalized package source and optional scope.
341
- */
342
214
  export async function isSourceInstalled(
343
215
  source: string,
344
216
  ctx: ExtensionCommandContext | ExtensionContext,
345
- pi: ExtensionAPI,
346
217
  options?: { scope?: "global" | "project" }
347
218
  ): Promise<boolean> {
348
- try {
349
- const res = await pi.exec("pi", ["list"], { timeout: TIMEOUTS.listPackages, cwd: ctx.cwd });
350
- if (res.code !== 0) return false;
219
+ const installed = await getPackageCatalog(ctx.cwd).listInstalledPackages({ dedupe: false });
220
+ const expected = normalizePackageIdentity(source, { cwd: ctx.cwd });
351
221
 
352
- const installed = parseInstalledPackagesOutputAllScopes(res.stdout || "");
353
- const expected = normalizePackageIdentity(source);
354
-
355
- return installed.some((pkg) => {
356
- if (getInstalledPackageIdentity(pkg) !== expected) {
357
- return false;
358
- }
359
- return options?.scope ? pkg.scope === options.scope : true;
360
- });
361
- } catch {
362
- return false;
363
- }
364
- }
365
-
366
- /**
367
- * parseInstalledPackagesOutputAllScopes returns the raw parsed entries from
368
- * parseInstalledPackagesOutputInternal without deduplication or scope merging.
369
- * Prefer parseInstalledPackagesOutput for user-facing lists, since it applies
370
- * deduplication and normalized scope selection.
371
- */
372
- export function parseInstalledPackagesOutputAllScopes(text: string): InstalledPackage[] {
373
- return parseInstalledPackagesOutputInternal(text);
374
- }
375
-
376
- function extractGitPackageName(repoSpec: string): string {
377
- // git@github.com:user/repo(.git)
378
- if (repoSpec.startsWith("git@")) {
379
- const afterColon = repoSpec.split(":").slice(1).join(":");
380
- if (afterColon) {
381
- const last = afterColon.split("/").pop() || afterColon;
382
- return last.replace(/\.git$/i, "") || repoSpec;
222
+ return installed.some((pkg) => {
223
+ if (getInstalledPackageIdentity(pkg, { cwd: ctx.cwd }) !== expected) {
224
+ return false;
383
225
  }
384
- }
385
-
386
- // https://..., ssh://..., git://...
387
- try {
388
- const url = new URL(repoSpec);
389
- const last = url.pathname.split("/").filter(Boolean).pop();
390
- if (last) {
391
- return last.replace(/\.git$/i, "") || repoSpec;
392
- }
393
- } catch {
394
- // Fallback below
395
- }
396
-
397
- const last = repoSpec.split(/[/:]/).filter(Boolean).pop();
398
- return (last ? last.replace(/\.git$/i, "") : repoSpec) || repoSpec;
226
+ return options?.scope ? pkg.scope === options.scope : true;
227
+ });
399
228
  }
400
229
 
401
- function parsePackageNameAndVersion(fullSource: string): {
402
- name: string;
403
- version?: string | undefined;
404
- } {
405
- const parsedNpm = parseNpmSource(fullSource);
406
- if (parsedNpm) {
407
- return parsedNpm;
408
- }
409
-
410
- const sourceKind = getPackageSourceKind(fullSource);
411
- if (sourceKind === "git") {
412
- const gitSpec = stripGitSourcePrefix(fullSource);
413
- const { repo } = splitGitRepoAndRef(gitSpec);
414
- return { name: extractGitPackageName(repo) };
415
- }
416
-
417
- if (fullSource.includes("node_modules/")) {
418
- const nmMatch = fullSource.match(/node_modules\/(.+)$/);
419
- if (nmMatch?.[1]) {
420
- return { name: nmMatch[1] };
421
- }
422
- }
423
-
424
- const pathParts = fullSource.split(/[\\/]/);
425
- const fileName = pathParts[pathParts.length - 1];
426
- return { name: fileName || fullSource };
230
+ export async function getInstalledPackagesAllScopes(
231
+ ctx: ExtensionCommandContext | ExtensionContext
232
+ ): Promise<InstalledPackage[]> {
233
+ return getPackageCatalog(ctx.cwd).listInstalledPackages({ dedupe: false });
427
234
  }
428
235
 
429
236
  async function hydratePackageFromResolvedPath(pkg: InstalledPackage): Promise<void> {
@@ -471,7 +278,8 @@ async function hydratePackageFromResolvedPath(pkg: InstalledPackage): Promise<vo
471
278
  async function fetchPackageSize(
472
279
  pkgName: string,
473
280
  ctx: ExtensionCommandContext | ExtensionContext,
474
- pi: ExtensionAPI
281
+ pi: ExtensionAPI,
282
+ signal?: AbortSignal
475
283
  ): Promise<number | undefined> {
476
284
  // Check cache first
477
285
  const cachedSize = await getCachedPackageSize(pkgName);
@@ -481,6 +289,7 @@ async function fetchPackageSize(
481
289
  // Try to get unpacked size from npm view
482
290
  const res = await execNpm(pi, ["view", pkgName, "dist.unpackedSize", "--json"], ctx, {
483
291
  timeout: TIMEOUTS.npmView,
292
+ ...(signal ? { signal } : {}),
484
293
  });
485
294
  if (res.code === 0) {
486
295
  try {
@@ -503,25 +312,29 @@ async function addPackageMetadata(
503
312
  packages: InstalledPackage[],
504
313
  ctx: ExtensionCommandContext | ExtensionContext,
505
314
  pi: ExtensionAPI,
506
- onProgress?: (current: number, total: number) => void
315
+ onProgress?: (current: number, total: number) => void,
316
+ signal?: AbortSignal
507
317
  ): Promise<void> {
508
- // First, try to get descriptions from cache
318
+ throwIfAborted(signal);
319
+
509
320
  const cachedDescriptions = await getPackageDescriptions(packages);
510
321
  for (const [source, description] of cachedDescriptions) {
511
322
  const pkg = packages.find((p) => p.source === source);
512
323
  if (pkg) pkg.description = description;
513
324
  }
514
325
 
515
- // Process remaining packages in batches
516
326
  const batchSize = 5;
517
327
  for (let i = 0; i < packages.length; i += batchSize) {
328
+ throwIfAborted(signal);
329
+
518
330
  const batch = packages.slice(i, i + batchSize);
519
331
 
520
- // Report progress
521
332
  onProgress?.(i, packages.length);
522
333
 
523
334
  await Promise.all(
524
335
  batch.map(async (pkg) => {
336
+ throwIfAborted(signal);
337
+
525
338
  await hydratePackageFromResolvedPath(pkg);
526
339
 
527
340
  const needsDescription = !pkg.description;
@@ -546,6 +359,7 @@ async function addPackageMetadata(
546
359
  } else {
547
360
  const res = await execNpm(pi, ["view", pkgName, "description", "--json"], ctx, {
548
361
  timeout: TIMEOUTS.npmView,
362
+ ...(signal ? { signal } : {}),
549
363
  });
550
364
  if (res.code === 0) {
551
365
  try {
@@ -565,7 +379,7 @@ async function addPackageMetadata(
565
379
  }
566
380
 
567
381
  if (needsSize) {
568
- pkg.size = await fetchPackageSize(pkgName, ctx, pi);
382
+ pkg.size = await fetchPackageSize(pkgName, ctx, pi, signal);
569
383
  }
570
384
  }
571
385
  } else if (pkg.source.startsWith("git:")) {
@@ -578,8 +392,9 @@ async function addPackageMetadata(
578
392
  }
579
393
  })
580
394
  );
395
+
396
+ throwIfAborted(signal);
581
397
  }
582
398
 
583
- // Final progress update
584
399
  onProgress?.(packages.length, packages.length);
585
400
  }
@@ -1,12 +1,17 @@
1
- import { mkdir, readFile, writeFile, rename, rm, readdir } from "node:fs/promises";
2
- import type { Dirent } from "node:fs";
1
+ import { execFile } from "node:child_process";
2
+ import { type Dirent } from "node:fs";
3
+ import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
3
5
  import { dirname, join, matchesGlob, relative, resolve } from "node:path";
4
6
  import { fileURLToPath } from "node:url";
5
- import { homedir } from "node:os";
6
- import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
9
- import type { InstalledPackage, PackageExtensionEntry, Scope, State } from "../types/index.js";
9
+ import {
10
+ type InstalledPackage,
11
+ type PackageExtensionEntry,
12
+ type Scope,
13
+ type State,
14
+ } from "../types/index.js";
10
15
  import { parseNpmSource } from "../utils/format.js";
11
16
  import { fileExists, readSummary } from "../utils/fs.js";
12
17
  import { resolveNpmCommand } from "../utils/npm-exec.js";
@@ -1,25 +1,30 @@
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 { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
6
+ import { join } from "node:path";
7
+ import {
8
+ type ExtensionAPI,
9
+ type ExtensionCommandContext,
10
+ type ProgressEvent,
11
+ } from "@mariozechner/pi-coding-agent";
12
+ import { TIMEOUTS } from "../constants.js";
13
+ import { runTaskWithLoader } from "../ui/async-task.js";
8
14
  import { normalizePackageSource } from "../utils/format.js";
9
15
  import { fileExists } from "../utils/fs.js";
10
- import { clearSearchCache, isSourceInstalled } from "./discovery.js";
11
- import { discoverPackageExtensionEntrypoints, readPackageManifest } from "./extensions.js";
12
- import { waitForCondition } from "../utils/retry.js";
13
16
  import { logPackageInstall } from "../utils/history.js";
14
- import { clearUpdatesAvailable } from "../utils/settings.js";
15
- import { notify, error as notifyError, success } from "../utils/notify.js";
16
- import { confirmAction, confirmReload, showProgress } from "../utils/ui-helpers.js";
17
17
  import { tryOperation } from "../utils/mode.js";
18
- import { updateExtmgrStatus } from "../utils/status.js";
18
+ import { fetchWithTimeout } from "../utils/network.js";
19
+ import { notify, error as notifyError, success } from "../utils/notify.js";
19
20
  import { execNpm } from "../utils/npm-exec.js";
20
21
  import { normalizePackageIdentity } from "../utils/package-source.js";
21
- import { fetchWithTimeout } from "../utils/network.js";
22
- import { TIMEOUTS } from "../constants.js";
22
+ import { clearUpdatesAvailable } from "../utils/settings.js";
23
+ import { updateExtmgrStatus } from "../utils/status.js";
24
+ import { confirmAction, confirmReload, showProgress } from "../utils/ui-helpers.js";
25
+ import { getPackageCatalog } from "./catalog.js";
26
+ import { clearSearchCache } from "./discovery.js";
27
+ import { discoverPackageExtensionEntrypoints, readPackageManifest } from "./extensions.js";
23
28
 
24
29
  export type InstallScope = "global" | "project";
25
30
 
@@ -27,6 +32,10 @@ export interface InstallOptions {
27
32
  scope?: InstallScope;
28
33
  }
29
34
 
35
+ function getProgressMessage(event: ProgressEvent, fallback: string): string {
36
+ return event.message?.trim() || fallback;
37
+ }
38
+
30
39
  async function resolveInstallScope(
31
40
  ctx: ExtensionCommandContext,
32
41
  explicitScope?: InstallScope
@@ -162,7 +171,7 @@ export async function installPackage(
162
171
 
163
172
  // Check if it's a GitHub URL to a .ts file - handle as direct download
164
173
  const githubTsMatch = source.match(
165
- /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/(.+\.ts)$/
174
+ /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+\.ts)$/
166
175
  );
167
176
  const githubInfo = safeExtractGithubMatch(githubTsMatch);
168
177
  if (githubInfo) {
@@ -195,11 +204,25 @@ export async function installPackage(
195
204
 
196
205
  showProgress(ctx, "Installing", normalized);
197
206
 
198
- const args = ["install", ...(scope === "project" ? ["-l"] : []), normalized];
199
- const res = await pi.exec("pi", args, { timeout: TIMEOUTS.packageInstall, cwd: ctx.cwd });
200
-
201
- if (res.code !== 0) {
202
- const errorMsg = `Install failed:\n${res.stderr || res.stdout || `exit ${res.code}`}`;
207
+ try {
208
+ await runTaskWithLoader(
209
+ ctx,
210
+ {
211
+ title: "Install Package",
212
+ message: `Installing ${normalized}...`,
213
+ cancellable: false,
214
+ fallbackWithoutLoader: true,
215
+ },
216
+ async ({ setMessage }) => {
217
+ await getPackageCatalog(ctx.cwd).install(normalized, scope, (event) => {
218
+ setMessage(getProgressMessage(event, `Installing ${normalized}...`));
219
+ });
220
+ return undefined;
221
+ }
222
+ );
223
+ } catch (error) {
224
+ const message = error instanceof Error ? error.message : String(error);
225
+ const errorMsg = `Install failed:\n${message}`;
203
226
  logPackageInstall(pi, normalized, normalized, undefined, scope, false, errorMsg);
204
227
  notifyError(ctx, errorMsg);
205
228
  void updateExtmgrStatus(ctx, pi);
@@ -209,25 +232,7 @@ export async function installPackage(
209
232
  clearSearchCache();
210
233
  logPackageInstall(pi, normalized, normalized, undefined, scope, true);
211
234
  success(ctx, `Installed ${normalized} (${scope})`);
212
- clearUpdatesAvailable(pi, ctx, [normalizePackageIdentity(normalized)]);
213
-
214
- // Wait for the extension to be discoverable before reloading.
215
- // This prevents a race condition where ctx.reload() runs before
216
- // settings.json or extension files are fully flushed to disk.
217
- notify(ctx, "Waiting for extension to be ready...", "info");
218
- const isReady = await waitForCondition(() => isSourceInstalled(normalized, ctx, pi, { scope }), {
219
- maxAttempts: 10,
220
- delayMs: 100,
221
- backoff: "exponential",
222
- });
223
-
224
- if (!isReady) {
225
- notify(
226
- ctx,
227
- "Extension may not be immediately available. Reload pi manually if needed.",
228
- "warning"
229
- );
230
- }
235
+ clearUpdatesAvailable(pi, ctx, [normalizePackageIdentity(normalized, { cwd: ctx.cwd })]);
231
236
 
232
237
  const reloaded = await confirmReload(ctx, "Package installed.");
233
238
  if (!reloaded) {