Files
ely-eye/ely-eye-mcp/internal/setup/setup.go
T
ZacharyZhang-NY eb50367921
ci / backend (push) Failing after 3m25s
ci / mcp (push) Successful in 21s
ci / interface (push) Successful in 19s
ci / image (push) Failing after 1m5s
Move the canonical host to git.zacharyzhang.com and follow the module path
The GitHub account that hosted this repository is suspended, so the remote
is dead and the vanity import github.com/ZacharyZhang-NY/ely-eye/... no
longer resolves. The Go module path is that import, so it moves with the
host: git.zacharyzhang.com serves a go-import meta tag for the nested
ely-eye-mcp submodule, which go build and go get both need.

The installers read release assets from the host too, so their API and
download URLs move with it. The release workflow's -ldflags symbol path is
the module path, so it moves as well; a build stamped with the new path
prints the stamped version.
2026-10-10 11:26:19 -04:00

221 lines
5.8 KiB
Go

package setup
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"git.zacharyzhang.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/config"
)
type Options struct {
Client string
Scope string
ServerName string
}
type Action struct {
Client string `json:"client"`
Scope string `json:"scope"`
Target string `json:"target"`
Detail string `json:"detail"`
Command string `json:"command,omitempty"`
}
type Result struct {
ServerName string `json:"server_name"`
Binary string `json:"binary"`
ProjectRoot string `json:"project_root"`
Actions []Action `json:"actions"`
}
func Run(ctx context.Context, cfg config.Config, options Options) (Result, error) {
client := normalizeClient(options.Client)
if client != "codex" && client != "claude" && client != "both" {
return Result{}, errors.New("client must be codex, claude, or both")
}
scope := normalizeScope(options.Scope)
if scope != "project" && scope != "user" {
return Result{}, errors.New("claude scope must be project or user")
}
serverName := options.ServerName
if serverName == "" {
serverName = "ely-eye"
}
if cfg.Binary == "" {
return Result{}, errors.New("binary path is required for setup")
}
result := Result{ServerName: serverName, Binary: cfg.Binary, ProjectRoot: cfg.ProjectRoot}
var actions []Action
if client == "codex" || client == "both" {
action, err := setupCodex(ctx, cfg, serverName)
if err != nil {
return Result{}, err
}
actions = append(actions, action)
}
if client == "claude" || client == "both" {
action, err := setupClaude(ctx, cfg, serverName, scope)
if err != nil {
return Result{}, err
}
actions = append(actions, action)
}
result.Actions = actions
return result, nil
}
func normalizeClient(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all", "both":
return "both"
case "codex", "claude":
return strings.ToLower(value)
default:
return value
}
}
func normalizeScope(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "project":
return "project"
case "user":
return "user"
default:
return value
}
}
func serverCommand(cfg config.Config) []string {
return []string{
cfg.Binary,
"server",
"--project-root",
cfg.ProjectRoot,
"--ely-eye-home",
cfg.ElyEyeHome,
}
}
func serverEnv(cfg config.Config) map[string]string {
return map[string]string{
"ELY_EYE_HOME": cfg.ElyEyeHome,
}
}
func commandLine(parts []string) string {
quoted := make([]string, 0, len(parts))
for _, part := range parts {
if strings.ContainsAny(part, " \t\"'") {
quoted = append(quoted, `"`+strings.ReplaceAll(part, `"`, `\"`)+`"`)
} else {
quoted = append(quoted, part)
}
}
return strings.Join(quoted, " ")
}
func setupCodex(ctx context.Context, cfg config.Config, serverName string) (Action, error) {
codex, err := exec.LookPath("codex")
if err != nil {
return Action{}, errors.New("codex CLI is required to configure Codex MCP")
}
remove := exec.CommandContext(ctx, codex, "mcp", "remove", serverName)
_ = remove.Run()
args := []string{"mcp", "add", serverName, "--env", "ELY_EYE_HOME=" + cfg.ElyEyeHome, "--"}
args = append(args, serverCommand(cfg)...)
add := exec.CommandContext(ctx, codex, args...)
output, err := add.CombinedOutput()
if err != nil {
return Action{}, errors.New("codex mcp add failed: " + strings.TrimSpace(string(output)))
}
return Action{
Client: "codex",
Scope: "user",
Target: "~/.codex/config.toml",
Detail: "registered stdio MCP server through Codex CLI",
Command: commandLine(append([]string{"codex"}, args...)),
}, nil
}
func setupClaude(ctx context.Context, cfg config.Config, serverName string, scope string) (Action, error) {
if scope == "user" {
return setupClaudeUser(ctx, cfg, serverName)
}
if scope != "project" {
return Action{}, errors.New("claude scope must be project or user")
}
target := filepath.Join(cfg.ProjectRoot, ".mcp.json")
if err := writeProjectMCP(target, serverName, cfg); err != nil {
return Action{}, err
}
return Action{
Client: "claude",
Scope: "project",
Target: target,
Detail: "wrote project-scoped .mcp.json",
}, nil
}
func setupClaudeUser(ctx context.Context, cfg config.Config, serverName string) (Action, error) {
claude, err := exec.LookPath("claude")
if err != nil {
return Action{}, errors.New("claude CLI is required for user-scoped Claude Code MCP setup")
}
payload, err := json.Marshal(mcpServerConfig(cfg))
if err != nil {
return Action{}, err
}
args := []string{"mcp", "add-json", serverName, string(payload), "--scope", "user"}
cmd := exec.CommandContext(ctx, claude, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return Action{}, errors.New("claude mcp add-json failed: " + strings.TrimSpace(string(output)))
}
return Action{
Client: "claude",
Scope: "user",
Target: "~/.claude.json",
Detail: "registered stdio MCP server through Claude CLI",
Command: commandLine(append([]string{"claude"}, args...)),
}, nil
}
func writeProjectMCP(path string, serverName string, cfg config.Config) error {
payload := map[string]any{}
data, err := os.ReadFile(path)
if err == nil {
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
servers, ok := payload["mcpServers"].(map[string]any)
if !ok {
servers = map[string]any{}
}
servers[serverName] = mcpServerConfig(cfg)
payload["mcpServers"] = servers
data, err = json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(data, '\n'), 0644)
}
func mcpServerConfig(cfg config.Config) map[string]any {
command := serverCommand(cfg)
return map[string]any{
"type": "stdio",
"command": command[0],
"args": command[1:],
"env": serverEnv(cfg),
}
}