From 66108fff3c5e8b9f99fd0229d35f9dca528d8be2 Mon Sep 17 00:00:00 2001 From: simon Date: Sun, 22 Mar 2026 10:41:20 +0100 Subject: [PATCH 1/5] 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 | 71 ++++++--- 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, 679 insertions(+), 73 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 441bf31e48..e56d7f7bb8 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. @@ -31,10 +33,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 @@ -50,7 +57,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 @@ -63,6 +69,7 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti // Build install options. opts := installer.InstallOptions{ IncludeExperimental: includeExperimental, + Scope: scope, } opts.SpecificSkills = splitAndTrim(skillsFlag) @@ -76,6 +83,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 72c62e100e..38639705ea 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 }) @@ -436,3 +450,134 @@ func TestResolveAgentNamesDuplicatesDeduplicates(t *testing.T) { assert.Len(t, result, 1, "duplicate agent names should be deduplicated") assert.Equal(t, "claude-code", result[0].Name) } + +// --- 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 7993521a35..3554e85faf 100644 --- a/experimental/aitools/cmd/uninstall.go +++ b/experimental/aitools/cmd/uninstall.go @@ -7,6 +7,7 @@ import ( func newUninstallCmd() *cobra.Command { var skillsFlag string + var projectFlag, globalFlag bool cmd := &cobra.Command{ Use: "uninstall", @@ -16,12 +17,21 @@ func newUninstallCmd() *cobra.Command { By default, removes all skills. Use --skills to remove specific skills only.`, Args: cobra.NoArgs, 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, + } opts.Skills = splitAndTrim(skillsFlag) return installer.UninstallSkillsOpts(cmd.Context(), opts) }, } 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 115ea70f5a..fadea42488 100644 --- a/experimental/aitools/cmd/update.go +++ b/experimental/aitools/cmd/update.go @@ -10,6 +10,7 @@ import ( func newUpdateCmd() *cobra.Command { var check, force, noNew bool var skillsFlag string + var projectFlag, globalFlag bool cmd := &cobra.Command{ Use: "update", @@ -22,6 +23,12 @@ preview what would change without downloading.`, Args: cobra.NoArgs, 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{} @@ -29,6 +36,7 @@ preview what would change without downloading.`, Check: check, Force: force, NoNew: noNew, + Scope: scope, } opts.Skills = splitAndTrim(skillsFlag) @@ -47,5 +55,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 db6cba4e8f..67c38fec42 100644 --- a/experimental/aitools/cmd/version.go +++ b/experimental/aitools/cmd/version.go @@ -1,6 +1,7 @@ package aitools import ( + "context" "fmt" "strings" @@ -21,38 +22,47 @@ 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" - } + latestRef := installer.GetSkillsRef(ctx) + bothScopes := globalState != nil && projectState != nil cmdio.LogString(ctx, "Databricks AI Tools:") - latestRef := installer.GetSkillsRef(ctx) - if latestRef == 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(latestRef, "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 globalState != nil { + label := "Skills" + if bothScopes { + label = "Skills (global)" + } + printVersionLine(ctx, label, globalState, latestRef) + } + + if projectState != nil { + label := "Skills" + if bothScopes { + label = "Skills (project)" + } + printVersionLine(ctx, label, projectState, latestRef) } return nil @@ -61,3 +71,24 @@ func newVersionCmd() *cobra.Command { return cmd } + +// printVersionLine prints a single version line for a scope. +func printVersionLine(ctx context.Context, label string, state *installer.InstallState, latestRef string) { + version := strings.TrimPrefix(state.Release, "v") + skillNoun := "skills" + if len(state.Skills) == 1 { + skillNoun = "skill" + } + + if latestRef == state.Release { + cmdio.LogString(ctx, fmt.Sprintf(" %s: v%s (%d %s, up to date)", label, version, len(state.Skills), skillNoun)) + cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02")) + } else { + latestVersion := strings.TrimPrefix(latestRef, "v") + cmdio.LogString(ctx, fmt.Sprintf(" %s: v%s (%d %s)", label, 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.") + } +} 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 a4c51be885..76ee0ef0c7 100644 --- a/experimental/aitools/lib/installer/installer.go +++ b/experimental/aitools/lib/installer/installer.go @@ -62,6 +62,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. @@ -105,24 +106,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 errors.New("legacy install detected without state tracking; run 'databricks experimental aitools skills install' (without a skill name) first to rebuild state") + return errors.New("legacy install detected without state tracking; run 'databricks experimental aitools install' (without a skill name) first to rebuild state") } } @@ -144,14 +160,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, ref, name, meta.Files, targetAgents, globalDir); err != nil { + if err := installSkillForAgents(ctx, ref, name, meta.Files, targetAgents, baseDir, scope, cwd); err != nil { return err } } @@ -170,10 +186,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 } @@ -186,6 +203,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) { @@ -322,16 +360,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 @@ -366,6 +405,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 717f5958d4..c018b73d99 100644 --- a/experimental/aitools/lib/installer/installer_test.go +++ b/experimental/aitools/lib/installer/installer_test.go @@ -568,3 +568,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 aafa61ea42..3ccf57060e 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") @@ -68,8 +82,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) } @@ -78,14 +92,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 } } @@ -101,15 +115,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) @@ -147,22 +161,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 { @@ -178,8 +192,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 0c46de8f19..b0d4187d77 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") @@ -144,7 +161,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 } } @@ -155,7 +172,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 73797f5d055485e7ef7dfe6e11742407b003ad2f Mon Sep 17 00:00:00 2001 From: simon Date: Sun, 22 Mar 2026 11:34:14 +0100 Subject: [PATCH 2/5] Address review findings: relative symlinks, project scope list, zero-agent guard, param cleanup Co-authored-by: Isaac --- experimental/aitools/cmd/list.go | 123 +++++++++++++++--- experimental/aitools/cmd/list_test.go | 12 +- experimental/aitools/cmd/skills.go | 3 +- 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 +- 8 files changed, 238 insertions(+), 37 deletions(-) diff --git a/experimental/aitools/cmd/list.go b/experimental/aitools/cmd/list.go index 0c7dc54856..d79d3957ec 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,19 +18,33 @@ import ( var listSkillsFn = defaultListSkills func newListCmd() *cobra.Command { + var projectFlag, globalFlag bool + cmd := &cobra.Command{ Use: "list", Short: "List installed AI tools components", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - 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(&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() ref := installer.GetSkillsRef(ctx) @@ -40,14 +55,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. @@ -65,7 +94,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] @@ -74,15 +106,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++ } } @@ -91,6 +123,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 2c316f3422..31390110c0 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 } @@ -33,12 +33,20 @@ func TestListCommandCallsListFn(t *testing.T) { assert.True(t, called) } +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 aaaa314b3e..9995ff72a0 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_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 76ee0ef0c7..a462345743 100644 --- a/experimental/aitools/lib/installer/installer.go +++ b/experimental/aitools/lib/installer/installer.go @@ -123,7 +123,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. @@ -148,6 +152,13 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent return err } + params := installParams{ + baseDir: baseDir, + scope: scope, + cwd: cwd, + ref: ref, + } + // Install each skill in sorted order for determinism. skillNames := make([]string, 0, len(targetSkills)) for name := range targetSkills { @@ -167,7 +178,7 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent } } - if err := installSkillForAgents(ctx, ref, name, meta.Files, targetAgents, baseDir, scope, cwd); err != nil { + if err := installSkillForAgents(ctx, name, meta.Files, targetAgents, params); err != nil { return err } } @@ -224,6 +235,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) { @@ -360,17 +382,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 @@ -384,7 +414,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) @@ -427,8 +465,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 c018b73d99..7daac3439b 100644 --- a/experimental/aitools/lib/installer/installer_test.go +++ b/experimental/aitools/lib/installer/installer_test.go @@ -629,7 +629,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) @@ -639,7 +639,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) } } @@ -670,6 +679,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 3ccf57060e..5dc695ef9e 100644 --- a/experimental/aitools/lib/installer/uninstall.go +++ b/experimental/aitools/lib/installer/uninstall.go @@ -118,6 +118,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 @@ -146,9 +149,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 } @@ -165,6 +175,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 @@ -192,8 +205,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 b0d4187d77..d9a9b880e4 100644 --- a/experimental/aitools/lib/installer/update.go +++ b/experimental/aitools/lib/installer/update.go @@ -159,9 +159,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 } } From 041f5c292c78aa7ca9436022e81111be8514ecda Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 25 Mar 2026 09:45:18 +0100 Subject: [PATCH 3/5] Fix 4 project-scope bugs: idempotency check, list label, update guard, agent prompt 1. allAgentsHaveSkill now uses agentSkillsDirForScope so the idempotency check looks in the correct directory during --project installs. 2. list --project with no installed state now shows "(project)" instead of "(global)" in the summary line. 3. UpdateSkills now returns an error when all detected agents are incompatible with project scope, matching the install flow guard. 4. Install prompt pre-filters agents by project-scope compatibility, so users only see agents that can actually receive the skills. Co-authored-by: Isaac --- experimental/aitools/cmd/install.go | 19 +++++++++++++++++++ experimental/aitools/cmd/list.go | 5 +++-- .../aitools/lib/installer/installer.go | 6 +++--- experimental/aitools/lib/installer/update.go | 4 ++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/experimental/aitools/cmd/install.go b/experimental/aitools/cmd/install.go index e56d7f7bb8..697be46c43 100644 --- a/experimental/aitools/cmd/install.go +++ b/experimental/aitools/cmd/install.go @@ -53,6 +53,14 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti return nil } + // For project scope, pre-filter to compatible agents before prompting. + if scope == installer.ScopeProject { + detected = filterProjectScopeAgents(detected) + if len(detected) == 0 { + return fmt.Errorf("no detected agents support project-scoped skills") + } + } + switch { case len(detected) == 1: targetAgents = detected @@ -120,6 +128,17 @@ func resolveAgentNames(ctx context.Context, names string) ([]*agents.Agent, erro return result, nil } +// filterProjectScopeAgents returns only agents that support project-scoped skills. +func filterProjectScopeAgents(detected []*agents.Agent) []*agents.Agent { + var compatible []*agents.Agent + for _, a := range detected { + if a.SupportsProjectScope { + compatible = append(compatible, a) + } + } + return compatible +} + // printNoAgentsMessage prints the "no agents detected" message. func printNoAgentsMessage(ctx context.Context) { cmdio.LogString(ctx, color.YellowString("No supported coding agents detected.")) diff --git a/experimental/aitools/cmd/list.go b/experimental/aitools/cmd/list.go index d79d3957ec..0774a0d584 100644 --- a/experimental/aitools/cmd/list.go +++ b/experimental/aitools/cmd/list.go @@ -129,9 +129,10 @@ func defaultListSkills(cmd *cobra.Command, scope string) error { 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))) + case scope == installer.ScopeProject: + cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", 0, len(names))) default: - installedCount := globalCount - cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global)", installedCount, len(names))) + cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global)", globalCount, len(names))) } return nil } diff --git a/experimental/aitools/lib/installer/installer.go b/experimental/aitools/lib/installer/installer.go index a462345743..82d1364b04 100644 --- a/experimental/aitools/lib/installer/installer.go +++ b/experimental/aitools/lib/installer/installer.go @@ -172,7 +172,7 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent // dir exists, AND every requested agent already has the skill on disk. if state != nil && state.Skills[name] == meta.Version { skillDir := filepath.Join(baseDir, name) - if _, statErr := os.Stat(skillDir); statErr == nil && allAgentsHaveSkill(ctx, name, targetAgents) { + if _, statErr := os.Stat(skillDir); statErr == nil && allAgentsHaveSkill(ctx, name, targetAgents, scope, cwd) { log.Debugf(ctx, "%s v%s already installed for all agents, skipping", name, meta.Version) continue } @@ -369,9 +369,9 @@ func hasSkillsOnDisk(dir string) bool { // allAgentsHaveSkill returns true if every agent in the list has the named // skill directory present (either as a real directory or symlink). -func allAgentsHaveSkill(ctx context.Context, skillName string, targetAgents []*agents.Agent) bool { +func allAgentsHaveSkill(ctx context.Context, skillName string, targetAgents []*agents.Agent, scope, cwd string) bool { for _, agent := range targetAgents { - agentSkillDir, err := agent.SkillsDir(ctx) + agentSkillDir, err := agentSkillsDirForScope(ctx, agent, scope, cwd) if err != nil { return false } diff --git a/experimental/aitools/lib/installer/update.go b/experimental/aitools/lib/installer/update.go index d9a9b880e4..f92de2f6d3 100644 --- a/experimental/aitools/lib/installer/update.go +++ b/experimental/aitools/lib/installer/update.go @@ -61,7 +61,11 @@ func UpdateSkills(ctx context.Context, src ManifestSource, targetAgents []*agent if err != nil { return nil, fmt.Errorf("failed to determine working directory: %w", err) } + incompatible := incompatibleAgentNames(targetAgents) targetAgents = filterProjectAgents(ctx, targetAgents) + if len(targetAgents) == 0 { + return nil, fmt.Errorf("no agents support project-scoped skills. The following detected agents are global-only: %s", strings.Join(incompatible, ", ")) + } } state, err := LoadState(baseDir) From ea74946feda8437b5d35fac0d87d3d4a0b6ce669 Mon Sep 17 00:00:00 2001 From: simon Date: Thu, 26 Mar 2026 09:34:45 +0100 Subject: [PATCH 4/5] Fix project-scope tests: remove stale mock fields and hardcoded version refs The mockManifestSource no longer has release/authoritative fields after FetchLatestRelease was removed. Update test struct literals and replace hardcoded "v0.1.0" assertions with defaultSkillsRepoRef. --- .../aitools/lib/installer/installer_test.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/experimental/aitools/lib/installer/installer_test.go b/experimental/aitools/lib/installer/installer_test.go index 7daac3439b..fdec99a3cd 100644 --- a/experimental/aitools/lib/installer/installer_test.go +++ b/experimental/aitools/lib/installer/installer_test.go @@ -3,9 +3,11 @@ package installer import ( "bytes" "context" + "fmt" "log/slog" "os" "path/filepath" + "strings" "testing" "github.com/databricks/cli/experimental/aitools/lib/agents" @@ -593,7 +595,7 @@ func TestInstallProjectScopeWritesState(t *testing.T) { require.NoError(t, os.MkdirAll(projectDir, 0o755)) t.Chdir(projectDir) - src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true} + src := &mockManifestSource{manifest: testManifest()} agent := testProjectAgent(tmp) err := InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{Scope: ScopeProject}) @@ -604,10 +606,11 @@ func TestInstallProjectScopeWritesState(t *testing.T) { require.NoError(t, err) require.NotNil(t, state) assert.Equal(t, ScopeProject, state.Scope) - assert.Equal(t, "v0.1.0", state.Release) + assert.Equal(t, defaultSkillsRepoRef, state.Release) assert.Len(t, state.Skills, 2) - assert.Contains(t, stderr.String(), "Installed 2 skills (v0.1.0).") + tag := strings.TrimPrefix(defaultSkillsRepoRef, "v") + assert.Contains(t, stderr.String(), fmt.Sprintf("Installed 2 skills (v%s).", tag)) } func TestInstallProjectScopeCreatesSymlinks(t *testing.T) { @@ -623,7 +626,7 @@ func TestInstallProjectScopeCreatesSymlinks(t *testing.T) { cwd, err := os.Getwd() require.NoError(t, err) - src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true} + src := &mockManifestSource{manifest: testManifest()} agent := testProjectAgent(tmp) err = InstallSkillsForAgents(ctx, src, []*agents.Agent{agent}, InstallOptions{Scope: ScopeProject}) @@ -661,7 +664,7 @@ func TestInstallProjectScopeFiltersIncompatibleAgents(t *testing.T) { require.NoError(t, os.MkdirAll(projectDir, 0o755)) t.Chdir(projectDir) - src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true} + src := &mockManifestSource{manifest: testManifest()} compatibleAgent := testProjectAgent(tmp) incompatibleAgent := &agents.Agent{ @@ -676,7 +679,7 @@ func TestInstallProjectScopeFiltersIncompatibleAgents(t *testing.T) { 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).") + assert.Contains(t, stderr.String(), fmt.Sprintf("Installed 2 skills (v%s).", strings.TrimPrefix(defaultSkillsRepoRef, "v"))) } func TestInstallProjectScopeZeroCompatibleAgentsReturnsError(t *testing.T) { @@ -688,7 +691,7 @@ func TestInstallProjectScopeZeroCompatibleAgentsReturnsError(t *testing.T) { require.NoError(t, os.MkdirAll(projectDir, 0o755)) t.Chdir(projectDir) - src := &mockManifestSource{manifest: testManifest(), release: "v0.1.0", authoritative: true} + src := &mockManifestSource{manifest: testManifest()} // Only provide agents that don't support project scope. globalOnlyAgent := &agents.Agent{ From 45093294c244cb732ccc9c5baa8a169454b59e28 Mon Sep 17 00:00:00 2001 From: simon Date: Thu, 26 Mar 2026 09:38:46 +0100 Subject: [PATCH 5/5] Fix perfsprint: use errors.New instead of fmt.Errorf without format verbs --- experimental/aitools/cmd/install.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experimental/aitools/cmd/install.go b/experimental/aitools/cmd/install.go index 697be46c43..876c0a3906 100644 --- a/experimental/aitools/cmd/install.go +++ b/experimental/aitools/cmd/install.go @@ -57,7 +57,7 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti if scope == installer.ScopeProject { detected = filterProjectScopeAgents(detected) if len(detected) == 0 { - return fmt.Errorf("no detected agents support project-scoped skills") + return errors.New("no detected agents support project-scoped skills") } }