commit 0daffeb8c2c3a283ad24ec6e5592cc65fefa5a83 Author: 王性驊 Date: Mon Mar 23 17:24:33 2026 +0800 first commit diff --git a/BROWSER.md b/BROWSER.md new file mode 100644 index 0000000..b024cdd --- /dev/null +++ b/BROWSER.md @@ -0,0 +1,271 @@ +# Browser — technical details + +This document covers the command reference and internals of gstack's headless browser. + +## Command reference + +| Category | Commands | What for | +|----------|----------|----------| +| Navigate | `goto`, `back`, `forward`, `reload`, `url` | Get to a page | +| Read | `text`, `html`, `links`, `forms`, `accessibility` | Extract content | +| Snapshot | `snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o] [-C]` | Get refs, diff, annotate | +| Interact | `click`, `fill`, `select`, `hover`, `type`, `press`, `scroll`, `wait`, `viewport`, `upload` | Use the page | +| Inspect | `js`, `eval`, `css`, `attrs`, `is`, `console`, `network`, `dialog`, `cookies`, `storage`, `perf` | Debug and verify | +| Visual | `screenshot [--viewport] [--clip x,y,w,h] [sel\|@ref] [path]`, `pdf`, `responsive` | See what Claude sees | +| Compare | `diff ` | Spot differences between environments | +| Dialogs | `dialog-accept [text]`, `dialog-dismiss` | Control alert/confirm/prompt handling | +| Tabs | `tabs`, `tab`, `newtab`, `closetab` | Multi-page workflows | +| Cookies | `cookie-import`, `cookie-import-browser` | Import cookies from file or real browser | +| Multi-step | `chain` (JSON from stdin) | Batch commands in one call | +| Handoff | `handoff [reason]`, `resume` | Switch to visible Chrome for user takeover | + +All selector arguments accept CSS selectors, `@e` refs after `snapshot`, or `@c` refs after `snapshot -C`. 50+ commands total plus cookie import. + +## How it works + +gstack's browser is a compiled CLI binary that talks to a persistent local Chromium daemon over HTTP. The CLI is a thin client — it reads a state file, sends a command, and prints the response to stdout. The server does the real work via [Playwright](https://playwright.dev/). + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Claude Code │ +│ │ +│ "browse goto https://staging.myapp.com" │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ HTTP POST ┌──────────────┐ │ +│ │ browse │ ──────────────── │ Bun HTTP │ │ +│ │ CLI │ localhost:rand │ server │ │ +│ │ │ Bearer token │ │ │ +│ │ compiled │ ◄────────────── │ Playwright │──── Chromium │ +│ │ binary │ plain text │ API calls │ (headless) │ +│ └──────────┘ └──────────────┘ │ +│ ~1ms startup persistent daemon │ +│ auto-starts on first call │ +│ auto-stops after 30 min idle │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Lifecycle + +1. **First call**: CLI checks `.gstack/browse.json` (in the project root) for a running server. None found — it spawns `bun run browse/src/server.ts` in the background. The server launches headless Chromium via Playwright, picks a random port (10000-60000), generates a bearer token, writes the state file, and starts accepting HTTP requests. This takes ~3 seconds. + +2. **Subsequent calls**: CLI reads the state file, sends an HTTP POST with the bearer token, prints the response. ~100-200ms round trip. + +3. **Idle shutdown**: After 30 minutes with no commands, the server shuts down and cleans up the state file. Next call restarts it automatically. + +4. **Crash recovery**: If Chromium crashes, the server exits immediately (no self-healing — don't hide failure). The CLI detects the dead server on the next call and starts a fresh one. + +### Key components + +``` +browse/ +├── src/ +│ ├── cli.ts # Thin client — reads state file, sends HTTP, prints response +│ ├── server.ts # Bun.serve HTTP server — routes commands to Playwright +│ ├── browser-manager.ts # Chromium lifecycle — launch, tabs, ref map, crash handling +│ ├── snapshot.ts # Accessibility tree → @ref assignment → Locator map + diff/annotate/-C +│ ├── read-commands.ts # Non-mutating commands (text, html, links, js, css, is, dialog, etc.) +│ ├── write-commands.ts # Mutating commands (click, fill, select, upload, dialog-accept, etc.) +│ ├── meta-commands.ts # Server management, chain, diff, snapshot routing +│ ├── cookie-import-browser.ts # Decrypt + import cookies from real Chromium browsers +│ ├── cookie-picker-routes.ts # HTTP routes for interactive cookie picker UI +│ ├── cookie-picker-ui.ts # Self-contained HTML/CSS/JS for cookie picker +│ └── buffers.ts # CircularBuffer + console/network/dialog capture +├── test/ # Integration tests + HTML fixtures +└── dist/ + └── browse # Compiled binary (~58MB, Bun --compile) +``` + +### The snapshot system + +The browser's key innovation is ref-based element selection, built on Playwright's accessibility tree API: + +1. `page.locator(scope).ariaSnapshot()` returns a YAML-like accessibility tree +2. The snapshot parser assigns refs (`@e1`, `@e2`, ...) to each element +3. For each ref, it builds a Playwright `Locator` (using `getByRole` + nth-child) +4. The ref-to-Locator map is stored on `BrowserManager` +5. Later commands like `click @e3` look up the Locator and call `locator.click()` + +No DOM mutation. No injected scripts. Just Playwright's native accessibility API. + +**Ref staleness detection:** SPAs can mutate the DOM without navigation (React router, tab switches, modals). When this happens, refs collected from a previous `snapshot` may point to elements that no longer exist. To handle this, `resolveRef()` runs an async `count()` check before using any ref — if the element count is 0, it throws immediately with a message telling the agent to re-run `snapshot`. This fails fast (~5ms) instead of waiting for Playwright's 30-second action timeout. + +**Extended snapshot features:** +- `--diff` (`-D`): Stores each snapshot as a baseline. On the next `-D` call, returns a unified diff showing what changed. Use this to verify that an action (click, fill, etc.) actually worked. +- `--annotate` (`-a`): Injects temporary overlay divs at each ref's bounding box, takes a screenshot with ref labels visible, then removes the overlays. Use `-o ` to control the output path. +- `--cursor-interactive` (`-C`): Scans for non-ARIA interactive elements (divs with `cursor:pointer`, `onclick`, `tabindex>=0`) using `page.evaluate`. Assigns `@c1`, `@c2`... refs with deterministic `nth-child` CSS selectors. These are elements the ARIA tree misses but users can still click. + +### Screenshot modes + +The `screenshot` command supports four modes: + +| Mode | Syntax | Playwright API | +|------|--------|----------------| +| Full page (default) | `screenshot [path]` | `page.screenshot({ fullPage: true })` | +| Viewport only | `screenshot --viewport [path]` | `page.screenshot({ fullPage: false })` | +| Element crop | `screenshot "#sel" [path]` or `screenshot @e3 [path]` | `locator.screenshot()` | +| Region clip | `screenshot --clip x,y,w,h [path]` | `page.screenshot({ clip })` | + +Element crop accepts CSS selectors (`.class`, `#id`, `[attr]`) or `@e`/`@c` refs from `snapshot`. Auto-detection: `@e`/`@c` prefix = ref, `.`/`#`/`[` prefix = CSS selector, `--` prefix = flag, everything else = output path. + +Mutual exclusion: `--clip` + selector and `--viewport` + `--clip` both throw errors. Unknown flags (e.g. `--bogus`) also throw. + +### Authentication + +Each server session generates a random UUID as a bearer token. The token is written to the state file (`.gstack/browse.json`) with chmod 600. Every HTTP request must include `Authorization: Bearer `. This prevents other processes on the machine from controlling the browser. + +### Console, network, and dialog capture + +The server hooks into Playwright's `page.on('console')`, `page.on('response')`, and `page.on('dialog')` events. All entries are kept in O(1) circular buffers (50,000 capacity each) and flushed to disk asynchronously via `Bun.write()`: + +- Console: `.gstack/browse-console.log` +- Network: `.gstack/browse-network.log` +- Dialog: `.gstack/browse-dialog.log` + +The `console`, `network`, and `dialog` commands read from the in-memory buffers, not disk. + +### User handoff + +When the headless browser can't proceed (CAPTCHA, MFA, complex auth), `handoff` opens a visible Chrome window at the exact same page with all cookies, localStorage, and tabs preserved. The user solves the problem manually, then `resume` returns control to the agent with a fresh snapshot. + +```bash +$B handoff "Stuck on CAPTCHA at login page" # opens visible Chrome +# User solves CAPTCHA... +$B resume # returns to headless with fresh snapshot +``` + +The browser auto-suggests `handoff` after 3 consecutive failures. State is fully preserved across the switch — no re-login needed. + +### Dialog handling + +Dialogs (alert, confirm, prompt) are auto-accepted by default to prevent browser lockup. The `dialog-accept` and `dialog-dismiss` commands control this behavior. For prompts, `dialog-accept ` provides the response text. All dialogs are logged to the dialog buffer with type, message, and action taken. + +### JavaScript execution (`js` and `eval`) + +`js` runs a single expression, `eval` runs a JS file. Both support `await` — expressions containing `await` are automatically wrapped in an async context: + +```bash +$B js "await fetch('/api/data').then(r => r.json())" # works +$B js "document.title" # also works (no wrapping needed) +$B eval my-script.js # file with await works too +``` + +For `eval` files, single-line files return the expression value directly. Multi-line files need explicit `return` when using `await`. Comments containing "await" don't trigger wrapping. + +### Multi-workspace support + +Each workspace gets its own isolated browser instance with its own Chromium process, tabs, cookies, and logs. State is stored in `.gstack/` inside the project root (detected via `git rev-parse --show-toplevel`). + +| Workspace | State file | Port | +|-----------|------------|------| +| `/code/project-a` | `/code/project-a/.gstack/browse.json` | random (10000-60000) | +| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (10000-60000) | + +No port collisions. No shared state. Each project is fully isolated. + +### Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `BROWSE_PORT` | 0 (random 10000-60000) | Fixed port for the HTTP server (debug override) | +| `BROWSE_IDLE_TIMEOUT` | 1800000 (30 min) | Idle shutdown timeout in ms | +| `BROWSE_STATE_FILE` | `.gstack/browse.json` | Path to state file (CLI passes to server) | +| `BROWSE_SERVER_SCRIPT` | auto-detected | Path to server.ts | + +### Performance + +| Tool | First call | Subsequent calls | Context overhead per call | +|------|-----------|-----------------|--------------------------| +| Chrome MCP | ~5s | ~2-5s | ~2000 tokens (schema + protocol) | +| Playwright MCP | ~3s | ~1-3s | ~1500 tokens (schema + protocol) | +| **gstack browse** | **~3s** | **~100-200ms** | **0 tokens** (plain text stdout) | + +The context overhead difference compounds fast. In a 20-command browser session, MCP tools burn 30,000-40,000 tokens on protocol framing alone. gstack burns zero. + +### Why CLI over MCP? + +MCP (Model Context Protocol) works well for remote services, but for local browser automation it adds pure overhead: + +- **Context bloat**: every MCP call includes full JSON schemas and protocol framing. A simple "get the page text" costs 10x more context tokens than it should. +- **Connection fragility**: persistent WebSocket/stdio connections drop and fail to reconnect. +- **Unnecessary abstraction**: Claude Code already has a Bash tool. A CLI that prints to stdout is the simplest possible interface. + +gstack skips all of this. Compiled binary. Plain text in, plain text out. No protocol. No schema. No connection management. + +## Acknowledgments + +The browser automation layer is built on [Playwright](https://playwright.dev/) by Microsoft. Playwright's accessibility tree API, locator system, and headless Chromium management are what make ref-based interaction possible. The snapshot system — assigning `@ref` labels to accessibility tree nodes and mapping them back to Playwright Locators — is built entirely on top of Playwright's primitives. Thank you to the Playwright team for building such a solid foundation. + +## Development + +### Prerequisites + +- [Bun](https://bun.sh/) v1.0+ +- Playwright's Chromium (installed automatically by `bun install`) + +### Quick start + +```bash +bun install # install dependencies + Playwright Chromium +bun test # run integration tests (~3s) +bun run dev # run CLI from source (no compile) +bun run build # compile to browse/dist/browse +``` + +### Dev mode vs compiled binary + +During development, use `bun run dev` instead of the compiled binary. It runs `browse/src/cli.ts` directly with Bun, so you get instant feedback without a compile step: + +```bash +bun run dev goto https://example.com +bun run dev text +bun run dev snapshot -i +bun run dev click @e3 +``` + +The compiled binary (`bun run build`) is only needed for distribution. It produces a single ~58MB executable at `browse/dist/browse` using Bun's `--compile` flag. + +### Running tests + +```bash +bun test # run all tests +bun test browse/test/commands # run command integration tests only +bun test browse/test/snapshot # run snapshot tests only +bun test browse/test/cookie-import-browser # run cookie import unit tests only +``` + +Tests spin up a local HTTP server (`browse/test/test-server.ts`) serving HTML fixtures from `browse/test/fixtures/`, then exercise the CLI commands against those pages. 203 tests across 3 files, ~15 seconds total. + +### Source map + +| File | Role | +|------|------| +| `browse/src/cli.ts` | Entry point. Reads `.gstack/browse.json`, sends HTTP to the server, prints response. | +| `browse/src/server.ts` | Bun HTTP server. Routes commands to the right handler. Manages idle timeout. | +| `browse/src/browser-manager.ts` | Chromium lifecycle — launch, tab management, ref map, crash detection. | +| `browse/src/snapshot.ts` | Parses accessibility tree, assigns `@e`/`@c` refs, builds Locator map. Handles `--diff`, `--annotate`, `-C`. | +| `browse/src/read-commands.ts` | Non-mutating commands: `text`, `html`, `links`, `js`, `css`, `is`, `dialog`, `forms`, etc. Exports `getCleanText()`. | +| `browse/src/write-commands.ts` | Mutating commands: `goto`, `click`, `fill`, `upload`, `dialog-accept`, `useragent` (with context recreation), etc. | +| `browse/src/meta-commands.ts` | Server management, chain routing, diff (DRY via `getCleanText`), snapshot delegation. | +| `browse/src/cookie-import-browser.ts` | Decrypt Chromium cookies via macOS Keychain + PBKDF2/AES-128-CBC. Auto-detects installed browsers. | +| `browse/src/cookie-picker-routes.ts` | HTTP routes for `/cookie-picker/*` — browser list, domain search, import, remove. | +| `browse/src/cookie-picker-ui.ts` | Self-contained HTML generator for the interactive cookie picker (dark theme, no frameworks). | +| `browse/src/buffers.ts` | `CircularBuffer` (O(1) ring buffer) + console/network/dialog capture with async disk flush. | + +### Deploying to the active skill + +The active skill lives at `~/.claude/skills/gstack/`. After making changes: + +1. Push your branch +2. Pull in the skill directory: `cd ~/.claude/skills/gstack && git pull` +3. Rebuild: `cd ~/.claude/skills/gstack && bun run build` + +Or copy the binary directly: `cp browse/dist/browse ~/.claude/skills/gstack/browse/dist/browse` + +### Adding a new command + +1. Add the handler in `read-commands.ts` (non-mutating) or `write-commands.ts` (mutating) +2. Register the route in `server.ts` +3. Add a test case in `browse/test/commands.test.ts` with an HTML fixture if needed +4. Run `bun test` to verify +5. Run `bun run build` to compile diff --git a/ETHOS.md b/ETHOS.md new file mode 100644 index 0000000..b056fcf --- /dev/null +++ b/ETHOS.md @@ -0,0 +1,129 @@ +# gstack Builder Ethos + +These are the principles that shape how gstack thinks, recommends, and builds. +They are injected into every workflow skill's preamble automatically. They +reflect what we believe about building software in 2026. + +--- + +## The Golden Age + +A single person with AI can now build what used to take a team of twenty. +The engineering barrier is gone. What remains is taste, judgment, and the +willingness to do the complete thing. + +This is not a prediction — it's happening right now. 10,000+ usable lines of +code per day. 100+ commits per week. Not by a team. By one person, part-time, +using the right tools. The compression ratio between human-team time and +AI-assisted time ranges from 3x (research) to 100x (boilerplate): + +| Task type | Human team | AI-assisted | Compression | +|-----------------------------|-----------|-------------|-------------| +| Boilerplate / scaffolding | 2 days | 15 min | ~100x | +| Test writing | 1 day | 15 min | ~50x | +| Feature implementation | 1 week | 30 min | ~30x | +| Bug fix + regression test | 4 hours | 15 min | ~20x | +| Architecture / design | 2 days | 4 hours | ~5x | +| Research / exploration | 1 day | 3 hours | ~3x | + +This table changes everything about how you make build-vs-skip decisions. +The last 10% of completeness that teams used to skip? It costs seconds now. + +--- + +## 1. Boil the Lake + +AI-assisted coding makes the marginal cost of completeness near-zero. When +the complete implementation costs minutes more than the shortcut — do the +complete thing. Every time. + +**Lake vs. ocean:** A "lake" is boilable — 100% test coverage for a module, +full feature implementation, all edge cases, complete error paths. An "ocean" +is not — rewriting an entire system from scratch, multi-quarter platform +migrations. Boil lakes. Flag oceans as out of scope. + +**Completeness is cheap.** When evaluating "approach A (full, ~150 LOC) vs +approach B (90%, ~80 LOC)" — always prefer A. The 70-line delta costs +seconds with AI coding. "Ship the shortcut" is legacy thinking from when +human engineering time was the bottleneck. + +**Anti-patterns:** +- "Choose B — it covers 90% with less code." (If A is 70 lines more, choose A.) +- "Let's defer tests to a follow-up PR." (Tests are the cheapest lake to boil.) +- "This would take 2 weeks." (Say: "2 weeks human / ~1 hour AI-assisted.") + +Read more: https://garryslist.org/posts/boil-the-ocean + +--- + +## 2. Search Before Building + +The 1000x engineer's first instinct is "has someone already solved this?" not +"let me design it from scratch." Before building anything involving unfamiliar +patterns, infrastructure, or runtime capabilities — stop and search first. +The cost of checking is near-zero. The cost of not checking is reinventing +something worse. + +### Three Layers of Knowledge + +There are three distinct sources of truth when building anything. Understand +which layer you're operating in: + +**Layer 1: Tried and true.** Standard patterns, battle-tested approaches, +things deeply in distribution. You probably already know these. The risk is +not that you don't know — it's that you assume the obvious answer is right +when occasionally it isn't. The cost of checking is near-zero. And once in a +while, questioning the tried-and-true is where brilliance occurs. + +**Layer 2: New and popular.** Current best practices, blog posts, ecosystem +trends. Search for these. But scrutinize what you find — humans are subject +to mania. Mr. Market is either too fearful or too greedy. The crowd can be +wrong about new things just as easily as old things. Search results are inputs +to your thinking, not answers. + +**Layer 3: First principles.** Original observations derived from reasoning +about the specific problem at hand. These are the most valuable of all. Prize +them above everything else. The best projects both avoid mistakes (don't +reinvent the wheel — Layer 1) while also making brilliant observations that +are out of distribution (Layer 3). + +### The Eureka Moment + +The most valuable outcome of searching is not finding a solution to copy. +It is: + +1. Understanding what everyone is doing and WHY (Layers 1 + 2) +2. Applying first-principles reasoning to their assumptions (Layer 3) +3. Discovering a clear reason why the conventional approach is wrong + +This is the 11 out of 10. The truly superlative projects are full of these +moments — zig while others zag. When you find one, name it. Celebrate it. +Build on it. + +**Anti-patterns:** +- Rolling a custom solution when the runtime has a built-in. (Layer 1 miss) +- Accepting blog posts uncritically in novel territory. (Layer 2 mania) +- Assuming tried-and-true is right without questioning premises. (Layer 3 blindness) + +--- + +## How They Work Together + +Boil the Lake says: **do the complete thing.** +Search Before Building says: **know what exists before you decide what to build.** + +Together: search first, then build the complete version of the right thing. +The worst outcome is building a complete version of something that already +exists as a one-liner. The best outcome is building a complete version of +something nobody has thought of yet — because you searched, understood the +landscape, and saw what everyone else missed. + +--- + +## Build for Yourself + +The best tools solve your own problem. gstack exists because its creator +wanted it. Every feature was built because it was needed, not because it +was requested. If you're building something for yourself, trust that instinct. +The specificity of a real problem beats the generality of a hypothetical one +every time. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dde5b33 --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# gstack-opencode + +Garry Tan's gstack ported to OpenCode. Complete AI engineering workflow with 27 skills. + +## What's Included + +### Sprint Skills (20) +| Skill | What it does | +|-------|-------------| +| `/office-hours` | YC Office Hours — reframes your product idea | +| `/plan-ceo-review` | CEO review — find the 10-star product | +| `/plan-eng-review` | Eng manager — lock architecture, data flow, tests | +| `/plan-design-review` | Designer review — rate each dimension 0-10 | +| `/design-consultation` | Build a design system from scratch | +| `/review` | Staff engineer — find bugs that pass CI | +| `/ship` | Release engineer — tests, push, PR | +| `/land-and-deploy` | Merge PR, CI, deploy, verify | +| `/qa` | QA lead — browser testing, fix bugs | +| `/qa-only` | QA reporter — bug report only | +| `/design-review` | Designer audit + auto-fix | +| `/canary` | SRE — post-deploy monitoring | +| `/benchmark` | Performance engineer — Core Web Vitals | +| `/document-release` | Technical writer — update docs | +| `/retro` | Eng manager — weekly retro | +| `/investigate` | Debugger — systematic root-cause | +| `/cso` | Security officer — OWASP + STRIDE | +| `/codex` | Second opinion — OpenAI Codex review | +| `/autoplan` | Auto-review pipeline | +| `/setup-deploy` | Deploy configurator | + +### Safety Skills (4) +| Skill | What it does | +|-------|-------------| +| `/careful` | Warn before destructive commands | +| `/freeze` | Lock edits to one directory | +| `/guard` | Full safety: careful + freeze | +| `/unfreeze` | Remove freeze restriction | + +### Browse Skills (8) +| Skill | What it does | +|-------|-------------| +| `/browse` | Headless Chromium — ~100ms/command | +| `/setup-browser-cookies` | Import cookies from real browser | +| `/qa` | Browser-based QA testing | +| `/design-review` | Visual audit with screenshots | +| `/canary` | Monitor deployed app | +| `/benchmark` | Performance baselines | +| `/land-and-deploy` | Post-deploy verification | +| `/setup-browser-cookies` | Session management | + +## Installation + +### Prerequisites +- [OpenCode](https://opencode.ai) +- [Bun](https://bun.sh) v1.0+ +- [Git](https://git-scm.com/) + +### Quick Install + +```bash +# Clone this repo +git clone https://github.com/your-username/gstack-opencode.git ~/gstack-opencode + +# Run setup +cd ~/gstack-opencode && ./setup + +# Add to your shell profile +echo 'source ~/gstack-opencode/bin/gstack-env.sh' >> ~/.zshrc +source ~/.zshrc +``` + +### Manual Install + +```bash +# 1. Clone gstack +git clone https://github.com/garrytan/gstack.git ~/gstack-opencode/source + +# 2. Build browse +cd ~/gstack-opencode/source +bun install +bun build --compile browse/src/cli.ts --outfile ~/gstack-opencode/browse/dist/browse + +# 3. Link skills to OpenCode +mkdir -p ~/.config/opencode/skills/gstack +ln -sf ~/gstack-opencode/skills/* ~/.config/opencode/skills/gstack/ +``` + +## Directory Structure + +``` +~/gstack-opencode/ +├── skills/ # 27 OpenCode skills +│ ├── office-hours/SKILL.md +│ ├── review/SKILL.md +│ ├── browse/SKILL.md +│ └── ... +├── browse/ # Headless browser engine +│ └── dist/browse # Compiled binary (63MB) +├── bin/ # Tool scripts +│ ├── gstack-env.sh # Environment setup +│ ├── gstack-slug # Generate repo slug +│ └── ... +├── plugin/ # OpenCode plugin +│ └── gstack-guardian.js # Safety mechanism +├── review/ # Shared assets +│ ├── checklist.md +│ └── ... +├── ETHOS.md # Builder philosophy +├── BROWSER.md # Browser command reference +├── setup # Installation script +└── README.md # This file +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `GSTACK_OPENCODE_DIR` | Path to gstack-opencode directory | +| `GSTACK_BROWSE` | Path to browse binary | + +## Safety Mechanism + +The `/careful`, `/freeze`, and `/guard` skills are implemented as an OpenCode plugin (`plugin/gstack-guardian.js`). It intercepts: + +- **Destructive commands:** `rm -rf`, `DROP TABLE`, `git push --force`, etc. +- **Freeze violations:** Edits outside the locked directory + +## Differences from Original gstack + +| Aspect | Claude Code | OpenCode | +|--------|-------------|----------| +| Hook mechanism | PreToolUse hooks | Plugin `tool.execute.before` | +| Environment vars | `${CLAUDE_SKILL_DIR}` | `${GSTACK_OPENCODE_DIR}` | +| Safety skills | Shell scripts | JavaScript plugin | +| Skill format | YAML + Markdown | YAML + Markdown (simplified) | + +## Credits + +Original gstack by [Garry Tan](https://github.com/garrytan/gstack) — MIT License + +OpenCode port by gstack-opencode contributors + +## License + +MIT diff --git a/README.zh-TW.md b/README.zh-TW.md new file mode 100644 index 0000000..b987a58 --- /dev/null +++ b/README.zh-TW.md @@ -0,0 +1,145 @@ +# gstack-opencode + +Garry Tan 的 gstack 移植至 OpenCode。完整的 AI 工程工作流程,內含 27 項技能。 + +## 內容總覽 + +### 衝刺技能(20 項) +| 技能 | 功能說明 | +|------|----------| +| `/office-hours` | YC 辦公時間 — 重新定義你的產品概念 | +| `/plan-ceo-review` | CEO 審查 — 找出 10 星級產品 | +| `/plan-eng-review` | 工程經理 — 鎖定架構、資料流、測試 | +| `/plan-design-review` | 設計師審查 — 各維度評分 0-10 | +| `/design-consultation` | 從零建立設計系統 | +| `/review` | 資深工程師 — 找出能通過 CI 的錯誤 | +| `/ship` | 發佈工程師 — 測試、推送、建立 PR | +| `/land-and-deploy` | 合併 PR、CI、部署、驗證 | +| `/qa` | QA 主管 — 瀏覽器測試、修復錯誤 | +| `/qa-only` | QA 報告員 — 僅產生錯誤報告 | +| `/design-review` | 設計師稽核 + 自動修復 | +| `/canary` | SRE — 部署後監控 | +| `/benchmark` | 效能工程師 — Core Web Vitals | +| `/document-release` | 技術文件撰寫 — 更新文件 | +| `/retro` | 工程經理 — 每週回顧 | +| `/investigate` | 除錯器 — 系統性根因分析 | +| `/cso` | 資安長 — OWASP + STRIDE | +| `/codex` | 第二意見 — OpenAI Codex 審查 | +| `/autoplan` | 自動審查流水線 | +| `/setup-deploy` | 部署設定器 | + +### 安全技能(4 項) +| 技能 | 功能說明 | +|------|----------| +| `/careful` | 破壞性指令前發出警告 | +| `/freeze` | 鎖定編輯範圍至單一目錄 | +| `/guard` | 完整安全防護:careful + freeze | +| `/unfreeze` | 解除 freeze 限制 | + +### 瀏覽技能(8 項) +| 技能 | 功能說明 | +|------|----------| +| `/browse` | 無頭 Chromium — 約 100 毫秒/指令 | +| `/setup-browser-cookies` | 從真實瀏覽器匯入 Cookie | +| `/qa` | 瀏覽器基礎的 QA 測試 | +| `/design-review` | 使用截圖進行視覺稽核 | +| `/canary` | 監控已部署的應用程式 | +| `/benchmark` | 效能基準測試 | +| `/land-and-deploy` | 部署後驗證 | +| `/setup-browser-cookies` | 工作階段管理 | + +## 安裝說明 + +### 系統需求 +- [OpenCode](https://opencode.ai) +- [Bun](https://bun.sh) v1.0+ +- [Git](https://git-scm.com/) + +### 快速安裝 + +```bash +# 複製此儲存庫 +git clone https://github.com/your-username/gstack-opencode.git ~/gstack-opencode + +# 執行安裝腳本 +cd ~/gstack-opencode && ./setup + +# 加入至你的 Shell 設定檔 +echo 'source ~/gstack-opencode/bin/gstack-env.sh' >> ~/.zshrc +source ~/.zshrc +``` + +### 手動安裝 + +```bash +# 1. 複製 gstack +git clone https://github.com/garrytan/gstack.git ~/gstack-opencode/source + +# 2. 編譯 browse +cd ~/gstack-opencode/source +bun install +bun build --compile browse/src/cli.ts --outfile ~/gstack-opencode/browse/dist/browse + +# 3. 將技能連結至 OpenCode +mkdir -p ~/.config/opencode/skills/gstack +ln -sf ~/gstack-opencode/skills/* ~/.config/opencode/skills/gstack/ +``` + +## 目錄結構 + +``` +~/gstack-opencode/ +├── skills/ # 27 項 OpenCode 技能 +│ ├── office-hours/SKILL.md +│ ├── review/SKILL.md +│ ├── browse/SKILL.md +│ └── ... +├── browse/ # 無頭瀏覽器引擎 +│ └── dist/browse # 已編譯的二進位檔(63MB) +├── bin/ # 工具指令稿 +│ ├── gstack-env.sh # 環境設定 +│ ├── gstack-slug # 產生儲存庫 slug +│ └── ... +├── plugin/ # OpenCode 外掛 +│ └── gstack-guardian.js # 安全機制 +├── review/ # 共用資產 +│ ├── checklist.md +│ └── ... +├── ETHOS.md # 建構者理念 +├── BROWSER.md # 瀏覽器指令參考 +├── setup # 安裝指令稿 +└── README.md # 本檔案 +``` + +## 環境變數 + +| 變數名稱 | 說明 | +|----------|------| +| `GSTACK_OPENCODE_DIR` | gstack-opencode 目錄的路徑 | +| `GSTACK_BROWSE` | browse 二進位檔的路徑 | + +## 安全機制 + +`/careful`、`/freeze` 和 `/guard` 技能透過 OpenCode 外掛(`plugin/gstack-guardian.js`)實作。它會攔截: + +- **破壞性指令:** `rm -rf`、`DROP TABLE`、`git push --force` 等。 +- **Freeze 違規:** 在鎖定目錄外的編輯操作。 + +## 與原版 gstack 的差異 + +| 面向 | Claude Code | OpenCode | +|------|-------------|----------| +| 鉤子機制 | PreToolUse hooks | 外掛 `tool.execute.before` | +| 環境變數 | `${CLAUDE_SKILL_DIR}` | `${GSTACK_OPENCODE_DIR}` | +| 安全技能 | Shell 指令稿 | JavaScript 外掛 | +| 技能格式 | YAML + Markdown | YAML + Markdown(簡化版) | + +## 致謝 + +原版 gstack 由 [Garry Tan](https://github.com/garrytan/gstack) 開發 — MIT 授權條款 + +OpenCode 移植由 gstack-opencode 貢獻者完成 + +## 授權條款 + +MIT \ No newline at end of file diff --git a/bin/dev-setup b/bin/dev-setup new file mode 100755 index 0000000..a5bd482 --- /dev/null +++ b/bin/dev-setup @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Set up gstack for local development — test skills from within this repo. +# +# Creates .claude/skills/gstack → (symlink to repo root) so Claude Code +# discovers skills from your working tree. Changes take effect immediately. +# +# Also copies .env from the main worktree if this is a Conductor workspace +# or git worktree (so API keys carry over automatically). +# +# Usage: bin/dev-setup # set up +# bin/dev-teardown # clean up +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# 1. Copy .env from main worktree (if we're a worktree and don't have one) +if [ ! -f "$REPO_ROOT/.env" ]; then + MAIN_WORKTREE="$(git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | head -1 | sed 's/^worktree //')" + if [ -n "$MAIN_WORKTREE" ] && [ "$MAIN_WORKTREE" != "$REPO_ROOT" ] && [ -f "$MAIN_WORKTREE/.env" ]; then + cp "$MAIN_WORKTREE/.env" "$REPO_ROOT/.env" + echo "Copied .env from main worktree ($MAIN_WORKTREE)" + fi +fi + +# 2. Install dependencies +if [ ! -d "$REPO_ROOT/node_modules" ]; then + echo "Installing dependencies..." + (cd "$REPO_ROOT" && bun install) +fi + +# 3. Create .claude/skills/ inside the repo +mkdir -p "$REPO_ROOT/.claude/skills" + +# 4. Symlink .claude/skills/gstack → repo root +# This makes setup think it's inside a real .claude/skills/ directory +GSTACK_LINK="$REPO_ROOT/.claude/skills/gstack" +if [ -L "$GSTACK_LINK" ]; then + echo "Updating existing symlink..." + rm "$GSTACK_LINK" +elif [ -d "$GSTACK_LINK" ]; then + echo "Error: .claude/skills/gstack is a real directory, not a symlink." >&2 + echo "Remove it manually if you want to use dev mode." >&2 + exit 1 +fi +ln -s "$REPO_ROOT" "$GSTACK_LINK" + +# 5. Create .agents/skills/gstack → repo root (for Codex/Gemini/Cursor) +mkdir -p "$REPO_ROOT/.agents/skills" +AGENTS_LINK="$REPO_ROOT/.agents/skills/gstack" +if [ -L "$AGENTS_LINK" ]; then + rm "$AGENTS_LINK" +elif [ -d "$AGENTS_LINK" ]; then + echo "Warning: .agents/skills/gstack is a real directory, skipping." >&2 +fi +if [ ! -e "$AGENTS_LINK" ]; then + ln -s "$REPO_ROOT" "$AGENTS_LINK" +fi + +# 6. Run setup via the symlink so it detects .claude/skills/ as its parent +"$GSTACK_LINK/setup" + +echo "" +echo "Dev mode active. Skills resolve from this working tree." +echo " .claude/skills/gstack → $REPO_ROOT" +echo " .agents/skills/gstack → $REPO_ROOT" +echo "Edit any SKILL.md and test immediately — no copy/deploy needed." +echo "" +echo "To tear down: bin/dev-teardown" diff --git a/bin/dev-teardown b/bin/dev-teardown new file mode 100755 index 0000000..dc8f742 --- /dev/null +++ b/bin/dev-teardown @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Remove local dev skill symlinks. Restores global gstack as the active install. +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +removed=() + +# ─── Clean up .claude/skills/ ───────────────────────────────── +CLAUDE_SKILLS="$REPO_ROOT/.claude/skills" +if [ -d "$CLAUDE_SKILLS" ]; then + for link in "$CLAUDE_SKILLS"/*/; do + name="$(basename "$link")" + [ "$name" = "gstack" ] && continue + if [ -L "${link%/}" ]; then + rm "${link%/}" + removed+=("claude/$name") + fi + done + + if [ -L "$CLAUDE_SKILLS/gstack" ]; then + rm "$CLAUDE_SKILLS/gstack" + removed+=("claude/gstack") + fi + + rmdir "$CLAUDE_SKILLS" 2>/dev/null || true + rmdir "$REPO_ROOT/.claude" 2>/dev/null || true +fi + +# ─── Clean up .agents/skills/ ──────────────────────────────── +AGENTS_SKILLS="$REPO_ROOT/.agents/skills" +if [ -d "$AGENTS_SKILLS" ]; then + for link in "$AGENTS_SKILLS"/*/; do + name="$(basename "$link")" + [ "$name" = "gstack" ] && continue + if [ -L "${link%/}" ]; then + rm "${link%/}" + removed+=("agents/$name") + fi + done + + if [ -L "$AGENTS_SKILLS/gstack" ]; then + rm "$AGENTS_SKILLS/gstack" + removed+=("agents/gstack") + fi + + rmdir "$AGENTS_SKILLS" 2>/dev/null || true + rmdir "$REPO_ROOT/.agents" 2>/dev/null || true +fi + +if [ ${#removed[@]} -gt 0 ]; then + echo "Removed: ${removed[*]}" +else + echo "No symlinks found." +fi +echo "Dev mode deactivated. Global gstack (~/.claude/skills/gstack) is now active." diff --git a/bin/gstack-analytics b/bin/gstack-analytics new file mode 100755 index 0000000..ad06edd --- /dev/null +++ b/bin/gstack-analytics @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# gstack-analytics — personal usage dashboard from local JSONL +# +# Usage: +# gstack-analytics # default: last 7 days +# gstack-analytics 7d # last 7 days +# gstack-analytics 30d # last 30 days +# gstack-analytics all # all time +# +# Env overrides (for testing): +# GSTACK_STATE_DIR — override ~/.gstack state directory +set -uo pipefail + +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +JSONL_FILE="$STATE_DIR/analytics/skill-usage.jsonl" + +# ─── Parse time window ─────────────────────────────────────── +WINDOW="${1:-7d}" +case "$WINDOW" in + 7d) DAYS=7; LABEL="last 7 days" ;; + 30d) DAYS=30; LABEL="last 30 days" ;; + all) DAYS=0; LABEL="all time" ;; + *) DAYS=7; LABEL="last 7 days" ;; +esac + +# ─── Check for data ────────────────────────────────────────── +if [ ! -f "$JSONL_FILE" ]; then + echo "gstack usage — no data yet" + echo "" + echo "Usage data will appear here after you use gstack skills" + echo "with telemetry enabled (gstack-config set telemetry anonymous)." + exit 0 +fi + +TOTAL_LINES="$(wc -l < "$JSONL_FILE" | tr -d ' ')" +if [ "$TOTAL_LINES" = "0" ]; then + echo "gstack usage — no data yet" + exit 0 +fi + +# ─── Filter by time window ─────────────────────────────────── +if [ "$DAYS" -gt 0 ] 2>/dev/null; then + # Calculate cutoff date + if date -v-1d +%Y-%m-%d >/dev/null 2>&1; then + # macOS date + CUTOFF="$(date -v-${DAYS}d -u +%Y-%m-%dT%H:%M:%SZ)" + else + # GNU date + CUTOFF="$(date -u -d "$DAYS days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "2000-01-01T00:00:00Z")" + fi + # Filter: skill_run events (new format) OR basic skill events (old format, no event_type) + # Old format: {"skill":"X","ts":"Y","repo":"Z"} (no event_type field) + # New format: {"event_type":"skill_run","skill":"X","ts":"Y",...} + FILTERED="$(awk -F'"' -v cutoff="$CUTOFF" ' + /"ts":"/ { + # Skip hook_fire events + if (/"event":"hook_fire"/) next + # Skip non-skill_run new-format events + if (/"event_type":"/ && !/"event_type":"skill_run"/) next + for (i=1; i<=NF; i++) { + if ($i == "ts" && $(i+1) ~ /^:/) { + ts = $(i+2) + if (ts >= cutoff) { print; break } + } + } + } + ' "$JSONL_FILE")" +else + # All time: include skill_run events + old-format basic events, exclude hook_fire + FILTERED="$(awk '/"ts":"/ && !/"event":"hook_fire"/' "$JSONL_FILE" | grep -v '"event_type":"upgrade_' 2>/dev/null || true)" +fi + +if [ -z "$FILTERED" ]; then + echo "gstack usage ($LABEL) — no skill runs found" + exit 0 +fi + +# ─── Aggregate by skill ────────────────────────────────────── +# Extract skill names and count +SKILL_COUNTS="$(echo "$FILTERED" | awk -F'"' ' + /"skill":"/ { + for (i=1; i<=NF; i++) { + if ($i == "skill" && $(i+1) ~ /^:/) { + skill = $(i+2) + counts[skill]++ + break + } + } + } + END { + for (s in counts) print counts[s], s + } +' | sort -rn)" + +# Count outcomes +TOTAL="$(echo "$FILTERED" | wc -l | tr -d ' ')" +SUCCESS="$(echo "$FILTERED" | grep -c '"outcome":"success"' || true)" +SUCCESS="${SUCCESS:-0}"; SUCCESS="$(echo "$SUCCESS" | tr -d ' \n\r\t')" +ERRORS="$(echo "$FILTERED" | grep -c '"outcome":"error"' || true)" +ERRORS="${ERRORS:-0}"; ERRORS="$(echo "$ERRORS" | tr -d ' \n\r\t')" +# Old format events have no outcome field — count them as successful +NO_OUTCOME="$(echo "$FILTERED" | grep -vc '"outcome":' || true)" +NO_OUTCOME="${NO_OUTCOME:-0}"; NO_OUTCOME="$(echo "$NO_OUTCOME" | tr -d ' \n\r\t')" +SUCCESS=$(( SUCCESS + NO_OUTCOME )) + +# Calculate success rate +if [ "$TOTAL" -gt 0 ] 2>/dev/null; then + SUCCESS_RATE=$(( SUCCESS * 100 / TOTAL )) +else + SUCCESS_RATE=100 +fi + +# ─── Calculate total duration ──────────────────────────────── +TOTAL_DURATION="$(echo "$FILTERED" | awk -F'[:,]' ' + /"duration_s"/ { + for (i=1; i<=NF; i++) { + if ($i ~ /"duration_s"/) { + val = $(i+1) + gsub(/[^0-9.]/, "", val) + if (val+0 > 0) total += val + } + } + } + END { printf "%.0f", total } +')" + +# Format duration +TOTAL_DURATION="${TOTAL_DURATION:-0}" +if [ "$TOTAL_DURATION" -ge 3600 ] 2>/dev/null; then + HOURS=$(( TOTAL_DURATION / 3600 )) + MINS=$(( (TOTAL_DURATION % 3600) / 60 )) + DUR_DISPLAY="${HOURS}h ${MINS}m" +elif [ "$TOTAL_DURATION" -ge 60 ] 2>/dev/null; then + MINS=$(( TOTAL_DURATION / 60 )) + DUR_DISPLAY="${MINS}m" +else + DUR_DISPLAY="${TOTAL_DURATION}s" +fi + +# ─── Render output ─────────────────────────────────────────── +echo "gstack usage ($LABEL)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Find max count for bar scaling +MAX_COUNT="$(echo "$SKILL_COUNTS" | head -1 | awk '{print $1}')" +BAR_WIDTH=20 + +echo "$SKILL_COUNTS" | while read -r COUNT SKILL; do + # Scale bar + if [ "$MAX_COUNT" -gt 0 ] 2>/dev/null; then + BAR_LEN=$(( COUNT * BAR_WIDTH / MAX_COUNT )) + else + BAR_LEN=1 + fi + [ "$BAR_LEN" -lt 1 ] && BAR_LEN=1 + + # Build bar + BAR="" + i=0 + while [ "$i" -lt "$BAR_LEN" ]; do + BAR="${BAR}█" + i=$(( i + 1 )) + done + + # Calculate avg duration for this skill + AVG_DUR="$(echo "$FILTERED" | awk -v skill="$SKILL" ' + index($0, "\"skill\":\"" skill "\"") > 0 { + # Extract duration_s value using split on "duration_s": + n = split($0, parts, "\"duration_s\":") + if (n >= 2) { + # parts[2] starts with the value, e.g. "142," + gsub(/[^0-9.].*/, "", parts[2]) + if (parts[2]+0 > 0) { total += parts[2]; count++ } + } + } + END { if (count > 0) printf "%.0f", total/count; else print "0" } + ')" + + # Format avg duration + if [ "$AVG_DUR" -ge 60 ] 2>/dev/null; then + AVG_DISPLAY="$(( AVG_DUR / 60 ))m" + else + AVG_DISPLAY="${AVG_DUR}s" + fi + + printf " /%-20s %s %d runs (avg %s)\n" "$SKILL" "$BAR" "$COUNT" "$AVG_DISPLAY" +done + +echo "" +echo "Success rate: ${SUCCESS_RATE}% | Errors: ${ERRORS} | Total time: ${DUR_DISPLAY}" +echo "Events: ${TOTAL} skill runs" diff --git a/bin/gstack-community-dashboard b/bin/gstack-community-dashboard new file mode 100755 index 0000000..5b7fc7e --- /dev/null +++ b/bin/gstack-community-dashboard @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# gstack-community-dashboard — community usage stats from Supabase +# +# Queries the Supabase REST API to show community-wide gstack usage: +# skill popularity, crash clusters, version distribution, retention. +# +# Env overrides (for testing): +# GSTACK_DIR — override auto-detected gstack root +# GSTACK_SUPABASE_URL — override Supabase project URL +# GSTACK_SUPABASE_ANON_KEY — override Supabase anon key +set -uo pipefail + +GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" + +# Source Supabase config if not overridden by env +if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then + . "$GSTACK_DIR/supabase/config.sh" +fi +SUPABASE_URL="${GSTACK_SUPABASE_URL:-}" +ANON_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" + +if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then + echo "gstack community dashboard" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Supabase not configured yet. The community dashboard will be" + echo "available once the gstack Supabase project is set up." + echo "" + echo "For local analytics, run: gstack-analytics" + exit 0 +fi + +# ─── Helper: query Supabase REST API ───────────────────────── +query() { + local table="$1" + local params="${2:-}" + curl -sf --max-time 10 \ + "${SUPABASE_URL}/rest/v1/${table}?${params}" \ + -H "apikey: ${ANON_KEY}" \ + -H "Authorization: Bearer ${ANON_KEY}" \ + 2>/dev/null || echo "[]" +} + +echo "gstack community dashboard" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +# ─── Weekly active installs ────────────────────────────────── +WEEK_AGO="$(date -u -v-7d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")" +if [ -n "$WEEK_AGO" ]; then + PULSE="$(curl -sf --max-time 10 \ + "${SUPABASE_URL}/functions/v1/community-pulse" \ + -H "Authorization: Bearer ${ANON_KEY}" \ + 2>/dev/null || echo '{"weekly_active":0}')" + + WEEKLY="$(echo "$PULSE" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")" + CHANGE="$(echo "$PULSE" | grep -o '"change_pct":[0-9-]*' | grep -o '[0-9-]*' || echo "0")" + + echo "Weekly active installs: ${WEEKLY}" + if [ "$CHANGE" -gt 0 ] 2>/dev/null; then + echo " Change: +${CHANGE}%" + elif [ "$CHANGE" -lt 0 ] 2>/dev/null; then + echo " Change: ${CHANGE}%" + fi + echo "" +fi + +# ─── Skill popularity (top 10) ─────────────────────────────── +echo "Top skills (last 7 days)" +echo "────────────────────────" + +# Query telemetry_events, group by skill +EVENTS="$(query "telemetry_events" "select=skill,gstack_version&event_type=eq.skill_run&event_timestamp=gte.${WEEK_AGO}&limit=1000" 2>/dev/null || echo "[]")" + +if [ "$EVENTS" != "[]" ] && [ -n "$EVENTS" ]; then + echo "$EVENTS" | grep -o '"skill":"[^"]*"' | awk -F'"' '{print $4}' | sort | uniq -c | sort -rn | head -10 | while read -r COUNT SKILL; do + printf " /%-20s %d runs\n" "$SKILL" "$COUNT" + done +else + echo " No data yet" +fi +echo "" + +# ─── Crash clusters ────────────────────────────────────────── +echo "Top crash clusters" +echo "──────────────────" + +CRASHES="$(query "crash_clusters" "select=error_class,gstack_version,total_occurrences,identified_users&limit=5" 2>/dev/null || echo "[]")" + +if [ "$CRASHES" != "[]" ] && [ -n "$CRASHES" ]; then + echo "$CRASHES" | grep -o '"error_class":"[^"]*"' | awk -F'"' '{print $4}' | head -5 | while read -r ERR; do + C="$(echo "$CRASHES" | grep -o "\"error_class\":\"$ERR\"[^}]*\"total_occurrences\":[0-9]*" | grep -o '"total_occurrences":[0-9]*' | head -1 | grep -o '[0-9]*')" + printf " %-30s %s occurrences\n" "$ERR" "${C:-?}" + done +else + echo " No crashes reported" +fi +echo "" + +# ─── Version distribution ──────────────────────────────────── +echo "Version distribution (last 7 days)" +echo "───────────────────────────────────" + +if [ "$EVENTS" != "[]" ] && [ -n "$EVENTS" ]; then + echo "$EVENTS" | grep -o '"gstack_version":"[^"]*"' | awk -F'"' '{print $4}' | sort | uniq -c | sort -rn | head -5 | while read -r COUNT VER; do + printf " v%-15s %d events\n" "$VER" "$COUNT" + done +else + echo " No data yet" +fi + +echo "" +echo "For local analytics: gstack-analytics" diff --git a/bin/gstack-config b/bin/gstack-config new file mode 100755 index 0000000..e99a940 --- /dev/null +++ b/bin/gstack-config @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# gstack-config — read/write ~/.gstack/config.yaml +# +# Usage: +# gstack-config get — read a config value +# gstack-config set — write a config value +# gstack-config list — show all config +# +# Env overrides (for testing): +# GSTACK_STATE_DIR — override ~/.gstack state directory +set -euo pipefail + +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +CONFIG_FILE="$STATE_DIR/config.yaml" + +case "${1:-}" in + get) + KEY="${2:?Usage: gstack-config get }" + grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true + ;; + set) + KEY="${2:?Usage: gstack-config set }" + VALUE="${3:?Usage: gstack-config set }" + mkdir -p "$STATE_DIR" + if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then + sed -i '' "s/^${KEY}:.*/${KEY}: ${VALUE}/" "$CONFIG_FILE" + else + echo "${KEY}: ${VALUE}" >> "$CONFIG_FILE" + fi + ;; + list) + cat "$CONFIG_FILE" 2>/dev/null || true + ;; + *) + echo "Usage: gstack-config {get|set|list} [key] [value]" + exit 1 + ;; +esac diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope new file mode 100755 index 0000000..f656732 --- /dev/null +++ b/bin/gstack-diff-scope @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# gstack-diff-scope — categorize what changed in the diff against a base branch +# Usage: source <(gstack-diff-scope main) → sets SCOPE_FRONTEND=true SCOPE_BACKEND=false ... +# Or: gstack-diff-scope main → prints SCOPE_*=... lines +set -euo pipefail + +BASE="${1:-main}" + +# Get changed file list +FILES=$(git diff "${BASE}...HEAD" --name-only 2>/dev/null || git diff "${BASE}" --name-only 2>/dev/null || echo "") + +if [ -z "$FILES" ]; then + echo "SCOPE_FRONTEND=false" + echo "SCOPE_BACKEND=false" + echo "SCOPE_PROMPTS=false" + echo "SCOPE_TESTS=false" + echo "SCOPE_DOCS=false" + echo "SCOPE_CONFIG=false" + exit 0 +fi + +FRONTEND=false +BACKEND=false +PROMPTS=false +TESTS=false +DOCS=false +CONFIG=false + +while IFS= read -r f; do + case "$f" in + # Frontend: CSS, views, components, templates + *.css|*.scss|*.less|*.sass|*.pcss|*.module.css|*.module.scss) FRONTEND=true ;; + *.tsx|*.jsx|*.vue|*.svelte|*.astro) FRONTEND=true ;; + *.erb|*.haml|*.slim|*.hbs|*.ejs) FRONTEND=true ;; + *.html) FRONTEND=true ;; + tailwind.config.*|postcss.config.*) FRONTEND=true ;; + app/views/*|*/components/*|styles/*|css/*|app/assets/stylesheets/*) FRONTEND=true ;; + + # Prompts: prompt builders, system prompts, generation services + *prompt_builder*|*generation_service*|*writer_service*|*designer_service*) PROMPTS=true ;; + *evaluator*|*scorer*|*classifier_service*|*analyzer*) PROMPTS=true ;; + *voice*.rb|*writing*.rb|*prompt*.rb|*token*.rb) PROMPTS=true ;; + app/services/chat_tools/*|app/services/x_thread_tools/*) PROMPTS=true ;; + config/system_prompts/*) PROMPTS=true ;; + + # Tests + *.test.*|*.spec.*|*_test.*|*_spec.*) TESTS=true ;; + test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) TESTS=true ;; + + # Docs + *.md) DOCS=true ;; + + # Config + package.json|package-lock.json|yarn.lock|bun.lockb) CONFIG=true ;; + Gemfile|Gemfile.lock) CONFIG=true ;; + *.yml|*.yaml) CONFIG=true ;; + .github/*) CONFIG=true ;; + requirements.txt|pyproject.toml|go.mod|Cargo.toml|composer.json) CONFIG=true ;; + + # Backend: everything else that's code (excluding views/components already matched) + *.rb|*.py|*.go|*.rs|*.java|*.php|*.ex|*.exs) BACKEND=true ;; + *.ts|*.js) BACKEND=true ;; # Non-component TS/JS is backend + esac +done <<< "$FILES" + +echo "SCOPE_FRONTEND=$FRONTEND" +echo "SCOPE_BACKEND=$BACKEND" +echo "SCOPE_PROMPTS=$PROMPTS" +echo "SCOPE_TESTS=$TESTS" +echo "SCOPE_DOCS=$DOCS" +echo "SCOPE_CONFIG=$CONFIG" diff --git a/bin/gstack-env.sh b/bin/gstack-env.sh new file mode 100755 index 0000000..89f7770 --- /dev/null +++ b/bin/gstack-env.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# gstack-env.sh — Set environment variables for gstack-opencode +# Source this file to set up the environment: +# source ~/gstack-opencode/bin/gstack-env.sh + +export GSTACK_OPENCODE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export GSTACK_BROWSE="${GSTACK_OPENCODE_DIR}/browse/dist/browse" +export PATH="${GSTACK_OPENCODE_DIR}/bin:$PATH" + +# Create ~/.gstack directory if it doesn't exist +mkdir -p ~/.gstack/{sessions,projects,analytics} diff --git a/bin/gstack-global-discover.ts b/bin/gstack-global-discover.ts new file mode 100755 index 0000000..e6c64f5 --- /dev/null +++ b/bin/gstack-global-discover.ts @@ -0,0 +1,591 @@ +#!/usr/bin/env bun +/** + * gstack-global-discover — Discover AI coding sessions across Claude Code, Codex CLI, and Gemini CLI. + * Resolves each session's working directory to a git repo, deduplicates by normalized remote URL, + * and outputs structured JSON to stdout. + * + * Usage: + * gstack-global-discover --since 7d [--format json|summary] + * gstack-global-discover --help + */ + +import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, closeSync } from "fs"; +import { join, basename } from "path"; +import { execSync } from "child_process"; +import { homedir } from "os"; + +// ── Types ────────────────────────────────────────────────────────────────── + +interface Session { + tool: "claude_code" | "codex" | "gemini"; + cwd: string; +} + +interface Repo { + name: string; + remote: string; + paths: string[]; + sessions: { claude_code: number; codex: number; gemini: number }; +} + +interface DiscoveryResult { + window: string; + start_date: string; + repos: Repo[]; + tools: { + claude_code: { total_sessions: number; repos: number }; + codex: { total_sessions: number; repos: number }; + gemini: { total_sessions: number; repos: number }; + }; + total_sessions: number; + total_repos: number; +} + +// ── CLI parsing ──────────────────────────────────────────────────────────── + +function printUsage(): void { + console.error(`Usage: gstack-global-discover --since [--format json|summary] + + --since Time window: e.g. 7d, 14d, 30d, 24h + --format Output format: json (default) or summary + --help Show this help + +Examples: + gstack-global-discover --since 7d + gstack-global-discover --since 14d --format summary`); +} + +function parseArgs(): { since: string; format: "json" | "summary" } { + const args = process.argv.slice(2); + let since = ""; + let format: "json" | "summary" = "json"; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--help" || args[i] === "-h") { + printUsage(); + process.exit(0); + } else if (args[i] === "--since" && args[i + 1]) { + since = args[++i]; + } else if (args[i] === "--format" && args[i + 1]) { + const f = args[++i]; + if (f !== "json" && f !== "summary") { + console.error(`Invalid format: ${f}. Use 'json' or 'summary'.`); + printUsage(); + process.exit(1); + } + format = f; + } else { + console.error(`Unknown argument: ${args[i]}`); + printUsage(); + process.exit(1); + } + } + + if (!since) { + console.error("Error: --since is required."); + printUsage(); + process.exit(1); + } + + if (!/^\d+(d|h|w)$/.test(since)) { + console.error(`Invalid window format: ${since}. Use e.g. 7d, 24h, 2w.`); + process.exit(1); + } + + return { since, format }; +} + +function windowToDate(window: string): Date { + const match = window.match(/^(\d+)(d|h|w)$/); + if (!match) throw new Error(`Invalid window: ${window}`); + const [, numStr, unit] = match; + const num = parseInt(numStr, 10); + const now = new Date(); + + if (unit === "h") { + return new Date(now.getTime() - num * 60 * 60 * 1000); + } else if (unit === "w") { + // weeks — midnight-aligned like days + const d = new Date(now); + d.setDate(d.getDate() - num * 7); + d.setHours(0, 0, 0, 0); + return d; + } else { + // days — midnight-aligned + const d = new Date(now); + d.setDate(d.getDate() - num); + d.setHours(0, 0, 0, 0); + return d; + } +} + +// ── URL normalization ────────────────────────────────────────────────────── + +export function normalizeRemoteUrl(url: string): string { + let normalized = url.trim(); + + // SSH → HTTPS: git@github.com:user/repo → https://github.com/user/repo + const sshMatch = normalized.match(/^(?:ssh:\/\/)?git@([^:]+):(.+)$/); + if (sshMatch) { + normalized = `https://${sshMatch[1]}/${sshMatch[2]}`; + } + + // Strip .git suffix + if (normalized.endsWith(".git")) { + normalized = normalized.slice(0, -4); + } + + // Lowercase the host portion + try { + const parsed = new URL(normalized); + parsed.hostname = parsed.hostname.toLowerCase(); + normalized = parsed.toString(); + // Remove trailing slash + if (normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + } catch { + // Not a valid URL (e.g., local:), return as-is + } + + return normalized; +} + +// ── Git helpers ──────────────────────────────────────────────────────────── + +function isGitRepo(dir: string): boolean { + return existsSync(join(dir, ".git")); +} + +function getGitRemote(cwd: string): string | null { + if (!existsSync(cwd) || !isGitRepo(cwd)) return null; + try { + const remote = execSync("git remote get-url origin", { + cwd, + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return remote || null; + } catch { + return null; + } +} + +// ── Scanners ─────────────────────────────────────────────────────────────── + +function scanClaudeCode(since: Date): Session[] { + const projectsDir = join(homedir(), ".claude", "projects"); + if (!existsSync(projectsDir)) return []; + + const sessions: Session[] = []; + + let dirs: string[]; + try { + dirs = readdirSync(projectsDir); + } catch { + return []; + } + + for (const dirName of dirs) { + const dirPath = join(projectsDir, dirName); + try { + const stat = statSync(dirPath); + if (!stat.isDirectory()) continue; + } catch { + continue; + } + + // Find JSONL files + let jsonlFiles: string[]; + try { + jsonlFiles = readdirSync(dirPath).filter((f) => f.endsWith(".jsonl")); + } catch { + continue; + } + if (jsonlFiles.length === 0) continue; + + // Coarse mtime pre-filter: check if any JSONL file is recent + const hasRecentFile = jsonlFiles.some((f) => { + try { + return statSync(join(dirPath, f)).mtime >= since; + } catch { + return false; + } + }); + if (!hasRecentFile) continue; + + // Resolve cwd + let cwd = resolveClaudeCodeCwd(dirPath, dirName, jsonlFiles); + if (!cwd) continue; + + // Count only JSONL files modified within the window as sessions + const recentFiles = jsonlFiles.filter((f) => { + try { + return statSync(join(dirPath, f)).mtime >= since; + } catch { + return false; + } + }); + for (let i = 0; i < recentFiles.length; i++) { + sessions.push({ tool: "claude_code", cwd }); + } + } + + return sessions; +} + +function resolveClaudeCodeCwd( + dirPath: string, + dirName: string, + jsonlFiles: string[] +): string | null { + // Fast-path: decode directory name + // e.g., -Users-garrytan-git-repo → /Users/garrytan/git/repo + const decoded = dirName.replace(/^-/, "/").replace(/-/g, "/"); + if (existsSync(decoded)) return decoded; + + // Fallback: read cwd from first JSONL file + // Sort by mtime descending, pick most recent + const sorted = jsonlFiles + .map((f) => { + try { + return { name: f, mtime: statSync(join(dirPath, f)).mtime.getTime() }; + } catch { + return null; + } + }) + .filter(Boolean) + .sort((a, b) => b!.mtime - a!.mtime) as { name: string; mtime: number }[]; + + for (const file of sorted.slice(0, 3)) { + const cwd = extractCwdFromJsonl(join(dirPath, file.name)); + if (cwd && existsSync(cwd)) return cwd; + } + + return null; +} + +function extractCwdFromJsonl(filePath: string): string | null { + try { + // Read only the first 8KB to avoid loading huge JSONL files into memory + const fd = openSync(filePath, "r"); + const buf = Buffer.alloc(8192); + const bytesRead = readSync(fd, buf, 0, 8192, 0); + closeSync(fd); + const text = buf.toString("utf-8", 0, bytesRead); + const lines = text.split("\n").slice(0, 15); + for (const line of lines) { + if (!line.trim()) continue; + try { + const obj = JSON.parse(line); + if (obj.cwd) return obj.cwd; + } catch { + continue; + } + } + } catch { + // File read error + } + return null; +} + +function scanCodex(since: Date): Session[] { + const sessionsDir = join(homedir(), ".codex", "sessions"); + if (!existsSync(sessionsDir)) return []; + + const sessions: Session[] = []; + + // Walk YYYY/MM/DD directory structure + try { + const years = readdirSync(sessionsDir); + for (const year of years) { + const yearPath = join(sessionsDir, year); + if (!statSync(yearPath).isDirectory()) continue; + + const months = readdirSync(yearPath); + for (const month of months) { + const monthPath = join(yearPath, month); + if (!statSync(monthPath).isDirectory()) continue; + + const days = readdirSync(monthPath); + for (const day of days) { + const dayPath = join(monthPath, day); + if (!statSync(dayPath).isDirectory()) continue; + + const files = readdirSync(dayPath).filter((f) => + f.startsWith("rollout-") && f.endsWith(".jsonl") + ); + + for (const file of files) { + const filePath = join(dayPath, file); + try { + const stat = statSync(filePath); + if (stat.mtime < since) continue; + } catch { + continue; + } + + // Read first line for session_meta (only first 4KB) + try { + const fd = openSync(filePath, "r"); + const buf = Buffer.alloc(4096); + const bytesRead = readSync(fd, buf, 0, 4096, 0); + closeSync(fd); + const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n")[0]; + if (!firstLine) continue; + const meta = JSON.parse(firstLine); + if (meta.type === "session_meta" && meta.payload?.cwd) { + sessions.push({ tool: "codex", cwd: meta.payload.cwd }); + } + } catch { + console.error(`Warning: could not parse Codex session ${filePath}`); + } + } + } + } + } + } catch { + // Directory read error + } + + return sessions; +} + +function scanGemini(since: Date): Session[] { + const tmpDir = join(homedir(), ".gemini", "tmp"); + if (!existsSync(tmpDir)) return []; + + // Load projects.json for path mapping + const projectsPath = join(homedir(), ".gemini", "projects.json"); + let projectsMap: Record = {}; // name → path + if (existsSync(projectsPath)) { + try { + const data = JSON.parse(readFileSync(projectsPath, { encoding: "utf-8" })); + // Format: { projects: { "/path": "name" } } — we want name → path + const projects = data.projects || {}; + for (const [path, name] of Object.entries(projects)) { + projectsMap[name as string] = path; + } + } catch { + console.error("Warning: could not parse ~/.gemini/projects.json"); + } + } + + const sessions: Session[] = []; + const seenTimestamps = new Map>(); // projectName → Set + + let projectDirs: string[]; + try { + projectDirs = readdirSync(tmpDir); + } catch { + return []; + } + + for (const projectName of projectDirs) { + const chatsDir = join(tmpDir, projectName, "chats"); + if (!existsSync(chatsDir)) continue; + + // Resolve cwd from projects.json + let cwd = projectsMap[projectName] || null; + + // Fallback: check .project_root + if (!cwd) { + const projectRootFile = join(tmpDir, projectName, ".project_root"); + if (existsSync(projectRootFile)) { + try { + cwd = readFileSync(projectRootFile, { encoding: "utf-8" }).trim(); + } catch {} + } + } + + if (!cwd || !existsSync(cwd)) continue; + + const seen = seenTimestamps.get(projectName) || new Set(); + seenTimestamps.set(projectName, seen); + + let files: string[]; + try { + files = readdirSync(chatsDir).filter((f) => + f.startsWith("session-") && f.endsWith(".json") + ); + } catch { + continue; + } + + for (const file of files) { + const filePath = join(chatsDir, file); + try { + const stat = statSync(filePath); + if (stat.mtime < since) continue; + } catch { + continue; + } + + try { + const data = JSON.parse(readFileSync(filePath, { encoding: "utf-8" })); + const startTime = data.startTime || ""; + + // Deduplicate by startTime within project + if (startTime && seen.has(startTime)) continue; + if (startTime) seen.add(startTime); + + sessions.push({ tool: "gemini", cwd }); + } catch { + console.error(`Warning: could not parse Gemini session ${filePath}`); + } + } + } + + return sessions; +} + +// ── Deduplication ────────────────────────────────────────────────────────── + +async function resolveAndDeduplicate(sessions: Session[]): Promise { + // Group sessions by cwd + const byCwd = new Map(); + for (const s of sessions) { + const existing = byCwd.get(s.cwd) || []; + existing.push(s); + byCwd.set(s.cwd, existing); + } + + // Resolve git remotes for each cwd + const cwds = Array.from(byCwd.keys()); + const remoteMap = new Map(); // cwd → normalized remote + + for (const cwd of cwds) { + const raw = getGitRemote(cwd); + if (raw) { + remoteMap.set(cwd, normalizeRemoteUrl(raw)); + } else if (existsSync(cwd) && isGitRepo(cwd)) { + remoteMap.set(cwd, `local:${cwd}`); + } + } + + // Group by normalized remote + const byRemote = new Map(); + for (const [cwd, cwdSessions] of byCwd) { + const remote = remoteMap.get(cwd); + if (!remote) continue; + + const existing = byRemote.get(remote) || { paths: [], sessions: [] }; + if (!existing.paths.includes(cwd)) existing.paths.push(cwd); + existing.sessions.push(...cwdSessions); + byRemote.set(remote, existing); + } + + // Build Repo objects + const repos: Repo[] = []; + for (const [remote, data] of byRemote) { + // Find first valid path + const validPath = data.paths.find((p) => existsSync(p) && isGitRepo(p)); + if (!validPath) continue; + + // Derive name from remote URL + let name: string; + if (remote.startsWith("local:")) { + name = basename(remote.replace("local:", "")); + } else { + try { + const url = new URL(remote); + name = basename(url.pathname); + } catch { + name = basename(remote); + } + } + + const sessionCounts = { claude_code: 0, codex: 0, gemini: 0 }; + for (const s of data.sessions) { + sessionCounts[s.tool]++; + } + + repos.push({ + name, + remote, + paths: data.paths, + sessions: sessionCounts, + }); + } + + // Sort by total sessions descending + repos.sort( + (a, b) => + b.sessions.claude_code + b.sessions.codex + b.sessions.gemini - + (a.sessions.claude_code + a.sessions.codex + a.sessions.gemini) + ); + + return repos; +} + +// ── Main ─────────────────────────────────────────────────────────────────── + +async function main() { + const { since, format } = parseArgs(); + const sinceDate = windowToDate(since); + const startDate = sinceDate.toISOString().split("T")[0]; + + // Run all scanners + const ccSessions = scanClaudeCode(sinceDate); + const codexSessions = scanCodex(sinceDate); + const geminiSessions = scanGemini(sinceDate); + + const allSessions = [...ccSessions, ...codexSessions, ...geminiSessions]; + + // Summary to stderr + console.error( + `Discovered: ${ccSessions.length} CC sessions, ${codexSessions.length} Codex sessions, ${geminiSessions.length} Gemini sessions` + ); + + // Deduplicate + const repos = await resolveAndDeduplicate(allSessions); + + console.error(`→ ${repos.length} unique repos`); + + // Count per-tool repo counts + const ccRepos = new Set(repos.filter((r) => r.sessions.claude_code > 0).map((r) => r.remote)).size; + const codexRepos = new Set(repos.filter((r) => r.sessions.codex > 0).map((r) => r.remote)).size; + const geminiRepos = new Set(repos.filter((r) => r.sessions.gemini > 0).map((r) => r.remote)).size; + + const result: DiscoveryResult = { + window: since, + start_date: startDate, + repos, + tools: { + claude_code: { total_sessions: ccSessions.length, repos: ccRepos }, + codex: { total_sessions: codexSessions.length, repos: codexRepos }, + gemini: { total_sessions: geminiSessions.length, repos: geminiRepos }, + }, + total_sessions: allSessions.length, + total_repos: repos.length, + }; + + if (format === "json") { + console.log(JSON.stringify(result, null, 2)); + } else { + // Summary format + console.log(`Window: ${since} (since ${startDate})`); + console.log(`Sessions: ${allSessions.length} total (CC: ${ccSessions.length}, Codex: ${codexSessions.length}, Gemini: ${geminiSessions.length})`); + console.log(`Repos: ${repos.length} unique`); + console.log(""); + for (const repo of repos) { + const total = repo.sessions.claude_code + repo.sessions.codex + repo.sessions.gemini; + const tools = []; + if (repo.sessions.claude_code > 0) tools.push(`CC:${repo.sessions.claude_code}`); + if (repo.sessions.codex > 0) tools.push(`Codex:${repo.sessions.codex}`); + if (repo.sessions.gemini > 0) tools.push(`Gemini:${repo.sessions.gemini}`); + console.log(` ${repo.name} (${total} sessions) — ${tools.join(", ")}`); + console.log(` Remote: ${repo.remote}`); + console.log(` Paths: ${repo.paths.join(", ")}`); + } + } +} + +// Only run main when executed directly (not when imported for testing) +if (import.meta.main) { + main().catch((err) => { + console.error(`Fatal error: ${err.message}`); + process.exit(1); + }); +} diff --git a/bin/gstack-repo-mode b/bin/gstack-repo-mode new file mode 100755 index 0000000..0b4d6da --- /dev/null +++ b/bin/gstack-repo-mode @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# gstack-repo-mode — detect solo vs collaborative repo mode +# Usage: source <(gstack-repo-mode) → sets REPO_MODE variable +# Or: gstack-repo-mode → prints REPO_MODE=... line +# +# Detection heuristic (90-day window): +# Solo: top author >= 80% of commits +# Collaborative: top author < 80% +# +# Override: gstack-config set repo_mode solo|collaborative +# Cache: ~/.gstack/projects/$SLUG/repo-mode.json (7-day TTL) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Compute SLUG directly (avoid eval of gstack-slug — branch names can contain shell metacharacters) +REMOTE_URL=$(git remote get-url origin 2>/dev/null || true) +if [ -z "$REMOTE_URL" ]; then + echo "REPO_MODE=unknown" + exit 0 +fi +SLUG=$(echo "$REMOTE_URL" | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-') +[ -z "${SLUG:-}" ] && { echo "REPO_MODE=unknown"; exit 0; } + +# Validate: only allow known values (prevent shell injection via source <(...)) +validate_mode() { + case "$1" in solo|collaborative|unknown) echo "$1" ;; *) echo "unknown" ;; esac +} + +# Config override takes precedence +OVERRIDE=$("$SCRIPT_DIR/gstack-config" get repo_mode 2>/dev/null || true) +if [ -n "$OVERRIDE" ] && [ "$OVERRIDE" != "null" ]; then + echo "REPO_MODE=$(validate_mode "$OVERRIDE")" + exit 0 +fi + +# Check cache (7-day TTL) +CACHE_DIR="$HOME/.gstack/projects/$SLUG" +CACHE_FILE="$CACHE_DIR/repo-mode.json" +if [ -f "$CACHE_FILE" ]; then + CACHE_AGE=$(( $(date +%s) - $(stat -f %m "$CACHE_FILE" 2>/dev/null || stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0) )) + if [ "$CACHE_AGE" -lt 604800 ]; then # 7 days in seconds + MODE=$(grep -o '"mode":"[^"]*"' "$CACHE_FILE" | head -1 | cut -d'"' -f4) + [ -n "$MODE" ] && echo "REPO_MODE=$(validate_mode "$MODE")" && exit 0 + fi +fi + +# Compute from git history (90-day window) +# Use default branch (not HEAD) to avoid feature-branch sampling bias +DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/||' || true) +# Fallback: try origin/main, then origin/master, then HEAD +if [ -z "$DEFAULT_BRANCH" ]; then + if git rev-parse --verify origin/main &>/dev/null; then + DEFAULT_BRANCH="origin/main" + elif git rev-parse --verify origin/master &>/dev/null; then + DEFAULT_BRANCH="origin/master" + else + DEFAULT_BRANCH="HEAD" + fi +fi +SHORTLOG=$(git shortlog -sn --since="90 days ago" --no-merges "$DEFAULT_BRANCH" 2>/dev/null) +if [ -z "$SHORTLOG" ]; then + echo "REPO_MODE=unknown" + exit 0 +fi + +# Compute TOTAL from ALL authors (not truncated) to avoid solo bias +TOTAL=$(echo "$SHORTLOG" | awk '{s+=$1} END {print s}') +TOP=$(echo "$SHORTLOG" | head -1 | awk '{print $1}') +AUTHORS=$(echo "$SHORTLOG" | wc -l | tr -d ' ') + +# Minimum sample: need at least 5 commits to classify +if [ "$TOTAL" -lt 5 ]; then + echo "REPO_MODE=unknown" + exit 0 +fi + +TOP_PCT=$(( TOP * 100 / TOTAL )) + +# Solo: top author >= 80% of commits (occasional outside PRs don't change mode) +if [ "$TOP_PCT" -ge 80 ]; then + MODE=solo +else + MODE=collaborative +fi + +# Cache result atomically (fail silently if ~/.gstack is unwritable) +mkdir -p "$CACHE_DIR" 2>/dev/null || true +CACHE_TMP=$(mktemp "$CACHE_DIR/.repo-mode-XXXXXX" 2>/dev/null || true) +if [ -n "$CACHE_TMP" ]; then + echo "{\"mode\":\"$MODE\",\"top_pct\":$TOP_PCT,\"authors\":$AUTHORS,\"total\":$TOTAL,\"computed\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$CACHE_TMP" 2>/dev/null && mv "$CACHE_TMP" "$CACHE_FILE" 2>/dev/null || rm -f "$CACHE_TMP" 2>/dev/null +fi + +echo "REPO_MODE=$MODE" diff --git a/bin/gstack-review-log b/bin/gstack-review-log new file mode 100755 index 0000000..d7235bc --- /dev/null +++ b/bin/gstack-review-log @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# gstack-review-log — atomically log a review result +# Usage: gstack-review-log '{"skill":"...","timestamp":"...","status":"..."}' +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +mkdir -p "$GSTACK_HOME/projects/$SLUG" +echo "$1" >> "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" diff --git a/bin/gstack-review-read b/bin/gstack-review-read new file mode 100755 index 0000000..ccf1d70 --- /dev/null +++ b/bin/gstack-review-read @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# gstack-review-read — read review log and config for dashboard +# Usage: gstack-review-read +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +cat "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" 2>/dev/null || echo "NO_REVIEWS" +echo "---CONFIG---" +"$SCRIPT_DIR/gstack-config" get skip_eng_review 2>/dev/null || echo "false" +echo "---HEAD---" +git rev-parse --short HEAD 2>/dev/null || echo "unknown" diff --git a/bin/gstack-slug b/bin/gstack-slug new file mode 100755 index 0000000..a7ae788 --- /dev/null +++ b/bin/gstack-slug @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# gstack-slug — output project slug and sanitized branch name +# Usage: eval "$(gstack-slug)" → sets SLUG and BRANCH variables +# Or: gstack-slug → prints SLUG=... and BRANCH=... lines +# +# Security: output is sanitized to [a-zA-Z0-9._-] only, preventing +# shell injection when consumed via source or eval. +set -euo pipefail +RAW_SLUG=$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-') +RAW_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-') +# Strip any characters that aren't alphanumeric, dot, hyphen, or underscore +SLUG=$(printf '%s' "$RAW_SLUG" | tr -cd 'a-zA-Z0-9._-') +BRANCH=$(printf '%s' "$RAW_BRANCH" | tr -cd 'a-zA-Z0-9._-') +echo "SLUG=$SLUG" +echo "BRANCH=$BRANCH" diff --git a/bin/gstack-telemetry-log b/bin/gstack-telemetry-log new file mode 100755 index 0000000..edcbdba --- /dev/null +++ b/bin/gstack-telemetry-log @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# gstack-telemetry-log — append a telemetry event to local JSONL +# +# Data flow: +# preamble (start) ──▶ .pending marker +# preamble (epilogue) ──▶ gstack-telemetry-log ──▶ skill-usage.jsonl +# └──▶ gstack-telemetry-sync (bg) +# +# Usage: +# gstack-telemetry-log --skill qa --duration 142 --outcome success \ +# --used-browse true --session-id "12345-1710756600" +# +# Env overrides (for testing): +# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_DIR — override auto-detected gstack root +# +# NOTE: Uses set -uo pipefail (no -e) — telemetry must never exit non-zero +set -uo pipefail + +GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +ANALYTICS_DIR="$STATE_DIR/analytics" +JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl" +PENDING_DIR="$ANALYTICS_DIR" # .pending-* files live here +CONFIG_CMD="$GSTACK_DIR/bin/gstack-config" +VERSION_FILE="$GSTACK_DIR/VERSION" + +# ─── Parse flags ───────────────────────────────────────────── +SKILL="" +DURATION="" +OUTCOME="unknown" +USED_BROWSE="false" +SESSION_ID="" +ERROR_CLASS="" +EVENT_TYPE="skill_run" + +while [ $# -gt 0 ]; do + case "$1" in + --skill) SKILL="$2"; shift 2 ;; + --duration) DURATION="$2"; shift 2 ;; + --outcome) OUTCOME="$2"; shift 2 ;; + --used-browse) USED_BROWSE="$2"; shift 2 ;; + --session-id) SESSION_ID="$2"; shift 2 ;; + --error-class) ERROR_CLASS="$2"; shift 2 ;; + --event-type) EVENT_TYPE="$2"; shift 2 ;; + *) shift ;; + esac +done + +# ─── Read telemetry tier ───────────────────────────────────── +TIER="$("$CONFIG_CMD" get telemetry 2>/dev/null || true)" +TIER="${TIER:-off}" + +# Validate tier +case "$TIER" in + off|anonymous|community) ;; + *) TIER="off" ;; # invalid value → default to off +esac + +if [ "$TIER" = "off" ]; then + # Still clear pending markers for this session even if telemetry is off + [ -n "$SESSION_ID" ] && rm -f "$PENDING_DIR/.pending-$SESSION_ID" 2>/dev/null || true + exit 0 +fi + +# ─── Finalize stale .pending markers ──────────────────────── +# Each session gets its own .pending-$SESSION_ID file to avoid races +# between concurrent sessions. Finalize any that don't match our session. +for PFILE in "$PENDING_DIR"/.pending-*; do + [ -f "$PFILE" ] || continue + # Skip our own session's marker (it's still in-flight) + PFILE_BASE="$(basename "$PFILE")" + PFILE_SID="${PFILE_BASE#.pending-}" + [ "$PFILE_SID" = "$SESSION_ID" ] && continue + + PENDING_DATA="$(cat "$PFILE" 2>/dev/null || true)" + rm -f "$PFILE" 2>/dev/null || true + if [ -n "$PENDING_DATA" ]; then + # Extract fields from pending marker using grep -o + awk + P_SKILL="$(echo "$PENDING_DATA" | grep -o '"skill":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" + P_TS="$(echo "$PENDING_DATA" | grep -o '"ts":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" + P_SID="$(echo "$PENDING_DATA" | grep -o '"session_id":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" + P_VER="$(echo "$PENDING_DATA" | grep -o '"gstack_version":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" + P_OS="$(uname -s | tr '[:upper:]' '[:lower:]')" + P_ARCH="$(uname -m)" + + # Write the stale event as outcome: unknown + mkdir -p "$ANALYTICS_DIR" + printf '{"v":1,"ts":"%s","event_type":"skill_run","skill":"%s","session_id":"%s","gstack_version":"%s","os":"%s","arch":"%s","duration_s":null,"outcome":"unknown","error_class":null,"used_browse":false,"sessions":1}\n' \ + "$P_TS" "$P_SKILL" "$P_SID" "$P_VER" "$P_OS" "$P_ARCH" >> "$JSONL_FILE" 2>/dev/null || true + fi +done + +# Clear our own session's pending marker (we're about to log the real event) +[ -n "$SESSION_ID" ] && rm -f "$PENDING_DIR/.pending-$SESSION_ID" 2>/dev/null || true + +# ─── Collect metadata ──────────────────────────────────────── +TS="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%S 2>/dev/null || echo "")" +GSTACK_VERSION="$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]' || echo "unknown")" +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +SESSIONS="1" +if [ -d "$STATE_DIR/sessions" ]; then + _SC="$(find "$STATE_DIR/sessions" -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' \n\r\t')" + [ -n "$_SC" ] && [ "$_SC" -gt 0 ] 2>/dev/null && SESSIONS="$_SC" +fi + +# Generate installation_id for community tier +INSTALL_ID="" +if [ "$TIER" = "community" ]; then + HOST="$(hostname 2>/dev/null || echo "unknown")" + USER="$(whoami 2>/dev/null || echo "unknown")" + if command -v shasum >/dev/null 2>&1; then + INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | shasum -a 256 | awk '{print $1}')" + elif command -v sha256sum >/dev/null 2>&1; then + INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | sha256sum | awk '{print $1}')" + elif command -v openssl >/dev/null 2>&1; then + INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | openssl dgst -sha256 | awk '{print $NF}')" + fi + # If no SHA-256 command available, install_id stays empty +fi + +# Local-only fields (never sent remotely) +REPO_SLUG="" +BRANCH="" +if command -v git >/dev/null 2>&1; then + REPO_SLUG="$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-' 2>/dev/null || true)" + BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +fi + +# ─── Construct and append JSON ─────────────────────────────── +mkdir -p "$ANALYTICS_DIR" + +# Escape null fields +ERR_FIELD="null" +[ -n "$ERROR_CLASS" ] && ERR_FIELD="\"$ERROR_CLASS\"" + +DUR_FIELD="null" +[ -n "$DURATION" ] && DUR_FIELD="$DURATION" + +INSTALL_FIELD="null" +[ -n "$INSTALL_ID" ] && INSTALL_FIELD="\"$INSTALL_ID\"" + +BROWSE_BOOL="false" +[ "$USED_BROWSE" = "true" ] && BROWSE_BOOL="true" + +printf '{"v":1,"ts":"%s","event_type":"%s","skill":"%s","session_id":"%s","gstack_version":"%s","os":"%s","arch":"%s","duration_s":%s,"outcome":"%s","error_class":%s,"used_browse":%s,"sessions":%s,"installation_id":%s,"_repo_slug":"%s","_branch":"%s"}\n' \ + "$TS" "$EVENT_TYPE" "$SKILL" "$SESSION_ID" "$GSTACK_VERSION" "$OS" "$ARCH" \ + "$DUR_FIELD" "$OUTCOME" "$ERR_FIELD" "$BROWSE_BOOL" "${SESSIONS:-1}" \ + "$INSTALL_FIELD" "$REPO_SLUG" "$BRANCH" >> "$JSONL_FILE" 2>/dev/null || true + +# ─── Trigger sync if tier is not off ───────────────────────── +SYNC_CMD="$GSTACK_DIR/bin/gstack-telemetry-sync" +if [ -x "$SYNC_CMD" ]; then + "$SYNC_CMD" 2>/dev/null & +fi + +exit 0 diff --git a/bin/gstack-telemetry-sync b/bin/gstack-telemetry-sync new file mode 100755 index 0000000..90e3724 --- /dev/null +++ b/bin/gstack-telemetry-sync @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# gstack-telemetry-sync — sync local JSONL events to Supabase +# +# Fire-and-forget, backgrounded, rate-limited to once per 5 minutes. +# Strips local-only fields before sending. Respects privacy tiers. +# +# Env overrides (for testing): +# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_DIR — override auto-detected gstack root +# GSTACK_TELEMETRY_ENDPOINT — override Supabase endpoint URL +set -uo pipefail + +GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +ANALYTICS_DIR="$STATE_DIR/analytics" +JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl" +CURSOR_FILE="$ANALYTICS_DIR/.last-sync-line" +RATE_FILE="$ANALYTICS_DIR/.last-sync-time" +CONFIG_CMD="$GSTACK_DIR/bin/gstack-config" + +# Source Supabase config if not overridden by env +if [ -z "${GSTACK_TELEMETRY_ENDPOINT:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then + . "$GSTACK_DIR/supabase/config.sh" +fi +ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}" +ANON_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" + +# ─── Pre-checks ────────────────────────────────────────────── +# No endpoint configured yet → exit silently +[ -z "$ENDPOINT" ] && exit 0 + +# No JSONL file → nothing to sync +[ -f "$JSONL_FILE" ] || exit 0 + +# Rate limit: once per 5 minutes +if [ -f "$RATE_FILE" ]; then + STALE=$(find "$RATE_FILE" -mmin +5 2>/dev/null || true) + [ -z "$STALE" ] && exit 0 +fi + +# ─── Read tier ─────────────────────────────────────────────── +TIER="$("$CONFIG_CMD" get telemetry 2>/dev/null || true)" +TIER="${TIER:-off}" +[ "$TIER" = "off" ] && exit 0 + +# ─── Read cursor ───────────────────────────────────────────── +CURSOR=0 +if [ -f "$CURSOR_FILE" ]; then + CURSOR="$(cat "$CURSOR_FILE" 2>/dev/null | tr -d ' \n\r\t')" + # Validate: must be a non-negative integer + case "$CURSOR" in *[!0-9]*) CURSOR=0 ;; esac +fi + +# Safety: if cursor exceeds file length, reset +TOTAL_LINES="$(wc -l < "$JSONL_FILE" | tr -d ' \n\r\t')" +if [ "$CURSOR" -gt "$TOTAL_LINES" ] 2>/dev/null; then + CURSOR=0 +fi + +# Nothing new to sync +[ "$CURSOR" -ge "$TOTAL_LINES" ] 2>/dev/null && exit 0 + +# ─── Read unsent lines ─────────────────────────────────────── +SKIP=$(( CURSOR + 1 )) +UNSENT="$(tail -n "+$SKIP" "$JSONL_FILE" 2>/dev/null || true)" +[ -z "$UNSENT" ] && exit 0 + +# ─── Strip local-only fields and build batch ───────────────── +BATCH="[" +FIRST=true +COUNT=0 + +while IFS= read -r LINE; do + # Skip empty or malformed lines + [ -z "$LINE" ] && continue + echo "$LINE" | grep -q '^{' || continue + + # Strip local-only fields + map JSONL field names to Postgres column names + CLEAN="$(echo "$LINE" | sed \ + -e 's/,"_repo_slug":"[^"]*"//g' \ + -e 's/,"_branch":"[^"]*"//g' \ + -e 's/"v":/"schema_version":/g' \ + -e 's/"ts":/"event_timestamp":/g' \ + -e 's/"sessions":/"concurrent_sessions":/g' \ + -e 's/,"repo":"[^"]*"//g')" + + # If anonymous tier, strip installation_id + if [ "$TIER" = "anonymous" ]; then + CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')" + fi + + if [ "$FIRST" = "true" ]; then + FIRST=false + else + BATCH="$BATCH," + fi + BATCH="$BATCH$CLEAN" + COUNT=$(( COUNT + 1 )) + + # Batch size limit + [ "$COUNT" -ge 100 ] && break +done <<< "$UNSENT" + +BATCH="$BATCH]" + +# Nothing to send after filtering +[ "$COUNT" -eq 0 ] && exit 0 + +# ─── POST to Supabase ──────────────────────────────────────── +HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \ + -X POST "${ENDPOINT}/telemetry_events" \ + -H "Content-Type: application/json" \ + -H "apikey: ${ANON_KEY}" \ + -H "Authorization: Bearer ${ANON_KEY}" \ + -H "Prefer: return=minimal" \ + -d "$BATCH" 2>/dev/null || echo "000")" + +# ─── Update cursor on success (2xx) ───────────────────────── +case "$HTTP_CODE" in + 2*) NEW_CURSOR=$(( CURSOR + COUNT )) + echo "$NEW_CURSOR" > "$CURSOR_FILE" 2>/dev/null || true ;; +esac + +# Update rate limit marker +touch "$RATE_FILE" 2>/dev/null || true + +exit 0 diff --git a/bin/gstack-update-check b/bin/gstack-update-check new file mode 100755 index 0000000..d0d0f1f --- /dev/null +++ b/bin/gstack-update-check @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# gstack-update-check — periodic version check for all skills. +# +# Output (one line, or nothing): +# JUST_UPGRADED — marker found from recent upgrade +# UPGRADE_AVAILABLE — remote VERSION differs from local +# (nothing) — up to date, snoozed, disabled, or check skipped +# +# Env overrides (for testing): +# GSTACK_DIR — override auto-detected gstack root +# GSTACK_REMOTE_URL — override remote VERSION URL +# GSTACK_STATE_DIR — override ~/.gstack state directory +set -euo pipefail + +GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +CACHE_FILE="$STATE_DIR/last-update-check" +MARKER_FILE="$STATE_DIR/just-upgraded-from" +SNOOZE_FILE="$STATE_DIR/update-snoozed" +VERSION_FILE="$GSTACK_DIR/VERSION" +REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}" + +# ─── Force flag (busts cache for standalone /gstack-upgrade) ── +if [ "${1:-}" = "--force" ]; then + rm -f "$CACHE_FILE" +fi + +# ─── Step 0: Check if updates are disabled ──────────────────── +_UC=$("$GSTACK_DIR/bin/gstack-config" get update_check 2>/dev/null || true) +if [ "$_UC" = "false" ]; then + exit 0 +fi + +# ─── Snooze helper ────────────────────────────────────────── +# check_snooze +# Returns 0 if snoozed (should stay quiet), 1 if not snoozed (should output). +# +# Snooze file format: +# Level durations: 1=24h, 2=48h, 3+=7d +# New version (version mismatch) resets snooze. +check_snooze() { + local remote_ver="$1" + if [ ! -f "$SNOOZE_FILE" ]; then + return 1 # no snooze file → not snoozed + fi + local snoozed_ver snoozed_level snoozed_epoch + snoozed_ver="$(awk '{print $1}' "$SNOOZE_FILE" 2>/dev/null || true)" + snoozed_level="$(awk '{print $2}' "$SNOOZE_FILE" 2>/dev/null || true)" + snoozed_epoch="$(awk '{print $3}' "$SNOOZE_FILE" 2>/dev/null || true)" + + # Validate: all three fields must be non-empty + if [ -z "$snoozed_ver" ] || [ -z "$snoozed_level" ] || [ -z "$snoozed_epoch" ]; then + return 1 # corrupt file → not snoozed + fi + + # Validate: level and epoch must be integers + case "$snoozed_level" in *[!0-9]*) return 1 ;; esac + case "$snoozed_epoch" in *[!0-9]*) return 1 ;; esac + + # New version dropped? Ignore snooze. + if [ "$snoozed_ver" != "$remote_ver" ]; then + return 1 + fi + + # Compute snooze duration based on level + local duration + case "$snoozed_level" in + 1) duration=86400 ;; # 24 hours + 2) duration=172800 ;; # 48 hours + *) duration=604800 ;; # 7 days (level 3+) + esac + + local now + now="$(date +%s)" + local expires=$(( snoozed_epoch + duration )) + if [ "$now" -lt "$expires" ]; then + return 0 # still snoozed + fi + + return 1 # snooze expired +} + +# ─── Step 1: Read local version ────────────────────────────── +LOCAL="" +if [ -f "$VERSION_FILE" ]; then + LOCAL="$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]')" +fi +if [ -z "$LOCAL" ]; then + exit 0 # No VERSION file → skip check +fi + +# ─── Step 2: Check "just upgraded" marker ───────────────────── +if [ -f "$MARKER_FILE" ]; then + OLD="$(cat "$MARKER_FILE" 2>/dev/null | tr -d '[:space:]')" + rm -f "$MARKER_FILE" + rm -f "$SNOOZE_FILE" + mkdir -p "$STATE_DIR" + echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE" + if [ -n "$OLD" ]; then + echo "JUST_UPGRADED $OLD $LOCAL" + fi + exit 0 +fi + +# ─── Step 3: Check cache freshness ────────────────────────── +# UP_TO_DATE: 60 min TTL (detect new releases quickly) +# UPGRADE_AVAILABLE: 720 min TTL (keep nagging) +if [ -f "$CACHE_FILE" ]; then + CACHED="$(cat "$CACHE_FILE" 2>/dev/null || true)" + case "$CACHED" in + UP_TO_DATE*) CACHE_TTL=60 ;; + UPGRADE_AVAILABLE*) CACHE_TTL=720 ;; + *) CACHE_TTL=0 ;; # corrupt → force re-fetch + esac + + STALE=$(find "$CACHE_FILE" -mmin +$CACHE_TTL 2>/dev/null || true) + if [ -z "$STALE" ] && [ "$CACHE_TTL" -gt 0 ]; then + case "$CACHED" in + UP_TO_DATE*) + CACHED_VER="$(echo "$CACHED" | awk '{print $2}')" + if [ "$CACHED_VER" = "$LOCAL" ]; then + exit 0 + fi + ;; + UPGRADE_AVAILABLE*) + CACHED_OLD="$(echo "$CACHED" | awk '{print $2}')" + if [ "$CACHED_OLD" = "$LOCAL" ]; then + CACHED_NEW="$(echo "$CACHED" | awk '{print $3}')" + if check_snooze "$CACHED_NEW"; then + exit 0 # snoozed — stay quiet + fi + echo "$CACHED" + exit 0 + fi + ;; + esac + fi +fi + +# ─── Step 4: Slow path — fetch remote version ──────────────── +mkdir -p "$STATE_DIR" + +# Fire Supabase install ping in background (parallel, non-blocking) +# This logs an update check event for community health metrics. +# If the endpoint isn't configured or Supabase is down, this is a no-op. +# Source Supabase config for install ping +if [ -z "${GSTACK_TELEMETRY_ENDPOINT:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then + . "$GSTACK_DIR/supabase/config.sh" +fi +_SUPA_ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}" +_SUPA_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" +# Respect telemetry opt-out — don't ping Supabase if user set telemetry: off +_TEL_TIER="$("$GSTACK_DIR/bin/gstack-config" get telemetry 2>/dev/null || true)" +if [ -n "$_SUPA_ENDPOINT" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off" ]; then + _OS="$(uname -s | tr '[:upper:]' '[:lower:]')" + curl -sf --max-time 5 \ + -X POST "${_SUPA_ENDPOINT}/update_checks" \ + -H "Content-Type: application/json" \ + -H "apikey: ${_SUPA_KEY}" \ + -H "Authorization: Bearer ${_SUPA_KEY}" \ + -H "Prefer: return=minimal" \ + -d "{\"gstack_version\":\"$LOCAL\",\"os\":\"$_OS\"}" \ + >/dev/null 2>&1 & +fi + +# GitHub raw fetch (primary, always reliable) +REMOTE="" +REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)" +REMOTE="$(echo "$REMOTE" | tr -d '[:space:]')" + +# Validate: must look like a version number (reject HTML error pages) +if ! echo "$REMOTE" | grep -qE '^[0-9]+\.[0-9.]+$'; then + # Invalid or empty response — assume up to date + echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE" + exit 0 +fi + +if [ "$LOCAL" = "$REMOTE" ]; then + echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE" + exit 0 +fi + +# Versions differ — upgrade available +echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE" +if check_snooze "$REMOTE"; then + exit 0 # snoozed — stay quiet +fi + +# Log upgrade_prompted event (only on slow-path fetch, not cached replays) +TEL_CMD="$GSTACK_DIR/bin/gstack-telemetry-log" +if [ -x "$TEL_CMD" ]; then + "$TEL_CMD" --event-type upgrade_prompted --skill "" --duration 0 \ + --outcome success --session-id "update-$$-$(date +%s)" 2>/dev/null & +fi + +echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" diff --git a/browse/SKILL.md b/browse/SKILL.md new file mode 100644 index 0000000..901135f --- /dev/null +++ b/browse/SKILL.md @@ -0,0 +1,553 @@ +--- +name: browse +version: 1.1.0 +description: | + Fast headless browser for QA testing and site dogfooding. Navigate any URL, interact with + elements, verify page state, diff before/after actions, take annotated screenshots, check + responsive layouts, test forms and uploads, handle dialogs, and assert element states. + ~100ms per command. Use when you need to test a feature, verify a deployment, dogfood a + user flow, or file a bug with evidence. Use when asked to "open in browser", "test the + site", "take a screenshot", or "dogfood this". +allowed-tools: + - Bash + - Read + - AskUserQuestion + +--- + + + +## Preamble (run first) + +```bash +_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true) +[ -n "$_UPD" ] && echo "$_UPD" || true +mkdir -p ~/.gstack/sessions +touch ~/.gstack/sessions/"$PPID" +_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ') +find ~/.gstack/sessions -mmin +120 -type f -delete 2>/dev/null || true +_CONTRIB=$(~/.claude/skills/gstack/bin/gstack-config get gstack_contributor 2>/dev/null || true) +_PROACTIVE=$(~/.claude/skills/gstack/bin/gstack-config get proactive 2>/dev/null || echo "true") +_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown") +echo "BRANCH: $_BRANCH" +echo "PROACTIVE: $_PROACTIVE" +source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true +REPO_MODE=${REPO_MODE:-unknown} +echo "REPO_MODE: $REPO_MODE" +_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") +echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" +mkdir -p ~/.gstack/analytics +echo '{"skill":"browse","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done +``` + +If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke +them when the user explicitly asks. The user opted out of proactive suggestions. + +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. + +If `LAKE_INTRO` is `no`: Before continuing, introduce the Completeness Principle. +Tell the user: "gstack follows the **Boil the Lake** principle — always do the complete +thing when AI makes the marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean" +Then offer to open the essay in their default browser: + +```bash +open https://garryslist.org/posts/boil-the-ocean +touch ~/.gstack/.completeness-intro-seen +``` + +Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. + +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> Help gstack get better! Community mode shares usage data (which skills you use, how long +> they take, crash info) with a stable device ID so we can track trends and fix bugs faster. +> No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Help gstack get better! (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry community` + +If B: ask a follow-up AskUserQuestion: + +> How about anonymous mode? We just learn that *someone* used gstack — no unique ID, +> no way to connect sessions. Just a counter that helps us know if anyone's out there. + +Options: +- A) Sure, anonymous is fine +- B) No thanks, fully off + +If B→A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B→B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + +## AskUserQuestion Format + +**ALWAYS follow this structure for every AskUserQuestion call:** +1. **Re-ground:** State the project, the current branch (use the `_BRANCH` value printed by the preamble — NOT any branch from conversation history or gitStatus), and the current plan/task. (1-2 sentences) +2. **Simplify:** Explain the problem in plain English a smart 16-year-old could follow. No raw function names, no internal jargon, no implementation details. Use concrete examples and analogies. Say what it DOES, not what it's called. +3. **Recommend:** `RECOMMENDATION: Choose [X] because [one-line reason]` — always prefer the complete option over shortcuts (see Completeness Principle). Include `Completeness: X/10` for each option. Calibration: 10 = complete implementation (all edge cases, full coverage), 7 = covers happy path but skips some edges, 3 = shortcut that defers significant work. If both options are 8+, pick the higher; if one is ≤5, flag it. +4. **Options:** Lettered options: `A) ... B) ... C) ...` — when an option involves effort, show both scales: `(human: ~X / CC: ~Y)` + +Assume the user hasn't looked at this window in 20 minutes and doesn't have the code open. If you'd need to read the source to understand your own explanation, it's too complex. + +Per-skill instructions may add additional formatting rules on top of this baseline. + +## Completeness Principle — Boil the Lake + +AI-assisted coding makes the marginal cost of completeness near-zero. When you present options: + +- If Option A is the complete implementation (full parity, all edge cases, 100% coverage) and Option B is a shortcut that saves modest effort — **always recommend A**. The delta between 80 lines and 150 lines is meaningless with CC+gstack. "Good enough" is the wrong instinct when "complete" costs minutes more. +- **Lake vs. ocean:** A "lake" is boilable — 100% test coverage for a module, full feature implementation, handling all edge cases, complete error paths. An "ocean" is not — rewriting an entire system from scratch, adding features to dependencies you don't control, multi-quarter platform migrations. Recommend boiling lakes. Flag oceans as out of scope. +- **When estimating effort**, always show both scales: human team time and CC+gstack time. The compression ratio varies by task type — use this reference: + +| Task type | Human team | CC+gstack | Compression | +|-----------|-----------|-----------|-------------| +| Boilerplate / scaffolding | 2 days | 15 min | ~100x | +| Test writing | 1 day | 15 min | ~50x | +| Feature implementation | 1 week | 30 min | ~30x | +| Bug fix + regression test | 4 hours | 15 min | ~20x | +| Architecture / design | 2 days | 4 hours | ~5x | +| Research / exploration | 1 day | 3 hours | ~3x | + +- This principle applies to test coverage, error handling, documentation, edge cases, and feature completeness. Don't skip the last 10% to "save time" — with AI, that 10% costs seconds. + +**Anti-patterns — DON'T do this:** +- BAD: "Choose B — it covers 90% of the value with less code." (If A is only 70 lines more, choose A.) +- BAD: "We can skip edge case handling to save time." (Edge case handling costs minutes with CC.) +- BAD: "Let's defer test coverage to a follow-up PR." (Tests are the cheapest lake to boil.) +- BAD: Quoting only human-team effort: "This would take 2 weeks." (Say: "2 weeks human / ~1 hour CC.") + +## Repo Ownership Mode — See Something, Say Something + +`REPO_MODE` from the preamble tells you who owns issues in this repo: + +- **`solo`** — One person does 80%+ of the work. They own everything. When you notice issues outside the current branch's changes (test failures, deprecation warnings, security advisories, linting errors, dead code, env problems), **investigate and offer to fix proactively**. The solo dev is the only person who will fix it. Default to action. +- **`collaborative`** — Multiple active contributors. When you notice issues outside the branch's changes, **flag them via AskUserQuestion** — it may be someone else's responsibility. Default to asking, not fixing. +- **`unknown`** — Treat as collaborative (safer default — ask before fixing). + +**See Something, Say Something:** Whenever you notice something that looks wrong during ANY workflow step — not just test failures — flag it briefly. One sentence: what you noticed and its impact. In solo mode, follow up with "Want me to fix it?" In collaborative mode, just flag it and move on. + +Never let a noticed issue silently pass. The whole point is proactive communication. + +## Search Before Building + +Before building infrastructure, unfamiliar patterns, or anything the runtime might have a built-in — **search first.** Read `~/.claude/skills/gstack/ETHOS.md` for the full philosophy. + +**Three layers of knowledge:** +- **Layer 1** (tried and true — in distribution). Don't reinvent the wheel. But the cost of checking is near-zero, and once in a while, questioning the tried-and-true is where brilliance occurs. +- **Layer 2** (new and popular — search for these). But scrutinize: humans are subject to mania. Search results are inputs to your thinking, not answers. +- **Layer 3** (first principles — prize these above all). Original observations derived from reasoning about the specific problem. The most valuable of all. + +**Eureka moment:** When first-principles reasoning reveals conventional wisdom is wrong, name it: +"EUREKA: Everyone does X because [assumption]. But [evidence] shows this is wrong. Y is better because [reasoning]." + +Log eureka moments: +```bash +jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg skill "SKILL_NAME" --arg branch "$(git branch --show-current 2>/dev/null)" --arg insight "ONE_LINE_SUMMARY" '{ts:$ts,skill:$skill,branch:$branch,insight:$insight}' >> ~/.gstack/analytics/eureka.jsonl 2>/dev/null || true +``` +Replace SKILL_NAME and ONE_LINE_SUMMARY. Runs inline — don't stop the workflow. + +**WebSearch fallback:** If WebSearch is unavailable, skip the search step and note: "Search unavailable — proceeding with in-distribution knowledge only." + +## Contributor Mode + +If `_CONTRIB` is `true`: you are in **contributor mode**. You're a gstack user who also helps make it better. + +**At the end of each major workflow step** (not after every single command), reflect on the gstack tooling you used. Rate your experience 0 to 10. If it wasn't a 10, think about why. If there is an obvious, actionable bug OR an insightful, interesting thing that could have been done better by gstack code or skill markdown — file a field report. Maybe our contributor will help make us better! + +**Calibration — this is the bar:** For example, `$B js "await fetch(...)"` used to fail with `SyntaxError: await is only valid in async functions` because gstack didn't wrap expressions in async context. Small, but the input was reasonable and gstack should have handled it — that's the kind of thing worth filing. Things less consequential than this, ignore. + +**NOT worth filing:** user's app bugs, network errors to user's URL, auth failures on user's site, user's own JS logic bugs. + +**To file:** write `~/.gstack/contributor-logs/{slug}.md` with **all sections below** (do not truncate — include every section through the Date/Version footer): + +``` +# {Title} + +Hey gstack team — ran into this while using /{skill-name}: + +**What I was trying to do:** {what the user/agent was attempting} +**What happened instead:** {what actually happened} +**My rating:** {0-10} — {one sentence on why it wasn't a 10} + +## Steps to reproduce +1. {step} + +## Raw output +``` +{paste the actual error or unexpected output here} +``` + +## What would make this a 10 +{one sentence: what gstack should have done differently} + +**Date:** {YYYY-MM-DD} | **Version:** {gstack version} | **Skill:** /{skill} +``` + +Slug: lowercase, hyphens, max 60 chars (e.g. `browse-js-no-await`). Skip if file already exists. Max 3 reports per session. File inline and continue — don't stop the workflow. Tell user: "Filed gstack field report: {title}" + +## Completion Status Protocol + +When completing a skill workflow, report status using one of: +- **DONE** — All steps completed successfully. Evidence provided for each claim. +- **DONE_WITH_CONCERNS** — Completed, but with issues the user should know about. List each concern. +- **BLOCKED** — Cannot proceed. State what is blocking and what was tried. +- **NEEDS_CONTEXT** — Missing information required to continue. State exactly what you need. + +### Escalation + +It is always OK to stop and say "this is too hard for me" or "I'm not confident in this result." + +Bad work is worse than no work. You will not be penalized for escalating. +- If you have attempted a task 3 times without success, STOP and escalate. +- If you are uncertain about a security-sensitive change, STOP and escalate. +- If the scope of work exceeds what you can verify, STOP and escalate. + +Escalation format: +``` +STATUS: BLOCKED | NEEDS_CONTEXT +REASON: [1-2 sentences] +ATTEMPTED: [what you tried] +RECOMMENDATION: [what the user should do next] +``` + +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). + +**PLAN MODE EXCEPTION — ALWAYS RUN:** This command writes telemetry to +`~/.gstack/analytics/` (user config directory, not project files). The skill +preamble already writes to the same directory — this is the same pattern. +Skipping this command loses session duration and outcome data. + +Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + +## Plan Status Footer + +When you are in plan mode and about to call ExitPlanMode: + +1. Check if the plan file already has a `## GSTACK REVIEW REPORT` section. +2. If it DOES — skip (a review skill already wrote a richer report). +3. If it does NOT — run this command: + +\`\`\`bash +~/.claude/skills/gstack/bin/gstack-review-read +\`\`\` + +Then write a `## GSTACK REVIEW REPORT` section to the end of the plan file: + +- If the output contains review entries (JSONL lines before `---CONFIG---`): format the + standard report table with runs/status/findings per skill, same format as the review + skills use. +- If the output is `NO_REVIEWS` or empty: write this placeholder table: + +\`\`\`markdown +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | \`/plan-ceo-review\` | Scope & strategy | 0 | — | — | +| Codex Review | \`/codex review\` | Independent 2nd opinion | 0 | — | — | +| Eng Review | \`/plan-eng-review\` | Architecture & tests (required) | 0 | — | — | +| Design Review | \`/plan-design-review\` | UI/UX gaps | 0 | — | — | + +**VERDICT:** NO REVIEWS YET — run \`/autoplan\` for full review pipeline, or individual reviews above. +\`\`\` + +**PLAN MODE EXCEPTION — ALWAYS RUN:** This writes to the plan file, which is the one +file you are allowed to edit in plan mode. The plan file review report is part of the +plan's living status. + +# browse: QA Testing & Dogfooding + +Persistent headless Chromium. First call auto-starts (~3s), then ~100ms per command. +State persists between calls (cookies, tabs, login sessions). + +## SETUP (run this check BEFORE any browse command) + +```bash +_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +B="" +[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" +[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse +if [ -x "$B" ]; then + echo "READY: $B" +else + echo "NEEDS_SETUP" +fi +``` + +If `NEEDS_SETUP`: +1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait. +2. Run: `cd && ./setup` +3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash` + +## Core QA Patterns + +### 1. Verify a page loads correctly +```bash +$B goto https://yourapp.com +$B text # content loads? +$B console # JS errors? +$B network # failed requests? +$B is visible ".main-content" # key elements present? +``` + +### 2. Test a user flow +```bash +$B goto https://app.com/login +$B snapshot -i # see all interactive elements +$B fill @e3 "user@test.com" +$B fill @e4 "password" +$B click @e5 # submit +$B snapshot -D # diff: what changed after submit? +$B is visible ".dashboard" # success state present? +``` + +### 3. Verify an action worked +```bash +$B snapshot # baseline +$B click @e3 # do something +$B snapshot -D # unified diff shows exactly what changed +``` + +### 4. Visual evidence for bug reports +```bash +$B snapshot -i -a -o /tmp/annotated.png # labeled screenshot +$B screenshot /tmp/bug.png # plain screenshot +$B console # error log +``` + +### 5. Find all clickable elements (including non-ARIA) +```bash +$B snapshot -C # finds divs with cursor:pointer, onclick, tabindex +$B click @c1 # interact with them +``` + +### 6. Assert element states +```bash +$B is visible ".modal" +$B is enabled "#submit-btn" +$B is disabled "#submit-btn" +$B is checked "#agree-checkbox" +$B is editable "#name-field" +$B is focused "#search-input" +$B js "document.body.textContent.includes('Success')" +``` + +### 7. Test responsive layouts +```bash +$B responsive /tmp/layout # mobile + tablet + desktop screenshots +$B viewport 375x812 # or set specific viewport +$B screenshot /tmp/mobile.png +``` + +### 8. Test file uploads +```bash +$B upload "#file-input" /path/to/file.pdf +$B is visible ".upload-success" +``` + +### 9. Test dialogs +```bash +$B dialog-accept "yes" # set up handler +$B click "#delete-button" # trigger dialog +$B dialog # see what appeared +$B snapshot -D # verify deletion happened +``` + +### 10. Compare environments +```bash +$B diff https://staging.app.com https://prod.app.com +``` + +### 11. Show screenshots to the user +After `$B screenshot`, `$B snapshot -a -o`, or `$B responsive`, always use the Read tool on the output PNG(s) so the user can see them. Without this, screenshots are invisible. + +## User Handoff + +When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor +login), hand off to the user: + +```bash +# 1. Open a visible Chrome at the current page +$B handoff "Stuck on CAPTCHA at login page" + +# 2. Tell the user what happened (via AskUserQuestion) +# "I've opened Chrome at the login page. Please solve the CAPTCHA +# and let me know when you're done." + +# 3. When user says "done", re-snapshot and continue +$B resume +``` + +**When to use handoff:** +- CAPTCHAs or bot detection +- Multi-factor authentication (SMS, authenticator app) +- OAuth flows that require user interaction +- Complex interactions the AI can't handle after 3 attempts + +The browser preserves all state (cookies, localStorage, tabs) across the handoff. +After `resume`, you get a fresh snapshot of wherever the user left off. + +## Snapshot Flags + +The snapshot is your primary tool for understanding and interacting with pages. + +``` +-i --interactive Interactive elements only (buttons, links, inputs) with @e refs +-c --compact Compact (no empty structural nodes) +-d --depth Limit tree depth (0 = root only, default: unlimited) +-s --selector Scope to CSS selector +-D --diff Unified diff against previous snapshot (first call stores baseline) +-a --annotate Annotated screenshot with red overlay boxes and ref labels +-o --output Output path for annotated screenshot (default: /browse-annotated.png) +-C --cursor-interactive Cursor-interactive elements (@c refs — divs with pointer, onclick) +``` + +All flags can be combined freely. `-o` only applies when `-a` is also used. +Example: `$B snapshot -i -a -C -o /tmp/annotated.png` + +**Ref numbering:** @e refs are assigned sequentially (@e1, @e2, ...) in tree order. +@c refs from `-C` are numbered separately (@c1, @c2, ...). + +After snapshot, use @refs as selectors in any command: +```bash +$B click @e3 $B fill @e4 "value" $B hover @e1 +$B html @e2 $B css @e5 "color" $B attrs @e6 +$B click @c1 # cursor-interactive ref (from -C) +``` + +**Output format:** indented accessibility tree with @ref IDs, one element per line. +``` + @e1 [heading] "Welcome" [level=1] + @e2 [textbox] "Email" + @e3 [button] "Submit" +``` + +Refs are invalidated on navigation — run `snapshot` again after `goto`. + +## Full Command List + +### Navigation +| Command | Description | +|---------|-------------| +| `back` | History back | +| `forward` | History forward | +| `goto ` | Navigate to URL | +| `reload` | Reload page | +| `url` | Print current URL | + +### Reading +| Command | Description | +|---------|-------------| +| `accessibility` | Full ARIA tree | +| `forms` | Form fields as JSON | +| `html [selector]` | innerHTML of selector (throws if not found), or full page HTML if no selector given | +| `links` | All links as "text → href" | +| `text` | Cleaned page text | + +### Interaction +| Command | Description | +|---------|-------------| +| `click ` | Click element | +| `cookie =` | Set cookie on current page domain | +| `cookie-import ` | Import cookies from JSON file | +| `cookie-import-browser [browser] [--domain d]` | Import cookies from Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import) | +| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response | +| `dialog-dismiss` | Auto-dismiss next dialog | +| `fill ` | Fill input | +| `header :` | Set custom request header (colon-separated, sensitive values auto-redacted) | +| `hover ` | Hover element | +| `press ` | Press key — Enter, Tab, Escape, ArrowUp/Down/Left/Right, Backspace, Delete, Home, End, PageUp, PageDown, or modifiers like Shift+Enter | +| `scroll [sel]` | Scroll element into view, or scroll to page bottom if no selector | +| `select ` | Select dropdown option by value, label, or visible text | +| `type ` | Type into focused element | +| `upload [file2...]` | Upload file(s) | +| `useragent ` | Set user agent | +| `viewport ` | Set viewport size | +| `wait ` | Wait for element, network idle, or page load (timeout: 15s) | + +### Inspection +| Command | Description | +|---------|-------------| +| `attrs ` | Element attributes as JSON | +| `console [--clear|--errors]` | Console messages (--errors filters to error/warning) | +| `cookies` | All cookies as JSON | +| `css ` | Computed CSS value | +| `dialog [--clear]` | Dialog messages | +| `eval ` | Run JavaScript from file and return result as string (path must be under /tmp or cwd) | +| `is ` | State check (visible/hidden/enabled/disabled/checked/editable/focused) | +| `js ` | Run JavaScript expression and return result as string | +| `network [--clear]` | Network requests | +| `perf` | Page load timings | +| `storage [set k v]` | Read all localStorage + sessionStorage as JSON, or set to write localStorage | + +### Visual +| Command | Description | +|---------|-------------| +| `diff ` | Text diff between pages | +| `pdf [path]` | Save as PDF | +| `responsive [prefix]` | Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc. | +| `screenshot [--viewport] [--clip x,y,w,h] [selector|@ref] [path]` | Save screenshot (supports element crop via CSS/@ref, --clip region, --viewport) | + +### Snapshot +| Command | Description | +|---------|-------------| +| `snapshot [flags]` | Accessibility tree with @e refs for element selection. Flags: -i interactive only, -c compact, -d N depth limit, -s sel scope, -D diff vs previous, -a annotated screenshot, -o path output, -C cursor-interactive @c refs | + +### Meta +| Command | Description | +|---------|-------------| +| `chain` | Run commands from JSON stdin. Format: [["cmd","arg1",...],...] | + +### Tabs +| Command | Description | +|---------|-------------| +| `closetab [id]` | Close tab | +| `newtab [url]` | Open new tab | +| `tab ` | Switch to tab | +| `tabs` | List open tabs | + +### Server +| Command | Description | +|---------|-------------| +| `handoff [message]` | Open visible Chrome at current page for user takeover | +| `restart` | Restart server | +| `resume` | Re-snapshot after user takeover, return control to AI | +| `status` | Health check | +| `stop` | Shutdown server | diff --git a/browse/SKILL.md.tmpl b/browse/SKILL.md.tmpl new file mode 100644 index 0000000..9c722f5 --- /dev/null +++ b/browse/SKILL.md.tmpl @@ -0,0 +1,141 @@ +--- +name: browse +version: 1.1.0 +description: | + Fast headless browser for QA testing and site dogfooding. Navigate any URL, interact with + elements, verify page state, diff before/after actions, take annotated screenshots, check + responsive layouts, test forms and uploads, handle dialogs, and assert element states. + ~100ms per command. Use when you need to test a feature, verify a deployment, dogfood a + user flow, or file a bug with evidence. Use when asked to "open in browser", "test the + site", "take a screenshot", or "dogfood this". +allowed-tools: + - Bash + - Read + - AskUserQuestion + +--- + +{{PREAMBLE}} + +# browse: QA Testing & Dogfooding + +Persistent headless Chromium. First call auto-starts (~3s), then ~100ms per command. +State persists between calls (cookies, tabs, login sessions). + +{{BROWSE_SETUP}} + +## Core QA Patterns + +### 1. Verify a page loads correctly +```bash +$B goto https://yourapp.com +$B text # content loads? +$B console # JS errors? +$B network # failed requests? +$B is visible ".main-content" # key elements present? +``` + +### 2. Test a user flow +```bash +$B goto https://app.com/login +$B snapshot -i # see all interactive elements +$B fill @e3 "user@test.com" +$B fill @e4 "password" +$B click @e5 # submit +$B snapshot -D # diff: what changed after submit? +$B is visible ".dashboard" # success state present? +``` + +### 3. Verify an action worked +```bash +$B snapshot # baseline +$B click @e3 # do something +$B snapshot -D # unified diff shows exactly what changed +``` + +### 4. Visual evidence for bug reports +```bash +$B snapshot -i -a -o /tmp/annotated.png # labeled screenshot +$B screenshot /tmp/bug.png # plain screenshot +$B console # error log +``` + +### 5. Find all clickable elements (including non-ARIA) +```bash +$B snapshot -C # finds divs with cursor:pointer, onclick, tabindex +$B click @c1 # interact with them +``` + +### 6. Assert element states +```bash +$B is visible ".modal" +$B is enabled "#submit-btn" +$B is disabled "#submit-btn" +$B is checked "#agree-checkbox" +$B is editable "#name-field" +$B is focused "#search-input" +$B js "document.body.textContent.includes('Success')" +``` + +### 7. Test responsive layouts +```bash +$B responsive /tmp/layout # mobile + tablet + desktop screenshots +$B viewport 375x812 # or set specific viewport +$B screenshot /tmp/mobile.png +``` + +### 8. Test file uploads +```bash +$B upload "#file-input" /path/to/file.pdf +$B is visible ".upload-success" +``` + +### 9. Test dialogs +```bash +$B dialog-accept "yes" # set up handler +$B click "#delete-button" # trigger dialog +$B dialog # see what appeared +$B snapshot -D # verify deletion happened +``` + +### 10. Compare environments +```bash +$B diff https://staging.app.com https://prod.app.com +``` + +### 11. Show screenshots to the user +After `$B screenshot`, `$B snapshot -a -o`, or `$B responsive`, always use the Read tool on the output PNG(s) so the user can see them. Without this, screenshots are invisible. + +## User Handoff + +When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor +login), hand off to the user: + +```bash +# 1. Open a visible Chrome at the current page +$B handoff "Stuck on CAPTCHA at login page" + +# 2. Tell the user what happened (via AskUserQuestion) +# "I've opened Chrome at the login page. Please solve the CAPTCHA +# and let me know when you're done." + +# 3. When user says "done", re-snapshot and continue +$B resume +``` + +**When to use handoff:** +- CAPTCHAs or bot detection +- Multi-factor authentication (SMS, authenticator app) +- OAuth flows that require user interaction +- Complex interactions the AI can't handle after 3 attempts + +The browser preserves all state (cookies, localStorage, tabs) across the handoff. +After `resume`, you get a fresh snapshot of wherever the user left off. + +## Snapshot Flags + +{{SNAPSHOT_FLAGS}} + +## Full Command List + +{{COMMAND_REFERENCE}} diff --git a/browse/bin/find-browse b/browse/bin/find-browse new file mode 100755 index 0000000..8f441b4 --- /dev/null +++ b/browse/bin/find-browse @@ -0,0 +1,21 @@ +#!/bin/bash +# Shim: delegates to compiled find-browse binary, falls back to basic discovery. +# The compiled binary handles git root detection for workspace-local installs. +DIR="$(cd "$(dirname "$0")/.." && pwd)/dist" +if test -x "$DIR/find-browse"; then + exec "$DIR/find-browse" "$@" +fi +# Fallback: basic discovery with priority chain +ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +for MARKER in .codex .agents .claude; do + if [ -n "$ROOT" ] && test -x "$ROOT/$MARKER/skills/gstack/browse/dist/browse"; then + echo "$ROOT/$MARKER/skills/gstack/browse/dist/browse" + exit 0 + fi + if test -x "$HOME/$MARKER/skills/gstack/browse/dist/browse"; then + echo "$HOME/$MARKER/skills/gstack/browse/dist/browse" + exit 0 + fi +done +echo "ERROR: browse binary not found. Run: cd && ./setup" >&2 +exit 1 diff --git a/browse/bin/remote-slug b/browse/bin/remote-slug new file mode 100755 index 0000000..5f68759 --- /dev/null +++ b/browse/bin/remote-slug @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Output the remote slug (owner-repo) for the current git repo. +# Used by SKILL.md files to derive project-specific paths in ~/.gstack/projects/. +set -e +URL=$(git remote get-url origin 2>/dev/null || true) +if [ -n "$URL" ]; then + # Strip trailing .git if present, then extract owner/repo + URL="${URL%.git}" + # Handle both SSH (git@host:owner/repo) and HTTPS (https://host/owner/repo) + OWNER_REPO=$(echo "$URL" | sed -E 's#.*[:/]([^/]+)/([^/]+)$#\1-\2#') + echo "$OWNER_REPO" +else + basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +fi diff --git a/browse/dist/browse b/browse/dist/browse new file mode 100755 index 0000000..e970563 Binary files /dev/null and b/browse/dist/browse differ diff --git a/browse/scripts/build-node-server.sh b/browse/scripts/build-node-server.sh new file mode 100755 index 0000000..539e391 --- /dev/null +++ b/browse/scripts/build-node-server.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Build a Node.js-compatible server bundle for Windows. +# +# On Windows, Bun can't launch or connect to Playwright's Chromium +# (oven-sh/bun#4253, #9911). This script produces a server bundle +# that runs under Node.js with Bun API polyfills. + +set -e + +GSTACK_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +SRC_DIR="$GSTACK_DIR/browse/src" +DIST_DIR="$GSTACK_DIR/browse/dist" + +echo "Building Node-compatible server bundle..." + +# Step 1: Transpile server.ts to a single .mjs bundle (externalize runtime deps) +bun build "$SRC_DIR/server.ts" \ + --target=node \ + --outfile "$DIST_DIR/server-node.mjs" \ + --external playwright \ + --external playwright-core \ + --external diff \ + --external "bun:sqlite" + +# Step 2: Post-process +# Replace import.meta.dir with a resolvable reference +perl -pi -e 's/import\.meta\.dir/__browseNodeSrcDir/g' "$DIST_DIR/server-node.mjs" +# Stub out bun:sqlite (macOS-only cookie import, not needed on Windows) +perl -pi -e 's|import { Database } from "bun:sqlite";|const Database = null; // bun:sqlite stubbed on Node|g' "$DIST_DIR/server-node.mjs" + +# Step 3: Create the final file with polyfill header injected after the first line +{ + head -1 "$DIST_DIR/server-node.mjs" + echo '// ── Windows Node.js compatibility (auto-generated) ──' + echo 'import { fileURLToPath as _ftp } from "node:url";' + echo 'import { dirname as _dn } from "node:path";' + echo 'const __browseNodeSrcDir = _dn(_dn(_ftp(import.meta.url))) + "/src";' + echo '{ const _r = createRequire(import.meta.url); _r("./bun-polyfill.cjs"); }' + echo '// ── end compatibility ──' + tail -n +2 "$DIST_DIR/server-node.mjs" +} > "$DIST_DIR/server-node.tmp.mjs" + +mv "$DIST_DIR/server-node.tmp.mjs" "$DIST_DIR/server-node.mjs" + +# Step 4: Copy polyfill to dist/ +cp "$SRC_DIR/bun-polyfill.cjs" "$DIST_DIR/bun-polyfill.cjs" + +echo "Node server bundle ready: $DIST_DIR/server-node.mjs" diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts new file mode 100644 index 0000000..43ce4c9 --- /dev/null +++ b/browse/src/browser-manager.ts @@ -0,0 +1,634 @@ +/** + * Browser lifecycle manager + * + * Chromium crash handling: + * browser.on('disconnected') → log error → process.exit(1) + * CLI detects dead server → auto-restarts on next command + * We do NOT try to self-heal — don't hide failure. + * + * Dialog handling: + * page.on('dialog') → auto-accept by default → store in dialog buffer + * Prevents browser lockup from alert/confirm/prompt + * + * Context recreation (useragent): + * recreateContext() saves cookies/storage/URLs, creates new context, + * restores state. Falls back to clean slate on any failure. + */ + +import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; +import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; +import { validateNavigationUrl } from './url-validation'; + +export interface RefEntry { + locator: Locator; + role: string; + name: string; +} + +export interface BrowserState { + cookies: Cookie[]; + pages: Array<{ + url: string; + isActive: boolean; + storage: { localStorage: Record; sessionStorage: Record } | null; + }>; +} + +export class BrowserManager { + private browser: Browser | null = null; + private context: BrowserContext | null = null; + private pages: Map = new Map(); + private activeTabId: number = 0; + private nextTabId: number = 1; + private extraHeaders: Record = {}; + private customUserAgent: string | null = null; + + /** Server port — set after server starts, used by cookie-import-browser command */ + public serverPort: number = 0; + + // ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ──────── + private refMap: Map = new Map(); + + // ─── Snapshot Diffing ───────────────────────────────────── + // NOT cleared on navigation — it's a text baseline for diffing + private lastSnapshot: string | null = null; + + // ─── Dialog Handling ────────────────────────────────────── + private dialogAutoAccept: boolean = true; + private dialogPromptText: string | null = null; + + // ─── Handoff State ───────────────────────────────────────── + private isHeaded: boolean = false; + private consecutiveFailures: number = 0; + + async launch() { + this.browser = await chromium.launch({ headless: true }); + + // Chromium crash → exit with clear message + this.browser.on('disconnected', () => { + console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.'); + console.error('[browse] Console/network logs flushed to .gstack/browse-*.log'); + process.exit(1); + }); + + const contextOptions: BrowserContextOptions = { + viewport: { width: 1280, height: 720 }, + }; + if (this.customUserAgent) { + contextOptions.userAgent = this.customUserAgent; + } + this.context = await this.browser.newContext(contextOptions); + + if (Object.keys(this.extraHeaders).length > 0) { + await this.context.setExtraHTTPHeaders(this.extraHeaders); + } + + // Create first tab + await this.newTab(); + } + + async close() { + if (this.browser) { + // Remove disconnect handler to avoid exit during intentional close + this.browser.removeAllListeners('disconnected'); + // Timeout: headed browser.close() can hang on macOS + await Promise.race([ + this.browser.close(), + new Promise(resolve => setTimeout(resolve, 5000)), + ]).catch(() => {}); + this.browser = null; + } + } + + /** Health check — verifies Chromium is connected AND responsive */ + async isHealthy(): Promise { + if (!this.browser || !this.browser.isConnected()) return false; + try { + const page = this.pages.get(this.activeTabId); + if (!page) return true; // connected but no pages — still healthy + await Promise.race([ + page.evaluate('1'), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000)), + ]); + return true; + } catch { + return false; + } + } + + // ─── Tab Management ──────────────────────────────────────── + async newTab(url?: string): Promise { + if (!this.context) throw new Error('Browser not launched'); + + // Validate URL before allocating page to avoid zombie tabs on rejection + if (url) { + await validateNavigationUrl(url); + } + + const page = await this.context.newPage(); + const id = this.nextTabId++; + this.pages.set(id, page); + this.activeTabId = id; + + // Wire up console/network/dialog capture + this.wirePageEvents(page); + + if (url) { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); + } + + return id; + } + + async closeTab(id?: number): Promise { + const tabId = id ?? this.activeTabId; + const page = this.pages.get(tabId); + if (!page) throw new Error(`Tab ${tabId} not found`); + + await page.close(); + this.pages.delete(tabId); + + // Switch to another tab if we closed the active one + if (tabId === this.activeTabId) { + const remaining = [...this.pages.keys()]; + if (remaining.length > 0) { + this.activeTabId = remaining[remaining.length - 1]; + } else { + // No tabs left — create a new blank one + await this.newTab(); + } + } + } + + switchTab(id: number): void { + if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`); + this.activeTabId = id; + } + + getTabCount(): number { + return this.pages.size; + } + + async getTabListWithTitles(): Promise> { + const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = []; + for (const [id, page] of this.pages) { + tabs.push({ + id, + url: page.url(), + title: await page.title().catch(() => ''), + active: id === this.activeTabId, + }); + } + return tabs; + } + + // ─── Page Access ─────────────────────────────────────────── + getPage(): Page { + const page = this.pages.get(this.activeTabId); + if (!page) throw new Error('No active page. Use "browse goto " first.'); + return page; + } + + getCurrentUrl(): string { + try { + return this.getPage().url(); + } catch { + return 'about:blank'; + } + } + + // ─── Ref Map ────────────────────────────────────────────── + setRefMap(refs: Map) { + this.refMap = refs; + } + + clearRefs() { + this.refMap.clear(); + } + + /** + * Resolve a selector that may be a @ref (e.g., "@e3", "@c1") or a CSS selector. + * Returns { locator } for refs or { selector } for CSS selectors. + */ + async resolveRef(selector: string): Promise<{ locator: Locator } | { selector: string }> { + if (selector.startsWith('@e') || selector.startsWith('@c')) { + const ref = selector.slice(1); // "e3" or "c1" + const entry = this.refMap.get(ref); + if (!entry) { + throw new Error( + `Ref ${selector} not found. Run 'snapshot' to get fresh refs.` + ); + } + const count = await entry.locator.count(); + if (count === 0) { + throw new Error( + `Ref ${selector} (${entry.role} "${entry.name}") is stale — element no longer exists. ` + + `Run 'snapshot' for fresh refs.` + ); + } + return { locator: entry.locator }; + } + return { selector }; + } + + /** Get the ARIA role for a ref selector, or null for CSS selectors / unknown refs. */ + getRefRole(selector: string): string | null { + if (selector.startsWith('@e') || selector.startsWith('@c')) { + const entry = this.refMap.get(selector.slice(1)); + return entry?.role ?? null; + } + return null; + } + + getRefCount(): number { + return this.refMap.size; + } + + // ─── Snapshot Diffing ───────────────────────────────────── + setLastSnapshot(text: string | null) { + this.lastSnapshot = text; + } + + getLastSnapshot(): string | null { + return this.lastSnapshot; + } + + // ─── Dialog Control ─────────────────────────────────────── + setDialogAutoAccept(accept: boolean) { + this.dialogAutoAccept = accept; + } + + getDialogAutoAccept(): boolean { + return this.dialogAutoAccept; + } + + setDialogPromptText(text: string | null) { + this.dialogPromptText = text; + } + + getDialogPromptText(): string | null { + return this.dialogPromptText; + } + + // ─── Viewport ────────────────────────────────────────────── + async setViewport(width: number, height: number) { + await this.getPage().setViewportSize({ width, height }); + } + + // ─── Extra Headers ───────────────────────────────────────── + async setExtraHeader(name: string, value: string) { + this.extraHeaders[name] = value; + if (this.context) { + await this.context.setExtraHTTPHeaders(this.extraHeaders); + } + } + + // ─── User Agent ──────────────────────────────────────────── + setUserAgent(ua: string) { + this.customUserAgent = ua; + } + + getUserAgent(): string | null { + return this.customUserAgent; + } + + // ─── State Save/Restore (shared by recreateContext + handoff) ─ + /** + * Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab. + * Skips pages that fail storage reads (e.g., already closed). + */ + async saveState(): Promise { + if (!this.context) throw new Error('Browser not launched'); + + const cookies = await this.context.cookies(); + const pages: BrowserState['pages'] = []; + + for (const [id, page] of this.pages) { + const url = page.url(); + let storage = null; + try { + storage = await page.evaluate(() => ({ + localStorage: { ...localStorage }, + sessionStorage: { ...sessionStorage }, + })); + } catch {} + pages.push({ + url: url === 'about:blank' ? '' : url, + isActive: id === this.activeTabId, + storage, + }); + } + + return { cookies, pages }; + } + + /** + * Restore browser state into the current context: cookies, pages, storage. + * Navigates to saved URLs, restores storage, wires page events. + * Failures on individual pages are swallowed — partial restore is better than none. + */ + async restoreState(state: BrowserState): Promise { + if (!this.context) throw new Error('Browser not launched'); + + // Restore cookies + if (state.cookies.length > 0) { + await this.context.addCookies(state.cookies); + } + + // Re-create pages + let activeId: number | null = null; + for (const saved of state.pages) { + const page = await this.context.newPage(); + const id = this.nextTabId++; + this.pages.set(id, page); + this.wirePageEvents(page); + + if (saved.url) { + await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}); + } + + if (saved.storage) { + try { + await page.evaluate((s: { localStorage: Record; sessionStorage: Record }) => { + if (s.localStorage) { + for (const [k, v] of Object.entries(s.localStorage)) { + localStorage.setItem(k, v); + } + } + if (s.sessionStorage) { + for (const [k, v] of Object.entries(s.sessionStorage)) { + sessionStorage.setItem(k, v); + } + } + }, saved.storage); + } catch {} + } + + if (saved.isActive) activeId = id; + } + + // If no pages were saved, create a blank one + if (this.pages.size === 0) { + await this.newTab(); + } else { + this.activeTabId = activeId ?? [...this.pages.keys()][0]; + } + + // Clear refs — pages are new, locators are stale + this.clearRefs(); + } + + /** + * Recreate the browser context to apply user agent changes. + * Saves and restores cookies, localStorage, sessionStorage, and open pages. + * Falls back to a clean slate on any failure. + */ + async recreateContext(): Promise { + if (!this.browser || !this.context) { + throw new Error('Browser not launched'); + } + + try { + // 1. Save state + const state = await this.saveState(); + + // 2. Close old pages and context + for (const page of this.pages.values()) { + await page.close().catch(() => {}); + } + this.pages.clear(); + await this.context.close().catch(() => {}); + + // 3. Create new context with updated settings + const contextOptions: BrowserContextOptions = { + viewport: { width: 1280, height: 720 }, + }; + if (this.customUserAgent) { + contextOptions.userAgent = this.customUserAgent; + } + this.context = await this.browser.newContext(contextOptions); + + if (Object.keys(this.extraHeaders).length > 0) { + await this.context.setExtraHTTPHeaders(this.extraHeaders); + } + + // 4. Restore state + await this.restoreState(state); + + return null; // success + } catch (err: unknown) { + // Fallback: create a clean context + blank tab + try { + this.pages.clear(); + if (this.context) await this.context.close().catch(() => {}); + + const contextOptions: BrowserContextOptions = { + viewport: { width: 1280, height: 720 }, + }; + if (this.customUserAgent) { + contextOptions.userAgent = this.customUserAgent; + } + this.context = await this.browser!.newContext(contextOptions); + await this.newTab(); + this.clearRefs(); + } catch { + // If even the fallback fails, we're in trouble — but browser is still alive + } + return `Context recreation failed: ${err instanceof Error ? err.message : String(err)}. Browser reset to blank tab.`; + } + } + + // ─── Handoff: Headless → Headed ───────────────────────────── + /** + * Hand off browser control to the user by relaunching in headed mode. + * + * Flow (launch-first-close-second for safe rollback): + * 1. Save state from current headless browser + * 2. Launch NEW headed browser + * 3. Restore state into new browser + * 4. Close OLD headless browser + * If step 2 fails → return error, headless browser untouched + */ + async handoff(message: string): Promise { + if (this.isHeaded) { + return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`; + } + if (!this.browser || !this.context) { + throw new Error('Browser not launched'); + } + + // 1. Save state from current browser + const state = await this.saveState(); + const currentUrl = this.getCurrentUrl(); + + // 2. Launch new headed browser (try-catch — if this fails, headless stays running) + let newBrowser: Browser; + try { + newBrowser = await chromium.launch({ headless: false, timeout: 15000 }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return `ERROR: Cannot open headed browser — ${msg}. Headless browser still running.`; + } + + // 3. Create context and restore state into new headed browser + try { + const contextOptions: BrowserContextOptions = { + viewport: { width: 1280, height: 720 }, + }; + if (this.customUserAgent) { + contextOptions.userAgent = this.customUserAgent; + } + const newContext = await newBrowser.newContext(contextOptions); + + if (Object.keys(this.extraHeaders).length > 0) { + await newContext.setExtraHTTPHeaders(this.extraHeaders); + } + + // Swap to new browser/context before restoreState (it uses this.context) + const oldBrowser = this.browser; + const oldContext = this.context; + + this.browser = newBrowser; + this.context = newContext; + this.pages.clear(); + + // Register crash handler on new browser + this.browser.on('disconnected', () => { + console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.'); + console.error('[browse] Console/network logs flushed to .gstack/browse-*.log'); + process.exit(1); + }); + + await this.restoreState(state); + this.isHeaded = true; + + // 4. Close old headless browser (fire-and-forget — close() can hang + // when another Playwright instance is active, so we don't await it) + oldBrowser.removeAllListeners('disconnected'); + oldBrowser.close().catch(() => {}); + + return [ + `HANDOFF: Browser opened at ${currentUrl}`, + `MESSAGE: ${message}`, + `STATUS: Waiting for user. Run 'resume' when done.`, + ].join('\n'); + } catch (err: unknown) { + // Restore failed — close the new browser, keep old one + await newBrowser.close().catch(() => {}); + const msg = err instanceof Error ? err.message : String(err); + return `ERROR: Handoff failed during state restore — ${msg}. Headless browser still running.`; + } + } + + /** + * Resume AI control after user handoff. + * Clears stale refs and resets failure counter. + * The meta-command handler calls handleSnapshot() after this. + */ + resume(): void { + this.clearRefs(); + this.resetFailures(); + } + + getIsHeaded(): boolean { + return this.isHeaded; + } + + // ─── Auto-handoff Hint (consecutive failure tracking) ─────── + incrementFailures(): void { + this.consecutiveFailures++; + } + + resetFailures(): void { + this.consecutiveFailures = 0; + } + + getFailureHint(): string | null { + if (this.consecutiveFailures >= 3 && !this.isHeaded) { + return `HINT: ${this.consecutiveFailures} consecutive failures. Consider using 'handoff' to let the user help.`; + } + return null; + } + + // ─── Console/Network/Dialog/Ref Wiring ──────────────────── + private wirePageEvents(page: Page) { + // Clear ref map on navigation — refs point to stale elements after page change + // (lastSnapshot is NOT cleared — it's a text baseline for diffing) + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame()) { + this.clearRefs(); + } + }); + + // ─── Dialog auto-handling (prevents browser lockup) ───── + page.on('dialog', async (dialog) => { + const entry: DialogEntry = { + timestamp: Date.now(), + type: dialog.type(), + message: dialog.message(), + defaultValue: dialog.defaultValue() || undefined, + action: this.dialogAutoAccept ? 'accepted' : 'dismissed', + response: this.dialogAutoAccept ? (this.dialogPromptText ?? undefined) : undefined, + }; + addDialogEntry(entry); + + try { + if (this.dialogAutoAccept) { + await dialog.accept(this.dialogPromptText ?? undefined); + } else { + await dialog.dismiss(); + } + } catch { + // Dialog may have been dismissed by navigation — ignore + } + }); + + page.on('console', (msg) => { + addConsoleEntry({ + timestamp: Date.now(), + level: msg.type(), + text: msg.text(), + }); + }); + + page.on('request', (req) => { + addNetworkEntry({ + timestamp: Date.now(), + method: req.method(), + url: req.url(), + }); + }); + + page.on('response', (res) => { + // Find matching request entry and update it (backward scan) + const url = res.url(); + const status = res.status(); + for (let i = networkBuffer.length - 1; i >= 0; i--) { + const entry = networkBuffer.get(i); + if (entry && entry.url === url && !entry.status) { + networkBuffer.set(i, { ...entry, status, duration: Date.now() - entry.timestamp }); + break; + } + } + }); + + // Capture response sizes via response finished + page.on('requestfinished', async (req) => { + try { + const res = await req.response(); + if (res) { + const url = req.url(); + const body = await res.body().catch(() => null); + const size = body ? body.length : 0; + for (let i = networkBuffer.length - 1; i >= 0; i--) { + const entry = networkBuffer.get(i); + if (entry && entry.url === url && !entry.size) { + networkBuffer.set(i, { ...entry, size }); + break; + } + } + } + } catch {} + }); + } +} diff --git a/browse/src/buffers.ts b/browse/src/buffers.ts new file mode 100644 index 0000000..27d3796 --- /dev/null +++ b/browse/src/buffers.ts @@ -0,0 +1,137 @@ +/** + * Shared buffers and types — extracted to break circular dependency + * between server.ts and browser-manager.ts + * + * CircularBuffer: O(1) insert ring buffer with fixed capacity. + * + * ┌───┬───┬───┬───┬───┬───┐ + * │ 3 │ 4 │ 5 │ │ 1 │ 2 │ capacity=6, head=4, size=5 + * └───┴───┴───┴───┴─▲─┴───┘ + * │ + * head (oldest entry) + * + * push() writes at (head+size) % capacity, O(1) + * toArray() returns entries in insertion order, O(n) + * totalAdded keeps incrementing past capacity (flush cursor) + */ + +// ─── CircularBuffer ───────────────────────────────────────── + +export class CircularBuffer { + private buffer: (T | undefined)[]; + private head: number = 0; + private _size: number = 0; + private _totalAdded: number = 0; + readonly capacity: number; + + constructor(capacity: number) { + this.capacity = capacity; + this.buffer = new Array(capacity); + } + + push(entry: T): void { + const index = (this.head + this._size) % this.capacity; + this.buffer[index] = entry; + if (this._size < this.capacity) { + this._size++; + } else { + // Buffer full — advance head (overwrites oldest) + this.head = (this.head + 1) % this.capacity; + } + this._totalAdded++; + } + + /** Return entries in insertion order (oldest first) */ + toArray(): T[] { + const result: T[] = []; + for (let i = 0; i < this._size; i++) { + result.push(this.buffer[(this.head + i) % this.capacity] as T); + } + return result; + } + + /** Return the last N entries (most recent first → reversed to oldest first) */ + last(n: number): T[] { + const count = Math.min(n, this._size); + const result: T[] = []; + const start = (this.head + this._size - count) % this.capacity; + for (let i = 0; i < count; i++) { + result.push(this.buffer[(start + i) % this.capacity] as T); + } + return result; + } + + get length(): number { + return this._size; + } + + get totalAdded(): number { + return this._totalAdded; + } + + clear(): void { + this.head = 0; + this._size = 0; + // Don't reset totalAdded — flush cursor depends on it + } + + /** Get entry by index (0 = oldest) — used by network response matching */ + get(index: number): T | undefined { + if (index < 0 || index >= this._size) return undefined; + return this.buffer[(this.head + index) % this.capacity]; + } + + /** Set entry by index (0 = oldest) — used by network response matching */ + set(index: number, entry: T): void { + if (index < 0 || index >= this._size) return; + this.buffer[(this.head + index) % this.capacity] = entry; + } +} + +// ─── Entry Types ──────────────────────────────────────────── + +export interface LogEntry { + timestamp: number; + level: string; + text: string; +} + +export interface NetworkEntry { + timestamp: number; + method: string; + url: string; + status?: number; + duration?: number; + size?: number; +} + +export interface DialogEntry { + timestamp: number; + type: string; // 'alert' | 'confirm' | 'prompt' | 'beforeunload' + message: string; + defaultValue?: string; + action: string; // 'accepted' | 'dismissed' + response?: string; // text provided for prompt +} + +// ─── Buffer Instances ─────────────────────────────────────── + +const HIGH_WATER_MARK = 50_000; + +export const consoleBuffer = new CircularBuffer(HIGH_WATER_MARK); +export const networkBuffer = new CircularBuffer(HIGH_WATER_MARK); +export const dialogBuffer = new CircularBuffer(HIGH_WATER_MARK); + +// ─── Convenience add functions ────────────────────────────── + +export function addConsoleEntry(entry: LogEntry) { + consoleBuffer.push(entry); +} + +export function addNetworkEntry(entry: NetworkEntry) { + networkBuffer.push(entry); +} + +export function addDialogEntry(entry: DialogEntry) { + dialogBuffer.push(entry); +} diff --git a/browse/src/bun-polyfill.cjs b/browse/src/bun-polyfill.cjs new file mode 100644 index 0000000..e0ada11 --- /dev/null +++ b/browse/src/bun-polyfill.cjs @@ -0,0 +1,109 @@ +/** + * Bun API polyfill for Node.js — Windows compatibility layer. + * + * On Windows, Bun can't launch or connect to Playwright's Chromium + * (oven-sh/bun#4253, #9911). The browse server falls back to running + * under Node.js with this polyfill providing Bun API equivalents. + * + * Loaded via --require before the transpiled server bundle. + */ + +'use strict'; + +const http = require('http'); +const { spawnSync, spawn } = require('child_process'); + +globalThis.Bun = { + serve(options) { + const { port, hostname = '127.0.0.1', fetch } = options; + + const server = http.createServer(async (nodeReq, nodeRes) => { + try { + const url = `http://${hostname}:${port}${nodeReq.url}`; + const headers = new Headers(); + for (const [key, val] of Object.entries(nodeReq.headers)) { + if (val) headers.set(key, Array.isArray(val) ? val[0] : val); + } + + let body = null; + if (nodeReq.method !== 'GET' && nodeReq.method !== 'HEAD') { + body = await new Promise((resolve) => { + const chunks = []; + nodeReq.on('data', (chunk) => chunks.push(chunk)); + nodeReq.on('end', () => resolve(Buffer.concat(chunks))); + }); + } + + const webReq = new Request(url, { + method: nodeReq.method, + headers, + body, + }); + + const webRes = await fetch(webReq); + + nodeRes.statusCode = webRes.status; + webRes.headers.forEach((val, key) => { + nodeRes.setHeader(key, val); + }); + + const resBody = await webRes.arrayBuffer(); + nodeRes.end(Buffer.from(resBody)); + } catch (err) { + nodeRes.statusCode = 500; + nodeRes.end(JSON.stringify({ error: err.message })); + } + }); + + server.listen(port, hostname); + + return { + stop() { server.close(); }, + port, + hostname, + }; + }, + + spawnSync(cmd, options = {}) { + const [command, ...args] = cmd; + const result = spawnSync(command, args, { + stdio: [ + options.stdin || 'pipe', + options.stdout === 'pipe' ? 'pipe' : 'ignore', + options.stderr === 'pipe' ? 'pipe' : 'ignore', + ], + timeout: options.timeout, + env: options.env, + cwd: options.cwd, + }); + + return { + exitCode: result.status, + stdout: result.stdout || Buffer.from(''), + stderr: result.stderr || Buffer.from(''), + }; + }, + + spawn(cmd, options = {}) { + const [command, ...args] = cmd; + const stdio = options.stdio || ['pipe', 'pipe', 'pipe']; + const proc = spawn(command, args, { + stdio, + env: options.env, + cwd: options.cwd, + }); + + return { + pid: proc.pid, + stdout: proc.stdout, + stderr: proc.stderr, + stdin: proc.stdin, + unref() { proc.unref(); }, + kill(signal) { proc.kill(signal); }, + }; + }, + + sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + }, +}; diff --git a/browse/src/cli.ts b/browse/src/cli.ts new file mode 100644 index 0000000..d48fab9 --- /dev/null +++ b/browse/src/cli.ts @@ -0,0 +1,420 @@ +/** + * gstack CLI — thin wrapper that talks to the persistent server + * + * Flow: + * 1. Read .gstack/browse.json for port + token + * 2. If missing or stale PID → start server in background + * 3. Health check + version mismatch detection + * 4. Send command via HTTP POST + * 5. Print response to stdout (or stderr for errors) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { resolveConfig, ensureStateDir, readVersionHash } from './config'; + +const config = resolveConfig(); +const IS_WINDOWS = process.platform === 'win32'; +const MAX_START_WAIT = IS_WINDOWS ? 15000 : 8000; // Node+Chromium takes longer on Windows + +export function resolveServerScript( + env: Record = process.env, + metaDir: string = import.meta.dir, + execPath: string = process.execPath +): string { + if (env.BROWSE_SERVER_SCRIPT) { + return env.BROWSE_SERVER_SCRIPT; + } + + // Dev mode: cli.ts runs directly from browse/src + // On macOS/Linux, import.meta.dir starts with / + // On Windows, it starts with a drive letter (e.g., C:\...) + if (!metaDir.includes('$bunfs')) { + const direct = path.resolve(metaDir, 'server.ts'); + if (fs.existsSync(direct)) { + return direct; + } + } + + // Compiled binary: derive the source tree from browse/dist/browse + if (execPath) { + const adjacent = path.resolve(path.dirname(execPath), '..', 'src', 'server.ts'); + if (fs.existsSync(adjacent)) { + return adjacent; + } + } + + throw new Error( + 'Cannot find server.ts. Set BROWSE_SERVER_SCRIPT env or run from the browse source tree.' + ); +} + +const SERVER_SCRIPT = resolveServerScript(); + +/** + * On Windows, resolve the Node.js-compatible server bundle. + * Falls back to null if not found (server will use Bun instead). + */ +export function resolveNodeServerScript( + metaDir: string = import.meta.dir, + execPath: string = process.execPath +): string | null { + // Dev mode + if (!metaDir.includes('$bunfs')) { + const distScript = path.resolve(metaDir, '..', 'dist', 'server-node.mjs'); + if (fs.existsSync(distScript)) return distScript; + } + + // Compiled binary: browse/dist/browse → browse/dist/server-node.mjs + if (execPath) { + const adjacent = path.resolve(path.dirname(execPath), 'server-node.mjs'); + if (fs.existsSync(adjacent)) return adjacent; + } + + return null; +} + +const NODE_SERVER_SCRIPT = IS_WINDOWS ? resolveNodeServerScript() : null; + +interface ServerState { + pid: number; + port: number; + token: string; + startedAt: string; + serverPath: string; + binaryVersion?: string; +} + +// ─── State File ──────────────────────────────────────────────── +function readState(): ServerState | null { + try { + const data = fs.readFileSync(config.stateFile, 'utf-8'); + return JSON.parse(data); + } catch { + return null; + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +// ─── Process Management ───────────────────────────────────────── +async function killServer(pid: number): Promise { + if (!isProcessAlive(pid)) return; + + try { process.kill(pid, 'SIGTERM'); } catch { return; } + + // Wait up to 2s for graceful shutdown + const deadline = Date.now() + 2000; + while (Date.now() < deadline && isProcessAlive(pid)) { + await Bun.sleep(100); + } + + // Force kill if still alive + if (isProcessAlive(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch {} + } +} + +/** + * Clean up legacy /tmp/browse-server*.json files from before project-local state. + * Verifies PID ownership before sending signals. + */ +function cleanupLegacyState(): void { + try { + const files = fs.readdirSync('/tmp').filter(f => f.startsWith('browse-server') && f.endsWith('.json')); + for (const file of files) { + const fullPath = `/tmp/${file}`; + try { + const data = JSON.parse(fs.readFileSync(fullPath, 'utf-8')); + if (data.pid && isProcessAlive(data.pid)) { + // Verify this is actually a browse server before killing + const check = Bun.spawnSync(['ps', '-p', String(data.pid), '-o', 'command='], { + stdout: 'pipe', stderr: 'pipe', timeout: 2000, + }); + const cmd = check.stdout.toString().trim(); + if (cmd.includes('bun') || cmd.includes('server.ts')) { + try { process.kill(data.pid, 'SIGTERM'); } catch {} + } + } + fs.unlinkSync(fullPath); + } catch { + // Best effort — skip files we can't parse or clean up + } + } + // Clean up legacy log files too + const logFiles = fs.readdirSync('/tmp').filter(f => + f.startsWith('browse-console') || f.startsWith('browse-network') || f.startsWith('browse-dialog') + ); + for (const file of logFiles) { + try { fs.unlinkSync(`/tmp/${file}`); } catch {} + } + } catch { + // /tmp read failed — skip legacy cleanup + } +} + +// ─── Server Lifecycle ────────────────────────────────────────── +async function startServer(): Promise { + ensureStateDir(config); + + // Clean up stale state file + try { fs.unlinkSync(config.stateFile); } catch {} + + // Start server as detached background process. + // On Windows, Bun can't launch/connect to Playwright's Chromium (oven-sh/bun#4253, #9911). + // Fall back to running the server under Node.js with Bun API polyfills. + const useNode = IS_WINDOWS && NODE_SERVER_SCRIPT; + const serverCmd = useNode + ? ['node', NODE_SERVER_SCRIPT] + : ['bun', 'run', SERVER_SCRIPT]; + const proc = Bun.spawn(serverCmd, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, BROWSE_STATE_FILE: config.stateFile }, + }); + + // Don't hold the CLI open + proc.unref(); + + // Wait for state file to appear + const start = Date.now(); + while (Date.now() - start < MAX_START_WAIT) { + const state = readState(); + if (state && isProcessAlive(state.pid)) { + return state; + } + await Bun.sleep(100); + } + + // If we get here, server didn't start in time + // Try to read stderr for error message + const stderr = proc.stderr; + if (stderr) { + const reader = stderr.getReader(); + const { value } = await reader.read(); + if (value) { + const errText = new TextDecoder().decode(value); + throw new Error(`Server failed to start:\n${errText}`); + } + } + throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`); +} + +/** + * Acquire an exclusive lockfile to prevent concurrent ensureServer() races (TOCTOU). + * Returns a cleanup function that releases the lock. + */ +function acquireServerLock(): (() => void) | null { + const lockPath = `${config.stateFile}.lock`; + try { + // O_CREAT | O_EXCL — fails if file already exists (atomic check-and-create) + const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY); + fs.writeSync(fd, `${process.pid}\n`); + fs.closeSync(fd); + return () => { try { fs.unlinkSync(lockPath); } catch {} }; + } catch { + // Lock already held — check if the holder is still alive + try { + const holderPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10); + if (holderPid && isProcessAlive(holderPid)) { + return null; // Another live process holds the lock + } + // Stale lock — remove and retry + fs.unlinkSync(lockPath); + return acquireServerLock(); + } catch { + return null; + } + } +} + +async function ensureServer(): Promise { + const state = readState(); + + if (state && isProcessAlive(state.pid)) { + // Check for binary version mismatch (auto-restart on update) + const currentVersion = readVersionHash(); + if (currentVersion && state.binaryVersion && currentVersion !== state.binaryVersion) { + console.error('[browse] Binary updated, restarting server...'); + await killServer(state.pid); + return startServer(); + } + + // Server appears alive — do a health check + try { + const resp = await fetch(`http://127.0.0.1:${state.port}/health`, { + signal: AbortSignal.timeout(2000), + }); + if (resp.ok) { + const health = await resp.json() as any; + if (health.status === 'healthy') { + return state; + } + } + } catch { + // Health check failed — server is dead or unhealthy + } + } + + // Acquire lock to prevent concurrent restart races (TOCTOU) + const releaseLock = acquireServerLock(); + if (!releaseLock) { + // Another process is starting the server — wait for it + console.error('[browse] Another instance is starting the server, waiting...'); + const start = Date.now(); + while (Date.now() - start < MAX_START_WAIT) { + const freshState = readState(); + if (freshState && isProcessAlive(freshState.pid)) return freshState; + await Bun.sleep(200); + } + throw new Error('Timed out waiting for another instance to start the server'); + } + + try { + // Re-read state under lock in case another process just started the server + const freshState = readState(); + if (freshState && isProcessAlive(freshState.pid)) { + return freshState; + } + + // Kill the old server to avoid orphaned chromium processes + if (state && state.pid) { + await killServer(state.pid); + } + console.error('[browse] Starting server...'); + return await startServer(); + } finally { + releaseLock(); + } +} + +// ─── Command Dispatch ────────────────────────────────────────── +async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise { + const body = JSON.stringify({ command, args }); + + try { + const resp = await fetch(`http://127.0.0.1:${state.port}/command`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${state.token}`, + }, + body, + signal: AbortSignal.timeout(30000), + }); + + if (resp.status === 401) { + // Token mismatch — server may have restarted + console.error('[browse] Auth failed — server may have restarted. Retrying...'); + const newState = readState(); + if (newState && newState.token !== state.token) { + return sendCommand(newState, command, args); + } + throw new Error('Authentication failed'); + } + + const text = await resp.text(); + + if (resp.ok) { + process.stdout.write(text); + if (!text.endsWith('\n')) process.stdout.write('\n'); + } else { + // Try to parse as JSON error + try { + const err = JSON.parse(text); + console.error(err.error || text); + if (err.hint) console.error(err.hint); + } catch { + console.error(text); + } + process.exit(1); + } + } catch (err: any) { + if (err.name === 'AbortError') { + console.error('[browse] Command timed out after 30s'); + process.exit(1); + } + // Connection error — server may have crashed + if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) { + if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting'); + console.error('[browse] Server connection lost. Restarting...'); + // Kill the old server to avoid orphaned chromium processes + const oldState = readState(); + if (oldState && oldState.pid) { + await killServer(oldState.pid); + } + const newState = await startServer(); + return sendCommand(newState, command, args, retries + 1); + } + throw err; + } +} + +// ─── Main ────────────────────────────────────────────────────── +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + console.log(`gstack browse — Fast headless browser for AI coding agents + +Usage: browse [args...] + +Navigation: goto | back | forward | reload | url +Content: text | html [sel] | links | forms | accessibility +Interaction: click | fill | select + hover | type | press + scroll [sel] | wait | viewport + upload [file2...] + cookie-import + cookie-import-browser [browser] [--domain ] +Inspection: js | eval | css | attrs + console [--clear|--errors] | network [--clear] | dialog [--clear] + cookies | storage [set ] | perf + is (visible|hidden|enabled|disabled|checked|editable|focused) +Visual: screenshot [--viewport] [--clip x,y,w,h] [@ref|sel] [path] + pdf [path] | responsive [prefix] +Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C] + -D/--diff: diff against previous snapshot + -a/--annotate: annotated screenshot with ref labels + -C/--cursor-interactive: find non-ARIA clickable elements +Compare: diff +Multi-step: chain (reads JSON from stdin) +Tabs: tabs | tab | newtab [url] | closetab [id] +Server: status | cookie = | header : + useragent | stop | restart +Dialogs: dialog-accept [text] | dialog-dismiss + +Refs: After 'snapshot', use @e1, @e2... as selectors: + click @e3 | fill @e4 "value" | hover @e1 + @c refs from -C: click @c1`); + process.exit(0); + } + + // One-time cleanup of legacy /tmp state files + cleanupLegacyState(); + + const command = args[0]; + const commandArgs = args.slice(1); + + // Special case: chain reads from stdin + if (command === 'chain' && commandArgs.length === 0) { + const stdin = await Bun.stdin.text(); + commandArgs.push(stdin.trim()); + } + + const state = await ensureServer(); + await sendCommand(state, command, commandArgs); +} + +if (import.meta.main) { + main().catch((err) => { + console.error(`[browse] ${err.message}`); + process.exit(1); + }); +} diff --git a/browse/src/commands.ts b/browse/src/commands.ts new file mode 100644 index 0000000..c3509af --- /dev/null +++ b/browse/src/commands.ts @@ -0,0 +1,111 @@ +/** + * Command registry — single source of truth for all browse commands. + * + * Dependency graph: + * commands.ts ──▶ server.ts (runtime dispatch) + * ──▶ gen-skill-docs.ts (doc generation) + * ──▶ skill-parser.ts (validation) + * ──▶ skill-check.ts (health reporting) + * + * Zero side effects. Safe to import from build scripts and tests. + */ + +export const READ_COMMANDS = new Set([ + 'text', 'html', 'links', 'forms', 'accessibility', + 'js', 'eval', 'css', 'attrs', + 'console', 'network', 'cookies', 'storage', 'perf', + 'dialog', 'is', +]); + +export const WRITE_COMMANDS = new Set([ + 'goto', 'back', 'forward', 'reload', + 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', + 'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent', + 'upload', 'dialog-accept', 'dialog-dismiss', +]); + +export const META_COMMANDS = new Set([ + 'tabs', 'tab', 'newtab', 'closetab', + 'status', 'stop', 'restart', + 'screenshot', 'pdf', 'responsive', + 'chain', 'diff', + 'url', 'snapshot', + 'handoff', 'resume', +]); + +export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]); + +export const COMMAND_DESCRIPTIONS: Record = { + // Navigation + 'goto': { category: 'Navigation', description: 'Navigate to URL', usage: 'goto ' }, + 'back': { category: 'Navigation', description: 'History back' }, + 'forward': { category: 'Navigation', description: 'History forward' }, + 'reload': { category: 'Navigation', description: 'Reload page' }, + 'url': { category: 'Navigation', description: 'Print current URL' }, + // Reading + 'text': { category: 'Reading', description: 'Cleaned page text' }, + 'html': { category: 'Reading', description: 'innerHTML of selector (throws if not found), or full page HTML if no selector given', usage: 'html [selector]' }, + 'links': { category: 'Reading', description: 'All links as "text → href"' }, + 'forms': { category: 'Reading', description: 'Form fields as JSON' }, + 'accessibility': { category: 'Reading', description: 'Full ARIA tree' }, + // Inspection + 'js': { category: 'Inspection', description: 'Run JavaScript expression and return result as string', usage: 'js ' }, + 'eval': { category: 'Inspection', description: 'Run JavaScript from file and return result as string (path must be under /tmp or cwd)', usage: 'eval ' }, + 'css': { category: 'Inspection', description: 'Computed CSS value', usage: 'css ' }, + 'attrs': { category: 'Inspection', description: 'Element attributes as JSON', usage: 'attrs ' }, + 'is': { category: 'Inspection', description: 'State check (visible/hidden/enabled/disabled/checked/editable/focused)', usage: 'is ' }, + 'console': { category: 'Inspection', description: 'Console messages (--errors filters to error/warning)', usage: 'console [--clear|--errors]' }, + 'network': { category: 'Inspection', description: 'Network requests', usage: 'network [--clear]' }, + 'dialog': { category: 'Inspection', description: 'Dialog messages', usage: 'dialog [--clear]' }, + 'cookies': { category: 'Inspection', description: 'All cookies as JSON' }, + 'storage': { category: 'Inspection', description: 'Read all localStorage + sessionStorage as JSON, or set to write localStorage', usage: 'storage [set k v]' }, + 'perf': { category: 'Inspection', description: 'Page load timings' }, + // Interaction + 'click': { category: 'Interaction', description: 'Click element', usage: 'click ' }, + 'fill': { category: 'Interaction', description: 'Fill input', usage: 'fill ' }, + 'select': { category: 'Interaction', description: 'Select dropdown option by value, label, or visible text', usage: 'select ' }, + 'hover': { category: 'Interaction', description: 'Hover element', usage: 'hover ' }, + 'type': { category: 'Interaction', description: 'Type into focused element', usage: 'type ' }, + 'press': { category: 'Interaction', description: 'Press key — Enter, Tab, Escape, ArrowUp/Down/Left/Right, Backspace, Delete, Home, End, PageUp, PageDown, or modifiers like Shift+Enter', usage: 'press ' }, + 'scroll': { category: 'Interaction', description: 'Scroll element into view, or scroll to page bottom if no selector', usage: 'scroll [sel]' }, + 'wait': { category: 'Interaction', description: 'Wait for element, network idle, or page load (timeout: 15s)', usage: 'wait ' }, + 'upload': { category: 'Interaction', description: 'Upload file(s)', usage: 'upload [file2...]' }, + 'viewport':{ category: 'Interaction', description: 'Set viewport size', usage: 'viewport ' }, + 'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie =' }, + 'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import ' }, + 'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' }, + 'header': { category: 'Interaction', description: 'Set custom request header (colon-separated, sensitive values auto-redacted)', usage: 'header :' }, + 'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent ' }, + 'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' }, + 'dialog-dismiss': { category: 'Interaction', description: 'Auto-dismiss next dialog' }, + // Visual + 'screenshot': { category: 'Visual', description: 'Save screenshot (supports element crop via CSS/@ref, --clip region, --viewport)', usage: 'screenshot [--viewport] [--clip x,y,w,h] [selector|@ref] [path]' }, + 'pdf': { category: 'Visual', description: 'Save as PDF', usage: 'pdf [path]' }, + 'responsive': { category: 'Visual', description: 'Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc.', usage: 'responsive [prefix]' }, + 'diff': { category: 'Visual', description: 'Text diff between pages', usage: 'diff ' }, + // Tabs + 'tabs': { category: 'Tabs', description: 'List open tabs' }, + 'tab': { category: 'Tabs', description: 'Switch to tab', usage: 'tab ' }, + 'newtab': { category: 'Tabs', description: 'Open new tab', usage: 'newtab [url]' }, + 'closetab':{ category: 'Tabs', description: 'Close tab', usage: 'closetab [id]' }, + // Server + 'status': { category: 'Server', description: 'Health check' }, + 'stop': { category: 'Server', description: 'Shutdown server' }, + 'restart': { category: 'Server', description: 'Restart server' }, + // Meta + 'snapshot':{ category: 'Snapshot', description: 'Accessibility tree with @e refs for element selection. Flags: -i interactive only, -c compact, -d N depth limit, -s sel scope, -D diff vs previous, -a annotated screenshot, -o path output, -C cursor-interactive @c refs', usage: 'snapshot [flags]' }, + 'chain': { category: 'Meta', description: 'Run commands from JSON stdin. Format: [["cmd","arg1",...],...]' }, + // Handoff + 'handoff': { category: 'Server', description: 'Open visible Chrome at current page for user takeover', usage: 'handoff [message]' }, + 'resume': { category: 'Server', description: 'Re-snapshot after user takeover, return control to AI', usage: 'resume' }, +}; + +// Load-time validation: descriptions must cover exactly the command sets +const allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]); +const descKeys = new Set(Object.keys(COMMAND_DESCRIPTIONS)); +for (const cmd of allCmds) { + if (!descKeys.has(cmd)) throw new Error(`COMMAND_DESCRIPTIONS missing entry for: ${cmd}`); +} +for (const key of descKeys) { + if (!allCmds.has(key)) throw new Error(`COMMAND_DESCRIPTIONS has unknown command: ${key}`); +} diff --git a/browse/src/config.ts b/browse/src/config.ts new file mode 100644 index 0000000..04f1664 --- /dev/null +++ b/browse/src/config.ts @@ -0,0 +1,150 @@ +/** + * Shared config for browse CLI + server. + * + * Resolution: + * 1. BROWSE_STATE_FILE env → derive stateDir from parent + * 2. git rev-parse --show-toplevel → projectDir/.gstack/ + * 3. process.cwd() fallback (non-git environments) + * + * The CLI computes the config and passes BROWSE_STATE_FILE to the + * spawned server. The server derives all paths from that env var. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +export interface BrowseConfig { + projectDir: string; + stateDir: string; + stateFile: string; + consoleLog: string; + networkLog: string; + dialogLog: string; +} + +/** + * Detect the git repository root, or null if not in a repo / git unavailable. + */ +export function getGitRoot(): string | null { + try { + const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { + stdout: 'pipe', + stderr: 'pipe', + timeout: 2_000, // Don't hang if .git is broken + }); + if (proc.exitCode !== 0) return null; + return proc.stdout.toString().trim() || null; + } catch { + return null; + } +} + +/** + * Resolve all browse config paths. + * + * If BROWSE_STATE_FILE is set (e.g. by CLI when spawning server, or by + * tests for isolation), all paths are derived from it. Otherwise, the + * project root is detected via git or cwd. + */ +export function resolveConfig( + env: Record = process.env, +): BrowseConfig { + let stateFile: string; + let stateDir: string; + let projectDir: string; + + if (env.BROWSE_STATE_FILE) { + stateFile = env.BROWSE_STATE_FILE; + stateDir = path.dirname(stateFile); + projectDir = path.dirname(stateDir); // parent of .gstack/ + } else { + projectDir = getGitRoot() || process.cwd(); + stateDir = path.join(projectDir, '.gstack'); + stateFile = path.join(stateDir, 'browse.json'); + } + + return { + projectDir, + stateDir, + stateFile, + consoleLog: path.join(stateDir, 'browse-console.log'), + networkLog: path.join(stateDir, 'browse-network.log'), + dialogLog: path.join(stateDir, 'browse-dialog.log'), + }; +} + +/** + * Create the .gstack/ state directory if it doesn't exist. + * Throws with a clear message on permission errors. + */ +export function ensureStateDir(config: BrowseConfig): void { + try { + fs.mkdirSync(config.stateDir, { recursive: true }); + } catch (err: any) { + if (err.code === 'EACCES') { + throw new Error(`Cannot create state directory ${config.stateDir}: permission denied`); + } + if (err.code === 'ENOTDIR') { + throw new Error(`Cannot create state directory ${config.stateDir}: a file exists at that path`); + } + throw err; + } + + // Ensure .gstack/ is in the project's .gitignore + const gitignorePath = path.join(config.projectDir, '.gitignore'); + try { + const content = fs.readFileSync(gitignorePath, 'utf-8'); + if (!content.match(/^\.gstack\/?$/m)) { + const separator = content.endsWith('\n') ? '' : '\n'; + fs.appendFileSync(gitignorePath, `${separator}.gstack/\n`); + } + } catch (err: any) { + if (err.code !== 'ENOENT') { + // Write warning to server log (visible even in daemon mode) + const logPath = path.join(config.stateDir, 'browse-server.log'); + try { + fs.appendFileSync(logPath, `[${new Date().toISOString()}] Warning: could not update .gitignore at ${gitignorePath}: ${err.message}\n`); + } catch { + // stateDir write failed too — nothing more we can do + } + } + // ENOENT (no .gitignore) — skip silently + } +} + +/** + * Derive a slug from the git remote origin URL (owner-repo format). + * Falls back to the directory basename if no remote is configured. + */ +export function getRemoteSlug(): string { + try { + const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], { + stdout: 'pipe', + stderr: 'pipe', + timeout: 2_000, + }); + if (proc.exitCode !== 0) throw new Error('no remote'); + const url = proc.stdout.toString().trim(); + // SSH: git@github.com:owner/repo.git → owner-repo + // HTTPS: https://github.com/owner/repo.git → owner-repo + const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/); + if (match) return `${match[1]}-${match[2]}`; + throw new Error('unparseable'); + } catch { + const root = getGitRoot(); + return path.basename(root || process.cwd()); + } +} + +/** + * Read the binary version (git SHA) from browse/dist/.version. + * Returns null if the file doesn't exist or can't be read. + */ +export function readVersionHash(execPath: string = process.execPath): string | null { + try { + const versionFile = path.resolve(path.dirname(execPath), '.version'); + return fs.readFileSync(versionFile, 'utf-8').trim() || null; + } catch { + return null; + } +} diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts new file mode 100644 index 0000000..29d9db3 --- /dev/null +++ b/browse/src/cookie-import-browser.ts @@ -0,0 +1,417 @@ +/** + * Chromium browser cookie import — read and decrypt cookies from real browsers + * + * Supports macOS Chromium-based browsers: Comet, Chrome, Arc, Brave, Edge. + * Pure logic module — no Playwright dependency, no HTTP concerns. + * + * Decryption pipeline (Chromium macOS "v10" format): + * + * ┌──────────────────────────────────────────────────────────────────┐ + * │ 1. Keychain: `security find-generic-password -s "" -w` │ + * │ → base64 password string │ + * │ │ + * │ 2. Key derivation: │ + * │ PBKDF2(password, salt="saltysalt", iter=1003, len=16, sha1) │ + * │ → 16-byte AES key │ + * │ │ + * │ 3. For each cookie with encrypted_value starting with "v10": │ + * │ - Ciphertext = encrypted_value[3:] │ + * │ - IV = 16 bytes of 0x20 (space character) │ + * │ - Plaintext = AES-128-CBC-decrypt(key, iv, ciphertext) │ + * │ - Remove PKCS7 padding │ + * │ - Skip first 32 bytes (HMAC-SHA256 authentication tag) │ + * │ - Remaining bytes = cookie value (UTF-8) │ + * │ │ + * │ 4. If encrypted_value is empty but `value` field is set, │ + * │ use value directly (unencrypted cookie) │ + * │ │ + * │ 5. Chromium epoch: microseconds since 1601-01-01 │ + * │ Unix seconds = (epoch - 11644473600000000) / 1000000 │ + * │ │ + * │ 6. sameSite: 0→"None", 1→"Lax", 2→"Strict", else→"Lax" │ + * └──────────────────────────────────────────────────────────────────┘ + */ + +import { Database } from 'bun:sqlite'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// ─── Types ────────────────────────────────────────────────────── + +export interface BrowserInfo { + name: string; + dataDir: string; // relative to ~/Library/Application Support/ + keychainService: string; + aliases: string[]; +} + +export interface DomainEntry { + domain: string; + count: number; +} + +export interface ImportResult { + cookies: PlaywrightCookie[]; + count: number; + failed: number; + domainCounts: Record; +} + +export interface PlaywrightCookie { + name: string; + value: string; + domain: string; + path: string; + expires: number; + secure: boolean; + httpOnly: boolean; + sameSite: 'Strict' | 'Lax' | 'None'; +} + +export class CookieImportError extends Error { + constructor( + message: string, + public code: string, + public action?: 'retry', + ) { + super(message); + this.name = 'CookieImportError'; + } +} + +// ─── Browser Registry ─────────────────────────────────────────── +// Hardcoded — NEVER interpolate user input into shell commands. + +const BROWSER_REGISTRY: BrowserInfo[] = [ + { name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] }, + { name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome'] }, + { name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] }, + { name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'] }, + { name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'] }, +]; + +// ─── Key Cache ────────────────────────────────────────────────── +// Cache derived AES keys per browser. First import per browser does +// Keychain + PBKDF2. Subsequent imports reuse the cached key. + +const keyCache = new Map(); + +// ─── Public API ───────────────────────────────────────────────── + +/** + * Find which browsers are installed (have a cookie DB on disk). + */ +export function findInstalledBrowsers(): BrowserInfo[] { + const appSupport = path.join(os.homedir(), 'Library', 'Application Support'); + return BROWSER_REGISTRY.filter(b => { + const dbPath = path.join(appSupport, b.dataDir, 'Default', 'Cookies'); + try { return fs.existsSync(dbPath); } catch { return false; } + }); +} + +/** + * List unique cookie domains + counts from a browser's DB. No decryption. + */ +export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } { + const browser = resolveBrowser(browserName); + const dbPath = getCookieDbPath(browser, profile); + const db = openDb(dbPath, browser.name); + try { + const now = chromiumNow(); + const rows = db.query( + `SELECT host_key AS domain, COUNT(*) AS count + FROM cookies + WHERE has_expires = 0 OR expires_utc > ? + GROUP BY host_key + ORDER BY count DESC` + ).all(now) as DomainEntry[]; + return { domains: rows, browser: browser.name }; + } finally { + db.close(); + } +} + +/** + * Decrypt and return Playwright-compatible cookies for specific domains. + */ +export async function importCookies( + browserName: string, + domains: string[], + profile = 'Default', +): Promise { + if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} }; + + const browser = resolveBrowser(browserName); + const derivedKey = await getDerivedKey(browser); + const dbPath = getCookieDbPath(browser, profile); + const db = openDb(dbPath, browser.name); + + try { + const now = chromiumNow(); + // Parameterized query — no SQL injection + const placeholders = domains.map(() => '?').join(','); + const rows = db.query( + `SELECT host_key, name, value, encrypted_value, path, expires_utc, + is_secure, is_httponly, has_expires, samesite + FROM cookies + WHERE host_key IN (${placeholders}) + AND (has_expires = 0 OR expires_utc > ?) + ORDER BY host_key, name` + ).all(...domains, now) as RawCookie[]; + + const cookies: PlaywrightCookie[] = []; + let failed = 0; + const domainCounts: Record = {}; + + for (const row of rows) { + try { + const value = decryptCookieValue(row, derivedKey); + const cookie = toPlaywrightCookie(row, value); + cookies.push(cookie); + domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1; + } catch { + failed++; + } + } + + return { cookies, count: cookies.length, failed, domainCounts }; + } finally { + db.close(); + } +} + +// ─── Internal: Browser Resolution ─────────────────────────────── + +function resolveBrowser(nameOrAlias: string): BrowserInfo { + const needle = nameOrAlias.toLowerCase().trim(); + const found = BROWSER_REGISTRY.find(b => + b.aliases.includes(needle) || b.name.toLowerCase() === needle + ); + if (!found) { + const supported = BROWSER_REGISTRY.flatMap(b => b.aliases).join(', '); + throw new CookieImportError( + `Unknown browser '${nameOrAlias}'. Supported: ${supported}`, + 'unknown_browser', + ); + } + return found; +} + +function validateProfile(profile: string): void { + if (/[/\\]|\.\./.test(profile) || /[\x00-\x1f]/.test(profile)) { + throw new CookieImportError( + `Invalid profile name: '${profile}'`, + 'bad_request', + ); + } +} + +function getCookieDbPath(browser: BrowserInfo, profile: string): string { + validateProfile(profile); + const appSupport = path.join(os.homedir(), 'Library', 'Application Support'); + const dbPath = path.join(appSupport, browser.dataDir, profile, 'Cookies'); + if (!fs.existsSync(dbPath)) { + throw new CookieImportError( + `${browser.name} is not installed (no cookie database at ${dbPath})`, + 'not_installed', + ); + } + return dbPath; +} + +// ─── Internal: SQLite Access ──────────────────────────────────── + +function openDb(dbPath: string, browserName: string): Database { + try { + return new Database(dbPath, { readonly: true }); + } catch (err: any) { + if (err.message?.includes('SQLITE_BUSY') || err.message?.includes('database is locked')) { + return openDbFromCopy(dbPath, browserName); + } + if (err.message?.includes('SQLITE_CORRUPT') || err.message?.includes('malformed')) { + throw new CookieImportError( + `Cookie database for ${browserName} is corrupt`, + 'db_corrupt', + ); + } + throw err; + } +} + +function openDbFromCopy(dbPath: string, browserName: string): Database { + const tmpPath = `/tmp/browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`; + try { + fs.copyFileSync(dbPath, tmpPath); + // Also copy WAL and SHM if they exist (for consistent reads) + const walPath = dbPath + '-wal'; + const shmPath = dbPath + '-shm'; + if (fs.existsSync(walPath)) fs.copyFileSync(walPath, tmpPath + '-wal'); + if (fs.existsSync(shmPath)) fs.copyFileSync(shmPath, tmpPath + '-shm'); + + const db = new Database(tmpPath, { readonly: true }); + // Schedule cleanup after the DB is closed + const origClose = db.close.bind(db); + db.close = () => { + origClose(); + try { fs.unlinkSync(tmpPath); } catch {} + try { fs.unlinkSync(tmpPath + '-wal'); } catch {} + try { fs.unlinkSync(tmpPath + '-shm'); } catch {} + }; + return db; + } catch { + // Clean up on failure + try { fs.unlinkSync(tmpPath); } catch {} + throw new CookieImportError( + `Cookie database is locked (${browserName} may be running). Try closing ${browserName} first.`, + 'db_locked', + 'retry', + ); + } +} + +// ─── Internal: Keychain Access (async, 10s timeout) ───────────── + +async function getDerivedKey(browser: BrowserInfo): Promise { + const cached = keyCache.get(browser.keychainService); + if (cached) return cached; + + const password = await getKeychainPassword(browser.keychainService); + const derived = crypto.pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1'); + keyCache.set(browser.keychainService, derived); + return derived; +} + +async function getKeychainPassword(service: string): Promise { + // Use async Bun.spawn with timeout to avoid blocking the event loop. + // macOS may show an Allow/Deny dialog that blocks until the user responds. + const proc = Bun.spawn( + ['security', 'find-generic-password', '-s', service, '-w'], + { stdout: 'pipe', stderr: 'pipe' }, + ); + + const timeout = new Promise((_, reject) => + setTimeout(() => { + proc.kill(); + reject(new CookieImportError( + `macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`, + 'keychain_timeout', + 'retry', + )); + }, 10_000), + ); + + try { + const exitCode = await Promise.race([proc.exited, timeout]); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + + if (exitCode !== 0) { + // Distinguish denied vs not found vs other + const errText = stderr.trim().toLowerCase(); + if (errText.includes('user canceled') || errText.includes('denied') || errText.includes('interaction not allowed')) { + throw new CookieImportError( + `Keychain access denied. Click "Allow" in the macOS dialog for "${service}".`, + 'keychain_denied', + 'retry', + ); + } + if (errText.includes('could not be found') || errText.includes('not found')) { + throw new CookieImportError( + `No Keychain entry for "${service}". Is this a Chromium-based browser?`, + 'keychain_not_found', + ); + } + throw new CookieImportError( + `Could not read Keychain: ${stderr.trim()}`, + 'keychain_error', + 'retry', + ); + } + + return stdout.trim(); + } catch (err) { + if (err instanceof CookieImportError) throw err; + throw new CookieImportError( + `Could not read Keychain: ${(err as Error).message}`, + 'keychain_error', + 'retry', + ); + } +} + +// ─── Internal: Cookie Decryption ──────────────────────────────── + +interface RawCookie { + host_key: string; + name: string; + value: string; + encrypted_value: Buffer | Uint8Array; + path: string; + expires_utc: number | bigint; + is_secure: number; + is_httponly: number; + has_expires: number; + samesite: number; +} + +function decryptCookieValue(row: RawCookie, key: Buffer): string { + // Prefer unencrypted value if present + if (row.value && row.value.length > 0) return row.value; + + const ev = Buffer.from(row.encrypted_value); + if (ev.length === 0) return ''; + + const prefix = ev.slice(0, 3).toString('utf-8'); + if (prefix !== 'v10') { + throw new Error(`Unknown encryption prefix: ${prefix}`); + } + + const ciphertext = ev.slice(3); + const iv = Buffer.alloc(16, 0x20); // 16 space characters + const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + + // First 32 bytes are HMAC-SHA256 authentication tag; actual value follows + if (plaintext.length <= 32) return ''; + return plaintext.slice(32).toString('utf-8'); +} + +function toPlaywrightCookie(row: RawCookie, value: string): PlaywrightCookie { + return { + name: row.name, + value, + domain: row.host_key, + path: row.path || '/', + expires: chromiumEpochToUnix(row.expires_utc, row.has_expires), + secure: row.is_secure === 1, + httpOnly: row.is_httponly === 1, + sameSite: mapSameSite(row.samesite), + }; +} + +// ─── Internal: Chromium Epoch Conversion ──────────────────────── + +const CHROMIUM_EPOCH_OFFSET = 11644473600000000n; + +function chromiumNow(): bigint { + // Current time in Chromium epoch (microseconds since 1601-01-01) + return BigInt(Date.now()) * 1000n + CHROMIUM_EPOCH_OFFSET; +} + +function chromiumEpochToUnix(epoch: number | bigint, hasExpires: number): number { + if (hasExpires === 0 || epoch === 0 || epoch === 0n) return -1; // session cookie + const epochBig = BigInt(epoch); + const unixMicro = epochBig - CHROMIUM_EPOCH_OFFSET; + return Number(unixMicro / 1000000n); +} + +function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' { + switch (value) { + case 0: return 'None'; + case 1: return 'Lax'; + case 2: return 'Strict'; + default: return 'Lax'; + } +} diff --git a/browse/src/cookie-picker-routes.ts b/browse/src/cookie-picker-routes.ts new file mode 100644 index 0000000..6a4a431 --- /dev/null +++ b/browse/src/cookie-picker-routes.ts @@ -0,0 +1,207 @@ +/** + * Cookie picker route handler — HTTP + Playwright glue + * + * Handles all /cookie-picker/* routes. Imports from cookie-import-browser.ts + * (decryption) and cookie-picker-ui.ts (HTML generation). + * + * Routes (no auth — localhost-only, accepted risk): + * GET /cookie-picker → serves the picker HTML page + * GET /cookie-picker/browsers → list installed browsers + * GET /cookie-picker/domains → list domains + counts for a browser + * POST /cookie-picker/import → decrypt + import cookies to Playwright + * POST /cookie-picker/remove → clear cookies for domains + * GET /cookie-picker/imported → currently imported domains + counts + */ + +import type { BrowserManager } from './browser-manager'; +import { findInstalledBrowsers, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser'; +import { getCookiePickerHTML } from './cookie-picker-ui'; + +// ─── State ────────────────────────────────────────────────────── +// Tracks which domains were imported via the picker. +// /imported only returns cookies for domains in this Set. +// /remove clears from this Set. +const importedDomains = new Set(); +const importedCounts = new Map(); + +// ─── JSON Helpers ─────────────────────────────────────────────── + +function corsOrigin(port: number): string { + return `http://127.0.0.1:${port}`; +} + +function jsonResponse(data: any, opts: { port: number; status?: number }): Response { + return new Response(JSON.stringify(data), { + status: opts.status ?? 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': corsOrigin(opts.port), + }, + }); +} + +function errorResponse(message: string, code: string, opts: { port: number; status?: number; action?: string }): Response { + return jsonResponse( + { error: message, code, ...(opts.action ? { action: opts.action } : {}) }, + { port: opts.port, status: opts.status ?? 400 }, + ); +} + +// ─── Route Handler ────────────────────────────────────────────── + +export async function handleCookiePickerRoute( + url: URL, + req: Request, + bm: BrowserManager, +): Promise { + const pathname = url.pathname; + const port = parseInt(url.port, 10) || 9400; + + // CORS preflight + if (req.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': corsOrigin(port), + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); + } + + try { + // GET /cookie-picker — serve the picker UI + if (pathname === '/cookie-picker' && req.method === 'GET') { + const html = getCookiePickerHTML(port); + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + // GET /cookie-picker/browsers — list installed browsers + if (pathname === '/cookie-picker/browsers' && req.method === 'GET') { + const browsers = findInstalledBrowsers(); + return jsonResponse({ + browsers: browsers.map(b => ({ + name: b.name, + aliases: b.aliases, + })), + }, { port }); + } + + // GET /cookie-picker/domains?browser= — list domains + counts + if (pathname === '/cookie-picker/domains' && req.method === 'GET') { + const browserName = url.searchParams.get('browser'); + if (!browserName) { + return errorResponse("Missing 'browser' parameter", 'missing_param', { port }); + } + const result = listDomains(browserName); + return jsonResponse({ + browser: result.browser, + domains: result.domains, + }, { port }); + } + + // POST /cookie-picker/import — decrypt + import to Playwright session + if (pathname === '/cookie-picker/import' && req.method === 'POST') { + let body: any; + try { + body = await req.json(); + } catch { + return errorResponse('Invalid JSON body', 'bad_request', { port }); + } + + const { browser, domains } = body; + if (!browser) return errorResponse("Missing 'browser' field", 'missing_param', { port }); + if (!domains || !Array.isArray(domains) || domains.length === 0) { + return errorResponse("Missing or empty 'domains' array", 'missing_param', { port }); + } + + // Decrypt cookies from the browser DB + const result = await importCookies(browser, domains); + + if (result.cookies.length === 0) { + return jsonResponse({ + imported: 0, + failed: result.failed, + domainCounts: {}, + message: result.failed > 0 + ? `All ${result.failed} cookies failed to decrypt` + : 'No cookies found for the specified domains', + }, { port }); + } + + // Add to Playwright context + const page = bm.getPage(); + await page.context().addCookies(result.cookies); + + // Track what was imported + for (const domain of Object.keys(result.domainCounts)) { + importedDomains.add(domain); + importedCounts.set(domain, (importedCounts.get(domain) || 0) + result.domainCounts[domain]); + } + + console.log(`[cookie-picker] Imported ${result.count} cookies for ${Object.keys(result.domainCounts).length} domains`); + + return jsonResponse({ + imported: result.count, + failed: result.failed, + domainCounts: result.domainCounts, + }, { port }); + } + + // POST /cookie-picker/remove — clear cookies for domains + if (pathname === '/cookie-picker/remove' && req.method === 'POST') { + let body: any; + try { + body = await req.json(); + } catch { + return errorResponse('Invalid JSON body', 'bad_request', { port }); + } + + const { domains } = body; + if (!domains || !Array.isArray(domains) || domains.length === 0) { + return errorResponse("Missing or empty 'domains' array", 'missing_param', { port }); + } + + const page = bm.getPage(); + const context = page.context(); + for (const domain of domains) { + await context.clearCookies({ domain }); + importedDomains.delete(domain); + importedCounts.delete(domain); + } + + console.log(`[cookie-picker] Removed cookies for ${domains.length} domains`); + + return jsonResponse({ + removed: domains.length, + domains, + }, { port }); + } + + // GET /cookie-picker/imported — currently imported domains + counts + if (pathname === '/cookie-picker/imported' && req.method === 'GET') { + const entries: Array<{ domain: string; count: number }> = []; + for (const domain of importedDomains) { + entries.push({ domain, count: importedCounts.get(domain) || 0 }); + } + entries.sort((a, b) => b.count - a.count); + + return jsonResponse({ + domains: entries, + totalDomains: entries.length, + totalCookies: entries.reduce((sum, e) => sum + e.count, 0), + }, { port }); + } + + return new Response('Not found', { status: 404 }); + } catch (err: any) { + if (err instanceof CookieImportError) { + return errorResponse(err.message, err.code, { port, status: 400, action: err.action }); + } + console.error(`[cookie-picker] Error: ${err.message}`); + return errorResponse(err.message || 'Internal error', 'internal_error', { port, status: 500 }); + } +} diff --git a/browse/src/cookie-picker-ui.ts b/browse/src/cookie-picker-ui.ts new file mode 100644 index 0000000..010c2dd --- /dev/null +++ b/browse/src/cookie-picker-ui.ts @@ -0,0 +1,541 @@ +/** + * Cookie picker UI — self-contained HTML page + * + * Dark theme, two-panel layout, vanilla HTML/CSS/JS. + * Left: source browser domains with search + import buttons. + * Right: imported domains with trash buttons. + * No cookie values exposed anywhere. + */ + +export function getCookiePickerHTML(serverPort: number): string { + const baseUrl = `http://127.0.0.1:${serverPort}`; + + return ` + + + + +Cookie Import — gstack browse + + + + +
+

