Skip to content

Commit 0ab792b

Browse files
aitools: derive experimental state from manifest repo_dir
Drop the parallel `experimental_skills` map and the per-skill `experimental` bool from the wire format. The manifest now exposes a single `skills` map where each entry carries `repo_dir` ("skills" or "experimental"), and the cli derives experimental state from it via SkillMeta.IsExperimental. Renames flattenManifest → normalizeManifest; the rest of the installer is unchanged. Acceptance fixtures and unit tests updated to the new shape. Co-authored-by: Isaac
1 parent e92febd commit 0ab792b

14 files changed

Lines changed: 129 additions & 137 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
=== --experimental against a manifest with no experimental skills logs a nudge
33
>>> [CLI] experimental aitools install --global --experimental
44
Installing Databricks AI skills for Claude Code...
5+
Using skills version test-ref
56
Warn: --experimental was set but the manifest at test-ref exposes no experimental skills. Set DATABRICKS_SKILLS_REF to a release that includes them (or =main for the latest).
6-
Installed 1 skill (vtest-ref).
7+
Installed 1 skill.

acceptance/experimental/aitools/skills/install-experimental-empty/test.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Ignore = [
88
[EnvMatrix]
99
DATABRICKS_BUNDLE_ENGINE = []
1010

11-
# Manifest with stable skills only — no experimental_skills section.
11+
# Manifest with stable skills only — no entries with repo_dir=experimental.
1212
# Simulates a release tag that pre-dates the experimental feature.
1313
[[Server]]
1414
Pattern = "GET /test-ref/manifest.json"

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22
=== install only one specific stable skill via --skills
33
>>> [CLI] experimental aitools install --global --skills test-stable-a
44
Installing Databricks AI skills for Claude Code...
5-
Installed 1 skill (vtest-ref).
5+
Using skills version test-ref
6+
Installed 1 skill.
67

78
=== install a specific experimental skill (note the -experimental suffix)
89
>>> [CLI] experimental aitools install --global --skills test-exp-experimental --experimental
910
Installing Databricks AI skills for Claude Code...
10-
Installed 1 skill (vtest-ref).
11+
Using skills version test-ref
12+
Installed 1 skill.
1113

1214
=== asking for an experimental skill without --experimental flag errors out
1315
>>> [CLI] experimental aitools install --global --skills test-exp-experimental
1416
Installing Databricks AI skills for Claude Code...
17+
Using skills version test-ref
1518
Error: skill "test-exp-experimental" is experimental; use --experimental to install
1619

1720
Exit code: 1

acceptance/experimental/aitools/skills/install-specific/test.toml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,9 @@ Response.Body = '''
1515
"version": "2",
1616
"updated_at": "2026-01-01T00:00:00Z",
1717
"skills": {
18-
"test-stable-a": {"version": "1.0.0", "files": ["SKILL.md"]},
19-
"test-stable-b": {"version": "1.0.0", "files": ["SKILL.md"]}
20-
},
21-
"experimental_skills": {
22-
"test-exp": {"version": "0.0.1", "files": ["SKILL.md"]}
18+
"test-stable-a": {"version": "1.0.0", "files": ["SKILL.md"], "repo_dir": "skills"},
19+
"test-stable-b": {"version": "1.0.0", "files": ["SKILL.md"], "repo_dir": "skills"},
20+
"test-exp": {"version": "0.0.1", "files": ["SKILL.md"], "repo_dir": "experimental"}
2321
}
2422
}
2523
'''

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22
=== stable-only install (no --experimental flag) installs 1 skill
33
>>> [CLI] experimental aitools install --global
44
Installing Databricks AI skills for Claude Code...
5-
Installed 1 skill (vtest-ref).
5+
Using skills version test-ref
6+
Installed 1 skill.
67

78
=== re-run with --experimental installs the experimental one too
89
>>> [CLI] experimental aitools install --global --experimental
910
Installing Databricks AI skills for Claude Code...
10-
Installed 2 skills (vtest-ref).
11+
Using skills version test-ref
12+
Installed 2 skills.
1113

1214
=== no-op re-run is idempotent (no new fetches, no errors)
1315
>>> [CLI] experimental aitools install --global --experimental
1416
Installing Databricks AI skills for Claude Code...
15-
Installed 2 skills (vtest-ref).
17+
Using skills version test-ref
18+
Installed 2 skills.

acceptance/experimental/aitools/skills/install/test.toml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ Ignore = [
1313
[EnvMatrix]
1414
DATABRICKS_BUNDLE_ENGINE = []
1515

16-
# Mock the manifest. One stable skill, one experimental skill.
16+
# Mock the manifest. One stable skill, one experimental skill — the
17+
# experimental state is conveyed by the per-skill repo_dir field, not a
18+
# separate experimental_skills map.
1719
[[Server]]
1820
Pattern = "GET /test-ref/manifest.json"
1921
Response.Body = '''
@@ -24,14 +26,14 @@ Response.Body = '''
2426
"test-stable": {
2527
"version": "1.0.0",
2628
"description": "Stable test skill",
27-
"files": ["SKILL.md"]
28-
}
29-
},
30-
"experimental_skills": {
29+
"files": ["SKILL.md"],
30+
"repo_dir": "skills"
31+
},
3132
"test-exp": {
3233
"version": "0.0.1",
3334
"description": "Experimental test skill",
34-
"files": ["SKILL.md"]
35+
"files": ["SKILL.md"],
36+
"repo_dir": "experimental"
3537
}
3638
}
3739
}

