#!/usr/bin/env node import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const MUTATING_TYPES = new Set(["click", "fill", "input", "type", "select", "check", "uncheck", "submit", "press"]); const WRITE_RISKS = new Set(["write", "dangerous", "destructive", "submit", "payment", "approval"]); export function generateSgRobotArtifacts(trace, options = {}) { const normalized = normalizeTrace(trace, options); const files = new Map(); const skillRoot = `.sgclaw-workspace/skills/${normalized.skillName}`; files.set(`plan_templates/${normalized.planId}/PlanTemplate.toml`, renderPlanTemplate(normalized)); files.set(`${skillRoot}/SKILL.toml`, renderSkillToml(normalized)); files.set(`${skillRoot}/scripts/execute_trace.js`, renderRuntimeScript(normalized)); files.set(`${skillRoot}/trace.semantic.json`, `${JSON.stringify(trace, null, 2)}\n`); return { summary: { planId: normalized.planId, skillName: normalized.skillName, expectedDomain: normalized.expectedDomain, defaultMode: normalized.defaultMode, requiresApproval: normalized.requiresApproval, actionCount: normalized.actions.length, }, files, }; } export async function writeArtifacts(root, files, options = {}) { const rootPath = path.resolve(root); const written = []; for (const [relativePath, content] of files.entries()) { if (path.isAbsolute(relativePath) || relativePath.includes("..")) { throw new Error(`Refusing unsafe generated path: ${relativePath}`); } const targetPath = path.resolve(rootPath, relativePath); if (!targetPath.startsWith(`${rootPath}${path.sep}`)) { throw new Error(`Refusing path outside output root: ${relativePath}`); } if (!options.force) { try { await access(targetPath); throw new Error(`Refusing to overwrite existing file without --force: ${relativePath}`); } catch (error) { if (error && error.code !== "ENOENT") { throw error; } } } await mkdir(path.dirname(targetPath), { recursive: true }); await writeFile(targetPath, content, "utf8"); written.push(targetPath); } return written; } function normalizeTrace(trace, options) { if (!trace || typeof trace !== "object" || Array.isArray(trace)) { throw new Error("Trace must be a JSON object."); } const id = requireString(trace.id, "trace.id"); const name = requireString(trace.name, "trace.name"); const startUrl = requireString(trace.startUrl || trace.start_url, "trace.startUrl"); const start = parseHttpUrl(startUrl, "trace.startUrl"); const actions = Array.isArray(trace.steps) ? trace.steps : trace.actions; if (!Array.isArray(actions) || actions.length === 0) { throw new Error("Trace must include a non-empty steps array."); } const baseId = toKebabId(id); const planId = cleanId(trace.planId || trace.plan_id || `rrweb-${baseId}`, "plan id"); const skillName = cleanId(trace.skillName || trace.skill_name || `${planId}-execute`, "skill name"); const expectedDomain = String(trace.expectedDomain || trace.expected_domain || start.hostname).trim(); const defaultMode = normalizeMode(options.defaultMode || options.default_mode || trace.defaultMode || trace.default_mode || "dry-run"); const normalizedActions = actions.map((action, index) => normalizeAction(action, index)); const hasMutatingAction = normalizedActions.some(isMutatingAction); const requiresApproval = trace.requiresApproval === true || trace.requires_approval === true || (defaultMode === "execute" && hasMutatingAction); return { id, name, description: String(trace.description || `${name} generated from rrweb semantic trace.`), startUrl: start.href, planId, skillName, expectedDomain, defaultMode, requiresApproval, actions: normalizedActions, }; } function normalizeAction(action, index) { if (!action || typeof action !== "object" || Array.isArray(action)) { throw new Error(`Trace step ${index + 1} must be an object.`); } const type = requireString(action.type || action.action, `trace.steps[${index}].type`); const normalized = { ...action, type, label: String(action.label || action.name || `${type} #${index + 1}`), }; if (needsTarget(type) && !action.selector && !Array.isArray(action.selectors) && !action.text) { throw new Error(`Trace step ${index + 1} (${type}) needs selector, selectors, or text.`); } if (needsValue(type) && action.value === undefined) { throw new Error(`Trace step ${index + 1} (${type}) needs value.`); } return normalized; } function renderPlanTemplate(trace) { const approvalLine = trace.requiresApproval ? "\nrequires_approval = true" : ""; return `[plan] id = ${tomlString(trace.planId)} name = ${tomlString(trace.name)} description = ${tomlString(trace.description)} version = "1.0.0" [plan.trigger] kind = "manual" [[plan.steps]] id = "open-target-page" title = "Open target page" description = ${tomlString(`Call browser.open_url to open ${trace.startUrl}.`)} skills = ["browser.open_url"] depends_on = [] [[plan.steps]] id = "run-recorded-flow" title = "Run recorded browser flow" description = ${tomlString(`Execute the generated rrweb semantic trace through sgBrowser on ${trace.expectedDomain}. Default mode: ${trace.defaultMode}.`)} skills = [${tomlString(trace.skillName)}] depends_on = ["open-target-page"]${approvalLine} [[plan.steps]] id = "save-result" title = "Save structured result" description = "Save the generated flow result as a structured JSON artifact." skills = ["json.save_result"] depends_on = ["run-recorded-flow"] `; } function renderSkillToml(trace) { return `[skill] name = ${tomlString(trace.skillName)} description = ${tomlString(`Run the generated rrweb semantic trace for ${trace.name} through sgBrowser.`)} version = "0.1.0" author = "rrweb-skill" tags = ["rrweb", "semantic-trace", "sgbrowser-action", "generated"] prompts = [ ${tomlString(`Use ${trace.skillName} only for the generated ${trace.name} browser flow.`)}, "Run it after browser.open_url has opened the target page.", "Return the structured JSON evidence emitted by the tool; do not use Playwright, CDP, or generic browser_action for this step." ] [[tools]] name = "execute_trace" description = ${tomlString(`Replay or dry-run the generated ${trace.name} semantic trace in sgBrowser.`)} kind = "sgbrowser_action" command = "sgBrowserExcuteJsCodeByDomain" expected_domain = ${tomlString(trace.expectedDomain)} [tools.args] script_path = "scripts/execute_trace.js" mode = ${tomlString(`Baked default mode is ${trace.defaultMode}. Runtime may pass mode=execute or mode=dry-run if supported.`)} `; } function renderRuntimeScript(trace) { const runtimeTrace = { id: trace.id, name: trace.name, start_url: trace.startUrl, expected_domain: trace.expectedDomain, default_mode: trace.defaultMode, actions: trace.actions, }; return `const TRACE = ${JSON.stringify(runtimeTrace, null, 2)}; const input = (typeof args === "object" && args) || (typeof tool_args === "object" && tool_args) || (typeof sgclaw_args === "object" && sgclaw_args) || {}; function normalizeMode(value) { const raw = String(value || TRACE.default_mode || "dry-run").trim().toLowerCase(); return raw === "execute" ? "execute" : "dry-run"; } function normalizeType(value) { return String(value || "").replace(/[\\s_-]+/g, "").toLowerCase(); } function cleanText(value) { return String(value || "").replace(/\\s+/g, " ").trim(); } function selectorList(action) { const selectors = []; if (action.selector) { selectors.push(action.selector); } if (Array.isArray(action.selectors)) { for (const selector of action.selectors) { if (selector) { selectors.push(selector); } } } return selectors; } function findByXPath(xpath) { try { return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; } catch (_) { return null; } } function findByText(text) { const needle = cleanText(text); if (!needle) { return null; } const candidates = Array.from(document.querySelectorAll("button,a,input,textarea,select,[role='button'],[role='link'],label")); return candidates.find((node) => cleanText(node.innerText || node.value || node.getAttribute("aria-label") || node.textContent).includes(needle)) || null; } function findElement(action) { for (const rawSelector of selectorList(action)) { const selector = String(rawSelector).trim(); if (!selector) { continue; } if (selector.startsWith("xpath=")) { const node = findByXPath(selector.slice("xpath=".length)); if (node) { return node; } continue; } if (selector.startsWith("//") || selector.startsWith("(//")) { const node = findByXPath(selector); if (node) { return node; } continue; } try { const node = document.querySelector(selector); if (node) { return node; } } catch (_) { continue; } } return findByText(action.text || action.label); } function describeElement(node) { if (!node) { return null; } return { tag: String(node.tagName || "").toLowerCase(), id: node.id || "", name: node.getAttribute ? node.getAttribute("name") || "" : "", text: cleanText(node.innerText || node.value || node.textContent).slice(0, 120), }; } function dispatchFieldEvents(node) { node.dispatchEvent(new Event("input", { bubbles: true })); node.dispatchEvent(new Event("change", { bubbles: true })); } function runAction(action, index, shouldExecute) { const type = normalizeType(action.type); const result = { index, type: action.type, label: action.label || "", mode: shouldExecute ? "execute" : "dry-run", ok: true, }; if (type === "asserttext" || type === "waitfortext") { const needle = cleanText(action.text || action.value); const haystack = cleanText(document.body ? document.body.innerText : document.documentElement.textContent); result.ok = needle ? haystack.includes(needle) : false; result.expected_text = needle; return result; } if (type === "extracttext") { const target = findElement(action) || document.body || document.documentElement; result.text = cleanText(target.innerText || target.textContent).slice(0, Number(action.max_chars || 2000)); result.element = describeElement(target); return result; } const element = findElement(action); result.element = describeElement(element); if (!element) { result.ok = false; result.error = "target element not found"; return result; } if (type === "click" || type === "submit") { if (shouldExecute) { element.scrollIntoView({ block: "center", inline: "center" }); if (typeof element.focus === "function") { element.focus(); } if (type === "submit" && element.form) { element.form.requestSubmit ? element.form.requestSubmit() : element.form.submit(); } else { element.click(); } } return result; } if (type === "fill" || type === "input" || type === "type") { result.value = action.value; if (shouldExecute) { element.focus && element.focus(); element.value = String(action.value); dispatchFieldEvents(element); } return result; } if (type === "select") { result.value = action.value; if (shouldExecute) { element.value = String(action.value); dispatchFieldEvents(element); } return result; } if (type === "check" || type === "uncheck") { result.checked = type === "check"; if (shouldExecute) { element.checked = type === "check"; dispatchFieldEvents(element); } return result; } result.ok = false; result.error = "unsupported action type"; return result; } const mode = normalizeMode(input.mode || input.execution_mode || input.executionMode); const shouldExecute = mode === "execute"; const actionResults = []; let ok = true; for (let index = 0; index < TRACE.actions.length; index += 1) { const actionResult = runAction(TRACE.actions[index], index, shouldExecute); actionResults.push(actionResult); if (!actionResult.ok) { ok = false; break; } } return JSON.stringify({ ok, trace_id: TRACE.id, trace_name: TRACE.name, mode, executed: shouldExecute, expected_domain: TRACE.expected_domain, url: window.location.href, title: document.title || "", action_count: TRACE.actions.length, action_results: actionResults, finished_at: new Date().toISOString(), }); `; } function isMutatingAction(action) { const type = String(action.type || "").trim().toLowerCase(); const risk = String(action.risk || "").trim().toLowerCase(); return MUTATING_TYPES.has(type) || WRITE_RISKS.has(risk); } function needsTarget(type) { const normalized = String(type || "").replace(/[\s_-]+/g, "").toLowerCase(); return !["asserttext", "waitfortext"].includes(normalized); } function needsValue(type) { const normalized = String(type || "").replace(/[\s_-]+/g, "").toLowerCase(); return ["fill", "input", "type", "select"].includes(normalized); } function normalizeMode(value) { const mode = String(value || "").trim().toLowerCase(); if (mode === "dry-run" || mode === "dryrun" || mode === "dry_run") { return "dry-run"; } if (mode === "execute") { return "execute"; } throw new Error(`Unsupported default mode: ${value}`); } function cleanId(value, label) { const cleaned = toKebabId(value); if (!cleaned) { throw new Error(`Invalid ${label}: ${value}`); } return cleaned; } function toKebabId(value) { return String(value || "") .trim() .replace(/([a-z0-9])([A-Z])/g, "$1-$2") .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } function requireString(value, label) { if (typeof value !== "string" || !value.trim()) { throw new Error(`${label} must be a non-empty string.`); } return value.trim(); } function parseHttpUrl(value, label) { try { const url = new URL(value); if (!["http:", "https:"].includes(url.protocol)) { throw new Error("Only http/https URLs are supported."); } return url; } catch (error) { throw new Error(`${label} must be a valid http/https URL. ${error.message}`); } } function tomlString(value) { return JSON.stringify(String(value)); } function parseCliArgs(argv) { const args = { defaultMode: "dry-run", force: false, }; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; if (token === "--trace") { args.trace = argv[++index]; } else if (token === "--out") { args.out = argv[++index]; } else if (token === "--default-mode" || token === "--mode") { args.defaultMode = argv[++index]; } else if (token === "--force") { args.force = true; } else if (token === "--help" || token === "-h") { args.help = true; } else { throw new Error(`Unknown argument: ${token}`); } } return args; } function usage() { return `Usage: node scripts/generate_sgrobot_flow.mjs --trace --out [--default-mode dry-run|execute] [--force] `; } async function main() { const cli = parseCliArgs(process.argv.slice(2)); if (cli.help) { console.log(usage()); return; } if (!cli.trace || !cli.out) { throw new Error(`${usage()}Both --trace and --out are required.`); } const trace = JSON.parse(await readFile(cli.trace, "utf8")); const result = generateSgRobotArtifacts(trace, { defaultMode: cli.defaultMode }); const written = await writeArtifacts(cli.out, result.files, { force: cli.force }); console.log(JSON.stringify({ ...result.summary, written }, null, 2)); } const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isCli) { main().catch((error) => { console.error(error.message); process.exitCode = 1; }); }