Files
pad/internal/mcp/install_test.go
T
xarmian 43c31c826e feat(cli): add claude-code + codex targets to pad mcp install (TASK-2040) (#909)
Extend `pad mcp install/uninstall/status` beyond the three JSON desktop
clients to cover the two most prominent CLI agents:

- claude-code — writes a project-local `.mcp.json` in the current
  directory (JSON, same mcpServers shape as the other clients). Because
  the config is project-scoped, it's install-on-request only: excluded
  from `--all` and `pad mcp status`, which cover the per-user clients.
- codex — writes an `[mcp_servers.pad]` table into `~/.codex/config.toml`
  (TOML). New load/merge/write path (BurntSushi/toml) that preserves
  unrelated top-level keys and other mcp_servers entries, is idempotent,
  tightens perms to 0600, and refuses to clobber a non-table mcp_servers.

Generalizes the Agent struct with a Format discriminator (JSON/TOML) and
a CWDBased flag; Install/Uninstall/Status dispatch to the right
reader/writer and resolve cwd-vs-home per agent. Existing
claude-desktop/cursor/windsurf behavior is unchanged. FindAgent's error
string is now built from the agent list. Docs updated in README.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-10 22:04:22 -04:00

835 lines
24 KiB
Go

package mcp
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/BurntSushi/toml"
)
func TestFindAgent_NameAndAliases(t *testing.T) {
cases := []struct {
input string
wantName string
wantError bool
}{
{"claude-desktop", "claude-desktop", false},
{"claude", "claude-desktop", false}, // alias
{"Claude", "claude-desktop", false}, // case-insensitive
{"anthropic", "claude-desktop", false},
{"cursor", "cursor", false},
{"windsurf", "windsurf", false},
{"claude-code", "claude-code", false},
{"claudecode", "claude-code", false}, // alias
{"Claude-Code", "claude-code", false}, // case-insensitive
{"codex", "codex", false},
{"CODEX", "codex", false}, // case-insensitive
{"vscode", "", true}, // unsupported
{"", "", true}, // empty
}
for _, c := range cases {
got, err := FindAgent(c.input)
if c.wantError {
if err == nil {
t.Errorf("FindAgent(%q) expected error, got %+v", c.input, got)
}
continue
}
if err != nil {
t.Errorf("FindAgent(%q): %v", c.input, err)
continue
}
if got.Name != c.wantName {
t.Errorf("FindAgent(%q).Name = %q, want %q", c.input, got.Name, c.wantName)
}
}
}
func TestPathResolvers_LinuxAndDarwin(t *testing.T) {
// Path-resolution logic is platform-aware. Test against fixed
// home + goos values so the assertions don't depend on the
// host OS.
cases := []struct {
agentName string
goos string
wantPath string
}{
{"claude-desktop", "linux", "/h/.config/Claude/claude_desktop_config.json"},
{"claude-desktop", "darwin", "/h/Library/Application Support/Claude/claude_desktop_config.json"},
{"cursor", "linux", "/h/.cursor/mcp.json"},
{"cursor", "darwin", "/h/.cursor/mcp.json"},
{"windsurf", "linux", "/h/.codeium/windsurf/mcp_config.json"},
{"codex", "linux", "/h/.codex/config.toml"},
{"codex", "darwin", "/h/.codex/config.toml"},
// claude-code's base is the working directory, not home — but the
// PathFor contract is the same (join base + relative path), so we
// pass "/h" as the base here and get the project-local file back.
{"claude-code", "linux", "/h/.mcp.json"},
{"claude-code", "darwin", "/h/.mcp.json"},
}
for _, c := range cases {
agent, _ := FindAgent(c.agentName)
got, err := agent.PathFor("/h", c.goos)
if err != nil {
t.Errorf("%s/%s: %v", c.agentName, c.goos, err)
continue
}
if got != c.wantPath {
t.Errorf("%s on %s = %q, want %q", c.agentName, c.goos, got, c.wantPath)
}
}
}
func TestAddPadEntry_NewConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "claude_desktop_config.json")
modified, err := AddPadEntry(path, "/usr/local/bin/pad")
if err != nil {
t.Fatalf("AddPadEntry: %v", err)
}
if !modified {
t.Errorf("expected modified=true on fresh install")
}
cfg := readConfig(t, path)
servers := cfg["mcpServers"].(map[string]any)
pad := servers[MCPServerKey].(map[string]any)
if pad["command"] != "/usr/local/bin/pad" {
t.Errorf("command = %v, want /usr/local/bin/pad", pad["command"])
}
args := pad["args"].([]any)
if len(args) != 2 || args[0] != "mcp" || args[1] != "serve" {
t.Errorf("args = %v, want [mcp serve]", args)
}
}
func TestAddPadEntry_PreservesOtherServers(t *testing.T) {
// The user has another MCP server configured (e.g. a postgres
// MCP). Our install MUST NOT clobber that entry — only modify
// `mcpServers.pad`.
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
existing := map[string]any{
"mcpServers": map[string]any{
"postgres": map[string]any{
"command": "/usr/local/bin/postgres-mcp",
"args": []any{"--db", "main"},
},
},
"theme": "dark",
}
writeConfig(t, path, existing)
if _, err := AddPadEntry(path, "/usr/bin/pad"); err != nil {
t.Fatalf("AddPadEntry: %v", err)
}
cfg := readConfig(t, path)
servers := cfg["mcpServers"].(map[string]any)
if _, ok := servers["postgres"]; !ok {
t.Errorf("preserved postgres entry was removed; servers=%v", servers)
}
if _, ok := servers["pad"]; !ok {
t.Errorf("pad entry not added; servers=%v", servers)
}
// Top-level keys outside mcpServers also preserved.
if cfg["theme"] != "dark" {
t.Errorf("unrelated top-level key 'theme' was lost: %v", cfg["theme"])
}
}
func TestAddPadEntry_Idempotent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if _, err := AddPadEntry(path, "/usr/bin/pad"); err != nil {
t.Fatalf("first install: %v", err)
}
modified, err := AddPadEntry(path, "/usr/bin/pad")
if err != nil {
t.Fatalf("second install: %v", err)
}
if modified {
t.Errorf("re-install with identical config should report modified=false")
}
}
func TestAddPadEntry_BinaryPathChangeMarksModified(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if _, err := AddPadEntry(path, "/old/pad"); err != nil {
t.Fatalf("initial: %v", err)
}
modified, err := AddPadEntry(path, "/new/pad")
if err != nil {
t.Fatalf("update: %v", err)
}
if !modified {
t.Errorf("binary path change should mark modified=true")
}
cfg := readConfig(t, path)
servers := cfg["mcpServers"].(map[string]any)
pad := servers[MCPServerKey].(map[string]any)
if pad["command"] != "/new/pad" {
t.Errorf("expected updated command, got %v", pad["command"])
}
}
func TestAddPadEntry_RequiresBinary(t *testing.T) {
if _, err := AddPadEntry("/tmp/x", ""); err == nil {
t.Errorf("expected error when binary path empty")
}
}
func TestRemovePadEntry_RemovesOnlyPad(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
writeConfig(t, path, map[string]any{
"mcpServers": map[string]any{
"pad": map[string]any{"command": "/usr/bin/pad"},
"postgres": map[string]any{"command": "/usr/bin/postgres-mcp"},
},
})
removed, err := RemovePadEntry(path)
if err != nil {
t.Fatalf("RemovePadEntry: %v", err)
}
if !removed {
t.Errorf("expected removed=true")
}
cfg := readConfig(t, path)
servers := cfg["mcpServers"].(map[string]any)
if _, ok := servers["pad"]; ok {
t.Errorf("pad entry not removed")
}
if _, ok := servers["postgres"]; !ok {
t.Errorf("postgres entry should be preserved")
}
}
func TestRemovePadEntry_MissingFileIsNoOp(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "does-not-exist.json")
removed, err := RemovePadEntry(path)
if err != nil {
t.Fatalf("missing file should be no-op, got: %v", err)
}
if removed {
t.Errorf("expected removed=false for missing file")
}
}
func TestRemovePadEntry_MissingEntryIsNoOp(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
writeConfig(t, path, map[string]any{"mcpServers": map[string]any{"postgres": map[string]any{}}})
removed, err := RemovePadEntry(path)
if err != nil {
t.Fatalf("RemovePadEntry: %v", err)
}
if removed {
t.Errorf("expected removed=false when pad entry not present")
}
}
func TestHasPadEntry_AllPaths(t *testing.T) {
dir := t.TempDir()
// missing file
missing := filepath.Join(dir, "missing.json")
installed, _, err := HasPadEntry(missing)
if err != nil {
t.Errorf("missing file: unexpected error: %v", err)
}
if installed {
t.Errorf("missing file: expected installed=false")
}
// no mcpServers
bare := filepath.Join(dir, "bare.json")
writeConfig(t, bare, map[string]any{"theme": "dark"})
installed, _, err = HasPadEntry(bare)
if err != nil || installed {
t.Errorf("bare config: installed=%v err=%v, want (false, nil)", installed, err)
}
// pad present
full := filepath.Join(dir, "full.json")
writeConfig(t, full, map[string]any{"mcpServers": map[string]any{"pad": map[string]any{"command": "/p"}}})
installed, cmd, err := HasPadEntry(full)
if err != nil {
t.Errorf("full config: %v", err)
}
if !installed {
t.Errorf("expected installed=true")
}
if cmd != "/p" {
t.Errorf("command = %q, want /p", cmd)
}
}
func TestInstaller_Install_RoundTripWithTempHome(t *testing.T) {
tmpHome := t.TempDir()
inst := &Installer{
Binary: "/usr/local/bin/pad",
Home: tmpHome,
GOOS: "linux",
}
path, modified, err := inst.Install("claude-desktop")
if err != nil {
t.Fatalf("Install: %v", err)
}
if !modified {
t.Errorf("first install should modify=true")
}
wantPath := filepath.Join(tmpHome, ".config", "Claude", "claude_desktop_config.json")
if path != wantPath {
t.Errorf("path = %q, want %q", path, wantPath)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("expected config file written at %s, got: %v", path, err)
}
}
func TestInstaller_Install_MissingBinaryErrors(t *testing.T) {
inst := &Installer{Home: t.TempDir(), GOOS: "linux"}
if _, _, err := inst.Install("cursor"); err == nil {
t.Errorf("expected error when Binary is empty")
}
}
func TestInstaller_Uninstall_AfterInstall(t *testing.T) {
tmpHome := t.TempDir()
inst := &Installer{Binary: "/p", Home: tmpHome, GOOS: "linux"}
if _, _, err := inst.Install("cursor"); err != nil {
t.Fatalf("install: %v", err)
}
_, removed, err := inst.Uninstall("cursor")
if err != nil {
t.Fatalf("uninstall: %v", err)
}
if !removed {
t.Errorf("expected removed=true after install")
}
// Re-uninstall is idempotent.
_, removed, err = inst.Uninstall("cursor")
if err != nil {
t.Fatalf("second uninstall: %v", err)
}
if removed {
t.Errorf("second uninstall should be no-op")
}
}
func TestInstaller_Status_ReportsAllAgents(t *testing.T) {
tmpHome := t.TempDir()
inst := &Installer{Binary: "/p", Home: tmpHome, GOOS: "linux"}
// install only cursor; status should reflect mixed state.
if _, _, err := inst.Install("cursor"); err != nil {
t.Fatalf("install cursor: %v", err)
}
status := inst.Status()
// Status reports only global (per-user) agents; CWDBased agents like
// claude-code are install-on-request and omitted from the sweep.
if len(status) != len(globalAgents()) {
t.Fatalf("status returned %d rows, want %d", len(status), len(globalAgents()))
}
for _, row := range status {
if row.Name == "claude-code" {
t.Errorf("claude-code (CWDBased) must not appear in Status()")
}
switch row.Name {
case "cursor":
if !row.Installed {
t.Errorf("cursor should be installed")
}
if row.Command != "/p" {
t.Errorf("cursor command = %q, want /p", row.Command)
}
default:
if row.Installed {
t.Errorf("%s reports installed but we never wrote it", row.Name)
}
}
if row.ConfigPath == "" && row.Error == "" {
t.Errorf("%s: ConfigPath and Error both empty", row.Name)
}
}
}
func TestAddPadEntry_HandlesEmptyAndWhitespaceFile(t *testing.T) {
dir := t.TempDir()
cases := []string{"", " \n\t", "\n"}
for _, body := range cases {
path := filepath.Join(dir, "x.json")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if _, err := AddPadEntry(path, "/p"); err != nil {
t.Errorf("empty body %q should be treated as fresh config; got %v", body, err)
}
_ = os.Remove(path)
}
}
func TestAddPadEntry_TightensPermsOnIdempotentNoop(t *testing.T) {
// Codex round 2 (TASK-948): the idempotent path returns BEFORE
// writeJSONConfig, so an already-up-to-date 0644 config kept
// loose perms. Tighten on the no-op path too.
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
desired := map[string]any{
"mcpServers": map[string]any{
"pad": map[string]any{
"command": "/p",
"args": []any{"mcp", "serve"},
},
},
}
b, _ := json.MarshalIndent(desired, "", " ")
if err := os.WriteFile(path, b, 0o644); err != nil {
t.Fatal(err)
}
modified, err := AddPadEntry(path, "/p")
if err != nil {
t.Fatalf("AddPadEntry: %v", err)
}
if modified {
t.Errorf("expected modified=false on identical content")
}
info, _ := os.Stat(path)
if mode := info.Mode().Perm(); mode != 0o600 {
t.Errorf("expected 0600 even on no-op path, got %#o", mode)
}
}
func TestAddPadEntry_TightensExistingFilePerms(t *testing.T) {
// Codex round 1 (TASK-948): os.WriteFile only honors the mode
// when CREATING the file. If the user had a pre-existing 0644
// config (the system default), the install must still leave it
// 0600 so the credentials in OTHER MCP server entries don't leak.
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
// Pre-create with loose perms.
if err := os.WriteFile(path, []byte(`{"mcpServers":{}}`), 0o644); err != nil {
t.Fatal(err)
}
if _, err := AddPadEntry(path, "/p"); err != nil {
t.Fatalf("AddPadEntry: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
mode := info.Mode().Perm()
if mode != 0o600 {
t.Errorf("expected 0600 after install, got %#o", mode)
}
}
func TestAddPadEntry_RejectsCorruptJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "broken.json")
if err := os.WriteFile(path, []byte(`{"mcpServers": {`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := AddPadEntry(path, "/p"); err == nil {
t.Errorf("expected error on malformed JSON; we should NOT silently overwrite")
}
}
// --- Codex (TOML) ---------------------------------------------------------
func TestAddPadEntryTOML_NewConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
modified, err := addPadEntryTOML(path, "/usr/local/bin/pad")
if err != nil {
t.Fatalf("addPadEntryTOML: %v", err)
}
if !modified {
t.Errorf("expected modified=true on fresh install")
}
cfg := readTOML(t, path)
servers := cfg[codexServersKey].(map[string]any)
pad := servers[MCPServerKey].(map[string]any)
if pad["command"] != "/usr/local/bin/pad" {
t.Errorf("command = %v, want /usr/local/bin/pad", pad["command"])
}
args := pad["args"].([]any)
if len(args) != 2 || args[0] != "mcp" || args[1] != "serve" {
t.Errorf("args = %v, want [mcp serve]", args)
}
// Sanity: the file is valid TOML with the expected table header.
raw, _ := os.ReadFile(path)
if !strings.Contains(string(raw), "[mcp_servers.pad]") {
t.Errorf("expected [mcp_servers.pad] table, got:\n%s", raw)
}
}
func TestAddPadEntryTOML_PreservesUnrelatedKeys(t *testing.T) {
// A real Codex config has top-level keys (model, approval policy) and
// possibly other mcp_servers. Install must merge, not clobber.
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
existing := `model = "gpt-5"
approval_policy = "on-request"
[mcp_servers.other]
command = "/usr/local/bin/other-mcp"
args = ["run"]
`
if err := os.WriteFile(path, []byte(existing), 0o600); err != nil {
t.Fatal(err)
}
if _, err := addPadEntryTOML(path, "/usr/bin/pad"); err != nil {
t.Fatalf("addPadEntryTOML: %v", err)
}
cfg := readTOML(t, path)
if cfg["model"] != "gpt-5" {
t.Errorf("unrelated top-level key 'model' lost: %v", cfg["model"])
}
if cfg["approval_policy"] != "on-request" {
t.Errorf("unrelated top-level key 'approval_policy' lost: %v", cfg["approval_policy"])
}
servers := cfg[codexServersKey].(map[string]any)
if _, ok := servers["other"]; !ok {
t.Errorf("preserved 'other' mcp server was removed; servers=%v", servers)
}
if _, ok := servers["pad"]; !ok {
t.Errorf("pad entry not added; servers=%v", servers)
}
}
func TestAddPadEntryTOML_Idempotent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if _, err := addPadEntryTOML(path, "/usr/bin/pad"); err != nil {
t.Fatalf("first install: %v", err)
}
modified, err := addPadEntryTOML(path, "/usr/bin/pad")
if err != nil {
t.Fatalf("second install: %v", err)
}
if modified {
t.Errorf("re-install with identical config should report modified=false")
}
}
func TestAddPadEntryTOML_BinaryChangeMarksModified(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if _, err := addPadEntryTOML(path, "/old/pad"); err != nil {
t.Fatalf("initial: %v", err)
}
modified, err := addPadEntryTOML(path, "/new/pad")
if err != nil {
t.Fatalf("update: %v", err)
}
if !modified {
t.Errorf("binary path change should mark modified=true")
}
cfg := readTOML(t, path)
pad := cfg[codexServersKey].(map[string]any)[MCPServerKey].(map[string]any)
if pad["command"] != "/new/pad" {
t.Errorf("expected updated command, got %v", pad["command"])
}
}
func TestAddPadEntryTOML_RequiresBinary(t *testing.T) {
if _, err := addPadEntryTOML("/tmp/x.toml", ""); err == nil {
t.Errorf("expected error when binary path empty")
}
}
func TestAddPadEntryTOML_RejectsCorruptTOML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "broken.toml")
if err := os.WriteFile(path, []byte("this is = not [valid toml"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := addPadEntryTOML(path, "/p"); err == nil {
t.Errorf("expected error on malformed TOML; we should NOT silently overwrite")
}
}
func TestAddPadEntryTOML_RejectsNonTableServers(t *testing.T) {
// mcp_servers present but a scalar — refuse rather than clobber a
// config we can't reconcile.
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte("mcp_servers = \"legacy\"\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := addPadEntryTOML(path, "/p"); err == nil {
t.Errorf("expected error when mcp_servers is not a table; must not clobber")
}
// Original content untouched.
b, _ := os.ReadFile(path)
if !strings.Contains(string(b), `mcp_servers = "legacy"`) {
t.Errorf("config was modified despite rejection:\n%s", b)
}
}
func TestAddPadEntryTOML_TightensPerms(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
// Pre-create loose (Codex configs can hold other servers' secrets).
if err := os.WriteFile(path, []byte("model = \"x\"\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := addPadEntryTOML(path, "/p"); err != nil {
t.Fatalf("addPadEntryTOML: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if mode := info.Mode().Perm(); mode != 0o600 {
t.Errorf("expected 0600 after install, got %#o", mode)
}
}
func TestRemovePadEntryTOML_RemovesOnlyPad(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
existing := `[mcp_servers.pad]
command = "/usr/bin/pad"
[mcp_servers.other]
command = "/usr/bin/other"
`
if err := os.WriteFile(path, []byte(existing), 0o600); err != nil {
t.Fatal(err)
}
removed, err := removePadEntryTOML(path)
if err != nil {
t.Fatalf("removePadEntryTOML: %v", err)
}
if !removed {
t.Errorf("expected removed=true")
}
cfg := readTOML(t, path)
servers := cfg[codexServersKey].(map[string]any)
if _, ok := servers["pad"]; ok {
t.Errorf("pad entry not removed")
}
if _, ok := servers["other"]; !ok {
t.Errorf("other entry should be preserved")
}
}
func TestRemovePadEntryTOML_MissingIsNoOp(t *testing.T) {
dir := t.TempDir()
// missing file
removed, err := removePadEntryTOML(filepath.Join(dir, "nope.toml"))
if err != nil || removed {
t.Errorf("missing file: removed=%v err=%v, want (false, nil)", removed, err)
}
// present file, no pad entry
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte("[mcp_servers.other]\ncommand = \"/x\"\n"), 0o600); err != nil {
t.Fatal(err)
}
removed, err = removePadEntryTOML(path)
if err != nil || removed {
t.Errorf("no pad entry: removed=%v err=%v, want (false, nil)", removed, err)
}
}
func TestHasPadEntryTOML_AllPaths(t *testing.T) {
dir := t.TempDir()
installed, _, err := hasPadEntryTOML(filepath.Join(dir, "missing.toml"))
if err != nil || installed {
t.Errorf("missing file: installed=%v err=%v, want (false, nil)", installed, err)
}
bare := filepath.Join(dir, "bare.toml")
if err := os.WriteFile(bare, []byte("model = \"x\"\n"), 0o600); err != nil {
t.Fatal(err)
}
installed, _, err = hasPadEntryTOML(bare)
if err != nil || installed {
t.Errorf("bare config: installed=%v err=%v, want (false, nil)", installed, err)
}
full := filepath.Join(dir, "full.toml")
if err := os.WriteFile(full, []byte("[mcp_servers.pad]\ncommand = \"/p\"\n"), 0o600); err != nil {
t.Fatal(err)
}
installed, cmd, err := hasPadEntryTOML(full)
if err != nil {
t.Fatalf("full config: %v", err)
}
if !installed || cmd != "/p" {
t.Errorf("full config: installed=%v cmd=%q, want (true, /p)", installed, cmd)
}
}
func TestInstaller_Codex_InstallUninstallStatus(t *testing.T) {
tmpHome := t.TempDir()
inst := &Installer{Binary: "/usr/local/bin/pad", Home: tmpHome, GOOS: "linux"}
path, modified, err := inst.Install("codex")
if err != nil {
t.Fatalf("Install codex: %v", err)
}
if !modified {
t.Errorf("first install should modify=true")
}
wantPath := filepath.Join(tmpHome, ".codex", "config.toml")
if path != wantPath {
t.Errorf("path = %q, want %q", path, wantPath)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("expected config written at %s, got: %v", path, err)
}
// codex is a global agent, so Status reports it.
var seen bool
for _, row := range inst.Status() {
if row.Name == "codex" {
seen = true
if !row.Installed || row.Command != "/usr/local/bin/pad" {
t.Errorf("codex status = {installed:%v cmd:%q}, want {true /usr/local/bin/pad}", row.Installed, row.Command)
}
}
}
if !seen {
t.Errorf("codex missing from Status()")
}
_, removed, err := inst.Uninstall("codex")
if err != nil {
t.Fatalf("uninstall codex: %v", err)
}
if !removed {
t.Errorf("expected removed=true after install")
}
}
// --- Claude Code (cwd-based .mcp.json) ------------------------------------
func TestInstaller_ClaudeCode_UsesCWDNotHome(t *testing.T) {
tmpHome := t.TempDir()
tmpCWD := t.TempDir()
inst := &Installer{Binary: "/usr/local/bin/pad", Home: tmpHome, CWD: tmpCWD, GOOS: "linux"}
path, modified, err := inst.Install("claude-code")
if err != nil {
t.Fatalf("Install claude-code: %v", err)
}
if !modified {
t.Errorf("first install should modify=true")
}
wantPath := filepath.Join(tmpCWD, ".mcp.json")
if path != wantPath {
t.Errorf("path = %q, want %q (must resolve against CWD, not home)", path, wantPath)
}
// Nothing should have been written under home.
if _, err := os.Stat(filepath.Join(tmpHome, ".mcp.json")); err == nil {
t.Errorf("claude-code wrote under home; should be cwd-only")
}
// Same JSON shape as the other JSON clients.
cfg := readConfig(t, path)
pad := cfg["mcpServers"].(map[string]any)[MCPServerKey].(map[string]any)
if pad["command"] != "/usr/local/bin/pad" {
t.Errorf("command = %v, want /usr/local/bin/pad", pad["command"])
}
_, removed, err := inst.Uninstall("claude-code")
if err != nil {
t.Fatalf("uninstall claude-code: %v", err)
}
if !removed {
t.Errorf("expected removed=true after install")
}
}
func TestGlobalAgents_ExcludesCWDBased(t *testing.T) {
for _, a := range globalAgents() {
if a.CWDBased {
t.Errorf("globalAgents() included CWDBased agent %q", a.Name)
}
if a.Name == "claude-code" {
t.Errorf("claude-code must not be a global agent")
}
}
// codex is global; make sure it's present.
var hasCodex bool
for _, a := range globalAgents() {
if a.Name == "codex" {
hasCodex = true
}
}
if !hasCodex {
t.Errorf("codex should be a global agent")
}
}
// Helpers ------------------------------------------------------------------
func readTOML(t *testing.T, path string) map[string]any {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var raw map[string]any
if err := toml.Unmarshal(b, &raw); err != nil {
t.Fatalf("parse %s: %v\n%s", path, err, b)
}
return raw
}
func writeConfig(t *testing.T, path string, data map[string]any) {
t.Helper()
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatal(err)
}
}
func readConfig(t *testing.T, path string) map[string]any {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("parse %s: %v\n%s", path, err, b)
}
return raw
}
// Quiet the "unused" lint when one or more helpers go unused in a
// future trim — we want to keep them around.
var _ = strings.Contains