Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.build/
*.test
*.out
.artifacts/
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
<a href="#supported-agents">Supported agents</a> |
<a href="#installation">Installation</a> |
<a href="#usage">Usage</a> |
<a href="#development">Development</a>
<a href="#development">Development</a> |
<a href="#contributing">Contributing</a>
</p>

<p align="center">
Expand All @@ -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
Expand Down Expand Up @@ -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).
3 changes: 3 additions & 0 deletions cmd/aperture/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1,043 changes: 1,043 additions & 0 deletions docs/adding-a-client.md

Large diffs are not rendered by default.

212 changes: 212 additions & 0 deletions internal/clients/hermes/hermes.go
Original file line number Diff line number Diff line change
@@ -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) }
104 changes: 104 additions & 0 deletions internal/clients/hermes/hermes_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
18 changes: 18 additions & 0 deletions internal/clients/hermes/install.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading
Loading