From f70ee02af4bb795579d8313852fdce36fb217ba9 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Thu, 22 Oct 2026 21:17:36 -0700 Subject: [PATCH] Add byre install-tool and a Homebrew row to the Server tab --- README.md | 3 +- cmd/byre/check.go | 44 +++++++++----- cmd/byre/install.go | 1 + cmd/byre/main.go | 4 +- cmd/byre/tool.go | 105 ++++++++++++++++++++++++++++++++++ docs/clean-install-vm.md | 55 ++++++++++++++++++ internal/agents/kb/setup.md | 8 +-- mac/Sources/Byre/Server.swift | 14 +++-- 8 files changed, 208 insertions(+), 26 deletions(-) create mode 100644 cmd/byre/tool.go create mode 100644 docs/clean-install-vm.md diff --git a/README.md b/README.md index 1760350..7d7a93e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ A server and macOS app for running many coding agents (Claude Code, Codex and An macOS and a [Tailscale](https://tailscale.com) tailnet with MagicDNS and HTTPS certificates turned on. byre and the app each join it as their own node, so no Mac needs the Tailscale app. Remote access uses Tailscale identity: byre serves the tailnet's users, as its node identifies them. Local scripts use a bearer token to check server status and run commands on connected Macs (see [docs/operations.md](docs/operations.md#tailnet)). -The Mac that runs the server needs `git` and at least one agent CLI: Claude Code (`claude`), Codex (`codex`) or Antigravity (`agy`, for Gemini models; `brew install --cask antigravity-cli`, signed in once by running `agy`). Browser tools also need Node.js (agents' Playwright tools run through `npx`) and Google Chrome (`brew install git node --cask google-chrome`). +The Mac that runs the server needs `git` and at least one agent CLI: Claude Code (`claude`), Codex (`codex`) or Antigravity (`agy`, for Gemini models). Browser tools also need Node.js (agents' Playwright tools run through `npx`) and Google Chrome. Codex, Antigravity, Node.js and Chrome install through Homebrew. The app's Server tab lists what's missing, each with a button that installs it and starts its sign-in. ## Installing @@ -36,6 +36,7 @@ The app updates itself, and the server along with it on the server's Mac. To bui - [docs/operations.md](docs/operations.md): external setup, vendor and platform quirks, and the intent behind byre's rules. - [docs/macos.md](docs/macos.md): the macOS app. +- [docs/clean-install-vm.md](docs/clean-install-vm.md): testing a first-time install on a fresh Mac in a VM. - [AGENTS.md](AGENTS.md): rules for agents working on byre. ## License diff --git a/cmd/byre/check.go b/cmd/byre/check.go index b44125b..cc3dc35 100644 --- a/cmd/byre/check.go +++ b/cmd/byre/check.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "os/exec" - "strings" ) const ( @@ -19,38 +18,48 @@ type check struct { Need string `json:"need"` For string `json:"for"` OK bool `json:"ok"` - Command string `json:"command"` + Tool string `json:"tool"` Fix string `json:"fix"` Blocking bool `json:"blocking"` } func checks() []check { + brew := command("Homebrew", "brew", needOptional, "installing codex, agy, node and Google Chrome", "brew") return []check{ + brew, gitCheck(), - command("claude", needAgent, "Claude models", "curl -fsSL https://claude.ai/install.sh | bash", "Then run claude once to sign in."), - command("codex", needAgent, "GPT models", "brew install --cask codex", "Then run codex once to sign in."), - command("agy", needAgent, "Gemini models", "brew install --cask antigravity-cli", "Then run agy once to sign in."), - command("npx", needOptional, "reviewers' Playwright browser tools", "brew install node", ""), - appCheck("Google Chrome", needOptional, "browser tools for agents and reviewers", "brew install --cask google-chrome"), + command("claude", "claude", needAgent, "Claude models", "claude"), + withBrew(brew, command("codex", "codex", needAgent, "GPT models", "codex")), + withBrew(brew, command("agy", "agy", needAgent, "Gemini models", "agy")), + withBrew(brew, command("npx", "npx", needOptional, "reviewers' Playwright browser tools", "node")), + withBrew(brew, appCheck("Google Chrome", needOptional, "browser tools for agents and reviewers", "chrome")), } } func gitCheck() check { - c := check{Name: "git", Need: needRequired, For: "worktrees, snapshots and merges", Command: "xcode-select --install"} + c := check{Name: "git", Need: needRequired, For: "worktrees, snapshots and merges", Tool: "git"} path, err := exec.LookPath("git") // /usr/bin/git is a stub that offers to install the Command Line Tools when they're missing. c.OK = err == nil && (path != "/usr/bin/git" || exec.Command("xcode-select", "-p").Run() == nil) return c } -func command(name, need, purpose, install, fix string) check { - _, err := exec.LookPath(name) - return check{Name: name, Need: need, For: purpose, OK: err == nil, Command: install, Fix: fix} +func command(name, binary, need, purpose, tool string) check { + _, err := exec.LookPath(binary) + return check{Name: name, Need: need, For: purpose, OK: err == nil, Tool: tool} } -func appCheck(name, need, purpose, install string) check { +func appCheck(name, need, purpose, tool string) check { _, err := os.Stat("/Applications/" + name + ".app") - return check{Name: name, Need: need, For: purpose, OK: err == nil, Command: install} + return check{Name: name, Need: need, For: purpose, OK: err == nil, Tool: tool} +} + +func withBrew(brew, c check) check { + if !c.OK && !brew.OK { + c.Tool = "" + c.Fix = "Install Homebrew first." + } + return c } func markBlocking(cs []check) bool { @@ -87,9 +96,14 @@ func requireChecks() { } fmt.Println("byre can't run on this Mac yet. It needs git and at least one of claude, codex and agy:") for _, c := range cs { - if c.Blocking { - fmt.Printf(" %s: %s\n", c.Name, strings.TrimSpace(c.Command+" "+c.Fix)) + if !c.Blocking { + continue } + if c.Tool == "" { + fmt.Printf(" %s: %s %s\n", c.Name, c.Fix, installCommand("brew")) + continue + } + fmt.Printf(" %s: %s\n", c.Name, installCommand(c.Tool)) } os.Exit(1) } diff --git a/cmd/byre/install.go b/cmd/byre/install.go index e344eef..f794462 100644 --- a/cmd/byre/install.go +++ b/cmd/byre/install.go @@ -96,6 +96,7 @@ func installCmd() { func run(name string, args ...string) { cmd := exec.Command(name, args...) + cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err := cmd.Run() diff --git a/cmd/byre/main.go b/cmd/byre/main.go index ce5dc10..17c6577 100644 --- a/cmd/byre/main.go +++ b/cmd/byre/main.go @@ -18,6 +18,8 @@ func main() { installCmd() case "check": checkCmd() + case "install-tool": + installToolCmd(os.Args[2:]) case "run": runCmd(os.Args[2:]) case "signing": @@ -36,7 +38,7 @@ func main() { } func usage() { - fmt.Fprintln(os.Stderr, "usage: byre serve | byre install | byre check | byre run [--resume ID] | byre signing CHECKOUT | byre grep [--glob GLOB] [--ignore-case] [--files-only] PATTERN PATH | byre list PATH | byre client-exec [--client NAME] [--timeout SECS] -- CMD... | byre screen") + fmt.Fprintln(os.Stderr, "usage: byre serve | byre install | byre check | byre install-tool NAME |byre run [--resume ID] | byre signing CHECKOUT | byre grep [--glob GLOB] [--ignore-case] [--files-only] PATTERN PATH | byre list PATH | byre client-exec [--client NAME] [--timeout SECS] -- CMD... | byre screen") os.Exit(2) } diff --git a/cmd/byre/tool.go b/cmd/byre/tool.go new file mode 100644 index 0000000..4c18e4e --- /dev/null +++ b/cmd/byre/tool.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" +) + +type brewTool struct { + args []string + signIn []string +} + +var brewTools = map[string]brewTool{ + "codex": {args: []string{"--cask", "codex"}, signIn: []string{"codex", "login"}}, + "agy": {args: []string{"--cask", "antigravity-cli"}, signIn: []string{"agy"}}, + "node": {args: []string{"node"}}, + "chrome": {args: []string{"--cask", "google-chrome"}}, +} + +const toolNames = "brew, git, claude, codex, agy, node or chrome" + +func installToolCmd(args []string) { + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "usage: byre install-tool NAME, where NAME is "+toolNames) + os.Exit(2) + } + switch args[0] { + case "brew": + run("/bin/sh", "-c", `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`) + addToProfile(".zprofile", `eval "$(/opt/homebrew/bin/brew shellenv)"`) + case "git": + run("xcode-select", "--install") + fmt.Println("macOS is showing its own dialog to install the Command Line Tools: click Install there.") + case "claude": + run("/bin/sh", "-c", "curl -fsSL https://claude.ai/install.sh | bash") + addToProfile(".zshrc", `export PATH="$HOME/.local/bin:$PATH"`) + signIn(filepath.Join(homeDir(), ".local", "bin", "claude")) + default: + t, ok := brewTools[args[0]] + if !ok { + fmt.Fprintln(os.Stderr, "unknown tool "+args[0]+": NAME is "+toolNames) + os.Exit(2) + } + brew, err := exec.LookPath("brew") + if err != nil { + fmt.Println("Homebrew isn't installed. Install it first:", installCommand("brew")) + os.Exit(1) + } + run(brew, append([]string{"install"}, t.args...)...) + if t.signIn != nil { + signIn(filepath.Join(filepath.Dir(brew), t.signIn[0]), t.signIn[1:]...) + } + } +} + +func installCommand(name string) string { + exe, err := os.Executable() + if err != nil { + fatal("find executable", "err", err) + } + return "'" + strings.ReplaceAll(exe, "'", `'\''`) + "' install-tool " + name +} + +func homeDir() string { + home, err := os.UserHomeDir() + if err != nil { + fatal("home dir", "err", err) + } + return home +} + +func addToProfile(name, line string) { + path := filepath.Join(homeDir(), name) + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + fatal("read profile", "path", path, "err", err) + } + if strings.Contains(string(data), line) { + return + } + text := line + "\n" + if len(data) > 0 && !strings.HasSuffix(string(data), "\n") { + text = "\n" + text + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644) + if err != nil { + fatal("open profile", "path", path, "err", err) + } + defer f.Close() + _, err = f.WriteString(text) + if err != nil { + fatal("write profile", "path", path, "err", err) + } + fmt.Println("Added", line, "to", path) +} + +func signIn(path string, args ...string) { + fmt.Println("Starting", filepath.Base(path), "so you can sign in.") + err := syscall.Exec(path, append([]string{path}, args...), os.Environ()) + fatal("start "+filepath.Base(path), "err", err) +} diff --git a/docs/clean-install-vm.md b/docs/clean-install-vm.md new file mode 100644 index 0000000..40a24ab --- /dev/null +++ b/docs/clean-install-vm.md @@ -0,0 +1,55 @@ +# Testing a clean install in a macOS VM + +A tart VM on the Mac byre runs on gives a fresh Mac in seconds, to install byre on as a new user would. `tart list` shows the VMs there. + +## The base VM + +`byre-base` is a stopped VM left as just set up, kept in `~/.tart/vms/` (outside any checkout), and never run or installed into. Tests run on clones of it. It has: + +- the macOS that `tart create --from-ipsw latest` installed (the newest the host supports; `tart get byre-base` and `sw_vers` inside show it), 4 CPUs, 8 GB of memory and an 80 GB disk; +- one account, `admin` with password `admin`, signed in automatically at boot, with passwordless `sudo`, the host user's `~/.ssh/id_ed25519.pub` in `authorized_keys`, Remote Login on, and sleep and screen lock off; +- no Apple Account, FileVault, Siri, Location Services or analytics, and macOS updates that download without installing; +- nothing else: no Homebrew, Command Line Tools, Tailscale or byre. + +## Testing + +``` +tart clone byre-base byre-test +tart run byre-test +``` + +Run `tart run` in the task's terminal: it keeps running while the VM is up, and the VM's screen opens as a `tart` window. `tart ip byre-test` prints the VM's address once it has booted; `ssh admin@` gets in. `tart delete byre-test` throws the clone away. A clone is a copy-on-write copy, so it takes seconds and little disk. Apple's license allows two macOS VMs running at once per Mac, and other tasks may be running one. + +To install a build, copy `~/byre-deploy/byre.dmg` (or a `mac/bundle.sh` build) in and put `Byre.app` in `/Applications` there. Copy an app bundle as a zip (`ditto -c -k --keepParent` on the host, `ditto -x -k` in the VM), since `scp -r` fails on the symlinks in its frameworks. Open apps in the VM's GUI session from SSH with `sudo launchctl asuser 501 sudo -u admin open /Applications/Byre.app`. + +The app's Tailscale sign-in opens Safari in the VM. To have the human approve it from their own Mac, read the address over SSH (`sudo launchctl asuser 501 sudo -u admin osascript -e 'tell application "Safari" to get URL of front document'`, which asks once in the VM for Automation of Safari) and open the `https://login.tailscale.com/a/` it names with `show_page` on the human's Mac. + +## Driving the VM's screen + +- Click the `tart` window's title bar first: the VM takes clicks and keys only while its window is the key window. +- Keys reach the VM without their modifiers through hands, so Shift, Cmd and symbols typed with `type` or `key` arrive wrong. Type only lowercase text and plain symbols there, and do the rest over SSH. +- Scrolling doesn't reach the VM; drag the scroll bar instead. +- `screencapture` over SSH fails for lack of Screen Recording in the VM; capture the `tart` window from the host with hands. +- Check `screen_list` before acting: another task's `tart` window can sit exactly on top of yours. + +## Rebuilding the base + +Only when it needs a newer macOS. `tart create --from-ipsw latest --disk-size 80 byre-base` downloads the restore image (about 15 GB, cached in `~/.tart/cache/IPSWs/`) and installs it; `tart set byre-base --cpu 4 --memory 8192`. Run it and click through Setup Assistant: "Set up as new", account `admin`/`admin`, skip the Apple Account ("Sign in Later in Settings"), and decline everything else. Then turn on Remote Login from the VM's Terminal: + +``` +sudo launchctl enable system/com.openssh.sshd; sudo launchctl bootstrap system /System/Library/LaunchDaemons/ssh.plist +``` + +and over SSH from the host (`ssh-copy-id admin@`, then): + +``` +echo 'admin ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/admin +sudo chmod 440 /etc/sudoers.d/admin +sudo launchctl asuser 501 sudo sysadminctl -autologin set -userName admin -password admin +sudo pmset -a sleep 0 displaysleep 0 +sudo shutdown -h now +``` + +`sysadminctl -autologin` fails with error 22 when run over SSH outside the GUI session, hence `launchctl asuser`. + +tart itself is installed from its GitHub release (`gh release download --repo cirruslabs/tart`, unpacked to `~/Applications/tart.app`, with `~/bin/tart` linked to `tart.app/Contents/MacOS/tart`), since the Homebrew formula in `cirruslabs/cli` fails on current Homebrew. diff --git a/internal/agents/kb/setup.md b/internal/agents/kb/setup.md index 3af7921..51224d9 100644 --- a/internal/agents/kb/setup.md +++ b/internal/agents/kb/setup.md @@ -14,10 +14,10 @@ Agents run on the Mac byre runs on, the one your own Bash runs on. Check its too "$SHELL" -l -i -c '/Applications/Byre.app/Contents/Helpers/byre check' ``` -It prints one JSON line: `ready` (false while something blocking is missing) and `checks`, each with `name`, `need` (`required`; `agent`, where one of claude, codex and agy is enough; or `optional`), `for` (what it is used for), `ok`, `command` (what installs it), `fix` (what to do after) and `blocking`. +It prints one JSON line: `ready` (false while something blocking is missing) and `checks`, each with `name`, `need` (`required`; `agent`, where one of claude, codex and agy is enough; or `optional`), `for` (what it is used for), `ok`, `tool` (what installs it: `byre install-tool `; empty while Homebrew, which it needs, is missing), `fix` (what to do instead) and `blocking`. -- Run each missing tool's `command` yourself through the login shell, `"$SHELL" -l -c ''`. `brew` must be there first; installing Homebrew asks for the human's password, so ask them to run its installer in a terminal on that Mac (the app's Server tab there lists each missing tool with a Run in Terminal button). `xcode-select --install` opens a macOS dialog on that Mac's screen for the human to click Install. -- Sign-in needs the human at a terminal on that Mac: `claude`, `codex login` or `agy`, each run once. `claude auth status` (JSON, `loggedIn`) and `codex login status` show whether one is signed in; agy has no status command, so ask. +- `byre install-tool ` installs one tool: `brew` (Homebrew, and its `shellenv` line in `~/.zprofile`), `git` (`xcode-select --install`, which opens a macOS dialog for the human to click Install), `claude` (its installer, and `~/.local/bin` on `PATH` in `~/.zshrc`), and `codex`, `agy`, `node` and `chrome` through Homebrew. Homebrew's installer asks for the human's password, and `claude`, `codex` and `agy` end by starting the CLI for the human to sign in, so these need the human at a terminal on that Mac: type the command into the task's terminal without the newline and ask them to press Enter, or point them at the app's Server tab there, which lists each missing tool with a Run in Terminal button. `node` and `chrome` you can run yourself through `"$SHELL" -l -c ''`. +- Sign-in, when a CLI is installed but not signed in: `claude`, `codex login` or `agy`, each run once by the human. `claude auth status` (JSON, `loggedIn`) and `codex login status` show whether one is signed in; agy has no status command, so ask. - The service's `PATH` is fixed when it is installed, and your own shell has the same one. It comes from an interactive login shell (`-l -i`), since claude's installer has the human add `~/.local/bin` to `.zshrc`. When a tool is found through that shell but not by `/Applications/Byre.app/Contents/Helpers/byre check` run directly, reinstall the service from it so it picks the new `PATH` up: `"$SHELL" -l -i -c '/Applications/Byre.app/Contents/Helpers/byre install'`. That restarts byre and cuts your turn off; you resume with a restart message, so say before running it that you will carry on afterwards. When you resume, check with `byre check` before reinstalling again: the restart usually took. - `byre install` and the app's Server tab (Turn off, Turn on) are the only ways to restart byre's service. Never run `launchctl` on it yourself, and never tell the human to. - Google Chrome, for agents' browser tools, asks "downloaded from the Internet" on its first launch, and can't be driven until the human answers Open. @@ -40,7 +40,7 @@ It prints a JSON array, one entry per grant: `name`, `status` (`granted`, `missi `list_clients` lists the human's Macs with the app open (and their iPhone, which needs nothing). Agents use a client Mac through `client_hands` and the client file tools, with grants that belong to byre's app there. Check them with `client_bash` on that Mac, running the same `byre-hands` command as above, and open a missing grant's pane there the same way with `open ''`. Ask before opening anything on a client Mac's screen, and only when the human is at it. The human can also run the check from the app's Settings tab on that Mac. -Client Macs need no agent CLIs. Google Chrome is optional there, for agents' `chrome` tool on that Mac; `"$SHELL" -l -c 'brew install --cask google-chrome'` through `client_bash` installs it when `brew` is there. +Client Macs need no agent CLIs. Google Chrome is optional there, for agents' `chrome` tool on that Mac; `"$SHELL" -l -c '/Applications/Byre.app/Contents/Helpers/byre install-tool chrome'` through `client_bash` installs it when Homebrew is there. ## What to leave alone diff --git a/mac/Sources/Byre/Server.swift b/mac/Sources/Byre/Server.swift index 2f2c6c5..b547f77 100644 --- a/mac/Sources/Byre/Server.swift +++ b/mac/Sources/Byre/Server.swift @@ -33,7 +33,7 @@ struct Check: Decodable { let need: String let `for`: String let ok: Bool - let command: String + let tool: String let fix: String let blocking: Bool } @@ -57,8 +57,8 @@ struct ChecksView: View { } Text("\(need(check)), for \(check.for)").foregroundStyle(Palette.muted) if !check.ok { - if !check.command.isEmpty { - CommandBox(command: check.command) + if !check.tool.isEmpty { + CommandBox(tool: check.tool) } if !check.fix.isEmpty { Text(check.fix) @@ -91,7 +91,7 @@ struct ChecksView: View { } private struct CommandBox: View { - let command: String + let tool: String @ViewState private var copied = false @ViewState private var failure: String? @@ -107,7 +107,7 @@ private struct CommandBox: View { private var box: some View { HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(verbatim: command) + Text(verbatim: "byre install-tool \(tool)") .font(Fonts.font(12, mono: true)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) @@ -125,6 +125,10 @@ private struct CommandBox: View { .padding(.vertical, 2) } + private var command: String { + "'\(serverHelper.replacingOccurrences(of: "'", with: "'\\''"))' install-tool \(tool)" + } + private func copy() { copyToClipboard(command) copied = true