diff --git a/.gitignore b/.gitignore index c017617b..aa6644e6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ docs/.vitepress/cache # Database files *.db +autoresearch/results/ +extension/dist/ diff --git a/README.md b/README.md index b4492f2a..a2dcb448 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,24 @@ opencli hackernews top --limit 5 # Public API, no browser needed opencli bilibili hot --limit 5 # Browser command (requires Extension) ``` +### 4. Browser Automation โ€” Make Websites Accessible for AI Agents + +#### AI Agent Quickstart (1 step) + +Point your AI agent (Claude Code, Cursor) to [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md). It has everything needed. + +#### Human Quickstart (3 steps) + +```bash +opencli operate open https://news.ycombinator.com # 1. Open a page +opencli operate state # 2. See interactive elements +opencli operate eval "document.title" # 3. Extract data +``` + +More commands: `click`, `type`, `select`, `keys`, `wait`, `get`, `screenshot`, `scroll`, `back`, `close`. + +See [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md) for full documentation. + ### Update ```bash diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 00000000..b09b94d2 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,59 @@ +--- +name: opencli +description: "OpenCLI โ€” Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login." +version: 1.5.6 +author: jackwener +tags: [cli, browser, web, chrome-extension, cdp, AI, agent, operate] +--- + +# OpenCLI + +> Make any website or Electron App your CLI. Reuse Chrome login, zero risk, AI-powered. + +## Skills + +OpenCLI has three specialized skills. Use the one that matches your task: + +### 1. CLI Commands (`skills/cli/SKILL.md`) +Use existing CLI commands to fetch data, interact with websites and desktop apps. +```bash +opencli twitter trending --limit 10 +opencli hackernews top --limit 5 +opencli bilibili hot +``` + +### 2. Browser Automation (`skills/opencli-operate/SKILL.md`) +Make websites accessible for AI agents. Navigate, click, type, extract, wait โ€” with existing Chrome login sessions. No LLM API key needed. +```bash +opencli operate open https://example.com +opencli operate state # See interactive elements with [N] indices +opencli operate click 3 # Click element [3] +opencli operate network # Discover APIs +opencli operate init site/cmd # Generate adapter scaffold +opencli operate verify site/cmd # Test the adapter +``` + +### 3. Adapter Development (`skills/adapter-dev/SKILL.md`) +Create new CLI commands from websites. Explore APIs, record traffic, write TypeScript adapters. +```bash +opencli explore https://example.com +opencli record https://example.com +opencli generate https://example.com --goal "hot" +``` + +## Quick Setup + +```bash +npm install -g @jackwener/opencli +opencli doctor # Verify Chrome extension + daemon +``` + +## Configuration + +```bash +# For AI agent (opencli operate) +export OPENCLI_PROVIDER=anthropic # or openai +export OPENCLI_MODEL=sonnet # model alias +export OPENCLI_API_KEY=sk-ant-... # API key +export OPENCLI_BASE_URL=https://... # optional proxy +``` diff --git a/autoresearch/baseline-browse.txt b/autoresearch/baseline-browse.txt new file mode 100644 index 00000000..cf0980e1 --- /dev/null +++ b/autoresearch/baseline-browse.txt @@ -0,0 +1 @@ +56/59 diff --git a/autoresearch/baseline-skill.txt b/autoresearch/baseline-skill.txt new file mode 100644 index 00000000..cefa61ce --- /dev/null +++ b/autoresearch/baseline-skill.txt @@ -0,0 +1 @@ +31/31 diff --git a/autoresearch/browse-tasks.json b/autoresearch/browse-tasks.json new file mode 100644 index 00000000..e99c6356 --- /dev/null +++ b/autoresearch/browse-tasks.json @@ -0,0 +1,688 @@ +[ + { + "name": "extract-title-example", + "steps": [ + "opencli operate open https://example.com", + "opencli operate eval \"document.title\"" + ], + "judge": { + "type": "contains", + "value": "Example Domain" + } + }, + { + "name": "extract-title-iana", + "steps": [ + "opencli operate open https://www.iana.org", + "opencli operate eval \"document.querySelector('h1')?.textContent\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "extract-paragraph-wiki-js", + "steps": [ + "opencli operate open https://en.wikipedia.org/wiki/JavaScript", + "opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50 && !t.startsWith('{')) return t.slice(0,300); } return ''; })()\"" + ], + "judge": { + "type": "contains", + "value": "programming language" + } + }, + { + "name": "extract-paragraph-wiki-python", + "steps": [ + "opencli operate open \"https://en.wikipedia.org/wiki/Python_(programming_language)\"", + "opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50 && !t.startsWith('{')) return t.slice(0,300); } return ''; })()\"" + ], + "judge": { + "type": "contains", + "value": "programming language" + } + }, + { + "name": "extract-github-stars", + "steps": [ + "opencli operate open https://github.com/browser-use/browser-use", + "opencli operate eval \"document.querySelector('#repo-stars-counter-star')?.textContent?.trim()\"" + ], + "judge": { + "type": "matchesPattern", + "pattern": "\\d" + } + }, + { + "name": "extract-github-description", + "steps": [ + "opencli operate open https://github.com/anthropics/claude-code", + "opencli operate eval \"document.querySelector('p.f4, [data-testid=about-description], .f4.my-3, .BorderGrid-cell p')?.textContent?.trim()\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "extract-github-readme-heading", + "steps": [ + "opencli operate open https://github.com/vercel/next.js", + "opencli operate eval \"document.querySelector('article h1, article h2')?.textContent?.trim()\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "extract-npm-downloads", + "steps": [ + "opencli operate open https://www.npmjs.com/package/zod", + "opencli operate eval \"document.querySelector('[data-nosnippet]')?.textContent?.trim() || document.querySelector('p.f2874b88')?.textContent?.trim()\"" + ], + "judge": { + "type": "matchesPattern", + "pattern": "\\d" + } + }, + { + "name": "extract-npm-description", + "steps": [ + "opencli operate open https://www.npmjs.com/package/express", + "opencli operate eval \"document.querySelector('p[class*=description], [data-testid=package-description], #readme p')?.textContent?.trim()\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "list-hn-top5", + "steps": [ + "opencli operate open https://news.ycombinator.com", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('.titleline > a')].slice(0,5).map(a=>({title:a.textContent,url:a.href})))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 5 + } + }, + { + "name": "list-hn-top10", + "steps": [ + "opencli operate open https://news.ycombinator.com", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('.athing')].slice(0,10).map(tr=>{const a=tr.querySelector('.titleline>a');const s=tr.nextElementSibling?.querySelector('.score');return{title:a?.textContent,score:parseInt(s?.textContent)||0}}))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 10 + } + }, + { + "name": "list-books-5", + "steps": [ + "opencli operate open https://books.toscrape.com", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.product_pod')].slice(0,5).map(el=>({title:el.querySelector('h3 a')?.getAttribute('title'),price:el.querySelector('.price_color')?.textContent})))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 5 + } + }, + { + "name": "list-books-10", + "steps": [ + "opencli operate open https://books.toscrape.com", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.product_pod')].slice(0,10).map(el=>({title:el.querySelector('h3 a')?.getAttribute('title'),price:el.querySelector('.price_color')?.textContent})))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 10 + } + }, + { + "name": "list-quotes-3", + "steps": [ + "opencli operate open https://quotes.toscrape.com", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,3).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent})))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 3 + } + }, + { + "name": "list-quotes-tags", + "steps": [ + "opencli operate open https://quotes.toscrape.com", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,5).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent,tags:[...el.querySelectorAll('.tag')].map(t=>t.textContent)})))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 5 + } + }, + { + "name": "list-github-trending", + "steps": [ + "opencli operate open https://github.com/trending", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row')].slice(0,3).map(el=>({name:el.querySelector('h2 a')?.textContent?.trim().replace(/\\s+/g,' '),desc:el.querySelector('p')?.textContent?.trim()})))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 3 + } + }, + { + "name": "list-github-trending-lang", + "steps": [ + "opencli operate open https://github.com/trending/python", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row')].slice(0,5).map(el=>({name:el.querySelector('h2 a')?.textContent?.trim().replace(/\\s+/g,' ')})))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 5 + } + }, + { + "name": "list-jsonplaceholder-posts", + "steps": [ + "opencli operate open https://jsonplaceholder.typicode.com/posts", + "opencli operate eval \"JSON.stringify(JSON.parse(document.body.innerText).slice(0,5).map(p=>({id:p.id,title:p.title})))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 5 + } + }, + { + "name": "list-jsonplaceholder-users", + "steps": [ + "opencli operate open https://jsonplaceholder.typicode.com/users", + "opencli operate eval \"JSON.stringify(JSON.parse(document.body.innerText).map(u=>({name:u.name,email:u.email})))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 5 + } + }, + { + "name": "search-google", + "steps": [ + "opencli operate open https://www.google.com", + "opencli operate eval \"document.querySelector('textarea[name=q], input[name=q]').value='opencli github';document.querySelector('form').submit();'submitted'\"", + "opencli operate wait time 3", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,3).map(h=>h.textContent))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 3 + }, + "note": "index 5 may vary" + }, + { + "name": "search-ddg", + "steps": [ + "opencli operate open https://duckduckgo.com", + "opencli operate state", + "opencli operate type 1 \"weather beijing\"", + "opencli operate keys Enter", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a]')].slice(0,3).map(a=>a.textContent))\"" + ], + "judge": { + "type": "nonEmpty" + }, + "note": "index may vary" + }, + { + "name": "search-ddg-tech", + "steps": [ + "opencli operate open https://duckduckgo.com", + "opencli operate eval \"document.querySelector('input[name=q]').value='TypeScript tutorial';document.querySelector('form').submit();'submitted'\"", + "opencli operate wait time 3", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a], .result__a')].slice(0,3).map(a=>({title:a.textContent,url:a.href})))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 3 + }, + "note": "index may vary" + }, + { + "name": "search-wiki", + "steps": [ + "opencli operate open https://en.wikipedia.org", + "opencli operate eval \"document.querySelector('input[name=search]').value='Rust programming language';document.querySelector('form#searchform, form[role=search]').submit();'submitted'\"", + "opencli operate wait time 3", + "opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\"" + ], + "judge": { + "type": "contains", + "value": "programming language" + }, + "note": "index may vary" + }, + { + "name": "search-npm", + "steps": [ + "opencli operate open https://www.npmjs.com", + "opencli operate state", + "opencli operate type 1 \"react\"", + "opencli operate keys Enter", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=pkg-list-item] h3, section h3')].slice(0,3).map(h=>h.textContent?.trim()))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 3 + }, + "note": "index may vary" + }, + { + "name": "search-github", + "steps": [ + "opencli operate open https://github.com/search?q=browser+automation&type=repositories", + "opencli operate wait time 3", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('.search-title a, [data-testid=results-list] a.Link--primary')].slice(0,3).map(a=>a.textContent?.trim().replace(/\\\\s+/g,' ')))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 3 + }, + "note": "index may vary" + }, + { + "name": "nav-click-link-example", + "steps": [ + "opencli operate open https://example.com", + "opencli operate eval \"document.querySelector('a')?.click();'clicked'\"", + "opencli operate wait time 2", + "opencli operate eval \"document.title\"" + ], + "judge": { + "type": "contains", + "value": "IANA" + } + }, + { + "name": "nav-click-hn-first", + "steps": [ + "opencli operate open https://news.ycombinator.com", + "opencli operate eval \"document.querySelector('.titleline a')?.click();'clicked'\"", + "opencli operate wait time 2", + "opencli operate eval \"document.title\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "nav-click-hn-comments", + "steps": [ + "opencli operate open https://news.ycombinator.com", + "opencli operate eval \"document.querySelector('.subtext a:last-child')?.click(); 'clicked'\"", + "opencli operate eval \"document.title\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "nav-click-wiki-link", + "steps": [ + "opencli operate open https://en.wikipedia.org/wiki/JavaScript", + "opencli operate eval \"document.querySelector('#toc a, .toc a, [href=\\\"#History\\\"]')?.click(); 'clicked'\"", + "opencli operate eval \"document.querySelector('#History, #History ~ p')?.textContent?.slice(0,100)\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "nav-click-github-tab", + "steps": [ + "opencli operate open https://github.com/vercel/next.js", + "opencli operate eval \"document.querySelector('[data-tab-item=i1issues-tab] a, #issues-tab')?.click(); 'clicked'\"", + "opencli operate eval \"document.title\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "nav-go-back", + "steps": [ + "opencli operate open https://example.com", + "opencli operate eval \"document.querySelector('a')?.click();'clicked'\"", + "opencli operate wait time 2", + "opencli operate back", + "opencli operate wait time 2", + "opencli operate eval \"document.title\"" + ], + "judge": { + "type": "contains", + "value": "Example Domain" + } + }, + { + "name": "nav-multi-step", + "steps": [ + "opencli operate open https://quotes.toscrape.com", + "opencli operate eval \"document.querySelector('.next a')?.click(); 'clicked'\"", + "opencli operate eval \"document.querySelector('.quote .text')?.textContent\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "scroll-footer-quotes", + "steps": [ + "opencli operate open https://quotes.toscrape.com", + "opencli operate scroll down", + "opencli operate scroll down", + "opencli operate eval \"document.querySelector('footer, .footer, .tags-box')?.textContent?.trim().slice(0,100)\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "scroll-footer-books", + "steps": [ + "opencli operate open https://books.toscrape.com", + "opencli operate scroll down", + "opencli operate scroll down", + "opencli operate eval \"document.querySelector('.pager .current')?.textContent?.trim()\"" + ], + "judge": { + "type": "matchesPattern", + "pattern": "\\d" + } + }, + { + "name": "scroll-long-page", + "steps": [ + "opencli operate open https://jsonplaceholder.typicode.com/posts", + "opencli operate eval \"JSON.parse(document.body.innerText).length\"" + ], + "judge": { + "type": "matchesPattern", + "pattern": "\\d" + } + }, + { + "name": "scroll-find-element", + "steps": [ + "opencli operate open https://quotes.toscrape.com", + "opencli operate eval \"document.querySelector('.next a')?.href\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "scroll-lazy-load", + "steps": [ + "opencli operate open https://books.toscrape.com", + "opencli operate eval \"document.querySelectorAll('article.product_pod').length\"" + ], + "judge": { + "type": "matchesPattern", + "pattern": "\\d" + } + }, + { + "name": "form-simple-name", + "steps": [ + "opencli operate open https://httpbin.org/forms/post", + "opencli operate eval \"var el=document.querySelector('[name=custname]');el.value='OpenCLI Test';el.dispatchEvent(new Event('input',{bubbles:true}));el.value\"" + ], + "judge": { + "type": "contains", + "value": "OpenCLI" + }, + "note": "index may vary" + }, + { + "name": "form-text-inputs", + "steps": [ + "opencli operate open https://httpbin.org/forms/post", + "opencli operate eval \"var n=document.querySelector('[name=custname]');n.value='Alice';n.dispatchEvent(new Event('input',{bubbles:true}));var t=document.querySelector('[name=custtel]');t.value='555-1234';t.dispatchEvent(new Event('input',{bubbles:true}));n.value+'|'+t.value\"" + ], + "judge": { + "type": "contains", + "value": "Alice" + }, + "note": "index may vary" + }, + { + "name": "form-radio-select", + "steps": [ + "opencli operate open https://httpbin.org/forms/post", + "opencli operate eval \"document.querySelector('[value=medium]').checked=true;document.querySelector('[value=medium]').dispatchEvent(new Event('change',{bubbles:true}));document.querySelector('[value=medium]').checked\"" + ], + "judge": { + "type": "contains", + "value": "true" + } + }, + { + "name": "form-checkbox", + "steps": [ + "opencli operate open https://httpbin.org/forms/post", + "opencli operate eval \"var cb=document.querySelector('[value=cheese]');cb.checked=true;cb.dispatchEvent(new Event('change',{bubbles:true}));cb.checked\"" + ], + "judge": { + "type": "contains", + "value": "true" + } + }, + { + "name": "form-textarea", + "steps": [ + "opencli operate open https://httpbin.org/forms/post", + "opencli operate eval \"var ta=document.querySelector('textarea[name=comments]');ta.value='AutoResearch test';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.value\"" + ], + "judge": { + "type": "contains", + "value": "AutoResearch" + } + }, + { + "name": "form-login-fake", + "steps": [ + "opencli operate open https://the-internet.herokuapp.com/login", + "opencli operate eval \"var u=document.querySelector('#username');u.value='testuser';u.dispatchEvent(new Event('input',{bubbles:true}));var p=document.querySelector('#password');p.value='testpass';p.dispatchEvent(new Event('input',{bubbles:true}));u.value+'|'+p.value\"" + ], + "judge": { + "type": "contains", + "value": "testuser" + }, + "note": "index may vary" + }, + { + "name": "complex-wiki-toc", + "steps": [ + "opencli operate open https://en.wikipedia.org/wiki/JavaScript", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('.toc li a, #toc li a, .vector-toc-contents a')].slice(0,8).map(a=>a.textContent?.trim()))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 5 + } + }, + { + "name": "complex-books-detail", + "steps": [ + "opencli operate open https://books.toscrape.com", + "opencli operate eval \"document.querySelector('article.product_pod h3 a')?.click();'clicked'\"", + "opencli operate eval \"JSON.stringify({title:document.querySelector('h1')?.textContent,price:document.querySelector('.price_color')?.textContent})\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "complex-quotes-page2", + "steps": [ + "opencli operate open https://quotes.toscrape.com", + "opencli operate eval \"document.querySelector('.next a')?.click();'clicked'\"", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,3).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent})))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 3 + } + }, + { + "name": "complex-github-repo-info", + "steps": [ + "opencli operate open https://github.com/expressjs/express", + "opencli operate eval \"JSON.stringify({lang:document.querySelector('[itemprop=programmingLanguage]')?.textContent?.trim(),license:document.querySelector('[data-analytics-event*=license] span, .Layout-sidebar [href*=LICENSE]')?.textContent?.trim()})\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "complex-hn-story-comments", + "steps": [ + "opencli operate open https://news.ycombinator.com", + "opencli operate eval \"document.querySelector('.subtext a:last-child')?.click();'clicked'\"", + "opencli operate eval \"document.querySelector('.fatitem .titleline a')?.textContent\"" + ], + "judge": { + "type": "nonEmpty" + } + }, + { + "name": "complex-multi-extract", + "steps": [ + "opencli operate open https://en.wikipedia.org/wiki/TypeScript", + "opencli operate eval \"JSON.stringify({title:document.title,firstParagraph:document.querySelector('#mw-content-text p')?.textContent?.slice(0,150)})\"" + ], + "judge": { + "type": "contains", + "value": "TypeScript" + } + }, + { + "name": "bench-reddit-top5", + "steps": [ + "opencli operate open https://old.reddit.com", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('#siteTable .thing .title a.title')].slice(0,5).map(a=>a.textContent))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 5 + }, + "set": "test" + }, + { + "name": "bench-imdb-matrix", + "steps": [ + "opencli operate open https://www.imdb.com/title/tt0133093/", + "opencli operate eval \"JSON.stringify({title:document.querySelector('h1 span, [data-testid=hero__pageTitle] span')?.textContent,year:document.querySelector('a[href*=releaseinfo], [data-testid=hero-title-block__metadata] a')?.textContent})\"" + ], + "judge": { + "type": "contains", + "value": "1999" + }, + "set": "test" + }, + { + "name": "bench-npm-zod", + "steps": [ + "opencli operate open https://www.npmjs.com/package/zod", + "opencli operate eval \"JSON.stringify({name:document.querySelector('h1 span, #top h2')?.textContent?.trim(),description:document.querySelector('[data-testid=package-description], p.package-description-redundant')?.textContent?.trim()})\"" + ], + "judge": { + "type": "nonEmpty" + }, + "set": "test" + }, + { + "name": "bench-wiki-search", + "steps": [ + "opencli operate open https://en.wikipedia.org/wiki/Machine_learning", + "opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\"" + ], + "judge": { + "type": "contains", + "value": "learning" + }, + "set": "test" + }, + { + "name": "bench-github-profile", + "steps": [ + "opencli operate open https://github.com/torvalds", + "opencli operate eval \"JSON.stringify({name:document.querySelector('[itemprop=name]')?.textContent?.trim(),bio:document.querySelector('[data-bio-text]')?.textContent?.trim()||document.querySelector('.p-note')?.textContent?.trim()})\"" + ], + "judge": { + "type": "nonEmpty" + }, + "set": "test" + }, + { + "name": "bench-books-category", + "steps": [ + "opencli operate open https://books.toscrape.com", + "opencli operate eval \"document.querySelector('a[href*=science]')?.click();'clicked'\"", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.product_pod h3 a')].slice(0,3).map(a=>a.getAttribute('title')))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 3 + }, + "set": "test" + }, + { + "name": "bench-quotes-author", + "steps": [ + "opencli operate open https://quotes.toscrape.com", + "opencli operate eval \"document.querySelector('.author + a, a[href*=author]')?.click();'clicked'\"", + "opencli operate eval \"document.querySelector('.author-description, .author-details p')?.textContent?.slice(0,100)\"" + ], + "judge": { + "type": "nonEmpty" + }, + "set": "test" + }, + { + "name": "bench-ddg-images", + "steps": [ + "opencli operate open https://duckduckgo.com", + "opencli operate eval \"document.querySelector('input[name=q]').value='sunset';document.querySelector('form').submit();'submitted'\"", + "opencli operate wait time 3", + "opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a], .result__a')].slice(0,3).map(a=>a.textContent))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 3 + }, + "set": "test", + "note": "index may vary" + }, + { + "name": "bench-httpbin-headers", + "steps": [ + "opencli operate open https://httpbin.org/headers", + "opencli operate eval \"JSON.parse(document.body.innerText).headers['User-Agent']\"" + ], + "judge": { + "type": "nonEmpty" + }, + "set": "test" + }, + { + "name": "bench-jsonapi-todo", + "steps": [ + "opencli operate open https://jsonplaceholder.typicode.com/todos", + "opencli operate eval \"JSON.stringify(JSON.parse(document.body.innerText).slice(0,5).map(t=>({id:t.id,title:t.title,completed:t.completed})))\"" + ], + "judge": { + "type": "arrayMinLength", + "minLength": 5 + }, + "set": "test" + } +] \ No newline at end of file diff --git a/autoresearch/eval-browse.ts b/autoresearch/eval-browse.ts new file mode 100644 index 00000000..81388e4c --- /dev/null +++ b/autoresearch/eval-browse.ts @@ -0,0 +1,185 @@ +#!/usr/bin/env npx tsx +/** + * Layer 1: Deterministic Browse Command Testing + * + * Runs predefined opencli operate command sequences against real websites. + * No LLM involved โ€” tests command reliability only. + * + * Usage: + * npx tsx autoresearch/eval-browse.ts # Run all tasks + * npx tsx autoresearch/eval-browse.ts --task hn-top5 # Run single task + */ + +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TASKS_FILE = join(__dirname, 'browse-tasks.json'); +const RESULTS_DIR = join(__dirname, 'results'); +const BASELINE_FILE = join(__dirname, 'baseline-browse.txt'); + +interface BrowseTask { + name: string; + steps: string[]; + judge: JudgeCriteria; + set?: 'test'; + note?: string; +} + +type JudgeCriteria = + | { type: 'contains'; value: string } + | { type: 'arrayMinLength'; minLength: number } + | { type: 'nonEmpty' } + | { type: 'matchesPattern'; pattern: string }; + +interface TaskResult { + name: string; + passed: boolean; + duration: number; + error?: string; + set: 'train' | 'test'; +} + +function judge(criteria: JudgeCriteria, output: string): boolean { + try { + switch (criteria.type) { + case 'contains': + return output.toLowerCase().includes(criteria.value.toLowerCase()); + case 'arrayMinLength': { + try { + const arr = JSON.parse(output); + if (Array.isArray(arr)) return arr.length >= criteria.minLength; + } catch { /* not JSON array */ } + return false; + } + case 'nonEmpty': + return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined'; + case 'matchesPattern': + return new RegExp(criteria.pattern).test(output); + default: + return false; + } + } catch { + return false; + } +} + +function runCommand(cmd: string): string { + try { + return execSync(cmd, { + cwd: join(__dirname, '..'), + timeout: 30000, + encoding: 'utf-8', + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch (err: any) { + return err.stdout?.trim() ?? ''; + } +} + +function runTask(task: BrowseTask): TaskResult { + const start = Date.now(); + let lastOutput = ''; + + try { + for (const step of task.steps) { + lastOutput = runCommand(step); + } + + const passed = judge(task.judge, lastOutput); + + return { + name: task.name, + passed, + duration: Date.now() - start, + error: passed ? undefined : `Output: ${lastOutput.slice(0, 100)}`, + set: task.set === 'test' ? 'test' : 'train', + }; + } catch (err: any) { + return { + name: task.name, + passed: false, + duration: Date.now() - start, + error: err.message?.slice(0, 100), + set: task.set === 'test' ? 'test' : 'train', + }; + } +} + +function main() { + const args = process.argv.slice(2); + const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null; + + const allTasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8')); + const tasks = singleTask ? allTasks.filter(t => t.name === singleTask) : allTasks; + + if (tasks.length === 0) { + console.error(`Task "${singleTask}" not found.`); + process.exit(1); + } + + console.log(`\n๐Ÿ”ฌ Layer 1: Browse Commands โ€” ${tasks.length} tasks\n`); + + const results: TaskResult[] = []; + + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i]; + process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`); + + const result = runTask(task); + results.push(result); + + const icon = result.passed ? 'โœ“' : 'โœ—'; + console.log(` ${icon} (${(result.duration / 1000).toFixed(1)}s)`); + + // Close browser between tasks for clean state + if (i < tasks.length - 1) { + try { runCommand('opencli operate close'); } catch { /* ignore */ } + } + } + + // Final close + try { runCommand('opencli operate close'); } catch { /* ignore */ } + + // Summary + const trainResults = results.filter(r => r.set === 'train'); + const testResults = results.filter(r => r.set === 'test'); + const totalPassed = results.filter(r => r.passed).length; + const trainPassed = trainResults.filter(r => r.passed).length; + const testPassed = testResults.filter(r => r.passed).length; + const totalDuration = results.reduce((s, r) => s + r.duration, 0); + + console.log(`\n${'โ”€'.repeat(50)}`); + console.log(` Score: ${totalPassed}/${results.length} (train: ${trainPassed}/${trainResults.length}, test: ${testPassed}/${testResults.length})`); + console.log(` Time: ${Math.round(totalDuration / 60000)}min`); + + const failures = results.filter(r => !r.passed); + if (failures.length > 0) { + console.log(`\n Failures:`); + for (const f of failures) { + console.log(` โœ— ${f.name}: ${f.error ?? 'unknown'}`); + } + } + console.log(''); + + // Save result + mkdirSync(RESULTS_DIR, { recursive: true }); + const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('browse-')).length; + const roundNum = String(existing + 1).padStart(3, '0'); + const resultPath = join(RESULTS_DIR, `browse-${roundNum}.json`); + writeFileSync(resultPath, JSON.stringify({ + timestamp: new Date().toISOString(), + score: `${totalPassed}/${results.length}`, + trainScore: `${trainPassed}/${trainResults.length}`, + testScore: `${testPassed}/${testResults.length}`, + duration: `${Math.round(totalDuration / 60000)}min`, + tasks: results, + }, null, 2), 'utf-8'); + console.log(` Results saved to: ${resultPath}`); + console.log(`\nSCORE=${totalPassed}/${results.length}`); +} + +main(); diff --git a/autoresearch/eval-skill.ts b/autoresearch/eval-skill.ts new file mode 100644 index 00000000..016fed16 --- /dev/null +++ b/autoresearch/eval-skill.ts @@ -0,0 +1,248 @@ +#!/usr/bin/env npx tsx +/** + * Layer 2: Claude Code Skill E2E Testing (LLM Judge) + * + * Spawns Claude Code with the opencli-operate skill. Claude Code + * completes the task using browse commands AND judges its own result. + * + * Task format: YAML with judge_context (multi-criteria, like Browser Use) + * + * Usage: + * npx tsx autoresearch/eval-skill.ts # Run all + * npx tsx autoresearch/eval-skill.ts --task hn-top5 # Run single + */ + +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TASKS_FILE = join(__dirname, 'skill-tasks.yaml'); +const RESULTS_DIR = join(__dirname, 'results'); +const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-operate', 'SKILL.md'); + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface SkillTask { + name: string; + task: string; + url?: string; + judge_context: string[]; + max_steps?: number; +} + +interface TaskResult { + name: string; + passed: boolean; + duration: number; + cost: number; + explanation: string; +} + +// โ”€โ”€ Task Definitions (inline, to avoid YAML dependency) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const TASKS: SkillTask[] = [ + // Extract + { name: "extract-title-example", task: "Extract the main heading text from this page", url: "https://example.com", judge_context: ["Output must contain 'Example Domain'"] }, + { name: "extract-paragraph-wiki", task: "Extract the first paragraph of the JavaScript article", url: "https://en.wikipedia.org/wiki/JavaScript", judge_context: ["Output must mention 'programming language'", "Output must contain actual paragraph text, not just the title"] }, + { name: "extract-github-stars", task: "Find the number of stars on this repository", url: "https://github.com/browser-use/browser-use", judge_context: ["Output must contain a number (the star count)"] }, + { name: "extract-npm-downloads", task: "Find the weekly download count for this package", url: "https://www.npmjs.com/package/zod", judge_context: ["Output must contain a number (weekly downloads)"] }, + + // List extraction + { name: "list-hn-top5", task: "Extract the top 5 stories with their titles", url: "https://news.ycombinator.com", judge_context: ["Output must contain 5 story titles", "Each title must be an actual HN story, not made up"] }, + { name: "list-books-5", task: "Extract the first 5 books with their title and price", url: "https://books.toscrape.com", judge_context: ["Output must contain 5 books", "Each book must have a title and a price"] }, + { name: "list-quotes-3", task: "Extract the first 3 quotes with their text and author", url: "https://quotes.toscrape.com", judge_context: ["Output must contain 3 quotes", "Each quote must have text and an author name"] }, + { name: "list-github-trending", task: "Extract the top 3 trending repositories with name and description", url: "https://github.com/trending", judge_context: ["Output must contain 3 repositories", "Each must have a repo name"] }, + { name: "list-jsonplaceholder", task: "Extract the first 5 posts with their title", url: "https://jsonplaceholder.typicode.com/posts", judge_context: ["Output must contain 5 posts", "Each post must have a title"] }, + + // Search + { name: "search-ddg", task: "Search for 'TypeScript tutorial' and extract the first 3 result titles", url: "https://duckduckgo.com", judge_context: ["The agent must type a search query", "Output must contain at least 3 search result titles"] }, + { name: "search-npm", task: "Search for 'react' and extract the top 3 package names", url: "https://www.npmjs.com", judge_context: ["The agent must search for 'react'", "Output must contain at least 3 package names"] }, + { name: "search-wiki", task: "Search for 'Rust programming language' and extract the first sentence of the article", url: "https://en.wikipedia.org", judge_context: ["The agent must search and navigate to the article", "Output must mention 'programming language'"] }, + + // Navigation + { name: "nav-click-link", task: "Click the 'More information...' link and extract the heading of the new page", url: "https://example.com", judge_context: ["The agent must click a link", "Output must contain 'IANA' or reference the new page"] }, + { name: "nav-click-hn", task: "Click on the first story link and tell me the title of the page you land on", url: "https://news.ycombinator.com", judge_context: ["The agent must click a story link", "Output must contain the title of the destination page"] }, + { name: "nav-go-back", task: "Click the 'More information...' link, then go back, and tell me the heading of the original page", url: "https://example.com", judge_context: ["The agent must click a link then go back", "Output must contain 'Example Domain'"] }, + { name: "nav-multi-step", task: "Click the Next page link at the bottom, then extract the first quote from page 2", url: "https://quotes.toscrape.com", judge_context: ["The agent must navigate to page 2", "Output must contain a quote from page 2"] }, + + // Scroll + { name: "scroll-footer", task: "Scroll to the bottom and extract the footer text", url: "https://quotes.toscrape.com", judge_context: ["The agent must scroll down", "Output must contain footer or bottom-of-page content"] }, + { name: "scroll-pagination", task: "Find the pagination info at the bottom of the page", url: "https://books.toscrape.com", judge_context: ["Output must contain page number or pagination info"] }, + + // Form + { name: "form-fill-basic", task: "Fill the Customer Name with 'OpenCLI' and Telephone with '555-0100'. Do not submit.", url: "https://httpbin.org/forms/post", judge_context: ["The agent must type 'OpenCLI' into a name field", "The agent must type '555-0100' into a phone field", "The form must NOT be submitted"] }, + { name: "form-radio", task: "Select the 'Medium' pizza size option. Do not submit.", url: "https://httpbin.org/forms/post", judge_context: ["The agent must select a radio button for Medium size"] }, + { name: "form-login", task: "Fill the username with 'testuser' and password with 'testpass'. Do not submit.", url: "https://the-internet.herokuapp.com/login", judge_context: ["The agent must fill the username field", "The agent must fill the password field", "The form must NOT be submitted"] }, + + // Complex + { name: "complex-wiki-toc", task: "Extract the table of contents headings", url: "https://en.wikipedia.org/wiki/JavaScript", judge_context: ["Output must contain at least 5 section headings from the table of contents"] }, + { name: "complex-books-detail", task: "Click on the first book and extract its title and price from the detail page", url: "https://books.toscrape.com", judge_context: ["The agent must click on a book", "Output must contain the book title", "Output must contain a price"] }, + { name: "complex-quotes-page2", task: "Navigate to page 2 and extract the first 3 quotes with their authors", url: "https://quotes.toscrape.com", judge_context: ["The agent must navigate to page 2", "Output must contain 3 quotes with authors"] }, + { name: "complex-multi-extract", task: "Extract both the page title and the first paragraph text", url: "https://en.wikipedia.org/wiki/TypeScript", judge_context: ["Output must contain 'TypeScript'", "Output must contain actual paragraph text"] }, + + // Bench (harder, real-world) + { name: "bench-reddit", task: "Extract the titles of the top 5 posts", url: "https://old.reddit.com", judge_context: ["Output must contain 5 post titles", "Titles must be actual Reddit posts"] }, + { name: "bench-imdb", task: "Find the year and rating of The Matrix", url: "https://www.imdb.com/title/tt0133093/", judge_context: ["Output must contain '1999'", "Output must contain a rating number"] }, + { name: "bench-github-profile", task: "Extract the bio and number of public repositories", url: "https://github.com/torvalds", judge_context: ["Output must contain bio text or 'Linux'", "Output must contain a number for repos"] }, + { name: "bench-httpbin", task: "Extract the User-Agent header shown on this page", url: "https://httpbin.org/headers", judge_context: ["Output must contain a User-Agent string"] }, + { name: "bench-jsonapi-todo", task: "Extract the first 5 todo items with their title and completion status", url: "https://jsonplaceholder.typicode.com/todos", judge_context: ["Output must contain 5 todo items", "Each must have a title and completed status"] }, + + // Codex form (the real test) + { name: "codex-form-fill", task: "Fill the basic information using 'opencli' as the identity (first name=open, last name=cli, email=opencli@example.com, GitHub username=opencli). Do NOT submit the form.", url: "https://openai.com/form/codex-for-oss/", judge_context: ["The agent must fill the first name field", "The agent must fill the last name field", "The agent must fill the email field", "The form must NOT be submitted"], max_steps: 15 }, +]; + +// โ”€โ”€ Run Task โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function runSkillTask(task: SkillTask): TaskResult { + const start = Date.now(); + const skillContent = readFileSync(SKILL_PATH, 'utf-8'); + const urlPart = task.url ? ` Start URL: ${task.url}` : ''; + const criteria = task.judge_context.map((c, i) => `${i + 1}. ${c}`).join('\n'); + + const prompt = `Complete this browser task using opencli operate commands: + +TASK: ${task.task}${urlPart} + +After completing the task, evaluate your own result against these criteria: +${criteria} + +At the very end of your response, output a JSON verdict on its own line: +{"success": true/false, "explanation": "brief explanation"} + +Always close the browser with 'opencli operate close' when done.`; + + try { + const output = execSync( + `claude -p --dangerously-skip-permissions --allowedTools "Bash(opencli:*)" --system-prompt ${JSON.stringify(skillContent)} --output-format json --no-session-persistence ${JSON.stringify(prompt)}`, + { + cwd: join(__dirname, '..'), + timeout: (task.max_steps ?? 10) * 15_000, + encoding: 'utf-8', + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + + const duration = Date.now() - start; + + // Parse Claude Code output + let resultText = ''; + let cost = 0; + try { + const parsed = JSON.parse(output); + resultText = parsed.result ?? output; + cost = parsed.total_cost_usd ?? 0; + } catch { + resultText = output; + } + + // Extract verdict JSON from the result + const verdict = extractVerdict(resultText); + + return { + name: task.name, + passed: verdict.success, + duration, + cost, + explanation: verdict.explanation, + }; + } catch (err: any) { + return { + name: task.name, + passed: false, + duration: Date.now() - start, + cost: 0, + explanation: (err.stdout ?? err.message ?? 'timeout or crash').slice(0, 200), + }; + } +} + +function extractVerdict(text: string): { success: boolean; explanation: string } { + // Try to find {"success": ...} JSON in the text + const jsonMatches = text.match(/\{"success"\s*:\s*(true|false)\s*,\s*"explanation"\s*:\s*"([^"]*)"\s*\}/g); + if (jsonMatches) { + const last = jsonMatches[jsonMatches.length - 1]; + try { + return JSON.parse(last); + } catch { /* fall through */ } + } + + // Fallback: check for success indicators in text + const lower = text.toLowerCase(); + if (lower.includes('"success": true') || lower.includes('"success":true')) { + return { success: true, explanation: 'Parsed success from output' }; + } + if (lower.includes('"success": false') || lower.includes('"success":false')) { + return { success: false, explanation: 'Parsed failure from output' }; + } + + // Final fallback: assume failure if we can't parse + return { success: false, explanation: 'Could not parse verdict from output' }; +} + +// โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function main() { + const args = process.argv.slice(2); + const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null; + const tasks = singleTask ? TASKS.filter(t => t.name === singleTask) : TASKS; + + if (tasks.length === 0) { + console.error(`Task "${singleTask}" not found. Available: ${TASKS.map(t => t.name).join(', ')}`); + process.exit(1); + } + + console.log(`\n๐Ÿ”ฌ Layer 2: Skill E2E (LLM Judge) โ€” ${tasks.length} tasks\n`); + + const results: TaskResult[] = []; + + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i]; + process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`); + + const result = runSkillTask(task); + results.push(result); + + const icon = result.passed ? 'โœ“' : 'โœ—'; + const costStr = result.cost > 0 ? `, $${result.cost.toFixed(2)}` : ''; + console.log(` ${icon} (${Math.round(result.duration / 1000)}s${costStr})`); + } + + // Summary + const totalPassed = results.filter(r => r.passed).length; + const totalCost = results.reduce((s, r) => s + r.cost, 0); + const totalDuration = results.reduce((s, r) => s + r.duration, 0); + + console.log(`\n${'โ”€'.repeat(50)}`); + console.log(` Score: ${totalPassed}/${results.length} (${Math.round(totalPassed / results.length * 100)}%)`); + console.log(` Cost: $${totalCost.toFixed(2)}`); + console.log(` Time: ${Math.round(totalDuration / 60000)}min`); + + const failures = results.filter(r => !r.passed); + if (failures.length > 0) { + console.log(`\n Failures:`); + for (const f of failures) { + console.log(` โœ— ${f.name}: ${f.explanation}`); + } + } + console.log(''); + + // Save + mkdirSync(RESULTS_DIR, { recursive: true }); + const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('skill-')).length; + const roundNum = String(existing + 1).padStart(3, '0'); + const resultPath = join(RESULTS_DIR, `skill-${roundNum}.json`); + writeFileSync(resultPath, JSON.stringify({ + timestamp: new Date().toISOString(), + score: `${totalPassed}/${results.length}`, + totalCost, + duration: `${Math.round(totalDuration / 60000)}min`, + tasks: results, + }, null, 2), 'utf-8'); + console.log(` Results saved to: ${resultPath}`); + console.log(`\nSCORE=${totalPassed}/${results.length}`); +} + +main(); diff --git a/autoresearch/run-browse.sh b/autoresearch/run-browse.sh new file mode 100755 index 00000000..ca003c29 --- /dev/null +++ b/autoresearch/run-browse.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Layer 1: Deterministic browse command testing +set -e +cd "$(dirname "$0")/.." +echo "Building OpenCLI..." +npm run build > /dev/null 2>&1 +echo "Build OK" +echo "" +npx tsx autoresearch/eval-browse.ts "$@" diff --git a/autoresearch/run-skill.sh b/autoresearch/run-skill.sh new file mode 100755 index 00000000..6d039988 --- /dev/null +++ b/autoresearch/run-skill.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Layer 2: Claude Code skill E2E testing +set -e +cd "$(dirname "$0")/.." +echo "Building OpenCLI..." +npm run build > /dev/null 2>&1 +echo "Build OK" +echo "" +npx tsx autoresearch/eval-skill.ts "$@" diff --git a/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md b/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md new file mode 100644 index 00000000..208e6047 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md @@ -0,0 +1,144 @@ +# Browse Skill Testing Design + +Two-layer testing framework for `opencli browse` commands and the +Claude Code skill integration. + +## Goal + +Verify that `opencli browse` works reliably on real websites and that +Claude Code can use the skill to complete browser tasks end-to-end. + +## Architecture + +``` +autoresearch/ +โ”œโ”€โ”€ browse-tasks.json โ† 59 task definitions with browse command sequences +โ”œโ”€โ”€ eval-browse.ts โ† Layer 1: deterministic browse command testing +โ”œโ”€โ”€ eval-skill.ts โ† Layer 2: Claude Code skill E2E testing +โ”œโ”€โ”€ run-browse.sh โ† Launch Layer 1 +โ”œโ”€โ”€ run-skill.sh โ† Launch Layer 2 +โ”œโ”€โ”€ baseline-browse.txt โ† Layer 1 best score +โ”œโ”€โ”€ baseline-skill.txt โ† Layer 2 best score +โ””โ”€โ”€ results/ โ† Per-run results (gitignored) +``` + +## Layer 1: Deterministic Browse Command Testing + +Tests `opencli browse` commands directly on real websites. No LLM +involved โ€” pure command reliability testing. + +### How It Works + +Each task defines a sequence of browse commands and a judge for the +last command's output: + +```json +{ + "name": "hn-top-stories", + "steps": [ + "opencli browse open https://news.ycombinator.com", + "opencli browse eval \"JSON.stringify([...document.querySelectorAll('.titleline a')].slice(0,5).map(a=>({title:a.textContent,url:a.href})))\"" + ], + "judge": { "type": "arrayMinLength", "minLength": 5 } +} +``` + +### Execution + +```bash +./autoresearch/run-browse.sh +``` + +- Runs all 59 tasks serially +- Each task: execute steps โ†’ judge last step output โ†’ pass/fail +- `opencli browse close` between tasks for clean state +- Expected: ~2 minutes, $0 cost + +### Task Categories + +| Category | Count | Example | +|----------|-------|---------| +| extract | 9 | Open page, eval JS to extract data | +| list | 10 | Open page, eval JS to extract array | +| search | 6 | Open, type query, keys Enter, eval results | +| nav | 7 | Open, click link, eval new page title | +| scroll | 5 | Open, scroll, eval footer/hidden content | +| form | 6 | Open, type into fields, eval field values | +| complex | 6 | Multi-step: open โ†’ click โ†’ navigate โ†’ extract | +| bench | 10 | Test set (various) | + +## Layer 2: Claude Code Skill E2E Testing + +Spawns Claude Code with the opencli-operate skill to complete tasks +autonomously using browse commands. + +### How It Works + +```bash +claude -p \ + --system-prompt "$(cat skills/opencli-operate/SKILL.md)" \ + --dangerously-skip-permissions \ + --allowedTools "Bash(opencli:*)" \ + --output-format json \ + "็”จ opencli browse ๅฎŒๆˆไปปๅŠก๏ผšExtract the top 5 stories from Hacker News with title and score. Start URL: https://news.ycombinator.com" +``` + +### Execution + +```bash +./autoresearch/run-skill.sh +``` + +- Runs all 59 tasks serially +- Each task: spawn Claude Code โ†’ it uses browse commands autonomously โ†’ judge output +- Expected: ~20 minutes, ~$5-10 + +### Judge + +Both layers use the same judge types: + +| Type | Description | +|------|-------------| +| `contains` | Output contains a substring | +| `arrayMinLength` | Output is an array with โ‰ฅ N items | +| `arrayFieldsPresent` | Array items have required fields | +| `nonEmpty` | Output is non-empty | +| `matchesPattern` | Output matches a regex | + +## Output Format + +``` +๐Ÿ”ฌ Layer 1: Browse Commands โ€” 59 tasks + + [1/59] extract-title-example... โœ“ (0.5s) + [2/59] hn-top-stories... โœ“ (1.2s) + ... + + Score: 55/59 (93%) + Time: 2min + Cost: $0 + +๐Ÿ”ฌ Layer 2: Skill E2E โ€” 59 tasks + + [1/59] extract-title-example... โœ“ (8s, $0.01) + [2/59] hn-top-stories... โœ“ (15s, $0.08) + ... + + Score: 52/59 (88%) + Time: 20min + Cost: $6.50 +``` + +## Constraints + +- All 59 tasks run on real websites (no mocks) +- Layer 1: zero LLM cost, ~2 min +- Layer 2: ~$5-10 LLM cost, ~20 min +- Results saved to `autoresearch/results/` (gitignored) +- Baselines tracked in `baseline-browse.txt` and `baseline-skill.txt` + +## Success Criteria + +- Layer 1 โ‰ฅ 90% (browse commands work on real sites) +- Layer 2 โ‰ฅ 85% (Claude Code can use skill effectively) +- Both layers cover all 8 task categories diff --git a/extension/dist/background.js b/extension/dist/background.js deleted file mode 100644 index 686e5d17..00000000 --- a/extension/dist/background.js +++ /dev/null @@ -1,681 +0,0 @@ -const DAEMON_PORT = 19825; -const DAEMON_HOST = "localhost"; -const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; -const WS_RECONNECT_BASE_DELAY = 2e3; -const WS_RECONNECT_MAX_DELAY = 5e3; - -const attached = /* @__PURE__ */ new Set(); -const BLANK_PAGE$1 = "data:text/html,"; -const FOREIGN_EXTENSION_URL_PREFIX = "chrome-extension://"; -const ATTACH_RECOVERY_DELAY_MS = 120; -function isDebuggableUrl$1(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === BLANK_PAGE$1; -} -async function removeForeignExtensionEmbeds(tabId) { - const tab = await chrome.tabs.get(tabId); - if (!tab.url || !tab.url.startsWith("http://") && !tab.url.startsWith("https://")) { - return { removed: 0 }; - } - if (!chrome.scripting?.executeScript) return { removed: 0 }; - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - args: [`${FOREIGN_EXTENSION_URL_PREFIX}${chrome.runtime.id}/`], - func: (ownExtensionPrefix) => { - const extensionPrefix = "chrome-extension://"; - const selectors = ["iframe", "frame", "embed", "object"]; - const visitedRoots = /* @__PURE__ */ new Set(); - const roots = [document]; - let removed = 0; - while (roots.length > 0) { - const root = roots.pop(); - if (!root || visitedRoots.has(root)) continue; - visitedRoots.add(root); - for (const selector of selectors) { - const nodes = root.querySelectorAll(selector); - for (const node of nodes) { - const src = node.getAttribute("src") || node.getAttribute("data") || ""; - if (!src.startsWith(extensionPrefix) || src.startsWith(ownExtensionPrefix)) continue; - node.remove(); - removed++; - } - } - const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); - let current = walker.nextNode(); - while (current) { - const element = current; - if (element.shadowRoot) roots.push(element.shadowRoot); - current = walker.nextNode(); - } - } - return { removed }; - } - }); - return result?.result ?? { removed: 0 }; - } catch { - return { removed: 0 }; - } -} -function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} -async function tryAttach(tabId) { - await chrome.debugger.attach({ tabId }, "1.3"); -} -async function ensureAttached(tabId) { - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - attached.delete(tabId); - throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); - } - } catch (e) { - if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; - attached.delete(tabId); - throw new Error(`Tab ${tabId} no longer exists`); - } - if (attached.has(tabId)) { - try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression: "1", - returnByValue: true - }); - return; - } catch { - attached.delete(tabId); - } - } - try { - await tryAttach(tabId); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const hint = msg.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering โ€” try disabling other extensions" : ""; - if (msg.includes("chrome-extension://")) { - const recoveryCleanup = await removeForeignExtensionEmbeds(tabId); - if (recoveryCleanup.removed > 0) { - console.warn(`[opencli] Removed ${recoveryCleanup.removed} foreign extension frame(s) after attach failure on tab ${tabId}`); - } - await delay(ATTACH_RECOVERY_DELAY_MS); - try { - await tryAttach(tabId); - } catch { - throw new Error(`attach failed: ${msg}${hint}`); - } - } else if (msg.includes("Another debugger is already attached")) { - try { - await chrome.debugger.detach({ tabId }); - } catch { - } - try { - await tryAttach(tabId); - } catch { - throw new Error(`attach failed: ${msg}${hint}`); - } - } else { - throw new Error(`attach failed: ${msg}${hint}`); - } - } - attached.add(tabId); - try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); - } catch { - } - try { - await chrome.debugger.sendCommand({ tabId }, "Debugger.enable"); - await chrome.debugger.sendCommand({ tabId }, "Debugger.setBreakpointsActive", { active: false }); - } catch { - } -} -async function evaluate(tabId, expression) { - await ensureAttached(tabId); - const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; -} -const evaluateAsync = evaluate; -async function screenshot(tabId, options = {}) { - await ensureAttached(tabId); - const format = options.format ?? "png"; - if (options.fullPage) { - const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); - const size = metrics.cssContentSize || metrics.contentSize; - if (size) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { - mobile: false, - width: Math.ceil(size.width), - height: Math.ceil(size.height), - deviceScaleFactor: 1 - }); - } - } - try { - const params = { format }; - if (format === "jpeg" && options.quality !== void 0) { - params.quality = Math.max(0, Math.min(100, options.quality)); - } - const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params); - return result.data; - } finally { - if (options.fullPage) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { - }); - } - } -} -async function setFileInputFiles(tabId, files, selector) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); - const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); - const query = selector || 'input[type="file"]'; - const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { - nodeId: doc.root.nodeId, - selector: query - }); - if (!result.nodeId) { - throw new Error(`No element found matching selector: ${query}`); - } - await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { - files, - nodeId: result.nodeId - }); -} -async function detach(tabId) { - if (!attached.has(tabId)) return; - attached.delete(tabId); - try { - await chrome.debugger.detach({ tabId }); - } catch { - } -} -function registerListeners() { - chrome.tabs.onRemoved.addListener((tabId) => { - attached.delete(tabId); - }); - chrome.debugger.onDetach.addListener((source) => { - if (source.tabId) attached.delete(source.tabId); - }); - chrome.tabs.onUpdated.addListener(async (tabId, info) => { - if (info.url && !isDebuggableUrl$1(info.url)) { - await detach(tabId); - } - }); -} - -let ws = null; -let reconnectTimer = null; -let reconnectAttempts = 0; -const _origLog = console.log.bind(console); -const _origWarn = console.warn.bind(console); -const _origError = console.error.bind(console); -function forwardLog(level, args) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - try { - const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); - ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() })); - } catch { - } -} -console.log = (...args) => { - _origLog(...args); - forwardLog("info", args); -}; -console.warn = (...args) => { - _origWarn(...args); - forwardLog("warn", args); -}; -console.error = (...args) => { - _origError(...args); - forwardLog("error", args); -}; -async function connect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); - if (!res.ok) return; - } catch { - return; - } - try { - ws = new WebSocket(DAEMON_WS_URL); - } catch { - scheduleReconnect(); - return; - } - ws.onopen = () => { - console.log("[opencli] Connected to daemon"); - reconnectAttempts = 0; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); - }; - ws.onmessage = async (event) => { - try { - const command = JSON.parse(event.data); - const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); - } catch (err) { - console.error("[opencli] Message handling error:", err); - } - }; - ws.onclose = () => { - console.log("[opencli] Disconnected from daemon"); - ws = null; - scheduleReconnect(); - }; - ws.onerror = () => { - ws?.close(); - }; -} -const MAX_EAGER_ATTEMPTS = 6; -function scheduleReconnect() { - if (reconnectTimer) return; - reconnectAttempts++; - if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; - const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connect(); - }, delay); -} -const automationSessions = /* @__PURE__ */ new Map(); -const WINDOW_IDLE_TIMEOUT = 3e4; -function getWorkspaceKey(workspace) { - return workspace?.trim() || "default"; -} -function resetWindowIdleTimer(workspace) { - const session = automationSessions.get(workspace); - if (!session) return; - if (session.idleTimer) clearTimeout(session.idleTimer); - session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; - session.idleTimer = setTimeout(async () => { - const current = automationSessions.get(workspace); - if (!current) return; - try { - await chrome.windows.remove(current.windowId); - console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); - } catch { - } - automationSessions.delete(workspace); - }, WINDOW_IDLE_TIMEOUT); -} -async function getAutomationWindow(workspace) { - const existing = automationSessions.get(workspace); - if (existing) { - try { - await chrome.windows.get(existing.windowId); - return existing.windowId; - } catch { - automationSessions.delete(workspace); - } - } - const win = await chrome.windows.create({ - url: BLANK_PAGE, - focused: false, - width: 1280, - height: 900, - type: "normal" - }); - const session = { - windowId: win.id, - idleTimer: null, - idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT - }; - automationSessions.set(workspace, session); - console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`); - resetWindowIdleTimer(workspace); - await new Promise((resolve) => setTimeout(resolve, 200)); - return session.windowId; -} -chrome.windows.onRemoved.addListener((windowId) => { - for (const [workspace, session] of automationSessions.entries()) { - if (session.windowId === windowId) { - console.log(`[opencli] Automation window closed (${workspace})`); - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - } - } -}); -let initialized = false; -function initialize() { - if (initialized) return; - initialized = true; - chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); - registerListeners(); - void connect(); - console.log("[opencli] OpenCLI extension initialized"); -} -chrome.runtime.onInstalled.addListener(() => { - initialize(); -}); -chrome.runtime.onStartup.addListener(() => { - initialize(); -}); -chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "keepalive") void connect(); -}); -chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type === "getStatus") { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null - }); - } - return false; -}); -async function handleCommand(cmd) { - const workspace = getWorkspaceKey(cmd.workspace); - resetWindowIdleTimer(workspace); - try { - switch (cmd.action) { - case "exec": - return await handleExec(cmd, workspace); - case "navigate": - return await handleNavigate(cmd, workspace); - case "tabs": - return await handleTabs(cmd, workspace); - case "cookies": - return await handleCookies(cmd); - case "screenshot": - return await handleScreenshot(cmd, workspace); - case "close-window": - return await handleCloseWindow(cmd, workspace); - case "sessions": - return await handleSessions(cmd); - case "set-file-input": - return await handleSetFileInput(cmd, workspace); - default: - return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; - } - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } -} -const BLANK_PAGE = "data:text/html,"; -function isDebuggableUrl(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === BLANK_PAGE; -} -function isSafeNavigationUrl(url) { - return url.startsWith("http://") || url.startsWith("https://"); -} -function normalizeUrlForComparison(url) { - if (!url) return ""; - try { - const parsed = new URL(url); - if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") { - parsed.port = ""; - } - const pathname = parsed.pathname === "/" ? "" : parsed.pathname; - return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; - } catch { - return url; - } -} -function isTargetUrl(currentUrl, targetUrl) { - return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); -} -async function resolveTabId(tabId, workspace) { - if (tabId !== void 0) { - try { - const tab = await chrome.tabs.get(tabId); - const session = automationSessions.get(workspace); - const matchesSession = session ? tab.windowId === session.windowId : false; - if (isDebuggableUrl(tab.url) && matchesSession) return tabId; - if (session && !matchesSession) { - console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`); - } else if (!isDebuggableUrl(tab.url)) { - console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); - } - } catch { - console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); - } - } - const windowId = await getAutomationWindow(workspace); - const tabs = await chrome.tabs.query({ windowId }); - const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); - if (debuggableTab?.id) return debuggableTab.id; - const reuseTab = tabs.find((t) => t.id); - if (reuseTab?.id) { - await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); - await new Promise((resolve) => setTimeout(resolve, 300)); - try { - const updated = await chrome.tabs.get(reuseTab.id); - if (isDebuggableUrl(updated.url)) return reuseTab.id; - console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); - } catch { - } - } - const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true }); - if (!newTab.id) throw new Error("Failed to create tab in automation window"); - return newTab.id; -} -async function listAutomationTabs(workspace) { - const session = automationSessions.get(workspace); - if (!session) return []; - try { - return await chrome.tabs.query({ windowId: session.windowId }); - } catch { - automationSessions.delete(workspace); - return []; - } -} -async function listAutomationWebTabs(workspace) { - const tabs = await listAutomationTabs(workspace); - return tabs.filter((tab) => isDebuggableUrl(tab.url)); -} -async function handleExec(cmd, workspace) { - if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const data = await evaluateAsync(tabId, cmd.code); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } -} -async function handleNavigate(cmd, workspace) { - if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" }; - if (!isSafeNavigationUrl(cmd.url)) { - return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - const beforeTab = await chrome.tabs.get(tabId); - const beforeNormalized = normalizeUrlForComparison(beforeTab.url); - const targetUrl = cmd.url; - if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) { - return { - id: cmd.id, - ok: true, - data: { title: beforeTab.title, url: beforeTab.url, tabId, timedOut: false } - }; - } - await detach(tabId); - await chrome.tabs.update(tabId, { url: targetUrl }); - let timedOut = false; - await new Promise((resolve) => { - let settled = false; - let checkTimer = null; - let timeoutTimer = null; - const finish = () => { - if (settled) return; - settled = true; - chrome.tabs.onUpdated.removeListener(listener); - if (checkTimer) clearTimeout(checkTimer); - if (timeoutTimer) clearTimeout(timeoutTimer); - resolve(); - }; - const isNavigationDone = (url) => { - return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; - }; - const listener = (id, info, tab2) => { - if (id !== tabId) return; - if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) { - finish(); - } - }; - chrome.tabs.onUpdated.addListener(listener); - checkTimer = setTimeout(async () => { - try { - const currentTab = await chrome.tabs.get(tabId); - if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) { - finish(); - } - } catch { - } - }, 100); - timeoutTimer = setTimeout(() => { - timedOut = true; - console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); - finish(); - }, 15e3); - }); - const tab = await chrome.tabs.get(tabId); - return { - id: cmd.id, - ok: true, - data: { title: tab.title, url: tab.url, tabId, timedOut } - }; -} -async function handleTabs(cmd, workspace) { - switch (cmd.op) { - case "list": { - const tabs = await listAutomationWebTabs(workspace); - const data = tabs.map((t, i) => ({ - index: i, - tabId: t.id, - url: t.url, - title: t.title, - active: t.active - })); - return { id: cmd.id, ok: true, data }; - } - case "new": { - if (cmd.url && !isSafeNavigationUrl(cmd.url)) { - return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; - } - const windowId = await getAutomationWindow(workspace); - const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); - return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; - } - case "close": { - if (cmd.index !== void 0) { - const tabs = await listAutomationWebTabs(workspace); - const target = tabs[cmd.index]; - if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; - await chrome.tabs.remove(target.id); - await detach(target.id); - return { id: cmd.id, ok: true, data: { closed: target.id } }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - await chrome.tabs.remove(tabId); - await detach(tabId); - return { id: cmd.id, ok: true, data: { closed: tabId } }; - } - case "select": { - if (cmd.index === void 0 && cmd.tabId === void 0) - return { id: cmd.id, ok: false, error: "Missing index or tabId" }; - if (cmd.tabId !== void 0) { - const session = automationSessions.get(workspace); - let tab; - try { - tab = await chrome.tabs.get(cmd.tabId); - } catch { - return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} no longer exists` }; - } - if (!session || tab.windowId !== session.windowId) { - return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} is not in the automation window` }; - } - await chrome.tabs.update(cmd.tabId, { active: true }); - return { id: cmd.id, ok: true, data: { selected: cmd.tabId } }; - } - const tabs = await listAutomationWebTabs(workspace); - const target = tabs[cmd.index]; - if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; - await chrome.tabs.update(target.id, { active: true }); - return { id: cmd.id, ok: true, data: { selected: target.id } }; - } - default: - return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` }; - } -} -async function handleCookies(cmd) { - if (!cmd.domain && !cmd.url) { - return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" }; - } - const details = {}; - if (cmd.domain) details.domain = cmd.domain; - if (cmd.url) details.url = cmd.url; - const cookies = await chrome.cookies.getAll(details); - const data = cookies.map((c) => ({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - secure: c.secure, - httpOnly: c.httpOnly, - expirationDate: c.expirationDate - })); - return { id: cmd.id, ok: true, data }; -} -async function handleScreenshot(cmd, workspace) { - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const data = await screenshot(tabId, { - format: cmd.format, - quality: cmd.quality, - fullPage: cmd.fullPage - }); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } -} -async function handleCloseWindow(cmd, workspace) { - const session = automationSessions.get(workspace); - if (session) { - try { - await chrome.windows.remove(session.windowId); - } catch { - } - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - } - return { id: cmd.id, ok: true, data: { closed: true } }; -} -async function handleSetFileInput(cmd, workspace) { - if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) { - return { id: cmd.id, ok: false, error: "Missing or empty files array" }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - await setFileInputFiles(tabId, cmd.files, cmd.selector); - return { id: cmd.id, ok: true, data: { count: cmd.files.length } }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } -} -async function handleSessions(cmd) { - const now = Date.now(); - const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ - workspace, - windowId: session.windowId, - tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, - idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) - }))); - return { id: cmd.id, ok: true, data }; -} diff --git a/extension/package-lock.json b/extension/package-lock.json index dfc34964..2288e01c 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencli-extension", - "version": "1.5.4", + "version": "1.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencli-extension", - "version": "1.5.4", + "version": "1.5.5", "devDependencies": { "@types/chrome": "^0.0.287", "typescript": "^5.7.0", diff --git a/extension/src/background.ts b/extension/src/background.ts index 5a8cabb2..36e3861b 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -250,6 +250,8 @@ async function handleCommand(cmd: Command): Promise { return await handleScreenshot(cmd, workspace); case 'close-window': return await handleCloseWindow(cmd, workspace); + case 'cdp': + return await handleCdp(cmd, workspace); case 'sessions': return await handleSessions(cmd); case 'set-file-input': @@ -269,12 +271,12 @@ async function handleCommand(cmd: Command): Promise { // โ”€โ”€โ”€ Action handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** Internal blank page used when no user URL is provided. */ -const BLANK_PAGE = 'data:text/html,'; +const BLANK_PAGE = 'about:blank'; -/** Check if a URL can be attached via CDP โ€” only allow http(s) and our internal blank page. */ +/** Check if a URL can be attached via CDP โ€” only allow http(s) and blank pages. */ function isDebuggableUrl(url?: string): boolean { if (!url) return true; // empty/undefined = tab still loading, allow it - return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE; + return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:'); } /** Check if a URL is safe for user-facing navigation (http/https only). */ @@ -387,7 +389,8 @@ async function handleExec(cmd: Command, workspace: string): Promise { if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' }; const tabId = await resolveTabId(cmd.tabId, workspace); try { - const data = await executor.evaluateAsync(tabId, cmd.code); + const aggressive = workspace.startsWith('operate:'); + const data = await executor.evaluateAsync(tabId, cmd.code, aggressive); return { id: cmd.id, ok: true, data }; } catch (err) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; @@ -578,6 +581,50 @@ async function handleScreenshot(cmd: Command, workspace: string): Promise { + if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: 'Missing cdpMethod' }; + if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) { + return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` }; + } + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const aggressive = workspace.startsWith('operate:'); + await executor.ensureAttached(tabId, aggressive); + const data = await chrome.debugger.sendCommand( + { tabId }, + cmd.cdpMethod, + cmd.cdpParams ?? {}, + ); + return { id: cmd.id, ok: true, data }; + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + async function handleCloseWindow(cmd: Command, workspace: string): Promise { const session = automationSessions.get(workspace); if (session) { diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index f9956d40..83c0e2f7 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -8,79 +8,13 @@ const attached = new Set(); -/** Internal blank page used when no user URL is provided. */ -const BLANK_PAGE = 'data:text/html,'; -const FOREIGN_EXTENSION_URL_PREFIX = 'chrome-extension://'; -const ATTACH_RECOVERY_DELAY_MS = 120; - -/** Check if a URL can be attached via CDP โ€” only allow http(s) and our internal blank page. */ +/** Check if a URL can be attached via CDP โ€” only allow http(s) and blank pages. */ function isDebuggableUrl(url?: string): boolean { if (!url) return true; // empty/undefined = tab still loading, allow it - return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE; + return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:'); } -type CleanupResult = { removed: number }; - -async function removeForeignExtensionEmbeds(tabId: number): Promise { - const tab = await chrome.tabs.get(tabId); - if (!tab.url || (!tab.url.startsWith('http://') && !tab.url.startsWith('https://'))) { - return { removed: 0 }; - } - if (!chrome.scripting?.executeScript) return { removed: 0 }; - - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - args: [`${FOREIGN_EXTENSION_URL_PREFIX}${chrome.runtime.id}/`], - func: (ownExtensionPrefix: string) => { - const extensionPrefix = 'chrome-extension://'; - const selectors = ['iframe', 'frame', 'embed', 'object']; - const visitedRoots = new Set(); - const roots: Array = [document]; - let removed = 0; - - while (roots.length > 0) { - const root = roots.pop(); - if (!root || visitedRoots.has(root)) continue; - visitedRoots.add(root); - - for (const selector of selectors) { - const nodes = root.querySelectorAll(selector); - for (const node of nodes) { - const src = node.getAttribute('src') || node.getAttribute('data') || ''; - if (!src.startsWith(extensionPrefix) || src.startsWith(ownExtensionPrefix)) continue; - node.remove(); - removed++; - } - } - - const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); - let current = walker.nextNode(); - while (current) { - const element = current as Element & { shadowRoot?: ShadowRoot | null }; - if (element.shadowRoot) roots.push(element.shadowRoot); - current = walker.nextNode(); - } - } - - return { removed }; - }, - }); - return result?.result ?? { removed: 0 }; - } catch { - return { removed: 0 }; - } -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function tryAttach(tabId: number): Promise { - await chrome.debugger.attach({ tabId }, '1.3'); -} - -async function ensureAttached(tabId: number): Promise { +export async function ensureAttached(tabId: number, aggressiveRetry: boolean = false): Promise { // Verify the tab URL is debuggable before attempting attach try { const tab = await chrome.tabs.get(tabId); @@ -109,35 +43,47 @@ async function ensureAttached(tabId: number): Promise { } } - try { - await tryAttach(tabId); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - const hint = msg.includes('chrome-extension://') - ? '. Tip: another Chrome extension may be interfering โ€” try disabling other extensions' - : ''; - if (msg.includes('chrome-extension://')) { - const recoveryCleanup = await removeForeignExtensionEmbeds(tabId); - if (recoveryCleanup.removed > 0) { - console.warn(`[opencli] Removed ${recoveryCleanup.removed} foreign extension frame(s) after attach failure on tab ${tabId}`); - } - await delay(ATTACH_RECOVERY_DELAY_MS); - try { - await tryAttach(tabId); - } catch { - throw new Error(`attach failed: ${msg}${hint}`); - } - } else if (msg.includes('Another debugger is already attached')) { + // Retry attach up to 3 times โ€” other extensions (1Password, Playwright MCP Bridge) + // can temporarily interfere with chrome.debugger. A short delay usually resolves it. + // Normal commands: 2 retries, 500ms delay (fast fail for non-operate use) + // Operate commands: 5 retries, 1500ms delay (aggressive, tolerates extension interference) + const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; + const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; + let lastError = ''; + + for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) { + try { + // Force detach first to clear any stale state from other extensions try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ } - try { - await tryAttach(tabId); - } catch { - throw new Error(`attach failed: ${msg}${hint}`); + await chrome.debugger.attach({ tabId }, '1.3'); + lastError = ''; + break; // Success + } catch (e: unknown) { + lastError = e instanceof Error ? e.message : String(e); + if (attempt < MAX_ATTACH_RETRIES) { + console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); + // Re-verify tab URL before retrying (it may have changed) + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl(tab.url)) { + lastError = `Tab URL changed to ${tab.url} during retry`; + break; // Don't retry if URL became un-debuggable + } + } catch { + lastError = `Tab ${tabId} no longer exists`; + break; + } } - } else { - throw new Error(`attach failed: ${msg}${hint}`); } } + + if (lastError) { + const hint = lastError.includes('chrome-extension://') + ? '. Tip: another Chrome extension may be interfering โ€” try disabling other extensions' + : ''; + throw new Error(`attach failed: ${lastError}${hint}`); + } attached.add(tabId); try { @@ -145,40 +91,47 @@ async function ensureAttached(tabId: number): Promise { } catch { // Some pages may not need explicit enable } - - // Disable breakpoints so that `debugger;` statements in page code don't - // pause execution. Anti-bot scripts use `debugger;` traps to detect CDP โ€” - // they measure the time gap caused by the pause. Deactivating breakpoints - // makes the engine skip `debugger;` entirely, neutralising the timing - // side-channel without patching page JS. - try { - await chrome.debugger.sendCommand({ tabId }, 'Debugger.enable'); - await chrome.debugger.sendCommand({ tabId }, 'Debugger.setBreakpointsActive', { active: false }); - } catch { - // Non-fatal: best-effort hardening - } } -export async function evaluate(tabId: number, expression: string): Promise { - await ensureAttached(tabId); - - const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { - expression, - returnByValue: true, - awaitPromise: true, - }) as { - result?: { type: string; value?: unknown; description?: string; subtype?: string }; - exceptionDetails?: { exception?: { description?: string }; text?: string }; - }; +export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise { + // Retry the entire evaluate (attach + command). + // Normal: 2 retries. Operate: 3 retries (tolerates extension interference). + const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; + for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) { + try { + await ensureAttached(tabId, aggressiveRetry); + + const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }) as { + result?: { type: string; value?: unknown; description?: string; subtype?: string }; + exceptionDetails?: { exception?: { description?: string }; text?: string }; + }; + + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description + || result.exceptionDetails.text + || 'Eval error'; + throw new Error(errMsg); + } - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description - || result.exceptionDetails.text - || 'Eval error'; - throw new Error(errMsg); + return result.result?.value; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + // Only retry on attach/debugger errors, not on JS eval errors + const isAttachError = msg.includes('attach failed') || msg.includes('Debugger is not attached') + || msg.includes('chrome-extension://') || msg.includes('Target closed'); + if (isAttachError && attempt < MAX_EVAL_RETRIES) { + attached.delete(tabId); // Force re-attach on next attempt + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + throw e; + } } - - return result.result?.value; + throw new Error('evaluate: max retries exhausted'); } export const evaluateAsync = evaluate; diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 4652dab7..381761c2 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -5,7 +5,7 @@ * Everything else is just JS code sent via 'exec'. */ -export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input'; +export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp'; export interface Command { /** Unique request ID */ @@ -36,6 +36,10 @@ export interface Command { files?: string[]; /** CSS selector for file input element (set-file-input action) */ selector?: string; + /** CDP method name for 'cdp' action (e.g. 'Accessibility.getFullAXTree') */ + cdpMethod?: string; + /** CDP method params for 'cdp' action */ + cdpParams?: Record; } export interface Result { diff --git a/package-lock.json b/package-lock.json index 767a1cb5..b0cb4211 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jackwener/opencli", - "version": "1.5.8", + "version": "1.5.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackwener/opencli", - "version": "1.5.8", + "version": "1.5.9", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -196,6 +196,7 @@ "integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", @@ -411,7 +412,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" @@ -424,7 +424,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -436,7 +435,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -914,22 +912,20 @@ "license": "BSD-2-Clause" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", "dev": true, "license": "MIT", "optional": true, "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { @@ -943,9 +939,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", "cpu": [ "arm64" ], @@ -960,9 +956,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", "cpu": [ "arm64" ], @@ -977,9 +973,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", "cpu": [ "x64" ], @@ -994,9 +990,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", "cpu": [ "x64" ], @@ -1011,9 +1007,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", + "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", "cpu": [ "arm" ], @@ -1028,9 +1024,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", "cpu": [ "arm64" ], @@ -1045,9 +1041,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", "cpu": [ "arm64" ], @@ -1062,9 +1058,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", "cpu": [ "ppc64" ], @@ -1079,9 +1075,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", "cpu": [ "s390x" ], @@ -1096,9 +1092,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", "cpu": [ "x64" ], @@ -1113,9 +1109,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", "cpu": [ "x64" ], @@ -1130,9 +1126,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", "cpu": [ "arm64" ], @@ -1147,9 +1143,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", + "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", "cpu": [ "wasm32" ], @@ -1164,9 +1160,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", "cpu": [ "arm64" ], @@ -1181,9 +1177,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", "cpu": [ "x64" ], @@ -1198,9 +1194,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", + "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", "dev": true, "license": "MIT" }, @@ -1785,31 +1781,31 @@ "license": "ISC" }, "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.2", + "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1830,26 +1826,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.1.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.1", "pathe": "^2.0.3" }, "funding": { @@ -1857,14 +1853,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1873,9 +1869,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", "dev": true, "license": "MIT", "funding": { @@ -1883,15 +1879,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2168,6 +2164,7 @@ "integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.15.2", "@algolia/client-abtesting": "5.49.2", @@ -2496,6 +2493,7 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -2624,6 +2622,7 @@ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -3090,11 +3089,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3198,14 +3198,14 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", + "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@rolldown/pluginutils": "1.0.0-rc.11" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3214,21 +3214,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-x64": "1.0.0-rc.11", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" } }, "node_modules/rollup": { @@ -3483,6 +3483,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3512,6 +3513,7 @@ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3640,16 +3642,17 @@ } }, "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", + "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", + "picomatch": "^4.0.3", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", + "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "bin": { @@ -4209,6 +4212,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -4264,19 +4268,19 @@ } }, "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4287,7 +4291,7 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", + "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, @@ -4304,10 +4308,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", + "@vitest/browser-playwright": "4.1.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -4351,6 +4355,7 @@ "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", diff --git a/skills/opencli-operate/SKILL.md b/skills/opencli-operate/SKILL.md new file mode 100644 index 00000000..8f517829 --- /dev/null +++ b/skills/opencli-operate/SKILL.md @@ -0,0 +1,213 @@ +--- +name: opencli-operate +description: Make websites accessible for AI agents. Navigate, click, type, extract, wait โ€” using Chrome with existing login sessions. No LLM API key needed. +allowed-tools: Bash(opencli:*), Read, Edit, Write +--- + +# OpenCLI โ€” Make Websites Accessible for AI Agents + +Control Chrome step-by-step via CLI. Reuses existing login sessions โ€” no passwords needed. + +## Prerequisites + +```bash +opencli doctor # Verify extension + daemon connectivity +``` + +Requires: Chrome running + OpenCLI Browser Bridge extension installed. + +## Quickstart for AI Agents (1 step) + +Point your AI agent to this file. It contains everything needed to operate browsers. + +## Quickstart for Humans (3 steps) + +```bash +npm install -g @jackwener/opencli # 1. Install +# Install extension from chrome://extensions # 2. Load extension +opencli operate open https://example.com # 3. Go! +``` + +## Core Workflow + +1. **Navigate**: `opencli operate open ` +2. **Inspect**: `opencli operate state` โ†’ see elements with `[N]` indices +3. **Interact**: use indices โ€” `click`, `type`, `select`, `keys` +4. **Wait**: `opencli operate wait selector ".loaded"` or `wait text "Success"` +5. **Verify**: `opencli operate get title` or `opencli operate screenshot` +6. **Repeat**: browser stays open between commands +7. **Save**: write a TS adapter to `~/.opencli/clis//.ts` + +## Commands + +### Navigation + +```bash +opencli operate open # Open URL +opencli operate back # Go back +opencli operate scroll down # Scroll (up/down, --amount N) +opencli operate scroll up --amount 1000 +``` + +### Inspect + +```bash +opencli operate state # Elements with [N] indices +opencli operate screenshot [path.png] # Screenshot +``` + +### Get (structured data) + +```bash +opencli operate get title # Page title +opencli operate get url # Current URL +opencli operate get text # Element text content +opencli operate get value # Input/textarea value +opencli operate get html # Full page HTML +opencli operate get html --selector "h1" # Scoped HTML +opencli operate get attributes # Element attributes +``` + +### Interact + +```bash +opencli operate click # Click element [N] +opencli operate type "text" # Type into element [N] +opencli operate select "option" # Select dropdown +opencli operate keys "Enter" # Press key (Enter, Escape, Tab, Control+a) +``` + +### Wait + +```bash +opencli operate wait selector ".loaded" # Wait for element +opencli operate wait selector ".spinner" --timeout 5000 # With timeout +opencli operate wait text "Success" # Wait for text +opencli operate wait time 3 # Wait N seconds +``` + +### Extract + +```bash +opencli operate eval "document.title" +opencli operate eval "JSON.stringify([...document.querySelectorAll('h2')].map(e => e.textContent))" +``` + +### Network (API Discovery) + +```bash +opencli operate network # Show captured API requests (auto-captured since open) +opencli operate network --detail 3 # Show full response body of request #3 +opencli operate network --all # Include static resources +``` + +### Sedimentation (Save as CLI) + +```bash +opencli operate init hn/top # Generate adapter scaffold +opencli operate verify hn/top # Test the adapter +``` + +### Session + +```bash +opencli operate close # Close automation window +``` + +## Example: Extract HN Stories + +```bash +opencli operate open https://news.ycombinator.com +opencli operate state # See [1] a "Story 1", [2] a "Story 2"... +opencli operate eval "JSON.stringify([...document.querySelectorAll('.titleline a')].slice(0,5).map(a => ({title: a.textContent, url: a.href})))" +opencli operate close +``` + +## Example: Fill a Form + +```bash +opencli operate open https://httpbin.org/forms/post +opencli operate state # See [3] input "Customer Name", [4] input "Telephone" +opencli operate type 3 "OpenCLI" +opencli operate type 4 "555-0100" +opencli operate get value 3 # Verify: "OpenCLI" +opencli operate close +``` + +## Saving as Reusable CLI โ€” Complete Workflow + +### Step-by-step sedimentation flow: + +```bash +# 1. Explore the website +opencli operate open https://news.ycombinator.com +opencli operate state # Understand DOM structure + +# 2. Discover APIs (crucial for high-quality adapters) +opencli operate eval "fetch('/api/...').then(r=>r.json())" # Trigger API calls +opencli operate network # See captured API requests +opencli operate network --detail 0 # Inspect response body + +# 3. Generate scaffold +opencli operate init hn/top # Creates ~/.opencli/clis/hn/top.ts + +# 4. Edit the adapter (fill in func logic) +# - If API found: use fetch() directly (Strategy.PUBLIC or COOKIE) +# - If no API: use page.evaluate() for DOM extraction (Strategy.UI) + +# 5. Verify +opencli operate verify hn/top # Runs the adapter and shows output + +# 6. If verify fails, edit and retry +# 7. Close when done +opencli operate close +``` + +### Example adapter: + +```typescript +// ~/.opencli/clis/hn/top.ts +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'hn', + name: 'top', + description: 'Top Hacker News stories', + domain: 'news.ycombinator.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [{ name: 'limit', type: 'int', default: 5 }], + columns: ['rank', 'title', 'score', 'url'], + func: async (_page, kwargs) => { + const limit = Math.min(Math.max(1, kwargs.limit ?? 5), 50); + const resp = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json'); + const ids = await resp.json(); + return Promise.all( + ids.slice(0, limit).map(async (id: number, i: number) => { + const item = await (await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`)).json(); + return { rank: i + 1, title: item.title, score: item.score, url: item.url ?? '' }; + }) + ); + }, +}); +``` + +Save to `~/.opencli/clis//.ts` โ†’ immediately available as `opencli `. + +### Strategy Guide + +| Strategy | When | browser: | +|----------|------|----------| +| `Strategy.PUBLIC` | Public API, no auth | `false` | +| `Strategy.COOKIE` | Needs login cookies | `true` | +| `Strategy.UI` | Direct DOM interaction | `true` | + +**Always prefer API over UI** โ€” if you discovered an API during browsing, use `fetch()` directly. + +## Troubleshooting + +| Error | Fix | +|-------|-----| +| "Browser not connected" | Run `opencli doctor` | +| "attach failed: chrome-extension://" | Disable 1Password temporarily | +| Element not found | `opencli operate scroll down` then `opencli operate state` | diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index c373ca00..774ade96 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -20,7 +20,7 @@ function generateId(): string { export interface DaemonCommand { id: string; - action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input'; + action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp'; tabId?: number; code?: string; workspace?: string; @@ -31,10 +31,13 @@ export interface DaemonCommand { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean; + /** Local file paths for set-file-input action */ files?: string[]; /** CSS selector for file input element (set-file-input action) */ selector?: string; + cdpMethod?: string; + cdpParams?: Record; } export interface DaemonResult { diff --git a/src/browser/page.ts b/src/browser/page.ts index 1274e61b..489d627f 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -179,6 +179,53 @@ export class Page extends BasePage { throw new Error('setFileInput returned no count โ€” command may not be supported by the extension'); } } + + async cdp(method: string, params: Record = {}): Promise { + return sendCommand('cdp', { + cdpMethod: method, + cdpParams: params, + ...this._cmdOpts(), + }); + } + + async nativeClick(x: number, y: number): Promise { + await this.cdp('Input.dispatchMouseEvent', { + type: 'mousePressed', + x, y, + button: 'left', + clickCount: 1, + }); + await this.cdp('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x, y, + button: 'left', + clickCount: 1, + }); + } + + async nativeType(text: string): Promise { + // Use Input.insertText for reliable Unicode/CJK text insertion + await this.cdp('Input.insertText', { text }); + } + + async nativeKeyPress(key: string, modifiers: string[] = []): Promise { + let modifierFlags = 0; + for (const mod of modifiers) { + if (mod === 'Alt') modifierFlags |= 1; + if (mod === 'Ctrl') modifierFlags |= 2; + if (mod === 'Meta') modifierFlags |= 4; + if (mod === 'Shift') modifierFlags |= 8; + } + await this.cdp('Input.dispatchKeyEvent', { + type: 'keyDown', + key, + modifiers: modifierFlags, + }); + await this.cdp('Input.dispatchKeyEvent', { + type: 'keyUp', + key, + modifiers: modifierFlags, + }); + } } -// (End of file) diff --git a/src/cli.ts b/src/cli.ts index d26b2971..230740e5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,13 @@ import { registerAllCommands } from './commanderAdapter.js'; import { EXIT_CODES, getErrorMessage } from './errors.js'; import { daemonStatus, daemonStop, daemonRestart } from './commands/daemon.js'; +/** Create a browser page for operate commands. Uses 'operate' workspace for session persistence. */ +async function getOperatePage(): Promise { + const { BrowserBridge } = await import('./browser/index.js'); + const bridge = new BrowserBridge(); + return bridge.connect({ timeout: 30, workspace: 'operate:default' }); +} + export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { const program = new Command(); // enablePositionalOptions: prevents parent from consuming flags meant for subcommands; @@ -229,6 +236,391 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { console.log(renderCascadeResult(result)); }); + // โ”€โ”€ Built-in: operate (browser control for Claude Code skill) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // + // Make websites accessible for AI agents. + // All commands wrapped in operateAction() for consistent error handling. + + const operate = program + .command('operate') + .description('Browser control โ€” navigate, click, type, extract, wait (no LLM needed)'); + + /** Wrap operate actions with error handling and optional --json output */ + function operateAction(fn: (page: Awaited>, ...args: any[]) => Promise) { + return async (...args: any[]) => { + try { + const page = await getOperatePage(); + await fn(page, ...args); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('Extension not connected') || msg.includes('Daemon')) { + console.error(`Browser not connected. Run 'opencli doctor' to diagnose.`); + } else if (msg.includes('attach failed') || msg.includes('chrome-extension://')) { + console.error(`Browser attach failed โ€” another extension may be interfering. Try disabling 1Password.`); + } else { + console.error(`Error: ${msg}`); + } + process.exitCode = EXIT_CODES.GENERIC_ERROR; + } + }; + } + + // โ”€โ”€ Navigation โ”€โ”€ + + /** Network interceptor JS โ€” injected on every open/navigate to capture fetch/XHR */ + const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=50000,F=window.fetch;window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();if(window.__opencli_net.length').description('Open URL in automation window') + .action(operateAction(async (page, url) => { + await page.goto(url); + await page.wait(2); + // Auto-inject network interceptor for API discovery + try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ } + console.log(`Navigated to: ${await page.getCurrentUrl?.() ?? url}`); + })); + + operate.command('back').description('Go back in browser history') + .action(operateAction(async (page) => { + await page.evaluate('history.back()'); + await page.wait(2); + console.log('Navigated back'); + })); + + operate.command('scroll').argument('', 'up or down').option('--amount ', 'Pixels to scroll', '500') + .description('Scroll page') + .action(operateAction(async (page, direction, opts) => { + if (direction !== 'up' && direction !== 'down') { + console.error(`Invalid direction "${direction}". Use "up" or "down".`); + process.exitCode = EXIT_CODES.USAGE_ERROR; + return; + } + await page.scroll(direction, parseInt(opts.amount, 10)); + console.log(`Scrolled ${direction}`); + })); + + // โ”€โ”€ Inspect โ”€โ”€ + + operate.command('state').description('Page state: URL, title, interactive elements with [N] indices') + .action(operateAction(async (page) => { + const snapshot = await page.snapshot({ viewportExpand: 800 }); + const url = await page.getCurrentUrl?.() ?? ''; + console.log(`URL: ${url}\n`); + console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2)); + })); + + operate.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)') + .description('Take screenshot') + .action(operateAction(async (page, path) => { + if (path) { + await page.screenshot({ path }); + console.log(`Screenshot saved to: ${path}`); + } else { + console.log(await page.screenshot({ format: 'png' })); + } + })); + + // โ”€โ”€ Get commands (structured data extraction) โ”€โ”€ + + const get = operate.command('get').description('Get page properties'); + + get.command('title').description('Page title') + .action(operateAction(async (page) => { + console.log(await page.evaluate('document.title')); + })); + + get.command('url').description('Current page URL') + .action(operateAction(async (page) => { + console.log(await page.getCurrentUrl?.() ?? await page.evaluate('location.href')); + })); + + get.command('text').argument('', 'Element index').description('Element text content') + .action(operateAction(async (page, index) => { + const text = await page.evaluate(`document.querySelector('[data-opencli-ref="${index}"]')?.textContent?.trim()`); + console.log(text ?? '(empty)'); + })); + + get.command('value').argument('', 'Element index').description('Input/textarea value') + .action(operateAction(async (page, index) => { + const val = await page.evaluate(`document.querySelector('[data-opencli-ref="${index}"]')?.value`); + console.log(val ?? '(empty)'); + })); + + get.command('html').option('--selector ', 'CSS selector scope').description('Page HTML (or scoped)') + .action(operateAction(async (page, opts) => { + const sel = opts.selector ? JSON.stringify(opts.selector) : 'null'; + const html = await page.evaluate(`(${sel} ? document.querySelector(${sel})?.outerHTML : document.documentElement.outerHTML)?.slice(0, 50000)`); + console.log(html ?? '(empty)'); + })); + + get.command('attributes').argument('', 'Element index').description('Element attributes') + .action(operateAction(async (page, index) => { + const attrs = await page.evaluate(`JSON.stringify(Object.fromEntries([...document.querySelector('[data-opencli-ref="${index}"]')?.attributes].map(a=>[a.name,a.value])))`); + console.log(attrs ?? '{}'); + })); + + // โ”€โ”€ Interact โ”€โ”€ + + operate.command('click').argument('', 'Element index from state').description('Click element by index') + .action(operateAction(async (page, index) => { + await page.click(index); + console.log(`Clicked element [${index}]`); + })); + + operate.command('type').argument('', 'Element index').argument('', 'Text to type') + .description('Click element, then type text') + .action(operateAction(async (page, index, text) => { + await page.click(index); + await page.wait(0.3); + await page.typeText(index, text); + console.log(`Typed "${text}" into element [${index}]`); + })); + + operate.command('select').argument('', 'Element index of ' }; + var match = Array.from(sel.options).find(o => o.text.trim() === ${JSON.stringify(option)} || o.value === ${JSON.stringify(option)}); + if (!match) return { error: 'Option not found', available: Array.from(sel.options).map(o => o.text.trim()) }; + var setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set; + if (setter) setter.call(sel, match.value); else sel.value = match.value; + sel.dispatchEvent(new Event('input', {bubbles:true})); + sel.dispatchEvent(new Event('change', {bubbles:true})); + return { selected: match.text }; + })() + `) as { error?: string; selected?: string; available?: string[] } | null; + if (result?.error) { + console.error(`Error: ${result.error}${result.available ? ` โ€” Available: ${result.available.join(', ')}` : ''}`); + process.exitCode = EXIT_CODES.GENERIC_ERROR; + } else { + console.log(`Selected "${result?.selected}" in element [${index}]`); + } + })); + + operate.command('keys').argument('', 'Key to press (Enter, Escape, Tab, Control+a)') + .description('Press keyboard key') + .action(operateAction(async (page, key) => { + await page.pressKey(key); + console.log(`Pressed: ${key}`); + })); + + // โ”€โ”€ Wait commands โ”€โ”€ + + operate.command('wait') + .argument('', 'selector, text, or time') + .argument('[value]', 'CSS selector, text string, or seconds') + .option('--timeout ', 'Timeout in milliseconds', '10000') + .description('Wait for selector, text, or time (e.g. wait selector ".loaded", wait text "Success", wait time 3)') + .action(operateAction(async (page, type, value, opts) => { + const timeout = parseInt(opts.timeout, 10); + if (type === 'time') { + const seconds = parseFloat(value ?? '2'); + await page.wait(seconds); + console.log(`Waited ${seconds}s`); + } else if (type === 'selector') { + if (!value) { console.error('Missing CSS selector'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; } + await page.wait({ selector: value, timeout: timeout / 1000 }); + console.log(`Element "${value}" appeared`); + } else if (type === 'text') { + if (!value) { console.error('Missing text'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; } + await page.wait({ text: value, timeout: timeout / 1000 }); + console.log(`Text "${value}" appeared`); + } else { + console.error(`Unknown wait type "${type}". Use: selector, text, or time`); + process.exitCode = EXIT_CODES.USAGE_ERROR; + } + })); + + // โ”€โ”€ Extract โ”€โ”€ + + operate.command('eval').argument('', 'JavaScript code').description('Execute JS in page context, return result') + .action(operateAction(async (page, js) => { + const result = await page.evaluate(js); + if (typeof result === 'string') console.log(result); + else console.log(JSON.stringify(result, null, 2)); + })); + + // โ”€โ”€ Network (API discovery) โ”€โ”€ + + operate.command('network') + .option('--detail ', 'Show full response body of request at index') + .option('--all', 'Show all requests including static resources') + .description('Show captured network requests (auto-captured since last open)') + .action(operateAction(async (page, opts) => { + const requests = await page.evaluate(`(function(){ + var reqs = window.__opencli_net || []; + return JSON.stringify(reqs); + })()`) as string; + + let items: Array<{ url: string; method: string; status: number; size: number; ct: string; body: unknown }> = []; + try { items = JSON.parse(requests); } catch { console.log('No network data captured. Run "operate open " first.'); return; } + + if (items.length === 0) { console.log('No requests captured.'); return; } + + // Filter out static resources unless --all + if (!opts.all) { + items = items.filter(r => + (r.ct?.includes('json') || r.ct?.includes('xml') || r.ct?.includes('text/plain')) && + !/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) && + !/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url) + ); + } + + if (opts.detail !== undefined) { + const idx = parseInt(opts.detail, 10); + const req = items[idx]; + if (!req) { console.error(`Request #${idx} not found. ${items.length} requests available.`); process.exitCode = EXIT_CODES.USAGE_ERROR; return; } + console.log(`${req.method} ${req.url}`); + console.log(`Status: ${req.status} | Size: ${req.size} | Type: ${req.ct}`); + console.log('---'); + console.log(typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2)); + } else { + console.log(`Captured ${items.length} API requests:\n`); + items.forEach((r, i) => { + const bodyPreview = r.body ? (typeof r.body === 'string' ? r.body.slice(0, 60) : JSON.stringify(r.body).slice(0, 60)) : ''; + console.log(` [${i}] ${r.method} ${r.status} ${r.url.slice(0, 80)}`); + if (bodyPreview) console.log(` ${bodyPreview}...`); + }); + console.log(`\nUse --detail to see full response body.`); + } + })); + + // โ”€โ”€ Init (adapter scaffolding) โ”€โ”€ + + operate.command('init') + .argument('', 'Adapter name in site/command format (e.g. hn/top)') + .description('Generate adapter scaffold in ~/.opencli/clis/') + .action(async (name: string) => { + try { + const parts = name.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + console.error('Name must be site/command format (e.g. hn/top)'); + process.exitCode = EXIT_CODES.USAGE_ERROR; + return; + } + const [site, command] = parts; + if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) { + console.error('Name parts must be alphanumeric/dash/underscore only'); + process.exitCode = EXIT_CODES.USAGE_ERROR; + return; + } + + const os = await import('node:os'); + const fs = await import('node:fs'); + const path = await import('node:path'); + const dir = path.join(os.homedir(), '.opencli', 'clis', site); + const filePath = path.join(dir, `${command}.ts`); + + if (fs.existsSync(filePath)) { + console.log(`Adapter already exists: ${filePath}`); + return; + } + + // Try to detect domain from last operate session + let domain = site; + try { + const page = await getOperatePage(); + const url = await page.getCurrentUrl?.(); + if (url) { try { domain = new URL(url).hostname; } catch {} } + } catch { /* no active session */ } + + const template = `import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: '${site}', + name: '${command}', + description: '', // TODO: describe what this command does + domain: '${domain}', + strategy: Strategy.PUBLIC, // TODO: PUBLIC (no auth), COOKIE (needs login), UI (DOM interaction) + browser: false, // TODO: set true if needs browser + args: [ + { name: 'limit', type: 'int', default: 10, help: 'Number of items' }, + ], + columns: [], // TODO: field names for table output (e.g. ['title', 'score', 'url']) + func: async (page, kwargs) => { + // TODO: implement data fetching + // Prefer API calls (fetch) over browser automation + // page is available if browser: true + return []; + }, +}); +`; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, template, 'utf-8'); + console.log(`Created: ${filePath}`); + console.log(`Edit the file to implement your adapter, then run: opencli operate verify ${name}`); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = EXIT_CODES.GENERIC_ERROR; + } + }); + + // โ”€โ”€ Verify (test adapter) โ”€โ”€ + + operate.command('verify') + .argument('', 'Adapter name in site/command format (e.g. hn/top)') + .description('Execute an adapter and show results') + .action(async (name: string) => { + try { + const parts = name.split('/'); + if (parts.length !== 2) { console.error('Name must be site/command format'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; } + const [site, command] = parts; + if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) { + console.error('Name parts must be alphanumeric/dash/underscore only'); + process.exitCode = EXIT_CODES.USAGE_ERROR; + return; + } + + const { execSync } = await import('node:child_process'); + const os = await import('node:os'); + const path = await import('node:path'); + const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.ts`); + + const fs = await import('node:fs'); + if (!fs.existsSync(filePath)) { + console.error(`Adapter not found: ${filePath}`); + console.error(`Run "opencli operate init ${name}" to create it.`); + process.exitCode = EXIT_CODES.GENERIC_ERROR; + return; + } + + console.log(`๐Ÿ” Verifying ${name}...\n`); + console.log(` Loading: ${filePath}`); + + try { + const output = execSync(`node dist/main.js ${site} ${command} --limit 3`, { + cwd: path.join(path.dirname(import.meta.url.replace('file://', '')), '..'), + timeout: 30000, + encoding: 'utf-8', + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + console.log(` Executing: opencli ${site} ${command} --limit 3\n`); + console.log(output); + console.log(`\n โœ“ Adapter works!`); + } catch (err: any) { + console.log(` Executing: opencli ${site} ${command} --limit 3\n`); + if (err.stdout) console.log(err.stdout); + if (err.stderr) console.error(err.stderr.slice(0, 500)); + console.log(`\n โœ— Adapter failed. Fix the code and try again.`); + process.exitCode = EXIT_CODES.GENERIC_ERROR; + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = EXIT_CODES.GENERIC_ERROR; + } + }); + + // โ”€โ”€ Session โ”€โ”€ + + operate.command('close').description('Close the automation window') + .action(operateAction(async (page) => { + await page.closeWindow?.(); + console.log('Automation window closed'); + })); + // โ”€โ”€ Built-in: doctor / completion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ program diff --git a/src/discovery.ts b/src/discovery.ts index 0483ccf2..024d8e08 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -76,6 +76,28 @@ export async function ensureUserCliCompatShims(baseDir: string = USER_OPENCLI_DI `${JSON.stringify({ name: 'opencli-user-runtime', private: true, type: 'module' }, null, 2)}\n`, ), ]); + + // Create node_modules/@jackwener/opencli symlink so user TS CLIs can import + // from '@jackwener/opencli/registry' (the package export). + // This is needed because ~/.opencli/clis/ is outside opencli's node_modules tree. + const opencliRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const symlinkDir = path.join(baseDir, 'node_modules', '@jackwener'); + const symlinkPath = path.join(symlinkDir, 'opencli'); + try { + // Only recreate if symlink is missing or points to wrong target + let needsUpdate = true; + try { + const existing = await fs.promises.readlink(symlinkPath); + if (existing === opencliRoot) needsUpdate = false; + } catch { /* doesn't exist */ } + if (needsUpdate) { + await fs.promises.mkdir(symlinkDir, { recursive: true }); + try { await fs.promises.unlink(symlinkPath); } catch { /* doesn't exist */ } + await fs.promises.symlink(opencliRoot, symlinkPath, 'dir'); + } + } catch { + // Non-fatal: npm-linked installs or permission issues may prevent this + } } /** diff --git a/src/doctor.ts b/src/doctor.ts index fe854c7f..a89b46b1 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -25,6 +25,7 @@ export type ConnectivityResult = { durationMs: number; }; + export type DoctorReport = { cliVersion?: string; daemonRunning: boolean; @@ -93,7 +94,6 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise; /** Returns the active tab ID, or undefined if not yet resolved. */ getActiveTabId?(): number | undefined; + /** Send a raw CDP command via chrome.debugger passthrough. */ + cdp?(method: string, params?: Record): Promise; + /** Click at native coordinates via CDP Input.dispatchMouseEvent. */ + nativeClick?(x: number, y: number): Promise; + /** Type text via CDP Input.insertText. */ + nativeType?(text: string): Promise; + /** Press a key via CDP Input.dispatchKeyEvent. */ + nativeKeyPress?(key: string, modifiers?: string[]): Promise; }