pi-extmgr 0.0.2 → 0.0.4

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 (3) hide show
  1. package/README.md +95 -47
  2. package/index.ts +308 -121
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -17,10 +17,20 @@
17
17
  ### 🎨 Interactive TUI Interface
18
18
 
19
19
  - **Beautiful themed interface** with color-coded status indicators
20
+ - **Unified view** - local extensions and npm/git packages in one screen
20
21
  - **Keyboard-driven navigation** - fast and efficient
21
22
  - **Real-time previews** with package descriptions
22
23
  - **Context-aware help** - press `?` anywhere for shortcuts
23
24
 
25
+ ### 📋 Unified Extension Manager
26
+
27
+ All your extensions in one place:
28
+
29
+ - **Local extensions**: `● enabled` / `○ disabled` with `[G]` global or `[P]` project scope
30
+ - **Installed packages**: `📦` icon with name@version
31
+ - **Visual distinction** between toggle-able locals and action-based packages
32
+ - **Smart deduplication** - packages already managed as local extensions are hidden
33
+
24
34
  ### 🔍 Smart Package Discovery
25
35
 
26
36
  - **Browse community packages** with pagination (20 per page)
@@ -41,9 +51,10 @@
41
51
  ### ⚡ Quick Extension Management
42
52
 
43
53
  - **Enable/disable extensions** with staging (preview before applying)
44
- - **Visual change indicators** (\*) show pending modifications
54
+ - **Package actions** - update/remove/view details without leaving the manager
55
+ - **Visual change indicators** (`*`) show pending modifications
45
56
  - **Bulk operations** - update all packages at once
46
- - **Scope indicators**: Global (G) vs Project (P) extensions
57
+ - **Scope indicators**: Global (G) vs Project (P) for all items
47
58
 
48
59
  ### 🎯 Quality of Life
49
60
 
@@ -51,7 +62,7 @@
51
62
  - **Status bar integration** - shows installed package count
52
63
  - **Keyboard shortcut**: `Ctrl+Shift+E` opens extension manager
53
64
  - **Non-interactive mode** - works in scripts and CI
54
- - **Smart deduplication** - handles same package in multiple scopes
65
+ - **Parallel data loading** - local extensions and packages fetched simultaneously
55
66
 
56
67
  ## 🚀 Installation
57
68
 
@@ -70,26 +81,38 @@ Then reload Pi:
70
81
  ### Interactive Mode (Recommended)
71
82
 
72
83
  ```
73
- /extensions # Open full interactive manager
84
+ /extensions # Open unified interactive manager
74
85
  ```
75
86
 
76
- #### Local Extensions Manager
87
+ The unified view displays:
77
88
 
78
- Manage your local extensions with an interactive list:
89
+ - **Local extensions** first (toggle-able)
90
+ - **Installed packages** second (action-based)
91
+ - Sorted alphabetically within each group
79
92
 
80
- | Key | Action |
81
- | ------------- | ----------------------- |
82
- | `↑↓` | Navigate extensions |
83
- | `Space/Enter` | Toggle enabled/disabled |
84
- | `S` | Save changes |
85
- | `I` | View installed packages |
86
- | `R` | Browse remote packages |
87
- | `M` | Return to command line |
88
- | `?` | Show help |
89
- | `Esc` | Cancel |
93
+ #### Keyboard Shortcuts
94
+
95
+ | Key | Action |
96
+ | ------------- | --------------------------------------------------- |
97
+ | `↑↓` | Navigate items |
98
+ | `Space/Enter` | Toggle local extension on/off |
99
+ | `S` | Save changes to local extensions |
100
+ | `A` | Actions on selected package (update/remove/details) |
101
+ | `R` | Browse remote packages |
102
+ | `?` / `H` | Show help |
103
+ | `Esc` | Cancel / Exit |
90
104
 
91
105
  **Staged Changes**: Toggle extensions on/off without immediate effect. Press `S` to apply all changes at once. Pending changes show `*` next to the extension name.
92
106
 
107
+ #### Package Actions
108
+
109
+ When a package is selected, press `A` to:
110
+
111
+ - **Update package** - fetch latest version
112
+ - **Remove package** - uninstall completely
113
+ - **View details** - see version, source, scope
114
+ - **Back to manager** - return to unified view
115
+
93
116
  #### Community Package Browser
94
117
 
95
118
  Browse and install from npm:
@@ -101,15 +124,17 @@ Browse and install from npm:
101
124
  | `N` | Next page |
102
125
  | `P` | Previous page |
103
126
  | `R` | Refresh search |
104
- | `M` | Back to menu |
105
127
  | `Esc` | Cancel |
106
128
 
107
129
  ### Command Reference
108
130
 
