Skip to content

Commit d298d26

Browse files
authored
Refactor UX (#17)
Huge refactor to restructure the code so each harness (internal/clients) has its own implementation. This allows specific logic to easier to manage and in an intuitive location.
1 parent a78cb7c commit d298d26

43 files changed

Lines changed: 4396 additions & 2710 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
.PHONY: build test clean install
22

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

56
GIT_DESC := $(shell git describe --always)
67
ifneq ($(shell git status --porcelain),)
78
GIT_DESC := $(GIT_DESC)-dirty
89
endif
910

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

1213
build:
1314
go build -ldflags "$(LDFLAGS)" -o .build/aperture ./cmd/aperture

cmd/aperture/main.go

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,29 @@ import (
55
"fmt"
66
"log/slog"
77
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"runtime"
811
"runtime/debug"
12+
"strings"
913

1014
tea "github.com/charmbracelet/bubbletea"
15+
"github.com/tailscale/aperture-cli/internal/config"
1116
"github.com/tailscale/aperture-cli/internal/profiles"
1217
"github.com/tailscale/aperture-cli/internal/tui"
18+
19+
// Side-effect imports register each client with internal/clients.
20+
_ "github.com/tailscale/aperture-cli/internal/clients/claudecode"
21+
_ "github.com/tailscale/aperture-cli/internal/clients/codex"
22+
_ "github.com/tailscale/aperture-cli/internal/clients/gemini"
23+
_ "github.com/tailscale/aperture-cli/internal/clients/opencode"
1324
)
1425

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

19-
buildVersion = "v0.0.0-dev"
30+
buildVersion = "B0-dev"
2031
buildCommit = "unknown"
2132
buildDate = "unknown"
2233
)
@@ -27,8 +38,12 @@ func init() {
2738
return
2839
}
2940

30-
if buildVersion == "v0.0.0-dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
31-
buildVersion = info.Main.Version
41+
if buildVersion == "B0-dev" {
42+
if height := gitCommitHeight(); height != "" {
43+
buildVersion = "B" + height
44+
} else if info.Main.Version != "" && info.Main.Version != "(devel)" {
45+
buildVersion = info.Main.Version
46+
}
3247
}
3348

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

