@@ -3,6 +3,7 @@ package aitools
33import (
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.
1617var 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.
1926func 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