@shnitzel/plugscout 0.3.18 → 0.3.19

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.
@@ -41,7 +41,7 @@ function filterByKinds(items, kinds) {
41
41
  function renderHtml(rows, allItems, stats, policy) {
42
42
  const kindCounts = countByKind(allItems);
43
43
  const riskScale = escapeHtml(formatRiskScale(policy));
44
- const cardsJson = rows.map((entry) => renderDetailCard(entry, policy)).join('\n');
44
+ const cardsJson = rows.map((entry) => renderDetailCard(entry)).join('\n');
45
45
  return `<!doctype html>
46
46
  <html lang="en">
47
47
  <head>
@@ -52,95 +52,138 @@ function renderHtml(rows, allItems, stats, policy) {
52
52
  :root {
53
53
  color-scheme: dark;
54
54
  --bg: #050916;
55
- --panel: #0e1628;
56
- --line: #22314d;
57
- --text: #e5edf8;
58
- --muted: #a8b6cc;
59
- --ok: #22c55e;
55
+ --panel: #0d1626;
56
+ --panel2: #111e32;
57
+ --line: #1e3050;
58
+ --text: #e8f0fb;
59
+ --muted: #9ab0cc;
60
+ --ok: #34d058;
60
61
  --warn: #f59e0b;
61
- --bad: #ef4444;
62
+ --bad: #f06060;
62
63
  --accent: #60a5fa;
64
+ --accent2: #818cf8;
65
+ --blocked-border: #7f1d1d;
63
66
  }
64
67
  * { box-sizing: border-box; }
68
+ .sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
65
69
  body {
66
70
  margin: 0;
67
- font: 15px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
71
+ font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
68
72
  color: var(--text);
69
- background: radial-gradient(circle at top, #111b33 0%, var(--bg) 45%);
70
- padding: 28px;
73
+ background: radial-gradient(ellipse at 50% 0%, #0d1f40 0%, var(--bg) 55%);
74
+ padding: 28px 24px;
71
75
  }
72
- .wrap { max-width: 1460px; margin: 0 auto; }
73
- h1 { margin: 0 0 8px; font-size: 34px; }
74
- .sub { color: var(--muted); margin: 0 0 22px; }
76
+ a:focus-visible, button:focus-visible, select:focus-visible, input:focus-visible {
77
+ outline: 2px solid var(--accent);
78
+ outline-offset: 2px;
79
+ }
80
+ .wrap { max-width: 1500px; margin: 0 auto; }
81
+ h1 { margin: 0 0 6px; font-size: 30px; letter-spacing: -0.3px; }
82
+ .sub { color: var(--muted); margin: 0 0 20px; font-size: 14px; line-height: 1.6; }
83
+ /* Stat cards */
75
84
  .stat-cards {
76
85
  display: grid;
77
- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
78
- gap: 12px;
79
- margin-bottom: 18px;
86
+ grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
87
+ gap: 10px;
88
+ margin-bottom: 16px;
80
89
  }
81
90
  .stat-card {
82
91
  background: var(--panel);
83
92
  border: 1px solid var(--line);
84
- border-radius: 12px;
85
- padding: 14px;
93
+ border-radius: 10px;
94
+ padding: 12px 14px;
95
+ cursor: default;
96
+ transition: border-color 0.12s, background 0.12s;
86
97
  }
87
- .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
88
- .v { font-size: 26px; font-weight: 700; margin-top: 4px; }
98
+ .stat-card.clickable { cursor: pointer; }
99
+ .stat-card.clickable:hover { border-color: var(--accent); background: var(--panel2); }
100
+ .stat-card.clickable:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
101
+ .stat-card.active { border-color: var(--accent); background: var(--panel2); }
102
+ .k { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; }
103
+ .v { font-size: 24px; font-weight: 700; margin-top: 3px; color: var(--text); }
104
+ /* Legend */
89
105
  .legend {
90
- margin-bottom: 18px;
106
+ margin-bottom: 16px;
91
107
  background: var(--panel);
92
108
  border: 1px solid var(--line);
93
- border-radius: 12px;
94
- padding: 14px 16px;
109
+ border-radius: 10px;
110
+ padding: 12px 16px;
95
111
  font-size: 13px;
96
112
  color: var(--muted);
113
+ line-height: 1.7;
97
114
  }
115
+ .legend strong { color: var(--text); }
98
116
  /* Filter bar */
99
117
  .filter-bar {
100
118
  background: var(--panel);
101
119
  border: 1px solid var(--line);
102
- border-radius: 12px;
103
- padding: 14px 16px;
104
- margin-bottom: 18px;
120
+ border-radius: 10px;
121
+ padding: 12px 16px;
122
+ margin-bottom: 16px;
105
123
  display: flex;
106
124
  flex-wrap: wrap;
107
- gap: 10px;
125
+ gap: 8px;
108
126
  align-items: center;
109
127
  }
110
128
  .filter-bar input, .filter-bar select {
111
- background: #060f1d;
112
- border: 1px solid #2a3d5c;
113
- border-radius: 8px;
129
+ background: #060e1c;
130
+ border: 1px solid #253a58;
131
+ border-radius: 7px;
114
132
  color: var(--text);
115
133
  padding: 7px 10px;
116
134
  font-size: 13px;
117
135
  outline: none;
136
+ transition: border-color 0.12s;
118
137
  }
119
- .filter-bar input { flex: 1; min-width: 160px; }
138
+ .filter-bar input { flex: 1; min-width: 180px; }
120
139
  .filter-bar input::placeholder { color: var(--muted); }
121
140
  .filter-bar select:focus, .filter-bar input:focus { border-color: var(--accent); }
141
+ .btn-clear {
142
+ background: none;
143
+ border: 1px solid #253a58;
144
+ border-radius: 7px;
145
+ color: var(--muted);
146
+ padding: 7px 12px;
147
+ font-size: 13px;
148
+ cursor: pointer;
149
+ transition: border-color 0.12s, color 0.12s;
150
+ }
151
+ .btn-clear:hover { border-color: var(--accent); color: var(--text); }
122
152
  #result-count { color: var(--muted); font-size: 13px; margin-left: auto; white-space: nowrap; }
123
153
  /* Grid */
124
154
  .detail-grid {
125
155
  display: grid;
126
- grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
127
- gap: 12px;
156
+ grid-template-columns: repeat(auto-fill, minmax(370px, 1fr));
157
+ gap: 10px;
128
158
  }
129
159
  /* Cards */
130
160
  .detail-card {
131
- border: 1px solid #243654;
161
+ border: 1px solid #1e3050;
132
162
  border-radius: 10px;
133
- background: #081121;
163
+ background: #080f1e;
134
164
  overflow: hidden;
135
- transition: border-color 0.15s;
165
+ transition: border-color 0.12s;
136
166
  }
137
167
  .detail-card:hover { border-color: #3a5a8a; }
168
+ .detail-card.is-blocked {
169
+ border-left: 3px solid var(--blocked-border);
170
+ }
171
+ .detail-card.is-blocked:hover { border-color: #a33; border-left-color: var(--bad); }
172
+ /* Card header is a button for accessibility */
138
173
  .card-header {
174
+ display: block;
175
+ width: 100%;
176
+ text-align: left;
177
+ background: none;
178
+ border: none;
139
179
  padding: 14px;
140
180
  cursor: pointer;
141
- user-select: none;
181
+ color: var(--text);
182
+ font-family: inherit;
183
+ font-size: inherit;
184
+ line-height: inherit;
142
185
  }
143
- .card-header:hover { background: rgba(255,255,255,0.03); }
186
+ .card-header:hover { background: rgba(255,255,255,0.025); }
144
187
  .card-title-row {
145
188
  display: flex;
146
189
  align-items: flex-start;
@@ -148,32 +191,32 @@ function renderHtml(rows, allItems, stats, policy) {
148
191
  gap: 8px;
149
192
  margin-bottom: 4px;
150
193
  }
151
- .title { margin: 0; font-size: 16px; line-height: 1.3; }
194
+ .title { margin: 0; font-size: 15px; font-weight: 600; line-height: 1.3; }
152
195
  .meta { color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; margin-bottom: 8px; }
153
- .pill-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
196
+ .pill-row { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 7px; }
154
197
  .pill {
155
198
  display: inline-block;
156
199
  border-radius: 999px;
157
- border: 1px solid #314664;
200
+ border: 1px solid #2d4868;
158
201
  padding: 2px 9px;
159
202
  font-size: 12px;
160
203
  color: var(--text);
161
204
  white-space: nowrap;
162
205
  }
163
- .ok { color: var(--ok); border-color: #166534; }
164
- .warn { color: var(--warn); border-color: #854d0e; }
206
+ .ok { color: var(--ok); border-color: #14532d; }
207
+ .warn { color: var(--warn); border-color: #78350f; }
165
208
  .bad { color: var(--bad); border-color: #7f1d1d; }
166
209
  .chips { display: flex; flex-wrap: wrap; gap: 5px; }
167
210
  .chip {
168
- border: 1px solid #2f476b;
211
+ border: 1px solid #2a4060;
169
212
  border-radius: 999px;
170
213
  padding: 2px 8px;
171
- color: #c9dbf5;
214
+ color: #bdd2f0;
172
215
  font-size: 12px;
173
216
  }
174
217
  .expand-hint {
175
218
  font-size: 11px;
176
- color: #4a6a94;
219
+ color: #7a9ec4;
177
220
  margin-top: 8px;
178
221
  text-align: right;
179
222
  }
@@ -182,24 +225,67 @@ function renderHtml(rows, allItems, stats, policy) {
182
225
  .card-body {
183
226
  display: none;
184
227
  padding: 0 14px 14px;
185
- border-top: 1px solid #1a2c44;
228
+ border-top: 1px solid #172538;
186
229
  }
187
230
  .detail-card.expanded .card-body { display: block; }
188
- .line { margin-top: 9px; color: var(--text); font-size: 14px; }
231
+ .line { margin-top: 9px; color: var(--text); font-size: 13.5px; line-height: 1.5; }
189
232
  .line .label { color: var(--muted); }
190
233
  .link { color: #93c5fd; text-decoration: none; }
191
234
  .link:hover { text-decoration: underline; }
192
- .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; }
193
- pre {
194
- margin: 10px 0 0;
195
- padding: 9px 10px;
196
- border: 1px solid #243654;
235
+ /* Install block */
236
+ .install-block {
237
+ margin-top: 10px;
238
+ border: 1px solid #1e3050;
197
239
  border-radius: 8px;
198
- background: #060f1d;
240
+ overflow: hidden;
241
+ }
242
+ .install-header {
243
+ display: flex;
244
+ align-items: center;
245
+ justify-content: space-between;
246
+ padding: 6px 10px;
247
+ background: #0a1626;
248
+ border-bottom: 1px solid #1e3050;
249
+ }
250
+ .install-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
251
+ .copy-btn {
252
+ background: #1a3050;
253
+ border: 1px solid #2d4868;
254
+ border-radius: 5px;
255
+ color: var(--accent);
256
+ padding: 3px 10px;
257
+ font-size: 12px;
258
+ cursor: pointer;
259
+ transition: background 0.12s;
260
+ }
261
+ .copy-btn:hover { background: #243d60; }
262
+ .copy-btn.copied { color: var(--ok); border-color: #14532d; }
263
+ pre.install-pre {
264
+ margin: 0;
265
+ padding: 9px 12px;
266
+ background: #050c1a;
199
267
  overflow-x: auto;
200
- color: #dbeafe;
268
+ color: #c9deff;
201
269
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
202
270
  font-size: 13px;
271
+ white-space: pre-wrap;
272
+ word-break: break-all;
273
+ }
274
+ .plugscout-install {
275
+ margin-top: 8px;
276
+ padding: 8px 12px;
277
+ background: #060e1c;
278
+ border: 1px solid #1e3050;
279
+ border-radius: 8px;
280
+ display: flex;
281
+ align-items: center;
282
+ justify-content: space-between;
283
+ gap: 8px;
284
+ }
285
+ .plugscout-install code {
286
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
287
+ font-size: 13px;
288
+ color: var(--accent);
203
289
  }
204
290
  .hidden { display: none !important; }
205
291
  </style>
@@ -207,31 +293,38 @@ function renderHtml(rows, allItems, stats, policy) {
207
293
  <body>
208
294
  <div class="wrap">
209
295
  <h1>PlugScout Web Report</h1>
210
- <p class="sub">Claude plugins · Claude connectors · Copilot extensions · Cursor extensions · Gemini extensions · Skills · MCP servers</p>
211
- ${stats.shownItems < stats.totalItems ? `<p class="sub" style="color:var(--muted)">Showing ${stats.shownItems.toLocaleString()} of ${stats.totalItems.toLocaleString()} catalog items · counts below reflect full catalog</p>` : ''}
296
+ <p class="sub">
297
+ Claude plugins &nbsp;·&nbsp; Claude connectors &nbsp;·&nbsp; Copilot extensions &nbsp;·&nbsp;
298
+ Cursor extensions &nbsp;·&nbsp; Gemini extensions &nbsp;·&nbsp; Skills &nbsp;·&nbsp; MCP servers
299
+ </p>
300
+ ${stats.shownItems < stats.totalItems ? `<p class="sub">Showing ${stats.shownItems.toLocaleString()} of ${stats.totalItems.toLocaleString()} catalog items &nbsp;·&nbsp; stat card counts reflect full catalog &nbsp;·&nbsp; rerun with <code>--limit ${stats.totalItems}</code> to include all</p>` : ''}
212
301
 
213
302
  <div class="stat-cards">
214
- <div class="stat-card"><div class="k">Total</div><div class="v">${stats.totalItems.toLocaleString()}</div></div>
215
- <div class="stat-card"><div class="k">Plugins</div><div class="v">${kindCounts['claude-plugin']}</div></div>
216
- <div class="stat-card"><div class="k">Connectors</div><div class="v">${kindCounts['claude-connector']}</div></div>
217
- <div class="stat-card"><div class="k">Copilot Ext</div><div class="v">${kindCounts['copilot-extension']}</div></div>
218
- <div class="stat-card"><div class="k">Cursor Ext</div><div class="v">${kindCounts['cursor-extension']}</div></div>
219
- <div class="stat-card"><div class="k">Gemini Ext</div><div class="v">${kindCounts['gemini-extension']}</div></div>
220
- <div class="stat-card"><div class="k">Skills</div><div class="v">${kindCounts.skill}</div></div>
221
- <div class="stat-card"><div class="k">MCP Servers</div><div class="v">${kindCounts.mcp}</div></div>
303
+ <div class="stat-card"><div class="k">Total catalog</div><div class="v">${stats.totalItems.toLocaleString()}</div></div>
304
+ <div class="stat-card clickable" role="button" tabindex="0" onclick="setKindFilter('claude-plugin')" onkeydown="if(event.key==='Enter'||event.key===' ')setKindFilter('claude-plugin')"><div class="k">Claude Plugins</div><div class="v">${kindCounts['claude-plugin'].toLocaleString()}</div></div>
305
+ <div class="stat-card clickable" role="button" tabindex="0" onclick="setKindFilter('claude-connector')" onkeydown="if(event.key==='Enter'||event.key===' ')setKindFilter('claude-connector')"><div class="k">Claude Connectors</div><div class="v">${kindCounts['claude-connector'].toLocaleString()}</div></div>
306
+ <div class="stat-card clickable" role="button" tabindex="0" onclick="setKindFilter('copilot-extension')" onkeydown="if(event.key==='Enter'||event.key===' ')setKindFilter('copilot-extension')"><div class="k">Copilot Extensions</div><div class="v">${kindCounts['copilot-extension'].toLocaleString()}</div></div>
307
+ <div class="stat-card clickable" role="button" tabindex="0" onclick="setKindFilter('cursor-extension')" onkeydown="if(event.key==='Enter'||event.key===' ')setKindFilter('cursor-extension')"><div class="k">Cursor Extensions</div><div class="v">${kindCounts['cursor-extension'].toLocaleString()}</div></div>
308
+ <div class="stat-card clickable" role="button" tabindex="0" onclick="setKindFilter('gemini-extension')" onkeydown="if(event.key==='Enter'||event.key===' ')setKindFilter('gemini-extension')"><div class="k">Gemini Extensions</div><div class="v">${kindCounts['gemini-extension'].toLocaleString()}</div></div>
309
+ <div class="stat-card clickable" role="button" tabindex="0" onclick="setKindFilter('skill')" onkeydown="if(event.key==='Enter'||event.key===' ')setKindFilter('skill')"><div class="k">Skills</div><div class="v">${kindCounts.skill.toLocaleString()}</div></div>
310
+ <div class="stat-card clickable" role="button" tabindex="0" onclick="setKindFilter('mcp')" onkeydown="if(event.key==='Enter'||event.key===' ')setKindFilter('mcp')"><div class="k">MCP Servers</div><div class="v">${kindCounts.mcp.toLocaleString()}</div></div>
222
311
  <div class="stat-card"><div class="k">Whitelist / Quarantine</div><div class="v">${stats.whitelist} / ${stats.quarantined}</div></div>
223
312
  </div>
224
313
 
225
- <div class="legend">
226
- <strong>Scores:</strong>
227
- Trust 0–100 (higher = more trustworthy) &nbsp;·&nbsp;
228
- Risk 0–100 (lower = safer) &nbsp;·&nbsp;
229
- ${riskScale} &nbsp;·&nbsp;
230
- <span style="color:var(--bad)">blocked</span> = high/critical risk or quarantined
231
- </div>
314
+ <details class="legend">
315
+ <summary><strong>Score &amp; risk legend</strong> <span style="font-weight:normal;color:var(--muted)">(click to expand)</span></summary>
316
+ <div style="margin-top:8px">
317
+ <strong>Trust</strong> 0–100 higher = more trustworthy (provenance, maintenance, adoption)<br>
318
+ <strong>Risk</strong> 0–100 — lower = safer &nbsp;·&nbsp; ${riskScale}<br>
319
+ <span style="color:var(--bad)">■</span> <strong>Blocked</strong> = high/critical risk or quarantined &nbsp;·&nbsp; left red border on card<br>
320
+ <span style="color:var(--ok)">■</span> <strong>Allowed</strong> = passes policy &nbsp;·&nbsp; <span style="color:var(--warn)">■</span> <strong>Approved</strong> = manually whitelisted
321
+ </div>
322
+ </details>
232
323
 
233
- <div class="filter-bar">
234
- <input id="search" type="text" placeholder="Search by name or ID…" oninput="applyFilters()" />
324
+ <div class="filter-bar" role="search">
325
+ <label class="sr-only" for="search">Search catalog</label>
326
+ <input id="search" type="search" placeholder="Search by name, ID, or capability…" oninput="applyFilters()" autocomplete="off" />
327
+ <label class="sr-only" for="kind-filter">Filter by kind</label>
235
328
  <select id="kind-filter" onchange="applyFilters()">
236
329
  <option value="">All kinds</option>
237
330
  <option value="claude-plugin">Claude plugin</option>
@@ -242,36 +335,63 @@ function renderHtml(rows, allItems, stats, policy) {
242
335
  <option value="skill">Skill</option>
243
336
  <option value="mcp">MCP server</option>
244
337
  </select>
338
+ <label class="sr-only" for="risk-filter">Filter by risk tier</label>
245
339
  <select id="risk-filter" onchange="applyFilters()">
246
340
  <option value="">All risk tiers</option>
247
- <option value="low">Low</option>
248
- <option value="medium">Medium</option>
249
- <option value="high">High</option>
250
- <option value="critical">Critical</option>
341
+ <option value="low">Low risk</option>
342
+ <option value="medium">Medium risk</option>
343
+ <option value="high">High risk</option>
344
+ <option value="critical">Critical risk</option>
251
345
  </select>
346
+ <label class="sr-only" for="status-filter">Filter by status</label>
252
347
  <select id="status-filter" onchange="applyFilters()">
253
348
  <option value="">All statuses</option>
254
349
  <option value="allowed">Allowed</option>
255
- <option value="approved">Approved</option>
350
+ <option value="approved">Whitelisted</option>
256
351
  <option value="blocked">Blocked</option>
257
352
  </select>
353
+ <label class="sr-only" for="sort-by">Sort by</label>
258
354
  <select id="sort-by" onchange="applyFilters()">
259
- <option value="name">Sort: Name A–Z</option>
260
- <option value="trust-desc">Sort: Trust ↓</option>
261
- <option value="risk-asc">Sort: Risk ↑ (safest first)</option>
262
- <option value="risk-desc">Sort: Risk ↓ (riskiest first)</option>
355
+ <option value="name">Name A–Z</option>
356
+ <option value="trust-desc">Trust ↓ (most trusted first)</option>
357
+ <option value="risk-asc">Risk ↑ (safest first)</option>
358
+ <option value="risk-desc">Risk ↓ (riskiest first)</option>
263
359
  </select>
264
- <span id="result-count"></span>
360
+ <button class="btn-clear" onclick="clearFilters()" type="button">Clear filters</button>
361
+ <span id="result-count" aria-live="polite" aria-atomic="true"></span>
265
362
  </div>
266
363
 
267
- <div class="detail-grid" id="card-grid">
364
+ <div class="detail-grid" id="card-grid" role="list">
268
365
  ${cardsJson}
269
366
  </div>
270
367
  </div>
271
368
 
272
369
  <script>
273
- function toggleCard(header) {
274
- header.closest('.detail-card').classList.toggle('expanded');
370
+ function toggleCard(btn) {
371
+ const card = btn.closest('.detail-card');
372
+ const expanded = card.classList.toggle('expanded');
373
+ btn.setAttribute('aria-expanded', String(expanded));
374
+ }
375
+
376
+ function setKindFilter(kind) {
377
+ document.getElementById('kind-filter').value = kind;
378
+ // Toggle: clicking active kind card resets to all
379
+ const cards = document.querySelectorAll('.stat-card.clickable');
380
+ cards.forEach(c => {
381
+ const onclick = c.getAttribute('onclick') || '';
382
+ c.classList.toggle('active', onclick.includes("'" + kind + "'"));
383
+ });
384
+ applyFilters();
385
+ }
386
+
387
+ function clearFilters() {
388
+ document.getElementById('search').value = '';
389
+ document.getElementById('kind-filter').value = '';
390
+ document.getElementById('risk-filter').value = '';
391
+ document.getElementById('status-filter').value = '';
392
+ document.getElementById('sort-by').value = 'name';
393
+ document.querySelectorAll('.stat-card.clickable').forEach(c => c.classList.remove('active'));
394
+ applyFilters();
275
395
  }
276
396
 
277
397
  function applyFilters() {
@@ -295,11 +415,10 @@ function renderHtml(rows, allItems, stats, policy) {
295
415
  if (show) visible++;
296
416
  });
297
417
 
298
- document.getElementById('result-count').textContent = 'Showing ' + visible + ' of ' + cards.length;
418
+ document.getElementById('result-count').textContent = visible.toLocaleString() + ' of ' + cards.length.toLocaleString() + ' shown';
299
419
 
300
420
  const shown = cards.filter(c => !c.classList.contains('hidden'));
301
421
  shown.sort((a, b) => {
302
- if (sort === 'name') return a.dataset.name.localeCompare(b.dataset.name);
303
422
  if (sort === 'trust-desc') return Number(b.dataset.trust) - Number(a.dataset.trust);
304
423
  if (sort === 'risk-asc') return Number(a.dataset.riskscore) - Number(b.dataset.riskscore);
305
424
  if (sort === 'risk-desc') return Number(b.dataset.riskscore) - Number(a.dataset.riskscore);
@@ -308,17 +427,35 @@ function renderHtml(rows, allItems, stats, policy) {
308
427
  shown.forEach(card => grid.appendChild(card));
309
428
  }
310
429
 
311
- // Initial count
430
+ function copyText(text, btn) {
431
+ navigator.clipboard.writeText(text).then(() => {
432
+ const orig = btn.textContent;
433
+ btn.textContent = 'Copied!';
434
+ btn.classList.add('copied');
435
+ setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); }, 1800);
436
+ }).catch(() => {
437
+ // Fallback: select text in pre
438
+ const pre = btn.closest('.install-block')?.querySelector('pre') ||
439
+ btn.closest('.plugscout-install')?.querySelector('code');
440
+ if (pre) {
441
+ const sel = window.getSelection();
442
+ const range = document.createRange();
443
+ range.selectNodeContents(pre);
444
+ sel.removeAllRanges();
445
+ sel.addRange(range);
446
+ }
447
+ });
448
+ }
449
+
450
+ // Initial render
312
451
  applyFilters();
313
452
  </script>
314
453
  </body>
315
454
  </html>`;
316
455
  }
317
- function renderDetailCard(entry, policy) {
456
+ function renderDetailCard(entry) {
318
457
  const metadata = asMetadata(entry.item.metadata);
319
458
  const trustScore = computeTrustScore(entry.item);
320
- const confidence = stringOr(metadata.sourceConfidence, 'official');
321
- const catalogType = stringOr(metadata.catalogType, 'standard');
322
459
  const sourceRepo = typeof metadata.sourceRepo === 'string' ? metadata.sourceRepo : '';
323
460
  const sourcePage = typeof metadata.sourcePage === 'string' ? metadata.sourcePage : '';
324
461
  const status = entry.blocked ? 'blocked' : entry.approved ? 'approved' : 'allowed';
@@ -332,7 +469,12 @@ function renderDetailCard(entry, policy) {
332
469
  : entry.assessment.riskTier === 'medium'
333
470
  ? 'warn'
334
471
  : 'bad';
472
+ const safeId = entry.item.id.replace(/[^a-zA-Z0-9-_]/g, '_');
473
+ const bodyId = `body-${safeId}`;
335
474
  const searchKey = escapeHtml(`${entry.item.id} ${entry.item.name} ${entry.item.capabilities.join(' ')}`.toLowerCase());
475
+ const plugscoutCmd = `plugscout install --id ${entry.item.id} --yes`;
476
+ const isManualUrl = entry.item.install.kind === 'manual' && typeof entry.item.install.url === 'string' && String(entry.item.install.url).startsWith('http');
477
+ const manualUrl = isManualUrl ? String(entry.item.install.url) : '';
336
478
  const previewChips = entry.item.capabilities
337
479
  .slice(0, 3)
338
480
  .map((cap) => `<span class="chip">${escapeHtml(cap)}</span>`)
@@ -340,7 +482,7 @@ function renderDetailCard(entry, policy) {
340
482
  const allChips = entry.item.capabilities.length > 0
341
483
  ? entry.item.capabilities.map((cap) => `<span class="chip">${escapeHtml(cap)}</span>`).join('')
342
484
  : '<span class="chip">no capability tags</span>';
343
- return `<article class="detail-card"
485
+ return `<article class="detail-card${entry.blocked ? ' is-blocked' : ''}" role="listitem"
344
486
  data-search="${searchKey}"
345
487
  data-kind="${escapeHtml(entry.item.kind)}"
346
488
  data-risk="${escapeHtml(entry.assessment.riskTier)}"
@@ -348,7 +490,7 @@ function renderDetailCard(entry, policy) {
348
490
  data-name="${escapeHtml(entry.item.name.toLowerCase())}"
349
491
  data-trust="${trustScore.toFixed(0)}"
350
492
  data-riskscore="${entry.assessment.riskScore.toFixed(0)}">
351
- <div class="card-header" onclick="toggleCard(this)">
493
+ <button class="card-header" onclick="toggleCard(this)" aria-expanded="false" aria-controls="${bodyId}" type="button">
352
494
  <div class="card-title-row">
353
495
  <h3 class="title">${escapeHtml(entry.item.name)}</h3>
354
496
  <span class="pill">${escapeHtml(entry.item.kind)}</span>
@@ -356,25 +498,39 @@ function renderDetailCard(entry, policy) {
356
498
  <div class="meta">${escapeHtml(entry.item.id)}</div>
357
499
  <div class="pill-row">
358
500
  <span class="pill">trust: ${trustScore.toFixed(0)}</span>
359
- <span class="pill ${riskClass}">risk: ${escapeHtml(entry.assessment.riskTier)} (${entry.assessment.riskScore.toFixed(0)})</span>
501
+ <span class="pill ${riskClass}">risk: ${escapeHtml(entry.assessment.riskTier)}&nbsp;(${entry.assessment.riskScore.toFixed(0)})</span>
360
502
  <span class="pill ${statusClass}">${escapeHtml(status)}</span>
361
503
  </div>
362
504
  ${previewChips ? `<div class="chips">${previewChips}</div>` : ''}
363
- <div class="expand-hint">▼ click for details</div>
364
- </div>
365
- <div class="card-body">
505
+ <div class="expand-hint">▼ details</div>
506
+ </button>
507
+ <div class="card-body" id="${bodyId}">
366
508
  <div class="line">${escapeHtml(entry.item.description)}</div>
367
- ${benefitSummary ? `<div class="line"><span class="label">What it does:</span> ${escapeHtml(benefitSummary)}</div>` : ''}
368
- ${bestFor.length > 0 ? `<div class="line"><span class="label">Best for:</span> ${escapeHtml(bestFor.join('; '))}</div>` : ''}
369
- ${tradeoffs.length > 0 ? `<div class="line"><span class="label">Tradeoffs:</span> ${escapeHtml(tradeoffs.join('; '))}</div>` : ''}
370
- <div class="line"><span class="label">Capabilities:</span></div>
371
- <div class="chips" style="margin-top:6px">${allChips}</div>
372
- <div class="line"><span class="label">Decision:</span> trust ${trustScore.toFixed(0)}/100 (${escapeHtml(describeTrustBand(trustScore))}), risk ${entry.assessment.riskScore.toFixed(0)}/100 (${escapeHtml(entry.assessment.riskTier)}; ${escapeHtml(describeRiskBand(entry.assessment.riskScore, policy))}), status ${escapeHtml(status)}.</div>
373
- <div class="line"><span class="label">Risk reasons:</span> ${escapeHtml(entry.assessment.reasons.join('; '))}</div>
374
- <div class="line"><span class="label">Provenance:</span> provider=${escapeHtml(entry.item.provider)} source=${escapeHtml(entry.item.source)} confidence=${escapeHtml(confidence)} catalog=${escapeHtml(catalogType)}</div>
375
- ${sourceRepo ? `<div class="line"><span class="label">Source repo:</span> <a class="link" href="${escapeHtml(sourceRepo)}" target="_blank" rel="noopener">${escapeHtml(sourceRepo)}</a></div>` : ''}
376
- ${sourcePage ? `<div class="line"><span class="label">Source page:</span> <a class="link" href="${escapeHtml(sourcePage)}" target="_blank" rel="noopener">${escapeHtml(sourcePage)}</a></div>` : ''}
377
- <pre class="mono">${escapeHtml(installHint)}</pre>
509
+ ${benefitSummary ? `<div class="line"><span class="label">What it does: </span>${escapeHtml(benefitSummary)}</div>` : ''}
510
+ ${bestFor.length > 0 ? `<div class="line"><span class="label">Best for: </span>${escapeHtml(bestFor.join(' · '))}</div>` : ''}
511
+ ${tradeoffs.length > 0 ? `<div class="line"><span class="label">Tradeoffs: </span>${escapeHtml(tradeoffs.join(' · '))}</div>` : ''}
512
+ ${entry.item.capabilities.length > 0 ? `<div class="line"><span class="label">Capabilities: </span></div><div class="chips" style="margin-top:6px">${allChips}</div>` : ''}
513
+ <div class="line">
514
+ <span class="label">Trust:</span> ${trustScore.toFixed(0)}/100 (${escapeHtml(describeTrustBand(trustScore))}) &nbsp;·&nbsp;
515
+ <span class="label">Risk:</span> ${entry.assessment.riskScore.toFixed(0)}/100 — ${escapeHtml(entry.assessment.riskTier)} &nbsp;·&nbsp;
516
+ <span class="label">Status:</span> ${escapeHtml(status)}
517
+ </div>
518
+ ${entry.assessment.reasons.length > 0 ? `<div class="line"><span class="label">Risk signals: </span>${escapeHtml(entry.assessment.reasons.join(' · '))}</div>` : ''}
519
+ <div class="line"><span class="label">Provider:</span> ${escapeHtml(entry.item.provider)} &nbsp;·&nbsp; <span class="label">Source:</span> ${escapeHtml(entry.item.source)}</div>
520
+ ${sourceRepo ? `<div class="line"><span class="label">Repo: </span><a class="link" href="${escapeHtml(sourceRepo)}" target="_blank" rel="noopener noreferrer">${escapeHtml(sourceRepo)}</a></div>` : ''}
521
+ ${sourcePage ? `<div class="line"><span class="label">Page: </span><a class="link" href="${escapeHtml(sourcePage)}" target="_blank" rel="noopener noreferrer">${escapeHtml(sourcePage)}</a></div>` : ''}
522
+ ${isManualUrl ? `<div class="line"><span class="label">Install page: </span><a class="link" href="${escapeHtml(manualUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(manualUrl)}</a></div>` : ''}
523
+ <div class="install-block">
524
+ <div class="install-header">
525
+ <span class="install-label">Install command</span>
526
+ <button class="copy-btn" type="button" onclick="copyText(${JSON.stringify(installHint)}, this)">Copy</button>
527
+ </div>
528
+ <pre class="install-pre">${escapeHtml(installHint)}</pre>
529
+ </div>
530
+ <div class="plugscout-install">
531
+ <code>${escapeHtml(plugscoutCmd)}</code>
532
+ <button class="copy-btn" type="button" onclick="copyText(${JSON.stringify(plugscoutCmd)}, this)">Copy</button>
533
+ </div>
378
534
  </div>
379
535
  </article>`;
380
536
  }
@@ -400,18 +556,6 @@ function describeTrustBand(score) {
400
556
  }
401
557
  return 'needs review';
402
558
  }
403
- function describeRiskBand(score, policy) {
404
- if (score <= policy.thresholds.lowMax) {
405
- return 'low-risk zone';
406
- }
407
- if (score <= policy.thresholds.mediumMax) {
408
- return 'medium-risk zone';
409
- }
410
- if (score <= policy.thresholds.highMax) {
411
- return 'high-risk zone';
412
- }
413
- return 'critical-risk zone';
414
- }
415
559
  function formatRiskScale(policy) {
416
560
  const low = policy.thresholds.lowMax;
417
561
  const medium = policy.thresholds.mediumMax;
@@ -424,12 +568,6 @@ function asMetadata(value) {
424
568
  }
425
569
  return value;
426
570
  }
427
- function stringOr(value, fallback) {
428
- if (typeof value === 'string' && value.trim().length > 0) {
429
- return value;
430
- }
431
- return fallback;
432
- }
433
571
  function escapeHtml(value) {
434
572
  return value
435
573
  .replaceAll('&', '&amp;')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shnitzel/plugscout",
3
- "version": "0.3.18",
3
+ "version": "0.3.19",
4
4
  "description": "Claude plugins + Claude connectors + Copilot extensions + Skills + MCP security intelligence framework",
5
5
  "private": false,
6
6
  "type": "module",