diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 000000000..1796f5d03 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,39 @@ +name: Release CLI + +on: + push: + tags: + - "cli/v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/browseros-agent/apps/cli + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: packages/browseros-agent/apps/cli/go.mod + + - name: Run tests + run: go test ./... -v + + - name: Run vet + run: go vet ./... + + - uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + workdir: packages/browseros-agent/apps/cli + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/browseros-agent/apps/cli/.gitignore b/packages/browseros-agent/apps/cli/.gitignore index d0c8d32a7..1e02ee36c 100644 --- a/packages/browseros-agent/apps/cli/.gitignore +++ b/packages/browseros-agent/apps/cli/.gitignore @@ -1 +1,2 @@ browseros-cli +dist diff --git a/packages/browseros-agent/apps/cli/.goreleaser.yml b/packages/browseros-agent/apps/cli/.goreleaser.yml new file mode 100644 index 000000000..94d6c37ca --- /dev/null +++ b/packages/browseros-agent/apps/cli/.goreleaser.yml @@ -0,0 +1,47 @@ +version: 2 + +project_name: browseros-cli + +builds: + - main: . + binary: browseros-cli + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w -X main.version={{ .Version }} + targets: + - darwin_amd64 + - darwin_arm64 + - linux_amd64 + - linux_arm64 + - windows_amd64 + - windows_arm64 + +archives: + - format: tar.gz + format_overrides: + - goos: windows + format: zip + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + files: + - "none*" + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + +release: + github: + owner: browseros-ai + name: BrowserOS + prerelease: auto + name_template: "browseros-cli v{{ .Version }}" diff --git a/packages/browseros-agent/apps/cli/Makefile b/packages/browseros-agent/apps/cli/Makefile index 520c660cf..c27b510ac 100644 --- a/packages/browseros-agent/apps/cli/Makefile +++ b/packages/browseros-agent/apps/cli/Makefile @@ -18,3 +18,9 @@ vet: test: go test -tags integration -v -timeout 120s ./... + +release-dry: + goreleaser release --snapshot --clean + +release: + goreleaser release --clean diff --git a/packages/browseros-agent/apps/cli/cmd/init.go b/packages/browseros-agent/apps/cli/cmd/init.go index df0fd8139..c38793c08 100644 --- a/packages/browseros-agent/apps/cli/cmd/init.go +++ b/packages/browseros-agent/apps/cli/cmd/init.go @@ -17,8 +17,10 @@ import ( ) func init() { + var autoDiscover bool + cmd := &cobra.Command{ - Use: "init", + Use: "init [url]", Short: "Configure the BrowserOS server connection", Long: `Set up the CLI by providing the MCP server URL from BrowserOS. @@ -26,33 +28,59 @@ 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.`, +Run again if your port changes. + +Three modes: + browseros-cli init Non-interactive, use the provided URL + browseros-cli init --auto Auto-discover from ~/.browseros/server.json + browseros-cli init Interactive prompt`, Annotations: map[string]string{"group": "Setup:"}, - Args: cobra.NoArgs, + Args: cobra.MaximumNArgs(1), 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() + var input string - 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) + switch { + case len(args) == 1: + // Non-interactive: URL provided as argument + input = args[0] - if input == "" { - output.Error("no URL provided", 1) + case autoDiscover: + // Auto-discover: server.json → config → probe common ports + discovered := probeRunningServer() + if discovered == "" { + output.Error("auto-discovery failed: no running BrowserOS found.\n\n"+ + " If not running: browseros-cli launch\n"+ + " If not installed: browseros-cli install", 1) + } + input = discovered + fmt.Printf("Auto-discovered server at %s\n", input) + + default: + // Interactive prompt (original behavior) + 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: ") + line, err := reader.ReadString('\n') + if err != nil { + output.Error("failed to read input", 1) + } + input = strings.TrimSpace(line) + + if input == "" { + output.Error("no URL provided", 1) + } } baseURL := normalizeServerURL(input) @@ -88,5 +116,6 @@ Run again if your port changes.`, }, } + cmd.Flags().BoolVar(&autoDiscover, "auto", false, "Auto-discover server URL from ~/.browseros/server.json") rootCmd.AddCommand(cmd) } diff --git a/packages/browseros-agent/apps/cli/cmd/install.go b/packages/browseros-agent/apps/cli/cmd/install.go new file mode 100644 index 000000000..2f4815087 --- /dev/null +++ b/packages/browseros-agent/apps/cli/cmd/install.go @@ -0,0 +1,247 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" + + "browseros-cli/output" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +func init() { + cmd := &cobra.Command{ + Use: "install", + Short: "Download and install BrowserOS for the current platform", + Long: `Download BrowserOS for your platform and start the installation. + +macOS: Downloads .dmg, mounts it, and copies BrowserOS to /Applications +Windows: Downloads installer .exe and launches it +Linux: Downloads AppImage (or .deb with --deb flag) + +After installation: + browseros-cli launch # start BrowserOS + browseros-cli init --auto # configure the CLI`, + Annotations: map[string]string{"group": "Setup:"}, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + dir, _ := cmd.Flags().GetString("dir") + deb, _ := cmd.Flags().GetBool("deb") + + if deb && runtime.GOOS != "linux" { + output.Error("--deb is only available on Linux", 1) + } + + downloadURL, filename := resolveDownload(deb) + destPath := filepath.Join(dir, filename) + + bold := color.New(color.Bold) + green := color.New(color.FgGreen) + dim := color.New(color.Faint) + + bold.Printf("Downloading BrowserOS for %s...\n", platformDisplayName()) + dim.Printf(" %s\n", downloadURL) + fmt.Println() + + client := &http.Client{Timeout: 10 * time.Minute} + resp, err := client.Get(downloadURL) + if err != nil { + output.Errorf(1, "download failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + output.Errorf(1, "download failed: HTTP %d", resp.StatusCode) + } + + file, err := os.Create(destPath) + if err != nil { + output.Errorf(1, "create file: %v", err) + } + + written, err := io.Copy(file, resp.Body) + file.Close() + if err != nil { + os.Remove(destPath) + output.Errorf(1, "download interrupted: %v", err) + } + + green.Printf("Downloaded %s (%.1f MB)\n", filename, float64(written)/(1024*1024)) + fmt.Println() + + runPostInstall(destPath, deb, dim) + + fmt.Println() + bold.Println("Next steps:") + dim.Println(" browseros-cli launch # start BrowserOS") + dim.Println(" browseros-cli init --auto # configure the CLI") + }, + } + + cmd.Flags().String("dir", ".", "Directory to download the installer to") + cmd.Flags().Bool("deb", false, "Download .deb package instead of AppImage (Linux only)") + + rootCmd.AddCommand(cmd) +} + +func resolveDownload(deb bool) (url, filename string) { + switch runtime.GOOS { + case "darwin": + return "https://files.browseros.com/download/BrowserOS.dmg", "BrowserOS.dmg" + case "windows": + return "https://files.browseros.com/download/BrowserOS_installer.exe", "BrowserOS_installer.exe" + case "linux": + if deb { + return "https://cdn.browseros.com/download/BrowserOS.deb", "BrowserOS.deb" + } + return "https://files.browseros.com/download/BrowserOS.AppImage", "BrowserOS.AppImage" + default: + output.Errorf(1, "unsupported platform: %s/%s\n Download manually from https://browseros.com", runtime.GOOS, runtime.GOARCH) + return "", "" + } +} + +func platformDisplayName() string { + switch runtime.GOOS { + case "darwin": + return "macOS" + case "windows": + return "Windows" + case "linux": + return "Linux" + default: + return runtime.GOOS + } +} + +func runPostInstall(path string, deb bool, dim *color.Color) { + switch runtime.GOOS { + case "darwin": + installMacOS(path, dim) + + case "linux": + if deb { + dim.Println("Install the .deb package:") + fmt.Printf(" sudo dpkg -i %s\n", path) + } else { + os.Chmod(path, 0755) + dim.Printf("AppImage is ready to run: ./%s\n", filepath.Base(path)) + } + + case "windows": + fmt.Println("Launching installer...") + if err := exec.Command("cmd", "/c", "start", "", path).Run(); err != nil { + dim.Printf("Could not launch installer automatically. Run: %s\n", path) + } else { + dim.Println("Follow the installer prompts to complete setup.") + } + } +} + +// installMacOS mounts the DMG and copies BrowserOS.app to /Applications. +func installMacOS(dmgPath string, dim *color.Color) { + fmt.Println("Mounting disk image...") + mountOut, err := exec.Command("hdiutil", "attach", dmgPath, "-nobrowse", "-quiet").Output() + if err != nil { + dim.Println("Could not mount DMG automatically.") + dim.Printf(" Open it manually: open %s\n", dmgPath) + return + } + + // Find the mount point (last field of last line of hdiutil output) + mountPoint := "" + for _, line := range splitLines(string(mountOut)) { + fields := splitTabs(line) + if len(fields) > 0 { + mountPoint = fields[len(fields)-1] + } + } + + if mountPoint == "" { + dim.Println("DMG mounted but could not determine mount point.") + dim.Printf(" Open it manually: open %s\n", dmgPath) + return + } + + // Look for BrowserOS.app in the mounted volume + appSrc := filepath.Join(mountPoint, "BrowserOS.app") + if _, err := os.Stat(appSrc); err != nil { + dim.Printf("DMG mounted at %s but BrowserOS.app not found inside.\n", mountPoint) + dim.Printf(" Check the volume manually: open %s\n", mountPoint) + exec.Command("hdiutil", "detach", mountPoint, "-quiet").Run() + return + } + + appDest := "/Applications/BrowserOS.app" + fmt.Printf("Installing to %s...\n", appDest) + + // Remove existing installation if present + os.RemoveAll(appDest) + + // Copy using cp -R (preserves code signatures, symlinks, etc.) + if err := exec.Command("cp", "-R", appSrc, appDest).Run(); err != nil { + dim.Printf("Could not copy to /Applications (may need sudo).\n") + dim.Printf(" Try: sudo cp -R \"%s\" /Applications/\n", appSrc) + exec.Command("hdiutil", "detach", mountPoint, "-quiet").Run() + return + } + + // Unmount + exec.Command("hdiutil", "detach", mountPoint, "-quiet").Run() + + // Clean up DMG + os.Remove(dmgPath) + + fmt.Println("BrowserOS installed to /Applications/BrowserOS.app") +} + +func splitLines(s string) []string { + var lines []string + for _, line := range filepath.SplitList(s) { + lines = append(lines, line) + } + // filepath.SplitList uses : on unix, not newlines — use manual split + result := []string{} + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + line := s[start:i] + if len(line) > 0 { + result = append(result, line) + } + start = i + 1 + } + } + if start < len(s) { + result = append(result, s[start:]) + } + return result +} + +func splitTabs(s string) []string { + result := []string{} + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\t' { + field := s[start:i] + if len(field) > 0 { + result = append(result, field) + } + start = i + 1 + } + } + if start < len(s) { + field := s[start:] + if len(field) > 0 { + result = append(result, field) + } + } + return result +} diff --git a/packages/browseros-agent/apps/cli/cmd/launch.go b/packages/browseros-agent/apps/cli/cmd/launch.go new file mode 100644 index 000000000..7454ab006 --- /dev/null +++ b/packages/browseros-agent/apps/cli/cmd/launch.go @@ -0,0 +1,287 @@ +package cmd + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "browseros-cli/output" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +// macOS bundle identifier — verified from BrowserOS.app/Contents/Info.plist +const browserOSBundleID = "com.browseros.BrowserOS" + +func init() { + cmd := &cobra.Command{ + Use: "launch", + Short: "Launch the BrowserOS application", + Long: `Find and launch the BrowserOS application. + +Uses platform-native detection to find BrowserOS, launches it, +and waits for the server to become ready. + +If BrowserOS is already running, reports the server URL.`, + Annotations: map[string]string{"group": "Setup:"}, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + green := color.New(color.FgGreen) + dim := color.New(color.Faint) + waitSecs, _ := cmd.Flags().GetInt("wait") + + if url := probeRunningServer(); url != "" { + green.Printf("BrowserOS is already running at %s\n", url) + return + } + + if !isBrowserOSInstalled() { + output.Error("BrowserOS is not installed.\n\n"+ + " To install: browseros-cli install", 1) + } + + fmt.Println("Launching BrowserOS...") + if err := startBrowserOS(); err != nil { + output.Errorf(1, "failed to launch: %v", err) + } + + fmt.Print("Waiting for server") + url, ok := waitForServer(time.Duration(waitSecs) * time.Second) + fmt.Println() + + if !ok { + output.Error("BrowserOS launched but server didn't respond within "+ + fmt.Sprintf("%d seconds.\n", waitSecs)+ + " Check if BrowserOS is fully loaded, then retry.", 1) + } + + green.Printf("BrowserOS is ready at %s\n", url) + fmt.Println() + dim.Println("Next: browseros-cli init --auto") + }, + } + + cmd.Flags().Int("wait", 30, "Seconds to wait for server to start") + rootCmd.AddCommand(cmd) +} + +// --------------------------------------------------------------------------- +// Server probing +// --------------------------------------------------------------------------- + +// probeRunningServer checks server.json, config, and common ports for a running server. +func probeRunningServer() string { + check := func(baseURL string) bool { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(baseURL + "/health") + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == 200 + } + + // 1. server.json — written by BrowserOS on startup with the actual port + if url := loadBrowserosServerURL(); url != "" && check(url) { + return url + } + + // 2. Saved config / env var + if url := defaultServerURL(); url != "" && check(url) { + return url + } + + // 3. Probe common BrowserOS ports as last resort + for _, port := range []int{9100, 9200, 9300} { + url := fmt.Sprintf("http://127.0.0.1:%d", port) + if check(url) { + return url + } + } + + return "" +} + +// --------------------------------------------------------------------------- +// Platform-native installation detection +// --------------------------------------------------------------------------- + +// isBrowserOSInstalled checks if BrowserOS is installed using platform-native methods. +// +// macOS: `open -Ra "BrowserOS"` — queries Launch Services (finds apps anywhere) +// Linux: checks /usr/bin/browseros (.deb), browseros.desktop, or AppImage files +// Windows: checks executable at %LOCALAPPDATA%\BrowserOS\Application\BrowserOS.exe +// and registry uninstall key (per-user Chromium install pattern) +func isBrowserOSInstalled() bool { + switch runtime.GOOS { + case "darwin": + // open -Ra checks if Launch Services knows about the app without launching it. + // Works regardless of where the app is installed. + return exec.Command("open", "-Ra", "BrowserOS").Run() == nil + + case "linux": + // .deb install puts `browseros` in /usr/bin/ + if _, err := exec.LookPath("browseros"); err == nil { + return true + } + // .deb also creates browseros.desktop + for _, dir := range []string{ + "/usr/share/applications", + filepath.Join(userHomeDir(), ".local/share/applications"), + } { + if _, err := os.Stat(filepath.Join(dir, "browseros.desktop")); err == nil { + return true + } + } + // AppImage — user may have it in ~/Downloads, ~/Applications, etc. + return findLinuxAppImage() != "" + + case "windows": + // Chromium per-user install: %LOCALAPPDATA%\BrowserOS\Application\BrowserOS.exe + if exePath := windowsBrowserOSExe(); exePath != "" { + if _, err := os.Stat(exePath); err == nil { + return true + } + } + // Fallback: check uninstall registry (per-user install uses HKCU) + for _, root := range []string{"HKCU", "HKLM"} { + key := root + `\Software\Microsoft\Windows\CurrentVersion\Uninstall\BrowserOS` + if exec.Command("reg", "query", key, "/v", "DisplayName").Run() == nil { + return true + } + } + return false + } + + return false +} + +// --------------------------------------------------------------------------- +// Platform-native launch +// --------------------------------------------------------------------------- + +// startBrowserOS launches BrowserOS using platform-native methods. +// +// macOS: `open -b com.browseros.BrowserOS` — launches by bundle ID +// Linux: runs `browseros` binary or AppImage directly +// Windows: runs BrowserOS.exe from the known install path +func startBrowserOS() error { + switch runtime.GOOS { + case "darwin": + // Launch by bundle ID via Launch Services — no hardcoded paths needed. + return exec.Command("open", "-b", browserOSBundleID).Run() + + case "linux": + // .deb install: browseros is in PATH + if p, err := exec.LookPath("browseros"); err == nil { + return startDetached(p) + } + // AppImage: run it directly + if appImage := findLinuxAppImage(); appImage != "" { + return startDetached(appImage) + } + // .desktop file: use gtk-launch (not xdg-open, which opens by MIME type) + if _, err := exec.LookPath("gtk-launch"); err == nil { + return exec.Command("gtk-launch", "browseros").Run() + } + return fmt.Errorf("BrowserOS found but could not determine how to launch it") + + case "windows": + if exePath := windowsBrowserOSExe(); exePath != "" { + if _, err := os.Stat(exePath); err == nil { + return startDetached(exePath) + } + } + return fmt.Errorf("BrowserOS.exe not found at expected location") + + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// startDetached starts a process in the background without inheriting stdio. +func startDetached(path string, args ...string) error { + cmd := exec.Command(path, args...) + cmd.Stdout = nil + cmd.Stderr = nil + cmd.Stdin = nil + return cmd.Start() +} + +// windowsBrowserOSExe returns the expected BrowserOS.exe path on Windows. +// Chromium per-user installs go to %LOCALAPPDATA%\\Application\. +// base_app_name = "BrowserOS" (from chromium_install_modes.h) +func windowsBrowserOSExe() string { + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + return "" + } + return filepath.Join(localAppData, "BrowserOS", "Application", "BrowserOS.exe") +} + +// findLinuxAppImage searches common locations for a BrowserOS AppImage. +func findLinuxAppImage() string { + home := userHomeDir() + if home == "" { + return "" + } + for _, dir := range []string{ + home, + filepath.Join(home, "Applications"), + filepath.Join(home, "Downloads"), + "/opt", + } { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, "BrowserOS") && strings.HasSuffix(name, ".AppImage") { + return filepath.Join(dir, name) + } + } + } + return "" +} + +// userHomeDir returns the home directory or empty string. +func userHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} + +// waitForServer polls until a BrowserOS server responds or timeout. +func waitForServer(maxWait time.Duration) (string, bool) { + client := &http.Client{Timeout: 2 * time.Second} + deadline := time.Now().Add(maxWait) + + for time.Now().Before(deadline) { + // server.json is written by BrowserOS on startup with the actual port + if url := loadBrowserosServerURL(); url != "" { + resp, err := client.Get(url + "/health") + if err == nil { + resp.Body.Close() + if resp.StatusCode == 200 { + return url, true + } + } + } + fmt.Print(".") + time.Sleep(1 * time.Second) + } + return "", false +} diff --git a/packages/browseros-agent/apps/cli/cmd/root.go b/packages/browseros-agent/apps/cli/cmd/root.go index a8f1e18f5..d7a704971 100644 --- a/packages/browseros-agent/apps/cli/cmd/root.go +++ b/packages/browseros-agent/apps/cli/cmd/root.go @@ -167,10 +167,17 @@ func envBool(key string) bool { } func defaultServerURL() string { + // 1. Explicit env var always wins if env := normalizeServerURL(os.Getenv("BROWSEROS_URL")); env != "" { return env } + // 2. Live discovery file from running BrowserOS (most current) + if url := loadBrowserosServerURL(); url != "" { + return url + } + + // 3. Saved config (may be stale if port changed) cfg, err := config.Load() if err == nil { if url := normalizeServerURL(cfg.ServerURL); url != "" { @@ -178,10 +185,6 @@ func defaultServerURL() string { } } - if url := loadBrowserosServerURL(); url != "" { - return url - } - return "" } @@ -225,6 +228,9 @@ func validateServerURL(raw string) (string, error) { } 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", + "BrowserOS server URL is not configured.\n\n" + + " If BrowserOS is running: browseros-cli init --auto\n" + + " If BrowserOS is closed: browseros-cli launch\n" + + " If not installed: browseros-cli install", ) } diff --git a/packages/browseros-agent/apps/cli/mcp/client.go b/packages/browseros-agent/apps/cli/mcp/client.go index d40b1f1d7..8321633fd 100644 --- a/packages/browseros-agent/apps/cli/mcp/client.go +++ b/packages/browseros-agent/apps/cli/mcp/client.go @@ -44,7 +44,10 @@ func (c *Client) connect(ctx context.Context) (*sdkmcp.ClientSession, error) { 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 nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n\n"+ + " If BrowserOS is running on a different port: browseros-cli init --auto\n"+ + " If BrowserOS is not running: browseros-cli launch\n"+ + " If not installed: browseros-cli install", c.BaseURL, err) } return session, nil } @@ -184,7 +187,10 @@ func (c *Client) Status() (map[string]any, error) { 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) + return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n\n"+ + " If BrowserOS is running on a different port: browseros-cli init --auto\n"+ + " If BrowserOS is not running: browseros-cli launch\n"+ + " If not installed: browseros-cli install", c.BaseURL, err) } defer resp.Body.Close()