feat: add browseros-cli Go CLI for browser automation (#421)

* feat: add browseros-cli Go CLI for browser automation

Implements a full-featured CLI that communicates with the BrowserOS MCP
server over JSON-RPC 2.0 / StreamableHTTP. Covers all 54 MCP tools across
10 categories with a hybrid command structure (flat verbs for hot-path
commands, grouped noun-verb for resource management).

- MCP client with initialize + tools/call pattern, thread-safe request IDs
- Dual output: human-readable default, --json for structured/piped usage
- Implicit active page resolution with --page override
- 21 command files: open, nav, snap, click, fill, scroll, eval, ss, pdf,
  dom, wait, dialog, pages, window, bookmark, history, group, health, info
- Cobra CLI framework with fatih/color for terminal formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add end-to-end integration tests for browseros-cli

Go integration tests gated by `//go:build integration` that exercise the
CLI binary against a running BrowserOS server. Tests build the binary,
run commands via exec.Command, and verify JSON output.

Covers: health, version, page lifecycle (open → text → snap → eval →
screenshot → nav → reload → close), active page, info, error handling,
and invalid page ID rejection. Skips gracefully when no server is running.

Run with: go test -tags integration -v ./...

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add init command and fix MCP client bugs

- Add `browseros-cli init` command that prompts for the server URL,
  verifies connectivity, and saves to ~/.config/browseros-cli/config.json
- Config priority: --server flag > BROWSEROS_URL env > config file > default
- Fix Accept header: include text/event-stream (required by StreamableHTTPTransport)
- Fix nil args: send empty object {} instead of null for tools with no params
- Update error messages to suggest `browseros-cli init` on connection failure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add README for browseros-cli with setup, usage, and testing guide

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: always send arguments object in MCP tools/call

Go's json omitempty omits empty maps, causing the arguments field to be
missing from tools/call requests. The MCP SDK requires arguments to be
an object (even empty {}), not undefined. Remove omitempty from the tag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update help menu to be have groups

* refactor: replace hand-rolled MCP client with official Go SDK

Switch from custom JSON-RPC implementation to the official
github.com/modelcontextprotocol/go-sdk. This removes all hand-rolled
protocol types (jsonrpcRequest, jsonrpcResponse, RPCError, etc.) and
uses the SDK's StreamableClientTransport with DisableStandaloneSSE
for clean CLI process lifecycle.

Also adds URL normalization/validation, config command, and
updates init/README to reference YAML config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nikhil
2026-03-05 14:49:45 -08:00
committed by GitHub
parent 9fdb361d67
commit b7e63a4a1f
35 changed files with 3041 additions and 0 deletions

1
apps/cli/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
browseros-cli

20
apps/cli/Makefile Normal file
View File

@@ -0,0 +1,20 @@
BINARY := browseros-cli
SOURCES := $(shell find . -name '*.go')
VERSION ?= dev
$(BINARY): $(SOURCES)
go build -ldflags "-X main.version=$(VERSION)" -o $(BINARY) .
.PHONY: install clean vet test
install:
go install -ldflags "-X main.version=$(VERSION)" .
clean:
rm -f $(BINARY)
vet:
go vet ./...
test:
go test -tags integration -v -timeout 120s ./...

166
apps/cli/README.md Normal file
View File

@@ -0,0 +1,166 @@
# browseros-cli
Command-line interface for controlling BrowserOS via MCP. Talks to the BrowserOS MCP server over JSON-RPC 2.0 / StreamableHTTP.
## Setup
Requires Go 1.25+.
```bash
# Build
make
# First run — configure server connection
./browseros-cli init
```
The `init` command prompts for your MCP server URL. Find it in:
**BrowserOS → Settings → BrowserOS MCP → Server URL**
The port varies per installation (e.g., `http://127.0.0.1:9004/mcp`).
Config is saved to `~/.config/browseros-cli/config.yaml`.
## Usage
```bash
# Check connection
browseros-cli health
browseros-cli status
# Pages
browseros-cli pages # List all tabs
browseros-cli active # Show active tab
browseros-cli open https://example.com
browseros-cli close 42
# Navigation
browseros-cli nav https://example.com
browseros-cli back
browseros-cli forward
browseros-cli reload
# Observation
browseros-cli snap # Accessibility tree snapshot
browseros-cli snap -e # Enhanced snapshot
browseros-cli text # Extract page as markdown
browseros-cli links # Extract all links
browseros-cli eval "document.title" # Run JavaScript
# Input
browseros-cli click e5 # Click element by ref
browseros-cli click-at 100 200 # Click at coordinates
browseros-cli fill e12 "hello" # Type into input
browseros-cli key Enter # Press key
browseros-cli hover e3
browseros-cli scroll down 500
# Screenshots & export
browseros-cli ss # Screenshot (saves to screenshot.png)
browseros-cli ss -o shot.png # Screenshot to specific file
browseros-cli pdf -o page.pdf # Export as PDF
# Resource management (grouped commands)
browseros-cli window list
browseros-cli bookmark search "github"
browseros-cli history recent
browseros-cli group list
```
## Global Flags
| Flag | Env Var | Description |
|------|---------|-------------|
| `--server, -s` | `BROWSEROS_URL` | Server URL (default: from config) |
| `--page, -p` | `BROWSEROS_PAGE` | Target page ID (default: active page) |
| `--json` | `BOS_JSON=1` | JSON output (outputs structuredContent) |
| `--debug` | `BOS_DEBUG=1` | Debug output |
| `--timeout, -t` | | Request timeout (default: 2m) |
Priority for server URL: `--server` flag > `BROWSEROS_URL` env > config file
If no server URL is configured, the CLI exits with setup instructions instead of assuming a localhost port.
## Testing
Integration tests require a running BrowserOS server with the dev build (for structured content support).
```bash
# 1. Start the dev server from the monorepo root
bun run dev:watch:new
# 2. Configure the CLI to point at the dev server
./browseros-cli init
# Enter the Server URL shown in BrowserOS settings
# 3. Run integration tests
make test
# Or with a custom server URL
BROWSEROS_URL=http://127.0.0.1:9105 go test -tags integration -v ./...
```
Tests skip gracefully if no server is reachable — they won't fail in environments without BrowserOS.
The integration tests (`integration_test.go`) cover:
- Health check and version
- Page lifecycle: open → text → snap → eval → screenshot → nav → reload → close
- Active page query
- Info command
- Error handling (invalid page ID, JS errors)
## Build
```bash
make # Build binary
make vet # Run go vet
make test # Run integration tests
make install # Install to $GOPATH/bin
make clean # Remove binary
VERSION=1.0 make # Build with version
```
## Architecture
```
apps/cli/
├── main.go # Entry point
├── Makefile # Build targets
├── config/
│ └── config.go # Config file (~/.config/browseros-cli/config.yaml)
├── cmd/
│ ├── root.go # Root command, global flags
│ ├── init.go # Server URL configuration
│ ├── open.go # open (new_page / new_hidden_page)
│ ├── nav.go # nav, back, forward, reload
│ ├── pages.go # pages, active, close
│ ├── snap.go # snap (take_snapshot / take_enhanced_snapshot)
│ ├── text.go # text, links
│ ├── screenshot.go # ss (take_screenshot / save_screenshot)
│ ├── eval.go # eval (evaluate_script)
│ ├── click.go # click, click-at
│ ├── fill.go # fill, clear, key
│ ├── interact.go # hover, focus, check, uncheck, select, drag, upload
│ ├── scroll.go # scroll
│ ├── dialog.go # dialog (handle_dialog)
│ ├── wait.go # wait (wait_for)
│ ├── file_actions.go # pdf, download
│ ├── dom.go # dom, dom-search
│ ├── window.go # window {list,create,close,activate}
│ ├── bookmark.go # bookmark {list,create,remove,update,move,search}
│ ├── history.go # history {search,recent,delete,delete-range}
│ ├── group.go # group {list,create,update,ungroup,close}
│ ├── health.go # health, status (REST endpoints)
│ └── info.go # info (browseros_info)
├── mcp/
│ ├── client.go # MCP JSON-RPC 2.0 client (initialize + tools/call)
│ └── types.go # JSON-RPC and MCP type definitions
└── output/
└── printer.go # Human-readable and JSON output formatting
```
The CLI communicates with BrowserOS via two HTTP POST requests per command:
1. `initialize` — MCP handshake
2. `tools/call` — execute the actual tool
All 54 MCP tools are mapped to CLI commands.

157
apps/cli/cmd/bookmark.go Normal file
View File

@@ -0,0 +1,157 @@
package cmd
import (
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
bookmarkCmd := &cobra.Command{
Use: "bookmark",
Annotations: map[string]string{"group": "Resources:"},
Short: "Manage bookmarks",
}
listCmd := &cobra.Command{
Use: "list",
Short: "List all bookmarks",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
result, err := c.CallTool("get_bookmarks", nil)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
createCmd := &cobra.Command{
Use: "create <title> [url]",
Short: "Create a bookmark or folder",
Args: cobra.RangeArgs(1, 2),
Run: func(cmd *cobra.Command, args []string) {
parent, _ := cmd.Flags().GetString("parent")
toolArgs := map[string]any{"title": args[0]}
if len(args) > 1 {
toolArgs["url"] = args[1]
}
if parent != "" {
toolArgs["parentId"] = parent
}
c := newClient()
result, err := c.CallTool("create_bookmark", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
createCmd.Flags().String("parent", "", "Parent folder ID")
removeCmd := &cobra.Command{
Use: "remove <id>",
Short: "Remove a bookmark or folder",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
result, err := c.CallTool("remove_bookmark", map[string]any{"id": args[0]})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
updateCmd := &cobra.Command{
Use: "update <id>",
Short: "Update a bookmark",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
title, _ := cmd.Flags().GetString("title")
url, _ := cmd.Flags().GetString("url")
toolArgs := map[string]any{"id": args[0]}
if title != "" {
toolArgs["title"] = title
}
if url != "" {
toolArgs["url"] = url
}
c := newClient()
result, err := c.CallTool("update_bookmark", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
updateCmd.Flags().String("title", "", "New title")
updateCmd.Flags().String("url", "", "New URL")
moveCmd := &cobra.Command{
Use: "move <id>",
Short: "Move a bookmark to a different folder",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
parent, _ := cmd.Flags().GetString("parent")
index, _ := cmd.Flags().GetInt("index")
toolArgs := map[string]any{"id": args[0]}
if parent != "" {
toolArgs["parentId"] = parent
}
if cmd.Flags().Changed("index") {
toolArgs["index"] = index
}
c := newClient()
result, err := c.CallTool("move_bookmark", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
moveCmd.Flags().String("parent", "", "Target parent folder ID")
moveCmd.Flags().Int("index", 0, "Position index")
searchCmd := &cobra.Command{
Use: "search <query>",
Short: "Search bookmarks",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
result, err := c.CallTool("search_bookmarks", map[string]any{"query": args[0]})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
bookmarkCmd.AddCommand(listCmd, createCmd, removeCmd, updateCmd, moveCmd, searchCmd)
rootCmd.AddCommand(bookmarkCmd)
}

102
apps/cli/cmd/click.go Normal file
View File

@@ -0,0 +1,102 @@
package cmd
import (
"fmt"
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
clickCmd := &cobra.Command{
Use: "click <element>",
Annotations: map[string]string{"group": "Input:"},
Short: "Click an element by snapshot ID",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var element int
if _, err := fmt.Sscanf(args[0], "%d", &element); err != nil {
output.Errorf(3, "invalid element ID: %s", args[0])
}
right, _ := cmd.Flags().GetBool("right")
middle, _ := cmd.Flags().GetBool("middle")
double, _ := cmd.Flags().GetBool("double")
button := "left"
if right {
button = "right"
} else if middle {
button = "middle"
}
clickCount := 1
if double {
clickCount = 2
}
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("click", map[string]any{
"page": pageID,
"element": element,
"button": button,
"clickCount": clickCount,
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
clickCmd.Flags().Bool("right", false, "Right-click")
clickCmd.Flags().Bool("middle", false, "Middle-click")
clickCmd.Flags().Bool("double", false, "Double-click")
clickAtCmd := &cobra.Command{
Use: "click-at <x> <y>",
Annotations: map[string]string{"group": "Input:"},
Short: "Click at specific coordinates",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
var x, y int
if _, err := fmt.Sscanf(args[0], "%d", &x); err != nil {
output.Errorf(3, "invalid x coordinate: %s", args[0])
}
if _, err := fmt.Sscanf(args[1], "%d", &y); err != nil {
output.Errorf(3, "invalid y coordinate: %s", args[1])
}
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("click_at", map[string]any{
"page": pageID,
"x": x,
"y": y,
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
rootCmd.AddCommand(clickCmd, clickAtCmd)
}

56
apps/cli/cmd/config.go Normal file
View File

@@ -0,0 +1,56 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"browseros-cli/config"
"github.com/spf13/cobra"
)
func init() {
cmd := &cobra.Command{
Use: "config",
Aliases: []string{"cfg"},
Annotations: map[string]string{"group": "Setup:"},
Short: "Open config in $EDITOR",
Long: `Open the browseros-cli config file in your editor.
Config file: ~/.config/browseros-cli/config.yaml
Creates the file if it doesn't exist.`,
RunE: func(cmd *cobra.Command, args []string) error {
showPath, _ := cmd.Flags().GetBool("path")
if showPath {
fmt.Println(config.Path())
return nil
}
// Ensure config exists
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
cfg.ServerURL = normalizeServerURL(cfg.ServerURL)
if err := config.Save(cfg); err != nil {
return fmt.Errorf("saving config: %w", err)
}
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
c := exec.Command(editor, config.Path())
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return c.Run()
},
}
cmd.Flags().Bool("path", false, "Print config file path and exit")
rootCmd.AddCommand(cmd)
}

51
apps/cli/cmd/dialog.go Normal file
View File

@@ -0,0 +1,51 @@
package cmd
import (
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
cmd := &cobra.Command{
Use: "dialog <accept|dismiss>",
Annotations: map[string]string{"group": "Input:"},
Short: "Handle a JavaScript dialog",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
action := args[0]
if action != "accept" && action != "dismiss" {
output.Errorf(3, "action must be 'accept' or 'dismiss', got: %s", action)
}
promptText, _ := cmd.Flags().GetString("text")
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
toolArgs := map[string]any{
"page": pageID,
"accept": action == "accept",
}
if promptText != "" {
toolArgs["promptText"] = promptText
}
result, err := c.CallTool("handle_dialog", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
cmd.Flags().String("text", "", "Text for prompt dialogs")
rootCmd.AddCommand(cmd)
}

77
apps/cli/cmd/dom.go Normal file
View File

@@ -0,0 +1,77 @@
package cmd
import (
"strings"
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
domCmd := &cobra.Command{
Use: "dom",
Annotations: map[string]string{"group": "Observe:"},
Short: "Get raw HTML DOM structure",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
selector, _ := cmd.Flags().GetString("selector")
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
toolArgs := map[string]any{"page": pageID}
if selector != "" {
toolArgs["selector"] = selector
}
result, err := c.CallTool("get_dom", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
domCmd.Flags().String("selector", "", "CSS selector to scope")
domSearchCmd := &cobra.Command{
Use: "dom-search <query>",
Annotations: map[string]string{"group": "Observe:"},
Short: "Search DOM by text, CSS selector, or XPath",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
query := strings.Join(args, " ")
limit, _ := cmd.Flags().GetInt("limit")
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("search_dom", map[string]any{
"page": pageID,
"query": query,
"limit": limit,
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
domSearchCmd.Flags().Int("limit", 25, "Max results")
rootCmd.AddCommand(domCmd, domSearchCmd)
}

40
apps/cli/cmd/eval.go Normal file
View File

@@ -0,0 +1,40 @@
package cmd
import (
"strings"
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
cmd := &cobra.Command{
Use: "eval <expression>",
Annotations: map[string]string{"group": "Observe:"},
Short: "Execute JavaScript in the page context",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
expression := strings.Join(args, " ")
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("evaluate_script", map[string]any{
"page": pageID,
"expression": expression,
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
rootCmd.AddCommand(cmd)
}

View File

@@ -0,0 +1,71 @@
package cmd
import (
"fmt"
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
pdfCmd := &cobra.Command{
Use: "pdf <path>",
Annotations: map[string]string{"group": "Observe:"},
Short: "Save the current page as PDF",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("save_pdf", map[string]any{
"page": pageID,
"path": args[0],
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
downloadCmd := &cobra.Command{
Use: "download <element> <dir>",
Annotations: map[string]string{"group": "Input:"},
Short: "Click element to trigger download and save to directory",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
var element int
if _, err := fmt.Sscanf(args[0], "%d", &element); err != nil {
output.Errorf(3, "invalid element ID: %s", args[0])
}
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("download_file", map[string]any{
"page": pageID,
"element": element,
"path": args[1],
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
rootCmd.AddCommand(pdfCmd, downloadCmd)
}

85
apps/cli/cmd/fill.go Normal file
View File

@@ -0,0 +1,85 @@
package cmd
import (
"fmt"
"strings"
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
fillCmd := &cobra.Command{
Use: "fill <element> <text>",
Annotations: map[string]string{"group": "Input:"},
Short: "Type text into an input element",
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
var element int
if _, err := fmt.Sscanf(args[0], "%d", &element); err != nil {
output.Errorf(3, "invalid element ID: %s", args[0])
}
text := strings.Join(args[1:], " ")
noClear, _ := cmd.Flags().GetBool("no-clear")
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("fill", map[string]any{
"page": pageID,
"element": element,
"text": text,
"clear": !noClear,
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
fillCmd.Flags().Bool("no-clear", false, "Don't clear existing text before typing")
clearCmd := &cobra.Command{
Use: "clear <element>",
Annotations: map[string]string{"group": "Input:"},
Short: "Clear text content of an input element",
Args: cobra.ExactArgs(1),
Run: elementAction("clear"),
}
keyCmd := &cobra.Command{
Use: "key <key>",
Annotations: map[string]string{"group": "Input:"},
Short: "Press a key or key combination (e.g., Enter, Control+A)",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("press_key", map[string]any{
"page": pageID,
"key": args[0],
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
rootCmd.AddCommand(fillCmd, clearCmd, keyCmd)
}

154
apps/cli/cmd/group.go Normal file
View File

@@ -0,0 +1,154 @@
package cmd
import (
"fmt"
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
groupCmd := &cobra.Command{
Use: "group",
Annotations: map[string]string{"group": "Resources:"},
Short: "Manage tab groups",
}
listCmd := &cobra.Command{
Use: "list",
Short: "List all tab groups",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
result, err := c.CallTool("list_tab_groups", nil)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
createCmd := &cobra.Command{
Use: "create <pageId...>",
Short: "Group pages together",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
title, _ := cmd.Flags().GetString("title")
pageIDs := make([]int, 0, len(args))
for _, a := range args {
var id int
if _, err := fmt.Sscanf(a, "%d", &id); err != nil {
output.Errorf(3, "invalid page ID: %s", a)
}
pageIDs = append(pageIDs, id)
}
toolArgs := map[string]any{"pageIds": pageIDs}
if title != "" {
toolArgs["title"] = title
}
c := newClient()
result, err := c.CallTool("group_tabs", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
createCmd.Flags().String("title", "", "Group title")
updateCmd := &cobra.Command{
Use: "update <groupId>",
Short: "Update tab group properties",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
title, _ := cmd.Flags().GetString("title")
color, _ := cmd.Flags().GetString("color")
collapsed, _ := cmd.Flags().GetBool("collapsed")
toolArgs := map[string]any{"groupId": args[0]}
if title != "" {
toolArgs["title"] = title
}
if color != "" {
toolArgs["color"] = color
}
if cmd.Flags().Changed("collapsed") {
toolArgs["collapsed"] = collapsed
}
c := newClient()
result, err := c.CallTool("update_tab_group", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
updateCmd.Flags().String("title", "", "Group title")
updateCmd.Flags().String("color", "", "Group color (grey,blue,red,yellow,green,pink,purple,cyan,orange)")
updateCmd.Flags().Bool("collapsed", false, "Collapse the group")
ungroupCmd := &cobra.Command{
Use: "ungroup <pageId...>",
Short: "Remove pages from their groups",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
pageIDs := make([]int, 0, len(args))
for _, a := range args {
var id int
if _, err := fmt.Sscanf(a, "%d", &id); err != nil {
output.Errorf(3, "invalid page ID: %s", a)
}
pageIDs = append(pageIDs, id)
}
c := newClient()
result, err := c.CallTool("ungroup_tabs", map[string]any{"pageIds": pageIDs})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
closeCmd := &cobra.Command{
Use: "close <groupId>",
Short: "Close a tab group and all its tabs",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
result, err := c.CallTool("close_tab_group", map[string]any{"groupId": args[0]})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
groupCmd.AddCommand(listCmd, createCmd, updateCmd, ungroupCmd, closeCmd)
rootCmd.AddCommand(groupCmd)
}

77
apps/cli/cmd/health.go Normal file
View File

@@ -0,0 +1,77 @@
package cmd
import (
"fmt"
"browseros-cli/output"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
func init() {
healthCmd := &cobra.Command{
Use: "health",
Annotations: map[string]string{"group": "Setup:"},
Short: "Check BrowserOS server health",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
data, err := c.Health()
if err != nil {
output.Error(err.Error(), 2)
}
if jsonOut {
output.JSONRaw(data)
return
}
status := fmt.Sprintf("%v", data["status"])
cdp := data["cdpConnected"]
green := color.New(color.FgGreen).SprintFunc()
red := color.New(color.FgRed).SprintFunc()
statusStr := red(status)
if status == "ok" {
statusStr = green(status)
}
fmt.Printf("Server: %s\n", statusStr)
if cdp != nil {
cdpStr := red("disconnected")
if b, ok := cdp.(bool); ok && b {
cdpStr = green("connected")
}
fmt.Printf("CDP: %s\n", cdpStr)
}
},
}
statusCmd := &cobra.Command{
Use: "status",
Annotations: map[string]string{"group": "Setup:"},
Short: "Check extension connection status",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
data, err := c.Status()
if err != nil {
output.Error(err.Error(), 2)
}
if jsonOut {
output.JSONRaw(data)
return
}
green := color.New(color.FgGreen).SprintFunc()
red := color.New(color.FgRed).SprintFunc()
ext := data["extensionConnected"]
extStr := red("disconnected")
if b, ok := ext.(bool); ok && b {
extStr = green("connected")
}
fmt.Printf("Extension: %s\n", extStr)
},
}
rootCmd.AddCommand(healthCmd, statusCmd)
}

111
apps/cli/cmd/history.go Normal file
View File

@@ -0,0 +1,111 @@
package cmd
import (
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
historyCmd := &cobra.Command{
Use: "history",
Annotations: map[string]string{"group": "Resources:"},
Short: "Manage browser history",
}
searchCmd := &cobra.Command{
Use: "search <query>",
Short: "Search browser history",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
max, _ := cmd.Flags().GetInt("max")
c := newClient()
toolArgs := map[string]any{"query": args[0]}
if cmd.Flags().Changed("max") {
toolArgs["maxResults"] = max
}
result, err := c.CallTool("search_history", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
searchCmd.Flags().Int("max", 0, "Max results")
recentCmd := &cobra.Command{
Use: "recent",
Short: "Show recent history",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
max, _ := cmd.Flags().GetInt("max")
c := newClient()
toolArgs := map[string]any{}
if cmd.Flags().Changed("max") {
toolArgs["maxResults"] = max
}
result, err := c.CallTool("get_recent_history", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
recentCmd.Flags().Int("max", 0, "Max results")
deleteCmd := &cobra.Command{
Use: "delete <url>",
Short: "Delete a URL from history",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
result, err := c.CallTool("delete_history_url", map[string]any{"url": args[0]})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
deleteRangeCmd := &cobra.Command{
Use: "delete-range",
Short: "Delete history within a time range",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
start, _ := cmd.Flags().GetInt("start")
end, _ := cmd.Flags().GetInt("end")
c := newClient()
result, err := c.CallTool("delete_history_range", map[string]any{
"startTime": start,
"endTime": end,
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
deleteRangeCmd.Flags().Int("start", 0, "Start time (epoch ms)")
deleteRangeCmd.Flags().Int("end", 0, "End time (epoch ms)")
_ = deleteRangeCmd.MarkFlagRequired("start")
_ = deleteRangeCmd.MarkFlagRequired("end")
historyCmd.AddCommand(searchCmd, recentCmd, deleteCmd, deleteRangeCmd)
rootCmd.AddCommand(historyCmd)
}

34
apps/cli/cmd/info.go Normal file
View File

@@ -0,0 +1,34 @@
package cmd
import (
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
cmd := &cobra.Command{
Use: "info [topic]",
Annotations: map[string]string{"group": "Setup:"},
Short: "Get information about BrowserOS features",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
toolArgs := map[string]any{}
if len(args) > 0 {
toolArgs["topic"] = args[0]
}
result, err := c.CallTool("browseros_info", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
rootCmd.AddCommand(cmd)
}

92
apps/cli/cmd/init.go Normal file
View File

@@ -0,0 +1,92 @@
package cmd
import (
"bufio"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"browseros-cli/config"
"browseros-cli/output"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
func init() {
cmd := &cobra.Command{
Use: "init",
Short: "Configure the BrowserOS server connection",
Long: `Set up the CLI by providing the MCP server URL from BrowserOS.
Open BrowserOS → Settings → BrowserOS MCP to find your Server URL.
The URL looks like: http://127.0.0.1:9004/mcp
The port varies per installation, so this step is required on first use.
Run again if your port changes.`,
Annotations: map[string]string{"group": "Setup:"},
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
dim := color.New(color.Faint)
fmt.Println()
bold.Println("BrowserOS CLI Setup")
fmt.Println()
fmt.Println("Open BrowserOS → Settings → BrowserOS MCP")
fmt.Println("Copy the Server URL shown there.")
fmt.Println()
dim.Println("It looks like: http://127.0.0.1:9004/mcp")
fmt.Println()
reader := bufio.NewReader(os.Stdin)
fmt.Print("Server URL: ")
input, err := reader.ReadString('\n')
if err != nil {
output.Error("failed to read input", 1)
}
input = strings.TrimSpace(input)
if input == "" {
output.Error("no URL provided", 1)
}
baseURL := normalizeServerURL(input)
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Host == "" {
output.Errorf(1, "invalid URL: %s", input)
}
// Verify connectivity
fmt.Printf("Checking connection to %s ...\n", baseURL)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(baseURL + "/health")
if err != nil {
output.Errorf(1, "cannot connect to %s: %v\nIs BrowserOS running?", baseURL, err)
}
resp.Body.Close()
if resp.StatusCode >= 400 {
output.Errorf(1, "server returned HTTP %d — check the URL", resp.StatusCode)
}
cfg := &config.Config{ServerURL: baseURL}
if err := config.Save(cfg); err != nil {
output.Errorf(1, "save config: %v", err)
}
fmt.Println()
green.Printf("Connected! Config saved to %s\n", config.Path())
fmt.Println()
dim.Println("Try: browseros-cli health")
dim.Println(" browseros-cli pages")
},
}
rootCmd.AddCommand(cmd)
}

175
apps/cli/cmd/interact.go Normal file
View File

@@ -0,0 +1,175 @@
package cmd
import (
"fmt"
"strings"
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
hoverCmd := &cobra.Command{
Use: "hover <element>",
Annotations: map[string]string{"group": "Input:"},
Short: "Hover over an element",
Args: cobra.ExactArgs(1),
Run: elementAction("hover"),
}
focusCmd := &cobra.Command{
Use: "focus <element>",
Annotations: map[string]string{"group": "Input:"},
Short: "Focus an element",
Args: cobra.ExactArgs(1),
Run: elementAction("focus"),
}
checkCmd := &cobra.Command{
Use: "check <element>",
Annotations: map[string]string{"group": "Input:"},
Short: "Check a checkbox or radio button",
Args: cobra.ExactArgs(1),
Run: elementAction("check"),
}
uncheckCmd := &cobra.Command{
Use: "uncheck <element>",
Annotations: map[string]string{"group": "Input:"},
Short: "Uncheck a checkbox",
Args: cobra.ExactArgs(1),
Run: elementAction("uncheck"),
}
selectCmd := &cobra.Command{
Use: "select <element> <value>",
Annotations: map[string]string{"group": "Input:"},
Short: "Select a dropdown option",
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
var element int
if _, err := fmt.Sscanf(args[0], "%d", &element); err != nil {
output.Errorf(3, "invalid element ID: %s", args[0])
}
value := strings.Join(args[1:], " ")
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("select_option", map[string]any{
"page": pageID,
"element": element,
"value": value,
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
dragCmd := &cobra.Command{
Use: "drag <source> --to <target>",
Annotations: map[string]string{"group": "Input:"},
Short: "Drag from one element to another",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var source int
if _, err := fmt.Sscanf(args[0], "%d", &source); err != nil {
output.Errorf(3, "invalid source element: %s", args[0])
}
target, _ := cmd.Flags().GetInt("to")
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("drag", map[string]any{
"page": pageID,
"sourceElement": source,
"targetElement": target,
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
dragCmd.Flags().Int("to", 0, "Target element ID")
_ = dragCmd.MarkFlagRequired("to")
uploadCmd := &cobra.Command{
Use: "upload <element> <file...>",
Annotations: map[string]string{"group": "Input:"},
Short: "Upload files to a file input",
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
var element int
if _, err := fmt.Sscanf(args[0], "%d", &element); err != nil {
output.Errorf(3, "invalid element ID: %s", args[0])
}
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("upload_file", map[string]any{
"page": pageID,
"element": element,
"files": args[1:],
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
rootCmd.AddCommand(hoverCmd, focusCmd, checkCmd, uncheckCmd, selectCmd, dragCmd, uploadCmd)
}
// elementAction creates a simple element-based tool command.
func elementAction(toolName string) func(*cobra.Command, []string) {
return func(cmd *cobra.Command, args []string) {
var element int
if _, err := fmt.Sscanf(args[0], "%d", &element); err != nil {
output.Errorf(3, "invalid element ID: %s", args[0])
}
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool(toolName, map[string]any{
"page": pageID,
"element": element,
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
}
}

84
apps/cli/cmd/nav.go Normal file
View File

@@ -0,0 +1,84 @@
package cmd
import (
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
navCmd := &cobra.Command{
Use: "nav <url>",
Annotations: map[string]string{"group": "Navigate:"},
Short: "Navigate the current page to a URL",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("navigate_page", map[string]any{
"page": pageID,
"action": "url",
"url": args[0],
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
backCmd := &cobra.Command{
Use: "back",
Annotations: map[string]string{"group": "Navigate:"},
Short: "Navigate back",
Args: cobra.NoArgs,
Run: navAction("back"),
}
forwardCmd := &cobra.Command{
Use: "forward",
Annotations: map[string]string{"group": "Navigate:"},
Short: "Navigate forward",
Args: cobra.NoArgs,
Run: navAction("forward"),
}
reloadCmd := &cobra.Command{
Use: "reload",
Annotations: map[string]string{"group": "Navigate:"},
Short: "Reload the current page",
Args: cobra.NoArgs,
Run: navAction("reload"),
}
rootCmd.AddCommand(navCmd, backCmd, forwardCmd, reloadCmd)
}
func navAction(action string) func(*cobra.Command, []string) {
return func(cmd *cobra.Command, args []string) {
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("navigate_page", map[string]any{
"page": pageID,
"action": action,
})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
}
}

56
apps/cli/cmd/open.go Normal file
View File

@@ -0,0 +1,56 @@
package cmd
import (
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
cmd := &cobra.Command{
Use: "open <url>",
Annotations: map[string]string{"group": "Navigate:"},
Short: "Open a new page (tab) and navigate to a URL",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
hidden, _ := cmd.Flags().GetBool("hidden")
bg, _ := cmd.Flags().GetBool("bg")
windowID, _ := cmd.Flags().GetInt("window")
c := newClient()
toolArgs := map[string]any{
"url": args[0],
"hidden": hidden,
"background": bg,
}
if cmd.Flags().Changed("window") {
toolArgs["windowId"] = windowID
}
toolName := "new_page"
if hidden {
toolName = "new_hidden_page"
toolArgs = map[string]any{"url": args[0]}
if cmd.Flags().Changed("window") {
toolArgs["windowId"] = windowID
}
}
result, err := c.CallTool(toolName, toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
cmd.Flags().Bool("hidden", false, "Open as hidden tab")
cmd.Flags().Bool("bg", false, "Open in background")
cmd.Flags().Int("window", 0, "Window ID to open in")
rootCmd.AddCommand(cmd)
}

83
apps/cli/cmd/pages.go Normal file
View File

@@ -0,0 +1,83 @@
package cmd
import (
"fmt"
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
pagesCmd := &cobra.Command{
Use: "pages",
Annotations: map[string]string{"group": "Navigate:"},
Short: "List all open pages (tabs)",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
result, err := c.CallTool("list_pages", nil)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.PageList(result)
}
},
}
activeCmd := &cobra.Command{
Use: "active",
Annotations: map[string]string{"group": "Navigate:"},
Short: "Show the active (focused) page",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
result, err := c.CallTool("get_active_page", nil)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.ActivePage(result)
}
},
}
closeCmd := &cobra.Command{
Use: "close [pageId]",
Annotations: map[string]string{"group": "Navigate:"},
Short: "Close a page (tab)",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
var pageID int
var err error
if len(args) > 0 {
_, err = fmt.Sscanf(args[0], "%d", &pageID)
if err != nil {
output.Errorf(3, "invalid page ID: %s", args[0])
}
} else {
pageID, err = resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
}
result, err := c.CallTool("close_page", map[string]any{"page": pageID})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
rootCmd.AddCommand(pagesCmd, activeCmd, closeCmd)
}

195
apps/cli/cmd/root.go Normal file
View File

@@ -0,0 +1,195 @@
package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"browseros-cli/config"
"browseros-cli/mcp"
"browseros-cli/output"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
var (
serverURL string
pageFlag int
pageSet bool
jsonOut bool
debug bool
timeout time.Duration
version = "dev"
)
func SetVersion(v string) {
version = v
}
var (
helpHeaderColor = color.New(color.Bold, color.FgCyan)
helpCmdColor = color.New(color.FgHiGreen)
helpAliasColor = color.New(color.FgYellow)
helpHintColor = color.New(color.Faint)
)
func helpHeader(s string) string { return helpHeaderColor.Sprint(s) }
func helpCmdCol(s string) string { return helpCmdColor.Sprint(s) }
func helpHint(s string) string { return helpHintColor.Sprint(s) }
func helpAliases(aliases []string) string {
return helpAliasColor.Sprintf("(aliases: %s)", strings.Join(aliases, ", "))
}
var groupOrder = []string{
"Navigate:",
"Observe:",
"Input:",
"Resources:",
"Setup:",
}
func groupedHelp(cmd *cobra.Command) string {
groups := map[string][]*cobra.Command{}
for _, c := range cmd.Commands() {
if !c.IsAvailableCommand() && c.Name() != "help" {
continue
}
g := c.Annotations["group"]
if g == "" {
g = "Setup:"
}
groups[g] = append(groups[g], c)
}
var b strings.Builder
for _, name := range groupOrder {
cmds, ok := groups[name]
if !ok {
continue
}
b.WriteString("\n" + helpHeader(name) + "\n")
for _, c := range cmds {
line := " " + helpCmdCol(fmt.Sprintf("%-14s", c.Name())) + " " + c.Short
if len(c.Aliases) > 0 {
line += " " + helpAliases(c.Aliases)
}
b.WriteString(line + "\n")
}
}
return b.String()
}
const usageTemplate = `{{helpHeader "Usage:"}}{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
{{helpHeader "Aliases:"}}
{{.NameAndAliases}}{{end}}{{if .HasExample}}
{{helpHeader "Examples:"}}
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}
{{groupedHelp .}}{{end}}{{if .HasAvailableLocalFlags}}
{{helpHeader "Flags:"}}
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
{{helpHeader "Global Flags:"}}
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableSubCommands}}
{{helpHint (printf "Use \"%s [command] --help\" for more information." .CommandPath)}}{{end}}
`
var rootCmd = &cobra.Command{
Use: "browseros-cli",
Short: "Browser control CLI for BrowserOS",
Long: "browseros-cli — command-line interface for controlling BrowserOS via MCP",
SilenceUsage: true,
SilenceErrors: true,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func init() {
cobra.AddTemplateFunc("helpHeader", helpHeader)
cobra.AddTemplateFunc("helpCmdCol", helpCmdCol)
cobra.AddTemplateFunc("helpAliases", helpAliases)
cobra.AddTemplateFunc("helpHint", helpHint)
cobra.AddTemplateFunc("groupedHelp", groupedHelp)
rootCmd.SetUsageTemplate(usageTemplate)
rootCmd.PersistentFlags().StringVarP(&serverURL, "server", "s", defaultServerURL(), "BrowserOS server URL")
rootCmd.PersistentFlags().IntVarP(&pageFlag, "page", "p", 0, "Target page ID (default: active page)")
rootCmd.PersistentFlags().BoolVar(&jsonOut, "json", envBool("BOS_JSON"), "JSON output")
rootCmd.PersistentFlags().BoolVar(&debug, "debug", envBool("BOS_DEBUG"), "Debug output")
rootCmd.PersistentFlags().DurationVarP(&timeout, "timeout", "t", 120*time.Second, "Request timeout")
rootCmd.Version = version
}
func newClient() *mcp.Client {
baseURL, err := validateServerURL(serverURL)
if err != nil {
output.Error(err.Error(), 1)
}
c := mcp.NewClient(baseURL, version, timeout)
c.Debug = debug
return c
}
func resolvePageID(c *mcp.Client) (int, error) {
if rootCmd.PersistentFlags().Changed("page") {
return pageFlag, nil
}
if env := os.Getenv("BROWSEROS_PAGE"); env != "" {
if v, err := strconv.Atoi(env); err == nil {
return v, nil
}
}
return c.ResolvePageID(nil)
}
func envBool(key string) bool {
v := os.Getenv(key)
return v == "1" || v == "true"
}
func defaultServerURL() string {
if env := normalizeServerURL(os.Getenv("BROWSEROS_URL")); env != "" {
return env
}
cfg, err := config.Load()
if err != nil {
return ""
}
return normalizeServerURL(cfg.ServerURL)
}
func normalizeServerURL(raw string) string {
normalized := strings.TrimSpace(raw)
normalized = strings.TrimSuffix(normalized, "/mcp")
return strings.TrimSuffix(normalized, "/")
}
func validateServerURL(raw string) (string, error) {
baseURL := normalizeServerURL(raw)
if baseURL != "" {
return baseURL, nil
}
return "", fmt.Errorf(
"BrowserOS server URL is not configured.\n Open BrowserOS -> Settings -> BrowserOS MCP and copy the Server URL.\n Then run: browseros-cli init",
)
}

104
apps/cli/cmd/screenshot.go Normal file
View File

@@ -0,0 +1,104 @@
package cmd
import (
"encoding/base64"
"fmt"
"os"
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
cmd := &cobra.Command{
Use: "ss",
Annotations: map[string]string{"group": "Observe:"},
Short: "Take a screenshot",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
outFile, _ := cmd.Flags().GetString("out")
full, _ := cmd.Flags().GetBool("full")
format, _ := cmd.Flags().GetString("format")
quality, _ := cmd.Flags().GetInt("quality")
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
if outFile != "" {
toolArgs := map[string]any{
"page": pageID,
"path": outFile,
"format": format,
}
if full {
toolArgs["fullPage"] = true
}
if cmd.Flags().Changed("quality") {
toolArgs["quality"] = quality
}
result, err := c.CallTool("save_screenshot", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
return
}
toolArgs := map[string]any{
"page": pageID,
"format": format,
}
if full {
toolArgs["fullPage"] = true
}
if cmd.Flags().Changed("quality") {
toolArgs["quality"] = quality
}
result, err := c.CallTool("take_screenshot", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
return
}
img := result.ImageContent()
if img == nil {
output.Confirm("Screenshot taken (no image data returned)")
return
}
ext := format
if ext == "" {
ext = "png"
}
filename := "screenshot." + ext
data, err := base64.StdEncoding.DecodeString(img.Data)
if err != nil {
output.Errorf(1, "decode image: %s", err)
}
if err := os.WriteFile(filename, data, 0644); err != nil {
output.Errorf(1, "write file: %s", err)
}
fmt.Printf("Screenshot saved: %s\n", filename)
},
}
cmd.Flags().StringP("out", "o", "", "Output file path")
cmd.Flags().BoolP("full", "f", false, "Full page screenshot")
cmd.Flags().String("format", "png", "Image format (png, jpeg, webp)")
cmd.Flags().Int("quality", 0, "Compression quality (jpeg/webp)")
rootCmd.AddCommand(cmd)
}

56
apps/cli/cmd/scroll.go Normal file
View File

@@ -0,0 +1,56 @@
package cmd
import (
"fmt"
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
cmd := &cobra.Command{
Use: "scroll <direction> [amount]",
Annotations: map[string]string{"group": "Input:"},
Short: "Scroll the page (up, down, left, right)",
Args: cobra.RangeArgs(1, 2),
Run: func(cmd *cobra.Command, args []string) {
direction := args[0]
amount := 3
if len(args) > 1 {
if _, err := fmt.Sscanf(args[1], "%d", &amount); err != nil {
output.Errorf(3, "invalid amount: %s", args[1])
}
}
element, _ := cmd.Flags().GetInt("element")
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
toolArgs := map[string]any{
"page": pageID,
"direction": direction,
"amount": amount,
}
if cmd.Flags().Changed("element") {
toolArgs["element"] = element
}
result, err := c.CallTool("scroll", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
cmd.Flags().Int("element", 0, "Element ID to scroll at")
rootCmd.AddCommand(cmd)
}

42
apps/cli/cmd/snap.go Normal file
View File

@@ -0,0 +1,42 @@
package cmd
import (
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
cmd := &cobra.Command{
Use: "snap",
Annotations: map[string]string{"group": "Observe:"},
Short: "Snapshot interactive elements on the page",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
enhanced, _ := cmd.Flags().GetBool("enhanced")
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
toolName := "take_snapshot"
if enhanced {
toolName = "take_enhanced_snapshot"
}
result, err := c.CallTool(toolName, map[string]any{"page": pageID})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
cmd.Flags().BoolP("enhanced", "e", false, "Detailed accessibility tree with structural context")
rootCmd.AddCommand(cmd)
}

78
apps/cli/cmd/text.go Normal file
View File

@@ -0,0 +1,78 @@
package cmd
import (
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
textCmd := &cobra.Command{
Use: "text",
Annotations: map[string]string{"group": "Observe:"},
Short: "Extract page content as markdown",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
selector, _ := cmd.Flags().GetString("selector")
viewport, _ := cmd.Flags().GetBool("viewport")
links, _ := cmd.Flags().GetBool("links")
images, _ := cmd.Flags().GetBool("images")
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
toolArgs := map[string]any{
"page": pageID,
"viewportOnly": viewport,
"includeLinks": links,
"includeImages": images,
}
if selector != "" {
toolArgs["selector"] = selector
}
result, err := c.CallTool("get_page_content", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
textCmd.Flags().String("selector", "", "CSS selector to scope extraction")
textCmd.Flags().Bool("viewport", false, "Only visible content")
textCmd.Flags().Bool("links", false, "Include links as [text](url)")
textCmd.Flags().Bool("images", false, "Include image references")
linksCmd := &cobra.Command{
Use: "links",
Annotations: map[string]string{"group": "Observe:"},
Short: "Extract all links from the page",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
result, err := c.CallTool("get_page_links", map[string]any{"page": pageID})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
rootCmd.AddCommand(textCmd, linksCmd)
}

57
apps/cli/cmd/wait.go Normal file
View File

@@ -0,0 +1,57 @@
package cmd
import (
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
cmd := &cobra.Command{
Use: "wait",
Annotations: map[string]string{"group": "Observe:"},
Short: "Wait for text or selector to appear on the page",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
text, _ := cmd.Flags().GetString("text")
selector, _ := cmd.Flags().GetString("selector")
waitTimeout, _ := cmd.Flags().GetInt("wait-timeout")
if text == "" && selector == "" {
output.Errorf(3, "provide --text or --selector")
}
c := newClient()
pageID, err := resolvePageID(c)
if err != nil {
output.Error(err.Error(), 2)
}
toolArgs := map[string]any{
"page": pageID,
"timeout": waitTimeout,
}
if text != "" {
toolArgs["text"] = text
}
if selector != "" {
toolArgs["selector"] = selector
}
result, err := c.CallTool("wait_for", toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
cmd.Flags().String("text", "", "Text to wait for")
cmd.Flags().String("selector", "", "CSS selector to wait for")
cmd.Flags().Int("wait-timeout", 10000, "Timeout in milliseconds")
rootCmd.AddCommand(cmd)
}

109
apps/cli/cmd/window.go Normal file
View File

@@ -0,0 +1,109 @@
package cmd
import (
"fmt"
"browseros-cli/output"
"github.com/spf13/cobra"
)
func init() {
windowCmd := &cobra.Command{
Use: "window",
Annotations: map[string]string{"group": "Resources:"},
Short: "Manage browser windows",
}
listCmd := &cobra.Command{
Use: "list",
Short: "List all browser windows",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
c := newClient()
result, err := c.CallTool("list_windows", nil)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Text(result)
}
},
}
createCmd := &cobra.Command{
Use: "create",
Short: "Create a new browser window",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
hidden, _ := cmd.Flags().GetBool("hidden")
c := newClient()
toolName := "create_window"
toolArgs := map[string]any{}
if hidden {
toolName = "create_hidden_window"
}
result, err := c.CallTool(toolName, toolArgs)
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
createCmd.Flags().Bool("hidden", false, "Create hidden window")
closeCmd := &cobra.Command{
Use: "close <windowId>",
Short: "Close a browser window",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var windowID int
if _, err := fmt.Sscanf(args[0], "%d", &windowID); err != nil {
output.Errorf(3, "invalid window ID: %s", args[0])
}
c := newClient()
result, err := c.CallTool("close_window", map[string]any{"windowId": windowID})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
activateCmd := &cobra.Command{
Use: "activate <windowId>",
Short: "Activate (focus) a browser window",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var windowID int
if _, err := fmt.Sscanf(args[0], "%d", &windowID); err != nil {
output.Errorf(3, "invalid window ID: %s", args[0])
}
c := newClient()
result, err := c.CallTool("activate_window", map[string]any{"windowId": windowID})
if err != nil {
output.Error(err.Error(), 1)
}
if jsonOut {
output.JSON(result)
} else {
output.Confirm(result.TextContent())
}
},
}
windowCmd.AddCommand(listCmd, createCmd, closeCmd, activateCmd)
rootCmd.AddCommand(windowCmd)
}

53
apps/cli/config/config.go Normal file
View File

@@ -0,0 +1,53 @@
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Config struct {
ServerURL string `yaml:"server_url"`
}
func Dir() string {
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "browseros-cli")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "browseros-cli")
}
func Path() string {
return filepath.Join(Dir(), "config.yaml")
}
func Load() (*Config, error) {
data, err := os.ReadFile(Path())
if err != nil {
if os.IsNotExist(err) {
return &Config{}, nil
}
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return &cfg, nil
}
func Save(cfg *Config) error {
dir := Dir()
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
header := "# browseros-cli configuration\n# Run: browseros-cli config --path\n\n"
return os.WriteFile(Path(), []byte(header+string(data)), 0644)
}

23
apps/cli/go.mod Normal file
View File

@@ -0,0 +1,23 @@
module browseros-cli
go 1.25.7
require (
github.com/fatih/color v1.18.0
github.com/spf13/cobra v1.10.2
)
require (
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modelcontextprotocol/go-sdk v1.4.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

37
apps/cli/go.sum Normal file
View File

@@ -0,0 +1,37 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=
github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,253 @@
//go:build integration
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
var (
cliBinary string
serverURL string
)
func TestMain(m *testing.M) {
serverURL = os.Getenv("BROWSEROS_URL")
if serverURL == "" {
serverURL = "http://127.0.0.1:9105"
}
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(serverURL + "/health")
if err != nil {
fmt.Fprintf(os.Stderr, "Skipping integration tests: server not reachable at %s\n", serverURL)
os.Exit(0)
}
resp.Body.Close()
tmpDir, err := os.MkdirTemp("", "browseros-cli-test-*")
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create temp dir: %v\n", err)
os.Exit(1)
}
cliBinary = filepath.Join(tmpDir, "browseros-cli")
buildCmd := exec.Command("go", "build", "-o", cliBinary, ".")
buildCmd.Stderr = os.Stderr
if err := buildCmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to build CLI: %v\n", err)
os.RemoveAll(tmpDir)
os.Exit(1)
}
code := m.Run()
os.RemoveAll(tmpDir)
os.Exit(code)
}
type runResult struct {
Stdout string
Stderr string
ExitCode int
}
func run(t *testing.T, args ...string) runResult {
t.Helper()
fullArgs := append([]string{"--server", serverURL}, args...)
cmd := exec.Command(cliBinary, fullArgs...)
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
code := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
code = exitErr.ExitCode()
} else {
t.Fatalf("exec error: %v", err)
}
}
return runResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExitCode: code,
}
}
func runJSON(t *testing.T, args ...string) map[string]any {
t.Helper()
fullArgs := append([]string{"--json"}, args...)
r := run(t, fullArgs...)
if r.ExitCode != 0 {
t.Fatalf("command %v exited %d: %s%s", args, r.ExitCode, r.Stdout, r.Stderr)
}
var data map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(r.Stdout)), &data); err != nil {
t.Fatalf("invalid JSON output for %v: %v\nraw: %s", args, err, r.Stdout)
}
return data
}
func TestHealth(t *testing.T) {
data := runJSON(t, "health")
status, ok := data["status"].(string)
if !ok || status != "ok" {
t.Errorf("expected status ok, got %v", data["status"])
}
}
func TestVersion(t *testing.T) {
r := run(t, "--version")
if r.ExitCode != 0 {
t.Fatalf("--version exited %d", r.ExitCode)
}
if !strings.Contains(r.Stdout, "browseros-cli") {
t.Errorf("expected version output to contain 'browseros-cli', got: %s", r.Stdout)
}
}
func TestPageLifecycle(t *testing.T) {
// List existing pages
pagesBefore := runJSON(t, "pages")
countBefore, _ := pagesBefore["count"].(float64)
if countBefore < 1 {
t.Log("Warning: no pages found before test, server may not have a browser connected")
}
// Open a new page
openData := runJSON(t, "open", "https://example.com")
pageIDFloat, ok := openData["pageId"].(float64)
if !ok {
t.Fatalf("expected pageId in open response, got: %v", openData)
}
pageID := int(pageIDFloat)
t.Logf("Opened page %d", pageID)
pageArg := fmt.Sprintf("-p=%d", pageID)
// Verify page count increased
pagesAfter := runJSON(t, "pages")
countAfter, _ := pagesAfter["count"].(float64)
if countAfter <= countBefore {
t.Errorf("expected page count to increase: before=%v after=%v", countBefore, countAfter)
}
// Wait for page to load
time.Sleep(2 * time.Second)
// Text extraction
t.Run("text", func(t *testing.T) {
data := runJSON(t, "text", pageArg)
// structuredContent may have a "text" key or the content items have text
// With --json, the output is structuredContent if present
raw, _ := json.Marshal(data)
if !strings.Contains(strings.ToLower(string(raw)), "example") {
t.Errorf("expected page content to mention 'example', got: %s", string(raw))
}
})
// Snapshot
t.Run("snap", func(t *testing.T) {
r := run(t, "--json", "snap", pageArg)
if r.ExitCode != 0 {
t.Fatalf("snap exited %d: %s%s", r.ExitCode, r.Stdout, r.Stderr)
}
if len(r.Stdout) < 10 {
t.Errorf("snapshot output too short: %s", r.Stdout)
}
})
// Eval
t.Run("eval", func(t *testing.T) {
r := run(t, "--json", "eval", pageArg, "document.title")
if r.ExitCode != 0 {
t.Fatalf("eval exited %d: %s%s", r.ExitCode, r.Stdout, r.Stderr)
}
out := strings.TrimSpace(r.Stdout)
if !strings.Contains(strings.ToLower(out), "example") {
t.Errorf("expected eval result to contain 'example', got: %s", out)
}
})
// Screenshot
t.Run("screenshot", func(t *testing.T) {
r := run(t, "--json", "ss", pageArg)
if r.ExitCode != 0 {
t.Fatalf("ss exited %d: %s%s", r.ExitCode, r.Stdout, r.Stderr)
}
out := strings.TrimSpace(r.Stdout)
// JSON output should contain image data or mimeType
if !strings.Contains(out, "image") && !strings.Contains(out, "data") {
t.Errorf("expected screenshot output to contain image data, got: %s", out[:min(len(out), 200)])
}
})
// Navigate
t.Run("nav", func(t *testing.T) {
r := run(t, "--json", "nav", pageArg, "https://example.com/nav-test")
if r.ExitCode != 0 {
t.Fatalf("nav exited %d: %s%s", r.ExitCode, r.Stdout, r.Stderr)
}
})
// Reload
t.Run("reload", func(t *testing.T) {
r := run(t, "--json", "reload", pageArg)
if r.ExitCode != 0 {
t.Fatalf("reload exited %d: %s%s", r.ExitCode, r.Stdout, r.Stderr)
}
})
// Close the page (cleanup)
closeR := run(t, "--json", "close", fmt.Sprintf("%d", pageID))
if closeR.ExitCode != 0 {
t.Errorf("close exited %d: %s%s", closeR.ExitCode, closeR.Stdout, closeR.Stderr)
}
}
func TestActivePage(t *testing.T) {
data := runJSON(t, "active")
// Should return structured content with pageId
raw, _ := json.Marshal(data)
rawStr := string(raw)
if !strings.Contains(rawStr, "pageId") && !strings.Contains(rawStr, "page") {
t.Errorf("expected active page response to contain pageId, got: %s", rawStr)
}
}
func TestInfo(t *testing.T) {
r := run(t, "--json", "info")
if r.ExitCode != 0 {
t.Fatalf("info exited %d: %s%s", r.ExitCode, r.Stdout, r.Stderr)
}
if len(r.Stdout) < 5 {
t.Errorf("info output too short: %s", r.Stdout)
}
}
func TestEvalError(t *testing.T) {
// Open a page for eval
openData := runJSON(t, "open", "about:blank")
pageID := int(openData["pageId"].(float64))
defer run(t, "close", fmt.Sprintf("%d", pageID))
r := run(t, "--json", "eval", fmt.Sprintf("-p=%d", pageID), "throw new Error('test-error')")
if r.ExitCode == 0 {
t.Errorf("expected eval with throw to exit non-zero")
}
}
func TestInvalidPage(t *testing.T) {
r := run(t, "--json", "snap", "-p=999999")
if r.ExitCode == 0 {
t.Errorf("expected snap with invalid page ID to exit non-zero")
}
}

10
apps/cli/main.go Normal file
View File

@@ -0,0 +1,10 @@
package main
import "browseros-cli/cmd"
var version = "dev"
func main() {
cmd.SetVersion(version)
cmd.Execute()
}

162
apps/cli/mcp/client.go Normal file
View File

@@ -0,0 +1,162 @@
package mcp
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
)
type Client struct {
BaseURL string
HTTPClient *http.Client
Version string
Debug bool
}
func NewClient(baseURL, version string, timeout time.Duration) *Client {
return &Client{
BaseURL: baseURL,
HTTPClient: &http.Client{
Timeout: timeout,
},
Version: version,
}
}
func (c *Client) connect(ctx context.Context) (*sdkmcp.ClientSession, error) {
sdkClient := sdkmcp.NewClient(&sdkmcp.Implementation{
Name: "browseros-cli",
Version: c.Version,
}, nil)
transport := &sdkmcp.StreamableClientTransport{
Endpoint: c.BaseURL + "/mcp",
HTTPClient: c.HTTPClient,
DisableStandaloneSSE: true,
}
session, err := sdkClient.Connect(ctx, transport, nil)
if err != nil {
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n Is the server running? Try: browseros-cli init", c.BaseURL, err)
}
return session, nil
}
// CallTool connects, initializes, calls the named tool, and returns the result.
func (c *Client) CallTool(name string, args map[string]any) (*ToolResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), c.HTTPClient.Timeout)
defer cancel()
session, err := c.connect(ctx)
if err != nil {
return nil, err
}
defer session.Close()
if args == nil {
args = map[string]any{}
}
sdkResult, err := session.CallTool(ctx, &sdkmcp.CallToolParams{
Name: name,
Arguments: args,
})
if err != nil {
return nil, err
}
result := convertResult(sdkResult)
if result.IsError {
return result, fmt.Errorf("%s", result.TextContent())
}
return result, nil
}
func convertResult(r *sdkmcp.CallToolResult) *ToolResult {
result := &ToolResult{
IsError: r.IsError,
}
for _, c := range r.Content {
switch v := c.(type) {
case *sdkmcp.TextContent:
result.Content = append(result.Content, ContentItem{Type: "text", Text: v.Text})
case *sdkmcp.ImageContent:
result.Content = append(result.Content, ContentItem{Type: "image", Data: base64.StdEncoding.EncodeToString(v.Data), MimeType: v.MIMEType})
}
}
if r.StructuredContent != nil {
switch sc := r.StructuredContent.(type) {
case map[string]any:
result.StructuredContent = sc
default:
data, err := json.Marshal(sc)
if err == nil {
var m map[string]any
if json.Unmarshal(data, &m) == nil {
result.StructuredContent = m
}
}
}
}
return result
}
// ResolvePageID returns the explicit page ID or fetches the active page.
func (c *Client) ResolvePageID(explicit *int) (int, error) {
if explicit != nil {
return *explicit, nil
}
result, err := c.CallTool("get_active_page", nil)
if err != nil {
return 0, fmt.Errorf("no active page: %w", err)
}
if sc := result.StructuredContent; sc != nil {
if v, ok := sc["pageId"]; ok {
if f, ok := v.(float64); ok {
return int(f), nil
}
}
}
return 0, fmt.Errorf("could not determine active page ID from response")
}
// Health checks the /health endpoint (REST, not MCP).
func (c *Client) Health() (map[string]any, error) {
return c.restGET("/health")
}
// Status checks the /status endpoint (REST, not MCP).
func (c *Client) Status() (map[string]any, error) {
return c.restGET("/status")
}
func (c *Client) restGET(path string) (map[string]any, error) {
resp, err := c.HTTPClient.Get(c.BaseURL + path)
if err != nil {
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n Try: browseros-cli init", c.BaseURL, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("server returned HTTP %d: %s", resp.StatusCode, string(body))
}
var data map[string]any
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
return data, nil
}

35
apps/cli/mcp/types.go Normal file
View File

@@ -0,0 +1,35 @@
package mcp
// ToolResult is the result from an MCP tools/call.
type ToolResult struct {
Content []ContentItem `json:"content"`
StructuredContent map[string]any `json:"structuredContent,omitempty"`
IsError bool `json:"isError,omitempty"`
}
type ContentItem struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Data string `json:"data,omitempty"`
MimeType string `json:"mimeType,omitempty"`
}
// TextContent returns the first text content item, or empty string.
func (r *ToolResult) TextContent() string {
for _, c := range r.Content {
if c.Type == "text" {
return c.Text
}
}
return ""
}
// ImageContent returns the first image content item, or nil.
func (r *ToolResult) ImageContent() *ContentItem {
for i, c := range r.Content {
if c.Type == "image" {
return &r.Content[i]
}
}
return nil
}

135
apps/cli/output/printer.go Normal file
View File

@@ -0,0 +1,135 @@
package output
import (
"encoding/json"
"fmt"
"os"
"browseros-cli/mcp"
"github.com/fatih/color"
)
var (
errColor = color.New(color.FgRed, color.Bold)
dimColor = color.New(color.Faint)
boldColor = color.New(color.Bold)
)
// JSON outputs raw JSON to stdout. If structuredContent exists, use it;
// otherwise fall back to serializing the full result.
func JSON(result *mcp.ToolResult) {
if result.StructuredContent != nil {
data, _ := json.Marshal(result.StructuredContent)
fmt.Println(string(data))
return
}
data, _ := json.Marshal(result)
fmt.Println(string(data))
}
// JSONRaw outputs any value as JSON.
func JSONRaw(v any) {
data, _ := json.Marshal(v)
fmt.Println(string(data))
}
// Text prints a text result to stdout.
func Text(result *mcp.ToolResult) {
fmt.Println(result.TextContent())
}
// Confirm prints a short confirmation message.
func Confirm(msg string) {
fmt.Println(msg)
}
// Error prints an error to stderr and exits with the given code.
func Error(msg string, code int) {
errColor.Fprintf(os.Stderr, "Error: %s\n", msg)
os.Exit(code)
}
// Errorf formats and prints an error to stderr and exits.
func Errorf(code int, format string, args ...any) {
errColor.Fprintf(os.Stderr, "Error: "+format+"\n", args...)
os.Exit(code)
}
// PageList formats a list of pages for human display.
func PageList(result *mcp.ToolResult) {
if result.StructuredContent == nil {
Text(result)
return
}
pages, ok := result.StructuredContent["pages"].([]any)
if !ok {
Text(result)
return
}
if len(pages) == 0 {
fmt.Println("No pages open.")
return
}
for _, p := range pages {
page, ok := p.(map[string]any)
if !ok {
continue
}
pageID := intVal(page["pageId"])
tabID := intVal(page["tabId"])
title := strVal(page["title"])
url := strVal(page["url"])
active := boolVal(page["isActive"])
marker := ""
if active {
marker = " " + boldColor.Sprint("[ACTIVE]")
}
fmt.Printf(" %d. %s (tab %d)%s\n", pageID, title, tabID, marker)
fmt.Printf(" %s\n", dimColor.Sprint(url))
}
}
// ActivePage formats a single active page for human display.
func ActivePage(result *mcp.ToolResult) {
if result.StructuredContent == nil {
Text(result)
return
}
sc := result.StructuredContent
pageID := intVal(sc["pageId"])
tabID := intVal(sc["tabId"])
title := strVal(sc["title"])
url := strVal(sc["url"])
fmt.Printf("Active page: %d (tab %d)\n", pageID, tabID)
fmt.Println(title)
fmt.Println(dimColor.Sprint(url))
}
func intVal(v any) int {
if f, ok := v.(float64); ok {
return int(f)
}
return 0
}
func strVal(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func boolVal(v any) bool {
if b, ok := v.(bool); ok {
return b
}
return false
}