From f83b036d5d5938ed1edf38255b3fbf225b15d004 Mon Sep 17 00:00:00 2001 From: simon Date: Sun, 22 Mar 2026 10:41:20 +0100 Subject: [PATCH 1/2] Add --project/--global scope flags and project-scoped skill installation Implements scope selection for aitools install, update, uninstall, and version commands. Skills can now be installed to a project directory (cwd) instead of only globally. Agents that support project scope (Claude Code, Cursor) get symlinks from their project config dirs to the canonical project skills dir. Co-authored-by: Isaac --- experimental/aitools/cmd/install.go | 15 +- experimental/aitools/cmd/install_test.go | 145 ++++++++++++++++++ experimental/aitools/cmd/scope.go | 74 +++++++++ experimental/aitools/cmd/uninstall.go | 12 +- experimental/aitools/cmd/update.go | 10 ++ experimental/aitools/cmd/version.go | 93 ++++++----- experimental/aitools/cmd/version_test.go | 103 +++++++++++++ experimental/aitools/lib/agents/agents.go | 32 +++- .../aitools/lib/installer/installer.go | 73 +++++++-- .../aitools/lib/installer/installer_test.go | 118 ++++++++++++++ experimental/aitools/lib/installer/state.go | 15 +- .../aitools/lib/installer/state_test.go | 7 +- .../aitools/lib/installer/uninstall.go | 50 +++--- experimental/aitools/lib/installer/update.go | 27 +++- 14 files changed, 683 insertions(+), 91 deletions(-) create mode 100644 experimental/aitools/cmd/scope.go create mode 100644 experimental/aitools/cmd/version_test.go diff --git a/experimental/aitools/cmd/install.go b/experimental/aitools/cmd/install.go index 86516abe04..5d0b1bc3e2 100644 --- a/experimental/aitools/cmd/install.go +++ b/experimental/aitools/cmd/install.go @@ -16,13 +16,15 @@ import ( func newInstallCmd() *cobra.Command { var skillsFlag, agentsFlag string var includeExperimental bool + var projectFlag, globalFlag bool cmd := &cobra.Command{ Use: "install", Short: "Install AI skills for coding agents", Long: `Install Databricks AI skills for detected coding agents. -Skills are installed globally to each agent's skills directory. +By default, skills are installed globally to each agent's skills directory. +Use --project to install to the current project directory instead. When multiple agents are detected, skills are stored in a canonical location and symlinked to each agent to avoid duplication. @@ -30,10 +32,15 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + // Resolve scope. + scope, err := resolveScopeWithPrompt(ctx, projectFlag, globalFlag) + if err != nil { + return err + } + // Resolve target agents. var targetAgents []*agents.Agent if agentsFlag != "" { - var err error targetAgents, err = resolveAgentNames(ctx, agentsFlag) if err != nil { return err @@ -49,7 +56,6 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti case len(detected) == 1: targetAgents = detected case cmdio.IsPromptSupported(ctx): - var err error targetAgents, err = promptAgentSelection(ctx, detected) if err != nil { return err @@ -62,6 +68,7 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti // Build install options. opts := installer.InstallOptions{ IncludeExperimental: includeExperimental, + Scope: scope, } if skillsFlag != "" { opts.SpecificSkills = strings.Split(skillsFlag, ",") @@ -77,6 +84,8 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to install (comma-separated)") cmd.Flags().StringVar(&agentsFlag, "agents", "", "Agents to install for (comma-separated, e.g. claude-code,cursor)") cmd.Flags().BoolVar(&includeExperimental, "experimental", false, "Include experimental skills") + cmd.Flags().BoolVar(&projectFlag, "project", false, "Install to project directory (cwd)") + cmd.Flags().BoolVar(&globalFlag, "global", false, "Install globally (default)") return cmd } diff --git a/experimental/aitools/cmd/install_test.go b/experimental/aitools/cmd/install_test.go index 2cffb8f0c9..26dfb9141b 100644 --- a/experimental/aitools/cmd/install_test.go +++ b/experimental/aitools/cmd/install_test.go @@ -31,6 +31,19 @@ func setupInstallMock(t *testing.T) *[]installCall { return &calls } +func setupScopeMock(t *testing.T, scope string) *bool { + t.Helper() + orig := promptScopeSelection + t.Cleanup(func() { promptScopeSelection = orig }) + + called := false + promptScopeSelection = func(_ context.Context) (string, error) { + called = true + return scope, nil + } + return &called +} + type installCall struct { agents []string opts installer.InstallOptions @@ -146,6 +159,7 @@ func TestInstallIncludeExperimental(t *testing.T) { func TestInstallInteractivePrompt(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) + setupScopeMock(t, installer.ScopeGlobal) origPrompt := promptAgentSelection t.Cleanup(func() { promptAgentSelection = origPrompt }) @@ -347,3 +361,134 @@ func TestResolveAgentNamesEmpty(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "no agents specified") } + +// --- Scope flag tests --- + +func TestInstallProjectFlag(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--project"}) + + err := cmd.Execute() + require.NoError(t, err) + + require.Len(t, *calls, 1) + assert.Equal(t, installer.ScopeProject, (*calls)[0].opts.Scope) +} + +func TestInstallGlobalFlag(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--global"}) + + err := cmd.Execute() + require.NoError(t, err) + + require.Len(t, *calls, 1) + assert.Equal(t, installer.ScopeGlobal, (*calls)[0].opts.Scope) +} + +func TestInstallGlobalAndProjectErrors(t *testing.T) { + setupTestAgents(t) + setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--global", "--project"}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot use --global and --project together") +} + +func TestInstallNoFlagNonInteractiveUsesGlobal(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newInstallCmd() + cmd.SetContext(ctx) + + err := cmd.RunE(cmd, nil) + require.NoError(t, err) + + require.Len(t, *calls, 1) + assert.Equal(t, installer.ScopeGlobal, (*calls)[0].opts.Scope) +} + +func TestInstallNoFlagInteractiveShowsScopePrompt(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + scopePromptCalled := setupScopeMock(t, installer.ScopeProject) + + // Also mock agent prompt since interactive mode triggers it. + origPrompt := promptAgentSelection + t.Cleanup(func() { promptAgentSelection = origPrompt }) + promptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) { + return detected, nil + } + + ctx, test := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true}) + defer test.Done() + + drain := func(r *bufio.Reader) { + buf := make([]byte, 4096) + for { + _, err := r.Read(buf) + if err != nil { + return + } + } + } + go drain(test.Stdout) + go drain(test.Stderr) + + cmd := newInstallCmd() + cmd.SetContext(ctx) + + err := cmd.RunE(cmd, nil) + require.NoError(t, err) + + assert.True(t, *scopePromptCalled, "scope prompt should be called in interactive mode") + require.Len(t, *calls, 1) + assert.Equal(t, installer.ScopeProject, (*calls)[0].opts.Scope) +} + +func TestResolveScopeValidation(t *testing.T) { + tests := []struct { + name string + project bool + global bool + want string + wantErr string + }{ + {name: "neither", want: installer.ScopeGlobal}, + {name: "global only", global: true, want: installer.ScopeGlobal}, + {name: "project only", project: true, want: installer.ScopeProject}, + {name: "both", project: true, global: true, wantErr: "cannot use --global and --project together"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveScope(tc.project, tc.global) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tc.want, got) + } + }) + } +} diff --git a/experimental/aitools/cmd/scope.go b/experimental/aitools/cmd/scope.go new file mode 100644 index 0000000000..98464d9b1f --- /dev/null +++ b/experimental/aitools/cmd/scope.go @@ -0,0 +1,74 @@ +package aitools + +import ( + "context" + "errors" + "os" + "path/filepath" + + "github.com/charmbracelet/huh" + "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/env" +) + +// promptScopeSelection is a package-level var so tests can replace it with a mock. +var promptScopeSelection = defaultPromptScopeSelection + +// resolveScope validates --project and --global flags and returns the scope. +func resolveScope(project, global bool) (string, error) { + if project && global { + return "", errors.New("cannot use --global and --project together") + } + if project { + return installer.ScopeProject, nil + } + return installer.ScopeGlobal, nil +} + +// resolveScopeWithPrompt resolves scope with optional interactive prompt. +// When neither flag is set: interactive mode shows a prompt (default: global), +// non-interactive mode uses global. +func resolveScopeWithPrompt(ctx context.Context, project, global bool) (string, error) { + if project || global { + return resolveScope(project, global) + } + + // No flag: prompt if interactive, default to global otherwise. + if cmdio.IsPromptSupported(ctx) { + return promptScopeSelection(ctx) + } + return installer.ScopeGlobal, nil +} + +func defaultPromptScopeSelection(ctx context.Context) (string, error) { + homeDir, err := env.UserHomeDir(ctx) + if err != nil { + return "", err + } + globalPath := filepath.Join(homeDir, ".databricks", "aitools", "skills") + + cwd, err := os.Getwd() + if err != nil { + return "", err + } + projectPath := filepath.Join(cwd, ".databricks", "aitools", "skills") + + globalLabel := "User global (" + globalPath + "/)\n Available to you across all projects." + projectLabel := "Project (" + projectPath + "/)\n Checked into the repo, shared with everyone on the project." + + var scope string + err = huh.NewSelect[string](). + Title("Where should skills be installed?"). + Options( + huh.NewOption(globalLabel, installer.ScopeGlobal), + huh.NewOption(projectLabel, installer.ScopeProject), + ). + Value(&scope). + Run() + if err != nil { + return "", err + } + + return scope, nil +} diff --git a/experimental/aitools/cmd/uninstall.go b/experimental/aitools/cmd/uninstall.go index 663d305506..980870e367 100644 --- a/experimental/aitools/cmd/uninstall.go +++ b/experimental/aitools/cmd/uninstall.go @@ -9,6 +9,7 @@ import ( func newUninstallCmd() *cobra.Command { var skillsFlag string + var projectFlag, globalFlag bool cmd := &cobra.Command{ Use: "uninstall", @@ -17,7 +18,14 @@ func newUninstallCmd() *cobra.Command { By default, removes all skills. Use --skills to remove specific skills only.`, RunE: func(cmd *cobra.Command, args []string) error { - opts := installer.UninstallOptions{} + scope, err := resolveScope(projectFlag, globalFlag) + if err != nil { + return err + } + + opts := installer.UninstallOptions{ + Scope: scope, + } if skillsFlag != "" { opts.Skills = strings.Split(skillsFlag, ",") } @@ -26,5 +34,7 @@ By default, removes all skills. Use --skills to remove specific skills only.`, } cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to uninstall (comma-separated)") + cmd.Flags().BoolVar(&projectFlag, "project", false, "Uninstall project-scoped skills") + cmd.Flags().BoolVar(&globalFlag, "global", false, "Uninstall globally-scoped skills (default)") return cmd } diff --git a/experimental/aitools/cmd/update.go b/experimental/aitools/cmd/update.go index 46d2d92b4c..0dffaff7e0 100644 --- a/experimental/aitools/cmd/update.go +++ b/experimental/aitools/cmd/update.go @@ -12,6 +12,7 @@ import ( func newUpdateCmd() *cobra.Command { var check, force, noNew bool var skillsFlag string + var projectFlag, globalFlag bool cmd := &cobra.Command{ Use: "update", @@ -23,6 +24,12 @@ 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() + + scope, err := resolveScope(projectFlag, globalFlag) + if err != nil { + return err + } + installed := agents.DetectInstalled(ctx) src := &installer.GitHubManifestSource{} @@ -30,6 +37,7 @@ preview what would change without downloading.`, Check: check, Force: force, NoNew: noNew, + Scope: scope, } if skillsFlag != "" { opts.Skills = strings.Split(skillsFlag, ",") @@ -50,5 +58,7 @@ preview what would change 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") cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to update (comma-separated)") + cmd.Flags().BoolVar(&projectFlag, "project", false, "Update project-scoped skills") + cmd.Flags().BoolVar(&globalFlag, "global", false, "Update globally-scoped skills (default)") return cmd } diff --git a/experimental/aitools/cmd/version.go b/experimental/aitools/cmd/version.go index 1857905036..81d5e93ef0 100644 --- a/experimental/aitools/cmd/version.go +++ b/experimental/aitools/cmd/version.go @@ -1,6 +1,7 @@ package aitools import ( + "context" "fmt" "strings" @@ -27,60 +28,58 @@ func newVersionCmd() *cobra.Command { if err != nil { return err } - - state, err := installer.LoadState(globalDir) + globalState, err := installer.LoadState(globalDir) if err != nil { - return fmt.Errorf("failed to load install state: %w", err) + return fmt.Errorf("failed to load global install state: %w", err) + } + + // Try loading project state (may fail if not in a project, that's ok). + var projectState *installer.InstallState + projectDir, projErr := installer.ProjectSkillsDir(ctx) + if projErr == nil { + projectState, err = installer.LoadState(projectDir) + if err != nil { + return fmt.Errorf("failed to load project install state: %w", err) + } } - if state == nil { + if globalState == nil && projectState == 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 + // Fetch latest release once for staleness check. + var latest string + var authoritative bool + if env.Get(ctx, "DATABRICKS_SKILLS_REF") == "" { + src := &installer.GitHubManifestSource{} + latest, authoritative, err = src.FetchLatestRelease(ctx) + if err != nil { + log.Debugf(ctx, "Could not check for updates: %v", err) + authoritative = false + } } - src := &installer.GitHubManifestSource{} - latest, authoritative, err := src.FetchLatestRelease(ctx) - if err != nil { - log.Debugf(ctx, "Could not check for updates: %v", err) - authoritative = false - } + bothScopes := globalState != nil && projectState != nil 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 globalState != nil { + label := "Skills" + if bothScopes { + label = "Skills (global)" + } + printVersionLine(ctx, label, globalState, latest, authoritative) } - 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.") + if projectState != nil { + label := "Skills" + if bothScopes { + label = "Skills (project)" + } + printVersionLine(ctx, label, projectState, latest, authoritative) } return nil @@ -90,3 +89,21 @@ func newVersionCmd() *cobra.Command { cmd.Flags().BoolVar(&showSkills, "skills", false, "Show detailed skills version information") return cmd } + +// printVersionLine prints a single version line for a scope. +func printVersionLine(ctx context.Context, label string, state *installer.InstallState, latest string, authoritative bool) { + version := strings.TrimPrefix(state.Release, "v") + skillNoun := "skills" + if len(state.Skills) == 1 { + skillNoun = "skill" + } + + if !authoritative { + cmdio.LogString(ctx, fmt.Sprintf(" %s: v%s (%d %s)", label, version, len(state.Skills), skillNoun)) + } else if latest == state.Release { + cmdio.LogString(ctx, fmt.Sprintf(" %s: v%s (%d %s, up to date)", label, version, len(state.Skills), skillNoun)) + } else { + latestVersion := strings.TrimPrefix(latest, "v") + cmdio.LogString(ctx, fmt.Sprintf(" %s: v%s (%d %s, update available: v%s)", label, version, len(state.Skills), skillNoun, latestVersion)) + } +} diff --git a/experimental/aitools/cmd/version_test.go b/experimental/aitools/cmd/version_test.go new file mode 100644 index 0000000000..8afd325abc --- /dev/null +++ b/experimental/aitools/cmd/version_test.go @@ -0,0 +1,103 @@ +package aitools + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/databricks/cli/libs/cmdio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersionShowsBothScopes(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("DATABRICKS_SKILLS_REF", "v0.1.0") + + // Create global state. + globalDir := filepath.Join(tmp, ".databricks", "aitools", "skills") + globalState := &installer.InstallState{ + SchemaVersion: 1, + Release: "v0.1.1", + LastUpdated: time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC), + Skills: map[string]string{ + "databricks-sql": "0.1.0", + "databricks-jobs": "0.1.0", + }, + Scope: installer.ScopeGlobal, + } + require.NoError(t, installer.SaveState(globalDir, globalState)) + + // Create project state in a temp project dir and chdir to it. + projectDir := filepath.Join(tmp, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o755)) + t.Chdir(projectDir) + + projectSkillsDir := filepath.Join(projectDir, ".databricks", "aitools", "skills") + projectState := &installer.InstallState{ + SchemaVersion: 1, + Release: "v0.2.0", + LastUpdated: time.Date(2026, 3, 22, 11, 0, 0, 0, time.UTC), + Skills: map[string]string{ + "databricks-sql": "0.2.0", + "databricks-jobs": "0.2.0", + "databricks-notebooks": "0.1.0", + }, + Scope: installer.ScopeProject, + } + require.NoError(t, installer.SaveState(projectSkillsDir, projectState)) + + ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) + cmd := newVersionCmd() + cmd.SetContext(ctx) + + err := cmd.RunE(cmd, nil) + require.NoError(t, err) + + output := stderr.String() + assert.Contains(t, output, "Skills (global)") + assert.Contains(t, output, "Skills (project)") + assert.Contains(t, output, "v0.1.1") + assert.Contains(t, output, "v0.2.0") + assert.Contains(t, output, "2 skills") + assert.Contains(t, output, "3 skills") +} + +func TestVersionShowsSingleScopeWithoutQualifier(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("DATABRICKS_SKILLS_REF", "v0.1.0") + + // Create only global state. + globalDir := filepath.Join(tmp, ".databricks", "aitools", "skills") + globalState := &installer.InstallState{ + SchemaVersion: 1, + Release: "v0.1.0", + LastUpdated: time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC), + Skills: map[string]string{ + "databricks-sql": "0.1.0", + }, + } + require.NoError(t, installer.SaveState(globalDir, globalState)) + + // Chdir to a project without skills. + projectDir := filepath.Join(tmp, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o755)) + t.Chdir(projectDir) + + ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) + cmd := newVersionCmd() + cmd.SetContext(ctx) + + err := cmd.RunE(cmd, nil) + require.NoError(t, err) + + output := stderr.String() + // Should show "Skills:" without qualifier when only one scope. + assert.Contains(t, output, "Skills: v0.1.0") + assert.NotContains(t, output, "Skills (global)") + assert.NotContains(t, output, "Skills (project)") +} diff --git a/experimental/aitools/lib/agents/agents.go b/experimental/aitools/lib/agents/agents.go index 56cf45171b..91f82f368d 100644 --- a/experimental/aitools/lib/agents/agents.go +++ b/experimental/aitools/lib/agents/agents.go @@ -17,6 +17,12 @@ type Agent struct { ConfigDir func(ctx context.Context) (string, error) // SkillsSubdir is the subdirectory within ConfigDir for skills (default: "skills"). SkillsSubdir string + // SupportsProjectScope indicates whether this agent supports project-scoped skills. + // When true, skills can be installed relative to the project root. + SupportsProjectScope bool + // ProjectConfigDir is the config directory name relative to a project root + // (e.g., ".claude"). Only used when SupportsProjectScope is true. + ProjectConfigDir string } // Detected returns true if the agent is installed on the system. @@ -54,17 +60,31 @@ func homeSubdir(subpath ...string) func(ctx context.Context) (string, error) { } } +// ProjectSkillsDir returns the project-scoped skills directory for this agent. +// Only valid for agents where SupportsProjectScope is true. +func (a *Agent) ProjectSkillsDir(cwd string) string { + subdir := a.SkillsSubdir + if subdir == "" { + subdir = "skills" + } + return filepath.Join(cwd, a.ProjectConfigDir, subdir) +} + // Registry contains all supported agents. var Registry = []Agent{ { - Name: "claude-code", - DisplayName: "Claude Code", - ConfigDir: homeSubdir(".claude"), + Name: "claude-code", + DisplayName: "Claude Code", + ConfigDir: homeSubdir(".claude"), + SupportsProjectScope: true, + ProjectConfigDir: ".claude", }, { - Name: "cursor", - DisplayName: "Cursor", - ConfigDir: homeSubdir(".cursor"), + Name: "cursor", + DisplayName: "Cursor", + ConfigDir: homeSubdir(".cursor"), + SupportsProjectScope: true, + ProjectConfigDir: ".cursor", }, { Name: "codex", diff --git a/experimental/aitools/lib/installer/installer.go b/experimental/aitools/lib/installer/installer.go index 3a5208be21..4f6a68c062 100644 --- a/experimental/aitools/lib/installer/installer.go +++ b/experimental/aitools/lib/installer/installer.go @@ -59,6 +59,7 @@ type SkillMeta struct { type InstallOptions struct { IncludeExperimental bool SpecificSkills []string // empty = all skills + Scope string // ScopeGlobal or ScopeProject (default: global) } // FetchManifest fetches the skills manifest from the skills repo. @@ -106,24 +107,39 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent return err } - globalDir, err := GlobalSkillsDir(ctx) + scope := opts.Scope + if scope == "" { + scope = ScopeGlobal + } + + baseDir, err := skillsDir(ctx, scope) if err != nil { return err } + // For project scope, filter to agents that support it and warn about the rest. + var cwd string + if scope == ScopeProject { + cwd, err = os.Getwd() + if err != nil { + return fmt.Errorf("failed to determine working directory: %w", err) + } + targetAgents = filterProjectAgents(ctx, targetAgents) + } + // Load existing state for idempotency checks. - state, err := LoadState(globalDir) + state, err := LoadState(baseDir) if err != nil { return fmt.Errorf("failed to load install state: %w", err) } - // Detect legacy installs (skills on disk but no state file). + // Detect legacy installs (skills on disk but no state file). Global only. // Block targeted installs on legacy setups to avoid writing incomplete state // that would hide the legacy warning on future runs. - if state == nil { - isLegacy := checkLegacyInstall(ctx, globalDir) + if state == nil && scope == ScopeGlobal { + isLegacy := checkLegacyInstall(ctx, baseDir) if isLegacy && len(opts.SpecificSkills) > 0 { - return fmt.Errorf("legacy install detected without state tracking; run 'databricks experimental aitools skills install' (without a skill name) first to rebuild state") + return fmt.Errorf("legacy install detected without state tracking; run 'databricks experimental aitools install' (without a skill name) first to rebuild state") } } @@ -145,14 +161,14 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent // Idempotency: skip if same version is already installed, the canonical // dir exists, AND every requested agent already has the skill on disk. if state != nil && state.Skills[name] == meta.Version { - skillDir := filepath.Join(globalDir, name) + skillDir := filepath.Join(baseDir, name) if _, statErr := os.Stat(skillDir); statErr == nil && allAgentsHaveSkill(ctx, name, targetAgents) { log.Debugf(ctx, "%s v%s already installed for all agents, skipping", name, meta.Version) continue } } - if err := installSkillForAgents(ctx, latestTag, name, meta.Files, targetAgents, globalDir); err != nil { + if err := installSkillForAgents(ctx, latestTag, name, meta.Files, targetAgents, baseDir, scope, cwd); err != nil { return err } } @@ -171,10 +187,11 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent // map may still contain experimental entries from a prior run with the flag // enabled; this field does not retroactively remove them. state.IncludeExperimental = opts.IncludeExperimental + state.Scope = scope for name, meta := range targetSkills { state.Skills[name] = meta.Version } - if err := SaveState(globalDir, state); err != nil { + if err := SaveState(baseDir, state); err != nil { return err } @@ -187,6 +204,27 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent return nil } +// skillsDir returns the base skills directory for the given scope. +func skillsDir(ctx context.Context, scope string) (string, error) { + if scope == ScopeProject { + return ProjectSkillsDir(ctx) + } + return GlobalSkillsDir(ctx) +} + +// filterProjectAgents returns only agents that support project scope and warns about skipped agents. +func filterProjectAgents(ctx context.Context, targetAgents []*agents.Agent) []*agents.Agent { + var compatible []*agents.Agent + for _, a := range targetAgents { + if a.SupportsProjectScope { + compatible = append(compatible, a) + } else { + cmdio.LogString(ctx, "Skipped "+a.DisplayName+": does not support project-scoped skills.") + } + } + return compatible +} + // resolveSkills filters the manifest skills based on the install options, // experimental flag, and CLI version constraints. func resolveSkills(ctx context.Context, skills map[string]SkillMeta, opts InstallOptions) (map[string]SkillMeta, error) { @@ -323,16 +361,17 @@ func allAgentsHaveSkill(ctx context.Context, skillName string, targetAgents []*a return true } -func installSkillForAgents(ctx context.Context, ref, skillName string, files []string, detectedAgents []*agents.Agent, globalDir string) error { - canonicalDir := filepath.Join(globalDir, skillName) +func installSkillForAgents(ctx context.Context, ref, skillName string, files []string, detectedAgents []*agents.Agent, baseDir, scope, cwd string) error { + canonicalDir := filepath.Join(baseDir, skillName) if err := installSkillToDir(ctx, ref, skillName, canonicalDir, files); err != nil { return err } - useSymlinks := len(detectedAgents) > 1 + // For project scope, always symlink. For global, symlink when multiple agents. + useSymlinks := scope == ScopeProject || len(detectedAgents) > 1 for _, agent := range detectedAgents { - agentSkillDir, err := agent.SkillsDir(ctx) + agentSkillDir, err := agentSkillsDirForScope(ctx, agent, scope, cwd) if err != nil { log.Warnf(ctx, "Skipped %s: %v", agent.DisplayName, err) continue @@ -367,6 +406,14 @@ func installSkillForAgents(ctx context.Context, ref, skillName string, files []s return nil } +// agentSkillsDirForScope returns the agent's skills directory for the given scope. +func agentSkillsDirForScope(ctx context.Context, agent *agents.Agent, scope, cwd string) (string, error) { + if scope == ScopeProject { + return agent.ProjectSkillsDir(cwd), nil + } + return agent.SkillsDir(ctx) +} + // backupThirdPartySkill moves destDir to a temp directory if it exists and is not // a symlink pointing to canonicalDir. This preserves skills installed by other tools. func backupThirdPartySkill(ctx context.Context, destDir, canonicalDir, skillName, agentName string) error { diff --git a/experimental/aitools/lib/installer/installer_test.go b/experimental/aitools/lib/installer/installer_test.go index a7ee7bc742..3cb437df71 100644 --- a/experimental/aitools/lib/installer/installer_test.go +++ b/experimental/aitools/lib/installer/installer_test.go @@ -574,3 +574,121 @@ func TestInstallAllSkillsSignaturePreserved(t *testing.T) { callback := func(fn func(context.Context) error) { _ = fn } callback(InstallAllSkills) } + +// --- Project scope tests --- + +func testProjectAgent(tmpHome string) *agents.Agent { + return &agents.Agent{ + Name: "test-project-agent", + DisplayName: "Test Project Agent", + SupportsProjectScope: true, + ProjectConfigDir: ".test-project-agent", + ConfigDir: func(_ context.Context) (string, error) { + return filepath.Join(tmpHome, ".test-project-agent"), nil + }, + } +} + +func TestInstallProjectScopeWritesState(t *testing.T) { + tmp := setupTestHome(t) + ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) + setupFetchMock(t) + + // Use project dir as cwd. + projectDir := filepath.Join(tmp, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o755)) + t.Chdir(projectDir) + + src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true} + agent := testProjectAgent(tmp) + + err := InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{Scope: ScopeProject}) + require.NoError(t, err) + + projectSkillsDir := filepath.Join(projectDir, ".databricks", "aitools", "skills") + state, err := LoadState(projectSkillsDir) + require.NoError(t, err) + require.NotNil(t, state) + assert.Equal(t, ScopeProject, state.Scope) + assert.Equal(t, "v0.1.0", state.Release) + assert.Len(t, state.Skills, 2) + + assert.Contains(t, stderr.String(), "Installed 2 skills (v0.1.0).") +} + +func TestInstallProjectScopeCreatesSymlinks(t *testing.T) { + tmp := setupTestHome(t) + ctx := cmdio.MockDiscard(t.Context()) + setupFetchMock(t) + + projectDir := filepath.Join(tmp, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o755)) + t.Chdir(projectDir) + + // Use os.Getwd() to match the path the installer sees (macOS may resolve symlinks). + cwd, err := os.Getwd() + require.NoError(t, err) + + src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true} + agent := testProjectAgent(tmp) + + err = InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{Scope: ScopeProject}) + require.NoError(t, err) + + // Check that agent's project skills dir has symlinks. + agentSkillDir := filepath.Join(projectDir, ".test-project-agent", "skills") + for _, skill := range []string{"databricks-sql", "databricks-jobs"} { + link := filepath.Join(agentSkillDir, skill) + fi, err := os.Lstat(link) + require.NoError(t, err, "symlink should exist for %s", skill) + assert.NotEqual(t, os.FileMode(0), fi.Mode()&os.ModeSymlink, "should be a symlink for %s", skill) + + target, err := os.Readlink(link) + require.NoError(t, err) + assert.Equal(t, filepath.Join(cwd, ".databricks", "aitools", "skills", skill), target) + } +} + +func TestInstallProjectScopeFiltersIncompatibleAgents(t *testing.T) { + tmp := setupTestHome(t) + ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) + setupFetchMock(t) + + projectDir := filepath.Join(tmp, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o755)) + t.Chdir(projectDir) + + src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true} + + compatibleAgent := testProjectAgent(tmp) + incompatibleAgent := &agents.Agent{ + Name: "no-project-agent", + DisplayName: "No Project Agent", + ConfigDir: func(_ context.Context) (string, error) { + return filepath.Join(tmp, ".no-project-agent"), nil + }, + } + + err := InstallSkillsForAgents(ctx, src, []*agents.Agent{compatibleAgent, incompatibleAgent}, InstallOptions{Scope: ScopeProject}) + require.NoError(t, err) + + assert.Contains(t, stderr.String(), "Skipped No Project Agent: does not support project-scoped skills.") + assert.Contains(t, stderr.String(), "Installed 2 skills (v0.1.0).") +} + +func TestSupportsProjectScopeSetCorrectly(t *testing.T) { + expected := map[string]bool{ + "claude-code": true, + "cursor": true, + "codex": false, + "opencode": false, + "copilot": false, + "antigravity": false, + } + + for _, agent := range agents.Registry { + want, ok := expected[agent.Name] + require.True(t, ok, "missing expected entry for %s", agent.Name) + assert.Equal(t, want, agent.SupportsProjectScope, "SupportsProjectScope for %s", agent.Name) + } +} diff --git a/experimental/aitools/lib/installer/state.go b/experimental/aitools/lib/installer/state.go index ec17db1f08..a666a58505 100644 --- a/experimental/aitools/lib/installer/state.go +++ b/experimental/aitools/lib/installer/state.go @@ -14,8 +14,11 @@ import ( const stateFileName = ".state.json" -// ErrNotImplemented indicates that a feature is not yet implemented. -var ErrNotImplemented = errors.New("project scope not yet implemented") +// Scope constants for skill installation. +const ( + ScopeGlobal = "global" + ScopeProject = "project" +) // InstallState records the state of all installed skills in a scope directory. type InstallState struct { @@ -92,7 +95,11 @@ func GlobalSkillsDir(ctx context.Context) (string, error) { } // ProjectSkillsDir returns the path to the project-scoped skills directory. -// Project scope is not yet implemented. +// The project root is the current working directory. func ProjectSkillsDir(_ context.Context) (string, error) { - return "", ErrNotImplemented + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to determine working directory: %w", err) + } + return filepath.Join(cwd, ".databricks", "aitools", "skills"), nil } diff --git a/experimental/aitools/lib/installer/state_test.go b/experimental/aitools/lib/installer/state_test.go index ea459dfafe..f1fcdb8c22 100644 --- a/experimental/aitools/lib/installer/state_test.go +++ b/experimental/aitools/lib/installer/state_test.go @@ -87,10 +87,11 @@ func TestGlobalSkillsDir(t *testing.T) { assert.Equal(t, filepath.Join("/fake/home", ".databricks", "aitools", "skills"), dir) } -func TestProjectSkillsDirNotImplemented(t *testing.T) { +func TestProjectSkillsDirReturnsCwdBased(t *testing.T) { dir, err := ProjectSkillsDir(t.Context()) - assert.ErrorIs(t, err, ErrNotImplemented) - assert.Empty(t, dir) + require.NoError(t, err) + cwd, _ := os.Getwd() + assert.Equal(t, filepath.Join(cwd, ".databricks", "aitools", "skills"), dir) } func TestSaveAndLoadStateWithOptionalFields(t *testing.T) { diff --git a/experimental/aitools/lib/installer/uninstall.go b/experimental/aitools/lib/installer/uninstall.go index e0d9a63083..1cf03cd1c1 100644 --- a/experimental/aitools/lib/installer/uninstall.go +++ b/experimental/aitools/lib/installer/uninstall.go @@ -16,6 +16,7 @@ import ( // UninstallOptions controls the behavior of UninstallSkillsOpts. type UninstallOptions struct { Skills []string // empty = all + Scope string // ScopeGlobal or ScopeProject (default: global) } // UninstallSkills removes all installed skills, their symlinks, and the state file. @@ -27,18 +28,31 @@ func UninstallSkills(ctx context.Context) error { // When opts.Skills is empty, all skills are removed (same as UninstallSkills). // When opts.Skills is non-empty, only the named skills are removed. func UninstallSkillsOpts(ctx context.Context, opts UninstallOptions) error { - globalDir, err := GlobalSkillsDir(ctx) + scope := opts.Scope + if scope == "" { + scope = ScopeGlobal + } + + baseDir, err := skillsDir(ctx, scope) if err != nil { return err } - state, err := LoadState(globalDir) + var cwd string + if scope == ScopeProject { + cwd, err = os.Getwd() + if err != nil { + return fmt.Errorf("failed to determine working directory: %w", err) + } + } + + state, err := LoadState(baseDir) if err != nil { return fmt.Errorf("failed to load install state: %w", err) } if state == nil { - if hasLegacyInstall(ctx, globalDir) { + if scope == ScopeGlobal && hasLegacyInstall(ctx, baseDir) { 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") @@ -63,8 +77,8 @@ func UninstallSkillsOpts(ctx context.Context, opts UninstallOptions) error { // Remove skill directories and symlinks for each skill. for _, name := range toRemove { - canonicalDir := filepath.Join(globalDir, name) - removeSymlinksFromAgents(ctx, name, canonicalDir) + canonicalDir := filepath.Join(baseDir, name) + removeSymlinksFromAgents(ctx, name, canonicalDir, scope, cwd) if err := os.RemoveAll(canonicalDir); err != nil { log.Warnf(ctx, "Failed to remove %s: %v", canonicalDir, err) } @@ -73,14 +87,14 @@ func UninstallSkillsOpts(ctx context.Context, opts UninstallOptions) error { if removeAll { // Clean up orphaned symlinks and delete state file. - cleanOrphanedSymlinks(ctx, globalDir) - stateFile := filepath.Join(globalDir, stateFileName) + cleanOrphanedSymlinks(ctx, baseDir, scope, cwd) + stateFile := filepath.Join(baseDir, stateFileName) if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove state file: %w", err) } } else { // Update state to reflect remaining skills. - if err := SaveState(globalDir, state); err != nil { + if err := SaveState(baseDir, state); err != nil { return err } } @@ -96,15 +110,15 @@ func UninstallSkillsOpts(ctx context.Context, opts UninstallOptions) error { // 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) { +func removeSymlinksFromAgents(ctx context.Context, skillName, canonicalDir, scope, cwd string) { for i := range agents.Registry { agent := &agents.Registry[i] - skillsDir, err := agent.SkillsDir(ctx) + agentDir, err := agentSkillsDirForScope(ctx, agent, scope, cwd) if err != nil { continue } - destDir := filepath.Join(skillsDir, skillName) + destDir := filepath.Join(agentDir, skillName) // Use Lstat to detect symlinks (Stat follows them). fi, err := os.Lstat(destDir) @@ -142,22 +156,22 @@ func removeSymlinksFromAgents(ctx context.Context, skillName, canonicalDir strin } // 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) { +// into baseDir that are not tracked in state, and removes them. +func cleanOrphanedSymlinks(ctx context.Context, baseDir, scope, cwd string) { for i := range agents.Registry { agent := &agents.Registry[i] - skillsDir, err := agent.SkillsDir(ctx) + agentDir, err := agentSkillsDirForScope(ctx, agent, scope, cwd) if err != nil { continue } - entries, err := os.ReadDir(skillsDir) + entries, err := os.ReadDir(agentDir) if err != nil { continue } for _, entry := range entries { - entryPath := filepath.Join(skillsDir, entry.Name()) + entryPath := filepath.Join(agentDir, entry.Name()) fi, err := os.Lstat(entryPath) if err != nil { @@ -173,8 +187,8 @@ func cleanOrphanedSymlinks(ctx context.Context, globalDir string) { continue } - // Check if the symlink points into our global skills dir. - if !strings.HasPrefix(target, globalDir+string(os.PathSeparator)) && target != globalDir { + // Check if the symlink points into our managed skills dir. + if !strings.HasPrefix(target, baseDir+string(os.PathSeparator)) && target != baseDir { continue } diff --git a/experimental/aitools/lib/installer/update.go b/experimental/aitools/lib/installer/update.go index 48f7290223..561f92d09d 100644 --- a/experimental/aitools/lib/installer/update.go +++ b/experimental/aitools/lib/installer/update.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "path/filepath" "sort" "strings" @@ -23,6 +24,7 @@ type UpdateOptions struct { NoNew bool Check bool // dry run: show what would change without downloading Skills []string // empty = all installed + Scope string // ScopeGlobal or ScopeProject (default: global) } // UpdateResult describes what UpdateSkills did (or would do in check mode). @@ -42,18 +44,33 @@ type SkillUpdate struct { // UpdateSkills updates installed skills to the latest release. func UpdateSkills(ctx context.Context, src ManifestSource, targetAgents []*agents.Agent, opts UpdateOptions) (*UpdateResult, error) { - globalDir, err := GlobalSkillsDir(ctx) + scope := opts.Scope + if scope == "" { + scope = ScopeGlobal + } + + baseDir, err := skillsDir(ctx, scope) if err != nil { return nil, err } - state, err := LoadState(globalDir) + // For project scope, filter to compatible agents. + var cwd string + if scope == ScopeProject { + cwd, err = os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to determine working directory: %w", err) + } + targetAgents = filterProjectAgents(ctx, targetAgents) + } + + state, err := LoadState(baseDir) if err != nil { return nil, fmt.Errorf("failed to load install state: %w", err) } if state == nil { - if hasLegacyInstall(ctx, globalDir) { + if scope == ScopeGlobal && hasLegacyInstall(ctx, baseDir) { return nil, errors.New("found skills from a previous install without state tracking; run 'databricks experimental aitools install' to refresh before updating") } return nil, errors.New("no skills installed. Run 'databricks experimental aitools install' to install") @@ -152,7 +169,7 @@ func UpdateSkills(ctx context.Context, src ManifestSource, targetAgents []*agent allChanges = append(allChanges, result.Added...) for _, change := range allChanges { meta := manifest.Skills[change.Name] - if err := installSkillForAgents(ctx, latestTag, change.Name, meta.Files, targetAgents, globalDir); err != nil { + if err := installSkillForAgents(ctx, latestTag, change.Name, meta.Files, targetAgents, baseDir, scope, cwd); err != nil { return nil, err } } @@ -163,7 +180,7 @@ func UpdateSkills(ctx context.Context, src ManifestSource, targetAgents []*agent for _, change := range allChanges { state.Skills[change.Name] = change.NewVersion } - if err := SaveState(globalDir, state); err != nil { + if err := SaveState(baseDir, state); err != nil { return nil, err } From 112b87b9e96a30d27a06922a20358c6ca8f8a890 Mon Sep 17 00:00:00 2001 From: simon Date: Sun, 22 Mar 2026 11:34:14 +0100 Subject: [PATCH 2/2] Address review findings: relative symlinks, project scope list, zero-agent guard, param cleanup Co-authored-by: Isaac --- experimental/aitools/cmd/list.go | 124 +++++++++++++++--- experimental/aitools/cmd/list_test.go | 12 +- experimental/aitools/cmd/skills.go | 3 +- experimental/aitools/cmd/version.go | 6 + experimental/aitools/cmd/version_test.go | 1 + .../aitools/lib/installer/installer.go | 62 +++++++-- .../aitools/lib/installer/installer_test.go | 39 +++++- .../aitools/lib/installer/uninstall.go | 25 +++- experimental/aitools/lib/installer/update.go | 10 +- 9 files changed, 244 insertions(+), 38 deletions(-) diff --git a/experimental/aitools/cmd/list.go b/experimental/aitools/cmd/list.go index 4ff9714c9f..c791d1721c 100644 --- a/experimental/aitools/cmd/list.go +++ b/experimental/aitools/cmd/list.go @@ -1,6 +1,7 @@ package aitools import ( + "errors" "fmt" "sort" "strings" @@ -17,6 +18,8 @@ import ( var listSkillsFn = defaultListSkills func newListCmd() *cobra.Command { + var projectFlag, globalFlag bool + // --skills is accepted for forward-compat (future component types) // but currently skills is the only component, so the output is the same. var showSkills bool @@ -25,16 +28,27 @@ func newListCmd() *cobra.Command { Use: "list", Short: "List installed AI tools components", RunE: func(cmd *cobra.Command, args []string) error { - _ = showSkills - return listSkillsFn(cmd) + if projectFlag && globalFlag { + return errors.New("cannot use --global and --project together") + } + // For list: no flag = show both scopes (empty string). + var scope string + if projectFlag { + scope = installer.ScopeProject + } else if globalFlag { + scope = installer.ScopeGlobal + } + return listSkillsFn(cmd, scope) }, } cmd.Flags().BoolVar(&showSkills, "skills", false, "Show detailed skills information") + cmd.Flags().BoolVar(&projectFlag, "project", false, "Show only project-scoped skills") + cmd.Flags().BoolVar(&globalFlag, "global", false, "Show only globally-scoped skills") return cmd } -func defaultListSkills(cmd *cobra.Command) error { +func defaultListSkills(cmd *cobra.Command, scope string) error { ctx := cmd.Context() src := &installer.GitHubManifestSource{} @@ -48,14 +62,28 @@ func defaultListSkills(cmd *cobra.Command) error { return fmt.Errorf("failed to fetch manifest: %w", err) } - globalDir, err := installer.GlobalSkillsDir(ctx) - if err != nil { - return err + // Load global state. + var globalState *installer.InstallState + if scope != installer.ScopeProject { + globalDir, gErr := installer.GlobalSkillsDir(ctx) + if gErr == nil { + globalState, err = installer.LoadState(globalDir) + if err != nil { + log.Debugf(ctx, "Could not load global install state: %v", err) + } + } } - state, err := installer.LoadState(globalDir) - if err != nil { - log.Debugf(ctx, "Could not load install state: %v", err) + // Load project state. + var projectState *installer.InstallState + if scope != installer.ScopeGlobal { + projectDir, pErr := installer.ProjectSkillsDir(ctx) + if pErr == nil { + projectState, err = installer.LoadState(projectDir) + if err != nil { + log.Debugf(ctx, "Could not load project install state: %v", err) + } + } } // Build sorted list of skill names. @@ -73,7 +101,10 @@ func defaultListSkills(cmd *cobra.Command) error { tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED") - installedCount := 0 + bothScopes := globalState != nil && projectState != nil + + globalCount := 0 + projectCount := 0 for _, name := range names { meta := manifest.Skills[name] @@ -82,15 +113,15 @@ func defaultListSkills(cmd *cobra.Command) error { tag = " [experimental]" } - installedStr := "not installed" - if state != nil { - if v, ok := state.Skills[name]; ok { - installedCount++ - if v == meta.Version { - installedStr = "v" + v + " (up to date)" - } else { - installedStr = "v" + v + " (update available)" - } + installedStr := installedStatus(name, meta.Version, globalState, projectState, bothScopes) + if globalState != nil { + if _, ok := globalState.Skills[name]; ok { + globalCount++ + } + } + if projectState != nil { + if _, ok := projectState.Skills[name]; ok { + projectCount++ } } @@ -99,6 +130,59 @@ func defaultListSkills(cmd *cobra.Command) error { tw.Flush() cmdio.LogString(ctx, buf.String()) - cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global)", installedCount, len(names))) + // Summary line. + switch { + case bothScopes: + cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", globalCount, len(names), projectCount, len(names))) + case projectState != nil: + cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", projectCount, len(names))) + default: + installedCount := globalCount + cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global)", installedCount, len(names))) + } return nil } + +// installedStatus returns the display string for a skill's installation status. +func installedStatus(name, latestVersion string, globalState, projectState *installer.InstallState, bothScopes bool) string { + globalVer := "" + projectVer := "" + + if globalState != nil { + globalVer = globalState.Skills[name] + } + if projectState != nil { + projectVer = projectState.Skills[name] + } + + if globalVer == "" && projectVer == "" { + return "not installed" + } + + // If both scopes have the skill, show the project version (takes precedence). + if bothScopes && globalVer != "" && projectVer != "" { + return versionLabel(projectVer, latestVersion) + " (project, global)" + } + + if projectVer != "" { + label := versionLabel(projectVer, latestVersion) + if bothScopes { + return label + " (project)" + } + return label + } + + label := versionLabel(globalVer, latestVersion) + if bothScopes { + return label + " (global)" + } + return label +} + +// versionLabel formats version with update status. +func versionLabel(installed, latest string) string { + if installed == latest { + return "v" + installed + " (up to date)" + } + return "v" + installed + " (update available)" +} diff --git a/experimental/aitools/cmd/list_test.go b/experimental/aitools/cmd/list_test.go index 81a304740d..cc4ec673d2 100644 --- a/experimental/aitools/cmd/list_test.go +++ b/experimental/aitools/cmd/list_test.go @@ -19,7 +19,7 @@ func TestListCommandCallsListFn(t *testing.T) { t.Cleanup(func() { listSkillsFn = orig }) called := false - listSkillsFn = func(cmd *cobra.Command) error { + listSkillsFn = func(cmd *cobra.Command, scope string) error { called = true return nil } @@ -40,12 +40,20 @@ func TestListCommandHasSkillsFlag(t *testing.T) { assert.Equal(t, "false", f.DefValue) } +func TestListCommandHasScopeFlags(t *testing.T) { + cmd := newListCmd() + f := cmd.Flags().Lookup("project") + require.NotNil(t, f, "--project flag should exist") + f = cmd.Flags().Lookup("global") + require.NotNil(t, f, "--global flag should exist") +} + func TestSkillsListDelegatesToListFn(t *testing.T) { orig := listSkillsFn t.Cleanup(func() { listSkillsFn = orig }) called := false - listSkillsFn = func(cmd *cobra.Command) error { + listSkillsFn = func(cmd *cobra.Command, scope string) error { called = true return nil } diff --git a/experimental/aitools/cmd/skills.go b/experimental/aitools/cmd/skills.go index 7f906f8b70..281844346d 100644 --- a/experimental/aitools/cmd/skills.go +++ b/experimental/aitools/cmd/skills.go @@ -66,7 +66,8 @@ func newSkillsListCmd() *cobra.Command { Use: "list", Short: "List available skills", RunE: func(cmd *cobra.Command, args []string) error { - return listSkillsFn(cmd) + // Default to showing all scopes (empty scope = both). + return listSkillsFn(cmd, "") }, } } diff --git a/experimental/aitools/cmd/version.go b/experimental/aitools/cmd/version.go index 81d5e93ef0..1ce6575cb8 100644 --- a/experimental/aitools/cmd/version.go +++ b/experimental/aitools/cmd/version.go @@ -106,4 +106,10 @@ func printVersionLine(ctx context.Context, label string, state *installer.Instal latestVersion := strings.TrimPrefix(latest, "v") cmdio.LogString(ctx, fmt.Sprintf(" %s: v%s (%d %s, update available: v%s)", label, version, len(state.Skills), skillNoun, latestVersion)) } + + cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02")) + + if authoritative && latest != state.Release { + cmdio.LogString(ctx, " Run 'databricks experimental aitools update' to update.") + } } diff --git a/experimental/aitools/cmd/version_test.go b/experimental/aitools/cmd/version_test.go index 8afd325abc..d24f7e99f8 100644 --- a/experimental/aitools/cmd/version_test.go +++ b/experimental/aitools/cmd/version_test.go @@ -64,6 +64,7 @@ func TestVersionShowsBothScopes(t *testing.T) { assert.Contains(t, output, "v0.2.0") assert.Contains(t, output, "2 skills") assert.Contains(t, output, "3 skills") + assert.Contains(t, output, "Last updated: 2026-03-22") } func TestVersionShowsSingleScopeWithoutQualifier(t *testing.T) { diff --git a/experimental/aitools/lib/installer/installer.go b/experimental/aitools/lib/installer/installer.go index 4f6a68c062..0548148631 100644 --- a/experimental/aitools/lib/installer/installer.go +++ b/experimental/aitools/lib/installer/installer.go @@ -124,7 +124,11 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent if err != nil { return fmt.Errorf("failed to determine working directory: %w", err) } + incompatible := incompatibleAgentNames(targetAgents) targetAgents = filterProjectAgents(ctx, targetAgents) + if len(targetAgents) == 0 { + return fmt.Errorf("no agents support project-scoped skills. The following detected agents are global-only: %s", strings.Join(incompatible, ", ")) + } } // Load existing state for idempotency checks. @@ -149,6 +153,13 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent return err } + params := installParams{ + baseDir: baseDir, + scope: scope, + cwd: cwd, + ref: latestTag, + } + // Install each skill in sorted order for determinism. skillNames := make([]string, 0, len(targetSkills)) for name := range targetSkills { @@ -168,7 +179,7 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent } } - if err := installSkillForAgents(ctx, latestTag, name, meta.Files, targetAgents, baseDir, scope, cwd); err != nil { + if err := installSkillForAgents(ctx, name, meta.Files, targetAgents, params); err != nil { return err } } @@ -225,6 +236,17 @@ func filterProjectAgents(ctx context.Context, targetAgents []*agents.Agent) []*a return compatible } +// incompatibleAgentNames returns the display names of agents that do not support project scope. +func incompatibleAgentNames(targetAgents []*agents.Agent) []string { + var names []string + for _, a := range targetAgents { + if !a.SupportsProjectScope { + names = append(names, a.DisplayName) + } + } + return names +} + // resolveSkills filters the manifest skills based on the install options, // experimental flag, and CLI version constraints. func resolveSkills(ctx context.Context, skills map[string]SkillMeta, opts InstallOptions) (map[string]SkillMeta, error) { @@ -361,17 +383,25 @@ func allAgentsHaveSkill(ctx context.Context, skillName string, targetAgents []*a return true } -func installSkillForAgents(ctx context.Context, ref, skillName string, files []string, detectedAgents []*agents.Agent, baseDir, scope, cwd string) error { - canonicalDir := filepath.Join(baseDir, skillName) - if err := installSkillToDir(ctx, ref, skillName, canonicalDir, files); err != nil { +// installParams bundles the parameters for installSkillForAgents to keep the signature manageable. +type installParams struct { + baseDir string + scope string + cwd string + ref string +} + +func installSkillForAgents(ctx context.Context, skillName string, files []string, detectedAgents []*agents.Agent, params installParams) error { + canonicalDir := filepath.Join(params.baseDir, skillName) + if err := installSkillToDir(ctx, params.ref, skillName, canonicalDir, files); err != nil { return err } // For project scope, always symlink. For global, symlink when multiple agents. - useSymlinks := scope == ScopeProject || len(detectedAgents) > 1 + useSymlinks := params.scope == ScopeProject || len(detectedAgents) > 1 for _, agent := range detectedAgents { - agentSkillDir, err := agentSkillsDirForScope(ctx, agent, scope, cwd) + agentSkillDir, err := agentSkillsDirForScope(ctx, agent, params.scope, params.cwd) if err != nil { log.Warnf(ctx, "Skipped %s: %v", agent.DisplayName, err) continue @@ -385,7 +415,15 @@ func installSkillForAgents(ctx context.Context, ref, skillName string, files []s } if useSymlinks { - if err := createSymlink(canonicalDir, destDir); err != nil { + symlinkTarget := canonicalDir + // For project scope, use relative symlinks so they work for teammates. + if params.scope == ScopeProject { + rel, relErr := filepath.Rel(filepath.Dir(destDir), canonicalDir) + if relErr == nil { + symlinkTarget = rel + } + } + if err := createSymlink(symlinkTarget, destDir); err != nil { log.Debugf(ctx, "Symlink failed for %s, copying instead: %v", agent.DisplayName, err) if err := copyDir(canonicalDir, destDir); err != nil { log.Warnf(ctx, "Failed to install for %s: %v", agent.DisplayName, err) @@ -428,8 +466,14 @@ func backupThirdPartySkill(ctx context.Context, destDir, canonicalDir, skillName // If it's a symlink to our canonical dir, no backup needed. if fi.Mode()&os.ModeSymlink != 0 { target, err := os.Readlink(destDir) - if err == nil && target == canonicalDir { - return nil + if err == nil { + absTarget := target + if !filepath.IsAbs(target) { + absTarget = filepath.Clean(filepath.Join(filepath.Dir(destDir), target)) + } + if absTarget == canonicalDir { + return nil + } } } diff --git a/experimental/aitools/lib/installer/installer_test.go b/experimental/aitools/lib/installer/installer_test.go index 3cb437df71..887d434914 100644 --- a/experimental/aitools/lib/installer/installer_test.go +++ b/experimental/aitools/lib/installer/installer_test.go @@ -635,7 +635,7 @@ func TestInstallProjectScopeCreatesSymlinks(t *testing.T) { err = InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{Scope: ScopeProject}) require.NoError(t, err) - // Check that agent's project skills dir has symlinks. + // Check that agent's project skills dir has relative symlinks. agentSkillDir := filepath.Join(projectDir, ".test-project-agent", "skills") for _, skill := range []string{"databricks-sql", "databricks-jobs"} { link := filepath.Join(agentSkillDir, skill) @@ -645,7 +645,16 @@ func TestInstallProjectScopeCreatesSymlinks(t *testing.T) { target, err := os.Readlink(link) require.NoError(t, err) - assert.Equal(t, filepath.Join(cwd, ".databricks", "aitools", "skills", skill), target) + // Project scope should use relative symlinks for portability. + expectedRel := filepath.Join("..", "..", ".databricks", "aitools", "skills", skill) + assert.Equal(t, expectedRel, target) + + // Verify the symlink resolves to a valid directory with the expected content. + resolved, err := filepath.EvalSymlinks(link) + require.NoError(t, err) + expectedResolved, err := filepath.EvalSymlinks(filepath.Join(cwd, ".databricks", "aitools", "skills", skill)) + require.NoError(t, err) + assert.Equal(t, expectedResolved, resolved) } } @@ -676,6 +685,32 @@ func TestInstallProjectScopeFiltersIncompatibleAgents(t *testing.T) { assert.Contains(t, stderr.String(), "Installed 2 skills (v0.1.0).") } +func TestInstallProjectScopeZeroCompatibleAgentsReturnsError(t *testing.T) { + tmp := setupTestHome(t) + ctx := cmdio.MockDiscard(t.Context()) + setupFetchMock(t) + + projectDir := filepath.Join(tmp, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o755)) + t.Chdir(projectDir) + + src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true} + + // Only provide agents that don't support project scope. + globalOnlyAgent := &agents.Agent{ + Name: "no-project-agent", + DisplayName: "No Project Agent", + ConfigDir: func(_ context.Context) (string, error) { + return filepath.Join(tmp, ".no-project-agent"), nil + }, + } + + err := InstallSkillsForAgents(ctx, src, []*agents.Agent{globalOnlyAgent}, InstallOptions{Scope: ScopeProject}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no agents support project-scoped skills") + assert.Contains(t, err.Error(), "No Project Agent") +} + func TestSupportsProjectScopeSetCorrectly(t *testing.T) { expected := map[string]bool{ "claude-code": true, diff --git a/experimental/aitools/lib/installer/uninstall.go b/experimental/aitools/lib/installer/uninstall.go index 1cf03cd1c1..7acce8ea6c 100644 --- a/experimental/aitools/lib/installer/uninstall.go +++ b/experimental/aitools/lib/installer/uninstall.go @@ -113,6 +113,9 @@ func UninstallSkillsOpts(ctx context.Context, opts UninstallOptions) error { func removeSymlinksFromAgents(ctx context.Context, skillName, canonicalDir, scope, cwd string) { for i := range agents.Registry { agent := &agents.Registry[i] + if scope == ScopeProject && !agent.SupportsProjectScope { + continue + } agentDir, err := agentSkillsDirForScope(ctx, agent, scope, cwd) if err != nil { continue @@ -141,9 +144,16 @@ func removeSymlinksFromAgents(ctx context.Context, skillName, canonicalDir, scop continue } + // Resolve relative symlinks to absolute for comparison. + absTarget := target + if !filepath.IsAbs(target) { + absTarget = filepath.Join(filepath.Dir(destDir), target) + absTarget = filepath.Clean(absTarget) + } + // 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) + if !strings.HasPrefix(absTarget, canonicalDir+string(os.PathSeparator)) && absTarget != canonicalDir { + log.Debugf(ctx, "Skipping symlink %s (points to %s, not %s)", destDir, absTarget, canonicalDir) continue } @@ -160,6 +170,9 @@ func removeSymlinksFromAgents(ctx context.Context, skillName, canonicalDir, scop func cleanOrphanedSymlinks(ctx context.Context, baseDir, scope, cwd string) { for i := range agents.Registry { agent := &agents.Registry[i] + if scope == ScopeProject && !agent.SupportsProjectScope { + continue + } agentDir, err := agentSkillsDirForScope(ctx, agent, scope, cwd) if err != nil { continue @@ -187,8 +200,14 @@ func cleanOrphanedSymlinks(ctx context.Context, baseDir, scope, cwd string) { continue } + // Resolve relative symlinks to absolute for comparison. + absTarget := target + if !filepath.IsAbs(target) { + absTarget = filepath.Clean(filepath.Join(filepath.Dir(entryPath), target)) + } + // Check if the symlink points into our managed skills dir. - if !strings.HasPrefix(target, baseDir+string(os.PathSeparator)) && target != baseDir { + if !strings.HasPrefix(absTarget, baseDir+string(os.PathSeparator)) && absTarget != baseDir { continue } diff --git a/experimental/aitools/lib/installer/update.go b/experimental/aitools/lib/installer/update.go index 561f92d09d..c34ef3e6cf 100644 --- a/experimental/aitools/lib/installer/update.go +++ b/experimental/aitools/lib/installer/update.go @@ -167,9 +167,17 @@ func UpdateSkills(ctx context.Context, src ManifestSource, targetAgents []*agent allChanges := make([]SkillUpdate, 0, len(result.Updated)+len(result.Added)) allChanges = append(allChanges, result.Updated...) allChanges = append(allChanges, result.Added...) + + params := installParams{ + baseDir: baseDir, + scope: scope, + cwd: cwd, + ref: latestTag, + } + for _, change := range allChanges { meta := manifest.Skills[change.Name] - if err := installSkillForAgents(ctx, latestTag, change.Name, meta.Files, targetAgents, baseDir, scope, cwd); err != nil { + if err := installSkillForAgents(ctx, change.Name, meta.Files, targetAgents, params); err != nil { return nil, err } }