109
131
  ```bash
110
- # Local Extension Management
132
+ # Unified Manager (Recommended)
133
+ /extensions # Open unified interactive manager
134
+
135
+ # Legacy Commands
111
136
  /extensions list # List local extensions (text output)
112
- /extensions local # Open interactive manager (default)
137
+ /extensions installed # Redirects to unified view
113
138
 
114
139
  # Package Discovery
115
140
  /extensions remote # Browse community packages
@@ -117,7 +142,6 @@ Browse and install from npm:
117
142
  /extensions search <query> # Search npm for packages
118
143
 
119
144
  # Package Management
120
- /extensions installed # List installed packages with actions
121
145
  /extensions install <source> # Install from npm/git/path
122
146
  /extensions remove [source] # Remove package (interactive if no source)
123
147
  /extensions uninstall [source]# Alias for remove
@@ -162,41 +186,46 @@ All commands work in non-interactive environments (CI, scripts):
162
186
 
163
187
  - `Ctrl+Shift+E` - Open Extensions Manager
164
188
 
165
- ### In Interactive Mode
189
+ ### In Unified Manager
166
190
 
167
- - `↑/↓` or `K/J` - Navigate
168
- - `Enter/Space` - Select/Toggle
169
- - `S` - Save changes
170
- - `I` - Installed packages
171
- - `R` - Remote packages
172
- - `M` - Main menu / Back
173
- - `?` or `H` - Help
174
- - `Esc` - Cancel/Back
191
+ | Key | Action |
192
+ | -------------- | ------------------------------- |
193
+ | `↑/↓` or `K/J` | Navigate |
194
+ | `Enter/Space` | Toggle local extension |
195
+ | `S` | Save changes |
196
+ | `A` | Package actions (update/remove) |
197
+ | `R` | Browse remote packages |
198
+ | `?` / `H` | Help |
199
+ | `Esc` | Cancel / Exit |
175
200
 
176
201
  ## 🏗️ Extension Discovery
177
202
 
178
- pi-extmgr discovers extensions from two locations:
203
+ pi-extmgr discovers extensions from multiple sources:
179
204
 
180
- ### Global Extensions
205
+ ### Local Extensions
181
206
 
182
207
  ```
183
- ~/.pi/agent/extensions/
208
+ ~/.pi/agent/extensions/ # Global
184
209
  ├── my-extension.ts
185
210
  ├── disabled-extension.ts.disabled
186
211
  └── my-extension/
187
212
  └── index.ts
188
- ```
189
-
190
- ### Project Extensions
191
213
 
192
- ```
193
- ./.pi/extensions/
214
+ ./.pi/extensions/ # Project
194
215
  ├── project-tool.ts
195
216
  └── local-helper/
196
217
  └── index.ts
197
218
  ```
198
219
 
199
- **Naming**: Append `.disabled` to disable an extension without removing it.
220
+ ### Installed Packages
221
+
222
+ Managed by `pi install`:
223
+
224
+ - npm packages (`npm:package@version`)
225
+ - git packages (`git:https://...`)
226
+ - Stored in pi's package cache
227
+
228
+ **Naming**: Append `.disabled` to disable a local extension without removing it.
200
229
 
201
230
  ## 🔧 Configuration
202
231
 
