Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions experimental/aitools/cmd/aitools.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ Provides commands to:
cmd.AddCommand(newInstallCmd())
cmd.AddCommand(newSkillsCmd())
cmd.AddCommand(newToolsCmd())
cmd.AddCommand(newUpdateCmd())
cmd.AddCommand(newUninstallCmd())
cmd.AddCommand(newVersionCmd())

return cmd
}
19 changes: 19 additions & 0 deletions experimental/aitools/cmd/uninstall.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package aitools

import (
"github.com/databricks/cli/experimental/aitools/lib/installer"
"github.com/spf13/cobra"
)

func newUninstallCmd() *cobra.Command {
return &cobra.Command{
Use: "uninstall",
Short: "Uninstall all AI skills",
Long: `Remove all installed Databricks AI skills from all coding agents.

Removes skill directories, symlinks, and the state file.`,
RunE: func(cmd *cobra.Command, args []string) error {
return installer.UninstallSkills(cmd.Context())
},
}
}
44 changes: 44 additions & 0 deletions experimental/aitools/cmd/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package aitools

import (
"github.com/databricks/cli/experimental/aitools/lib/agents"
"github.com/databricks/cli/experimental/aitools/lib/installer"
"github.com/databricks/cli/libs/cmdio"
"github.com/spf13/cobra"
)

func newUpdateCmd() *cobra.Command {
var check, force, noNew bool

cmd := &cobra.Command{
Use: "update",
Short: "Update installed AI skills",
Long: `Update installed Databricks AI skills to the latest release.

By default, updates all installed skills and auto-installs new skills
from the manifest. Use --no-new to skip new skills, or --check to
preview what would change without downloading.`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
installed := agents.DetectInstalled(ctx)
src := &installer.GitHubManifestSource{}
result, err := installer.UpdateSkills(ctx, src, installed, installer.UpdateOptions{
Check: check,
Force: force,
NoNew: noNew,
})
if err != nil {
return err
}
if result != nil && (len(result.Updated) > 0 || len(result.Added) > 0) {
cmdio.LogString(ctx, installer.FormatUpdateResult(result, check))
}
return nil
},
}

cmd.Flags().BoolVar(&check, "check", false, "Show what would be updated without downloading")
cmd.Flags().BoolVar(&force, "force", false, "Re-download even if versions match")
cmd.Flags().BoolVar(&noNew, "no-new", false, "Don't auto-install new skills from manifest")
return cmd
}
60 changes: 60 additions & 0 deletions experimental/aitools/cmd/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package aitools

import (
"fmt"
"strings"

"github.com/databricks/cli/experimental/aitools/lib/installer"
"github.com/databricks/cli/libs/cmdio"
"github.com/spf13/cobra"
)

func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Show installed AI skills version",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

globalDir, err := installer.GlobalSkillsDir(ctx)
if err != nil {
return err
}

state, err := installer.LoadState(globalDir)
if err != nil {
return fmt.Errorf("failed to load install state: %w", err)
}

if state == nil {
cmdio.LogString(ctx, "No Databricks AI Tools components installed.")
cmdio.LogString(ctx, "")
cmdio.LogString(ctx, "Run 'databricks experimental aitools install' to get started.")
return nil
}

version := strings.TrimPrefix(state.Release, "v")
skillNoun := "skills"
if len(state.Skills) == 1 {
skillNoun = "skill"
}

cmdio.LogString(ctx, "Databricks AI Tools:")

latestRef := installer.GetSkillsRef(ctx)
if latestRef == state.Release {
cmdio.LogString(ctx, fmt.Sprintf(" Skills: v%s (%d %s, up to date)", version, len(state.Skills), skillNoun))
cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02"))
} else {
latestVersion := strings.TrimPrefix(latestRef, "v")
cmdio.LogString(ctx, fmt.Sprintf(" Skills: v%s (%d %s)", version, len(state.Skills), skillNoun))
cmdio.LogString(ctx, " Update available: v"+latestVersion)
cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02"))
cmdio.LogString(ctx, "")
cmdio.LogString(ctx, "Run 'databricks experimental aitools update' to update.")
}

