Skip to content

Commit 6d9c479

Browse files
committed
aitools: keep experimental skill names stable
Use repo_dir as the experimental signal without suffixing install keys, and track repo dirs in state so source-folder moves still refresh installed files.
1 parent fc7f616 commit 6d9c479

12 files changed

Lines changed: 132 additions & 185 deletions

File tree

acceptance/experimental/aitools/skills/install-specific/output.txt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@ Installing Databricks AI skills for Claude Code...
66
Using skills version test-ref
77
Installed 1 skill.
88

9-
=== install a specific experimental skill (note the -experimental suffix)
10-
>>> [CLI] experimental aitools install --global --skills test-exp-experimental --experimental
9+
=== install a specific experimental skill
10+
>>> [CLI] experimental aitools install --global --skills test-exp --experimental
1111
Command "install" is deprecated, use "databricks aitools install" instead.
1212
Installing Databricks AI skills for Claude Code...
1313
Using skills version test-ref
1414
Installed 1 skill.
1515

1616
=== asking for an experimental skill without --experimental flag errors out
17-
>>> [CLI] experimental aitools install --global --skills test-exp-experimental
17+
>>> [CLI] experimental aitools install --global --skills test-exp
1818
Command "install" is deprecated, use "databricks aitools install" instead.
1919
Installing Databricks AI skills for Claude Code...
2020
Using skills version test-ref
21-
Error: skill "test-exp-experimental" is experimental; use --experimental to install
21+
Error: skill "test-exp" is experimental; use --experimental to install
2222

2323
Exit code: 1

acceptance/experimental/aitools/skills/install-specific/script

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ mkdir -p "${USERPROFILE:-$HOME}/.claude"
44
title "install only one specific stable skill via --skills"
55
trace $CLI experimental aitools install --global --skills test-stable-a
66

7-
title "install a specific experimental skill (note the -experimental suffix)"
8-
trace $CLI experimental aitools install --global --skills test-exp-experimental --experimental
7+
title "install a specific experimental skill"
8+
trace $CLI experimental aitools install --global --skills test-exp --experimental
99

1010
title "asking for an experimental skill without --experimental flag errors out"
11-
errcode trace $CLI experimental aitools install --global --skills test-exp-experimental
11+
errcode trace $CLI experimental aitools install --global --skills test-exp

libs/aitools/installer/installer.go

Lines changed: 13 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ const (
2828
skillsRepoName = "databricks-agent-skills"
2929
stableSkillsRepoPath = "skills"
3030
experimentalRepoPath = "experimental"
31-
experimentalSuffix = "-experimental"
3231
)
3332

