-
Notifications
You must be signed in to change notification settings - Fork 168
Expand file tree
/
Copy pathscope.go
More file actions
374 lines (326 loc) · 12.3 KB
/
scope.go
File metadata and controls
374 lines (326 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
package aitools
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/charmbracelet/huh"
"github.com/databricks/cli/libs/aitools/installer"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/env"
"github.com/spf13/cobra"
)
// promptScopeSelection is a package-level var so tests can replace it with a mock.
var promptScopeSelection = defaultPromptScopeSelection
// promptUpdateScopeSelection is a package-level var for the update scope prompt (3 options: global/project/both).
var promptUpdateScopeSelection = defaultPromptUpdateScopeSelection
// promptUninstallScopeSelection is a package-level var for the uninstall scope prompt (2 options: global/project).
var promptUninstallScopeSelection = defaultPromptUninstallScopeSelection
// 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
}
const scopeBoth = "both"
// markScopeBoolsDeprecated hides --project and --global from help and emits a
// stderr warning pointing at --scope when they're used. The booleans are kept
// so existing scripts and the experimental backward-compat aliases keep
// working through the next release.
func markScopeBoolsDeprecated(cmd *cobra.Command) {
cmd.Flags().Lookup("project").Deprecated = "use --scope=project"
cmd.Flags().Lookup("project").Hidden = true
cmd.Flags().Lookup("global").Deprecated = "use --scope=global"
cmd.Flags().Lookup("global").Hidden = true
}
// parseScopeFlag translates --scope into the equivalent --project/--global bool pair.
// Returns (projectFlag, globalFlag, nil) unchanged when --scope is empty so the
// deprecated booleans can keep flowing through the existing resolveScope* helpers
// (including update's supported `--project --global` "both scopes" path). Errors
// if --scope is combined with --project or --global. When allowBoth is false,
// --scope=both is rejected up front so install and uninstall don't have to
// special-case it.
//
// Note: install/list/uninstall reject the legacy `--project --global` combination
// at their own RunE / resolveScope layer; update intentionally accepts it as the
// "both scopes" path until those flags are removed.
func parseScopeFlag(scopeFlag string, projectFlag, globalFlag, allowBoth bool) (proj, glob bool, err error) {
if scopeFlag == "" {
return projectFlag, globalFlag, nil
}
if projectFlag || globalFlag {
return false, false, errors.New("cannot use --scope with --project or --global; --project and --global are deprecated aliases for --scope")
}
switch scopeFlag {
case installer.ScopeProject:
return true, false, nil
case installer.ScopeGlobal:
return false, true, nil
case scopeBoth:
if !allowBoth {
return false, false, errors.New("--scope=both is not supported for this command; use 'project' or 'global'")
}
return true, true, nil
default:
if allowBoth {
return false, false, fmt.Errorf("invalid --scope %q: must be one of project, global, both", scopeFlag)
}
return false, false, fmt.Errorf("invalid --scope %q: must be one of project, global", scopeFlag)
}
}
// detectInstalledScopes checks which scopes have a .state.json file present.
func detectInstalledScopes(globalDir, projectDir string) (global, project bool, err error) {
globalState, err := installer.LoadState(globalDir)
if err != nil {
return false, false, err
}
projectState, err := installer.LoadState(projectDir)
if err != nil {
return false, false, err
}
return globalState != nil, projectState != nil, nil
}
// resolveScopeForUpdate resolves scopes for the update command.
// Returns one or more scopes to update. When both flags are set, global always passes through
// (for legacy install detection) and project is checked via state.
func resolveScopeForUpdate(ctx context.Context, projectFlag, globalFlag bool, globalDir, projectDir string) ([]string, error) {
hasGlobal, hasProject, err := detectInstalledScopes(globalDir, projectDir)
if err != nil {
return nil, err
}
if projectFlag && globalFlag {
var scopes []string
if hasGlobal {
scopes = append(scopes, installer.ScopeGlobal)
}
if hasProject {
scopes = append(scopes, installer.ScopeProject)
}
if len(scopes) == 0 {
// Neither installed. Fall through to global for legacy detection.
return []string{installer.ScopeGlobal}, nil
}
return scopes, nil
}
if projectFlag {
return withExplicitScopeCheck(projectDir, installer.ScopeProject, "update", projectDir, hasGlobal, hasProject)
}
if globalFlag {
// Always pass through to the installer layer, which handles legacy installs.
return []string{installer.ScopeGlobal}, nil
}
// No flags: auto-detect.
switch {
case hasGlobal && hasProject:
if !cmdio.IsPromptSupported(ctx) {
return nil, errors.New("skills are installed in both global and project scopes; use --scope=global, --scope=project, or --scope=both to specify which to update")
}
scopes, err := promptUpdateScopeSelection(ctx)
if err != nil {
return nil, err
}
return scopes, nil
case hasGlobal:
return []string{installer.ScopeGlobal}, nil
case hasProject:
return []string{installer.ScopeProject}, nil
default:
// Fall through to global scope so the installer layer can detect
// legacy installs (skills on disk without .state.json) and provide
// appropriate migration guidance.
return []string{installer.ScopeGlobal}, nil
}
}
// resolveScopeForUninstall resolves the scope for the uninstall command.
// Unlike update, uninstall never allows "both" scopes at once.
func resolveScopeForUninstall(ctx context.Context, projectFlag, globalFlag bool, globalDir, projectDir string) (string, error) {
if projectFlag && globalFlag {
return "", errors.New("cannot uninstall both scopes at once; run uninstall separately with --scope=global and --scope=project")
}
hasGlobal, hasProject, err := detectInstalledScopes(globalDir, projectDir)
if err != nil {
return "", err
}
if projectFlag {
scopes, err := withExplicitScopeCheck(projectDir, installer.ScopeProject, "uninstall", projectDir, hasGlobal, hasProject)
if err != nil {
return "", err
}
return scopes[0], nil
}
if globalFlag {
// Always pass through to the installer layer, which handles legacy installs.
return installer.ScopeGlobal, nil
}
// No flags: auto-detect.
switch {
case hasGlobal && hasProject:
if !cmdio.IsPromptSupported(ctx) {
return "", errors.New("skills are installed in both global and project scopes; use --scope=global or --scope=project to specify which to uninstall")
}
scope, err := promptUninstallScopeSelection(ctx)
if err != nil {
return "", err
}
return scope, nil
case hasGlobal:
return installer.ScopeGlobal, nil
case hasProject:
return installer.ScopeProject, nil
default:
// Fall through to global scope so the installer layer can detect
// legacy installs (skills on disk without .state.json) and provide
// appropriate migration guidance.
return installer.ScopeGlobal, nil
}
}
// withExplicitScopeCheck validates that the explicitly requested scope has an installation.
// Returns a helpful error with CWD guidance for project scope and cross-scope hints.
// The verb parameter (e.g. "update", "uninstall") is used in cross-scope hint messages.
func withExplicitScopeCheck(dir, scope, verb, projectDir string, hasGlobal, hasProject bool) ([]string, error) {
state, err := installer.LoadState(dir)
if err != nil {
return nil, err
}
if state == nil {
return nil, scopeNotInstalledError(scope, verb, projectDir, hasGlobal, hasProject)
}
return []string{scope}, nil
}
// scopeNotInstalledError builds a detailed error for when the requested scope has no installation.
// Includes cross-scope hints when the other scope is installed.
// The verb parameter (e.g. "update", "uninstall") is used in cross-scope hint messages.
func scopeNotInstalledError(scope, verb, projectDir string, hasGlobal, hasProject bool) error {
var msg string
if scope == installer.ScopeProject {
expectedPath := filepath.ToSlash(projectDir)
msg = fmt.Sprintf(
"no project-scoped skills found in the current directory.\n\n"+
"Project-scoped skills are detected based on your working directory.\n"+
"Make sure you are in the project root where you originally ran\n"+
"'databricks aitools install --scope=project'.\n\n"+
"Expected location: %s/", expectedPath)
} else {
msg = "no globally-scoped skills installed. Run 'databricks aitools install --scope=global' to install"
}
hint := crossScopeHint(scope, verb, hasGlobal, hasProject)
if hint != "" {
msg += "\n\n" + hint
}
return errors.New(msg)
}
// crossScopeHint returns a hint string if the opposite scope has an installation.
// The verb parameter (e.g. "update", "uninstall") controls the action in the hint message.
func crossScopeHint(requestedScope, verb string, hasGlobal, hasProject bool) string {
if requestedScope == installer.ScopeProject && hasGlobal {
return fmt.Sprintf("Global skills are installed. Run with --scope=global to %s those.", verb)
}
if requestedScope == installer.ScopeGlobal && hasProject {
return fmt.Sprintf("Project-scoped skills are installed. Run with --scope=project to %s those.", verb)
}
return ""
}
func defaultPromptUpdateScopeSelection(ctx context.Context) ([]string, error) {
homeDir, err := env.UserHomeDir(ctx)
if err != nil {
return nil, err
}
globalPath := filepath.Join(homeDir, ".databricks", "aitools", "skills")
cwd, err := os.Getwd()
if err != nil {
return nil, err
}
projectPath := filepath.Join(cwd, ".databricks", "aitools", "skills")
globalLabel := "Global (" + globalPath + "/)"
projectLabel := "Project (" + projectPath + "/)"
bothLabel := "Both global and project"
var scope string
err = huh.NewSelect[string]().
Title("Which installation should be updated?").
Options(
huh.NewOption(globalLabel, installer.ScopeGlobal),
huh.NewOption(projectLabel, installer.ScopeProject),
huh.NewOption(bothLabel, scopeBoth),
).
Value(&scope).
Run()
if err != nil {
return nil, err
}
if scope == scopeBoth {
return []string{installer.ScopeGlobal, installer.ScopeProject}, nil
}
return []string{scope}, nil
}
func defaultPromptUninstallScopeSelection(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 := "Global (" + globalPath + "/)"
projectLabel := "Project (" + projectPath + "/)"
var scope string
err = huh.NewSelect[string]().
Title("Which installation should be uninstalled?").
Options(
huh.NewOption(globalLabel, installer.ScopeGlobal),
huh.NewOption(projectLabel, installer.ScopeProject),
).
Value(&scope).
Run()
if err != nil {
return "", err
}
return scope, nil
}