diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 1d5c30efb5..199cde4e24 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -13,6 +13,8 @@ import ( "github.com/charmbracelet/huh" "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/experimental/aitools/lib/agents" + "github.com/databricks/cli/experimental/aitools/lib/installer" "github.com/databricks/cli/libs/apps/generator" "github.com/databricks/cli/libs/apps/initializer" "github.com/databricks/cli/libs/apps/manifest" @@ -829,6 +831,16 @@ func runCreate(ctx context.Context, opts createOptions) error { prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, "") } + // Recommend skills installation if coding agents are detected without skills. + // In flags mode, only print a hint — never prompt interactively. + if flagsMode { + if !agents.HasDatabricksSkillsInstalled() { + cmdio.LogString(ctx, "Tip: coding agents detected without Databricks skills. Run 'databricks experimental aitools skills install' to install them.") + } + } else if err := agents.RecommendSkillsInstall(ctx, installer.InstallAllSkills); err != nil { + log.Warnf(ctx, "Skills recommendation failed: %v", err) + } + // Execute post-creation actions (deploy and/or run) if shouldDeploy || runMode != prompt.RunModeNone { // Change to project directory for subsequent commands diff --git a/experimental/aitools/cmd/skills.go b/experimental/aitools/cmd/skills.go index 646fb61367..c08de15ed2 100644 --- a/experimental/aitools/cmd/skills.go +++ b/experimental/aitools/cmd/skills.go @@ -1,48 +1,10 @@ package mcp import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "time" - - "github.com/databricks/cli/experimental/aitools/lib/agents" - "github.com/databricks/cli/libs/cmdio" - "github.com/fatih/color" + "github.com/databricks/cli/experimental/aitools/lib/installer" "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 -} - -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"` - Files []string `json:"files"` -} - func newSkillsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "skills", @@ -61,7 +23,7 @@ func newSkillsListCmd() *cobra.Command { Use: "list", Short: "List available skills", RunE: func(cmd *cobra.Command, args []string) error { - return listSkills(cmd.Context()) + return installer.ListSkills(cmd.Context()) }, } } @@ -79,239 +41,9 @@ 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 installer.InstallSkill(cmd.Context(), args[0]) } - return installAllSkills(cmd.Context()) + return installer.InstallAllSkills(cmd.Context()) }, } } - -func fetchManifest(ctx context.Context) (*Manifest, error) { - url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/manifest.json", - 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) - } - - 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) { - url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s/%s/%s", - skillsRepoOwner, skillsRepoName, getSkillsBranch(), skillsRepoPath, skillName, filePath) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - 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 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 - } - - // detect agents once for all skills - detectedAgents := agents.DetectInstalled() - if len(detectedAgents) == 0 { - printNoAgentsDetected(ctx) - return nil - } - - printDetectedAgents(ctx, detectedAgents) - - for name, meta := range manifest.Skills { - if err := installSkillForAgents(ctx, name, meta.Files, detectedAgents); err != nil { - return err - } - } - return nil -} - -func printNoAgentsDetected(ctx context.Context) { - 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.") -} - -func printDetectedAgents(ctx context.Context, detectedAgents []*agents.Agent) { - cmdio.LogString(ctx, "Detected coding agents:") - for _, agent := range detectedAgents { - cmdio.LogString(ctx, " - "+agent.DisplayName) - } - cmdio.LogString(ctx, "") -} - -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) - } - - detectedAgents := agents.DetectInstalled() - if len(detectedAgents) == 0 { - printNoAgentsDetected(ctx) - return nil - } - - printDetectedAgents(ctx, detectedAgents) - - return installSkillForAgents(ctx, skillName, manifest.Skills[skillName].Files, detectedAgents) -} - -func installSkillForAgents(ctx context.Context, skillName string, files []string, detectedAgents []*agents.Agent) error { - 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/recommend.go b/experimental/aitools/lib/agents/recommend.go new file mode 100644 index 0000000000..1cf60de10d --- /dev/null +++ b/experimental/aitools/lib/agents/recommend.go @@ -0,0 +1,35 @@ +package agents + +import ( + "context" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" +) + +// RecommendSkillsInstall checks if coding agents are detected but have no skills installed. +// In interactive mode, prompts the user to install now using installFn. In non-interactive mode, prints a hint. +func RecommendSkillsInstall(ctx context.Context, installFn func(context.Context) error) error { + if HasDatabricksSkillsInstalled() { + return nil + } + + if !cmdio.IsPromptSupported(ctx) { + cmdio.LogString(ctx, "Tip: coding agents detected without Databricks skills. Run 'databricks experimental aitools skills install' to install them.") + return nil + } + + yes, err := cmdio.AskYesOrNo(ctx, "Coding agents detected without Databricks skills. Install skills now?") + if err != nil { + return err + } + if !yes { + return nil + } + + if err := installFn(ctx); err != nil { + log.Warnf(ctx, "Skills installation failed: %v", err) + } + + return nil +} diff --git a/experimental/aitools/lib/agents/recommend_test.go b/experimental/aitools/lib/agents/recommend_test.go new file mode 100644 index 0000000000..208711c94c --- /dev/null +++ b/experimental/aitools/lib/agents/recommend_test.go @@ -0,0 +1,103 @@ +package agents + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func noopInstall(context.Context) error { return nil } + +func TestRecommendSkillsInstallSkipsWhenSkillsExist(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + // Skills must be in canonical location to be detected. + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, CanonicalSkillsDir, "databricks"), 0o755)) + + agentDir := filepath.Join(tmpDir, ".claude") + require.NoError(t, os.MkdirAll(agentDir, 0o755)) + + origRegistry := Registry + Registry = []Agent{ + { + Name: "test-agent", + DisplayName: "Test Agent", + ConfigDir: func() (string, error) { return agentDir, nil }, + }, + } + defer func() { Registry = origRegistry }() + + ctx := cmdio.MockDiscard(context.Background()) + err := RecommendSkillsInstall(ctx, noopInstall) + assert.NoError(t, err) +} + +func TestRecommendSkillsInstallSkipsWhenNoAgents(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + origRegistry := Registry + Registry = []Agent{} + defer func() { Registry = origRegistry }() + + ctx := cmdio.MockDiscard(context.Background()) + err := RecommendSkillsInstall(ctx, noopInstall) + assert.NoError(t, err) +} + +func TestRecommendSkillsInstallNonInteractive(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + origRegistry := Registry + Registry = []Agent{ + { + Name: "test-agent", + DisplayName: "Test Agent", + ConfigDir: func() (string, error) { return tmpDir, nil }, + }, + } + defer func() { Registry = origRegistry }() + + ctx, stderr := cmdio.NewTestContextWithStderr(context.Background()) + err := RecommendSkillsInstall(ctx, noopInstall) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "databricks experimental aitools skills install") +} + +func TestRecommendSkillsInstallInteractiveDecline(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + origRegistry := Registry + Registry = []Agent{ + { + Name: "test-agent", + DisplayName: "Test Agent", + ConfigDir: func() (string, error) { return tmpDir, nil }, + }, + } + defer func() { Registry = origRegistry }() + + ctx, testIO := cmdio.SetupTest(context.Background(), cmdio.TestOptions{PromptSupported: true}) + defer testIO.Done() + + // Drain stderr so the prompt write doesn't block. + go func() { _, _ = io.Copy(io.Discard, testIO.Stderr) }() + + errc := make(chan error, 1) + go func() { + errc <- RecommendSkillsInstall(ctx, noopInstall) + }() + + _, err := testIO.Stdin.WriteString("n\n") + require.NoError(t, err) + require.NoError(t, testIO.Stdin.Flush()) + + assert.NoError(t, <-errc) +} diff --git a/experimental/aitools/lib/agents/skills.go b/experimental/aitools/lib/agents/skills.go new file mode 100644 index 0000000000..a3eceee7bd --- /dev/null +++ b/experimental/aitools/lib/agents/skills.go @@ -0,0 +1,45 @@ +package agents + +import ( + "os" + "path/filepath" + "strings" +) + +const ( + // databricksSkillPrefix is the prefix used by Databricks skills (e.g., "databricks", "databricks-apps"). + databricksSkillPrefix = "databricks" + + // CanonicalSkillsDir is the shared location for skills when multiple agents are detected. + CanonicalSkillsDir = ".databricks/agent-skills" +) + +// HasDatabricksSkillsInstalled checks if Databricks skills are installed in the canonical location. +// Returns true if no agents are detected (nothing to recommend) or if skills exist in ~/.databricks/agent-skills/. +// Only the canonical location is checked so that skills installed by other tools are not mistaken for a proper installation. +func HasDatabricksSkillsInstalled() bool { + installed := DetectInstalled() + if len(installed) == 0 { + return true + } + + homeDir, err := getHomeDir() + if err != nil { + return false + } + return hasDatabricksSkillsIn(filepath.Join(homeDir, CanonicalSkillsDir)) +} + +// hasDatabricksSkillsIn checks if dir contains a subdirectory starting with "databricks". +func hasDatabricksSkillsIn(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(e.Name(), databricksSkillPrefix) { + return true + } + } + return false +} diff --git a/experimental/aitools/lib/agents/skills_test.go b/experimental/aitools/lib/agents/skills_test.go new file mode 100644 index 0000000000..fdb6acde6d --- /dev/null +++ b/experimental/aitools/lib/agents/skills_test.go @@ -0,0 +1,135 @@ +package agents + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHasDatabricksSkillsInstalledNoAgents(t *testing.T) { + origRegistry := Registry + Registry = []Agent{} + defer func() { Registry = origRegistry }() + + assert.True(t, HasDatabricksSkillsInstalled()) +} + +func TestHasDatabricksSkillsInstalledCanonicalOnly(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, CanonicalSkillsDir, "databricks"), 0o755)) + + origRegistry := Registry + Registry = []Agent{ + { + Name: "test-agent", + DisplayName: "Test Agent", + ConfigDir: func() (string, error) { return filepath.Join(tmpHome, ".claude"), nil }, + }, + } + defer func() { Registry = origRegistry }() + + assert.True(t, HasDatabricksSkillsInstalled()) +} + +func TestHasDatabricksSkillsInstalledIgnoresAgentDir(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + // Skills in agent dir only (e.g., installed by another tool) should not count. + agentDir := filepath.Join(tmpHome, ".claude") + require.NoError(t, os.MkdirAll(filepath.Join(agentDir, "skills", "databricks"), 0o755)) + + origRegistry := Registry + Registry = []Agent{ + { + Name: "test-agent", + DisplayName: "Test Agent", + ConfigDir: func() (string, error) { return agentDir, nil }, + }, + } + defer func() { Registry = origRegistry }() + + assert.False(t, HasDatabricksSkillsInstalled()) +} + +func TestHasDatabricksSkillsInstalledWithOnlyNonDatabricksSkills(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + // Non-databricks skills should not count. + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "skills", "mcp-builder"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "skills", "rust-webapp"), 0o755)) + + origRegistry := Registry + Registry = []Agent{ + { + Name: "test-agent", + DisplayName: "Test Agent", + ConfigDir: func() (string, error) { return tmpDir, nil }, + }, + } + defer func() { Registry = origRegistry }() + + assert.False(t, HasDatabricksSkillsInstalled()) +} + +func TestHasDatabricksSkillsInstalledNoSkillsDir(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + origRegistry := Registry + Registry = []Agent{ + { + Name: "test-agent", + DisplayName: "Test Agent", + ConfigDir: func() (string, error) { return tmpDir, nil }, + }, + } + defer func() { Registry = origRegistry }() + + assert.False(t, HasDatabricksSkillsInstalled()) +} + +func TestHasDatabricksSkillsInstalledCustomSubdirNotChecked(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + // Skills in agent's custom subdir should not count — only canonical matters. + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".gemini", "antigravity", "global_skills", "databricks"), 0o755)) + + origRegistry := Registry + Registry = []Agent{ + { + Name: "test-agent", + DisplayName: "Test Agent", + ConfigDir: func() (string, error) { return filepath.Join(tmpHome, ".gemini", "antigravity"), nil }, + SkillsSubdir: "global_skills", + }, + } + defer func() { Registry = origRegistry }() + + assert.False(t, HasDatabricksSkillsInstalled()) +} + +func TestHasDatabricksSkillsInstalledDatabricksAppsCanonical(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + // databricks-apps prefix should match in canonical location. + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, CanonicalSkillsDir, "databricks-apps"), 0o755)) + + agentDir := filepath.Join(tmpHome, ".claude") + require.NoError(t, os.MkdirAll(agentDir, 0o755)) + + origRegistry := Registry + Registry = []Agent{ + { + Name: "test-agent", + DisplayName: "Test Agent", + ConfigDir: func() (string, error) { return agentDir, nil }, + }, + } + defer func() { Registry = origRegistry }() + + assert.True(t, HasDatabricksSkillsInstalled()) +} diff --git a/experimental/aitools/lib/installer/installer.go b/experimental/aitools/lib/installer/installer.go new file mode 100644 index 0000000000..a9c1a42b88 --- /dev/null +++ b/experimental/aitools/lib/installer/installer.go @@ -0,0 +1,312 @@ +package installer + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/databricks/cli/experimental/aitools/lib/agents" + "github.com/databricks/cli/libs/cmdio" + "github.com/fatih/color" +) + +const ( + skillsRepoOwner = "databricks" + skillsRepoName = "databricks-agent-skills" + skillsRepoPath = "skills" + defaultSkillsRepoBranch = "main" +) + +func getSkillsBranch() string { + if branch := os.Getenv("DATABRICKS_SKILLS_BRANCH"); branch != "" { + return branch + } + return defaultSkillsRepoBranch +} + +// Manifest describes the skills manifest fetched from the skills repo. +type Manifest struct { + Version string `json:"version"` + UpdatedAt string `json:"updated_at"` + Skills map[string]SkillMeta `json:"skills"` +} + +// SkillMeta describes a single skill entry in the manifest. +type SkillMeta struct { + Version string `json:"version"` + UpdatedAt string `json:"updated_at"` + Files []string `json:"files"` +} + +// FetchManifest fetches the skills manifest from the skills repo. +func FetchManifest(ctx context.Context) (*Manifest, error) { + url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/manifest.json", + 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) + } + + 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) { + url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s/%s/%s", + skillsRepoOwner, skillsRepoName, getSkillsBranch(), skillsRepoPath, skillName, filePath) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + 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) +} + +// ListSkills fetches and prints available skills. +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 +} + +// InstallAllSkills fetches the manifest and installs all skills for detected agents. +func InstallAllSkills(ctx context.Context) error { + manifest, err := FetchManifest(ctx) + if err != nil { + return err + } + + detectedAgents := agents.DetectInstalled() + if len(detectedAgents) == 0 { + printNoAgentsDetected(ctx) + return nil + } + + printDetectedAgents(ctx, detectedAgents) + + for name, meta := range manifest.Skills { + if err := installSkillForAgents(ctx, name, meta.Files, detectedAgents); err != nil { + return err + } + } + return nil +} + +// InstallSkill fetches the manifest and installs a single skill by name. +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) + } + + detectedAgents := agents.DetectInstalled() + if len(detectedAgents) == 0 { + printNoAgentsDetected(ctx) + return nil + } + + printDetectedAgents(ctx, detectedAgents) + + return installSkillForAgents(ctx, skillName, manifest.Skills[skillName].Files, detectedAgents) +} + +func printNoAgentsDetected(ctx context.Context) { + 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.") +} + +func printDetectedAgents(ctx context.Context, detectedAgents []*agents.Agent) { + cmdio.LogString(ctx, "Detected coding agents:") + for _, agent := range detectedAgents { + cmdio.LogString(ctx, " - "+agent.DisplayName) + } + cmdio.LogString(ctx, "") +} + +func installSkillForAgents(ctx context.Context, skillName string, files []string, detectedAgents []*agents.Agent) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + // Always install to canonical location first. + canonicalDir := filepath.Join(homeDir, agents.CanonicalSkillsDir, skillName) + if err := installSkillToDir(ctx, skillName, canonicalDir, files); err != nil { + return err + } + + useSymlinks := len(detectedAgents) > 1 + + // 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) + + // Back up existing non-canonical skills before overwriting. + if err := backupThirdPartySkill(ctx, destDir, canonicalDir, skillName, agent.DisplayName); err != nil { + cmdio.LogString(ctx, color.YellowString("⊘ Failed to back up existing skill for %s: %v", agent.DisplayName, err)) + continue + } + + 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 - copy from canonical + 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 +} + +// backupThirdPartySkill moves destDir to a temp directory if it exists and is not +// a symlink pointing to canonicalDir. This preserves skills installed by other tools. +func backupThirdPartySkill(ctx context.Context, destDir, canonicalDir, skillName, agentName string) error { + fi, err := os.Lstat(destDir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + + // If it's a symlink to our canonical dir, no backup needed. + if fi.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(destDir) + if err == nil && target == canonicalDir { + return nil + } + } + + backupDir, err := os.MkdirTemp("", fmt.Sprintf("databricks-skill-backup-%s-*", skillName)) + if err != nil { + return fmt.Errorf("failed to create backup directory: %w", err) + } + + backupDest := filepath.Join(backupDir, skillName) + if err := os.Rename(destDir, backupDest); err != nil { + return fmt.Errorf("failed to move existing skill: %w", err) + } + + cmdio.LogString(ctx, color.YellowString(" Existing %q for %s moved to %s", skillName, agentName, backupDest)) + 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/installer/installer_test.go b/experimental/aitools/lib/installer/installer_test.go new file mode 100644 index 0000000000..d570a9853a --- /dev/null +++ b/experimental/aitools/lib/installer/installer_test.go @@ -0,0 +1,112 @@ +package installer + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBackupThirdPartySkillDestDoesNotExist(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + destDir := filepath.Join(t.TempDir(), "nonexistent") + + err := backupThirdPartySkill(ctx, destDir, "/canonical", "databricks", "Test Agent") + assert.NoError(t, err) +} + +func TestBackupThirdPartySkillSymlinkToCanonical(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + tmp := t.TempDir() + + canonicalDir := filepath.Join(tmp, "canonical", "databricks") + require.NoError(t, os.MkdirAll(canonicalDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(canonicalDir, "skill.md"), []byte("ok"), 0o644)) + + destDir := filepath.Join(tmp, "agent", "skills", "databricks") + require.NoError(t, os.MkdirAll(filepath.Dir(destDir), 0o755)) + require.NoError(t, os.Symlink(canonicalDir, destDir)) + + err := backupThirdPartySkill(ctx, destDir, canonicalDir, "databricks", "Test Agent") + assert.NoError(t, err) + + // Symlink should still be in place. + target, err := os.Readlink(destDir) + require.NoError(t, err) + assert.Equal(t, canonicalDir, target) +} + +func TestBackupThirdPartySkillRegularDir(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + tmp := t.TempDir() + + destDir := filepath.Join(tmp, "agent", "skills", "databricks") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(destDir, "custom.md"), []byte("custom"), 0o644)) + + err := backupThirdPartySkill(ctx, destDir, "/some/canonical", "databricks", "Test Agent") + require.NoError(t, err) + + // destDir should no longer exist. + _, err = os.Stat(destDir) + assert.True(t, os.IsNotExist(err)) + + // Backup should contain the original file. + matches, err := filepath.Glob(filepath.Join(os.TempDir(), "databricks-skill-backup-databricks-*", "databricks", "custom.md")) + require.NoError(t, err) + require.NotEmpty(t, matches) + + content, err := os.ReadFile(matches[0]) + require.NoError(t, err) + assert.Equal(t, "custom", string(content)) + + // Clean up backup. + require.NoError(t, os.RemoveAll(filepath.Dir(filepath.Dir(matches[0])))) +} + +func TestBackupThirdPartySkillSymlinkToOtherTarget(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + tmp := t.TempDir() + + otherDir := filepath.Join(tmp, "other", "databricks") + require.NoError(t, os.MkdirAll(otherDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(otherDir, "other.md"), []byte("other"), 0o644)) + + destDir := filepath.Join(tmp, "agent", "skills", "databricks") + require.NoError(t, os.MkdirAll(filepath.Dir(destDir), 0o755)) + require.NoError(t, os.Symlink(otherDir, destDir)) + + canonicalDir := filepath.Join(tmp, "canonical", "databricks") + + err := backupThirdPartySkill(ctx, destDir, canonicalDir, "databricks", "Test Agent") + require.NoError(t, err) + + // destDir (the symlink) should no longer exist. + _, err = os.Lstat(destDir) + assert.True(t, os.IsNotExist(err)) + + // Original target should be untouched. + content, err := os.ReadFile(filepath.Join(otherDir, "other.md")) + require.NoError(t, err) + assert.Equal(t, "other", string(content)) +} + +func TestBackupThirdPartySkillRegularFile(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + tmp := t.TempDir() + + // Edge case: destDir is a file, not a directory. + destDir := filepath.Join(tmp, "agent", "skills", "databricks") + require.NoError(t, os.MkdirAll(filepath.Dir(destDir), 0o755)) + require.NoError(t, os.WriteFile(destDir, []byte("file"), 0o644)) + + err := backupThirdPartySkill(ctx, destDir, "/some/canonical", "databricks", "Test Agent") + require.NoError(t, err) + + _, err = os.Stat(destDir) + assert.True(t, os.IsNotExist(err)) +}