Cookie Import

+ localhost:${serverPort} +
+ + + +
+ +
+
Source Browser
+
+
+ +
+
+
Detecting browsers...
+
+ +
+ + +
+
Imported to Session
+
+
No cookies imported yet
+
+ +
+
+ + + +`; +} diff --git a/browse/src/find-browse.ts b/browse/src/find-browse.ts new file mode 100644 index 0000000..93c4a26 --- /dev/null +++ b/browse/src/find-browse.ts @@ -0,0 +1,61 @@ +/** + * find-browse — locate the gstack browse binary. + * + * Compiled to browse/dist/find-browse (standalone binary, no bun runtime needed). + * Outputs the absolute path to the browse binary on stdout, or exits 1 if not found. + */ + +import { existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +// ─── Binary Discovery ─────────────────────────────────────────── + +function getGitRoot(): string | null { + try { + const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { + stdout: 'pipe', + stderr: 'pipe', + }); + if (proc.exitCode !== 0) return null; + return proc.stdout.toString().trim(); + } catch { + return null; + } +} + +export function locateBinary(): string | null { + const root = getGitRoot(); + const home = homedir(); + const markers = ['.codex', '.agents', '.claude']; + + // Workspace-local takes priority (for development) + if (root) { + for (const m of markers) { + const local = join(root, m, 'skills', 'gstack', 'browse', 'dist', 'browse'); + if (existsSync(local)) return local; + } + } + + // Global fallback + for (const m of markers) { + const global = join(home, m, 'skills', 'gstack', 'browse', 'dist', 'browse'); + if (existsSync(global)) return global; + } + + return null; +} + +// ─── Main ─────────────────────────────────────────────────────── + +function main() { + const bin = locateBinary(); + if (!bin) { + process.stderr.write('ERROR: browse binary not found. Run: cd && ./setup\n'); + process.exit(1); + } + + console.log(bin); +} + +main(); diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts new file mode 100644 index 0000000..16ed7f8 --- /dev/null +++ b/browse/src/meta-commands.ts @@ -0,0 +1,269 @@ +/** + * Meta commands — tabs, server control, screenshots, chain, diff, snapshot + */ + +import type { BrowserManager } from './browser-manager'; +import { handleSnapshot } from './snapshot'; +import { getCleanText } from './read-commands'; +import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; +import { validateNavigationUrl } from './url-validation'; +import * as Diff from 'diff'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TEMP_DIR, isPathWithin } from './platform'; + +// Security: Path validation to prevent path traversal attacks +const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()]; + +export function validateOutputPath(filePath: string): void { + const resolved = path.resolve(filePath); + const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir)); + if (!isSafe) { + throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`); + } +} + +export async function handleMetaCommand( + command: string, + args: string[], + bm: BrowserManager, + shutdown: () => Promise | void +): Promise { + switch (command) { + // ─── Tabs ────────────────────────────────────────── + case 'tabs': { + const tabs = await bm.getTabListWithTitles(); + return tabs.map(t => + `${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'} — ${t.url}` + ).join('\n'); + } + + case 'tab': { + const id = parseInt(args[0], 10); + if (isNaN(id)) throw new Error('Usage: browse tab '); + bm.switchTab(id); + return `Switched to tab ${id}`; + } + + case 'newtab': { + const url = args[0]; + const id = await bm.newTab(url); + return `Opened tab ${id}${url ? ` → ${url}` : ''}`; + } + + case 'closetab': { + const id = args[0] ? parseInt(args[0], 10) : undefined; + await bm.closeTab(id); + return `Closed tab${id ? ` ${id}` : ''}`; + } + + // ─── Server Control ──────────────────────────────── + case 'status': { + const page = bm.getPage(); + const tabs = bm.getTabCount(); + return [ + `Status: healthy`, + `URL: ${page.url()}`, + `Tabs: ${tabs}`, + `PID: ${process.pid}`, + ].join('\n'); + } + + case 'url': { + return bm.getCurrentUrl(); + } + + case 'stop': { + await shutdown(); + return 'Server stopped'; + } + + case 'restart': { + // Signal that we want a restart — the CLI will detect exit and restart + console.log('[browse] Restart requested. Exiting for CLI to restart.'); + await shutdown(); + return 'Restarting...'; + } + + // ─── Visual ──────────────────────────────────────── + case 'screenshot': { + // Parse priority: flags (--viewport, --clip) → selector (@ref, CSS) → output path + const page = bm.getPage(); + let outputPath = `${TEMP_DIR}/browse-screenshot.png`; + let clipRect: { x: number; y: number; width: number; height: number } | undefined; + let targetSelector: string | undefined; + let viewportOnly = false; + + const remaining: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--viewport') { + viewportOnly = true; + } else if (args[i] === '--clip') { + const coords = args[++i]; + if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]'); + const parts = coords.split(',').map(Number); + if (parts.length !== 4 || parts.some(isNaN)) + throw new Error('Usage: screenshot --clip x,y,width,height — all must be numbers'); + clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }; + } else if (args[i].startsWith('--')) { + throw new Error(`Unknown screenshot flag: ${args[i]}`); + } else { + remaining.push(args[i]); + } + } + + // Separate target (selector/@ref) from output path + for (const arg of remaining) { + if (arg.startsWith('@e') || arg.startsWith('@c') || arg.startsWith('.') || arg.startsWith('#') || arg.includes('[')) { + targetSelector = arg; + } else { + outputPath = arg; + } + } + + validateOutputPath(outputPath); + + if (clipRect && targetSelector) { + throw new Error('Cannot use --clip with a selector/ref — choose one'); + } + if (viewportOnly && clipRect) { + throw new Error('Cannot use --viewport with --clip — choose one'); + } + + if (targetSelector) { + const resolved = await bm.resolveRef(targetSelector); + const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector); + await locator.screenshot({ path: outputPath, timeout: 5000 }); + return `Screenshot saved (element): ${outputPath}`; + } + + if (clipRect) { + await page.screenshot({ path: outputPath, clip: clipRect }); + return `Screenshot saved (clip ${clipRect.x},${clipRect.y},${clipRect.width},${clipRect.height}): ${outputPath}`; + } + + await page.screenshot({ path: outputPath, fullPage: !viewportOnly }); + return `Screenshot saved${viewportOnly ? ' (viewport)' : ''}: ${outputPath}`; + } + + case 'pdf': { + const page = bm.getPage(); + const pdfPath = args[0] || `${TEMP_DIR}/browse-page.pdf`; + validateOutputPath(pdfPath); + await page.pdf({ path: pdfPath, format: 'A4' }); + return `PDF saved: ${pdfPath}`; + } + + case 'responsive': { + const page = bm.getPage(); + const prefix = args[0] || `${TEMP_DIR}/browse-responsive`; + validateOutputPath(prefix); + const viewports = [ + { name: 'mobile', width: 375, height: 812 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1280, height: 720 }, + ]; + const originalViewport = page.viewportSize(); + const results: string[] = []; + + for (const vp of viewports) { + await page.setViewportSize({ width: vp.width, height: vp.height }); + const path = `${prefix}-${vp.name}.png`; + await page.screenshot({ path, fullPage: true }); + results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`); + } + + // Restore original viewport + if (originalViewport) { + await page.setViewportSize(originalViewport); + } + + return results.join('\n'); + } + + // ─── Chain ───────────────────────────────────────── + case 'chain': { + // Read JSON array from args[0] (if provided) or expect it was passed as body + const jsonStr = args[0]; + if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain'); + + let commands: string[][]; + try { + commands = JSON.parse(jsonStr); + } catch { + throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]'); + } + + if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands'); + + const results: string[] = []; + const { handleReadCommand } = await import('./read-commands'); + const { handleWriteCommand } = await import('./write-commands'); + + for (const cmd of commands) { + const [name, ...cmdArgs] = cmd; + try { + let result: string; + if (WRITE_COMMANDS.has(name)) result = await handleWriteCommand(name, cmdArgs, bm); + else if (READ_COMMANDS.has(name)) result = await handleReadCommand(name, cmdArgs, bm); + else if (META_COMMANDS.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown); + else throw new Error(`Unknown command: ${name}`); + results.push(`[${name}] ${result}`); + } catch (err: any) { + results.push(`[${name}] ERROR: ${err.message}`); + } + } + + return results.join('\n\n'); + } + + // ─── Diff ────────────────────────────────────────── + case 'diff': { + const [url1, url2] = args; + if (!url1 || !url2) throw new Error('Usage: browse diff '); + + const page = bm.getPage(); + await validateNavigationUrl(url1); + await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const text1 = await getCleanText(page); + + await validateNavigationUrl(url2); + await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const text2 = await getCleanText(page); + + const changes = Diff.diffLines(text1, text2); + const output: string[] = [`--- ${url1}`, `+++ ${url2}`, '']; + + for (const part of changes) { + const prefix = part.added ? '+' : part.removed ? '-' : ' '; + const lines = part.value.split('\n').filter(l => l.length > 0); + for (const line of lines) { + output.push(`${prefix} ${line}`); + } + } + + return output.join('\n'); + } + + // ─── Snapshot ───────────────────────────────────── + case 'snapshot': { + return await handleSnapshot(args, bm); + } + + // ─── Handoff ──────────────────────────────────── + case 'handoff': { + const message = args.join(' ') || 'User takeover requested'; + return await bm.handoff(message); + } + + case 'resume': { + bm.resume(); + // Re-snapshot to capture current page state after human interaction + const snapshot = await handleSnapshot(['-i'], bm); + return `RESUMED\n${snapshot}`; + } + + default: + throw new Error(`Unknown meta command: ${command}`); + } +} diff --git a/browse/src/platform.ts b/browse/src/platform.ts new file mode 100644 index 0000000..c022b1d --- /dev/null +++ b/browse/src/platform.ts @@ -0,0 +1,17 @@ +/** + * Cross-platform constants for gstack browse. + * + * On macOS/Linux: TEMP_DIR = '/tmp', path.sep = '/' — identical to hardcoded values. + * On Windows: TEMP_DIR = os.tmpdir(), path.sep = '\\' — correct Windows behavior. + */ + +import * as os from 'os'; +import * as path from 'path'; + +export const IS_WINDOWS = process.platform === 'win32'; +export const TEMP_DIR = IS_WINDOWS ? os.tmpdir() : '/tmp'; + +/** Check if resolvedPath is within dir, using platform-aware separators. */ +export function isPathWithin(resolvedPath: string, dir: string): boolean { + return resolvedPath === dir || resolvedPath.startsWith(dir + path.sep); +} diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts new file mode 100644 index 0000000..5d93156 --- /dev/null +++ b/browse/src/read-commands.ts @@ -0,0 +1,335 @@ +/** + * Read commands — extract data from pages without side effects + * + * text, html, links, forms, accessibility, js, eval, css, attrs, + * console, network, cookies, storage, perf + */ + +import type { BrowserManager } from './browser-manager'; +import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers'; +import type { Page } from 'playwright'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TEMP_DIR, isPathWithin } from './platform'; + +/** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */ +function hasAwait(code: string): boolean { + const stripped = code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); + return /\bawait\b/.test(stripped); +} + +/** Detect whether code needs a block wrapper {…} vs expression wrapper (…) inside an async IIFE. */ +function needsBlockWrapper(code: string): boolean { + const trimmed = code.trim(); + if (trimmed.split('\n').length > 1) return true; + if (/\b(const|let|var|function|class|return|throw|if|for|while|switch|try)\b/.test(trimmed)) return true; + if (trimmed.includes(';')) return true; + return false; +} + +/** Wrap code for page.evaluate(), using async IIFE with block or expression body as needed. */ +function wrapForEvaluate(code: string): string { + if (!hasAwait(code)) return code; + const trimmed = code.trim(); + return needsBlockWrapper(trimmed) + ? `(async()=>{\n${code}\n})()` + : `(async()=>(${trimmed}))()`; +} + +// Security: Path validation to prevent path traversal attacks +const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()]; + +export function validateReadPath(filePath: string): void { + if (path.isAbsolute(filePath)) { + const resolved = path.resolve(filePath); + const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir)); + if (!isSafe) { + throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(', ')}`); + } + } + const normalized = path.normalize(filePath); + if (normalized.includes('..')) { + throw new Error('Path traversal sequences (..) are not allowed'); + } +} + +/** + * Extract clean text from a page (strips script/style/noscript/svg). + * Exported for DRY reuse in meta-commands (diff). + */ +export async function getCleanText(page: Page): Promise { + return await page.evaluate(() => { + const body = document.body; + if (!body) return ''; + const clone = body.cloneNode(true) as HTMLElement; + clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove()); + return clone.innerText + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .join('\n'); + }); +} + +export async function handleReadCommand( + command: string, + args: string[], + bm: BrowserManager +): Promise { + const page = bm.getPage(); + + switch (command) { + case 'text': { + return await getCleanText(page); + } + + case 'html': { + const selector = args[0]; + if (selector) { + const resolved = await bm.resolveRef(selector); + if ('locator' in resolved) { + return await resolved.locator.innerHTML({ timeout: 5000 }); + } + return await page.innerHTML(resolved.selector); + } + return await page.content(); + } + + case 'links': { + const links = await page.evaluate(() => + [...document.querySelectorAll('a[href]')].map(a => ({ + text: a.textContent?.trim().slice(0, 120) || '', + href: (a as HTMLAnchorElement).href, + })).filter(l => l.text && l.href) + ); + return links.map(l => `${l.text} → ${l.href}`).join('\n'); + } + + case 'forms': { + const forms = await page.evaluate(() => { + return [...document.querySelectorAll('form')].map((form, i) => { + const fields = [...form.querySelectorAll('input, select, textarea')].map(el => { + const input = el as HTMLInputElement; + return { + tag: el.tagName.toLowerCase(), + type: input.type || undefined, + name: input.name || undefined, + id: input.id || undefined, + placeholder: input.placeholder || undefined, + required: input.required || undefined, + value: input.type === 'password' ? '[redacted]' : (input.value || undefined), + options: el.tagName === 'SELECT' + ? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text })) + : undefined, + }; + }); + return { + index: i, + action: form.action || undefined, + method: form.method || 'get', + id: form.id || undefined, + fields, + }; + }); + }); + return JSON.stringify(forms, null, 2); + } + + case 'accessibility': { + const snapshot = await page.locator("body").ariaSnapshot(); + return snapshot; + } + + case 'js': { + const expr = args[0]; + if (!expr) throw new Error('Usage: browse js '); + const wrapped = wrapForEvaluate(expr); + const result = await page.evaluate(wrapped); + return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); + } + + case 'eval': { + const filePath = args[0]; + if (!filePath) throw new Error('Usage: browse eval '); + validateReadPath(filePath); + if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); + const code = fs.readFileSync(filePath, 'utf-8'); + const wrapped = wrapForEvaluate(code); + const result = await page.evaluate(wrapped); + return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); + } + + case 'css': { + const [selector, property] = args; + if (!selector || !property) throw new Error('Usage: browse css '); + const resolved = await bm.resolveRef(selector); + if ('locator' in resolved) { + const value = await resolved.locator.evaluate( + (el, prop) => getComputedStyle(el).getPropertyValue(prop), + property + ); + return value; + } + const value = await page.evaluate( + ([sel, prop]) => { + const el = document.querySelector(sel); + if (!el) return `Element not found: ${sel}`; + return getComputedStyle(el).getPropertyValue(prop); + }, + [resolved.selector, property] + ); + return value; + } + + case 'attrs': { + const selector = args[0]; + if (!selector) throw new Error('Usage: browse attrs '); + const resolved = await bm.resolveRef(selector); + if ('locator' in resolved) { + const attrs = await resolved.locator.evaluate((el) => { + const result: Record = {}; + for (const attr of el.attributes) { + result[attr.name] = attr.value; + } + return result; + }); + return JSON.stringify(attrs, null, 2); + } + const attrs = await page.evaluate((sel) => { + const el = document.querySelector(sel); + if (!el) return `Element not found: ${sel}`; + const result: Record = {}; + for (const attr of el.attributes) { + result[attr.name] = attr.value; + } + return result; + }, resolved.selector); + return typeof attrs === 'string' ? attrs : JSON.stringify(attrs, null, 2); + } + + case 'console': { + if (args[0] === '--clear') { + consoleBuffer.clear(); + return 'Console buffer cleared.'; + } + const entries = args[0] === '--errors' + ? consoleBuffer.toArray().filter(e => e.level === 'error' || e.level === 'warning') + : consoleBuffer.toArray(); + if (entries.length === 0) return args[0] === '--errors' ? '(no console errors)' : '(no console messages)'; + return entries.map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` + ).join('\n'); + } + + case 'network': { + if (args[0] === '--clear') { + networkBuffer.clear(); + return 'Network buffer cleared.'; + } + if (networkBuffer.length === 0) return '(no network requests)'; + return networkBuffer.toArray().map(e => + `${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` + ).join('\n'); + } + + case 'dialog': { + if (args[0] === '--clear') { + dialogBuffer.clear(); + return 'Dialog buffer cleared.'; + } + if (dialogBuffer.length === 0) return '(no dialogs captured)'; + return dialogBuffer.toArray().map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` + ).join('\n'); + } + + case 'is': { + const property = args[0]; + const selector = args[1]; + if (!property || !selector) throw new Error('Usage: browse is \nProperties: visible, hidden, enabled, disabled, checked, editable, focused'); + + const resolved = await bm.resolveRef(selector); + let locator; + if ('locator' in resolved) { + locator = resolved.locator; + } else { + locator = page.locator(resolved.selector); + } + + switch (property) { + case 'visible': return String(await locator.isVisible()); + case 'hidden': return String(await locator.isHidden()); + case 'enabled': return String(await locator.isEnabled()); + case 'disabled': return String(await locator.isDisabled()); + case 'checked': return String(await locator.isChecked()); + case 'editable': return String(await locator.isEditable()); + case 'focused': { + const isFocused = await locator.evaluate( + (el) => el === document.activeElement + ); + return String(isFocused); + } + default: + throw new Error(`Unknown property: ${property}. Use: visible, hidden, enabled, disabled, checked, editable, focused`); + } + } + + case 'cookies': { + const cookies = await page.context().cookies(); + return JSON.stringify(cookies, null, 2); + } + + case 'storage': { + if (args[0] === 'set' && args[1]) { + const key = args[1]; + const value = args[2] || ''; + await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]); + return `Set localStorage["${key}"]`; + } + const storage = await page.evaluate(() => ({ + localStorage: { ...localStorage }, + sessionStorage: { ...sessionStorage }, + })); + // Redact values that look like secrets (tokens, keys, passwords, JWTs) + const SENSITIVE_KEY = /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf)($|[_.-])|api.?key/i; + const SENSITIVE_VALUE = /^(eyJ|sk-|sk_live_|sk_test_|pk_live_|pk_test_|rk_live_|sk-ant-|ghp_|gho_|github_pat_|xox[bpsa]-|AKIA[A-Z0-9]{16}|AIza|SG\.|Bearer\s|sbp_)/; + const redacted = JSON.parse(JSON.stringify(storage)); + for (const storeType of ['localStorage', 'sessionStorage'] as const) { + const store = redacted[storeType]; + if (!store) continue; + for (const [key, value] of Object.entries(store)) { + if (typeof value !== 'string') continue; + if (SENSITIVE_KEY.test(key) || SENSITIVE_VALUE.test(value)) { + store[key] = `[REDACTED — ${value.length} chars]`; + } + } + } + return JSON.stringify(redacted, null, 2); + } + + case 'perf': { + const timings = await page.evaluate(() => { + const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + if (!nav) return 'No navigation timing data available.'; + return { + dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart), + tcp: Math.round(nav.connectEnd - nav.connectStart), + ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0), + ttfb: Math.round(nav.responseStart - nav.requestStart), + download: Math.round(nav.responseEnd - nav.responseStart), + domParse: Math.round(nav.domInteractive - nav.responseEnd), + domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime), + load: Math.round(nav.loadEventEnd - nav.startTime), + total: Math.round(nav.loadEventEnd - nav.startTime), + }; + }); + if (typeof timings === 'string') return timings; + return Object.entries(timings) + .map(([k, v]) => `${k.padEnd(12)} ${v}ms`) + .join('\n'); + } + + default: + throw new Error(`Unknown read command: ${command}`); + } +} diff --git a/browse/src/server.ts b/browse/src/server.ts new file mode 100644 index 0000000..82af28b --- /dev/null +++ b/browse/src/server.ts @@ -0,0 +1,369 @@ +/** + * gstack browse server — persistent Chromium daemon + * + * Architecture: + * Bun.serve HTTP on localhost → routes commands to Playwright + * Console/network/dialog buffers: CircularBuffer in-memory + async disk flush + * Chromium crash → server EXITS with clear error (CLI auto-restarts) + * Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min) + * + * State: + * State file: /.gstack/browse.json (set via BROWSE_STATE_FILE env) + * Log files: /.gstack/browse-{console,network,dialog}.log + * Port: random 10000-60000 (or BROWSE_PORT env for debug override) + */ + +import { BrowserManager } from './browser-manager'; +import { handleReadCommand } from './read-commands'; +import { handleWriteCommand } from './write-commands'; +import { handleMetaCommand } from './meta-commands'; +import { handleCookiePickerRoute } from './cookie-picker-routes'; +import { COMMAND_DESCRIPTIONS } from './commands'; +import { SNAPSHOT_FLAGS } from './snapshot'; +import { resolveConfig, ensureStateDir, readVersionHash } from './config'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; + +// ─── Config ───────────────────────────────────────────────────── +const config = resolveConfig(); +ensureStateDir(config); + +// ─── Auth ─────────────────────────────────────────────────────── +const AUTH_TOKEN = crypto.randomUUID(); +const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); +const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min + +function validateAuth(req: Request): boolean { + const header = req.headers.get('authorization'); + return header === `Bearer ${AUTH_TOKEN}`; +} + +// ─── Help text (auto-generated from COMMAND_DESCRIPTIONS) ──────── +function generateHelpText(): string { + // Group commands by category + const groups = new Map(); + for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) { + const display = meta.usage || cmd; + const list = groups.get(meta.category) || []; + list.push(display); + groups.set(meta.category, list); + } + + const categoryOrder = [ + 'Navigation', 'Reading', 'Interaction', 'Inspection', + 'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server', + ]; + + const lines = ['gstack browse — headless browser for AI agents', '', 'Commands:']; + for (const cat of categoryOrder) { + const cmds = groups.get(cat); + if (!cmds) continue; + lines.push(` ${(cat + ':').padEnd(15)}${cmds.join(', ')}`); + } + + // Snapshot flags from source of truth + lines.push(''); + lines.push('Snapshot flags:'); + const flagPairs: string[] = []; + for (const flag of SNAPSHOT_FLAGS) { + const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short; + flagPairs.push(`${label} ${flag.long}`); + } + // Print two flags per line for compact display + for (let i = 0; i < flagPairs.length; i += 2) { + const left = flagPairs[i].padEnd(28); + const right = flagPairs[i + 1] || ''; + lines.push(` ${left}${right}`); + } + + return lines.join('\n'); +} + +// ─── Buffer (from buffers.ts) ──────────────────────────────────── +import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers'; +export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry }; + +const CONSOLE_LOG_PATH = config.consoleLog; +const NETWORK_LOG_PATH = config.networkLog; +const DIALOG_LOG_PATH = config.dialogLog; +let lastConsoleFlushed = 0; +let lastNetworkFlushed = 0; +let lastDialogFlushed = 0; +let flushInProgress = false; + +async function flushBuffers() { + if (flushInProgress) return; // Guard against concurrent flush + flushInProgress = true; + + try { + // Console buffer + const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed; + if (newConsoleCount > 0) { + const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length)); + const lines = entries.map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` + ).join('\n') + '\n'; + fs.appendFileSync(CONSOLE_LOG_PATH, lines); + lastConsoleFlushed = consoleBuffer.totalAdded; + } + + // Network buffer + const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed; + if (newNetworkCount > 0) { + const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length)); + const lines = entries.map(e => + `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` + ).join('\n') + '\n'; + fs.appendFileSync(NETWORK_LOG_PATH, lines); + lastNetworkFlushed = networkBuffer.totalAdded; + } + + // Dialog buffer + const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed; + if (newDialogCount > 0) { + const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length)); + const lines = entries.map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` + ).join('\n') + '\n'; + fs.appendFileSync(DIALOG_LOG_PATH, lines); + lastDialogFlushed = dialogBuffer.totalAdded; + } + } catch { + // Flush failures are non-fatal — buffers are in memory + } finally { + flushInProgress = false; + } +} + +// Flush every 1 second +const flushInterval = setInterval(flushBuffers, 1000); + +// ─── Idle Timer ──────────────────────────────────────────────── +let lastActivity = Date.now(); + +function resetIdleTimer() { + lastActivity = Date.now(); +} + +const idleCheckInterval = setInterval(() => { + if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) { + console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`); + shutdown(); + } +}, 60_000); + +// ─── Command Sets (from commands.ts — single source of truth) ─── +import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; +export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS }; + +// ─── Server ──────────────────────────────────────────────────── +const browserManager = new BrowserManager(); +let isShuttingDown = false; + +// Find port: explicit BROWSE_PORT, or random in 10000-60000 +async function findPort(): Promise { + // Explicit port override (for debugging) + if (BROWSE_PORT) { + try { + const testServer = Bun.serve({ port: BROWSE_PORT, fetch: () => new Response('ok') }); + testServer.stop(); + return BROWSE_PORT; + } catch { + throw new Error(`[browse] Port ${BROWSE_PORT} (from BROWSE_PORT env) is in use`); + } + } + + // Random port with retry + const MIN_PORT = 10000; + const MAX_PORT = 60000; + const MAX_RETRIES = 5; + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const port = MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT)); + try { + const testServer = Bun.serve({ port, fetch: () => new Response('ok') }); + testServer.stop(); + return port; + } catch { + continue; + } + } + throw new Error(`[browse] No available port after ${MAX_RETRIES} attempts in range ${MIN_PORT}-${MAX_PORT}`); +} + +/** + * Translate Playwright errors into actionable messages for AI agents. + */ +function wrapError(err: any): string { + const msg = err.message || String(err); + // Timeout errors + if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) { + if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) { + return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`; + } + if (msg.includes('page.goto') || msg.includes('Navigation')) { + return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`; + } + return `Operation timed out: ${msg.split('\n')[0]}`; + } + // Multiple elements matched + if (msg.includes('resolved to') && msg.includes('elements')) { + return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`; + } + // Pass through other errors + return msg; +} + +async function handleCommand(body: any): Promise { + const { command, args = [] } = body; + + if (!command) { + return new Response(JSON.stringify({ error: 'Missing "command" field' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + try { + let result: string; + + if (READ_COMMANDS.has(command)) { + result = await handleReadCommand(command, args, browserManager); + } else if (WRITE_COMMANDS.has(command)) { + result = await handleWriteCommand(command, args, browserManager); + } else if (META_COMMANDS.has(command)) { + result = await handleMetaCommand(command, args, browserManager, shutdown); + } else if (command === 'help') { + const helpText = generateHelpText(); + return new Response(helpText, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + } else { + return new Response(JSON.stringify({ + error: `Unknown command: ${command}`, + hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`, + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + browserManager.resetFailures(); + return new Response(result, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + } catch (err: any) { + browserManager.incrementFailures(); + let errorMsg = wrapError(err); + const hint = browserManager.getFailureHint(); + if (hint) errorMsg += '\n' + hint; + return new Response(JSON.stringify({ error: errorMsg }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} + +async function shutdown() { + if (isShuttingDown) return; + isShuttingDown = true; + + console.log('[browse] Shutting down...'); + clearInterval(flushInterval); + clearInterval(idleCheckInterval); + await flushBuffers(); // Final flush (async now) + + await browserManager.close(); + + // Clean up state file + try { fs.unlinkSync(config.stateFile); } catch {} + + process.exit(0); +} + +// Handle signals +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); + +// ─── Start ───────────────────────────────────────────────────── +async function start() { + // Clear old log files + try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch {} + try { fs.unlinkSync(NETWORK_LOG_PATH); } catch {} + try { fs.unlinkSync(DIALOG_LOG_PATH); } catch {} + + const port = await findPort(); + + // Launch browser + await browserManager.launch(); + + const startTime = Date.now(); + const server = Bun.serve({ + port, + hostname: '127.0.0.1', + fetch: async (req) => { + resetIdleTimer(); + + const url = new URL(req.url); + + // Cookie picker routes — no auth required (localhost-only) + if (url.pathname.startsWith('/cookie-picker')) { + return handleCookiePickerRoute(url, req, browserManager); + } + + // Health check — no auth required (now async) + if (url.pathname === '/health') { + const healthy = await browserManager.isHealthy(); + return new Response(JSON.stringify({ + status: healthy ? 'healthy' : 'unhealthy', + uptime: Math.floor((Date.now() - startTime) / 1000), + tabs: browserManager.getTabCount(), + currentUrl: browserManager.getCurrentUrl(), + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // All other endpoints require auth + if (!validateAuth(req)) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (url.pathname === '/command' && req.method === 'POST') { + const body = await req.json(); + return handleCommand(body); + } + + return new Response('Not found', { status: 404 }); + }, + }); + + // Write state file (atomic: write .tmp then rename) + const state = { + pid: process.pid, + port, + token: AUTH_TOKEN, + startedAt: new Date().toISOString(), + serverPath: path.resolve(import.meta.dir, 'server.ts'), + binaryVersion: readVersionHash() || undefined, + }; + const tmpFile = config.stateFile + '.tmp'; + fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); + fs.renameSync(tmpFile, config.stateFile); + + browserManager.serverPort = port; + console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`); + console.log(`[browse] State file: ${config.stateFile}`); + console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`); +} + +start().catch((err) => { + console.error(`[browse] Failed to start: ${err.message}`); + process.exit(1); +}); diff --git a/browse/src/snapshot.ts b/browse/src/snapshot.ts new file mode 100644 index 0000000..24380ba --- /dev/null +++ b/browse/src/snapshot.ts @@ -0,0 +1,398 @@ +/** + * Snapshot command — accessibility tree with ref-based element selection + * + * Architecture (Locator map — no DOM mutation): + * 1. page.locator(scope).ariaSnapshot() → YAML-like accessibility tree + * 2. Parse tree, assign refs @e1, @e2, ... + * 3. Build Playwright Locator for each ref (getByRole + nth) + * 4. Store Map on BrowserManager + * 5. Return compact text output with refs prepended + * + * Extended features: + * --diff / -D: Compare against last snapshot, return unified diff + * --annotate / -a: Screenshot with overlay boxes at each @ref + * --output / -o: Output path for annotated screenshot + * -C / --cursor-interactive: Scan for cursor:pointer/onclick/tabindex elements + * + * Later: "click @e3" → look up Locator → locator.click() + */ + +import type { Page, Locator } from 'playwright'; +import type { BrowserManager, RefEntry } from './browser-manager'; +import * as Diff from 'diff'; +import { TEMP_DIR, isPathWithin } from './platform'; + +// Roles considered "interactive" for the -i flag +const INTERACTIVE_ROLES = new Set([ + 'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', + 'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio', + 'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', + 'treeitem', +]); + +interface SnapshotOptions { + interactive?: boolean; // -i: only interactive elements + compact?: boolean; // -c: remove empty structural elements + depth?: number; // -d N: limit tree depth + selector?: string; // -s SEL: scope to CSS selector + diff?: boolean; // -D / --diff: diff against last snapshot + annotate?: boolean; // -a / --annotate: annotated screenshot + outputPath?: string; // -o / --output: path for annotated screenshot + cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc. +} + +/** + * Snapshot flag metadata — single source of truth for CLI parsing and doc generation. + * + * Imported by: + * - gen-skill-docs.ts (generates {{SNAPSHOT_FLAGS}} tables) + * - skill-parser.ts (validates flags in SKILL.md examples) + */ +export const SNAPSHOT_FLAGS: Array<{ + short: string; + long: string; + description: string; + takesValue?: boolean; + valueHint?: string; + optionKey: keyof SnapshotOptions; +}> = [ + { short: '-i', long: '--interactive', description: 'Interactive elements only (buttons, links, inputs) with @e refs', optionKey: 'interactive' }, + { short: '-c', long: '--compact', description: 'Compact (no empty structural nodes)', optionKey: 'compact' }, + { short: '-d', long: '--depth', description: 'Limit tree depth (0 = root only, default: unlimited)', takesValue: true, valueHint: '', optionKey: 'depth' }, + { short: '-s', long: '--selector', description: 'Scope to CSS selector', takesValue: true, valueHint: '', optionKey: 'selector' }, + { short: '-D', long: '--diff', description: 'Unified diff against previous snapshot (first call stores baseline)', optionKey: 'diff' }, + { short: '-a', long: '--annotate', description: 'Annotated screenshot with red overlay boxes and ref labels', optionKey: 'annotate' }, + { short: '-o', long: '--output', description: 'Output path for annotated screenshot (default: /browse-annotated.png)', takesValue: true, valueHint: '', optionKey: 'outputPath' }, + { short: '-C', long: '--cursor-interactive', description: 'Cursor-interactive elements (@c refs — divs with pointer, onclick)', optionKey: 'cursorInteractive' }, +]; + +interface ParsedNode { + indent: number; + role: string; + name: string | null; + props: string; // e.g., "[level=1]" + children: string; // inline text content after ":" + rawLine: string; +} + +/** + * Parse CLI args into SnapshotOptions — driven by SNAPSHOT_FLAGS metadata. + */ +export function parseSnapshotArgs(args: string[]): SnapshotOptions { + const opts: SnapshotOptions = {}; + for (let i = 0; i < args.length; i++) { + const flag = SNAPSHOT_FLAGS.find(f => f.short === args[i] || f.long === args[i]); + if (!flag) throw new Error(`Unknown snapshot flag: ${args[i]}`); + if (flag.takesValue) { + const value = args[++i]; + if (!value) throw new Error(`Usage: snapshot ${flag.short} `); + if (flag.optionKey === 'depth') { + (opts as any)[flag.optionKey] = parseInt(value, 10); + if (isNaN(opts.depth!)) throw new Error('Usage: snapshot -d '); + } else { + (opts as any)[flag.optionKey] = value; + } + } else { + (opts as any)[flag.optionKey] = true; + } + } + return opts; +} + +/** + * Parse one line of ariaSnapshot output. + * + * Format examples: + * - heading "Test" [level=1] + * - link "Link A": + * - /url: /a + * - textbox "Name" + * - paragraph: Some text + * - combobox "Role": + */ +function parseLine(line: string): ParsedNode | null { + // Match: (indent)(- )(role)( "name")?( [props])?(: inline)? + const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/); + if (!match) { + // Skip metadata lines like "- /url: /a" + return null; + } + return { + indent: match[1].length, + role: match[2], + name: match[3] ?? null, + props: match[4] || '', + children: match[5]?.trim() || '', + rawLine: line, + }; +} + +/** + * Take an accessibility snapshot and build the ref map. + */ +export async function handleSnapshot( + args: string[], + bm: BrowserManager +): Promise { + const opts = parseSnapshotArgs(args); + const page = bm.getPage(); + + // Get accessibility tree via ariaSnapshot + let rootLocator: Locator; + if (opts.selector) { + rootLocator = page.locator(opts.selector); + const count = await rootLocator.count(); + if (count === 0) throw new Error(`Selector not found: ${opts.selector}`); + } else { + rootLocator = page.locator('body'); + } + + const ariaText = await rootLocator.ariaSnapshot(); + if (!ariaText || ariaText.trim().length === 0) { + bm.setRefMap(new Map()); + return '(no accessible elements found)'; + } + + // Parse the ariaSnapshot output + const lines = ariaText.split('\n'); + const refMap = new Map(); + const output: string[] = []; + let refCounter = 1; + + // Track role+name occurrences for nth() disambiguation + const roleNameCounts = new Map(); + const roleNameSeen = new Map(); + + // First pass: count role+name pairs for disambiguation + for (const line of lines) { + const node = parseLine(line); + if (!node) continue; + const key = `${node.role}:${node.name || ''}`; + roleNameCounts.set(key, (roleNameCounts.get(key) || 0) + 1); + } + + // Second pass: assign refs and build locators + for (const line of lines) { + const node = parseLine(line); + if (!node) continue; + + const depth = Math.floor(node.indent / 2); + const isInteractive = INTERACTIVE_ROLES.has(node.role); + + // Depth filter + if (opts.depth !== undefined && depth > opts.depth) continue; + + // Interactive filter: skip non-interactive but still count for locator indices + if (opts.interactive && !isInteractive) { + // Still track for nth() counts + const key = `${node.role}:${node.name || ''}`; + roleNameSeen.set(key, (roleNameSeen.get(key) || 0) + 1); + continue; + } + + // Compact filter: skip elements with no name and no inline content that aren't interactive + if (opts.compact && !isInteractive && !node.name && !node.children) continue; + + // Assign ref + const ref = `e${refCounter++}`; + const indent = ' '.repeat(depth); + + // Build Playwright locator + const key = `${node.role}:${node.name || ''}`; + const seenIndex = roleNameSeen.get(key) || 0; + roleNameSeen.set(key, seenIndex + 1); + const totalCount = roleNameCounts.get(key) || 1; + + let locator: Locator; + if (opts.selector) { + locator = page.locator(opts.selector).getByRole(node.role as any, { + name: node.name || undefined, + }); + } else { + locator = page.getByRole(node.role as any, { + name: node.name || undefined, + }); + } + + // Disambiguate with nth() if multiple elements share role+name + if (totalCount > 1) { + locator = locator.nth(seenIndex); + } + + refMap.set(ref, { locator, role: node.role, name: node.name || '' }); + + // Format output line + let outputLine = `${indent}@${ref} [${node.role}]`; + if (node.name) outputLine += ` "${node.name}"`; + if (node.props) outputLine += ` ${node.props}`; + if (node.children) outputLine += `: ${node.children}`; + + output.push(outputLine); + } + + // ─── Cursor-interactive scan (-C) ───────────────────────── + if (opts.cursorInteractive) { + try { + const cursorElements = await page.evaluate(() => { + const STANDARD_INTERACTIVE = new Set([ + 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'DETAILS', + ]); + + const results: Array<{ selector: string; text: string; reason: string }> = []; + const allElements = document.querySelectorAll('*'); + + for (const el of allElements) { + // Skip standard interactive elements (already in ARIA tree) + if (STANDARD_INTERACTIVE.has(el.tagName)) continue; + // Skip hidden elements + if (!(el as HTMLElement).offsetParent && el.tagName !== 'BODY') continue; + + const style = getComputedStyle(el); + const hasCursorPointer = style.cursor === 'pointer'; + const hasOnclick = el.hasAttribute('onclick'); + const hasTabindex = el.hasAttribute('tabindex') && parseInt(el.getAttribute('tabindex')!, 10) >= 0; + const hasRole = el.hasAttribute('role'); + + if (!hasCursorPointer && !hasOnclick && !hasTabindex) continue; + // Skip if it has an ARIA role (likely already captured) + if (hasRole) continue; + + // Build deterministic nth-child CSS path + const parts: string[] = []; + let current: Element | null = el; + while (current && current !== document.documentElement) { + const parent = current.parentElement; + if (!parent) break; + const siblings = [...parent.children]; + const index = siblings.indexOf(current) + 1; + parts.unshift(`${current.tagName.toLowerCase()}:nth-child(${index})`); + current = parent; + } + const selector = parts.join(' > '); + + const text = (el as HTMLElement).innerText?.trim().slice(0, 80) || el.tagName.toLowerCase(); + const reasons: string[] = []; + if (hasCursorPointer) reasons.push('cursor:pointer'); + if (hasOnclick) reasons.push('onclick'); + if (hasTabindex) reasons.push(`tabindex=${el.getAttribute('tabindex')}`); + + results.push({ selector, text, reason: reasons.join(', ') }); + } + return results; + }); + + if (cursorElements.length > 0) { + output.push(''); + output.push('── cursor-interactive (not in ARIA tree) ──'); + let cRefCounter = 1; + for (const elem of cursorElements) { + const ref = `c${cRefCounter++}`; + const locator = page.locator(elem.selector); + refMap.set(ref, { locator, role: 'cursor-interactive', name: elem.text }); + output.push(`@${ref} [${elem.reason}] "${elem.text}"`); + } + } + } catch { + output.push(''); + output.push('(cursor scan failed — CSP restriction)'); + } + } + + // Store ref map on BrowserManager + bm.setRefMap(refMap); + + if (output.length === 0) { + return '(no interactive elements found)'; + } + + const snapshotText = output.join('\n'); + + // ─── Annotated screenshot (-a) ──────────────────────────── + if (opts.annotate) { + const screenshotPath = opts.outputPath || `${TEMP_DIR}/browse-annotated.png`; + // Validate output path (consistent with screenshot/pdf/responsive) + const resolvedPath = require('path').resolve(screenshotPath); + const safeDirs = [TEMP_DIR, process.cwd()]; + if (!safeDirs.some((dir: string) => isPathWithin(resolvedPath, dir))) { + throw new Error(`Path must be within: ${safeDirs.join(', ')}`); + } + try { + // Inject overlay divs at each ref's bounding box + const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number } }> = []; + for (const [ref, entry] of refMap) { + try { + const box = await entry.locator.boundingBox({ timeout: 1000 }); + if (box) { + boxes.push({ ref: `@${ref}`, box }); + } + } catch { + // Element may be offscreen or hidden — skip + } + } + + await page.evaluate((boxes) => { + for (const { ref, box } of boxes) { + const overlay = document.createElement('div'); + overlay.className = '__browse_annotation__'; + overlay.style.cssText = ` + position: absolute; top: ${box.y}px; left: ${box.x}px; + width: ${box.width}px; height: ${box.height}px; + border: 2px solid red; background: rgba(255,0,0,0.1); + pointer-events: none; z-index: 99999; + font-size: 10px; color: red; font-weight: bold; + `; + const label = document.createElement('span'); + label.textContent = ref; + label.style.cssText = 'position: absolute; top: -14px; left: 0; background: red; color: white; padding: 0 3px; font-size: 10px;'; + overlay.appendChild(label); + document.body.appendChild(overlay); + } + }, boxes); + + await page.screenshot({ path: screenshotPath, fullPage: true }); + + // Always remove overlays + await page.evaluate(() => { + document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove()); + }); + + output.push(''); + output.push(`[annotated screenshot: ${screenshotPath}]`); + } catch { + // Remove overlays even on screenshot failure + try { + await page.evaluate(() => { + document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove()); + }); + } catch {} + } + } + + // ─── Diff mode (-D) ─────────────────────────────────────── + if (opts.diff) { + const lastSnapshot = bm.getLastSnapshot(); + if (!lastSnapshot) { + bm.setLastSnapshot(snapshotText); + return snapshotText + '\n\n(no previous snapshot to diff against — this snapshot stored as baseline)'; + } + + const changes = Diff.diffLines(lastSnapshot, snapshotText); + const diffOutput: string[] = ['--- previous snapshot', '+++ current snapshot', '']; + + for (const part of changes) { + const prefix = part.added ? '+' : part.removed ? '-' : ' '; + const diffLines = part.value.split('\n').filter(l => l.length > 0); + for (const line of diffLines) { + diffOutput.push(`${prefix} ${line}`); + } + } + + bm.setLastSnapshot(snapshotText); + return diffOutput.join('\n'); + } + + // Store for future diffs + bm.setLastSnapshot(snapshotText); + + return output.join('\n'); +} diff --git a/browse/src/url-validation.ts b/browse/src/url-validation.ts new file mode 100644 index 0000000..8c23d7c --- /dev/null +++ b/browse/src/url-validation.ts @@ -0,0 +1,91 @@ +/** + * URL validation for navigation commands — blocks dangerous schemes and cloud metadata endpoints. + * Localhost and private IPs are allowed (primary use case: QA testing local dev servers). + */ + +const BLOCKED_METADATA_HOSTS = new Set([ + '169.254.169.254', // AWS/GCP/Azure instance metadata + 'fd00::', // IPv6 unique local (metadata in some cloud setups) + 'metadata.google.internal', // GCP metadata + 'metadata.azure.internal', // Azure IMDS +]); + +/** + * Normalize hostname for blocklist comparison: + * - Strip trailing dot (DNS fully-qualified notation) + * - Strip IPv6 brackets (URL.hostname includes [] for IPv6) + * - Resolve hex (0xA9FEA9FE) and decimal (2852039166) IP representations + */ +function normalizeHostname(hostname: string): string { + // Strip IPv6 brackets + let h = hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; + // Strip trailing dot + if (h.endsWith('.')) h = h.slice(0, -1); + return h; +} + +/** + * Check if a hostname resolves to the link-local metadata IP 169.254.169.254. + * Catches hex (0xA9FEA9FE), decimal (2852039166), and octal (0251.0376.0251.0376) forms. + */ +function isMetadataIp(hostname: string): boolean { + // Try to parse as a numeric IP via URL constructor — it normalizes all forms + try { + const probe = new URL(`http://${hostname}`); + const normalized = probe.hostname; + if (BLOCKED_METADATA_HOSTS.has(normalized)) return true; + // Also check after stripping trailing dot + if (normalized.endsWith('.') && BLOCKED_METADATA_HOSTS.has(normalized.slice(0, -1))) return true; + } catch { + // Not a valid hostname — can't be a metadata IP + } + return false; +} + +/** + * Resolve a hostname to its IP addresses and check if any resolve to blocked metadata IPs. + * Mitigates DNS rebinding: even if the hostname looks safe, the resolved IP might not be. + */ +async function resolvesToBlockedIp(hostname: string): Promise { + try { + const dns = await import('node:dns'); + const { resolve4 } = dns.promises; + const addresses = await resolve4(hostname); + return addresses.some(addr => BLOCKED_METADATA_HOSTS.has(addr)); + } catch { + // DNS resolution failed — not a rebinding risk + return false; + } +} + +export async function validateNavigationUrl(url: string): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error( + `Blocked: scheme "${parsed.protocol}" is not allowed. Only http: and https: URLs are permitted.` + ); + } + + const hostname = normalizeHostname(parsed.hostname.toLowerCase()); + + if (BLOCKED_METADATA_HOSTS.has(hostname) || isMetadataIp(hostname)) { + throw new Error( + `Blocked: ${parsed.hostname} is a cloud metadata endpoint. Access is denied for security.` + ); + } + + // DNS rebinding protection: resolve hostname and check if it points to metadata IPs + if (await resolvesToBlockedIp(hostname)) { + throw new Error( + `Blocked: ${parsed.hostname} resolves to a cloud metadata IP. Possible DNS rebinding attack.` + ); + } +} diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts new file mode 100644 index 0000000..73b44ca --- /dev/null +++ b/browse/src/write-commands.ts @@ -0,0 +1,352 @@ +/** + * Write commands — navigate and interact with pages (side effects) + * + * goto, back, forward, reload, click, fill, select, hover, type, + * press, scroll, wait, viewport, cookie, header, useragent + */ + +import type { BrowserManager } from './browser-manager'; +import { findInstalledBrowsers, importCookies } from './cookie-import-browser'; +import { validateNavigationUrl } from './url-validation'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TEMP_DIR, isPathWithin } from './platform'; + +export async function handleWriteCommand( + command: string, + args: string[], + bm: BrowserManager +): Promise { + const page = bm.getPage(); + + switch (command) { + case 'goto': { + const url = args[0]; + if (!url) throw new Error('Usage: browse goto '); + await validateNavigationUrl(url); + const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const status = response?.status() || 'unknown'; + return `Navigated to ${url} (${status})`; + } + + case 'back': { + await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 }); + return `Back → ${page.url()}`; + } + + case 'forward': { + await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 }); + return `Forward → ${page.url()}`; + } + + case 'reload': { + await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }); + return `Reloaded ${page.url()}`; + } + + case 'click': { + const selector = args[0]; + if (!selector) throw new Error('Usage: browse click '); + + // Auto-route: if ref points to a real