From 53a12a9f544b6051e954f5c9445a532f174d1275 Mon Sep 17 00:00:00 2001 From: Mingthean Lay <39415776+mingtheanlay@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:48:28 +0700 Subject: [PATCH 1/4] fix: detect Codex without auth file --- internal/tools/codex.go | 6 +++++- internal/tools/tools_test.go | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/tools/codex.go b/internal/tools/codex.go index 0c58433..91bc12e 100644 --- a/internal/tools/codex.go +++ b/internal/tools/codex.go @@ -3,6 +3,7 @@ package tools import ( "encoding/json" "os" + "os/exec" "path/filepath" "strings" @@ -77,7 +78,10 @@ func newCodex() *Tool { return writeTOMLMap(configPath, cfg, 0o600) }, Detected: func() bool { - _, err := os.Stat(authPath) + if _, err := exec.LookPath("codex"); err == nil { + return true + } + _, err := os.Stat(dir) return err == nil }, Describe: func() (Info, error) { diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index d37bdea..1bc3cd2 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -76,6 +76,15 @@ func TestCodexDescribeAndApply(t *testing.T) { } } +func TestCodexDetectedWithoutAuth(t *testing.T) { + home := sandboxHome(t) + writeFile(t, filepath.Join(home, ".codex", "config.toml"), "model = \"gpt-5.5\"\n") + + if !Find("codex").Detected() { + t.Fatal("codex should be detected via ~/.codex without auth.json") + } +} + func TestCodexPinsClaudeContextWindow(t *testing.T) { home := sandboxHome(t) // A prior Claude profile pinned the window; switching to an OpenAI model @@ -515,6 +524,7 @@ func TestEnsureOnlyCharonChanged(t *testing.T) { func TestNotDetectedInEmptyHome(t *testing.T) { sandboxHome(t) + t.Setenv("PATH", t.TempDir()) for _, name := range []string{"codex", "opencode", "pi"} { if Find(name).Detected() { t.Errorf("%s should not be detected in empty HOME", name) From 3a1adbf185ec155128fa44463ea0e45685565da8 Mon Sep 17 00:00:00 2001 From: Mingthean Lay <39415776+mingtheanlay@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:51:28 +0700 Subject: [PATCH 2/4] fix: detect installed tools without auth --- internal/tools/claude.go | 5 +++-- internal/tools/codex.go | 7 +------ internal/tools/opencode.go | 7 ++++--- internal/tools/pi.go | 3 +-- internal/tools/tool.go | 15 +++++++++++++++ internal/tools/tools_test.go | 26 ++++++++++++++++++++++---- 6 files changed, 46 insertions(+), 17 deletions(-) diff --git a/internal/tools/claude.go b/internal/tools/claude.go index 3a42294..26133c6 100644 --- a/internal/tools/claude.go +++ b/internal/tools/claude.go @@ -14,7 +14,8 @@ const claudeKeychainService = "Claude Code-credentials" // newClaude describes Claude Code: API keys in ~/.claude/settings.json, OAuth in the keychain. func newClaude() *Tool { - settingsPath := filepath.Join(home(), ".claude", "settings.json") + dir := filepath.Join(home(), ".claude") + settingsPath := filepath.Join(dir, "settings.json") return &Tool{ Name: "claude", @@ -70,7 +71,7 @@ func newClaude() *Tool { return writeJSONMap(settingsPath, s, 0o600) }, Detected: func() bool { - if _, err := os.Stat(settingsPath); err == nil { + if detected("claude", dir) { return true } _, err := secret.KeychainRead(claudeKeychainService) diff --git a/internal/tools/codex.go b/internal/tools/codex.go index 91bc12e..33a4c5b 100644 --- a/internal/tools/codex.go +++ b/internal/tools/codex.go @@ -3,7 +3,6 @@ package tools import ( "encoding/json" "os" - "os/exec" "path/filepath" "strings" @@ -78,11 +77,7 @@ func newCodex() *Tool { return writeTOMLMap(configPath, cfg, 0o600) }, Detected: func() bool { - if _, err := exec.LookPath("codex"); err == nil { - return true - } - _, err := os.Stat(dir) - return err == nil + return detected("codex", dir) }, Describe: func() (Info, error) { var info Info diff --git a/internal/tools/opencode.go b/internal/tools/opencode.go index 321ff5b..f1c6770 100644 --- a/internal/tools/opencode.go +++ b/internal/tools/opencode.go @@ -27,7 +27,9 @@ func opencodeConfigPath() string { // credentials in ~/.local/share/opencode/auth.json. func newOpenCode() *Tool { configPath := opencodeConfigPath() - authPath := filepath.Join(home(), ".local", "share", "opencode", "auth.json") + configDir := filepath.Dir(configPath) + dataDir := filepath.Join(home(), ".local", "share", "opencode") + authPath := filepath.Join(dataDir, "auth.json") return &Tool{ Name: "opencode", @@ -106,8 +108,7 @@ func newOpenCode() *Tool { return writeJSONMap(configPath, cfg, 0o600) }, Detected: func() bool { - _, err := os.Stat(authPath) - return err == nil + return detected("opencode", configDir, dataDir) }, Describe: func() (Info, error) { var info Info diff --git a/internal/tools/pi.go b/internal/tools/pi.go index 2472acd..fed20d4 100644 --- a/internal/tools/pi.go +++ b/internal/tools/pi.go @@ -195,8 +195,7 @@ func newPi() *Tool { return writeJSONMap(settingsPath, s, 0o600) }, Detected: func() bool { - _, err := os.Stat(dir) - return err == nil + return detected("pi", dir) }, Describe: func() (Info, error) { var info Info diff --git a/internal/tools/tool.go b/internal/tools/tool.go index fc24646..35ecac7 100644 --- a/internal/tools/tool.go +++ b/internal/tools/tool.go @@ -2,6 +2,9 @@ package tools import ( + "os" + "os/exec" + "charon/internal/artifact" ) @@ -46,6 +49,18 @@ type Tool struct { ApplyAuth func(AuthSpec) error // write endpoint/key/model into live config } +func detected(executable string, paths ...string) bool { + if _, err := exec.LookPath(executable); err == nil { + return true + } + for _, path := range paths { + if _, err := os.Stat(path); err == nil { + return true + } + } + return false +} + // All returns the supported tools in a stable display order. func All() []*Tool { return []*Tool{newCodex(), newClaude(), newOpenCode(), newPi()} diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index 1bc3cd2..6fbcf22 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -76,12 +76,30 @@ func TestCodexDescribeAndApply(t *testing.T) { } } -func TestCodexDetectedWithoutAuth(t *testing.T) { +func TestDetectedWithoutAuth(t *testing.T) { home := sandboxHome(t) - writeFile(t, filepath.Join(home, ".codex", "config.toml"), "model = \"gpt-5.5\"\n") + t.Setenv("PATH", t.TempDir()) - if !Find("codex").Detected() { - t.Fatal("codex should be detected via ~/.codex without auth.json") + for _, tc := range []struct { + tool string + dir string + }{ + {tool: "codex", dir: filepath.Join(home, ".codex")}, + {tool: "claude", dir: filepath.Join(home, ".claude")}, + {tool: "opencode", dir: filepath.Join(home, ".config", "opencode")}, + {tool: "pi", dir: filepath.Join(home, ".pi", "agent")}, + } { + t.Run(tc.tool, func(t *testing.T) { + if err := os.MkdirAll(tc.dir, 0o700); err != nil { + t.Fatal(err) + } + if !Find(tc.tool).Detected() { + t.Errorf("%s should be detected via config directory without auth", tc.tool) + } + if err := os.RemoveAll(tc.dir); err != nil { + t.Fatal(err) + } + }) } } From e677069ddea1340ae344ed4b7dc9602e4296a94d Mon Sep 17 00:00:00 2001 From: Mingthean Lay <39415776+mingtheanlay@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:06:42 +0700 Subject: [PATCH 3/4] fix: ignore stale empty tool directories --- internal/tools/claude.go | 2 +- internal/tools/codex.go | 2 +- internal/tools/opencode.go | 3 +-- internal/tools/pi.go | 2 +- internal/tools/tools_test.go | 41 ++++++++++++++++++++++++++---------- 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/internal/tools/claude.go b/internal/tools/claude.go index 26133c6..ee8e91e 100644 --- a/internal/tools/claude.go +++ b/internal/tools/claude.go @@ -71,7 +71,7 @@ func newClaude() *Tool { return writeJSONMap(settingsPath, s, 0o600) }, Detected: func() bool { - if detected("claude", dir) { + if detected("claude", settingsPath) { return true } _, err := secret.KeychainRead(claudeKeychainService) diff --git a/internal/tools/codex.go b/internal/tools/codex.go index 33a4c5b..cc82bec 100644 --- a/internal/tools/codex.go +++ b/internal/tools/codex.go @@ -77,7 +77,7 @@ func newCodex() *Tool { return writeTOMLMap(configPath, cfg, 0o600) }, Detected: func() bool { - return detected("codex", dir) + return detected("codex", configPath, authPath) }, Describe: func() (Info, error) { var info Info diff --git a/internal/tools/opencode.go b/internal/tools/opencode.go index f1c6770..f29d651 100644 --- a/internal/tools/opencode.go +++ b/internal/tools/opencode.go @@ -27,7 +27,6 @@ func opencodeConfigPath() string { // credentials in ~/.local/share/opencode/auth.json. func newOpenCode() *Tool { configPath := opencodeConfigPath() - configDir := filepath.Dir(configPath) dataDir := filepath.Join(home(), ".local", "share", "opencode") authPath := filepath.Join(dataDir, "auth.json") @@ -108,7 +107,7 @@ func newOpenCode() *Tool { return writeJSONMap(configPath, cfg, 0o600) }, Detected: func() bool { - return detected("opencode", configDir, dataDir) + return detected("opencode", configPath, authPath) }, Describe: func() (Info, error) { var info Info diff --git a/internal/tools/pi.go b/internal/tools/pi.go index fed20d4..9a31dec 100644 --- a/internal/tools/pi.go +++ b/internal/tools/pi.go @@ -195,7 +195,7 @@ func newPi() *Tool { return writeJSONMap(settingsPath, s, 0o600) }, Detected: func() bool { - return detected("pi", dir) + return detected("pi", settingsPath, authPath, extensionPath) }, Describe: func() (Info, error) { var info Info diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index 6fbcf22..fe2f4ab 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -81,28 +81,47 @@ func TestDetectedWithoutAuth(t *testing.T) { t.Setenv("PATH", t.TempDir()) for _, tc := range []struct { - tool string - dir string + tool string + config string }{ - {tool: "codex", dir: filepath.Join(home, ".codex")}, - {tool: "claude", dir: filepath.Join(home, ".claude")}, - {tool: "opencode", dir: filepath.Join(home, ".config", "opencode")}, - {tool: "pi", dir: filepath.Join(home, ".pi", "agent")}, + {tool: "codex", config: filepath.Join(home, ".codex", "config.toml")}, + {tool: "claude", config: filepath.Join(home, ".claude", "settings.json")}, + {tool: "opencode", config: filepath.Join(home, ".config", "opencode", "opencode.jsonc")}, + {tool: "pi", config: filepath.Join(home, ".pi", "agent", "settings.json")}, } { t.Run(tc.tool, func(t *testing.T) { - if err := os.MkdirAll(tc.dir, 0o700); err != nil { - t.Fatal(err) - } + writeFile(t, tc.config, "") if !Find(tc.tool).Detected() { - t.Errorf("%s should be detected via config directory without auth", tc.tool) + t.Errorf("%s should be detected via config without auth", tc.tool) } - if err := os.RemoveAll(tc.dir); err != nil { + if err := os.RemoveAll(filepath.Dir(tc.config)); err != nil { t.Fatal(err) } }) } } +func TestEmptyConfigDirectoriesAreNotDetected(t *testing.T) { + home := sandboxHome(t) + t.Setenv("PATH", t.TempDir()) + + for _, dir := range []string{ + filepath.Join(home, ".codex"), + filepath.Join(home, ".claude"), + filepath.Join(home, ".config", "opencode"), + filepath.Join(home, ".pi", "agent"), + } { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + for _, name := range []string{"codex", "claude", "opencode", "pi"} { + if Find(name).Detected() { + t.Errorf("%s should not be detected via an empty config directory", name) + } + } +} + func TestCodexPinsClaudeContextWindow(t *testing.T) { home := sandboxHome(t) // A prior Claude profile pinned the window; switching to an OpenAI model From bd42b8dc7df4fee9e38df2af749d10ab68fc9b79 Mon Sep 17 00:00:00 2001 From: Mingthean Lay <39415776+mingtheanlay@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:10:30 +0700 Subject: [PATCH 4/4] test: detect Pi from settings artifact --- internal/tools/tools_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index fe2f4ab..88ac2c3 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -572,13 +572,11 @@ func TestNotDetectedInEmptyHome(t *testing.T) { func TestPiDescribeAndApply(t *testing.T) { home := sandboxHome(t) dir := filepath.Join(home, ".pi", "agent") - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } + writeFile(t, filepath.Join(dir, "settings.json"), `{}`) c := Find("pi") if !c.Detected() { - t.Fatal("pi should be detected via ~/.pi/agent") + t.Fatal("pi should be detected via settings.json") } if err := c.ApplyAuth(AuthSpec{ Endpoint: "https://openrouter.ai/api/v1",