pi-extmgr 0.1.23 → 0.1.25

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
@@ -26,6 +26,7 @@ Requires Node.js `>=22.5.0`.
26
26
  - Scope indicators (global/project), status indicators, update badges
27
27
  - **Package extension configuration panel**
28
28
  - Configure individual extension entrypoints inside an installed package (`c` on package row)
29
+ - Works with manifest-declared entrypoints and conventional `extensions/` package layouts
29
30
  - Persists to package filters in `settings.json` (no manual JSON editing)
30
31
  - **Safe staged local extension toggles**
31
32
  - Toggle with `Space/Enter`, apply with `S`
@@ -36,15 +37,16 @@ Requires Node.js `>=22.5.0`.
36
37
  - **Remote discovery and install**
37
38
  - npm search/browse with pagination
38
39
  - Install by source (`npm:`, `git:`, `https://`, `ssh://`, `git@...`, local path)
39
- - Supports direct GitHub `.ts` installs and local standalone install mode
40
+ - Supports direct GitHub `.ts` installs and standalone local install for self-contained packages
40
41
  - **Auto-update**
41
42
  - Interactive wizard (`t` in manager, or `/extensions auto-update`)
42
43
  - Persistent schedule restored on startup and session switch
43
- - Background checks + status bar updates
44
+ - Background checks + status bar updates for installed npm packages
44
45
  - **Operational visibility**
45
46
  - Session history (`/extensions history`)
46
- - Cache controls (`/extensions clear-cache`)
47
+ - Cache controls (`/extensions clear-cache` clears persistent + runtime extmgr caches)
47
48
  - Status line summary (`pkg count • auto-update • known updates`)
49
+ - History now records local extension deletions and auto-update configuration changes
48
50
  - **Interactive + non-interactive support**
49
51
  - Works in TUI and non-UI modes
50
52
  - Non-interactive commands for list/install/remove/update/auto-update
@@ -91,9 +93,9 @@ Open the manager:
91
93
  /extensions remove [source] # Remove package
92
94
  /extensions uninstall [source] # Alias: remove
93
95
  /extensions update [source] # Update one package (or all when omitted)
94
- /extensions auto-update [every] # No arg opens wizard in UI; accepts 1d, 1w, never, etc.
96
+ /extensions auto-update [every] # No arg opens wizard in UI; accepts 1d, 1w, 1mo, never, etc.
95
97
  /extensions history [options] # View change history (supports filters)
