claude-code-marketplace 0.3.2 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-marketplace",
3
- "version": "0.3.2",
3
+ "version": "0.4.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
@@ -42,9 +42,14 @@ const ICONS = {
42
42
  kebab:
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
+ ICONS.readme = SVG(
46
+ '<path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/>',
47
+ );
45
48
  ICONS.settings = ICONS.gear;
46
49
  ICONS.openEditor =
47
50
  '<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>';
51
+ ICONS.copyPath =
52
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
48
53
  const COMP_HAS_DIR = new Set(['skills', 'commands', 'agents']);
49
54
  const COMP_LABELS = {
50
55
  skills: 'Skills',
@@ -54,6 +59,7 @@ const COMP_LABELS = {
54
59
  hooks: 'Hooks',
55
60
  lspServers: 'LSP Servers',
56
61
  settings: 'Settings',
62
+ readme: 'README',
57
63
  };
58
64
 
59
65
  function updateArrow(p) {
@@ -68,6 +74,7 @@ function updateArrow(p) {
68
74
  // --- Init ---
69
75
  document.addEventListener('DOMContentLoaded', () => {
70
76
  document.getElementById('contentOpenEditor').innerHTML = ICONS.openEditor;
77
+ document.getElementById('contentCopyPath').innerHTML = ICONS.copyPath;
71
78
  restoreAppState();
72
79
  loadProject();
73
80
  loadData();
@@ -443,7 +450,7 @@ async function showDetail(pluginId) {
443
450
  <div class="detail-header">
444
451
  <h3>${headerIcon} ${esc(plugin.name)} ${plugin.version ? `<span class="version">v${esc(plugin.version)}</span>` : ''}</h3>
445
452
  <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>` : ''}
453
+ ${plugin._pluginDir ? `<button class="modal-action-btn" title="${esc(plugin._pluginDir)}" onclick="copyPluginPath('${escJs(plugin._pluginDir)}', event)">${ICONS.copyPath}</button><button class="modal-action-btn" title="Open in VS Code" onclick="openFolderInEditor({pluginId:'${esc(plugin.fullId)}',event})">${ICONS.openEditor}</button>` : ''}
447
454
  <button class="detail-close" onclick="closeDetail()">\u2715</button>
448
455
  </div>
449
456
  </div>
@@ -505,41 +512,52 @@ function renderDetailComponents(pluginId, comps, hasDirAccess) {
505
512
  const entries = Object.entries(comps).filter(
506
513
  ([k, v]) => !k.startsWith('_') && (Array.isArray(v) ? v.length > 0 : v > 0),
507
514
  );
508
- if (!entries.length) return '<div style="color:var(--text-dim);font-size:12px">No components found</div>';
515
+ if (!entries.length && !comps._readmePath)
516
+ return '<div style="color:var(--text-dim);font-size:12px">No components found</div>';
509
517
 
510
- return entries
511
- .map(([type, items]) => {
512
- const names = Array.isArray(items) ? items : [];
513
- const count = names.length || items;
514
- let html = `<div class="detail-comp-group">
518
+ let readmeHtml = '';
519
+ if (comps._readmePath && hasDirAccess) {
520
+ readmeHtml = `<div class="readme-comp-item" onclick="openContentModal('${esc(pluginId)}', '${esc(comps._readmePath)}', 'readme')">
521
+ <span class="icon">${ICONS.readme}</span> ${esc(comps._readmePath)}
522
+ </div>`;
523
+ }
524
+
525
+ return (
526
+ readmeHtml +
527
+ entries
528
+ .map(([type, items]) => {
529
+ const names = Array.isArray(items) ? items : [];
530
+ const count = names.length || items;
531
+ let html = `<div class="detail-comp-group">
515
532
  <div class="detail-comp-header">
516
533
  <span class="comp-icon">${ICONS[type] || ''}</span>
517
534
  ${COMP_LABELS[type] || type}
518
535
  <span class="count">${count}</span>
519
536
  </div>`;
520
537
 
521
- if (names.length) {
522
- const configFile = configFiles[type];
523
- const dir = COMP_HAS_DIR.has(type) ? type : null;
524
- html += '<div class="detail-comp-items">';
525
- for (const name of names) {
526
- const clickPath = configFile || (dir ? `${dir}/${name}` : name);
527
- const cls = hasDirAccess ? '' : ' disabled';
528
- const click = hasDirAccess
529
- ? ` onclick="openContentModal('${esc(pluginId)}', '${esc(clickPath)}', '${esc(type)}')"`
530
- : '';
531
- html += `<div class="detail-comp-item${cls}"${click}>
538
+ if (names.length) {
539
+ const configFile = configFiles[type];
540
+ const dir = COMP_HAS_DIR.has(type) ? type : null;
541
+ html += '<div class="detail-comp-items">';
542
+ for (const name of names) {
543
+ const clickPath = configFile || (dir ? `${dir}/${name}` : name);
544
+ const cls = hasDirAccess ? '' : ' disabled';
545
+ const click = hasDirAccess
546
+ ? ` onclick="openContentModal('${esc(pluginId)}', '${esc(clickPath)}', '${esc(type)}')"`
547
+ : '';
548
+ html += `<div class="detail-comp-item${cls}"${click}>
532
549
  <span class="icon">${type === 'skills' ? ICONS.folder : ICONS.file}</span>
533
550
  ${esc(name)}
534
551
  </div>`;
552
+ }
553
+ html += '</div>';
535
554
  }
536
- html += '</div>';
537
- }
538
555
 
539
- html += '</div>';
540
- return html;
541
- })
542
- .join('');
556
+ html += '</div>';
557
+ return html;
558
+ })
559
+ .join('')
560
+ );
543
561
  }
544
562
 
545
563
  const EXT_TO_LANG = {
@@ -561,6 +579,7 @@ const EXT_TO_LANG = {
561
579
  const PREFERRED_FILE = 'SKILL.MD';
562
580
  let _contentCodeEl = null;
563
581
  let _contentPluginId = null;
582
+ let _contentPluginDir = null;
564
583
 
565
584
  function highlightSource(text, fileName) {
566
585
  const ext = (fileName || '').split('.').pop().toLowerCase();
@@ -588,31 +607,94 @@ function getContentCodeEl() {
588
607
  return _contentCodeEl;
589
608
  }
590
609
 
591
- async function openInEditor() {
592
- if (!_contentPluginId) return;
593
- const relativePath = document.getElementById('contentViewerPath').textContent || '';
610
+ async function postAndFlash(endpoint, data, btn) {
594
611
  try {
595
- await fetch('/api/open-in-editor', {
612
+ await fetch(endpoint, {
596
613
  method: 'POST',
597
614
  headers: { 'Content-Type': 'application/json' },
598
- body: JSON.stringify({ pluginId: _contentPluginId, relativePath }),
615
+ body: JSON.stringify(data),
599
616
  });
617
+ if (btn) flashButton(btn);
600
618
  } catch {}
601
619
  }
602
620
 
603
- async function openFolderInEditor({ pluginId, marketplaceName } = {}) {
621
+ async function openInEditor(event) {
622
+ if (!_contentPluginId) return;
623
+ const relativePath = document.getElementById('contentViewerPath').textContent || '';
624
+ await postAndFlash('/api/open-in-editor', { pluginId: _contentPluginId, relativePath }, event?.currentTarget);
625
+ }
626
+
627
+ const CHECKMARK_SVG =
628
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>';
629
+
630
+ function flashButton(btn) {
631
+ if (btn._flashTimeout) clearTimeout(btn._flashTimeout);
632
+ if (!btn._flashOrig) btn._flashOrig = btn.innerHTML;
633
+ btn.innerHTML = CHECKMARK_SVG;
634
+ btn.classList.add('copy-success');
635
+ btn._flashTimeout = setTimeout(() => {
636
+ btn.innerHTML = btn._flashOrig;
637
+ btn.classList.remove('copy-success');
638
+ btn._flashOrig = null;
639
+ btn._flashTimeout = null;
640
+ }, 1000);
641
+ }
642
+
643
+ async function copyToClipboard(text, btn) {
644
+ try {
645
+ await navigator.clipboard.writeText(text);
646
+ } catch {
647
+ const ta = document.createElement('textarea');
648
+ ta.value = text;
649
+ document.body.appendChild(ta);
650
+ ta.select();
651
+ document.execCommand('copy');
652
+ document.body.removeChild(ta);
653
+ }
654
+ if (btn) flashButton(btn);
655
+ }
656
+
657
+ async function copyContentPath(event) {
658
+ if (!_contentPluginDir) return;
659
+ const relativePath = document.getElementById('contentViewerPath').textContent || '';
660
+ const full = relativePath ? `${_contentPluginDir}/${relativePath}` : _contentPluginDir;
661
+ await copyToClipboard(full, event?.currentTarget);
662
+ }
663
+
664
+ async function copyPluginPath(pluginDir, event) {
665
+ if (pluginDir) await copyToClipboard(pluginDir, event?.currentTarget);
666
+ }
667
+
668
+ async function openFolderInEditor({ pluginId, marketplaceName, event } = {}) {
604
669
  if (!pluginId && !marketplaceName) return;
670
+ await postAndFlash('/api/open-folder-in-editor', { pluginId, marketplaceName }, event?.currentTarget);
671
+ }
672
+
673
+ async function openReadmeModal(title, fetchUrl) {
674
+ _contentPluginId = null;
675
+ _contentPluginDir = null;
676
+ document.getElementById('contentModalTitle').textContent = `${title} \u2014 README`;
677
+ const tree = document.getElementById('contentTree');
678
+ const codeEl = getContentCodeEl();
679
+ const pathEl = document.getElementById('contentViewerPath');
680
+ tree.innerHTML = '';
681
+ pathEl.textContent = 'README.md';
682
+ codeEl.innerHTML = '<span style="color:var(--text-dim)">Loading...</span>';
683
+ document.getElementById('contentModal').classList.add('open');
605
684
  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 {}
685
+ const res = await fetch(fetchUrl);
686
+ if (!res.ok) throw new Error('Not found');
687
+ const data = await res.json();
688
+ codeEl.innerHTML = highlightSource(data.content || '', data.name || 'README.md');
689
+ } catch {
690
+ codeEl.innerHTML = '<span style="color:var(--error)">Failed to load README</span>';
691
+ }
612
692
  }
613
693
 
614
694
  async function openContentModal(pluginId, initialPath, componentType) {
615
695
  _contentPluginId = pluginId;
696
+ const comps = await fetchComponents(pluginId);
697
+ _contentPluginDir = comps?._pluginDir || null;
616
698
  const plugin = findPlugin(pluginId);
617
699
  const label = COMP_LABELS[componentType] || componentType;
618
700
  document.getElementById('contentModalTitle').textContent = `${plugin?.name || pluginId} \u2014 ${label}`;
@@ -732,7 +814,7 @@ function showMarketplaceDetail(name) {
732
814
  <div class="detail-header">
733
815
  <h3>${ICONS.marketplace} ${esc(m.name)} ${m.version ? `<span class="version">v${esc(m.version)}</span>` : ''}</h3>
734
816
  <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>` : ''}
817
+ ${m.installLocation ? `<button class="modal-action-btn" title="${esc(m.installLocation)}" onclick="copyPluginPath('${escJs(m.installLocation)}', event)">${ICONS.copyPath}</button><button class="modal-action-btn" title="Open in VS Code" onclick="openFolderInEditor({marketplaceName:'${esc(m.name)}',event})">${ICONS.openEditor}</button>` : ''}
736
818
  <button class="detail-close" onclick="closeDetail()">\u2715</button>
737
819
  </div>
738
820
  </div>
@@ -752,6 +834,7 @@ function showMarketplaceDetail(name) {
752
834
  <span class="meta-value">${installed} installed / ${total} total</span>
753
835
  </div>
754
836
  </div>
837
+ ${m.readmeFile ? `<div class="detail-section"><h4>Documentation</h4><div class="readme-comp-item" onclick="openReadmeModal('${esc(m.name)}', '/api/marketplaces/${encodeURIComponent(m.name)}/readme')">${ICONS.readme} ${esc(m.readmeFile)}</div></div>` : ''}
755
838
  <div class="detail-section">
756
839
  <h4>Actions</h4>
757
840
  <div class="mkt-actions">
@@ -968,6 +1051,11 @@ function esc(str) {
968
1051
  .replace(/'/g, '&#39;');
969
1052
  }
970
1053
 
1054
+ function escJs(str) {
1055
+ if (!str) return '';
1056
+ return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1057
+ }
1058
+
971
1059
  function shortenPath(p) {
972
1060
  if (!p) return '';
973
1061
  const home = '~';
package/public/index.html CHANGED
@@ -158,7 +158,8 @@
158
158
  <div class="modal-header">
159
159
  <h3 id="contentModalTitle">File Preview</h3>
160
160
  <div class="modal-header-actions">
161
- <button class="modal-action-btn" id="contentOpenEditor" title="Open in VS Code" onclick="openInEditor()"></button>
161
+ <button class="modal-action-btn" id="contentCopyPath" title="Copy path" onclick="copyContentPath(event)"></button>
162
+ <button class="modal-action-btn" id="contentOpenEditor" title="Open in VS Code" onclick="openInEditor(event)"></button>
162
163
  <button class="modal-close" onclick="closeModal('contentModal')">&#10005;</button>
163
164
  </div>
164
165
  </div>
package/public/style.css CHANGED
@@ -934,6 +934,31 @@ body.light .scope-toggle.local {
934
934
  background: none;
935
935
  color: var(--text-tertiary);
936
936
  }
937
+ .readme-comp-item {
938
+ display: flex;
939
+ align-items: center;
940
+ gap: 8px;
941
+ padding: 6px 10px;
942
+ font-size: 12px;
943
+ color: var(--accent);
944
+ cursor: pointer;
945
+ border: 1px dashed var(--border);
946
+ border-radius: 6px;
947
+ background: var(--bg-deep);
948
+ transition:
949
+ background 0.12s,
950
+ border-color 0.12s;
951
+ margin-bottom: 8px;
952
+ }
953
+ .readme-comp-item:hover {
954
+ background: var(--bg-hover);
955
+ border-color: var(--accent);
956
+ }
957
+ .readme-comp-item svg {
958
+ width: 14px;
959
+ height: 14px;
960
+ opacity: 0.7;
961
+ }
937
962
 
938
963
  /* === TOAST === */
939
964
 
@@ -1017,6 +1042,21 @@ body.light .scope-toggle.local {
1017
1042
  color: var(--accent);
1018
1043
  background: var(--hover);
1019
1044
  }
1045
+ .modal-action-btn.copy-success {
1046
+ color: var(--success, #22c55e);
1047
+ animation: copy-flash 0.3s ease;
1048
+ }
1049
+ @keyframes copy-flash {
1050
+ 0% {
1051
+ transform: scale(1);
1052
+ }
1053
+ 50% {
1054
+ transform: scale(1.3);
1055
+ }
1056
+ 100% {
1057
+ transform: scale(1);
1058
+ }
1059
+ }
1020
1060
  .modal-close {
1021
1061
  background: none;
1022
1062
  border: none;
package/server.js CHANGED
@@ -37,6 +37,12 @@ function readJsonSafe(filePath) {
37
37
  } catch { return null; }
38
38
  }
39
39
 
40
+ function findReadmeFile(dirPath) {
41
+ try {
42
+ return fs.readdirSync(dirPath).find(f => f.toLowerCase() === 'readme.md') || null;
43
+ } catch { return null; }
44
+ }
45
+
40
46
  function readJsonKey(filePath, key) {
41
47
  const data = readJsonSafe(filePath);
42
48
  return data ? (data[key] || {}) : {};
@@ -168,6 +174,8 @@ function loadMarketplaces() {
168
174
  marketplace.version = mData.version || null;
169
175
  marketplace.owner = mData.owner || null;
170
176
  marketplace.description = mData.description || null;
177
+ const mktReadme = findReadmeFile(installLocation);
178
+ if (mktReadme) marketplace.readmeFile = mktReadme;
171
179
 
172
180
  for (const pd of (mData.plugins || [])) {
173
181
  if (!pd.name) continue;
@@ -370,6 +378,9 @@ function countComponents(pluginDir, meta = {}) {
370
378
  }
371
379
  }
372
380
 
381
+ const readmeFile = findReadmeFile(pluginDir);
382
+ if (readmeFile) result._readmePath = readmeFile;
383
+
373
384
  result._configFiles = configFiles;
374
385
  return result;
375
386
  }
@@ -529,6 +540,18 @@ app.get('/api/marketplaces', (req, res) => {
529
540
  }
530
541
  });
531
542
 
543
+ app.get('/api/marketplaces/:name/readme', (req, res) => {
544
+ const mktData = getCachedMarketplaces();
545
+ const m = mktData.find(m => m.name === req.params.name);
546
+ if (!m?.readmeFile) return res.status(404).json({ error: 'No README found' });
547
+ try {
548
+ const content = fs.readFileSync(path.join(m.installLocation, m.readmeFile), 'utf-8');
549
+ res.json({ type: 'file', content, name: m.readmeFile });
550
+ } catch {
551
+ res.status(404).json({ error: 'Failed to read README' });
552
+ }
553
+ });
554
+
532
555
  app.get('/api/plugins/:pluginId/components', (req, res) => {
533
556
  res.setHeader('Cache-Control', 'no-store');
534
557
  const pluginId = decodeURIComponent(req.params.pluginId);