74+
func gitCommitHeight() string {
75+
_, file, _, ok := runtime.Caller(0)
76+
if !ok {
77+
return ""
78+
}
79+
for dir := filepath.Dir(file); ; dir = filepath.Dir(dir) {
80+
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
81+
return gitCommitHeightInDir(dir)
82+
}
83+
parent := filepath.Dir(dir)
84+
if parent == dir {
85+
return ""
86+
}
87+
}
88+
}
89+
90+
func gitCommitHeightInDir(dir string) string {
91+
cmd := exec.Command("git", "rev-list", "--count", "HEAD")
92+
cmd.Dir = dir
93+
out, err := cmd.Output()
94+
if err != nil {
95+
return ""
96+
}
97+
height := strings.TrimSpace(string(out))
98+
if height == "" {
99+
return ""
100+
}
101+
for _, r := range height {
102+
if r < '0' || r > '9' {
103+
return ""
104+
}
105+
}
106+
return height
107+
}
108+
59109
func main() {
60110
flag.Parse()
61111

@@ -68,16 +118,17 @@ func main() {
68118
os.Exit(0)
69119
}
70120

71-
settings, _ := profiles.LoadSettings()
72-
state, _ := profiles.LoadState()
73-
74-
// Use the first saved endpoint as the active host; fall back to the default.
75-
host := "http://ai"
76-
if len(settings.Endpoints) > 0 {
77-
host = settings.Endpoints[0].URL
121+
g, err := config.Load()
122+
if err != nil {
123+
slog.Error("loading launcher config", "err", err)
124+
os.Exit(1)
78125
}
126+
g.Debug = *flagDebug
127+
128+
// Register Claude Desktop on supported platforms (darwin, windows).
129+
profiles.RegisterIfSupported()
79130

80-
p := tea.NewProgram(tui.NewModel(host, settings, state, *flagDebug))
131+
p := tea.NewProgram(tui.NewModel(g, buildVersion))
81132
if _, err := p.Run(); err != nil {
82133
slog.Error("launcher error", "err", err)
83134
os.Exit(1)

internal/clients/binary.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Package clients holds the registry of AI coding agent clients (Claude Code,
2+
// Codex, Gemini, OpenCode, ...). Each client owns its own install, launch,
3+
// and configuration logic inside a sub-package; this file provides shared
4+
// helpers for discovering client binaries on disk.
5+
package clients
6+
7+
import (
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"runtime"
12+
"strings"
13+
)
14+
15+
// FindBinary returns the resolved path to a client binary. It checks
16+
// exec.LookPath (i.e. $PATH) first, then the client-supplied extra paths,
17+
// then general well-known user-local binary directories. Returns "" if the
18+
// binary cannot be found.
19+
func FindBinary(name string, extraPaths []string) string {
20+
if name == "" {
21+
return ""
22+
}
23+
if path, err := exec.LookPath(name); err == nil {
24+
return path
25+
}
26+
for _, p := range extraPaths {
27+
if isExecutable(p) {
28+
return p
29+
}
30+
}
31+
for _, dir := range commonBinDirs() {
32+
p := filepath.Join(dir, name)
33+
if isExecutable(p) {
34+
return p
35+
}
36+
}
37+
return ""
38+
}
39+
40+
// IsInstalled reports whether the named binary can be found on disk.
41+
func IsInstalled(name string, extraPaths []string) bool {
42+
if name == "" {
43+
return true
44+
}
45+
return FindBinary(name, extraPaths) != ""
46+
}
47+
48+
// commonBinDirs returns well-known user-local directories that may not be on
49+
// PATH yet (e.g. after a fresh install that updated shell profiles but the
50+
// running shell still has the old PATH). System-wide directories are
51+
// intentionally excluded: binaries there are found by exec.LookPath.
52+
func commonBinDirs() []string {
53+
home, err := os.UserHomeDir()
54+
if err != nil {
55+
return nil
56+
}
57+
return []string{
58+
filepath.Join(home, ".local", "bin"),
59+
filepath.Join(home, "bin"),
60+
filepath.Join(home, ".npm-global", "bin"),
61+
}
62+
}
63+
64+
func isExecutable(path string) bool {
65+
info, err := os.Stat(path)
66+
if err != nil {
67+
return false
68+
}
69+
if info.IsDir() {
70+
return false
71+
}
72+
if runtime.GOOS == "windows" {
73+
ext := strings.ToLower(filepath.Ext(path))
74+
return ext == ".exe" || ext == ".cmd" || ext == ".bat" || ext == ".com"
75+
}
76+
return info.Mode()&0o111 != 0
77+
}

internal/clients/binary_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package clients_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/tailscale/aperture-cli/internal/clients"
9+
)
10+
11+
func TestFindBinary_PrefersPath(t *testing.T) {
12+
tmp := t.TempDir()
13+
t.Setenv("HOME", tmp)
14+
15+
pathBin := filepath.Join(tmp, "pathbin")
16+
if err := os.MkdirAll(pathBin, 0o755); err != nil {
17+
t.Fatal(err)
18+
}
19+
pathBinary := filepath.Join(pathBin, "opencode")
20+
if err := os.WriteFile(pathBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
21+
t.Fatal(err)
22+
}
23+
24+
commonBin := filepath.Join(tmp, ".opencode", "bin")
25+
if err := os.MkdirAll(commonBin, 0o755); err != nil {
26+
t.Fatal(err)
27+
}
28+
commonBinary := filepath.Join(commonBin, "opencode")
29+
if err := os.WriteFile(commonBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
30+
t.Fatal(err)
31+
}
32+
33+
t.Setenv("PATH", pathBin)
34+
35+
got := clients.FindBinary("opencode", []string{commonBinary})
36+
if got != pathBinary {
37+
t.Errorf("FindBinary() = %q, want %q (PATH should be preferred)", got, pathBinary)
38+
}
39+
}
40+
41+
func TestFindBinary_FallbackToExtraPaths(t *testing.T) {
42+
tmp := t.TempDir()
43+
t.Setenv("PATH", tmp)
44+
t.Setenv("HOME", tmp)
45+
46+
binDir := filepath.Join(tmp, ".opencode", "bin")
47+
if err := os.MkdirAll(binDir, 0o755); err != nil {
48+
t.Fatal(err)
49+
}
50+
fakeBinary := filepath.Join(binDir, "opencode")
51+
if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
52+
t.Fatal(err)
53+
}
54+
55+
got := clients.FindBinary("opencode", []string{fakeBinary})
56+
if got != fakeBinary {
57+
t.Errorf("FindBinary() = %q, want %q", got, fakeBinary)
58+
}
59+
if !clients.IsInstalled("opencode", []string{fakeBinary}) {
60+
t.Error("IsInstalled() = false, want true")
61+
}
62+
}
63+
64+
func TestFindBinary_FallbackToCommonBinDirs(t *testing.T) {
65+
tmp := t.TempDir()
66+
t.Setenv("PATH", tmp)
67+
t.Setenv("HOME", tmp)
68+
69+
localBin := filepath.Join(tmp, ".local", "bin")
70+
if err := os.MkdirAll(localBin, 0o755); err != nil {
71+
t.Fatal(err)
72+
}
73+
fakeBinary := filepath.Join(localBin, "claude")
74+
if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
75+
t.Fatal(err)
76+
}
77+
78+
got := clients.FindBinary("claude", nil)
79+
if got != fakeBinary {
80+
t.Errorf("FindBinary() = %q, want %q", got, fakeBinary)
81+
}
82+
}
83+
84+
func TestFindBinary_NotFound(t *testing.T) {
85+
tmp := t.TempDir()
86+
t.Setenv("PATH", tmp)
87+
t.Setenv("HOME", tmp)
88+
89+
got := clients.FindBinary("claude", nil)
90+
if got != "" {
91+
t.Errorf("FindBinary() = %q, want empty", got)
92+
}
93+
if clients.IsInstalled("claude", nil) {
94+
t.Error("IsInstalled() = true, want false")
95+
}
96+
}
97+
98+
func TestFindBinary_SkipsNonExecutable(t *testing.T) {
99+
tmp := t.TempDir()
100+
t.Setenv("PATH", tmp)
101+
t.Setenv("HOME", tmp)
102+
103+
binDir := filepath.Join(tmp, ".local", "bin")
104+
if err := os.MkdirAll(binDir, 0o755); err != nil {
105+
t.Fatal(err)
106+
}
107+
nonExec := filepath.Join(binDir, "claude")
108+
if err := os.WriteFile(nonExec, []byte("not executable"), 0o644); err != nil {
109+
t.Fatal(err)
110+
}
111+
112+
got := clients.FindBinary("claude", nil)
113+
if got != "" {
114+
t.Errorf("FindBinary() = %q, want empty (not executable)", got)
115+
}
116+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package claudecode
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
)
10+
11+
// checkClaudeSettings validates that ~/.claude/settings.json does not set
12+
// environment variables that conflict with what the launcher manages.
13+
// Claude Code applies env from settings.json at startup, which would
14+
// override the values the launcher injects via the process environment.
15+
func checkClaudeSettings() error {
16+
home, err := os.UserHomeDir()
17+
if err != nil {
18+
return fmt.Errorf("cannot determine home directory: %w", err)
19+
}
20+
21+
settingsPath := filepath.Join(home, ".claude", "settings.json")
22+
data, err := os.ReadFile(settingsPath)
23+
if os.IsNotExist(err) {
24+
return nil
25+
}
26+
if err != nil {
27+
return fmt.Errorf("cannot read %s\n\nCheck file permissions and try again", settingsPath)
28+
}
29+
30+
var settings struct {
31+
Env map[string]any `json:"env"`
32+
}
33+
if err := json.Unmarshal(data, &settings); err != nil {
34+
return fmt.Errorf("%s contains invalid JSON\n\nFix the syntax or delete the file and let Claude Code recreate it", settingsPath)
35+
}
36+
if len(settings.Env) == 0 {
37+
return nil
38+
}
39+
40+
var conflicts []string
41+
for _, key := range managedEnvVars {
42+
if _, ok := settings.Env[key]; ok {
43+
conflicts = append(conflicts, key)
44+
}
45+
}
46+
if len(conflicts) == 0 {
47+
return nil
48+
}
49+
return fmt.Errorf(
50+
"~/.claude/settings.json sets env vars that conflict with the launcher:\n\n %s\n\n"+
51+
"The launcher manages these variables automatically.\n"+
52+
"Remove them from the \"env\" section of ~/.claude/settings.json",
53+
strings.Join(conflicts, "\n "),
54+
)
55+
}

0 commit comments

Comments
 (0)