From 01b337d60c4609dfcfba486767bb76c10d60ce60 Mon Sep 17 00:00:00 2001 From: joelazar Date: Fri, 9 Jan 2026 18:45:58 +0100 Subject: [PATCH] :sparkles: bunch of more useful skills --- dot_claude/skills/github/SKILL.md | 47 + dot_claude/skills/improve-skill/SKILL.md | 155 +++ .../scripts/executable_extract-session.js | 349 +++++++ dot_claude/skills/web-browser/SKILL.md | 78 ++ dot_claude/skills/web-browser/scripts/cdp.js | 175 ++++ .../scripts/executable_dismiss-cookies.js | 373 +++++++ .../web-browser/scripts/executable_eval.js | 68 ++ .../web-browser/scripts/executable_nav.js | 65 ++ .../web-browser/scripts/executable_pick.js | 165 ++++ .../scripts/executable_screenshot.js | 52 + .../web-browser/scripts/executable_start.js | 76 ++ .../web-browser/scripts/package-lock.json | 924 ++++++++++++++++++ .../skills/web-browser/scripts/package.json | 7 + dot_pi/agent/extensions/answer.ts | 533 ++++++++++ dot_pi/agent/extensions/qna.ts | 167 ++++ dot_pi/agent/settings.json | 10 + 16 files changed, 3244 insertions(+) create mode 100644 dot_claude/skills/github/SKILL.md create mode 100644 dot_claude/skills/improve-skill/SKILL.md create mode 100644 dot_claude/skills/improve-skill/scripts/executable_extract-session.js create mode 100644 dot_claude/skills/web-browser/SKILL.md create mode 100644 dot_claude/skills/web-browser/scripts/cdp.js create mode 100644 dot_claude/skills/web-browser/scripts/executable_dismiss-cookies.js create mode 100644 dot_claude/skills/web-browser/scripts/executable_eval.js create mode 100644 dot_claude/skills/web-browser/scripts/executable_nav.js create mode 100644 dot_claude/skills/web-browser/scripts/executable_pick.js create mode 100644 dot_claude/skills/web-browser/scripts/executable_screenshot.js create mode 100644 dot_claude/skills/web-browser/scripts/executable_start.js create mode 100644 dot_claude/skills/web-browser/scripts/package-lock.json create mode 100644 dot_claude/skills/web-browser/scripts/package.json create mode 100644 dot_pi/agent/extensions/answer.ts create mode 100644 dot_pi/agent/extensions/qna.ts create mode 100644 dot_pi/agent/settings.json diff --git a/dot_claude/skills/github/SKILL.md b/dot_claude/skills/github/SKILL.md new file mode 100644 index 00000000..03b2a003 --- /dev/null +++ b/dot_claude/skills/github/SKILL.md @@ -0,0 +1,47 @@ +--- +name: github +description: "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries." +--- + +# GitHub Skill + +Use the `gh` CLI to interact with GitHub. Always specify `--repo owner/repo` when not in a git directory, or use URLs directly. + +## Pull Requests + +Check CI status on a PR: +```bash +gh pr checks 55 --repo owner/repo +``` + +List recent workflow runs: +```bash +gh run list --repo owner/repo --limit 10 +``` + +View a run and see which steps failed: +```bash +gh run view --repo owner/repo +``` + +View logs for failed steps only: +```bash +gh run view --repo owner/repo --log-failed +``` + +## API for Advanced Queries + +The `gh api` command is useful for accessing data not available through other subcommands. + +Get PR with specific fields: +```bash +gh api repos/owner/repo/pulls/55 --jq '.title, .state, .user.login' +``` + +## JSON Output + +Most commands support `--json` for structured output. You can use `--jq` to filter: + +```bash +gh issue list --repo owner/repo --json number,title --jq '.[] | "\(.number): \(.title)"' +``` diff --git a/dot_claude/skills/improve-skill/SKILL.md b/dot_claude/skills/improve-skill/SKILL.md new file mode 100644 index 00000000..6a5f8a6e --- /dev/null +++ b/dot_claude/skills/improve-skill/SKILL.md @@ -0,0 +1,155 @@ +--- +name: improve-skill +description: "Analyze coding agent session transcripts to improve existing skills or create new ones. Use when asked to improve a skill based on a session, or extract a new skill from session history." +--- + +# Improve Skill + +This skill helps analyze coding agent sessions to improve or create skills. It works with Claude Code, Pi, and Codex session files. + +## Quick Start + +Extract the current session and generate an improvement prompt: + +```bash +# Auto-detect agent and extract current session +./scripts/extract-session.js +``` + +## Session Extraction + +The `extract-session.js` script finds and parses session files from any of the three agents: + +```bash +# Auto-detect (uses most recent session for current working directory) +./scripts/extract-session.js + +# Specify agent type +./scripts/extract-session.js --agent claude +./scripts/extract-session.js --agent pi +./scripts/extract-session.js --agent codex + +# Specify a different working directory +./scripts/extract-session.js --cwd /path/to/project + +# Use a specific session file +./scripts/extract-session.js /path/to/session.jsonl +``` + +**Session file locations:** +- **Claude Code**: `~/.claude/projects//*.jsonl` +- **Pi**: `~/.pi/agent/sessions//*.jsonl` +- **Codex**: `~/.codex/sessions/YYYY/MM/DD/*.jsonl` + +## Workflow: Improve an Existing Skill + +When asked to improve a skill based on a session: + +1. **Extract the session transcript:** + ```bash + ./scripts/extract-session.js > /tmp/session-transcript.txt + ``` + +2. **Find the existing skill** in one of these locations: + - `~/.codex/skills//SKILL.md` + - `~/.claude/skills//SKILL.md` + - `~/.pi/agent/skills//SKILL.md` + +3. **Generate an improvement prompt** for a new session: + +``` +═══════════════════════════════════════════════════════════════════════════════ +COPY THE FOLLOWING PROMPT INTO A NEW AGENT SESSION: +═══════════════════════════════════════════════════════════════════════════════ + +I need to improve the "" skill based on a session where I used it. + +First, read the current skill at: + +Then analyze this session transcript to understand: +- Where I struggled to use the skill correctly +- What information was missing from the skill +- What examples would have helped +- What I had to figure out on my own + + + + + +Based on this analysis, improve the skill by: +1. Adding missing instructions or clarifications +2. Adding examples for common use cases discovered +3. Fixing any incorrect guidance +4. Making the skill more concise where possible + +Write the improved skill back to the same location. + +═══════════════════════════════════════════════════════════════════════════════ +``` + +## Workflow: Create a New Skill + +When asked to create a new skill from a session: + +1. **Extract the session transcript:** + ```bash + ./scripts/extract-session.js > /tmp/session-transcript.txt + ``` + +2. **Generate a creation prompt** for a new session: + +``` +═══════════════════════════════════════════════════════════════════════════════ +COPY THE FOLLOWING PROMPT INTO A NEW AGENT SESSION: +═══════════════════════════════════════════════════════════════════════════════ + +Analyze this session transcript to extract a reusable skill called "": + + + + + +Create a new skill that captures: +1. The core capability or workflow demonstrated +2. Key commands, APIs, or patterns used +3. Common pitfalls and how to avoid them +4. Example usage for typical scenarios + +Write the skill to: ~/.codex/skills//SKILL.md + +Use this format: +--- +name: +description: "" +--- + +# Skill + + + +##
+ + + +═══════════════════════════════════════════════════════════════════════════════ +``` + +## Why a Separate Session? + +The improvement prompt is meant to be copied into a **fresh agent session** because: + +1. **Token efficiency** - The current session already has a lot of context; starting fresh means only the transcript and skill are loaded +2. **Clean analysis** - The new session can focus purely on improvement without being influenced by the current task +3. **Reproducibility** - The prompt is self-contained and can be shared or reused + +## Tips for Good Skill Improvements + +When analyzing a transcript, look for: + +- **Confusion patterns** - Where did the agent retry or change approach? +- **Missing examples** - What specific commands or code patterns were discovered? +- **Workarounds** - What did the agent have to figure out that wasn't documented? +- **Errors** - What failed and how was it resolved? +- **Successful patterns** - What worked well and should be highlighted? + +Keep skills concise - focus on the most important information and examples. diff --git a/dot_claude/skills/improve-skill/scripts/executable_extract-session.js b/dot_claude/skills/improve-skill/scripts/executable_extract-session.js new file mode 100644 index 00000000..f1eb43ac --- /dev/null +++ b/dot_claude/skills/improve-skill/scripts/executable_extract-session.js @@ -0,0 +1,349 @@ +#!/usr/bin/env node + +/** + * Extract session transcript from Claude Code, Pi, or Codex session files. + * + * Usage: + * ./extract-session.js [session-path] + * ./extract-session.js --agent claude|pi|codex [--cwd /path/to/dir] + * + * If no arguments, auto-detects based on current working directory. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Parse arguments +const args = process.argv.slice(2); +let sessionPath = null; +let agent = null; +let cwd = process.cwd(); + +for (let i = 0; i < args.length; i++) { + if (args[i] === '--agent' && args[i + 1]) { + agent = args[++i]; + } else if (args[i] === '--cwd' && args[i + 1]) { + cwd = args[++i]; + } else if (!args[i].startsWith('-')) { + sessionPath = args[i]; + } +} + +/** + * Encode CWD for session path lookup + */ +function encodeCwd(cwd, style) { + if (style === 'pi') { + // Pi uses: ---- + // e.g., /Users/mitsuhiko/Development/myproject -> --Users-mitsuhiko-Development-myproject-- + const safePath = `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`; + return safePath; + } + // Claude Code: just replace / with - + return cwd.replace(/\//g, '-'); +} + +/** + * Find the most recent session file in a directory + */ +function findMostRecentSession(dir) { + if (!fs.existsSync(dir)) return null; + + const files = fs.readdirSync(dir) + .filter(f => f.endsWith('.jsonl')) + .map(f => ({ + name: f, + path: path.join(dir, f), + mtime: fs.statSync(path.join(dir, f)).mtime + })) + .sort((a, b) => b.mtime - a.mtime); + + return files.length > 0 ? files[0].path : null; +} + +/** + * Find Codex session matching CWD + */ +function findCodexSession(targetCwd) { + const baseDir = path.join(os.homedir(), '.codex', 'sessions'); + if (!fs.existsSync(baseDir)) return null; + + // Find all session files, sorted by mtime + const allSessions = []; + + function walkDir(dir) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkDir(fullPath); + } else if (entry.name.endsWith('.jsonl')) { + allSessions.push({ + path: fullPath, + mtime: fs.statSync(fullPath).mtime + }); + } + } + } + + walkDir(baseDir); + allSessions.sort((a, b) => b.mtime - a.mtime); + + // Find most recent matching CWD + for (const session of allSessions.slice(0, 50)) { // Check last 50 + try { + const firstLine = fs.readFileSync(session.path, 'utf8').split('\n')[0]; + const data = JSON.parse(firstLine); + if (data.payload?.cwd === targetCwd) { + return session.path; + } + } catch (e) { + // Skip invalid files + } + } + + return null; +} + +/** + * Auto-detect session based on CWD + */ +function autoDetectSession(cwd) { + // Try Claude Code first + const claudePath = path.join(os.homedir(), '.claude', 'projects', encodeCwd(cwd, 'claude')); + let session = findMostRecentSession(claudePath); + if (session) return { agent: 'claude', path: session }; + + // Try Pi + const piPath = path.join(os.homedir(), '.pi', 'agent', 'sessions', encodeCwd(cwd, 'pi')); + session = findMostRecentSession(piPath); + if (session) return { agent: 'pi', path: session }; + + // Try Codex + session = findCodexSession(cwd); + if (session) return { agent: 'codex', path: session }; + + return null; +} + +/** + * Parse Claude Code session format + */ +function parseClaudeSession(content) { + const messages = []; + const lines = content.trim().split('\n'); + + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.message?.role && entry.message?.content) { + const msg = entry.message; + messages.push({ + role: msg.role, + content: extractContent(msg.content), + timestamp: entry.timestamp + }); + } + } catch (e) { + // Skip invalid lines + } + } + + return messages; +} + +/** + * Parse Pi session format + */ +function parsePiSession(content) { + const messages = []; + const lines = content.trim().split('\n'); + + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'message' && entry.message?.role) { + messages.push({ + role: entry.message.role, + content: extractContent(entry.message.content), + timestamp: entry.timestamp + }); + } + } catch (e) { + // Skip invalid lines + } + } + + return messages; +} + +/** + * Parse Codex session format + */ +function parseCodexSession(content) { + const messages = []; + const lines = content.trim().split('\n'); + + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'response_item' && entry.payload?.role) { + const payload = entry.payload; + messages.push({ + role: payload.role, + content: extractContent(payload.content), + timestamp: entry.timestamp + }); + } + } catch (e) { + // Skip invalid lines + } + } + + return messages; +} + +/** + * Extract text content from various content formats + */ +function extractContent(content) { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return JSON.stringify(content); + + const parts = []; + for (const item of content) { + if (typeof item === 'string') { + parts.push(item); + } else if (item.type === 'text') { + parts.push(item.text); + } else if (item.type === 'input_text') { + parts.push(item.text); + } else if (item.type === 'tool_use') { + parts.push(`[Tool: ${item.name}]\n${JSON.stringify(item.input, null, 2)}`); + } else if (item.type === 'tool_result') { + const result = typeof item.content === 'string' + ? item.content + : JSON.stringify(item.content); + // Truncate long tool results + const truncated = result.length > 500 + ? result.slice(0, 500) + '\n[... truncated ...]' + : result; + parts.push(`[Tool Result]\n${truncated}`); + } else { + parts.push(`[${item.type}]`); + } + } + + return parts.join('\n'); +} + +/** + * Format messages as readable transcript + */ +function formatTranscript(messages, maxMessages = 100) { + const recent = messages.slice(-maxMessages); + const lines = []; + + for (const msg of recent) { + const role = msg.role.toUpperCase(); + lines.push(`\n### ${role}:\n`); + lines.push(msg.content); + } + + if (messages.length > maxMessages) { + lines.unshift(`\n[... ${messages.length - maxMessages} earlier messages omitted ...]\n`); + } + + return lines.join('\n'); +} + +// Main +async function main() { + let result; + + if (sessionPath) { + // Explicit path provided + if (!fs.existsSync(sessionPath)) { + console.error(`Session file not found: ${sessionPath}`); + process.exit(1); + } + // Guess agent from path + if (sessionPath.includes('.claude')) { + result = { agent: 'claude', path: sessionPath }; + } else if (sessionPath.includes('.pi')) { + result = { agent: 'pi', path: sessionPath }; + } else if (sessionPath.includes('.codex')) { + result = { agent: 'codex', path: sessionPath }; + } else { + // Default to Claude format + result = { agent: 'claude', path: sessionPath }; + } + } else if (agent) { + // Agent specified, find session for that agent + if (agent === 'claude') { + const dir = path.join(os.homedir(), '.claude', 'projects', encodeCwd(cwd, 'claude')); + const session = findMostRecentSession(dir); + if (!session) { + console.error(`No Claude Code session found for: ${cwd}`); + process.exit(1); + } + result = { agent: 'claude', path: session }; + } else if (agent === 'pi') { + const dir = path.join(os.homedir(), '.pi', 'agent', 'sessions', encodeCwd(cwd, 'pi')); + const session = findMostRecentSession(dir); + if (!session) { + console.error(`No Pi session found for: ${cwd}`); + process.exit(1); + } + result = { agent: 'pi', path: session }; + } else if (agent === 'codex') { + const session = findCodexSession(cwd); + if (!session) { + console.error(`No Codex session found for: ${cwd}`); + process.exit(1); + } + result = { agent: 'codex', path: session }; + } else { + console.error(`Unknown agent: ${agent}`); + process.exit(1); + } + } else { + // Auto-detect + result = autoDetectSession(cwd); + if (!result) { + console.error(`No session found for: ${cwd}`); + console.error('Try specifying --agent claude|pi|codex or provide a session path directly.'); + process.exit(1); + } + } + + // Read and parse session + const content = fs.readFileSync(result.path, 'utf8'); + + let messages; + switch (result.agent) { + case 'claude': + messages = parseClaudeSession(content); + break; + case 'pi': + messages = parsePiSession(content); + break; + case 'codex': + messages = parseCodexSession(content); + break; + } + + // Output metadata and transcript + console.log(`# Session Transcript`); + console.log(`Agent: ${result.agent}`); + console.log(`File: ${result.path}`); + console.log(`Messages: ${messages.length}`); + console.log(''); + console.log(formatTranscript(messages)); +} + +main().catch(e => { + console.error(e.message); + process.exit(1); +}); diff --git a/dot_claude/skills/web-browser/SKILL.md b/dot_claude/skills/web-browser/SKILL.md new file mode 100644 index 00000000..9d9de095 --- /dev/null +++ b/dot_claude/skills/web-browser/SKILL.md @@ -0,0 +1,78 @@ +--- +name: web-browser +description: "Allows to interact with web pages by performing actions such as clicking buttons, filling out forms, and navigating links. It works by remote controlling Google Chrome or Chromium browsers using the Chrome DevTools Protocol (CDP). When Claude needs to browse the web, it can use this skill to do so." +license: Stolen from Mario +--- + +# Web Browser Skill + +Minimal CDP tools for collaborative site exploration. + +## Start Chrome + +\`\`\`bash +./scripts/start.js # Fresh profile +./scripts/start.js --profile # Copy your profile (cookies, logins) +\`\`\` + +Start Chrome on `:9222` with remote debugging. + +## Navigate + +\`\`\`bash +./scripts/nav.js https://example.com +./scripts/nav.js https://example.com --new +\`\`\` + +Navigate current tab or open new tab. + +## Evaluate JavaScript + +\`\`\`bash +./scripts/eval.js 'document.title' +./scripts/eval.js 'document.querySelectorAll("a").length' +./scripts/eval.js 'JSON.stringify(Array.from(document.querySelectorAll("a")).map(a => ({ text: a.textContent.trim(), href: a.href })).filter(link => !link.href.startsWith("https://")))' +\`\`\` + +Execute JavaScript in active tab (async context). Be careful with string escaping, best to use single quotes. + +## Screenshot + +\`\`\`bash +./scripts/screenshot.js +\`\`\` + +Screenshot current viewport, returns temp file path + +## Pick Elements + +\`\`\`bash +./scripts/pick.js "Click the submit button" +\`\`\` + +Interactive element picker. Click to select, Cmd/Ctrl+Click for multi-select, Enter to finish. + +## Dismiss Cookie Dialogs + +\`\`\`bash +./scripts/dismiss-cookies.js # Accept cookies +./scripts/dismiss-cookies.js --reject # Reject cookies (where possible) +\`\`\` + +Automatically dismisses EU cookie consent dialogs. Supports: +- **OneTrust** (booking.com, ikea.com, many others) +- **Google** consent dialogs +- **Cookiebot** +- **Didomi** +- **Quantcast Choice** +- **Usercentrics** (shadow DOM) +- **Sourcepoint** (BBC, etc. - works with iframes) +- **Amazon** +- **TrustArc** +- **Klaro** +- Generic cookie banners with common button text patterns + +Run after navigating to a page (with a short delay for dialogs to load): +\`\`\`bash +./scripts/nav.js https://example.com && sleep 2 && ./scripts/dismiss-cookies.js +\`\`\` diff --git a/dot_claude/skills/web-browser/scripts/cdp.js b/dot_claude/skills/web-browser/scripts/cdp.js new file mode 100644 index 00000000..b72474eb --- /dev/null +++ b/dot_claude/skills/web-browser/scripts/cdp.js @@ -0,0 +1,175 @@ +/** + * Minimal CDP client - no puppeteer, no hangs + */ + +import WebSocket from "ws"; + +export async function connect(timeout = 5000) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const resp = await fetch("http://localhost:9222/json/version", { + signal: controller.signal, + }); + const { webSocketDebuggerUrl } = await resp.json(); + clearTimeout(timeoutId); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(webSocketDebuggerUrl); + const connectTimeout = setTimeout(() => { + ws.close(); + reject(new Error("WebSocket connect timeout")); + }, timeout); + + ws.on("open", () => { + clearTimeout(connectTimeout); + resolve(new CDP(ws)); + }); + ws.on("error", (e) => { + clearTimeout(connectTimeout); + reject(e); + }); + }); + } catch (e) { + clearTimeout(timeoutId); + if (e.name === "AbortError") { + throw new Error("Connection timeout - is Chrome running with --remote-debugging-port=9222?"); + } + throw e; + } +} + +class CDP { + constructor(ws) { + this.ws = ws; + this.id = 0; + this.callbacks = new Map(); + this.sessions = new Map(); + + ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.id && this.callbacks.has(msg.id)) { + const { resolve, reject } = this.callbacks.get(msg.id); + this.callbacks.delete(msg.id); + if (msg.error) { + reject(new Error(msg.error.message)); + } else { + resolve(msg.result); + } + } + }); + } + + send(method, params = {}, sessionId = null, timeout = 10000) { + return new Promise((resolve, reject) => { + const msgId = ++this.id; + const msg = { id: msgId, method, params }; + if (sessionId) msg.sessionId = sessionId; + + const timeoutId = setTimeout(() => { + this.callbacks.delete(msgId); + reject(new Error(`CDP timeout: ${method}`)); + }, timeout); + + this.callbacks.set(msgId, { + resolve: (result) => { + clearTimeout(timeoutId); + resolve(result); + }, + reject: (err) => { + clearTimeout(timeoutId); + reject(err); + }, + }); + + this.ws.send(JSON.stringify(msg)); + }); + } + + async getPages() { + const { targetInfos } = await this.send("Target.getTargets"); + return targetInfos.filter((t) => t.type === "page"); + } + + async attachToPage(targetId) { + const { sessionId } = await this.send("Target.attachToTarget", { + targetId, + flatten: true, + }); + return sessionId; + } + + async evaluate(sessionId, expression, timeout = 30000) { + const result = await this.send( + "Runtime.evaluate", + { + expression, + returnByValue: true, + awaitPromise: true, + }, + sessionId, + timeout + ); + + if (result.exceptionDetails) { + throw new Error( + result.exceptionDetails.exception?.description || + result.exceptionDetails.text + ); + } + return result.result?.value; + } + + async screenshot(sessionId, timeout = 10000) { + const { data } = await this.send( + "Page.captureScreenshot", + { format: "png" }, + sessionId, + timeout + ); + return Buffer.from(data, "base64"); + } + + async navigate(sessionId, url, timeout = 30000) { + await this.send("Page.navigate", { url }, sessionId, timeout); + } + + async getFrameTree(sessionId) { + const { frameTree } = await this.send("Page.getFrameTree", {}, sessionId); + return frameTree; + } + + async evaluateInFrame(sessionId, frameId, expression, timeout = 30000) { + // Create isolated world for the frame + const { executionContextId } = await this.send( + "Page.createIsolatedWorld", + { frameId, worldName: "cdp-eval" }, + sessionId + ); + + const result = await this.send( + "Runtime.evaluate", + { + expression, + contextId: executionContextId, + returnByValue: true, + awaitPromise: true, + }, + sessionId, + timeout + ); + + if (result.exceptionDetails) { + throw new Error( + result.exceptionDetails.exception?.description || + result.exceptionDetails.text + ); + } + return result.result?.value; + } + + close() { + this.ws.close(); + } +} diff --git a/dot_claude/skills/web-browser/scripts/executable_dismiss-cookies.js b/dot_claude/skills/web-browser/scripts/executable_dismiss-cookies.js new file mode 100644 index 00000000..09e24509 --- /dev/null +++ b/dot_claude/skills/web-browser/scripts/executable_dismiss-cookies.js @@ -0,0 +1,373 @@ +#!/usr/bin/env node + +/** + * Cookie Consent Dismissal Helper + * + * Automatically accepts cookie consent dialogs on EU websites. + * Supports common CMPs: OneTrust, Cookiebot, Didomi, Quantcast, Google, BBC, Amazon, etc. + * + * Usage: + * ./dismiss-cookies.js # Accept cookies + * ./dismiss-cookies.js --reject # Reject cookies (where possible) + */ + +import { connect } from "./cdp.js"; + +const DEBUG = process.env.DEBUG === "1"; +const log = DEBUG ? (...args) => console.error("[debug]", ...args) : () => {}; + +const reject = process.argv.includes("--reject"); +const mode = reject ? "reject" : "accept"; + +const COOKIE_DISMISS_SCRIPT = `(acceptCookies) => { + const clicked = []; + + const isVisible = (el) => { + if (!el) return false; + const style = getComputedStyle(el); + return style.display !== 'none' && + style.visibility !== 'hidden' && + style.opacity !== '0' && + (el.offsetParent !== null || style.position === 'fixed' || style.position === 'sticky'); + }; + + const tryClick = (selector, description) => { + const el = typeof selector === 'string' ? document.querySelector(selector) : selector; + if (isVisible(el)) { + el.click(); + clicked.push(description || selector); + return true; + } + return false; + }; + + const findButtonByText = (patterns, container = document) => { + const buttons = Array.from(container.querySelectorAll('button, [role="button"], a.button, input[type="submit"], input[type="button"]')); + // Sort patterns by length descending to match more specific patterns first + const sortedPatterns = [...patterns].sort((a, b) => b.length - a.length); + + // Check patterns in order of specificity (longest first) + for (const pattern of sortedPatterns) { + for (const btn of buttons) { + const text = (btn.textContent || btn.value || '').trim().toLowerCase(); + if (text.length > 100) continue; // Skip buttons with very long text + if (!isVisible(btn)) continue; // Skip hidden buttons + if (typeof pattern === 'string' ? text.includes(pattern) : pattern.test(text)) { + return btn; + } + } + } + return null; + }; + + const acceptPatterns = [ + 'accept all', 'accept cookies', 'allow all', 'allow cookies', + 'i agree', 'i accept', 'yes, i agree', 'agree and continue', + 'alle akzeptieren', 'akzeptieren', 'alle zulassen', 'zustimmen', + 'annehmen', 'einverstanden', + 'accepter tout', 'tout accepter', "j'accepte", 'accepter et continuer', 'accepter', + 'accetta tutti', 'accetta', 'accetto', + 'aceptar todo', 'aceptar', 'acepto', + 'aceitar tudo', 'aceitar', + 'continue', 'agree', + ]; + + const rejectPatterns = [ + 'reject all', 'decline all', 'deny all', 'refuse all', + 'i do not agree', 'i disagree', 'no thanks', + 'alle ablehnen', 'ablehnen', 'nicht zustimmen', + 'refuser tout', 'tout refuser', 'refuser', + 'rifiuta tutti', 'rifiuta', + 'rechazar todo', 'rechazar', + 'rejeitar tudo', 'rejeitar', + 'only necessary', 'necessary only', 'nur notwendige', + 'essential only', 'nur essentielle', + ]; + + const patterns = acceptCookies ? acceptPatterns : rejectPatterns; + + // OneTrust + if (document.querySelector('#onetrust-banner-sdk')) { + const selector = acceptCookies ? '#onetrust-accept-btn-handler' : '#onetrust-reject-all-handler'; + if (tryClick(selector, 'OneTrust')) return clicked; + } + + // Google + if (document.querySelector('[data-consent-dialog]') || document.querySelector('form[action*="consent.google"]') || document.querySelector('#CXQnmb')) { + const selector = acceptCookies ? '#L2AGLb' : '#W0wltc'; + if (tryClick(selector, 'Google Consent')) return clicked; + } + + // YouTube (Google-owned, custom consent element) + if (document.querySelector('ytd-consent-bump-v2-lightbox')) { + const btn = Array.from(document.querySelectorAll('ytd-consent-bump-v2-lightbox button')) + .find(b => acceptCookies + ? b.textContent.includes('Accept all') || b.ariaLabel?.includes('Accept') + : b.textContent.includes('Reject all') || b.ariaLabel?.includes('Reject')); + if (btn) { + btn.click(); + clicked.push('YouTube'); + return clicked; + } + } + + // Cookiebot + if (document.querySelector('#CybotCookiebotDialog')) { + const selector = acceptCookies + ? '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll, #CybotCookiebotDialogBodyButtonAccept' + : '#CybotCookiebotDialogBodyButtonDecline, #CybotCookiebotDialogBodyLevelButtonLevelOptinDeclineAll'; + if (tryClick(selector, 'Cookiebot')) return clicked; + } + + // Didomi + if (document.querySelector('#didomi-host') || window.Didomi) { + const selector = acceptCookies ? '#didomi-notice-agree-button' : '#didomi-notice-disagree-button, [data-testid="disagree-button"]'; + if (tryClick(selector, 'Didomi')) return clicked; + } + + // Quantcast + if (document.querySelector('.qc-cmp2-container')) { + const selector = acceptCookies + ? '.qc-cmp2-summary-buttons button[mode="primary"], .qc-cmp2-button[data-testid="accept-all"]' + : '.qc-cmp2-summary-buttons button[mode="secondary"], .qc-cmp2-button[data-testid="reject-all"]'; + if (tryClick(selector, 'Quantcast')) return clicked; + } + + // Usercentrics (shadow DOM) + const ucRoot = document.querySelector('#usercentrics-root'); + if (ucRoot && ucRoot.shadowRoot) { + const shadow = ucRoot.shadowRoot; + const btn = acceptCookies + ? shadow.querySelector('[data-testid="uc-accept-all-button"]') + : shadow.querySelector('[data-testid="uc-deny-all-button"]'); + if (btn) { btn.click(); clicked.push('Usercentrics'); return clicked; } + } + + // TrustArc + if (document.querySelector('#truste-consent-track') || document.querySelector('.trustarc-banner')) { + const selector = acceptCookies ? '#truste-consent-button, .trustarc-agree-btn' : '.trustarc-decline-btn'; + if (tryClick(selector, 'TrustArc')) return clicked; + } + + // Klaro + if (document.querySelector('.klaro')) { + const selector = acceptCookies ? '.klaro .cm-btn-accept-all, .klaro .cm-btn-success' : '.klaro .cm-btn-decline'; + if (tryClick(selector, 'Klaro')) return clicked; + } + + // BBC + if (document.querySelector('#bbccookies, .bbccookies-banner')) { + if (acceptCookies && tryClick('#bbccookies-continue-button', 'BBC')) return clicked; + } + + // Amazon + if (document.querySelector('#sp-cc') || document.querySelector('#sp-cc-accept')) { + const selector = acceptCookies ? '#sp-cc-accept' : '#sp-cc-rejectall-link, #sp-cc-decline'; + if (tryClick(selector, 'Amazon')) return clicked; + } + + // CookieYes + if (document.querySelector('#cookie-law-info-bar') || document.querySelector('.cky-consent-container')) { + const selector = acceptCookies ? '#cookie_action_close_header, .cky-btn-accept' : '.cky-btn-reject'; + if (tryClick(selector, 'CookieYes')) return clicked; + } + + // Generic containers + const consentContainers = [ + '[class*="cookie-banner"]', '[class*="cookie-consent"]', '[class*="cookie-notice"]', + '[class*="cookieBanner"]', '[class*="cookieConsent"]', '[class*="cookieNotice"]', + '[id*="cookie-banner"]', '[id*="cookie-consent"]', '[id*="cookie-notice"]', + '[class*="consent-banner"]', '[class*="consent-modal"]', '[class*="consent-dialog"]', + '[class*="gdpr"]', '[id*="gdpr"]', '[class*="privacy-banner"]', '[class*="privacy-notice"]', + '[role="dialog"][aria-label*="cookie" i]', '[role="dialog"][aria-label*="consent" i]', + ]; + + for (const containerSel of consentContainers) { + const containers = document.querySelectorAll(containerSel); + for (const container of containers) { + if (!isVisible(container)) continue; + // Skip html/body elements that might match + if (container.tagName === 'HTML' || container.tagName === 'BODY') continue; + const btn = findButtonByText(patterns, container); + if (btn) { btn.click(); clicked.push('Generic (' + containerSel + ')'); return clicked; } + } + } + + // Last resort: find button near cookie-related text content + // Look for visible containers that mention "cookie" and have accept/reject buttons + // Include custom elements (Reddit uses rpl-modal-card, etc.) + const allContainers = document.querySelectorAll('div, section, aside, [class*="modal"], [class*="dialog"], [role="dialog"]'); + for (const container of allContainers) { + if (!isVisible(container)) continue; + const text = container.textContent?.toLowerCase() || ''; + // Must mention cookies and be reasonably sized (not the whole page) + if (text.includes('cookie') && text.length > 100 && text.length < 3000) { + const btn = findButtonByText(patterns, container); + if (btn && isVisible(btn)) { + btn.click(); + clicked.push('Generic (text-based)'); + return clicked; + } + } + } + + // Final fallback: look for any visible button with exact accept/reject text + // that appears alongside cookie-related content on the page + if (document.body.textContent?.toLowerCase().includes('cookie')) { + const exactPatterns = acceptCookies + ? ['accept all', 'accept cookies', 'allow all', 'i agree', 'alle akzeptieren'] + : ['reject all', 'decline all', 'reject optional', 'alle ablehnen']; + const singleWordPatterns = acceptCookies ? ['accept', 'agree'] : ['reject', 'decline']; + const buttons = document.querySelectorAll('button, [role="button"]'); + for (const btn of buttons) { + if (!isVisible(btn)) continue; + const text = (btn.textContent || '').trim().toLowerCase(); + if (exactPatterns.some(p => text.includes(p))) { + btn.click(); + clicked.push('Generic (exact match)'); + return clicked; + } + } + // Try single-word matches as last resort + for (const btn of buttons) { + if (!isVisible(btn)) continue; + const text = (btn.textContent || '').trim().toLowerCase(); + if (singleWordPatterns.some(p => text === p)) { + btn.click(); + clicked.push('Generic (single word)'); + return clicked; + } + } + } + + return clicked; +}`; + +const IFRAME_DISMISS_SCRIPT = `(acceptCookies) => { + const clicked = []; + + const isVisible = (el) => { + if (!el) return false; + const style = getComputedStyle(el); + return style.display !== 'none' && + style.visibility !== 'hidden' && + style.opacity !== '0' && + (el.offsetParent !== null || style.position === 'fixed' || style.position === 'sticky'); + }; + + const rejectIndicators = ['do not', "don't", 'nicht', 'no ', 'refuse', 'reject', 'decline', 'deny', 'disagree', 'ablehnen', 'refuser', 'rifiuta', 'rechazar', 'manage', 'settings', 'options', 'customize']; + const acceptIndicators = ['accept', 'agree', 'allow', 'yes', 'ok', 'got it', 'continue', 'akzeptieren', 'zustimmen', 'accepter', 'accetta', 'aceptar']; + + const isRejectButton = (text) => rejectIndicators.some(p => text.includes(p)); + const isAcceptButton = (text) => acceptIndicators.some(p => text.includes(p)) && !isRejectButton(text); + + const buttons = document.querySelectorAll('button, [role="button"]'); + for (const btn of buttons) { + const text = (btn.textContent || '').trim().toLowerCase(); + if (!isVisible(btn)) continue; + const shouldClick = acceptCookies ? isAcceptButton(text) : isRejectButton(text); + if (shouldClick) { btn.click(); clicked.push('iframe: ' + text.slice(0, 30)); return clicked; } + } + + const spBtn = acceptCookies + ? document.querySelector('[title="Accept All"], [title="Accept"], [aria-label*="Accept"]') + : document.querySelector('[title="Reject All"], [title="Reject"], [aria-label*="Reject"]'); + if (spBtn) { spBtn.click(); clicked.push('Sourcepoint iframe'); return clicked; } + + return clicked; +}`; + +// Recursively collect all frames +function collectFrames(frameTree, frames = []) { + frames.push({ id: frameTree.frame.id, url: frameTree.frame.url }); + if (frameTree.childFrames) { + for (const child of frameTree.childFrames) { + collectFrames(child, frames); + } + } + return frames; +} + +// Global timeout +const globalTimeout = setTimeout(() => { + console.error("✗ Global timeout exceeded (30s)"); + process.exit(1); +}, 30000); + +try { + log("connecting..."); + const cdp = await connect(5000); + + log("getting pages..."); + const pages = await cdp.getPages(); + const page = pages.at(-1); + + if (!page) { + console.error("✗ No active tab found"); + process.exit(1); + } + + log("attaching to page..."); + const sessionId = await cdp.attachToPage(page.targetId); + + // Wait a bit for consent dialogs to appear + await new Promise((r) => setTimeout(r, 500)); + + log("trying main page..."); + let result = await cdp.evaluate(sessionId, `(${COOKIE_DISMISS_SCRIPT})(${!reject})`); + + // If nothing found, try iframes + if (result.length === 0) { + log("trying iframes..."); + try { + const frameTree = await cdp.getFrameTree(sessionId); + const frames = collectFrames(frameTree); + + for (const frame of frames) { + if (frame.url === "about:blank" || frame.url.startsWith("javascript:")) continue; + if ( + frame.url.includes("sp_message") || + frame.url.includes("consent") || + frame.url.includes("privacy") || + frame.url.includes("cmp") || + frame.url.includes("sourcepoint") || + frame.url.includes("cookie") || + frame.url.includes("privacy-mgmt") + ) { + log("trying frame:", frame.url.slice(0, 60)); + try { + const frameResult = await cdp.evaluateInFrame( + sessionId, + frame.id, + `(${IFRAME_DISMISS_SCRIPT})(${!reject})` + ); + if (frameResult.length > 0) { + result = frameResult; + break; + } + } catch (e) { + log("frame error:", e.message); + } + } + } + } catch (e) { + log("getFrameTree error:", e.message); + } + } + + if (result.length > 0) { + console.log(`✓ Dismissed cookie dialog (${mode}): ${result.join(", ")}`); + } else { + console.log(`○ No cookie dialog found to ${mode}`); + } + + log("closing..."); + cdp.close(); + log("done"); +} catch (e) { + console.error("✗", e.message); + process.exit(1); +} finally { + clearTimeout(globalTimeout); + setTimeout(() => process.exit(0), 100); +} diff --git a/dot_claude/skills/web-browser/scripts/executable_eval.js b/dot_claude/skills/web-browser/scripts/executable_eval.js new file mode 100644 index 00000000..98e6f0d0 --- /dev/null +++ b/dot_claude/skills/web-browser/scripts/executable_eval.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node + +import { connect } from "./cdp.js"; + +const DEBUG = process.env.DEBUG === "1"; +const log = DEBUG ? (...args) => console.error("[debug]", ...args) : () => {}; + +const code = process.argv.slice(2).join(" "); +if (!code) { + console.log("Usage: eval.js 'code'"); + console.log("\nExamples:"); + console.log(' eval.js "document.title"'); + console.log(" eval.js \"document.querySelectorAll('a').length\""); + process.exit(1); +} + +// Global timeout +const globalTimeout = setTimeout(() => { + console.error("✗ Global timeout exceeded (45s)"); + process.exit(1); +}, 45000); + +try { + log("connecting..."); + const cdp = await connect(5000); + + log("getting pages..."); + const pages = await cdp.getPages(); + const page = pages.at(-1); + + if (!page) { + console.error("✗ No active tab found"); + process.exit(1); + } + + log("attaching to page..."); + const sessionId = await cdp.attachToPage(page.targetId); + + log("evaluating..."); + const expression = `(async () => { return (${code}); })()`; + const result = await cdp.evaluate(sessionId, expression); + + log("formatting result..."); + if (Array.isArray(result)) { + for (let i = 0; i < result.length; i++) { + if (i > 0) console.log(""); + for (const [key, value] of Object.entries(result[i])) { + console.log(`${key}: ${value}`); + } + } + } else if (typeof result === "object" && result !== null) { + for (const [key, value] of Object.entries(result)) { + console.log(`${key}: ${value}`); + } + } else { + console.log(result); + } + + log("closing..."); + cdp.close(); + log("done"); +} catch (e) { + console.error("✗", e.message); + process.exit(1); +} finally { + clearTimeout(globalTimeout); + setTimeout(() => process.exit(0), 100); +} diff --git a/dot_claude/skills/web-browser/scripts/executable_nav.js b/dot_claude/skills/web-browser/scripts/executable_nav.js new file mode 100644 index 00000000..ef100c6b --- /dev/null +++ b/dot_claude/skills/web-browser/scripts/executable_nav.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +import { connect } from "./cdp.js"; + +const DEBUG = process.env.DEBUG === "1"; +const log = DEBUG ? (...args) => console.error("[debug]", ...args) : () => {}; + +const url = process.argv[2]; +const newTab = process.argv[3] === "--new"; + +if (!url) { + console.log("Usage: nav.js [--new]"); + console.log("\nExamples:"); + console.log(" nav.js https://example.com # Navigate current tab"); + console.log(" nav.js https://example.com --new # Open in new tab"); + process.exit(1); +} + +// Global timeout +const globalTimeout = setTimeout(() => { + console.error("✗ Global timeout exceeded (45s)"); + process.exit(1); +}, 45000); + +try { + log("connecting..."); + const cdp = await connect(5000); + + log("getting pages..."); + let targetId; + + if (newTab) { + log("creating new tab..."); + const { targetId: newTargetId } = await cdp.send("Target.createTarget", { + url: "about:blank", + }); + targetId = newTargetId; + } else { + const pages = await cdp.getPages(); + const page = pages.at(-1); + if (!page) { + console.error("✗ No active tab found"); + process.exit(1); + } + targetId = page.targetId; + } + + log("attaching to page..."); + const sessionId = await cdp.attachToPage(targetId); + + log("navigating..."); + await cdp.navigate(sessionId, url); + + console.log(newTab ? "✓ Opened:" : "✓ Navigated to:", url); + + log("closing..."); + cdp.close(); + log("done"); +} catch (e) { + console.error("✗", e.message); + process.exit(1); +} finally { + clearTimeout(globalTimeout); + setTimeout(() => process.exit(0), 100); +} diff --git a/dot_claude/skills/web-browser/scripts/executable_pick.js b/dot_claude/skills/web-browser/scripts/executable_pick.js new file mode 100644 index 00000000..a592e43b --- /dev/null +++ b/dot_claude/skills/web-browser/scripts/executable_pick.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node + +import { connect } from "./cdp.js"; + +const DEBUG = process.env.DEBUG === "1"; +const log = DEBUG ? (...args) => console.error("[debug]", ...args) : () => {}; + +const message = process.argv.slice(2).join(" "); +if (!message) { + console.log("Usage: pick.js 'message'"); + console.log("\nExample:"); + console.log(' pick.js "Click the submit button"'); + process.exit(1); +} + +// Global timeout - 5 minutes for interactive picking +const globalTimeout = setTimeout(() => { + console.error("✗ Global timeout exceeded (5m)"); + process.exit(1); +}, 300000); + +const PICK_SCRIPT = `(message) => { + if (!message) throw new Error("pick() requires a message parameter"); + return new Promise((resolve) => { + const selections = []; + const selectedElements = new Set(); + + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;pointer-events:none"; + + const highlight = document.createElement("div"); + highlight.style.cssText = "position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);transition:all 0.1s"; + overlay.appendChild(highlight); + + const banner = document.createElement("div"); + banner.style.cssText = "position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1f2937;color:white;padding:12px 24px;border-radius:8px;font:14px sans-serif;box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;z-index:2147483647"; + + const updateBanner = () => { + banner.textContent = message + " (" + selections.length + " selected, Cmd/Ctrl+click to add, Enter to finish, ESC to cancel)"; + }; + updateBanner(); + + document.body.append(banner, overlay); + + const cleanup = () => { + document.removeEventListener("mousemove", onMove, true); + document.removeEventListener("click", onClick, true); + document.removeEventListener("keydown", onKey, true); + overlay.remove(); + banner.remove(); + selectedElements.forEach((el) => { el.style.outline = ""; }); + }; + + const onMove = (e) => { + const el = document.elementFromPoint(e.clientX, e.clientY); + if (!el || overlay.contains(el) || banner.contains(el)) return; + const r = el.getBoundingClientRect(); + highlight.style.cssText = "position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);top:" + r.top + "px;left:" + r.left + "px;width:" + r.width + "px;height:" + r.height + "px"; + }; + + const buildElementInfo = (el) => { + const parents = []; + let current = el.parentElement; + while (current && current !== document.body) { + const parentInfo = current.tagName.toLowerCase(); + const id = current.id ? "#" + current.id : ""; + const cls = current.className ? "." + current.className.trim().split(/\\s+/).join(".") : ""; + parents.push(parentInfo + id + cls); + current = current.parentElement; + } + return { + tag: el.tagName.toLowerCase(), + id: el.id || null, + class: el.className || null, + text: (el.textContent || "").trim().slice(0, 200) || null, + html: el.outerHTML.slice(0, 500), + parents: parents.join(" > "), + }; + }; + + const onClick = (e) => { + if (banner.contains(e.target)) return; + e.preventDefault(); + e.stopPropagation(); + const el = document.elementFromPoint(e.clientX, e.clientY); + if (!el || overlay.contains(el) || banner.contains(el)) return; + + if (e.metaKey || e.ctrlKey) { + if (!selectedElements.has(el)) { + selectedElements.add(el); + el.style.outline = "3px solid #10b981"; + selections.push(buildElementInfo(el)); + updateBanner(); + } + } else { + cleanup(); + const info = buildElementInfo(el); + resolve(selections.length > 0 ? selections : info); + } + }; + + const onKey = (e) => { + if (e.key === "Escape") { + e.preventDefault(); + cleanup(); + resolve(null); + } else if (e.key === "Enter" && selections.length > 0) { + e.preventDefault(); + cleanup(); + resolve(selections); + } + }; + + document.addEventListener("mousemove", onMove, true); + document.addEventListener("click", onClick, true); + document.addEventListener("keydown", onKey, true); + }); +}`; + +try { + log("connecting..."); + const cdp = await connect(5000); + + log("getting pages..."); + const pages = await cdp.getPages(); + const page = pages.at(-1); + + if (!page) { + console.error("✗ No active tab found"); + process.exit(1); + } + + log("attaching to page..."); + const sessionId = await cdp.attachToPage(page.targetId); + + log("waiting for user pick..."); + const expression = `(${PICK_SCRIPT})(${JSON.stringify(message)})`; + const result = await cdp.evaluate(sessionId, expression, 300000); + + log("formatting result..."); + if (Array.isArray(result)) { + for (let i = 0; i < result.length; i++) { + if (i > 0) console.log(""); + for (const [key, value] of Object.entries(result[i])) { + console.log(`${key}: ${value}`); + } + } + } else if (typeof result === "object" && result !== null) { + for (const [key, value] of Object.entries(result)) { + console.log(`${key}: ${value}`); + } + } else { + console.log(result); + } + + log("closing..."); + cdp.close(); + log("done"); +} catch (e) { + console.error("✗", e.message); + process.exit(1); +} finally { + clearTimeout(globalTimeout); + setTimeout(() => process.exit(0), 100); +} diff --git a/dot_claude/skills/web-browser/scripts/executable_screenshot.js b/dot_claude/skills/web-browser/scripts/executable_screenshot.js new file mode 100644 index 00000000..ccffc529 --- /dev/null +++ b/dot_claude/skills/web-browser/scripts/executable_screenshot.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node + +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeFileSync } from "node:fs"; +import { connect } from "./cdp.js"; + +const DEBUG = process.env.DEBUG === "1"; +const log = DEBUG ? (...args) => console.error("[debug]", ...args) : () => {}; + +// Global timeout +const globalTimeout = setTimeout(() => { + console.error("✗ Global timeout exceeded (15s)"); + process.exit(1); +}, 15000); + +try { + log("connecting..."); + const cdp = await connect(5000); + + log("getting pages..."); + const pages = await cdp.getPages(); + const page = pages.at(-1); + + if (!page) { + console.error("✗ No active tab found"); + process.exit(1); + } + + log("attaching to page..."); + const sessionId = await cdp.attachToPage(page.targetId); + + log("taking screenshot..."); + const data = await cdp.screenshot(sessionId); + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `screenshot-${timestamp}.png`; + const filepath = join(tmpdir(), filename); + + writeFileSync(filepath, data); + console.log(filepath); + + log("closing..."); + cdp.close(); + log("done"); +} catch (e) { + console.error("✗", e.message); + process.exit(1); +} finally { + clearTimeout(globalTimeout); + setTimeout(() => process.exit(0), 100); +} diff --git a/dot_claude/skills/web-browser/scripts/executable_start.js b/dot_claude/skills/web-browser/scripts/executable_start.js new file mode 100644 index 00000000..42f79587 --- /dev/null +++ b/dot_claude/skills/web-browser/scripts/executable_start.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +import { spawn, execSync } from "node:child_process"; +import puppeteer from "puppeteer-core"; + +const useProfile = process.argv[2] === "--profile"; + +if (process.argv[2] && process.argv[2] !== "--profile") { + console.log("Usage: start.ts [--profile]"); + console.log("\nOptions:"); + console.log( + " --profile Copy your default Chrome profile (cookies, logins)", + ); + console.log("\nExamples:"); + console.log(" start.ts # Start with fresh profile"); + console.log(" start.ts --profile # Start with your Chrome profile"); + process.exit(1); +} + +// Kill existing Chrome +try { + execSync("killall 'Google Chrome'", { stdio: "ignore" }); +} catch {} + +// Wait a bit for processes to fully die +await new Promise((r) => setTimeout(r, 1000)); + +// Setup profile directory +execSync("mkdir -p ~/.cache/scraping", { stdio: "ignore" }); + +if (useProfile) { + // Sync profile with rsync (much faster on subsequent runs) + execSync( + `rsync -a --delete "${process.env["HOME"]}/Library/Application Support/Google/Chrome/" ~/.cache/scraping/`, + { stdio: "pipe" }, + ); +} + +// Start Chrome in background (detached so Node can exit) +spawn( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + [ + "--remote-debugging-port=9222", + `--user-data-dir=${process.env["HOME"]}/.cache/scraping`, + "--profile-directory=Default", + "--disable-search-engine-choice-screen", + "--no-first-run", + "--disable-features=ProfilePicker", + ], + { detached: true, stdio: "ignore" }, +).unref(); + +// Wait for Chrome to be ready by attempting to connect +let connected = false; +for (let i = 0; i < 30; i++) { + try { + const browser = await puppeteer.connect({ + browserURL: "http://localhost:9222", + defaultViewport: null, + }); + await browser.disconnect(); + connected = true; + break; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } +} + +if (!connected) { + console.error("✗ Failed to connect to Chrome"); + process.exit(1); +} + +console.log( + `✓ Chrome started on :9222${useProfile ? " with your profile" : ""}`, +); diff --git a/dot_claude/skills/web-browser/scripts/package-lock.json b/dot_claude/skills/web-browser/scripts/package-lock.json new file mode 100644 index 00000000..336f26d6 --- /dev/null +++ b/dot_claude/skills/web-browser/scripts/package-lock.json @@ -0,0 +1,924 @@ +{ + "name": "scripts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "puppeteer-core": "^24.34.0", + "ws": "^8.18.0" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.0.tgz", + "integrity": "sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chromium-bidi": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-12.0.1.tgz", + "integrity": "sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1534754", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", + "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", + "license": "BSD-3-Clause" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer-core": { + "version": "24.34.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.34.0.tgz", + "integrity": "sha512-24evawO+mUGW4mvS2a2ivwLdX3gk8zRLZr9HP+7+VT2vBQnm0oh9jJEZmUE3ePJhRkYlZ93i7OMpdcoi2qNCLg==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.11.0", + "chromium-bidi": "12.0.1", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1534754", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.10", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT", + "optional": true + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.10.tgz", + "integrity": "sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw==", + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "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 + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/dot_claude/skills/web-browser/scripts/package.json b/dot_claude/skills/web-browser/scripts/package.json new file mode 100644 index 00000000..6e5e1979 --- /dev/null +++ b/dot_claude/skills/web-browser/scripts/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "dependencies": { + "puppeteer-core": "^24.34.0", + "ws": "^8.18.0" + } +} diff --git a/dot_pi/agent/extensions/answer.ts b/dot_pi/agent/extensions/answer.ts new file mode 100644 index 00000000..ac1d53c5 --- /dev/null +++ b/dot_pi/agent/extensions/answer.ts @@ -0,0 +1,533 @@ +/** + * Q&A extraction hook - extracts questions from assistant responses + * + * Alternative to qna.ts with a custom interactive TUI for answering questions. + * + * Demonstrates the "prompt generator" pattern with custom TUI: + * 1. /answer command gets the last assistant message + * 2. Shows a spinner while extracting questions as structured JSON + * 3. Presents an interactive TUI to navigate and answer questions + * 4. Submits the compiled answers when done + */ + +import { complete, type Model, type Api, type UserMessage } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { BorderedLoader } from "@mariozechner/pi-coding-agent"; +import { + type Component, + Editor, + type EditorTheme, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "@mariozechner/pi-tui"; + +// Structured output format for question extraction +interface ExtractedQuestion { + question: string; + context?: string; +} + +interface ExtractionResult { + questions: ExtractedQuestion[]; +} + +const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering. + +Output a JSON object with this structure: +{ + "questions": [ + { + "question": "The question text", + "context": "Optional context that helps answer the question" + } + ] +} + +Rules: +- Extract all questions that require user input +- Keep questions in the order they appeared +- Be concise with question text +- Include context only when it provides essential information for answering +- If no questions are found, return {"questions": []} + +Example output: +{ + "questions": [ + { + "question": "What is your preferred database?", + "context": "We can only configure MySQL and PostgreSQL because of what is implemented." + }, + { + "question": "Should we use TypeScript or JavaScript?" + } + ] +}`; + +const HAIKU_MODEL_ID = "claude-haiku-4-5"; + +/** + * Check if the current model is a Claude Opus or Sonnet model from Anthropic provider. + * If so, try to use claude-haiku-4-5 instead for cost efficiency. + */ +async function selectExtractionModel( + currentModel: Model, + modelRegistry: { + find: (provider: string, modelId: string) => Model | undefined; + getApiKey: (model: Model) => Promise; + }, +): Promise> { + if (currentModel.provider !== "anthropic") { + return currentModel; + } + + const modelId = currentModel.id.toLowerCase(); + const isOpusOrSonnet = modelId.includes("opus") || modelId.includes("sonnet"); + if (!isOpusOrSonnet) { + return currentModel; + } + + const haikuModel = modelRegistry.find("anthropic", HAIKU_MODEL_ID); + if (!haikuModel) { + return currentModel; + } + + const apiKey = await modelRegistry.getApiKey(haikuModel); + if (!apiKey) { + return currentModel; + } + + return haikuModel; +} + +/** + * Parse the JSON response from the LLM + */ +function parseExtractionResult(text: string): ExtractionResult | null { + try { + // Try to find JSON in the response (it might be wrapped in markdown code blocks) + let jsonStr = text; + + // Remove markdown code block if present + const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (jsonMatch) { + jsonStr = jsonMatch[1].trim(); + } + + const parsed = JSON.parse(jsonStr); + if (parsed && Array.isArray(parsed.questions)) { + return parsed as ExtractionResult; + } + return null; + } catch { + return null; + } +} + +/** + * Interactive Q&A component for answering extracted questions + */ +class QnAComponent implements Component { + private questions: ExtractedQuestion[]; + private answers: string[]; + private currentIndex: number = 0; + private editor: Editor; + private tui: { requestRender: () => void }; + private onDone: (result: string | null) => void; + private showingConfirmation: boolean = false; + + // Cache + private cachedWidth?: number; + private cachedLines?: string[]; + + // Colors - using proper reset sequences + private dim = (s: string) => `\x1b[2m${s}\x1b[0m`; + private bold = (s: string) => `\x1b[1m${s}\x1b[0m`; + private cyan = (s: string) => `\x1b[36m${s}\x1b[0m`; + private green = (s: string) => `\x1b[32m${s}\x1b[0m`; + private yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; + private gray = (s: string) => `\x1b[90m${s}\x1b[0m`; + + constructor( + questions: ExtractedQuestion[], + tui: { requestRender: () => void }, + onDone: (result: string | null) => void, + ) { + this.questions = questions; + this.answers = questions.map(() => ""); + this.tui = tui; + this.onDone = onDone; + + // Create a minimal theme for the editor + const editorTheme: EditorTheme = { + borderColor: this.dim, + selectList: { + selectedBg: (s: string) => `\x1b[44m${s}\x1b[0m`, + matchHighlight: this.cyan, + itemSecondary: this.gray, + }, + }; + + this.editor = new Editor(editorTheme); + // Disable the editor's built-in submit (which clears the editor) + // We'll handle Enter ourselves to preserve the text + this.editor.disableSubmit = true; + this.editor.onChange = () => { + this.invalidate(); + this.tui.requestRender(); + }; + } + + private allQuestionsAnswered(): boolean { + this.saveCurrentAnswer(); + return this.answers.every((a) => (a?.trim() || "").length > 0); + } + + private saveCurrentAnswer(): void { + this.answers[this.currentIndex] = this.editor.getText(); + } + + private navigateTo(index: number): void { + if (index < 0 || index >= this.questions.length) return; + this.saveCurrentAnswer(); + this.currentIndex = index; + this.editor.setText(this.answers[index] || ""); + this.invalidate(); + } + + private submit(): void { + this.saveCurrentAnswer(); + + // Build the response text + const parts: string[] = []; + for (let i = 0; i < this.questions.length; i++) { + const q = this.questions[i]; + const a = this.answers[i]?.trim() || "(no answer)"; + parts.push(`Q: ${q.question}`); + if (q.context) { + parts.push(`> ${q.context}`); + } + parts.push(`A: ${a}`); + parts.push(""); + } + + this.onDone(parts.join("\n").trim()); + } + + private cancel(): void { + this.onDone(null); + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + handleInput(data: string): void { + // Handle confirmation dialog + if (this.showingConfirmation) { + if (matchesKey(data, Key.enter) || data.toLowerCase() === "y") { + this.submit(); + return; + } + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data.toLowerCase() === "n") { + this.showingConfirmation = false; + this.invalidate(); + this.tui.requestRender(); + return; + } + return; + } + + // Global navigation and commands + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { + this.cancel(); + return; + } + + // Tab / Shift+Tab for navigation + if (matchesKey(data, Key.tab)) { + if (this.currentIndex < this.questions.length - 1) { + this.navigateTo(this.currentIndex + 1); + this.tui.requestRender(); + } + return; + } + if (matchesKey(data, Key.shift("tab"))) { + if (this.currentIndex > 0) { + this.navigateTo(this.currentIndex - 1); + this.tui.requestRender(); + } + return; + } + + // Arrow up/down for question navigation when editor is empty + // (Editor handles its own cursor navigation when there's content) + if (matchesKey(data, Key.up) && this.editor.getText() === "") { + if (this.currentIndex > 0) { + this.navigateTo(this.currentIndex - 1); + this.tui.requestRender(); + return; + } + } + if (matchesKey(data, Key.down) && this.editor.getText() === "") { + if (this.currentIndex < this.questions.length - 1) { + this.navigateTo(this.currentIndex + 1); + this.tui.requestRender(); + return; + } + } + + // Handle Enter ourselves (editor's submit is disabled) + // Plain Enter moves to next question or shows confirmation on last question + // Shift+Enter adds a newline (handled by editor) + if (matchesKey(data, Key.enter) && !matchesKey(data, Key.shift("enter"))) { + this.saveCurrentAnswer(); + if (this.currentIndex < this.questions.length - 1) { + this.navigateTo(this.currentIndex + 1); + } else { + // On last question - show confirmation + this.showingConfirmation = true; + } + this.invalidate(); + this.tui.requestRender(); + return; + } + + // Pass to editor + this.editor.handleInput(data); + this.invalidate(); + this.tui.requestRender(); + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const lines: string[] = []; + const boxWidth = Math.min(width - 4, 120); // Allow wider box + const contentWidth = boxWidth - 4; // 2 chars padding on each side + + // Helper to create horizontal lines (dim the whole thing at once) + const horizontalLine = (count: number) => "─".repeat(count); + + // Helper to create a box line + const boxLine = (content: string, leftPad: number = 2): string => { + const paddedContent = " ".repeat(leftPad) + content; + const contentLen = visibleWidth(paddedContent); + const rightPad = Math.max(0, boxWidth - contentLen - 2); + return this.dim("│") + paddedContent + " ".repeat(rightPad) + this.dim("│"); + }; + + const emptyBoxLine = (): string => { + return this.dim("│") + " ".repeat(boxWidth - 2) + this.dim("│"); + }; + + const padToWidth = (line: string): string => { + const len = visibleWidth(line); + return line + " ".repeat(Math.max(0, width - len)); + }; + + // Title + lines.push(padToWidth(this.dim("╭" + horizontalLine(boxWidth - 2) + "╮"))); + const title = `${this.bold(this.cyan("Questions"))} ${this.dim(`(${this.currentIndex + 1}/${this.questions.length})`)}`; + lines.push(padToWidth(boxLine(title))); + lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤"))); + + // Progress indicator + const progressParts: string[] = []; + for (let i = 0; i < this.questions.length; i++) { + const answered = (this.answers[i]?.trim() || "").length > 0; + const current = i === this.currentIndex; + if (current) { + progressParts.push(this.cyan("●")); + } else if (answered) { + progressParts.push(this.green("●")); + } else { + progressParts.push(this.dim("○")); + } + } + lines.push(padToWidth(boxLine(progressParts.join(" ")))); + lines.push(padToWidth(emptyBoxLine())); + + // Current question + const q = this.questions[this.currentIndex]; + const questionText = `${this.bold("Q:")} ${q.question}`; + const wrappedQuestion = wrapTextWithAnsi(questionText, contentWidth); + for (const line of wrappedQuestion) { + lines.push(padToWidth(boxLine(line))); + } + + // Context if present + if (q.context) { + lines.push(padToWidth(emptyBoxLine())); + const contextText = this.gray(`> ${q.context}`); + const wrappedContext = wrapTextWithAnsi(contextText, contentWidth - 2); + for (const line of wrappedContext) { + lines.push(padToWidth(boxLine(line))); + } + } + + lines.push(padToWidth(emptyBoxLine())); + + // Render the editor component (multi-line input) with padding + // Skip the first and last lines (editor's own border lines) + const answerPrefix = this.bold("A: "); + const editorWidth = contentWidth - 4 - 3; // Extra padding + space for "A: " + const editorLines = this.editor.render(editorWidth); + for (let i = 1; i < editorLines.length - 1; i++) { + if (i === 1) { + // First content line gets the "A: " prefix + lines.push(padToWidth(boxLine(answerPrefix + editorLines[i]))); + } else { + // Subsequent lines get padding to align with the first line + lines.push(padToWidth(boxLine(" " + editorLines[i]))); + } + } + + lines.push(padToWidth(emptyBoxLine())); + + // Confirmation dialog or footer with controls + if (this.showingConfirmation) { + lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤"))); + const confirmMsg = `${this.yellow("Submit all answers?")} ${this.dim("(Enter/y to confirm, Esc/n to cancel)")}`; + lines.push(padToWidth(boxLine(truncateToWidth(confirmMsg, contentWidth)))); + } else { + lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤"))); + const controls = `${this.dim("Tab/Enter")} next · ${this.dim("Shift+Tab")} prev · ${this.dim("Shift+Enter")} newline · ${this.dim("Esc")} cancel`; + lines.push(padToWidth(boxLine(truncateToWidth(controls, contentWidth)))); + } + lines.push(padToWidth(this.dim("╰" + horizontalLine(boxWidth - 2) + "╯"))); + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } +} + +export default function (pi: ExtensionAPI) { + const answerHandler = async (ctx: ExtensionContext) => { + if (!ctx.hasUI) { + ctx.ui.notify("answer requires interactive mode", "error"); + return; + } + + if (!ctx.model) { + ctx.ui.notify("No model selected", "error"); + return; + } + + // Find the last assistant message on the current branch + const branch = ctx.sessionManager.getBranch(); + let lastAssistantText: string | undefined; + + for (let i = branch.length - 1; i >= 0; i--) { + const entry = branch[i]; + if (entry.type === "message") { + const msg = entry.message; + if ("role" in msg && msg.role === "assistant") { + if (msg.stopReason !== "stop") { + ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error"); + return; + } + const textParts = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text); + if (textParts.length > 0) { + lastAssistantText = textParts.join("\n"); + break; + } + } + } + } + + if (!lastAssistantText) { + ctx.ui.notify("No assistant messages found", "error"); + return; + } + + // Select the best model for extraction (prefer haiku for cost efficiency) + const extractionModel = await selectExtractionModel(ctx.model, ctx.modelRegistry); + + // Run extraction with loader UI + const extractionResult = await ctx.ui.custom((tui, theme, _kb, done) => { + const loader = new BorderedLoader(tui, theme, `Extracting questions using ${extractionModel.id}...`); + loader.onAbort = () => done(null); + + const doExtract = async () => { + const apiKey = await ctx.modelRegistry.getApiKey(extractionModel); + const userMessage: UserMessage = { + role: "user", + content: [{ type: "text", text: lastAssistantText! }], + timestamp: Date.now(), + }; + + const response = await complete( + extractionModel, + { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, + { apiKey, signal: loader.signal }, + ); + + if (response.stopReason === "aborted") { + return null; + } + + const responseText = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + + return parseExtractionResult(responseText); + }; + + doExtract() + .then(done) + .catch(() => done(null)); + + return loader; + }); + + if (extractionResult === null) { + ctx.ui.notify("Cancelled", "info"); + return; + } + + if (extractionResult.questions.length === 0) { + ctx.ui.notify("No questions found in the last message", "info"); + return; + } + + // Show the Q&A component + const answersResult = await ctx.ui.custom((tui, _theme, _kb, done) => { + return new QnAComponent(extractionResult.questions, tui, done); + }); + + if (answersResult === null) { + ctx.ui.notify("Cancelled", "info"); + return; + } + + // Send the answers directly as a message and trigger a turn + pi.sendMessage( + { + customType: "answers", + content: "I answered your questions in the following way:\n\n" + answersResult, + display: true, + }, + { triggerTurn: true }, + ); + }; + + pi.registerCommand("answer", { + description: "Extract questions from last assistant message into interactive Q&A", + handler: (_args, ctx) => answerHandler(ctx), + }); + + pi.registerShortcut("ctrl+.", { + description: "Extract and answer questions", + handler: answerHandler, + }); +} diff --git a/dot_pi/agent/extensions/qna.ts b/dot_pi/agent/extensions/qna.ts new file mode 100644 index 00000000..fe85f831 --- /dev/null +++ b/dot_pi/agent/extensions/qna.ts @@ -0,0 +1,167 @@ +/** + * Q&A extraction hook - extracts questions from assistant responses + * + * Demonstrates the "prompt generator" pattern: + * 1. /qna command gets the last assistant message + * 2. Shows a spinner while extracting (hides editor) + * 3. Loads the result into the editor for user to fill in answers + */ + +import { complete, type Model, type Api, type UserMessage } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { BorderedLoader } from "@mariozechner/pi-coding-agent"; + +const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in. + +Output format: +- List each question on its own line, prefixed with "Q: " +- After each question, add a blank line for the answer prefixed with "A: " +- If no questions are found, output "No questions found in the last message." +- When there is important context needed to answer a question, use quote characters (">") and add it into the question block as additional lines. + +Example output: +Q: What is your preferred database? +> we can only configure MySQL and PostgreSQL because of what is implemented. +A: + +Q: Should we use TypeScript or JavaScript? +A: + +Keep questions in the order they appeared. Be concise.`; + +const HAIKU_MODEL_ID = "claude-haiku-4-5"; + +/** + * Check if the current model is a Claude Opus or Sonnet model from Anthropic provider. + * If so, try to use claude-haiku-4-5 instead for cost efficiency. + */ +async function selectExtractionModel( + currentModel: Model, + modelRegistry: { find: (provider: string, modelId: string) => Model | undefined; getApiKey: (model: Model) => Promise }, +): Promise> { + // Only consider switching if the provider is anthropic and the model is opus or sonnet + if (currentModel.provider !== "anthropic") { + return currentModel; + } + + const modelId = currentModel.id.toLowerCase(); + const isOpusOrSonnet = modelId.includes("opus") || modelId.includes("sonnet"); + if (!isOpusOrSonnet) { + return currentModel; + } + + // Try to find and use claude-haiku-4-5 + const haikuModel = modelRegistry.find("anthropic", HAIKU_MODEL_ID); + if (!haikuModel) { + return currentModel; + } + + // Check if we have an API key for the haiku model + const apiKey = await modelRegistry.getApiKey(haikuModel); + if (!apiKey) { + return currentModel; + } + + return haikuModel; +} + +export default function (pi: ExtensionAPI) { + const qnaHandler = async (ctx: ExtensionContext) => { + if (!ctx.hasUI) { + ctx.ui.notify("qna requires interactive mode", "error"); + return; + } + + if (!ctx.model) { + ctx.ui.notify("No model selected", "error"); + return; + } + + // Find the last assistant message on the current branch + const branch = ctx.sessionManager.getBranch(); + let lastAssistantText: string | undefined; + + for (let i = branch.length - 1; i >= 0; i--) { + const entry = branch[i]; + if (entry.type === "message") { + const msg = entry.message; + if ("role" in msg && msg.role === "assistant") { + if (msg.stopReason !== "stop") { + ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error"); + return; + } + const textParts = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text); + if (textParts.length > 0) { + lastAssistantText = textParts.join("\n"); + break; + } + } + } + } + + if (!lastAssistantText) { + ctx.ui.notify("No assistant messages found", "error"); + return; + } + + // Select the best model for extraction (prefer haiku for cost efficiency) + const extractionModel = await selectExtractionModel(ctx.model, ctx.modelRegistry); + + // Run extraction with loader UI + const result = await ctx.ui.custom((tui, theme, done) => { + const loader = new BorderedLoader(tui, theme, `Extracting questions using ${extractionModel.id}...`); + loader.onAbort = () => done(null); + + // Do the work + const doExtract = async () => { + const apiKey = await ctx.modelRegistry.getApiKey(extractionModel); + const userMessage: UserMessage = { + role: "user", + content: [{ type: "text", text: lastAssistantText! }], + timestamp: Date.now(), + }; + + const response = await complete( + extractionModel, + { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, + { apiKey, signal: loader.signal }, + ); + + if (response.stopReason === "aborted") { + return null; + } + + return response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + }; + + doExtract() + .then(done) + .catch(() => done(null)); + + return loader; + }); + + if (result === null) { + ctx.ui.notify("Cancelled", "info"); + return; + } + + ctx.ui.setEditorText(result); + ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info"); + }; + + pi.registerCommand("qna", { + description: "Extract questions from last assistant message into editor", + handler: (_args, ctx) => qnaHandler(ctx), + }); + + pi.registerShortcut("ctrl+,", { + description: "Extract questions into editor", + handler: qnaHandler, + }); +} diff --git a/dot_pi/agent/settings.json b/dot_pi/agent/settings.json new file mode 100644 index 00000000..38ef6805 --- /dev/null +++ b/dot_pi/agent/settings.json @@ -0,0 +1,10 @@ +{ + "lastChangelogVersion": "0.42.0", + "hideThinkingBlock": true, + "collapseChangelog": false, + "defaultThinkingLevel": "off", + "theme": "dark", + "defaultProvider": "anthropic", + "defaultModel": "claude-sonnet-4-5", + "doubleEscapeAction": "tree" +} \ No newline at end of file -- 2.51.2