From 1ee807d14b32b9a66fc731372f54a29a8fbe7002 Mon Sep 17 00:00:00 2001 From: Danny Teller Date: Wed, 11 Mar 2026 14:23:18 +0200 Subject: [PATCH] Add multi-runtime MCP config support (v0.6.0) Agents now register with Claude Code, Codex, and Gemini CLI automatically. New --runtime flag on build/install/uninstall controls targeting (auto, all, or specific runtime). Adds pkg/runtimecfg/ package with ConfigWriter interface and implementations for each runtime's config format (JSON for Claude Code/Gemini, TOML for Codex). Migrates MCP config logic from cmd/agentfile/mcpgen.go into the new package and updates all docs to reflect multi-runtime support. Co-Authored-By: Claude --- .gitignore | 4 +- Makefile | 2 +- README.md | 6 +- cmd/agentfile/build.go | 29 ++- cmd/agentfile/install.go | 81 +++++--- cmd/agentfile/main.go | 2 +- cmd/agentfile/mcpgen.go | 53 ----- cmd/agentfile/uninstall.go | 83 ++++---- cmd/agentfile/update.go | 4 +- doc.go | 2 +- docs/concepts.md | 6 +- docs/faq.md | 10 +- docs/guides/distribution.md | 14 +- docs/guides/mcp.md | 18 +- docs/quickstart.md | 25 ++- docs/reference.md | 36 +++- examples/README.md | 8 +- go.mod | 3 +- go.sum | 8 + pkg/runtimecfg/claude.go | 106 ++++++++++ pkg/runtimecfg/codex.go | 104 ++++++++++ pkg/runtimecfg/gemini.go | 30 +++ pkg/runtimecfg/runtime.go | 119 ++++++++++++ pkg/runtimecfg/runtime_test.go | 340 +++++++++++++++++++++++++++++++++ 24 files changed, 908 insertions(+), 185 deletions(-) delete mode 100644 cmd/agentfile/mcpgen.go create mode 100644 pkg/runtimecfg/claude.go create mode 100644 pkg/runtimecfg/codex.go create mode 100644 pkg/runtimecfg/gemini.go create mode 100644 pkg/runtimecfg/runtime.go create mode 100644 pkg/runtimecfg/runtime_test.go diff --git a/.gitignore b/.gitignore index 4df8a5d..284fa7d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,10 @@ go.work.sum # .idea/ # .vscode/ -# Generated MCP config +# Generated MCP config (multi-runtime) .mcp.json +.codex/config.toml +.gemini/settings.json # Build output (agentfile CLI + agent binaries) build/ diff --git a/Makefile b/Makefile index caf0f17..60f76b7 100644 --- a/Makefile +++ b/Makefile @@ -43,5 +43,5 @@ bench-report: bench-all: bench bench-integration bench-report clean: - rm -rf build/ .agentfile/ .mcp.json + rm -rf build/ .agentfile/ .mcp.json .codex/config.toml .gemini/settings.json go clean ./... diff --git a/README.md b/README.md index 8df85a2..14424cc 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ agentfile publish agentfile install github.com/acme/my-agent ``` -That last command downloads the right binary for your platform, wires it into Claude Code via MCP, and tracks it for future updates. No cloning, no building from source, no editing config files. +That last command downloads the right binary for your platform, wires it into your MCP-compatible runtime (Claude Code, Codex, Gemini CLI — auto-detected), and tracks it for future updates. No cloning, no building from source, no editing config files. **Claude Code is the brain. The binary is the body** — it provides the instructions, the hands (tools), and the memory. Claude Code loads the agent's prompt, discovers its tools via MCP, and handles all reasoning. @@ -90,9 +90,9 @@ You are a helpful coding assistant. Use your tools to read and modify files. ```bash agentfile build -# -> ./build/my-agent binary + .mcp.json for Claude Code +# -> ./build/my-agent binary + MCP config for detected runtimes -# Claude Code auto-discovers the agent — start using it immediately +# Your runtime auto-discovers the agent — start using it immediately ``` ### 3. Share it diff --git a/cmd/agentfile/build.go b/cmd/agentfile/build.go index 51400b4..923d01d 100644 --- a/cmd/agentfile/build.go +++ b/cmd/agentfile/build.go @@ -9,6 +9,7 @@ import ( "github.com/teabranch/agentfile/pkg/builder" "github.com/teabranch/agentfile/pkg/definition" "github.com/teabranch/agentfile/pkg/plugin" + "github.com/teabranch/agentfile/pkg/runtimecfg" ) // loadSkillFiles reads skill file contents from disk, resolving paths @@ -42,6 +43,7 @@ func newBuildCommand() *cobra.Command { agentName string pluginFlag bool parallelism int + runtimeFlag string ) cmd := &cobra.Command{ @@ -50,9 +52,10 @@ func newBuildCommand() *cobra.Command { Long: `Parses the Agentfile, reads each agent's .md file, generates Go source, and compiles standalone binaries into the output directory. -Also generates/updates .mcp.json with serve-mcp entries for each agent.`, +Also generates/updates MCP config for detected runtimes (Claude Code, Codex, Gemini). +Use --runtime to target a specific runtime or "all" for all supported runtimes.`, RunE: func(cmd *cobra.Command, args []string) error { - return runBuild(agentfilePath, outputDir, agentName, pluginFlag, parallelism) + return runBuild(agentfilePath, outputDir, agentName, pluginFlag, parallelism, runtimeFlag) }, } @@ -61,11 +64,12 @@ Also generates/updates .mcp.json with serve-mcp entries for each agent.`, cmd.Flags().StringVar(&agentName, "agent", "", "Build a single agent by name") cmd.Flags().BoolVar(&pluginFlag, "plugin", false, "Also generate a Claude Code plugin directory") cmd.Flags().IntVar(¶llelism, "parallelism", 0, "Max concurrent agent builds (0 = sequential)") + cmd.Flags().StringVar(&runtimeFlag, "runtime", "auto", "Target runtime: auto, all, claude-code, codex, gemini") return cmd } -func runBuild(agentfilePath, outputDir, agentName string, pluginOutput bool, parallelism int) error { +func runBuild(agentfilePath, outputDir, agentName string, pluginOutput bool, parallelism int, runtimeFlag string) error { if agentfilePath == "" { agentfilePath = resolveAgentfile() } @@ -121,20 +125,27 @@ func runBuild(agentfilePath, outputDir, agentName string, pluginOutput bool, par return err } - // Generate .mcp.json with serve-mcp entries. + // Generate MCP config for target runtimes. + writers, err := runtimecfg.Resolve(runtimeFlag) + if err != nil { + return fmt.Errorf("resolving runtimes: %w", err) + } + absOut, _ := filepath.Abs(outputDir) - entries := make(map[string]MCPServerEntry) + entries := make(map[string]runtimecfg.ServerEntry) for name := range defs { - entries[name] = MCPServerEntry{ + entries[name] = runtimecfg.ServerEntry{ Command: filepath.Join(absOut, name), Args: []string{"serve-mcp"}, } } - if err := mergeMCPJSON(".mcp.json", entries); err != nil { - return fmt.Errorf("updating .mcp.json: %w", err) + for _, w := range writers { + if err := w.Merge(w.LocalPath(), entries); err != nil { + return fmt.Errorf("updating %s for %s: %w", w.LocalPath(), w.Runtime(), err) + } + fmt.Printf("Updated %s (%s)\n", w.LocalPath(), w.Runtime()) } - fmt.Println("Updated .mcp.json") // Generate plugin directories if --plugin flag is set. if pluginOutput { diff --git a/cmd/agentfile/install.go b/cmd/agentfile/install.go index b49a2a8..448baa3 100644 --- a/cmd/agentfile/install.go +++ b/cmd/agentfile/install.go @@ -12,16 +12,18 @@ import ( "github.com/teabranch/agentfile/pkg/fsutil" "github.com/teabranch/agentfile/pkg/github" "github.com/teabranch/agentfile/pkg/registry" + "github.com/teabranch/agentfile/pkg/runtimecfg" ) func newInstallCommand() *cobra.Command { var global bool var modelOverride string + var runtimeFlag string cmd := &cobra.Command{ Use: "install ", Short: "Install an agent binary (local or remote)", - Long: `Installs an agent binary and updates the MCP config. + Long: `Installs an agent binary and updates the MCP config for detected runtimes. Local install (from ./build/): agentfile install my-agent @@ -30,13 +32,19 @@ Remote install (from GitHub Releases): agentfile install github.com/owner/repo/agent agentfile install github.com/owner/repo/agent@1.0.0 -By default, installs to .agentfile/bin/ (project-local) and updates .mcp.json. -With --global, installs to /usr/local/bin/ and updates ~/.claude/mcp.json. +By default, installs to .agentfile/bin/ (project-local) and updates MCP config. +With --global, installs to /usr/local/bin/ and updates global MCP config. Override settings at install time: - agentfile install --model gpt-5 github.com/owner/repo/agent`, + agentfile install --model gpt-5 github.com/owner/repo/agent + agentfile install --runtime codex github.com/owner/repo/agent`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + writers, err := runtimecfg.Resolve(runtimeFlag) + if err != nil { + return err + } + var agentName string if github.IsRemoteRef(args[0]) { parsed, err := github.ParseRef(args[0]) @@ -44,12 +52,12 @@ Override settings at install time: return err } agentName = parsed.Agent - if err := runRemoteInstall(args[0], global); err != nil { + if err := runRemoteInstall(args[0], global, writers); err != nil { return err } } else { agentName = args[0] - if err := runLocalInstall(args[0], global); err != nil { + if err := runLocalInstall(args[0], global, writers); err != nil { return err } } @@ -67,17 +75,18 @@ Override settings at install time: cmd.Flags().BoolVarP(&global, "global", "g", false, "Install globally to /usr/local/bin") cmd.Flags().StringVar(&modelOverride, "model", "", "Override the agent's model in ~/.agentfile//config.yaml") + cmd.Flags().StringVar(&runtimeFlag, "runtime", "auto", "Target runtime: auto, all, claude-code, codex, gemini") return cmd } -func runLocalInstall(name string, global bool) error { +func runLocalInstall(name string, global bool, writers []runtimecfg.ConfigWriter) error { src := filepath.Join("build", name) if _, err := os.Stat(src); err != nil { return fmt.Errorf("binary not found: %s (run 'agentfile build' first)", src) } - binDir, mcpPath := installPaths(global) + binDir := installBinDir(global) if err := os.MkdirAll(binDir, 0o755); err != nil { return fmt.Errorf("creating bin dir: %w", err) @@ -92,21 +101,20 @@ func runLocalInstall(name string, global bool) error { } fmt.Printf("Installed %s → %s\n", name, dst) - // Update mcp.json. + // Update MCP configs for target runtimes. absDst, err := filepath.Abs(dst) if err != nil { return fmt.Errorf("resolving absolute path: %w", err) } - entries := map[string]MCPServerEntry{ + entries := map[string]runtimecfg.ServerEntry{ name: { Command: absDst, Args: []string{"serve-mcp"}, }, } - if err := mergeMCPJSON(mcpPath, entries); err != nil { - return fmt.Errorf("updating %s: %w", mcpPath, err) + if err := mergeRuntimeConfigs(writers, global, entries); err != nil { + return err } - fmt.Printf("Updated %s\n", mcpPath) // Track in registry. version := "" @@ -120,7 +128,7 @@ func runLocalInstall(name string, global bool) error { return trackInstall(name, "local", version, absDst, scope) } -func runRemoteInstall(ref string, global bool) error { +func runRemoteInstall(ref string, global bool, writers []runtimecfg.ConfigWriter) error { parsed, err := github.ParseRef(ref) if err != nil { return err @@ -196,7 +204,7 @@ func runRemoteInstall(ref string, global bool) error { } // Move to install location. - binDir, mcpPath := installPaths(global) + binDir := installBinDir(global) if err := os.MkdirAll(binDir, 0o755); err != nil { return fmt.Errorf("creating bin dir: %w", err) } @@ -210,21 +218,20 @@ func runRemoteInstall(ref string, global bool) error { } fmt.Printf("Installed %s → %s\n", parsed.Agent, dst) - // Wire MCP. + // Wire MCP for target runtimes. absDst, err := filepath.Abs(dst) if err != nil { return fmt.Errorf("resolving absolute path: %w", err) } - entries := map[string]MCPServerEntry{ + entries := map[string]runtimecfg.ServerEntry{ parsed.Agent: { Command: absDst, Args: []string{"serve-mcp"}, }, } - if err := mergeMCPJSON(mcpPath, entries); err != nil { - return fmt.Errorf("updating %s: %w", mcpPath, err) + if err := mergeRuntimeConfigs(writers, global, entries); err != nil { + return err } - fmt.Printf("Updated %s\n", mcpPath) // Track in registry. source := fmt.Sprintf("github.com/%s/%s/%s", parsed.Owner, parsed.Repo, parsed.Agent) @@ -235,16 +242,34 @@ func runRemoteInstall(ref string, global bool) error { return trackInstall(parsed.Agent, source, manifest.Version, absDst, scope) } -func installPaths(global bool) (binDir, mcpPath string) { +// installBinDir returns the binary install directory. +// Binary location is agentfile-internal, independent of runtime. +func installBinDir(global bool) string { if global { - binDir = "/usr/local/bin" - home, _ := os.UserHomeDir() - mcpPath = filepath.Join(home, ".claude", "mcp.json") - } else { - binDir = filepath.Join(".agentfile", "bin") - mcpPath = ".mcp.json" + return "/usr/local/bin" + } + return filepath.Join(".agentfile", "bin") +} + +// mergeRuntimeConfigs writes MCP server entries to all target runtime configs. +func mergeRuntimeConfigs(writers []runtimecfg.ConfigWriter, global bool, entries map[string]runtimecfg.ServerEntry) error { + for _, w := range writers { + var cfgPath string + if global { + var err error + cfgPath, err = w.GlobalPath() + if err != nil { + return fmt.Errorf("resolving global path for %s: %w", w.Runtime(), err) + } + } else { + cfgPath = w.LocalPath() + } + if err := w.Merge(cfgPath, entries); err != nil { + return fmt.Errorf("updating %s for %s: %w", cfgPath, w.Runtime(), err) + } + fmt.Printf("Updated %s (%s)\n", cfgPath, w.Runtime()) } - return + return nil } func trackInstall(name, source, version, path, scope string) error { diff --git a/cmd/agentfile/main.go b/cmd/agentfile/main.go index c5981e6..4b0f193 100644 --- a/cmd/agentfile/main.go +++ b/cmd/agentfile/main.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" ) -const cliVersion = "0.5.0" +const cliVersion = "0.6.0" func main() { root := newRootCommand() diff --git a/cmd/agentfile/mcpgen.go b/cmd/agentfile/mcpgen.go deleted file mode 100644 index a2ed018..0000000 --- a/cmd/agentfile/mcpgen.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" -) - -// MCPServerEntry describes a single MCP server in the config. -type MCPServerEntry struct { - Command string `json:"command"` - Args []string `json:"args"` -} - -// mergeMCPJSON reads an existing MCP config (if present), merges new entries, -// and writes it back. Preserves all existing keys and server entries that are -// not being overwritten. Creates parent directories as needed. -func mergeMCPJSON(path string, entries map[string]MCPServerEntry) error { - // Use a generic map to preserve any unknown top-level or per-server fields. - cfg := make(map[string]any) - - data, err := os.ReadFile(path) - if err == nil { - _ = json.Unmarshal(data, &cfg) - } - - // Get or create the mcpServers map. - servers, _ := cfg["mcpServers"].(map[string]any) - if servers == nil { - servers = make(map[string]any) - } - - // Merge new entries (overwrites existing entries with same key only). - for k, v := range entries { - servers[k] = v - } - cfg["mcpServers"] = servers - - out, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - - // Create parent directory if needed (for paths like ~/.claude/mcp.json). - dir := filepath.Dir(path) - if dir != "." && dir != "" { - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - } - - return os.WriteFile(path, append(out, '\n'), 0o644) -} diff --git a/cmd/agentfile/uninstall.go b/cmd/agentfile/uninstall.go index 97990a3..d36c08c 100644 --- a/cmd/agentfile/uninstall.go +++ b/cmd/agentfile/uninstall.go @@ -1,29 +1,39 @@ package main import ( - "encoding/json" "fmt" "os" - "path/filepath" "github.com/spf13/cobra" "github.com/teabranch/agentfile/pkg/registry" + "github.com/teabranch/agentfile/pkg/runtimecfg" ) func newUninstallCommand() *cobra.Command { - return &cobra.Command{ + var runtimeFlag string + + cmd := &cobra.Command{ Use: "uninstall ", Short: "Remove an installed agent", - Long: `Removes an agent binary, unwires it from MCP config, and removes it -from the registry. Uses the registry to find the binary path and scope.`, + Long: `Removes an agent binary, unwires it from MCP config for all detected +runtimes, and removes it from the registry. Use --runtime to target a +specific runtime or "all" for all supported runtimes.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runUninstall(args[0]) + writers, err := runtimecfg.Resolve(runtimeFlag) + if err != nil { + return err + } + return runUninstall(args[0], writers) }, } + + cmd.Flags().StringVar(&runtimeFlag, "runtime", "auto", "Target runtime: auto, all, claude-code, codex, gemini") + + return cmd } -func runUninstall(name string) error { +func runUninstall(name string, writers []runtimecfg.ConfigWriter) error { regPath, err := registry.DefaultPath() if err != nil { return err @@ -44,19 +54,24 @@ func runUninstall(name string) error { } fmt.Printf("Removed %s\n", entry.Path) - // Unwire from MCP config. - mcpPath := ".mcp.json" - if entry.Scope == "global" { - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("getting home dir: %w", err) + // Unwire from MCP config for all target runtimes. + global := entry.Scope == "global" + for _, w := range writers { + var cfgPath string + if global { + cfgPath, err = w.GlobalPath() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not resolve global path for %s: %v\n", w.Runtime(), err) + continue + } + } else { + cfgPath = w.LocalPath() + } + if err := w.Remove(cfgPath, name); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not update %s (%s): %v\n", cfgPath, w.Runtime(), err) + } else { + fmt.Printf("Updated %s (%s)\n", cfgPath, w.Runtime()) } - mcpPath = filepath.Join(home, ".claude", "mcp.json") - } - if err := removeMCPEntry(mcpPath, name); err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not update %s: %v\n", mcpPath, err) - } else { - fmt.Printf("Updated %s\n", mcpPath) } // Remove from registry. @@ -67,33 +82,3 @@ func runUninstall(name string) error { fmt.Printf("Uninstalled %s\n", name) return nil } - -// removeMCPEntry removes a single server entry from an MCP config file. -func removeMCPEntry(path, name string) error { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - var cfg map[string]any - if err := json.Unmarshal(data, &cfg); err != nil { - return err - } - - servers, ok := cfg["mcpServers"].(map[string]any) - if !ok { - return nil - } - - delete(servers, name) - cfg["mcpServers"] = servers - - out, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, append(out, '\n'), 0o644) -} diff --git a/cmd/agentfile/update.go b/cmd/agentfile/update.go index f1fe56b..622376f 100644 --- a/cmd/agentfile/update.go +++ b/cmd/agentfile/update.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/teabranch/agentfile/pkg/github" "github.com/teabranch/agentfile/pkg/registry" + "github.com/teabranch/agentfile/pkg/runtimecfg" ) func newUpdateCommand() *cobra.Command { @@ -93,7 +94,8 @@ func runUpdate(name string) error { global := entry.Scope == "global" ref.Version = latestVersion newRef := fmt.Sprintf("github.com/%s/%s/%s@%s", ref.Owner, ref.Repo, ref.Agent, latestVersion) - if err := runRemoteInstall(newRef, global); err != nil { + writers := runtimecfg.Detect() + if err := runRemoteInstall(newRef, global, writers); err != nil { fmt.Fprintf(os.Stderr, "%s: update failed: %v\n", entry.Name, err) continue } diff --git a/doc.go b/doc.go index 7a382c2..4b468b3 100644 --- a/doc.go +++ b/doc.go @@ -25,7 +25,7 @@ // Build with the agentfile CLI: // // agentfile build # → build/my-agent (standalone binary) -// agentfile install my-agent # → .agentfile/bin/ + .mcp.json +// agentfile install my-agent # → .agentfile/bin/ + MCP config (auto-detected runtimes) // // # Runtime Library // diff --git a/docs/concepts.md b/docs/concepts.md index a0694be..39993b9 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -3,9 +3,9 @@ ## Architecture ``` -Claude Code (LLM Runtime / Orchestrator) +LLM Runtime (Claude Code / Codex / Gemini CLI) | - | MCP-over-stdio (.mcp.json) + | MCP-over-stdio | v Agent Binary (Agentfile) @@ -257,7 +257,7 @@ This writes the override to `config.yaml` during install so it takes effect imme - You need MCP-based composition with other agents or tools - You want one-command install from GitHub: `agentfile install github.com/org/repo/agent` -**Composition patterns:** These are not mutually exclusive. A project can have a CLAUDE.md for repo-level instructions, Agent Skills for lightweight context injection, and Agentfile binaries for specialized agent capabilities registered via `.mcp.json`. Sub-agents can also invoke Agentfile agents' MCP tools. +**Composition patterns:** These are not mutually exclusive. A project can have a CLAUDE.md for repo-level instructions, Agent Skills for lightweight context injection, and Agentfile binaries for specialized agent capabilities registered via MCP config. Sub-agents can also invoke Agentfile agents' MCP tools. ## Plugins: Beyond the Binary diff --git a/docs/faq.md b/docs/faq.md index c36c3d1..d72e1df 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -15,7 +15,7 @@ CLAUDE.md works well for repo-specific instructions in a single project. Agentfi - **Validation** -- `validate` subcommand checks that tools exist, memory is writable, prompts load - **Machine-readable** -- `--describe` returns a JSON manifest -They are not mutually exclusive. A project can have both a CLAUDE.md and Agentfile agents in `.mcp.json`. +They are not mutually exclusive. A project can have both a CLAUDE.md and Agentfile agents registered via MCP config. ## When should I use skills vs sub-agents vs Agentfile agents? @@ -29,7 +29,7 @@ These approaches compose well together. An Agentfile agent can coexist with skil ## Can I use this without Claude Code? -Yes. The `serve-mcp` subcommand starts a standard MCP-over-stdio server. Any MCP client can connect to it. The binary is a generic MCP server that happens to be built with Agentfile. +Yes. Agentfile supports Claude Code, Codex, and Gemini CLI out of the box. The `serve-mcp` subcommand starts a standard MCP-over-stdio server, so any MCP client can connect to it. Use `--runtime` on `build`/`install` to target your preferred runtime. You can also use the CLI directly: @@ -143,7 +143,7 @@ Go 1.24 or later. The `go.mod` specifies `go 1.24.0`. 1. Create an `Agentfile` (YAML) and an agent `.md` file with dual frontmatter 2. Build: `agentfile build` -3. The `.mcp.json` is auto-generated +3. MCP config is auto-generated for detected runtimes (use `--runtime` to target a specific one) ## What is the `agentfile build` command? @@ -155,7 +155,7 @@ agentfile build --agent foo # build a single agent agentfile build --plugin # also generate Claude Code plugin directories ``` -Flags: `-f` (Agentfile path), `-o` (output dir), `--agent` (single agent), `--plugin` (generate plugin dir). +Flags: `-f` (Agentfile path), `-o` (output dir), `--agent` (single agent), `--plugin` (generate plugin dir), `--runtime` (target runtime: auto, all, claude-code, codex, gemini). ## What is a plugin? @@ -236,4 +236,4 @@ Only agents installed from a remote source can be auto-updated. For locally-buil agentfile uninstall my-agent ``` -This removes the binary, unwires it from `.mcp.json`, and removes it from the registry. +This removes the binary, unwires it from all detected runtime configs, and removes it from the registry. diff --git a/docs/guides/distribution.md b/docs/guides/distribution.md index 2879493..03bf2e5 100644 --- a/docs/guides/distribution.md +++ b/docs/guides/distribution.md @@ -124,7 +124,7 @@ agentfile install -g github.com/owner/repo/agent-name 3. **Download** -- downloads the binary to a temp file 4. **Verify** -- runs ` --describe` to confirm it's a valid agent 5. **Install** -- moves to `.agentfile/bin/` (or `/usr/local/bin/` with `-g`) -6. **Wire MCP** -- updates `.mcp.json` (or `~/.claude/mcp.json` with `-g`) +6. **Wire MCP** -- updates MCP config for detected runtimes (Claude Code `.mcp.json`, Codex `.codex/config.toml`, Gemini `.gemini/settings.json`) 7. **Track** -- records the install in `~/.agentfile/registry.json` ### Private Repositories @@ -164,8 +164,9 @@ Local installs from `./build/` continue to work as before, and now also track in ```bash agentfile build -agentfile install my-agent # .agentfile/bin/ + .mcp.json + registry -agentfile install -g my-agent # /usr/local/bin/ + ~/.claude/mcp.json + registry +agentfile install my-agent # .agentfile/bin/ + MCP config (auto-detected runtimes) + registry +agentfile install -g my-agent # /usr/local/bin/ + global MCP config + registry +agentfile install --runtime codex my-agent # target Codex specifically ``` ## Updating @@ -211,14 +212,15 @@ Shows all agents tracked in the registry regardless of source. ```bash agentfile uninstall my-agent # Removed /path/.agentfile/bin/my-agent -# Updated .mcp.json +# Updated .mcp.json (claude-code) +# Updated .codex/config.toml (codex) # Uninstalled my-agent ``` Uninstall performs three actions: 1. **Removes the binary** from its installed path -2. **Unwires MCP** -- removes the entry from `.mcp.json` or `~/.claude/mcp.json` +2. **Unwires MCP** -- removes the entry from all detected runtime configs (or specify `--runtime`) 3. **Removes from registry** -- cleans up `~/.agentfile/registry.json` ## Registry @@ -277,7 +279,7 @@ agentfile publish --agent my-agent # Install from your team's repo agentfile install github.com/your-org/agents/code-reviewer -# Claude Code auto-discovers it via .mcp.json +# Your runtime auto-discovers it via MCP config # Later, check for updates agentfile update code-reviewer ``` diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 5414092..602cbc3 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -1,6 +1,6 @@ # MCP Integration Guide -Agentfile agents integrate with Claude Code through the Model Context Protocol (MCP). The `serve-mcp` subcommand starts an MCP-over-stdio server that exposes the agent's tools, prompts, and memory. +Agentfile agents integrate with MCP-compatible runtimes (Claude Code, Codex, Gemini CLI) through the Model Context Protocol (MCP). The `serve-mcp` subcommand starts an MCP-over-stdio server that exposes the agent's tools, prompts, and memory. ## What `serve-mcp` Exposes @@ -39,7 +39,17 @@ This is informational — the runtime decides which model to use. - `system` -- returns the agent's system prompt as a prompt message - `memory-context` (when memory is enabled) -- returns memory state; accepts an optional `key` argument to return a specific key's content -## `.mcp.json` Configuration +## Runtime Config Files + +`agentfile build` and `agentfile install` auto-generate MCP config for detected runtimes. Use `--runtime` to target a specific runtime. + +| Runtime | Local Config | Global Config | Format | +|---------|-------------|---------------|--------| +| Claude Code | `.mcp.json` | `~/.claude/mcp.json` | JSON `{"mcpServers": {...}}` | +| Codex | `.codex/config.toml` | `~/.codex/config.toml` | TOML `[mcp_servers.name]` | +| Gemini CLI | `.gemini/settings.json` | `~/.gemini/settings.json` | JSON `{"mcpServers": {...}}` | + +## `.mcp.json` Configuration (Claude Code) Claude Code discovers MCP servers through `.mcp.json` in the project root. @@ -230,4 +240,6 @@ The plugin's `.mcp.json` uses a relative path (`./my-agent`), making the directo ## Compatibility -Agentfile uses the standard MCP protocol. While it is designed for Claude Code, any MCP client can connect to an agent's `serve-mcp` server. The binary is a generic MCP server that happens to be built with Agentfile. +Agentfile uses the standard MCP protocol. All three supported runtimes (Claude Code, Codex, Gemini CLI) connect via MCP-over-stdio, and any other MCP client can connect to an agent's `serve-mcp` server. The binary is a generic MCP server that happens to be built with Agentfile. + +The `--runtime` flag on `build`, `install`, and `uninstall` controls which runtime configs are generated. The `auto` default detects installed runtimes. Use `all` to target all three. diff --git a/docs/quickstart.md b/docs/quickstart.md index 16f99bc..cae46dc 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -79,7 +79,11 @@ See the [Agentfile Format Guide](./guides/agentfile-format.md) for full details. ./build/agentfile build # Building my-agent... # → ./build/my-agent -# Updated .mcp.json +# Updated .mcp.json (claude-code) + +# Target a specific runtime or all runtimes +./build/agentfile build --runtime codex # → .codex/config.toml +./build/agentfile build --runtime all # → all detected runtimes # Optional: also generate a Claude Code plugin directory (with skills support) ./build/agentfile build --plugin @@ -117,9 +121,9 @@ See the [Agentfile Format Guide](./guides/agentfile-format.md) for full details. ./build/my-agent config reset model # revert to default ``` -## Step 6: Connect to Claude Code +## Step 6: Connect to Your Runtime -`agentfile build` auto-generates `.mcp.json`. Claude Code picks it up: +`agentfile build` auto-generates MCP config for detected runtimes. For Claude Code, this is `.mcp.json`: ```json { @@ -135,11 +139,20 @@ See the [Agentfile Format Guide](./guides/agentfile-format.md) for full details. Or install the binary explicitly: ```bash -./build/agentfile install my-agent # local: .agentfile/bin/ + .mcp.json -./build/agentfile install -g my-agent # global: /usr/local/bin/ + ~/.claude/mcp.json +./build/agentfile install my-agent # local install, auto-detect runtimes +./build/agentfile install -g my-agent # global install +./build/agentfile install --runtime codex my-agent # target Codex specifically ``` -Claude Code auto-discovers the agent via MCP. It sees the agent's tools, can read its system prompt, and can interact with its memory. +Supported runtimes: + +| Runtime | Local Config | Global Config | +|---------|-------------|---------------| +| Claude Code | `.mcp.json` | `~/.claude/mcp.json` | +| Codex | `.codex/config.toml` | `~/.codex/config.toml` | +| Gemini CLI | `.gemini/settings.json` | `~/.gemini/settings.json` | + +Your runtime auto-discovers the agent via MCP. It sees the agent's tools, can read its system prompt, and can interact with its memory. ## What You Get vs. a CLAUDE.md diff --git a/docs/reference.md b/docs/reference.md index c158d86..5d6db19 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -474,13 +474,25 @@ Usage: agentfile build [flags] Flags: - -f, --file string Path to Agentfile (default: auto-detect Agentfile or agentfile.yaml) - -o, --output string Output directory for binaries (default: "./build") - --agent string Build a single agent by name - --plugin Also generate a Claude Code plugin directory + -f, --file string Path to Agentfile (default: auto-detect Agentfile or agentfile.yaml) + -o, --output string Output directory for binaries (default: "./build") + --agent string Build a single agent by name + --plugin Also generate a Claude Code plugin directory + --runtime string Target runtime: auto, all, claude-code, codex, gemini (default: "auto") ``` -Parses the Agentfile, generates Go source from each agent's `.md` file, and compiles standalone binaries. Also generates/updates `.mcp.json`. +Parses the Agentfile, generates Go source from each agent's `.md` file, and compiles standalone binaries. Also generates/updates MCP config for the target runtime(s). + +The `--runtime` flag controls which runtimes receive MCP config: +- `auto` (default) — detects installed runtimes by checking for their global config directories, falls back to Claude Code +- `all` — generates config for all supported runtimes (Claude Code, Codex, Gemini CLI) +- `claude-code` / `codex` / `gemini` — targets a specific runtime + +| Runtime | Local Config | Global Config | +|---------|-------------|---------------| +| Claude Code | `.mcp.json` | `~/.claude/mcp.json` | +| Codex | `.codex/config.toml` | `~/.codex/config.toml` | +| Gemini CLI | `.gemini/settings.json` | `~/.gemini/settings.json` | When `--plugin` is passed, each agent also gets a `.claude-plugin/` directory in the output folder containing the binary, an MCP config, and any declared skills. See [Plugins guide](guides/plugins.md). @@ -491,11 +503,12 @@ Usage: agentfile install [flags] Flags: - -g, --global Install globally to /usr/local/bin - --model string Override the agent's model in ~/.agentfile//config.yaml + -g, --global Install globally to /usr/local/bin + --model string Override the agent's model in ~/.agentfile//config.yaml + --runtime string Target runtime: auto, all, claude-code, codex, gemini (default: "auto") ``` -Installs an agent binary and wires it into MCP. Supports two modes: +Installs an agent binary and wires it into the MCP config for detected (or specified) runtimes. Supports two modes: **Local install** (from `./build/`): @@ -557,10 +570,13 @@ If no agent name is given, checks all remote-installed agents. ``` Usage: - agentfile uninstall + agentfile uninstall [flags] + +Flags: + --runtime string Target runtime: auto, all, claude-code, codex, gemini (default: "auto") ``` -Removes an installed agent: deletes the binary, removes the MCP entry from `.mcp.json` (or `~/.claude/mcp.json` for global installs), and removes the entry from the registry. +Removes an installed agent: deletes the binary, removes the MCP entry from all detected (or specified) runtime configs, and removes the entry from the registry. --- diff --git a/examples/README.md b/examples/README.md index a87408e..3365605 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,7 +18,7 @@ Build and use: ```bash cd basic -agentfile build # -> ./build/my-agent + .mcp.json +agentfile build # -> ./build/my-agent + MCP config ./build/my-agent --version ./build/my-agent --describe ./build/my-agent validate @@ -39,10 +39,10 @@ Build and use: ```bash cd multi-agent -agentfile build # -> ./build/golang-pro, ./build/code-reviewer + .mcp.json +agentfile build # -> ./build/golang-pro, ./build/code-reviewer + MCP config ``` -Both agents are auto-discovered by Claude Code via the generated `.mcp.json`. +Both agents are auto-discovered by your runtime via the generated MCP config. ### [`model-override/`](model-override/) @@ -58,7 +58,7 @@ Build and use: ```bash cd model-override -agentfile build # -> ./build/smart-reviewer + .mcp.json +agentfile build # -> ./build/smart-reviewer + MCP config ./build/smart-reviewer --describe # shows model in manifest ./build/smart-reviewer config get # shows compiled defaults ./build/smart-reviewer config set model sonnet # override at runtime diff --git a/go.mod b/go.mod index 375d734..0fb94af 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/teabranch/agentfile go 1.26.0 require ( + github.com/BurntSushi/toml v1.6.0 github.com/modelcontextprotocol/go-sdk v1.4.0 + github.com/pkoukk/tiktoken-go v0.1.8 github.com/spf13/cobra v1.8.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -13,7 +15,6 @@ require ( github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/pkoukk/tiktoken-go v0.1.8 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.3 // indirect github.com/spf13/pflag v1.0.10 // indirect diff --git a/go.sum b/go.sum index cf3ecd5..29df499 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,8 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= @@ -15,6 +19,8 @@ github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE4 github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo= github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= @@ -25,6 +31,8 @@ github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3k github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= diff --git a/pkg/runtimecfg/claude.go b/pkg/runtimecfg/claude.go new file mode 100644 index 0000000..118d753 --- /dev/null +++ b/pkg/runtimecfg/claude.go @@ -0,0 +1,106 @@ +package runtimecfg + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// claudeWriter implements ConfigWriter for Claude Code. +// Config format: JSON with {"mcpServers": {"name": {"command": "...", "args": [...]}}} +type claudeWriter struct{} + +// mcpServerJSON is the JSON representation of an MCP server entry. +type mcpServerJSON struct { + Command string `json:"command"` + Args []string `json:"args"` +} + +func (c *claudeWriter) Runtime() Runtime { return ClaudeCode } + +func (c *claudeWriter) LocalPath() string { return ".mcp.json" } + +func (c *claudeWriter) GlobalPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".claude", "mcp.json"), nil +} + +func (c *claudeWriter) Merge(path string, entries map[string]ServerEntry) error { + return mergeJSON(path, entries) +} + +func (c *claudeWriter) Remove(path string, name string) error { + return removeJSON(path, name) +} + +// mergeJSON reads an existing MCP JSON config (if present), merges new entries, +// and writes it back. Preserves all existing keys and server entries that are +// not being overwritten. Creates parent directories as needed. +func mergeJSON(path string, entries map[string]ServerEntry) error { + cfg := make(map[string]any) + + data, err := os.ReadFile(path) + if err == nil { + _ = json.Unmarshal(data, &cfg) + } + + servers, _ := cfg["mcpServers"].(map[string]any) + if servers == nil { + servers = make(map[string]any) + } + + for k, v := range entries { + servers[k] = mcpServerJSON{ + Command: v.Command, + Args: v.Args, + } + } + cfg["mcpServers"] = servers + + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + dir := filepath.Dir(path) + if dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + + return os.WriteFile(path, append(out, '\n'), 0o644) +} + +// removeJSON removes a single server entry from an MCP JSON config file. +func removeJSON(path, name string) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + return err + } + + servers, ok := cfg["mcpServers"].(map[string]any) + if !ok { + return nil + } + + delete(servers, name) + cfg["mcpServers"] = servers + + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(out, '\n'), 0o644) +} diff --git a/pkg/runtimecfg/codex.go b/pkg/runtimecfg/codex.go new file mode 100644 index 0000000..7ee865d --- /dev/null +++ b/pkg/runtimecfg/codex.go @@ -0,0 +1,104 @@ +package runtimecfg + +import ( + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +// codexWriter implements ConfigWriter for Codex CLI. +// Config format: TOML with [mcp_servers.] sections. +type codexWriter struct{} + +// codexConfig represents the subset of Codex config we need to read/write. +// We preserve unknown fields by using a generic map for decode/encode. +type codexServerEntry struct { + Command string `toml:"command"` + Args []string `toml:"args"` +} + +func (c *codexWriter) Runtime() Runtime { return Codex } + +func (c *codexWriter) LocalPath() string { + return filepath.Join(".codex", "config.toml") +} + +func (c *codexWriter) GlobalPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".codex", "config.toml"), nil +} + +func (c *codexWriter) Merge(path string, entries map[string]ServerEntry) error { + cfg := make(map[string]any) + + data, err := os.ReadFile(path) + if err == nil { + _ = toml.Unmarshal(data, &cfg) + } + + // Get or create the mcp_servers map. + servers, _ := cfg["mcp_servers"].(map[string]any) + if servers == nil { + servers = make(map[string]any) + } + + for k, v := range entries { + servers[k] = codexServerEntry{ + Command: v.Command, + Args: v.Args, + } + } + cfg["mcp_servers"] = servers + + dir := filepath.Dir(path) + if dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + enc := toml.NewEncoder(f) + return enc.Encode(cfg) +} + +func (c *codexWriter) Remove(path string, name string) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + cfg := make(map[string]any) + if err := toml.Unmarshal(data, &cfg); err != nil { + return err + } + + servers, ok := cfg["mcp_servers"].(map[string]any) + if !ok { + return nil + } + + delete(servers, name) + cfg["mcp_servers"] = servers + + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + enc := toml.NewEncoder(f) + return enc.Encode(cfg) +} diff --git a/pkg/runtimecfg/gemini.go b/pkg/runtimecfg/gemini.go new file mode 100644 index 0000000..73c5a23 --- /dev/null +++ b/pkg/runtimecfg/gemini.go @@ -0,0 +1,30 @@ +package runtimecfg + +import "os" +import "path/filepath" + +// geminiWriter implements ConfigWriter for Gemini CLI. +// Same JSON schema as Claude Code, different file paths. +type geminiWriter struct{} + +func (g *geminiWriter) Runtime() Runtime { return Gemini } + +func (g *geminiWriter) LocalPath() string { + return filepath.Join(".gemini", "settings.json") +} + +func (g *geminiWriter) GlobalPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".gemini", "settings.json"), nil +} + +func (g *geminiWriter) Merge(path string, entries map[string]ServerEntry) error { + return mergeJSON(path, entries) +} + +func (g *geminiWriter) Remove(path string, name string) error { + return removeJSON(path, name) +} diff --git a/pkg/runtimecfg/runtime.go b/pkg/runtimecfg/runtime.go new file mode 100644 index 0000000..6a4aa54 --- /dev/null +++ b/pkg/runtimecfg/runtime.go @@ -0,0 +1,119 @@ +// Package runtimecfg abstracts MCP server config generation for multiple +// AI coding runtimes (Claude Code, Codex, Gemini CLI). +package runtimecfg + +import ( + "fmt" + "os" + "path/filepath" +) + +// Runtime identifies an AI coding runtime that supports MCP servers. +type Runtime string + +const ( + ClaudeCode Runtime = "claude-code" + Codex Runtime = "codex" + Gemini Runtime = "gemini" +) + +// AllRuntimes returns all supported runtimes in deterministic order. +func AllRuntimes() []Runtime { + return []Runtime{ClaudeCode, Codex, Gemini} +} + +// ServerEntry describes an MCP server to register with a runtime. +type ServerEntry struct { + Command string + Args []string +} + +// ConfigWriter handles reading/writing MCP server entries for a specific runtime. +type ConfigWriter interface { + // Runtime returns which runtime this writer targets. + Runtime() Runtime + + // LocalPath returns the project-local config path (e.g. ".mcp.json"). + LocalPath() string + + // GlobalPath returns the user-global config path (e.g. "~/.claude/mcp.json"). + GlobalPath() (string, error) + + // Merge reads existing config at path, adds/overwrites the given entries, + // and writes back. Creates the file and parent directories if needed. + Merge(path string, entries map[string]ServerEntry) error + + // Remove deletes a single server entry from config at path. + Remove(path string, name string) error +} + +// Parse converts a string to a Runtime, returning an error for unknown values. +func Parse(s string) (Runtime, error) { + switch s { + case string(ClaudeCode): + return ClaudeCode, nil + case string(Codex): + return Codex, nil + case string(Gemini): + return Gemini, nil + default: + return "", fmt.Errorf("unknown runtime %q (supported: claude-code, codex, gemini)", s) + } +} + +// For returns the ConfigWriter for a specific runtime. +func For(r Runtime) ConfigWriter { + switch r { + case ClaudeCode: + return &claudeWriter{} + case Codex: + return &codexWriter{} + case Gemini: + return &geminiWriter{} + default: + return &claudeWriter{} // fallback + } +} + +// Detect returns ConfigWriters for all runtimes whose global config directories +// exist on the current system. Falls back to Claude Code if none are detected. +func Detect() []ConfigWriter { + var writers []ConfigWriter + for _, r := range AllRuntimes() { + w := For(r) + gp, err := w.GlobalPath() + if err != nil { + continue + } + // Check if the parent directory of the global config exists. + dir := filepath.Dir(gp) + if _, err := os.Stat(dir); err == nil { + writers = append(writers, w) + } + } + if len(writers) == 0 { + writers = append(writers, For(ClaudeCode)) + } + return writers +} + +// Resolve returns the list of ConfigWriters for a given --runtime flag value. +// Supported values: "auto", "all", or a specific runtime name. +func Resolve(flag string) ([]ConfigWriter, error) { + switch flag { + case "auto": + return Detect(), nil + case "all": + var writers []ConfigWriter + for _, r := range AllRuntimes() { + writers = append(writers, For(r)) + } + return writers, nil + default: + r, err := Parse(flag) + if err != nil { + return nil, err + } + return []ConfigWriter{For(r)}, nil + } +} diff --git a/pkg/runtimecfg/runtime_test.go b/pkg/runtimecfg/runtime_test.go new file mode 100644 index 0000000..77ba7a7 --- /dev/null +++ b/pkg/runtimecfg/runtime_test.go @@ -0,0 +1,340 @@ +package runtimecfg + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/BurntSushi/toml" +) + +func TestParse(t *testing.T) { + tests := []struct { + input string + want Runtime + err bool + }{ + {"claude-code", ClaudeCode, false}, + {"codex", Codex, false}, + {"gemini", Gemini, false}, + {"unknown", "", true}, + } + for _, tt := range tests { + got, err := Parse(tt.input) + if tt.err && err == nil { + t.Errorf("Parse(%q) expected error", tt.input) + } + if !tt.err && err != nil { + t.Errorf("Parse(%q) unexpected error: %v", tt.input, err) + } + if got != tt.want { + t.Errorf("Parse(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestResolve(t *testing.T) { + // "all" should return 3 writers. + writers, err := Resolve("all") + if err != nil { + t.Fatal(err) + } + if len(writers) != 3 { + t.Errorf("Resolve(all) returned %d writers, want 3", len(writers)) + } + + // Specific runtime. + writers, err = Resolve("codex") + if err != nil { + t.Fatal(err) + } + if len(writers) != 1 || writers[0].Runtime() != Codex { + t.Errorf("Resolve(codex) unexpected result") + } + + // Invalid. + _, err = Resolve("nope") + if err == nil { + t.Error("Resolve(nope) expected error") + } +} + +func TestFor(t *testing.T) { + for _, r := range AllRuntimes() { + w := For(r) + if w.Runtime() != r { + t.Errorf("For(%s).Runtime() = %s", r, w.Runtime()) + } + } +} + +func TestClaudeWriterMergeAndRemove(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".mcp.json") + w := For(ClaudeCode) + + // Merge. + entries := map[string]ServerEntry{ + "my-agent": {Command: "/usr/local/bin/my-agent", Args: []string{"serve-mcp"}}, + } + if err := w.Merge(path, entries); err != nil { + t.Fatal(err) + } + + // Verify JSON structure. + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + servers := cfg["mcpServers"].(map[string]any) + agent := servers["my-agent"].(map[string]any) + if agent["command"] != "/usr/local/bin/my-agent" { + t.Errorf("command = %v", agent["command"]) + } + + // Merge a second entry — first should still exist. + entries2 := map[string]ServerEntry{ + "other": {Command: "/bin/other", Args: []string{"serve-mcp"}}, + } + if err := w.Merge(path, entries2); err != nil { + t.Fatal(err) + } + data, _ = os.ReadFile(path) + json.Unmarshal(data, &cfg) + servers = cfg["mcpServers"].(map[string]any) + if _, ok := servers["my-agent"]; !ok { + t.Error("my-agent was lost after second merge") + } + if _, ok := servers["other"]; !ok { + t.Error("other was not added") + } + + // Remove. + if err := w.Remove(path, "my-agent"); err != nil { + t.Fatal(err) + } + data, _ = os.ReadFile(path) + json.Unmarshal(data, &cfg) + servers = cfg["mcpServers"].(map[string]any) + if _, ok := servers["my-agent"]; ok { + t.Error("my-agent still present after remove") + } + if _, ok := servers["other"]; !ok { + t.Error("other was removed unexpectedly") + } +} + +func TestGeminiWriterMergeAndRemove(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".gemini", "settings.json") + w := For(Gemini) + + entries := map[string]ServerEntry{ + "my-agent": {Command: "/bin/my-agent", Args: []string{"serve-mcp"}}, + } + if err := w.Merge(path, entries); err != nil { + t.Fatal(err) + } + + // Verify. + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + json.Unmarshal(data, &cfg) + servers := cfg["mcpServers"].(map[string]any) + if _, ok := servers["my-agent"]; !ok { + t.Error("my-agent not found") + } + + // Remove. + if err := w.Remove(path, "my-agent"); err != nil { + t.Fatal(err) + } + data, _ = os.ReadFile(path) + json.Unmarshal(data, &cfg) + servers = cfg["mcpServers"].(map[string]any) + if _, ok := servers["my-agent"]; ok { + t.Error("my-agent still present after remove") + } +} + +func TestCodexWriterMergeAndRemove(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".codex", "config.toml") + w := For(Codex) + + entries := map[string]ServerEntry{ + "my-agent": {Command: "/bin/my-agent", Args: []string{"serve-mcp"}}, + } + if err := w.Merge(path, entries); err != nil { + t.Fatal(err) + } + + // Verify file exists and has content. + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + content := string(data) + if len(content) == 0 { + t.Error("empty config file") + } + + // Merge a second entry. + entries2 := map[string]ServerEntry{ + "other": {Command: "/bin/other", Args: []string{"serve-mcp"}}, + } + if err := w.Merge(path, entries2); err != nil { + t.Fatal(err) + } + + // Remove first entry. + if err := w.Remove(path, "my-agent"); err != nil { + t.Fatal(err) + } + + // Remove from non-existent file should not error. + if err := w.Remove(filepath.Join(dir, "nope.toml"), "x"); err != nil { + t.Errorf("remove from missing file: %v", err) + } +} + +func TestCodexWriterPreservesExistingConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + w := For(Codex) + + // Write an existing config with non-MCP fields. + existing := `model = "o3" +approval_mode = "full-auto" +` + if err := os.WriteFile(path, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + // Merge an MCP server. + entries := map[string]ServerEntry{ + "test": {Command: "/bin/test", Args: []string{"serve-mcp"}}, + } + if err := w.Merge(path, entries); err != nil { + t.Fatal(err) + } + + // Re-read and verify non-MCP fields are preserved. + data, _ := os.ReadFile(path) + content := string(data) + + // The TOML encoder should preserve the model and approval_mode fields. + // We can't check exact format but can re-parse and verify. + cfg := make(map[string]any) + if _, err := tomlDecode(content, &cfg); err != nil { + t.Fatal(err) + } + if cfg["model"] != "o3" { + t.Errorf("model field lost: %v", cfg["model"]) + } + if cfg["approval_mode"] != "full-auto" { + t.Errorf("approval_mode field lost: %v", cfg["approval_mode"]) + } + servers, ok := cfg["mcp_servers"].(map[string]any) + if !ok { + t.Fatal("mcp_servers section missing") + } + if _, ok := servers["test"]; !ok { + t.Error("test server missing") + } +} + +func TestDetectFallback(t *testing.T) { + // With HOME set to empty dir, Detect should still return at least Claude Code. + t.Setenv("HOME", t.TempDir()) + writers := Detect() + if len(writers) == 0 { + t.Error("Detect returned no writers") + } + if writers[0].Runtime() != ClaudeCode { + t.Errorf("fallback should be ClaudeCode, got %s", writers[0].Runtime()) + } +} + +func TestDetectFindsExisting(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // Create Codex global config dir. + os.MkdirAll(filepath.Join(home, ".codex"), 0o755) + + writers := Detect() + found := false + for _, w := range writers { + if w.Runtime() == Codex { + found = true + } + } + if !found { + t.Error("Detect did not find Codex with .codex dir present") + } +} + +func TestRemoveFromNonExistentFile(t *testing.T) { + dir := t.TempDir() + w := For(ClaudeCode) + if err := w.Remove(filepath.Join(dir, "nope.json"), "x"); err != nil { + t.Errorf("remove from missing file: %v", err) + } +} + +func TestClaudeWriterPaths(t *testing.T) { + w := For(ClaudeCode) + if w.LocalPath() != ".mcp.json" { + t.Errorf("LocalPath = %q", w.LocalPath()) + } + gp, err := w.GlobalPath() + if err != nil { + t.Fatal(err) + } + if filepath.Base(gp) != "mcp.json" { + t.Errorf("GlobalPath base = %q", filepath.Base(gp)) + } +} + +func TestGeminiWriterPaths(t *testing.T) { + w := For(Gemini) + if w.LocalPath() != filepath.Join(".gemini", "settings.json") { + t.Errorf("LocalPath = %q", w.LocalPath()) + } + gp, err := w.GlobalPath() + if err != nil { + t.Fatal(err) + } + if filepath.Base(gp) != "settings.json" { + t.Errorf("GlobalPath base = %q", filepath.Base(gp)) + } +} + +func TestCodexWriterPaths(t *testing.T) { + w := For(Codex) + if w.LocalPath() != filepath.Join(".codex", "config.toml") { + t.Errorf("LocalPath = %q", w.LocalPath()) + } + gp, err := w.GlobalPath() + if err != nil { + t.Fatal(err) + } + if filepath.Base(gp) != "config.toml" { + t.Errorf("GlobalPath base = %q", filepath.Base(gp)) + } +} + +// tomlDecode is a test helper wrapping toml.Decode. +func tomlDecode(data string, v any) (any, error) { + _, err := toml.Decode(data, v) + return v, err +}