Skip to content

Commit b2584d2

Browse files
committed
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
1 parent f2fba9a commit b2584d2

File tree

14 files changed

+684
-91
lines changed

14 files changed

+684
-91
lines changed

experimental/aitools/cmd/install.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,31 @@ 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
2931
Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity`,
3032
RunE: func(cmd *cobra.Command, args []string) error {
3133
ctx := cmd.Context()
3234

35+
// Resolve scope.
36+
scope, err := resolveScopeWithPrompt(ctx, projectFlag, globalFlag)
37+
if err != nil {
38+
return err
39+
}
40+
3341
// Resolve target agents.
3442
var targetAgents []*agents.Agent
3543
if agentsFlag != "" {
36-
var err error
3744
targetAgents, err = resolveAgentNames(ctx, agentsFlag)
3845
if err != nil {
3946
return err
@@ -49,7 +56,6 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
4956
case len(detected) == 1:
5057
targetAgents = detected
5158
case cmdio.IsPromptSupported(ctx):
52-
var err error
5359
targetAgents, err = promptAgentSelection(ctx, detected)
5460
if err != nil {
5561
return err
@@ -62,6 +68,7 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
6268
// Build install options.
6369
opts := installer.InstallOptions{
6470
IncludeExperimental: includeExperimental,
71+
Scope: scope,
6572
}
6673
if skillsFlag != "" {
6774
opts.SpecificSkills = strings.Split(skillsFlag, ",")
@@ -77,6 +84,8 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
7784
cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to install (comma-separated)")
7885
cmd.Flags().StringVar(&agentsFlag, "agents", "", "Agents to install for (comma-separated, e.g. claude-code,cursor)")
7986
cmd.Flags().BoolVar(&includeExperimental, "include-experimental", false, "Include experimental skills")
87+
cmd.Flags().BoolVar(&projectFlag, "project", false, "Install to project directory (cwd)")
88+
cmd.Flags().BoolVar(&globalFlag, "global", false, "Install globally (default)")
8089
return cmd
8190
}
8291

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 })
@@ -347,3 +361,134 @@ func TestResolveAgentNamesEmpty(t *testing.T) {
347361
require.Error(t, err)
348362
assert.Contains(t, err.Error(), "no agents specified")
349363
}
364+
365+
// --- Scope flag tests ---
366+
367+
func TestInstallProjectFlag(t *testing.T) {
368+
setupTestAgents(t)
369+
calls := setupInstallMock(t)
370+
371+
ctx := cmdio.MockDiscard(t.Context())
372+
cmd := newInstallCmd()
373+
cmd.SetContext(ctx)
374+
cmd.SetArgs([]string{"--project"})
375+
376+
err := cmd.Execute()
377+
require.NoError(t, err)
378+
379+
require.Len(t, *calls, 1)
380+
assert.Equal(t, installer.ScopeProject, (*calls)[0].opts.Scope)
381+
}
382+
383+
func TestInstallGlobalFlag(t *testing.T) {
384+
setupTestAgents(t)
385+
calls := setupInstallMock(t)
386+
387+
ctx := cmdio.MockDiscard(t.Context())
388+
cmd := newInstallCmd()
389+
cmd.SetContext(ctx)
390+
cmd.SetArgs([]string{"--global"})
391+
392+
err := cmd.Execute()
393+
require.NoError(t, err)
394+
395+
require.Len(t, *calls, 1)
396+
assert.Equal(t, installer.ScopeGlobal, (*calls)[0].opts.Scope)
397+
}
398+
399+
func TestInstallGlobalAndProjectErrors(t *testing.T) {
400+
setupTestAgents(t)
401+
setupInstallMock(t)
402+
403+
ctx := cmdio.MockDiscard(t.Context())
404+
cmd := newInstallCmd()
405+
cmd.SetContext(ctx)
406+
cmd.SetArgs([]string{"--global", "--project"})
407+
cmd.SilenceErrors = true
408+
cmd.SilenceUsage = true
409+
410+
err := cmd.Execute()
411+
require.Error(t, err)
412+
assert.Contains(t, err.Error(), "cannot use --global and --project together")
413+
}
414+
415+
func TestInstallNoFlagNonInteractiveUsesGlobal(t *testing.T) {
416+
setupTestAgents(t)
417+
calls := setupInstallMock(t)
418+
419+
ctx := cmdio.MockDiscard(t.Context())
420+
cmd := newInstallCmd()
421+
cmd.SetContext(ctx)
422+
423+
err := cmd.RunE(cmd, nil)
424+
require.NoError(t, err)
425+
426+
require.Len(t, *calls, 1)
427+
assert.Equal(t, installer.ScopeGlobal, (*calls)[0].opts.Scope)
428+
}
429+
430+
func TestInstallNoFlagInteractiveShowsScopePrompt(t *testing.T) {
431+
setupTestAgents(t)
432+
calls := setupInstallMock(t)
433+
scopePromptCalled := setupScopeMock(t, installer.ScopeProject)
434+
435+
// Also mock agent prompt since interactive mode triggers it.
436+
origPrompt := promptAgentSelection
437+
t.Cleanup(func() { promptAgentSelection = origPrompt })
438+
promptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) {
439+
return detected, nil
440+
}
441+
442+
ctx, test := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true})
443+
defer test.Done()
444+
445+
drain := func(r *bufio.Reader) {
446+
buf := make([]byte, 4096)
447+
for {
448+
_, err := r.Read(buf)
449+
if err != nil {
450+
return
451+
}
452+
}
453+
}
454+
go drain(test.Stdout)
455+
go drain(test.Stderr)
456+
457+
cmd := newInstallCmd()
458+
cmd.SetContext(ctx)
459+
460+
err := cmd.RunE(cmd, nil)
461+
require.NoError(t, err)
462+
463+
assert.True(t, *scopePromptCalled, "scope prompt should be called in interactive mode")
464+
require.Len(t, *calls, 1)
465+
assert.Equal(t, installer.ScopeProject, (*calls)[0].opts.Scope)
466+
}
467+
468+
func TestResolveScopeValidation(t *testing.T) {
469+
tests := []struct {
470+
name string
471+
project bool
472+
global bool
473+
want string
474+
wantErr string
475+
}{
476+
{name: "neither", want: installer.ScopeGlobal},
477+
{name: "global only", global: true, want: installer.ScopeGlobal},
478+
{name: "project only", project: true, want: installer.ScopeProject},
479+
{name: "both", project: true, global: true, wantErr: "cannot use --global and --project together"},
480+
}
481+
482+
for _, tc := range tests {
483+
t.Run(tc.name, func(t *testing.T) {
484+
got, err := resolveScope(tc.project, tc.global)
485+
if tc.wantErr != "" {
486+
require.Error(t, err)
487+
assert.Contains(t, err.Error(), tc.wantErr)
488+
} else {
489+
require.NoError(t, err)
490+
assert.Equal(t, tc.want, got)
491+
}
492+
})
493+
}
494+
}

experimental/aitools/cmd/scope.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package aitools
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/charmbracelet/huh"
10+
"github.com/databricks/cli/experimental/aitools/lib/installer"
11+
"github.com/databricks/cli/libs/cmdio"
12+
"github.com/databricks/cli/libs/env"
13+
)
14+
15+
// promptScopeSelection is a package-level var so tests can replace it with a mock.
16+
var promptScopeSelection = defaultPromptScopeSelection
17+
18+
// resolveScope validates --project and --global flags and returns the scope.
19+
func resolveScope(project, global bool) (string, error) {
20+
if project && global {
21+
return "", errors.New("cannot use --global and --project together")
22+
}
23+
if project {
24+
return installer.ScopeProject, nil
25+
}
26+
return installer.ScopeGlobal, nil
27+
}
28+
29+
// resolveScopeWithPrompt resolves scope with optional interactive prompt.
30+
// When neither flag is set: interactive mode shows a prompt (default: global),
31+
// non-interactive mode uses global.
32+
func resolveScopeWithPrompt(ctx context.Context, project, global bool) (string, error) {
33+
if project || global {
34+
return resolveScope(project, global)
35+
}
36+
37+
// No flag: prompt if interactive, default to global otherwise.
38+
if cmdio.IsPromptSupported(ctx) {
39+
return promptScopeSelection(ctx)
40+
}
41+
return installer.ScopeGlobal, nil
42+
}
43+
44+
func defaultPromptScopeSelection(ctx context.Context) (string, error) {
45+
homeDir, err := env.UserHomeDir(ctx)
46+
if err != nil {
47+
return "", err
48+
}
49+
globalPath := filepath.Join(homeDir, ".databricks", "aitools", "skills")
50+
51+
cwd, err := os.Getwd()
52+
if err != nil {
53+
return "", err
54+
}
55+
projectPath := filepath.Join(cwd, ".databricks", "aitools", "skills")
56+
57+
globalLabel := "User global (" + globalPath + "/)\n Available to you across all projects."
58+
projectLabel := "Project (" + projectPath + "/)\n Checked into the repo, shared with everyone on the project."
59+
60+
var scope string
61+
err = huh.NewSelect[string]().
62+
Title("Where should skills be installed?").
63+
Options(
64+
huh.NewOption(globalLabel, installer.ScopeGlobal),
65+
huh.NewOption(projectLabel, installer.ScopeProject),
66+
).
67+
Value(&scope).
68+
Run()
69+
if err != nil {
70+
return "", err
71+
}
72+
73+
return scope, nil
74+
}

experimental/aitools/cmd/uninstall.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
func newUninstallCmd() *cobra.Command {
1111
var skillsFlag string
12+
var projectFlag, globalFlag bool
1213

1314
cmd := &cobra.Command{
1415
Use: "uninstall",
@@ -17,7 +18,14 @@ func newUninstallCmd() *cobra.Command {
1718
1819
By default, removes all skills. Use --skills to remove specific skills only.`,
1920
RunE: func(cmd *cobra.Command, args []string) error {
20-
opts := installer.UninstallOptions{}
21+
scope, err := resolveScope(projectFlag, globalFlag)
22+
if err != nil {
23+
return err
24+
}
25+
26+
opts := installer.UninstallOptions{
27+
Scope: scope,
28+
}
2129
if skillsFlag != "" {
2230
opts.Skills = strings.Split(skillsFlag, ",")
2331
}
@@ -26,5 +34,7 @@ By default, removes all skills. Use --skills to remove specific skills only.`,
2634
}
2735

2836
cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to uninstall (comma-separated)")
37+
cmd.Flags().BoolVar(&projectFlag, "project", false, "Uninstall project-scoped skills")
38+
cmd.Flags().BoolVar(&globalFlag, "global", false, "Uninstall globally-scoped skills (default)")
2939
return cmd
3040
}

experimental/aitools/cmd/update.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
func newUpdateCmd() *cobra.Command {
1313
var check, force, noNew bool
1414
var skillsFlag string
15+
var projectFlag, globalFlag bool
1516

1617
cmd := &cobra.Command{
1718
Use: "update",
@@ -23,13 +24,20 @@ from the manifest. Use --no-new to skip new skills, or --check to
2324
preview what would change without downloading.`,
2425
RunE: func(cmd *cobra.Command, args []string) error {
2526
ctx := cmd.Context()
27+
28+
scope, err := resolveScope(projectFlag, globalFlag)
29+
if err != nil {
30+
return err
31+
}
32+
2633
installed := agents.DetectInstalled(ctx)
2734
src := &installer.GitHubManifestSource{}
2835

2936
opts := installer.UpdateOptions{
3037
Check: check,
3138
Force: force,
3239
NoNew: noNew,
40+
Scope: scope,
3341
}
3442
if skillsFlag != "" {
3543
opts.Skills = strings.Split(skillsFlag, ",")
@@ -50,5 +58,7 @@ preview what would change without downloading.`,
5058
cmd.Flags().BoolVar(&force, "force", false, "Re-download even if versions match")
5159
cmd.Flags().BoolVar(&noNew, "no-new", false, "Don't auto-install new skills from manifest")
5260
cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to update (comma-separated)")
61+
cmd.Flags().BoolVar(&projectFlag, "project", false, "Update project-scoped skills")
62+
cmd.Flags().BoolVar(&globalFlag, "global", false, "Update globally-scoped skills (default)")
5363
return cmd
5464
}

0 commit comments

Comments
 (0)