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
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
.PHONY: build test clean install

BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
GIT_HEIGHT := $(shell git rev-list --count HEAD 2>/dev/null || echo 0)

GIT_DESC := $(shell git describe --always)
ifneq ($(shell git status --porcelain),)
GIT_DESC := $(GIT_DESC)-dirty
endif

LDFLAGS := -X main.buildCommit=$(GIT_DESC) -X main.buildDate=$(BUILD_DATE)
LDFLAGS := -X main.buildVersion=B$(GIT_HEIGHT) -X main.buildCommit=$(GIT_DESC) -X main.buildDate=$(BUILD_DATE)

build:
go build -ldflags "$(LDFLAGS)" -o .build/aperture ./cmd/aperture
Expand Down
73 changes: 62 additions & 11 deletions cmd/aperture/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,29 @@ import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"runtime/debug"
"strings"

tea "github.com/charmbracelet/bubbletea"
"github.com/tailscale/aperture-cli/internal/config"
"github.com/tailscale/aperture-cli/internal/profiles"
"github.com/tailscale/aperture-cli/internal/tui"

// 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/gemini"
_ "github.com/tailscale/aperture-cli/internal/clients/opencode"
)

var (
flagVersion = flag.Bool("version", false, "print version and exit")
flagDebug = flag.Bool("debug", false, "print env vars set before launching agent")

buildVersion = "v0.0.0-dev"
buildVersion = "B0-dev"
buildCommit = "unknown"
buildDate = "unknown"
)
Expand All @@ -27,8 +38,12 @@ func init() {
return
}

if buildVersion == "v0.0.0-dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
buildVersion = info.Main.Version
if buildVersion == "B0-dev" {
if height := gitCommitHeight(); height != "" {
buildVersion = "B" + height
} else if info.Main.Version != "" && info.Main.Version != "(devel)" {
buildVersion = info.Main.Version
}
}

// Only fill in VCS info when ldflags haven't already set these values.
Expand Down Expand Up @@ -56,6 +71,41 @@ func init() {
}
}

func gitCommitHeight() string {
_, file, _, ok := runtime.Caller(0)
if !ok {
return ""
}
for dir := filepath.Dir(file); ; dir = filepath.Dir(dir) {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return gitCommitHeightInDir(dir)
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
}
}

func gitCommitHeightInDir(dir string) string {
cmd := exec.Command("git", "rev-list", "--count", "HEAD")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return ""
}
height := strings.TrimSpace(string(out))
if height == "" {
return ""
}
for _, r := range height {
if r < '0' || r > '9' {
return ""
}
}
return height
}

