@shnitzel/plugscout 0.3.7 → 0.3.9

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.
@@ -183,6 +183,8 @@ export async function renderInteractiveHome() {
183
183
  process.stdout.write(screen + '\n');
184
184
  return;
185
185
  }
186
+ const screen = await renderHomeScreen();
187
+ process.stdout.write(screen + '\n\n');
186
188
  let selected = 0;
187
189
  const ARROW_UP = '\u001b[A';
188
190
  const ARROW_DOWN = '\u001b[B';
@@ -205,60 +207,74 @@ export async function renderInteractiveHome() {
205
207
  }
206
208
  }
207
209
  }
208
- process.stdout.write('\n');
209
- process.stdin.setRawMode(true);
210
- process.stdin.resume();
211
- process.stdin.setEncoding('utf8');
212
- render(true);
213
- await new Promise((resolve) => {
214
- process.stdin.on('data', async function onKey(key) {
215
- if (key === CTRL_C) {
216
- process.stdin.removeListener('data', onKey);
217
- process.stdin.setRawMode(false);
218
- process.stdin.pause();
219
- process.stdout.write('\n');
220
- resolve();
221
- return;
222
- }
223
- else if (key === ARROW_UP) {
224
- selected = (selected - 1 + menuItems.length) % menuItems.length;
225
- render(false);
226
- }
227
- else if (key === ARROW_DOWN) {
228
- selected = (selected + 1) % menuItems.length;
229
- render(false);
230
- }
231
- else if (key === ENTER) {
232
- process.stdin.removeListener('data', onKey);
233
- process.stdin.setRawMode(false);
234
- process.stdin.pause();
235
- process.stdout.write('\n');
236
- const item = menuItems[selected];
237
- if (!item.command) {
238
- resolve();
210
+ let running = true;
211
+ while (running) {
212
+ process.stdout.write('\n');
213
+ process.stdin.setRawMode(true);
214
+ process.stdin.resume();
215
+ process.stdin.setEncoding('utf8');
216
+ render(true);
217
+ const action = await new Promise((resolve) => {
218
+ process.stdin.on('data', async function onKey(key) {
219
+ if (key === CTRL_C) {
220
+ process.stdin.removeListener('data', onKey);
221
+ process.stdin.setRawMode(false);
222
+ process.stdin.pause();
223
+ process.stdout.write('\n');
224
+ resolve({ exit: true });
239
225
  return;
240
226
  }
241
- let args = [...item.command];
242
- if (item.needsId) {
243
- const rl = createInterface({ input: process.stdin, output: process.stdout });
244
- process.stdin.resume();
245
- const id = await new Promise((res) => {
246
- rl.question(' Enter catalog ID: ', (answer) => {
247
- rl.close();
248
- res(answer.trim());
249
- });
250
- });
251
- if (!id) {
252
- resolve();
227
+ else if (key === ARROW_UP) {
228
+ selected = (selected - 1 + menuItems.length) % menuItems.length;
229
+ render(false);
230
+ }
231
+ else if (key === ARROW_DOWN) {
232
+ selected = (selected + 1) % menuItems.length;
233
+ render(false);
234
+ }
235
+ else if (key === ENTER) {
236
+ process.stdin.removeListener('data', onKey);
237
+ process.stdin.setRawMode(false);
238
+ process.stdin.pause();
239
+ process.stdout.write('\n');
240
+ const item = menuItems[selected];
241
+ if (!item.command) {
242
+ resolve({ exit: true });
253
243
  return;
254
244
  }
255
- args = [...args, '--id', id];
245
+ let args = [...item.command];
246
+ if (item.needsId) {
247
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
248
+ process.stdin.resume();
249
+ const id = await new Promise((res) => {
250
+ rl.question(' Enter catalog ID: ', (answer) => {
251
+ rl.close();
252
+ res(answer.trim());
253
+ });
254
+ });
255
+ if (!id) {
256
+ resolve({ exit: false });
257
+ return;
258
+ }
259
+ args = [...args, '--id', id];
260
+ }
261
+ resolve({ exit: false, args });
256
262
  }
263
+ });
264
+ });
265
+ if (action.exit) {
266
+ running = false;
267
+ }
268
+ else {
269
+ if (action.args) {
257
270
  const cliPath = getPackagePath('dist/cli.js');
258
- const child = spawn(process.execPath, [cliPath, ...args], { stdio: 'inherit' });
259
- child.on('close', () => resolve());
260
- child.on('error', () => resolve());
271
+ await new Promise((done) => {
272
+ const child = spawn(process.execPath, [cliPath, ...action.args], { stdio: 'inherit' });
273
+ child.on('close', () => done());
274
+ child.on('error', () => done());
275
+ });
261
276
  }
262
- });
263
- });
277
+ process.stdout.write('\n');
278
+ }
279
+ }
264
280
  }
@@ -38,12 +38,8 @@ function filterByKinds(items, kinds) {
38
38
  }
39
39
  function renderHtml(rows, stats, policy) {
40
40
  const kindCounts = countByKind(rows.map((entry) => entry.item));
41
- const topClaude = rows.filter((entry) => entry.item.kind === 'claude-plugin').slice(0, 15);
42
- const topConnectors = rows.filter((entry) => entry.item.kind === 'claude-connector').slice(0, 15);
43
- const topCopilot = rows.filter((entry) => entry.item.kind === 'copilot-extension').slice(0, 15);
44
- const allRows = rows.slice(0, 120);
45
- const detailRows = rows.slice(0, 80);
46
41
  const riskScale = escapeHtml(formatRiskScale(policy));
42
+ const cardsJson = rows.map((entry) => renderDetailCard(entry, policy)).join('\n');
47
43
  return `<!doctype html>
48
44
  <html lang="en">
49
45
  <head>
@@ -74,13 +70,13 @@ function renderHtml(rows, stats, policy) {
74
70
  .wrap { max-width: 1460px; margin: 0 auto; }
75
71
  h1 { margin: 0 0 8px; font-size: 34px; }
76
72
  .sub { color: var(--muted); margin: 0 0 22px; }
77
- .cards {
73
+ .stat-cards {
78
74
  display: grid;
79
- grid-template-columns: repeat(3, minmax(180px, 1fr));
75
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
80
76
  gap: 12px;
81
77
  margin-bottom: 18px;
82
78
  }
83
- .card {
79
+ .stat-card {
84
80
  background: var(--panel);
85
81
  border: 1px solid var(--line);
86
82
  border-radius: 12px;
@@ -88,75 +84,84 @@ function renderHtml(rows, stats, policy) {
88
84
  }
89
85
  .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
90
86
  .v { font-size: 26px; font-weight: 700; margin-top: 4px; }
91
- .section {
92
- margin-top: 18px;
87
+ .legend {
88
+ margin-bottom: 18px;
93
89
  background: var(--panel);
94
90
  border: 1px solid var(--line);
95
91
  border-radius: 12px;
96
- overflow: hidden;
97
- }
98
- .section-body { padding: 14px 16px; }
99
- .section h2 {
100
- margin: 0;
101
92
  padding: 14px 16px;
102
- font-size: 18px;
103
- border-bottom: 1px solid var(--line);
104
- color: var(--accent);
93
+ font-size: 13px;
94
+ color: var(--muted);
105
95
  }
106
- table { width: 100%; border-collapse: collapse; }
107
- th, td {
108
- text-align: left;
109
- padding: 10px 12px;
110
- border-bottom: 1px solid #1d2a41;
111
- vertical-align: top;
112
- word-break: break-word;
96
+ /* Filter bar */
97
+ .filter-bar {
98
+ background: var(--panel);
99
+ border: 1px solid var(--line);
100
+ border-radius: 12px;
101
+ padding: 14px 16px;
102
+ margin-bottom: 18px;
103
+ display: flex;
104
+ flex-wrap: wrap;
105
+ gap: 10px;
106
+ align-items: center;
113
107
  }
114
- th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
115
- .pill {
116
- display: inline-block;
117
- border-radius: 999px;
118
- border: 1px solid #314664;
119
- padding: 2px 9px;
120
- font-size: 12px;
108
+ .filter-bar input, .filter-bar select {
109
+ background: #060f1d;
110
+ border: 1px solid #2a3d5c;
111
+ border-radius: 8px;
121
112
  color: var(--text);
122
- white-space: nowrap;
113
+ padding: 7px 10px;
114
+ font-size: 13px;
115
+ outline: none;
123
116
  }
124
- .ok { color: var(--ok); border-color: #166534; }
125
- .warn { color: var(--warn); border-color: #854d0e; }
126
- .bad { color: var(--bad); border-color: #7f1d1d; }
127
- .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 13px; }
117
+ .filter-bar input { flex: 1; min-width: 160px; }
118
+ .filter-bar input::placeholder { color: var(--muted); }
119
+ .filter-bar select:focus, .filter-bar input:focus { border-color: var(--accent); }
120
+ #result-count { color: var(--muted); font-size: 13px; margin-left: auto; white-space: nowrap; }
121
+ /* Grid */
128
122
  .detail-grid {
129
123
  display: grid;
130
- grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
124
+ grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
131
125
  gap: 12px;
132
- padding: 14px;
133
126
  }
127
+ /* Cards */
134
128
  .detail-card {
135
129
  border: 1px solid #243654;
136
130
  border-radius: 10px;
137
131
  background: #081121;
138
- padding: 12px;
132
+ overflow: hidden;
133
+ transition: border-color 0.15s;
134
+ }
135
+ .detail-card:hover { border-color: #3a5a8a; }
136
+ .card-header {
137
+ padding: 14px;
138
+ cursor: pointer;
139
+ user-select: none;
139
140
  }
140
- .detail-head {
141
+ .card-header:hover { background: rgba(255,255,255,0.03); }
142
+ .card-title-row {
141
143
  display: flex;
142
144
  align-items: flex-start;
143
145
  justify-content: space-between;
144
- gap: 10px;
145
- margin-bottom: 8px;
146
- }
147
- .title {
148
- margin: 0;
149
- font-size: 16px;
150
- line-height: 1.3;
146
+ gap: 8px;
147
+ margin-bottom: 4px;
151
148
  }
152
- .meta {
153
- color: var(--muted);
149
+ .title { margin: 0; font-size: 16px; line-height: 1.3; }
150
+ .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; }
152
+ .pill {
153
+ display: inline-block;
154
+ border-radius: 999px;
155
+ border: 1px solid #314664;
156
+ padding: 2px 9px;
154
157
  font-size: 12px;
155
- margin-top: 4px;
158
+ color: var(--text);
159
+ white-space: nowrap;
156
160
  }
157
- .line { margin-top: 8px; color: var(--text); }
158
- .line .label { color: var(--muted); }
159
- .chips { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
161
+ .ok { color: var(--ok); border-color: #166534; }
162
+ .warn { color: var(--warn); border-color: #854d0e; }
163
+ .bad { color: var(--bad); border-color: #7f1d1d; }
164
+ .chips { display: flex; flex-wrap: wrap; gap: 5px; }
160
165
  .chip {
161
166
  border: 1px solid #2f476b;
162
167
  border-radius: 999px;
@@ -164,102 +169,144 @@ function renderHtml(rows, stats, policy) {
164
169
  color: #c9dbf5;
165
170
  font-size: 12px;
166
171
  }
172
+ .expand-hint {
173
+ font-size: 11px;
174
+ color: #4a6a94;
175
+ margin-top: 8px;
176
+ text-align: right;
177
+ }
178
+ .detail-card.expanded .expand-hint { display: none; }
179
+ /* Expanded body */
180
+ .card-body {
181
+ display: none;
182
+ padding: 0 14px 14px;
183
+ border-top: 1px solid #1a2c44;
184
+ }
185
+ .detail-card.expanded .card-body { display: block; }
186
+ .line { margin-top: 9px; color: var(--text); font-size: 14px; }
187
+ .line .label { color: var(--muted); }
167
188
  .link { color: #93c5fd; text-decoration: none; }
168
189
  .link:hover { text-decoration: underline; }
190
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; }
169
191
  pre {
170
- margin: 8px 0 0;
192
+ margin: 10px 0 0;
171
193
  padding: 9px 10px;
172
194
  border: 1px solid #243654;
173
195
  border-radius: 8px;
174
196
  background: #060f1d;
175
197
  overflow-x: auto;
176
198
  color: #dbeafe;
199
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
200
+ font-size: 13px;
177
201
  }
202
+ .hidden { display: none !important; }
178
203
  </style>
179
204
  </head>
180
205
  <body>
181
206
  <div class="wrap">
182
207
  <h1>PlugScout Web Report</h1>
183
- <p class="sub">Readable catalog view for Claude plugins, Claude connectors, Copilot extensions, Skills, and MCP servers.</p>
184
- <div class="cards">
185
- <div class="card"><div class="k">Items</div><div class="v">${stats.totalItems}</div></div>
186
- <div class="card"><div class="k">Claude Plugins</div><div class="v">${kindCounts['claude-plugin']}</div></div>
187
- <div class="card"><div class="k">Claude Connectors</div><div class="v">${kindCounts['claude-connector']}</div></div>
188
- <div class="card"><div class="k">Copilot Extensions</div><div class="v">${kindCounts['copilot-extension']}</div></div>
189
- <div class="card"><div class="k">Skills</div><div class="v">${kindCounts.skill}</div></div>
190
- <div class="card"><div class="k">MCP Servers</div><div class="v">${kindCounts.mcp}</div></div>
191
- <div class="card"><div class="k">Whitelist / Quarantine</div><div class="v">${stats.whitelist} / ${stats.quarantined}</div></div>
208
+ <p class="sub">Claude plugins · Claude connectors · Copilot extensions · Skills · MCP servers</p>
209
+
210
+ <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">Skills</div><div class="v">${kindCounts.skill}</div></div>
216
+ <div class="stat-card"><div class="k">MCP Servers</div><div class="v">${kindCounts.mcp}</div></div>
217
+ <div class="stat-card"><div class="k">Whitelist / Quarantine</div><div class="v">${stats.whitelist} / ${stats.quarantined}</div></div>
218
+ </div>
219
+
220
+ <div class="legend">
221
+ <strong>Scores:</strong>
222
+ Trust 0–100 (higher = more trustworthy) &nbsp;·&nbsp;
223
+ Risk 0–100 (lower = safer) &nbsp;·&nbsp;
224
+ ${riskScale} &nbsp;·&nbsp;
225
+ <span style="color:var(--bad)">blocked</span> = high/critical risk or quarantined
226
+ </div>
227
+
228
+ <div class="filter-bar">
229
+ <input id="search" type="text" placeholder="Search by name or ID…" oninput="applyFilters()" />
230
+ <select id="kind-filter" onchange="applyFilters()">
231
+ <option value="">All kinds</option>
232
+ <option value="claude-plugin">Claude plugin</option>
233
+ <option value="claude-connector">Claude connector</option>
234
+ <option value="copilot-extension">Copilot extension</option>
235
+ <option value="skill">Skill</option>
236
+ <option value="mcp">MCP server</option>
237
+ </select>
238
+ <select id="risk-filter" onchange="applyFilters()">
239
+ <option value="">All risk tiers</option>
240
+ <option value="low">Low</option>
241
+ <option value="medium">Medium</option>
242
+ <option value="high">High</option>
243
+ <option value="critical">Critical</option>
244
+ </select>
245
+ <select id="status-filter" onchange="applyFilters()">
246
+ <option value="">All statuses</option>
247
+ <option value="allowed">Allowed</option>
248
+ <option value="approved">Approved</option>
249
+ <option value="blocked">Blocked</option>
250
+ </select>
251
+ <select id="sort-by" onchange="applyFilters()">
252
+ <option value="name">Sort: Name A–Z</option>
253
+ <option value="trust-desc">Sort: Trust ↓</option>
254
+ <option value="risk-asc">Sort: Risk ↑ (safest first)</option>
255
+ <option value="risk-desc">Sort: Risk ↓ (riskiest first)</option>
256
+ </select>
257
+ <span id="result-count"></span>
258
+ </div>
259
+
260
+ <div class="detail-grid" id="card-grid">
261
+ ${cardsJson}
192
262
  </div>
193
- <section class="section">
194
- <h2>How to read scores</h2>
195
- <div class="section-body">
196
- <div><span class="pill ok">trust</span> 0-100, higher is better.</div>
197
- <div style="margin-top:6px;"><span class="pill warn">risk</span> 0-100, lower is safer. ${riskScale}</div>
198
- <div style="margin-top:6px;"><span class="pill bad">blocked</span> means policy high/critical risk or quarantined.</div>
199
- </div>
200
- </section>
201
- ${renderTableSection('Top Claude Plugins', topClaude)}
202
- ${renderTableSection('Top Claude Connectors', topConnectors)}
203
- ${renderTableSection('Top Copilot Extensions', topCopilot)}
204
- ${renderTableSection('Catalog Snapshot', allRows)}
205
- ${renderDetailSection('Decision details per item', detailRows, policy)}
206
263
  </div>
264
+
265
+ <script>
266
+ function toggleCard(header) {
267
+ header.closest('.detail-card').classList.toggle('expanded');
268
+ }
269
+
270
+ function applyFilters() {
271
+ const search = document.getElementById('search').value.toLowerCase().trim();
272
+ const kind = document.getElementById('kind-filter').value;
273
+ const risk = document.getElementById('risk-filter').value;
274
+ const status = document.getElementById('status-filter').value;
275
+ const sort = document.getElementById('sort-by').value;
276
+
277
+ const grid = document.getElementById('card-grid');
278
+ const cards = Array.from(grid.querySelectorAll('.detail-card'));
279
+
280
+ let visible = 0;
281
+ cards.forEach(card => {
282
+ const matchSearch = !search || card.dataset.search.includes(search);
283
+ const matchKind = !kind || card.dataset.kind === kind;
284
+ const matchRisk = !risk || card.dataset.risk === risk;
285
+ const matchStatus = !status || card.dataset.status === status;
286
+ const show = matchSearch && matchKind && matchRisk && matchStatus;
287
+ card.classList.toggle('hidden', !show);
288
+ if (show) visible++;
289
+ });
290
+
291
+ document.getElementById('result-count').textContent = 'Showing ' + visible + ' of ' + cards.length;
292
+
293
+ const shown = cards.filter(c => !c.classList.contains('hidden'));
294
+ shown.sort((a, b) => {
295
+ if (sort === 'name') return a.dataset.name.localeCompare(b.dataset.name);
296
+ if (sort === 'trust-desc') return Number(b.dataset.trust) - Number(a.dataset.trust);
297
+ if (sort === 'risk-asc') return Number(a.dataset.riskscore) - Number(b.dataset.riskscore);
298
+ if (sort === 'risk-desc') return Number(b.dataset.riskscore) - Number(a.dataset.riskscore);
299
+ return a.dataset.name.localeCompare(b.dataset.name);
300
+ });
301
+ shown.forEach(card => grid.appendChild(card));
302
+ }
303
+
304
+ // Initial count
305
+ applyFilters();
306
+ </script>
207
307
  </body>
208
308
  </html>`;
209
309
  }
210
- function renderTableSection(title, rows) {
211
- return `<section class="section">
212
- <h2>${escapeHtml(title)}</h2>
213
- <table>
214
- <thead>
215
- <tr>
216
- <th>ID</th>
217
- <th>Name</th>
218
- <th>Kind</th>
219
- <th>Provider</th>
220
- <th>Trust</th>
221
- <th>Source</th>
222
- <th>Confidence</th>
223
- <th>Risk</th>
224
- <th>Status</th>
225
- </tr>
226
- </thead>
227
- <tbody>
228
- ${rows
229
- .map((entry) => {
230
- const metadata = asMetadata(entry.item.metadata);
231
- const confidence = stringOr(metadata.sourceConfidence, 'official');
232
- const trustScore = computeTrustScore(entry.item);
233
- const riskClass = entry.assessment.riskTier === 'low'
234
- ? 'ok'
235
- : entry.assessment.riskTier === 'medium'
236
- ? 'warn'
237
- : 'bad';
238
- return `<tr>
239
- <td class="mono">${escapeHtml(entry.item.id)}</td>
240
- <td>${escapeHtml(entry.item.name)}</td>
241
- <td>${escapeHtml(entry.item.kind)}</td>
242
- <td>${escapeHtml(entry.item.provider)}</td>
243
- <td><span class="pill">${trustScore.toFixed(0)}</span></td>
244
- <td>${escapeHtml(entry.item.source)}</td>
245
- <td><span class="pill">${escapeHtml(confidence)}</span></td>
246
- <td><span class="pill ${riskClass}">${escapeHtml(entry.assessment.riskTier)} (${entry.assessment.riskScore.toFixed(0)})</span></td>
247
- <td>${entry.blocked ? '<span class="pill bad">blocked</span>' : entry.approved ? '<span class="pill ok">approved</span>' : '<span class="pill ok">allowed</span>'}</td>
248
- </tr>`;
249
- })
250
- .join('\n')}
251
- </tbody>
252
- </table>
253
- </section>`;
254
- }
255
- function renderDetailSection(title, rows, policy) {
256
- return `<section class="section">
257
- <h2>${escapeHtml(title)}</h2>
258
- <div class="detail-grid">
259
- ${rows.map((entry) => renderDetailCard(entry, policy)).join('\n')}
260
- </div>
261
- </section>`;
262
- }
263
310
  function renderDetailCard(entry, policy) {
264
311
  const metadata = asMetadata(entry.item.metadata);
265
312
  const trustScore = computeTrustScore(entry.item);
@@ -268,48 +315,62 @@ function renderDetailCard(entry, policy) {
268
315
  const sourceRepo = typeof metadata.sourceRepo === 'string' ? metadata.sourceRepo : '';
269
316
  const sourcePage = typeof metadata.sourcePage === 'string' ? metadata.sourcePage : '';
270
317
  const status = entry.blocked ? 'blocked' : entry.approved ? 'approved' : 'allowed';
318
+ const statusClass = entry.blocked ? 'bad' : 'ok';
271
319
  const installHint = buildInstallHint(entry.item);
272
320
  const bestFor = entry.insight?.bestFor ?? [];
273
321
  const tradeoffs = entry.insight?.tradeoffs ?? [];
274
- return `<article class="detail-card">
275
- <div class="detail-head">
276
- <div>
322
+ const benefitSummary = entry.insight?.benefitSummary ?? '';
323
+ const riskClass = entry.assessment.riskTier === 'low'
324
+ ? 'ok'
325
+ : entry.assessment.riskTier === 'medium'
326
+ ? 'warn'
327
+ : 'bad';
328
+ const searchKey = escapeHtml(`${entry.item.id} ${entry.item.name} ${entry.item.capabilities.join(' ')}`.toLowerCase());
329
+ const previewChips = entry.item.capabilities
330
+ .slice(0, 3)
331
+ .map((cap) => `<span class="chip">${escapeHtml(cap)}</span>`)
332
+ .join('');
333
+ const allChips = entry.item.capabilities.length > 0
334
+ ? entry.item.capabilities.map((cap) => `<span class="chip">${escapeHtml(cap)}</span>`).join('')
335
+ : '<span class="chip">no capability tags</span>';
336
+ return `<article class="detail-card"
337
+ data-search="${searchKey}"
338
+ data-kind="${escapeHtml(entry.item.kind)}"
339
+ data-risk="${escapeHtml(entry.assessment.riskTier)}"
340
+ data-status="${escapeHtml(status)}"
341
+ data-name="${escapeHtml(entry.item.name.toLowerCase())}"
342
+ data-trust="${trustScore.toFixed(0)}"
343
+ data-riskscore="${entry.assessment.riskScore.toFixed(0)}">
344
+ <div class="card-header" onclick="toggleCard(this)">
345
+ <div class="card-title-row">
277
346
  <h3 class="title">${escapeHtml(entry.item.name)}</h3>
278
- <div class="meta mono">${escapeHtml(entry.item.id)}</div>
279
- </div>
280
- <div>
281
347
  <span class="pill">${escapeHtml(entry.item.kind)}</span>
282
348
  </div>
349
+ <div class="meta">${escapeHtml(entry.item.id)}</div>
350
+ <div class="pill-row">
351
+ <span class="pill">trust: ${trustScore.toFixed(0)}</span>
352
+ <span class="pill ${riskClass}">risk: ${escapeHtml(entry.assessment.riskTier)} (${entry.assessment.riskScore.toFixed(0)})</span>
353
+ <span class="pill ${statusClass}">${escapeHtml(status)}</span>
354
+ </div>
355
+ ${previewChips ? `<div class="chips">${previewChips}</div>` : ''}
356
+ <div class="expand-hint">▼ click for details</div>
357
+ </div>
358
+ <div class="card-body">
359
+ <div class="line">${escapeHtml(entry.item.description)}</div>
360
+ ${benefitSummary ? `<div class="line"><span class="label">What it does:</span> ${escapeHtml(benefitSummary)}</div>` : ''}
361
+ ${bestFor.length > 0 ? `<div class="line"><span class="label">Best for:</span> ${escapeHtml(bestFor.join('; '))}</div>` : ''}
362
+ ${tradeoffs.length > 0 ? `<div class="line"><span class="label">Tradeoffs:</span> ${escapeHtml(tradeoffs.join('; '))}</div>` : ''}
363
+ <div class="line"><span class="label">Capabilities:</span></div>
364
+ <div class="chips" style="margin-top:6px">${allChips}</div>
365
+ <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>
366
+ <div class="line"><span class="label">Risk reasons:</span> ${escapeHtml(entry.assessment.reasons.join('; '))}</div>
367
+ <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>
368
+ ${sourceRepo ? `<div class="line"><span class="label">Source repo:</span> <a class="link" href="${escapeHtml(sourceRepo)}" target="_blank" rel="noopener">${escapeHtml(sourceRepo)}</a></div>` : ''}
369
+ ${sourcePage ? `<div class="line"><span class="label">Source page:</span> <a class="link" href="${escapeHtml(sourcePage)}" target="_blank" rel="noopener">${escapeHtml(sourcePage)}</a></div>` : ''}
370
+ <pre class="mono">${escapeHtml(installHint)}</pre>
283
371
  </div>
284
- <div class="line">${escapeHtml(entry.item.description)}</div>
285
- <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>
286
- <div class="line"><span class="label">Risk reasons:</span> ${escapeHtml(entry.assessment.reasons.join('; '))}</div>
287
- <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>
288
- ${sourceRepo
289
- ? `<div class="line"><span class="label">Source repo:</span> <a class="link" href="${escapeHtml(sourceRepo)}">${escapeHtml(sourceRepo)}</a></div>`
290
- : ''}
291
- ${sourcePage
292
- ? `<div class="line"><span class="label">Source page:</span> <a class="link" href="${escapeHtml(sourcePage)}">${escapeHtml(sourcePage)}</a></div>`
293
- : ''}
294
- ${bestFor.length > 0
295
- ? `<div class="line"><span class="label">Best for:</span> ${escapeHtml(bestFor.join('; '))}</div>`
296
- : ''}
297
- ${tradeoffs.length > 0
298
- ? `<div class="line"><span class="label">Tradeoffs:</span> ${escapeHtml(tradeoffs.join('; '))}</div>`
299
- : ''}
300
- <div class="chips">${renderChips(entry.item.capabilities)}</div>
301
- <pre class="mono">${escapeHtml(installHint)}</pre>
302
372
  </article>`;
303
373
  }
304
- function renderChips(values) {
305
- if (values.length === 0) {
306
- return '<span class="chip">no capability tags</span>';
307
- }
308
- return values
309
- .slice(0, 8)
310
- .map((value) => `<span class="chip">${escapeHtml(value)}</span>`)
311
- .join('');
312
- }
313
374
  function buildInstallHint(item) {
314
375
  if (item.install.kind === 'manual') {
315
376
  if (item.install.url) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shnitzel/plugscout",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Claude plugins + Claude connectors + Copilot extensions + Skills + MCP security intelligence framework",
5
5
  "private": false,
6
6
  "type": "module",