return nil
},
}
}
8 changes: 5 additions & 3 deletions experimental/aitools/lib/installer/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ const (
// It is a package-level var so tests can replace it with a mock.
var fetchFileFn = fetchSkillFile

func getSkillsRef(ctx context.Context) string {
// GetSkillsRef returns the skills repo ref to use. If DATABRICKS_SKILLS_REF
// is set, it returns that value; otherwise it returns the default ref.
func GetSkillsRef(ctx context.Context) string {
if ref := env.Get(ctx, "DATABRICKS_SKILLS_REF"); ref != "" {
return ref
}
Expand Down Expand Up @@ -66,7 +68,7 @@ type InstallOptions struct {
// This is a convenience wrapper that uses the default GitHubManifestSource.
func FetchManifest(ctx context.Context) (*Manifest, error) {
src := &GitHubManifestSource{}
ref := getSkillsRef(ctx)
ref := GetSkillsRef(ctx)
return src.FetchManifest(ctx, ref)
}

Expand Down Expand Up @@ -117,7 +119,7 @@ func ListSkills(ctx context.Context) error {
// This is the core installation function. Callers are responsible for agent detection,
// prompting, and printing the "Installing..." header.
func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgents []*agents.Agent, opts InstallOptions) error {
ref := getSkillsRef(ctx)
ref := GetSkillsRef(ctx)
manifest, err := src.FetchManifest(ctx, ref)
if err != nil {
return err
Expand Down
160 changes: 160 additions & 0 deletions experimental/aitools/lib/installer/uninstall.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package installer

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/databricks/cli/experimental/aitools/lib/agents"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/log"
)

// UninstallSkills removes all installed skills, their symlinks, and the state file.
func UninstallSkills(ctx context.Context) error {
globalDir, err := GlobalSkillsDir(ctx)
if err != nil {
return err
}

state, err := LoadState(globalDir)
if err != nil {
return fmt.Errorf("failed to load install state: %w", err)
}

if state == nil {
if hasLegacyInstall(ctx, globalDir) {
return errors.New("found skills from a previous install without state tracking; run 'databricks experimental aitools install' first, then uninstall")
}
return errors.New("no skills installed")
}

skillCount := len(state.Skills)

// Remove skill directories and symlinks for each skill in state.
for name := range state.Skills {
canonicalDir := filepath.Join(globalDir, name)

// Remove symlinks from agent directories (only symlinks pointing to canonical dir).
removeSymlinksFromAgents(ctx, name, canonicalDir)

// Remove canonical skill directory.
if err := os.RemoveAll(canonicalDir); err != nil {
log.Warnf(ctx, "Failed to remove %s: %v", canonicalDir, err)
}
}

// Clean up orphaned symlinks pointing into the canonical dir.
cleanOrphanedSymlinks(ctx, globalDir)

// Delete state file.
stateFile := filepath.Join(globalDir, stateFileName)
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove state file: %w", err)
}

noun := "skills"
if skillCount == 1 {
noun = "skill"
}
cmdio.LogString(ctx, fmt.Sprintf("Uninstalled %d %s.", skillCount, noun))
return nil
}

// removeSymlinksFromAgents removes a skill's symlink from all agent directories
// in the registry, but only if the entry is a symlink pointing into canonicalDir.
// Non-symlink directories are left untouched to avoid deleting user-managed content.
func removeSymlinksFromAgents(ctx context.Context, skillName, canonicalDir string) {
for i := range agents.Registry {
agent := &agents.Registry[i]
skillsDir, err := agent.SkillsDir(ctx)
if err != nil {
continue
}

destDir := filepath.Join(skillsDir, skillName)

// Use Lstat to detect symlinks (Stat follows them).
fi, err := os.Lstat(destDir)
if os.IsNotExist(err) {
continue
}
if err != nil {
log.Warnf(ctx, "Failed to stat %s for %s: %v", destDir, agent.DisplayName, err)
continue
}

if fi.Mode()&os.ModeSymlink == 0 {
log.Debugf(ctx, "Skipping non-symlink %s for %s", destDir, agent.DisplayName)
continue
}

target, err := os.Readlink(destDir)
if err != nil {
log.Warnf(ctx, "Failed to read symlink %s: %v", destDir, err)
continue
}

// Only remove if the symlink points into our canonical dir.
if !strings.HasPrefix(target, canonicalDir+string(os.PathSeparator)) && target != canonicalDir {
log.Debugf(ctx, "Skipping symlink %s (points to %s, not %s)", destDir, target, canonicalDir)
continue
}

if err := os.Remove(destDir); err != nil {
log.Warnf(ctx, "Failed to remove symlink %s: %v", destDir, err)
} else {
log.Debugf(ctx, "Removed %q from %s", skillName, agent.DisplayName)
}
}
}

// cleanOrphanedSymlinks scans all agent skill directories for symlinks pointing
// into globalDir that are not tracked in state, and removes them.
func cleanOrphanedSymlinks(ctx context.Context, globalDir string) {
for i := range agents.Registry {
agent := &agents.Registry[i]
skillsDir, err := agent.SkillsDir(ctx)
if err != nil {
continue
}

entries, err := os.ReadDir(skillsDir)
if err != nil {
continue
}

for _, entry := range entries {
entryPath := filepath.Join(skillsDir, entry.Name())

fi, err := os.Lstat(entryPath)
if err != nil {
continue
}

if fi.Mode()&os.ModeSymlink == 0 {
continue
}

target, err := os.Readlink(entryPath)
if err != nil {
continue
}

// Check if the symlink points into our global skills dir.
if !strings.HasPrefix(target, globalDir+string(os.PathSeparator)) && target != globalDir {
continue
}

// This symlink points into our managed dir. Remove it.
if err := os.Remove(entryPath); err != nil {
log.Warnf(ctx, "Failed to remove orphaned symlink %s: %v", entryPath, err)
} else {
log.Debugf(ctx, "Removed orphaned symlink %s -> %s", entryPath, target)
}
}
}
}
Loading
Loading