diff --git a/experimental/aitools/cmd/aitools.go b/experimental/aitools/cmd/aitools.go index e467db5cd0..a3deaf0c29 100644 --- a/experimental/aitools/cmd/aitools.go +++ b/experimental/aitools/cmd/aitools.go @@ -28,6 +28,7 @@ Provides commands to: cmd.AddCommand(newMcpCmd()) cmd.AddCommand(newInstallCmd()) + cmd.AddCommand(newSkillsCmd()) cmd.AddCommand(newToolsCmd()) return cmd diff --git a/experimental/aitools/cmd/install.go b/experimental/aitools/cmd/install.go index 90b98eedc0..159a80b81b 100644 --- a/experimental/aitools/cmd/install.go +++ b/experimental/aitools/cmd/install.go @@ -30,25 +30,24 @@ func runInstall(ctx context.Context) error { // Check for non-interactive mode with agent detection // If running in an AI agent, install automatically without prompts if !cmdio.IsPromptSupported(ctx) { + var targetAgent *agents.Agent switch agent.Product(ctx) { case agent.ClaudeCode: - if err := agents.InstallClaude(); err != nil { - return err - } - cmdio.LogString(ctx, color.GreenString("✓ Installed Databricks MCP server for Claude Code")) - cmdio.LogString(ctx, color.YellowString("⚠️ Please restart Claude Code for changes to take effect")) - return nil + targetAgent = agents.GetByName("claude-code") case agent.Cursor: - if err := agents.InstallCursor(); err != nil { + targetAgent = agents.GetByName("cursor") + } + + if targetAgent != nil && targetAgent.InstallMCP != nil { + if err := targetAgent.InstallMCP(); err != nil { return err } - cmdio.LogString(ctx, color.GreenString("✓ Installed Databricks MCP server for Cursor")) - cmdio.LogString(ctx, color.YellowString("⚠️ Please restart Cursor for changes to take effect")) + cmdio.LogString(ctx, color.GreenString("✓ Installed Databricks MCP server for %s", targetAgent.DisplayName)) + cmdio.LogString(ctx, color.YellowString("⚠️ Please restart %s for changes to take effect", targetAgent.DisplayName)) return nil - default: - // Unknown agent in non-interactive mode - show manual instructions - return agents.ShowCustomInstructions(ctx) } + // Unknown agent in non-interactive mode - show manual instructions + return agents.ShowCustomInstructions(ctx) } cmdio.LogString(ctx, "") @@ -69,39 +68,32 @@ func runInstall(ctx context.Context) error { anySuccess := false - ans, err := cmdio.AskSelect(ctx, "Install for Claude Code?", []string{"yes", "no"}) - if err != nil { - return err - } - if ans == "yes" { - fmt.Fprint(os.Stderr, "Installing MCP server for Claude Code...") - if err := agents.InstallClaude(); err != nil { - fmt.Fprint(os.Stderr, "\r"+color.YellowString("⊘ Skipped Claude Code: "+err.Error())+"\n") - } else { - fmt.Fprint(os.Stderr, "\r"+color.GreenString("✓ Installed for Claude Code")+" \n") - anySuccess = true + // Install for agents that have MCP support + for i := range agents.Registry { + a := &agents.Registry[i] + if a.InstallMCP == nil { + continue } - cmdio.LogString(ctx, "") - } - ans, err = cmdio.AskSelect(ctx, "Install for Cursor?", []string{"yes", "no"}) - if err != nil { - return err - } - if ans == "yes" { - fmt.Fprint(os.Stderr, "Installing MCP server for Cursor...") - if err := agents.InstallCursor(); err != nil { - fmt.Fprint(os.Stderr, "\r"+color.YellowString("⊘ Skipped Cursor: "+err.Error())+"\n") - } else { - // Brief delay so users see the "Installing..." message before it's replaced - time.Sleep(1 * time.Second) - fmt.Fprint(os.Stderr, "\r"+color.GreenString("✓ Installed for Cursor")+" \n") - anySuccess = true + ans, err := cmdio.AskSelect(ctx, fmt.Sprintf("Install for %s?", a.DisplayName), []string{"yes", "no"}) + if err != nil { + return err + } + if ans == "yes" { + fmt.Fprintf(os.Stderr, "Installing MCP server for %s...", a.DisplayName) + if err := a.InstallMCP(); err != nil { + fmt.Fprint(os.Stderr, "\r"+color.YellowString("⊘ Skipped %s: %s", a.DisplayName, err.Error())+"\n") + } else { + // Brief delay so users see the "Installing..." message before it's replaced + time.Sleep(500 * time.Millisecond) + fmt.Fprint(os.Stderr, "\r"+color.GreenString("✓ Installed for %s", a.DisplayName)+" \n") + anySuccess = true + } + cmdio.LogString(ctx, "") } - cmdio.LogString(ctx, "") } - ans, err = cmdio.AskSelect(ctx, "Show manual installation instructions for other agents?", []string{"yes", "no"}) + ans, err := cmdio.AskSelect(ctx, "Show manual installation instructions for other agents?", []string{"yes", "no"}) if err != nil { return err } diff --git a/experimental/aitools/cmd/skills.go b/experimental/aitools/cmd/skills.go new file mode 100644 index 0000000000..40cbf81ce3 --- /dev/null +++ b/experimental/aitools/cmd/skills.go @@ -0,0 +1,440 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/databricks/cli/experimental/aitools/lib/agents" + "github.com/databricks/cli/libs/cmdio" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +const ( + skillsRepoOwner = "databricks" + skillsRepoName = "databricks-agent-skills" + skillsRepoPath = "skills" + defaultSkillsRepoBranch = "main" + canonicalSkillsDir = ".databricks/agent-skills" // canonical location for symlink source +) + +func getSkillsBranch() string { + if branch := os.Getenv("DATABRICKS_SKILLS_BRANCH"); branch != "" { + return branch + } + return defaultSkillsRepoBranch +} + +// getGitHubToken returns GitHub token from environment or gh CLI. +// TODO: once databricks-agent-skills repo is public, replace GitHub API calls +// with raw.githubusercontent.com URLs and remove authentication logic. +func getGitHubToken() string { + // check environment variables first + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + return token + } + if token := os.Getenv("GH_TOKEN"); token != "" { + return token + } + // try gh CLI + out, err := exec.Command("gh", "auth", "token").Output() + if err == nil { + return strings.TrimSpace(string(out)) + } + return "" +} + +// addGitHubAuth adds authentication header if token is available. +func addGitHubAuth(req *http.Request) { + if token := getGitHubToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } +} + +type Manifest struct { + Version string `json:"version"` + UpdatedAt string `json:"updated_at"` + Skills map[string]SkillMeta `json:"skills"` +} + +type SkillMeta struct { + Version string `json:"version"` + UpdatedAt string `json:"updated_at"` +} + +func newSkillsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "skills", + Short: "Manage Databricks skills for coding agents", + Long: `Manage Databricks skills that extend coding agents with Databricks-specific capabilities.`, + } + + cmd.AddCommand(newSkillsListCmd()) + cmd.AddCommand(newSkillsInstallCmd()) + + return cmd +} + +func newSkillsListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List available skills", + RunE: func(cmd *cobra.Command, args []string) error { + return listSkills(cmd.Context()) + }, + } +} + +func newSkillsInstallCmd() *cobra.Command { + return &cobra.Command{ + Use: "install [skill-name]", + Short: "Install Databricks skills for detected coding agents", + Long: `Install Databricks skills to all detected coding agents. + +Skills are installed globally to each agent's skills directory. +When multiple agents are detected, skills are stored in a canonical location +and symlinked to each agent to avoid duplication. + +Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return installSkill(cmd.Context(), args[0]) + } + return installAllSkills(cmd.Context()) + }, + } +} + +func fetchManifest(ctx context.Context) (*Manifest, error) { + // use GitHub API for private repo support + // manifest.json is at repo root, skills are in skillsRepoPath subdirectory + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/manifest.json?ref=%s", + skillsRepoOwner, skillsRepoName, getSkillsBranch()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github.raw+json") + addGitHubAuth(req) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch manifest: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch manifest: HTTP %d", resp.StatusCode) + } + + var manifest Manifest + if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { + return nil, fmt.Errorf("failed to parse manifest: %w", err) + } + + return &manifest, nil +} + +func fetchSkillFile(ctx context.Context, skillName, filePath string) ([]byte, error) { + // use GitHub API for private repo support + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s/%s/%s?ref=%s", + skillsRepoOwner, skillsRepoName, skillsRepoPath, skillName, filePath, getSkillsBranch()) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github.raw+json") + addGitHubAuth(req) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s: %w", filePath, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch %s: HTTP %d", filePath, resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +func fetchSkillFileList(ctx context.Context, skillName string) ([]string, error) { + // use GitHub API to list files in skill directory + skillPath := skillsRepoPath + "/" + skillName + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s?ref=%s", + skillsRepoOwner, skillsRepoName, skillPath, getSkillsBranch()) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + addGitHubAuth(req) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list skill files: HTTP %d", resp.StatusCode) + } + + var items []struct { + Path string `json:"path"` + Type string `json:"type"` + } + if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { + return nil, err + } + + var files []string + for _, item := range items { + switch item.Type { + case "file": + // strip skills/skill-name prefix from path + relPath := strings.TrimPrefix(item.Path, skillPath+"/") + files = append(files, relPath) + case "dir": + // recursively list subdirectory + subFiles, err := fetchSubdirFiles(ctx, item.Path) + if err != nil { + return nil, err + } + for _, sf := range subFiles { + relPath := strings.TrimPrefix(sf, skillPath+"/") + files = append(files, relPath) + } + } + } + + return files, nil +} + +func fetchSubdirFiles(ctx context.Context, dirPath string) ([]string, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s?ref=%s", + skillsRepoOwner, skillsRepoName, dirPath, getSkillsBranch()) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + addGitHubAuth(req) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list directory %s: HTTP %d", dirPath, resp.StatusCode) + } + + var items []struct { + Path string `json:"path"` + Type string `json:"type"` + } + if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { + return nil, err + } + + var files []string + for _, item := range items { + switch item.Type { + case "file": + files = append(files, item.Path) + case "dir": + subFiles, err := fetchSubdirFiles(ctx, item.Path) + if err != nil { + return nil, err + } + files = append(files, subFiles...) + } + } + + return files, nil +} + +func listSkills(ctx context.Context) error { + manifest, err := fetchManifest(ctx) + if err != nil { + return err + } + + cmdio.LogString(ctx, "Available skills:") + cmdio.LogString(ctx, "") + + for name, meta := range manifest.Skills { + cmdio.LogString(ctx, fmt.Sprintf(" %s (v%s)", name, meta.Version)) + } + + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, "Install all with: databricks experimental aitools skills install") + cmdio.LogString(ctx, "Install one with: databricks experimental aitools skills install ") + return nil +} + +func installAllSkills(ctx context.Context) error { + manifest, err := fetchManifest(ctx) + if err != nil { + return err + } + + for name := range manifest.Skills { + if err := installSkill(ctx, name); err != nil { + return err + } + } + return nil +} + +func installSkill(ctx context.Context, skillName string) error { + manifest, err := fetchManifest(ctx) + if err != nil { + return err + } + + if _, ok := manifest.Skills[skillName]; !ok { + return fmt.Errorf("skill %q not found", skillName) + } + + // detect installed agents using shared registry + detectedAgents := agents.DetectInstalled() + if len(detectedAgents) == 0 { + cmdio.LogString(ctx, color.YellowString("No supported coding agents detected.")) + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, "Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity") + cmdio.LogString(ctx, "Please install at least one coding agent first.") + return nil + } + + // print detected agents + cmdio.LogString(ctx, "Detected coding agents:") + for _, agent := range detectedAgents { + cmdio.LogString(ctx, " - "+agent.DisplayName) + } + cmdio.LogString(ctx, "") + + // get list of files in skill + files, err := fetchSkillFileList(ctx, skillName) + if err != nil { + return fmt.Errorf("failed to list skill files: %w", err) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + // determine installation strategy + useSymlinks := len(detectedAgents) > 1 + var canonicalDir string + + if useSymlinks { + // install to canonical location and symlink to each agent + canonicalDir = filepath.Join(homeDir, canonicalSkillsDir, skillName) + if err := installSkillToDir(ctx, skillName, canonicalDir, files); err != nil { + return err + } + } + + // install/symlink to each agent + for _, agent := range detectedAgents { + agentSkillDir, err := agent.SkillsDir() + if err != nil { + cmdio.LogString(ctx, color.YellowString("⊘ Skipped %s: %v", agent.DisplayName, err)) + continue + } + + destDir := filepath.Join(agentSkillDir, skillName) + + if useSymlinks { + if err := createSymlink(canonicalDir, destDir); err != nil { + // fallback to copy on symlink failure (e.g., Windows without admin) + cmdio.LogString(ctx, color.YellowString(" Symlink failed for %s, copying instead...", agent.DisplayName)) + if err := installSkillToDir(ctx, skillName, destDir, files); err != nil { + cmdio.LogString(ctx, color.YellowString("⊘ Failed to install for %s: %v", agent.DisplayName, err)) + continue + } + } + cmdio.LogString(ctx, color.GreenString("✓ Installed %q for %s (symlinked)", skillName, agent.DisplayName)) + } else { + // single agent - install directly + if err := installSkillToDir(ctx, skillName, destDir, files); err != nil { + cmdio.LogString(ctx, color.YellowString("⊘ Failed to install for %s: %v", agent.DisplayName, err)) + continue + } + cmdio.LogString(ctx, color.GreenString("✓ Installed %q for %s", skillName, agent.DisplayName)) + } + } + + return nil +} + +func installSkillToDir(ctx context.Context, skillName, destDir string, files []string) error { + // remove existing skill directory for clean install + if err := os.RemoveAll(destDir); err != nil { + return fmt.Errorf("failed to remove existing skill: %w", err) + } + + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // download all files + for _, file := range files { + content, err := fetchSkillFile(ctx, skillName, file) + if err != nil { + return err + } + + destPath := filepath.Join(destDir, file) + + // create parent directories if needed + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + if err := os.WriteFile(destPath, content, 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", file, err) + } + } + + return nil +} + +func createSymlink(source, dest string) error { + // ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + // remove existing symlink or directory + if err := os.RemoveAll(dest); err != nil { + return fmt.Errorf("failed to remove existing path: %w", err) + } + + // create symlink + if err := os.Symlink(source, dest); err != nil { + return fmt.Errorf("failed to create symlink: %w", err) + } + + return nil +} diff --git a/experimental/aitools/lib/agents/agents.go b/experimental/aitools/lib/agents/agents.go new file mode 100644 index 0000000000..7fcf8e8df5 --- /dev/null +++ b/experimental/aitools/lib/agents/agents.go @@ -0,0 +1,124 @@ +package agents + +import ( + "os" + "path/filepath" + "runtime" +) + +// Agent defines a coding agent that can have skills installed and optionally MCP server. +type Agent struct { + Name string + DisplayName string + // ConfigDir returns the agent's config directory (e.g., ~/.claude). + // Used for detection and as base for skills directory. + ConfigDir func() (string, error) + // SkillsSubdir is the subdirectory within ConfigDir for skills (default: "skills"). + SkillsSubdir string + // InstallMCP installs the Databricks MCP server for this agent. + // Nil if agent doesn't support MCP or we haven't implemented it. + InstallMCP func() error +} + +// Detected returns true if the agent is installed on the system. +func (a *Agent) Detected() bool { + dir, err := a.ConfigDir() + if err != nil { + return false + } + _, err = os.Stat(dir) + return err == nil +} + +// SkillsDir returns the full path to the agent's skills directory. +func (a *Agent) SkillsDir() (string, error) { + configDir, err := a.ConfigDir() + if err != nil { + return "", err + } + subdir := a.SkillsSubdir + if subdir == "" { + subdir = "skills" + } + return filepath.Join(configDir, subdir), nil +} + +// getHomeDir returns home directory, handling Windows USERPROFILE. +func getHomeDir() (string, error) { + if runtime.GOOS == "windows" { + if userProfile := os.Getenv("USERPROFILE"); userProfile != "" { + return userProfile, nil + } + } + return os.UserHomeDir() +} + +// homeSubdir returns a function that computes ~/subpath. +func homeSubdir(subpath ...string) func() (string, error) { + return func() (string, error) { + home, err := getHomeDir() + if err != nil { + return "", err + } + parts := append([]string{home}, subpath...) + return filepath.Join(parts...), nil + } +} + +// Registry contains all supported agents. +var Registry = []Agent{ + { + Name: "claude-code", + DisplayName: "Claude Code", + ConfigDir: homeSubdir(".claude"), + InstallMCP: InstallClaude, + }, + { + Name: "cursor", + DisplayName: "Cursor", + ConfigDir: homeSubdir(".cursor"), + InstallMCP: InstallCursor, + }, + { + Name: "codex", + DisplayName: "Codex CLI", + ConfigDir: homeSubdir(".codex"), + }, + { + Name: "opencode", + DisplayName: "OpenCode", + ConfigDir: homeSubdir(".config", "opencode"), + }, + { + Name: "copilot", + DisplayName: "GitHub Copilot", + ConfigDir: homeSubdir(".copilot"), + }, + { + Name: "antigravity", + DisplayName: "Antigravity", + ConfigDir: homeSubdir(".gemini", "antigravity"), + SkillsSubdir: "global_skills", + }, +} + +// DetectInstalled returns all agents that are installed on the system. +func DetectInstalled() []*Agent { + var installed []*Agent + for i := range Registry { + if Registry[i].Detected() { + installed = append(installed, &Registry[i]) + } + } + return installed +} + +// GetByName returns an agent by name, or nil if not found. +func GetByName(name string) *Agent { + for i := range Registry { + if Registry[i].Name == name { + return &Registry[i] + } + } + return nil +} diff --git a/experimental/aitools/lib/agents/claude.go b/experimental/aitools/lib/agents/claude.go index d5207dd887..9beffaed83 100644 --- a/experimental/aitools/lib/agents/claude.go +++ b/experimental/aitools/lib/agents/claude.go @@ -7,16 +7,11 @@ import ( "os/exec" ) -// DetectClaude checks if Claude Code CLI is installed and available on PATH. -func DetectClaude() bool { - _, err := exec.LookPath("claude") - return err == nil -} - // InstallClaude installs the Databricks AI Tools MCP server in Claude Code. func InstallClaude() error { - if !DetectClaude() { - return errors.New("claude Code CLI is not installed or not on PATH\n\nPlease install Claude Code and ensure 'claude' is available on your system PATH.\nFor installation instructions, visit: https://docs.anthropic.com/en/docs/claude-code") + // Check if claude CLI is available + if _, err := exec.LookPath("claude"); err != nil { + return errors.New("'claude' CLI is not installed or not on PATH\n\nPlease install Claude Code and ensure 'claude' is available on your system PATH.\nFor installation instructions, visit: https://docs.anthropic.com/en/docs/claude-code") } databricksPath, err := os.Executable() diff --git a/experimental/aitools/lib/agents/cursor.go b/experimental/aitools/lib/agents/cursor.go index 7303953069..16effbe062 100644 --- a/experimental/aitools/lib/agents/cursor.go +++ b/experimental/aitools/lib/agents/cursor.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "runtime" ) type cursorConfig struct { @@ -18,47 +17,20 @@ type mcpServer struct { Env map[string]string `json:"env,omitempty"` } -func getCursorConfigPath() (string, error) { - if runtime.GOOS == "windows" { - userProfile := os.Getenv("USERPROFILE") - if userProfile == "" { - return "", os.ErrNotExist - } - return filepath.Join(userProfile, ".cursor", "mcp.json"), nil - } - - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".cursor", "mcp.json"), nil -} - -// DetectCursor checks if Cursor is installed by looking for its config directory. -func DetectCursor() bool { - configPath, err := getCursorConfigPath() - if err != nil { - return false - } - // Check if the .cursor directory exists (not just the mcp.json file) - cursorDir := filepath.Dir(configPath) - _, err = os.Stat(cursorDir) - return err == nil -} - // InstallCursor installs the Databricks AI Tools MCP server in Cursor. func InstallCursor() error { - configPath, err := getCursorConfigPath() + configDir, err := homeSubdir(".cursor")() if err != nil { return fmt.Errorf("failed to determine Cursor config path: %w", err) } - // Check if .cursor directory exists (not the file, we'll create that if needed) - cursorDir := filepath.Dir(configPath) - if _, err := os.Stat(cursorDir); err != nil { - return fmt.Errorf("cursor directory not found at: %s\n\nPlease install Cursor from: https://cursor.sh", cursorDir) + // Check if .cursor directory exists + if _, err := os.Stat(configDir); err != nil { + return fmt.Errorf(".cursor directory not found at: %s\n\nPlease install Cursor from: https://cursor.sh", configDir) } + configPath := filepath.Join(configDir, "mcp.json") + // Read existing config var config cursorConfig data, err := os.ReadFile(configPath)