experimental/aitools/cmd/list.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ func defaultListSkills(cmd *cobra.Command, scope string) error {
102102
meta := manifest.Skills[name]
103103

104104
tag := ""
105-
if meta.Experimental {
105+
if meta.IsExperimental() {
106106
tag = " [experimental]"
107107
}
108108

experimental/aitools/lib/installer/installer.go

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ const (
3131
experimentalSuffix = "-experimental"
3232
)
3333

34-
// manifestHasExperimental reports whether the flattened manifest contains
35-
// at least one entry marked Experimental=true.
34+
// manifestHasExperimental reports whether the manifest contains at least one
35+
// experimental skill (sourced from the experimental/ directory upstream).
3636
func manifestHasExperimental(m *Manifest) bool {
3737
for _, meta := range m.Skills {
38-
if meta.Experimental {
38+
if meta.IsExperimental() {
3939
return true
4040
}
4141
}
@@ -87,39 +87,44 @@ func GetSkillsBaseURL(ctx context.Context) string {
8787
// Manifest describes the skills manifest fetched from the skills repo.
8888
//
8989
// The repo exposes stable skills under skills/ and experimental skills under
90-
// experimental/. Both are flattened into Skills during FetchManifest so the
91-
// rest of the installer can treat them uniformly; experimental entries carry
92-
// Experimental=true and RepoDir=experimentalRepoPath.
90+
// experimental/. Both appear in a single Skills map; each entry carries
91+
// RepoDir indicating which top-level directory holds its files, which is
92+
// also the source of truth for whether the skill is experimental.
9393
type Manifest struct {
94-
Version string `json:"version"`
95-
UpdatedAt string `json:"updated_at"`
96-
Skills map[string]SkillMeta `json:"skills"`
97-
ExperimentalSkills map[string]SkillMeta `json:"experimental_skills,omitempty"`
94+
Version string `json:"version"`
95+
UpdatedAt string `json:"updated_at"`
96+
Skills map[string]SkillMeta `json:"skills"`
9897
}
9998

10099
// SkillMeta describes a single skill entry in the manifest.
101100
type SkillMeta struct {
102-
Version string `json:"version"`
103-
UpdatedAt string `json:"updated_at"`
104-
Files []string `json:"files"`
105-
Experimental bool `json:"experimental,omitempty"`
106-
Description string `json:"description,omitempty"`
107-
MinCLIVer string `json:"min_cli_version,omitempty"`
101+
Version string `json:"version"`
102+
UpdatedAt string `json:"updated_at"`
103+
Files []string `json:"files"`
104+
Description string `json:"description,omitempty"`
105+
MinCLIVer string `json:"min_cli_version,omitempty"`
108106

109107
// RepoDir is the top-level repo subdirectory (skills/ or experimental/)
110-
// that holds this skill's files. Populated after manifest parsing; not
111-
// part of the wire format.
112-
RepoDir string `json:"-"`
108+
// that holds this skill's files. Provided by the manifest; serves as
109+
// the source of truth for whether the skill is experimental.
110+
RepoDir string `json:"repo_dir,omitempty"`
113111

114112
// SourceName is the directory name within RepoDir that holds this
115113
// skill's files in the upstream repo. For stable skills this equals
116114
// the manifest key. For experimental skills the manifest key has a
117115
// "-experimental" suffix appended for collision-free install paths,
118116
// but the upstream repo dir does not — SourceName preserves it for
119-
// the fetch URL.
117+
// the fetch URL. Populated during normalization; not part of the wire
118+
// format.
120119
SourceName string `json:"-"`
121120
}
122121

122+
// IsExperimental reports whether the skill is sourced from the experimental/
123+
// directory of the upstream repo.
124+
func (s SkillMeta) IsExperimental() bool {
125+
return s.RepoDir == experimentalRepoPath
126+
}
127+
123128
// InstallOptions controls the behavior of InstallSkillsForAgents.
124129
type InstallOptions struct {
125130
IncludeExperimental bool
@@ -188,7 +193,7 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent
188193
}
189194

190195
// Helpful nudge for users testing --experimental against a ref that
191-
// pre-dates the experimental_skills manifest section.
196+
// pre-dates the experimental/ directory upstream.
192197
if opts.IncludeExperimental && !manifestHasExperimental(manifest) {
193198
log.Warnf(ctx, "--experimental was set but the manifest at %s exposes no experimental skills. Set DATABRICKS_SKILLS_REF to a release that includes them (or =main for the latest).", ref)
194199
}
@@ -369,7 +374,7 @@ func resolveSkills(ctx context.Context, skills map[string]SkillMeta, opts Instal
369374

370375
result := make(map[string]SkillMeta, len(candidates))
371376
for name, meta := range candidates {
372-
if meta.Experimental && !opts.IncludeExperimental {
377+
if meta.IsExperimental() && !opts.IncludeExperimental {
373378
if isSpecific {
374379
return nil, fmt.Errorf("skill %q is experimental; use --experimental to install", name)
375380
}

experimental/aitools/lib/installer/installer_test.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func (m *mockManifestSource) FetchManifest(_ context.Context, _ string) (*Manife
2929
if m.fetchErr != nil {
3030
return nil, m.fetchErr
3131
}
32-
flattenManifest(m.manifest)
32+
normalizeManifest(m.manifest)
3333
return m.manifest, nil
3434
}
3535

@@ -261,10 +261,10 @@ func TestExperimentalSkillsSkippedByDefault(t *testing.T) {
261261
t.Setenv("DATABRICKS_SKILLS_REF", testSkillsRef)
262262

263263
manifest := testManifest()
264-
manifest.Skills["databricks-experimental"] = SkillMeta{
265-
Version: "0.1.0",
266-
Files: []string{"SKILL.md"},
267-
Experimental: true,
264+
manifest.Skills["databricks-iceberg"] = SkillMeta{
265+
Version: "0.1.0",
266+
Files: []string{"SKILL.md"},
267+
RepoDir: experimentalRepoPath,
268268
}
269269

270270
src := &mockManifestSource{manifest: manifest}
@@ -278,7 +278,7 @@ func TestExperimentalSkillsSkippedByDefault(t *testing.T) {
278278
require.NoError(t, err)
279279
// Only non-experimental skills should be installed.
280280
assert.Len(t, state.Skills, 2)
281-
assert.NotContains(t, state.Skills, "databricks-experimental")
281+
assert.NotContains(t, state.Skills, "databricks-iceberg-experimental")
282282

283283
assert.Contains(t, stderr.String(), "Installed 2 skills.")
284284
}
@@ -290,10 +290,10 @@ func TestExperimentalSkillsIncludedWithFlag(t *testing.T) {
290290
t.Setenv("DATABRICKS_SKILLS_REF", testSkillsRef)
291291

292292
manifest := testManifest()
293-
manifest.Skills["databricks-experimental"] = SkillMeta{
294-
Version: "0.1.0",
295-
Files: []string{"SKILL.md"},
296-
Experimental: true,
293+
manifest.Skills["databricks-iceberg"] = SkillMeta{
294+
Version: "0.1.0",
295+
Files: []string{"SKILL.md"},
296+
RepoDir: experimentalRepoPath,
297297
}
298298

299299
src := &mockManifestSource{manifest: manifest}
@@ -308,7 +308,7 @@ func TestExperimentalSkillsIncludedWithFlag(t *testing.T) {
308308
state, err := LoadState(globalDir)
309309
require.NoError(t, err)
310310
assert.Len(t, state.Skills, 3)
311-
assert.Contains(t, state.Skills, "databricks-experimental")
311+
assert.Contains(t, state.Skills, "databricks-iceberg-experimental")
312312
assert.True(t, state.IncludeExperimental)
313313

314314
assert.Contains(t, stderr.String(), "Installed 3 skills.")
@@ -754,8 +754,8 @@ func TestInstallReplacesAlternateVariant(t *testing.T) {
754754
// Now flip to experimental upstream. New install run.
755755
experimentalManifest := &Manifest{
756756
Version: "1",
757-
ExperimentalSkills: map[string]SkillMeta{
758-
"databricks-jobs": {Version: "0.2.0", Files: []string{"SKILL.md"}},
757+
Skills: map[string]SkillMeta{
758+
"databricks-jobs": {Version: "0.2.0", Files: []string{"SKILL.md"}, RepoDir: experimentalRepoPath},
759759
},
760760
}
761761
require.NoError(t, InstallSkillsForAgents(

experimental/aitools/lib/installer/source.go

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,32 +49,33 @@ func (s *GitHubManifestSource) FetchManifest(ctx context.Context, ref string) (*
4949
return nil, fmt.Errorf("failed to parse manifest: %w", err)
5050
}
5151

52-
flattenManifest(&manifest)
52+
normalizeManifest(&manifest)
5353
return &manifest, nil
5454
}
5555

56-
// flattenManifest merges ExperimentalSkills into Skills and stamps each entry
57-
// with its RepoDir + SourceName so the rest of the installer can treat both
58-
// kinds uniformly.
59-
//
60-
// Every experimental skill's manifest key gets a "-experimental" suffix so
61-
// the on-disk install path is unambiguously separate from any stable skill
56+
// normalizeManifest stamps SourceName, defaults a missing RepoDir to the
57+
// stable directory, and re-keys experimental skills with a "-experimental"
58+
// suffix so their on-disk install paths can't collide with stable skills
6259
// of the same name. SourceName preserves the upstream repo directory name
6360
// (which does not carry the suffix) for fetch URLs.
64-
func flattenManifest(m *Manifest) {
61+
//
62+
// RepoDir is provided by the manifest and is the source of truth for
63+
// whether a skill is experimental — see SkillMeta.IsExperimental.
64+
func normalizeManifest(m *Manifest) {
6565
if m.Skills == nil {
6666
m.Skills = map[string]SkillMeta{}
6767
}
68+
out := make(map[string]SkillMeta, len(m.Skills))
6869
for name, meta := range m.Skills {
69-
meta.RepoDir = stableSkillsRepoPath
70-
meta.SourceName = name
71-
m.Skills[name] = meta
72-
}
73-
for name, meta := range m.ExperimentalSkills {
74-
meta.Experimental = true
75-
meta.RepoDir = experimentalRepoPath
70+
if meta.RepoDir == "" {
71+
meta.RepoDir = stableSkillsRepoPath
72+
}
7673
meta.SourceName = name
77-
m.Skills[name+"-experimental"] = meta
74+
if meta.IsExperimental() {
75+
out[name+experimentalSuffix] = meta
76+
} else {
77+
out[name] = meta
78+
}
7879
}
79-
m.ExperimentalSkills = nil
80+
m.Skills = out
8081
}

0 commit comments

Comments
 (0)