@laststance/claude-plugin-dashboard 0.2.0 → 0.2.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
@@ -1,6 +1,8 @@
1
1
  # Claude Code Plugin Dashboard
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@laststance/claude-plugin-dashboard)](https://www.npmjs.com/package/@laststance/claude-plugin-dashboard)
4
+ [![CI](https://github.com/laststance/claude-plugin-dashboard/actions/workflows/ci.yml/badge.svg)](https://github.com/laststance/claude-plugin-dashboard/actions/workflows/ci.yml)
5
+ [![codecov](https://codecov.io/gh/laststance/claude-plugin-dashboard/graph/badge.svg?token=LO8NM55XCF)](https://codecov.io/gh/laststance/claude-plugin-dashboard)
4
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
7
 
6
8
  An interactive CLI dashboard to browse, install, and manage [Claude Code](https://claude.ai/code) plugins.
@@ -12,12 +14,14 @@ Built with [Ink](https://github.com/vadimdemedes/ink) (React for CLI).
12
14
 
13
15
  ## Features
14
16
 
15
- - 🔍 **Discover** - Browse 100+ plugins from multiple marketplaces
17
+ - **Enabled** - Default view showing active plugins (installed AND enabled)
16
18
  - 📦 **Install/Uninstall** - Install and uninstall plugins directly from the dashboard
17
- - **Enable/Disable** - Toggle plugins on/off with a single key
18
- - 🏪 **Marketplaces** - Explore plugin sources
19
+ - 🔄 **Enable/Disable** - Toggle plugins on/off with a single key
20
+ - 🔍 **Discover** - Browse 100+ plugins from multiple marketplaces
21
+ - 🏪 **Marketplaces** - Add, remove, and update plugin sources
19
22
  - ⚠️ **Errors** - Debug plugin issues
20
- - ⌨️ **Keyboard-driven** - Full keyboard navigation with Emacs-style shortcuts
23
+ - ⌨️ **Focus Zone Navigation** - 3-zone keyboard model (TabBar Search → List)
24
+ - 🆘 **Help Overlay** - Press `h` for full keyboard shortcuts
21
25
  - 🎨 **Beautiful TUI** - Terminal UI that matches Claude Code's design
22
26
 
23
27
  ## Prerequisites
@@ -43,21 +47,26 @@ This opens the interactive TUI dashboard.
43
47
 
44
48
  **Keyboard Shortcuts:**
45
49
 
46
- | Key | Action |
47
- | --------- | ------------------------------------------- |
48
- | `←` `→` | Switch tabs |
49
- | `↑` `↓` | Navigate list |
50
- | `^P` `^N` | Navigate list (Emacs-style) |
51
- | `i` | Install selected plugin |
52
- | `u` | Uninstall selected plugin (with confirm) |
53
- | `Space` | Toggle plugin enable/disable |
54
- | `Enter` | View details / Toggle |
55
- | `/` | Search plugins |
56
- | `s` | Cycle sort options (Installs Name → Date) |
57
- | `S` | Toggle sort order (Asc/Desc) |
58
- | `Tab` | Next tab |
59
- | `Esc` | Clear search / Cancel |
60
- | `q` | Quit |
50
+ | Key | Action |
51
+ | --------- | --------------------------------------------- |
52
+ | `↑` `↓` | Navigate within zone / Move between zones |
53
+ | `←` `→` | Switch tabs (when TabBar focused) |
54
+ | `^P` `^N` | Navigate list (Emacs-style vertical) |
55
+ | `^B` `^F` | Switch tabs (Emacs-style horizontal) |
56
+ | `Tab` | Next tab |
57
+ | `i` | Install selected plugin |
58
+ | `u` | Uninstall selected plugin (with confirm) |
59
+ | `Space` | Toggle plugin enable/disable |
60
+ | `Enter` | Install (Discover) / Toggle (Installed) |
61
+ | `/` | Search (works on all plugin/marketplace tabs) |
62
+ | `s` | Cycle sort options (Installs → Name → Date) |
63
+ | `S` | Toggle sort order (Asc/Desc) |
64
+ | `a` | Add marketplace (Marketplaces tab) |
65
+ | `d` | Remove marketplace (Marketplaces tab) |
66
+ | `r` | Update marketplace (Marketplaces tab) |
67
+ | `h` | Show help overlay |
68
+ | `Esc` | Clear search / Close dialog / Cancel |
69
+ | `q`/`^C` | Quit |
61
70
 
62
71
  ### Command Line Mode
63
72
 
@@ -92,14 +101,14 @@ claude-plugin-dashboard help
92
101
 
93
102
  ## Dashboard Tabs
94
103
 
95
- ### Discover
104
+ ### Enabled (Default)
96
105
 
97
- Browse all available plugins from all configured marketplaces:
106
+ View your currently active plugins (installed AND enabled):
98
107
 
99
- - Search by name, description, or tags
100
- - Sort by install count, name, or date
101
- - Install plugins with `i` key
102
- - View plugin details including install count and description
108
+ - Shows only plugins that are ready to use
109
+ - Quick status overview of your Claude Code setup
110
+ - Toggle plugins on/off with `Space`
111
+ - Search with `/` to filter enabled plugins
103
112
 
104
113
  ### Installed
105
114
 
@@ -108,15 +117,26 @@ Manage your installed plugins:
108
117
  - See enabled (●) and disabled (◐) status at a glance
109
118
  - Toggle plugins on/off with `Space`
110
119
  - Uninstall plugins with `u` (with Y/N confirmation)
111
- - View installation details
120
+ - Search installed plugins with `/`
121
+
122
+ ### Discover
123
+
124
+ Browse all available plugins from all configured marketplaces:
125
+
126
+ - Search by name, description, or tags
127
+ - Sort by install count, name, or date
128
+ - Install plugins with `i` key or `Enter`
129
+ - View plugin details including install count and description
112
130
 
113
131
  ### Marketplaces
114
132
 
115
- Explore plugin sources:
133
+ Manage plugin sources:
116
134
 
117
- - View all configured marketplaces
118
- - See plugin counts per marketplace
119
- - Check last update times
135
+ - **Add** marketplace with `a` key (supports GitHub shorthand, Git URLs, local paths)
136
+ - **Remove** marketplace with `d` key (with confirmation)
137
+ - **Update** marketplace catalog with `r` key
138
+ - Search marketplaces with `/`
139
+ - View plugin counts and last update times
120
140
 
121
141
  ### Errors
122
142
 
@@ -198,8 +218,8 @@ Contributions are welcome! Please feel free to submit a Pull Request.
198
218
  ## Local Development
199
219
 
200
220
  ```bash
201
- git clone https://github.com/laststance/claude-code-plugin-dashboard.git
202
- cd claude-code-plugin-dashboard
221
+ git clone https://github.com/laststance/claude-plugin-dashboard.git
222
+ cd claude-plugin-dashboard
203
223
  pnpm install
204
224
  pnpm build
205
225
  pnpm start
@@ -224,6 +244,24 @@ MIT © [Laststance.io](https://github.com/laststance)
224
244
 
225
245
  ## Changelog
226
246
 
247
+ ### v0.2.0
248
+
249
+ - **Enabled tab**: New default view showing active plugins (installed AND enabled)
250
+ - **Focus zone navigation**: 3-zone keyboard model (TabBar → Search → List)
251
+ - **Emacs keybindings**: `Ctrl+B`/`Ctrl+F` for horizontal tab switching
252
+ - **Marketplace management**: Add (`a`), remove (`d`), update (`r`) plugin sources
253
+ - **Search on all tabs**: `/` now works on Enabled, Installed, and Marketplaces tabs
254
+ - **Help overlay**: Press `h` to show all keyboard shortcuts
255
+ - **Context-aware Enter**: Installs on Discover, toggles on Installed/Enabled
256
+ - **Plugin component types**: Show component types (skill, hook, agent) in detail view
257
+ - **CI/CD**: GitHub Actions workflow for lint, test, and build
258
+ - **96.67% test coverage**: Comprehensive Vitest and E2E test suites
259
+
260
+ ### v0.1.1
261
+
262
+ - Fix: Clear terminal screen when exiting dashboard
263
+ - Fix: Repository URL in package.json
264
+
227
265
  ### v0.1.0
228
266
 
229
267
  - Initial release
package/dist/app.d.ts CHANGED
@@ -19,7 +19,12 @@ export declare function getAvailableZones(activeTab: AppState['activeTab']): Foc
19
19
  */
20
20
  export declare function appReducer(state: AppState, action: Action): AppState;
21
21
  /**
22
- * Get items array for current tab
22
+ * Get items array for current tab with search filter applied
23
+ * @param state - Current app state
24
+ * @returns Filtered array of items for the active tab
25
+ * @example
26
+ * getItemsForTab({ activeTab: 'installed', searchQuery: 'su', plugins: [...] })
27
+ * // => Only installed plugins matching 'su'
23
28
  */
24
29
  export declare function getItemsForTab(state: AppState): unknown[];
25
30
  /**
package/dist/app.js CHANGED
@@ -15,6 +15,8 @@ import ErrorsTab from './tabs/ErrorsTab.js';
15
15
  import { loadAllPlugins, loadMarketplaces, searchPlugins, searchMarketplaces, sortPlugins, } from './services/pluginService.js';
16
16
  import { togglePlugin } from './services/settingsService.js';
17
17
  import { installPlugin, uninstallPlugin, } from './services/pluginActionsService.js';
18
+ import { addMarketplace, removeMarketplace, updateMarketplace, } from './services/marketplaceActionsService.js';
19
+ import AddMarketplaceDialog from './components/AddMarketplaceDialog.js';
18
20
  import ConfirmDialog from './components/ConfirmDialog.js';
19
21
  import HelpOverlay from './components/HelpOverlay.js';
20
22
  import packageJson from '../package.json' with { type: 'json' };
@@ -38,6 +40,11 @@ export const initialState = {
38
40
  operationPluginId: null,
39
41
  confirmUninstall: false,
40
42
  showHelp: false,
43
+ marketplaceOperation: 'idle',
44
+ operationMarketplaceId: null,
45
+ confirmRemoveMarketplace: false,
46
+ showAddMarketplaceDialog: false,
47
+ addMarketplaceError: null,
41
48
  };
42
49
  /**
43
50
  * Get available focus zones for the current tab
@@ -51,6 +58,24 @@ export function getAvailableZones(activeTab) {
51
58
  }
52
59
  return ['tabbar', 'search', 'list'];
53
60
  }
61
+ /**
62
+ * Get display message for marketplace operation status
63
+ * @param operation - The marketplace operation type
64
+ * @param marketplaceId - Optional marketplace identifier
65
+ * @returns Display message for the operation
66
+ */
67
+ function getMarketplaceOperationMessage(operation, marketplaceId) {
68
+ switch (operation) {
69
+ case 'adding':
70
+ return 'Adding marketplace...';
71
+ case 'removing':
72
+ return `Removing ${marketplaceId}...`;
73
+ case 'updating':
74
+ return `Updating ${marketplaceId || 'marketplaces'}...`;
75
+ default:
76
+ return '';
77
+ }
78
+ }
54
79
  /**
55
80
  * State reducer for application state management
56
81
  */
@@ -210,19 +235,76 @@ export function appReducer(state, action) {
210
235
  ...state,
211
236
  showHelp: !state.showHelp,
212
237
  };
238
+ case 'SHOW_CONFIRM_REMOVE_MARKETPLACE':
239
+ return {
240
+ ...state,
241
+ confirmRemoveMarketplace: true,
242
+ operationMarketplaceId: action.payload,
243
+ };
244
+ case 'HIDE_CONFIRM_REMOVE_MARKETPLACE':
245
+ return {
246
+ ...state,
247
+ confirmRemoveMarketplace: false,
248
+ operationMarketplaceId: null,
249
+ };
250
+ case 'SHOW_ADD_MARKETPLACE_DIALOG':
251
+ return {
252
+ ...state,
253
+ showAddMarketplaceDialog: true,
254
+ searchQuery: '', // Reuse searchQuery for dialog input
255
+ addMarketplaceError: null, // Clear previous error
256
+ };
257
+ case 'HIDE_ADD_MARKETPLACE_DIALOG':
258
+ return {
259
+ ...state,
260
+ showAddMarketplaceDialog: false,
261
+ searchQuery: '',
262
+ addMarketplaceError: null,
263
+ };
264
+ case 'SET_ADD_MARKETPLACE_ERROR':
265
+ return {
266
+ ...state,
267
+ addMarketplaceError: action.payload,
268
+ };
269
+ case 'START_MARKETPLACE_OPERATION':
270
+ return {
271
+ ...state,
272
+ marketplaceOperation: action.payload.operation,
273
+ operationMarketplaceId: action.payload.marketplaceId ?? null,
274
+ message: getMarketplaceOperationMessage(action.payload.operation, action.payload.marketplaceId),
275
+ };
276
+ case 'END_MARKETPLACE_OPERATION':
277
+ return {
278
+ ...state,
279
+ marketplaceOperation: 'idle',
280
+ operationMarketplaceId: null,
281
+ };
213
282
  default:
214
283
  return state;
215
284
  }
216
285
  }
217
286
  /**
218
- * Get items array for current tab
287
+ * Get items array for current tab with search filter applied
288
+ * @param state - Current app state
289
+ * @returns Filtered array of items for the active tab
290
+ * @example
291
+ * getItemsForTab({ activeTab: 'installed', searchQuery: 'su', plugins: [...] })
292
+ * // => Only installed plugins matching 'su'
219
293
  */
220
294
  export function getItemsForTab(state) {
221
295
  switch (state.activeTab) {
222
- case 'enabled':
223
- return state.plugins.filter((p) => p.isInstalled && p.isEnabled);
224
- case 'installed':
225
- return state.plugins.filter((p) => p.isInstalled);
296
+ case 'enabled': {
297
+ const enabledPlugins = state.plugins.filter((p) => p.isInstalled && p.isEnabled);
298
+ return state.searchQuery
299
+ ? searchPlugins(state.searchQuery, enabledPlugins)
300
+ : enabledPlugins;
301
+ }
302
+ case 'installed': {
303
+ const installedPlugins = state.plugins.filter((p) => p.isInstalled);
304
+ return state.searchQuery
305
+ ? searchPlugins(state.searchQuery, installedPlugins)
306
+ : installedPlugins;
307
+ }
226
308
  case 'discover':
227
309
  return getFilteredPlugins(state);
228
310
  case 'marketplaces':
@@ -313,10 +395,103 @@ export default function App() {
313
395
  });
314
396
  }
315
397
  }
398
+ /**
399
+ * Handle adding a new marketplace
400
+ * @param source - Marketplace source (e.g., "owner/repo", URL, or local path)
401
+ */
402
+ async function handleAddMarketplace(source) {
403
+ dispatch({
404
+ type: 'START_MARKETPLACE_OPERATION',
405
+ payload: { operation: 'adding' },
406
+ });
407
+ const result = await addMarketplace(source);
408
+ dispatch({ type: 'END_MARKETPLACE_OPERATION' });
409
+ if (result.success) {
410
+ dispatch({ type: 'HIDE_ADD_MARKETPLACE_DIALOG' });
411
+ // Reload marketplaces to get fresh state
412
+ const marketplaces = loadMarketplaces();
413
+ dispatch({ type: 'SET_MARKETPLACES', payload: marketplaces });
414
+ // Also reload plugins as new marketplace may have plugins
415
+ const plugins = loadAllPlugins();
416
+ dispatch({ type: 'SET_PLUGINS', payload: plugins });
417
+ // Reset selection to avoid pointing to a different marketplace after re-sort
418
+ dispatch({ type: 'SET_SELECTED_INDEX', payload: 0 });
419
+ dispatch({ type: 'SET_MESSAGE', payload: `✅ ${result.message}` });
420
+ }
421
+ else {
422
+ // Keep dialog open and show error inline
423
+ dispatch({
424
+ type: 'SET_ADD_MARKETPLACE_ERROR',
425
+ payload: result.error || result.message,
426
+ });
427
+ }
428
+ }
429
+ /**
430
+ * Handle removing a marketplace
431
+ * @param marketplaceId - Marketplace identifier to remove
432
+ */
433
+ async function handleRemoveMarketplace(marketplaceId) {
434
+ dispatch({
435
+ type: 'START_MARKETPLACE_OPERATION',
436
+ payload: { operation: 'removing', marketplaceId },
437
+ });
438
+ const result = await removeMarketplace(marketplaceId);
439
+ dispatch({ type: 'END_MARKETPLACE_OPERATION' });
440
+ if (result.success) {
441
+ // Reload marketplaces to get fresh state
442
+ const marketplaces = loadMarketplaces();
443
+ dispatch({ type: 'SET_MARKETPLACES', payload: marketplaces });
444
+ // Also reload plugins as removed marketplace's plugins should be gone
445
+ const plugins = loadAllPlugins();
446
+ dispatch({ type: 'SET_PLUGINS', payload: plugins });
447
+ dispatch({ type: 'SET_MESSAGE', payload: `✅ ${result.message}` });
448
+ // Reset selection if needed
449
+ if (state.selectedIndex >= marketplaces.length) {
450
+ dispatch({
451
+ type: 'SET_SELECTED_INDEX',
452
+ payload: Math.max(0, marketplaces.length - 1),
453
+ });
454
+ }
455
+ }
456
+ else {
457
+ dispatch({
458
+ type: 'SET_MESSAGE',
459
+ payload: `❌ ${result.message}${result.error ? `: ${result.error}` : ''}`,
460
+ });
461
+ }
462
+ }
463
+ /**
464
+ * Handle updating a marketplace (or all marketplaces)
465
+ * @param marketplaceId - Optional marketplace identifier. If omitted, updates all.
466
+ */
467
+ async function handleUpdateMarketplace(marketplaceId) {
468
+ dispatch({
469
+ type: 'START_MARKETPLACE_OPERATION',
470
+ payload: { operation: 'updating', marketplaceId },
471
+ });
472
+ const result = await updateMarketplace(marketplaceId);
473
+ dispatch({ type: 'END_MARKETPLACE_OPERATION' });
474
+ if (result.success) {
475
+ // Reload marketplaces and plugins to get fresh state
476
+ const marketplaces = loadMarketplaces();
477
+ dispatch({ type: 'SET_MARKETPLACES', payload: marketplaces });
478
+ const plugins = loadAllPlugins();
479
+ dispatch({ type: 'SET_PLUGINS', payload: plugins });
480
+ // Reset selection to avoid pointing to a different marketplace after re-sort
481
+ dispatch({ type: 'SET_SELECTED_INDEX', payload: 0 });
482
+ dispatch({ type: 'SET_MESSAGE', payload: `✅ ${result.message}` });
483
+ }
484
+ else {
485
+ dispatch({
486
+ type: 'SET_MESSAGE',
487
+ payload: `❌ ${result.message}${result.error ? `: ${result.error}` : ''}`,
488
+ });
489
+ }
490
+ }
316
491
  // Keyboard input handling
317
492
  useInput((input, key) => {
318
- // Block all input during operations
319
- if (state.operation !== 'idle') {
493
+ // Block all input during operations (plugin or marketplace)
494
+ if (state.operation !== 'idle' || state.marketplaceOperation !== 'idle') {
320
495
  return;
321
496
  }
322
497
  // Handle help overlay
@@ -336,7 +511,7 @@ export default function App() {
336
511
  dispatch({ type: 'TOGGLE_HELP' });
337
512
  return;
338
513
  }
339
- // Handle confirmation dialog
514
+ // Handle plugin uninstall confirmation dialog
340
515
  if (state.confirmUninstall && state.operationPluginId) {
341
516
  if (input === 'y' || input === 'Y') {
342
517
  dispatch({ type: 'HIDE_CONFIRM_UNINSTALL' });
@@ -350,6 +525,51 @@ export default function App() {
350
525
  }
351
526
  return;
352
527
  }
528
+ // Handle marketplace remove confirmation dialog
529
+ if (state.confirmRemoveMarketplace && state.operationMarketplaceId) {
530
+ if (input === 'y' || input === 'Y') {
531
+ dispatch({ type: 'HIDE_CONFIRM_REMOVE_MARKETPLACE' });
532
+ handleRemoveMarketplace(state.operationMarketplaceId);
533
+ return;
534
+ }
535
+ if (input === 'n' || input === 'N' || key.escape) {
536
+ dispatch({ type: 'HIDE_CONFIRM_REMOVE_MARKETPLACE' });
537
+ dispatch({ type: 'SET_MESSAGE', payload: 'Remove cancelled' });
538
+ return;
539
+ }
540
+ return;
541
+ }
542
+ // Handle add marketplace dialog input
543
+ if (state.showAddMarketplaceDialog) {
544
+ // Submit on Enter
545
+ if (key.return && state.searchQuery.trim()) {
546
+ handleAddMarketplace(state.searchQuery.trim());
547
+ return;
548
+ }
549
+ // Cancel on Escape
550
+ if (key.escape) {
551
+ dispatch({ type: 'HIDE_ADD_MARKETPLACE_DIALOG' });
552
+ dispatch({ type: 'SET_MESSAGE', payload: 'Add marketplace cancelled' });
553
+ return;
554
+ }
555
+ // Backspace
556
+ if (key.backspace || key.delete) {
557
+ dispatch({
558
+ type: 'SET_SEARCH_QUERY',
559
+ payload: state.searchQuery.slice(0, -1),
560
+ });
561
+ return;
562
+ }
563
+ // Character input
564
+ if (input && input.length === 1 && !key.ctrl && !key.meta) {
565
+ dispatch({
566
+ type: 'SET_SEARCH_QUERY',
567
+ payload: state.searchQuery + input,
568
+ });
569
+ return;
570
+ }
571
+ return;
572
+ }
353
573
  // Search mode input (when focusZone is 'search')
354
574
  if (state.focusZone === 'search') {
355
575
  // Up arrow: move focus to tabbar
@@ -453,11 +673,7 @@ export default function App() {
453
673
  (state.activeTab === 'enabled' ||
454
674
  state.activeTab === 'installed' ||
455
675
  state.activeTab === 'discover')) {
456
- const items = state.activeTab === 'enabled'
457
- ? state.plugins.filter((p) => p.isInstalled && p.isEnabled)
458
- : state.activeTab === 'installed'
459
- ? state.plugins.filter((p) => p.isInstalled)
460
- : getFilteredPlugins(state);
676
+ const items = getItemsForTab(state);
461
677
  const selectedPlugin = items[state.selectedIndex];
462
678
  if (selectedPlugin) {
463
679
  if (!selectedPlugin.isInstalled) {
@@ -494,11 +710,7 @@ export default function App() {
494
710
  (state.activeTab === 'enabled' ||
495
711
  state.activeTab === 'installed' ||
496
712
  state.activeTab === 'discover')) {
497
- const items = state.activeTab === 'enabled'
498
- ? state.plugins.filter((p) => p.isInstalled && p.isEnabled)
499
- : state.activeTab === 'installed'
500
- ? state.plugins.filter((p) => p.isInstalled)
501
- : getFilteredPlugins(state);
713
+ const items = getItemsForTab(state);
502
714
  const selectedPlugin = items[state.selectedIndex];
503
715
  if (selectedPlugin && selectedPlugin.isInstalled) {
504
716
  try {
@@ -552,16 +764,43 @@ export default function App() {
552
764
  dispatch({ type: 'SET_SEARCH_QUERY', payload: '' });
553
765
  return;
554
766
  }
767
+ // Marketplace-specific key bindings
768
+ if (state.activeTab === 'marketplaces' && state.focusZone === 'list') {
769
+ // Add marketplace (a key)
770
+ if (input === 'a') {
771
+ dispatch({ type: 'SHOW_ADD_MARKETPLACE_DIALOG' });
772
+ return;
773
+ }
774
+ // Remove marketplace (d key or Backspace)
775
+ if (input === 'd' || key.backspace || key.delete) {
776
+ const selectedMarketplace = filteredMarketplaces[state.selectedIndex];
777
+ if (selectedMarketplace) {
778
+ dispatch({
779
+ type: 'SHOW_CONFIRM_REMOVE_MARKETPLACE',
780
+ payload: selectedMarketplace.id,
781
+ });
782
+ }
783
+ return;
784
+ }
785
+ // Update marketplace (u key)
786
+ if (input === 'u') {
787
+ const selectedMarketplace = filteredMarketplaces[state.selectedIndex];
788
+ if (selectedMarketplace) {
789
+ handleUpdateMarketplace(selectedMarketplace.id);
790
+ }
791
+ else {
792
+ // No marketplace selected, update all
793
+ handleUpdateMarketplace();
794
+ }
795
+ return;
796
+ }
797
+ }
555
798
  // Install (i key) - only on enabled/installed/discover tabs
556
799
  if (input === 'i' &&
557
800
  (state.activeTab === 'enabled' ||
558
801
  state.activeTab === 'installed' ||
559
802
  state.activeTab === 'discover')) {
560
- const items = state.activeTab === 'enabled'
561
- ? state.plugins.filter((p) => p.isInstalled && p.isEnabled)
562
- : state.activeTab === 'installed'
563
- ? state.plugins.filter((p) => p.isInstalled)
564
- : getFilteredPlugins(state);
803
+ const items = getItemsForTab(state);
565
804
  const selectedPlugin = items[state.selectedIndex];
566
805
  if (selectedPlugin && !selectedPlugin.isInstalled) {
567
806
  handleInstall(selectedPlugin.id);
@@ -579,11 +818,7 @@ export default function App() {
579
818
  (state.activeTab === 'enabled' ||
580
819
  state.activeTab === 'installed' ||
581
820
  state.activeTab === 'discover')) {
582
- const items = state.activeTab === 'enabled'
583
- ? state.plugins.filter((p) => p.isInstalled && p.isEnabled)
584
- : state.activeTab === 'installed'
585
- ? state.plugins.filter((p) => p.isInstalled)
586
- : getFilteredPlugins(state);
821
+ const items = getItemsForTab(state);
587
822
  const selectedPlugin = items[state.selectedIndex];
588
823
  if (selectedPlugin && selectedPlugin.isInstalled) {
589
824
  dispatch({ type: 'SHOW_CONFIRM_UNINSTALL', payload: selectedPlugin.id });
@@ -618,7 +853,7 @@ export default function App() {
618
853
  const filteredMarketplaces = state.searchQuery
619
854
  ? searchMarketplaces(state.searchQuery, state.marketplaces)
620
855
  : state.marketplaces;
621
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsx(Text, { bold: true, color: "magenta", children: "\u26A1 Claude Code Plugin Dashboard" }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { dimColor: true, children: ["v", packageJson.version] })] }), _jsx(TabBar, { activeTab: state.activeTab, isFocused: state.focusZone === 'tabbar' }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [state.activeTab === 'enabled' && (_jsx(EnabledTab, { plugins: enabledPlugins, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, focusZone: state.focusZone })), state.activeTab === 'installed' && (_jsx(InstalledTab, { plugins: installedPlugins, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, focusZone: state.focusZone })), state.activeTab === 'discover' && (_jsx(DiscoverTab, { plugins: filteredPlugins, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, sortBy: state.sortBy, sortOrder: state.sortOrder, focusZone: state.focusZone })), state.activeTab === 'marketplaces' && (_jsx(MarketplacesTab, { marketplaces: filteredMarketplaces, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, focusZone: state.focusZone })), state.activeTab === 'errors' && (_jsx(ErrorsTab, { errors: state.errors, selectedIndex: state.selectedIndex }))] }), state.confirmUninstall && state.operationPluginId && (_jsx(ConfirmDialog, { message: `Uninstall ${state.operationPluginId}?` })), _jsx(HelpOverlay, { isVisible: state.showHelp }), state.message && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: state.message }) })), _jsx(KeyHints, { focusZone: state.focusZone, extraHints: (() => {
856
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsx(Text, { bold: true, color: "magenta", children: "\u26A1 Claude Code Plugin Dashboard" }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { dimColor: true, children: ["v", packageJson.version] })] }), _jsx(TabBar, { activeTab: state.activeTab, isFocused: state.focusZone === 'tabbar' }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [state.activeTab === 'enabled' && (_jsx(EnabledTab, { plugins: enabledPlugins, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, focusZone: state.focusZone })), state.activeTab === 'installed' && (_jsx(InstalledTab, { plugins: installedPlugins, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, focusZone: state.focusZone })), state.activeTab === 'discover' && (_jsx(DiscoverTab, { plugins: filteredPlugins, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, sortBy: state.sortBy, sortOrder: state.sortOrder, focusZone: state.focusZone })), state.activeTab === 'marketplaces' && (_jsx(MarketplacesTab, { marketplaces: filteredMarketplaces, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, focusZone: state.focusZone })), state.activeTab === 'errors' && (_jsx(ErrorsTab, { errors: state.errors, selectedIndex: state.selectedIndex }))] }), state.confirmUninstall && state.operationPluginId && (_jsx(ConfirmDialog, { message: `Uninstall ${state.operationPluginId}?` })), state.confirmRemoveMarketplace && state.operationMarketplaceId && (_jsx(ConfirmDialog, { message: `Remove marketplace ${state.operationMarketplaceId}?` })), state.showAddMarketplaceDialog && (_jsx(AddMarketplaceDialog, { value: state.searchQuery, error: state.addMarketplaceError ?? undefined })), _jsx(HelpOverlay, { isVisible: state.showHelp }), state.message && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: state.message }) })), _jsx(KeyHints, { focusZone: state.focusZone, extraHints: (() => {
622
857
  // Search mode - no extra hints (base hints cover it)
623
858
  if (state.focusZone === 'search') {
624
859
  return undefined;
@@ -661,7 +896,12 @@ export default function App() {
661
896
  }
662
897
  // Marketplaces tab hints
663
898
  if (state.activeTab === 'marketplaces') {
664
- return [{ key: '/', action: 'search' }];
899
+ return [
900
+ { key: '/', action: 'search' },
901
+ { key: 'a', action: 'add' },
902
+ { key: 'd', action: 'remove' },
903
+ { key: 'u', action: 'update' },
904
+ ];
665
905
  }
666
906
  return undefined;
667
907
  })() })] }));
@@ -0,0 +1,20 @@
1
+ /**
2
+ * AddMarketplaceDialog component
3
+ * Dialog for adding a new marketplace source
4
+ */
5
+ export interface AddMarketplaceDialogProps {
6
+ /** Current input value */
7
+ value: string;
8
+ /** Error message to display (if any) */
9
+ error?: string;
10
+ }
11
+ /**
12
+ * Dialog for adding a new marketplace
13
+ * Displays input field with format hints
14
+ * @param value - Current input value (controlled by parent)
15
+ * @param error - Error message to display
16
+ * @returns Dialog component
17
+ * @example
18
+ * <AddMarketplaceDialog value={inputValue} />
19
+ */
20
+ export default function AddMarketplaceDialog({ value, error, }: AddMarketplaceDialogProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * AddMarketplaceDialog component
4
+ * Dialog for adding a new marketplace source
5
+ */
6
+ import { Box, Text } from 'ink';
7
+ /**
8
+ * Dialog for adding a new marketplace
9
+ * Displays input field with format hints
10
+ * @param value - Current input value (controlled by parent)
11
+ * @param error - Error message to display
12
+ * @returns Dialog component
13
+ * @example
14
+ * <AddMarketplaceDialog value={inputValue} />
15
+ */
16
+ export default function AddMarketplaceDialog({ value, error, }) {
17
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "cyan", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Add Marketplace" }) }), _jsx(Text, { children: "Enter marketplace source:" }), _jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, marginY: 1, children: [value ? _jsx(Text, { children: value }) : _jsx(Text, { dimColor: true, children: "owner/repo" }), _jsx(Text, { color: "cyan", children: "\u258C" })] }), error && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "red", children: error }) })), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Supported formats:" }), _jsx(Text, { dimColor: true, children: " \u2022 owner/repo (GitHub)" }), _jsx(Text, { dimColor: true, children: " \u2022 https://github.com/org/repo" }), _jsx(Text, { dimColor: true, children: " \u2022 ./local-path" })] }), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "[" }), _jsx(Text, { color: "red", children: "ESC" }), _jsx(Text, { dimColor: true, children: "] Cancel" })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "[" }), _jsx(Text, { color: "green", children: "Enter" }), _jsx(Text, { dimColor: true, children: "] Add" })] })] })] }));
18
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * ComponentBadges component
3
+ * Displays plugin component type badges with icons and counts
4
+ * Icons: Skills(S), Commands(/), Agents(@), Hooks(H), MCP(M), LSP(L)
5
+ */
6
+ import type { PluginComponents } from '../types/index.js';
7
+ /**
8
+ * Props for ComponentBadges
9
+ */
10
+ export interface ComponentBadgesProps {
11
+ /** Component counts/flags from plugin */
12
+ components: PluginComponents | undefined;
13
+ }
14
+ /**
15
+ * Displays component type badges for a plugin
16
+ * Only shows badges for components that exist
17
+ * @param components - PluginComponents object with counts/flags
18
+ * @returns Badges component or null if no components
19
+ * @example
20
+ * <ComponentBadges components={{ skills: 5, commands: 2 }} />
21
+ * // Renders: [S:5] [/:2]
22
+ */
23
+ export default function ComponentBadges({ components, }: ComponentBadgesProps): React.ReactNode;
24
+ /**
25
+ * Get a human-readable description of component types
26
+ * @param components - PluginComponents object
27
+ * @returns Formatted string describing components
28
+ * @example
29
+ * getComponentsDescription({ skills: 3, mcpServers: 1 })
30
+ * // => "3 skills, 1 MCP server"
31
+ */
32
+ export declare function getComponentsDescription(components: PluginComponents | undefined): string;
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * ComponentBadges component
4
+ * Displays plugin component type badges with icons and counts
5
+ * Icons: Skills(S), Commands(/), Agents(@), Hooks(H), MCP(M), LSP(L)
6
+ */
7
+ import { Box, Text } from 'ink';
8
+ /**
9
+ * Badge configurations with intuitive abbreviations and colors
10
+ */
11
+ const BADGE_CONFIGS = [
12
+ { label: 'S', color: 'magenta', key: 'skills' },
13
+ { label: '/', color: 'cyan', key: 'commands' },
14
+ { label: '@', color: 'blue', key: 'agents' },
15
+ { label: 'H', color: 'yellow', key: 'hooks', isBoolean: true },
16
+ { label: 'M', color: 'green', key: 'mcpServers' },
17
+ { label: 'L', color: 'blueBright', key: 'lspServers' },
18
+ ];
19
+ /**
20
+ * Displays component type badges for a plugin
21
+ * Only shows badges for components that exist
22
+ * @param components - PluginComponents object with counts/flags
23
+ * @returns Badges component or null if no components
24
+ * @example
25
+ * <ComponentBadges components={{ skills: 5, commands: 2 }} />
26
+ * // Renders: [S:5] [/:2]
27
+ */
28
+ export default function ComponentBadges({ components, }) {
29
+ if (!components) {
30
+ return null;
31
+ }
32
+ const badges = BADGE_CONFIGS.filter((config) => {
33
+ const value = components[config.key];
34
+ if (config.isBoolean) {
35
+ return value === true;
36
+ }
37
+ return typeof value === 'number' && value > 0;
38
+ });
39
+ if (badges.length === 0) {
40
+ return null;
41
+ }
42
+ return (_jsx(Box, { gap: 1, flexWrap: "wrap", children: badges.map((config) => (_jsx(Badge, { label: config.label, count: config.isBoolean ? undefined : components[config.key], color: config.color }, config.key))) }));
43
+ }
44
+ /**
45
+ * Single badge component
46
+ */
47
+ function Badge({ label, count, color, }) {
48
+ return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "[" }), _jsx(Text, { color: color, bold: true, children: label }), count !== undefined && _jsxs(Text, { dimColor: true, children: [":", count] }), _jsx(Text, { dimColor: true, children: "]" })] }));
49
+ }
50
+ /**
51
+ * Get a human-readable description of component types
52
+ * @param components - PluginComponents object
53
+ * @returns Formatted string describing components
54
+ * @example
55
+ * getComponentsDescription({ skills: 3, mcpServers: 1 })
56
+ * // => "3 skills, 1 MCP server"
57
+ */
58
+ export function getComponentsDescription(components) {
59
+ if (!components) {
60
+ return '';
61
+ }
62
+ const parts = [];
63
+ if (components.skills) {
64
+ parts.push(`${components.skills} skill${components.skills > 1 ? 's' : ''}`);
65
+ }
66
+ if (components.commands) {
67
+ parts.push(`${components.commands} command${components.commands > 1 ? 's' : ''}`);
68
+ }
69
+ if (components.agents) {
70
+ parts.push(`${components.agents} agent${components.agents > 1 ? 's' : ''}`);
71
+ }
72
+ if (components.hooks) {
73
+ parts.push('hooks');
74
+ }
75
+ if (components.mcpServers) {
76
+ parts.push(`${components.mcpServers} MCP server${components.mcpServers > 1 ? 's' : ''}`);
77
+ }
78
+ if (components.lspServers) {
79
+ parts.push(`${components.lspServers} LSP server${components.lspServers > 1 ? 's' : ''}`);
80
+ }
81
+ return parts.join(', ');
82
+ }
@@ -5,6 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  */
6
6
  import { Box, Text } from 'ink';
7
7
  import StatusIcon from './StatusIcon.js';
8
+ import ComponentBadges from './ComponentBadges.js';
8
9
  /**
9
10
  * Displays detailed information about a selected plugin
10
11
  * @example
@@ -14,7 +15,7 @@ export default function PluginDetail({ plugin }) {
14
15
  if (!plugin) {
15
16
  return (_jsx(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "gray", children: _jsx(Text, { dimColor: true, children: "Select a plugin to view details" }) }));
16
17
  }
17
- return (_jsxs(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "cyan", children: [_jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(StatusIcon, { isInstalled: plugin.isInstalled, isEnabled: plugin.isEnabled }), _jsx(Text, { bold: true, color: "cyan", children: plugin.name })] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { wrap: "wrap", children: plugin.description || 'No description available' }) }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(DetailRow, { label: "Marketplace", value: plugin.marketplace }), _jsx(DetailRow, { label: "Version", value: plugin.version }), _jsx(DetailRow, { label: "Installs", value: formatCount(plugin.installCount) }), plugin.category && (_jsx(DetailRow, { label: "Category", value: plugin.category })), plugin.author && (_jsx(DetailRow, { label: "Author", value: plugin.author.name })), plugin.homepage && (_jsx(DetailRow, { label: "Homepage", value: plugin.homepage }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { children: "Status:" }), plugin.isInstalled ? (plugin.isEnabled ? (_jsx(Text, { color: "green", bold: true, children: "Installed & Enabled" })) : (_jsx(Text, { color: "yellow", bold: true, children: "Installed & Disabled" }))) : (_jsx(Text, { color: "gray", children: "Not Installed" }))] }), plugin.installedAt && (_jsxs(Text, { dimColor: true, children: ["Installed: ", formatDate(plugin.installedAt)] }))] }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "column", children: plugin.isInstalled ? (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { dimColor: true, children: [_jsx(Text, { bold: true, color: "white", children: "Space" }), ' ', plugin.isEnabled ? 'disable' : 'enable', " |", ' ', _jsx(Text, { bold: true, color: "white", children: "u" }), ' ', "uninstall"] }) })) : (_jsxs(Text, { dimColor: true, children: [_jsx(Text, { bold: true, color: "white", children: "i" }), ' ', "install"] })) })] }));
18
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "cyan", children: [_jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(StatusIcon, { isInstalled: plugin.isInstalled, isEnabled: plugin.isEnabled }), _jsx(Text, { bold: true, color: "cyan", children: plugin.name })] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { wrap: "wrap", children: plugin.description || 'No description available' }) }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(DetailRow, { label: "Marketplace", value: plugin.marketplace }), _jsx(DetailRow, { label: "Version", value: plugin.version }), _jsx(DetailRow, { label: "Installs", value: formatCount(plugin.installCount) }), plugin.category && (_jsx(DetailRow, { label: "Category", value: plugin.category })), plugin.author && (_jsx(DetailRow, { label: "Author", value: plugin.author.name })), plugin.homepage && (_jsx(DetailRow, { label: "Homepage", value: plugin.homepage })), plugin.components && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "gray", children: "Components:" }), _jsx(ComponentBadges, { components: plugin.components })] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { children: "Status:" }), plugin.isInstalled ? (plugin.isEnabled ? (_jsx(Text, { color: "green", bold: true, children: "Installed & Enabled" })) : (_jsx(Text, { color: "yellow", bold: true, children: "Installed & Disabled" }))) : (_jsx(Text, { color: "gray", children: "Not Installed" }))] }), plugin.installedAt && (_jsxs(Text, { dimColor: true, children: ["Installed: ", formatDate(plugin.installedAt)] }))] }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "column", children: plugin.isInstalled ? (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { dimColor: true, children: [_jsx(Text, { bold: true, color: "white", children: "Space" }), ' ', plugin.isEnabled ? 'disable' : 'enable', " |", ' ', _jsx(Text, { bold: true, color: "white", children: "u" }), ' ', "uninstall"] }) })) : (_jsxs(Text, { dimColor: true, children: [_jsx(Text, { bold: true, color: "white", children: "i" }), ' ', "install"] })) })] }));
18
19
  }
19
20
  /**
20
21
  * Single detail row with label and value
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Component service for detecting plugin component types
3
+ * Parses plugin.json and scans plugin directory structure to identify
4
+ * skills, commands, agents, hooks, MCP servers, and LSP servers
5
+ */
6
+ import type { PluginComponents } from '../types/index.js';
7
+ /**
8
+ * Detect all component types for a plugin at the given install path
9
+ * @param installPath - Absolute path to the installed plugin directory
10
+ * @returns PluginComponents object with detected component counts
11
+ * - Returns undefined values for components that are not present
12
+ * - Returns counts > 0 for components that exist
13
+ * @example
14
+ * const components = detectPluginComponents('/path/to/plugin')
15
+ * // => { skills: 5, commands: 2, mcpServers: 1 }
16
+ */
17
+ export declare function detectPluginComponents(installPath: string): PluginComponents | undefined;
18
+ /**
19
+ * Check if a plugin has any components
20
+ * @param components - PluginComponents object
21
+ * @returns true if at least one component type is present
22
+ * @example
23
+ * hasAnyComponents({ skills: 2 }) // => true
24
+ * hasAnyComponents({}) // => false
25
+ * hasAnyComponents(undefined) // => false
26
+ */
27
+ export declare function hasAnyComponents(components: PluginComponents | undefined): boolean;
28
+ /**
29
+ * Get total component count for a plugin
30
+ * @param components - PluginComponents object
31
+ * @returns Total number of components (hooks count as 1)
32
+ * @example
33
+ * getTotalComponentCount({ skills: 3, commands: 2, hooks: true }) // => 6
34
+ */
35
+ export declare function getTotalComponentCount(components: PluginComponents | undefined): number;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Component service for detecting plugin component types
3
+ * Parses plugin.json and scans plugin directory structure to identify
4
+ * skills, commands, agents, hooks, MCP servers, and LSP servers
5
+ */
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import { readJsonFile, directoryExists, fileExists } from './fileService.js';
9
+ /**
10
+ * Detect all component types for a plugin at the given install path
11
+ * @param installPath - Absolute path to the installed plugin directory
12
+ * @returns PluginComponents object with detected component counts
13
+ * - Returns undefined values for components that are not present
14
+ * - Returns counts > 0 for components that exist
15
+ * @example
16
+ * const components = detectPluginComponents('/path/to/plugin')
17
+ * // => { skills: 5, commands: 2, mcpServers: 1 }
18
+ */
19
+ export function detectPluginComponents(installPath) {
20
+ if (!directoryExists(installPath)) {
21
+ return undefined;
22
+ }
23
+ const components = {};
24
+ // Detect skills (count directories in skills/ folder)
25
+ const skillsCount = countSkills(installPath);
26
+ if (skillsCount > 0) {
27
+ components.skills = skillsCount;
28
+ }
29
+ // Detect commands (count .md files in commands/ folder)
30
+ const commandsCount = countMarkdownFiles(installPath, 'commands');
31
+ if (commandsCount > 0) {
32
+ components.commands = commandsCount;
33
+ }
34
+ // Detect agents (count .md files in agents/ folder)
35
+ const agentsCount = countMarkdownFiles(installPath, 'agents');
36
+ if (agentsCount > 0) {
37
+ components.agents = agentsCount;
38
+ }
39
+ // Detect hooks
40
+ const hasHooks = detectHooks(installPath);
41
+ if (hasHooks) {
42
+ components.hooks = true;
43
+ }
44
+ // Detect MCP servers from plugin.json
45
+ const mcpCount = countMcpServers(installPath);
46
+ if (mcpCount > 0) {
47
+ components.mcpServers = mcpCount;
48
+ }
49
+ // Detect LSP servers from .lsp.json
50
+ const lspCount = countLspServers(installPath);
51
+ if (lspCount > 0) {
52
+ components.lspServers = lspCount;
53
+ }
54
+ // Return undefined if no components detected
55
+ if (Object.keys(components).length === 0) {
56
+ return undefined;
57
+ }
58
+ return components;
59
+ }
60
+ /**
61
+ * Count skill directories in the skills/ folder
62
+ * Skills are stored as subdirectories with SKILL.md files
63
+ * @param installPath - Plugin install path
64
+ * @returns Number of skill directories
65
+ */
66
+ function countSkills(installPath) {
67
+ const skillsPath = path.join(installPath, 'skills');
68
+ if (!directoryExists(skillsPath)) {
69
+ return 0;
70
+ }
71
+ try {
72
+ const entries = fs.readdirSync(skillsPath, { withFileTypes: true });
73
+ return entries.filter((entry) => entry.isDirectory()).length;
74
+ }
75
+ catch {
76
+ return 0;
77
+ }
78
+ }
79
+ /**
80
+ * Count .md files in a specific directory
81
+ * @param installPath - Plugin install path
82
+ * @param subdir - Subdirectory name ('commands' or 'agents')
83
+ * @returns Number of .md files
84
+ */
85
+ function countMarkdownFiles(installPath, subdir) {
86
+ const dirPath = path.join(installPath, subdir);
87
+ if (!directoryExists(dirPath)) {
88
+ return 0;
89
+ }
90
+ try {
91
+ const files = fs.readdirSync(dirPath);
92
+ return files.filter((file) => file.endsWith('.md')).length;
93
+ }
94
+ catch {
95
+ return 0;
96
+ }
97
+ }
98
+ /**
99
+ * Detect if plugin has hooks configured
100
+ * Checks for hooks/ directory or hooks.json file
101
+ * @param installPath - Plugin install path
102
+ * @returns true if hooks are configured
103
+ */
104
+ function detectHooks(installPath) {
105
+ const hooksDir = path.join(installPath, 'hooks');
106
+ const hooksJson = path.join(installPath, 'hooks.json');
107
+ return directoryExists(hooksDir) || fileExists(hooksJson);
108
+ }
109
+ /**
110
+ * Count MCP servers defined in plugin.json
111
+ * @param installPath - Plugin install path
112
+ * @returns Number of MCP server configurations
113
+ */
114
+ function countMcpServers(installPath) {
115
+ // Check both .claude-plugin/plugin.json and plugin.json at root
116
+ const pluginJsonPaths = [
117
+ path.join(installPath, '.claude-plugin', 'plugin.json'),
118
+ path.join(installPath, 'plugin.json'),
119
+ ];
120
+ for (const pluginJsonPath of pluginJsonPaths) {
121
+ const pluginJson = readJsonFile(pluginJsonPath);
122
+ if (pluginJson?.mcpServers) {
123
+ return Object.keys(pluginJson.mcpServers).length;
124
+ }
125
+ }
126
+ return 0;
127
+ }
128
+ /**
129
+ * Count LSP servers defined in .lsp.json
130
+ * @param installPath - Plugin install path
131
+ * @returns Number of LSP server configurations
132
+ */
133
+ function countLspServers(installPath) {
134
+ const lspJsonPath = path.join(installPath, '.lsp.json');
135
+ const lspConfig = readJsonFile(lspJsonPath);
136
+ if (!lspConfig) {
137
+ return 0;
138
+ }
139
+ return Object.keys(lspConfig).length;
140
+ }
141
+ /**
142
+ * Check if a plugin has any components
143
+ * @param components - PluginComponents object
144
+ * @returns true if at least one component type is present
145
+ * @example
146
+ * hasAnyComponents({ skills: 2 }) // => true
147
+ * hasAnyComponents({}) // => false
148
+ * hasAnyComponents(undefined) // => false
149
+ */
150
+ export function hasAnyComponents(components) {
151
+ if (!components) {
152
+ return false;
153
+ }
154
+ return ((components.skills ?? 0) > 0 ||
155
+ (components.commands ?? 0) > 0 ||
156
+ (components.agents ?? 0) > 0 ||
157
+ components.hooks === true ||
158
+ (components.mcpServers ?? 0) > 0 ||
159
+ (components.lspServers ?? 0) > 0);
160
+ }
161
+ /**
162
+ * Get total component count for a plugin
163
+ * @param components - PluginComponents object
164
+ * @returns Total number of components (hooks count as 1)
165
+ * @example
166
+ * getTotalComponentCount({ skills: 3, commands: 2, hooks: true }) // => 6
167
+ */
168
+ export function getTotalComponentCount(components) {
169
+ if (!components) {
170
+ return 0;
171
+ }
172
+ return ((components.skills ?? 0) +
173
+ (components.commands ?? 0) +
174
+ (components.agents ?? 0) +
175
+ (components.hooks ? 1 : 0) +
176
+ (components.mcpServers ?? 0) +
177
+ (components.lspServers ?? 0));
178
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Marketplace actions service for add/remove/update operations
3
+ * Executes `claude plugin marketplace <action>` as subprocess
4
+ */
5
+ /**
6
+ * Result of a marketplace CLI action
7
+ */
8
+ export interface MarketplaceActionResult {
9
+ success: boolean;
10
+ message: string;
11
+ error?: string;
12
+ }
13
+ /**
14
+ * Add a new marketplace via Claude CLI
15
+ * @param source - Marketplace source (e.g., "owner/repo", "https://...", "./local-path")
16
+ * @returns Promise resolving to action result
17
+ * @example
18
+ * // GitHub shorthand
19
+ * addMarketplace('anthropics/claude-plugins')
20
+ * // Git URL
21
+ * addMarketplace('https://github.com/org/plugins.git')
22
+ * // Local path
23
+ * addMarketplace('./my-marketplace')
24
+ */
25
+ export declare function addMarketplace(source: string): Promise<MarketplaceActionResult>;
26
+ /**
27
+ * Remove an existing marketplace via Claude CLI
28
+ * @param name - Marketplace name/identifier to remove
29
+ * @returns Promise resolving to action result
30
+ * @example
31
+ * removeMarketplace('my-marketplace')
32
+ */
33
+ export declare function removeMarketplace(name: string): Promise<MarketplaceActionResult>;
34
+ /**
35
+ * Update marketplace catalog(s) via Claude CLI
36
+ * @param name - Optional marketplace name. If omitted, updates all marketplaces.
37
+ * @returns Promise resolving to action result
38
+ * @example
39
+ * // Update specific marketplace
40
+ * updateMarketplace('claude-plugins-official')
41
+ * // Update all marketplaces
42
+ * updateMarketplace()
43
+ */
44
+ export declare function updateMarketplace(name?: string): Promise<MarketplaceActionResult>;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Marketplace actions service for add/remove/update operations
3
+ * Executes `claude plugin marketplace <action>` as subprocess
4
+ */
5
+ import { spawn } from 'node:child_process';
6
+ /**
7
+ * Add a new marketplace via Claude CLI
8
+ * @param source - Marketplace source (e.g., "owner/repo", "https://...", "./local-path")
9
+ * @returns Promise resolving to action result
10
+ * @example
11
+ * // GitHub shorthand
12
+ * addMarketplace('anthropics/claude-plugins')
13
+ * // Git URL
14
+ * addMarketplace('https://github.com/org/plugins.git')
15
+ * // Local path
16
+ * addMarketplace('./my-marketplace')
17
+ */
18
+ export function addMarketplace(source) {
19
+ return executeMarketplaceCommand(['plugin', 'marketplace', 'add', source], `Added marketplace: ${source}`, `Failed to add marketplace: ${source}`);
20
+ }
21
+ /**
22
+ * Remove an existing marketplace via Claude CLI
23
+ * @param name - Marketplace name/identifier to remove
24
+ * @returns Promise resolving to action result
25
+ * @example
26
+ * removeMarketplace('my-marketplace')
27
+ */
28
+ export function removeMarketplace(name) {
29
+ return executeMarketplaceCommand(['plugin', 'marketplace', 'remove', name], `Removed marketplace: ${name}`, `Failed to remove marketplace: ${name}`);
30
+ }
31
+ /**
32
+ * Update marketplace catalog(s) via Claude CLI
33
+ * @param name - Optional marketplace name. If omitted, updates all marketplaces.
34
+ * @returns Promise resolving to action result
35
+ * @example
36
+ * // Update specific marketplace
37
+ * updateMarketplace('claude-plugins-official')
38
+ * // Update all marketplaces
39
+ * updateMarketplace()
40
+ */
41
+ export function updateMarketplace(name) {
42
+ const args = ['plugin', 'marketplace', 'update'];
43
+ if (name) {
44
+ args.push(name);
45
+ }
46
+ return executeMarketplaceCommand(args, name ? `Updated ${name}` : 'Updated all marketplaces', `Failed to update ${name || 'marketplaces'}`);
47
+ }
48
+ /**
49
+ * Execute a marketplace CLI command with generic args and messages
50
+ * @param args - CLI arguments to pass to claude command
51
+ * @param successMessage - Message to return on success
52
+ * @param failureMessage - Message to return on failure
53
+ * @returns Promise resolving to action result
54
+ */
55
+ function executeMarketplaceCommand(args, successMessage, failureMessage) {
56
+ return new Promise((resolve) => {
57
+ const child = spawn('claude', args, {
58
+ stdio: ['ignore', 'pipe', 'pipe'],
59
+ shell: false,
60
+ });
61
+ let stdout = '';
62
+ let stderr = '';
63
+ child.stdout?.on('data', (data) => {
64
+ stdout += data.toString();
65
+ });
66
+ child.stderr?.on('data', (data) => {
67
+ stderr += data.toString();
68
+ });
69
+ child.on('close', (code) => {
70
+ if (code === 0) {
71
+ resolve({
72
+ success: true,
73
+ message: successMessage,
74
+ });
75
+ }
76
+ else {
77
+ resolve({
78
+ success: false,
79
+ message: failureMessage,
80
+ error: stderr || stdout || `Exit code: ${code}`,
81
+ });
82
+ }
83
+ });
84
+ child.on('error', (err) => {
85
+ resolve({
86
+ success: false,
87
+ message: 'Failed to execute claude command',
88
+ error: err.message,
89
+ });
90
+ });
91
+ });
92
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { readJsonFile, directoryExists, listDirectories, } from './fileService.js';
6
6
  import { getEnabledPlugins } from './settingsService.js';
7
+ import { detectPluginComponents } from './componentService.js';
7
8
  import { PATHS, getMarketplaceJsonPath } from '../utils/paths.js';
8
9
  /**
9
10
  * Load all plugins from all marketplaces
@@ -44,6 +45,10 @@ export function loadAllPlugins() {
44
45
  for (const plugin of manifest.plugins) {
45
46
  const pluginId = `${plugin.name}@${marketplace}`;
46
47
  const installedEntry = installedMap.get(pluginId);
48
+ // Detect components for installed plugins
49
+ const components = installedEntry
50
+ ? detectPluginComponents(installedEntry.installPath)
51
+ : undefined;
47
52
  plugins.push({
48
53
  id: pluginId,
49
54
  name: plugin.name,
@@ -61,6 +66,7 @@ export function loadAllPlugins() {
61
66
  tags: plugin.tags || plugin.keywords,
62
67
  isLocal: installedEntry?.isLocal,
63
68
  gitCommitSha: installedEntry?.gitCommitSha,
69
+ components,
64
70
  });
65
71
  }
66
72
  }
@@ -2,6 +2,26 @@
2
2
  * TypeScript interfaces for Claude Code Plugin Dashboard
3
3
  * These types aggregate data from multiple Claude Code configuration files
4
4
  */
5
+ /**
6
+ * Component types provided by a plugin
7
+ * Detected by scanning plugin directory structure and plugin.json
8
+ * @example
9
+ * { skills: 5, commands: 2, mcpServers: 1 } // Plugin with skills, commands, and MCP
10
+ */
11
+ export interface PluginComponents {
12
+ /** Count of skill directories in skills/ */
13
+ skills?: number;
14
+ /** Count of slash command .md files in commands/ */
15
+ commands?: number;
16
+ /** Count of subagent .md files in agents/ */
17
+ agents?: number;
18
+ /** Whether hooks are configured (hooks/ dir or hooks.json exists) */
19
+ hooks?: boolean;
20
+ /** Count of MCP server configurations in plugin.json mcpServers field */
21
+ mcpServers?: number;
22
+ /** Count of LSP server configurations in .lsp.json */
23
+ lspServers?: number;
24
+ }
5
25
  /**
6
26
  * Aggregated plugin data from multiple sources
7
27
  * Combines data from settings.json, installed_plugins.json, marketplace.json, etc.
@@ -42,6 +62,8 @@ export interface Plugin {
42
62
  isLocal?: boolean;
43
63
  /** Git commit SHA (if installed) */
44
64
  gitCommitSha?: string;
65
+ /** Component types provided by this plugin (skills, commands, MCP, etc.) */
66
+ components?: PluginComponents;
45
67
  }
46
68
  /**
47
69
  * Raw installed plugin data from installed_plugins.json
@@ -157,6 +179,10 @@ export interface Settings {
157
179
  * Defines which UI area currently has keyboard focus
158
180
  */
159
181
  export type FocusZone = 'tabbar' | 'search' | 'list';
182
+ /**
183
+ * Marketplace operation types
184
+ */
185
+ export type MarketplaceOperation = 'idle' | 'adding' | 'removing' | 'updating';
160
186
  /**
161
187
  * Application state for useReducer
162
188
  */
@@ -193,6 +219,16 @@ export interface AppState {
193
219
  confirmUninstall: boolean;
194
220
  /** Whether help overlay is showing */
195
221
  showHelp: boolean;
222
+ /** Current marketplace operation */
223
+ marketplaceOperation: MarketplaceOperation;
224
+ /** Marketplace ID being operated on */
225
+ operationMarketplaceId: string | null;
226
+ /** Whether remove marketplace confirmation dialog is showing */
227
+ confirmRemoveMarketplace: boolean;
228
+ /** Whether add marketplace dialog is showing */
229
+ showAddMarketplaceDialog: boolean;
230
+ /** Error message for add marketplace dialog */
231
+ addMarketplaceError: string | null;
196
232
  }
197
233
  /**
198
234
  * Action types for useReducer
@@ -261,4 +297,24 @@ export type Action = {
261
297
  } | {
262
298
  type: 'SET_FOCUS_ZONE';
263
299
  payload: FocusZone;
300
+ } | {
301
+ type: 'SHOW_CONFIRM_REMOVE_MARKETPLACE';
302
+ payload: string;
303
+ } | {
304
+ type: 'HIDE_CONFIRM_REMOVE_MARKETPLACE';
305
+ } | {
306
+ type: 'SHOW_ADD_MARKETPLACE_DIALOG';
307
+ } | {
308
+ type: 'HIDE_ADD_MARKETPLACE_DIALOG';
309
+ } | {
310
+ type: 'START_MARKETPLACE_OPERATION';
311
+ payload: {
312
+ operation: MarketplaceOperation;
313
+ marketplaceId?: string;
314
+ };
315
+ } | {
316
+ type: 'END_MARKETPLACE_OPERATION';
317
+ } | {
318
+ type: 'SET_ADD_MARKETPLACE_ERROR';
319
+ payload: string | null;
264
320
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laststance/claude-plugin-dashboard",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Interactive CLI dashboard to manage Claude Code plugins",
5
5
  "license": "MIT",
6
6
  "author": "Ryota Murakami <ryota.murakami@laststance.io> (https://github.com/ryota-murakami)",
@@ -65,4 +65,4 @@
65
65
  "typescript": "^5.7.2",
66
66
  "vitest": "^4.0.16"
67
67
  }
68
- }
68
+ }