From 193cd45db8e164687b14fd23f52d6850ca571c9c Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Fri, 24 Apr 2026 02:20:06 -0500 Subject: [PATCH] feat(installer): improve cursor and opencode skill handling --- README.md | 2 + docs/CURSOR.md | 29 +- docs/INSTALLER.md | 2 +- docs/OPENCODE.md | 17 +- docs/WEB-AUTOMATION.md | 16 + scripts/lib/skill-manager-core.mjs | 131 +- scripts/manage-skills.mjs | 35 +- scripts/tests/skill-manager-core.test.mjs | 191 ++- skills/create-plan/cursor/SKILL.md | 10 +- skills/create-plan/opencode/SKILL.md | 7 +- skills/do-task/cursor/SKILL.md | 14 +- skills/do-task/opencode/SKILL.md | 19 +- skills/implement-plan/cursor/SKILL.md | 14 +- skills/implement-plan/opencode/SKILL.md | 7 +- skills/web-automation/cursor/SKILL.md | 112 ++ skills/web-automation/cursor/scripts/auth.ts | 575 ++++++++ .../web-automation/cursor/scripts/browse.ts | 188 +++ .../cursor/scripts/check-install.js | 40 + .../web-automation/cursor/scripts/extract.js | 188 +++ skills/web-automation/cursor/scripts/flow.ts | 329 +++++ .../cursor/scripts/package.json | 36 + .../cursor/scripts/pnpm-lock.yaml | 1292 +++++++++++++++++ .../cursor/scripts/scan-local-app.ts | 174 +++ .../web-automation/cursor/scripts/scrape.ts | 351 +++++ .../cursor/scripts/test-full.ts | 36 + .../cursor/scripts/test-minimal.ts | 23 + .../cursor/scripts/test-profile.ts | 33 + .../cursor/scripts/tsconfig.json | 16 + .../cursor/scripts/turndown-plugin-gfm.d.ts | 8 + .../web-automation/scripts/sync-variants.sh | 1 + 30 files changed, 3832 insertions(+), 64 deletions(-) create mode 100644 skills/web-automation/cursor/SKILL.md create mode 100644 skills/web-automation/cursor/scripts/auth.ts create mode 100644 skills/web-automation/cursor/scripts/browse.ts create mode 100644 skills/web-automation/cursor/scripts/check-install.js create mode 100755 skills/web-automation/cursor/scripts/extract.js create mode 100644 skills/web-automation/cursor/scripts/flow.ts create mode 100644 skills/web-automation/cursor/scripts/package.json create mode 100644 skills/web-automation/cursor/scripts/pnpm-lock.yaml create mode 100644 skills/web-automation/cursor/scripts/scan-local-app.ts create mode 100644 skills/web-automation/cursor/scripts/scrape.ts create mode 100644 skills/web-automation/cursor/scripts/test-full.ts create mode 100644 skills/web-automation/cursor/scripts/test-minimal.ts create mode 100644 skills/web-automation/cursor/scripts/test-profile.ts create mode 100644 skills/web-automation/cursor/scripts/tsconfig.json create mode 100644 skills/web-automation/cursor/scripts/turndown-plugin-gfm.d.ts diff --git a/README.md b/README.md index fc4da2c..fd45d07 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ ai-coding-skills/ │ └── web-automation/ │ ├── codex/ │ ├── claude-code/ +│ ├── cursor/ │ ├── opencode/ │ └── pi/ ├── .codex/ @@ -104,6 +105,7 @@ ai-coding-skills/ | implement-plan | pi | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) | | web-automation | codex | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) | | web-automation | claude-code | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) | +| web-automation | cursor | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) | | web-automation | opencode | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) | | web-automation | pi | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) | diff --git a/docs/CURSOR.md b/docs/CURSOR.md index 802b3c6..d50e7d1 100644 --- a/docs/CURSOR.md +++ b/docs/CURSOR.md @@ -23,7 +23,21 @@ mkdir -p ~/.cursor/skills/create-plan cp -R skills/create-plan/cursor/* ~/.cursor/skills/create-plan/ ``` -Cursor variants exist for `atlassian`, `create-plan`, `do-task`, and `implement-plan`. `web-automation` currently has no Cursor variant. +Cursor variants exist for `atlassian`, `create-plan`, `do-task`, `implement-plan`, and `web-automation`. + +Web automation repo-local install: + +```bash +mkdir -p .cursor/skills/web-automation +cp -R skills/web-automation/cursor/* .cursor/skills/web-automation/ +cd .cursor/skills/web-automation/scripts +pnpm install +npx cloakbrowser install +pnpm approve-builds +pnpm rebuild better-sqlite3 esbuild +``` + +Global web automation installs use `~/.cursor/skills/web-automation/` instead. ## Reviewer Runtime @@ -40,14 +54,19 @@ Global uses `~/.cursor/skills/reviewer-runtime/` instead. ## Superpowers -Cursor variants expect the nested Superpowers layout: +Cursor can discover Superpowers from the Cursor plugin cache or from manual +repo-local/global skill roots. Prefer the plugin install when it is present; +do not also install a manual Superpowers copy, or Cursor may show each +Superpowers skill twice. ```bash +.cursor/plugins/cache/cursor-public/superpowers//skills//SKILL.md +~/.cursor/plugins/cache/cursor-public/superpowers//skills//SKILL.md .cursor/skills/superpowers/skills//SKILL.md ~/.cursor/skills/superpowers/skills//SKILL.md ``` -Recommended symlink: +Manual symlink, only when the Cursor plugin is not installed: ```bash mkdir -p .cursor/skills/superpowers @@ -72,7 +91,7 @@ Repo-local scope: ```bash cursor-agent --version test -f .cursor/skills/create-plan/SKILL.md -test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md +test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q . ``` Global scope: @@ -80,5 +99,5 @@ Global scope: ```bash cursor-agent --version test -f ~/.cursor/skills/create-plan/SKILL.md -test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md +test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q . ``` diff --git a/docs/INSTALLER.md b/docs/INSTALLER.md index 998bc60..0b06fd4 100644 --- a/docs/INSTALLER.md +++ b/docs/INSTALLER.md @@ -50,7 +50,7 @@ The current skill matrix is: | `create-plan` | yes | yes | yes | yes | yes | | `do-task` | yes | yes | yes | yes | yes | | `implement-plan` | yes | yes | yes | yes | yes | -| `web-automation` | yes | yes | no | yes | yes | +| `web-automation` | yes | yes | yes | yes | yes | ## Superpowers Handling diff --git a/docs/OPENCODE.md b/docs/OPENCODE.md index 0f94eda..058b828 100644 --- a/docs/OPENCODE.md +++ b/docs/OPENCODE.md @@ -28,13 +28,21 @@ chmod +x ~/.config/opencode/skills/reviewer-runtime/*.sh ## Superpowers -OpenCode variants expect Superpowers at: +OpenCode can discover Superpowers from the shared agents skill root or the +OpenCode-specific skills root: ```bash +~/.agents/skills/superpowers ~/.config/opencode/skills/superpowers ``` -Example: +OpenCode's native setup commonly exposes the shared agents root: + +```bash +ln -s /absolute/path/to/obra/superpowers/skills ~/.agents/skills/superpowers +``` + +OpenCode-specific setup is also supported: ```bash ln -s /absolute/path/to/obra/superpowers/skills ~/.config/opencode/skills/superpowers @@ -43,9 +51,8 @@ ln -s /absolute/path/to/obra/superpowers/skills ~/.config/opencode/skills/superp Verify: ```bash -ls -l ~/.config/opencode/skills/superpowers -test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md -test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md +test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md +test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md ``` ## OpenCode Reviewer Notes diff --git a/docs/WEB-AUTOMATION.md b/docs/WEB-AUTOMATION.md index f2a82c6..05e62eb 100644 --- a/docs/WEB-AUTOMATION.md +++ b/docs/WEB-AUTOMATION.md @@ -48,6 +48,22 @@ pnpm approve-builds pnpm rebuild better-sqlite3 esbuild ``` +### Cursor + +Repo-local install: + +```bash +mkdir -p .cursor/skills/web-automation +cp -R skills/web-automation/cursor/* .cursor/skills/web-automation/ +cd .cursor/skills/web-automation/scripts +pnpm install +npx cloakbrowser install +pnpm approve-builds +pnpm rebuild better-sqlite3 esbuild +``` + +Global installs use `~/.cursor/skills/web-automation/` instead. + ### OpenCode ```bash diff --git a/scripts/lib/skill-manager-core.mjs b/scripts/lib/skill-manager-core.mjs index ed96d91..27f88c9 100644 --- a/scripts/lib/skill-manager-core.mjs +++ b/scripts/lib/skill-manager-core.mjs @@ -1,4 +1,4 @@ -import { access, cp, lstat, mkdir, readFile, realpath, rm, stat, symlink, chmod, unlink } from "node:fs/promises"; +import { access, cp, lstat, mkdir, readFile, realpath, rm, stat, symlink, chmod, unlink, readdir } from "node:fs/promises"; import { constants, existsSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; @@ -66,7 +66,7 @@ export const CLIENTS = { scopes: { global: { skillsRoot: "~/.config/opencode/skills" } }, variant: "opencode", superpowers: { - roots: ["~/.config/opencode/skills/superpowers"], + roots: ["~/.agents/skills/superpowers", "~/.config/opencode/skills/superpowers"], layout: "flat", }, reviewerRuntime: { @@ -130,7 +130,7 @@ export const SKILLS = { }, "web-automation": { name: "web-automation", - variants: ["codex", "claude-code", "opencode", "pi"], + variants: ["codex", "claude-code", "cursor", "opencode", "pi"], requiresSuperpowers: [], requiresReviewerRuntime: false, bootstrap: "web-automation", @@ -138,9 +138,9 @@ export const SKILLS = { }, }; -export function expandHome(value, cwd = process.cwd()) { +export function expandHome(value, cwd = process.cwd(), homeDir = homedir()) { if (!value) return value; - let expanded = value.replace(/^~(?=$|\/)/, homedir()); + let expanded = value.replace(/^~(?=$|\/)/, homeDir); if (!path.isAbsolute(expanded)) expanded = path.resolve(cwd, expanded); return expanded; } @@ -243,14 +243,57 @@ export async function detectInstalledSkills({ skillsRoot, clientId, repoRoot = p return state; } -export async function findInstalledSuperpowers(clientId, cwd = process.cwd()) { +export async function findInstalledSuperpowers(clientId, cwd = process.cwd(), { homeDir = homedir() } = {}) { const client = CLIENTS[clientId]; - const found = []; + const found = new Set(); for (const root of client.superpowers.roots) { - const expanded = expandHome(root, cwd); - if (await pathExists(expanded)) found.push(expanded); + const expanded = expandHome(root, cwd, homeDir); + if (await pathExists(expanded)) found.add(expanded); } - return found; + if (clientId === "claude-code") { + for (const root of await findClaudeCodeSuperpowersPluginRoots(homeDir)) found.add(root); + } + if (clientId === "cursor") { + for (const root of await findCursorSuperpowersPluginRoots(homeDir)) found.add(root); + } + return [...found]; +} + +async function findClaudeCodeSuperpowersPluginRoots(homeDir) { + const pluginId = "superpowers@claude-plugins-official"; + const settings = await readJsonIfExists(path.join(homeDir, ".claude", "settings.json")); + if (settings?.enabledPlugins?.[pluginId] !== true) return []; + + const installed = await readJsonIfExists(path.join(homeDir, ".claude", "plugins", "installed_plugins.json")); + const entries = installed?.plugins?.[pluginId]; + if (!Array.isArray(entries)) return []; + + const roots = []; + for (const entry of entries) { + if (!entry?.installPath) continue; + const skillsRoot = path.join(entry.installPath, "skills"); + if (await pathExists(skillsRoot)) roots.push(skillsRoot); + } + return roots; +} + +async function findCursorSuperpowersPluginRoots(homeDir) { + const pluginRoot = path.join(homeDir, ".cursor", "plugins", "cache", "cursor-public", "superpowers"); + let entries = []; + try { + entries = await readdir(pluginRoot, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + + const roots = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillsRoot = path.join(pluginRoot, entry.name, "skills"); + if (await pathExists(skillsRoot)) roots.push(skillsRoot); + } + return roots.sort(); } function piPackageSettingsPath(scope, repoRoot) { @@ -286,6 +329,19 @@ export function isBootstrapInstalled(action, target) { return false; } +export async function detectHelperInstallState({ source, target, files }) { + let differs = false; + for (const file of files) { + const sourceFile = path.join(source, file); + const targetFile = path.join(target, file); + if (!existsSync(targetFile)) return "not-installed"; + if (!existsSync(sourceFile)) return "unknown"; + const [sourceContents, targetContents] = await Promise.all([readFile(sourceFile), readFile(targetFile)]); + if (!sourceContents.equals(targetContents)) differs = true; + } + return differs ? "stale" : "installed"; +} + export function piPackageCommand({ action, repoRoot, piInstallArg = "" }) { if (["install", "update", "reinstall"].includes(action)) { const args = ["install"]; @@ -311,10 +367,36 @@ export function requiredSuperpowersFor(actions) { return [...required]; } +async function buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot, action = null }) { + if (action === "skip") return null; + const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot); + const helperSource = path.join(repoRoot, CLIENTS[clientId].reviewerRuntime.source); + const helperFiles = CLIENTS[clientId].reviewerRuntime.files; + const helperState = await detectHelperInstallState({ source: helperSource, target: helperTarget, files: helperFiles }); + const effectiveAction = action || (helperState === "stale" || helperState === "unknown" ? "update" : "install"); + const operation = { + kind: "helper", + helper: "reviewer-runtime", + clientId, + scope, + action: effectiveAction, + source: helperSource, + target: helperTarget, + files: helperFiles, + skillsRoot, + }; + if (!action && helperState === "installed") { + operation.status = "skipped"; + operation.details = "runtime helper already installed"; + } + return operation; +} + export async function buildOperationPlan({ selections, repoRoot = process.cwd(), assumeYes = false, superpowersByClient = null }) { const operations = []; const prompts = []; const reportRows = []; + const plannedHelpers = new Set(); for (const selection of selections || []) { const { clientId, scope = "global" } = selection; @@ -380,6 +462,7 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(), } const installed = await detectInstalledSkills({ skillsRoot, clientId, repoRoot }); const actions = selection.actions || {}; + const helperActions = selection.helperActions || {}; const missingSuperpowers = requiredSuperpowersFor(actions); if (missingSuperpowers.length > 0) { @@ -391,6 +474,16 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(), } } + for (const [helper, action] of Object.entries(helperActions)) { + if (helper !== "reviewer-runtime") continue; + const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot); + const helperKey = `${clientId}\0${scope}\0${helperTarget}`; + if (plannedHelpers.has(helperKey)) continue; + plannedHelpers.add(helperKey); + const helperOperation = await buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot, action }); + if (helperOperation) operations.push(helperOperation); + } + for (const [skillName, action] of Object.entries(actions)) { const source = getSkillSource(skillName, clientId, repoRoot); if (!source) { @@ -405,10 +498,15 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(), operations.push({ kind: "skill", clientId, scope, skill: skillName, action, source, target, skillsRoot }); if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].requiresReviewerRuntime) { const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot); - operations.push({ kind: "helper", helper: "reviewer-runtime", clientId, scope, action: "install", source: path.join(repoRoot, CLIENTS[clientId].reviewerRuntime.source), target: helperTarget, files: CLIENTS[clientId].reviewerRuntime.files, skillsRoot }); + const helperKey = `${clientId}\0${scope}\0${helperTarget}`; + if (!plannedHelpers.has(helperKey)) { + plannedHelpers.add(helperKey); + const helperOperation = await buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot }); + if (helperOperation) operations.push(helperOperation); + } } if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].bootstrap) { - operations.push({ kind: "bootstrap", clientId, scope, skill: skillName, action: SKILLS[skillName].bootstrap, target }); + operations.push({ kind: "bootstrap", clientId, scope, skill: skillName, item: `${skillName} deps`, action: SKILLS[skillName].bootstrap, displayAction: "bootstrap-deps", target }); } } @@ -424,7 +522,7 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(), reportRows.push({ client: op.clientId, scope: op.scope, - item: op.skill || op.helper || op.kind, + item: op.item || op.skill || op.helper || op.kind, action: op.displayAction || op.action, status: op.status || "planned", details: op.details || op.target || "", @@ -520,6 +618,13 @@ export async function executeOperation(op) { return { ...op, status: "ok" }; } if (op.kind === "helper") { + if (op.action === "remove") { + await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT }); + const info = existsSync(op.target) ? await lstat(op.target) : null; + if (info?.isSymbolicLink()) await unlink(op.target); + else await rm(op.target, { recursive: true, force: true }); + return { ...op, status: "ok" }; + } await installHelperAllowlist(op); return { ...op, status: "ok" }; } diff --git a/scripts/manage-skills.mjs b/scripts/manage-skills.mjs index 9cbb3bd..5050fcf 100755 --- a/scripts/manage-skills.mjs +++ b/scripts/manage-skills.mjs @@ -8,6 +8,7 @@ import { CLIENTS, SKILLS, buildOperationPlan, + detectHelperInstallState, detectInstalledClients, detectInstalledSkills, executeOperation, @@ -42,7 +43,8 @@ Answers JSON example: { "clientId": "codex", "scope": "global", - "actions": { "create-plan": "install", "web-automation": "skip" } + "actions": { "create-plan": "install", "web-automation": "skip" }, + "helperActions": { "reviewer-runtime": "skip" } } ] } @@ -168,7 +170,32 @@ async function interactiveAnswers({ dryRun = false } = {}) { actions[skill] = chosen; } } - selections.push({ clientId, scope, actions }); + const helperActions = {}; + const workflowNeedsReviewerRuntime = Object.entries(actions).some(([skill, action]) => ( + ["install", "update", "reinstall"].includes(action) && SKILLS[skill]?.requiresReviewerRuntime + )); + const helperTarget = reviewerRuntimeRoot(clientId, scopeInfo.skillsRoot, process.cwd()); + const helperState = await detectHelperInstallState({ + source: path.join(process.cwd(), CLIENTS[clientId].reviewerRuntime.source), + target: helperTarget, + files: CLIENTS[clientId].reviewerRuntime.files, + }); + if (workflowNeedsReviewerRuntime || helperState !== "not-installed") { + const choices = helperState === "not-installed" ? "install/skip" : "update/reinstall/remove/skip"; + let defaultAction = "skip"; + if (workflowNeedsReviewerRuntime && helperState === "not-installed") defaultAction = "install"; + if (workflowNeedsReviewerRuntime && ["stale", "unknown"].includes(helperState)) defaultAction = "update"; + const answer = await rl.question(`${clientId}/${scope}/reviewer-runtime is ${helperState}; action (${choices}) [${defaultAction}]: `); + const chosen = answer.trim() || defaultAction; + const allowed = choices.split("/"); + if (!allowed.includes(chosen)) { + console.log(`Invalid action '${chosen}', using skip.`); + helperActions["reviewer-runtime"] = "skip"; + } else { + helperActions["reviewer-runtime"] = chosen; + } + } + selections.push({ clientId, scope, actions, helperActions }); } return { selections }; } finally { @@ -212,6 +239,8 @@ async function main() { } } + if (plan.operations.length === 0) return; + if (args.dryRun) { console.log("\nDry-run mode: no filesystem changes performed."); return; @@ -275,7 +304,7 @@ async function main() { const rows = results.map((op) => ({ client: op.clientId, scope: op.scope, - item: op.skill || op.helper || op.kind, + item: op.item || op.skill || op.helper || op.kind, action: op.displayAction || op.action, status: op.status, details: op.details || op.target || "", diff --git a/scripts/tests/skill-manager-core.test.mjs b/scripts/tests/skill-manager-core.test.mjs index c90aa54..b25e82a 100644 --- a/scripts/tests/skill-manager-core.test.mjs +++ b/scripts/tests/skill-manager-core.test.mjs @@ -11,6 +11,7 @@ import { SKILLS, buildOperationPlan, detectInstalledSkills, + findInstalledSuperpowers, getSkillSource, piPackageCommand, parseReviewerShorthand, @@ -20,8 +21,7 @@ import { const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); test("manifest records supported variants and helper allowlists", () => { - assert.deepEqual(SKILLS["web-automation"].variants, ["codex", "claude-code", "opencode", "pi"]); - assert.equal(SKILLS["web-automation"].variants.includes("cursor"), false); + assert.deepEqual(SKILLS["web-automation"].variants, ["codex", "claude-code", "cursor", "opencode", "pi"]); assert.deepEqual(CLIENTS.codex.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]); assert.deepEqual(CLIENTS.pi.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]); }); @@ -39,7 +39,7 @@ test("parseReviewerShorthand keeps provider-qualified model ids verbatim", () => }); test("unsupported skill variant is reported as unsupported", () => { - assert.equal(getSkillSource("web-automation", "cursor"), null); + assert.ok(getSkillSource("web-automation", "cursor").endsWith("skills/web-automation/cursor")); assert.ok(getSkillSource("web-automation", "pi").endsWith("pi-package/skills/web-automation")); }); @@ -75,6 +75,176 @@ test("plan install workflow skill includes reviewer-runtime and missing Superpow } }); +test("plan skips already current reviewer-runtime helper for workflow skill updates", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-current-")); + try { + const repo = path.join(dir, "repo"); + const install = path.join(dir, "install"); + await mkdir(path.join(repo, "skills", "create-plan", "cursor"), { recursive: true }); + await mkdir(path.join(repo, "skills", "do-task", "cursor"), { recursive: true }); + await writeFile(path.join(repo, "skills", "create-plan", "cursor", "SKILL.md"), "---\nname: create-plan\n---\n"); + await writeFile(path.join(repo, "skills", "do-task", "cursor", "SKILL.md"), "---\nname: do-task\n---\n"); + await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true }); + await mkdir(path.join(install, "reviewer-runtime"), { recursive: true }); + for (const file of CLIENTS.cursor.reviewerRuntime.files) { + await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}\n`); + await writeFile(path.join(install, "reviewer-runtime", file), `${file}\n`); + } + + const plan = await buildOperationPlan({ + selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: { "create-plan": "update", "do-task": "update" } }], + repoRoot: repo, + superpowersByClient: { cursor: [path.join(dir, "superpowers")] }, + }); + + const helperRows = plan.reportRows.filter((row) => row.item === "reviewer-runtime"); + assert.equal(helperRows.length, 1); + assert.equal(helperRows[0].action, "install"); + assert.equal(helperRows[0].status, "skipped"); + assert.match(helperRows[0].details, /already installed/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("plan auto-updates stale reviewer-runtime helper for workflow skill updates", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-stale-")); + try { + const repo = path.join(dir, "repo"); + const install = path.join(dir, "install"); + await mkdir(path.join(repo, "skills", "create-plan", "cursor"), { recursive: true }); + await writeFile(path.join(repo, "skills", "create-plan", "cursor", "SKILL.md"), "---\nname: create-plan\n---\n"); + await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true }); + await mkdir(path.join(install, "reviewer-runtime"), { recursive: true }); + for (const file of CLIENTS.cursor.reviewerRuntime.files) { + await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}:new\n`); + await writeFile(path.join(install, "reviewer-runtime", file), `${file}:old\n`); + } + + const plan = await buildOperationPlan({ + selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: { "create-plan": "update" } }], + repoRoot: repo, + superpowersByClient: { cursor: [path.join(dir, "superpowers")] }, + }); + + const helperRows = plan.reportRows.filter((row) => row.item === "reviewer-runtime"); + assert.equal(helperRows.length, 1); + assert.equal(helperRows[0].action, "update"); + assert.equal(helperRows[0].status, "planned"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("plan honors explicit reviewer-runtime helper actions", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-explicit-")); + try { + const repo = path.join(dir, "repo"); + const install = path.join(dir, "install"); + await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true }); + await mkdir(path.join(install, "reviewer-runtime"), { recursive: true }); + for (const file of CLIENTS.cursor.reviewerRuntime.files) { + await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}\n`); + await writeFile(path.join(install, "reviewer-runtime", file), `${file}\n`); + } + + const plan = await buildOperationPlan({ + selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: {}, helperActions: { "reviewer-runtime": "reinstall" } }], + repoRoot: repo, + }); + + assert.deepEqual(plan.reportRows.map((row) => [row.item, row.action, row.status]), [ + ["reviewer-runtime", "reinstall", "planned"], + ]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("plan labels skill bootstrap rows as dependency rows", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-bootstrap-label-")); + try { + const repo = path.join(dir, "repo"); + const install = path.join(dir, "install"); + await mkdir(path.join(repo, "skills", "web-automation", "claude-code"), { recursive: true }); + await writeFile(path.join(repo, "skills", "web-automation", "claude-code", "SKILL.md"), "---\nname: web-automation\n---\n"); + + const plan = await buildOperationPlan({ + selections: [{ clientId: "claude-code", scope: "global", skillsRoot: install, actions: { "web-automation": "update" } }], + repoRoot: repo, + }); + + assert.deepEqual(plan.reportRows.map((row) => [row.item, row.action]), [ + ["web-automation", "update"], + ["web-automation deps", "bootstrap-deps"], + ]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("findInstalledSuperpowers detects Claude Code Superpowers plugin installs", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-claude-superpowers-")); + try { + const installPath = path.join(dir, ".claude", "plugins", "cache", "claude-plugins-official", "superpowers", "4.2.0"); + await mkdir(path.join(installPath, "skills", "brainstorming"), { recursive: true }); + await writeFile(path.join(installPath, "skills", "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n"); + await mkdir(path.join(dir, ".claude", "plugins"), { recursive: true }); + await writeFile(path.join(dir, ".claude", "settings.json"), JSON.stringify({ + enabledPlugins: { + "superpowers@claude-plugins-official": true, + }, + })); + await writeFile(path.join(dir, ".claude", "plugins", "installed_plugins.json"), JSON.stringify({ + plugins: { + "superpowers@claude-plugins-official": [ + { + scope: "user", + installPath, + version: "4.2.0", + }, + ], + }, + })); + + assert.deepEqual(await findInstalledSuperpowers("claude-code", process.cwd(), { homeDir: dir }), [ + path.join(installPath, "skills"), + ]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("findInstalledSuperpowers detects OpenCode shared agents Superpowers installs", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-opencode-superpowers-")); + try { + const sharedRoot = path.join(dir, ".agents", "skills", "superpowers"); + await mkdir(path.join(sharedRoot, "brainstorming"), { recursive: true }); + await writeFile(path.join(sharedRoot, "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n"); + + assert.deepEqual(await findInstalledSuperpowers("opencode", process.cwd(), { homeDir: dir }), [ + sharedRoot, + ]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("findInstalledSuperpowers detects Cursor Superpowers plugin installs", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-cursor-superpowers-")); + try { + const pluginSkills = path.join(dir, ".cursor", "plugins", "cache", "cursor-public", "superpowers", "abc123", "skills"); + await mkdir(path.join(pluginSkills, "brainstorming"), { recursive: true }); + await writeFile(path.join(pluginSkills, "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n"); + + assert.deepEqual(await findInstalledSuperpowers("cursor", process.cwd(), { homeDir: dir }), [ + pluginSkills, + ]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("plan removing last workflow skill prompts for optional Superpowers removal", async () => { const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-remove-")); try { @@ -230,6 +400,21 @@ test("cli package mode preserves package action and ignores skill narrowing", () assert.equal(plan.operations[0].action, "remove"); }); +test("cli exits without confirmation when no operations are planned", () => { + const output = execFileSync(process.execPath, [ + path.join(REPO_ROOT, "scripts", "manage-skills.mjs"), + "--answers", + "/dev/stdin", + ], { + cwd: REPO_ROOT, + encoding: "utf8", + input: JSON.stringify({ selections: [{ clientId: "pi", scope: "packageGlobal", action: "skip", actions: {} }] }), + }); + assert.match(output, /No operations planned\./); + assert.doesNotMatch(output, /Proceed with these operations/); + assert.doesNotMatch(output, /Final report/); +}); + test("validateRemoveTarget rejects paths outside the manifest root", async () => { const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-safe-")); try { diff --git a/skills/create-plan/cursor/SKILL.md b/skills/create-plan/cursor/SKILL.md index 99e0732..d9c8b8c 100644 --- a/skills/create-plan/cursor/SKILL.md +++ b/skills/create-plan/cursor/SKILL.md @@ -15,7 +15,7 @@ This skill wraps the current Superpowers flow for the Cursor Agent CLI (`cursor- 3. Review the plan iteratively with a second model/provider 4. Persist a local execution package in `ai_plan/YYYY-MM-DD-/` -**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local or `~/.cursor/skills/` global). It also reads `AGENTS.md` at the repo root for additional instructions. +**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local), `~/.cursor/skills/` (global), and installed Cursor plugin cache entries. It also reads `AGENTS.md` at the repo root for additional instructions. ## Prerequisite Check (MANDATORY) @@ -23,7 +23,7 @@ Required: - Cursor Agent CLI: `cursor-agent --version` (install via `curl https://cursor.com/install -fsS | bash`). The binary is `cursor-agent` (installed to `~/.local/bin/`). Some environments alias it as `cursor agent` (subcommand of the Cursor IDE CLI) — both forms work, but this skill uses `cursor-agent` throughout. - `jq` (required only if using `cursor` as the reviewer CLI): `jq --version` (install via `brew install jq` or your package manager) - Superpowers repo: `https://github.com/obra/superpowers` -- Superpowers skills installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global) +- Superpowers skills available from the Cursor plugin cache, `.cursor/skills/` (repo-local), or `~/.cursor/skills/` (global). Do not install both the plugin and a manual Superpowers copy, or Cursor may show duplicate skill entries. - `superpowers:brainstorming` - `superpowers:writing-plans` @@ -31,15 +31,15 @@ Verify before proceeding: ```bash cursor-agent --version -test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md -test -f .cursor/skills/superpowers/skills/writing-plans/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/writing-plans/SKILL.md +test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q . +test -f .cursor/skills/superpowers/skills/writing-plans/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/writing-plans/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/writing-plans/SKILL.md' -print -quit 2>/dev/null | grep -q . # Only if using cursor as reviewer CLI: # jq --version ``` If any dependency is missing, stop and return: -`Missing dependency: Superpowers planning skills are required (superpowers:brainstorming, superpowers:writing-plans). Install from https://github.com/obra/superpowers and copy into .cursor/skills/ or ~/.cursor/skills/, then retry.` +`Missing dependency: Superpowers planning skills are required (superpowers:brainstorming, superpowers:writing-plans). Install the Cursor Superpowers plugin or install Superpowers under .cursor/skills/ or ~/.cursor/skills/, then retry.` ## Required Skill Invocation Rules diff --git a/skills/create-plan/opencode/SKILL.md b/skills/create-plan/opencode/SKILL.md index 8df5266..80509a5 100644 --- a/skills/create-plan/opencode/SKILL.md +++ b/skills/create-plan/opencode/SKILL.md @@ -9,18 +9,19 @@ Create and maintain a local plan folder under `ai_plan/` at project root. ## Prerequisite Check (MANDATORY) -This OpenCode variant depends on Superpowers skills being installed via OpenCode's native skill system. +This OpenCode variant depends on Superpowers skills being available through OpenCode's native skill system. Required: - Superpowers repo: `https://github.com/obra/superpowers` -- OpenCode Superpowers skills symlink: `~/.config/opencode/skills/superpowers` +- OpenCode Superpowers skills available at `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers` - `superpowers/brainstorming` - `superpowers/writing-plans` Verify before proceeding: ```bash -ls -l ~/.config/opencode/skills/superpowers +test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md +test -f ~/.agents/skills/superpowers/writing-plans/SKILL.md || test -f ~/.config/opencode/skills/superpowers/writing-plans/SKILL.md ``` If dependencies are missing, stop immediately and return: diff --git a/skills/do-task/cursor/SKILL.md b/skills/do-task/cursor/SKILL.md index 25cee0a..b1fdb1c 100644 --- a/skills/do-task/cursor/SKILL.md +++ b/skills/do-task/cursor/SKILL.md @@ -9,7 +9,7 @@ Execute an ad-hoc user prompt end-to-end: parse → clarify → plan (with revie This is a single-artifact sibling of `create-plan` + `implement-plan`. Unlike `implement-plan`, `do-task` operates on one persistent `task-plan.md` (not a full milestone plan) and defaults to the **current branch** (not a worktree). -**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global). It also reads `AGENTS.md` at the repo root for additional instructions. +**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local), `~/.cursor/skills/` (global), and installed Cursor plugin cache entries. It also reads `AGENTS.md` at the repo root for additional instructions. ## Prerequisite Check (MANDATORY) @@ -17,7 +17,7 @@ Required: - Cursor Agent CLI: `cursor-agent --version` (install via `curl https://cursor.com/install -fsS | bash`). Binary is `cursor-agent`; the alias `cursor agent` also works. - `jq` (**required** — `do-task` always parses JSON output from at least the cursor reviewer branch, and other reviewers may produce JSON). Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`. - Superpowers repo: `https://github.com/obra/superpowers` -- Superpowers skills installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global) +- Superpowers skills available from the Cursor plugin cache, `.cursor/skills/` (repo-local), or `~/.cursor/skills/` (global). Do not install both the plugin and a manual Superpowers copy, or Cursor may show duplicate skill entries. - `superpowers:brainstorming` - `superpowers:test-driven-development` - `superpowers:verification-before-completion` @@ -31,15 +31,15 @@ Verify before proceeding: ```bash cursor-agent --version jq --version -test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md -test -f .cursor/skills/superpowers/skills/test-driven-development/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/test-driven-development/SKILL.md -test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md -test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md +test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q . +test -f .cursor/skills/superpowers/skills/test-driven-development/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/test-driven-development/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/test-driven-development/SKILL.md' -print -quit 2>/dev/null | grep -q . +test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/verification-before-completion/SKILL.md' -print -quit 2>/dev/null | grep -q . +test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/finishing-a-development-branch/SKILL.md' -print -quit 2>/dev/null | grep -q . ``` If any required dependency is missing, stop immediately and return: -`Missing dependency: [specific missing item]. Install Cursor Agent CLI, jq, and Superpowers skills under .cursor/skills/ or ~/.cursor/skills/, then retry.` +`Missing dependency: [specific missing item]. Install Cursor Agent CLI, jq, and the Cursor Superpowers plugin or Superpowers skills under .cursor/skills/ or ~/.cursor/skills/, then retry.` ## Required Skill Invocation Rules diff --git a/skills/do-task/opencode/SKILL.md b/skills/do-task/opencode/SKILL.md index 4d1953e..a07cf09 100644 --- a/skills/do-task/opencode/SKILL.md +++ b/skills/do-task/opencode/SKILL.md @@ -9,14 +9,14 @@ Execute an ad-hoc user prompt end-to-end: parse → clarify → plan (with revie This is a single-artifact sibling of `create-plan` + `implement-plan`. Unlike `implement-plan`, `do-task` operates on one persistent `task-plan.md` (not a full milestone plan) and defaults to the **current branch** (not a worktree). -**Core principle:** OpenCode loads skills through its native skill tool from `~/.config/opencode/skills/`. Sub-skill invocations use OpenCode's native mechanism — not Claude's `Skill` tool, not Codex's native-discovery patterns, not Cursor's workspace discovery. +**Core principle:** OpenCode loads skills through its native skill tool. Local skills live under `~/.config/opencode/skills/`, and OpenCode can also expose shared agent skills from `~/.agents/skills/`. Sub-skill invocations use OpenCode's native mechanism — not Claude's `Skill` tool, not Cursor's workspace discovery. ## Prerequisite Check (MANDATORY) Required: - OpenCode CLI: `opencode --version` (install via your package manager or `brew install opencode`). - Superpowers repo: `https://github.com/obra/superpowers` -- OpenCode Superpowers skills symlink: `~/.config/opencode/skills/superpowers` +- OpenCode Superpowers skills available at `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers` - `superpowers/brainstorming` - `superpowers/test-driven-development` - `superpowers/verification-before-completion` @@ -29,11 +29,10 @@ Verify before proceeding: ```bash opencode --version -ls -l ~/.config/opencode/skills/superpowers -test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md -test -f ~/.config/opencode/skills/superpowers/test-driven-development/SKILL.md -test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md -test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md +test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md +test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.config/opencode/skills/superpowers/test-driven-development/SKILL.md +test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md +test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md ``` If any required dependency is missing, stop immediately and return: @@ -46,7 +45,7 @@ If any required dependency is missing, stop immediately and return: - Announce skill usage explicitly: - `I've read the [Skill Name] skill and I'm using it to [purpose].` - For skills with checklists, track checklist items explicitly in conversation. -- Do NOT use Claude's `Skill` tool syntax, Codex's `~/.agents/skills/` native-discovery paths, or Cursor's workspace discovery. OpenCode's skill system is independent. +- Do NOT use Claude's `Skill` tool syntax or Cursor's workspace discovery. OpenCode's skill system may expose shared files from `~/.agents/skills/`, but invocation still goes through OpenCode's native skill mechanism. ## Trigger Phrase Detection @@ -795,7 +794,7 @@ Review History is append-only. ## Variant Hardening Notes — OpenCode -- Must use OpenCode's native skill tool for sub-skill invocation. Do NOT use Claude's `Skill` tool syntax or Codex's `~/.agents/skills/` paths. +- Must use OpenCode's native skill tool for sub-skill invocation. Do NOT use Claude's `Skill` tool syntax. OpenCode may load shared skill files from `~/.agents/skills/`, but invocation is still OpenCode-native. - Phase 1 includes a Bootstrap Superpowers Context step that lists installed skills and confirms `superpowers/brainstorming`, `superpowers/test-driven-development`, `superpowers/verification-before-completion`, and `superpowers/finishing-a-development-branch` are discoverable before any other phase runs. - Helper paths are `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`. - OpenCode reviewer CLI branch (when `REVIEWER_CLI=opencode`): @@ -809,7 +808,7 @@ Review History is append-only. ## Common Mistakes - Skipping the Bootstrap Superpowers Context step in Phase 1 (breaks native skill discovery). -- Using Claude `Skill` tool syntax or Codex `~/.agents/skills/` paths. +- Using Claude `Skill` tool syntax, or treating shared `~/.agents/skills/` files as anything other than OpenCode-native skill entries. - Forgetting to set `--agent plan` on opencode reviewer calls (would use the default `build` agent which can write files). - Asking multiple clarifying questions in a single message. - Skipping the per-payload secret scan because "the previous round was clean". diff --git a/skills/implement-plan/cursor/SKILL.md b/skills/implement-plan/cursor/SKILL.md index 9971fc8..9500f6f 100644 --- a/skills/implement-plan/cursor/SKILL.md +++ b/skills/implement-plan/cursor/SKILL.md @@ -16,7 +16,7 @@ This skill wraps the Superpowers execution flow for the Cursor Agent CLI (`curso 4. Review each milestone with a second model/provider 5. Commit approved milestones, merge to parent branch, and delete worktree -**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local or `~/.cursor/skills/` global). It also reads `AGENTS.md` at the repo root for additional instructions. +**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local), `~/.cursor/skills/` (global), and installed Cursor plugin cache entries. It also reads `AGENTS.md` at the repo root for additional instructions. ## Prerequisite Check (MANDATORY) @@ -28,7 +28,7 @@ Required: - `milestone-plan.md` exists in plan folder - `story-tracker.md` exists in plan folder - Git repo with worktree support: `git worktree list` -- Superpowers skills installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global) +- Superpowers skills available from the Cursor plugin cache, `.cursor/skills/` (repo-local), or `~/.cursor/skills/` (global). Do not install both the plugin and a manual Superpowers copy, or Cursor may show duplicate skill entries. - Superpowers execution skills: - `superpowers:executing-plans` - `superpowers:using-git-worktrees` @@ -39,17 +39,17 @@ Verify before proceeding: ```bash cursor-agent --version -test -f .cursor/skills/superpowers/skills/executing-plans/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/executing-plans/SKILL.md -test -f .cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md -test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md -test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md +test -f .cursor/skills/superpowers/skills/executing-plans/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/executing-plans/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/executing-plans/SKILL.md' -print -quit 2>/dev/null | grep -q . +test -f .cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/using-git-worktrees/SKILL.md' -print -quit 2>/dev/null | grep -q . +test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/verification-before-completion/SKILL.md' -print -quit 2>/dev/null | grep -q . +test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/finishing-a-development-branch/SKILL.md' -print -quit 2>/dev/null | grep -q . # Only if using cursor as reviewer CLI: # jq --version ``` If any dependency is missing, stop and return: -`Missing dependency: [specific missing item]. Install from https://github.com/obra/superpowers and copy into .cursor/skills/ or ~/.cursor/skills/, then retry.` +`Missing dependency: [specific missing item]. Install the Cursor Superpowers plugin or install Superpowers under .cursor/skills/ or ~/.cursor/skills/, then retry.` If no plan folder exists: diff --git a/skills/implement-plan/opencode/SKILL.md b/skills/implement-plan/opencode/SKILL.md index 7e642f7..442026c 100644 --- a/skills/implement-plan/opencode/SKILL.md +++ b/skills/implement-plan/opencode/SKILL.md @@ -17,7 +17,7 @@ Required: - `milestone-plan.md` exists in plan folder - `story-tracker.md` exists in plan folder - Git repo with worktree support: `git worktree list` -- OpenCode Superpowers skills symlink: `~/.config/opencode/skills/superpowers` +- OpenCode Superpowers skills available at `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers` - Superpowers execution skills: - `superpowers/executing-plans` - `superpowers/using-git-worktrees` @@ -27,7 +27,10 @@ Required: Verify before proceeding: ```bash -ls -l ~/.config/opencode/skills/superpowers +test -f ~/.agents/skills/superpowers/executing-plans/SKILL.md || test -f ~/.config/opencode/skills/superpowers/executing-plans/SKILL.md +test -f ~/.agents/skills/superpowers/using-git-worktrees/SKILL.md || test -f ~/.config/opencode/skills/superpowers/using-git-worktrees/SKILL.md +test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md +test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md ``` If dependencies are missing, stop immediately and return: diff --git a/skills/web-automation/cursor/SKILL.md b/skills/web-automation/cursor/SKILL.md new file mode 100644 index 0000000..adeef60 --- /dev/null +++ b/skills/web-automation/cursor/SKILL.md @@ -0,0 +1,112 @@ +--- +name: web-automation +description: Browse and scrape web pages using Playwright-compatible CloakBrowser. Use when automating web workflows, extracting rendered page content, handling authenticated sessions, or running multi-step browser flows. +--- + +# Web Automation with CloakBrowser (Cursor) + +Automated web browsing and scraping using Playwright-compatible CloakBrowser with two execution paths: + +- one-shot extraction via `extract.js` +- broader stateful automation via `auth.ts`, `browse.ts`, `flow.ts`, `scan-local-app.ts`, and `scrape.ts` + +## Requirements + +- Node.js 20+ +- pnpm +- Network access to download the CloakBrowser binary on first use + +## First-Time Setup + +Repo-local install: + +```bash +cd .cursor/skills/web-automation/scripts +pnpm install +npx cloakbrowser install +pnpm approve-builds +pnpm rebuild better-sqlite3 esbuild +``` + +Global install: + +```bash +cd ~/.cursor/skills/web-automation/scripts +pnpm install +npx cloakbrowser install +pnpm approve-builds +pnpm rebuild better-sqlite3 esbuild +``` + +## Updating CloakBrowser + +Run from the installed `scripts/` directory: + +```bash +pnpm up cloakbrowser playwright-core +npx cloakbrowser install +pnpm approve-builds +pnpm rebuild better-sqlite3 esbuild +``` + +## Prerequisite Check (MANDATORY) + +Before running automation, verify CloakBrowser and Playwright Core are installed and wired correctly. + +```bash +cd .cursor/skills/web-automation/scripts || cd ~/.cursor/skills/web-automation/scripts +node check-install.js +``` + +If the check fails, stop and return: + +"Missing dependency/config: web-automation requires `cloakbrowser` and `playwright-core` with CloakBrowser-based scripts. Run setup in this skill, then retry." + +If runtime fails with missing native bindings for `better-sqlite3` or `esbuild`, run: + +```bash +cd .cursor/skills/web-automation/scripts || cd ~/.cursor/skills/web-automation/scripts +pnpm approve-builds +pnpm rebuild better-sqlite3 esbuild +``` + +## When To Use Which Command + +- Use `node extract.js ""` for a one-shot rendered fetch with JSON output. +- Use `npx tsx scrape.ts ...` when you need markdown extraction, Readability cleanup, or selector-based scraping. +- Use `npx tsx browse.ts ...`, `auth.ts`, or `flow.ts` when the task needs login handling, persistent sessions, clicks, typing, screenshots, or multi-step navigation. +- Use `npx tsx scan-local-app.ts` when you need a configurable local-app smoke pass driven by `SCAN_*` and `CLOAKBROWSER_*` environment variables. + +## Quick Reference + +- Install check: `node check-install.js` +- One-shot JSON extract: `node extract.js "https://example.com"` +- Browse page: `npx tsx browse.ts --url "https://example.com"` +- Scrape markdown: `npx tsx scrape.ts --url "https://example.com" --mode main --output page.md` +- Authenticate: `npx tsx auth.ts --url "https://example.com/login"` +- Natural-language flow: `npx tsx flow.ts --instruction 'go to https://example.com then click on "Login" then type "user@example.com" in #email then press enter'` +- Local app smoke scan: `SCAN_BASE_URL=http://localhost:3000 SCAN_ROUTES=/,/dashboard npx tsx scan-local-app.ts` + +## Local App Smoke Scan + +`scan-local-app.ts` is intentionally generic. Configure it with environment variables instead of editing the file: + +- `SCAN_BASE_URL` +- `SCAN_LOGIN_PATH` +- `SCAN_USERNAME` +- `SCAN_PASSWORD` +- `SCAN_USERNAME_SELECTOR` +- `SCAN_PASSWORD_SELECTOR` +- `SCAN_SUBMIT_SELECTOR` +- `SCAN_ROUTES` +- `SCAN_REPORT_PATH` +- `SCAN_HEADLESS` + +If `SCAN_USERNAME` or `SCAN_PASSWORD` are omitted, the script falls back to `CLOAKBROWSER_USERNAME` and `CLOAKBROWSER_PASSWORD`. + +## Notes + +- Sessions persist in CloakBrowser profile storage. +- Use `--wait` for dynamic pages. +- Use `--mode selector --selector "..."` for targeted extraction. +- `extract.js` keeps a bounded stealth/rendered fetch path without needing a long-lived automation session. diff --git a/skills/web-automation/cursor/scripts/auth.ts b/skills/web-automation/cursor/scripts/auth.ts new file mode 100644 index 0000000..e79f23d --- /dev/null +++ b/skills/web-automation/cursor/scripts/auth.ts @@ -0,0 +1,575 @@ +#!/usr/bin/env npx tsx + +/** + * Authentication handler for web automation + * Supports generic form login and Microsoft SSO (MSAL) + * + * Usage: + * npx tsx auth.ts --url "https://example.com/login" --type form + * npx tsx auth.ts --url "https://example.com" --type msal + * npx tsx auth.ts --url "https://example.com" --type auto + */ + +import { getPage, launchBrowser } from './browse.js'; +import parseArgs from 'minimist'; +import type { Page, BrowserContext } from 'playwright-core'; +import { createInterface } from 'readline'; + +// Types +type AuthType = 'auto' | 'form' | 'msal'; + +interface AuthOptions { + url: string; + authType: AuthType; + credentials?: { + username: string; + password: string; + }; + headless?: boolean; + timeout?: number; +} + +interface AuthResult { + success: boolean; + finalUrl: string; + authType: AuthType; + message: string; +} + +// Get credentials from environment or options +function getCredentials(options?: { + username?: string; + password?: string; +}): { username: string; password: string } | null { + const username = options?.username || process.env.CLOAKBROWSER_USERNAME; + const password = options?.password || process.env.CLOAKBROWSER_PASSWORD; + + if (!username || !password) { + return null; + } + + return { username, password }; +} + +// Prompt user for input (for MFA or credentials) +async function promptUser(question: string, hidden = false): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + if (hidden) { + process.stdout.write(question); + // Note: This is a simple implementation. For production, use a proper hidden input library + } + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +// Detect authentication type from page +async function detectAuthType(page: Page): Promise { + const url = page.url(); + + // Check for Microsoft login + if ( + url.includes('login.microsoftonline.com') || + url.includes('login.live.com') || + url.includes('login.windows.net') + ) { + return 'msal'; + } + + // Check for common form login patterns + const hasLoginForm = await page.evaluate(() => { + const passwordField = document.querySelector( + 'input[type="password"], input[name*="password"], input[id*="password"]' + ); + const usernameField = document.querySelector( + 'input[type="email"], input[type="text"][name*="user"], input[type="text"][name*="email"], input[id*="user"], input[id*="email"]' + ); + return !!(passwordField && usernameField); + }); + + if (hasLoginForm) { + return 'form'; + } + + return 'auto'; +} + +// Handle generic form login +async function handleFormLogin( + page: Page, + credentials: { username: string; password: string }, + timeout: number +): Promise { + console.log('Attempting form login...'); + + // Find and fill username/email field + const usernameSelectors = [ + 'input[type="email"]', + 'input[name*="user" i]', + 'input[name*="email" i]', + 'input[id*="user" i]', + 'input[id*="email" i]', + 'input[autocomplete="username"]', + 'input[type="text"]:first-of-type', + ]; + + let usernameField = null; + for (const selector of usernameSelectors) { + usernameField = await page.$(selector); + if (usernameField) break; + } + + if (!usernameField) { + console.error('Could not find username/email field'); + return false; + } + + await usernameField.fill(credentials.username); + console.log('Filled username field'); + + // Find and fill password field + const passwordSelectors = [ + 'input[type="password"]', + 'input[name*="password" i]', + 'input[id*="password" i]', + 'input[autocomplete="current-password"]', + ]; + + let passwordField = null; + for (const selector of passwordSelectors) { + passwordField = await page.$(selector); + if (passwordField) break; + } + + if (!passwordField) { + console.error('Could not find password field'); + return false; + } + + await passwordField.fill(credentials.password); + console.log('Filled password field'); + + // Check for "Remember me" checkbox and check it + const rememberCheckbox = await page.$( + 'input[type="checkbox"][name*="remember" i], input[type="checkbox"][id*="remember" i]' + ); + if (rememberCheckbox) { + await rememberCheckbox.check(); + console.log('Checked "Remember me" checkbox'); + } + + // Find and click submit button + const submitSelectors = [ + 'button[type="submit"]', + 'input[type="submit"]', + 'button:has-text("Sign in")', + 'button:has-text("Log in")', + 'button:has-text("Login")', + 'button:has-text("Submit")', + '[role="button"]:has-text("Sign in")', + ]; + + let submitButton = null; + for (const selector of submitSelectors) { + submitButton = await page.$(selector); + if (submitButton) break; + } + + if (!submitButton) { + // Try pressing Enter as fallback + await passwordField.press('Enter'); + } else { + await submitButton.click(); + } + + console.log('Submitted login form'); + + // Wait for navigation or error + try { + await page.waitForNavigation({ timeout, waitUntil: 'domcontentloaded' }); + return true; + } catch { + // Check if we're still on login page with error + const errorMessages = await page.$$eval( + '.error, .alert-danger, [role="alert"], .login-error', + (els) => els.map((el) => el.textContent?.trim()).filter(Boolean) + ); + + if (errorMessages.length > 0) { + console.error('Login error:', errorMessages.join(', ')); + return false; + } + + return true; // Might have succeeded without navigation + } +} + +// Handle Microsoft SSO login +async function handleMsalLogin( + page: Page, + credentials: { username: string; password: string }, + timeout: number +): Promise { + console.log('Attempting Microsoft SSO login...'); + + const currentUrl = page.url(); + + // If not already on Microsoft login, wait for redirect + if (!currentUrl.includes('login.microsoftonline.com')) { + try { + await page.waitForURL('**/login.microsoftonline.com/**', { timeout: 10000 }); + } catch { + console.log('Not redirected to Microsoft login'); + return false; + } + } + + // Wait for email input + const emailInput = await page.waitForSelector( + 'input[type="email"], input[name="loginfmt"]', + { timeout } + ); + + if (!emailInput) { + console.error('Could not find email input on Microsoft login'); + return false; + } + + // Fill email and submit + await emailInput.fill(credentials.username); + console.log('Filled email field'); + + const nextButton = await page.$('input[type="submit"], button[type="submit"]'); + if (nextButton) { + await nextButton.click(); + } else { + await emailInput.press('Enter'); + } + + // Wait for password page + try { + await page.waitForSelector( + 'input[type="password"], input[name="passwd"]', + { timeout } + ); + } catch { + // Might be using passwordless auth or different flow + console.log('Password field not found - might be using different auth flow'); + return false; + } + + // Fill password + const passwordInput = await page.$('input[type="password"], input[name="passwd"]'); + if (!passwordInput) { + console.error('Could not find password input'); + return false; + } + + await passwordInput.fill(credentials.password); + console.log('Filled password field'); + + // Submit + const signInButton = await page.$('input[type="submit"], button[type="submit"]'); + if (signInButton) { + await signInButton.click(); + } else { + await passwordInput.press('Enter'); + } + + // Handle "Stay signed in?" prompt + try { + const staySignedInButton = await page.waitForSelector( + 'input[value="Yes"], button:has-text("Yes")', + { timeout: 5000 } + ); + if (staySignedInButton) { + await staySignedInButton.click(); + console.log('Clicked "Stay signed in" button'); + } + } catch { + // Prompt might not appear + } + + // Check for Conditional Access Policy error + const caError = await page.$('text=Conditional Access policy'); + if (caError) { + console.error('Blocked by Conditional Access Policy'); + // Take screenshot for debugging + await page.screenshot({ path: 'ca-policy-error.png' }); + console.log('Screenshot saved: ca-policy-error.png'); + return false; + } + + // Wait for redirect away from Microsoft login + try { + await page.waitForURL( + (url) => !url.href.includes('login.microsoftonline.com'), + { timeout } + ); + return true; + } catch { + return false; + } +} + +// Check if user is already authenticated +async function isAuthenticated(page: Page, targetUrl: string): Promise { + const currentUrl = page.url(); + + // If we're on the target URL (not a login page), we're likely authenticated + if (currentUrl.startsWith(targetUrl)) { + // Check for common login page indicators + const isLoginPage = await page.evaluate(() => { + const loginIndicators = [ + 'input[type="password"]', + 'form[action*="login"]', + 'form[action*="signin"]', + '.login-form', + '#login', + ]; + return loginIndicators.some((sel) => document.querySelector(sel) !== null); + }); + + return !isLoginPage; + } + + return false; +} + +// Main authentication function +export async function authenticate(options: AuthOptions): Promise { + const browser = await launchBrowser({ headless: options.headless ?? true }); + const page = await browser.newPage(); + const timeout = options.timeout ?? 30000; + + try { + // Navigate to URL + console.log(`Navigating to: ${options.url}`); + await page.goto(options.url, { timeout: 60000, waitUntil: 'domcontentloaded' }); + + // Check if already authenticated + if (await isAuthenticated(page, options.url)) { + return { + success: true, + finalUrl: page.url(), + authType: 'auto', + message: 'Already authenticated (session persisted from profile)', + }; + } + + // Get credentials + const credentials = options.credentials + ? options.credentials + : getCredentials(); + + if (!credentials) { + // No credentials - open interactive browser + console.log('\nNo credentials provided. Opening browser for manual login...'); + console.log('Please complete the login process manually.'); + console.log('The session will be saved to your profile.'); + + // Switch to headed mode for manual login + await browser.close(); + const interactiveBrowser = await launchBrowser({ headless: false }); + const interactivePage = await interactiveBrowser.newPage(); + await interactivePage.goto(options.url); + + await promptUser('\nPress Enter when you have completed login...'); + + const finalUrl = interactivePage.url(); + await interactiveBrowser.close(); + + return { + success: true, + finalUrl, + authType: 'auto', + message: 'Manual login completed - session saved to profile', + }; + } + + // Detect auth type if auto + let authType = options.authType; + if (authType === 'auto') { + authType = await detectAuthType(page); + console.log(`Detected auth type: ${authType}`); + } + + // Handle authentication based on type + let success = false; + switch (authType) { + case 'msal': + success = await handleMsalLogin(page, credentials, timeout); + break; + case 'form': + default: + success = await handleFormLogin(page, credentials, timeout); + break; + } + + const finalUrl = page.url(); + + return { + success, + finalUrl, + authType, + message: success + ? `Authentication successful - session saved to profile` + : 'Authentication failed', + }; + } finally { + await browser.close(); + } +} + +// Navigate to authenticated page (handles auth if needed) +export async function navigateAuthenticated( + url: string, + options?: { + credentials?: { username: string; password: string }; + headless?: boolean; + } +): Promise<{ page: Page; browser: BrowserContext }> { + const { page, browser } = await getPage({ headless: options?.headless ?? true }); + + await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' }); + + // Check if we need to authenticate + if (!(await isAuthenticated(page, url))) { + console.log('Session expired or not authenticated. Attempting login...'); + + // Get credentials + const credentials = options?.credentials ?? getCredentials(); + + if (!credentials) { + throw new Error( + 'Authentication required but no credentials provided. ' + + 'Set CLOAKBROWSER_USERNAME and CLOAKBROWSER_PASSWORD environment variables.' + ); + } + + // Detect and handle auth + const authType = await detectAuthType(page); + + let success = false; + if (authType === 'msal') { + success = await handleMsalLogin(page, credentials, 30000); + } else { + success = await handleFormLogin(page, credentials, 30000); + } + + if (!success) { + await browser.close(); + throw new Error('Authentication failed'); + } + + // Navigate back to original URL if we were redirected + if (!page.url().startsWith(url)) { + await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' }); + } + } + + return { page, browser }; +} + +// CLI entry point +async function main() { + const args = parseArgs(process.argv.slice(2), { + string: ['url', 'type', 'username', 'password'], + boolean: ['headless', 'help'], + default: { + type: 'auto', + headless: false, // Default to headed for auth so user can see/interact + }, + alias: { + u: 'url', + t: 'type', + h: 'help', + }, + }); + + if (args.help || !args.url) { + console.log(` +Web Authentication Handler + +Usage: + npx tsx auth.ts --url [options] + +Options: + -u, --url URL to authenticate (required) + -t, --type Auth type: auto, form, or msal (default: auto) + --username Username/email (or set CLOAKBROWSER_USERNAME env var) + --password Password (or set CLOAKBROWSER_PASSWORD env var) + --headless Run in headless mode (default: false for auth) + -h, --help Show this help message + +Auth Types: + auto Auto-detect authentication type + form Generic username/password form + msal Microsoft SSO (login.microsoftonline.com) + +Environment Variables: + CLOAKBROWSER_USERNAME Default username/email for authentication + CLOAKBROWSER_PASSWORD Default password for authentication + +Examples: + # Interactive login (no credentials, opens browser) + npx tsx auth.ts --url "https://example.com/login" + + # Form login with credentials + npx tsx auth.ts --url "https://example.com/login" --type form \\ + --username "user@example.com" --password "secret" + + # Microsoft SSO login + CLOAKBROWSER_USERNAME=user@company.com CLOAKBROWSER_PASSWORD=secret \\ + npx tsx auth.ts --url "https://internal.company.com" --type msal + +Notes: + - Session is saved to ~/.cloakbrowser-profile/ for persistence + - After successful auth, subsequent browses will be authenticated + - Use --headless false if you need to handle MFA manually +`); + process.exit(args.help ? 0 : 1); + } + + const authType = args.type as AuthType; + if (!['auto', 'form', 'msal'].includes(authType)) { + console.error(`Invalid auth type: ${authType}. Must be auto, form, or msal.`); + process.exit(1); + } + + try { + const result = await authenticate({ + url: args.url, + authType, + credentials: + args.username && args.password + ? { username: args.username, password: args.password } + : undefined, + headless: args.headless, + }); + + console.log(`\nAuthentication result:`); + console.log(` Success: ${result.success}`); + console.log(` Auth type: ${result.authType}`); + console.log(` Final URL: ${result.finalUrl}`); + console.log(` Message: ${result.message}`); + + process.exit(result.success ? 0 : 1); + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error); + process.exit(1); + } +} + +// Run if executed directly +const isMainModule = process.argv[1]?.includes('auth.ts'); +if (isMainModule) { + main(); +} diff --git a/skills/web-automation/cursor/scripts/browse.ts b/skills/web-automation/cursor/scripts/browse.ts new file mode 100644 index 0000000..01cf098 --- /dev/null +++ b/skills/web-automation/cursor/scripts/browse.ts @@ -0,0 +1,188 @@ +#!/usr/bin/env npx tsx + +/** + * Browser launcher using CloakBrowser with persistent profile + * + * Usage: + * npx tsx browse.ts --url "https://example.com" + * npx tsx browse.ts --url "https://example.com" --screenshot --output page.png + * npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 + */ + +import { launchPersistentContext } from 'cloakbrowser'; +import { homedir } from 'os'; +import { join } from 'path'; +import { existsSync, mkdirSync } from 'fs'; +import parseArgs from 'minimist'; +import type { Page, BrowserContext } from 'playwright-core'; + +interface BrowseOptions { + url: string; + headless?: boolean; + screenshot?: boolean; + output?: string; + wait?: number; + timeout?: number; + interactive?: boolean; +} + +interface BrowseResult { + title: string; + url: string; + screenshotPath?: string; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const getProfilePath = (): string => { + const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; + if (customPath) return customPath; + + const profileDir = join(homedir(), '.cloakbrowser-profile'); + if (!existsSync(profileDir)) { + mkdirSync(profileDir, { recursive: true }); + } + return profileDir; +}; + +export async function launchBrowser(options: { + headless?: boolean; +}): Promise { + const profilePath = getProfilePath(); + const envHeadless = process.env.CLOAKBROWSER_HEADLESS; + const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); + + console.log(`Using profile: ${profilePath}`); + console.log(`Headless mode: ${headless}`); + + const context = await launchPersistentContext({ + userDataDir: profilePath, + headless, + humanize: true, + }); + + return context; +} + +export async function browse(options: BrowseOptions): Promise { + const browser = await launchBrowser({ headless: options.headless }); + const page = browser.pages()[0] || await browser.newPage(); + + try { + console.log(`Navigating to: ${options.url}`); + await page.goto(options.url, { + timeout: options.timeout ?? 60000, + waitUntil: 'domcontentloaded', + }); + + if (options.wait) { + console.log(`Waiting ${options.wait}ms...`); + await sleep(options.wait); + } + + const result: BrowseResult = { + title: await page.title(), + url: page.url(), + }; + + console.log(`Page title: ${result.title}`); + console.log(`Final URL: ${result.url}`); + + if (options.screenshot) { + const outputPath = options.output ?? 'screenshot.png'; + await page.screenshot({ path: outputPath, fullPage: true }); + result.screenshotPath = outputPath; + console.log(`Screenshot saved: ${outputPath}`); + } + + if (options.interactive) { + console.log('\nInteractive mode - browser will stay open.'); + console.log('Press Ctrl+C to close.'); + await new Promise(() => {}); + } + + return result; + } finally { + if (!options.interactive) { + await browser.close(); + } + } +} + +export async function getPage(options?: { + headless?: boolean; +}): Promise<{ page: Page; browser: BrowserContext }> { + const browser = await launchBrowser({ headless: options?.headless }); + const page = browser.pages()[0] || await browser.newPage(); + return { page, browser }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2), { + string: ['url', 'output'], + boolean: ['screenshot', 'headless', 'interactive', 'help'], + default: { + headless: true, + screenshot: false, + interactive: false, + }, + alias: { + u: 'url', + o: 'output', + s: 'screenshot', + h: 'help', + i: 'interactive', + }, + }); + + if (args.help || !args.url) { + console.log(` +Web Browser with CloakBrowser + +Usage: + npx tsx browse.ts --url [options] + +Options: + -u, --url URL to navigate to (required) + -s, --screenshot Take a screenshot of the page + -o, --output Output path for screenshot (default: screenshot.png) + --headless Run in headless mode (default: true) + --wait Wait time after page load in milliseconds + --timeout Navigation timeout (default: 60000) + -i, --interactive Keep browser open for manual interaction + -h, --help Show this help message + +Examples: + npx tsx browse.ts --url "https://example.com" + npx tsx browse.ts --url "https://example.com" --screenshot --output page.png + npx tsx browse.ts --url "https://example.com" --headless false --interactive + +Environment Variables: + CLOAKBROWSER_PROFILE_PATH Custom profile directory (default: ~/.cloakbrowser-profile/) + CLOAKBROWSER_HEADLESS Default headless mode (true/false) +`); + process.exit(args.help ? 0 : 1); + } + + try { + await browse({ + url: args.url, + headless: args.headless, + screenshot: args.screenshot, + output: args.output, + wait: args.wait ? parseInt(args.wait, 10) : undefined, + timeout: args.timeout ? parseInt(args.timeout, 10) : undefined, + interactive: args.interactive, + }); + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error); + process.exit(1); + } +} + +const isMainModule = process.argv[1]?.includes('browse.ts'); +if (isMainModule) { + main(); +} diff --git a/skills/web-automation/cursor/scripts/check-install.js b/skills/web-automation/cursor/scripts/check-install.js new file mode 100644 index 0000000..50e4884 --- /dev/null +++ b/skills/web-automation/cursor/scripts/check-install.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function fail(message, details) { + const payload = { error: message }; + if (details) payload.details = details; + process.stderr.write(`${JSON.stringify(payload)}\n`); + process.exit(1); +} + +async function main() { + try { + await import("cloakbrowser"); + await import("playwright-core"); + } catch (error) { + fail( + "Missing dependency/config: web-automation requires cloakbrowser and playwright-core.", + error instanceof Error ? error.message : String(error) + ); + } + + const browsePath = path.join(__dirname, "browse.ts"); + const browseSource = fs.readFileSync(browsePath, "utf8"); + if (!/launchPersistentContext/.test(browseSource) || !/from ['"]cloakbrowser['"]/.test(browseSource)) { + fail("browse.ts is not configured for CloakBrowser."); + } + + process.stdout.write("OK: cloakbrowser + playwright-core installed\n"); + process.stdout.write("OK: CloakBrowser integration detected in browse.ts\n"); +} + +main().catch((error) => { + fail("Install check failed.", error instanceof Error ? error.message : String(error)); +}); diff --git a/skills/web-automation/cursor/scripts/extract.js b/skills/web-automation/cursor/scripts/extract.js new file mode 100755 index 0000000..5e3908a --- /dev/null +++ b/skills/web-automation/cursor/scripts/extract.js @@ -0,0 +1,188 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_WAIT_MS = 5000; +const MAX_WAIT_MS = 20000; +const NAV_TIMEOUT_MS = 30000; +const EXTRA_CHALLENGE_WAIT_MS = 8000; +const CONTENT_LIMIT = 12000; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function fail(message, details) { + const payload = { error: message }; + if (details) payload.details = details; + process.stderr.write(`${JSON.stringify(payload)}\n`); + process.exit(1); +} + +function parseWaitTime(raw) { + const value = Number.parseInt(raw || `${DEFAULT_WAIT_MS}`, 10); + if (!Number.isFinite(value) || value < 0) return DEFAULT_WAIT_MS; + return Math.min(value, MAX_WAIT_MS); +} + +function parseTarget(rawUrl) { + if (!rawUrl) { + fail("Missing URL. Usage: node extract.js "); + } + + let parsed; + try { + parsed = new URL(rawUrl); + } catch (error) { + fail("Invalid URL.", error.message); + } + + if (!["http:", "https:"].includes(parsed.protocol)) { + fail("Only http and https URLs are allowed."); + } + + return parsed.toString(); +} + +function ensureParentDir(filePath) { + if (!filePath) return; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function detectChallenge(page) { + try { + return await page.evaluate(() => { + const text = (document.body?.innerText || "").toLowerCase(); + return ( + text.includes("checking your browser") || + text.includes("just a moment") || + text.includes("verify you are human") || + text.includes("press and hold") || + document.querySelector('iframe[src*="challenge"]') !== null || + document.querySelector('iframe[src*="cloudflare"]') !== null + ); + }); + } catch { + return false; + } +} + +async function loadCloakBrowser() { + try { + return await import("cloakbrowser"); + } catch (error) { + fail( + "CloakBrowser is not installed for this skill. Run pnpm install in this skill's scripts directory first.", + error.message + ); + } +} + +async function runWithStderrLogs(fn) { + const originalLog = console.log; + const originalError = console.error; + console.log = (...args) => process.stderr.write(`${args.join(" ")}\n`); + console.error = (...args) => process.stderr.write(`${args.join(" ")}\n`); + try { + return await fn(); + } finally { + console.log = originalLog; + console.error = originalError; + } +} + +async function main() { + const requestedUrl = parseTarget(process.argv[2]); + const waitTime = parseWaitTime(process.env.WAIT_TIME); + const screenshotPath = process.env.SCREENSHOT_PATH || ""; + const saveHtml = process.env.SAVE_HTML === "true"; + const headless = process.env.HEADLESS !== "false"; + const userAgent = process.env.USER_AGENT || undefined; + const startedAt = Date.now(); + const { ensureBinary, launchContext } = await loadCloakBrowser(); + + let context; + try { + await runWithStderrLogs(() => ensureBinary()); + + context = await runWithStderrLogs(() => launchContext({ + headless, + userAgent, + locale: "en-US", + viewport: { width: 1440, height: 900 }, + humanize: true, + })); + + const page = await context.newPage(); + const response = await page.goto(requestedUrl, { + waitUntil: "domcontentloaded", + timeout: NAV_TIMEOUT_MS + }); + + await sleep(waitTime); + + let challengeDetected = await detectChallenge(page); + if (challengeDetected) { + await sleep(EXTRA_CHALLENGE_WAIT_MS); + challengeDetected = await detectChallenge(page); + } + + const extracted = await page.evaluate((contentLimit) => { + const bodyText = document.body?.innerText || ""; + return { + finalUrl: window.location.href, + title: document.title || "", + content: bodyText.slice(0, contentLimit), + metaDescription: + document.querySelector('meta[name="description"]')?.content || + document.querySelector('meta[property="og:description"]')?.content || + "" + }; + }, CONTENT_LIMIT); + + const result = { + requestedUrl, + finalUrl: extracted.finalUrl, + title: extracted.title, + content: extracted.content, + metaDescription: extracted.metaDescription, + status: response ? response.status() : null, + challengeDetected, + elapsedSeconds: ((Date.now() - startedAt) / 1000).toFixed(2) + }; + + if (screenshotPath) { + ensureParentDir(screenshotPath); + await page.screenshot({ path: screenshotPath, fullPage: false, timeout: 10000 }); + result.screenshot = screenshotPath; + } + + if (saveHtml) { + const htmlTarget = screenshotPath + ? screenshotPath.replace(/\.[^.]+$/, ".html") + : path.resolve(__dirname, `page-${Date.now()}.html`); + ensureParentDir(htmlTarget); + fs.writeFileSync(htmlTarget, await page.content()); + result.htmlFile = htmlTarget; + } + + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + await context.close(); + } catch (error) { + if (context) { + try { + await context.close(); + } catch { + // Ignore close errors after the primary failure. + } + } + fail("Scrape failed.", error.message); + } +} + +main(); diff --git a/skills/web-automation/cursor/scripts/flow.ts b/skills/web-automation/cursor/scripts/flow.ts new file mode 100644 index 0000000..5d01e55 --- /dev/null +++ b/skills/web-automation/cursor/scripts/flow.ts @@ -0,0 +1,329 @@ +#!/usr/bin/env npx tsx + +import parseArgs from 'minimist'; +import type { Page } from 'playwright-core'; +import { launchBrowser } from './browse'; + +type Step = + | { action: 'goto'; url: string } + | { action: 'click'; selector?: string; text?: string; role?: string; name?: string } + | { action: 'type'; selector?: string; text: string } + | { action: 'press'; key: string; selector?: string } + | { action: 'wait'; ms: number } + | { action: 'screenshot'; path: string } + | { action: 'extract'; selector: string; count?: number }; + +function normalizeNavigationUrl(rawUrl: string): string { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error(`Invalid navigation URL: ${rawUrl}`); + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error(`Only http and https URLs are allowed in flow steps: ${rawUrl}`); + } + + return parsed.toString(); +} + +function normalizeKey(k: string): string { + if (!k) return 'Enter'; + const lower = k.toLowerCase(); + if (lower === 'enter' || lower === 'return') return 'Enter'; + if (lower === 'tab') return 'Tab'; + if (lower === 'escape' || lower === 'esc') return 'Escape'; + return k; +} + +function splitInstructions(instruction: string): string[] { + return instruction + .split(/\bthen\b|;/gi) + .map((s) => s.trim()) + .filter(Boolean); +} + +function parseInstruction(instruction: string): Step[] { + const parts = splitInstructions(instruction); + const steps: Step[] = []; + + for (const p of parts) { + // go to https://... + const goto = p.match(/^(?:go to|open|navigate to)\s+(https?:\/\/\S+)/i); + if (goto) { + steps.push({ action: 'goto', url: normalizeNavigationUrl(goto[1]) }); + continue; + } + + // click on "text" or click #selector or click button "name" + const clickRole = p.match(/^click\s+(button|link|textbox|img|image|tab)\s+"([^"]+)"$/i); + if (clickRole) { + const role = clickRole[1].toLowerCase() === 'image' ? 'img' : clickRole[1].toLowerCase(); + steps.push({ action: 'click', role, name: clickRole[2] }); + continue; + } + const clickText = p.match(/^click(?: on)?\s+"([^"]+)"/i); + if (clickText) { + steps.push({ action: 'click', text: clickText[1] }); + continue; + } + const clickSelector = p.match(/^click(?: on)?\s+(#[\w-]+|\.[\w-]+|[a-z]+\[[^\]]+\])/i); + if (clickSelector) { + steps.push({ action: 'click', selector: clickSelector[1] }); + continue; + } + + // type "text" [in selector] + const typeInto = p.match(/^type\s+"([^"]+)"\s+in\s+(.+)$/i); + if (typeInto) { + steps.push({ action: 'type', text: typeInto[1], selector: typeInto[2].trim() }); + continue; + } + const typeOnly = p.match(/^type\s+"([^"]+)"$/i); + if (typeOnly) { + steps.push({ action: 'type', text: typeOnly[1] }); + continue; + } + + // press enter [in selector] + const pressIn = p.match(/^press\s+(\w+)\s+in\s+(.+)$/i); + if (pressIn) { + steps.push({ action: 'press', key: normalizeKey(pressIn[1]), selector: pressIn[2].trim() }); + continue; + } + const pressOnly = p.match(/^press\s+(\w+)$/i); + if (pressOnly) { + steps.push({ action: 'press', key: normalizeKey(pressOnly[1]) }); + continue; + } + + // wait 2s / wait 500ms + const waitS = p.match(/^wait\s+(\d+)\s*s(?:ec(?:onds?)?)?$/i); + if (waitS) { + steps.push({ action: 'wait', ms: parseInt(waitS[1], 10) * 1000 }); + continue; + } + const waitMs = p.match(/^wait\s+(\d+)\s*ms$/i); + if (waitMs) { + steps.push({ action: 'wait', ms: parseInt(waitMs[1], 10) }); + continue; + } + + // screenshot path + const shot = p.match(/^screenshot(?: to)?\s+(.+)$/i); + if (shot) { + steps.push({ action: 'screenshot', path: shot[1].trim() }); + continue; + } + + throw new Error(`Could not parse step: "${p}"`); + } + + return steps; +} + +function validateSteps(steps: Step[]): Step[] { + return steps.map((step) => + step.action === 'goto' + ? { + ...step, + url: normalizeNavigationUrl(step.url), + } + : step + ); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function isLikelyLoginText(text: string): boolean { + return /(login|accedi|sign\s*in|entra)/i.test(text); +} + +async function clickByText(page: Page, text: string): Promise { + const patterns = [new RegExp(`^${escapeRegExp(text)}$`, 'i'), new RegExp(escapeRegExp(text), 'i')]; + + for (const pattern of patterns) { + const targets = [ + page.getByRole('button', { name: pattern }).first(), + page.getByRole('link', { name: pattern }).first(), + page.getByText(pattern).first(), + ]; + + for (const target of targets) { + if (await target.count()) { + try { + await target.click({ timeout: 8000 }); + return true; + } catch { + // keep trying next candidate + } + } + } + } + + return false; +} + +async function fallbackLoginNavigation(page: Page, requestedText: string): Promise { + if (!isLikelyLoginText(requestedText)) return false; + + const current = new URL(page.url()); + + const candidateLinks = await page.evaluate(() => { + const loginTerms = ['login', 'accedi', 'sign in', 'entra']; + const anchors = Array.from(document.querySelectorAll('a[href], a[onclick], button[onclick]')) as Array; + + return anchors + .map((el) => { + const text = (el.textContent || '').trim().toLowerCase(); + const href = (el as HTMLAnchorElement).getAttribute('href') || ''; + return { text, href }; + }) + .filter((x) => x.text && loginTerms.some((t) => x.text.includes(t))) + .map((x) => x.href) + .filter(Boolean); + }); + + // Prefer real URLs (not javascript:) + const realCandidate = candidateLinks.find((h) => /login|account\/login/i.test(h) && !h.startsWith('javascript:')); + if (realCandidate) { + const target = new URL(realCandidate, page.url()).toString(); + await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 60000 }); + return true; + } + + // Site-specific fallback for Corriere + if (/corriere\.it$/i.test(current.hostname) || /\.corriere\.it$/i.test(current.hostname)) { + await page.goto('https://www.corriere.it/account/login', { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + return true; + } + + return false; +} + +async function typeInBestTarget(page: Page, text: string, selector?: string) { + if (selector) { + await page.locator(selector).first().click({ timeout: 10000 }); + await page.locator(selector).first().fill(text); + return; + } + const loc = page.locator('input[name="q"], input[type="search"], input[type="text"], textarea').first(); + await loc.click({ timeout: 10000 }); + await loc.fill(text); +} + +async function pressOnTarget(page: Page, key: string, selector?: string) { + if (selector) { + await page.locator(selector).first().press(key); + return; + } + await page.keyboard.press(key); +} + +async function runSteps(page: Page, steps: Step[]) { + for (const step of steps) { + switch (step.action) { + case 'goto': + await page.goto(normalizeNavigationUrl(step.url), { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + break; + case 'click': + if (step.selector) { + await page.locator(step.selector).first().click({ timeout: 15000 }); + } else if (step.role && step.name) { + await page.getByRole(step.role as any, { name: new RegExp(escapeRegExp(step.name), 'i') }).first().click({ timeout: 15000 }); + } else if (step.text) { + const clicked = await clickByText(page, step.text); + if (!clicked) { + const recovered = await fallbackLoginNavigation(page, step.text); + if (!recovered) { + throw new Error(`Could not click target text: ${step.text}`); + } + } + } else { + throw new Error('click step missing selector/text/role'); + } + try { + await page.waitForLoadState('domcontentloaded', { timeout: 10000 }); + } catch { + // no navigation is fine + } + break; + case 'type': + await typeInBestTarget(page, step.text, step.selector); + break; + case 'press': + await pressOnTarget(page, step.key, step.selector); + break; + case 'wait': + await page.waitForTimeout(step.ms); + break; + case 'screenshot': + await page.screenshot({ path: step.path, fullPage: true }); + break; + case 'extract': { + const items = await page.locator(step.selector).allTextContents(); + const out = items.slice(0, step.count ?? items.length).map((t) => t.trim()).filter(Boolean); + console.log(JSON.stringify(out, null, 2)); + break; + } + default: + throw new Error('Unknown step'); + } + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2), { + string: ['instruction', 'steps'], + boolean: ['headless', 'help'], + default: { headless: true }, + alias: { i: 'instruction', s: 'steps', h: 'help' }, + }); + + if (args.help || (!args.instruction && !args.steps)) { + console.log(` +General Web Flow Runner (CloakBrowser) + +Usage: + npx tsx flow.ts --instruction "go to https://example.com then type \"hello\" then press enter" + npx tsx flow.ts --steps '[{"action":"goto","url":"https://example.com"}]' + +Supported natural steps: + - go to/open/navigate to + - click on "Text" + - click + - type "text" + - type "text" in + - press + - press in + - wait s | wait ms + - screenshot +`); + process.exit(args.help ? 0 : 1); + } + + const steps = validateSteps(args.steps ? JSON.parse(args.steps) : parseInstruction(args.instruction)); + const browser = await launchBrowser({ headless: args.headless }); + const page = await browser.newPage(); + + try { + await runSteps(page, steps); + console.log('Flow complete. Final URL:', page.url()); + } finally { + await browser.close(); + } +} + +main().catch((e) => { + console.error('Error:', e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/skills/web-automation/cursor/scripts/package.json b/skills/web-automation/cursor/scripts/package.json new file mode 100644 index 0000000..a2221e8 --- /dev/null +++ b/skills/web-automation/cursor/scripts/package.json @@ -0,0 +1,36 @@ +{ + "name": "web-automation-scripts", + "version": "1.0.0", + "description": "Web browsing and scraping scripts using CloakBrowser", + "type": "module", + "scripts": { + "check-install": "node check-install.js", + "extract": "node extract.js", + "browse": "tsx browse.ts", + "auth": "tsx auth.ts", + "flow": "tsx flow.ts", + "scrape": "tsx scrape.ts", + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "pnpm run typecheck && node --check check-install.js && node --check extract.js", + "fetch-browser": "npx cloakbrowser install" + }, + "dependencies": { + "@mozilla/readability": "^0.5.0", + "better-sqlite3": "^12.6.2", + "cloakbrowser": "^0.3.22", + "jsdom": "^24.0.0", + "minimist": "^1.2.8", + "playwright-core": "^1.59.1", + "turndown": "^7.1.2", + "turndown-plugin-gfm": "^1.0.2" + }, + "devDependencies": { + "@types/jsdom": "^21.1.6", + "@types/minimist": "^1.2.5", + "@types/turndown": "^5.0.4", + "esbuild": "0.27.0", + "tsx": "^4.7.0", + "typescript": "^5.3.0" + }, + "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34" +} diff --git a/skills/web-automation/cursor/scripts/pnpm-lock.yaml b/skills/web-automation/cursor/scripts/pnpm-lock.yaml new file mode 100644 index 0000000..59dba9c --- /dev/null +++ b/skills/web-automation/cursor/scripts/pnpm-lock.yaml @@ -0,0 +1,1292 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@mozilla/readability': + specifier: ^0.5.0 + version: 0.5.0 + better-sqlite3: + specifier: ^12.6.2 + version: 12.8.0 + cloakbrowser: + specifier: ^0.3.22 + version: 0.3.22(mmdb-lib@3.0.1)(playwright-core@1.59.1) + jsdom: + specifier: ^24.0.0 + version: 24.1.3 + minimist: + specifier: ^1.2.8 + version: 1.2.8 + playwright-core: + specifier: ^1.59.1 + version: 1.59.1 + turndown: + specifier: ^7.1.2 + version: 7.2.2 + turndown-plugin-gfm: + specifier: ^1.0.2 + version: 1.0.2 + devDependencies: + '@types/jsdom': + specifier: ^21.1.6 + version: 21.1.7 + '@types/minimist': + specifier: ^1.2.5 + version: 1.2.5 + '@types/turndown': + specifier: ^5.0.4 + version: 5.0.6 + esbuild: + specifier: 0.27.0 + version: 0.27.0 + tsx: + specifier: ^4.7.0 + version: 4.21.0 + typescript: + specifier: ^5.3.0 + version: 5.9.3 + +packages: + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.27.0': + resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.0': + resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.0': + resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.0': + resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.0': + resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.0': + resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.0': + resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.0': + resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.0': + resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.0': + resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.0': + resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.0': + resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.0': + resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.0': + resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.0': + resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.0': + resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.0': + resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.0': + resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.0': + resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.0': + resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.0': + resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.0': + resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.0': + resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.0': + resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.0': + resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.0': + resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + + '@mozilla/readability@0.5.0': + resolution: {integrity: sha512-Z+CZ3QaosfFaTqvhQsIktyGrjFjSC0Fa4EMph4mqKnWhmyoGICsV/8QK+8HpXut6zV7zwfWwqDmEjtk1Qf6EgQ==} + engines: {node: '>=14.0.0'} + + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + + '@types/minimist@1.2.5': + resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + + '@types/node@25.0.6': + resolution: {integrity: sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@12.8.0: + resolution: {integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + cloakbrowser@0.3.22: + resolution: {integrity: sha512-L2CWQiVdunhKslTli8HCe4INhaAt4npbvsM2Ox4/idqiRmT2BADndQ05eDS8TonNSWeWqbjsh04UhSZOD3B6mg==} + engines: {node: '>=18.0.0'} + hasBin: true + peerDependencies: + mmdb-lib: '>=2.0.0' + playwright-core: '>=1.40.0' + puppeteer-core: '>=21.0.0' + peerDependenciesMeta: + mmdb-lib: + optional: true + playwright-core: + optional: true + puppeteer-core: + optional: true + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.0: + resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==} + engines: {node: '>=18'} + hasBin: true + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + jsdom@24.1.3: + resolution: {integrity: sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mmdb-lib@3.0.1: + resolution: {integrity: sha512-dyAyMR+cRykZd1mw5altC9f4vKpCsuywPwo8l/L5fKqDay2zmqT0mF/BvUoXnQiqGn+nceO914rkPKJoyFnGxA==} + engines: {node: '>=10', npm: '>=6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.85.0: + resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} + engines: {node: '>=10'} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@7.5.13: + resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} + engines: {node: '>=18'} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + turndown-plugin-gfm@1.0.2: + resolution: {integrity: sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==} + + turndown@7.2.2: + resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + +snapshots: + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.27.0': + optional: true + + '@esbuild/android-arm64@0.27.0': + optional: true + + '@esbuild/android-arm@0.27.0': + optional: true + + '@esbuild/android-x64@0.27.0': + optional: true + + '@esbuild/darwin-arm64@0.27.0': + optional: true + + '@esbuild/darwin-x64@0.27.0': + optional: true + + '@esbuild/freebsd-arm64@0.27.0': + optional: true + + '@esbuild/freebsd-x64@0.27.0': + optional: true + + '@esbuild/linux-arm64@0.27.0': + optional: true + + '@esbuild/linux-arm@0.27.0': + optional: true + + '@esbuild/linux-ia32@0.27.0': + optional: true + + '@esbuild/linux-loong64@0.27.0': + optional: true + + '@esbuild/linux-mips64el@0.27.0': + optional: true + + '@esbuild/linux-ppc64@0.27.0': + optional: true + + '@esbuild/linux-riscv64@0.27.0': + optional: true + + '@esbuild/linux-s390x@0.27.0': + optional: true + + '@esbuild/linux-x64@0.27.0': + optional: true + + '@esbuild/netbsd-arm64@0.27.0': + optional: true + + '@esbuild/netbsd-x64@0.27.0': + optional: true + + '@esbuild/openbsd-arm64@0.27.0': + optional: true + + '@esbuild/openbsd-x64@0.27.0': + optional: true + + '@esbuild/openharmony-arm64@0.27.0': + optional: true + + '@esbuild/sunos-x64@0.27.0': + optional: true + + '@esbuild/win32-arm64@0.27.0': + optional: true + + '@esbuild/win32-ia32@0.27.0': + optional: true + + '@esbuild/win32-x64@0.27.0': + optional: true + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + + '@mixmark-io/domino@2.2.0': {} + + '@mozilla/readability@0.5.0': {} + + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 25.0.6 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/minimist@1.2.5': {} + + '@types/node@25.0.6': + dependencies: + undici-types: 7.16.0 + + '@types/tough-cookie@4.0.5': {} + + '@types/turndown@5.0.6': {} + + agent-base@7.1.4: {} + + asynckit@0.4.0: {} + + base64-js@1.5.1: {} + + better-sqlite3@12.8.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + chownr@1.1.4: {} + + chownr@3.0.0: {} + + cloakbrowser@0.3.22(mmdb-lib@3.0.1)(playwright-core@1.59.1): + dependencies: + tar: 7.5.13 + optionalDependencies: + mmdb-lib: 3.0.1 + playwright-core: 1.59.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.27.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.0 + '@esbuild/android-arm': 0.27.0 + '@esbuild/android-arm64': 0.27.0 + '@esbuild/android-x64': 0.27.0 + '@esbuild/darwin-arm64': 0.27.0 + '@esbuild/darwin-x64': 0.27.0 + '@esbuild/freebsd-arm64': 0.27.0 + '@esbuild/freebsd-x64': 0.27.0 + '@esbuild/linux-arm': 0.27.0 + '@esbuild/linux-arm64': 0.27.0 + '@esbuild/linux-ia32': 0.27.0 + '@esbuild/linux-loong64': 0.27.0 + '@esbuild/linux-mips64el': 0.27.0 + '@esbuild/linux-ppc64': 0.27.0 + '@esbuild/linux-riscv64': 0.27.0 + '@esbuild/linux-s390x': 0.27.0 + '@esbuild/linux-x64': 0.27.0 + '@esbuild/netbsd-arm64': 0.27.0 + '@esbuild/netbsd-x64': 0.27.0 + '@esbuild/openbsd-arm64': 0.27.0 + '@esbuild/openbsd-x64': 0.27.0 + '@esbuild/openharmony-arm64': 0.27.0 + '@esbuild/sunos-x64': 0.27.0 + '@esbuild/win32-arm64': 0.27.0 + '@esbuild/win32-ia32': 0.27.0 + '@esbuild/win32-x64': 0.27.0 + + expand-template@2.0.3: {} + + file-uri-to-path@1.0.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-from-package@0.0.0: {} + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + is-potential-custom-element-name@1.0.1: {} + + jsdom@24.1.3: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + lru-cache@10.4.3: {} + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.2 + + mkdirp-classic@0.5.3: {} + + mmdb-lib@3.0.1: + optional: true + + ms@2.1.3: {} + + napi-build-utils@2.0.0: {} + + node-abi@3.85.0: + dependencies: + semver: 7.7.3 + + nwsapi@2.2.23: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + playwright-core@1.59.1: {} + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.85.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + querystringify@2.2.0: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + requires-port@1.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@7.7.3: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + symbol-tree@3.2.4: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@7.5.13: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.1.0 + yallist: 5.0.0 + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tsx@4.21.0: + dependencies: + esbuild: 0.27.0 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + turndown-plugin-gfm@1.0.2: {} + + turndown@7.2.2: + dependencies: + '@mixmark-io/domino': 2.2.0 + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + universalify@0.2.0: {} + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + util-deprecate@1.0.2: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + wrappy@1.0.2: {} + + ws@8.19.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@5.0.0: {} diff --git a/skills/web-automation/cursor/scripts/scan-local-app.ts b/skills/web-automation/cursor/scripts/scan-local-app.ts new file mode 100644 index 0000000..6a05b35 --- /dev/null +++ b/skills/web-automation/cursor/scripts/scan-local-app.ts @@ -0,0 +1,174 @@ +#!/usr/bin/env npx tsx + +import { mkdirSync, writeFileSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { getPage } from './browse.js'; + +type NavResult = { + requestedUrl: string; + url: string; + status: number | null; + title: string; + error?: string; +}; + +type RouteCheck = { + route: string; + result: NavResult; + heading: string | null; +}; + +const DEFAULT_BASE_URL = 'http://localhost:3000'; +const DEFAULT_REPORT_PATH = resolve(process.cwd(), 'scan-local-app.md'); + +function env(name: string): string | undefined { + const value = process.env[name]?.trim(); + return value ? value : undefined; +} + +function getRoutes(baseUrl: string): string[] { + const routeList = env('SCAN_ROUTES'); + if (routeList) { + return routeList + .split(',') + .map((route) => route.trim()) + .filter(Boolean) + .map((route) => new URL(route, baseUrl).toString()); + } + + return [baseUrl]; +} + +async function gotoWithStatus(page: any, url: string): Promise { + const response = await page + .goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) + .catch((error: unknown) => ({ error })); + + if (response?.error) { + return { + requestedUrl: url, + url: page.url(), + status: null, + title: await page.title().catch(() => ''), + error: String(response.error), + }; + } + + return { + requestedUrl: url, + url: page.url(), + status: response ? response.status() : null, + title: await page.title().catch(() => ''), + }; +} + +async function textOrNull(page: any, selector: string): Promise { + const locator = page.locator(selector).first(); + try { + if ((await locator.count()) === 0) return null; + const value = await locator.textContent(); + return value ? value.trim().replace(/\s+/g, ' ') : null; + } catch { + return null; + } +} + +async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { + const loginPath = env('SCAN_LOGIN_PATH'); + const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); + const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); + const usernameSelector = env('SCAN_USERNAME_SELECTOR') ?? 'input[type="email"], input[name="email"]'; + const passwordSelector = env('SCAN_PASSWORD_SELECTOR') ?? 'input[type="password"], input[name="password"]'; + const submitSelector = env('SCAN_SUBMIT_SELECTOR') ?? 'button[type="submit"], input[type="submit"]'; + + if (!loginPath) { + lines.push('## Login'); + lines.push('- Skipped: set `SCAN_LOGIN_PATH` to enable login smoke checks.'); + lines.push(''); + return; + } + + const loginUrl = new URL(loginPath, baseUrl).toString(); + lines.push('## Login'); + lines.push(`- Login URL: ${loginUrl}`); + await gotoWithStatus(page, loginUrl); + + if (!username || !password) { + lines.push('- Skipped: set `SCAN_USERNAME`/`SCAN_PASSWORD` or `CLOAKBROWSER_USERNAME`/`CLOAKBROWSER_PASSWORD`.'); + lines.push(''); + return; + } + + await page.locator(usernameSelector).first().fill(username); + await page.locator(passwordSelector).first().fill(password); + await page.locator(submitSelector).first().click(); + await page.waitForTimeout(2500); + + lines.push(`- After submit URL: ${page.url()}`); + lines.push(`- Cookie count: ${(await page.context().cookies()).length}`); + lines.push(''); +} + +async function checkRoutes(page: any, baseUrl: string, lines: string[]) { + const routes = getRoutes(baseUrl); + const routeChecks: RouteCheck[] = []; + + for (const url of routes) { + const result = await gotoWithStatus(page, url); + const heading = await textOrNull(page, 'h1'); + routeChecks.push({ + route: url, + result, + heading, + }); + } + + lines.push('## Route Checks'); + for (const check of routeChecks) { + const relativeUrl = check.route.startsWith(baseUrl) ? check.route.slice(baseUrl.length) || '/' : check.route; + const finalPath = check.result.url.startsWith(baseUrl) + ? check.result.url.slice(baseUrl.length) || '/' + : check.result.url; + const suffix = check.heading ? `, h1="${check.heading}"` : ''; + const errorSuffix = check.result.error ? `, error="${check.result.error}"` : ''; + lines.push( + `- ${relativeUrl} → status ${check.result.status ?? 'ERR'} (final ${finalPath})${suffix}${errorSuffix}` + ); + } + lines.push(''); +} + +async function main() { + const baseUrl = env('SCAN_BASE_URL') ?? DEFAULT_BASE_URL; + const reportPath = resolve(env('SCAN_REPORT_PATH') ?? DEFAULT_REPORT_PATH); + const headless = (env('SCAN_HEADLESS') ?? env('CLOAKBROWSER_HEADLESS') ?? 'true') === 'true'; + const { page, browser } = await getPage({ headless }); + const lines: string[] = []; + + lines.push('# Web Automation Scan (local)'); + lines.push(''); + lines.push(`- Base URL: ${baseUrl}`); + lines.push(`- Timestamp: ${new Date().toISOString()}`); + lines.push(`- Headless: ${headless}`); + lines.push(`- Report Path: ${reportPath}`); + lines.push(''); + + try { + await loginIfConfigured(page, baseUrl, lines); + await checkRoutes(page, baseUrl, lines); + lines.push('## Notes'); + lines.push('- This generic smoke helper records route availability and top-level headings for a local app.'); + lines.push('- Configure login and route coverage with `SCAN_*` environment variables.'); + } finally { + await browser.close(); + } + + mkdirSync(dirname(reportPath), { recursive: true }); + writeFileSync(reportPath, `${lines.join('\n')}\n`, 'utf-8'); + console.log(`Report written to ${reportPath}`); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/skills/web-automation/cursor/scripts/scrape.ts b/skills/web-automation/cursor/scripts/scrape.ts new file mode 100644 index 0000000..0820de0 --- /dev/null +++ b/skills/web-automation/cursor/scripts/scrape.ts @@ -0,0 +1,351 @@ +#!/usr/bin/env npx tsx + +/** + * Web scraper that extracts content to markdown + * + * Usage: + * npx tsx scrape.ts --url "https://example.com" --mode main + * npx tsx scrape.ts --url "https://example.com" --mode full --output page.md + * npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".content" + */ + +import TurndownService from 'turndown'; +import * as turndownPluginGfm from 'turndown-plugin-gfm'; +import { Readability } from '@mozilla/readability'; +import { JSDOM } from 'jsdom'; +import { writeFileSync } from 'fs'; +import parseArgs from 'minimist'; +import { getPage } from './browse.js'; + +// Types +type ScrapeMode = 'main' | 'full' | 'selector'; + +interface ScrapeOptions { + url: string; + mode: ScrapeMode; + selector?: string; + output?: string; + includeLinks?: boolean; + includeTables?: boolean; + includeImages?: boolean; + headless?: boolean; + wait?: number; +} + +interface ScrapeResult { + title: string; + url: string; + markdown: string; + byline?: string; + excerpt?: string; +} + +// Configure Turndown for markdown conversion +function createTurndownService(options: { + includeLinks?: boolean; + includeTables?: boolean; + includeImages?: boolean; +}): TurndownService { + const turndown = new TurndownService({ + headingStyle: 'atx', + hr: '---', + bulletListMarker: '-', + codeBlockStyle: 'fenced', + fence: '```', + emDelimiter: '*', + strongDelimiter: '**', + linkStyle: 'inlined', + }); + + // Add GFM support (tables, strikethrough, task lists) + turndown.use(turndownPluginGfm.gfm); + + // Custom rule for code blocks with language detection + turndown.addRule('codeBlockWithLanguage', { + filter: (node) => { + return ( + node.nodeName === 'PRE' && + node.firstChild?.nodeName === 'CODE' + ); + }, + replacement: (_content, node) => { + const codeNode = node.firstChild as HTMLElement; + const className = codeNode.getAttribute('class') || ''; + const langMatch = className.match(/language-(\w+)/); + const lang = langMatch ? langMatch[1] : ''; + const code = codeNode.textContent || ''; + return `\n\n\`\`\`${lang}\n${code}\n\`\`\`\n\n`; + }, + }); + + // Remove images if not included + if (!options.includeImages) { + turndown.addRule('removeImages', { + filter: 'img', + replacement: () => '', + }); + } + + // Remove links but keep text if not included + if (!options.includeLinks) { + turndown.addRule('removeLinks', { + filter: 'a', + replacement: (content) => content, + }); + } + + // Remove script, style, nav, footer, aside elements + turndown.remove(['script', 'style', 'nav', 'footer', 'aside', 'noscript']); + + return turndown; +} + +// Extract main content using Readability +function extractMainContent(html: string, url: string): { + content: string; + title: string; + byline?: string; + excerpt?: string; +} { + const dom = new JSDOM(html, { url }); + const reader = new Readability(dom.window.document); + const article = reader.parse(); + + if (!article) { + throw new Error('Could not extract main content from page'); + } + + return { + content: article.content, + title: article.title, + byline: article.byline || undefined, + excerpt: article.excerpt || undefined, + }; +} + +// Scrape a URL and return markdown +export async function scrape(options: ScrapeOptions): Promise { + const { page, browser } = await getPage({ headless: options.headless ?? true }); + + try { + // Navigate to URL + console.log(`Navigating to: ${options.url}`); + await page.goto(options.url, { + timeout: 60000, + waitUntil: 'domcontentloaded', + }); + + // Wait if specified + if (options.wait) { + console.log(`Waiting ${options.wait}ms for dynamic content...`); + await page.waitForTimeout(options.wait); + } + + const pageTitle = await page.title(); + const pageUrl = page.url(); + + let html: string; + let title = pageTitle; + let byline: string | undefined; + let excerpt: string | undefined; + + // Get HTML based on mode + switch (options.mode) { + case 'main': { + // Get full page HTML and extract with Readability + const fullHtml = await page.content(); + const extracted = extractMainContent(fullHtml, pageUrl); + html = extracted.content; + title = extracted.title || pageTitle; + byline = extracted.byline; + excerpt = extracted.excerpt; + break; + } + + case 'selector': { + if (!options.selector) { + throw new Error('Selector mode requires --selector option'); + } + const element = await page.$(options.selector); + if (!element) { + throw new Error(`Selector not found: ${options.selector}`); + } + html = await element.innerHTML(); + break; + } + + case 'full': + default: { + // Get body content, excluding common non-content elements + html = await page.evaluate(() => { + // Remove common non-content elements + const selectorsToRemove = [ + 'script', 'style', 'noscript', 'iframe', + 'nav', 'header', 'footer', '.cookie-banner', + '.advertisement', '.ads', '#ads', '.social-share', + '.comments', '#comments', '.sidebar' + ]; + + selectorsToRemove.forEach(selector => { + document.querySelectorAll(selector).forEach(el => el.remove()); + }); + + return document.body.innerHTML; + }); + break; + } + } + + // Convert to markdown + const turndown = createTurndownService({ + includeLinks: options.includeLinks ?? true, + includeTables: options.includeTables ?? true, + includeImages: options.includeImages ?? false, + }); + + let markdown = turndown.turndown(html); + + // Add title as H1 if not already present + if (!markdown.startsWith('# ')) { + markdown = `# ${title}\n\n${markdown}`; + } + + // Add metadata header + const metadataLines = [ + ``, + byline ? `` : null, + excerpt ? `` : null, + ``, + '', + ].filter(Boolean); + + markdown = metadataLines.join('\n') + '\n' + markdown; + + // Clean up excessive whitespace + markdown = markdown + .replace(/\n{4,}/g, '\n\n\n') + .replace(/[ \t]+$/gm, '') + .trim(); + + const result: ScrapeResult = { + title, + url: pageUrl, + markdown, + byline, + excerpt, + }; + + // Save to file if output specified + if (options.output) { + writeFileSync(options.output, markdown, 'utf-8'); + console.log(`Markdown saved to: ${options.output}`); + } + + return result; + } finally { + await browser.close(); + } +} + +// CLI entry point +async function main() { + const args = parseArgs(process.argv.slice(2), { + string: ['url', 'mode', 'selector', 'output'], + boolean: ['headless', 'links', 'tables', 'images', 'help'], + default: { + mode: 'main', + headless: true, + links: true, + tables: true, + images: false, + }, + alias: { + u: 'url', + m: 'mode', + s: 'selector', + o: 'output', + h: 'help', + }, + }); + + if (args.help || !args.url) { + console.log(` +Web Scraper - Extract content to Markdown + +Usage: + npx tsx scrape.ts --url [options] + +Options: + -u, --url URL to scrape (required) + -m, --mode Scrape mode: main, full, or selector (default: main) + -s, --selector CSS selector for selector mode + -o, --output Output file path for markdown + --headless Run in headless mode (default: true) + --wait Wait time for dynamic content + --links Include links in output (default: true) + --tables Include tables in output (default: true) + --images Include images in output (default: false) + -h, --help Show this help message + +Scrape Modes: + main Extract main article content using Readability (best for articles) + full Full page content with common elements removed + selector Extract specific element by CSS selector + +Examples: + npx tsx scrape.ts --url "https://docs.example.com/guide" --mode main + npx tsx scrape.ts --url "https://example.com" --mode full --output page.md + npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".api-docs" + npx tsx scrape.ts --url "https://example.com" --mode main --no-links --output clean.md + +Output Format: + - GitHub Flavored Markdown (tables, strikethrough, task lists) + - Proper heading hierarchy + - Code blocks with language detection + - Metadata comments at top (source URL, date) +`); + process.exit(args.help ? 0 : 1); + } + + const mode = args.mode as ScrapeMode; + if (!['main', 'full', 'selector'].includes(mode)) { + console.error(`Invalid mode: ${mode}. Must be main, full, or selector.`); + process.exit(1); + } + + try { + const result = await scrape({ + url: args.url, + mode, + selector: args.selector, + output: args.output, + includeLinks: args.links, + includeTables: args.tables, + includeImages: args.images, + headless: args.headless, + wait: args.wait ? parseInt(args.wait, 10) : undefined, + }); + + // Print result summary + console.log(`\nScrape complete:`); + console.log(` Title: ${result.title}`); + console.log(` URL: ${result.url}`); + if (result.byline) console.log(` Author: ${result.byline}`); + console.log(` Markdown length: ${result.markdown.length} chars`); + + // Print markdown if not saved to file + if (!args.output) { + console.log('\n--- Markdown Output ---\n'); + console.log(result.markdown); + } + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error); + process.exit(1); + } +} + +// Run if executed directly +const isMainModule = process.argv[1]?.includes('scrape.ts'); +if (isMainModule) { + main(); +} diff --git a/skills/web-automation/cursor/scripts/test-full.ts b/skills/web-automation/cursor/scripts/test-full.ts new file mode 100644 index 0000000..356bbab --- /dev/null +++ b/skills/web-automation/cursor/scripts/test-full.ts @@ -0,0 +1,36 @@ +import { launchPersistentContext } from 'cloakbrowser'; +import { homedir } from 'os'; +import { join } from 'path'; +import { mkdirSync, existsSync } from 'fs'; + +async function test() { + const profilePath = join(homedir(), '.cloakbrowser-profile'); + if (!existsSync(profilePath)) { + mkdirSync(profilePath, { recursive: true }); + } + + console.log('Profile path:', profilePath); + console.log('Launching CloakBrowser with full options...'); + + const browser = await launchPersistentContext({ + headless: true, + userDataDir: profilePath, + humanize: true, + }); + + console.log('Browser launched'); + const page = browser.pages()[0] || await browser.newPage(); + console.log('Page created'); + + await page.goto('https://github.com', { timeout: 30000 }); + console.log('Navigated to:', page.url()); + console.log('Title:', await page.title()); + + await page.screenshot({ path: '/tmp/github-test.png' }); + console.log('Screenshot saved'); + + await browser.close(); + console.log('Done'); +} + +test().catch(console.error); diff --git a/skills/web-automation/cursor/scripts/test-minimal.ts b/skills/web-automation/cursor/scripts/test-minimal.ts new file mode 100644 index 0000000..a5412e7 --- /dev/null +++ b/skills/web-automation/cursor/scripts/test-minimal.ts @@ -0,0 +1,23 @@ +import { launch } from 'cloakbrowser'; + +async function test() { + console.log('Launching CloakBrowser with minimal config...'); + + const browser = await launch({ + headless: true, + humanize: true, + }); + + console.log('Browser launched'); + const page = await browser.newPage(); + console.log('Page created'); + + await page.goto('https://example.com', { timeout: 30000 }); + console.log('Navigated to:', page.url()); + console.log('Title:', await page.title()); + + await browser.close(); + console.log('Done'); +} + +test().catch(console.error); diff --git a/skills/web-automation/cursor/scripts/test-profile.ts b/skills/web-automation/cursor/scripts/test-profile.ts new file mode 100644 index 0000000..ec59ddd --- /dev/null +++ b/skills/web-automation/cursor/scripts/test-profile.ts @@ -0,0 +1,33 @@ +import { launchPersistentContext } from 'cloakbrowser'; +import { homedir } from 'os'; +import { join } from 'path'; +import { mkdirSync, existsSync } from 'fs'; + +async function test() { + const profilePath = join(homedir(), '.cloakbrowser-profile'); + if (!existsSync(profilePath)) { + mkdirSync(profilePath, { recursive: true }); + } + + console.log('Profile path:', profilePath); + console.log('Launching with persistent userDataDir...'); + + const browser = await launchPersistentContext({ + headless: true, + userDataDir: profilePath, + humanize: true, + }); + + console.log('Browser launched'); + const page = browser.pages()[0] || await browser.newPage(); + console.log('Page created'); + + await page.goto('https://example.com', { timeout: 30000 }); + console.log('Navigated to:', page.url()); + console.log('Title:', await page.title()); + + await browser.close(); + console.log('Done'); +} + +test().catch(console.error); diff --git a/skills/web-automation/cursor/scripts/tsconfig.json b/skills/web-automation/cursor/scripts/tsconfig.json new file mode 100644 index 0000000..4c23583 --- /dev/null +++ b/skills/web-automation/cursor/scripts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/skills/web-automation/cursor/scripts/turndown-plugin-gfm.d.ts b/skills/web-automation/cursor/scripts/turndown-plugin-gfm.d.ts new file mode 100644 index 0000000..316bed1 --- /dev/null +++ b/skills/web-automation/cursor/scripts/turndown-plugin-gfm.d.ts @@ -0,0 +1,8 @@ +declare module 'turndown-plugin-gfm' { + import TurndownService from 'turndown'; + + export function gfm(turndownService: TurndownService): void; + export function strikethrough(turndownService: TurndownService): void; + export function tables(turndownService: TurndownService): void; + export function taskListItems(turndownService: TurndownService): void; +} diff --git a/skills/web-automation/scripts/sync-variants.sh b/skills/web-automation/scripts/sync-variants.sh index be3bd09..e91ab23 100755 --- a/skills/web-automation/scripts/sync-variants.sh +++ b/skills/web-automation/scripts/sync-variants.sh @@ -5,6 +5,7 @@ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) SOURCE_DIR="${ROOT_DIR}/codex/scripts" TARGETS=( "claude-code" + "cursor" "opencode" "pi" )