@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.
- package/dist/interfaces/cli/ui/web-report.js +273 -132
- package/package.json +1 -1
|
@@ -11,7 +11,8 @@ export async function writeWebReport(options) {
|
|
|
11
11
|
loadSecurityPolicy(),
|
|
12
12
|
loadItemInsights()
|
|
13
13
|
]);
|
|
14
|
-
const
|
|
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:
|
|
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(
|
|
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
|
|
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: #
|
|
54
|
-
--
|
|
55
|
-
--
|
|
56
|
-
--
|
|
57
|
-
--
|
|
55
|
+
--panel: #0d1626;
|
|
56
|
+
--panel2: #111e32;
|
|
57
|
+
--line: #1e3050;
|
|
58
|
+
--text: #e8f0fb;
|
|
59
|
+
--muted: #9ab0cc;
|
|
60
|
+
--ok: #34d058;
|
|
58
61
|
--warn: #f59e0b;
|
|
59
|
-
--bad: #
|
|
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.
|
|
71
|
+
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
66
72
|
color: var(--text);
|
|
67
|
-
background: radial-gradient(
|
|
68
|
-
padding: 28px;
|
|
73
|
+
background: radial-gradient(ellipse at 50% 0%, #0d1f40 0%, var(--bg) 55%);
|
|
74
|
+
padding: 28px 24px;
|
|
69
75
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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(
|
|
76
|
-
gap:
|
|
77
|
-
margin-bottom:
|
|
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:
|
|
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
|
-
.
|
|
86
|
-
.
|
|
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:
|
|
106
|
+
margin-bottom: 16px;
|
|
89
107
|
background: var(--panel);
|
|
90
108
|
border: 1px solid var(--line);
|
|
91
|
-
border-radius:
|
|
92
|
-
padding:
|
|
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:
|
|
101
|
-
padding:
|
|
102
|
-
margin-bottom:
|
|
120
|
+
border-radius: 10px;
|
|
121
|
+
padding: 12px 16px;
|
|
122
|
+
margin-bottom: 16px;
|
|
103
123
|
display: flex;
|
|
104
124
|
flex-wrap: wrap;
|
|
105
|
-
gap:
|
|
125
|
+
gap: 8px;
|
|
106
126
|
align-items: center;
|
|
107
127
|
}
|
|
108
128
|
.filter-bar input, .filter-bar select {
|
|
109
|
-
background: #
|
|
110
|
-
border: 1px solid #
|
|
111
|
-
border-radius:
|
|
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:
|
|
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(
|
|
125
|
-
gap:
|
|
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 #
|
|
161
|
+
border: 1px solid #1e3050;
|
|
130
162
|
border-radius: 10px;
|
|
131
|
-
background: #
|
|
163
|
+
background: #080f1e;
|
|
132
164
|
overflow: hidden;
|
|
133
|
-
transition: border-color 0.
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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:
|
|
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 #
|
|
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: #
|
|
162
|
-
.warn { color: var(--warn); border-color: #
|
|
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 #
|
|
211
|
+
border: 1px solid #2a4060;
|
|
167
212
|
border-radius: 999px;
|
|
168
213
|
padding: 2px 8px;
|
|
169
|
-
color: #
|
|
214
|
+
color: #bdd2f0;
|
|
170
215
|
font-size: 12px;
|
|
171
216
|
}
|
|
172
217
|
.expand-hint {
|
|
173
218
|
font-size: 11px;
|
|
174
|
-
color: #
|
|
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 #
|
|
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:
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
margin: 10px
|
|
193
|
-
|
|
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
|
-
|
|
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: #
|
|
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">
|
|
296
|
+
<p class="sub">
|
|
297
|
+
Claude plugins · Claude connectors · Copilot extensions ·
|
|
298
|
+
Cursor extensions · Gemini extensions · Skills · MCP servers
|
|
299
|
+
</p>
|
|
300
|
+
${stats.shownItems < stats.totalItems ? `<p class="sub">Showing ${stats.shownItems.toLocaleString()} of ${stats.totalItems.toLocaleString()} catalog items · stat card counts reflect full catalog · 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
|
|
215
|
-
<div class="stat-card"><div class="k">Cursor
|
|
216
|
-
<div class="stat-card"><div class="k">Gemini
|
|
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
|
-
<
|
|
223
|
-
<strong>
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
314
|
+
<details class="legend">
|
|
315
|
+
<summary><strong>Score & 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 · ${riskScale}<br>
|
|
319
|
+
<span style="color:var(--bad)">■</span> <strong>Blocked</strong> = high/critical risk or quarantined · left red border on card<br>
|
|
320
|
+
<span style="color:var(--ok)">■</span> <strong>Allowed</strong> = passes policy · <span style="color:var(--warn)">■</span> <strong>Approved</strong> = manually whitelisted
|
|
321
|
+
</div>
|
|
322
|
+
</details>
|
|
229
323
|
|
|
230
|
-
<div class="filter-bar">
|
|
231
|
-
<
|
|
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">
|
|
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">
|
|
257
|
-
<option value="trust-desc">
|
|
258
|
-
<option value="risk-asc">
|
|
259
|
-
<option value="risk-desc">
|
|
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
|
-
<
|
|
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(
|
|
271
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
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)}
|
|
501
|
+
<span class="pill ${riskClass}">risk: ${escapeHtml(entry.assessment.riskTier)} (${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">▼
|
|
361
|
-
</
|
|
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
|
|
365
|
-
${bestFor.length > 0 ? `<div class="line"><span class="label">Best for
|
|
366
|
-
${tradeoffs.length > 0 ? `<div class="line"><span class="label">Tradeoffs
|
|
367
|
-
|
|
368
|
-
<div class="
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
${
|
|
374
|
-
<
|
|
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))}) ·
|
|
515
|
+
<span class="label">Risk:</span> ${entry.assessment.riskScore.toFixed(0)}/100 — ${escapeHtml(entry.assessment.riskTier)} ·
|
|
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)} · <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('&', '&')
|
package/package.json
CHANGED