Skip to content
Open
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
}
84 changes: 84 additions & 0 deletions experimental/aitools/cmd/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package aitools

import (
"fmt"
"strings"

"github.com/databricks/cli/experimental/aitools/lib/installer"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/env"
"github.com/databricks/cli/libs/log"
"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"
}

// Best-effort staleness check.
if env.Get(ctx, "DATABRICKS_SKILLS_REF") != "" {
cmdio.LogString(ctx, "Databricks AI Tools:")
cmdio.LogString(ctx, fmt.Sprintf(" Skills: v%s (%d %s)", version, len(state.Skills), skillNoun))
cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02"))
cmdio.LogString(ctx, " Using custom ref: $DATABRICKS_SKILLS_REF")
return nil
}

src := &installer.GitHubManifestSource{}
latest, authoritative, err := src.FetchLatestRelease(ctx)
if err != nil {
log.Debugf(ctx, "Could not check for updates: %v", err)
authoritative = false
}

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

if !authoritative {
cmdio.LogString(ctx, fmt.Sprintf(" Skills: v%s (%d %s)", version, len(state.Skills), skillNoun))
cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02"))
cmdio.LogString(ctx, " Could not check for latest version.")
return nil
}

if latest == 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(latest, "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
},
}
}
2 changes: 1 addition & 1 deletion experimental/aitools/lib/installer/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,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 {
latestTag, err := src.FetchLatestRelease(ctx)
latestTag, _, err := src.FetchLatestRelease(ctx)
if err != nil {
return fmt.Errorf("failed to fetch latest release: %w", err)
}
Expand Down
35 changes: 18 additions & 17 deletions experimental/aitools/lib/installer/installer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import (

// mockManifestSource is a test double for ManifestSource.
type mockManifestSource struct {
manifest *Manifest
release string
fetchErr error
manifest *Manifest
release string
authoritative bool
fetchErr error
}

func (m *mockManifestSource) FetchManifest(_ context.Context, _ string) (*Manifest, error) {
Expand All @@ -30,8 +31,8 @@ func (m *mockManifestSource) FetchManifest(_ context.Context, _ string) (*Manife
return m.manifest, nil
}

func (m *mockManifestSource) FetchLatestRelease(_ context.Context) (string, error) {
return m.release, nil
func (m *mockManifestSource) FetchLatestRelease(_ context.Context) (string, bool, error) {
return m.release, m.authoritative, nil
}

func testManifest() *Manifest {
Expand Down Expand Up @@ -195,7 +196,7 @@ func TestInstallSkillsForAgentsWritesState(t *testing.T) {
ctx, stderr := cmdio.NewTestContextWithStderr(t.Context())
setupFetchMock(t)

src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0"}
src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true}
agent := testAgent(tmp)

err := InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{})
Expand All @@ -219,7 +220,7 @@ func TestInstallSkillForSingleWritesState(t *testing.T) {
ctx, stderr := cmdio.NewTestContextWithStderr(t.Context())
setupFetchMock(t)

src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0"}
src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true}
agent := testAgent(tmp)

err := InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{
Expand All @@ -242,7 +243,7 @@ func TestInstallSkillsSpecificNotFound(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
setupFetchMock(t)

src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0"}
src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true}
agent := testAgent(tmp)

err := InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{
Expand All @@ -264,7 +265,7 @@ func TestExperimentalSkillsSkippedByDefault(t *testing.T) {
Experimental: true,
}

src := &mockManifestSource{manifest: manifest, release: "v0.1.0"}
src := &mockManifestSource{manifest: manifest, release: "v0.1.0", authoritative: true}
agent := testAgent(tmp)

err := InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{})
Expand Down Expand Up @@ -292,7 +293,7 @@ func TestExperimentalSkillsIncludedWithFlag(t *testing.T) {
Experimental: true,
}

src := &mockManifestSource{manifest: manifest, release: "v0.1.0"}
src := &mockManifestSource{manifest: manifest, release: "v0.1.0", authoritative: true}
agent := testAgent(tmp)

err := InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{
Expand Down Expand Up @@ -328,7 +329,7 @@ func TestMinCLIVersionSkipWithWarningForInstallAll(t *testing.T) {
MinCLIVer: "0.300.0",
}

src := &mockManifestSource{manifest: manifest, release: "v0.1.0"}
src := &mockManifestSource{manifest: manifest, release: "v0.1.0", authoritative: true}
agent := testAgent(tmp)

err := InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{})
Expand Down Expand Up @@ -358,7 +359,7 @@ func TestMinCLIVersionHardErrorForInstallSingle(t *testing.T) {
MinCLIVer: "0.300.0",
}

src := &mockManifestSource{manifest: manifest, release: "v0.1.0"}
src := &mockManifestSource{manifest: manifest, release: "v0.1.0", authoritative: true}
agent := testAgent(tmp)

err := InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{
Expand All @@ -374,7 +375,7 @@ func TestIdempotentSecondInstallSkips(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
setupFetchMock(t)

src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0"}
src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true}
agent := testAgent(tmp)

// First install.
Expand Down Expand Up @@ -403,7 +404,7 @@ func TestIdempotentInstallUpdatesNewVersions(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
setupFetchMock(t)

src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0"}
src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true}
agent := testAgent(tmp)

// First install.
Expand All @@ -416,7 +417,7 @@ func TestIdempotentInstallUpdatesNewVersions(t *testing.T) {
Version: "0.2.0",
Files: []string{"SKILL.md"},
}
src2 := &mockManifestSource{manifest: updatedManifest, release: "v0.2.0"}
src2 := &mockManifestSource{manifest: updatedManifest, release: "v0.2.0", authoritative: true}

// Track which skills are fetched.
var fetchedSkills []string
Expand Down Expand Up @@ -452,7 +453,7 @@ func TestLegacyDetectMessagePrinted(t *testing.T) {
globalDir := filepath.Join(tmp, ".databricks", "aitools", "skills")
require.NoError(t, os.MkdirAll(filepath.Join(globalDir, "databricks-sql"), 0o755))

src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0"}
src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true}
agent := testAgent(tmp)

err := InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{})
Expand All @@ -470,7 +471,7 @@ func TestLegacyDetectLegacyDir(t *testing.T) {
legacyDir := filepath.Join(tmp, ".databricks", "agent-skills")
require.NoError(t, os.MkdirAll(filepath.Join(legacyDir, "databricks-sql"), 0o755))

src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0"}
src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true}
agent := testAgent(tmp)

err := InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{})
Expand Down
33 changes: 17 additions & 16 deletions experimental/aitools/lib/installer/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ type ManifestSource interface {
// FetchManifest fetches the skills manifest at the given ref.
FetchManifest(ctx context.Context, ref string) (*Manifest, error)

// FetchLatestRelease returns the latest release tag.
// Implementations should fall back to a default ref on network errors rather
// than returning an error. The error return exists for cases where fallback is
// not possible (e.g., mock implementations in tests that want to simulate hard
// failures).
FetchLatestRelease(ctx context.Context) (string, error)
// FetchLatestRelease returns the latest release tag and whether the result
// is authoritative. When authoritative is true, the tag came from a
// successful API call. When false, the tag is a fallback default (e.g.,
// due to network failure). Callers should use this to decide whether
// to trust the result for staleness comparisons.
FetchLatestRelease(ctx context.Context) (tag string, authoritative bool, err error)
}

// GitHubManifestSource fetches manifests and release info from GitHub.
Expand Down Expand Up @@ -58,15 +58,16 @@ func (s *GitHubManifestSource) FetchManifest(ctx context.Context, ref string) (*
}

// FetchLatestRelease returns the latest release tag from GitHub.
// If DATABRICKS_SKILLS_REF is set, it is returned immediately.
// On any error (network, non-200, parse), falls back to defaultSkillsRepoRef.
// If DATABRICKS_SKILLS_REF is set, it is returned as authoritative.
// On any error (network, non-200, parse), falls back to defaultSkillsRepoRef
// with authoritative=false.
//
// The DATABRICKS_SKILLS_REF check is intentionally duplicated in getSkillsRef()
// because callers may use either the ManifestSource interface directly or the
// convenience FetchManifest wrapper.
func (s *GitHubManifestSource) FetchLatestRelease(ctx context.Context) (string, error) {
func (s *GitHubManifestSource) FetchLatestRelease(ctx context.Context) (string, bool, error) {
if ref := env.Get(ctx, "DATABRICKS_SKILLS_REF"); ref != "" {
return ref, nil
return ref, true, nil
}

url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest",
Expand All @@ -75,34 +76,34 @@ func (s *GitHubManifestSource) FetchLatestRelease(ctx context.Context) (string,
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
log.Debugf(ctx, "Failed to create release request, falling back to %s: %v", defaultSkillsRepoRef, err)
return defaultSkillsRepoRef, nil
return defaultSkillsRepoRef, false, nil
}

client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Debugf(ctx, "Failed to fetch latest release, falling back to %s: %v", defaultSkillsRepoRef, err)
return defaultSkillsRepoRef, nil
return defaultSkillsRepoRef, false, nil
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
log.Debugf(ctx, "Latest release returned HTTP %d, falling back to %s", resp.StatusCode, defaultSkillsRepoRef)
return defaultSkillsRepoRef, nil
return defaultSkillsRepoRef, false, nil
}

var release struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
log.Debugf(ctx, "Failed to parse release response, falling back to %s: %v", defaultSkillsRepoRef, err)
return defaultSkillsRepoRef, nil
return defaultSkillsRepoRef, false, nil
}

if release.TagName == "" {
log.Debugf(ctx, "Empty tag_name in release response, falling back to %s", defaultSkillsRepoRef)
return defaultSkillsRepoRef, nil
return defaultSkillsRepoRef, false, nil
}

return release.TagName, nil
return release.TagName, true, nil
}
Loading
Loading