| 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 }; |