96
- /extensions clear-cache # Clear metadata cache
98
+ /extensions clear-cache # Clear persistent + runtime extmgr caches
97
99
  ```
98
100
 
99
101
  ### Non-interactive mode
@@ -107,23 +109,33 @@ When Pi is running without UI, extmgr still supports command-driven workflows:
107
109
  - `/extensions update [source]`
108
110
  - `/extensions history [options]`
109
111
  - `/extensions auto-update <duration>`
112
+ - Use `1mo` for monthly schedules (`/extensions history --since <duration>` also accepts `1mo`; `30m`/`24h` are just lookback examples)
110
113
 
111
- Remote browsing/search menus require interactive mode.
114
+ Remote browsing/search menus require the full interactive TUI.
115
+
116
+ ### RPC / limited-UI mode
117
+
118
+ In RPC mode, dialog-based commands still work, but the custom TUI panels do not:
119
+
120
+ - `/extensions` falls back to read-only local/package lists
121
+ - `/extensions installed` lists packages directly
122
+ - remote browsing/search panels require the full interactive TUI
123
+ - package extension configuration requires the full interactive TUI
112
124
 
113
125
  History options (works in non-interactive mode too):
114
126
 
115
127
  - `--limit <n>`
116
- - `--action <extension_toggle|package_install|package_update|package_remove|cache_clear>`
128
+ - `--action <extension_toggle|extension_delete|package_install|package_update|package_remove|cache_clear|auto_update_config>`
117
129
  - `--success` / `--failed`
118
130
  - `--package <query>`
119
- - `--since <duration>` (e.g. `30m`, `24h`, `7d`, `1mo`)
120
- - `--global` (non-interactive mode only; reads all persisted sessions)
131
+ - `--since <duration>` (e.g. `30m`, `24h`, `7d`, `1mo`; `1mo` is supported for monthly lookbacks)
132
+ - `--global` (non-interactive mode only; reads all persisted sessions under `~/.pi/agent/sessions`)
121
133
 
122
134
  Examples:
123
135
 
124
136
  - `/extensions history --failed --limit 50`
125
137
  - `/extensions history --action package_update --since 7d`
126
- - `/extensions history --global --package extmgr --since 24h`
138
+ - `/extensions history --global --package extmgr --since 1mo`
127
139
 
128
140
  ### Install sources
129
141
 
@@ -144,9 +156,10 @@ Examples:
144
156
  - **Package extension config**: Select a package and press `c` (or Enter/A → Configure) to enable/disable individual package entrypoints.
145
157
  - After saving package extension config, restart pi to fully apply changes.
146
158
  - **Two install modes**:
147
- - **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache
148
- - **Local** (standalone): Copies to `~/.pi/agent/extensions/{package}/`, supports multi-file extensions
159
+ - **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache, supports Pi package manifest/convention loading
160
+ - **Local** (standalone): Copies to `~/.pi/agent/extensions/{package}/`, so it only accepts runnable standalone layouts (manifest-declared/root entrypoints), requires `tar` on `PATH`, and rejects packages whose runtime `dependencies` are not already bundled with the package contents
149
161
  - **Auto-update schedule is persistent**: `/extensions auto-update 1d` stays active across future Pi sessions and is restored when switching sessions.
162
+ - **Auto-update coverage is npm-only today**: extmgr checks update availability for managed npm packages; git/local installs are not included in the background update badge yet.
150
163
  - **Settings/cache writes are hardened**: extmgr serializes writes and uses safe file replacement to reduce JSON corruption issues.
151
164
  - **Invalid JSON is handled safely**: malformed `auto-update.json` / metadata cache files are backed up and reset; invalid `.pi/settings.json` is not overwritten during package-extension toggles.
152
165
  - **Reload is built-in**: When extmgr asks to reload, it calls `ctx.reload()` directly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -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
- " 1m - Check monthly",
57
+ " 1mo - Check monthly (1m also works)",
58
58
  " daily - Check daily (alias)",
59
59
  " weekly - Check weekly (alias)",
60
60
  ];
@@ -1,4 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
+ import { clearSearchCache } from "../packages/discovery.js";
3
+ import { clearRemotePackageInfoCache } from "../ui/remote.js";
2
4
  import { clearCache } from "../utils/cache.js";
3
5
  import { logCacheClear } from "../utils/history.js";
4
6
  import { notify } from "../utils/notify.js";
@@ -10,8 +12,10 @@ export async function clearMetadataCacheCommand(
10
12
  ): Promise<void> {
11
13
  try {
12
14
  await clearCache();
15
+ clearSearchCache();
16
+ clearRemotePackageInfoCache();
13
17
  logCacheClear(pi, true);
14
- notify(ctx, "Metadata cache cleared.", "info");
18
+ notify(ctx, "Metadata and in-memory extmgr caches cleared.", "info");
15
19
  } catch (error) {
16
20
  const message = error instanceof Error ? error.message : String(error);
17
21
  logCacheClear(pi, false, message);
@@ -11,10 +11,12 @@ import { formatListOutput } from "../utils/ui-helpers.js";
11
11
 
12
12
  const HISTORY_ACTIONS: ChangeAction[] = [
13
13
  "extension_toggle",
14
+ "extension_delete",
14
15
  "package_install",
15
16
  "package_update",
16
17
  "package_remove",
17
18
  "cache_clear",
19
+ "auto_update_config",
18
20
  ];
19
21
 
20
22
  interface ParsedHistoryArgs {
@@ -187,12 +189,12 @@ function showHistoryHelp(ctx: ExtensionCommandContext): void {
187
189
  "Options:",
188
190
  " --limit <n> Maximum entries to show (default: 20)",
189
191
  " --action <type> Filter by action",
190
- " extension_toggle | package_install | package_update | package_remove | cache_clear",
192
+ ` ${HISTORY_ACTIONS.join(" | ")}`,
191
193
  " --success Show only successful entries",
192
194
  " --failed Show only failed entries",
193
195
  " --package <q> Filter by package/source/extension id",
194
196
  " --since <d> Show only entries newer than duration (e.g. 30m, 24h, 7d, 1mo)",
195
- " --global Read all persisted sessions (non-interactive mode only)",
197
+ " --global Read all persisted sessions from ~/.pi/agent/sessions (non-interactive mode only)",
196
198
  "",
197
199
  "Examples:",
198
200
  " /extensions history --failed --limit 50",
@@ -34,7 +34,7 @@ function showNonInteractiveHelp(ctx: ExtensionCommandContext): void {
34
34
  " /extensions remove <source> - Remove a package",
35
35
  " /extensions update [source] - Update one package or all packages",
36
36
  " /extensions history [opts] - Show history (supports filters)",
37
- " /extensions auto-update <d> - Configure auto-update (e.g. 1d, 1w, never)",
37
+ " /extensions auto-update <d> - Configure auto-update (e.g. 1d, 1w, 1mo, never)",
38
38
  "",
39
39
  "History examples:",
40
40
  " /extensions history --failed --limit 50",
@@ -14,10 +14,30 @@ import { readSummary } from "../utils/fs.js";
14
14
  import { parseNpmSource } from "../utils/format.js";
15
15
  import {
16
16
  getPackageSourceKind,
17
- normalizeLocalSourceIdentity,
17
+ normalizePackageIdentity,
18
18
  splitGitRepoAndRef,
19
+ stripGitSourcePrefix,
19
20
  } from "../utils/package-source.js";
20
21
  import { execNpm } from "../utils/npm-exec.js";
22
+ import { fetchWithTimeout } from "../utils/network.js";
23
+
24
+ const NPM_SEARCH_API = "https://registry.npmjs.org/-/v1/search";
25
+ const NPM_SEARCH_PAGE_SIZE = 250;
26
+
27
+ interface NpmSearchResultObject {
28
+ package?: {
29
+ name?: string;
30
+ version?: string;
31
+ description?: string;
32
+ keywords?: string[];
33
+ date?: string;
34
+ };
35
+ }
36
+
37
+ interface NpmSearchResponse {
38
+ total?: number;
39
+ objects?: NpmSearchResultObject[];
40
+ }
21
41
 
22
42
  let searchCache: SearchCache | null = null;
23
43
 
@@ -50,15 +70,86 @@ import {
50
70
  setCachedPackageSize,
51
71
  } from "../utils/cache.js";
52
72
 
73
+ function toNpmPackage(entry: NpmSearchResultObject): NpmPackage | undefined {
74
+ const pkg = entry.package;
75
+ if (!pkg) return undefined;
76
+
77
+ const name = pkg.name?.trim();
78
+ if (!name) return undefined;
79
+
80
+ return {
81
+ name,
82
+ version: pkg.version,
83
+ description: pkg.description,
84
+ keywords: Array.isArray(pkg.keywords) ? pkg.keywords : undefined,
85
+ date: pkg.date,
86
+ };
87
+ }
88
+
89
+ async function fetchNpmSearchPage(
90
+ query: string,
91
+ from: number
92
+ ): Promise<{
93
+ total: number;
94
+ resultCount: number;
95
+ packages: NpmPackage[];
96
+ }> {
97
+ const params = new URLSearchParams({
98
+ text: query,
99
+ size: String(NPM_SEARCH_PAGE_SIZE),
100
+ from: String(from),
101
+ });
102
+ const response = await fetchWithTimeout(
103
+ `${NPM_SEARCH_API}?${params.toString()}`,
104
+ TIMEOUTS.npmSearch
105
+ );
106
+
107
+ if (!response.ok) {
108
+ throw new Error(`npm registry search failed: HTTP ${response.status}`);
109
+ }
110
+
111
+ const data = (await response.json()) as NpmSearchResponse;
112
+ const objects = data.objects ?? [];
113
+ const packages = objects.map(toNpmPackage).filter((pkg): pkg is NpmPackage => !!pkg);
114
+
115
+ return {
116
+ total:
117
+ typeof data.total === "number" && Number.isFinite(data.total) ? data.total : packages.length,
118
+ resultCount: objects.length,
119
+ packages,
120
+ };
121
+ }
122
+
123
+ export async function fetchNpmRegistrySearchResults(query: string): Promise<NpmPackage[]> {
124
+ const packagesByName = new Map<string, NpmPackage>();
125
+ let from = 0;
126
+ let total = Infinity;
127
+
128
+ while (from < total) {
129
+ const page = await fetchNpmSearchPage(query, from);
130
+ total = page.total;
131
+
132
+ if (page.resultCount === 0) {
133
+ break;
134
+ }
135
+
136
+ for (const pkg of page.packages) {
137
+ if (!packagesByName.has(pkg.name)) {
138
+ packagesByName.set(pkg.name, pkg);
139
+ }
140
+ }
141
+
142
+ from += page.resultCount;
143
+ }
144
+
145
+ return [...packagesByName.values()];
146
+ }
147
+
53
148
  export async function searchNpmPackages(
54
149
  query: string,
55
150
  ctx: ExtensionCommandContext,
56
- pi: ExtensionAPI
151
+ _pi: ExtensionAPI
57
152
  ): Promise<NpmPackage[]> {
58
- // Pull more results so browse mode has meaningful pagination.
59
- // npm search can still cap server-side, but this improves coverage.
60
- const searchLimit = 250;
61
-
62
153
  // Check persistent cache first
63
154
  const cached = await getCachedSearch(query);
64
155
  if (cached && cached.length > 0) {
@@ -72,25 +163,12 @@ export async function searchNpmPackages(
72
163
  ctx.ui.notify(`Searching npm for "${query}"...`, "info");
73
164
  }
74
165
 
75
- const res = await execNpm(pi, ["search", "--json", `--searchlimit=${searchLimit}`, query], ctx, {
76
- timeout: TIMEOUTS.npmSearch,
77
- });
166
+ const packages = await fetchNpmRegistrySearchResults(query);
78
167
 
79
- if (res.code !== 0) {
80
- throw new Error(`npm search failed: ${res.stderr || res.stdout || `exit ${res.code}`}`);
81
- }
168
+ // Cache the results
169
+ await setCachedSearch(query, packages);
82
170
 
83
- try {
84
- const parsed = JSON.parse(res.stdout || "[]") as NpmPackage[];
85
- const filtered = parsed.filter((p) => !!p?.name);
86
-
87
- // Cache the results
88
- await setCachedSearch(query, filtered);
89
-
90
- return filtered;
91
- } catch {
92
- throw new Error("Failed to parse npm search output");
93
- }
171
+ return packages;
94
172
  }
95
173
 
96
174
  export async function getInstalledPackages(
@@ -121,15 +199,11 @@ function sanitizeListSourceSuffix(source: string): string {
121
199
  .trim();
122
200
  }
123
201
 
124
- function normalizeSourceIdentity(source: string): string {
125
- const sanitized = sanitizeListSourceSuffix(source);
126
- const kind = getPackageSourceKind(sanitized);
127
-
128
- if (kind === "local") {
129
- return normalizeLocalSourceIdentity(sanitized);
130
- }
131
-
132
- return sanitized.replace(/\\/g, "/").toLowerCase();
202
+ function getInstalledPackageIdentity(pkg: InstalledPackage): string {
203
+ return normalizePackageIdentity(
204
+ pkg.source,
205
+ pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : undefined
206
+ );
133
207
  }
134
208
 
135
209
  function isScopeHeader(lowerTrimmed: string, scope: "global" | "project"): boolean {
@@ -181,12 +255,8 @@ function parseResolvedPathLine(line: string): string | undefined {
181
255
  return undefined;
182
256
  }
183
257
 
184
- function parseInstalledPackagesOutputInternal(
185
- text: string,
186
- options?: { dedupeBySource?: boolean }
187
- ): InstalledPackage[] {
258
+ function parseInstalledPackagesOutputInternal(text: string): InstalledPackage[] {
188
259
  const packages: InstalledPackage[] = [];
189
- const seenSources = new Set<string>();
190
260
 
191
261
  const lines = text.split("\n");
192
262
  let currentScope: "global" | "project" = "global";
@@ -222,15 +292,6 @@ function parseInstalledPackagesOutputInternal(
222
292
  if (!looksLikePackageSource(candidate)) continue;
223
293
 
224
294
  const source = sanitizeListSourceSuffix(candidate);
225
- if (options?.dedupeBySource !== false) {
226
- const sourceIdentity = normalizeSourceIdentity(source);
227
- if (seenSources.has(sourceIdentity)) {
228
- currentPackage = undefined;
229
- continue;
230
- }
231
- seenSources.add(sourceIdentity);
232
- }
233
-
234
295
  const { name, version } = parsePackageNameAndVersion(source);
235
296
 
236
297
  const pkg: InstalledPackage = { source, name, scope: currentScope };
@@ -244,8 +305,34 @@ function parseInstalledPackagesOutputInternal(
244
305
  return packages;
245
306
  }
246
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
+
247
323
  export function parseInstalledPackagesOutput(text: string): InstalledPackage[] {
248
- return parseInstalledPackagesOutputInternal(text, { dedupeBySource: true });
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());
249
336
  }
250
337
 
251
338
  /**
@@ -263,10 +350,10 @@ export async function isSourceInstalled(
263
350
  if (res.code !== 0) return false;
264
351
 
265
352
  const installed = parseInstalledPackagesOutputAllScopes(res.stdout || "");
266
- const expected = normalizeSourceIdentity(source);
353
+ const expected = normalizePackageIdentity(source);
267
354
 
268
355
  return installed.some((pkg) => {
269
- if (normalizeSourceIdentity(pkg.source) !== expected) {
356
+ if (getInstalledPackageIdentity(pkg) !== expected) {
270
357
  return false;
271
358
  }
272
359
  return options?.scope ? pkg.scope === options.scope : true;
@@ -276,8 +363,14 @@ export async function isSourceInstalled(
276
363
  }
277
364
  }
278
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
+ */
279
372
  export function parseInstalledPackagesOutputAllScopes(text: string): InstalledPackage[] {
280
- return parseInstalledPackagesOutputInternal(text, { dedupeBySource: false });
373
+ return parseInstalledPackagesOutputInternal(text);
281
374
  }
282
375
 
283
376
  function extractGitPackageName(repoSpec: string): string {
@@ -316,7 +409,7 @@ function parsePackageNameAndVersion(fullSource: string): {
316
409
 
317
410
  const sourceKind = getPackageSourceKind(fullSource);
318
411
  if (sourceKind === "git") {
319
- const gitSpec = fullSource.startsWith("git:") ? fullSource.slice(4) : fullSource;
412
+ const gitSpec = stripGitSourcePrefix(fullSource);
320
413
  const { repo } = splitGitRepoAndRef(gitSpec);
321
414
  return { name: extractGitPackageName(repo) };
322
415
  }