pi-extmgr 0.0.5 → 0.1.1

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
@@ -27,16 +27,17 @@
27
27
  All your extensions in one place:
28
28
 
29
29
  - **Local extensions**: `● enabled` / `○ disabled` with `[G]` global or `[P]` project scope
30
- - **Installed packages**: `📦` icon with name@version
30
+ - **Installed packages**: `◆` npm / `◇` git icon with name@version and size info
31
31
  - **Visual distinction** between toggle-able locals and action-based packages
32
32
  - **Smart deduplication** - packages already managed as local extensions are hidden
33
+ - **Theme adaptive UI** - Works consistently in dark and light themes
33
34
 
34
35
  ### 🔍 Smart Package Discovery
35
36
 
36
37
  - **Browse community packages** with pagination (20 per page)
37
- - **Cached search results** for lightning-fast navigation
38
+ - **Cached search results** - npm metadata cached for 24 hours for fast navigation
38
39
  - **Keyword filtering** - automatically shows `pi-package` tagged npm packages
39
- - **Detailed package info** - view version, author, homepage
40
+ - **Detailed package info** - view version, author, homepage, and install size
40
41
 
41
42
  ### 📦 Flexible Installation
42
43
 
@@ -56,6 +57,15 @@ All your extensions in one place:
56
57
  - **Bulk operations** - update all packages at once
57
58
  - **Scope indicators**: Global (G) vs Project (P) for all items
58
59
 
60
+ ### 📊 Extension Change History
61
+
62
+ Track all your extension management actions:
63
+
64
+ - **Automatic logging** - Every toggle, install, update, and remove is recorded
65
+ - **Session persistence** - View change history with `/extensions history`
66
+ - **Statistics** - View totals by action type with `/extensions stats`
67
+ - **Error tracking** - Failed operations are logged with error details
68
+
59
69
  ### 🎯 Quality of Life
60
70
 
61
71
  - **Tab autocomplete** for all subcommands
@@ -63,6 +73,7 @@ All your extensions in one place:
63
73
  - **Keyboard shortcut**: `Ctrl+Shift+E` opens extension manager
64
74
  - **Non-interactive mode** - works in scripts and CI
65
75
  - **Parallel data loading** - local extensions and packages fetched simultaneously
76
+ - **Metadata caching** - npm package info cached for 24 hours for faster browsing
66
77
 
67
78
  ## 🚀 Installation
68
79
 
@@ -145,6 +156,11 @@ Browse and install from npm:
145
156
  /extensions install <source> # Install from npm/git/path
146
157
  /extensions remove [source] # Remove package (interactive if no source)
147
158
  /extensions uninstall [source]# Alias for remove
