Skip to content

Commit 57711c5

Browse files
authored
[3/5] Aitools: update, uninstall, and version commands (#4812)
## PR Stack 1. [1/5] State + release discovery + directory rename (#4810) 2. [2/5] Install writes state + interactive agent selection (#4811) 3. **[3/5] Update + uninstall + version commands** (this PR) 4. [4/5] List improvements + command restructuring + flags (#4813) 5. [5/5] Project scope (--project/--global) (pending) **Base**: `simonfaltum/aitools-pr2-install` (PR 2) ## Why Users can install skills but can't update, uninstall, or check what version they have. These are core lifecycle gaps. ## Changes Three new commands, each small but grouped because they share patterns. **`aitools update`**: Compares installed release against latest. Downloads changed skills. Flags: `--check` (dry run), `--force` (re-download), `--no-new` (don't auto-add new manifest skills). Authoritative release detection (distinguishes real API response from fallback). Applies experimental and min_cli_version filtering. Warns on skills removed from manifest. **`aitools uninstall`**: Removes skill directories from canonical location. Removes symlinks from ALL agent directories (full registry scan, not just detected). Cleans orphaned symlinks. Only removes symlinks pointing to canonical dir (safe for third-party skills). Deletes `.state.json` on success. **`aitools version`**: Shows installed version, skill count, last updated date. Best-effort staleness check against latest release. Graceful offline degradation. Suppresses staleness check when `DATABRICKS_SKILLS_REF` is set. ## Test plan - [x] Update: no state -> error, already up to date, version diff, check dry run, force, no-new, new skill auto-installed, removed skill warning - [x] Uninstall: removes dirs + symlinks, orphan cleanup, state deletion, no state -> error, missing dirs handled - [x] Version: installed/up-to-date, update available, not installed, offline graceful - [x] All lint checks pass
1 parent 3fbef89 commit 57711c5

File tree

9 files changed

+1206
-3
lines changed

9 files changed

+1206
-3
lines changed

experimental/aitools/cmd/aitools.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ Provides commands to:
2020
cmd.AddCommand(newInstallCmd())
2121
cmd.AddCommand(newSkillsCmd())
2222
cmd.AddCommand(newToolsCmd())
23+
cmd.AddCommand(newUpdateCmd())
24+
cmd.AddCommand(newUninstallCmd())
25+
cmd.AddCommand(newVersionCmd())
2326

2427
return cmd
2528
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package aitools
2+
3+
import (
4+
"github.com/databricks/cli/experimental/aitools/lib/installer"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
func newUninstallCmd() *cobra.Command {
9+
return &cobra.Command{
10+
Use: "uninstall",
11+
Short: "Uninstall all AI skills",
12+
Long: `Remove all installed Databricks AI skills from all coding agents.
13+
14+
Removes skill directories, symlinks, and the state file.`,
15+
RunE: func(cmd *cobra.Command, args []string) error {
16+
return installer.UninstallSkills(cmd.Context())
17+
},
18+
}
19+
}

experimental/aitools/cmd/update.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package aitools
2+
3+
import (
4+
"github.com/databricks/cli/experimental/aitools/lib/agents"
5+
"github.com/databricks/cli/experimental/aitools/lib/installer"
6+
"github.com/databricks/cli/libs/cmdio"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
func newUpdateCmd() *cobra.Command {
11+
var check, force, noNew bool
12+
13+
cmd := &cobra.Command{
14+
Use: "update",
15+
Short: "Update installed AI skills",
16+
Long: `Update installed Databricks AI skills to the latest release.
17+
18+
By default, updates all installed skills and auto-installs new skills
19+
from the manifest. Use --no-new to skip new skills, or --check to
20+
preview what would change without downloading.`,
21+
RunE: func(cmd *cobra.Command, args []string) error {
22+
ctx := cmd.Context()
23+
installed := agents.DetectInstalled(ctx)
24+
src := &installer.GitHubManifestSource{}
25+
result, err := installer.UpdateSkills(ctx, src, installed, installer.UpdateOptions{
26+
Check: check,
27+
Force: force,
28+
NoNew: noNew,
29+
})
30+
if err != nil {
31+
return err
32+
}
33+
if result != nil && (len(result.Updated) > 0 || len(result.Added) > 0) {
34+
cmdio.LogString(ctx, installer.FormatUpdateResult(result, check))
35+
}
36+
return nil
37+
},
38+
}
39+
40+
cmd.Flags().BoolVar(&check, "check", false, "Show what would be updated without downloading")
41+
cmd.Flags().BoolVar(&force, "force", false, "Re-download even if versions match")
42+
cmd.Flags().BoolVar(&noNew, "no-new", false, "Don't auto-install new skills from manifest")
43+
return cmd
44+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package aitools
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/databricks/cli/experimental/aitools/lib/installer"
8+
"github.com/databricks/cli/libs/cmdio"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func newVersionCmd() *cobra.Command {
13+
return &cobra.Command{
14+
Use: "version",
15+
Short: "Show installed AI skills version",
16+
RunE: func(cmd *cobra.Command, args []string) error {
17+
ctx := cmd.Context()
18+
19+
globalDir, err := installer.GlobalSkillsDir(ctx)
20+
if err != nil {
21+
return err
22+
}
23+
24+
state, err := installer.LoadState(globalDir)
25+
if err != nil {
26+
return fmt.Errorf("failed to load install state: %w", err)
27+
}
28+
29+
if state == nil {
30+
cmdio.LogString(ctx, "No Databricks AI Tools components installed.")
31+
cmdio.LogString(ctx, "")
32+
cmdio.LogString(ctx, "Run 'databricks experimental aitools install' to get started.")
33+
return nil
34+
}
35+
36+
version := strings.TrimPrefix(state.Release, "v")
37+
skillNoun := "skills"
38+
if len(state.Skills) == 1 {
39+
skillNoun = "skill"
40+
}
41+
42+
cmdio.LogString(ctx, "Databricks AI Tools:")
43+
44+
latestRef := installer.GetSkillsRef(ctx)
45+
if latestRef == state.Release {
46+
cmdio.LogString(ctx, fmt.Sprintf(" Skills: v%s (%d %s, up to date)", version, len(state.Skills), skillNoun))
47+
cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02"))
48+
} else {
49+
latestVersion := strings.TrimPrefix(latestRef, "v")
50+
cmdio.LogString(ctx, fmt.Sprintf(" Skills: v%s (%d %s)", version, len(state.Skills), skillNoun))
51+
cmdio.LogString(ctx, " Update available: v"+latestVersion)
52+
cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02"))
53+
cmdio.LogString(ctx, "")
54+
cmdio.LogString(ctx, "Run 'databricks experimental aitools update' to update.")
55+
}
56+
57+
return nil
58+
},
59+
}
60+
}

experimental/aitools/lib/installer/installer.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ const (
3232
// It is a package-level var so tests can replace it with a mock.
3333
var fetchFileFn = fetchSkillFile
3434

35-
func getSkillsRef(ctx context.Context) string {
35+
// GetSkillsRef returns the skills repo ref to use. If DATABRICKS_SKILLS_REF
36+
// is set, it returns that value; otherwise it returns the default ref.
37+
func GetSkillsRef(ctx context.Context) string {
3638
if ref := env.Get(ctx, "DATABRICKS_SKILLS_REF"); ref != "" {
3739
return ref
3840
}
@@ -66,7 +68,7 @@ type InstallOptions struct {
6668
// This is a convenience wrapper that uses the default GitHubManifestSource.
6769
func FetchManifest(ctx context.Context) (*Manifest, error) {
6870
src := &GitHubManifestSource{}
69-
ref := getSkillsRef(ctx)
71+
ref := GetSkillsRef(ctx)
7072
return src.FetchManifest(ctx, ref)
7173
}
7274

@@ -117,7 +119,7 @@ func ListSkills(ctx context.Context) error {
117119
// This is the core installation function. Callers are responsible for agent detection,
118120
// prompting, and printing the "Installing..." header.
119121
func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgents []*agents.Agent, opts InstallOptions) error {
120-
ref := getSkillsRef(ctx)
122+
ref := GetSkillsRef(ctx)
121123
manifest, err := src.FetchManifest(ctx, ref)
122124
if err != nil {
123125
return err
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package installer
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/databricks/cli/experimental/aitools/lib/agents"
12+
"github.com/databricks/cli/libs/cmdio"
13+
"github.com/databricks/cli/libs/log"
14+
)
15+
16+
// UninstallSkills removes all installed skills, their symlinks, and the state file.
17+
func UninstallSkills(ctx context.Context) error {
18+
globalDir, err := GlobalSkillsDir(ctx)
19+
if err != nil {
20+
return err
21+
}
22+
23+
state, err := LoadState(globalDir)
24+
if err != nil {
25+
return fmt.Errorf("failed to load install state: %w", err)
26+
}
27+
28+
if state == nil {
29+
if hasLegacyInstall(ctx, globalDir) {
30+
return errors.New("found skills from a previous install without state tracking; run 'databricks experimental aitools install' first, then uninstall")
31+
}
32+
return errors.New("no skills installed")
33+
}
34+
35+
skillCount := len(state.Skills)
36+
37+
// Remove skill directories and symlinks for each skill in state.
38+
for name := range state.Skills {
39+
canonicalDir := filepath.Join(globalDir, name)
40+
41+
// Remove symlinks from agent directories (only symlinks pointing to canonical dir).
42+
removeSymlinksFromAgents(ctx, name, canonicalDir)
43+
44+
// Remove canonical skill directory.
45+
if err := os.RemoveAll(canonicalDir); err != nil {
46+
log.Warnf(ctx, "Failed to remove %s: %v", canonicalDir, err)
47+
}
48+
}
49+
50+
// Clean up orphaned symlinks pointing into the canonical dir.
51+
cleanOrphanedSymlinks(ctx, globalDir)
52+
53+
// Delete state file.
54+
stateFile := filepath.Join(globalDir, stateFileName)
55+
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
56+
return fmt.Errorf("failed to remove state file: %w", err)
57+
}
58+
59+
noun := "skills"
60+
if skillCount == 1 {
61+
noun = "skill"
62+
}
63+
cmdio.LogString(ctx, fmt.Sprintf("Uninstalled %d %s.", skillCount, noun))
64+
return nil
65+
}
66+
67+
// removeSymlinksFromAgents removes a skill's symlink from all agent directories
68+
// in the registry, but only if the entry is a symlink pointing into canonicalDir.
69+
// Non-symlink directories are left untouched to avoid deleting user-managed content.
70+
func removeSymlinksFromAgents(ctx context.Context, skillName, canonicalDir string) {
71+
for i := range agents.Registry {
72+
agent := &agents.Registry[i]
73+
skillsDir, err := agent.SkillsDir(ctx)
74+
if err != nil {
75+
continue
76+
}
77+
78+
destDir := filepath.Join(skillsDir, skillName)
79+
80+
// Use Lstat to detect symlinks (Stat follows them).
81+
fi, err := os.Lstat(destDir)
82+
if os.IsNotExist(err) {
83+
continue
84+
}
85+
if err != nil {
86+
log.Warnf(ctx, "Failed to stat %s for %s: %v", destDir, agent.DisplayName, err)
87+
continue
88+
}
89+
90+
if fi.Mode()&os.ModeSymlink == 0 {
91+
log.Debugf(ctx, "Skipping non-symlink %s for %s", destDir, agent.DisplayName)
92+
continue
93+
}
94+
95+
target, err := os.Readlink(destDir)
96+
if err != nil {
97+
log.Warnf(ctx, "Failed to read symlink %s: %v", destDir, err)
98+
continue
99+
}
100+
101+
// Only remove if the symlink points into our canonical dir.
102+
if !strings.HasPrefix(target, canonicalDir+string(os.PathSeparator)) && target != canonicalDir {
103+
log.Debugf(ctx, "Skipping symlink %s (points to %s, not %s)", destDir, target, canonicalDir)
104+
continue
105+
}
106+
107+
if err := os.Remove(destDir); err != nil {
108+
log.Warnf(ctx, "Failed to remove symlink %s: %v", destDir, err)
109+
} else {
110+
log.Debugf(ctx, "Removed %q from %s", skillName, agent.DisplayName)
111+
}
112+
}
113+
}
114+
115+
// cleanOrphanedSymlinks scans all agent skill directories for symlinks pointing
116+
// into globalDir that are not tracked in state, and removes them.
117+
func cleanOrphanedSymlinks(ctx context.Context, globalDir string) {
118+
for i := range agents.Registry {
119+
agent := &agents.Registry[i]
120+
skillsDir, err := agent.SkillsDir(ctx)
121+
if err != nil {
122+
continue
123+
}
124+
125+
entries, err := os.ReadDir(skillsDir)
126+
if err != nil {
127+
continue
128+
}
129+
130+
for _, entry := range entries {
131+
entryPath := filepath.Join(skillsDir, entry.Name())
132+
133+
fi, err := os.Lstat(entryPath)
134+
if err != nil {
135+
continue
136+
}
137+
138+
if fi.Mode()&os.ModeSymlink == 0 {
139+
continue
140+
}
141+
142+
target, err := os.Readlink(entryPath)
143+
if err != nil {
144+
continue
145+
}
146+
147+
// Check if the symlink points into our global skills dir.
148+
if !strings.HasPrefix(target, globalDir+string(os.PathSeparator)) && target != globalDir {
149+
continue
150+
}
151+
152+
// This symlink points into our managed dir. Remove it.
153+
if err := os.Remove(entryPath); err != nil {
154+
log.Warnf(ctx, "Failed to remove orphaned symlink %s: %v", entryPath, err)
155+
} else {
156+
log.Debugf(ctx, "Removed orphaned symlink %s -> %s", entryPath, target)
157+
}
158+
}
159+
}
160+
}

0 commit comments

Comments
 (0)