mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
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:
1
apps/cli/.gitignore
vendored
Normal file
1
apps/cli/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
browseros-cli
|
||||
20
apps/cli/Makefile
Normal file
20
apps/cli/Makefile
Normal 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
166
apps/cli/README.md
Normal 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
157
apps/cli/cmd/bookmark.go
Normal 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
102
apps/cli/cmd/click.go
Normal 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
56
apps/cli/cmd/config.go
Normal 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
51
apps/cli/cmd/dialog.go
Normal 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
77
apps/cli/cmd/dom.go
Normal 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
40
apps/cli/cmd/eval.go
Normal 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)
|
||||
}
|
||||
71
apps/cli/cmd/file_actions.go
Normal file
71
apps/cli/cmd/file_actions.go
Normal 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
85
apps/cli/cmd/fill.go
Normal 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
154
apps/cli/cmd/group.go
Normal 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
77
apps/cli/cmd/health.go
Normal 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
111
apps/cli/cmd/history.go
Normal 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
34
apps/cli/cmd/info.go
Normal 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
92
apps/cli/cmd/init.go
Normal 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
175
apps/cli/cmd/interact.go
Normal 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
84
apps/cli/cmd/nav.go
Normal 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
56
apps/cli/cmd/open.go
Normal 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
83
apps/cli/cmd/pages.go
Normal 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
195
apps/cli/cmd/root.go
Normal 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
104
apps/cli/cmd/screenshot.go
Normal 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
56
apps/cli/cmd/scroll.go
Normal 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
42
apps/cli/cmd/snap.go
Normal 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
78
apps/cli/cmd/text.go
Normal 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
57
apps/cli/cmd/wait.go
Normal 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
109
apps/cli/cmd/window.go
Normal 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
53
apps/cli/config/config.go
Normal 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
23
apps/cli/go.mod
Normal 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
37
apps/cli/go.sum
Normal 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=
|
||||
253
apps/cli/integration_test.go
Normal file
253
apps/cli/integration_test.go
Normal 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
10
apps/cli/main.go
Normal 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
162
apps/cli/mcp/client.go
Normal 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
35
apps/cli/mcp/types.go
Normal 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
135
apps/cli/output/printer.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user