mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
43c31c826e
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
835 lines
24 KiB
Go
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
|