diff --git a/.gitignore b/.gitignore
index b70978c..19868a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
.build/
*.test
*.out
+.artifacts/
diff --git a/README.md b/README.md
index 9fcb3e6..9a5e3af 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,8 @@
Supported agents |
Installation |
Usage |
- Development
+ Development |
+ Contributing
@@ -23,6 +24,9 @@ A CLI launcher for coding agents preconfigured to work with [Aperture](https://a
- [OpenCode](https://github.com/sst/opencode)
- [Codex](https://github.com/openai/codex)
- [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/cli-getting-started)
+- [Hermes Agent](https://hermes-agent.nousresearch.com)
+- [Oh My Pi](https://omp.sh)
+- [Pi](https://pi.dev)
- [Claude Cowork](https://support.claude.com/en/articles/13345190-get-started-with-claude-cowork)
## Installation
@@ -75,3 +79,7 @@ make test # run tests
make install # install to $GOPATH/bin
make clean # remove built binary
```
+
+## Contributing
+
+To add a new coding agent, see [docs/adding-a-client.md](./docs/adding-a-client.md).
diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go
index 710f049..d391de6 100644
--- a/cmd/aperture/main.go
+++ b/cmd/aperture/main.go
@@ -22,7 +22,10 @@ import (
_ "github.com/tailscale/aperture-cli/internal/clients/codex"
_ "github.com/tailscale/aperture-cli/internal/clients/copilot"
_ "github.com/tailscale/aperture-cli/internal/clients/gemini"
+ _ "github.com/tailscale/aperture-cli/internal/clients/hermes"
+ _ "github.com/tailscale/aperture-cli/internal/clients/omp"
_ "github.com/tailscale/aperture-cli/internal/clients/opencode"
+ _ "github.com/tailscale/aperture-cli/internal/clients/pi"
)
var (
diff --git a/docs/adding-a-client.md b/docs/adding-a-client.md
new file mode 100644
index 0000000..e72e676
--- /dev/null
+++ b/docs/adding-a-client.md
@@ -0,0 +1,1043 @@
+# Add support for a new client
+
+This guide is for contributors who want to add a new coding agent to `aperture-cli`. The repository ships several command-line agents, each in its own self-contained package under `internal/clients`, plus Claude Cowork as a desktop app in `internal/profiles`. If you have used a harness like OpenCode or Pi and want `aperture` to launch it preconfigured against an Aperture endpoint, this walks you through the whole path. By the end you will have a new package that appears in the launcher menu, installs and uninstalls itself, routes the harness through an Aperture endpoint, replays the last session on the quick-select row, and passes CI.
+
+One naming note before you start. This guide says "harness" because that is the word you probably arrived with, but the codebase does not use it. In the code, a harness is a **client**: the interface is `clients.Client`, the registry is `internal/clients`, and each harness is a sub-package such as `internal/clients/opencode`. When you read code or write comments, use "client" so your work matches everything around it.
+
+## Requirements and assumptions
+
+Before you start, make sure the following are true. The guide does not teach these things and will not work well without them.
+
+- You have Go 1.26.6 or newer installed, which is the version pinned in `go.mod` and the lower bound of the CI matrix.
+- You have cloned the `tailscale/aperture-cli` repository and can run `make build` and `make test` successfully on an unmodified checkout.
+- You are comfortable reading and writing Go, including interfaces, methods on pointer receivers, closures, and table-driven tests.
+- You have the harness you want to add already installed on your machine, so you can test a real launch.
+- You have a reachable Aperture endpoint to test against, and you know its URL. The default is `http://ai`, which resolves over Tailscale.
+- You know, or can find out, how your harness accepts a custom API base URL and a custom API key. This is the single most important prerequisite, and Step 1 covers how to find it.
+- You are working on macOS or Linux. The guide's commands assume a POSIX shell. The code you write will be cross-platform, but the shell snippets are not.
+- You have read the top-level comment in `internal/clients/registry.go`, which is the closest thing the repo has to a contract for this work.
+
+The guide does not cover adding a graphical desktop application. Claude Cowork is a desktop app and it lives in `internal/profiles` behind an adapter, which is a different and more awkward path. Everything here assumes your harness is a command-line binary.
+
+## Variables
+
+You will substitute your own values into the code and commands throughout this guide. Decide each of these before you start writing code, and use the same value everywhere the placeholder appears.
+
+| Variable | Description |
+|---|---|
+| `` | The Go package name and directory name for your client, lowercase with no separators. Existing examples are `opencode`, `codex`, and `claudecode`. |
+| `` | The name the user sees in the launcher menu, written the way the vendor writes it. Existing examples are `OpenCode`, `Gemini CLI`, and `GitHub Copilot`. |
+| `` | The name of the executable as it appears on `$PATH`, for example `opencode` or `claude`. |
+| `` | The shell command that installs the harness, for example `npm install -g @openai/codex`. Get this from the harness's own documentation. |
+| `` | The shell command that uninstalls the harness, for example `npm uninstall -g @openai/codex`. Step 5 uses this twice: once verbatim as a display hint, and once split into separate arguments (`"npm", "uninstall", "-g", "@openai/codex"`) because there is no shell to split it. If the harness has no uninstall command, Step 5 explains what to do instead. |
+| `` | The `config.Endpoint*` constant naming the API path your harness needs a provider to serve, for example `config.EndpointOpenAIResponses`. Step 2 lists the constants and explains how to pick yours. |
+| `` | A short stable identifier your client records in `LaunchState.LastBackendType` and matches again on replay, for example `openai_chat`. It is private to your client. If you built a `backend` struct in Step 7, this is its `id`. Step 9 covers it. |
+| `` | The URL of the Aperture endpoint you will test against, for example `http://ai` or `https://ai.example.com`. |
+| `` | The environment variable your harness reads for its API base URL, for example `OPENAI_BASE_URL`. Step 1 explains how to find it. |
+| `` | The environment variable your harness reads for its API key, for example `OPENAI_API_KEY`. Found the same way. |
+| `` | The environment variable your harness reads for its default model, for example `OPENAI_MODEL`. If your harness has no such variable, delete the whole `if model != ""` block that sets it in Step 9 rather than leaving the placeholder in place. |
+| `` | The command-line flag that makes your harness skip permission prompts, for example `--yolo`. If your harness has no such flag, delete the whole `args` block in Step 9 rather than leaving the placeholder in place. |
+| `` | The environment variable that points your harness at a config file or config directory, for example `OPENCODE_CONFIG`. Only needed if Step 1 told you your harness requires a config file. |
+
+## How it works
+
+The launcher is a registry of clients plus a generic menu engine, and the two know almost nothing about each other. Each client sub-package declares itself at startup by calling `clients.Register` from an `init()` function, as in `internal/clients/opencode/opencode.go:20`. That `init()` only runs if the package is linked into the binary, which is why `cmd/aperture/main.go:21` holds a block of underscore imports whose only purpose is that side effect. Forgetting to add your package to that block is the most common way for a new client to silently not exist.
+
+That `init()` call is also why this guide leaves it until the very end. `clients.Register` takes a `clients.Client`, so the moment you write it, your package stops compiling until every one of the interface's nine methods exists. Adding it last keeps the package buildable and testable at each step along the way.
+
+Everything the launcher can do with a client goes through the `clients.Client` interface in `internal/clients/registry.go:16`. The interface is deliberately wide, because each client owns its own flow end to end. The TUI never asks "what providers does this client support" or "what environment variables does it need". It asks for a `menu.MenuItem` and renders it, and the client's own closures take over from there. The TUI reads the registry through one indirection, the `registeredClients` variable in `internal/tui/tui.go`, which exists so tests can swap in fakes.
+
+The user's path through a client looks like this. Nothing in the diagram is mandatory except the first and last box, and Step 7 explains how to collapse the middle steps when there is only one option.
+
+```mermaid
+flowchart TD
+ A["Root menu
(installed clients)"] --> B["Menu()
returns your MenuItem"]
+ B --> C["providerStep
filter by supported endpoint"]
+ C --> D["backendStep
pick a routing flavor"]
+ D --> E["modelStep
pick a default model"]
+ E --> F["launch()
build env, write config"]
+ F --> G["clients.Launch
exec the binary in the foreground"]
+ G --> H["ExecDoneMsg
TUI regains control, re-runs preflight"]
+```
+
+Two things decide most of the work. The first is how your harness accepts a custom base URL. Some harnesses read environment variables only, which makes the client short: GitHub Copilot is entirely `buildEnv` at `internal/clients/copilot/copilot.go:173`. Others need a config file on disk, so the client writes one per launch and points the harness at it with a single environment variable, which is what `writeProviderConfig` does at `internal/clients/opencode/sdk.go:79`. The second is which API protocols your harness speaks, because the launcher only offers a client the providers that can serve it.
+
+That second part works through the supported-endpoint set. On startup the TUI fetches `GET /v1/models` from the active Aperture endpoint and hands the body to `config.ParseProviders` (see `fetchProvidersContext` in `internal/tui/tui.go` and `internal/config/providers.go:69`). The response is an OpenAI-style `{"object":"list","data":[...]}` list of *model* rows, each carrying a `supported_endpoints` array of API paths and a `metadata.provider` object. `ParseProviders` aggregates those rows upward into one `config.ProviderInfo` per provider, unioning every model's endpoints into `SupportedEndpoints map[string]bool` and collecting the model IDs into `Models`. Your client filters that list down to providers it can actually talk to by calling `p.SupportsEndpoint(...)`, and if the list comes back empty it shows an error instead of a menu.
+
+The TUI sends `User-Agent: aperture-cli` on that request on purpose. Aperture filters the model list for Claude Code user agents, and discovery needs the full grant-filtered list for every harness, not the Claude Code subset.
+
+## Step 1: Work out how your harness accepts a custom base URL
+
+Everything downstream depends on this answer, so get it before you write any Go. You are looking for two things: how to point the harness at an arbitrary HTTP endpoint instead of the vendor's own API, and how to satisfy its API key check without a real key.
+
+Start with the harness's own documentation, searching for "base URL", "custom endpoint", "proxy", "self-hosted", or "OpenAI-compatible". Then check the harness's help output and its environment, which often reveals more than the docs do.
+
+```bash
+ --help
+env | grep -i
+```
+
+If the harness is open source, searching its source for `baseURL`, `base_url`, or `BASE_URL` is usually faster than reading its documentation.
+
+Sort what you find into one of four shapes. In the environment-variable shape, the harness reads a base URL and an API key from the process environment and needs nothing on disk. GitHub Copilot works this way through `COPILOT_PROVIDER_BASE_URL` and friends. In the config-file shape, the harness insists on reading a config file, so your client writes that file at launch time and passes its path or its parent directory in one environment variable. OpenCode works this way through `OPENCODE_CONFIG` and Gemini CLI through `GEMINI_CLI_HOME`.
+
+The third shape is a command-line override. The harness accepts its whole routing configuration as flags, so your client assembles them per launch and never writes to the harness's own home directory. Codex works this way: `apertureLaunchConfig` at `internal/clients/codex/config.go:16` emits `--config model_provider=...` and `--config model_providers.=...` pairs, plus a `--config model_catalog_json=...` pointing at a temporary catalog built by `prepareModelCatalog` in `internal/clients/codex/catalog.go`. It deliberately leaves `CODEX_HOME` alone so the user's real Codex configuration and credentials keep working. Prefer this shape when you can get it.
+
+The fourth shape is a plugin: the harness has no base-URL variable at all and no config file you can point at in isolation, but it can load a file of code that registers a provider at startup. Pi works this way. It accepts `-e ` and calls the file's exported function with its own extension API, which `internal/clients/pilike` uses to register one Aperture provider per launch. Treat this like the config-file shape, writing the file per launch and cleaning it up after, but note that it is code rather than data, so generate it by marshaling values to JSON and interpolating them rather than by hand-writing strings. Clean up on the next launch as well as on exit: a crash never runs your cleanup function, and `sweepOrphanedExtensions` in `internal/clients/pilike/extension.go` shows the pattern.
+
+Do not assume a variable exists just because every other harness has one. Search the harness's own documentation and source for the exact name before writing it down. If you cannot find one, that is a finding, not a gap in your search — invent nothing. A harness with a permissive plugin API often has no URL variable at all, and a variable that looks right may not be an input: Pi *sets* `PI_MODEL` and `PI_PROVIDER` for the tools it spawns to read, so setting them yourself does nothing.
+
+You will also need a value to satisfy the harness's API key check. Aperture handles authentication itself, so no real key is involved. The convention in this repo is a placeholder string, and existing clients use `not-needed`, `not-required`, or a bare `-` depending on what the harness accepts. Check whether the key is genuinely optional: Pi loads a provider without one, then silently hides its models from every picker, which looks like a compatibility bug rather than a missing placeholder.
+
+Two more questions are worth settling now, because both are cheap to answer and expensive to discover later. First, if the harness needs an on-disk home directory, find out what else lives there — if the same directory also holds the user's saved logins, settings, or session history, redirecting it to an Aperture-owned path will hide all of that, and a per-launch plugin or config file is the better route. Second, if the harness has a "skip permission prompts" flag, confirm it actually governs tool approval. Pi's `--approve` looks like one but controls whether project-local config files are trusted, and wiring `YoloMode` to it would grant something the user did not ask for while still prompting for everything they did.
+
+Write down the exact variable names and the exact config file schema before you continue. To verify you have enough, launch the harness by hand with those values set and confirm it reaches your Aperture endpoint. This one-off check saves you from debugging your Go code when the problem was the harness contract all along.
+
+Then confirm your endpoint is reachable and answering, because nothing later in this guide works without it.
+
+```bash
+curl -s -H 'User-Agent: aperture-cli' /v1/models
+```
+
+You should get back an OpenAI-style listing: an object with `"object": "list"` and a `data` array of *model* rows. Each row carries an `id`, a `supported_endpoints` array of API paths, and a `metadata.provider` object with `id`, `name`, `description`, `requires_client_auth`, and `upstream`. Keep that output open, because the next step reads it. If the request fails or returns nothing, fix your Aperture connectivity before continuing.
+
+Note that the rows are models, not providers. There is no provider-level object in the response at all; `config.ParseProviders` synthesizes one per distinct `metadata.provider.id`. Sending `User-Agent: aperture-cli` matters for the same reason the TUI sends it: Aperture serves a filtered model list to Claude Code user agents.
+
+## Step 2: Choose the endpoints your harness can speak
+
+A supported endpoint is how your client declares which providers it can use. Pick the wrong one and your client will either never appear in a provider list or will appear and then fail at runtime.
+
+The endpoints are canonical API paths, and they are centrally defined in this repository as constants at the top of `internal/config/providers.go:10`. That block is the authoritative list — use the constants rather than writing path strings by hand, so a rename reaches every client through the compiler.
+
+```go
+const (
+ EndpointAnthropicMessages = "/v1/messages"
+ EndpointOpenAIResponses = "/v1/responses"
+ EndpointOpenAIChat = "/v1/chat/completions"
+ EndpointGemini = "/v1beta/models/{model}:generateContent"
+ EndpointVertexGemini = "/v1/projects/{project}/locations/{region}/publishers/google/models/{model}:generateContent"
+ EndpointVertexClaude = "/v1/projects/{project}/locations/{region}/publishers/anthropic/models/{model}:rawPredict"
+ EndpointBedrockInvoke = "/bedrock/model/{model}/invoke"
+ EndpointBedrockConverse = "/bedrock/model/{model}/converse"
+)
+```
+
+Every client draws from that one block. The longest selection is `supportedEndpoints` in `internal/clients/opencode/opencode.go:34`, which names all eight. Aperture may advertise paths the constants do not cover — `SupportedEndpoints` is an open map keyed on whatever the server sent, so an unknown path lands in it harmlessly and simply matches nothing.
+
+Map the protocol you found in Step 1 onto one or more of these constants. A harness that speaks OpenAI Chat Completions wants `config.EndpointOpenAIChat`. One that speaks the newer OpenAI Responses API wants `config.EndpointOpenAIResponses`. One that speaks Anthropic's Messages API wants `config.EndpointAnthropicMessages`.
+
+How many endpoints you need decides how much menu you write. If your harness speaks exactly one protocol, you need one constant and no backend step, which is what Codex does by testing `config.EndpointOpenAIResponses` directly in `compatibleProviders` at `internal/clients/codex/codex.go:222`. If it speaks several and the user should choose between them, you need a `backend` struct with an `endpoint` field and one entry per protocol, which is what Copilot does at `internal/clients/copilot/copilot.go:37`. If it speaks several but the choice can be made for the user automatically, you need a list of endpoints and a resolver, which is what OpenCode does in `pickSDK` at `internal/clients/opencode/sdk.go:35`.
+
+To verify your choice, inspect the JSON from Step 1 and confirm at least one model on your endpoint lists your path in its `supported_endpoints`. This aggregates the rows the way `ParseProviders` does.
+
+```bash
+curl -s -H 'User-Agent: aperture-cli' /v1/models | python3 -c '
+import json, sys
+provs = {}
+for m in json.load(sys.stdin)["data"]:
+ p = m["metadata"]["provider"]
+ e = provs.setdefault(p["id"], {"upstream": p["upstream"], "models": 0, "eps": set()})
+ e["models"] += 1
+ e["eps"].update(m.get("supported_endpoints") or [])
+for pid, v in provs.items():
+ up, n, eps = v["upstream"], v["models"], sorted(v["eps"])
+ print(f"{pid:22} upstream={up:16} models={n:3} {eps}")
+'
+```
+
+On a typical endpoint that prints something like this, and the paths on the right are exactly what `SupportsEndpoint` is matching against.
+
+```
+vercel-ent-zdr upstream=vercel models= 38 ['/v1/chat/completions', '/v1/messages', '/v1/responses']
+anthropic upstream=anthropic models= 14 ['/v1/chat/completions', '/v1/messages']
+openai-api upstream=openai models= 28 ['/v1/chat/completions', '/v1/responses']
+bedrock upstream=bedrock-runtime models= 12 ['/bedrock/model/{model}/converse', ...]
+```
+
+If no provider serves your path, your client will correctly refuse to launch, and that is a configuration problem on the Aperture side rather than something to work around in code.
+
+One provider distinction cannot be made from endpoints alone. A Mantle provider speaks the Anthropic Messages protocol like any other, but Claude Code needs a different transport mode for it, so `backendMatches` at `internal/clients/claudecode/claudecode.go:299` keys off `p.Upstream == "bedrock-mantle"` before it looks at endpoints at all. If your harness needs a similar distinction, `ProviderInfo.Upstream` is where it lives. `ProviderInfo.RequiresClientAuth` is available on the same struct for providers that want the caller's own credentials.
+
+## Step 3: Create the package directory and files
+
+Now create the package. Every client sub-package follows the same file layout, and matching it makes your code reviewable by anyone who has read the others.
+
+Run this from the repository root. It creates the directory and the three files you will fill in, each with its package clause already in place.
+
+```bash
+mkdir -p internal/clients/
+cd internal/clients/
+printf 'package \n' > .go
+printf 'package \n' > install.go
+printf 'package \n' > _test.go
+cd -
+```
+
+Write the package clause now rather than creating the files empty. A zero-byte `.go` file is a parse error, not an empty package, so `go build ./...` fails with `expected 'package', found 'EOF'` for every empty file in the directory — which would break the verification at the end of this step and every step after it until all three files have content.
+
+The main file holds the `Client` type and every interface method. The `install.go` file holds only `commonBinaryPaths`, kept separate because it is the one function that tends to differ per operating system. The test file holds your table-driven tests. If your harness needs routing configuration built outside the main file, Step 8 adds a fourth file for it: `sdk.go` in OpenCode, `config.go` in Gemini and Codex, and `extension.go` in `internal/clients/pilike`, the package shared by Pi and Oh My Pi.
+
+A harness that speaks the same plugin API as one already in the repo is the exception to this layout. Share the existing package instead of forking it, so a change lands once and the reasoning behind it stays in one place. `internal/clients/pilike` is the example: it holds the whole client, and `internal/clients/pi` and `internal/clients/omp` hold only a `pilike.Variant` describing what differs, such as the display name, the binary name, the install command, and the yolo arguments. Add a field to `Variant` for each real difference you find, and resist copying the package to change a string.
+
+Open the main file and replace its bare package clause with the doc comment, the package declaration, the type, and your constants. The doc comment matters more here than in most Go code, because the existing client packages each explain their routing model up front and reviewers will look for that.
+
+```go
+// Package is the client. Describe
+// here which protocols it speaks, how routing is configured (environment
+// variables, a config file, CLI overrides, or a mix), and what the menu flow
+// looks like.
+package
+
+// Client is the client.
+type Client struct{}
+
+const (
+ name = ""
+ binaryName = ""
+)
+```
+
+The endpoint your client needs is not a constant of your own: it is `` from `internal/config`, referenced directly wherever you need it. No client declares a private copy of an endpoint path.
+
+`Client` is an empty struct because clients hold no state of their own. All state lives in the `*config.Global` that gets passed into each method.
+
+There are deliberately no imports and no `init()` yet. Go treats an unused import as a compile error, so adding the import block before the code that uses it would break the build, and Step 11 adds the `init()` once every interface method exists. Add each import as the step that needs it arrives, or let your editor do it.
+
+To verify, build the whole module.
+
+```bash
+go build ./...
+```
+
+That should produce no output at all. Your package now compiles, which means you can keep it compiling after every step that follows. If you see an error about an unused import, delete the import rather than the code. If you see `expected 'package', found 'EOF'`, one of your three files is still empty; give it the package clause shown above.
+
+## Step 4: Implement identity and binary discovery
+
+These four methods tell the launcher what your client is called and whether it is installed. They are the shortest methods in the interface and none of them make decisions.
+
+Add them to your main file, below the constants, along with the `clients` import they need.
+
+```go
+import "github.com/tailscale/aperture-cli/internal/clients"
+```
+
+```go
+// Name implements clients.Client.
+func (c *Client) Name() string { return name }
+
+// BinaryName implements clients.Client.
+func (c *Client) BinaryName() string { return binaryName }
+
+// CommonPaths implements clients.Client.
+func (c *Client) CommonPaths() []string { return commonBinaryPaths() }
+
+// IsInstalled implements clients.Client.
+func (c *Client) IsInstalled() bool {
+ return clients.IsInstalled(binaryName, c.CommonPaths())
+}
+```
+
+`Name` is what the user reads in the menu. `BinaryName` is what gets looked up on `$PATH`. `CommonPaths` covers the case where the binary exists but `$PATH` does not know about it yet, which happens constantly right after an install has updated a shell profile that the running shell has not reloaded. Delegate `IsInstalled` to the shared helper rather than writing your own check, so binary discovery stays consistent across clients.
+
+Now fill in `install.go` with the paths where your harness's installer actually puts the binary.
+
+```go
+package
+
+import (
+ "os"
+ "path/filepath"
+)
+
+// commonBinaryPaths returns the non-PATH locations where
+// is commonly installed.
+func commonBinaryPaths() []string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil
+ }
+ return []string{
+ filepath.Join(home, ".local", "bin", ""),
+ }
+}
+```
+
+Return full paths to the binary, not directories. `FindBinary` at `internal/clients/binary.go:19` treats these entries as complete paths and stats each one directly. You do not need to list `~/.local/bin`, `~/bin`, or `~/.npm-global/bin`, because `commonBinDirs` in the same file already checks those for every client. Add an entry only for a location specific to your harness, the way OpenCode adds `~/.opencode/bin/opencode`.
+
+To verify, confirm the package still builds and that your harness is where you think it is.
+
+```bash
+go build ./... && which
+```
+
+The build should print nothing, and `which` should print a path. If `which` prints a path, `IsInstalled` will return `true` through the `$PATH` lookup alone. To confirm your `commonBinaryPaths` entries also work, temporarily remove the binary's directory from `$PATH` in a throwaway shell and check that `which` fails while the path you listed still exists on disk.
+
+## Step 5: Describe how to install and uninstall the harness
+
+The launcher can install a harness for the user from the `[i] Install agents` menu, and remove it from `Settings` then `Uninstall`. Both are described declaratively: your client returns a plan, and the TUI shows the hint, asks for confirmation, and runs the command.
+
+Add both methods to your main file, and add `"os/exec"` and `"github.com/tailscale/aperture-cli/internal/config"` to its imports.
+
+```go
+// Install implements clients.Client.
+func (c *Client) Install(_ *config.Global) clients.InstallPlan {
+ return clients.InstallPlan{
+ Hint: "",
+ Run: func() (*exec.Cmd, error) {
+ return exec.Command("bash", "-o", "pipefail", "-c", ""), nil
+ },
+ }
+}
+
+// Uninstall implements clients.Client.
+func (c *Client) Uninstall() clients.UninstallPlan {
+ return clients.UninstallPlan{
+ Hint: "",
+ Run: func() error {
+ // Split into separate arguments: there is no shell here.
+ return exec.Command("npm", "uninstall", "-g", "@openai/codex").Run()
+ },
+ }
+}
+```
+
+Note the difference between the two `Run` fields. `Install.Run` passes the command as one string to a shell, which splits it. `Uninstall.Run` has no shell, so you must split `` into its arguments yourself, exactly as Codex does in `npmUninstallPlan` at `internal/clients/codex/codex.go:88`. The example above is Codex's literal argument list — substitute your own. Passing the whole command as a single argument compiles fine and then fails at runtime with `fork/exec npm uninstall -g ...: no such file or directory`, because it looks for one executable whose filename contains spaces. No build or test step catches this, so get it right here.
+
+Use `bash -o pipefail -c` rather than `/bin/sh -c` whenever your install command contains a pipe. A plain shell reports only the exit status of the last stage, so `curl -fsSL ... | bash` succeeds with status zero when the download 404s and the empty body is piped into a shell that has nothing to do. With `pipefail` the failing `curl` propagates. Claude Code and OpenCode both do this, at `internal/clients/claudecode/claudecode.go:76` and `internal/clients/opencode/opencode.go:64`. Clients whose install command has no pipe, such as Copilot's bare `npm install -g`, still use `/bin/sh -c`.
+
+The `Hint` is shown to the user verbatim before they confirm, so write the actual command rather than a description of it. `Install.Run` returns an `*exec.Cmd` that the TUI executes, and it returns rather than runs the command so the TUI controls the terminal handoff.
+
+A zero exit status is not on its own treated as success. After the installer finishes, `installDoneMsg` at `internal/tui/menus.go:648` calls `client.IsInstalled()` and reports a failure if the binary still cannot be found, which catches an installer that printed an error and exited clean. Leave `SkipInstalledCheck` at its zero value so you get that check. Set it to `true` only when your `Run` merely *starts* a user-driven installation and cannot itself finish one — opening a vendor download page, for example, which is why the Claude Cowork desktop adapter sets it at `internal/profiles/adapter.go:46`.
+
+Two cases need different handling. If your harness has no scripted install, set `Run` to `nil` and the TUI will show the hint and do nothing, leaving the user to install it by hand. If uninstalling means deleting files rather than running a command, do the deletion in Go, the way Claude Code does at `internal/clients/claudecode/claudecode.go:82`.
+
+Verify with a test rather than by actually installing anything, following the pattern in `TestInstallPlan` at `internal/clients/codex/codex_test.go:67`. Put this in your test file, which needs the package clause and two imports.
+
+```go
+package
+
+import (
+ "testing"
+
+ "github.com/tailscale/aperture-cli/internal/config"
+)
+
+func TestInstallUninstall(t *testing.T) {
+ c := &Client{}
+ install := c.Install(&config.Global{})
+ if install.Hint != "" {
+ t.Errorf("Install.Hint = %q", install.Hint)
+ }
+ if install.Run == nil {
+ t.Error("Install.Run is nil")
+ }
+
+ uninstall := c.Uninstall()
+ if uninstall.Hint != "" {
+ t.Errorf("Uninstall.Hint = %q", uninstall.Hint)
+ }
+ if uninstall.Run == nil {
+ t.Error("Uninstall.Run is nil")
+ }
+}
+```
+
+Replace the bare package clause in your test file with the block above. The test lives in your own package rather than a `_test` package, so it can reach unexported identifiers such as `name` and `binaryName`. Note that it asserts only on the hints and on `Run` being non-nil — it never invokes `Run`, because doing so would really uninstall your harness. That is why the argument-splitting mistake described above survives a green test suite.
+
+If your install command is piped, assert on the argument list too, so nobody quietly reverts the `pipefail` wrapper. This is `TestInstallCommandDetectsPipelineFailures` from `internal/clients/opencode/opencode_test.go:15`; it needs `slices` added to your test imports.
+
+```go
+func TestInstallCommandDetectsPipelineFailures(t *testing.T) {
+ plan := (&Client{}).Install(&config.Global{})
+ cmd, err := plan.Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Equal(cmd.Args, []string{
+ "bash", "-o", "pipefail", "-c", "",
+ }) {
+ t.Errorf("install command args = %q, want bash with pipefail", cmd.Args)
+ }
+}
+```
+
+Run it to confirm.
+
+```bash
+go test ./internal/clients//
+```
+
+You should see `ok` and the package path. Tests run at this point precisely because you have not added `init()` yet.
+
+## Step 6: Filter providers by supported endpoint
+
+Your client must decide which of the endpoint's providers it can use. This is a small piece of code with an outsized effect, because it gates whether the client shows a menu at all, and Step 10 reuses it to decide whether a replay is still valid.
+
+Add these helpers near the bottom of your main file, next to the other unexported functions.
+
+```go
+// compatibleProviders returns the subset of providers this client can use.
+func compatibleProviders(all []config.ProviderInfo) []config.ProviderInfo {
+ var out []config.ProviderInfo
+ for _, p := range all {
+ if providerMatches(p) {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func providerMatches(p config.ProviderInfo) bool {
+ return p.SupportsEndpoint()
+}
+```
+
+That is the single-protocol version. `SupportsEndpoint` reads `SupportedEndpoints`, which is a `map[string]bool`, so a provider that does not advertise the path yields `false` without any existence check.
+
+If your harness speaks several protocols, `providerMatches` should return true when any of them match, as OpenCode does at `internal/clients/opencode/opencode.go:186`. If the user chooses between protocols, replace `providerMatches` with a `backendsFor` function that returns every matching backend and treat a non-empty result as a match, as Copilot does at `internal/clients/copilot/copilot.go:248`.
+
+Most clients also need the model list in fully-qualified form, because the launcher displays models as `provider_id/model_id` while harnesses usually want the bare model ID. If your client offers a model choice, add both helpers, and add `"strings"` to your imports for the second one.
+
+```go
+// fqnModels returns the provider's models in "provider_id/model_id" form.
+func fqnModels(p config.ProviderInfo) []string {
+ out := make([]string, len(p.Models))
+ for i, m := range p.Models {
+ out[i] = p.ID + "/" + m
+ }
+ return out
+}
+
+func stripProviderPrefix(fqn string) string {
+ if _, after, ok := strings.Cut(fqn, "/"); ok {
+ return after
+ }
+ return fqn
+}
+```
+
+Using `stripProviderPrefix` before you put a model name into the environment is not optional. Leaving the prefix on breaks path-based routing, and there is a comment explaining a concrete instance of that breakage at `internal/clients/claudecode/claudecode.go:328`.
+
+Verify with a test in the style of `TestCompatibleProviders` at `internal/clients/opencode/opencode_test.go:29`. Add it to the test file you started in Step 5.
+
+```go
+func TestCompatibleProviders(t *testing.T) {
+ provs := []config.ProviderInfo{
+ {ID: "match", SupportedEndpoints: map[string]bool{: true}},
+ {ID: "nomatch", SupportedEndpoints: map[string]bool{"/unknown": true}},
+ }
+ got := compatibleProviders(provs)
+ if len(got) != 1 || got[0].ID != "match" {
+ t.Errorf("compatibleProviders = %+v, want just the matching provider", got)
+ }
+}
+```
+
+Constructing a `ProviderInfo` literal by hand is the normal way to test a filter, but note what it skips: `ParseProviders` never produces a provider with a nil `SupportedEndpoints` map, because it allocates one for every provider it creates. It also never produces one with a model ID appearing twice, since it de-duplicates as it aggregates. A provider with zero models is possible only if the endpoint sends no rows for it, which cannot happen given that providers are discovered *from* model rows.
+
+Run the tests and confirm both pass.
+
+```bash
+go test ./internal/clients//
+```
+
+The filter is now the only thing standing between the provider list and your menu.
+
+## Step 7: Build the menu flow
+
+This step turns your client into something the user can actually select. `Menu` is the entry point the root menu renders, and each subsequent step either descends automatically or shows a submenu.
+
+Add `Menu` and the provider step to your main file. Both need the `menu` package, and the error helper at the end of this step needs Bubble Tea, so add these two imports now.
+
+```go
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/tailscale/aperture-cli/internal/menu"
+```
+
+```go
+// Menu implements clients.Client.
+func (c *Client) Menu(g *config.Global) menu.MenuItem {
+ return menu.MenuItem{
+ Label: name,
+ Action: func() menu.Result { return c.providerStep(g) },
+ }
+}
+
+func (c *Client) providerStep(g *config.Global) menu.Result {
+ provs := compatibleProviders(g.Providers)
+ if len(provs) == 0 {
+ return errorResult("No providers support .")
+ }
+ if len(provs) == 1 {
+ return c.modelStep(g, provs[0])
+ }
+ items := make([]menu.MenuItem, 0, len(provs))
+ for _, p := range provs {
+ items = append(items, menu.MenuItem{
+ Label: p.DisplayName(),
+ Description: p.Description,
+ Action: func() menu.Result { return c.modelStep(g, p) },
+ })
+ }
+ return menu.Result{Next: &menu.Menu{
+ Title: "Choose a provider for " + name + ":",
+ Items: items,
+ }}
+}
+```
+
+Three conventions are at work there, and every existing client follows all three. An empty list produces an error rather than an empty menu. A single option descends straight to the next step instead of making the user press Enter on a menu of one. Anything more shows a submenu returned as `Result.Next`, which pushes onto the TUI's menu stack so Esc pops back.
+
+The loop variable capture is safe here because each iteration gets a fresh `p`. That has been true since Go 1.22, and this repository targets Go 1.26.6, so you do not need the old workaround. You may still see `c := c` or `p := p` lines in code such as `internal/tui/menus.go:195`; they are no longer necessary and you do not need to copy them.
+
+Add the model step next. This one shows the model picker only when there is a real choice to make.
+
+```go
+func (c *Client) modelStep(g *config.Global, p config.ProviderInfo) menu.Result {
+ models := fqnModels(p)
+ if len(models) <= 1 {
+ var m string
+ if len(models) == 1 {
+ m = models[0]
+ }
+ return c.launch(g, p, m)
+ }
+ items := make([]menu.MenuItem, 0, len(models))
+ for _, m := range models {
+ items = append(items, menu.MenuItem{
+ Label: m,
+ Action: func() menu.Result { return c.launch(g, p, m) },
+ })
+ }
+ return menu.Result{Next: &menu.Menu{
+ Title: "Choose a default model for " + name + " via " + p.DisplayName() + ":",
+ Items: items,
+ }}
+}
+```
+
+Note that zero models is not an error. It passes an empty model string through to `launch`, which then omits the model environment variable entirely and lets the harness pick its own default. In practice a provider always has at least one model, because `ParseProviders` discovers providers *from* model rows, so the zero branch is defensive rather than load-bearing. If your harness has its own model picker, skip the model step entirely and go from provider straight to launch, as OpenCode does at `internal/clients/opencode/opencode.go:94`.
+
+If Step 2 told you the user needs to choose a protocol, insert a backend step between the provider step and the model step. Define a `backend` struct with an `endpoint` field holding the relevant `config.Endpoint*` constant, plus whatever else your routing needs, and a package-level slice of them. Then write a `backendStep` with the same empty-check, single-option, submenu shape. `internal/clients/copilot/copilot.go:107` is the clearest example, and `internal/clients/gemini/gemini.go:119` shows a two-backend version.
+
+Every client also needs a way to surface an error, so add this helper at the bottom of the file.
+
+```go
+func errorResult(msg string) menu.Result {
+ return menu.Result{Cmd: func() tea.Msg {
+ return menu.SimpleDoneMsg{Err: errString(msg)}
+ }}
+}
+
+type errString string
+
+func (e errString) Error() string { return string(e) }
+```
+
+A `SimpleDoneMsg` carrying an error puts the TUI into its error state and prints your message, which you can see handled at `internal/tui/tui.go:400`. The tiny `errString` type exists so you can build an error from a string without importing `errors` or `fmt`, and every client package declares its own copy.
+
+The build will fail at this point, because your menu closures call a `launch` method that does not exist yet.
+
+```bash
+go build ./...
+```
+
+Expect two errors, one per closure that calls `launch`, both reading `c.launch undefined (type *Client has no field or method launch)`:
+
+```
+internal/clients//.go:76:12: c.launch undefined (type *Client has no field or method launch)
+internal/clients//.go:82:42: c.launch undefined (type *Client has no field or method launch)
+```
+
+Your line numbers will differ. Step 9 resolves both. If you see errors naming anything other than `c.launch`, fix those before moving on.
+
+This is the last verification until Step 9 if your harness needs no config file, or until the end of Step 8 if it does. Both of those steps end by building, so you will find out then whether anything you wrote here was wrong.
+
+## Step 8: Write the routing config, if your harness needs one
+
+Skip this step if Step 1 told you your harness is configured entirely through environment variables. Copilot has no config file at all, and its client is simpler for it.
+
+Skip it too if Step 1 told you the harness takes its whole configuration as CLI flags. Codex is configured that way now and writes nothing durable; only its temporary model catalog touches disk.
+
+If your harness does need a file, you have a choice about lifetime. A per-launch temporary file is right when the file's contents depend on the provider and model the user just picked, and it should be deleted when the harness exits. OpenCode works this way, as does Codex's model catalog. A persistent directory is right when the harness stores its own state alongside your config, such as credentials you do not want to destroy on every run. Gemini CLI works this way.
+
+For the per-launch shape, create a new file `config.go` in your package and write a function that returns the path plus a cleanup closure. This is a condensed version of `writeProviderConfig` at `internal/clients/opencode/sdk.go:79`.
+
+The whole file follows, including its imports and the `harnessConfig` struct. You must define that struct yourself: its fields and JSON tags have to match the schema your harness expects, which you wrote down in Step 1. The version below is a plausible shape, not a real harness's schema, so treat it as a template to replace rather than code to keep.
+
+```go
+package
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+
+ "github.com/tailscale/aperture-cli/internal/config"
+)
+
+// harnessConfig is the on-disk schema expects. Replace
+// these fields and JSON tags with your harness's real schema.
+type harnessConfig struct {
+ BaseURL string `json:"baseUrl"`
+ APIKey string `json:"apiKey"`
+ Provider string `json:"provider"`
+ Models []string `json:"models"`
+}
+
+// writeProviderConfig writes the per-launch config and returns its path plus
+// a cleanup function that removes the file.
+func writeProviderConfig(apertureHost string, p config.ProviderInfo) (string, func(), error) {
+ cfg := harnessConfig{
+ BaseURL: apertureHost + "/v1",
+ APIKey: "not-needed",
+ Provider: p.ID,
+ Models: p.Models,
+ }
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ return "", nil, err
+ }
+ dir, err := config.ClientConfigDir("")
+ if err != nil {
+ return "", nil, err
+ }
+ path := filepath.Join(dir, "tmp_aperture_config.json")
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ return "", nil, err
+ }
+ return path, func() { os.Remove(path) }, nil
+}
+```
+
+The cleanup closure is the important part. You hand it to `clients.Launch` as `LaunchSpec.Cleanup`, and the TUI calls it after the harness process exits, as you can see at `internal/clients/launch.go:61`. Without it you leave a file containing your endpoint URL behind after every session.
+
+Use `config.ClientConfigDir` from `internal/config/client_config.go:13` rather than building a path by hand. It returns `/aperture/clients/`, creates the directory with mode `0o700`, and keeps every client's files in one predictable place. Write files themselves with mode `0o600`. If your harness insists on a fixed location in the user's home directory, follow OpenCode's example and write there instead, but keep the permissions.
+
+For the persistent shape, drop the cleanup function and return just the directory path, as `writeConfig` does at `internal/clients/gemini/config.go:17`. Note the comment in `gemini/config.go` explaining that its path is deliberately the pre-refactor legacy one, kept so existing user OAuth credentials keep resolving. If you ever need to move a path like that, expect to migrate the contents.
+
+Before you commit to writing into the harness's own home directory, reconsider. Codex used to be configured by redirecting `CODEX_HOME`, which also relocated the user's real Codex state. It now passes `--config` overrides instead and leaves that directory untouched, and the doc comment on `config.ClientConfigDir` was narrowed to Gemini for exactly that reason. Redirect a harness's home only when it gives you no other way in.
+
+First confirm the new file compiles. Your package as a whole still will not build, because Step 7's menu closures are still waiting on `launch`, so build just this package and expect the same two `c.launch undefined` errors and nothing else.
+
+```bash
+go build ./internal/clients//
+```
+
+If you see `undefined: json`, `undefined: os`, `undefined: filepath`, `undefined: config`, or `undefined: harnessConfig`, you are missing part of the file above — the import block or the struct definition.
+
+Then verify the behavior with a test. Add this to your test file, which now needs four more imports: `encoding/json`, `os`, `path/filepath`, and `testing`.
+
+```go
+func TestWriteProviderConfig(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("HOME", tmp)
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config"))
+
+ p := config.ProviderInfo{ID: "openai", Models: []string{"gpt-5"}}
+ path, cleanup, err := writeProviderConfig("http://ai.example.com", p)
+ if err != nil {
+ t.Fatalf("writeProviderConfig: %v", err)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("config unreadable: %v", err)
+ }
+ var got harnessConfig
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.BaseURL != "http://ai.example.com/v1" {
+ t.Errorf("BaseURL = %q, want http://ai.example.com/v1", got.BaseURL)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if perm := info.Mode().Perm(); perm != 0o600 {
+ t.Errorf("perm = %o, want 600", perm)
+ }
+
+ cleanup()
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Error("config file still exists after cleanup")
+ }
+}
+```
+
+```bash
+go test -run TestWriteProviderConfig ./internal/clients//
+```
+
+That should report `ok`. The two `t.Setenv` calls are the part to copy without thinking about it: `config.ClientConfigDir` resolves through `os.UserConfigDir()`, so without them the test writes into your own `~/.config`. `TestWriteProviderConfig` at `internal/clients/opencode/opencode_test.go:76` uses the same isolation for the same reason.
+
+## Step 9: Implement the launch
+
+This is where the client stops describing itself and does something. `launch` resolves the binary, assembles the environment, records what the user chose, and hands off to the shared launcher.
+
+There are two versions below and you want exactly one of them. Use variant A if your harness is configured entirely through environment variables and you skipped Step 8. Use variant B if you wrote a config file in Step 8. They are complete alternatives, not a base plus a patch — do not paste both.
+
+### Variant A: environment variables only
+
+Add this to your main file, above `Replay`.
+
+```go
+func (c *Client) launch(g *config.Global, p config.ProviderInfo, model string) menu.Result {
+ bin := clients.FindBinary(binaryName, c.CommonPaths())
+ if bin == "" {
+ bin = binaryName
+ }
+
+ env := map[string]string{
+ "": strings.TrimRight(g.ApertureHost, "/") + "/v1",
+ "": "not-needed",
+ }
+ if model != "" {
+ env[""] = stripProviderPrefix(model)
+ }
+
+ var args []string
+ if g.Settings.YoloMode {
+ args = append(args, "")
+ }
+
+ _ = g.RecordLaunch(config.LaunchState{
+ LastClientName: name,
+ LastBackendType: "",
+ LastProviderID: p.ID,
+ LastModel: model,
+ })
+
+ cmd := clients.Launch(clients.LaunchSpec{
+ Binary: bin,
+ Args: args,
+ Env: env,
+ Debug: g.Debug,
+ })
+ return menu.Result{Cmd: cmd, PopOnDone: true}
+}
+```
+
+Substitute the four harness-specific names from the values you gathered in Step 1. If your harness has no model environment variable, delete the whole `if model != ""` block rather than leaving the placeholder in the map, or you will set a variable literally named ``. If it has no permission-skipping flag, delete the whole `args` block the same way and drop `Args` from the `LaunchSpec`, as OpenCode does with an explanatory comment at `internal/clients/opencode/opencode.go:136`.
+
+Several details there are easy to get wrong. Falling back to the bare `binaryName` when `FindBinary` returns empty is deliberate: it lets the operating system try one more time and produces a clearer error than an empty path would. Trimming the trailing slash off `g.ApertureHost` matters because the user may have typed one and string concatenation will happily produce `//v1`. The `/v1` suffix is a guess based on the most common case, so use whatever path your harness and provider protocol actually need, and compare against `internal/clients/copilot/copilot.go:180`, where the suffix is added for OpenAI-style routing but not for Anthropic. The `Env` map is overlaid on the user's real environment rather than replacing it, as `internal/clients/launch.go:39` shows, so you only need to set what you are changing.
+
+The `RecordLaunch` error is deliberately discarded, matching every other client. A failure to persist the quick-select record is not worth interrupting a launch the user has already confirmed. `LastBackendType` must be a stable string you can match again in Step 10; if you built a `backend` struct in Step 7 use `b.id` here, and if you did not, pick a short fixed identifier of your own. It is a private token for your client's own replay check, not an endpoint path — Codex and OpenCode both record the literal `"openai"`.
+
+`RecordLaunch` also stamps `LastEndpointURL` and `LastBridgeID` onto the state from `g.ActiveEndpoint()` before saving, at `internal/config/global.go:232`. You do not set those yourself, and Step 10 explains what the TUI does with them.
+
+Setting `PopOnDone: true` is what returns the user to the root menu after the harness exits. `clients.Launch` runs the binary in the foreground through `tea.ExecProcess`, so the TUI gives up the terminal entirely and takes it back when the child exits, at which point `ExecDoneMsg` triggers a fresh preflight (`internal/tui/tui.go:373`).
+
+### Variant B: config file
+
+If you wrote a config file in Step 8, use this instead of variant A. It is the same function with the `env` map built from the config path and the cleanup closure threaded into the `LaunchSpec`. Do not paste it below variant A — a second `env := ...` in the same function is a compile error (`no new variables on left side of :=`), and an unused `cleanup` is another (`declared and not used: cleanup`).
+
+```go
+func (c *Client) launch(g *config.Global, p config.ProviderInfo, model string) menu.Result {
+ bin := clients.FindBinary(binaryName, c.CommonPaths())
+ if bin == "" {
+ bin = binaryName
+ }
+
+ configPath, cleanup, err := writeProviderConfig(g.ApertureHost, p)
+ if err != nil {
+ return errorResult("Failed to write config: " + err.Error())
+ }
+
+ env := map[string]string{
+ "": configPath,
+ }
+ if model != "" {
+ env[""] = stripProviderPrefix(model)
+ }
+
+ var args []string
+ if g.Settings.YoloMode {
+ args = append(args, "")
+ }
+
+ _ = g.RecordLaunch(config.LaunchState{
+ LastClientName: name,
+ LastBackendType: "",
+ LastProviderID: p.ID,
+ LastModel: model,
+ })
+
+ cmd := clients.Launch(clients.LaunchSpec{
+ Binary: bin,
+ Args: args,
+ Env: env,
+ Cleanup: cleanup,
+ Debug: g.Debug,
+ })
+ return menu.Result{Cmd: cmd, PopOnDone: true}
+}
+```
+
+`Cleanup: cleanup` is the field to not forget; without it every launch leaves a config file behind. Whether your harness also needs a base URL and API key in the environment depends on its contract: some read everything from the config file, others still want the URL in both places. Set whichever Step 1 told you it reads, and note that if you end up needing none of `strings.TrimRight`, `stripProviderPrefix`, or anything else from `strings`, Go will reject the now-unused import.
+
+### Verify either variant
+
+The build should now be clean again, because `launch` exists and every menu closure can reach it.
+
+```bash
+go build ./... && go test ./internal/clients//
+```
+
+The build should print nothing and the tests should report `ok`. Now test your environment construction the way Copilot's tests do in `TestBuildEnv_OpenAIChat` at `internal/clients/copilot/copilot_test.go:11`, by pulling the environment building out into its own `buildEnv` function and asserting on the map it returns. That refactor is worth doing precisely because it makes the routing testable without launching anything. If your harness is configured by CLI flags rather than environment variables, do the same with an `apertureLaunchConfig`-style function returning both, as Codex's `TestApertureLaunchConfig` covers at `internal/clients/codex/codex_test.go:39`.
+
+## Step 10: Implement replay and quick select
+
+The root menu offers a `[0]` row that re-runs the user's last session in one keystroke. `Replay` decides whether your client can honor that, and `QuickSelectLabel` describes it.
+
+One check happens before your code runs. `quickSelect` at `internal/tui/menus.go:88` compares the recorded `LastEndpointURL` and `LastBridgeID` against the active endpoint, and offers no replay unless they match and that endpoint is still configured. Launch state written before those fields existed identifies no endpoint, so it is never replayed rather than being silently re-run against whichever endpoint happens to be active. Your `Replay` therefore only ever sees a record made against the current endpoint, and does not need to check the host itself.
+
+Add both methods, plus the `"slices"` import that the model check needs.
+
+```go
+// Replay implements clients.Client.
+func (c *Client) Replay(g *config.Global) tea.Cmd {
+ if g.LastLaunch.LastClientName != name || !c.IsInstalled() {
+ return nil
+ }
+ prov, ok := g.Provider(g.LastLaunch.LastProviderID)
+ if !ok {
+ return nil
+ }
+ if !providerMatches(prov) {
+ return nil
+ }
+ model := g.LastLaunch.LastModel
+ if model != "" && !slices.Contains(fqnModels(prov), model) {
+ return nil
+ }
+ res := c.launch(g, prov, model)
+ return res.Cmd
+}
+
+// QuickSelectLabel implements clients.Client.
+func (c *Client) QuickSelectLabel(g *config.Global) string {
+ prov, _ := g.Provider(g.LastLaunch.LastProviderID)
+ label := name + " via " + prov.DisplayName()
+ if g.LastLaunch.LastModel != "" {
+ label += " - " + g.LastLaunch.LastModel
+ }
+ return label
+}
+```
+
+`Replay` is a chain of staleness checks, and returning `nil` from any of them means "I cannot replay this", which is normal rather than an error. Check all four things. The launch record must name your client, or another client owns it. The binary must still be installed, since the user may have removed it. The provider must still exist in the freshly fetched list, since the endpoint's configuration may have changed. The recorded model must still be offered by that provider, since model lists change often. If you added a backend step, also confirm the recorded backend ID still exists and that the provider still serves its endpoint, as at `internal/clients/copilot/copilot.go:203`.
+
+Two subtleties are worth knowing. `Replay` returns the `tea.Cmd` from inside the `menu.Result` rather than the `Result` itself, because the root menu wraps it in a fresh `Result` at `internal/tui/menus.go:43`. And `QuickSelectLabel` is only ever called after `Replay` returned non-nil, which is why ignoring the `ok` from `g.Provider` is safe there. On a zero `ProviderInfo`, `DisplayName()` returns an empty string rather than panicking. Note that the TUI appends the endpoint label to whatever you return, so do not name the endpoint yourself.
+
+Verify with a test in the style of `TestReplay_StaleProvider` at `internal/clients/codex/codex_test.go:206`, added to your existing test file.
+
+```go
+func TestReplay_NotReplayable(t *testing.T) {
+ c := &Client{}
+ g := &config.Global{
+ LastLaunch: config.LaunchState{
+ LastClientName: name,
+ LastProviderID: "missing",
+ },
+ }
+ // Returns nil on the first failing check. On a machine without the
+ // harness installed that is !IsInstalled(), so this asserts "does not
+ // replay" rather than "rejects a stale provider" specifically.
+ if cmd := c.Replay(g); cmd != nil {
+ t.Error("Replay should return nil when the launch cannot be replayed")
+ }
+}
+```
+
+Be clear about what that test does and does not prove. `Replay` returns `nil` at the first check that fails, and `!c.IsInstalled()` comes before the provider lookup. On any machine where your harness is not installed — including CI, which installs no agents — this test passes without ever reaching the provider check, and it would keep passing if you deleted that check entirely. The codex test it is modeled on says as much in a comment at `internal/clients/codex/codex_test.go:214`.
+
+If you want real coverage of the staleness logic, test the parts that do not depend on the binary being present. `providerMatches` and `fqnModels` are both unexported and directly callable, and between them they decide three of the four checks:
+
+```go
+func TestReplayStalenessChecks(t *testing.T) {
+ prov := config.ProviderInfo{
+ ID: "openai",
+ Models: []string{"gpt-5"},
+ SupportedEndpoints: map[string]bool{: true},
+ }
+ if !providerMatches(prov) {
+ t.Error("provider serving our endpoint should match")
+ }
+ if providerMatches(config.ProviderInfo{
+ SupportedEndpoints: map[string]bool{"/unknown": true},
+ }) {
+ t.Error("provider not serving our endpoint should not match")
+ }
+ if got := fqnModels(prov); len(got) != 1 || got[0] != "openai/gpt-5" {
+ t.Errorf("fqnModels = %v, want [openai/gpt-5]", got)
+ }
+ // A recorded model that the provider no longer lists is what makes
+ // Replay bail on the model check.
+ if slices.Contains(fqnModels(prov), "openai/gpt-4") {
+ t.Error("stale model should not be found in the current model list")
+ }
+}
+```
+
+Your type now has every method the interface requires, so check the build, the vet pass, and the tests together.
+
+```bash
+go build ./... && go vet ./... && go test ./internal/clients//
+```
+
+The first two should produce no output and the tests should report `ok`. To confirm you really did satisfy the interface, which nothing has actually asserted yet, add this line to your main file.
+
+```go
+var _ clients.Client = (*Client)(nil)
+```
+
+That is a compile-time assertion: if any method is missing or has the wrong signature, `go build` names it. Step 11 replaces the need for it with the real `init()`, but it is a faster way to find a typo in a method signature right now. If the build reports a missing method, compare your method set against the interface at `internal/clients/registry.go:16` and check that every receiver is `*Client` rather than `Client`.
+
+## Step 11: Register the client, test it, and update the README
+
+Your package compiles and satisfies the interface, but the launcher still does not know it exists, because nothing registers it and nothing links it into the binary.
+
+First add the registration hook to your main file, just below the `Client` type. If you added the `var _ clients.Client` assertion in Step 10, delete it now, since `Register` does the same job.
+
+```go
+func init() {
+ clients.Register(&Client{})
+}
+```
+
+Then open `cmd/aperture/main.go` and add your package to the side-effect import block that starts at line 20. Insert the line in its correct alphabetical position, not at the end of the block. The example below shows where a package named `nimbus` would go.
+
+```go
+ // Side-effect imports register each client with internal/clients.
+ _ "github.com/tailscale/aperture-cli/internal/clients/claudecode"
+ _ "github.com/tailscale/aperture-cli/internal/clients/codex"
+ _ "github.com/tailscale/aperture-cli/internal/clients/copilot"
+ _ "github.com/tailscale/aperture-cli/internal/clients/gemini"
+ _ "github.com/tailscale/aperture-cli/internal/clients/hermes"
+ _ "github.com/tailscale/aperture-cli/internal/clients/nimbus"
+ _ "github.com/tailscale/aperture-cli/internal/clients/omp"
+ _ "github.com/tailscale/aperture-cli/internal/clients/opencode"
+ _ "github.com/tailscale/aperture-cli/internal/clients/pi"
+```
+
+Alphabetical order is not a style preference here, it is what gofmt enforces. gofmt sorts the paths within an import block, so appending your line at the end leaves the file unformatted and fails the CI formatting gate described below. If you are unsure where the line goes, put it anywhere and run `gofmt -w cmd/aperture/main.go` to have it moved for you.
+
+The blank identifier import exists purely to run your `init()`, which calls `clients.Register`. Registration order is display order in the menu, per the comment on `Register` at `internal/clients/registry.go:83`, and registration order follows the order of the imports in this block. That means your client's position in the menu is decided by where your package name sorts alphabetically, and you cannot change it by moving the import line: gofmt will sort it straight back, and leaving it out of order fails CI. Your client will appear between the packages that alphabetically surround it. If a client ever genuinely needs a different position, that calls for an explicit ordering mechanism in `internal/clients`, not a hand-ordered import block.
+
+From here on, a missing or misnamed interface method breaks the build rather than showing up as a missing menu row, which is exactly what you want.
+
+Next, finish your tests. Aim to cover the environment or config your client produces for each protocol it supports, the provider filter, the backend filter if you have one, the install and uninstall hints, and at least one `Replay` staleness path. Every existing client package covers roughly that set, and `internal/clients/claudecode/claudecode_test.go` is the most thorough example. Test the unexported helpers directly, in the same package, rather than trying to drive the TUI.
+
+Run the checks CI runs. The formatting check is not advisory: the Linux CI job fails the build if `gofmt -l .` prints anything.
+
+```bash
+gofmt -l . && make test && make build
+```
+
+`gofmt -l .` should print nothing at all. `make test` should report `ok` for every package including yours. If `gofmt` lists files, run `gofmt -w .` and re-check.
+
+Now test the real thing by launching the built binary.
+
+```bash
+./.build/aperture
+```
+
+Your client should appear in the root menu if the harness is installed, or under `[i] Install agents` if it is not. Select it, pick a provider and model, and confirm the harness starts and can complete a request through Aperture. Then quit the harness, confirm you land back on the root menu, and check that `[0] Quick select` now names your client. Re-run with the debug flag to see exactly what you are setting.
+
+```bash
+./.build/aperture -debug
+```
+
+That prints the resolved environment and arguments to stderr before exec, which is the fastest way to spot a wrong variable name or a doubled slash in the URL.
+
+Finally, add your harness to the `Supported agents` list in `README.md`, with a link to its documentation, so the list stays accurate. Then commit. The repository's commit messages lead with the touched paths, as in `internal/profiles: add z.ai backend for Claude Code with fixed models (#14)`, so a message like `internal/clients: add client` fits the house style.
+
+## Troubleshooting
+
+These are the failure modes you are most likely to hit, roughly in order of how often they come up.
+
+### The client does not appear in the root menu
+
+First check whether the launcher thinks the harness is uninstalled. Press `i` for `Install agents` and look for your client's name there. If it is in that list, your discovery logic is the problem rather than your registration, so re-read Step 4 and confirm `binaryName` exactly matches the executable name and that your `commonBinaryPaths` entries are full paths to the binary rather than directories.
+
+If it appears in neither list, your package is not linked into the binary. Confirm your import line is present in `cmd/aperture/main.go` and that it uses the blank identifier. A normal import of a package you never reference will not compile, and a missing import produces no error at all, which is why this failure is silent.
+
+Then confirm your `init()` function actually calls `clients.Register(&Client{})`, and that you are running a freshly built binary. Run `make build` again and use `./.build/aperture` rather than an `aperture` on your `$PATH` from an earlier `make install`.
+
+One more silent case: the root menu skips any installed client whose `Menu()` returns a `MenuItem` with a nil `Action`, at `internal/tui/menus.go:49`. If your client registers and is installed but still never appears, confirm `Menu` sets `Action`.
+
+### The launcher says no providers support your client
+
+Your endpoint path does not match anything the endpoint offers. Fetch the model list directly and look at the paths it actually advertises.
+
+```bash
+curl -s -H 'User-Agent: aperture-cli' /v1/models |
+ python3 -c 'import json,sys; print(sorted({e for m in json.load(sys.stdin)["data"] for e in (m.get("supported_endpoints") or [])}))'
+```
+
+Compare that set against the constant your code passes to `SupportsEndpoint`. Because the constants are centrally defined in `internal/config/providers.go`, a typo in the path itself is a compile error rather than a silent mismatch — so the likely cause is a genuine gap rather than a spelling slip. If no provider serves your path, the client is behaving correctly, so choose a different protocol your harness also speaks or configure the provider in Aperture.
+
+If the response is empty or the request fails, the problem is connectivity rather than protocol support, and the launcher's own preflight would have shown you its setup guide before you got this far.
+
+### The install finishes but the launcher reports it failed
+
+The installer exited zero and the TUI still could not find your binary afterwards. That message comes from `installDoneMsg`, and it is usually correct: many installers print an error and exit clean. Run your `Install.Hint` by hand and check where the binary landed.
+
+If it landed somewhere real, your discovery is at fault rather than the install, so add that location to `commonBinaryPaths` as a full path to the binary. If the install genuinely failed silently and your command contains a pipe, you are missing `bash -o pipefail -c`. Do not reach for `SkipInstalledCheck` to quiet the message unless your `Run` really cannot complete an install on its own.
+
+### The harness starts but every request fails
+
+Run the launcher with `-debug` and read the environment it printed. Check the base URL first, looking for a missing or doubled `/v1`, a doubled slash from an untrimmed host, or a trailing slash the harness does not tolerate.
+
+Next check whether the model name still carries its provider prefix. If your debug output shows something like `openai/gpt-5` where the harness expects `gpt-5`, you are missing a `stripProviderPrefix` call, and path-based routing will produce a confusing 404 rather than a clear error.
+
+If the failure looks like an authentication error, your placeholder API key value may not satisfy the harness's validation. Try the other conventions used in this repo, which are `not-needed`, `not-required`, and a bare `-`.
+
+If the harness reads a config file, confirm the file exists and contains what you expect while the harness is running. Add a temporary `fmt.Fprintln(os.Stderr, configPath)` in `launch`, or comment out the cleanup closure so the file survives the exit, then inspect it.
+
+One case is specific to Gemini CLI and may apply to your harness too. Gemini CLI rejects base URLs that are not HTTPS with a fully-qualified domain name, so the default `http://ai` endpoint cannot work with it. The client blocks the launch with an explanation rather than letting the harness fail confusingly, in `validateHost` at `internal/clients/gemini/gemini.go:256`. It is called first thing in `launch`, before any binary lookup or config write. If your harness validates URLs similarly, copy that approach.
+
+### The quick select row never appears
+
+Check the endpoint gate before your own checks, because it runs first and your `Replay` is never called when it fails. The row is suppressed if `lastEndpointUrl` in the saved state does not match the active endpoint, or if that endpoint is no longer in your configured list. Switching endpoints between sessions is therefore expected to hide the row, and so is a record written before those fields existed.
+
+If the endpoint does match, `Replay` is returning `nil`. Work through its four checks in order. Confirm the `name` constant you compare against `LastClientName` is byte-identical to the one you pass to `RecordLaunch`, since a display name that changed between the two will never match. Confirm the binary is still installed. Confirm the recorded provider ID is still in the fetched list. Confirm the recorded model is still in `fqnModels(prov)`.
+
+Read the persisted record directly to see what was actually stored. On macOS the file is here.
+
+```bash
+cat ~/Library/Application\ Support/aperture/launcher.json
+```
+
+On Linux it is at `~/.config/aperture/launcher.json` instead, or under `$XDG_CONFIG_HOME` if you have set that. Both paths come from `os.UserConfigDir()` in `statePath` at `internal/config/state.go:21`.
+
+```bash
+cat ~/.config/aperture/launcher.json
+```
+
+Compare the `lastClientName`, `lastProviderId`, `lastModel`, and `lastEndpointUrl` values in that JSON against what your checks expect. If the file is missing entirely, `RecordLaunch` never succeeded, so confirm you are calling it inside `launch`. If `lastEndpointUrl` is absent, the record predates endpoint tracking and will never replay; launch once more to rewrite it.
+
+### CI fails on formatting
+
+The Linux CI job runs `gofmt -l .` and fails if it prints any filename. Run `gofmt -w .` from the repository root and commit the result. This catches people who write Go without a formatting editor hook, and it is the single most common CI failure in this repository.
+
+### A temporary config file is left behind after the harness exits
+
+You built a cleanup closure but did not pass it through. Confirm you set `Cleanup: cleanup` on the `clients.LaunchSpec` in `launch`. The TUI calls it after the child process exits, at `internal/clients/launch.go:61`, and it is never called if the field is nil.
+
+Note that cleanup does not run if the launcher itself is killed mid-session, so treat the file as best-effort and never put a real secret in it.
+
+## Security notes
+
+The launcher deliberately writes placeholder credentials rather than real ones. Aperture authenticates the caller itself over Tailscale, so the harness's own API key check has nothing to validate. That is why you see literal strings such as `not-needed`, `not-required`, and `-` throughout the client packages. Keep using placeholders, and never add a code path that reads a real API key from the user's environment and forwards it, because that would move a live credential into a config file the launcher writes.
+
+Set file permissions the way the existing code does. Config files are written with mode `0o600` and directories created with `0o700`, so nothing you write is readable by other users on a shared machine. `config.ClientConfigDir` already creates its directory with `0o700`, so use it rather than calling `os.MkdirAll` yourself, and pass `0o600` to every `os.WriteFile`.
+
+Remember that the config files you write contain the user's Aperture endpoint URL, which reveals a tailnet hostname. That is not a credential, but it is not something to leave lying around either, which is the practical reason the per-launch cleanup closure exists. Cleanup is best-effort and will not run if the launcher is killed, so do not rely on it to protect anything that actually matters.
+
+Be careful with the `-debug` flag. It dumps the full resolved environment to stderr before exec, at `internal/clients/launch.go:44`, so anything you put in the `Env` map ends up in the user's terminal scrollback and in any log they paste into a bug report. Since the values are placeholders this is safe today, and it stays safe only as long as you keep real secrets out of that map.
+
+Finally, keep real endpoints out of your tests. The existing test files use a `testHost` constant set to `http://ai.example.com`, and they redirect `HOME` and `XDG_CONFIG_HOME` to a `t.TempDir()` so nothing touches the real config directory. Copy both habits. A test that writes to your actual `~/.config` will eventually corrupt someone's working setup, and a committed internal hostname is a small but needless disclosure.
diff --git a/internal/clients/hermes/hermes.go b/internal/clients/hermes/hermes.go
new file mode 100644
index 0000000..0d7b840
--- /dev/null
+++ b/internal/clients/hermes/hermes.go
@@ -0,0 +1,212 @@
+// Package hermes is the Hermes Agent client. Hermes speaks OpenAI Chat
+// Completions and accepts a custom endpoint through CUSTOM_BASE_URL, so this
+// client configures routing entirely through environment variables and a
+// provider argument without replacing the user's Hermes configuration.
+package hermes
+
+import (
+ "os/exec"
+ "slices"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/tailscale/aperture-cli/internal/clients"
+ "github.com/tailscale/aperture-cli/internal/config"
+ "github.com/tailscale/aperture-cli/internal/menu"
+)
+
+func init() {
+ clients.Register(&Client{})
+}
+
+// Client is the Hermes Agent client.
+type Client struct{}
+
+const (
+ name = "Hermes Agent"
+ binaryName = "hermes"
+
+ // backendType identifies Hermes' single backend in persisted launch state.
+ // Users have this string in their state.json, so it must not change.
+ backendType = "openai_chat"
+
+ installCmd = "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
+ uninstallCmd = "hermes uninstall --yes"
+)
+
+// Name implements clients.Client.
+func (c *Client) Name() string { return name }
+
+// BinaryName implements clients.Client.
+func (c *Client) BinaryName() string { return binaryName }
+
+// CommonPaths implements clients.Client.
+func (c *Client) CommonPaths() []string { return commonBinaryPaths() }
+
+// IsInstalled implements clients.Client.
+func (c *Client) IsInstalled() bool { return clients.IsInstalled(binaryName, c.CommonPaths()) }
+
+// Install implements clients.Client.
+func (c *Client) Install(_ *config.Global) clients.InstallPlan {
+ return clients.InstallPlan{
+ Hint: installCmd,
+ Run: func() (*exec.Cmd, error) {
+ return exec.Command("bash", "-o", "pipefail", "-c", installCmd), nil
+ },
+ }
+}
+
+// Uninstall implements clients.Client.
+func (c *Client) Uninstall() clients.UninstallPlan {
+ return clients.UninstallPlan{
+ Hint: uninstallCmd,
+ Run: func() error {
+ return exec.Command(binaryName, "uninstall", "--yes").Run()
+ },
+ }
+}
+
+// Menu implements clients.Client.
+func (c *Client) Menu(g *config.Global) menu.MenuItem {
+ return menu.MenuItem{Label: name, Action: func() menu.Result { return c.providerStep(g) }}
+}
+
+func (c *Client) providerStep(g *config.Global) menu.Result {
+ provs := compatibleProviders(g.Providers)
+ if len(provs) == 0 {
+ return errorResult("No providers support " + name + ".")
+ }
+ if len(provs) == 1 {
+ return c.modelStep(g, provs[0])
+ }
+ items := make([]menu.MenuItem, 0, len(provs))
+ for _, p := range provs {
+ items = append(items, menu.MenuItem{
+ Label: p.DisplayName(), Description: p.Description,
+ Action: func() menu.Result { return c.modelStep(g, p) },
+ })
+ }
+ return menu.Result{Next: &menu.Menu{Title: "Choose a provider for " + name + ":", Items: items}}
+}
+
+func (c *Client) modelStep(g *config.Global, p config.ProviderInfo) menu.Result {
+ models := fqnModels(p)
+ if len(models) <= 1 {
+ var model string
+ if len(models) == 1 {
+ model = models[0]
+ }
+ return c.launch(g, p, model)
+ }
+ items := make([]menu.MenuItem, 0, len(models))
+ for _, model := range models {
+ items = append(items, menu.MenuItem{Label: model, Action: func() menu.Result { return c.launch(g, p, model) }})
+ }
+ return menu.Result{Next: &menu.Menu{Title: "Choose a default model for " + name + " via " + p.DisplayName() + ":", Items: items}}
+}
+
+func (c *Client) launch(g *config.Global, p config.ProviderInfo, model string) menu.Result {
+ bin := clients.FindBinary(binaryName, c.CommonPaths())
+ if bin == "" {
+ bin = binaryName
+ }
+ env := buildEnv(g.ApertureHost, model)
+ args := buildArgs(g.Settings.YoloMode)
+ _ = g.RecordLaunch(config.LaunchState{
+ LastClientName: name, LastBackendType: backendType, LastProviderID: p.ID, LastModel: model,
+ })
+ cmd := clients.Launch(clients.LaunchSpec{Binary: bin, Args: args, Env: env, Debug: g.Debug})
+ return menu.Result{Cmd: cmd, PopOnDone: true}
+}
+
+func buildEnv(apertureHost, model string) map[string]string {
+ env := map[string]string{
+ "CUSTOM_BASE_URL": strings.TrimRight(apertureHost, "/") + "/v1",
+ }
+ if model != "" {
+ env["HERMES_INFERENCE_MODEL"] = stripProviderPrefix(model)
+ }
+ return env
+}
+
+func buildArgs(yolo bool) []string {
+ args := []string{"--provider", "custom"}
+ if yolo {
+ args = append(args, "--yolo")
+ }
+ return args
+}
+
+func resolveReplay(g *config.Global) (config.ProviderInfo, string, bool) {
+ if g.LastLaunch.LastClientName != name || g.LastLaunch.LastBackendType != backendType {
+ return config.ProviderInfo{}, "", false
+ }
+ prov, ok := g.Provider(g.LastLaunch.LastProviderID)
+ if !ok || !providerMatches(prov) {
+ return config.ProviderInfo{}, "", false
+ }
+ model := g.LastLaunch.LastModel
+ if model != "" && !slices.Contains(fqnModels(prov), model) {
+ return config.ProviderInfo{}, "", false
+ }
+ return prov, model, true
+}
+
+// Replay implements clients.Client.
+func (c *Client) Replay(g *config.Global) tea.Cmd {
+ if !c.IsInstalled() {
+ return nil
+ }
+ prov, model, ok := resolveReplay(g)
+ if !ok {
+ return nil
+ }
+ return c.launch(g, prov, model).Cmd
+}
+
+// QuickSelectLabel implements clients.Client.
+func (c *Client) QuickSelectLabel(g *config.Global) string {
+ prov, _ := g.Provider(g.LastLaunch.LastProviderID)
+ label := name + " via " + prov.DisplayName()
+ if g.LastLaunch.LastModel != "" {
+ label += " - " + g.LastLaunch.LastModel
+ }
+ return label
+}
+
+func compatibleProviders(all []config.ProviderInfo) []config.ProviderInfo {
+ var out []config.ProviderInfo
+ for _, p := range all {
+ if providerMatches(p) {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func providerMatches(p config.ProviderInfo) bool {
+ return p.SupportsEndpoint(config.EndpointOpenAIChat)
+}
+
+func fqnModels(p config.ProviderInfo) []string {
+ out := make([]string, len(p.Models))
+ for i, model := range p.Models {
+ out[i] = p.ID + "/" + model
+ }
+ return out
+}
+
+func stripProviderPrefix(fqn string) string {
+ if _, after, ok := strings.Cut(fqn, "/"); ok {
+ return after
+ }
+ return fqn
+}
+
+func errorResult(msg string) menu.Result {
+ return menu.Result{Cmd: func() tea.Msg { return menu.SimpleDoneMsg{Err: errString(msg)} }}
+}
+
+type errString string
+
+func (e errString) Error() string { return string(e) }
diff --git a/internal/clients/hermes/hermes_test.go b/internal/clients/hermes/hermes_test.go
new file mode 100644
index 0000000..60d5d70
--- /dev/null
+++ b/internal/clients/hermes/hermes_test.go
@@ -0,0 +1,104 @@
+package hermes
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/tailscale/aperture-cli/internal/config"
+)
+
+const testHost = "http://ai.example.com"
+
+func TestBuildEnv(t *testing.T) {
+ got := buildEnv(testHost+"/", "provider/model/name")
+ want := map[string]string{
+ "CUSTOM_BASE_URL": testHost + "/v1",
+ "HERMES_INFERENCE_MODEL": "model/name",
+ }
+ if len(got) != len(want) {
+ t.Fatalf("buildEnv = %v, want %v", got, want)
+ }
+ for key, value := range want {
+ if got[key] != value {
+ t.Errorf("buildEnv[%q] = %q, want %q", key, got[key], value)
+ }
+ }
+ if got := buildEnv(testHost, ""); got["HERMES_INFERENCE_MODEL"] != "" {
+ t.Errorf("buildEnv with no model = %v", got)
+ }
+}
+
+func TestBuildArgs(t *testing.T) {
+ if got, want := buildArgs(false), []string{"--provider", "custom"}; !slices.Equal(got, want) {
+ t.Errorf("buildArgs(false) = %v, want %v", got, want)
+ }
+ if got, want := buildArgs(true), []string{"--provider", "custom", "--yolo"}; !slices.Equal(got, want) {
+ t.Errorf("buildArgs(true) = %v, want %v", got, want)
+ }
+}
+
+func TestCompatibleProviders(t *testing.T) {
+ provs := []config.ProviderInfo{
+ {ID: "match", SupportedEndpoints: map[string]bool{config.EndpointOpenAIChat: true}},
+ {ID: "other", SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}},
+ }
+ got := compatibleProviders(provs)
+ if len(got) != 1 || got[0].ID != "match" {
+ t.Errorf("compatibleProviders = %+v", got)
+ }
+}
+
+func TestModels(t *testing.T) {
+ p := config.ProviderInfo{ID: "provider", Models: []string{"model/name"}}
+ if got := fqnModels(p); !slices.Equal(got, []string{"provider/model/name"}) {
+ t.Errorf("fqnModels = %v", got)
+ }
+ if got := stripProviderPrefix("provider/model/name"); got != "model/name" {
+ t.Errorf("stripProviderPrefix = %q", got)
+ }
+}
+
+func TestResolveReplay(t *testing.T) {
+ p := config.ProviderInfo{ID: "provider", Models: []string{"model"}, SupportedEndpoints: map[string]bool{config.EndpointOpenAIChat: true}}
+ g := &config.Global{
+ Providers: []config.ProviderInfo{p},
+ LastLaunch: config.LaunchState{LastClientName: name, LastBackendType: backendType, LastProviderID: p.ID, LastModel: "provider/model"},
+ }
+ _, model, ok := resolveReplay(g)
+ if !ok || model != "provider/model" {
+ t.Fatalf("resolveReplay = %q, %v", model, ok)
+ }
+ g.LastLaunch.LastModel = "provider/stale"
+ if _, _, ok := resolveReplay(g); ok {
+ t.Error("resolveReplay accepted a stale model")
+ }
+ g.LastLaunch.LastModel = "provider/model"
+ g.LastLaunch.LastBackendType = "openai_responses"
+ if _, _, ok := resolveReplay(g); ok {
+ t.Error("resolveReplay accepted a stale backend")
+ }
+}
+
+func TestInstallCommandDetectsPipelineFailures(t *testing.T) {
+ plan := (&Client{}).Install(&config.Global{})
+ cmd, err := plan.Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Equal(cmd.Args, []string{
+ "bash", "-o", "pipefail", "-c",
+ "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash",
+ }) {
+ t.Errorf("install command args = %q, want bash with pipefail", cmd.Args)
+ }
+}
+
+func TestInstallUninstall(t *testing.T) {
+ c := &Client{}
+ if got := c.Install(&config.Global{}); got.Hint != installCmd || got.Run == nil {
+ t.Errorf("Install = %+v", got)
+ }
+ if got := c.Uninstall(); got.Hint != uninstallCmd || got.Run == nil {
+ t.Errorf("Uninstall = %+v", got)
+ }
+}
diff --git a/internal/clients/hermes/install.go b/internal/clients/hermes/install.go
new file mode 100644
index 0000000..bd7806a
--- /dev/null
+++ b/internal/clients/hermes/install.go
@@ -0,0 +1,18 @@
+package hermes
+
+import (
+ "os"
+ "path/filepath"
+)
+
+// commonBinaryPaths returns the non-PATH locations where hermes is commonly installed.
+func commonBinaryPaths() []string {
+ paths := []string{
+ filepath.Join("/usr", "local", "bin", binaryName),
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return paths
+ }
+ return append(paths, filepath.Join(home, ".local", "bin", binaryName))
+}
diff --git a/internal/clients/omp/install.go b/internal/clients/omp/install.go
new file mode 100644
index 0000000..4469f0c
--- /dev/null
+++ b/internal/clients/omp/install.go
@@ -0,0 +1,23 @@
+package omp
+
+import (
+ "os"
+ "path/filepath"
+)
+
+// commonBinaryPaths returns the non-PATH locations where omp is commonly installed.
+func commonBinaryPaths() []string {
+ paths := []string{
+ filepath.Join("/opt", "homebrew", "bin", "omp"),
+ filepath.Join("/usr", "local", "bin", "omp"),
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return paths
+ }
+ return append(paths,
+ filepath.Join(home, ".local", "bin", "omp"),
+ filepath.Join(home, ".bun", "bin", "omp"),
+ filepath.Join(home, ".npm-global", "bin", "omp"),
+ )
+}
diff --git a/internal/clients/omp/omp.go b/internal/clients/omp/omp.go
new file mode 100644
index 0000000..90f4631
--- /dev/null
+++ b/internal/clients/omp/omp.go
@@ -0,0 +1,32 @@
+// Package omp is the Oh My Pi client. OMP is a fork of Pi and accepts custom
+// providers through the same pi.registerProvider extension API, so the client
+// itself lives in internal/clients/pilike and only the differences live here.
+package omp
+
+import (
+ "github.com/tailscale/aperture-cli/internal/clients"
+ "github.com/tailscale/aperture-cli/internal/clients/pilike"
+)
+
+func init() {
+ clients.Register(&pilike.Client{V: variant})
+}
+
+// variant describes Oh My Pi to the shared pi-like client. Name is persisted
+// as LaunchState.LastClientName, so it cannot change without invalidating
+// every user's recorded launch.
+var variant = pilike.Variant{
+ Name: "Oh My Pi",
+ BinaryName: "omp",
+ ConfigDir: "omp",
+ InstallCmd: "bun install -g @oh-my-pi/pi-coding-agent",
+ UninstallArgv: []string{"bun", "uninstall", "-g", "@oh-my-pi/pi-coding-agent"},
+ CommonPaths: commonBinaryPaths,
+
+ // Unlike Pi, OMP prompts before running a tool, and --auto-approve is a
+ // real approval bypass rather than a project-file trust flag, so yolo
+ // mode maps onto it.
+ YoloArgs: []string{"--auto-approve"},
+
+ ExtensionHeader: "// Generated by aperture-cli. Removed when the client exits.\n",
+}
diff --git a/internal/clients/omp/omp_test.go b/internal/clients/omp/omp_test.go
new file mode 100644
index 0000000..22d4e8e
--- /dev/null
+++ b/internal/clients/omp/omp_test.go
@@ -0,0 +1,63 @@
+package omp
+
+import (
+ "path/filepath"
+ "slices"
+ "testing"
+
+ "github.com/tailscale/aperture-cli/internal/clients/pilike"
+)
+
+// The shared behavior is tested in internal/clients/pilike. What is left here
+// is only what Oh My Pi itself decides.
+
+func TestVariantIdentity(t *testing.T) {
+ c := &pilike.Client{V: variant}
+ // Name is persisted as LaunchState.LastClientName; changing it
+ // invalidates every recorded Oh My Pi launch.
+ if got := c.Name(); got != "Oh My Pi" {
+ t.Errorf("Name = %q, want Oh My Pi", got)
+ }
+ if got := c.BinaryName(); got != "omp" {
+ t.Errorf("BinaryName = %q, want omp", got)
+ }
+ if variant.ConfigDir != "omp" {
+ t.Errorf("ConfigDir = %q, want omp", variant.ConfigDir)
+ }
+}
+
+func TestCommonBinaryPaths(t *testing.T) {
+ paths := commonBinaryPaths()
+ if len(paths) == 0 {
+ t.Fatal("commonBinaryPaths is empty")
+ }
+ // CommonPaths must be full paths to the binary, not directories:
+ // FindBinary stats each entry directly.
+ for _, p := range paths {
+ if filepath.Base(p) != "omp" {
+ t.Errorf("CommonPaths entry %q does not end in the binary name", p)
+ }
+ if !filepath.IsAbs(p) {
+ t.Errorf("CommonPaths entry %q is not absolute", p)
+ }
+ }
+}
+
+func TestInstallUninstallCommands(t *testing.T) {
+ const wantInstall = "bun install -g @oh-my-pi/pi-coding-agent"
+ if variant.InstallCmd != wantInstall {
+ t.Errorf("InstallCmd = %q, want %q", variant.InstallCmd, wantInstall)
+ }
+ wantUninstall := []string{"bun", "uninstall", "-g", "@oh-my-pi/pi-coding-agent"}
+ if !slices.Equal(variant.UninstallArgv, wantUninstall) {
+ t.Errorf("UninstallArgv = %v, want %v", variant.UninstallArgv, wantUninstall)
+ }
+}
+
+// TestYoloArgs pins the one place OMP diverges from Pi on permissions.
+// --auto-approve is a real approval bypass here, unlike Pi's --approve.
+func TestYoloArgs(t *testing.T) {
+ if want := []string{"--auto-approve"}; !slices.Equal(variant.YoloArgs, want) {
+ t.Errorf("YoloArgs = %v, want %v", variant.YoloArgs, want)
+ }
+}
diff --git a/internal/clients/pi/install.go b/internal/clients/pi/install.go
new file mode 100644
index 0000000..4da75fd
--- /dev/null
+++ b/internal/clients/pi/install.go
@@ -0,0 +1,25 @@
+package pi
+
+import (
+ "os"
+ "path/filepath"
+)
+
+// commonBinaryPaths returns the non-PATH locations where pi is commonly
+// installed. Homebrew's npm prefix is listed because the pi.dev installer
+// and `npm install -g` both land there on a Homebrew-managed Node, which is
+// not always on PATH in a fresh shell.
+func commonBinaryPaths() []string {
+ paths := []string{
+ filepath.Join("/opt", "homebrew", "bin", "pi"),
+ filepath.Join("/usr", "local", "bin", "pi"),
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return paths
+ }
+ return append(paths,
+ filepath.Join(home, ".pi", "bin", "pi"),
+ filepath.Join(home, ".npm-global", "bin", "pi"),
+ )
+}
diff --git a/internal/clients/pi/pi.go b/internal/clients/pi/pi.go
new file mode 100644
index 0000000..a93be37
--- /dev/null
+++ b/internal/clients/pi/pi.go
@@ -0,0 +1,37 @@
+// Package pi is the Pi coding agent client. Everything Pi shares with the
+// other harnesses built on the same plugin API lives in
+// internal/clients/pilike, including the per-launch extension, the backend
+// table, and the menu flow. Only what makes Pi different from its forks
+// lives here.
+package pi
+
+import (
+ "github.com/tailscale/aperture-cli/internal/clients"
+ "github.com/tailscale/aperture-cli/internal/clients/pilike"
+)
+
+func init() {
+ clients.Register(&pilike.Client{V: variant})
+}
+
+// variant describes Pi to the shared pi-like client. Name is persisted as
+// LaunchState.LastClientName, so it cannot change without invalidating every
+// user's recorded launch.
+var variant = pilike.Variant{
+ Name: "Pi",
+ BinaryName: "pi",
+ ConfigDir: "pi",
+ InstallCmd: "npm install -g --ignore-scripts @earendil-works/pi-coding-agent",
+ UninstallArgv: []string{"npm", "uninstall", "-g", "@earendil-works/pi-coding-agent"},
+ CommonPaths: commonBinaryPaths,
+
+ // No yolo arguments, deliberately. Pi ships no sandbox and never prompts
+ // before running a tool, so it has no skip-permissions flag to pass. Its
+ // --approve/-a flag looks like one but governs whether project-local .pi
+ // files are trusted, which is unrelated to tool approval and not the
+ // user's intent when they enable yolo mode.
+ YoloArgs: nil,
+
+ ExtensionHeader: "// Generated by aperture-cli. Rewritten on every launch and removed\n" +
+ "// when the agent exits; safe to delete.\n",
+}
diff --git a/internal/clients/pi/pi_test.go b/internal/clients/pi/pi_test.go
new file mode 100644
index 0000000..2f1f8ce
--- /dev/null
+++ b/internal/clients/pi/pi_test.go
@@ -0,0 +1,64 @@
+package pi
+
+import (
+ "path/filepath"
+ "slices"
+ "testing"
+
+ "github.com/tailscale/aperture-cli/internal/clients/pilike"
+)
+
+// The shared behavior is tested in internal/clients/pilike. What is left here
+// is only what Pi itself decides.
+
+func TestVariantIdentity(t *testing.T) {
+ c := &pilike.Client{V: variant}
+ // Name is persisted as LaunchState.LastClientName; changing it
+ // invalidates every recorded Pi launch.
+ if got := c.Name(); got != "Pi" {
+ t.Errorf("Name = %q, want Pi", got)
+ }
+ if got := c.BinaryName(); got != "pi" {
+ t.Errorf("BinaryName = %q, want pi", got)
+ }
+ if variant.ConfigDir != "pi" {
+ t.Errorf("ConfigDir = %q, want pi", variant.ConfigDir)
+ }
+}
+
+func TestCommonBinaryPaths(t *testing.T) {
+ paths := commonBinaryPaths()
+ if len(paths) == 0 {
+ t.Fatal("commonBinaryPaths is empty")
+ }
+ // CommonPaths must be full paths to the binary, not directories:
+ // FindBinary stats each entry directly.
+ for _, p := range paths {
+ if filepath.Base(p) != "pi" {
+ t.Errorf("CommonPaths entry %q does not end in the binary name", p)
+ }
+ if !filepath.IsAbs(p) {
+ t.Errorf("CommonPaths entry %q is not absolute", p)
+ }
+ }
+}
+
+func TestInstallUninstallCommands(t *testing.T) {
+ const wantInstall = "npm install -g --ignore-scripts @earendil-works/pi-coding-agent"
+ if variant.InstallCmd != wantInstall {
+ t.Errorf("InstallCmd = %q, want %q", variant.InstallCmd, wantInstall)
+ }
+ wantUninstall := []string{"npm", "uninstall", "-g", "@earendil-works/pi-coding-agent"}
+ if !slices.Equal(variant.UninstallArgv, wantUninstall) {
+ t.Errorf("UninstallArgv = %v, want %v", variant.UninstallArgv, wantUninstall)
+ }
+}
+
+// TestNoYoloArgs is the assertion behind the comment on the field. Pi has no
+// permission prompts, so nothing here should look like an approval bypass:
+// --approve is project-file trust, not tool approval.
+func TestNoYoloArgs(t *testing.T) {
+ if len(variant.YoloArgs) != 0 {
+ t.Errorf("YoloArgs = %v, want none; Pi has no approval flag to skip", variant.YoloArgs)
+ }
+}
diff --git a/internal/clients/pilike/contract_test.go b/internal/clients/pilike/contract_test.go
new file mode 100644
index 0000000..18d5a6e
--- /dev/null
+++ b/internal/clients/pilike/contract_test.go
@@ -0,0 +1,30 @@
+package pilike_test
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/tailscale/aperture-cli/internal/clients"
+ _ "github.com/tailscale/aperture-cli/internal/clients/omp"
+ _ "github.com/tailscale/aperture-cli/internal/clients/pi"
+ "github.com/tailscale/aperture-cli/internal/config"
+)
+
+// TestRegisteredClientNamesAreStable pins the other half of the
+// persisted-state contract. A client's display name is written to state.json
+// as LaunchState.LastClientName and compared against on replay, so renaming
+// one makes every recorded launch for it unreplayable.
+//
+// It lives in the external test package because the names belong to the thin
+// client packages, which import pilike.
+func TestRegisteredClientNamesAreStable(t *testing.T) {
+ var got []string
+ for _, c := range clients.All(&config.Global{}) {
+ got = append(got, c.Name())
+ }
+ for _, want := range []string{"Pi", "Oh My Pi"} {
+ if !slices.Contains(got, want) {
+ t.Errorf("registered clients = %v, missing %q", got, want)
+ }
+ }
+}
diff --git a/internal/clients/pilike/extension.go b/internal/clients/pilike/extension.go
new file mode 100644
index 0000000..a0165f7
--- /dev/null
+++ b/internal/clients/pilike/extension.go
@@ -0,0 +1,204 @@
+package pilike
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/tailscale/aperture-cli/internal/config"
+)
+
+// provider is the provider-config form accepted by the harness's
+// pi.registerProvider(id, config) extension API. Field names match pi's
+// documented models.json / registerProvider schema.
+type provider struct {
+ Name string `json:"name"`
+ BaseURL string `json:"baseUrl"`
+ APIKey string `json:"apiKey"`
+ API string `json:"api"`
+ Models []model `json:"models"`
+}
+
+// model is one entry in a provider's model list.
+//
+// Every field must be populated. Unlike the models.json path, which fills in
+// defaults for a partial model definition, a provider registered from an
+// extension is used as given: an omitted maxTokens reaches the endpoint as a
+// literal null and Anthropic rejects the request with "max_tokens: expected
+// number, received null". An omitted input list crashes the harness outright,
+// because its --list-models formatter dereferences it without a nil check.
+//
+// reasoning stays false because GET /v1/models reports no thinking
+// capability, and claiming it makes the harness send parameters the model may
+// reject.
+type model struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Reasoning bool `json:"reasoning"`
+ Input []string `json:"input"`
+ ContextWindow int `json:"contextWindow"`
+ MaxTokens int `json:"maxTokens"`
+ Cost cost `json:"cost"`
+}
+
+// Aperture's provider list carries no token metadata, so every model gets
+// the harness's own documented defaults rather than invented per-model
+// numbers. Users who need different limits can override them per model in
+// models.json.
+// provider.
+const (
+ defaultContextWindow = 128000
+ defaultMaxTokens = 16384
+)
+
+// cost is the harness's per-million-token rate block. Aperture reports no
+// pricing, so every rate is zero.
+type cost struct {
+ Input float64 `json:"input"`
+ Output float64 `json:"output"`
+ CacheRead float64 `json:"cacheRead"`
+ CacheWrite float64 `json:"cacheWrite"`
+}
+
+// namespacedProviderID namespaces the Aperture provider ID so registering it
+// can never overwrite one of the harness's built-in providers (the harness
+// merges a registration into a same-named built-in, which would silently
+// retarget the user's own "anthropic" or "openai" models at Aperture).
+func namespacedProviderID(providerID string) string {
+ return "aperture-" + providerID
+}
+
+// modelRef is the "provider/model" reference the harness's --model flag
+// expects, built from the namespaced provider ID and a bare model ID.
+func modelRef(providerID, m string) string {
+ return namespacedProviderID(providerID) + "/" + stripProviderPrefix(m)
+}
+
+// baseURL returns the endpoint the harness should call for this backend. The
+// suffix differs per wire protocol: OpenAI-style APIs are rooted at /v1,
+// Anthropic takes the bare host because the harness appends /v1/messages
+// itself, and Vertex needs the project-scoped publisher path that Aperture's
+// router matches.
+func (b backend) baseURL(apertureHost string) string {
+ host := strings.TrimRight(apertureHost, "/")
+ switch b.id {
+ case "anthropic":
+ return host
+ case "vertex":
+ // The magic _aperture_auto_*_ placeholders are rewritten upstream,
+ // as in internal/clients/opencode/sdk.go.
+ return host + "/v1/projects/_aperture_auto_vertex_project_id_/locations/_aperture_auto_vertex_region_/publishers/google"
+ default:
+ return host + "/v1"
+ }
+}
+
+// buildProvider assembles the harness's provider config for one Aperture
+// provider routed over the given backend.
+func buildProvider(apertureHost string, p config.ProviderInfo, b backend) provider {
+ models := make([]model, len(p.Models))
+ for i, m := range p.Models {
+ models[i] = model{
+ ID: m,
+ Name: m,
+ Input: []string{"text"},
+ Reasoning: false,
+ ContextWindow: defaultContextWindow,
+ MaxTokens: defaultMaxTokens,
+ }
+ }
+ return provider{
+ Name: "Aperture (" + p.ID + ")",
+ BaseURL: b.baseURL(apertureHost),
+ APIKey: "not-needed",
+ API: b.api,
+ Models: models,
+ }
+}
+
+// extensionSource renders the JavaScript extension the harness loads with -e.
+// The provider config is emitted as marshaled JSON so no value needs hand
+// escaping.
+func extensionSource(v Variant, providerID string, prov provider) (string, error) {
+ id, err := json.Marshal(namespacedProviderID(providerID))
+ if err != nil {
+ return "", err
+ }
+ cfg, err := json.MarshalIndent(prov, " ", " ")
+ if err != nil {
+ return "", err
+ }
+ return v.ExtensionHeader +
+ "export default function (pi) {\n" +
+ " pi.registerProvider(" + string(id) + ", " + string(cfg) + ");\n" +
+ "}\n", nil
+}
+
+// extensionGlob matches every extension this package has ever written into a
+// client config directory. os.CreateTemp fills the * with a unique suffix.
+const extensionGlob = "tmp_aperture_provider_*.js"
+
+// sweepOrphanedExtensions removes extensions left behind by earlier launches.
+// The cleanup function returned by writeProviderExtension only runs when the
+// child exits normally, so a crash, a kill, or a panic strands the file, and
+// each one embeds the tailnet hostname.
+//
+// Removing a file that a running launch is still using is safe: the harness
+// reads the extension once at startup and never reopens it, so unlinking it
+// afterwards does not affect that process. Removal errors are ignored for the
+// same reason they are unlikely to matter — a file another process holds open
+// is not a reason to fail this launch.
+func sweepOrphanedExtensions(dir string) {
+ matches, err := filepath.Glob(filepath.Join(dir, extensionGlob))
+ if err != nil {
+ return
+ }
+ for _, m := range matches {
+ _ = os.Remove(m)
+ }
+}
+
+// writeProviderExtension writes the per-launch extension and returns its
+// path plus a cleanup function that removes the file. Extensions stranded by
+// an earlier launch are swept first; see sweepOrphanedExtensions.
+//
+// Routing the harness through an extension rather than its own models.json is
+// deliberate. The harness reads models.json from the directory named by
+// PI_CODING_AGENT_DIR, but that same directory also roots settings.json,
+// auth.json, sessions, themes, and extensions. Redirecting it would hide the
+// user's saved logins, settings, and session history — breaking --continue
+// and --resume — so instead we inject the provider for one run and leave
+// the harness's own config directory untouched.
+func writeProviderExtension(v Variant, apertureHost string, p config.ProviderInfo, b backend) (string, func(), error) {
+ src, err := extensionSource(v, p.ID, buildProvider(apertureHost, p, b))
+ if err != nil {
+ return "", nil, err
+ }
+ dir, err := config.ClientConfigDir(v.ConfigDir)
+ if err != nil {
+ return "", nil, err
+ }
+ sweepOrphanedExtensions(dir)
+ f, err := os.CreateTemp(dir, extensionGlob)
+ if err != nil {
+ return "", nil, err
+ }
+ path := f.Name()
+ remove := func() { _ = os.Remove(path) }
+ if err := f.Chmod(0o600); err != nil {
+ _ = f.Close()
+ remove()
+ return "", nil, err
+ }
+ if _, err := f.WriteString(src); err != nil {
+ _ = f.Close()
+ remove()
+ return "", nil, err
+ }
+ if err := f.Close(); err != nil {
+ remove()
+ return "", nil, err
+ }
+ return path, remove, nil
+}
diff --git a/internal/clients/pilike/pilike.go b/internal/clients/pilike/pilike.go
new file mode 100644
index 0000000..4b7e059
--- /dev/null
+++ b/internal/clients/pilike/pilike.go
@@ -0,0 +1,390 @@
+// Package pilike is the shared client for harnesses built on the Pi coding
+// agent's plugin API. Pi and its forks have no environment variable for a
+// custom API base URL — routing is expressed as a provider definition, either
+// in the harness's own models.json or through an extension that calls
+// pi.registerProvider(). This client writes a per-launch extension and loads
+// it with `-e`, which leaves the user's own config directory (settings,
+// logins, session history) untouched. See extension.go for why.
+//
+// These harnesses speak four wire protocols that Aperture serves: OpenAI Chat
+// Completions, OpenAI Responses, Anthropic Messages, and Google Generative
+// AI (Vertex), so the menu flow is provider, then backend, then model.
+//
+// Everything that differs between one harness and another lives in Variant.
+// A harness that shares this plugin API belongs here as a Variant rather than
+// as a forked copy of the package.
+package pilike
+
+import (
+ "os/exec"
+ "slices"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/tailscale/aperture-cli/internal/clients"
+ "github.com/tailscale/aperture-cli/internal/config"
+ "github.com/tailscale/aperture-cli/internal/menu"
+)
+
+// Variant is everything that differs between one pi-like harness and
+// another. Every other part of the client is shared.
+type Variant struct {
+ // Name is the user-visible display name. It is also the value persisted
+ // as LaunchState.LastClientName, so changing it invalidates a user's
+ // recorded launch.
+ Name string
+ // BinaryName is the executable name looked up on PATH and under
+ // CommonPaths.
+ BinaryName string
+ // ConfigDir is the per-client directory name passed to
+ // config.ClientConfigDir, where the per-launch extension is written.
+ ConfigDir string
+ // InstallCmd is the shell command line that installs the harness. It is
+ // shown to the user as the install hint and run through /bin/sh.
+ InstallCmd string
+ // UninstallArgv is the uninstall command, already split into arguments:
+ // there is no shell in the uninstall path. Joined with spaces, it is
+ // also the uninstall hint.
+ UninstallArgv []string
+ // CommonPaths returns the non-PATH locations where the binary is
+ // commonly installed. Each entry is an absolute path to the binary
+ // itself, not a directory: clients.FindBinary stats each one directly.
+ CommonPaths func() []string
+ // YoloArgs are the arguments appended when the user has enabled yolo
+ // mode. It is empty for a harness with no permission prompts to skip;
+ // see buildArgs for why that is not an oversight.
+ YoloArgs []string
+ // ExtensionHeader is the comment block placed at the top of the
+ // generated extension. It must end in a newline.
+ ExtensionHeader string
+}
+
+// Client is a pi-like client. The zero value is not usable; V must be set.
+type Client struct {
+ V Variant
+}
+
+// backend is one pi-like wire protocol paired with the Aperture endpoints a
+// provider must advertise to serve it.
+type backend struct {
+ id string
+ displayName string
+ // api is the value the harness expects in a provider definition's "api"
+ // field.
+ api string
+ // endpoints are the Aperture endpoints that satisfy this backend; a
+ // provider matches if it advertises any one of them.
+ endpoints []string
+}
+
+// backends is ordered most-preferred first, which is also the order the
+// backend menu shows. Bedrock is absent on purpose: the
+// bedrock-converse-stream API type loads from a provider definition but
+// fails at request time against Aperture, so offering it would only produce
+// a confusing runtime error.
+//
+// The IDs are persisted as LaunchState.LastBackendType, so renaming one
+// silently drops every user's recorded launch back to the full menu.
+//
+// The vertex backend claims the Gemini endpoint only. Its base URL is rooted
+// at publishers/google, and Aperture's Vertex router picks the wire protocol
+// from that publisher segment alone, never from the model name. Claiming
+// EndpointVertexClaude as well would offer this backend to a provider that
+// serves Claude over publishers/anthropic with :rawPredict, which no API type
+// in the harness's union can produce.
+//
+// Narrowing the claim does not make every Vertex model safe. A Vertex
+// provider that advertises both endpoints still matches here and still lists
+// its Claude models, which fail at the provider once launched. Filtering the
+// model list by family is the remaining fix, and it belongs with the same fix
+// in opencode rather than here.
+var backends = []backend{
+ {id: "openai_responses", displayName: "OpenAI Responses", api: "openai-responses", endpoints: []string{config.EndpointOpenAIResponses}},
+ {id: "anthropic", displayName: "Anthropic Messages", api: "anthropic-messages", endpoints: []string{config.EndpointAnthropicMessages}},
+ {id: "openai_chat", displayName: "OpenAI Chat Completions", api: "openai-completions", endpoints: []string{config.EndpointOpenAIChat}},
+ {id: "vertex", displayName: "Google Vertex", api: "google-generative-ai", endpoints: []string{config.EndpointVertexGemini}},
+}
+
+// Name implements clients.Client.
+func (c *Client) Name() string { return c.V.Name }
+
+// BinaryName implements clients.Client.
+func (c *Client) BinaryName() string { return c.V.BinaryName }
+
+// CommonPaths implements clients.Client.
+func (c *Client) CommonPaths() []string { return c.V.CommonPaths() }
+
+// IsInstalled implements clients.Client.
+func (c *Client) IsInstalled() bool {
+ return clients.IsInstalled(c.V.BinaryName, c.CommonPaths())
+}
+
+// Install implements clients.Client.
+func (c *Client) Install(_ *config.Global) clients.InstallPlan {
+ return clients.InstallPlan{
+ Hint: c.V.InstallCmd,
+ Run: func() (*exec.Cmd, error) {
+ return exec.Command("/bin/sh", "-c", c.V.InstallCmd), nil
+ },
+ }
+}
+
+// Uninstall implements clients.Client.
+func (c *Client) Uninstall() clients.UninstallPlan {
+ argv := c.V.UninstallArgv
+ return clients.UninstallPlan{
+ Hint: strings.Join(argv, " "),
+ Run: func() error {
+ // Already separate arguments: there is no shell here.
+ return exec.Command(argv[0], argv[1:]...).Run()
+ },
+ }
+}
+
+// Menu implements clients.Client.
+func (c *Client) Menu(g *config.Global) menu.MenuItem {
+ return menu.MenuItem{
+ Label: c.V.Name,
+ Action: func() menu.Result { return c.providerStep(g) },
+ }
+}
+
+func (c *Client) providerStep(g *config.Global) menu.Result {
+ provs := compatibleProviders(g.Providers)
+ if len(provs) == 0 {
+ return errorResult("No providers support " + c.V.Name + ".")
+ }
+ if len(provs) == 1 {
+ return c.backendStep(g, provs[0])
+ }
+ items := make([]menu.MenuItem, 0, len(provs))
+ for _, p := range provs {
+ items = append(items, menu.MenuItem{
+ Label: p.DisplayName(),
+ Description: p.Description,
+ Action: func() menu.Result { return c.backendStep(g, p) },
+ })
+ }
+ return menu.Result{Next: &menu.Menu{
+ Title: "Choose a provider for " + c.V.Name + ":",
+ Items: items,
+ }}
+}
+
+func (c *Client) backendStep(g *config.Global, p config.ProviderInfo) menu.Result {
+ bs := backendsFor(p)
+ if len(bs) == 0 {
+ return errorResult("No compatible backends for " + p.DisplayName() + ".")
+ }
+ if len(bs) == 1 {
+ return c.modelStep(g, p, bs[0])
+ }
+ items := make([]menu.MenuItem, 0, len(bs))
+ for _, b := range bs {
+ items = append(items, menu.MenuItem{
+ Label: b.displayName,
+ Action: func() menu.Result { return c.modelStep(g, p, b) },
+ })
+ }
+ return menu.Result{Next: &menu.Menu{
+ Title: "Choose a backend for " + c.V.Name + " via " + p.DisplayName() + ":",
+ Items: items,
+ }}
+}
+
+func (c *Client) modelStep(g *config.Global, p config.ProviderInfo, b backend) menu.Result {
+ models := fqnModels(p)
+ if len(models) <= 1 {
+ var m string
+ if len(models) == 1 {
+ m = models[0]
+ }
+ return c.launch(g, p, b, m)
+ }
+ items := make([]menu.MenuItem, 0, len(models))
+ for _, m := range models {
+ items = append(items, menu.MenuItem{
+ Label: m,
+ Action: func() menu.Result { return c.launch(g, p, b, m) },
+ })
+ }
+ return menu.Result{Next: &menu.Menu{
+ Title: "Choose a default model for " + c.V.Name + " via " + p.DisplayName() + ":",
+ Items: items,
+ }}
+}
+
+func (c *Client) launch(g *config.Global, p config.ProviderInfo, b backend, m string) menu.Result {
+ bin := clients.FindBinary(c.V.BinaryName, c.CommonPaths())
+ if bin == "" {
+ bin = c.V.BinaryName
+ }
+
+ extPath, cleanup, err := writeProviderExtension(c.V, g.ApertureHost, p, b)
+ if err != nil {
+ return errorResult("Failed to write " + c.V.Name + " provider extension: " + err.Error())
+ }
+
+ args := buildArgs(c.V, extPath, p.ID, m, g.Settings.YoloMode)
+
+ _ = g.RecordLaunch(config.LaunchState{
+ LastClientName: c.V.Name,
+ LastBackendType: b.id,
+ LastProviderID: p.ID,
+ LastModel: m,
+ })
+
+ cmd := clients.Launch(clients.LaunchSpec{
+ Binary: bin,
+ Args: args,
+ Cleanup: cleanup,
+ Debug: g.Debug,
+ })
+ return menu.Result{Cmd: cmd, PopOnDone: true}
+}
+
+// buildArgs assembles the harness's command line: load the generated
+// extension, and preselect the model when the user chose one. Routing lives
+// entirely in the extension, so no environment variables are set.
+//
+// Yolo mode appends v.YoloArgs, which is empty for a harness that has
+// nothing to skip. Pi itself is such a harness: it ships no sandbox and never
+// prompts before running a tool, so it has no skip-permissions flag to pass.
+// Its --approve/-a flag looks like one but governs whether project-local .pi
+// files are trusted, which is unrelated to tool approval and not the user's
+// intent when they enable yolo mode. A fork that does prompt before running a
+// tool — Oh My Pi, whose --auto-approve is a real approval bypass — sets
+// YoloArgs to that flag. Do not fill YoloArgs with a flag that merely sounds
+// like one.
+func buildArgs(v Variant, extPath, providerID, m string, yolo bool) []string {
+ args := []string{"-e", extPath}
+ if m != "" {
+ args = append(args, "--model", modelRef(providerID, m))
+ }
+ if yolo {
+ args = append(args, v.YoloArgs...)
+ }
+ return args
+}
+
+// resolveReplay reports whether g.LastLaunch still describes a launch this
+// client can repeat, returning the provider, backend, and model to use.
+//
+// Every staleness check except the binary-installed one lives here so it can
+// be tested directly. Driving Replay instead would prove very little: Replay
+// returns nil at !IsInstalled() before reaching any of this, so on a machine
+// without the harness — including CI, which installs no agents — such a test
+// passes even if the checks below are deleted.
+func resolveReplay(v Variant, g *config.Global) (config.ProviderInfo, backend, string, bool) {
+ if g.LastLaunch.LastClientName != v.Name {
+ return config.ProviderInfo{}, backend{}, "", false
+ }
+ // The provider must still exist in the freshly fetched list.
+ prov, ok := g.Provider(g.LastLaunch.LastProviderID)
+ if !ok {
+ return config.ProviderInfo{}, backend{}, "", false
+ }
+ // The recorded backend must still be one we offer.
+ b, ok := backendByID(g.LastLaunch.LastBackendType)
+ if !ok {
+ return config.ProviderInfo{}, backend{}, "", false
+ }
+ // The provider must still serve that backend's protocol.
+ if len(prov.Models) == 0 || !providerSupports(prov, b) {
+ return config.ProviderInfo{}, backend{}, "", false
+ }
+ // The recorded model must still be offered by that provider.
+ m := g.LastLaunch.LastModel
+ if m != "" && !slices.Contains(fqnModels(prov), m) {
+ return config.ProviderInfo{}, backend{}, "", false
+ }
+ return prov, b, m, true
+}
+
+// Replay implements clients.Client.
+func (c *Client) Replay(g *config.Global) tea.Cmd {
+ if !c.IsInstalled() {
+ return nil
+ }
+ prov, b, m, ok := resolveReplay(c.V, g)
+ if !ok {
+ return nil
+ }
+ res := c.launch(g, prov, b, m)
+ return res.Cmd
+}
+
+// QuickSelectLabel implements clients.Client.
+func (c *Client) QuickSelectLabel(g *config.Global) string {
+ prov, _ := g.Provider(g.LastLaunch.LastProviderID)
+ label := c.V.Name + " via " + prov.DisplayName()
+ if b, ok := backendByID(g.LastLaunch.LastBackendType); ok {
+ label += " - " + b.displayName
+ }
+ if g.LastLaunch.LastModel != "" {
+ label += " - " + g.LastLaunch.LastModel
+ }
+ return label
+}
+
+func compatibleProviders(all []config.ProviderInfo) []config.ProviderInfo {
+ var out []config.ProviderInfo
+ for _, p := range all {
+ if len(p.Models) > 0 && len(backendsFor(p)) > 0 {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func backendsFor(p config.ProviderInfo) []backend {
+ var out []backend
+ for _, b := range backends {
+ if providerSupports(p, b) {
+ out = append(out, b)
+ }
+ }
+ return out
+}
+
+func providerSupports(p config.ProviderInfo, b backend) bool {
+ for _, endpoint := range b.endpoints {
+ if p.SupportsEndpoint(endpoint) {
+ return true
+ }
+ }
+ return false
+}
+
+func backendByID(id string) (backend, bool) {
+ idx := slices.IndexFunc(backends, func(b backend) bool { return b.id == id })
+ if idx < 0 {
+ return backend{}, false
+ }
+ return backends[idx], true
+}
+
+func fqnModels(p config.ProviderInfo) []string {
+ out := make([]string, len(p.Models))
+ for i, m := range p.Models {
+ out[i] = p.ID + "/" + m
+ }
+ return out
+}
+
+func stripProviderPrefix(fqn string) string {
+ if _, after, ok := strings.Cut(fqn, "/"); ok {
+ return after
+ }
+ return fqn
+}
+
+func errorResult(msg string) menu.Result {
+ return menu.Result{Cmd: func() tea.Msg {
+ return menu.SimpleDoneMsg{Err: errString(msg)}
+ }}
+}
+
+type errString string
+
+func (e errString) Error() string { return string(e) }
diff --git a/internal/clients/pilike/pilike_test.go b/internal/clients/pilike/pilike_test.go
new file mode 100644
index 0000000..7ff6d16
--- /dev/null
+++ b/internal/clients/pilike/pilike_test.go
@@ -0,0 +1,893 @@
+package pilike
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/tailscale/aperture-cli/internal/config"
+)
+
+const testHost = "http://ai.example.com"
+
+// The two variants stand in for the two shapes a pi-like harness comes in:
+// one with no approval prompt and so no yolo flag, and one with both. Tests
+// that do not turn on that difference use noYolo.
+var (
+ noYolo = Variant{
+ Name: "No Yolo",
+ BinaryName: "noyolo",
+ ConfigDir: "test-noyolo",
+ InstallCmd: "npm install -g no-yolo",
+ UninstallArgv: []string{"npm", "uninstall", "-g", "no-yolo"},
+ CommonPaths: func() []string { return nil },
+ YoloArgs: nil,
+ ExtensionHeader: "// Generated by aperture-cli. Rewritten on every launch.\n",
+ }
+ withYolo = Variant{
+ Name: "With Yolo",
+ BinaryName: "withyolo",
+ ConfigDir: "test-withyolo",
+ InstallCmd: "bun install -g with-yolo",
+ UninstallArgv: []string{"bun", "uninstall", "-g", "with-yolo"},
+ CommonPaths: func() []string { return nil },
+ YoloArgs: []string{"--auto-approve"},
+ ExtensionHeader: "// Generated by aperture-cli.\n",
+ }
+ variants = []Variant{noYolo, withYolo}
+)
+
+// isolateConfigDir points config.ClientConfigDir at a temp directory so tests
+// never write into the developer's real ~/.config.
+func isolateConfigDir(t *testing.T) {
+ t.Helper()
+ tmp := t.TempDir()
+ t.Setenv("HOME", tmp)
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config"))
+}
+
+func backendByIDOrFatal(t *testing.T, id string) backend {
+ t.Helper()
+ b, ok := backendByID(id)
+ if !ok {
+ t.Fatalf("no backend with id %q", id)
+ }
+ return b
+}
+
+// TestBackendIDsAreStable pins the persisted-state contract. A backend ID is
+// written to state.json as LaunchState.LastBackendType, so renaming one
+// silently drops every user's recorded launch back to the full menu.
+func TestBackendIDsAreStable(t *testing.T) {
+ want := []string{"openai_responses", "anthropic", "openai_chat", "vertex"}
+ got := make([]string, len(backends))
+ for i, b := range backends {
+ got[i] = b.id
+ }
+ if !slices.Equal(got, want) {
+ t.Errorf("backend ids = %v, want %v", got, want)
+ }
+}
+
+func TestBackendBaseURL(t *testing.T) {
+ vertexPath := "/v1/projects/_aperture_auto_vertex_project_id_/locations/_aperture_auto_vertex_region_/publishers/google"
+ cases := []struct {
+ backendID string
+ want string
+ }{
+ // The harness appends /v1/messages itself, so Anthropic takes the
+ // bare host.
+ {"anthropic", testHost},
+ {"openai_chat", testHost + "/v1"},
+ {"openai_responses", testHost + "/v1"},
+ {"vertex", testHost + vertexPath},
+ }
+ for _, tc := range cases {
+ t.Run(tc.backendID, func(t *testing.T) {
+ b := backendByIDOrFatal(t, tc.backendID)
+ if got := b.baseURL(testHost); got != tc.want {
+ t.Errorf("baseURL = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+// TestBackendBaseURL_TrimsTrailingSlash guards the routing bug this would
+// otherwise cause: the harness hangs on a doubled slash rather than following
+// the endpoint's redirect.
+func TestBackendBaseURL_TrimsTrailingSlash(t *testing.T) {
+ for _, b := range backends {
+ t.Run(b.id, func(t *testing.T) {
+ got := b.baseURL(testHost + "/")
+ if strings.Contains(got, "//v1") || strings.HasSuffix(got, "//") {
+ t.Errorf("baseURL = %q, contains a doubled slash", got)
+ }
+ if want := b.baseURL(testHost); got != want {
+ t.Errorf("baseURL with trailing slash = %q, want %q", got, want)
+ }
+ })
+ }
+}
+
+func TestBuildProvider(t *testing.T) {
+ p := config.ProviderInfo{
+ ID: "openai-api",
+ Name: "OpenAI",
+ Models: []string{"gpt-5", "gpt-5-mini"},
+ SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true},
+ }
+ b := backendByIDOrFatal(t, "openai_responses")
+ prov := buildProvider(testHost, p, b)
+
+ if prov.API != "openai-responses" {
+ t.Errorf("api = %q, want openai-responses", prov.API)
+ }
+ if prov.BaseURL != testHost+"/v1" {
+ t.Errorf("baseUrl = %q, want %q", prov.BaseURL, testHost+"/v1")
+ }
+ // The harness drops a provider whose models carry no apiKey: it loads the
+ // file but never offers the models in /model or --list-models.
+ if prov.APIKey != "not-needed" {
+ t.Errorf("apiKey = %q, want not-needed", prov.APIKey)
+ }
+ if prov.Name != "Aperture (openai-api)" {
+ t.Errorf("name = %q, want Aperture (openai-api)", prov.Name)
+ }
+ if len(prov.Models) != 2 {
+ t.Fatalf("models len = %d, want 2", len(prov.Models))
+ }
+ // Model IDs must be bare: the harness sends id verbatim as the wire model
+ // name.
+ for i, want := range []string{"gpt-5", "gpt-5-mini"} {
+ if prov.Models[i].ID != want {
+ t.Errorf("models[%d].id = %q, want %q", i, prov.Models[i].ID, want)
+ }
+ }
+ // input must be non-empty or the harness's --list-models dereferences nil.
+ for i, m := range prov.Models {
+ if len(m.Input) == 0 {
+ t.Errorf("models[%d].input is empty; the harness crashes on a nil input list", i)
+ }
+ // A model registered from an extension gets no default token limits,
+ // unlike one declared in models.json. Leaving these zero sends
+ // max_tokens: null and the request fails at the provider.
+ if m.MaxTokens <= 0 {
+ t.Errorf("models[%d].maxTokens = %d; must be positive or the request is rejected", i, m.MaxTokens)
+ }
+ if m.ContextWindow <= 0 {
+ t.Errorf("models[%d].contextWindow = %d; must be positive", i, m.ContextWindow)
+ }
+ }
+}
+
+// TestExtensionSource_NoNullFields guards the failure that end-to-end testing
+// found: a model field omitted from the generated JSON reaches the provider as
+// a literal null rather than falling back to a harness default.
+func TestExtensionSource_NoNullFields(t *testing.T) {
+ for _, v := range variants {
+ t.Run(v.Name, func(t *testing.T) {
+ p := config.ProviderInfo{ID: "anthropic", Models: []string{"claude-sonnet-4-5"}}
+ src, err := extensionSource(v, p.ID, buildProvider(testHost, p, backendByIDOrFatal(t, "anthropic")))
+ if err != nil {
+ t.Fatalf("extensionSource: %v", err)
+ }
+ if strings.Contains(src, "null") {
+ t.Errorf("generated extension contains a null value:\n%s", src)
+ }
+ for _, field := range []string{"maxTokens", "contextWindow", "input", "apiKey", "baseUrl", "api"} {
+ if !strings.Contains(src, `"`+field+`"`) {
+ t.Errorf("generated extension omits %q", field)
+ }
+ }
+ })
+ }
+}
+
+func TestBuildProvider_NoModels(t *testing.T) {
+ p := config.ProviderInfo{ID: "empty", SupportedEndpoints: map[string]bool{config.EndpointOpenAIChat: true}}
+ prov := buildProvider(testHost, p, backendByIDOrFatal(t, "openai_chat"))
+ if len(prov.Models) != 0 {
+ t.Errorf("models len = %d, want 0", len(prov.Models))
+ }
+}
+
+func TestExtensionSource(t *testing.T) {
+ for _, v := range variants {
+ t.Run(v.Name, func(t *testing.T) {
+ p := config.ProviderInfo{
+ ID: "anthropic",
+ Models: []string{"claude-sonnet-4-5"},
+ }
+ b := backendByIDOrFatal(t, "anthropic")
+ src, err := extensionSource(v, p.ID, buildProvider(testHost, p, b))
+ if err != nil {
+ t.Fatalf("extensionSource: %v", err)
+ }
+
+ // The extension must default-export a function that registers the
+ // provider; anything else and the harness loads the file and does
+ // nothing.
+ if !strings.Contains(src, "export default function") {
+ t.Error("source has no default-exported function")
+ }
+ if !strings.Contains(src, "pi.registerProvider(") {
+ t.Error("source never calls pi.registerProvider")
+ }
+ // The registered ID must be namespaced so it cannot merge into
+ // the harness's own built-in "anthropic" provider and retarget
+ // the user's models.
+ if !strings.Contains(src, `"aperture-anthropic"`) {
+ t.Errorf("source does not register the namespaced provider id:\n%s", src)
+ }
+ if strings.Contains(src, `registerProvider("anthropic"`) {
+ t.Error("source registers the bare provider id, which would override the harness's built-in provider")
+ }
+ // The variant's header must survive into the file: it is the only
+ // marker telling a user the file is generated and disposable.
+ if !strings.HasPrefix(src, v.ExtensionHeader) {
+ t.Errorf("source does not start with the variant's header:\n%s", src)
+ }
+ })
+ }
+}
+
+func TestNamespacedProviderIDAndModelRef(t *testing.T) {
+ if got := namespacedProviderID("openai-api"); got != "aperture-openai-api" {
+ t.Errorf("namespacedProviderID = %q, want aperture-openai-api", got)
+ }
+ // The model arrives fully-qualified from the menu; the reference the
+ // harness wants is the namespaced provider plus the bare model ID.
+ if got := modelRef("openai-api", "openai-api/gpt-5"); got != "aperture-openai-api/gpt-5" {
+ t.Errorf("modelRef = %q, want aperture-openai-api/gpt-5", got)
+ }
+ if got := modelRef("vertex", "gemini-2.5-pro"); got != "aperture-vertex/gemini-2.5-pro" {
+ t.Errorf("modelRef = %q, want aperture-vertex/gemini-2.5-pro", got)
+ }
+}
+
+func TestWriteProviderExtension(t *testing.T) {
+ for _, v := range variants {
+ t.Run(v.Name, func(t *testing.T) {
+ isolateConfigDir(t)
+
+ p := config.ProviderInfo{
+ ID: "anthropic",
+ Name: "Anthropic",
+ Models: []string{"claude-sonnet-4-5"},
+ SupportedEndpoints: map[string]bool{config.EndpointAnthropicMessages: true},
+ }
+ b := backendByIDOrFatal(t, "anthropic")
+
+ path, cleanup, err := writeProviderExtension(v, testHost, p, b)
+ if err != nil {
+ t.Fatalf("writeProviderExtension: %v", err)
+ }
+
+ // The harness resolves -e by extension, so a non-.js path is
+ // never loaded.
+ if filepath.Ext(path) != ".js" {
+ t.Errorf("path = %q, want a .js file", path)
+ }
+ // The file belongs to the variant's own config directory, not a
+ // directory shared with another harness.
+ if got := filepath.Base(filepath.Dir(path)); got != v.ConfigDir {
+ t.Errorf("extension written to %q, want the %q config dir", got, v.ConfigDir)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("extension unreadable: %v", err)
+ }
+ if !strings.Contains(string(data), testHost) {
+ t.Errorf("extension does not contain the aperture host:\n%s", data)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if perm := info.Mode().Perm(); perm != 0o600 {
+ t.Errorf("perm = %o, want 600", perm)
+ }
+
+ cleanup()
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Error("extension file still exists after cleanup")
+ }
+ })
+ }
+}
+
+func TestWriteProviderExtension_UniquePaths(t *testing.T) {
+ isolateConfigDir(t)
+
+ p := config.ProviderInfo{ID: "openai-api", Models: []string{"gpt-5"}}
+ b := backendByIDOrFatal(t, "openai_responses")
+ path1, cleanup1, err := writeProviderExtension(noYolo, testHost, p, b)
+ if err != nil {
+ t.Fatalf("first writeProviderExtension: %v", err)
+ }
+ defer cleanup1()
+ path2, cleanup2, err := writeProviderExtension(noYolo, testHost, p, b)
+ if err != nil {
+ t.Fatalf("second writeProviderExtension: %v", err)
+ }
+ defer cleanup2()
+
+ if path1 == path2 {
+ t.Fatalf("concurrent extensions use the same path %q", path1)
+ }
+ cleanup1()
+ if _, err := os.Stat(path2); err != nil {
+ t.Errorf("cleaning up the first extension affected the second: %v", err)
+ }
+}
+
+// TestWriteProviderExtension_EmbeddedJSONIsValid checks the generated file's
+// provider config parses as JSON, which is what catches an unescaped value
+// silently producing a broken extension.
+func TestWriteProviderExtension_EmbeddedJSONIsValid(t *testing.T) {
+ isolateConfigDir(t)
+
+ p := config.ProviderInfo{
+ ID: "openai-api",
+ Models: []string{"gpt-5"},
+ SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true},
+ }
+ b := backendByIDOrFatal(t, "openai_responses")
+ path, cleanup, err := writeProviderExtension(noYolo, testHost, p, b)
+ if err != nil {
+ t.Fatalf("writeProviderExtension: %v", err)
+ }
+ defer cleanup()
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ src := string(data)
+
+ // The config object is the second argument to registerProvider: it starts
+ // at the ", {" that follows the provider ID and ends at the closing ");".
+ start := strings.Index(src, ", {")
+ end := strings.LastIndex(src, ");")
+ if start < 0 || end <= start {
+ t.Fatalf("no provider config object found in:\n%s", src)
+ }
+ blob := src[start+2 : end]
+
+ var got provider
+ if err := json.Unmarshal([]byte(blob), &got); err != nil {
+ t.Fatalf("embedded provider config is not valid JSON: %v\n%s", err, blob)
+ }
+ if got.API != "openai-responses" {
+ t.Errorf("api = %q, want openai-responses", got.API)
+ }
+ if got.BaseURL != testHost+"/v1" {
+ t.Errorf("baseUrl = %q, want %q", got.BaseURL, testHost+"/v1")
+ }
+ if len(got.Models) != 1 || got.Models[0].ID != "gpt-5" {
+ t.Errorf("models = %+v, want one gpt-5 entry", got.Models)
+ }
+}
+
+// TestEveryBackendEmitsALoadableExtension is the offline form of the live
+// end-to-end run: every backend in the table was driven against a real
+// Aperture endpoint with both a text request and a tool-calling request, and
+// all four returned successfully. What that run actually exercised is the
+// artifact each backend produces, so this asserts the same properties on every
+// backend without needing an endpoint at test time.
+//
+// It is a loop over backends rather than fixed cases on purpose: a new
+// protocol added to the table is covered the moment it is added, instead of
+// shipping untested because nobody remembered to add a case.
+func TestEveryBackendEmitsALoadableExtension(t *testing.T) {
+ for _, v := range variants {
+ for _, b := range backends {
+ t.Run(v.Name+"/"+b.id, func(t *testing.T) {
+ p := config.ProviderInfo{
+ ID: "prov",
+ Name: "Prov",
+ Models: []string{"model-a"},
+ }
+ src, err := extensionSource(v, p.ID, buildProvider(testHost, p, b))
+ if err != nil {
+ t.Fatalf("extensionSource: %v", err)
+ }
+
+ // A null anywhere is the failure mode the live run was built
+ // to catch: the harness passes the value through and the
+ // provider rejects it.
+ if strings.Contains(src, "null") {
+ t.Errorf("extension contains a null value:\n%s", src)
+ }
+ // The base URL must never carry a doubled slash; the harness
+ // hangs rather than following the endpoint's redirect.
+ if strings.Contains(b.baseURL(testHost), "//v1") {
+ t.Errorf("baseURL = %q, contains a doubled slash", b.baseURL(testHost))
+ }
+
+ start := strings.Index(src, ", {")
+ end := strings.LastIndex(src, ");")
+ if start < 0 || end <= start {
+ t.Fatalf("no provider config object found in:\n%s", src)
+ }
+ var got provider
+ if err := json.Unmarshal([]byte(src[start+2:end]), &got); err != nil {
+ t.Fatalf("embedded provider config is not valid JSON: %v", err)
+ }
+
+ // The api value is what selects the harness's wire protocol; a
+ // wrong or empty one routes the request at the wrong endpoint
+ // shape.
+ if got.API != b.api {
+ t.Errorf("api = %q, want %q", got.API, b.api)
+ }
+ if got.BaseURL != b.baseURL(testHost) {
+ t.Errorf("baseUrl = %q, want %q", got.BaseURL, b.baseURL(testHost))
+ }
+ if got.APIKey == "" {
+ t.Error("apiKey is empty; the harness drops a provider with no key")
+ }
+ if len(got.Models) != 1 {
+ t.Fatalf("models len = %d, want 1", len(got.Models))
+ }
+ // Every field the tool-calling path needs must be populated.
+ m := got.Models[0]
+ if m.ID != "model-a" {
+ t.Errorf("models[0].id = %q, want model-a", m.ID)
+ }
+ if len(m.Input) == 0 {
+ t.Error("models[0].input is empty; the harness crashes on a nil input list")
+ }
+ if m.MaxTokens <= 0 {
+ t.Errorf("models[0].maxTokens = %d; a zero value is sent as null and rejected", m.MaxTokens)
+ }
+ if m.ContextWindow <= 0 {
+ t.Errorf("models[0].contextWindow = %d; must be positive", m.ContextWindow)
+ }
+
+ // The argv the harness is launched with must name the
+ // extension and a model reference carrying no provider/
+ // prefix on the model half.
+ args := buildArgs(v, "/tmp/ext.js", p.ID, "prov/model-a", false)
+ want := []string{"-e", "/tmp/ext.js", "--model", "aperture-prov/model-a"}
+ if !slices.Equal(args, want) {
+ t.Errorf("buildArgs = %v, want %v", args, want)
+ }
+ })
+ }
+ }
+}
+
+func TestBuildArgs(t *testing.T) {
+ t.Run("with_model", func(t *testing.T) {
+ got := buildArgs(noYolo, "/tmp/ext.js", "openai-api", "openai-api/gpt-5", false)
+ want := []string{"-e", "/tmp/ext.js", "--model", "aperture-openai-api/gpt-5"}
+ if !slices.Equal(got, want) {
+ t.Errorf("buildArgs = %v, want %v", got, want)
+ }
+ })
+ t.Run("no_model_omits_flag", func(t *testing.T) {
+ got := buildArgs(noYolo, "/tmp/ext.js", "openai-api", "", false)
+ want := []string{"-e", "/tmp/ext.js"}
+ if !slices.Equal(got, want) {
+ t.Errorf("buildArgs = %v, want %v", got, want)
+ }
+ })
+ t.Run("empty_yolo_args_add_nothing", func(t *testing.T) {
+ // A harness with no permission prompts has nothing to skip, so yolo
+ // mode must not invent a flag for it. --approve is project-file
+ // trust, not tool approval.
+ for _, a := range buildArgs(noYolo, "/tmp/ext.js", "p", "p/m", true) {
+ if a == "--approve" || a == "-a" || a == "--yolo" {
+ t.Errorf("buildArgs included %q", a)
+ }
+ }
+ got := buildArgs(noYolo, "/tmp/ext.js", "p", "p/m", true)
+ want := []string{"-e", "/tmp/ext.js", "--model", "aperture-p/m"}
+ if !slices.Equal(got, want) {
+ t.Errorf("buildArgs = %v, want %v", got, want)
+ }
+ })
+ t.Run("yolo_args_appended_last", func(t *testing.T) {
+ got := buildArgs(withYolo, "/tmp/ext.js", "p", "p/m", true)
+ want := []string{"-e", "/tmp/ext.js", "--model", "aperture-p/m", "--auto-approve"}
+ if !slices.Equal(got, want) {
+ t.Errorf("buildArgs = %v, want %v", got, want)
+ }
+ })
+ t.Run("yolo_args_omitted_when_off", func(t *testing.T) {
+ got := buildArgs(withYolo, "/tmp/ext.js", "p", "p/m", false)
+ want := []string{"-e", "/tmp/ext.js", "--model", "aperture-p/m"}
+ if !slices.Equal(got, want) {
+ t.Errorf("buildArgs = %v, want %v", got, want)
+ }
+ })
+}
+
+func TestBackendsFor(t *testing.T) {
+ cases := []struct {
+ name string
+ endpoints map[string]bool
+ want []string
+ }{
+ {
+ name: "openai_both_ordered_responses_first",
+ endpoints: map[string]bool{config.EndpointOpenAIChat: true, config.EndpointOpenAIResponses: true},
+ want: []string{"openai_responses", "openai_chat"},
+ },
+ {
+ name: "anthropic_only",
+ endpoints: map[string]bool{config.EndpointAnthropicMessages: true},
+ want: []string{"anthropic"},
+ },
+ {
+ name: "vertex_via_generate_content",
+ endpoints: map[string]bool{config.EndpointVertexGemini: true},
+ want: []string{"vertex"},
+ },
+ {
+ // The vertex backend always calls publishers/google, and Aperture
+ // routes on that publisher segment alone. A provider that serves
+ // Claude over publishers/anthropic with :rawPredict cannot be
+ // reached this way, so it must not be offered the backend.
+ name: "vertex_via_raw_predict_is_not_offered",
+ endpoints: map[string]bool{config.EndpointVertexClaude: true},
+ want: nil,
+ },
+ {
+ // Advertising the Anthropic endpoint as well does not disqualify a
+ // provider: the Gemini endpoint is the one this backend drives.
+ name: "vertex_offered_when_both_endpoints_advertised",
+ endpoints: map[string]bool{config.EndpointVertexGemini: true, config.EndpointVertexClaude: true},
+ want: []string{"vertex"},
+ },
+ {
+ // The bedrock API type fails at request time, so it is not offered.
+ name: "bedrock_unsupported",
+ endpoints: map[string]bool{config.EndpointBedrockConverse: true, config.EndpointBedrockInvoke: true},
+ want: nil,
+ },
+ {
+ name: "unknown_endpoint",
+ endpoints: map[string]bool{"/unknown": true},
+ want: nil,
+ },
+ {
+ name: "all_four",
+ endpoints: map[string]bool{config.EndpointOpenAIChat: true, config.EndpointOpenAIResponses: true, config.EndpointAnthropicMessages: true, config.EndpointVertexGemini: true},
+ want: []string{"openai_responses", "anthropic", "openai_chat", "vertex"},
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ bs := backendsFor(config.ProviderInfo{SupportedEndpoints: tc.endpoints})
+ got := make([]string, len(bs))
+ for i, b := range bs {
+ got[i] = b.id
+ }
+ if !slices.Equal(got, tc.want) {
+ t.Errorf("backendsFor = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestCompatibleProviders(t *testing.T) {
+ provs := []config.ProviderInfo{
+ {ID: "openai-api", Models: []string{"gpt-5"}, SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}},
+ {ID: "bedrock", Models: []string{"model"}, SupportedEndpoints: map[string]bool{config.EndpointBedrockConverse: true}},
+ {ID: "anthropic", Models: []string{"claude"}, SupportedEndpoints: map[string]bool{config.EndpointAnthropicMessages: true}},
+ {ID: "empty", SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}},
+ {ID: "none", Models: []string{"model"}, SupportedEndpoints: map[string]bool{"/unknown": true}},
+ }
+ got := compatibleProviders(provs)
+ ids := make([]string, len(got))
+ for i, p := range got {
+ ids[i] = p.ID
+ }
+ // bedrock and none have no supported protocol; empty has no routable model.
+ if want := []string{"openai-api", "anthropic"}; !slices.Equal(ids, want) {
+ t.Errorf("compatibleProviders = %v, want %v", ids, want)
+ }
+}
+
+func TestBackendByID(t *testing.T) {
+ if b, ok := backendByID("vertex"); !ok || b.api != "google-generative-ai" {
+ t.Errorf("backendByID(vertex) = %+v, %v", b, ok)
+ }
+ if _, ok := backendByID("bedrock"); ok {
+ t.Error("backendByID(bedrock) should not resolve")
+ }
+ if _, ok := backendByID(""); ok {
+ t.Error("backendByID(\"\") should not resolve")
+ }
+}
+
+// TestResolveReplay covers every staleness path Replay depends on. It calls
+// resolveReplay rather than Replay on purpose: Replay returns nil at
+// !IsInstalled() before reaching any of these checks, so a test driving
+// Replay would pass on CI even if the staleness logic were deleted.
+func TestResolveReplay(t *testing.T) {
+ liveProvider := config.ProviderInfo{
+ ID: "openai-api",
+ Name: "OpenAI",
+ Models: []string{"gpt-5"},
+ SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true, config.EndpointOpenAIChat: true},
+ }
+ base := config.LaunchState{
+ LastClientName: noYolo.Name,
+ LastBackendType: "openai_responses",
+ LastProviderID: "openai-api",
+ LastModel: "openai-api/gpt-5",
+ }
+
+ t.Run("replayable", func(t *testing.T) {
+ g := &config.Global{Providers: []config.ProviderInfo{liveProvider}, LastLaunch: base}
+ prov, b, m, ok := resolveReplay(noYolo, g)
+ if !ok {
+ t.Fatal("resolveReplay = not ok, want a replayable launch")
+ }
+ if prov.ID != "openai-api" {
+ t.Errorf("provider = %q, want openai-api", prov.ID)
+ }
+ if b.id != "openai_responses" {
+ t.Errorf("backend = %q, want openai_responses", b.id)
+ }
+ if m != "openai-api/gpt-5" {
+ t.Errorf("model = %q, want openai-api/gpt-5", m)
+ }
+ })
+
+ t.Run("empty_model_is_replayable", func(t *testing.T) {
+ ls := base
+ ls.LastModel = ""
+ g := &config.Global{Providers: []config.ProviderInfo{liveProvider}, LastLaunch: ls}
+ if _, _, m, ok := resolveReplay(noYolo, g); !ok || m != "" {
+ t.Errorf("resolveReplay = %q, %v; want \"\", true", m, ok)
+ }
+ })
+
+ // A record written by one variant must not replay under another, or the
+ // user's last Oh My Pi launch would come back as a Pi launch.
+ t.Run("another_variant_owns_the_record", func(t *testing.T) {
+ g := &config.Global{Providers: []config.ProviderInfo{liveProvider}, LastLaunch: base}
+ if _, _, _, ok := resolveReplay(withYolo, g); ok {
+ t.Error("resolveReplay = ok, want a record owned by another variant to be ignored")
+ }
+ })
+
+ stale := []struct {
+ name string
+ providers []config.ProviderInfo
+ mutate func(*config.LaunchState)
+ }{
+ {
+ name: "another_client_owns_the_record",
+ providers: []config.ProviderInfo{liveProvider},
+ mutate: func(ls *config.LaunchState) { ls.LastClientName = "Codex" },
+ },
+ {
+ name: "provider_gone_from_endpoint",
+ providers: []config.ProviderInfo{liveProvider},
+ mutate: func(ls *config.LaunchState) { ls.LastProviderID = "removed" },
+ },
+ {
+ name: "backend_id_no_longer_offered",
+ providers: []config.ProviderInfo{liveProvider},
+ mutate: func(ls *config.LaunchState) { ls.LastBackendType = "bedrock" },
+ },
+ {
+ name: "backend_id_empty",
+ providers: []config.ProviderInfo{liveProvider},
+ mutate: func(ls *config.LaunchState) { ls.LastBackendType = "" },
+ },
+ {
+ // The provider still exists but dropped the protocol we recorded.
+ name: "provider_dropped_the_protocol",
+ providers: []config.ProviderInfo{{
+ ID: "openai-api",
+ Models: []string{"gpt-5"},
+ SupportedEndpoints: map[string]bool{config.EndpointOpenAIChat: true},
+ }},
+ mutate: func(ls *config.LaunchState) {},
+ },
+ {
+ name: "model_no_longer_listed",
+ providers: []config.ProviderInfo{liveProvider},
+ mutate: func(ls *config.LaunchState) { ls.LastModel = "openai-api/gpt-4" },
+ },
+ {
+ name: "provider_has_no_models_anymore",
+ providers: []config.ProviderInfo{{
+ ID: "openai-api",
+ SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true},
+ }},
+ mutate: func(ls *config.LaunchState) {},
+ },
+ }
+ for _, tc := range stale {
+ t.Run(tc.name, func(t *testing.T) {
+ ls := base
+ tc.mutate(&ls)
+ g := &config.Global{Providers: tc.providers, LastLaunch: ls}
+ if _, _, _, ok := resolveReplay(noYolo, g); ok {
+ t.Error("resolveReplay = ok, want not replayable")
+ }
+ })
+ }
+}
+
+// TestReplayStalenessChecks exercises the decisions Replay makes, directly
+// against the unexported helpers. Calling Replay itself would return nil at
+// the !IsInstalled() check on any machine without the harness, so it would
+// pass even if the staleness logic were deleted.
+func TestReplayStalenessChecks(t *testing.T) {
+ prov := config.ProviderInfo{
+ ID: "openai-api",
+ Models: []string{"gpt-5"},
+ SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true},
+ }
+
+ b := backendByIDOrFatal(t, "openai_responses")
+ if !providerSupports(prov, b) {
+ t.Error("provider should support the recorded backend")
+ }
+
+ // A backend the provider no longer serves must not replay.
+ if providerSupports(prov, backendByIDOrFatal(t, "anthropic")) {
+ t.Error("provider without /v1/messages should not support the anthropic backend")
+ }
+
+ // A backend ID that no longer exists in the table must not replay.
+ if _, ok := backendByID("openai_completions_legacy"); ok {
+ t.Error("an unknown recorded backend id should not resolve")
+ }
+
+ if got := fqnModels(prov); !slices.Equal(got, []string{"openai-api/gpt-5"}) {
+ t.Errorf("fqnModels = %v, want [openai-api/gpt-5]", got)
+ }
+ // A recorded model the provider no longer lists is what makes Replay bail.
+ if slices.Contains(fqnModels(prov), "openai-api/gpt-4") {
+ t.Error("stale model should not be found in the current model list")
+ }
+}
+
+func TestQuickSelectLabel(t *testing.T) {
+ g := &config.Global{
+ Providers: []config.ProviderInfo{
+ {ID: "openai-api", Name: "OpenAI", Models: []string{"gpt-5"}},
+ },
+ LastLaunch: config.LaunchState{
+ LastClientName: noYolo.Name,
+ LastBackendType: "openai_responses",
+ LastProviderID: "openai-api",
+ LastModel: "openai-api/gpt-5",
+ },
+ }
+ c := &Client{V: noYolo}
+ want := noYolo.Name + " via OpenAI - OpenAI Responses - openai-api/gpt-5"
+ if got := c.QuickSelectLabel(g); got != want {
+ t.Errorf("QuickSelectLabel = %q, want %q", got, want)
+ }
+
+ // No recorded model: the label stops after the backend.
+ g.LastLaunch.LastModel = ""
+ if got, want := c.QuickSelectLabel(g), noYolo.Name+" via OpenAI - OpenAI Responses"; got != want {
+ t.Errorf("QuickSelectLabel = %q, want %q", got, want)
+ }
+}
+
+func TestFqnModels(t *testing.T) {
+ p := config.ProviderInfo{ID: "openai-api", Models: []string{"gpt-5", "gpt-5-mini"}}
+ want := []string{"openai-api/gpt-5", "openai-api/gpt-5-mini"}
+ if got := fqnModels(p); !slices.Equal(got, want) {
+ t.Errorf("fqnModels = %v, want %v", got, want)
+ }
+}
+
+func TestStripProviderPrefix(t *testing.T) {
+ cases := map[string]string{
+ "openai-api/gpt-5": "gpt-5",
+ "anthropic/claude-sonnet-4": "claude-sonnet-4",
+ "bare-model": "bare-model",
+ "provider/nested/model": "nested/model",
+ }
+ for in, want := range cases {
+ if got := stripProviderPrefix(in); got != want {
+ t.Errorf("stripProviderPrefix(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestIdentity(t *testing.T) {
+ for _, v := range variants {
+ t.Run(v.Name, func(t *testing.T) {
+ c := &Client{V: v}
+ if c.Name() != v.Name {
+ t.Errorf("Name = %q, want %q", c.Name(), v.Name)
+ }
+ if c.BinaryName() != v.BinaryName {
+ t.Errorf("BinaryName = %q, want %q", c.BinaryName(), v.BinaryName)
+ }
+ })
+ }
+}
+
+func TestInstallUninstall(t *testing.T) {
+ for _, v := range variants {
+ t.Run(v.Name, func(t *testing.T) {
+ c := &Client{V: v}
+ install := c.Install(&config.Global{})
+ if install.Hint != v.InstallCmd {
+ t.Errorf("Install.Hint = %q, want %q", install.Hint, v.InstallCmd)
+ }
+ if install.Run == nil {
+ t.Error("Install.Run is nil")
+ }
+
+ uninstall := c.Uninstall()
+ // The hint is the argv the uninstall actually runs, so a user who
+ // copies it gets the same command.
+ if want := strings.Join(v.UninstallArgv, " "); uninstall.Hint != want {
+ t.Errorf("Uninstall.Hint = %q, want %q", uninstall.Hint, want)
+ }
+ if uninstall.Run == nil {
+ t.Error("Uninstall.Run is nil")
+ }
+ })
+ }
+}
+
+// TestWriteProviderExtension_SweepsOrphans covers the file a crashed or
+// killed launch leaves behind. The cleanup function never runs in that case,
+// and every stranded file embeds the tailnet hostname.
+func TestWriteProviderExtension_SweepsOrphans(t *testing.T) {
+ isolateConfigDir(t)
+
+ dir, err := config.ClientConfigDir(noYolo.ConfigDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var stale []string
+ for _, suffix := range []string{"stale1", "stale2"} {
+ path := filepath.Join(dir, "tmp_aperture_provider_"+suffix+".js")
+ if err := os.WriteFile(path, []byte("// orphan\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ stale = append(stale, path)
+ }
+ // A file that is not ours must survive the sweep.
+ keep := filepath.Join(dir, "settings.json")
+ if err := os.WriteFile(keep, []byte("{}\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ p := config.ProviderInfo{ID: "openai-api", Models: []string{"gpt-5"}}
+ path, cleanup, err := writeProviderExtension(noYolo, testHost, p, backendByIDOrFatal(t, "openai_responses"))
+ if err != nil {
+ t.Fatalf("writeProviderExtension: %v", err)
+ }
+ defer cleanup()
+
+ for _, s := range stale {
+ if _, err := os.Stat(s); !os.IsNotExist(err) {
+ t.Errorf("orphaned extension %q survived the sweep", filepath.Base(s))
+ }
+ }
+ if _, err := os.Stat(keep); err != nil {
+ t.Errorf("the sweep removed an unrelated file: %v", err)
+ }
+
+ matches, err := filepath.Glob(filepath.Join(dir, extensionGlob))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Equal(matches, []string{path}) {
+ t.Errorf("extensions in the config dir = %v, want only %v", matches, []string{path})
+ }
+}
diff --git a/internal/config/state_test.go b/internal/config/state_test.go
index a99ca31..5bd8191 100644
--- a/internal/config/state_test.go
+++ b/internal/config/state_test.go
@@ -146,8 +146,13 @@ func TestSettings_MissingFileUsesDefaults(t *testing.T) {
func TestSettings_InvalidJSONReturnsErrorWithoutReplacingFile(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
- cfgDir := filepath.Join(tmp, ".config")
- t.Setenv("XDG_CONFIG_HOME", cfgDir)
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config"))
+ // Ask for the config dir rather than assuming XDG layout: on darwin
+ // os.UserConfigDir ignores XDG_CONFIG_HOME and resolves under HOME.
+ cfgDir, err := os.UserConfigDir()
+ if err != nil {
+ t.Fatal(err)
+ }
path := filepath.Join(cfgDir, "aperture", "settings.json")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
@@ -157,7 +162,7 @@ func TestSettings_InvalidJSONReturnsErrorWithoutReplacingFile(t *testing.T) {
t.Fatal(err)
}
- _, err := config.LoadSettings()
+ _, err = config.LoadSettings()
if err == nil || !strings.Contains(err.Error(), "parsing settings") {
t.Fatalf("LoadSettings error = %v, want parsing error", err)
}