Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Binary
bin/
/bin/
/browser/bin/agent-browser-*
cdp

# Go
Expand Down
7 changes: 7 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ version: 2

project_name: tap

before:
hooks:
- scripts/prepare-agent-browser.sh

builds:
- main: ./cmd/tap
binary: tap
Expand All @@ -14,6 +18,9 @@ builds:
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
ldflags:
- -s -w -X main.version={{.Version}}

Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

## Project

Go CLI and library for running JS scripts against websites (QuickJS + Chrome CDP fallback) and extracting clean content from URLs via go-defuddle.
Go CLI and library for running JS scripts against websites (QuickJS + agent-browser fallback) and extracting clean content from URLs via go-defuddle.

## Stack

Go 1.26+, urfave/cli v3, QuickJS (fastschema/qjs), chromedp, go-defuddle, mise.
Go 1.26+, urfave/cli v3, QuickJS (fastschema/qjs), agent-browser, go-defuddle, mise.

## Commands

Expand Down Expand Up @@ -35,8 +35,8 @@ Emoji-prefixed Conventional Commits: `✨ feat:`, `🐛 fix:`, `♻️ refactor:

```
tap.go / options.go → Client API + functional options
transport/ → Shared HTTP + CDP browser layer
browser/ → Persistent sessions, tabs, network interception
transport/ → Shared HTTP + agent-browser bridge
browser/ → agent-browser adapter, binary install, pass-through types
engine/ → QuickJS + browser fallback
fetch/ → URL → clean content (go-defuddle)
cmd/tap/ → CLI (site, fetch, sync, browser)
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Changed