func main() {
flag.Parse()

Expand All @@ -68,16 +118,17 @@ func main() {
os.Exit(0)
}

settings, _ := profiles.LoadSettings()
state, _ := profiles.LoadState()

// Use the first saved endpoint as the active host; fall back to the default.
host := "http://ai"
if len(settings.Endpoints) > 0 {
host = settings.Endpoints[0].URL
g, err := config.Load()
if err != nil {
slog.Error("loading launcher config", "err", err)
os.Exit(1)
}
g.Debug = *flagDebug

// Register Claude Desktop on supported platforms (darwin, windows).
profiles.RegisterIfSupported()

p := tea.NewProgram(tui.NewModel(host, settings, state, *flagDebug))
p := tea.NewProgram(tui.NewModel(g, buildVersion))
if _, err := p.Run(); err != nil {
slog.Error("launcher error", "err", err)
os.Exit(1)
Expand Down
77 changes: 77 additions & 0 deletions internal/clients/binary.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Package clients holds the registry of AI coding agent clients (Claude Code,
// Codex, Gemini, OpenCode, ...). Each client owns its own install, launch,
// and configuration logic inside a sub-package; this file provides shared
// helpers for discovering client binaries on disk.
package clients

import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)

// FindBinary returns the resolved path to a client binary. It checks
// exec.LookPath (i.e. $PATH) first, then the client-supplied extra paths,
// then general well-known user-local binary directories. Returns "" if the
// binary cannot be found.
func FindBinary(name string, extraPaths []string) string {
if name == "" {
return ""
}
if path, err := exec.LookPath(name); err == nil {
return path
}
for _, p := range extraPaths {
if isExecutable(p) {
return p
}
}
for _, dir := range commonBinDirs() {
p := filepath.Join(dir, name)
if isExecutable(p) {
return p
}
}
return ""
}

// IsInstalled reports whether the named binary can be found on disk.
func IsInstalled(name string, extraPaths []string) bool {
if name == "" {
return true
}
return FindBinary(name, extraPaths) != ""
}

// commonBinDirs returns well-known user-local directories that may not be on
// PATH yet (e.g. after a fresh install that updated shell profiles but the
// running shell still has the old PATH). System-wide directories are
// intentionally excluded: binaries there are found by exec.LookPath.
func commonBinDirs() []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
return []string{
filepath.Join(home, ".local", "bin"),
filepath.Join(home, "bin"),
filepath.Join(home, ".npm-global", "bin"),
}
}

func isExecutable(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
if info.IsDir() {
return false
}
if runtime.GOOS == "windows" {
ext := strings.ToLower(filepath.Ext(path))
return ext == ".exe" || ext == ".cmd" || ext == ".bat" || ext == ".com"
}
return info.Mode()&0o111 != 0
}
116 changes: 116 additions & 0 deletions internal/clients/binary_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package clients_test

import (
"os"
"path/filepath"
"testing"

"github.com/tailscale/aperture-cli/internal/clients"
)

func TestFindBinary_PrefersPath(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)

pathBin := filepath.Join(tmp, "pathbin")
if err := os.MkdirAll(pathBin, 0o755); err != nil {
t.Fatal(err)
}
pathBinary := filepath.Join(pathBin, "opencode")
if err := os.WriteFile(pathBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}

commonBin := filepath.Join(tmp, ".opencode", "bin")
if err := os.MkdirAll(commonBin, 0o755); err != nil {
t.Fatal(err)
}
commonBinary := filepath.Join(commonBin, "opencode")
if err := os.WriteFile(commonBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}

t.Setenv("PATH", pathBin)

got := clients.FindBinary("opencode", []string{commonBinary})
if got != pathBinary {
t.Errorf("FindBinary() = %q, want %q (PATH should be preferred)", got, pathBinary)
}
}

func TestFindBinary_FallbackToExtraPaths(t *testing.T) {
tmp := t.TempDir()
t.Setenv("PATH", tmp)
t.Setenv("HOME", tmp)

binDir := filepath.Join(tmp, ".opencode", "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
fakeBinary := filepath.Join(binDir, "opencode")
if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}

got := clients.FindBinary("opencode", []string{fakeBinary})
if got != fakeBinary {
t.Errorf("FindBinary() = %q, want %q", got, fakeBinary)
}
if !clients.IsInstalled("opencode", []string{fakeBinary}) {
t.Error("IsInstalled() = false, want true")
}
}

func TestFindBinary_FallbackToCommonBinDirs(t *testing.T) {
tmp := t.TempDir()
t.Setenv("PATH", tmp)
t.Setenv("HOME", tmp)

localBin := filepath.Join(tmp, ".local", "bin")
if err := os.MkdirAll(localBin, 0o755); err != nil {
t.Fatal(err)
}
fakeBinary := filepath.Join(localBin, "claude")
if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}

got := clients.FindBinary("claude", nil)
if got != fakeBinary {
t.Errorf("FindBinary() = %q, want %q", got, fakeBinary)
}
}

func TestFindBinary_NotFound(t *testing.T) {
tmp := t.TempDir()
t.Setenv("PATH", tmp)
t.Setenv("HOME", tmp)

got := clients.FindBinary("claude", nil)
if got != "" {
t.Errorf("FindBinary() = %q, want empty", got)
}
if clients.IsInstalled("claude", nil) {
t.Error("IsInstalled() = true, want false")
}
}

func TestFindBinary_SkipsNonExecutable(t *testing.T) {
tmp := t.TempDir()
t.Setenv("PATH", tmp)
t.Setenv("HOME", tmp)

binDir := filepath.Join(tmp, ".local", "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
nonExec := filepath.Join(binDir, "claude")
if err := os.WriteFile(nonExec, []byte("not executable"), 0o644); err != nil {
t.Fatal(err)
}

got := clients.FindBinary("claude", nil)
if got != "" {
t.Errorf("FindBinary() = %q, want empty (not executable)", got)
}
}
55 changes: 55 additions & 0 deletions internal/clients/claudecode/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package claudecode

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)

// checkClaudeSettings validates that ~/.claude/settings.json does not set
// environment variables that conflict with what the launcher manages.
// Claude Code applies env from settings.json at startup, which would
// override the values the launcher injects via the process environment.
func checkClaudeSettings() error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("cannot determine home directory: %w", err)
}

settingsPath := filepath.Join(home, ".claude", "settings.json")
data, err := os.ReadFile(settingsPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("cannot read %s\n\nCheck file permissions and try again", settingsPath)
}

var settings struct {
Env map[string]any `json:"env"`
}
if err := json.Unmarshal(data, &settings); err != nil {
return fmt.Errorf("%s contains invalid JSON\n\nFix the syntax or delete the file and let Claude Code recreate it", settingsPath)
}
if len(settings.Env) == 0 {
return nil
}

var conflicts []string
for _, key := range managedEnvVars {
if _, ok := settings.Env[key]; ok {
conflicts = append(conflicts, key)
}
}
if len(conflicts) == 0 {
return nil
}
return fmt.Errorf(
"~/.claude/settings.json sets env vars that conflict with the launcher:\n\n %s\n\n"+
"The launcher manages these variables automatically.\n"+
"Remove them from the \"env\" section of ~/.claude/settings.json",
strings.Join(conflicts, "\n "),
)
}
Loading
Loading