3433
func manifestHasExperimental(m *Manifest) bool {
@@ -40,41 +39,13 @@ func manifestHasExperimental(m *Manifest) bool {
4039
return false
4140
}
4241

43-
// alternateVariantKey maps between "foo" and "foo-experimental".
44-
func alternateVariantKey(key string) string {
45-
if base, ok := strings.CutSuffix(key, experimentalSuffix); ok {
46-
return base
47-
}
48-
return key + experimentalSuffix
49-
}
50-
51-
// installedSkillVersion returns the recorded version for name, or for its
52-
// stable/experimental alternate when only that variant is installed.
53-
func installedSkillVersion(state *InstallState, name string) (version string, selfInstalled, alternateInstalled bool) {
54-
version, selfInstalled = state.Skills[name]
55-
altVersion, alternateInstalled := state.Skills[alternateVariantKey(name)]
56-
if !selfInstalled && alternateInstalled {
57-
version = altVersion
58-
}
59-
return version, selfInstalled, alternateInstalled
60-
}
61-
62-
func removeAlternateVariant(ctx context.Context, state *InstallState, baseDir, name, scope, cwd string) {
63-
if state == nil {
64-
return
65-
}
66-
alt := alternateVariantKey(name)
67-
if _, ok := state.Skills[alt]; !ok {
68-
return
69-
}
70-
71-
altDir := filepath.Join(baseDir, alt)
72-
removeSymlinksFromAgents(ctx, alt, altDir, scope, cwd)
73-
if err := os.RemoveAll(altDir); err != nil {
74-
log.Warnf(ctx, "Failed to remove previous variant %s: %v", altDir, err)
42+
func stateRepoDir(state *InstallState, name string) string {
43+
if state != nil && state.RepoDirs != nil {
44+
if repoDir := state.RepoDirs[name]; repoDir != "" {
45+
return repoDir
46+
}
7547
}
76-
delete(state.Skills, alt)
77-
cmdio.LogString(ctx, fmt.Sprintf("Replaced previous variant %s with %s", alt, name))
48+
return stableSkillsRepoPath
7849
}
7950

8051
// fetchFileFn is the function used to download individual skill files.
@@ -123,8 +94,7 @@ type SkillMeta struct {
12394
RepoDir string `json:"repo_dir,omitempty"`
12495

12596
// SourceName is the upstream skill directory name within RepoDir.
126-
// For experimental skills the manifest key is suffixed (-experimental)
127-
// but SourceName is not; set during normalization, not from JSON.
97+
// Set during normalization, not from JSON.
12898
SourceName string `json:"-"`
12999
}
130100

@@ -262,11 +232,9 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent
262232
for _, name := range skillNames {
263233
meta := targetSkills[name]
264234

265-
removeAlternateVariant(ctx, state, baseDir, name, scope, cwd)
266-
267235
// Idempotency: skip if same version is already installed, the canonical
268236
// dir exists, AND every requested agent already has the skill on disk.
269-
if state != nil && state.Skills[name] == meta.Version {
237+
if state != nil && state.Skills[name] == meta.Version && stateRepoDir(state, name) == meta.RepoDir {
270238
skillDir := filepath.Join(baseDir, name)
271239
if _, statErr := os.Stat(skillDir); statErr == nil && allAgentsHaveSkill(ctx, name, targetAgents, scope, cwd) {
272240
log.Debugf(ctx, "%s v%s already installed for all agents, skipping", name, meta.Version)
@@ -285,8 +253,12 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent
285253
state = &InstallState{
286254
SchemaVersion: 1,
287255
Skills: make(map[string]string, len(targetSkills)),
256+
RepoDirs: make(map[string]string, len(targetSkills)),
288257
}
289258
}
259+
if state.RepoDirs == nil {
260+
state.RepoDirs = make(map[string]string, len(state.Skills)+len(targetSkills))
261+
}
290262
state.Release = ref
291263
state.LastUpdated = time.Now()
292264
// IncludeExperimental reflects the last invocation's flag value. The Skills
@@ -296,6 +268,7 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent
296268
state.Scope = scope
297269
for name, meta := range targetSkills {
298270
state.Skills[name] = meta.Version
271+
state.RepoDirs[name] = meta.RepoDir
299272
}
300273
if err := SaveState(baseDir, state); err != nil {
301274
return err

libs/aitools/installer/installer_test.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ func TestExperimentalSkillsSkippedByDefault(t *testing.T) {
279279
require.NoError(t, err)
280280
// Only non-experimental skills should be installed.
281281
assert.Len(t, state.Skills, 2)
282-
assert.NotContains(t, state.Skills, "databricks-iceberg-experimental")
282+
assert.NotContains(t, state.Skills, "databricks-iceberg")
283283

284284
assert.Contains(t, stderr.String(), "Installed 2 skills.")
285285
}
@@ -309,7 +309,7 @@ func TestExperimentalSkillsIncludedWithFlag(t *testing.T) {
309309
state, err := LoadState(globalDir)
310310
require.NoError(t, err)
311311
assert.Len(t, state.Skills, 3)
312-
assert.Contains(t, state.Skills, "databricks-iceberg-experimental")
312+
assert.Contains(t, state.Skills, "databricks-iceberg")
313313
assert.True(t, state.IncludeExperimental)
314314

315315
assert.Contains(t, stderr.String(), "Installed 3 skills.")
@@ -728,11 +728,17 @@ func TestInstallProjectScopeZeroCompatibleAgentsReturnsError(t *testing.T) {
728728
assert.Contains(t, err.Error(), "No Project Agent")
729729
}
730730

731-
func TestInstallReplacesAlternateVariant(t *testing.T) {
731+
func TestInstallKeepsNameWhenRepoDirChanges(t *testing.T) {
732732
tmp := setupTestHome(t)
733-
ctx, stderr := cmdio.NewTestContextWithStderr(t.Context())
734-
setupFetchMock(t)
733+
ctx := cmdio.MockDiscard(t.Context())
735734
agent := testAgent(tmp)
735+
var fetchedFrom []string
736+
orig := fetchFileFn
737+
t.Cleanup(func() { fetchFileFn = orig })
738+
fetchFileFn = func(_ context.Context, _, repoDir, skillName, filePath string) ([]byte, error) {
739+
fetchedFrom = append(fetchedFrom, filepath.Join(repoDir, skillName, filePath))
740+
return []byte("# " + skillName + "/" + filePath), nil
741+
}
736742

737743
stableManifest := &Manifest{
738744
Version: "1",
@@ -747,11 +753,13 @@ func TestInstallReplacesAlternateVariant(t *testing.T) {
747753

748754
globalDir := filepath.Join(tmp, ".databricks", "aitools", "skills")
749755
require.DirExists(t, filepath.Join(globalDir, "databricks-jobs"))
756+
assert.Contains(t, fetchedFrom, filepath.Join(stableSkillsRepoPath, "databricks-jobs", "SKILL.md"))
757+
fetchedFrom = nil
750758

751759
experimentalManifest := &Manifest{
752760
Version: "1",
753761
Skills: map[string]SkillMeta{
754-
"databricks-jobs": {Version: "0.2.0", Files: []string{"SKILL.md"}, RepoDir: experimentalRepoPath},
762+
"databricks-jobs": {Version: "0.1.0", Files: []string{"SKILL.md"}, RepoDir: experimentalRepoPath},
755763
},
756764
}
757765
require.NoError(t, InstallSkillsForAgents(
@@ -761,11 +769,10 @@ func TestInstallReplacesAlternateVariant(t *testing.T) {
761769

762770
state, err := LoadState(globalDir)
763771
require.NoError(t, err)
764-
assert.NotContains(t, state.Skills, "databricks-jobs")
765-
assert.Equal(t, "0.2.0", state.Skills["databricks-jobs-experimental"])
766-
assert.NoDirExists(t, filepath.Join(globalDir, "databricks-jobs"))
767-
assert.DirExists(t, filepath.Join(globalDir, "databricks-jobs-experimental"))
768-
assert.Contains(t, stderr.String(), "Replaced previous variant databricks-jobs with databricks-jobs-experimental")
772+
assert.Equal(t, "0.1.0", state.Skills["databricks-jobs"])
773+
assert.Equal(t, experimentalRepoPath, state.RepoDirs["databricks-jobs"])
774+
assert.DirExists(t, filepath.Join(globalDir, "databricks-jobs"))
775+
assert.Contains(t, fetchedFrom, filepath.Join(experimentalRepoPath, "databricks-jobs", "SKILL.md"))
769776
}
770777

771778
func TestSupportsProjectScopeSetCorrectly(t *testing.T) {

libs/aitools/installer/source.go

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,23 +53,16 @@ func (s *GitHubManifestSource) FetchManifest(ctx context.Context, ref string) (*
5353
return &manifest, nil
5454
}
5555

56-
// normalizeManifest stamps SourceName, defaults RepoDir, and suffixes
57-
// experimental install keys while preserving unsuffixed upstream fetch paths.
56+
// normalizeManifest stamps SourceName and defaults RepoDir for older manifests.
5857
func normalizeManifest(m *Manifest) {
5958
if m.Skills == nil {
6059
m.Skills = map[string]SkillMeta{}
6160
}
62-
out := make(map[string]SkillMeta, len(m.Skills))
6361
for name, meta := range m.Skills {
6462
if meta.RepoDir == "" {
6563
meta.RepoDir = stableSkillsRepoPath
6664
}
6765
meta.SourceName = name
68-
if meta.IsExperimental() {
69-
out[name+experimentalSuffix] = meta
70-
} else {
71-
out[name] = meta
72-
}
66+
m.Skills[name] = meta
7367
}
74-
m.Skills = out
7568
}

libs/aitools/installer/source_test.go

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"github.com/stretchr/testify/assert"
77
)
88

9-
func TestNormalizeManifestStampsSourceNameAndSuffixesExperimental(t *testing.T) {
9+
func TestNormalizeManifestStampsSourceNameAndRepoDir(t *testing.T) {
1010
m := &Manifest{
1111
Skills: map[string]SkillMeta{
1212
"databricks-apps": {Version: "0.1.0", Files: []string{"SKILL.md"}},
@@ -21,14 +21,11 @@ func TestNormalizeManifestStampsSourceNameAndSuffixesExperimental(t *testing.T)
2121
assert.Equal(t, stableSkillsRepoPath, stable.RepoDir)
2222
assert.Equal(t, "databricks-apps", stable.SourceName)
2323

24-
exp, ok := m.Skills["databricks-iceberg-experimental"]
24+
exp, ok := m.Skills["databricks-iceberg"]
2525
assert.True(t, ok)
2626
assert.True(t, exp.IsExperimental())
2727
assert.Equal(t, experimentalRepoPath, exp.RepoDir)
28-
assert.Equal(t, "databricks-iceberg", exp.SourceName, "SourceName preserves the upstream repo dir name (no suffix)")
29-
30-
_, original := m.Skills["databricks-iceberg"]
31-
assert.False(t, original)
28+
assert.Equal(t, "databricks-iceberg", exp.SourceName)
3229
}
3330

3431
func TestManifestHasExperimental(t *testing.T) {
@@ -48,13 +45,6 @@ func TestManifestHasExperimental(t *testing.T) {
4845
assert.True(t, manifestHasExperimental(withExperimental))
4946
}
5047

51-
func TestAlternateVariantKey(t *testing.T) {
52-
assert.Equal(t, "databricks-jobs-experimental", alternateVariantKey("databricks-jobs"))
53-
assert.Equal(t, "databricks-jobs", alternateVariantKey("databricks-jobs-experimental"))
54-
assert.Equal(t, "databricks-jobs",
55-
alternateVariantKey(alternateVariantKey("databricks-jobs")))
56-
}
57-
5848
func TestNormalizeManifestOnlyExperimentalSkills(t *testing.T) {
5949
m := &Manifest{
6050
Skills: map[string]SkillMeta{
@@ -64,7 +54,7 @@ func TestNormalizeManifestOnlyExperimentalSkills(t *testing.T) {
6454

6555
normalizeManifest(m)
6656

67-
got, ok := m.Skills["x-experimental"]
57+
got, ok := m.Skills["x"]
6858
assert.True(t, ok)
6959
assert.True(t, got.IsExperimental())
7060
assert.Equal(t, experimentalRepoPath, got.RepoDir)

libs/aitools/installer/state.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type InstallState struct {
2727
Release string `json:"release"`
2828
LastUpdated time.Time `json:"last_updated"`
2929
Skills map[string]string `json:"skills"`
30+
RepoDirs map[string]string `json:"repo_dirs,omitempty"`
3031
Scope string `json:"scope,omitempty"`
3132
}
3233

libs/aitools/installer/state_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ func TestSaveAndLoadStateRoundtrip(t *testing.T) {
2626
Skills: map[string]string{
2727
"databricks": "1.0.0",
2828
},
29+
RepoDirs: map[string]string{
30+
"databricks": stableSkillsRepoPath,
31+
},
2932
}
3033

3134
err := SaveState(dir, original)
@@ -105,6 +108,10 @@ func TestSaveAndLoadStateWithOptionalFields(t *testing.T) {
105108
"databricks": "1.0.0",
106109
"sql-tools": "0.2.0",
107110
},
111+
RepoDirs: map[string]string{
112+
"databricks": stableSkillsRepoPath,
113+
"sql-tools": experimentalRepoPath,
114+
},
108115
Scope: "project",
109116
}
110117

libs/aitools/installer/uninstall.go

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,25 +65,13 @@ func UninstallSkillsOpts(ctx context.Context, opts UninstallOptions) error {
6565
seen := make(map[string]bool)
6666
for _, name := range opts.Skills {
6767
if _, ok := state.Skills[name]; !ok {
68-
alt := alternateVariantKey(name)
69-
if _, ok := state.Skills[alt]; ok {
70-
name = alt
71-
} else {
72-
return fmt.Errorf("skill %q is not installed", name)
73-
}
68+
return fmt.Errorf("skill %q is not installed", name)
7469
}
7570
if seen[name] {
7671
continue
7772
}
7873
seen[name] = true
7974
toRemove = append(toRemove, name)
80-
81-
if alt := alternateVariantKey(name); !seen[alt] {
82-
if _, ok := state.Skills[alt]; ok {
83-
seen[alt] = true
84-
toRemove = append(toRemove, alt)
85-
}
86-
}
8775
}
8876
} else {
8977
for name := range state.Skills {
@@ -101,6 +89,7 @@ func UninstallSkillsOpts(ctx context.Context, opts UninstallOptions) error {
10189
log.Warnf(ctx, "Failed to remove %s: %v", canonicalDir, err)
10290
}
10391
delete(state.Skills, name)
92+
delete(state.RepoDirs, name)
10493
}
10594

10695
if removeAll {

0 commit comments

Comments
 (0)