- **Replaced browser backend with embedded agent-browser** — removed ~4,000 lines of hand-rolled chromedp/CDP code and switched to [agent-browser](https://github.com/vercel-labs/agent-browser) as the sole browser backend. Tap embeds the native agent-browser binary for supported release platforms and lets agent-browser manage Chrome/browser installation.
- **Removed chromedp dependency** — `go.mod` no longer depends on `chromedp/chromedp`, `chromedp/cdproto`, or `chromedp/sysutil`.

### Added

- **New browser pass-through commands** — `tap browser set` (viewport, device, geo, offline, headers, credentials, media), `tap browser storage` (local/session), `tap browser state` (save/load/list/show/clear), `tap browser auth` (save/login/list/show/delete), `tap browser get` (text/html/value/attr/title/url/count/box/styles), `tap browser vitals`, `tap browser diff` (snapshot/screenshot/url).
- **Global Lightpanda engine flag** — `--lightpanda` / `--lp` selects Lightpanda as the agent-browser engine for browser-backed commands.

## [0.4.8] - 2026-05-18

### Added
Expand Down
27 changes: 14 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Upgrade later with:
tap upgrade
```

Browser features use Chrome by default. Check dependencies with `tap doctor`.
Browser features use the embedded agent-browser backend. Run `tap doctor --install` to let agent-browser install browser dependencies.

## Quick start

Expand Down Expand Up @@ -54,7 +54,7 @@ tap fetch -b https://github.com/notifications

### Reuse your existing Chrome

Chrome must already expose DevTools.
agent-browser manages Chrome automatically. To attach to an existing Chrome with DevTools enabled:

```bash
tap attach chrome
Expand All @@ -70,7 +70,6 @@ You can also attach explicitly:

```bash
tap attach chrome --browser-url http://127.0.0.1:9222
tap attach chrome --port-file ~/Library/Application\ Support/Google/Chrome/DevToolsActivePort
```

### Browser workflow
Expand All @@ -79,7 +78,7 @@ tap attach chrome --port-file ~/Library/Application\ Support/Google/Chrome/DevTo
tap browser open https://news.ycombinator.com
tap browser open https://github.com --new-tab
tap browser tabs
tap browser switch tab-2
tap browser switch t2
tap browser screenshot --output github.png
tap browser status
```
Expand Down Expand Up @@ -152,9 +151,9 @@ These show up on the relevant commands instead of only in global help:
| `--wait-selector` | Wait for a CSS selector |
| `--wait-js` | Wait for a JS expression |
| `--timeout` | Set execution timeout |
| `--browser-url` | One-shot DevTools override |
| `--browser-url` | One-shot agent-browser/DevTools connection override |
| `--profile-dir` | One-shot profile override |
| `--lightpanda`, `--lp` | Use Lightpanda instead of Chrome |
| `--lightpanda`, `--lp` | Use Lightpanda as the browser engine |

Compatibility aliases still work:
- `--ws-url` -> `--browser-url`
Expand All @@ -171,16 +170,18 @@ tap browser snapshot
tap browser forms
tap browser cookies ...
tap browser network ...
tap browser set ...
tap browser storage ...
tap browser state ...
tap browser auth ...
tap browser get ...
tap browser vitals
tap browser diff ...
```

## Lightpanda
## Browser backend

| Backend | Platforms | Best for |
| --- | --- | --- |
| Chrome | macOS, Linux, Windows | Full browser automation, auth, network interception |
| Lightpanda | macOS, Linux | Fast headless rendering without auth-heavy flows |

Install or update Lightpanda with:
Tap embeds agent-browser as the single browser backend. Chrome is the default engine; pass `--lightpanda`/`--lp` to use Lightpanda for fast browser-backed rendering. agent-browser manages browser installation for full automation, auth, screenshots, and network workflows. Install browser dependencies with:

```bash
tap doctor --install
Expand Down
140 changes: 140 additions & 0 deletions browser/agentbrowser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package browser

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"slices"
"strings"
)

const DefaultAgentBrowserSession = "default"

type AgentBrowser struct {
Path string
SessionName string
ProfileDir string
Headed bool
Attached bool
Engine string
}

type OpenOpts struct {
Headed bool
Headers map[string]string
InitScript string
}

type ExecResult struct {
Stdout json.RawMessage
Stderr string
}

func NewAgentBrowser(path string) (*AgentBrowser, error) {
if path == "" {
resolved, err := ResolveAgentBrowserPath()
if err != nil {
return nil, err
}
path = resolved
}
return &AgentBrowser{Path: path, SessionName: DefaultAgentBrowserSession}, nil
}

func (a *AgentBrowser) Exec(ctx context.Context, args ...string) (json.RawMessage, string, error) {
return a.exec(ctx, nil, args...)
}

func (a *AgentBrowser) exec(ctx context.Context, stdin []byte, args ...string) (json.RawMessage, string, error) {
cmdArgs := a.commandArgs(args...)
cmd := exec.CommandContext(ctx, a.Path, cmdArgs...)
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, stderr.String(), fmt.Errorf("agent-browser %s: %w: %s", strings.Join(cmdArgs, " "), err, strings.TrimSpace(stderr.String()))
}
return json.RawMessage(bytes.TrimSpace(stdout.Bytes())), stderr.String(), nil
}

func (a *AgentBrowser) commandArgs(args ...string) []string {
out := make([]string, 0, len(args)+8)
out = append(out, args...)
if !slices.Contains(out, "--json") {
out = append(out, "--json")
}
if !a.Attached && a.SessionName != "" && !hasFlag(out, "--session-name") {
out = append(out, "--session-name", a.SessionName)
}
if a.ProfileDir != "" && !hasFlag(out, "--profile") {
out = append(out, "--profile", a.ProfileDir)
}
if a.Headed && !slices.Contains(out, "--headed") {
out = append(out, "--headed")
}
if a.Engine != "" && !hasFlag(out, "--engine") {
out = append(out, "--engine", a.Engine)
}
return out
}

func hasFlag(args []string, flag string) bool {
for _, arg := range args {
if arg == flag || strings.HasPrefix(arg, flag+"=") {
return true
}
}
return false
}

func (a *AgentBrowser) Open(ctx context.Context, url string, opts OpenOpts) error {
args := []string{"open", url}
if opts.Headed {
args = append(args, "--headed")
}
if opts.InitScript != "" {
args = append(args, "--init-script", opts.InitScript)
}
for name, value := range opts.Headers {
args = append(args, "--headers", name+": "+value)
}
_, _, err := a.Exec(ctx, args...)
return err
}

func (a *AgentBrowser) Eval(ctx context.Context, js string) (any, error) {
out, stderr, err := a.exec(ctx, []byte(js), "eval", "--stdin")
if err != nil {
return nil, err
}
var envelope AgentBrowserEnvelope[map[string]any]
if err := json.Unmarshal(out, &envelope); err == nil && envelope.Success {
return envelope.Data["result"], nil
}
var value any
if err := json.Unmarshal(out, &value); err != nil {
return nil, fmt.Errorf("parse eval JSON: %w: %s", err, stderr)
}
return value, nil
}

func (a *AgentBrowser) GetHTML(ctx context.Context) (string, error) {
value, err := a.Eval(ctx, "document.documentElement.outerHTML")
if err != nil {
return "", err
}
if s, ok := value.(string); ok {
return s, nil
}
return "", fmt.Errorf("agent-browser html result is %T, not string", value)
}

func (a *AgentBrowser) Close(ctx context.Context) error {
_, _, err := a.Exec(ctx, "close")
return err
}
55 changes: 55 additions & 0 deletions browser/agentbrowser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package browser

import (
"slices"
"testing"
)

func TestAgentBrowserCommandArgs(t *testing.T) {
ab := &AgentBrowser{Path: "agent-browser", SessionName: "dev", ProfileDir: "/tmp/profile", Headed: true}
args := ab.commandArgs("open", "https://example.com")
for _, want := range []string{"open", "https://example.com", "--json", "--session-name", "dev", "--profile", "/tmp/profile", "--headed"} {
if !slices.Contains(args, want) {
t.Fatalf("args %v missing %q", args, want)
}
}
}

func TestAgentBrowserCommandArgsIncludesEngine(t *testing.T) {
ab := &AgentBrowser{Path: "agent-browser", SessionName: "dev", Engine: "lightpanda"}
args := ab.commandArgs("open", "https://example.com")
for _, want := range []string{"--engine", "lightpanda"} {
if !slices.Contains(args, want) {
t.Fatalf("args %v missing %q", args, want)
}
}
}

func TestAgentBrowserCommandArgsAttachedSkipsSession(t *testing.T) {
ab := &AgentBrowser{Path: "agent-browser", SessionName: "dev", Attached: true}
args := ab.commandArgs("get", "url")
if slices.Contains(args, "--session-name") {
t.Fatalf("attached args included session name: %v", args)
}
}

func TestAgentBrowserCommandArgsPreservesExplicitJSONAndSession(t *testing.T) {
ab := &AgentBrowser{Path: "agent-browser", SessionName: "dev"}
args := ab.commandArgs("session", "--json", "--session-name", "other")
if got := count(args, "--json"); got != 1 {
t.Fatalf("--json count = %d, want 1 in %v", got, args)
}
if got := count(args, "--session-name"); got != 1 {
t.Fatalf("--session-name count = %d, want 1 in %v", got, args)
}
}

func count(values []string, target string) int {
var n int
for _, value := range values {
if value == target {
n++
}
}
return n
}
Loading
Loading