Skip to content

Commit a9c8a28

Browse files
authored
[5/5] Aitools: project scope (--project/--global) (#4814)
## PR Stack 1. [1/5] State + release discovery + directory rename (#4810) 2. [2/5] Install writes state + interactive agent selection (#4811) 3. [3/5] Update + uninstall + version commands (#4812) 4. [4/5] List improvements + command restructuring + flags (#4813) 5. **[5/5] Project scope (--project/--global)** (this PR) Manifest v2 PR: databricks/databricks-agent-skills#35 **Base**: `simonfaltum/aitools-pr4-restructure` (PR 4) ## Why Skills are currently global-only. Teams working on the same project can't share a curated set of skills via their repo. Project-scoped installation puts skills alongside the code, so they can be committed to git and shared. ## Changes Before: All skills install to `~/.databricks/aitools/skills/` (global only). Now: - `--project` flag on install, update, uninstall: operates on `<cwd>/.databricks/aitools/skills/` - `--global` flag: explicit global scope (default behavior) - `--global` + `--project` -> error - Interactive scope prompt on install (default: global). Uses `huh.NewSelect` in cmd layer. - Non-interactive defaults to global. - `SupportsProjectScope` field on `Agent` struct. Only Claude Code and Cursor support project scope. - Incompatible agents get a warning, not an error: "Skipped <agent>: does not support project-scoped skills." - Version shows both scopes when both exist. - State includes `scope: "project"` field for project installs. ## Test plan - [x] `--project` installs to cwd-relative directory - [x] `--global` + `--project` -> error - [x] No flag, interactive -> scope prompt shown, default is global - [x] No flag, non-interactive -> global (no prompt) - [x] Incompatible agents warned, not errored - [x] Symlinks only created for capable agents in project scope - [x] Version shows both scopes when both exist - [x] `SupportsProjectScope` set correctly for all 6 agents - [x] All lint checks pass
1 parent 113448b commit a9c8a28

File tree

17 files changed

+938
-104
lines changed

17 files changed

+938
-104
lines changed

experimental/aitools/cmd/install.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ import (
1616
func newInstallCmd() *cobra.Command {
1717
var skillsFlag, agentsFlag string
1818
var includeExperimental bool
19+
var projectFlag, globalFlag bool
1920

2021
cmd := &cobra.Command{
2122
Use: "install",
2223
Short: "Install AI skills for coding agents",
2324
Long: `Install Databricks AI skills for detected coding agents.
2425
25-
Skills are installed globally to each agent's skills directory.
26+
By default, skills are installed globally to each agent's skills directory.
27+
Use --project to install to the current project directory instead.
2628
When multiple agents are detected, skills are stored in a canonical location
2729
and symlinked to each agent to avoid duplication.
2830
@@ -31,10 +33,15 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
3133
RunE: func(cmd *cobra.Command, args []string) error {
3234
ctx := cmd.Context()
3335

36+
// Resolve scope.
37+
scope, err := resolveScopeWithPrompt(ctx, projectFlag, globalFlag)
38+
if err != nil {
39+
return err
40+
}
41+
3442
// Resolve target agents.
3543
var targetAgents []*agents.Agent
3644
if agentsFlag != "" {
37-
var err error
3845
targetAgents, err = resolveAgentNames(ctx, agentsFlag)
3946
if err != nil {
4047
return err
@@ -46,11 +53,18 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
4653
return nil
4754
}
4855

56+
// For project scope, pre-filter to compatible agents before prompting.
57+
if scope == installer.ScopeProject {
58+
detected = filterProjectScopeAgents(detected)
59+
if len(detected) == 0 {
60+
return errors.New("no detected agents support project-scoped skills")
61+
}
62+
}
63+
4964
switch {
5065
case len(detected) == 1:
5166
targetAgents = detected
5267
case cmdio.IsPromptSupported(ctx):
53-
var err error
5468
targetAgents, err = promptAgentSelection(ctx, detected)
5569
if err != nil {
5670
return err
@@ -63,6 +77,7 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
6377
// Build install options.
6478
opts := installer.InstallOptions{
6579
IncludeExperimental: includeExperimental,
80+
Scope: scope,
6681
}
6782
opts.SpecificSkills = splitAndTrim(skillsFlag)
6883

@@ -76,6 +91,8 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
7691
cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to install (comma-separated)")
7792
cmd.Flags().StringVar(&agentsFlag, "agents", "", "Agents to install for (comma-separated, e.g. claude-code,cursor)")
7893
cmd.Flags().BoolVar(&includeExperimental, "experimental", false, "Include experimental skills")
94+
cmd.Flags().BoolVar(&projectFlag, "project", false, "Install to project directory (cwd)")
95+
cmd.Flags().BoolVar(&globalFlag, "global", false, "Install globally (default)")
7996
return cmd
8097
}
8198

@@ -111,6 +128,17 @@ func resolveAgentNames(ctx context.Context, names string) ([]*agents.Agent, erro
111128
return result, nil
112129
}
113130

131+
// filterProjectScopeAgents returns only agents that support project-scoped skills.
132+
func filterProjectScopeAgents(detected []*agents.Agent) []*agents.Agent {
133+
var compatible []*agents.Agent
134+
for _, a := range detected {
135+
if a.SupportsProjectScope {
136+
compatible = append(compatible, a)
137+
}
138+
}
139+
return compatible
140+
}
141+
114142
// printNoAgentsMessage prints the "no agents detected" message.
115143
func printNoAgentsMessage(ctx context.Context) {
116144
cmdio.LogString(ctx, color.YellowString("No supported coding agents detected."))

experimental/aitools/cmd/install_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ func setupInstallMock(t *testing.T) *[]installCall {
3131
return &calls
3232
}
3333

34+
func setupScopeMock(t *testing.T, scope string) *bool {
35+
t.Helper()
36+
orig := promptScopeSelection
37+
t.Cleanup(func() { promptScopeSelection = orig })
38+
39+
called := false
40+
promptScopeSelection = func(_ context.Context) (string, error) {
41+
called = true
42+
return scope, nil
43+
}
44+
return &called
45+
}
46+
3447
type installCall struct {
3548
agents []string
3649
opts installer.InstallOptions
@@ -146,6 +159,7 @@ func TestInstallIncludeExperimental(t *testing.T) {
146159
func TestInstallInteractivePrompt(t *testing.T) {
147160
setupTestAgents(t)
148161
calls := setupInstallMock(t)
162+
setupScopeMock(t, installer.ScopeGlobal)
149163

150164
origPrompt := promptAgentSelection
151165
t.Cleanup(func() { promptAgentSelection = origPrompt })
@@ -436,3 +450,134 @@ func TestResolveAgentNamesDuplicatesDeduplicates(t *testing.T) {
436450
assert.Len(t, result, 1, "duplicate agent names should be deduplicated")
437451
assert.Equal(t, "claude-code", result[0].Name)
438452
}
453+
454+
// --- Scope flag tests ---
455+
456+
func TestInstallProjectFlag(t *testing.T) {
457+
setupTestAgents(t)
458+
calls := setupInstallMock(t)
459+
460+
ctx := cmdio.MockDiscard(t.Context())
461+
cmd := newInstallCmd()
462+
cmd.SetContext(ctx)
463+
cmd.SetArgs([]string{"--project"})
464+
465+
err := cmd.Execute()
466+
require.NoError(t, err)
467+
468+
require.Len(t, *calls, 1)
469+
assert.Equal(t, installer.ScopeProject, (*calls)[0].opts.Scope)
470+
}
471+
472+
func TestInstallGlobalFlag(t *testing.T) {
473+
setupTestAgents(t)
474+
calls := setupInstallMock(t)
475+
476+
ctx := cmdio.MockDiscard(t.Context())
477+
cmd := newInstallCmd()
478+
cmd.SetContext(ctx)
479+
cmd.SetArgs([]string{"--global"})
480+
481+
err := cmd.Execute()
482+
require.NoError(t, err)
483+
484+
require.Len(t, *calls, 1)
485+
assert.Equal(t, installer.ScopeGlobal, (*calls)[0].opts.Scope)
486+
}
487+
488+
func TestInstallGlobalAndProjectErrors(t *testing.T) {
489+
setupTestAgents(t)
490+
setupInstallMock(t)
491+
492+
ctx := cmdio.MockDiscard(t.Context())
493+
cmd := newInstallCmd()
494+
cmd.SetContext(ctx)
495+
cmd.SetArgs([]string{"--global", "--project"})
496+
cmd.SilenceErrors = true
497+
cmd.SilenceUsage = true
498+
499+
err := cmd.Execute()
500+
require.Error(t, err)
501+
assert.Contains(t, err.Error(), "cannot use --global and --project together")
502+
}
503+
504+
func TestInstallNoFlagNonInteractiveUsesGlobal(t *testing.T) {
505+
setupTestAgents(t)
506+
calls := setupInstallMock(t)
507+
508+
ctx := cmdio.MockDiscard(t.Context())
509+
cmd := newInstallCmd()
510+
cmd.SetContext(ctx)
511+
512+
err := cmd.RunE(cmd, nil)
513+
require.NoError(t, err)
514+
515+
require.Len(t, *calls, 1)
516+
assert.Equal(t, installer.ScopeGlobal, (*calls)[0].opts.Scope)
517+
}
518+
519+
func TestInstallNoFlagInteractiveShowsScopePrompt(t *testing.T) {
520+
setupTestAgents(t)
521+
calls := setupInstallMock(t)
522+
scopePromptCalled := setupScopeMock(t, installer.ScopeProject)
523+
524+
// Also mock agent prompt since interactive mode triggers it.
525+
origPrompt := promptAgentSelection
526+
t.Cleanup(func() { promptAgentSelection = origPrompt })
527+
promptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) {
528+
return detected, nil
529+
}
530+
531+
ctx, test := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true})
532+
defer test.Done()
533+
534+
drain := func(r *bufio.Reader) {
535+
buf := make([]byte, 4096)
536+
for {
537+
_, err := r.Read(buf)
538+
if err != nil {
539+
return
540+
}
541+
}
542+
}
543+
go drain(test.Stdout)
544+
go drain(test.Stderr)
545+
546+
cmd := newInstallCmd()
547+
cmd.SetContext(ctx)
548+
549+
err := cmd.RunE(cmd, nil)
550+
require.NoError(t, err)
551+
552+
assert.True(t, *scopePromptCalled, "scope prompt should be called in interactive mode")
553+
require.Len(t, *calls, 1)
554+
assert.Equal(t, installer.ScopeProject, (*calls)[0].opts.Scope)
555+
}
556+
557+
func TestResolveScopeValidation(t *testing.T) {
558+
tests := []struct {
559+
name string
560+
project bool
561+
global bool
562+
want string
563+
wantErr string
564+
}{
565+
{name: "neither", want: installer.ScopeGlobal},
566+
{name: "global only", global: true, want: installer.ScopeGlobal},
567+
{name: "project only", project: true, want: installer.ScopeProject},
568+
{name: "both", project: true, global: true, wantErr: "cannot use --global and --project together"},
569+
}
570+
571+
for _, tc := range tests {
572+
t.Run(tc.name, func(t *testing.T) {
573+
got, err := resolveScope(tc.project, tc.global)
574+
if tc.wantErr != "" {
575+
require.Error(t, err)
576+
assert.Contains(t, err.Error(), tc.wantErr)
577+
} else {
578+
require.NoError(t, err)
579+
assert.Equal(t, tc.want, got)
580+
}
581+
})
582+
}
583+
}

0 commit comments

Comments
 (0)