@shnitzel/plugscout 0.3.17 → 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.
@@ -11,7 +11,8 @@ export async function writeWebReport(options) {
11
11
  loadSecurityPolicy(),
12
12
  loadItemInsights()
13
13
  ]);
14
- const filtered = filterByKinds(items, options.kinds).slice(0, options.limit);
14
+ const allFiltered = filterByKinds(items, options.kinds);
15
+ const filtered = allFiltered.slice(0, options.limit);
15
16
  const quarantineIds = new Set(quarantine.map((entry) => entry.id));
16
17
  const rows = filtered.map((item) => {
17
18
  const assessment = buildAssessment(item, policy);
@@ -19,8 +20,9 @@ export async function writeWebReport(options) {
19
20
  const blocked = blockedByPolicy || quarantineIds.has(item.id);
20
21
  return { item, assessment, blocked, approved: whitelist.has(item.id), insight: insights.get(item.id) };
21
22
  });
22
- const html = renderHtml(rows, {
23
- totalItems: filtered.length,
23
+ const html = renderHtml(rows, allFiltered, {
24
+ totalItems: allFiltered.length,
25
+ shownItems: filtered.length,
24
26
  whitelist: whitelist.size,
25
27
  quarantined: quarantine.length
26
28
  }, policy);
@@ -36,10 +38,10 @@ function filterByKinds(items, kinds) {
36
38
  const set = new Set(kinds);
37
39
  return items.filter((item) => set.has(item.kind));
38
40
  }
39
- function renderHtml(rows, stats, policy) {
40
- const kindCounts = countByKind(rows.map((entry) => entry.item));
41
+ function renderHtml(rows, allItems, stats, policy) {
42
+ const kindCounts = countByKind(allItems);
41
43
  const riskScale = escapeHtml(formatRiskScale(policy));
42
- const cardsJson = rows.map((entry) => renderDetailCard(entry, policy)).join('\n');
44
+ const cardsJson = rows.map((entry) => renderDetailCard(entry)).join('\n');
43
45
  return `<!doctype html>
44
46
  <html lang="en">
45
47
  <head>
@@ -50,95 +52,138 @@ function renderHtml(rows, stats, policy) {
50
52
  :root {
51
53
  color-scheme: dark;
52
54
  --bg: #050916;
53
- --panel: #0e1628;
54
- --line: #22314d;
55
- --text: #e5edf8;
56
- --muted: #a8b6cc;
57
- --ok: #22c55e;
55
+ --panel: #0d1626;
56
+ --panel2: #111e32;
57
+ --line: #1e3050;
58
+ --text: #e8f0fb;
59
+ --muted: #9ab0cc;
60
+ --ok: #34d058;
58
61
  --warn: #f59e0b;
59
- --bad: #ef4444;
62
+ --bad: #f06060;
60
63
  --accent: #60a5fa;
64
+ --accent2: #818cf8;
65
+ --blocked-border: #7f1d1d;
61
66
  }
62
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; }
63
69
  body {
64
70
  margin: 0;
65
- 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;
66
72
  color: var(--text);
67
- background: radial-gradient(circle at top, #111b33 0%, var(--bg) 45%);
68
- padding: 28px;
73
+ background: radial-gradient(ellipse at 50% 0%, #0d1f40 0%, var(--bg) 55%);
74
+ padding: 28px 24px;
69
75
  }
70
- .wrap { max-width: 1460px; margin: 0 auto; }
71
- h1 { margin: 0 0 8px; font-size: 34px; }
72
- .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 */
73
84
  .stat-cards {
74
85
  display: grid;
75
- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
76
- gap: 12px;
77
- margin-bottom: 18px;
86
+ grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
87
+ gap: 10px;
88
+ margin-bottom: 16px;
78
89
  }
79
90
  .stat-card {
80
91
  background: var(--panel);
81
92
  border: 1px solid var(--line);
82
- border-radius: 12px;
83
- padding: 14px;
93
+ border-radius: 10px;
94
+ padding: 12px 14px;
95
+ cursor: default;
96
+ transition: border-color 0.12s, background 0.12s;
84
97
  }
85
- .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
86
- .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 */
87
105
  .legend {
88
- margin-bottom: 18px;
106
+ margin-bottom: 16px;
89
107
  background: var(--panel);
90
108
  border: 1px solid var(--line);
91
- border-radius: 12px;
92
- padding: 14px 16px;
109
+ border-radius: 10px;
110
+ padding: 12px 16px;
93
111
  font-size: 13px;
94
112
  color: var(--muted);
113
+ line-height: 1.7;
95
114
  }
115
+ .legend strong { color: var(--text); }
96
116
  /* Filter bar */
97
117
  .filter-bar {
98
118
  background: var(--panel);
99
119
  border: 1px solid var(--line);
100
- border-radius: 12px;
101
- padding: 14px 16px;
102
- margin-bottom: 18px;
120
+ border-radius: 10px;
121
+ padding: 12px 16px;
122
+ margin-bottom: 16px;
103
123
  display: flex;
104
124
  flex-wrap: wrap;
105
- gap: 10px;
125
+ gap: 8px;
106
126
  align-items: center;
107
127
  }
108
128
  .filter-bar input, .filter-bar select {
109
- background: #060f1d;
110
- border: 1px solid #2a3d5c;
111
- border-radius: 8px;
129
+ background: #060e1c;
130
+ border: 1px solid #253a58;
131
+ border-radius: 7px;
112
132
  color: var(--text);
113
133
  padding: 7px 10px;
114
134
  font-size: 13px;
115
135
  outline: none;
136
+ transition: border-color 0.12s;
116
137
  }
117
- .filter-bar input { flex: 1; min-width: 160px; }
138
+ .filter-bar input { flex: 1; min-width: 180px; }
118
139
  .filter-bar input::placeholder { color: var(--muted); }
119
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); }
120
152
  #result-count { color: var(--muted); font-size: 13px; margin-left: auto; white-space: nowrap; }
121
153
  /* Grid */
122
154
  .detail-grid {
123
155
  display: grid;
124
- grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
125
- gap: 12px;
156
+ grid-template-columns: repeat(auto-fill, minmax(370px, 1fr));
157
+ gap: 10px;
126
158
  }
127
159
  /* Cards */
128
160
  .detail-card {
129
- border: 1px solid #243654;
161
+ border: 1px solid #1e3050;
130
162
  border-radius: 10px;
131
- background: #081121;
163
+ background: #080f1e;
132
164
  overflow: hidden;
133
- transition: border-color 0.15s;
165
+ transition: border-color 0.12s;
134
166
  }
135
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 */
136
173
  .card-header {
174
+ display: block;
175
+ width: 100%;
176
+ text-align: left;
177
+ background: none;
178
+ border: none;
137
179
  padding: 14px;
138
180
  cursor: pointer;
139
- user-select: none;
181
+ color: var(--text);
182
+ font-family: inherit;
183
+ font-size: inherit;
184
+ line-height: inherit;
140
185
  }
141
- .card-header:hover { background: rgba(255,255,255,0.03); }
186
+ .card-header:hover { background: rgba(255,255,255,0.025); }
142
187
  .card-title-row {
143
188
  display: flex;
144
189
  align-items: flex-start;
@@ -146,32 +191,32 @@ function renderHtml(rows, stats, policy) {
146
191
  gap: 8px;
147
192
  margin-bottom: 4px;
148
193
  }
149
- .title { margin: 0; font-size: 16px; line-height: 1.3; }
194
+ .title { margin: 0; font-size: 15px; font-weight: 600; line-height: 1.3; }
150
195
  .meta { color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; margin-bottom: 8px; }
151
- .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; }
152
197
  .pill {
153
198
  display: inline-block;
154
199
  border-radius: 999px;
155
- border: 1px solid #314664;
200
+ border: 1px solid #2d4868;
156
201
  padding: 2px 9px;
157
202
  font-size: 12px;
158
203
  color: var(--text);
159
204
  white-space: nowrap;
160
205
  }
161
- .ok { color: var(--ok); border-color: #166534; }
162
- .warn { color: var(--warn); border-color: #854d0e; }
206
+ .ok { color: var(--ok); border-color: #14532d; }
207
+ .warn { color: var(--warn); border-color: #78350f; }
163
208
  .bad { color: var(--bad); border-color: #7f1d1d; }
164
209
  .chips { display: flex; flex-wrap: wrap; gap: 5px; }
165
210
  .chip {
166
- border: 1px solid #2f476b;
211
+ border: 1px solid #2a4060;
167
212
  border-radius: 999px;
168
213
  padding: 2px 8px;
169
- color: #c9dbf5;
214
+ color: #bdd2f0;
170
215
  font-size: 12px;
171
216
  }
172
217
  .expand-hint {
173
218
  font-size: 11px;
174
- color: #4a6a94;
219
+ color: #7a9ec4;
175
220
  margin-top: 8px;
176
221
  text-align: right;
177
222
  }
@@ -180,24 +225,67 @@ function renderHtml(rows, stats, policy) {
180
225
  .card-body {
181
226
  display: none;
182
227
  padding: 0 14px 14px;
183
- border-top: 1px solid #1a2c44;
228
+ border-top: 1px solid #172538;
184
229
  }
185
230
  .detail-card.expanded .card-body { display: block; }
186
- .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; }
187
232
  .line .label { color: var(--muted); }
188
233
  .link { color: #93c5fd; text-decoration: none; }
189
234
  .link:hover { text-decoration: underline; }
190
- .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; }
191
- pre {
192
- margin: 10px 0 0;
193
- padding: 9px 10px;
194
- border: 1px solid #243654;
235
+ /* Install block */
236
+ .install-block {
237
+ margin-top: 10px;
238
+ border: 1px solid #1e3050;
195
239
  border-radius: 8px;
196
- 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;
197
267
  overflow-x: auto;
198
- color: #dbeafe;
268
+ color: #c9deff;
199
269
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
200
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);
201
289
  }
202
290
  .hidden { display: none !important; }
203
291
  </style>
@@ -205,30 +293,38 @@ function renderHtml(rows, stats, policy) {
205
293
  <body>
206
294
  <div class="wrap">
207
295
  <h1>PlugScout Web Report</h1>
208
- <p class="sub">Claude plugins · Claude connectors · Copilot extensions · Cursor extensions · Gemini extensions · Skills · MCP servers</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>` : ''}
209
301
 
210
302
  <div class="stat-cards">
211
- <div class="stat-card"><div class="k">Total</div><div class="v">${stats.totalItems}</div></div>
212
- <div class="stat-card"><div class="k">Plugins</div><div class="v">${kindCounts['claude-plugin']}</div></div>
213
- <div class="stat-card"><div class="k">Connectors</div><div class="v">${kindCounts['claude-connector']}</div></div>
214
- <div class="stat-card"><div class="k">Copilot Ext</div><div class="v">${kindCounts['copilot-extension']}</div></div>
215
- <div class="stat-card"><div class="k">Cursor Ext</div><div class="v">${kindCounts['cursor-extension']}</div></div>
216
- <div class="stat-card"><div class="k">Gemini Ext</div><div class="v">${kindCounts['gemini-extension']}</div></div>
217
- <div class="stat-card"><div class="k">Skills</div><div class="v">${kindCounts.skill}</div></div>
218
- <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>
219
311
  <div class="stat-card"><div class="k">Whitelist / Quarantine</div><div class="v">${stats.whitelist} / ${stats.quarantined}</div></div>
220
312
  </div>
221
313
 
222
- <div class="legend">
223
- <strong>Scores:</strong>
224
- Trust 0–100 (higher = more trustworthy) &nbsp;·&nbsp;
225
- Risk 0–100 (lower = safer) &nbsp;·&nbsp;
226
- ${riskScale} &nbsp;·&nbsp;
227
- <span style="color:var(--bad)">blocked</span> = high/critical risk or quarantined
228
- </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>
229
323
 
230
- <div class="filter-bar">
231
- <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>
232
328
  <select id="kind-filter" onchange="applyFilters()">
233
329
  <option value="">All kinds</option>
234
330
  <option value="claude-plugin">Claude plugin</option>
@@ -239,36 +335,63 @@ function renderHtml(rows, stats, policy) {
239
335
  <option value="skill">Skill</option>
240
336
  <option value="mcp">MCP server</option>
241
337
  </select>
338
+ <label class="sr-only" for="risk-filter">Filter by risk tier</label>
242
339
  <select id="risk-filter" onchange="applyFilters()">
243
340
  <option value="">All risk tiers</option>
244
- <option value="low">Low</option>
245
- <option value="medium">Medium</option>
246
- <option value="high">High</option>
247
- <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>
248
345
  </select>
346
+ <label class="sr-only" for="status-filter">Filter by status</label>
249
347
  <select id="status-filter" onchange="applyFilters()">
250
348
  <option value="">All statuses</option>
251
349
  <option value="allowed">Allowed</option>
252
- <option value="approved">Approved</option>
350
+ <option value="approved">Whitelisted</option>
253
351
  <option value="blocked">Blocked</option>
254
352
  </select>
353
+ <label class="sr-only" for="sort-by">Sort by</label>
255
354
  <select id="sort-by" onchange="applyFilters()">
256
- <option value="name">Sort: Name A–Z</option>
257
- <option value="trust-desc">Sort: Trust ↓</option>
258
- <option value="risk-asc">Sort: Risk ↑ (safest first)</option>
259
- <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>
260
359
  </select>
261
- <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>
262
362
  </div>
263
363
 
264
- <div class="detail-grid" id="card-grid">
364
+ <div class="detail-grid" id="card-grid" role="list">
265
365
  ${cardsJson}
266
366
  </div>
267
367
  </div>
268
368
 
269
369
  <script>
270
- function toggleCard(header) {
271
- 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();
272
395
  }
273
396
 
274
397
  function applyFilters() {
@@ -292,11 +415,10 @@ function renderHtml(rows, stats, policy) {
292
415
  if (show) visible++;
293
416
  });
294
417
 
295
- document.getElementById('result-count').textContent = 'Showing ' + visible + ' of ' + cards.length;
418
+ document.getElementById('result-count').textContent = visible.toLocaleString() + ' of ' + cards.length.toLocaleString() + ' shown';
296
419
 
297
420
  const shown = cards.filter(c => !c.classList.contains('hidden'));
298
421
  shown.sort((a, b) => {
299
- if (sort === 'name') return a.dataset.name.localeCompare(b.dataset.name);
300
422
  if (sort === 'trust-desc') return Number(b.dataset.trust) - Number(a.dataset.trust);
301
423
  if (sort === 'risk-asc') return Number(a.dataset.riskscore) - Number(b.dataset.riskscore);
302
424
  if (sort === 'risk-desc') return Number(b.dataset.riskscore) - Number(a.dataset.riskscore);
@@ -305,17 +427,35 @@ function renderHtml(rows, stats, policy) {
305
427
  shown.forEach(card => grid.appendChild(card));
306
428
  }
307
429
 
308
- // 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
309
451
  applyFilters();
310
452
  </script>
311
453
  </body>
312
454
  </html>`;
313
455
  }
314
- function renderDetailCard(entry, policy) {
456
+ function renderDetailCard(entry) {
315
457
  const metadata = asMetadata(entry.item.metadata);
316
458
  const trustScore = computeTrustScore(entry.item);
317
- const confidence = stringOr(metadata.sourceConfidence, 'official');
318
- const catalogType = stringOr(metadata.catalogType, 'standard');
319
459
  const sourceRepo = typeof metadata.sourceRepo === 'string' ? metadata.sourceRepo : '';
320
460
  const sourcePage = typeof metadata.sourcePage === 'string' ? metadata.sourcePage : '';
321
461
  const status = entry.blocked ? 'blocked' : entry.approved ? 'approved' : 'allowed';
@@ -329,7 +469,12 @@ function renderDetailCard(entry, policy) {
329
469
  : entry.assessment.riskTier === 'medium'
330
470
  ? 'warn'
331
471
  : 'bad';
472
+ const safeId = entry.item.id.replace(/[^a-zA-Z0-9-_]/g, '_');
473
+ const bodyId = `body-${safeId}`;
332
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) : '';
333
478
  const previewChips = entry.item.capabilities
334
479
  .slice(0, 3)
335
480
  .map((cap) => `<span class="chip">${escapeHtml(cap)}</span>`)
@@ -337,7 +482,7 @@ function renderDetailCard(entry, policy) {
337
482
  const allChips = entry.item.capabilities.length > 0
338
483
  ? entry.item.capabilities.map((cap) => `<span class="chip">${escapeHtml(cap)}</span>`).join('')
339
484
  : '<span class="chip">no capability tags</span>';
340
- return `<article class="detail-card"
485
+ return `<article class="detail-card${entry.blocked ? ' is-blocked' : ''}" role="listitem"
341
486
  data-search="${searchKey}"
342
487
  data-kind="${escapeHtml(entry.item.kind)}"
343
488
  data-risk="${escapeHtml(entry.assessment.riskTier)}"
@@ -345,7 +490,7 @@ function renderDetailCard(entry, policy) {
345
490
  data-name="${escapeHtml(entry.item.name.toLowerCase())}"
346
491
  data-trust="${trustScore.toFixed(0)}"
347
492
  data-riskscore="${entry.assessment.riskScore.toFixed(0)}">
348
- <div class="card-header" onclick="toggleCard(this)">
493
+ <button class="card-header" onclick="toggleCard(this)" aria-expanded="false" aria-controls="${bodyId}" type="button">
349
494
  <div class="card-title-row">
350
495
  <h3 class="title">${escapeHtml(entry.item.name)}</h3>
351
496
  <span class="pill">${escapeHtml(entry.item.kind)}</span>
@@ -353,25 +498,39 @@ function renderDetailCard(entry, policy) {
353
498
  <div class="meta">${escapeHtml(entry.item.id)}</div>
354
499
  <div class="pill-row">
355
500
  <span class="pill">trust: ${trustScore.toFixed(0)}</span>
356
- <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>
357
502
  <span class="pill ${statusClass}">${escapeHtml(status)}</span>
358
503
  </div>
359
504
  ${previewChips ? `<div class="chips">${previewChips}</div>` : ''}
360
- <div class="expand-hint">▼ click for details</div>
361
- </div>
362
- <div class="card-body">
505
+ <div class="expand-hint">▼ details</div>
506
+ </button>
507
+ <div class="card-body" id="${bodyId}">
363
508
  <div class="line">${escapeHtml(entry.item.description)}</div>
364
- ${benefitSummary ? `<div class="line"><span class="label">What it does:</span> ${escapeHtml(benefitSummary)}</div>` : ''}
365
- ${bestFor.length > 0 ? `<div class="line"><span class="label">Best for:</span> ${escapeHtml(bestFor.join('; '))}</div>` : ''}
366
- ${tradeoffs.length > 0 ? `<div class="line"><span class="label">Tradeoffs:</span> ${escapeHtml(tradeoffs.join('; '))}</div>` : ''}
367
- <div class="line"><span class="label">Capabilities:</span></div>
368
- <div class="chips" style="margin-top:6px">${allChips}</div>
369
- <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>
370
- <div class="line"><span class="label">Risk reasons:</span> ${escapeHtml(entry.assessment.reasons.join('; '))}</div>
371
- <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>
372
- ${sourceRepo ? `<div class="line"><span class="label">Source repo:</span> <a class="link" href="${escapeHtml(sourceRepo)}" target="_blank" rel="noopener">${escapeHtml(sourceRepo)}</a></div>` : ''}
373
- ${sourcePage ? `<div class="line"><span class="label">Source page:</span> <a class="link" href="${escapeHtml(sourcePage)}" target="_blank" rel="noopener">${escapeHtml(sourcePage)}</a></div>` : ''}
374
- <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>
375
534
  </div>
376
535
  </article>`;
377
536
  }
@@ -397,18 +556,6 @@ function describeTrustBand(score) {
397
556
  }
398
557
  return 'needs review';
399
558
  }
400
- function describeRiskBand(score, policy) {
401
- if (score <= policy.thresholds.lowMax) {
402
- return 'low-risk zone';
403
- }
404
- if (score <= policy.thresholds.mediumMax) {
405
- return 'medium-risk zone';
406
- }
407
- if (score <= policy.thresholds.highMax) {
408
- return 'high-risk zone';
409
- }
410
- return 'critical-risk zone';
411
- }
412
559
  function formatRiskScale(policy) {
413
560
  const low = policy.thresholds.lowMax;
414
561
  const medium = policy.thresholds.mediumMax;
@@ -421,12 +568,6 @@ function asMetadata(value) {
421
568
  }
422
569
  return value;
423
570
  }
424
- function stringOr(value, fallback) {
425
- if (typeof value === 'string' && value.trim().length > 0) {
426
- return value;
427
- }
428
- return fallback;
429
- }
430
571
  function escapeHtml(value) {
431
572
  return value
432
573
  .replaceAll('&', '&amp;')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shnitzel/plugscout",
3
- "version": "0.3.17",
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",