diff --git a/README.md b/README.md index 1d8d6cb..f2ef95a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Transform any Cobra CLI into an MCP server** -Ophis automatically converts your Cobra commands into MCP tools, and provides CLI commands for integration with Claude Desktop, VSCode, and Cursor. +Ophis automatically converts your Cobra commands into MCP tools, and provides CLI commands for integration with Claude Desktop, VSCode, Cursor, and Zed. ## Quick Start @@ -32,7 +32,7 @@ func main() { } ``` -### Enable in Claude Desktop, VSCode, or Cursor +### Enable in Claude Desktop, VSCode, Cursor, or Zed ```bash # Claude Desktop @@ -44,6 +44,9 @@ func main() { # Cursor ./my-cli mcp cursor enable + +# Zed +./my-cli mcp zed enable ``` Your CLI commands are now available as MCP tools! @@ -73,10 +76,14 @@ mcp │ ├── enable # Add server to VSCode config │ ├── disable # Remove server from VSCode config │ └── list # List VSCode MCP servers -└── cursor - ├── enable # Add server to Cursor config - ├── disable # Remove server from Cursor config - └── list # List Cursor MCP servers +├── cursor +│ ├── enable # Add server to Cursor config +│ ├── disable # Remove server from Cursor config +│ └── list # List Cursor MCP servers +└── zed + ├── enable # Add server to Zed config + ├── disable # Remove server from Zed config + └── list # List Zed MCP servers ``` ## Configuration diff --git a/docs/config.md b/docs/config.md index 1ac238d..0bac35b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -21,7 +21,7 @@ Always filtered regardless of configuration: ## DefaultEnv -Editors like Claude Desktop, VSCode, and Cursor launch MCP server subprocesses with a minimal environment. On macOS this typically means a PATH of just `/usr/bin:/bin:/usr/sbin:/sbin`, which cannot find executables managed by mise, asdf, homebrew, nix, or installed to non-standard paths. +Editors like Claude Desktop, VSCode, Cursor, and Zed launch MCP server subprocesses with a minimal environment. On macOS this typically means a PATH of just `/usr/bin:/bin:/usr/sbin:/sbin`, which cannot find executables managed by mise, asdf, homebrew, nix, or installed to non-standard paths. `DefaultEnv` specifies environment variables that are automatically included when `enable` writes a server config for any editor. These are merged with user-provided `--env` values; user values take precedence on conflict. diff --git a/examples/make/go.mod b/examples/make/go.mod index fe4db66..7eda628 100644 --- a/examples/make/go.mod +++ b/examples/make/go.mod @@ -5,15 +5,19 @@ go 1.24.6 replace github.com/njayp/ophis => ../../ require ( - github.com/njayp/ophis v1.1.3 + github.com/njayp/ophis v1.1.4 github.com/spf13/cobra v1.10.2 ) require ( github.com/google/jsonschema-go v0.4.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/modelcontextprotocol/go-sdk v1.3.0 // indirect + github.com/modelcontextprotocol/go-sdk v1.3.1 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.3 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/examples/make/go.sum b/examples/make/go.sum index 68e4d8f..8f7c1dd 100644 --- a/examples/make/go.sum +++ b/examples/make/go.sum @@ -9,11 +9,15 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs= -github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE= +github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= +github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -21,11 +25,15 @@ 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I= +github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= 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= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/go.mod b/go.mod index 253d615..070fcf8 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,11 @@ go 1.24.6 require ( github.com/google/jsonschema-go v0.4.2 - github.com/modelcontextprotocol/go-sdk v1.3.0 + github.com/modelcontextprotocol/go-sdk v1.3.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 + github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a ) require ( @@ -16,8 +17,11 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.3 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/tools v0.37.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index eb2c731..90cf988 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs= -github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE= +github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= +github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -26,6 +26,10 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -33,11 +37,15 @@ 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I= +github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= 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= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cfgmgr/cmd/zed/disable.go b/internal/cfgmgr/cmd/zed/disable.go new file mode 100644 index 0000000..34b644a --- /dev/null +++ b/internal/cfgmgr/cmd/zed/disable.go @@ -0,0 +1,54 @@ +package zed + +import ( + "fmt" + "os" + + "github.com/njayp/ophis/internal/cfgmgr/manager" + "github.com/spf13/cobra" +) + +type disableFlags struct { + configPath string + serverName string + workspace bool +} + +// disableCommand creates a Cobra command for removing an MCP server from Zed. +func disableCommand() *cobra.Command { + f := &disableFlags{} + cmd := &cobra.Command{ + Use: "disable", + Short: "Remove server from Zed config", + Long: "Remove this application from Zed MCP context servers", + RunE: func(_ *cobra.Command, _ []string) error { + return f.run() + }, + } + + // Add flags + flags := cmd.Flags() + flags.StringVar(&f.configPath, "config-path", "", "Path to Zed settings file") + flags.StringVar(&f.serverName, "server-name", "", "Name of the MCP server to remove (default: derived from executable name)") + flags.BoolVar(&f.workspace, "workspace", false, "Remove from workspace settings (.zed/settings.json) instead of user settings") + + return cmd +} + +func (f *disableFlags) run() error { + if f.serverName == "" { + executablePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to determine executable path: %w", err) + } + + f.serverName = manager.DeriveServerName(executablePath) + } + + m, err := manager.NewZedManager(f.configPath, f.workspace) + if err != nil { + return err + } + + return m.DisableServer(f.serverName) +} diff --git a/internal/cfgmgr/cmd/zed/doc.go b/internal/cfgmgr/cmd/zed/doc.go new file mode 100644 index 0000000..ef06aff --- /dev/null +++ b/internal/cfgmgr/cmd/zed/doc.go @@ -0,0 +1,14 @@ +// Package zed provides CLI commands for managing Zed MCP context servers. +// +// This package implements the 'mcp zed' subcommands: +// - enable: Add MCP server to Zed configuration +// - disable: Remove MCP server from Zed configuration +// - list: Show all configured MCP servers +// +// Supports both workspace (.zed/settings.json) and user-level configurations +// (~/.config/zed/settings.json). When modifying the settings file, all existing +// Zed settings (theme, fonts, keybindings, etc.) are preserved unchanged. +// +// This is an internal package and should not be imported directly by users of the ophis library. +// These commands are automatically available when using ophis.Command() in your application. +package zed diff --git a/internal/cfgmgr/cmd/zed/enable.go b/internal/cfgmgr/cmd/zed/enable.go new file mode 100644 index 0000000..fd320e3 --- /dev/null +++ b/internal/cfgmgr/cmd/zed/enable.go @@ -0,0 +1,93 @@ +package zed + +import ( + "fmt" + "os" + + "github.com/njayp/ophis/internal/cfgmgr/manager" + "github.com/njayp/ophis/internal/cfgmgr/manager/zed" + "github.com/spf13/cobra" +) + +type enableFlags struct { + commandName string + defaultEnv map[string]string + configPath string + logLevel string + serverName string + workspace bool + env map[string]string +} + +// enableCommand creates a Cobra command for adding an MCP server to Zed. +// commandName is the Use name of the ophis root command (e.g. "mcp" or "agent"). +// defaultEnv is merged into the server env; user-provided --env values take precedence. +func enableCommand(commandName string, defaultEnv map[string]string) *cobra.Command { + f := &enableFlags{commandName: commandName, defaultEnv: defaultEnv} + cmd := &cobra.Command{ + Use: "enable", + Short: "Add server to Zed config", + Long: "Add this application as an MCP context server in Zed", + RunE: func(cmd *cobra.Command, _ []string) error { + return f.run(cmd) + }, + } + + // Add flags + flags := cmd.Flags() + flags.StringVar(&f.logLevel, "log-level", "", "Log level (debug, info, warn, error)") + flags.StringVar(&f.configPath, "config-path", "", "Path to Zed settings file") + flags.StringVar(&f.serverName, "server-name", "", "Name for the MCP server (default: derived from executable name)") + flags.BoolVar(&f.workspace, "workspace", false, "Add to workspace settings (.zed/settings.json) instead of user settings") + flags.StringToStringVarP(&f.env, "env", "e", nil, "Environment variables (e.g., --env KEY1=value1 --env KEY2=value2)") + + return cmd +} + +func (f *enableFlags) run(cmd *cobra.Command) error { + // Get the current executable path + executablePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to determine executable path: %w", err) + } + + // Build server configuration + mcpPath, err := manager.GetCmdPath(cmd, f.commandName) + if err != nil { + return fmt.Errorf("failed to determine MCP command path: %w", err) + } + + server := zed.Server{ + Command: executablePath, + Args: append(mcpPath, "start"), + } + + // Add log level to args if specified + if f.logLevel != "" { + server.Args = append(server.Args, "--log-level", f.logLevel) + } + + // Merge default env with user-provided env. + // User values take precedence on conflict. + env := make(map[string]string) + for k, v := range f.defaultEnv { + env[k] = v + } + for k, v := range f.env { + env[k] = v + } + if len(env) > 0 { + server.Env = env + } + + if f.serverName == "" { + f.serverName = manager.DeriveServerName(executablePath) + } + + m, err := manager.NewZedManager(f.configPath, f.workspace) + if err != nil { + return err + } + + return m.EnableServer(f.serverName, server) +} diff --git a/internal/cfgmgr/cmd/zed/list.go b/internal/cfgmgr/cmd/zed/list.go new file mode 100644 index 0000000..3fe3372 --- /dev/null +++ b/internal/cfgmgr/cmd/zed/list.go @@ -0,0 +1,44 @@ +package zed + +import ( + "fmt" + + "github.com/njayp/ophis/internal/cfgmgr/manager" + "github.com/spf13/cobra" +) + +type listFlags struct { + configPath string + workspace bool +} + +// listCommand creates a Cobra command for listing configured MCP servers in Zed. +func listCommand() *cobra.Command { + f := &listFlags{} + cmd := &cobra.Command{ + Use: "list", + Short: "Show Zed MCP context servers", + Long: "Show all MCP context servers configured in Zed", + RunE: func(_ *cobra.Command, _ []string) error { + return f.run() + }, + } + + // Add flags + flags := cmd.Flags() + flags.StringVar(&f.configPath, "config-path", "", "Path to Zed settings file") + flags.BoolVar(&f.workspace, "workspace", false, "List from workspace settings (.zed/settings.json) instead of user settings") + + return cmd +} + +func (f *listFlags) run() error { + m, err := manager.NewZedManager(f.configPath, f.workspace) + if err != nil { + return err + } + + fmt.Printf("Zed MCP context servers:\n\n") + m.Print() + return nil +} diff --git a/internal/cfgmgr/cmd/zed/root.go b/internal/cfgmgr/cmd/zed/root.go new file mode 100644 index 0000000..2381614 --- /dev/null +++ b/internal/cfgmgr/cmd/zed/root.go @@ -0,0 +1,21 @@ +package zed + +import ( + "github.com/spf13/cobra" +) + +// Command creates a new Cobra command for managing Zed MCP context servers. +// commandName is the name of the ophis command in the Cobra tree (e.g. "mcp" or "agent"), +// used by enable to build the correct command path for editor config files. +// defaultEnv is merged into the server env on enable; user --env values take precedence. +func Command(commandName string, defaultEnv map[string]string) *cobra.Command { + cmd := &cobra.Command{ + Use: "zed", + Short: "Manage Zed MCP context servers", + Long: "Manage MCP context server configuration for Zed", + } + + // Add subcommands + cmd.AddCommand(enableCommand(commandName, defaultEnv), disableCommand(), listCommand()) + return cmd +} diff --git a/internal/cfgmgr/manager/manager.go b/internal/cfgmgr/manager/manager.go index 5927efa..18e1ad7 100644 --- a/internal/cfgmgr/manager/manager.go +++ b/internal/cfgmgr/manager/manager.go @@ -1,6 +1,7 @@ package manager import ( + "bytes" "encoding/json" "fmt" "io" @@ -11,6 +12,7 @@ import ( "github.com/njayp/ophis/internal/cfgmgr/manager/claude" "github.com/njayp/ophis/internal/cfgmgr/manager/cursor" "github.com/njayp/ophis/internal/cfgmgr/manager/vscode" + "github.com/njayp/ophis/internal/cfgmgr/manager/zed" ) // Config represents MCP server configuration that can be managed. @@ -26,12 +28,32 @@ type Server interface { Print() } +// Preprocessor is an optional interface that a Config may implement to +// transform raw file bytes before JSON unmarshaling. This is used by +// configs whose backing file is not strict JSON (e.g. Zed's settings.json +// is JSONC — JSON with comments and trailing commas). +type Preprocessor interface { + Preprocess(data []byte) ([]byte, error) +} + +// Serializer is an optional interface that a Config may implement to +// serialize itself back to disk while preserving the original file format. +// When implemented, Serialize is called instead of json.MarshalIndent during +// save, and receives the raw file bytes that were loaded before any +// preprocessing (e.g. the original JSONC with comments and trailing commas). +// If original is empty (new file), the implementation should fall back to +// standard JSON output. +type Serializer interface { + Serialize(original []byte) ([]byte, error) +} + // Manager provides generic configuration management for MCP servers. // It handles loading, saving, and modifying MCP server configurations. // It is not thread-safe. type Manager[S Server, C Config[S]] struct { - configPath string - config C + configPath string + config C + originalData []byte // raw file bytes before any preprocessing; passed to Serializer on save } // NewVSCodeManager creates a new Manager configured for VSCode MCP servers. @@ -66,6 +88,22 @@ func NewCursorManager(configPath string, workspace bool) (*Manager[cursor.Server return m, m.loadConfig() } +// NewZedManager creates a new Manager configured for Zed MCP context servers. +// If workspace is true, uses workspace configuration (.zed/settings.json), +// otherwise uses user-level configuration (~/.config/zed/settings.json). +func NewZedManager(configPath string, workspace bool) (*Manager[zed.Server, *zed.Config], error) { + if configPath == "" { + configPath = zed.ConfigPath(workspace) + } + + m := &Manager[zed.Server, *zed.Config]{ + config: &zed.Config{}, + configPath: configPath, + } + + return m, m.loadConfig() +} + // NewClaudeManager creates a new Manager configured for Claude Desktop MCP servers. func NewClaudeManager(configPath string) (*Manager[claude.Server, *claude.Config], error) { if configPath == "" { @@ -122,6 +160,20 @@ func (m *Manager[S, C]) loadConfig() error { if err != nil { return fmt.Errorf("failed to read configuration file at %q: %w", m.configPath, err) } + // Take a defensive copy before preprocessing. hujson.Standardize rewrites + // comment characters to spaces in-place via sub-slices that alias the + // original buffer, so without a copy the comments would be silently + // overwritten in originalData before Serialize ever sees them. + m.originalData = bytes.Clone(data) + + // If the config knows how to preprocess its raw bytes (e.g. strip JSONC + // comments and trailing commas), do that before standard JSON parsing. + if pp, ok := any(m.config).(Preprocessor); ok { + data, err = pp.Preprocess(data) + if err != nil { + return fmt.Errorf("failed to preprocess configuration file at %q: %w", m.configPath, err) + } + } if err := json.Unmarshal(data, m.config); err != nil { return fmt.Errorf("failed to parse configuration file at %q: invalid JSON format: %w", m.configPath, err) @@ -137,14 +189,21 @@ func (m *Manager[S, C]) saveConfig() error { return fmt.Errorf("failed to create directory for configuration file at %q: %w", filepath.Dir(m.configPath), err) } - data, err := json.MarshalIndent(m.config, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal configuration to JSON: %w", err) + var ( + data []byte + marshalErr error + ) + if s, ok := any(m.config).(Serializer); ok { + data, marshalErr = s.Serialize(m.originalData) + } else { + data, marshalErr = json.MarshalIndent(m.config, "", " ") + } + if marshalErr != nil { + return fmt.Errorf("failed to marshal configuration to JSON: %w", marshalErr) } // backup file - err = m.backupConfig() - if err != nil { + if err := m.backupConfig(); err != nil { return err } diff --git a/internal/cfgmgr/manager/manager_test.go b/internal/cfgmgr/manager/manager_test.go index 70453cf..db3dae2 100644 --- a/internal/cfgmgr/manager/manager_test.go +++ b/internal/cfgmgr/manager/manager_test.go @@ -7,7 +7,9 @@ import ( "testing" "github.com/njayp/ophis/internal/cfgmgr/manager/claude" + "github.com/njayp/ophis/internal/cfgmgr/manager/cursor" "github.com/njayp/ophis/internal/cfgmgr/manager/vscode" + "github.com/njayp/ophis/internal/cfgmgr/manager/zed" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -148,6 +150,344 @@ func TestClaudeManager(t *testing.T) { }) } +func TestCursorManager(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "cursor_config.json") + + t.Run("NewCursorManager creates empty config", func(t *testing.T) { + m, err := NewCursorManager(configPath, false) + require.NoError(t, err) + assert.NotNil(t, m) + assert.Equal(t, configPath, m.configPath) + }) + + t.Run("EnableServer adds new server", func(t *testing.T) { + m, err := NewCursorManager(configPath, false) + require.NoError(t, err) + + server := cursor.Server{ + Type: "stdio", + Command: "/usr/local/bin/myapp", + Args: []string{"mcp", "start"}, + } + + err = m.EnableServer("test-server", server) + require.NoError(t, err) + + assert.True(t, m.config.HasServer("test-server")) + + data, err := os.ReadFile(configPath) + require.NoError(t, err) + + var savedConfig cursor.Config + err = json.Unmarshal(data, &savedConfig) + require.NoError(t, err) + assert.True(t, savedConfig.HasServer("test-server")) + }) + + t.Run("EnableServer with environment variables", func(t *testing.T) { + m, err := NewCursorManager(configPath, false) + require.NoError(t, err) + + server := cursor.Server{ + Type: "stdio", + Command: "/usr/local/bin/myapp", + Args: []string{"mcp", "start"}, + Env: map[string]string{ + "DEBUG": "true", + "PORT": "9090", + }, + } + + err = m.EnableServer("env-test", server) + require.NoError(t, err) + + m2, err := NewCursorManager(configPath, false) + require.NoError(t, err) + assert.True(t, m2.config.HasServer("env-test")) + }) + + t.Run("DisableServer removes existing server", func(t *testing.T) { + m, err := NewCursorManager(configPath, false) + require.NoError(t, err) + + server := cursor.Server{ + Type: "stdio", + Command: "/usr/local/bin/myapp", + Args: []string{"mcp", "start"}, + } + + err = m.EnableServer("test-server", server) + require.NoError(t, err) + + err = m.DisableServer("test-server") + require.NoError(t, err) + + assert.False(t, m.config.HasServer("test-server")) + + m2, err := NewCursorManager(configPath, false) + require.NoError(t, err) + assert.False(t, m2.config.HasServer("test-server")) + }) + + t.Run("DisableServer handles non-existent server", func(t *testing.T) { + m, err := NewCursorManager(configPath, false) + require.NoError(t, err) + + err = m.DisableServer("non-existent") + require.NoError(t, err) + }) + + t.Run("loadConfig handles missing file", func(t *testing.T) { + nonExistentPath := filepath.Join(tmpDir, "cursor_non_existent.json") + m, err := NewCursorManager(nonExistentPath, false) + require.NoError(t, err) + assert.NotNil(t, m) + }) + + t.Run("loadConfig handles invalid JSON", func(t *testing.T) { + invalidPath := filepath.Join(tmpDir, "cursor_invalid.json") + err := os.WriteFile(invalidPath, []byte("not valid json"), 0o644) + require.NoError(t, err) + + _, err = NewCursorManager(invalidPath, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid JSON format") + }) + + t.Run("Multiple servers can coexist", func(t *testing.T) { + m, err := NewCursorManager(configPath, false) + require.NoError(t, err) + + for _, name := range []string{"server-a", "server-b", "server-c"} { + err = m.EnableServer(name, cursor.Server{ + Type: "stdio", + Command: "/usr/local/bin/" + name, + Args: []string{"mcp", "start"}, + }) + require.NoError(t, err) + } + + assert.True(t, m.config.HasServer("server-a")) + assert.True(t, m.config.HasServer("server-b")) + assert.True(t, m.config.HasServer("server-c")) + + err = m.DisableServer("server-b") + require.NoError(t, err) + + assert.True(t, m.config.HasServer("server-a")) + assert.False(t, m.config.HasServer("server-b")) + assert.True(t, m.config.HasServer("server-c")) + }) +} + +func TestZedManager(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "zed_settings.json") + + t.Run("NewZedManager creates empty config", func(t *testing.T) { + m, err := NewZedManager(configPath, false) + require.NoError(t, err) + assert.NotNil(t, m) + assert.Equal(t, configPath, m.configPath) + }) + + t.Run("EnableServer adds new server", func(t *testing.T) { + m, err := NewZedManager(configPath, false) + require.NoError(t, err) + + server := zed.Server{ + Command: "/usr/local/bin/myapp", + Args: []string{"mcp", "start"}, + } + + err = m.EnableServer("test-server", server) + require.NoError(t, err) + + assert.True(t, m.config.HasServer("test-server")) + + // Verify persisted as valid strict JSON (no comments/trailing commas) + data, err := os.ReadFile(configPath) + require.NoError(t, err) + + var savedConfig zed.Config + err = json.Unmarshal(data, &savedConfig) + require.NoError(t, err) + assert.True(t, savedConfig.HasServer("test-server")) + assert.Equal(t, "/usr/local/bin/myapp", savedConfig.ContextServers["test-server"].Command) + }) + + t.Run("EnableServer with environment variables", func(t *testing.T) { + m, err := NewZedManager(configPath, false) + require.NoError(t, err) + + server := zed.Server{ + Command: "/usr/local/bin/myapp", + Args: []string{"mcp", "start"}, + Env: map[string]string{ + "DEBUG": "true", + "PATH": "/usr/local/bin:/usr/bin", + }, + } + + err = m.EnableServer("env-test", server) + require.NoError(t, err) + + m2, err := NewZedManager(configPath, false) + require.NoError(t, err) + assert.True(t, m2.config.HasServer("env-test")) + }) + + t.Run("DisableServer removes existing server", func(t *testing.T) { + m, err := NewZedManager(configPath, false) + require.NoError(t, err) + + server := zed.Server{ + Command: "/usr/local/bin/myapp", + Args: []string{"mcp", "start"}, + } + + err = m.EnableServer("test-server", server) + require.NoError(t, err) + + err = m.DisableServer("test-server") + require.NoError(t, err) + + assert.False(t, m.config.HasServer("test-server")) + + m2, err := NewZedManager(configPath, false) + require.NoError(t, err) + assert.False(t, m2.config.HasServer("test-server")) + }) + + t.Run("DisableServer handles non-existent server", func(t *testing.T) { + m, err := NewZedManager(configPath, false) + require.NoError(t, err) + + err = m.DisableServer("non-existent") + require.NoError(t, err) + }) + + t.Run("loadConfig handles missing file", func(t *testing.T) { + nonExistentPath := filepath.Join(tmpDir, "zed_non_existent.json") + m, err := NewZedManager(nonExistentPath, false) + require.NoError(t, err) + assert.NotNil(t, m) + }) + + t.Run("loadConfig handles JSONC with comments and trailing commas", func(t *testing.T) { + jsoncPath := filepath.Join(tmpDir, "zed_jsonc.json") + jsoncContent := []byte(`// Zed settings +{ + "theme": "One Dark", + "ui_font_size": 16, + "context_servers": { + "existing-server": { + "command": "/usr/local/bin/existing", + "args": ["mcp", "start"], + }, + }, +}`) + err := os.WriteFile(jsoncPath, jsoncContent, 0o644) + require.NoError(t, err) + + m, err := NewZedManager(jsoncPath, false) + require.NoError(t, err) + assert.True(t, m.config.HasServer("existing-server")) + }) + + t.Run("EnableServer preserves other Zed settings from JSONC file", func(t *testing.T) { + jsoncPath := filepath.Join(tmpDir, "zed_preserve.json") + jsoncContent := []byte(`// Zed settings +{ + "theme": "Gruvbox", + "ui_font_size": 14, + "context_servers": { + "pre-existing": { + "command": "/usr/local/bin/other", + }, + }, +}`) + err := os.WriteFile(jsoncPath, jsoncContent, 0o644) + require.NoError(t, err) + + m, err := NewZedManager(jsoncPath, false) + require.NoError(t, err) + + err = m.EnableServer("new-server", zed.Server{ + Command: "/usr/local/bin/myapp", + Args: []string{"mcp", "start"}, + }) + require.NoError(t, err) + + // Reload and verify both servers and other settings are present + m2, err := NewZedManager(jsoncPath, false) + require.NoError(t, err) + assert.True(t, m2.config.HasServer("pre-existing")) + assert.True(t, m2.config.HasServer("new-server")) + + // Verify the written file preserved JSONC (comments and trailing commas + // from the original should still be present). + data, err := os.ReadFile(jsoncPath) + require.NoError(t, err) + written := string(data) + assert.Contains(t, written, "// Zed settings") + assert.Contains(t, written, `"theme"`) + assert.Contains(t, written, `"ui_font_size"`) + assert.Contains(t, written, `"context_servers"`) + }) + + t.Run("loadConfig handles invalid content", func(t *testing.T) { + invalidPath := filepath.Join(tmpDir, "zed_invalid.json") + err := os.WriteFile(invalidPath, []byte(`{ completely: broken ??? `), 0o644) + require.NoError(t, err) + + _, err = NewZedManager(invalidPath, false) + require.Error(t, err) + }) + + t.Run("Multiple servers can coexist", func(t *testing.T) { + m, err := NewZedManager(configPath, false) + require.NoError(t, err) + + for _, name := range []string{"server-a", "server-b", "server-c"} { + err = m.EnableServer(name, zed.Server{ + Command: "/usr/local/bin/" + name, + Args: []string{"mcp", "start"}, + }) + require.NoError(t, err) + } + + assert.True(t, m.config.HasServer("server-a")) + assert.True(t, m.config.HasServer("server-b")) + assert.True(t, m.config.HasServer("server-c")) + + err = m.DisableServer("server-b") + require.NoError(t, err) + + assert.True(t, m.config.HasServer("server-a")) + assert.False(t, m.config.HasServer("server-b")) + assert.True(t, m.config.HasServer("server-c")) + }) + + t.Run("backupConfig creates backup", func(t *testing.T) { + backupTestPath := filepath.Join(tmpDir, "zed_backup_test.json") + m, err := NewZedManager(backupTestPath, false) + require.NoError(t, err) + + err = m.EnableServer("backup-test", zed.Server{Command: "/usr/local/bin/myapp"}) + require.NoError(t, err) + + // Second write triggers backup + err = m.EnableServer("backup-test-2", zed.Server{Command: "/usr/local/bin/myapp"}) + require.NoError(t, err) + + backupPath := filepath.Join(tmpDir, "zed_backup_test.backup.json") + _, err = os.Stat(backupPath) + require.NoError(t, err) + }) +} + func TestVSCodeManager(t *testing.T) { // Create a temporary directory for test configs tmpDir := t.TempDir() diff --git a/internal/cfgmgr/manager/zed/config.go b/internal/cfgmgr/manager/zed/config.go new file mode 100644 index 0000000..08c1e5b --- /dev/null +++ b/internal/cfgmgr/manager/zed/config.go @@ -0,0 +1,173 @@ +package zed + +import ( + "encoding/json" + "fmt" + "slices" + + "github.com/tailscale/hujson" +) + +// Config represents Zed's settings.json file. +// It uses custom JSON marshaling to preserve all existing Zed settings +// (e.g. theme, font, keybindings) while only managing the "context_servers" key. +type Config struct { + ContextServers map[string]Server + extra map[string]json.RawMessage +} + +// UnmarshalJSON deserializes the Zed settings file, extracting "context_servers" +// and preserving all other keys unchanged. +func (c *Config) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + if cs, ok := raw["context_servers"]; ok { + if err := json.Unmarshal(cs, &c.ContextServers); err != nil { + return err + } + delete(raw, "context_servers") + } + + c.extra = raw + return nil +} + +// Preprocess implements manager.Preprocessor by normalizing Zed's JSONC +// settings file to standard JSON before unmarshaling. Zed's settings.json +// uses JSONC syntax — it may contain // line comments, /* block comments */, +// and trailing commas after the last element in objects and arrays, none of +// which are valid in strict JSON. +func (c *Config) Preprocess(data []byte) ([]byte, error) { + standardized, err := hujson.Standardize(data) + if err != nil { + return nil, fmt.Errorf("failed to normalize JSONC: %w", err) + } + return standardized, nil +} + +// Serialize implements manager.Serializer by surgically updating the +// "context_servers" key in the original JSONC AST and packing it back, +// preserving all comments, trailing commas, and formatting in the rest +// of the file. If original is empty (new file), it falls back to standard +// indented JSON. +func (c *Config) Serialize(original []byte) ([]byte, error) { + if len(original) == 0 { + return json.MarshalIndent(c, "", " ") + } + + root, err := hujson.Parse(original) + if err != nil { + // Unparseable original — fall back to standard JSON. + return json.MarshalIndent(c, "", " ") + } + + obj, ok := root.Value.(*hujson.Object) + if !ok { + return json.MarshalIndent(c, "", " ") + } + + // Locate the index of the existing "context_servers" member, if any. + csIdx := -1 + for i, m := range obj.Members { + if lit, ok := m.Name.Value.(hujson.Literal); ok && lit.String() == "context_servers" { + csIdx = i + break + } + } + + if len(c.ContextServers) == 0 { + // No context servers — remove the key entirely if present. + if csIdx >= 0 { + obj.Members = slices.Delete(obj.Members, csIdx, csIdx+1) + } + return root.Pack(), nil + } + + // Marshal the current context_servers map to indented JSON using the + // same 2-space indent as Zed's settings file, then parse it as a hujson + // Value so it can be inserted into the existing JSONC AST. + // prefix=" " ensures the closing brace and nested keys align correctly + // when the key sits at the top level of the root object. + csJSON, err := json.MarshalIndent(c.ContextServers, " ", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal context_servers: %w", err) + } + csVal, err := hujson.Parse(csJSON) + if err != nil { + return nil, fmt.Errorf("failed to parse context_servers JSON: %w", err) + } + + if csIdx >= 0 { + // Update the value in place, keeping the key's surrounding whitespace + // and any comments that immediately follow it. + obj.Members[csIdx].Value = csVal + } else { + // Append a new member. Give the key a leading newline + indent so it + // sits on its own line consistent with the rest of the file. + obj.Members = append(obj.Members, hujson.ObjectMember{ + Name: hujson.Value{ + BeforeExtra: hujson.Extra("\n "), + Value: hujson.String("context_servers"), + }, + Value: csVal, + }) + } + + return root.Pack(), nil +} + +// MarshalJSON serializes the config back to JSON, merging the managed +// "context_servers" with all other preserved Zed settings. +func (c Config) MarshalJSON() ([]byte, error) { + result := make(map[string]json.RawMessage, len(c.extra)+1) + + for k, v := range c.extra { + result[k] = v + } + + if c.ContextServers != nil { + cs, err := json.Marshal(c.ContextServers) + if err != nil { + return nil, err + } + result["context_servers"] = cs + } + + return json.Marshal(result) +} + +// AddServer adds or updates a server in the configuration. +func (c *Config) AddServer(name string, server Server) { + if c.ContextServers == nil { + c.ContextServers = make(map[string]Server) + } + c.ContextServers[name] = server +} + +// HasServer returns true if a server with the given name exists in the configuration. +func (c *Config) HasServer(name string) bool { + _, ok := c.ContextServers[name] + return ok +} + +// RemoveServer removes a server from the configuration. +func (c *Config) RemoveServer(name string) { + delete(c.ContextServers, name) +} + +// Print displays all configured MCP servers. +func (c *Config) Print() { + if len(c.ContextServers) == 0 { + fmt.Println("No MCP servers are currently configured.") + return + } + + for name, server := range c.ContextServers { + fmt.Printf("Server: %s\n", name) + server.Print() + fmt.Println() + } +} diff --git a/internal/cfgmgr/manager/zed/config_test.go b/internal/cfgmgr/manager/zed/config_test.go new file mode 100644 index 0000000..4da6711 --- /dev/null +++ b/internal/cfgmgr/manager/zed/config_test.go @@ -0,0 +1,440 @@ +package zed + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfig(t *testing.T) { + t.Run("AddServer adds new server", func(t *testing.T) { + config := &Config{} + server := Server{ + Command: "/usr/local/bin/myapp", + Args: []string{"mcp", "start"}, + } + + config.AddServer("test", server) + + assert.True(t, config.HasServer("test")) + assert.Equal(t, 1, len(config.ContextServers)) + }) + + t.Run("AddServer initializes map if nil", func(t *testing.T) { + config := &Config{} + assert.Nil(t, config.ContextServers) + + server := Server{ + Command: "/usr/local/bin/myapp", + } + + config.AddServer("test", server) + assert.NotNil(t, config.ContextServers) + assert.True(t, config.HasServer("test")) + }) + + t.Run("AddServer updates existing server", func(t *testing.T) { + config := &Config{} + + originalServer := Server{ + Command: "/usr/local/bin/myapp", + Args: []string{"start"}, + } + config.AddServer("test", originalServer) + + updatedServer := Server{ + Command: "/usr/local/bin/myapp", + Args: []string{"start", "--verbose"}, + } + config.AddServer("test", updatedServer) + + assert.Equal(t, 1, len(config.ContextServers)) + assert.Equal(t, 2, len(config.ContextServers["test"].Args)) + }) + + t.Run("HasServer returns false for non-existent server", func(t *testing.T) { + config := &Config{ + ContextServers: map[string]Server{ + "server1": {Command: "/bin/app"}, + }, + } + + assert.False(t, config.HasServer("non-existent")) + assert.True(t, config.HasServer("server1")) + }) + + t.Run("RemoveServer removes existing server", func(t *testing.T) { + config := &Config{ + ContextServers: map[string]Server{ + "server1": {Command: "/bin/app1"}, + "server2": {Command: "/bin/app2"}, + }, + } + + config.RemoveServer("server1") + + assert.False(t, config.HasServer("server1")) + assert.True(t, config.HasServer("server2")) + assert.Equal(t, 1, len(config.ContextServers)) + }) + + t.Run("RemoveServer handles non-existent server", func(t *testing.T) { + config := &Config{ + ContextServers: map[string]Server{ + "server1": {Command: "/bin/app"}, + }, + } + + config.RemoveServer("non-existent") + + assert.Equal(t, 1, len(config.ContextServers)) + assert.True(t, config.HasServer("server1")) + }) + + t.Run("Multiple servers can be managed", func(t *testing.T) { + config := &Config{} + + servers := []struct { + name string + server Server + }{ + {"kubectl", Server{Command: "/usr/local/bin/kubectl"}}, + {"helm", Server{Command: "/usr/local/bin/helm"}}, + {"argocd", Server{Command: "/usr/local/bin/argocd"}}, + } + + for _, s := range servers { + config.AddServer(s.name, s.server) + } + + assert.Equal(t, 3, len(config.ContextServers)) + for _, s := range servers { + assert.True(t, config.HasServer(s.name)) + } + + config.RemoveServer("helm") + assert.Equal(t, 2, len(config.ContextServers)) + assert.False(t, config.HasServer("helm")) + }) +} + +func TestConfigJSON(t *testing.T) { + t.Run("UnmarshalJSON extracts context_servers", func(t *testing.T) { + data := []byte(`{ + "theme": "One Dark", + "font_size": 14, + "context_servers": { + "my-server": { + "command": "/usr/local/bin/myapp", + "args": ["mcp", "start"] + } + } + }`) + + config := &Config{} + err := json.Unmarshal(data, config) + require.NoError(t, err) + + assert.True(t, config.HasServer("my-server")) + assert.Equal(t, "/usr/local/bin/myapp", config.ContextServers["my-server"].Command) + assert.Equal(t, []string{"mcp", "start"}, config.ContextServers["my-server"].Args) + }) + + t.Run("UnmarshalJSON handles missing context_servers", func(t *testing.T) { + data := []byte(`{ + "theme": "One Dark", + "font_size": 14 + }`) + + config := &Config{} + err := json.Unmarshal(data, config) + require.NoError(t, err) + + assert.Nil(t, config.ContextServers) + assert.Equal(t, 2, len(config.extra)) + }) + + t.Run("MarshalJSON preserves other settings", func(t *testing.T) { + data := []byte(`{"font_size":14,"theme":"One Dark","context_servers":{"my-server":{"command":"/usr/local/bin/myapp"}}}`) + + config := &Config{} + err := json.Unmarshal(data, config) + require.NoError(t, err) + + // Add a new server + config.AddServer("new-server", Server{Command: "/usr/local/bin/other"}) + + out, err := json.Marshal(config) + require.NoError(t, err) + + // Round-trip back to verify all data is present + var result map[string]json.RawMessage + err = json.Unmarshal(out, &result) + require.NoError(t, err) + + assert.Contains(t, result, "theme") + assert.Contains(t, result, "font_size") + assert.Contains(t, result, "context_servers") + + var servers map[string]Server + err = json.Unmarshal(result["context_servers"], &servers) + require.NoError(t, err) + assert.True(t, len(servers) == 2) + assert.Contains(t, servers, "my-server") + assert.Contains(t, servers, "new-server") + }) + + t.Run("MarshalJSON round-trips empty config", func(t *testing.T) { + config := &Config{} + config.AddServer("test", Server{Command: "/bin/app", Args: []string{"mcp", "start"}}) + + out, err := json.Marshal(config) + require.NoError(t, err) + + config2 := &Config{} + err = json.Unmarshal(out, config2) + require.NoError(t, err) + + assert.True(t, config2.HasServer("test")) + assert.Equal(t, "/bin/app", config2.ContextServers["test"].Command) + assert.Equal(t, []string{"mcp", "start"}, config2.ContextServers["test"].Args) + }) + + t.Run("MarshalJSON omits context_servers when nil", func(t *testing.T) { + data := []byte(`{"theme":"One Dark"}`) + + config := &Config{} + err := json.Unmarshal(data, config) + require.NoError(t, err) + + out, err := json.Marshal(config) + require.NoError(t, err) + + var result map[string]json.RawMessage + err = json.Unmarshal(out, &result) + require.NoError(t, err) + + assert.NotContains(t, result, "context_servers") + assert.Contains(t, result, "theme") + }) + + t.Run("RemoveServer then marshal does not include removed server", func(t *testing.T) { + data := []byte(`{ + "theme": "Gruvbox", + "context_servers": { + "server-a": {"command": "/bin/a"}, + "server-b": {"command": "/bin/b"} + } + }`) + + config := &Config{} + err := json.Unmarshal(data, config) + require.NoError(t, err) + + config.RemoveServer("server-a") + + out, err := json.Marshal(config) + require.NoError(t, err) + + config2 := &Config{} + err = json.Unmarshal(out, config2) + require.NoError(t, err) + + assert.False(t, config2.HasServer("server-a")) + assert.True(t, config2.HasServer("server-b")) + + var result map[string]json.RawMessage + err = json.Unmarshal(out, &result) + require.NoError(t, err) + assert.Contains(t, result, "theme") + }) +} + +func TestServer(t *testing.T) { + t.Run("Server with command only", func(t *testing.T) { + server := Server{ + Command: "/usr/local/bin/myapp", + } + + assert.Equal(t, "/usr/local/bin/myapp", server.Command) + assert.Empty(t, server.Args) + assert.Empty(t, server.Env) + assert.Empty(t, server.URL) + }) + + t.Run("Server with args", func(t *testing.T) { + server := Server{ + Command: "/usr/local/bin/myapp", + Args: []string{"mcp", "start", "--log-level", "debug"}, + } + + assert.Equal(t, 4, len(server.Args)) + assert.Equal(t, "mcp", server.Args[0]) + assert.Equal(t, "debug", server.Args[3]) + }) + + t.Run("Server with environment variables", func(t *testing.T) { + server := Server{ + Command: "/usr/local/bin/myapp", + Env: map[string]string{ + "DEBUG": "true", + "LOG_FILE": "/var/log/app.log", + }, + } + + assert.Equal(t, 2, len(server.Env)) + assert.Equal(t, "true", server.Env["DEBUG"]) + assert.Equal(t, "/var/log/app.log", server.Env["LOG_FILE"]) + }) + + t.Run("Server with remote URL", func(t *testing.T) { + server := Server{ + URL: "https://api.example.com/mcp", + Headers: map[string]string{ + "Authorization": "Bearer token123", + }, + } + + assert.Equal(t, "https://api.example.com/mcp", server.URL) + assert.Equal(t, 1, len(server.Headers)) + assert.Equal(t, "Bearer token123", server.Headers["Authorization"]) + assert.Empty(t, server.Command) + }) + + t.Run("Server with all fields", func(t *testing.T) { + server := Server{ + Command: "/usr/local/bin/myapp", + Args: []string{"mcp", "start"}, + Env: map[string]string{ + "API_KEY": "secret", + }, + } + + assert.NotEmpty(t, server.Command) + assert.NotEmpty(t, server.Args) + assert.NotEmpty(t, server.Env) + }) +} + +func TestPreprocess(t *testing.T) { + t.Run("Preprocess strips line comments", func(t *testing.T) { + data := []byte(`{ + // This is a comment + "theme": "One Dark" + }`) + + config := &Config{} + result, err := config.Preprocess(data) + require.NoError(t, err) + + var out map[string]any + err = json.Unmarshal(result, &out) + require.NoError(t, err) + assert.Equal(t, "One Dark", out["theme"]) + }) + + t.Run("Preprocess strips trailing commas in objects", func(t *testing.T) { + data := []byte(`{ + "theme": "One Dark", + "font_size": 14, + }`) + + config := &Config{} + result, err := config.Preprocess(data) + require.NoError(t, err) + + var out map[string]any + err = json.Unmarshal(result, &out) + require.NoError(t, err) + assert.Equal(t, float64(14), out["font_size"]) + }) + + t.Run("Preprocess strips trailing commas in arrays", func(t *testing.T) { + data := []byte(`{ + "favorite_models": ["gpt-4", "claude",], + }`) + + config := &Config{} + result, err := config.Preprocess(data) + require.NoError(t, err) + + var out map[string]any + err = json.Unmarshal(result, &out) + require.NoError(t, err) + models := out["favorite_models"].([]any) + assert.Equal(t, 2, len(models)) + }) + + t.Run("Preprocess handles real Zed settings.json format", func(t *testing.T) { + data := []byte(`// Zed settings +// +// For information on how to configure Zed, see the Zed +// documentation: https://zed.dev/docs/configuring-zed +{ + "agent": { + "tool_permissions": { + "default": "allow" + }, + "default_model": { + "provider": "copilot_chat", + "model": "claude-sonnet-4", + }, + "favorite_models": [], + }, + "ui_font_size": 16, + "theme": { + "mode": "system", + "light": "One Light", + "dark": "One Dark", + }, + "context_servers": { + "my-server": { + "command": "/usr/local/bin/myapp", + "args": ["mcp", "start"] + } + }, +}`) + + // Mirror what loadConfig does: Preprocess first, then json.Unmarshal. + // json.Unmarshal validates the entire input before dispatching to a + // custom UnmarshalJSON, so JSONC must be normalised to strict JSON + // before it is handed to the standard library. + config := &Config{} + normalized, err := config.Preprocess(data) + require.NoError(t, err) + + err = json.Unmarshal(normalized, config) + require.NoError(t, err) + + assert.True(t, config.HasServer("my-server")) + assert.Equal(t, "/usr/local/bin/myapp", config.ContextServers["my-server"].Command) + // Other settings are preserved in extra + assert.Contains(t, config.extra, "theme") + assert.Contains(t, config.extra, "ui_font_size") + assert.Contains(t, config.extra, "agent") + }) + + t.Run("Preprocess leaves valid JSON unchanged", func(t *testing.T) { + data := []byte(`{"theme":"One Dark","font_size":14}`) + + config := &Config{} + result, err := config.Preprocess(data) + require.NoError(t, err) + + var out map[string]any + err = json.Unmarshal(result, &out) + require.NoError(t, err) + assert.Equal(t, "One Dark", out["theme"]) + assert.Equal(t, float64(14), out["font_size"]) + }) + + t.Run("Preprocess returns error for invalid content", func(t *testing.T) { + data := []byte(`{ not valid at all ???`) + + config := &Config{} + _, err := config.Preprocess(data) + assert.Error(t, err) + }) +} diff --git a/internal/cfgmgr/manager/zed/doc.go b/internal/cfgmgr/manager/zed/doc.go new file mode 100644 index 0000000..d7cb147 --- /dev/null +++ b/internal/cfgmgr/manager/zed/doc.go @@ -0,0 +1,19 @@ +// Package zed provides configuration management for Zed MCP context servers. +// +// This package handles: +// - Zed workspace configuration (.zed/settings.json) +// - Zed user-level configuration (~/.config/zed/settings.json) +// - Platform-specific configuration file paths (macOS, Linux, Windows) +// - MCP server entry management under the "context_servers" key +// +// Unlike other editors, Zed's settings.json is a general-purpose configuration +// file containing many editor settings beyond MCP servers. This package uses +// custom JSON marshaling to safely read and write only the "context_servers" +// section while preserving all other existing Zed settings unchanged. +// +// Platform-specific path functions use build tags to locate the Zed +// configuration directory on different operating systems. On Linux, the +// XDG_CONFIG_HOME environment variable is respected. +// +// This is an internal package and should not be imported by users of the ophis library. +package zed diff --git a/internal/cfgmgr/manager/zed/server.go b/internal/cfgmgr/manager/zed/server.go new file mode 100644 index 0000000..46c36ad --- /dev/null +++ b/internal/cfgmgr/manager/zed/server.go @@ -0,0 +1,37 @@ +package zed + +import "fmt" + +// Server represents an MCP server configuration entry for Zed. +type Server struct { + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + +// Print displays the server configuration details. +func (s Server) Print() { + if s.Command != "" { + fmt.Printf(" Command: %s\n", s.Command) + } + if s.URL != "" { + fmt.Printf(" URL: %s\n", s.URL) + } + if len(s.Args) > 0 { + fmt.Printf(" Args: %v\n", s.Args) + } + if len(s.Env) > 0 { + fmt.Printf(" Environment:\n") + for key, value := range s.Env { + fmt.Printf(" %s: %s\n", key, value) + } + } + if len(s.Headers) > 0 { + fmt.Printf(" Headers:\n") + for key, value := range s.Headers { + fmt.Printf(" %s: %s\n", key, value) + } + } +} diff --git a/internal/cfgmgr/manager/zed/zed.go b/internal/cfgmgr/manager/zed/zed.go new file mode 100644 index 0000000..0069426 --- /dev/null +++ b/internal/cfgmgr/manager/zed/zed.go @@ -0,0 +1,28 @@ +package zed + +import ( + "os" + "path/filepath" +) + +// ConfigPath returns the platform-specific default path for Zed configuration. +// If workspace is true, returns workspace configuration path (.zed/settings.json), +// otherwise returns user-level configuration path. +func ConfigPath(workspace bool) string { + if workspace { + return getDefaultWorkspaceConfigPath() + } + + return getDefaultZedUserConfigPath() +} + +// getDefaultWorkspaceConfigPath returns the default workspace configuration path (.zed/settings.json). +func getDefaultWorkspaceConfigPath() string { + workingDir, err := os.Getwd() + if err != nil { + // Fallback to current directory + return filepath.Join(".zed", "settings.json") + } + + return filepath.Join(workingDir, ".zed", "settings.json") +} diff --git a/internal/cfgmgr/manager/zed/zed_darwin.go b/internal/cfgmgr/manager/zed/zed_darwin.go new file mode 100644 index 0000000..50534ec --- /dev/null +++ b/internal/cfgmgr/manager/zed/zed_darwin.go @@ -0,0 +1,16 @@ +package zed + +import ( + "os" + "path/filepath" +) + +// getDefaultZedUserConfigPath returns the default Zed user settings.json path on macOS +func getDefaultZedUserConfigPath() string { + homeDir, err := os.UserHomeDir() + if err != nil { + // Fallback to a reasonable default + return filepath.Join("/Users", os.Getenv("USER"), ".config", "zed", "settings.json") + } + return filepath.Join(homeDir, ".config", "zed", "settings.json") +} diff --git a/internal/cfgmgr/manager/zed/zed_linux.go b/internal/cfgmgr/manager/zed/zed_linux.go new file mode 100644 index 0000000..dd6e24d --- /dev/null +++ b/internal/cfgmgr/manager/zed/zed_linux.go @@ -0,0 +1,23 @@ +package zed + +import ( + "os" + "path/filepath" +) + +// getDefaultZedUserConfigPath returns the default Zed user settings.json path on Linux +func getDefaultZedUserConfigPath() string { + homeDir, err := os.UserHomeDir() + if err != nil { + // Fallback to a reasonable default + return filepath.Join("/home", os.Getenv("USER"), ".config", "zed", "settings.json") + } + + // Check for XDG_CONFIG_HOME first + configDir := os.Getenv("XDG_CONFIG_HOME") + if configDir == "" { + configDir = filepath.Join(homeDir, ".config") + } + + return filepath.Join(configDir, "zed", "settings.json") +} diff --git a/internal/cfgmgr/manager/zed/zed_windows.go b/internal/cfgmgr/manager/zed/zed_windows.go new file mode 100644 index 0000000..1e6d735 --- /dev/null +++ b/internal/cfgmgr/manager/zed/zed_windows.go @@ -0,0 +1,16 @@ +package zed + +import ( + "os" + "path/filepath" +) + +// getDefaultZedUserConfigPath returns the default Zed user settings.json path on Windows +func getDefaultZedUserConfigPath() string { + homeDir, err := os.UserHomeDir() + if err != nil { + // Fallback using USERPROFILE environment variable + return filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Roaming", "Zed", "settings.json") + } + return filepath.Join(homeDir, "AppData", "Roaming", "Zed", "settings.json") +} diff --git a/root.go b/root.go index 0fd2341..91e9aac 100644 --- a/root.go +++ b/root.go @@ -4,6 +4,7 @@ import ( "github.com/njayp/ophis/internal/cfgmgr/cmd/claude" "github.com/njayp/ophis/internal/cfgmgr/cmd/cursor" "github.com/njayp/ophis/internal/cfgmgr/cmd/vscode" + "github.com/njayp/ophis/internal/cfgmgr/cmd/zed" "github.com/spf13/cobra" ) @@ -31,6 +32,7 @@ func Command(config *Config) *cobra.Command { claude.Command(name, defaultEnv), vscode.Command(name, defaultEnv), cursor.Command(name, defaultEnv), + zed.Command(name, defaultEnv), ) return cmd }