@@ -220,9 +249,19 @@ export default function myTheme(pi: ExtensionAPI) {
220
249
 
221
250
  ## 📝 Example Workflows
222
251
 
223
- ### Installing a New Extension
252
+ ### Managing All Extensions
224
253
 
225
254
  1. Press `Ctrl+Shift+E` or type `/extensions`
255
+ 2. See all local extensions and installed packages in one view
256
+ 3. Navigate with `↑↓`
257
+ 4. For local extensions: press `Space` to toggle on/off
258
+ 5. For packages: press `A` for actions (update/remove)
259
+ 6. Press `S` to save any changes to local extensions
260
+ 7. Confirm reload to apply changes
261
+
262
+ ### Installing a New Extension
263
+
264
+ 1. Type `/extensions` to open manager
226
265
  2. Press `R` for remote packages
227
266
  3. Browse or search for the extension
228
267
  4. Press `Enter` on the desired package
@@ -242,10 +281,18 @@ export default function myTheme(pi: ExtensionAPI) {
242
281
  └── package.json # Original package.json preserved
243
282
  ```
244
283
 
245
- ### Disabling an Extension Temporarily
284
+ ### Updating a Package
285
+
286
+ 1. Type `/extensions` to open unified manager
287
+ 2. Navigate to the installed package
288
+ 3. Press `A` for actions
289
+ 4. Select "Update package"
290
+ 5. Confirm reload if updated
291
+
292
+ ### Disabling a Local Extension Temporarily
246
293
 
247
294
  1. Type `/extensions` to open manager
248
- 2. Navigate to the extension with `↑↓`
295
+ 2. Navigate to the local extension with `↑↓`
249
296
  3. Press `Space` to toggle it off
250
297
  4. Press `S` to save
251
298
  5. Confirm reload
@@ -254,10 +301,11 @@ The extension remains installed but won't load until re-enabled.
254
301
 
255
302
  ### Updating All Packages
256
303
 
257
- 1. Type `/extensions installed`
258
- 2. Select "[Update all packages]"
259
- 3. Wait for updates to complete
260
- 4. Reload Pi if updates were applied
304
+ 1. Type `/extensions` to open unified manager
305
+ 2. Navigate to any installed package
306
+ 3. Press `A` for actions
307
+ 4. Select "Update package"
308
+ 5. Or use command: `/extensions install npm:pi-extmgr` then select "[Update all packages]"
261
309
 
262
310
  ## 🐛 Troubleshooting
263
311
 
@@ -282,9 +330,9 @@ Check that the file has a `.ts` or `.js` extension and is in one of the discover
282
330
  - For git installs, ensure git is available
283
331
  - Verify the package has the `pi-package` keyword for browsing
284
332
 
285
- ### Same package showing twice in installed list
333
+ ### Back to manager closes everything
286
334
 
287
- This can happen when `pi list` returns the same package in different formats (e.g., both `npm:package@1.0.0` and the full node_modules path). The extension now automatically deduplicates by package name.
335
+ Fixed! Pressing "Back to manager" now correctly returns to the unified view instead of closing.
288
336
 
289
337
  ## 🤝 Contributing
290
338
 
package/index.ts CHANGED
@@ -118,7 +118,7 @@ export default function extensionsManager(pi: ExtensionAPI) {
118
118
  await showRemote(rest.join(" "), ctx, pi);
119
119
  break;
120
120
  case "installed":
121
- await showInstalledPackages(ctx, pi);
121
+ await showInstalledPackagesLegacy(ctx, pi);
122
122
  break;
123
123
  case "search":
124
124
  await searchPackages(rest.join(" "), ctx, pi);
@@ -187,7 +187,8 @@ async function handleNonInteractive(args: string, ctx: ExtensionCommandContext,
187
187
  await showListOnly(ctx);
188
188
  break;
189
189
  case "installed":
190
- await showInstalledPackages(ctx, pi);
190
+ // Legacy: show package list in non-interactive mode
191
+ await showInstalledPackagesList(ctx, pi);
191
192
  break;
192
193
  default:
193
194
  console.log("Extensions Manager (non-interactive mode)");
@@ -232,72 +233,163 @@ async function showInteractive(ctx: ExtensionCommandContext, pi: ExtensionAPI) {
232
233
  }
233
234
  }
234
235
 
236
+ // Unified item type for local extensions and installed packages
237
+ interface UnifiedItem {
238
+ type: "local" | "package";
239
+ id: string;
240
+ displayName: string;
241
+ summary: string;
242
+ scope: Scope | "global" | "project";
243
+ // Local extension fields
244
+ state?: State;
245
+ activePath?: string;
246
+ disabledPath?: string;
247
+ originalState?: State;
248
+ // Package fields
249
+ source?: string;
250
+ version?: string | undefined;
251
+ }
252
+
235
253
  async function showInteractiveOnce(
236
254
  ctx: ExtensionCommandContext,
237
255
  pi: ExtensionAPI
238
256
  ): Promise<boolean> {
239
- const entries = await discoverExtensions(ctx.cwd);
257
+ // Load both local extensions and installed packages in parallel for performance
258
+ const [localEntries, installedPackages] = await Promise.all([
259
+ discoverExtensions(ctx.cwd),
260
+ getInstalledPackages(ctx, pi),
261
+ ]);
240
262
 
241
- // If no local extensions, offer to browse remote
242
- if (entries.length === 0) {
243
- const choice = await ctx.ui.select("No local extensions found", [
263
+ // Build unified items list
264
+ const items: UnifiedItem[] = [];
265
+
266
+ // Add local extensions
267
+ for (const entry of localEntries) {
268
+ items.push({
269
+ type: "local",
270
+ id: entry.id,
271
+ displayName: entry.displayName,
272
+ summary: entry.summary,
273
+ scope: entry.scope,
274
+ state: entry.state,
275
+ activePath: entry.activePath,
276
+ disabledPath: entry.disabledPath,
277
+ originalState: entry.state,
278
+ });
279
+ }
280
+
281
+ // Add installed packages (filter out duplicates that exist as local extensions)
282
+ const localPaths = new Set(localEntries.map((e) => e.activePath?.toLowerCase()));
283
+ for (const pkg of installedPackages) {
284
+ // Skip if this package is already managed as a local extension
285
+ const pkgPath = pkg.source.toLowerCase();
286
+ if (localPaths.has(pkgPath)) continue;
287
+
288
+ items.push({
289
+ type: "package",
290
+ id: `pkg:${pkg.source}`,
291
+ displayName: pkg.name,
292
+ summary: `${pkg.source} (${pkg.scope})`,
293
+ scope: pkg.scope,
294
+ source: pkg.source,
295
+ version: pkg.version,
296
+ });
297
+ }
298
+
299
+ // Sort: locals first, then packages, both alphabetically
300
+ items.sort((a, b) => {
301
+ if (a.type !== b.type) return a.type === "local" ? -1 : 1;
302
+ return a.displayName.localeCompare(b.displayName);
303
+ });
304
+
305
+ // If nothing found, show quick actions
306
+ if (items.length === 0) {
307
+ const choice = await ctx.ui.select("No extensions or packages found", [
244
308
  "Browse community packages",
245
- "List installed packages",
246
309
  "Cancel",
247
310
  ]);
248
311
 
249
312
  if (choice === "Browse community packages") {
250
313
  await browseRemotePackages(ctx, "keywords:pi-package", pi);
251
- return false; // Return to main menu
252
- } else if (choice === "List installed packages") {
253
- await showInstalledPackages(ctx, pi);
254
- return false; // Return to main menu
314
+ return false;
255
315
  }
256
- return true; // Exit
316
+ return true;
257
317
  }
258
318
 
259
- // Staged changes tracking
260
- const staged = new Map(entries.map((e) => [e.id, e.state]));
261
- const byId = new Map(entries.map((e) => [e.id, e]));
319
+ // Staged changes tracking for local extensions
320
+ const staged = new Map<string, State>();
321
+ const byId = new Map(items.map((item) => [item.id, item]));
262
322
 
263
- type Action = "cancel" | "apply" | "installed" | "remote" | "help" | "menu";
323
+ type Action =
324
+ | { type: "cancel" }
325
+ | { type: "apply" }
326
+ | { type: "remote" }
327
+ | { type: "help" }
328
+ | { type: "menu" }
329
+ | { type: "action"; itemId: string };
264
330
 
265
331
  const result = await ctx.ui.custom<Action>((tui, theme, _keybindings, done) => {
266
332
  const container = new Container();
333
+ const hasLocals = items.some((i) => i.type === "local");
334
+ const hasPackages = items.some((i) => i.type === "package");
267
335
 
268
336
  // Header
269
337
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
270
- container.addChild(new Text(theme.fg("accent", theme.bold("Local Extensions Manager")), 2, 0));
338
+ container.addChild(new Text(theme.fg("accent", theme.bold("Extensions Manager")), 2, 0));
271
339
  container.addChild(
272
- new Text(theme.fg("muted", "Enable/disable extensions - Changes apply on save"), 2, 0)
340
+ new Text(
341
+ theme.fg(
342
+ "muted",
343
+ `${items.length} item${items.length === 1 ? "" : "s"} • Space/Enter to toggle local extensions, A for actions on packages`
344
+ ),
345
+ 2,
346
+ 0
347
+ )
273
348
  );
274
349
  container.addChild(new Spacer(1));
275
350
 
276
- // Settings list for extensions
277
- const items: SettingItem[] = entries.map((entry) => ({
278
- id: entry.id,
279
- label: formatEntryLabel(entry, entry.state, theme),
280
- currentValue: entry.state,
281
- values: ["enabled", "disabled"],
282
- }));
351
+ // Build settings items
352
+ const settingsItems: SettingItem[] = items.map((item) => {
353
+ if (item.type === "local") {
354
+ const currentState = staged.get(item.id) ?? item.state!;
355
+ const changed = staged.has(item.id) && staged.get(item.id) !== item.originalState;
356
+ return {
357
+ id: item.id,
358
+ label: formatUnifiedItemLabel(item, currentState, theme, changed),
359
+ currentValue: currentState,
360
+ values: ["enabled", "disabled"],
361
+ };
362
+ } else {
363
+ // Package - show as read-only with action indicator
364
+ return {
365
+ id: item.id,
366
+ label: formatUnifiedItemLabel(item, "enabled", theme, false),
367
+ currentValue: "enabled",
368
+ values: ["enabled"], // Packages don't toggle, they use actions
369
+ };
370
+ }
371
+ });
283
372
 
284
373
  const settingsList = new SettingsList(
285
- items,
286
- Math.min(entries.length + 2, 12),
374
+ settingsItems,
375
+ Math.min(items.length + 2, 16),
287
376
  getSettingsListTheme(),
288
377
  (id: string, newValue: string) => {
289
- const entry = byId.get(id);
290
- const item = items.find((x) => x.id === id);
291
- if (!entry || !item) return;
378
+ const item = byId.get(id);
379
+ if (!item || item.type !== "local") return;
292
380
 
293
381
  const state = newValue as State;
294
382
  staged.set(id, state);
295
- item.currentValue = state;
296
- item.label = formatEntryLabel(entry, state, theme, entry.state !== state);
383
+
384
+ const settingsItem = settingsItems.find((x) => x.id === id);
385
+ if (settingsItem) {
386
+ const changed = state !== item.originalState;
387
+ settingsItem.label = formatUnifiedItemLabel(item, state, theme, changed);
388
+ }
297
389
  tui.requestRender();
298
390
  },
299
- () => done("cancel"),
300
- { enableSearch: entries.length > 8 }
391
+ () => done({ type: "cancel" }),
392
+ { enableSearch: items.length > 8 }
301
393
  );
302
394
 
303
395
  container.addChild(settingsList);
@@ -305,15 +397,20 @@ async function showInteractiveOnce(
305
397
 
306
398
  // Footer with keyboard shortcuts
307
399
  const hasChanges = Array.from(staged.entries()).some(([id, state]) => {
308
- const entry = byId.get(id);
309
- return entry && entry.state !== state;
400
+ const item = byId.get(id);
401
+ return item?.type === "local" && item.originalState !== state;
310
402
  });
311
403
 
312
- const helpText = hasChanges
313
- ? "↑↓ Navigate | Space/Enter Toggle | S Save* | I Installed | R Remote | M Menu | ? Help | Esc Cancel"
314
- : "↑↓ Navigate | Space/Enter Toggle | S Save | I Installed | R Remote | M Menu | ? Help | Esc Cancel";
404
+ const footerParts: string[] = [];
405
+ footerParts.push("↑↓ Navigate");
406
+ if (hasLocals) footerParts.push("Space/Enter Toggle");
407
+ if (hasLocals) footerParts.push(hasChanges ? "S Save*" : "S Save");
408
+ if (hasPackages) footerParts.push("A Actions");
409
+ footerParts.push("R Browse");
410
+ footerParts.push("? Help");
411
+ footerParts.push("Esc Cancel");
315
412
 
316
- container.addChild(new Text(theme.fg("dim", helpText), 2, 0));
413
+ container.addChild(new Text(theme.fg("dim", footerParts.join(" | ")), 2, 0));
317
414
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
318
415
 
319
416
  return {
@@ -325,23 +422,29 @@ async function showInteractiveOnce(
325
422
  },
326
423
  handleInput(data: string) {
327
424
  if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") {
328
- done("apply");
425
+ done({ type: "apply" });
329
426
  return;
330
427
  }
331
- if (data === "i" || data === "I") {
332
- done("installed");
428
+ if (data === "a" || data === "A") {
429
+ // Get currently selected item and show actions
430
+ // Access internal selectedIndex from SettingsList
431
+ const selIdx = (settingsList as unknown as { selectedIndex: number }).selectedIndex ?? 0;
432
+ const selectedId = settingsItems[selIdx]?.id ?? settingsItems[0]?.id;
433
+ if (selectedId) {
434
+ done({ type: "action", itemId: selectedId });
435
+ }
333
436
  return;
334
437
  }
335
438
  if (data === "r" || data === "R") {
336
- done("remote");
439
+ done({ type: "remote" });
337
440
  return;
338
441
  }
339
442
  if (data === "?" || data === "h" || data === "H") {
340
- done("help");
443
+ done({ type: "help" });
341
444
  return;
342
445
  }
343
446
  if (data === "m" || data === "M") {
344
- done("menu");
447
+ done({ type: "menu" });
345
448
  return;
346
449
  }
347
450
  settingsList.handleInput?.(data);
@@ -350,25 +453,42 @@ async function showInteractiveOnce(
350
453
  };
351
454
  });
352
455
 
353
- switch (result) {
354
- case "cancel":
456
+ // Handle results
457
+ if (result.type === "cancel") {
458
+ if (staged.size > 0) {
355
459
  ctx.ui.notify("No changes applied.", "info");
356
- return true; // Exit
357
- case "installed":
358
- await showInstalledPackages(ctx, pi);
359
- return false; // Return to main menu
360
- case "remote":
361
- await showRemote("", ctx, pi);
362
- return false; // Return to main menu
363
- case "help":
364
- showHelp(ctx);
365
- return false; // Return to main menu
366
- case "menu":
367
- return false; // Return to main menu (already there)
460
+ }
461
+ return true;
368
462
  }
369
463
 
370
- // Apply changes
371
- const apply = await applyStagedChanges(entries, staged);
464
+ if (result.type === "remote") {
465
+ await showRemote("", ctx, pi);
466
+ return false;
467
+ }
468
+
469
+ if (result.type === "help") {
470
+ showHelp(ctx);
471
+ return false;
472
+ }
473
+
474
+ if (result.type === "menu") {
475
+ return false;
476
+ }
477
+
478
+ if (result.type === "action") {
479
+ const item = byId.get(result.itemId);
480
+ if (item?.type === "package") {
481
+ const exitManager = await handlePackageAction(item, ctx, pi);
482
+ return exitManager; // true = exit manager, false = return to unified view
483
+ }
484
+ return false;
485
+ }
486
+
487
+ // Apply changes for local extensions
488
+ const localItems = items.filter((i) => i.type === "local" && i.activePath) as Required<
489
+ Pick<UnifiedItem, "id" | "activePath" | "disabledPath" | "originalState">
490
+ >[];
491
+ const apply = await applyStagedChangesUnified(localItems, staged);
372
492
 
373
493
  if (apply.errors.length > 0) {
374
494
  ctx.ui.notify(
@@ -377,82 +497,164 @@ async function showInteractiveOnce(
377
497
  );
378
498
  } else if (apply.changed === 0) {
379
499
  ctx.ui.notify("No changes to apply.", "info");
380
- return false; // Return to main menu
381
500
  } else {
382
501
  ctx.ui.notify(`Applied ${apply.changed} extension change(s).`, "info");
383
502
  }
384
503
 
385
- // Prompt for reload
386
- const shouldReload = await ctx.ui.confirm(
387
- "Reload Required",
388
- "Extensions changed. Reload pi now?"
389
- );
504
+ // Prompt for reload if changes were made
505
+ if (apply.changed > 0) {
506
+ const shouldReload = await ctx.ui.confirm(
507
+ "Reload Required",
508
+ "Extensions changed. Reload pi now?"
509
+ );
390
510
 
391
- if (shouldReload) {
392
- ctx.ui.setEditorText("/reload");
393
- return true; // Exit the UI so user can see the reload command
511
+ if (shouldReload) {
512
+ ctx.ui.setEditorText("/reload");
513
+ return true;
514
+ }
394
515
  }
395
516
 
396
- return false; // Return to main menu
517
+ return false;
397
518
  }
398
519
 
399
- function formatEntryLabel(
400
- entry: ExtensionEntry,
520
+ function formatUnifiedItemLabel(
521
+ item: UnifiedItem,
401
522
  state: State,
402
523
  theme: Theme,
403
524
  changed = false
404
525
  ): string {
405
- const statusIcon = state === "enabled" ? theme.fg("success", "●") : theme.fg("error", "○");
406
- const scopeIcon = entry.scope === "global" ? theme.fg("muted", "G") : theme.fg("accent", "P");
407
- const changeMarker = changed ? theme.fg("warning", " *") : "";
408
- const name = theme.bold(entry.displayName);
409
- const summary = theme.fg("dim", entry.summary);
410
- return `${statusIcon} [${scopeIcon}] ${name} - ${summary}${changeMarker}`;
526
+ if (item.type === "local") {
527
+ const statusIcon = state === "enabled" ? theme.fg("success", "") : theme.fg("error", "");
528
+ const scopeIcon = item.scope === "global" ? theme.fg("muted", "G") : theme.fg("accent", "P");
529
+ const changeMarker = changed ? theme.fg("warning", " *") : "";
530
+ const name = theme.bold(item.displayName);
531
+ const summary = theme.fg("dim", item.summary);
532
+ return `${statusIcon} [${scopeIcon}] ${name} - ${summary}${changeMarker}`;
533
+ } else {
534
+ // Package
535
+ const pkgIcon = theme.fg("accent", "📦");
536
+ const scopeIcon = item.scope === "global" ? theme.fg("muted", "G") : theme.fg("accent", "P");
537
+ const name = theme.bold(item.displayName);
538
+ const version = item.version ? theme.fg("dim", `@${item.version}`) : "";
539
+ const source = theme.fg("dim", item.summary.split(" (")[0] ?? "");
540
+ return `${pkgIcon} [${scopeIcon}] ${name}${version} - ${source}`;
541
+ }
411
542
  }
412
543
 
413
- async function applyStagedChanges(entries: ExtensionEntry[], staged: Map<string, State>) {
544
+ async function applyStagedChangesUnified(
545
+ items: Required<Pick<UnifiedItem, "id" | "activePath" | "disabledPath" | "originalState">>[],
546
+ staged: Map<string, State>
547
+ ) {
414
548
  let changed = 0;
415
549
  const errors: string[] = [];
416
550
 
417
- for (const entry of entries) {
418
- const target = staged.get(entry.id) ?? entry.state;
419
- if (target === entry.state) continue;
551
+ for (const item of items) {
552
+ const target = staged.get(item.id) ?? item.originalState;
553
+ if (target === item.originalState) continue;
420
554
 
421
- const result = await setState(entry, target);
555
+ const result = await setStateUnified(item, target);
422
556
  if (result.ok) {
423
- entry.state = target;
424
557
  changed++;
425
558
  } else {
426
- errors.push(`${entry.displayName}: ${(result as { error: string }).error}`);
559
+ errors.push(`${item.id}: ${result.error}`);
427
560
  }
428
561
  }
429
562
 
430
563
  return { changed, errors };
431
564
  }
432
565
 
566
+ async function setStateUnified(
567
+ item: Pick<UnifiedItem, "activePath" | "disabledPath">,
568
+ target: State
569
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
570
+ try {
571
+ if (!item.activePath || !item.disabledPath) {
572
+ return { ok: false, error: "Missing paths" };
573
+ }
574
+ if (target === "enabled") {
575
+ await rename(item.disabledPath, item.activePath);
576
+ } else {
577
+ await rename(item.activePath, item.disabledPath);
578
+ }
579
+ return { ok: true };
580
+ } catch (error) {
581
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
582
+ }
583
+ }
584
+
585
+ async function handlePackageAction(
586
+ item: UnifiedItem,
587
+ ctx: ExtensionCommandContext,
588
+ pi: ExtensionAPI
589
+ ): Promise<boolean> {
590
+ if (!ctx.hasUI || !item.source) return true;
591
+
592
+ const choice = await ctx.ui.select(`${item.displayName} Actions`, [
593
+ `Update package`,
594
+ `Remove package`,
595
+ `View details`,
596
+ `Back to manager`,
597
+ ]);
598
+
599
+ if (!choice || choice.includes("Back")) {
600
+ return false; // Stay in manager
601
+ }
602
+
603
+ if (choice.includes("Update")) {
604
+ await updatePackage(item.source, ctx, pi);
605
+ } else if (choice.includes("Remove")) {
606
+ await removePackage(item.source, ctx, pi);
607
+ } else if (choice.includes("details")) {
608
+ ctx.ui.notify(
609
+ `Name: ${item.displayName}\nVersion: ${item.version || "unknown"}\nSource: ${item.source}\nScope: ${item.scope}`,
610
+ "info"
611
+ );
612
+ // Show actions again
613
+ return handlePackageAction(item, ctx, pi);
614
+ }
615
+
616
+ return false; // Stay in manager
617
+ }
618
+
619
+ // Legacy function kept for backward compatibility - use unified view instead
620
+ async function showInstalledPackagesLegacy(ctx: ExtensionCommandContext, pi: ExtensionAPI) {
621
+ ctx.ui.notify(
622
+ "📦 Use /extensions for the unified view.\nInstalled packages are now shown alongside local extensions.",
623
+ "info"
624
+ );
625
+ // Small delay then open the main manager
626
+ await new Promise((r) => setTimeout(r, 1500));
627
+ await showInteractive(ctx, pi);
628
+ }
629
+
433
630
  function showHelp(ctx: ExtensionCommandContext): void {
434
631
  const lines = [
435
632
  "Extensions Manager Help",
436
633
  "",
437
- "Local Extensions:",
438
- " Extensions are loaded from:",
439
- " - ~/.pi/agent/extensions/ (global)",
440
- " - .pi/extensions/ (project-local)",
634
+ "Unified View:",
635
+ " Local extensions and npm/git packages are displayed together",
636
+ " Local extensions show ● enabled / ○ disabled with G/P scope",
637
+ " Packages show 📦 with name@version and G/P scope",
441
638
  "",
442
639
  "Navigation:",
443
640
  " ↑↓ Navigate list",
444
- " Space/Enter Toggle enabled/disabled",
445
- " S Save changes",
446
- " I View installed packages",
641
+ " Space/Enter Toggle local extension enabled/disabled",
642
+ " S Save changes to local extensions",
643
+ " A Actions on selected package (update/remove)",
447
644
  " R Browse remote packages",
448
- " M Main menu (exit to command line)",
449
645
  " ?/H Show this help",
450
646
  " Esc Cancel",
451
647
  "",
648
+ "Extension Sources:",
649
+ " - ~/.pi/agent/extensions/ (global - G)",
650
+ " - .pi/extensions/ (project-local - P)",
651
+ " - npm packages installed via pi install",
652
+ " - git packages installed via pi install",
653
+ "",
452
654
  "Commands:",
453
655
  " /extensions Open manager",
454
656
  " /extensions list List local extensions",
455
- " /extensions installed List installed packages",
657
+ " /extensions installed List installed packages (legacy)",
456
658
  " /extensions remote Browse community packages",
457
659
  " /extensions search <q> Search for packages",
458
660
  " /extensions install <s> Install package (npm:, git:, or path)",
@@ -465,7 +667,6 @@ function showHelp(ctx: ExtensionCommandContext): void {
465
667
  } else {
466
668
  console.log(output);
467
669
  }
468
- // Note: Removed auto-return to main menu (was causing memory leak potential)
469
670
  }
470
671
 
471
672
  // ============== Remote Package Management ==============
@@ -478,7 +679,8 @@ async function showRemote(args: string, ctx: ExtensionCommandContext, pi: Extens
478
679
  switch (sub) {
479
680
  case "list":
480
681
  case "installed":
481
- await showInstalledPackages(ctx, pi);
682
+ // Legacy: redirect to unified view
683
+ await showInstalledPackagesLegacy(ctx, pi);
482
684
  return;
483
685
  case "install":
484
686
  if (query) {
@@ -501,7 +703,7 @@ async function showRemote(args: string, ctx: ExtensionCommandContext, pi: Extens
501
703
  }
502
704
 
503
705
  async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI) {
504
- type MenuAction = "browse" | "search" | "install" | "list" | "cancel" | "main" | "help";
706
+ type MenuAction = "browse" | "search" | "install" | "cancel" | "main" | "help";
505
707
 
506
708
  const result = await ctx.ui.custom<MenuAction>((tui, theme, _kb, done) => {
507
709
  const container = new Container();
@@ -509,6 +711,9 @@ async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI) {
509
711
  // Header
510
712
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
511
713
  container.addChild(new Text(theme.fg("accent", theme.bold("Community Packages")), 2, 1));
714
+ container.addChild(
715
+ new Text(theme.fg("muted", "Use /extensions for unified view of installed items"), 2, 0)
716
+ );
512
717
  container.addChild(new Spacer(1));
513
718
 
514
719
  const menuItems: SelectItem[] = [
@@ -519,7 +724,6 @@ async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI) {
519
724
  },
520
725
  { value: "search", label: "🔎 Search packages", description: "Search npm with custom query" },
521
726
  { value: "install", label: "📥 Install by source", description: "npm:, git:, or local path" },
522
- { value: "list", label: "📋 List installed", description: "View your installed packages" },
523
727
  ];
524
728
 
525
729
  const selectList = new SelectList(menuItems, menuItems.length + 2, {
@@ -576,9 +780,6 @@ async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI) {
576
780
  case "install":
577
781
  await promptInstall(ctx, pi);
578
782
  break;
579
- case "list":
580
- await showInstalledPackages(ctx, pi);
581
- break;
582
783
  case "main":
583
784
  return;
584
785
  case "help":
@@ -1344,7 +1545,8 @@ async function removePackage(source: string, ctx: ExtensionCommandContext, pi: E
1344
1545
 
1345
1546
  // ============== Installed Packages ==============
1346
1547
 
1347
- async function showInstalledPackages(ctx: ExtensionCommandContext, pi: ExtensionAPI) {
1548
+ // Legacy list view for non-interactive mode and backward compatibility
1549
+ async function showInstalledPackagesList(ctx: ExtensionCommandContext, pi: ExtensionAPI) {
1348
1550
  const packages = await getInstalledPackages(ctx, pi);
1349
1551
 
1350
1552
  if (packages.length === 0) {
@@ -1420,7 +1622,8 @@ async function showPackageActions(
1420
1622
  );
1421
1623
  await showPackageActions(source, pkg, ctx, pi);
1422
1624
  } else if (choice.includes("Back")) {
1423
- await showInstalledPackages(ctx, pi);
1625
+ // Return to unified view instead of old list
1626
+ await showInstalledPackagesLegacy(ctx, pi);
1424
1627
  }
1425
1628
  }
1426
1629
 
@@ -1803,22 +2006,6 @@ async function parseDirectoryIndex(
1803
2006
  return undefined;
1804
2007
  }
1805
2008
 
1806
- async function setState(
1807
- entry: ExtensionEntry,
1808
- target: State
1809
- ): Promise<{ ok: true } | { ok: false; error: string }> {
1810
- try {
1811
- if (target === "enabled") {
1812
- await rename(entry.disabledPath, entry.activePath);
1813
- } else {
1814
- await rename(entry.activePath, entry.disabledPath);
1815
- }
1816
- return { ok: true };
1817
- } catch (error) {
1818
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
1819
- }
1820
- }
1821
-
1822
2009
  async function readSummary(filePath: string): Promise<string> {
1823
2010
  try {
1824
2011
  const text = await readFile(filePath, "utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -29,6 +29,7 @@
29
29
  "extensions": [
30
30
  "./index.ts"
31
31
  ],
32
+ "video": "https://github.com/ayagmar/pi-extmgr/releases/download/v0.0.2/Screencast_20260207_013142.mp4",
32
33
  "image": "https://i.imgur.com/TjAp7Hv.png"
33
34
  },
34
35
  "peerDependencies": {