Skip to content

Commit 4aef5b3

Browse files
authored
Add smart scope detection for aitools update and uninstall (#4923)
## Why When running `aitools update` or `uninstall` without `--project`/`--global`, the CLI defaults to global scope regardless of what's actually installed. If a user only has project-scoped skills, `update` says "no skills installed" instead of detecting and updating the project install. There's no detection of what's present on disk. ## Changes Before: `update` and `uninstall` always defaulted to global scope when no flag was passed. `--project --global` together was an error. Now: both commands auto-detect which scopes have installations and behave accordingly: - **No flags, one scope installed**: auto-selects that scope - **No flags, both scopes installed, interactive**: prompts the user to choose - **No flags, both scopes installed, non-interactive**: errors with guidance on which flags to use - **`--project --global` on update**: updates both installed scopes (ignores uninstalled ones) - **`--project --global` on uninstall**: always errors (safety, prevents accidental full removal) - **Explicit flag pointing to non-existent install**: detailed error with CWD guidance (project) or install hint (global), plus cross-scope hints when the other scope is installed The implementation adds `detectInstalledScopes`, `resolveScopeForUpdate`, and `resolveScopeForUninstall` to `scope.go`. The existing `resolveScope`/`resolveScopeWithPrompt` used by `install` are untouched. Legacy installs (skills on disk without `.state.json`) still get the installer layer's migration guidance. ## Test plan - Added 35 tests in `scope_test.go` covering all branches of scope detection and resolution - Tests cover: all 4 scope detection combinations, both-flags behavior, single-flag with/without state, no-flags auto-detection, interactive vs non-interactive, cross-scope error hints, legacy fallthrough - `go test ./experimental/aitools/cmd/... -count=1` passes - `make checks` passes
1 parent 3ad1ad3 commit 4aef5b3

4 files changed

Lines changed: 870 additions & 18 deletions

File tree

experimental/aitools/cmd/scope.go

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package aitools
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"os"
78
"path/filepath"
89

@@ -15,6 +16,12 @@ import (
1516
// promptScopeSelection is a package-level var so tests can replace it with a mock.
1617
var promptScopeSelection = defaultPromptScopeSelection
1718

19+
// promptUpdateScopeSelection is a package-level var for the update scope prompt (3 options: global/project/both).
20+
var promptUpdateScopeSelection = defaultPromptUpdateScopeSelection
21+
22+
// promptUninstallScopeSelection is a package-level var for the uninstall scope prompt (2 options: global/project).
23+
var promptUninstallScopeSelection = defaultPromptUninstallScopeSelection
24+
1825
// resolveScope validates --project and --global flags and returns the scope.
1926
func resolveScope(project, global bool) (string, error) {
2027
if project && global {
@@ -72,3 +79,248 @@ func defaultPromptScopeSelection(ctx context.Context) (string, error) {
7279

7380
return scope, nil
7481
}
82+
83+
const scopeBoth = "both"
84+
85+
// detectInstalledScopes checks which scopes have a .state.json file present.
86+
func detectInstalledScopes(globalDir, projectDir string) (global, project bool, err error) {
87+
globalState, err := installer.LoadState(globalDir)
88+
if err != nil {
89+
return false, false, err
90+
}
91+
92+
projectState, err := installer.LoadState(projectDir)
93+
if err != nil {
94+
return false, false, err
95+
}
96+
97+
return globalState != nil, projectState != nil, nil
98+
}
99+
100+
// resolveScopeForUpdate resolves scopes for the update command.
101+
// Returns one or more scopes to update. When both flags are set, global always passes through
102+
// (for legacy install detection) and project is checked via state.
103+
func resolveScopeForUpdate(ctx context.Context, projectFlag, globalFlag bool, globalDir, projectDir string) ([]string, error) {
104+
hasGlobal, hasProject, err := detectInstalledScopes(globalDir, projectDir)
105+
if err != nil {
106+
return nil, err
107+
}
108+
109+
if projectFlag && globalFlag {
110+
var scopes []string
111+
if hasGlobal {
112+
scopes = append(scopes, installer.ScopeGlobal)
113+
}
114+
if hasProject {
115+
scopes = append(scopes, installer.ScopeProject)
116+
}
117+
if len(scopes) == 0 {
118+
// Neither installed. Fall through to global for legacy detection.
119+
return []string{installer.ScopeGlobal}, nil
120+
}
121+
return scopes, nil
122+
}
123+
if projectFlag {
124+
return withExplicitScopeCheck(projectDir, installer.ScopeProject, "update", projectDir, hasGlobal, hasProject)
125+
}
126+
if globalFlag {
127+
// Always pass through to the installer layer, which handles legacy installs.
128+
return []string{installer.ScopeGlobal}, nil
129+
}
130+
131+
// No flags: auto-detect.
132+
switch {
133+
case hasGlobal && hasProject:
134+
if !cmdio.IsPromptSupported(ctx) {
135+
return nil, errors.New("skills are installed in both global and project scopes; use --global, --project, or both flags to specify which to update")
136+
}
137+
scopes, err := promptUpdateScopeSelection(ctx)
138+
if err != nil {
139+
return nil, err
140+
}
141+
return scopes, nil
142+
143+
case hasGlobal:
144+
return []string{installer.ScopeGlobal}, nil
145+
146+
case hasProject:
147+
return []string{installer.ScopeProject}, nil
148+
149+
default:
150+
// Fall through to global scope so the installer layer can detect
151+
// legacy installs (skills on disk without .state.json) and provide
152+
// appropriate migration guidance.
153+
return []string{installer.ScopeGlobal}, nil
154+
}
155+
}
156+
157+
// resolveScopeForUninstall resolves the scope for the uninstall command.
158+
// Unlike update, uninstall never allows "both" scopes at once.
159+
func resolveScopeForUninstall(ctx context.Context, projectFlag, globalFlag bool, globalDir, projectDir string) (string, error) {
160+
if projectFlag && globalFlag {
161+
return "", errors.New("cannot uninstall both scopes at once; run uninstall separately for --global and --project")
162+
}
163+
164+
hasGlobal, hasProject, err := detectInstalledScopes(globalDir, projectDir)
165+
if err != nil {
166+
return "", err
167+
}
168+
169+
if projectFlag {
170+
scopes, err := withExplicitScopeCheck(projectDir, installer.ScopeProject, "uninstall", projectDir, hasGlobal, hasProject)
171+
if err != nil {
172+
return "", err
173+
}
174+
return scopes[0], nil
175+
}
176+
if globalFlag {
177+
// Always pass through to the installer layer, which handles legacy installs.
178+
return installer.ScopeGlobal, nil
179+
}
180+
181+
// No flags: auto-detect.
182+
switch {
183+
case hasGlobal && hasProject:
184+
if !cmdio.IsPromptSupported(ctx) {
185+
return "", errors.New("skills are installed in both global and project scopes; use --global or --project to specify which to uninstall")
186+
}
187+
scope, err := promptUninstallScopeSelection(ctx)
188+
if err != nil {
189+
return "", err
190+
}
191+
return scope, nil
192+
193+
case hasGlobal:
194+
return installer.ScopeGlobal, nil
195+
196+
case hasProject:
197+
return installer.ScopeProject, nil
198+
199+
default:
200+
// Fall through to global scope so the installer layer can detect
201+
// legacy installs (skills on disk without .state.json) and provide
202+
// appropriate migration guidance.
203+
return installer.ScopeGlobal, nil
204+
}
205+
}
206+
207+
// withExplicitScopeCheck validates that the explicitly requested scope has an installation.
208+
// Returns a helpful error with CWD guidance for project scope and cross-scope hints.
209+
// The verb parameter (e.g. "update", "uninstall") is used in cross-scope hint messages.
210+
func withExplicitScopeCheck(dir, scope, verb, projectDir string, hasGlobal, hasProject bool) ([]string, error) {
211+
state, err := installer.LoadState(dir)
212+
if err != nil {
213+
return nil, err
214+
}
215+
if state == nil {
216+
return nil, scopeNotInstalledError(scope, verb, projectDir, hasGlobal, hasProject)
217+
}
218+
219+
return []string{scope}, nil
220+
}
221+
222+
// scopeNotInstalledError builds a detailed error for when the requested scope has no installation.
223+
// Includes cross-scope hints when the other scope is installed.
224+
// The verb parameter (e.g. "update", "uninstall") is used in cross-scope hint messages.
225+
func scopeNotInstalledError(scope, verb, projectDir string, hasGlobal, hasProject bool) error {
226+
var msg string
227+
if scope == installer.ScopeProject {
228+
expectedPath := filepath.ToSlash(projectDir)
229+
msg = fmt.Sprintf(
230+
"no project-scoped skills found in the current directory.\n\n"+
231+
"Project-scoped skills are detected based on your working directory.\n"+
232+
"Make sure you are in the project root where you originally ran\n"+
233+
"'databricks experimental aitools install --project'.\n\n"+
234+
"Expected location: %s/", expectedPath)
235+
} else {
236+
msg = "no globally-scoped skills installed. Run 'databricks experimental aitools install --global' to install"
237+
}
238+
239+
hint := crossScopeHint(scope, verb, hasGlobal, hasProject)
240+
if hint != "" {
241+
msg += "\n\n" + hint
242+
}
243+
244+
return errors.New(msg)
245+
}
246+
247+
// crossScopeHint returns a hint string if the opposite scope has an installation.
248+
// The verb parameter (e.g. "update", "uninstall") controls the action in the hint message.
249+
func crossScopeHint(requestedScope, verb string, hasGlobal, hasProject bool) string {
250+
if requestedScope == installer.ScopeProject && hasGlobal {
251+
return fmt.Sprintf("Global skills are installed. Run without --project to %s those.", verb)
252+
}
253+
if requestedScope == installer.ScopeGlobal && hasProject {
254+
return fmt.Sprintf("Project-scoped skills are installed. Run without --global to %s those.", verb)
255+
}
256+
return ""
257+
}
258+
259+
func defaultPromptUpdateScopeSelection(ctx context.Context) ([]string, error) {
260+
homeDir, err := env.UserHomeDir(ctx)
261+
if err != nil {
262+
return nil, err
263+
}
264+
globalPath := filepath.Join(homeDir, ".databricks", "aitools", "skills")
265+
266+
cwd, err := os.Getwd()
267+
if err != nil {
268+
return nil, err
269+
}
270+
projectPath := filepath.Join(cwd, ".databricks", "aitools", "skills")
271+
272+
globalLabel := "Global (" + globalPath + "/)"
273+
projectLabel := "Project (" + projectPath + "/)"
274+
bothLabel := "Both global and project"
275+
276+
var scope string
277+
err = huh.NewSelect[string]().
278+
Title("Which installation should be updated?").
279+
Options(
280+
huh.NewOption(globalLabel, installer.ScopeGlobal),
281+
huh.NewOption(projectLabel, installer.ScopeProject),
282+
huh.NewOption(bothLabel, scopeBoth),
283+
).
284+
Value(&scope).
285+
Run()
286+
if err != nil {
287+
return nil, err
288+
}
289+
290+
if scope == scopeBoth {
291+
return []string{installer.ScopeGlobal, installer.ScopeProject}, nil
292+
}
293+
return []string{scope}, nil
294+
}
295+
296+
func defaultPromptUninstallScopeSelection(ctx context.Context) (string, error) {
297+
homeDir, err := env.UserHomeDir(ctx)
298+
if err != nil {
299+
return "", err
300+
}
301+
globalPath := filepath.Join(homeDir, ".databricks", "aitools", "skills")
302+
303+
cwd, err := os.Getwd()
304+
if err != nil {
305+
return "", err
306+
}
307+
projectPath := filepath.Join(cwd, ".databricks", "aitools", "skills")
308+
309+
globalLabel := "Global (" + globalPath + "/)"
310+
projectLabel := "Project (" + projectPath + "/)"
311+
312+
var scope string
313+
err = huh.NewSelect[string]().
314+
Title("Which installation should be uninstalled?").
315+
Options(
316+
huh.NewOption(globalLabel, installer.ScopeGlobal),
317+
huh.NewOption(projectLabel, installer.ScopeProject),
318+
).
319+
Value(&scope).
320+
Run()
321+
if err != nil {
322+
return "", err
323+
}
324+
325+
return scope, nil
326+
}

0 commit comments

Comments
 (0)