main
js 401 lines 12.9 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { marked } from "/vendor/marked/marked.esm.js";
3 import sleep from "/js/sleep.js";
4 import * as api from "/js/api.js";
5 import { openModal } from "/js/modals.js";
6 import { getUserTimezone } from "/js/time-utils.js";
7 import {
8 toastFrontendError,
9 toastFrontendWarning,
10 } from "/components/notifications/notification-store.js";
11
12 const SCAN_ASSET_BASE = "/components/settings/skills";
13 const SCAN_POLL_INTERVAL_MS = 2000;
14 const SCAN_MAX_POLL_MS = 10 * 60 * 1000;
15 const SCAN_TITLE = "Skill Scanner";
16
17 let scanChecksConfig = null;
18 let scanPromptTemplate = null;
19 let scanPollGeneration = 0;
20
21 async function fetchText(url, label) {
22 const response = await fetch(url);
23 if (!response.ok) {
24 const body = await response.text().catch(() => "");
25 throw new Error(`Failed to load ${label}: ${response.status} ${response.statusText}${body ? ` - ${body}` : ""}`);
26 }
27 return response.text();
28 }
29
30 async function fetchJson(url, label) {
31 const response = await fetch(url);
32 if (!response.ok) {
33 const body = await response.text().catch(() => "");
34 throw new Error(`Failed to load ${label}: ${response.status} ${response.statusText}${body ? ` - ${body}` : ""}`);
35 }
36 return response.json();
37 }
38
39 async function loadScanChecks() {
40 if (scanChecksConfig) return scanChecksConfig;
41 scanChecksConfig = await fetchJson(`${SCAN_ASSET_BASE}/skill-scan-checks.json`, "skill scan checks");
42 return scanChecksConfig;
43 }
44
45 async function loadScanTemplate() {
46 if (scanPromptTemplate) return scanPromptTemplate;
47 scanPromptTemplate = await fetchText(`${SCAN_ASSET_BASE}/skill-scan-prompt.md`, "skill scan prompt");
48 return scanPromptTemplate;
49 }
50
51 function formatCriteria(ratings, criteria) {
52 return Object.entries(criteria || {})
53 .map(([level, desc]) => `- ${ratings[level]?.icon || level}: ${desc}`)
54 .join("\n");
55 }
56
57 function formatStatusLegend(ratings) {
58 return Object.values(ratings || {})
59 .map((rating) => `- ${rating.icon} ${rating.label}`)
60 .join("\n");
61 }
62
63 function formatRatingIcons(ratings) {
64 return Object.values(ratings || {}).map((rating) => rating.icon).join("/");
65 }
66
67 function splitTargetLines(value) {
68 return String(value || "")
69 .split(/\r?\n/u)
70 .map((line) => line.trim())
71 .filter(Boolean);
72 }
73
74 function inferTargetType(value) {
75 const text = String(value || "").trim();
76 if (/^(https?:\/\/|git@)/iu.test(text)) return "git_url";
77 return "path";
78 }
79
80 function shellQuote(value) {
81 return `'${String(value || "").replace(/'/g, "'\"'\"'")}'`;
82 }
83
84 function createDefaultScanOptions() {
85 return {
86 useSnykAgentScan: true,
87 };
88 }
89
90 function formatErrorMessage(error) {
91 return error instanceof Error ? error.message : String(error);
92 }
93
94 const model = {
95 sectionTarget: "",
96 sectionLoading: false,
97 installedTargets: [],
98
99 targetType: "path",
100 targetLabel: "Manual target",
101 targetText: "",
102 targetSummary: "{}",
103 cleanupPaths: [],
104
105 scanChecks: {},
106 scanChecksMeta: {},
107 scanOptions: createDefaultScanOptions(),
108 scanPrompt: "",
109 scanOutput: "",
110 scanCtxId: "",
111 agentScanning: false,
112 preparingUpload: false,
113
114 get renderedScanOutput() {
115 return this.scanOutput ? marked.parse(this.scanOutput, { breaks: true }) : "";
116 },
117
118 async init() {
119 await this.ensureScanFramework();
120 },
121
122 async ensureScanFramework() {
123 try {
124 const cfg = await loadScanChecks();
125 this.scanChecksMeta = cfg.checks || {};
126 if (Object.keys(this.scanChecks).length === 0) {
127 const checks = {};
128 for (const key of Object.keys(this.scanChecksMeta)) checks[key] = true;
129 this.scanChecks = checks;
130 }
131 return cfg;
132 } catch (error) {
133 console.error("Failed to load skill scanner framework:", error);
134 void toastFrontendError(`Failed to load skill scanner: ${formatErrorMessage(error)}`, SCAN_TITLE);
135 return null;
136 }
137 },
138
139 async loadInstalledTargets() {
140 this.sectionLoading = true;
141 try {
142 const response = await api.callJsonApi("/skills_scan", { action: "targets" });
143 if (!response?.success) throw new Error(response?.error || "Unable to load installed skill targets");
144 this.installedTargets = response.targets || [];
145 return response;
146 } catch (error) {
147 console.error("Failed to load installed skill targets:", error);
148 void toastFrontendError(`Failed to load installed skills: ${formatErrorMessage(error)}`, SCAN_TITLE);
149 return null;
150 } finally {
151 this.sectionLoading = false;
152 }
153 },
154
155 async openForInstalledSkills() {
156 const response = await this.loadInstalledTargets();
157 const paths = response?.paths || [];
158 if (!paths.length) {
159 void toastFrontendWarning("No installed skills found to scan.", SCAN_TITLE);
160 return;
161 }
162
163 await this.openModalForTarget({
164 target_type: "installed",
165 target_label: "Installed Agent Zero skills",
166 paths,
167 summary: {
168 skill_count: response.skill_count || 0,
169 roots: response.targets || [],
170 },
171 });
172 },
173
174 async openForManualTarget() {
175 const target = String(this.sectionTarget || "").trim();
176 if (!target) {
177 await this.openForInstalledSkills();
178 return;
179 }
180
181 await this.openModalForTarget({
182 target_type: inferTargetType(target),
183 target_label: target,
184 paths: splitTargetLines(target),
185 summary: {},
186 });
187 },
188
189 async openForUploadedFile(file, metadata = {}) {
190 if (!file) {
191 void toastFrontendError("Select a skills .zip file first", SCAN_TITLE);
192 return false;
193 }
194
195 this.preparingUpload = true;
196 try {
197 const formData = new FormData();
198 formData.append("skills_file", file);
199 formData.append("ctxid", globalThis.getContext ? globalThis.getContext() : "");
200 if (metadata.namespace) formData.append("namespace", metadata.namespace);
201
202 const response = await api.fetchApi("/skills_scan", {
203 method: "POST",
204 body: formData,
205 });
206 const result = await response.json();
207 if (!result?.success) throw new Error(result?.error || "Failed to prepare skill scan");
208
209 await this.openModalForTarget({
210 ...result,
211 summary: {
212 skill_count: result.skill_count || 0,
213 skills: result.skills || [],
214 warnings: result.warnings || [],
215 display_path: result.display_path || "",
216 },
217 });
218 return true;
219 } catch (error) {
220 console.error("Failed to prepare uploaded skill scan:", error);
221 void toastFrontendError(`Skill scan failed: ${formatErrorMessage(error)}`, SCAN_TITLE);
222 return false;
223 } finally {
224 this.preparingUpload = false;
225 }
226 },
227
228 async openModalForTarget(target = {}) {
229 this.applyTarget(target);
230 await this.ensureScanFramework();
231 await this.buildScanPrompt();
232 await openModal("settings/skills/skill-scan.html");
233 },
234
235 applyTarget(target = {}) {
236 const paths = Array.isArray(target.paths) ? target.paths.filter(Boolean) : [];
237 const fallbackText = target.scan_path || target.target_text || "";
238 const targetText = paths.length ? paths.join("\n") : fallbackText;
239
240 this.targetType = target.target_type || inferTargetType(targetText);
241 this.targetLabel = target.target_label || target.label || targetText || "Manual target";
242 this.targetText = targetText;
243 this.targetSummary = JSON.stringify(target.summary || {}, null, 2);
244 this.cleanupPaths = Array.isArray(target.cleanup_paths) ? target.cleanup_paths.filter(Boolean) : [];
245 this.scanOutput = "";
246 this.scanCtxId = "";
247 this.agentScanning = false;
248 },
249
250 async onScanModalOpen() {
251 await this.ensureScanFramework();
252 if (!this.targetText && this.sectionTarget) {
253 this.applyTarget({
254 target_type: inferTargetType(this.sectionTarget),
255 target_label: this.sectionTarget,
256 paths: splitTargetLines(this.sectionTarget),
257 });
258 }
259 await this.buildScanPrompt();
260 },
261
262 async buildScanPrompt() {
263 try {
264 const [cfg, template] = await Promise.all([loadScanChecks(), loadScanTemplate()]);
265 const ratings = cfg.ratings || {};
266 const checks = cfg.checks || {};
267 const selected = Object.entries(this.scanChecks)
268 .filter(([, enabled]) => enabled)
269 .map(([key]) => checks[key])
270 .filter(Boolean);
271
272 const targetPaths = splitTargetLines(this.targetText);
273 const targetArgs = this.targetType === "git_url"
274 ? "<cloned skill repository path>"
275 : targetPaths.map((path) => shellQuote(path)).join(" ");
276 const cleanupText = this.cleanupPaths.length ? this.cleanupPaths.join("\n") : "(none)";
277
278 let prompt = template;
279 prompt = prompt.replace(/\{\{TARGET_TYPE\}\}/g, this.targetType || "path");
280 prompt = prompt.replace(/\{\{TARGET_LABEL\}\}/g, this.targetLabel || "Manual target");
281 prompt = prompt.replace(/\{\{TARGET_PATHS\}\}/g, targetPaths.length ? targetPaths.join("\n") : "(none)");
282 prompt = prompt.replace(/\{\{TARGET_SUMMARY\}\}/g, this.targetSummary || "{}");
283 prompt = prompt.replace(/\{\{CLEANUP_PATHS\}\}/g, cleanupText);
284 prompt = prompt.replace(/\{\{SNYK_SCAN_ENABLED\}\}/g, this.scanOptions.useSnykAgentScan ? "yes" : "no");
285 prompt = prompt.replace(/\{\{SNYK_TARGET_ARGS\}\}/g, targetArgs || "<target path>");
286 prompt = prompt.replace(
287 /\{\{SELECTED_CHECKS\}\}/g,
288 selected.length ? selected.map((check) => `- ${check.label}`).join("\n") : "- (no checks selected)",
289 );
290 prompt = prompt.replace(
291 /\{\{CHECK_DETAILS\}\}/g,
292 selected.length
293 ? selected.map((check) => `**${check.label}**: ${check.detail}\n${formatCriteria(ratings, check.criteria)}`).join("\n\n")
294 : "(no checks selected)",
295 );
296 prompt = prompt.replace(/\{\{STATUS_LEGEND\}\}/g, formatStatusLegend(ratings));
297 prompt = prompt.replace(/\{\{RATING_ICONS\}\}/g, formatRatingIcons(ratings));
298 prompt = prompt.replace(/\{\{RATING_PASS\}\}/g, ratings.pass?.icon || "PASS");
299 prompt = prompt.replace(/\{\{RATING_WARNING\}\}/g, ratings.warning?.icon || "WARN");
300 prompt = prompt.replace(/\{\{RATING_FAIL\}\}/g, ratings.fail?.icon || "FAIL");
301 this.scanPrompt = prompt;
302 } catch (error) {
303 console.error("Failed to build skill scan prompt:", error);
304 void toastFrontendError(`Failed to build scan prompt: ${formatErrorMessage(error)}`, SCAN_TITLE);
305 }
306 },
307
308 async copyScanPrompt() {
309 try {
310 await navigator.clipboard.writeText(this.scanPrompt || "");
311 } catch {
312 void toastFrontendError("Failed to copy the scan prompt", SCAN_TITLE);
313 }
314 },
315
316 async runAgentScan() {
317 if (this.agentScanning) return;
318 await this.buildScanPrompt();
319
320 const prompt = String(this.scanPrompt || "").trim();
321 if (!prompt) {
322 void toastFrontendError("Scan prompt is empty", SCAN_TITLE);
323 return;
324 }
325
326 const gen = ++scanPollGeneration;
327 this.scanOutput = "";
328
329 let ctxId = "";
330 try {
331 const resp = await api.callJsonApi("/chat_create", {});
332 if (!resp?.ok || !resp.ctxid) throw new Error(resp?.message || "Failed to create scan chat");
333 ctxId = resp.ctxid;
334 this.scanCtxId = ctxId;
335 await api.callJsonApi("/message_queue_add", { context: ctxId, text: prompt });
336 this.agentScanning = true;
337 await api.callJsonApi("/message_queue_send", { context: ctxId });
338 void this.pollAgentScan(gen, ctxId);
339 } catch (error) {
340 this.agentScanning = false;
341 console.error("Skill agent scan failed:", error);
342 void toastFrontendError(`Scan failed: ${formatErrorMessage(error)}`, SCAN_TITLE);
343 }
344 },
345
346 async pollAgentScan(gen, ctxId) {
347 let started = false;
348 const deadline = Date.now() + SCAN_MAX_POLL_MS;
349 while (gen === scanPollGeneration) {
350 if (Date.now() >= deadline) {
351 this.agentScanning = false;
352 void toastFrontendError("Scan timed out while waiting for Agent Zero", SCAN_TITLE);
353 return;
354 }
355 await sleep(SCAN_POLL_INTERVAL_MS);
356 try {
357 const snap = await api.callJsonApi("/poll", {
358 context: ctxId,
359 log_from: 0,
360 notifications_from: 0,
361 timezone: getUserTimezone(),
362 });
363
364 if (snap.logs?.length) {
365 const last = snap.logs
366 .filter((log) => log.type === "response" && log.no > 0)
367 .pop();
368 if (last) this.scanOutput = last.content || "";
369 }
370
371 if (snap.log_progress_active) started = true;
372 if (started && !snap.log_progress_active) {
373 this.agentScanning = false;
374 return;
375 }
376 if (snap.deselect_chat) return;
377 } catch (error) {
378 if (gen === scanPollGeneration) console.error("Skill scan poll error:", error);
379 }
380 }
381 },
382
383 openScanChatInNewWindow() {
384 if (!this.scanCtxId) return;
385 const url = new URL(window.location.href);
386 url.searchParams.set("ctxid", this.scanCtxId);
387 window.open(url.toString(), "_blank");
388 },
389
390 scanCleanup() {
391 scanPollGeneration++;
392 this.agentScanning = false;
393 },
394
395 onClose() {
396 this.scanCleanup();
397 },
398 };
399
400 const store = createStore("skillsScanStore", model);
401 export { store };