claude-code-marketplace 0.2.1 → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oleksii Nikiforov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-marketplace",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Web UI for browsing and managing Claude Code marketplace plugins",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -43,6 +43,8 @@ const ICONS = {
43
43
  '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="12" cy="5" r="2.5"/><circle cx="12" cy="12" r="2.5"/><circle cx="12" cy="19" r="2.5"/></svg>',
44
44
  };
45
45
  ICONS.settings = ICONS.gear;
46
+ ICONS.openEditor =
47
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M17.583 2.207a1.1 1.1 0 0 1 1.541.033l2.636 2.636a1.1 1.1 0 0 1 .033 1.541L10.68 17.53a1.1 1.1 0 0 1-.345.247l-4.56 1.903a.55.55 0 0 1-.725-.725l1.903-4.56a1.1 1.1 0 0 1 .247-.345zm.902 1.87-8.794 8.793-.946 2.268 2.268-.946 8.794-8.793z"/></svg>';
46
48
  const COMP_HAS_DIR = new Set(['skills', 'commands', 'agents']);
47
49
  const COMP_LABELS = {
48
50
  skills: 'Skills',
@@ -65,6 +67,7 @@ function updateArrow(p) {
65
67
 
66
68
  // --- Init ---
67
69
  document.addEventListener('DOMContentLoaded', () => {
70
+ document.getElementById('contentOpenEditor').innerHTML = ICONS.openEditor;
68
71
  restoreAppState();
69
72
  loadProject();
70
73
  loadData();
@@ -88,17 +91,20 @@ document.addEventListener('DOMContentLoaded', () => {
88
91
  document.getElementById('addMarketplaceBtn').addEventListener('click', openAddMarketplace);
89
92
  document.getElementById('helpBtn').addEventListener('click', showHelpModal);
90
93
 
91
- // Enter key in modal
92
- document.getElementById('marketplaceSource').addEventListener('keydown', (e) => {
93
- if (e.key === 'Enter') {
94
- e.preventDefault();
95
- submitAddMarketplace();
96
- }
97
- if (e.key === 'Escape') {
98
- e.preventDefault();
99
- closeModal('addMarketplaceModal');
100
- }
101
- });
94
+ function bindModalKeys(inputId, modalId, onSubmit) {
95
+ document.getElementById(inputId).addEventListener('keydown', (e) => {
96
+ if (e.key === 'Enter') {
97
+ e.preventDefault();
98
+ onSubmit();
99
+ }
100
+ if (e.key === 'Escape') {
101
+ e.preventDefault();
102
+ closeModal(modalId);
103
+ }
104
+ });
105
+ }
106
+ bindModalKeys('marketplaceSource', 'addMarketplaceModal', submitAddMarketplace);
107
+ bindModalKeys('projectPathInput', 'projectPickerModal', submitProjectPicker);
102
108
 
103
109
  const savedTheme = localStorage.getItem('theme');
104
110
  if (savedTheme === 'dark') {
@@ -138,6 +144,7 @@ async function loadProject() {
138
144
  const data = await res.json();
139
145
  document.getElementById('projectPath').textContent = shortenPath(data.path);
140
146
  document.getElementById('projectBtn').title = data.path;
147
+ saveRecentProject(data.path);
141
148
  } catch {}
142
149
  }
143
150
 
@@ -178,25 +185,68 @@ async function refresh() {
178
185
  toast('Data refreshed', 'success');
179
186
  }
180
187
 
181
- async function changeProject() {
182
- // Try native directory picker API first, fall back to prompt
183
- let dirPath = null;
184
- if (window.showDirectoryPicker) {
185
- try {
186
- const handle = await window.showDirectoryPicker({ mode: 'read' });
187
- dirPath = handle.name;
188
- // showDirectoryPicker doesn't give full path — fall back to prompt with the name as hint
189
- dirPath = prompt('Confirm project directory (browser cannot read full path):', dirPath);
190
- } catch (e) {
191
- if (e.name === 'AbortError') return;
192
- }
188
+ function getRecentProjects() {
189
+ try {
190
+ return JSON.parse(localStorage.getItem('recentProjects') || '[]');
191
+ } catch {
192
+ return [];
193
193
  }
194
- if (!dirPath) {
195
- const current = document.getElementById('projectBtn').title;
196
- dirPath = prompt('Project directory:', current);
194
+ }
195
+
196
+ function saveRecentProject(projectPath) {
197
+ const recent = getRecentProjects().filter((p) => p !== projectPath);
198
+ recent.unshift(projectPath);
199
+ localStorage.setItem('recentProjects', JSON.stringify(recent.slice(0, 10)));
200
+ }
201
+
202
+ function removeRecentProject(projectPath, e) {
203
+ e.stopPropagation();
204
+ const recent = getRecentProjects().filter((p) => p !== projectPath);
205
+ localStorage.setItem('recentProjects', JSON.stringify(recent));
206
+ renderRecentProjects();
207
+ }
208
+
209
+ function renderRecentProjects() {
210
+ const container = document.getElementById('recentProjectsList');
211
+ const current = document.getElementById('projectBtn').title;
212
+ const recent = getRecentProjects().filter((p) => p !== current);
213
+ if (!recent.length) {
214
+ container.innerHTML = '';
215
+ return;
197
216
  }
198
- if (!dirPath) return;
217
+ const escAttr = (s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
218
+ const escJs = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
219
+ container.innerHTML =
220
+ '<div class="recent-projects-label">Recent</div>' +
221
+ recent
222
+ .map(
223
+ (p) =>
224
+ `<div class="recent-project-item" onclick="selectRecentProject('${escJs(p)}')">` +
225
+ `<span>${escAttr(p)}</span>` +
226
+ `<button class="recent-project-remove" onclick="removeRecentProject('${escJs(p)}', event)" title="Remove">&#10005;</button>` +
227
+ `</div>`,
228
+ )
229
+ .join('');
230
+ }
199
231
 
232
+ function selectRecentProject(projectPath) {
233
+ document.getElementById('projectPathInput').value = projectPath;
234
+ }
235
+
236
+ function changeProject() {
237
+ const current = document.getElementById('projectBtn').title;
238
+ document.getElementById('projectPathInput').value = current;
239
+ renderRecentProjects();
240
+ document.getElementById('projectPickerModal').classList.add('open');
241
+ setTimeout(() => document.getElementById('projectPathInput').focus(), 100);
242
+ }
243
+
244
+ async function submitProjectPicker() {
245
+ const dirPath = document.getElementById('projectPathInput').value.trim();
246
+ if (!dirPath) return;
247
+ const btn = document.getElementById('projectPickerSubmit');
248
+ btn.disabled = true;
249
+ btn.textContent = 'Switching...';
200
250
  try {
201
251
  const res = await fetch('/api/project', {
202
252
  method: 'PUT',
@@ -208,11 +258,15 @@ async function changeProject() {
208
258
  toast(err.error, 'error');
209
259
  return;
210
260
  }
261
+ closeModal('projectPickerModal');
211
262
  await loadProject();
212
263
  await loadData();
213
264
  toast('Project switched', 'success');
214
265
  } catch (err) {
215
266
  toast(err.message, 'error');
267
+ } finally {
268
+ btn.disabled = false;
269
+ btn.textContent = 'Switch';
216
270
  }
217
271
  }
218
272
 
@@ -388,7 +442,10 @@ async function showDetail(pluginId) {
388
442
  panel.innerHTML = `
389
443
  <div class="detail-header">
390
444
  <h3>${headerIcon} ${esc(plugin.name)} ${plugin.version ? `<span class="version">v${esc(plugin.version)}</span>` : ''}</h3>
391
- <button class="detail-close" onclick="closeDetail()">\u2715</button>
445
+ <div class="detail-header-actions">
446
+ ${plugin._pluginDir ? `<button class="modal-action-btn" title="Open in VS Code" onclick="openFolderInEditor({pluginId:'${esc(plugin.fullId)}'})">${ICONS.openEditor}</button>` : ''}
447
+ <button class="detail-close" onclick="closeDetail()">\u2715</button>
448
+ </div>
392
449
  </div>
393
450
  <div class="detail-body">
394
451
  ${updateBanner}
@@ -503,6 +560,7 @@ const EXT_TO_LANG = {
503
560
 
504
561
  const PREFERRED_FILE = 'SKILL.MD';
505
562
  let _contentCodeEl = null;
563
+ let _contentPluginId = null;
506
564
 
507
565
  function highlightSource(text, fileName) {
508
566
  const ext = (fileName || '').split('.').pop().toLowerCase();
@@ -530,7 +588,31 @@ function getContentCodeEl() {
530
588
  return _contentCodeEl;
531
589
  }
532
590
 
591
+ async function openInEditor() {
592
+ if (!_contentPluginId) return;
593
+ const relativePath = document.getElementById('contentViewerPath').textContent || '';
594
+ try {
595
+ await fetch('/api/open-in-editor', {
596
+ method: 'POST',
597
+ headers: { 'Content-Type': 'application/json' },
598
+ body: JSON.stringify({ pluginId: _contentPluginId, relativePath }),
599
+ });
600
+ } catch {}
601
+ }
602
+
603
+ async function openFolderInEditor({ pluginId, marketplaceName } = {}) {
604
+ if (!pluginId && !marketplaceName) return;
605
+ try {
606
+ await fetch('/api/open-folder-in-editor', {
607
+ method: 'POST',
608
+ headers: { 'Content-Type': 'application/json' },
609
+ body: JSON.stringify({ pluginId, marketplaceName }),
610
+ });
611
+ } catch {}
612
+ }
613
+
533
614
  async function openContentModal(pluginId, initialPath, componentType) {
615
+ _contentPluginId = pluginId;
534
616
  const plugin = findPlugin(pluginId);
535
617
  const label = COMP_LABELS[componentType] || componentType;
536
618
  document.getElementById('contentModalTitle').textContent = `${plugin?.name || pluginId} \u2014 ${label}`;
@@ -649,7 +731,10 @@ function showMarketplaceDetail(name) {
649
731
  panel.innerHTML = `
650
732
  <div class="detail-header">
651
733
  <h3>${ICONS.marketplace} ${esc(m.name)} ${m.version ? `<span class="version">v${esc(m.version)}</span>` : ''}</h3>
652
- <button class="detail-close" onclick="closeDetail()">\u2715</button>
734
+ <div class="detail-header-actions">
735
+ ${m.installLocation ? `<button class="modal-action-btn" title="Open in VS Code" onclick="openFolderInEditor({marketplaceName:'${esc(m.name)}'})">${ICONS.openEditor}</button>` : ''}
736
+ <button class="detail-close" onclick="closeDetail()">\u2715</button>
737
+ </div>
653
738
  </div>
654
739
  <div class="detail-body">
655
740
  <div class="detail-section">
package/public/index.html CHANGED
@@ -95,6 +95,28 @@
95
95
  </div>
96
96
  </div>
97
97
 
98
+ <!-- Project picker modal -->
99
+ <div class="modal-overlay" id="projectPickerModal">
100
+ <div class="modal">
101
+ <div class="modal-header">
102
+ <h3>Switch Project</h3>
103
+ <button class="modal-close" onclick="closeModal('projectPickerModal')">&#10005;</button>
104
+ </div>
105
+ <div class="modal-body">
106
+ <div class="modal-field">
107
+ <label>Project directory</label>
108
+ <input type="text" id="projectPathInput" placeholder="/path/to/project" autocomplete="off">
109
+ <span class="modal-hint">Full path to the project directory</span>
110
+ </div>
111
+ <div id="recentProjectsList"></div>
112
+ <div class="modal-actions">
113
+ <button class="action-btn" onclick="closeModal('projectPickerModal')">Cancel</button>
114
+ <button class="action-btn primary" id="projectPickerSubmit" onclick="submitProjectPicker()">Switch</button>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ </div>
119
+
98
120
  <!-- Help modal -->
99
121
  <div class="modal-overlay" id="helpModal">
100
122
  <div class="modal help-modal" onclick="event.stopPropagation()">
@@ -135,7 +157,10 @@
135
157
  <div class="modal content-modal" onclick="event.stopPropagation()">
136
158
  <div class="modal-header">
137
159
  <h3 id="contentModalTitle">File Preview</h3>
138
- <button class="modal-close" onclick="closeModal('contentModal')">&#10005;</button>
160
+ <div class="modal-header-actions">
161
+ <button class="modal-action-btn" id="contentOpenEditor" title="Open in VS Code" onclick="openInEditor()"></button>
162
+ <button class="modal-close" onclick="closeModal('contentModal')">&#10005;</button>
163
+ </div>
139
164
  </div>
140
165
  <div class="content-modal-body">
141
166
  <div class="content-tree" id="contentTree"></div>
package/public/style.css CHANGED
@@ -720,6 +720,12 @@ body.light .scope-toggle.local {
720
720
  font-size: 11px;
721
721
  font-weight: 400;
722
722
  }
723
+ .detail-header-actions,
724
+ .modal-header-actions {
725
+ display: flex;
726
+ align-items: center;
727
+ gap: 4px;
728
+ }
723
729
  .detail-close {
724
730
  background: transparent;
725
731
  border: 1px solid transparent;
@@ -997,6 +1003,20 @@ body.light .scope-toggle.local {
997
1003
  font-size: 13px;
998
1004
  font-weight: 600;
999
1005
  }
1006
+ .modal-action-btn {
1007
+ background: none;
1008
+ border: none;
1009
+ color: var(--text-muted);
1010
+ cursor: pointer;
1011
+ padding: 4px;
1012
+ display: flex;
1013
+ align-items: center;
1014
+ border-radius: 4px;
1015
+ }
1016
+ .modal-action-btn:hover {
1017
+ color: var(--accent);
1018
+ background: var(--hover);
1019
+ }
1000
1020
  .modal-close {
1001
1021
  background: none;
1002
1022
  border: none;
@@ -1004,6 +1024,10 @@ body.light .scope-toggle.local {
1004
1024
  cursor: pointer;
1005
1025
  font-size: 16px;
1006
1026
  padding: 4px;
1027
+ display: flex;
1028
+ align-items: center;
1029
+ justify-content: center;
1030
+ line-height: 1;
1007
1031
  }
1008
1032
  .modal-body {
1009
1033
  padding: 16px;
@@ -1072,6 +1096,52 @@ body.light .scope-toggle.local {
1072
1096
  margin-top: 16px;
1073
1097
  }
1074
1098
 
1099
+ /* === RECENT PROJECTS === */
1100
+
1101
+ .recent-projects-label {
1102
+ font-size: 11px;
1103
+ color: var(--text-muted);
1104
+ margin-bottom: 6px;
1105
+ text-transform: uppercase;
1106
+ letter-spacing: 0.5px;
1107
+ }
1108
+
1109
+ .recent-project-item {
1110
+ display: flex;
1111
+ align-items: center;
1112
+ gap: 8px;
1113
+ padding: 6px 8px;
1114
+ border-radius: 4px;
1115
+ cursor: pointer;
1116
+ font-size: 13px;
1117
+ color: var(--text-secondary);
1118
+ font-family: var(--font-mono);
1119
+ }
1120
+
1121
+ .recent-project-item:hover {
1122
+ background: var(--hover-bg);
1123
+ color: var(--text-primary);
1124
+ }
1125
+
1126
+ .recent-project-remove {
1127
+ margin-left: auto;
1128
+ opacity: 0;
1129
+ background: none;
1130
+ border: none;
1131
+ color: var(--text-muted);
1132
+ cursor: pointer;
1133
+ font-size: 14px;
1134
+ padding: 0 4px;
1135
+ }
1136
+
1137
+ .recent-project-item:hover .recent-project-remove {
1138
+ opacity: 1;
1139
+ }
1140
+
1141
+ .recent-project-remove:hover {
1142
+ color: var(--danger);
1143
+ }
1144
+
1075
1145
  /* === HELP MODAL === */
1076
1146
 
1077
1147
  .help-modal {
package/server.js CHANGED
@@ -202,6 +202,21 @@ function loadMarketplaces() {
202
202
 
203
203
  const compKeys = ['skills', 'commands', 'agents', 'mcpServers', 'hooks', 'lspServers'];
204
204
 
205
+ // Resolve origin dir from marketplace source
206
+ let originDir = null;
207
+ if (installLocation) {
208
+ const rawSource = pd.source;
209
+ if (typeof rawSource === 'string' && rawSource) {
210
+ const srcDir = path.resolve(installLocation, rawSource);
211
+ if (fs.existsSync(srcDir)) originDir = srcDir;
212
+ }
213
+ if (!originDir) {
214
+ const pluginSubdir = path.join(installLocation, 'plugins', pd.name);
215
+ if (fs.existsSync(pluginSubdir)) originDir = pluginSubdir;
216
+ else if ((mData.plugins || []).length === 1) originDir = installLocation;
217
+ }
218
+ }
219
+
205
220
  // Resolve plugin dir for filesystem-based component counts
206
221
  let pluginDir = null;
207
222
  for (const s of ['user', 'project', 'local']) {
@@ -209,11 +224,7 @@ function loadMarketplaces() {
209
224
  const resolved = resolveInstallPath(ip);
210
225
  if (resolved) { pluginDir = resolved; break; }
211
226
  }
212
- if (!pluginDir && installLocation) {
213
- const pluginSubdir = path.join(installLocation, 'plugins', pd.name);
214
- if (fs.existsSync(pluginSubdir)) pluginDir = pluginSubdir;
215
- else if ((mData.plugins || []).length === 1) pluginDir = installLocation;
216
- }
227
+ if (!pluginDir) pluginDir = originDir;
217
228
 
218
229
  const fsComps = pluginDir ? countComponents(pluginDir) : null;
219
230
  const components = {};
@@ -253,6 +264,7 @@ function loadMarketplaces() {
253
264
  installedScopes,
254
265
  components,
255
266
  _pluginDir: pluginDir,
267
+ _originDir: originDir,
256
268
  _fsComps: fsComps,
257
269
  metadata: Object.fromEntries(
258
270
  Object.entries(pd).filter(([k]) => !['name', 'description', 'source', 'version', ...compKeys].includes(k))
@@ -540,10 +552,11 @@ app.get('/api/plugins/:pluginId/preview/*', (req, res) => {
540
552
  const pluginDir = resolvePluginDir(pluginId, marketplaces);
541
553
  if (!pluginDir) return res.status(404).json({ error: 'Plugin not found' });
542
554
 
543
- const fullPath = path.resolve(pluginDir, relPath);
555
+ let fullPath = path.resolve(pluginDir, relPath);
544
556
  if (!fullPath.startsWith(path.resolve(pluginDir))) {
545
557
  return res.status(403).json({ error: 'Access denied' });
546
558
  }
559
+ if (!fs.existsSync(fullPath) && fs.existsSync(fullPath + '.md')) fullPath += '.md';
547
560
 
548
561
  try {
549
562
  const stat = fs.statSync(fullPath);
@@ -562,6 +575,54 @@ app.get('/api/plugins/:pluginId/preview/*', (req, res) => {
562
575
  }
563
576
  });
564
577
 
578
+ function openVSCode(args, res) {
579
+ execFile('code', args, { shell: true }, (err) => {
580
+ if (err) return res.status(500).json({ error: 'Failed to open editor' });
581
+ res.json({ ok: true });
582
+ });
583
+ }
584
+
585
+ app.post('/api/open-in-editor', (req, res) => {
586
+ const { pluginId, relativePath } = req.body;
587
+ if (!pluginId) return res.status(400).json({ error: 'pluginId required' });
588
+
589
+ const marketplaces = getCachedMarketplaces();
590
+ const pluginDir = resolvePluginDir(pluginId, marketplaces);
591
+ if (!pluginDir) return res.status(404).json({ error: 'Plugin not found' });
592
+
593
+ const args = ['-n', pluginDir];
594
+
595
+ const pluginJson = path.join(pluginDir, '.claude-plugin', 'plugin.json');
596
+ if (fs.existsSync(pluginJson)) args.push(pluginJson);
597
+
598
+ if (relativePath) {
599
+ const fullPath = path.resolve(pluginDir, relativePath);
600
+ if (fullPath.startsWith(path.resolve(pluginDir))) {
601
+ args.push(fullPath);
602
+ }
603
+ }
604
+
605
+ openVSCode(args, res);
606
+ });
607
+
608
+ app.post('/api/open-folder-in-editor', (req, res) => {
609
+ const { pluginId, marketplaceName } = req.body;
610
+ const marketplaces = getCachedMarketplaces();
611
+ let folder;
612
+
613
+ if (pluginId) {
614
+ const plugin = findPlugin(pluginId, marketplaces);
615
+ folder = plugin?._originDir || plugin?._pluginDir || null;
616
+ } else if (marketplaceName) {
617
+ const m = marketplaces.find(m => m.name === marketplaceName);
618
+ folder = m?.installLocation || null;
619
+ }
620
+
621
+ if (!folder) return res.status(404).json({ error: 'Directory not found' });
622
+
623
+ openVSCode(['-n', folder], res);
624
+ });
625
+
565
626
  app.get('/api/project', (req, res) => {
566
627
  res.json({ path: projectPath });
567
628
  });
@@ -569,9 +630,11 @@ app.get('/api/project', (req, res) => {
569
630
  app.put('/api/project', (req, res) => {
570
631
  const newPath = req.body.path;
571
632
  if (!newPath) return res.status(400).json({ error: 'path required' });
572
- const resolved = newPath.startsWith('~') ? newPath.replace('~', os.homedir()) : newPath;
633
+ const expanded = newPath.startsWith('~') ? newPath.replace('~', os.homedir()) : newPath;
634
+ const resolved = path.resolve(expanded);
573
635
  if (!fs.existsSync(resolved)) return res.status(400).json({ error: 'Directory does not exist' });
574
636
  projectPath = resolved;
637
+ invalidateCache();
575
638
  res.json({ path: projectPath });
576
639
  });
577
640