diff --git a/skills/atlassian/pi/SKILL.md b/skills/atlassian/pi/SKILL.md new file mode 100644 index 0000000..03c8955 --- /dev/null +++ b/skills/atlassian/pi/SKILL.md @@ -0,0 +1,93 @@ +--- +name: atlassian +description: Interact with Atlassian Cloud Jira and Confluence through a portable task-oriented CLI for search, issue/page edits, comments, transitions, and bounded raw requests. +--- + +# Atlassian (Pi) + +Portable Atlassian workflows for pi using the shared TypeScript CLI in `scripts/`. + +## Requirements + +- Node.js 20+ +- `pnpm` +- Atlassian Cloud account access +- `ATLASSIAN_BASE_URL` +- `ATLASSIAN_EMAIL` +- `ATLASSIAN_API_TOKEN` + +The `ATLASSIAN_*` values may come from the shell environment or a `.env` file in the installed skill's `scripts/` directory. + +## First-Time Setup + +Global install: + +```bash +mkdir -p ~/.pi/agent/skills/atlassian +cp -R skills/atlassian/pi/* ~/.pi/agent/skills/atlassian/ +cd ~/.pi/agent/skills/atlassian/scripts +pnpm install +``` + +Project-local install: + +```bash +mkdir -p .pi/skills/atlassian +cp -R skills/atlassian/pi/* .pi/skills/atlassian/ +cd .pi/skills/atlassian/scripts +pnpm install +``` + +Pi can also load this repo through settings or package installs as documented in [docs/PI.md](../../../docs/PI.md). + +## Prerequisite Check (MANDATORY) + +Run inside the installed skill directory. The command block below uses the global install path; for a project-local install, replace `~/.pi/agent/skills/atlassian/` with `.pi/skills/atlassian/`. + +```bash +cd ~/.pi/agent/skills/atlassian/scripts +node -e "require.resolve('commander');require.resolve('dotenv');console.log('OK: runtime dependencies installed')" +node -e 'require("dotenv").config({ path: ".env" }); const required = ["ATLASSIAN_BASE_URL", "ATLASSIAN_EMAIL", "ATLASSIAN_API_TOKEN"]; const missing = required.filter((key) => !(process.env[key] || "").trim()); if (missing.length) { console.error("Missing required Atlassian config: " + missing.join(", ")); process.exit(1); } console.log("OK: Atlassian config present")' +pnpm atlassian health +``` + +If any check fails, stop and return: + +`Missing dependency/config: atlassian requires installed CLI dependencies and valid Atlassian Cloud credentials. Configure ATLASSIAN_* in the shell environment or scripts/.env, then retry.` + +## Supported Commands + +- `pnpm atlassian health` +- `pnpm atlassian jira-search --jql "..."` +- `pnpm atlassian jira-get --issue ABC-123` +- `pnpm atlassian jira-create ... [--dry-run]` +- `pnpm atlassian jira-update ... [--dry-run]` +- `pnpm atlassian jira-comment ... [--dry-run]` +- `pnpm atlassian jira-transitions --issue ABC-123` +- `pnpm atlassian jira-transition ... [--dry-run]` +- `pnpm atlassian conf-search --query "..."` +- `pnpm atlassian conf-get --page 12345` +- `pnpm atlassian conf-create ... [--dry-run]` +- `pnpm atlassian conf-update ... [--dry-run]` +- `pnpm atlassian conf-comment ... [--dry-run]` +- `pnpm atlassian conf-children --page 12345` +- `pnpm atlassian raw --product jira|confluence --method GET|POST|PUT --path ...` + +## Usage Examples + +- `pnpm atlassian jira-search --jql "project = ENG ORDER BY updated DESC" --max-results 10` +- `pnpm atlassian conf-comment --page 12345 --body-file comment.storage.html --dry-run` +- `pnpm atlassian raw --product jira --method GET --path "/rest/api/3/issue/ENG-123"` + +## Safety Rules + +- Default output is JSON; prefer that for agent workflows. +- Use `--dry-run` before any mutating command unless the user clearly wants the write to happen immediately. +- `raw` is for explicit edge cases only and does not allow `DELETE`. +- `--body-file` must stay inside the current workspace. +- Confluence write bodies should be storage-format inputs in v1. + +## Notes + +- Atlassian Cloud is the primary supported platform in v1. +- Pi validates skill naming leniently, so this repo keeps the shared `skills//pi/` layout even though the installed parent directory should be `atlassian`. diff --git a/skills/atlassian/pi/scripts/package.json b/skills/atlassian/pi/scripts/package.json new file mode 100644 index 0000000..a9d8adc --- /dev/null +++ b/skills/atlassian/pi/scripts/package.json @@ -0,0 +1,20 @@ +{ + "name": "atlassian-skill-scripts", + "version": "1.0.0", + "description": "Shared runtime for the Atlassian skill", + "type": "module", + "scripts": { + "atlassian": "tsx src/cli.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "commander": "^13.1.0", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + }, + "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34" +} diff --git a/skills/atlassian/pi/scripts/pnpm-lock.yaml b/skills/atlassian/pi/scripts/pnpm-lock.yaml new file mode 100644 index 0000000..76a55ac --- /dev/null +++ b/skills/atlassian/pi/scripts/pnpm-lock.yaml @@ -0,0 +1,361 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + commander: + specifier: ^13.1.0 + version: 13.1.0 + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + devDependencies: + '@types/node': + specifier: ^24.3.0 + version: 24.12.0 + tsx: + specifier: ^4.20.5 + version: 4.21.0 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + 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==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + commander@13.1.0: {} + + dotenv@16.6.1: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + resolve-pkg-maps@1.0.0: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@7.16.0: {} diff --git a/skills/atlassian/pi/scripts/src/adf.ts b/skills/atlassian/pi/scripts/src/adf.ts new file mode 100644 index 0000000..638914c --- /dev/null +++ b/skills/atlassian/pi/scripts/src/adf.ts @@ -0,0 +1,92 @@ +const TEXT_NODE = "text"; + +function textNode(text: string) { + return { + type: TEXT_NODE, + text, + }; +} + +function paragraphNode(lines: string[]) { + const content: Array<{ type: string; text?: string }> = []; + + lines.forEach((line, index) => { + if (index > 0) { + content.push({ type: "hardBreak" }); + } + + if (line.length > 0) { + content.push(textNode(line)); + } + }); + + return { + type: "paragraph", + ...(content.length > 0 ? { content } : {}), + }; +} + +export function markdownToAdf(input: string) { + const lines = input.replace(/\r\n/g, "\n").split("\n"); + const content: Array> = []; + let index = 0; + + while (index < lines.length) { + const current = lines[index]?.trimEnd() ?? ""; + + if (current.trim().length === 0) { + index += 1; + continue; + } + + const heading = current.match(/^(#{1,6})\s+(.*)$/); + + if (heading) { + content.push({ + type: "heading", + attrs: { level: heading[1].length }, + content: [textNode(heading[2])], + }); + index += 1; + continue; + } + + if (/^[-*]\s+/.test(current)) { + const items: Array> = []; + + while (index < lines.length && /^[-*]\s+/.test(lines[index] ?? "")) { + items.push({ + type: "listItem", + content: [ + { + type: "paragraph", + content: [textNode((lines[index] ?? "").replace(/^[-*]\s+/, ""))], + }, + ], + }); + index += 1; + } + + content.push({ + type: "bulletList", + content: items, + }); + continue; + } + + const paragraphLines: string[] = []; + + while (index < lines.length && (lines[index]?.trim().length ?? 0) > 0) { + paragraphLines.push(lines[index] ?? ""); + index += 1; + } + + content.push(paragraphNode(paragraphLines)); + } + + return { + type: "doc", + version: 1, + content, + }; +} diff --git a/skills/atlassian/pi/scripts/src/cli.ts b/skills/atlassian/pi/scripts/src/cli.ts new file mode 100644 index 0000000..6012b99 --- /dev/null +++ b/skills/atlassian/pi/scripts/src/cli.ts @@ -0,0 +1,332 @@ +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +import { Command } from "commander"; + +import { createConfluenceClient } from "./confluence.js"; +import { loadConfig } from "./config.js"; +import { readWorkspaceFile } from "./files.js"; +import { runHealthCheck } from "./health.js"; +import { createJiraClient } from "./jira.js"; +import { writeOutput } from "./output.js"; +import { runRawCommand } from "./raw.js"; +import type { FetchLike, OutputFormat, Writer } from "./types.js"; + +type CliContext = { + cwd?: string; + env?: NodeJS.ProcessEnv; + fetchImpl?: FetchLike; + stdout?: Writer; + stderr?: Writer; +}; + +function resolveFormat(format: string | undefined): OutputFormat { + return format === "text" ? "text" : "json"; +} + +function createRuntime(context: CliContext) { + const cwd = context.cwd ?? process.cwd(); + const env = context.env ?? process.env; + const stdout = context.stdout ?? process.stdout; + const stderr = context.stderr ?? process.stderr; + let configCache: ReturnType | undefined; + let jiraCache: ReturnType | undefined; + let confluenceCache: ReturnType | undefined; + + function getConfig() { + configCache ??= loadConfig(env, { cwd }); + return configCache; + } + + function getJiraClient() { + jiraCache ??= createJiraClient({ + config: getConfig(), + fetchImpl: context.fetchImpl, + }); + return jiraCache; + } + + function getConfluenceClient() { + confluenceCache ??= createConfluenceClient({ + config: getConfig(), + fetchImpl: context.fetchImpl, + }); + return confluenceCache; + } + + async function readBodyFile(filePath: string | undefined) { + if (!filePath) { + return undefined; + } + + return readWorkspaceFile(filePath, cwd); + } + + return { + cwd, + stdout, + stderr, + readBodyFile, + getConfig, + getJiraClient, + getConfluenceClient, + fetchImpl: context.fetchImpl, + }; +} + +export function buildProgram(context: CliContext = {}) { + const runtime = createRuntime(context); + const program = new Command() + .name("atlassian") + .description("Portable Atlassian CLI for multi-agent skills") + .version("0.1.0"); + + program + .command("health") + .description("Validate configuration and Atlassian connectivity") + .option("--format ", "Output format", "json") + .action(async (options) => { + const payload = await runHealthCheck(runtime.getConfig(), runtime.fetchImpl); + writeOutput( + runtime.stdout, + payload, + resolveFormat(options.format), + ); + }); + + program + .command("conf-search") + .requiredOption("--query ", "CQL search query") + .option("--max-results ", "Maximum results to return", "50") + .option("--start-at ", "Result offset", "0") + .option("--format ", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getConfluenceClient().searchPages({ + query: options.query, + maxResults: Number(options.maxResults), + startAt: Number(options.startAt), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("conf-get") + .requiredOption("--page ", "Confluence page ID") + .option("--format ", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getConfluenceClient().getPage(options.page); + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("conf-create") + .requiredOption("--title ", "Confluence page title") + .requiredOption("--body-file <path>", "Workspace-relative storage-format body file") + .option("--space <space>", "Confluence space ID") + .option("--dry-run", "Print the request without sending it") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getConfluenceClient().createPage({ + space: options.space, + title: options.title, + body: (await runtime.readBodyFile(options.bodyFile)) as string, + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("conf-update") + .requiredOption("--page <page>", "Confluence page ID") + .requiredOption("--title <title>", "Confluence page title") + .requiredOption("--body-file <path>", "Workspace-relative storage-format body file") + .option("--dry-run", "Print the request without sending it") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getConfluenceClient().updatePage({ + pageId: options.page, + title: options.title, + body: (await runtime.readBodyFile(options.bodyFile)) as string, + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("conf-comment") + .requiredOption("--page <page>", "Confluence page ID") + .requiredOption("--body-file <path>", "Workspace-relative storage-format body file") + .option("--dry-run", "Print the request without sending it") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getConfluenceClient().commentPage({ + pageId: options.page, + body: (await runtime.readBodyFile(options.bodyFile)) as string, + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("conf-children") + .requiredOption("--page <page>", "Confluence page ID") + .option("--max-results <number>", "Maximum results to return", "50") + .option("--start-at <number>", "Cursor/start token", "0") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getConfluenceClient().listChildren( + options.page, + Number(options.maxResults), + Number(options.startAt), + ); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("raw") + .requiredOption("--product <product>", "jira or confluence") + .requiredOption("--method <method>", "GET, POST, or PUT") + .requiredOption("--path <path>", "Validated API path") + .option("--body-file <path>", "Workspace-relative JSON file") + .option("--dry-run", "Print the request without sending it") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runRawCommand(runtime.getConfig(), runtime.fetchImpl, { + product: options.product, + method: String(options.method).toUpperCase(), + path: options.path, + bodyFile: options.bodyFile, + cwd: runtime.cwd, + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-search") + .requiredOption("--jql <jql>", "JQL expression to execute") + .option("--max-results <number>", "Maximum results to return", "50") + .option("--start-at <number>", "Result offset", "0") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().searchIssues({ + jql: options.jql, + maxResults: Number(options.maxResults), + startAt: Number(options.startAt), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-get") + .requiredOption("--issue <issue>", "Issue key") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().getIssue(options.issue); + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-create") + .requiredOption("--type <type>", "Issue type name") + .requiredOption("--summary <summary>", "Issue summary") + .option("--project <project>", "Project key") + .option("--description-file <path>", "Workspace-relative markdown/text file") + .option("--dry-run", "Print the request without sending it") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().createIssue({ + project: options.project, + type: options.type, + summary: options.summary, + description: await runtime.readBodyFile(options.descriptionFile), + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-update") + .requiredOption("--issue <issue>", "Issue key") + .option("--summary <summary>", "Updated summary") + .option("--description-file <path>", "Workspace-relative markdown/text file") + .option("--dry-run", "Print the request without sending it") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().updateIssue({ + issue: options.issue, + summary: options.summary, + description: await runtime.readBodyFile(options.descriptionFile), + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-comment") + .requiredOption("--issue <issue>", "Issue key") + .requiredOption("--body-file <path>", "Workspace-relative markdown/text file") + .option("--dry-run", "Print the request without sending it") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().commentIssue({ + issue: options.issue, + body: (await runtime.readBodyFile(options.bodyFile)) as string, + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-transitions") + .requiredOption("--issue <issue>", "Issue key") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().getTransitions(options.issue); + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-transition") + .requiredOption("--issue <issue>", "Issue key") + .requiredOption("--transition <transition>", "Transition ID") + .option("--dry-run", "Print the request without sending it") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().transitionIssue({ + issue: options.issue, + transition: options.transition, + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + return program; +} + +export async function runCli(argv = process.argv, context: CliContext = {}) { + const program = buildProgram(context); + await program.parseAsync(argv); +} + +const isDirectExecution = + Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isDirectExecution) { + runCli().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; + }); +} diff --git a/skills/atlassian/pi/scripts/src/config.ts b/skills/atlassian/pi/scripts/src/config.ts new file mode 100644 index 0000000..eb34a39 --- /dev/null +++ b/skills/atlassian/pi/scripts/src/config.ts @@ -0,0 +1,52 @@ +import path from "node:path"; + +import { config as loadDotEnv } from "dotenv"; + +import type { AtlassianConfig } from "./types.js"; + +function normalizeBaseUrl(value: string) { + return value.replace(/\/+$/, ""); +} + +function readRequired(env: NodeJS.ProcessEnv, key: string) { + const value = env[key]?.trim(); + + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + + return value; +} + +export function loadConfig( + env: NodeJS.ProcessEnv = process.env, + options?: { + cwd?: string; + }, +): AtlassianConfig { + loadDotEnv({ + path: path.resolve(options?.cwd ?? process.cwd(), ".env"), + processEnv: env as Record<string, string>, + override: false, + }); + + const baseUrl = normalizeBaseUrl(readRequired(env, "ATLASSIAN_BASE_URL")); + + return { + baseUrl, + jiraBaseUrl: normalizeBaseUrl(env.ATLASSIAN_JIRA_BASE_URL?.trim() || baseUrl), + confluenceBaseUrl: normalizeBaseUrl(env.ATLASSIAN_CONFLUENCE_BASE_URL?.trim() || baseUrl), + email: readRequired(env, "ATLASSIAN_EMAIL"), + apiToken: readRequired(env, "ATLASSIAN_API_TOKEN"), + defaultProject: env.ATLASSIAN_DEFAULT_PROJECT?.trim() || undefined, + defaultSpace: env.ATLASSIAN_DEFAULT_SPACE?.trim() || undefined, + }; +} + +export function createBasicAuthHeader(config: { + email: string; + apiToken: string; + [key: string]: unknown; +}) { + return `Basic ${Buffer.from(`${config.email}:${config.apiToken}`).toString("base64")}`; +} diff --git a/skills/atlassian/pi/scripts/src/confluence.ts b/skills/atlassian/pi/scripts/src/confluence.ts new file mode 100644 index 0000000..f22d66d --- /dev/null +++ b/skills/atlassian/pi/scripts/src/confluence.ts @@ -0,0 +1,292 @@ +import { sendJsonRequest } from "./http.js"; +import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; + +type ConfluenceClientOptions = { + config: AtlassianConfig; + fetchImpl?: FetchLike; +}; + +type SearchInput = { + query: string; + maxResults: number; + startAt: number; +}; + +type CreateInput = { + space?: string; + title: string; + body: string; + dryRun?: boolean; +}; + +type UpdateInput = { + pageId: string; + title: string; + body: string; + dryRun?: boolean; +}; + +type CommentInput = { + pageId: string; + body: string; + dryRun?: boolean; +}; + +type PageSummary = { + id: string; + title: string; + type: string; + status?: string; + spaceId?: string; + url?: string; +}; + +function buildUrl(baseUrl: string, path: string) { + return new URL(path, `${baseUrl}/`).toString(); +} + +function normalizePage(baseUrl: string, page: Record<string, unknown>, excerpt?: string) { + const links = (page._links ?? {}) as Record<string, unknown>; + + return { + id: String(page.id ?? ""), + title: String(page.title ?? ""), + type: String(page.type ?? "page"), + ...(page.status ? { status: String(page.status) } : {}), + ...(page.spaceId ? { spaceId: String(page.spaceId) } : {}), + ...(excerpt ? { excerpt } : {}), + ...(links.webui ? { url: `${baseUrl}${String(links.webui)}` } : {}), + }; +} + +export function createConfluenceClient(options: ConfluenceClientOptions) { + const config = options.config; + + async function getPageForUpdate(pageId: string) { + return (await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: buildUrl(config.confluenceBaseUrl, `/wiki/api/v2/pages/${pageId}?body-format=storage`), + method: "GET", + errorPrefix: "Confluence request failed", + })) as Record<string, unknown>; + } + + return { + async searchPages(input: SearchInput): Promise<CommandOutput<unknown>> { + const url = new URL("/wiki/rest/api/search", `${config.confluenceBaseUrl}/`); + url.searchParams.set("cql", input.query); + url.searchParams.set("limit", String(input.maxResults)); + url.searchParams.set("start", String(input.startAt)); + + const raw = (await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: url.toString(), + method: "GET", + errorPrefix: "Confluence request failed", + })) as Record<string, unknown>; + + const results = Array.isArray(raw.results) ? raw.results : []; + + return { + ok: true, + data: { + pages: results.map((entry) => { + const result = entry as Record<string, unknown>; + return normalizePage( + config.baseUrl, + (result.content ?? {}) as Record<string, unknown>, + result.excerpt ? String(result.excerpt) : undefined, + ); + }), + startAt: Number(raw.start ?? input.startAt), + maxResults: Number(raw.limit ?? input.maxResults), + total: Number(raw.totalSize ?? raw.size ?? results.length), + }, + }; + }, + + async getPage(pageId: string): Promise<CommandOutput<unknown>> { + const raw = (await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: buildUrl(config.confluenceBaseUrl, `/wiki/api/v2/pages/${pageId}?body-format=storage`), + method: "GET", + errorPrefix: "Confluence request failed", + })) as Record<string, unknown>; + + const body = ((raw.body ?? {}) as Record<string, unknown>).storage as Record<string, unknown> | undefined; + + return { + ok: true, + data: { + page: { + ...normalizePage(config.baseUrl, raw), + version: Number((((raw.version ?? {}) as Record<string, unknown>).number ?? 0)), + body: body?.value ? String(body.value) : "", + }, + }, + raw, + }; + }, + + async listChildren(pageId: string, maxResults: number, startAt: number): Promise<CommandOutput<unknown>> { + const url = new URL(`/wiki/api/v2/pages/${pageId}/direct-children`, `${config.confluenceBaseUrl}/`); + url.searchParams.set("limit", String(maxResults)); + url.searchParams.set("cursor", String(startAt)); + + const raw = (await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: url.toString(), + method: "GET", + errorPrefix: "Confluence request failed", + })) as Record<string, unknown>; + + const results = Array.isArray(raw.results) ? raw.results : []; + const links = (raw._links ?? {}) as Record<string, unknown>; + + return { + ok: true, + data: { + pages: results.map((page) => normalizePage(config.baseUrl, page as Record<string, unknown>)), + nextCursor: links.next ? String(links.next) : null, + }, + }; + }, + + async createPage(input: CreateInput): Promise<CommandOutput<unknown>> { + const spaceId = input.space || config.defaultSpace; + + if (!spaceId) { + throw new Error("conf-create requires --space or ATLASSIAN_DEFAULT_SPACE"); + } + + const request = { + method: "POST" as const, + url: buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/pages"), + body: { + spaceId, + title: input.title, + status: "current", + body: { + representation: "storage", + value: input.body, + }, + }, + }; + + if (input.dryRun) { + return { + ok: true, + dryRun: true, + data: request, + }; + } + + const raw = await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: request.url, + method: request.method, + body: request.body, + errorPrefix: "Confluence request failed", + }); + + return { + ok: true, + data: raw, + }; + }, + + async updatePage(input: UpdateInput): Promise<CommandOutput<unknown>> { + const currentPage = await getPageForUpdate(input.pageId); + const version = (((currentPage.version ?? {}) as Record<string, unknown>).number ?? 0) as number; + const spaceId = String(currentPage.spaceId ?? ""); + + const request = { + method: "PUT" as const, + url: buildUrl(config.confluenceBaseUrl, `/wiki/api/v2/pages/${input.pageId}`), + body: { + id: input.pageId, + status: String(currentPage.status ?? "current"), + title: input.title, + spaceId, + version: { + number: Number(version) + 1, + }, + body: { + representation: "storage", + value: input.body, + }, + }, + }; + + if (input.dryRun) { + return { + ok: true, + dryRun: true, + data: request, + }; + } + + const raw = await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: request.url, + method: request.method, + body: request.body, + errorPrefix: "Confluence request failed", + handleResponseError(response) { + if (response.status === 409) { + return new Error(`Confluence update conflict: page ${input.pageId} was updated by someone else`); + } + + return undefined; + }, + }); + + return { + ok: true, + data: raw, + }; + }, + + async commentPage(input: CommentInput): Promise<CommandOutput<unknown>> { + const request = { + method: "POST" as const, + url: buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/footer-comments"), + body: { + pageId: input.pageId, + body: { + representation: "storage", + value: input.body, + }, + }, + }; + + if (input.dryRun) { + return { + ok: true, + dryRun: true, + data: request, + }; + } + + const raw = await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: request.url, + method: request.method, + body: request.body, + errorPrefix: "Confluence request failed", + }); + + return { + ok: true, + data: raw, + }; + }, + }; +} diff --git a/skills/atlassian/pi/scripts/src/files.ts b/skills/atlassian/pi/scripts/src/files.ts new file mode 100644 index 0000000..8339109 --- /dev/null +++ b/skills/atlassian/pi/scripts/src/files.ts @@ -0,0 +1,13 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +export async function readWorkspaceFile(filePath: string, cwd: string) { + const resolved = path.resolve(cwd, filePath); + const relative = path.relative(cwd, resolved); + + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`--body-file must stay within the active workspace: ${filePath}`); + } + + return readFile(resolved, "utf8"); +} diff --git a/skills/atlassian/pi/scripts/src/health.ts b/skills/atlassian/pi/scripts/src/health.ts new file mode 100644 index 0000000..b2d4d24 --- /dev/null +++ b/skills/atlassian/pi/scripts/src/health.ts @@ -0,0 +1,69 @@ +import { createJsonHeaders, createStatusError } from "./http.js"; +import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; + +type ProductHealth = { + ok: boolean; + status?: number; + message?: string; +}; + +function buildUrl(baseUrl: string, path: string) { + return new URL(path, `${baseUrl}/`).toString(); +} + +export async function runHealthCheck( + config: AtlassianConfig, + fetchImpl: FetchLike | undefined, +): Promise<CommandOutput<unknown>> { + const client = fetchImpl ?? globalThis.fetch; + + if (!client) { + throw new Error("Fetch API is not available in this runtime"); + } + + async function probe(product: "Jira" | "Confluence", url: string): Promise<ProductHealth> { + try { + const response = await client(url, { + method: "GET", + headers: createJsonHeaders(config, false), + }); + + if (!response.ok) { + const error = createStatusError(`${product} health check failed`, response); + return { + ok: false, + status: response.status, + message: error.message, + }; + } + + return { + ok: true, + status: response.status, + }; + } catch (error: unknown) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + const jira = await probe("Jira", buildUrl(config.jiraBaseUrl, "/rest/api/3/myself")); + const confluence = await probe("Confluence", buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/spaces?limit=1")); + + return { + ok: jira.ok && confluence.ok, + data: { + baseUrl: config.baseUrl, + jiraBaseUrl: config.jiraBaseUrl, + confluenceBaseUrl: config.confluenceBaseUrl, + defaultProject: config.defaultProject, + defaultSpace: config.defaultSpace, + products: { + jira, + confluence, + }, + }, + }; +} diff --git a/skills/atlassian/pi/scripts/src/http.ts b/skills/atlassian/pi/scripts/src/http.ts new file mode 100644 index 0000000..5791886 --- /dev/null +++ b/skills/atlassian/pi/scripts/src/http.ts @@ -0,0 +1,86 @@ +import { createBasicAuthHeader } from "./config.js"; +import type { AtlassianConfig, FetchLike } from "./types.js"; + +export type HttpMethod = "GET" | "POST" | "PUT"; + +export function createJsonHeaders(config: AtlassianConfig, includeJsonBody: boolean) { + const headers: Array<[string, string]> = [ + ["Accept", "application/json"], + ["Authorization", createBasicAuthHeader(config)], + ]; + + if (includeJsonBody) { + headers.push(["Content-Type", "application/json"]); + } + + return headers; +} + +export async function parseResponse(response: Response) { + if (response.status === 204) { + return null; + } + + const contentType = response.headers.get("content-type") ?? ""; + + if (contentType.includes("application/json")) { + try { + return await response.json(); + } catch { + throw new Error("Malformed JSON response from Atlassian API"); + } + } + + return response.text(); +} + +export function createStatusError(errorPrefix: string, response: Response) { + const base = `${errorPrefix}: ${response.status} ${response.statusText}`; + + switch (response.status) { + case 401: + return new Error(`${base} - check ATLASSIAN_EMAIL and ATLASSIAN_API_TOKEN`); + case 403: + return new Error(`${base} - verify product permissions for this account`); + case 404: + return new Error(`${base} - verify the resource identifier or API path`); + case 429: + return new Error(`${base} - retry later or reduce request rate`); + default: + return new Error(base); + } +} + +export async function sendJsonRequest(options: { + config: AtlassianConfig; + fetchImpl?: FetchLike; + url: string; + method: HttpMethod; + body?: unknown; + errorPrefix: string; + handleResponseError?: (response: Response) => Error | undefined; +}) { + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + + if (!fetchImpl) { + throw new Error("Fetch API is not available in this runtime"); + } + + const response = await fetchImpl(options.url, { + method: options.method, + headers: createJsonHeaders(options.config, options.body !== undefined), + ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }), + }); + + if (!response.ok) { + const customError = options.handleResponseError?.(response); + + if (customError) { + throw customError; + } + + throw createStatusError(options.errorPrefix, response); + } + + return parseResponse(response); +} diff --git a/skills/atlassian/pi/scripts/src/jira.ts b/skills/atlassian/pi/scripts/src/jira.ts new file mode 100644 index 0000000..5cf3a6e --- /dev/null +++ b/skills/atlassian/pi/scripts/src/jira.ts @@ -0,0 +1,264 @@ +import { markdownToAdf } from "./adf.js"; +import { sendJsonRequest } from "./http.js"; +import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; + +const ISSUE_FIELDS = ["summary", "issuetype", "status", "assignee", "created", "updated"] as const; + +type JiraClientOptions = { + config: AtlassianConfig; + fetchImpl?: FetchLike; +}; + +type SearchInput = { + jql: string; + maxResults: number; + startAt: number; +}; + +type CreateInput = { + project?: string; + type: string; + summary: string; + description?: string; + dryRun?: boolean; +}; + +type UpdateInput = { + issue: string; + summary?: string; + description?: string; + dryRun?: boolean; +}; + +type CommentInput = { + issue: string; + body: string; + dryRun?: boolean; +}; + +type TransitionInput = { + issue: string; + transition: string; + dryRun?: boolean; +}; + +function normalizeIssue(config: AtlassianConfig, issue: Record<string, unknown>): JiraIssueSummary { + const fields = (issue.fields ?? {}) as Record<string, unknown>; + const issueType = (fields.issuetype ?? {}) as Record<string, unknown>; + const status = (fields.status ?? {}) as Record<string, unknown>; + const assignee = (fields.assignee ?? {}) as Record<string, unknown>; + + return { + key: String(issue.key ?? ""), + summary: String(fields.summary ?? ""), + issueType: String(issueType.name ?? ""), + status: String(status.name ?? ""), + assignee: assignee.displayName ? String(assignee.displayName) : undefined, + created: String(fields.created ?? ""), + updated: String(fields.updated ?? ""), + url: `${config.baseUrl}/browse/${issue.key ?? ""}`, + }; +} + +function createRequest(config: AtlassianConfig, method: "GET" | "POST" | "PUT", path: string, body?: unknown) { + const url = new URL(path, `${config.jiraBaseUrl}/`); + + return { + method, + url: url.toString(), + ...(body === undefined ? {} : { body }), + }; +} + +export function createJiraClient(options: JiraClientOptions) { + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + + if (!fetchImpl) { + throw new Error("Fetch API is not available in this runtime"); + } + + async function send(method: "GET" | "POST" | "PUT", path: string, body?: unknown) { + const request = createRequest(options.config, method, path, body); + return sendJsonRequest({ + config: options.config, + fetchImpl, + url: request.url, + method, + body, + errorPrefix: "Jira request failed", + }); + } + + return { + async searchIssues(input: SearchInput): Promise<CommandOutput<unknown>> { + const raw = (await send("POST", "/rest/api/3/search", { + jql: input.jql, + maxResults: input.maxResults, + startAt: input.startAt, + fields: [...ISSUE_FIELDS], + })) as Record<string, unknown>; + + const issues = Array.isArray(raw.issues) ? raw.issues : []; + + return { + ok: true, + data: { + issues: issues.map((issue) => normalizeIssue(options.config, issue as Record<string, unknown>)), + startAt: Number(raw.startAt ?? input.startAt), + maxResults: Number(raw.maxResults ?? input.maxResults), + total: Number(raw.total ?? issues.length), + }, + }; + }, + + async getIssue(issue: string): Promise<CommandOutput<unknown>> { + const url = new URL(`/rest/api/3/issue/${issue}`, `${options.config.jiraBaseUrl}/`); + url.searchParams.set("fields", ISSUE_FIELDS.join(",")); + + const raw = (await send("GET", `${url.pathname}${url.search}`)) as Record<string, unknown>; + + return { + ok: true, + data: { + issue: normalizeIssue(options.config, raw), + }, + raw, + }; + }, + + async getTransitions(issue: string): Promise<CommandOutput<unknown>> { + const raw = (await send( + "GET", + `/rest/api/3/issue/${issue}/transitions`, + )) as { transitions?: Array<Record<string, unknown>> }; + + return { + ok: true, + data: { + transitions: (raw.transitions ?? []).map((transition) => ({ + id: String(transition.id ?? ""), + name: String(transition.name ?? ""), + toStatus: String(((transition.to ?? {}) as Record<string, unknown>).name ?? ""), + hasScreen: Boolean(transition.hasScreen), + })), + }, + }; + }, + + async createIssue(input: CreateInput): Promise<CommandOutput<unknown>> { + const project = input.project || options.config.defaultProject; + + if (!project) { + throw new Error("jira-create requires --project or ATLASSIAN_DEFAULT_PROJECT"); + } + + const request = createRequest(options.config, "POST", "/rest/api/3/issue", { + fields: { + project: { key: project }, + issuetype: { name: input.type }, + summary: input.summary, + ...(input.description ? { description: markdownToAdf(input.description) } : {}), + }, + }); + + if (input.dryRun) { + return { + ok: true, + dryRun: true, + data: request, + }; + } + + const raw = await send("POST", "/rest/api/3/issue", request.body); + return { ok: true, data: raw }; + }, + + async updateIssue(input: UpdateInput): Promise<CommandOutput<unknown>> { + const fields: Record<string, unknown> = {}; + + if (input.summary) { + fields.summary = input.summary; + } + + if (input.description) { + fields.description = markdownToAdf(input.description); + } + + if (Object.keys(fields).length === 0) { + throw new Error("jira-update requires --summary and/or --description-file"); + } + + const request = createRequest(options.config, "PUT", `/rest/api/3/issue/${input.issue}`, { + fields, + }); + + if (input.dryRun) { + return { + ok: true, + dryRun: true, + data: request, + }; + } + + await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); + return { + ok: true, + data: { + issue: input.issue, + updated: true, + }, + }; + }, + + async commentIssue(input: CommentInput): Promise<CommandOutput<unknown>> { + const request = createRequest(options.config, "POST", `/rest/api/3/issue/${input.issue}/comment`, { + body: markdownToAdf(input.body), + }); + + if (input.dryRun) { + return { + ok: true, + dryRun: true, + data: request, + }; + } + + const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); + return { + ok: true, + data: raw, + }; + }, + + async transitionIssue(input: TransitionInput): Promise<CommandOutput<unknown>> { + const request = createRequest( + options.config, + "POST", + `/rest/api/3/issue/${input.issue}/transitions`, + { + transition: { + id: input.transition, + }, + }, + ); + + if (input.dryRun) { + return { + ok: true, + dryRun: true, + data: request, + }; + } + + await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); + return { + ok: true, + data: { + issue: input.issue, + transitioned: true, + transition: input.transition, + }, + }; + }, + }; +} diff --git a/skills/atlassian/pi/scripts/src/output.ts b/skills/atlassian/pi/scripts/src/output.ts new file mode 100644 index 0000000..06b8a6e --- /dev/null +++ b/skills/atlassian/pi/scripts/src/output.ts @@ -0,0 +1,44 @@ +import type { CommandOutput, OutputFormat, Writer } from "./types.js"; + +function renderText(payload: CommandOutput<unknown>) { + const data = payload.data as Record<string, unknown>; + + if (Array.isArray(data?.issues)) { + return data.issues + .map((issue) => { + const item = issue as Record<string, string>; + return `${item.key} [${item.status}] ${item.issueType} - ${item.summary}`; + }) + .join("\n"); + } + + if (data?.issue && typeof data.issue === "object") { + const issue = data.issue as Record<string, string>; + return [ + issue.key, + `${issue.issueType} | ${issue.status}`, + issue.summary, + issue.url, + ].join("\n"); + } + + if (Array.isArray(data?.transitions)) { + return data.transitions + .map((transition) => { + const item = transition as Record<string, string>; + return `${item.id} ${item.name} -> ${item.toStatus}`; + }) + .join("\n"); + } + + return JSON.stringify(payload, null, 2); +} + +export function writeOutput( + writer: Writer, + payload: CommandOutput<unknown>, + format: OutputFormat = "json", +) { + const body = format === "text" ? renderText(payload) : JSON.stringify(payload, null, 2); + writer.write(`${body}\n`); +} diff --git a/skills/atlassian/pi/scripts/src/raw.ts b/skills/atlassian/pi/scripts/src/raw.ts new file mode 100644 index 0000000..8e11793 --- /dev/null +++ b/skills/atlassian/pi/scripts/src/raw.ts @@ -0,0 +1,85 @@ +import { readWorkspaceFile } from "./files.js"; +import { sendJsonRequest } from "./http.js"; +import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; + +const JIRA_ALLOWED_PREFIXES = ["/rest/api/3/"] as const; +const CONFLUENCE_ALLOWED_PREFIXES = ["/wiki/api/v2/", "/wiki/rest/api/"] as const; + +type RawInput = { + product: "jira" | "confluence"; + method: string; + path: string; + bodyFile?: string; + cwd: string; + dryRun?: boolean; +}; + +function getAllowedPrefixes(product: RawInput["product"]) { + return product === "jira" ? JIRA_ALLOWED_PREFIXES : CONFLUENCE_ALLOWED_PREFIXES; +} + +function buildUrl(config: AtlassianConfig, product: RawInput["product"], path: string) { + const baseUrl = product === "jira" ? config.jiraBaseUrl : config.confluenceBaseUrl; + return new URL(path, `${baseUrl}/`).toString(); +} + +function validateMethod(method: string): asserts method is "GET" | "POST" | "PUT" { + if (!["GET", "POST", "PUT"].includes(method)) { + throw new Error("raw only allows GET, POST, and PUT"); + } +} + +function validatePath(product: RawInput["product"], path: string) { + const allowedPrefixes = getAllowedPrefixes(product); + + if (!allowedPrefixes.some((prefix) => path.startsWith(prefix))) { + throw new Error(`raw path is not allowed for ${product}: ${path}`); + } +} + +async function readRawBody(bodyFile: string | undefined, cwd: string) { + if (!bodyFile) { + return undefined; + } + + const contents = await readWorkspaceFile(bodyFile, cwd); + return JSON.parse(contents) as unknown; +} + +export async function runRawCommand( + config: AtlassianConfig, + fetchImpl: FetchLike | undefined, + input: RawInput, +): Promise<CommandOutput<unknown>> { + validateMethod(input.method); + validatePath(input.product, input.path); + + const body = await readRawBody(input.bodyFile, input.cwd); + const request = { + method: input.method, + url: buildUrl(config, input.product, input.path), + ...(body === undefined ? {} : { body }), + }; + + if (input.dryRun) { + return { + ok: true, + dryRun: true, + data: request, + }; + } + + const data = await sendJsonRequest({ + config, + fetchImpl, + url: request.url, + method: input.method, + body, + errorPrefix: "Raw request failed", + }); + + return { + ok: true, + data, + }; +} diff --git a/skills/atlassian/pi/scripts/src/types.ts b/skills/atlassian/pi/scripts/src/types.ts new file mode 100644 index 0000000..7f48f56 --- /dev/null +++ b/skills/atlassian/pi/scripts/src/types.ts @@ -0,0 +1,35 @@ +export type AtlassianConfig = { + baseUrl: string; + jiraBaseUrl: string; + confluenceBaseUrl: string; + email: string; + apiToken: string; + defaultProject?: string; + defaultSpace?: string; +}; + +export type CommandOutput<T> = { + ok: boolean; + data: T; + dryRun?: boolean; + raw?: unknown; +}; + +export type JiraIssueSummary = { + key: string; + summary: string; + issueType: string; + status: string; + assignee?: string; + created: string; + updated: string; + url: string; +}; + +export type Writer = { + write(chunk: string | Uint8Array): unknown; +}; + +export type FetchLike = typeof fetch; + +export type OutputFormat = "json" | "text"; diff --git a/skills/atlassian/pi/scripts/tsconfig.json b/skills/atlassian/pi/scripts/tsconfig.json new file mode 100644 index 0000000..6a0692b --- /dev/null +++ b/skills/atlassian/pi/scripts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"], + "outDir": "dist" + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"] +} diff --git a/skills/atlassian/shared/scripts/scripts/sync-agents.ts b/skills/atlassian/shared/scripts/scripts/sync-agents.ts index fa87e44..231e0c1 100644 --- a/skills/atlassian/shared/scripts/scripts/sync-agents.ts +++ b/skills/atlassian/shared/scripts/scripts/sync-agents.ts @@ -7,7 +7,7 @@ const __dirname = path.dirname(__filename); const SHARED_SCRIPTS_DIR = path.resolve(__dirname, ".."); const ATLASSIAN_SKILL_DIR = path.resolve(SHARED_SCRIPTS_DIR, "..", ".."); -const AGENTS = ["codex", "claude-code", "cursor", "opencode"] as const; +const AGENTS = ["codex", "claude-code", "cursor", "opencode", "pi"] as const; const ENTRIES_TO_COPY = ["pnpm-lock.yaml", "tsconfig.json", "src"] as const; async function replaceEntry(source: string, target: string) { diff --git a/skills/web-automation/pi/SKILL.md b/skills/web-automation/pi/SKILL.md new file mode 100644 index 0000000..b881a58 --- /dev/null +++ b/skills/web-automation/pi/SKILL.md @@ -0,0 +1,116 @@ +--- +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 (Pi) + +Automated web browsing and scraping for pi using the shared runtime bundle in `scripts/`. + +## Requirements + +- Node.js 20+ +- `pnpm` +- Network access to download the CloakBrowser binary on first use + +## First-Time Setup + +Global install: + +```bash +mkdir -p ~/.pi/agent/skills/web-automation +cp -R skills/web-automation/pi/* ~/.pi/agent/skills/web-automation/ +cd ~/.pi/agent/skills/web-automation/scripts +pnpm install +npx cloakbrowser install +pnpm approve-builds +pnpm rebuild better-sqlite3 esbuild +``` + +Project-local install: + +```bash +mkdir -p .pi/skills/web-automation +cp -R skills/web-automation/pi/* .pi/skills/web-automation/ +cd .pi/skills/web-automation/scripts +pnpm install +npx cloakbrowser install +pnpm approve-builds +pnpm rebuild better-sqlite3 esbuild +``` + +Pi can also load this repo through settings or package installs as documented in [docs/PI.md](../../../docs/PI.md). + +## Updating CloakBrowser + +Run inside the installed `scripts/` directory for the pi skill. The commands below work for both global and project-local installs as long as you run them 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. The command block below uses the global install path; for a project-local install, replace `~/.pi/agent/skills/web-automation/` with `.pi/skills/web-automation/`. + +```bash +cd ~/.pi/agent/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 the same commands from your installed `scripts/` directory: + +```bash +cd ~/.pi/agent/skills/web-automation/scripts +pnpm approve-builds +pnpm rebuild better-sqlite3 esbuild +``` + +## When To Use Which Command + +- Use `node extract.js "<URL>"` 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. +- Pi validates skill naming leniently, so this repo keeps the shared `skills/<family>/pi/` layout even though the installed parent directory should be `web-automation`. diff --git a/skills/web-automation/pi/scripts/auth.ts b/skills/web-automation/pi/scripts/auth.ts new file mode 100644 index 0000000..e79f23d --- /dev/null +++ b/skills/web-automation/pi/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<string> { + 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<AuthType> { + 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<boolean> { + 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<boolean> { + 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<boolean> { + 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<AuthResult> { + 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 <url> [options] + +Options: + -u, --url <url> URL to authenticate (required) + -t, --type <type> Auth type: auto, form, or msal (default: auto) + --username <user> Username/email (or set CLOAKBROWSER_USERNAME env var) + --password <pass> Password (or set CLOAKBROWSER_PASSWORD env var) + --headless <bool> 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/pi/scripts/browse.ts b/skills/web-automation/pi/scripts/browse.ts new file mode 100644 index 0000000..01cf098 --- /dev/null +++ b/skills/web-automation/pi/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<void> { + 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<BrowserContext> { + 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<BrowseResult> { + 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 <url> [options] + +Options: + -u, --url <url> URL to navigate to (required) + -s, --screenshot Take a screenshot of the page + -o, --output <path> Output path for screenshot (default: screenshot.png) + --headless <bool> Run in headless mode (default: true) + --wait <ms> Wait time after page load in milliseconds + --timeout <ms> 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/pi/scripts/check-install.js b/skills/web-automation/pi/scripts/check-install.js new file mode 100644 index 0000000..50e4884 --- /dev/null +++ b/skills/web-automation/pi/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/pi/scripts/extract.js b/skills/web-automation/pi/scripts/extract.js new file mode 100755 index 0000000..5e3908a --- /dev/null +++ b/skills/web-automation/pi/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 <URL>"); + } + + 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/pi/scripts/flow.ts b/skills/web-automation/pi/scripts/flow.ts new file mode 100644 index 0000000..5d01e55 --- /dev/null +++ b/skills/web-automation/pi/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<boolean> { + 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<boolean> { + 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<HTMLAnchorElement | HTMLButtonElement>; + + 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 <url> + - click on "Text" + - click <css-selector> + - type "text" + - type "text" in <css-selector> + - press <key> + - press <key> in <css-selector> + - wait <N>s | wait <N>ms + - screenshot <path> +`); + 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/pi/scripts/package.json b/skills/web-automation/pi/scripts/package.json new file mode 100644 index 0000000..a2221e8 --- /dev/null +++ b/skills/web-automation/pi/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/pi/scripts/pnpm-lock.yaml b/skills/web-automation/pi/scripts/pnpm-lock.yaml new file mode 100644 index 0000000..59dba9c --- /dev/null +++ b/skills/web-automation/pi/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/pi/scripts/scan-local-app.ts b/skills/web-automation/pi/scripts/scan-local-app.ts new file mode 100644 index 0000000..6a05b35 --- /dev/null +++ b/skills/web-automation/pi/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<NavResult> { + 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<string | null> { + 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/pi/scripts/scrape.ts b/skills/web-automation/pi/scripts/scrape.ts new file mode 100644 index 0000000..0820de0 --- /dev/null +++ b/skills/web-automation/pi/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<ScrapeResult> { + 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 = [ + `<!-- Scraped from: ${pageUrl} -->`, + byline ? `<!-- Author: ${byline} -->` : null, + excerpt ? `<!-- Excerpt: ${excerpt} -->` : null, + `<!-- Scraped at: ${new Date().toISOString()} -->`, + '', + ].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 <url> [options] + +Options: + -u, --url <url> URL to scrape (required) + -m, --mode <mode> Scrape mode: main, full, or selector (default: main) + -s, --selector <sel> CSS selector for selector mode + -o, --output <path> Output file path for markdown + --headless <bool> Run in headless mode (default: true) + --wait <ms> 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/pi/scripts/test-full.ts b/skills/web-automation/pi/scripts/test-full.ts new file mode 100644 index 0000000..356bbab --- /dev/null +++ b/skills/web-automation/pi/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/pi/scripts/test-minimal.ts b/skills/web-automation/pi/scripts/test-minimal.ts new file mode 100644 index 0000000..a5412e7 --- /dev/null +++ b/skills/web-automation/pi/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/pi/scripts/test-profile.ts b/skills/web-automation/pi/scripts/test-profile.ts new file mode 100644 index 0000000..ec59ddd --- /dev/null +++ b/skills/web-automation/pi/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/pi/scripts/tsconfig.json b/skills/web-automation/pi/scripts/tsconfig.json new file mode 100644 index 0000000..4c23583 --- /dev/null +++ b/skills/web-automation/pi/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/pi/scripts/turndown-plugin-gfm.d.ts b/skills/web-automation/pi/scripts/turndown-plugin-gfm.d.ts new file mode 100644 index 0000000..316bed1 --- /dev/null +++ b/skills/web-automation/pi/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 new file mode 100755 index 0000000..be3bd09 --- /dev/null +++ b/skills/web-automation/scripts/sync-variants.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +SOURCE_DIR="${ROOT_DIR}/codex/scripts" +TARGETS=( + "claude-code" + "opencode" + "pi" +) + +replace_dir() { + local source=$1 + local target=$2 + if [[ -z "$target" || "$target" == "/" || "$target" == "." || "$target" == ".." ]]; then + echo "Refusing to sync into unsafe target: $target" >&2 + exit 1 + fi + case "$target" in + "${ROOT_DIR}"/*) ;; + *) + echo "Refusing to remove target outside root: $target" >&2 + exit 1 + ;; + esac + rm -rf "$target" + mkdir -p "$target" + cp -R "${source}/." "$target/" +} + +if [[ ! -d "$SOURCE_DIR" ]]; then + echo "Missing canonical source directory: $SOURCE_DIR" >&2 + exit 1 +fi + +for variant in "${TARGETS[@]}"; do + replace_dir "$SOURCE_DIR" "${ROOT_DIR}/${variant}/scripts" +done + +echo "Synced web-automation scripts from codex into ${#TARGETS[@]} variant directories."