159
+
160
+ # History & Stats
161
+ /extensions history # View recent extension changes
162
+ /extensions stats # View extension manager statistics
163
+ /extensions clear-cache # Clear metadata cache
148
164
  ```
149
165
 
150
166
  ### Install Sources
@@ -299,6 +315,16 @@ export default function myTheme(pi: ExtensionAPI) {
299
315
 
300
316
  The extension remains installed but won't load until re-enabled.
301
317
 
318
+ ### Removing a Package
319
+
320
+ 1. Type `/extensions` to open unified manager
321
+ 2. Navigate to the installed package with `↑↓`
322
+ 3. Press `A` for actions
323
+ 4. Select "Remove package"
324
+ 5. Confirm removal
325
+
326
+ **Important**: Unlike install/update, removing a package requires a **full restart** of pi (not just `/reload`). The extension files are deleted, but the extension remains loaded in memory until you exit and restart pi.
327
+
302
328
  ### Updating All Packages
303
329
 
304
330
  1. Type `/extensions` to open unified manager
@@ -307,6 +333,32 @@ The extension remains installed but won't load until re-enabled.
307
333
  4. Select "Update package"
308
334
  5. Or use command: `/extensions install npm:pi-extmgr` then select "[Update all packages]"
309
335
 
336
+ ### Viewing Change History
337
+
338
+ See what you've done in the current session:
339
+
340
+ ```
341
+ /extensions history
342
+ ```
343
+
344
+ Output shows recent actions like:
345
+
346
+ ```
347
+ [12:34:56] ✓ global:my-extension: enabled → disabled
348
+ [12:35:10] ✓ Installed pi-some-package@1.2.3
349
+ [12:36:22] ✓ Updated pi-other-package → @2.0.0
350
+ ```
351
+
352
+ ### Viewing Statistics
353
+
354
+ Get a summary of your extension management activity:
355
+
356
+ ```
357
+ /extensions stats
358
+ ```
359
+
360
+ Shows installed package count, total changes, and breakdown by action type.
361
+
310
362
  ## 🐛 Troubleshooting
311
363
 
312
364
  ### Commands not showing after install
@@ -330,6 +382,15 @@ Check that the file has a `.ts` or `.js` extension and is in one of the discover
330
382
  - For git installs, ensure git is available
331
383
  - Verify the package has the `pi-package` keyword for browsing
332
384
 
385
+ ### Commands still work after removing package
386
+
387
+ This is expected behavior. When you remove a package:
388
+
389
+ 1. The files are deleted from disk
390
+ 2. The extension remains loaded in pi's memory
391
+ 3. Commands continue to work until you **restart pi**\n
392
+ A full restart (Ctrl+C or `/quit`) is required to fully unload removed extensions. `/reload` is only sufficient for install/update operations.
393
+
333
394
  ### Back to manager closes everything
334
395
 
335
396
  Fixed! Pressing "Back to manager" now correctly returns to the unified view instead of closing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.0.5",
3
+ "version": "0.1.1",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -9,9 +9,9 @@
9
9
  "pi-extension-manager"
10
10
  ],
11
11
  "type": "module",
12
- "main": "./index.ts",
12
+ "main": "./src/index.ts",
13
13
  "files": [
14
- "index.ts",
14
+ "src/",
15
15
  "README.md"
16
16
  ],
17
17
  "scripts": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "pi": {
29
29
  "extensions": [
30
- "./index.ts"
30
+ "./src/index.ts"
31
31
  ],
32
32
  "video": "https://github.com/ayagmar/pi-extmgr/releases/download/v0.0.2/Screencast_20260207_013142.mp4",
33
33
  "image": "https://i.imgur.com/nP5rJPC.png"
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Constants for pi-extmgr
3
+ */
4
+
5
+ export const DISABLED_SUFFIX = ".disabled";
6
+ export const PAGE_SIZE = 20;
7
+ export const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Local extension discovery
3
+ */
4
+ import { readdir, rename } from "node:fs/promises";
5
+ import { join, relative } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import type { Dirent } from "node:fs";
8
+ import type { ExtensionEntry, Scope, State } from "../types/index.js";
9
+ import { DISABLED_SUFFIX } from "../constants.js";
10
+ import { fileExists, readSummary } from "../utils/fs.js";
11
+
12
+ interface RootConfig {
13
+ root: string;
14
+ scope: Scope;
15
+ label: string;
16
+ }
17
+
18
+ export async function discoverExtensions(cwd: string): Promise<ExtensionEntry[]> {
19
+ const roots: RootConfig[] = [
20
+ {
21
+ root: join(homedir(), ".pi", "agent", "extensions"),
22
+ scope: "global",
23
+ label: "~/.pi/agent/extensions",
24
+ },
25
+ { root: join(cwd, ".pi", "extensions"), scope: "project", label: ".pi/extensions" },
26
+ ];
27
+
28
+ const all: ExtensionEntry[] = [];
29
+ for (const root of roots) {
30
+ all.push(...(await discoverInRoot(root.root, root.scope, root.label)));
31
+ }
32
+
33
+ all.sort((a, b) => a.displayName.localeCompare(b.displayName));
34
+ return dedupeExtensions(all);
35
+ }
36
+
37
+ async function discoverInRoot(
38
+ root: string,
39
+ scope: Scope,
40
+ label: string
41
+ ): Promise<ExtensionEntry[]> {
42
+ let dirEntries: Dirent[];
43
+ try {
44
+ dirEntries = await readdir(root, { withFileTypes: true });
45
+ } catch (error) {
46
+ // Silently ignore ENOENT (directory doesn't exist) - this is expected
47
+ // for project scope when .pi/extensions doesn't exist yet
48
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
49
+ return [];
50
+ }
51
+ // Log other errors for debugging
52
+ console.error(`[extensions-manager] Error reading ${root}:`, error);
53
+ return [];
54
+ }
55
+
56
+ const found: ExtensionEntry[] = [];
57
+
58
+ for (const item of dirEntries) {
59
+ const name = item.name;
60
+
61
+ // Skip hidden files and directories (e.g., .temp, .git, etc.)
62
+ if (name.startsWith(".")) continue;
63
+
64
+ if (item.isFile()) {
65
+ const entry = await parseTopLevelFile(root, label, scope, name);
66
+ if (entry) found.push(entry);
67
+ continue;
68
+ }
69
+
70
+ if (item.isDirectory()) {
71
+ const entry = await parseDirectoryIndex(root, label, scope, name);
72
+ if (entry) found.push(entry);
73
+ }
74
+ }
75
+
76
+ return found;
77
+ }
78
+
79
+ async function parseTopLevelFile(
80
+ root: string,
81
+ label: string,
82
+ scope: Scope,
83
+ fileName: string
84
+ ): Promise<ExtensionEntry | undefined> {
85
+ const isEnabledTsJs = /\.(ts|js)$/i.test(fileName) && !fileName.endsWith(DISABLED_SUFFIX);
86
+ const isDisabledTsJs = /\.(ts|js)\.disabled$/i.test(fileName);
87
+
88
+ if (!isEnabledTsJs && !isDisabledTsJs) return undefined;
89
+
90
+ const currentPath = join(root, fileName);
91
+ const activePath = isDisabledTsJs ? currentPath.slice(0, -DISABLED_SUFFIX.length) : currentPath;
92
+ const disabledPath = `${activePath}${DISABLED_SUFFIX}`;
93
+ const state: State = isDisabledTsJs ? "disabled" : "enabled";
94
+ const summary = await readSummary(state === "enabled" ? activePath : disabledPath);
95
+
96
+ const relativePath = relative(root, activePath).replace(/\.disabled$/i, "");
97
+
98
+ return {
99
+ id: `${scope}:${activePath}`,
100
+ scope,
101
+ state,
102
+ activePath,
103
+ disabledPath,
104
+ displayName: `${label}/${relativePath}`,
105
+ summary,
106
+ };
107
+ }
108
+
109
+ async function parseDirectoryIndex(
110
+ root: string,
111
+ label: string,
112
+ scope: Scope,
113
+ dirName: string
114
+ ): Promise<ExtensionEntry | undefined> {
115
+ const dir = join(root, dirName);
116
+
117
+ for (const ext of [".ts", ".js"]) {
118
+ const activePath = join(dir, `index${ext}`);
119
+ const disabledPath = `${activePath}${DISABLED_SUFFIX}`;
120
+
121
+ if (await fileExists(activePath)) {
122
+ return {
123
+ id: `${scope}:${activePath}`,
124
+ scope,
125
+ state: "enabled",
126
+ activePath,
127
+ disabledPath,
128
+ displayName: `${label}/${dirName}/index${ext}`,
129
+ summary: await readSummary(activePath),
130
+ };
131
+ }
132
+
133
+ if (await fileExists(disabledPath)) {
134
+ return {
135
+ id: `${scope}:${activePath}`,
136
+ scope,
137
+ state: "disabled",
138
+ activePath,
139
+ disabledPath,
140
+ displayName: `${label}/${dirName}/index${ext}`,
141
+ summary: await readSummary(disabledPath),
142
+ };
143
+ }
144
+ }
145
+
146
+ return undefined;
147
+ }
148
+
149
+ function dedupeExtensions(entries: ExtensionEntry[]): ExtensionEntry[] {
150
+ const byId = new Map<string, ExtensionEntry>();
151
+ for (const entry of entries) {
152
+ if (!byId.has(entry.id)) {
153
+ byId.set(entry.id, entry);
154
+ }
155
+ }
156
+ return Array.from(byId.values());
157
+ }
158
+
159
+ export async function setExtensionState(
160
+ entry: Pick<ExtensionEntry, "activePath" | "disabledPath">,
161
+ target: State
162
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
163
+ try {
164
+ if (!entry.activePath || !entry.disabledPath) {
165
+ return { ok: false, error: "Missing paths" };
166
+ }
167
+ if (target === "enabled") {
168
+ await rename(entry.disabledPath, entry.activePath);
169
+ } else {
170
+ await rename(entry.activePath, entry.disabledPath);
171
+ }
172
+ return { ok: true };
173
+ } catch (error) {
174
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
175
+ }
176
+ }
package/src/index.ts ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Extensions Manager - Enhanced UI/UX for managing Pi extensions and packages
3
+ *
4
+ * Entry point - exports the main extension function
5
+ */
6
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
7
+ import type { AutocompleteItem } from "@mariozechner/pi-tui";
8
+ import { isPackageSource } from "./utils/format.js";
9
+ import { showInteractive, showListOnly, showInstalledPackagesLegacy } from "./ui/unified.js";
10
+ import { showRemote } from "./ui/remote.js";
11
+ import { showHelp } from "./ui/help.js";
12
+ import { installPackage } from "./packages/install.js";
13
+ import { removePackage, promptRemove, showInstalledPackagesList } from "./packages/management.js";
14
+ import { getInstalledPackages } from "./packages/discovery.js";
15
+ import { getRecentChanges, formatChangeEntry, getChangeStats } from "./utils/history.js";
16
+ import { clearCache } from "./utils/cache.js";
17
+
18
+ export default function extensionsManager(pi: ExtensionAPI) {
19
+ pi.registerCommand("extensions", {
20
+ description: "Manage local extensions and browse/install community packages",
21
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
22
+ const commands = [
23
+ { value: "list", description: "List local extensions" },
24
+ { value: "local", description: "Open interactive manager (default)" },
25
+ { value: "remote", description: "Browse community packages" },
26
+ { value: "packages", description: "Browse community packages (alias)" },
27
+ { value: "installed", description: "List installed packages" },
28
+ { value: "search", description: "Search npm for packages" },
29
+ { value: "install", description: "Install a package" },
30
+ { value: "remove", description: "Remove an installed package" },
31
+ { value: "uninstall", description: "Remove an installed package (alias)" },
32
+ { value: "history", description: "View extension change history" },
33
+ { value: "stats", description: "View extension manager statistics" },
34
+ { value: "clear-cache", description: "Clear metadata cache" },
35
+ ];
36
+
37
+ const safePrefix = (prefix ?? "").toLowerCase();
38
+ const filtered = commands.filter(
39
+ (c) => c.value.startsWith(safePrefix) || c.description.toLowerCase().includes(safePrefix)
40
+ );
41
+
42
+ return filtered.length > 0
43
+ ? filtered.map((c) => ({ value: c.value, label: `${c.value} - ${c.description}` }))
44
+ : null;
45
+ },
46
+ handler: async (args, ctx) => {
47
+ // Check if we have UI support
48
+ if (!ctx.hasUI) {
49
+ await handleNonInteractive(args, ctx, pi);
50
+ return;
51
+ }
52
+
53
+ const input = args.trim();
54
+ const [subcommand, ...rest] = input.split(/\s+/).filter(Boolean);
55
+ const sub = (subcommand ?? "").toLowerCase();
56
+
57
+ switch (sub) {
58
+ case "":
59
+ case "local":
60
+ await showInteractive(ctx, pi);
61
+ break;
62
+ case "list":
63
+ await showListOnly(ctx);
64
+ break;
65
+ case "remote":
66
+ case "packages":
67
+ await showRemote(rest.join(" "), ctx, pi);
68
+ break;
69
+ case "installed":
70
+ await showInstalledPackagesLegacy(ctx, pi);
71
+ break;
72
+ case "search":
73
+ await showRemote(`search ${rest.join(" ")}`, ctx, pi);
74
+ break;
75
+ case "install":
76
+ if (rest.length > 0) {
77
+ await installPackage(rest.join(" "), ctx, pi);
78
+ } else {
79
+ await showRemote("install", ctx, pi);
80
+ }
81
+ break;
82
+ case "remove":
83
+ case "uninstall":
84
+ if (rest.length > 0) {
85
+ await removePackage(rest.join(" "), ctx, pi);
86
+ } else {
87
+ await promptRemove(ctx, pi);
88
+ }
89
+ break;
90
+ case "history":
91
+ showHistory(ctx, pi);
92
+ break;
93
+ case "stats":
94
+ await showStats(ctx, pi);
95
+ break;
96
+ case "clear-cache":
97
+ await clearMetadataCache(ctx, pi);
98
+ break;
99
+ default:
100
+ // If it looks like a package source, try to install it
101
+ if (subcommand && isPackageSource(subcommand)) {
102
+ await installPackage(input, ctx, pi);
103
+ } else {
104
+ ctx.ui.notify(
105
+ `Unknown command: ${subcommand ?? "(empty)"}. Try: local, remote, installed, search, install, remove`,
106
+ "warning"
107
+ );
108
+ }
109
+ }
110
+ },
111
+ });
112
+
113
+ // Status bar integration - show installed package count
114
+ pi.on("session_start", (_event, ctx) => {
115
+ if (!ctx.hasUI) return;
116
+
117
+ // Defer status update to avoid interfering with extension loading lifecycle
118
+ // This prevents race conditions during reload where commands might not appear
119
+ setImmediate(() => {
120
+ void (async () => {
121
+ try {
122
+ const packages = await getInstalledPackages(ctx, pi);
123
+ if (packages.length > 0) {
124
+ ctx.ui.setStatus(
125
+ "extmgr",
126
+ ctx.ui.theme.fg("dim", `${packages.length} pkg${packages.length === 1 ? "" : "s"}`)
127
+ );
128
+ }
129
+ } catch {
130
+ // Silently ignore status bar errors
131
+ }
132
+ })();
133
+ });
134
+ });
135
+ }
136
+
137
+ async function handleNonInteractive(
138
+ args: string,
139
+ ctx: ExtensionCommandContext,
140
+ pi: ExtensionAPI
141
+ ): Promise<void> {
142
+ const input = args.trim();
143
+ const [subcommand, ...rest] = input.split(/\s+/).filter(Boolean);
144
+ const sub = (subcommand ?? "").toLowerCase();
145
+
146
+ switch (sub) {
147
+ case "list":
148
+ await showListOnly(ctx);
149
+ break;
150
+ case "installed":
151
+ await showInstalledPackagesList(ctx, pi);
152
+ break;
153
+ case "remote":
154
+ case "packages":
155
+ // Non-interactive: just show help
156
+ console.log("Extensions Manager (non-interactive mode)");
157
+ console.log("Remote package browsing requires interactive mode.");
158
+ console.log("");
159
+ console.log("Available commands:");
160
+ console.log(" /extensions list - List local extensions");
161
+ console.log(" /extensions installed - List installed packages");
162
+ console.log(" /extensions install <source> - Install a package");
163
+ console.log(" /extensions remove <source> - Remove a package");
164
+ break;
165
+ case "search":
166
+ console.log("Search requires interactive mode.");
167
+ break;
168
+ case "install":
169
+ if (rest.length > 0) {
170
+ await installPackage(rest.join(" "), ctx, pi);
171
+ } else {
172
+ console.log("Usage: /extensions install <npm:package|git:url|path>");
173
+ }
174
+ break;
175
+ case "remove":
176
+ case "uninstall":
177
+ if (rest.length > 0) {
178
+ await removePackage(rest.join(" "), ctx, pi);
179
+ } else {
180
+ console.log("Usage: /extensions remove <npm:package|git:url|path>");
181
+ }
182
+ break;
183
+ default:
184
+ // If it looks like a package source, try to install it
185
+ if (subcommand && isPackageSource(subcommand)) {
186
+ await installPackage(input, ctx, pi);
187
+ } else {
188
+ console.log("Extensions Manager (non-interactive mode)");
189
+ console.log("");
190
+ console.log("Commands:");
191
+ console.log(" /extensions list - List local extensions");
192
+ console.log(" /extensions installed - List installed packages");
193
+ console.log("");
194
+ console.log("For full functionality, run in interactive mode.");
195
+ }
196
+ }
197
+ }
198
+
199
+ export { showHelp };
200
+
201
+ function showHistory(ctx: ExtensionCommandContext, _pi: ExtensionAPI): void {
202
+ const changes = getRecentChanges(ctx, 20);
203
+
204
+ if (changes.length === 0) {
205
+ const msg = "No extension changes recorded in this session.";
206
+ if (ctx.hasUI) {
207
+ ctx.ui.notify(msg, "info");
208
+ } else {
209
+ console.log(msg);
210
+ }
211
+ return;
212
+ }
213
+
214
+ const lines = changes.map(formatChangeEntry);
215
+ const output = ["Extension Change History (recent 20):", "", ...lines].join("\n");
216
+
217
+ if (ctx.hasUI) {
218
+ ctx.ui.notify(output, "info");
219
+ } else {
220
+ console.log(output);
221
+ }
222
+ }
223
+
224
+ async function showStats(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
225
+ const stats = getChangeStats(ctx);
226
+ const packages = await getInstalledPackages(ctx, pi);
227
+
228
+ const lines = [
229
+ "Extension Manager Statistics",
230
+ "",
231
+ `Installed packages: ${packages.length}`,
232
+ `Session changes: ${stats.total}`,
233
+ ` - Successful: ${stats.successful}`,
234
+ ` - Failed: ${stats.failed}`,
235
+ "",
236
+ "Changes by type:",
237
+ ` - Extension toggles: ${stats.byAction.extension_toggle}`,
238
+ ` - Package installs: ${stats.byAction.package_install}`,
239
+ ` - Package updates: ${stats.byAction.package_update}`,
240
+ ` - Package removals: ${stats.byAction.package_remove}`,
241
+ ];
242
+
243
+ const output = lines.join("\n");
244
+
245
+ if (ctx.hasUI) {
246
+ ctx.ui.notify(output, "info");
247
+ } else {
248
+ console.log(output);
249
+ }
250
+ }
251
+
252
+ async function clearMetadataCache(ctx: ExtensionCommandContext, _pi: ExtensionAPI): Promise<void> {
253
+ await clearCache();
254
+
255
+ const msg = "Metadata cache cleared.";
256
+ if (ctx.hasUI) {
257
+ ctx.ui.notify(msg, "info");
258
+ } else {
259
+ console.log(msg);
260
+ }
261
+ }