From 1b58ed136c76482da21f79280fa5dfbc76c67377 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 23 Feb 2026 18:23:06 +0100 Subject: [PATCH 01/13] Add completion install, uninstall, and status subcommands Add `databricks completion install`, `completion uninstall`, and `completion status` commands that auto-detect the user's shell and manage tab completion configuration. Install appends an eval shim (wrapped in BEGIN/END markers) to the appropriate RC file. Uninstall removes it. Status reports whether completions are installed, including detection of Homebrew-based installs for zsh. Supported shells: bash, zsh, fish, powershell (pwsh 7+), powershell5 (Windows PowerShell 5.1). The core logic lives in libs/completion/ for reusability from other commands (e.g. guided setup flows). The cmd/completion/ package provides thin cobra wrappers. Shell script generation subcommands (bash/zsh/fish/powershell) are reimplemented with runtime writer resolution to avoid a Cobra output-capture timing issue. --- acceptance/cmd/completion/out.test.toml | 5 + acceptance/cmd/completion/output.txt | 31 ++++ acceptance/cmd/completion/script | 32 ++++ acceptance/cmd/completion/test.toml | 10 ++ cmd/cmd.go | 2 + cmd/completion/completion.go | 176 +++++++++++++++++++++ cmd/completion/install.go | 49 ++++++ cmd/completion/status.go | 54 +++++++ cmd/completion/uninstall.go | 49 ++++++ libs/completion/install.go | 80 ++++++++++ libs/completion/install_test.go | 158 ++++++++++++++++++ libs/completion/shell.go | 202 ++++++++++++++++++++++++ libs/completion/shell_test.go | 171 ++++++++++++++++++++ libs/completion/status.go | 50 ++++++ libs/completion/status_test.go | 124 +++++++++++++++ libs/completion/uninstall.go | 86 ++++++++++ libs/completion/uninstall_test.go | 140 ++++++++++++++++ 17 files changed, 1419 insertions(+) create mode 100644 acceptance/cmd/completion/out.test.toml create mode 100644 acceptance/cmd/completion/output.txt create mode 100644 acceptance/cmd/completion/script create mode 100644 acceptance/cmd/completion/test.toml create mode 100644 cmd/completion/completion.go create mode 100644 cmd/completion/install.go create mode 100644 cmd/completion/status.go create mode 100644 cmd/completion/uninstall.go create mode 100644 libs/completion/install.go create mode 100644 libs/completion/install_test.go create mode 100644 libs/completion/shell.go create mode 100644 libs/completion/shell_test.go create mode 100644 libs/completion/status.go create mode 100644 libs/completion/status_test.go create mode 100644 libs/completion/uninstall.go create mode 100644 libs/completion/uninstall_test.go diff --git a/acceptance/cmd/completion/out.test.toml b/acceptance/cmd/completion/out.test.toml new file mode 100644 index 0000000000..90061dedb1 --- /dev/null +++ b/acceptance/cmd/completion/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform"] diff --git a/acceptance/cmd/completion/output.txt b/acceptance/cmd/completion/output.txt new file mode 100644 index 0000000000..936b828b28 --- /dev/null +++ b/acceptance/cmd/completion/output.txt @@ -0,0 +1,31 @@ + +>>> [CLI] completion install --shell bash +Databricks CLI completions installed for bash. +Restart your shell or run 'source [HOME]/.bash_profile' to activate. +# BEGIN databricks-cli completion +eval "$(databricks completion bash)" +# END databricks-cli completion + +>>> [CLI] completion status --shell bash +Shell: bash +File: [HOME]/.bash_profile +Status: installed + +>>> [CLI] completion install --shell bash +Databricks CLI completions are already installed for bash in [HOME]/.bash_profile. +# BEGIN databricks-cli completion +eval "$(databricks completion bash)" +# END databricks-cli completion + +>>> [CLI] completion uninstall --shell bash +Databricks CLI completions removed for bash from [HOME]/.bash_profile. + +>>> [CLI] completion status --shell bash +Shell: bash +File: [HOME]/.bash_profile +Status: not installed +# bash completion V2 for databricks -*- shell-script -*- +#compdef databricks +# fish completion for databricks -*- shell-script -*- +# powershell completion for databricks -*- shell-script -*- +# bash completion V2 for databricks -*- shell-script -*- diff --git a/acceptance/cmd/completion/script b/acceptance/cmd/completion/script new file mode 100644 index 0000000000..ab1bf03af7 --- /dev/null +++ b/acceptance/cmd/completion/script @@ -0,0 +1,32 @@ +export HOME=$TMPDIR/fakehome-$$ +mkdir -p $HOME + +# Test install +trace $CLI completion install --shell bash + +# Verify the RC file was created with correct content +cat $HOME/.bash_profile + +# Test status shows installed +trace $CLI completion status --shell bash + +# Test idempotent install +trace $CLI completion install --shell bash + +# Verify still only one block +cat $HOME/.bash_profile + +# Test uninstall +trace $CLI completion uninstall --shell bash + +# Test status after uninstall +trace $CLI completion status --shell bash + +# Test shell subcommands produce output +$CLI completion bash 2>&1 | head -1 +$CLI completion zsh 2>&1 | head -1 +$CLI completion fish 2>&1 | head -1 +$CLI completion powershell 2>&1 | head -1 + +# Test bash --no-descriptions +$CLI completion bash --no-descriptions 2>&1 | head -1 diff --git a/acceptance/cmd/completion/test.toml b/acceptance/cmd/completion/test.toml new file mode 100644 index 0000000000..b661bdb03f --- /dev/null +++ b/acceptance/cmd/completion/test.toml @@ -0,0 +1,10 @@ +Local = true +Cloud = false + +# Completion tests don't involve bundles, skip the engine matrix. +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = ["terraform"] + +[[Repls]] +Old = '/[^\s]*fakehome-\d+' +New = "[HOME]" diff --git a/cmd/cmd.go b/cmd/cmd.go index 6d0fd090c3..014471f763 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -12,6 +12,7 @@ import ( "github.com/databricks/cli/cmd/auth" "github.com/databricks/cli/cmd/bundle" "github.com/databricks/cli/cmd/cache" + "github.com/databricks/cli/cmd/completion" "github.com/databricks/cli/cmd/configure" "github.com/databricks/cli/cmd/experimental" "github.com/databricks/cli/cmd/fs" @@ -94,6 +95,7 @@ func New(ctx context.Context) *cobra.Command { // Add other subcommands. cli.AddCommand(api.New()) cli.AddCommand(auth.New()) + cli.AddCommand(completion.New()) cli.AddCommand(bundle.New()) cli.AddCommand(cache.New()) cli.AddCommand(experimental.New()) diff --git a/cmd/completion/completion.go b/cmd/completion/completion.go new file mode 100644 index 0000000000..fed8069466 --- /dev/null +++ b/cmd/completion/completion.go @@ -0,0 +1,176 @@ +package completion + +import ( + "github.com/databricks/cli/cmd/root" + "github.com/spf13/cobra" +) + +// New returns the "completion" command with shell subcommands and +// install/uninstall/status subcommands. Providing a command named +// "completion" prevents Cobra's InitDefaultCompletionCmd from generating +// its default, so we replicate the shell subcommands here with runtime +// writer resolution to avoid the frozen-writer bug. +func New() *cobra.Command { + cmd := &cobra.Command{ + Use: "completion", + Short: "Generate the autocompletion script for the specified shell", + Long: `Generate the autocompletion script for databricks for the specified shell. +See each sub-command's help for details on how to use the generated script. +`, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: root.ReportUnknownSubcommand, + } + + cmd.AddCommand( + newBashCmd(), + newZshCmd(), + newFishCmd(), + newPowershellCmd(), + newInstallCmd(), + newUninstallCmd(), + newStatusCmd(), + ) + + return cmd +} + +func newBashCmd() *cobra.Command { + var noDesc bool + cmd := &cobra.Command{ + Use: "bash", + Short: "Generate the autocompletion script for bash", + Long: `Generate the autocompletion script for the bash shell. + +This script depends on the 'bash-completion' package. +If it is not installed already, you can install it via your OS's package manager. + +To load completions in your current shell session: + + source <(databricks completion bash) + +To load completions for every new session, execute once: + +#### Linux: + + databricks completion bash > /etc/bash_completion.d/databricks + +#### macOS: + + databricks completion bash > $(brew --prefix)/etc/bash_completion.d/databricks + +You will need to start a new shell for this setup to take effect. +`, + Args: cobra.NoArgs, + DisableFlagsInUseLine: true, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Root().GenBashCompletionV2(cmd.OutOrStdout(), !noDesc) + }, + } + cmd.Flags().BoolVar(&noDesc, "no-descriptions", false, "disable completion descriptions") + return cmd +} + +func newZshCmd() *cobra.Command { + var noDesc bool + cmd := &cobra.Command{ + Use: "zsh", + Short: "Generate the autocompletion script for zsh", + Long: `Generate the autocompletion script for the zsh shell. + +If shell completion is not already enabled in your environment you will need +to enable it. You can execute the following once: + + echo "autoload -U compinit; compinit" >> ~/.zshrc + +To load completions in your current shell session: + + source <(databricks completion zsh) + +To load completions for every new session, execute once: + +#### Linux: + + databricks completion zsh > "${fpath[1]}/_databricks" + +#### macOS: + + databricks completion zsh > $(brew --prefix)/share/zsh/site-functions/_databricks + +You will need to start a new shell for this setup to take effect. +`, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + if noDesc { + return cmd.Root().GenZshCompletionNoDesc(cmd.OutOrStdout()) + } + return cmd.Root().GenZshCompletion(cmd.OutOrStdout()) + }, + } + cmd.Flags().BoolVar(&noDesc, "no-descriptions", false, "disable completion descriptions") + return cmd +} + +func newFishCmd() *cobra.Command { + var noDesc bool + cmd := &cobra.Command{ + Use: "fish", + Short: "Generate the autocompletion script for fish", + Long: `Generate the autocompletion script for the fish shell. + +To load completions in your current shell session: + + databricks completion fish | source + +To load completions for every new session, execute once: + + databricks completion fish > ~/.config/fish/completions/databricks.fish + +You will need to start a new shell for this setup to take effect. +`, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Root().GenFishCompletion(cmd.OutOrStdout(), !noDesc) + }, + } + cmd.Flags().BoolVar(&noDesc, "no-descriptions", false, "disable completion descriptions") + return cmd +} + +func newPowershellCmd() *cobra.Command { + var noDesc bool + cmd := &cobra.Command{ + Use: "powershell", + Short: "Generate the autocompletion script for powershell", + Long: `Generate the autocompletion script for powershell. + +To load completions in your current shell session: + + databricks completion powershell | Out-String | Invoke-Expression + +To load completions for every new session, add the output of the above command +to your powershell profile. +`, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + if noDesc { + return cmd.Root().GenPowerShellCompletion(cmd.OutOrStdout()) + } + return cmd.Root().GenPowerShellCompletionWithDesc(cmd.OutOrStdout()) + }, + } + cmd.Flags().BoolVar(&noDesc, "no-descriptions", false, "disable completion descriptions") + return cmd +} + +// addShellFlag registers the --shell flag and its completion function on cmd. +func addShellFlag(cmd *cobra.Command, target *string) { + cmd.Flags().StringVar(target, "shell", "", "Shell type: bash, zsh, fish, powershell, powershell5") + cmd.RegisterFlagCompletionFunc("shell", func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + return []cobra.Completion{"bash", "zsh", "fish", "powershell", "powershell5"}, cobra.ShellCompDirectiveNoFileComp + }) +} diff --git a/cmd/completion/install.go b/cmd/completion/install.go new file mode 100644 index 0000000000..1bf40a5ff9 --- /dev/null +++ b/cmd/completion/install.go @@ -0,0 +1,49 @@ +package completion + +import ( + "fmt" + "os" + + "github.com/databricks/cli/libs/cmdio" + libcompletion "github.com/databricks/cli/libs/completion" + "github.com/spf13/cobra" +) + +func newInstallCmd() *cobra.Command { + var shellFlag string + cmd := &cobra.Command{ + Use: "install", + Short: "Install shell completions", + Long: "Install Databricks CLI tab completions into your shell configuration file.", + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + shell, err := libcompletion.DetectShell(shellFlag) + if err != nil { + return err + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + filePath, alreadyInstalled, err := libcompletion.Install(shell, home) + if err != nil { + return err + } + + if alreadyInstalled { + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions are already installed for %s in %s.", shell, filePath)) + return nil + } + + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions installed for %s.\nRestart your shell or run 'source %s' to activate.", shell, filePath)) + return nil + }, + } + addShellFlag(cmd, &shellFlag) + return cmd +} diff --git a/cmd/completion/status.go b/cmd/completion/status.go new file mode 100644 index 0000000000..4856a995b3 --- /dev/null +++ b/cmd/completion/status.go @@ -0,0 +1,54 @@ +package completion + +import ( + "fmt" + "os" + + "github.com/databricks/cli/libs/cmdio" + libcompletion "github.com/databricks/cli/libs/completion" + "github.com/spf13/cobra" +) + +func newStatusCmd() *cobra.Command { + var shellFlag string + cmd := &cobra.Command{ + Use: "status", + Short: "Show shell completion status", + Long: "Show whether Databricks CLI tab completions are installed for your shell.", + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + shell, err := libcompletion.DetectShell(shellFlag) + if err != nil { + return err + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + result, err := libcompletion.Status(shell, home) + if err != nil { + return err + } + + statusStr := "not installed" + if result.Installed { + statusStr = "installed" + if result.Method != "" && result.Method != "marker" { + statusStr = fmt.Sprintf("installed (via %s)", result.Method) + } + } + + cmdio.LogString(ctx, "Shell: "+shell.DisplayName()) + cmdio.LogString(ctx, "File: "+result.FilePath) + cmdio.LogString(ctx, "Status: "+statusStr) + return nil + }, + } + addShellFlag(cmd, &shellFlag) + return cmd +} diff --git a/cmd/completion/uninstall.go b/cmd/completion/uninstall.go new file mode 100644 index 0000000000..1855e49d6d --- /dev/null +++ b/cmd/completion/uninstall.go @@ -0,0 +1,49 @@ +package completion + +import ( + "fmt" + "os" + + "github.com/databricks/cli/libs/cmdio" + libcompletion "github.com/databricks/cli/libs/completion" + "github.com/spf13/cobra" +) + +func newUninstallCmd() *cobra.Command { + var shellFlag string + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Uninstall shell completions", + Long: "Remove Databricks CLI tab completions from your shell configuration file.", + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + shell, err := libcompletion.DetectShell(shellFlag) + if err != nil { + return err + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + filePath, wasInstalled, err := libcompletion.Uninstall(shell, home) + if err != nil { + return err + } + + if !wasInstalled { + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions were not installed for %s.", shell)) + return nil + } + + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions removed for %s from %s.", shell, filePath)) + return nil + }, + } + addShellFlag(cmd, &shellFlag) + return cmd +} diff --git a/libs/completion/install.go b/libs/completion/install.go new file mode 100644 index 0000000000..7139a42345 --- /dev/null +++ b/libs/completion/install.go @@ -0,0 +1,80 @@ +package completion + +import ( + "os" + "path/filepath" + "strings" +) + +// Install configures shell completion for the given shell. homeDir is used +// as the base for RC file resolution (typically os.UserHomeDir()). +// Returns the file path modified and whether it was already installed. +func Install(shell Shell, homeDir string) (filePath string, alreadyInstalled bool, err error) { + filePath = TargetFilePath(shell, homeDir) + + if shell == Fish { + return installFish(filePath, shell) + } + return installRC(filePath, shell) +} + +// installFish handles the file-drop model for fish completions. +func installFish(filePath string, shell Shell) (string, bool, error) { + if _, err := os.Stat(filePath); err == nil { + content, err := os.ReadFile(filePath) + if err != nil { + return filePath, false, err + } + if strings.Contains(string(content), BeginMarker) { + return filePath, true, nil + } + } + + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return filePath, false, err + } + + return filePath, false, os.WriteFile(filePath, []byte(ShimContent(shell)), 0o644) +} + +// installRC handles the RC file model for bash, zsh, and powershell. +func installRC(filePath string, shell Shell) (string, bool, error) { + var content []byte + var perm os.FileMode = 0o644 + + if info, err := os.Stat(filePath); err == nil { + perm = info.Mode() + content, err = os.ReadFile(filePath) + if err != nil { + return filePath, false, err + } + if strings.Contains(string(content), BeginMarker) { + return filePath, true, nil + } + } + + // Create parent directory if needed (e.g. for PowerShell profiles). + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return filePath, false, err + } + + // Ensure a leading newline before the block if the file doesn't end with one. + shim := ShimContent(shell) + if len(content) > 0 && content[len(content)-1] != '\n' { + shim = "\n" + shim + } + + f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, perm) + if err != nil { + return filePath, false, err + } + defer f.Close() + + if _, err := f.WriteString(shim); err != nil { + return filePath, false, err + } + + return filePath, false, nil +} diff --git a/libs/completion/install_test.go b/libs/completion/install_test.go new file mode 100644 index 0000000000..65c6cee200 --- /dev/null +++ b/libs/completion/install_test.go @@ -0,0 +1,158 @@ +package completion + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallFreshZsh(t *testing.T) { + home := t.TempDir() + + filePath, alreadyInstalled, err := Install(Zsh, home) + require.NoError(t, err) + assert.False(t, alreadyInstalled) + assert.Equal(t, filepath.Join(home, ".zshrc"), filePath) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), BeginMarker) + assert.Contains(t, string(content), EndMarker) + assert.Contains(t, string(content), `eval "$(databricks completion zsh)"`) +} + +func TestInstallIdempotent(t *testing.T) { + home := t.TempDir() + + _, _, err := Install(Zsh, home) + require.NoError(t, err) + + filePath, alreadyInstalled, err := Install(Zsh, home) + require.NoError(t, err) + assert.True(t, alreadyInstalled) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(string(content), BeginMarker)) +} + +func TestInstallAppendsToExistingFile(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte("# existing config\n"), 0o644)) + + _, _, err := Install(Zsh, home) + require.NoError(t, err) + + content, err := os.ReadFile(rcPath) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(content), "# existing config\n")) + assert.Contains(t, string(content), BeginMarker) +} + +func TestInstallAddsNewlineIfMissing(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte("# no trailing newline"), 0o644)) + + _, _, err := Install(Zsh, home) + require.NoError(t, err) + + content, err := os.ReadFile(rcPath) + require.NoError(t, err) + assert.Contains(t, string(content), "# no trailing newline\n"+BeginMarker) +} + +func TestInstallPreservesPermissions(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte(""), 0o600)) + + _, _, err := Install(Zsh, home) + require.NoError(t, err) + + info, err := os.Stat(rcPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestInstallFish(t *testing.T) { + home := t.TempDir() + + filePath, alreadyInstalled, err := Install(Fish, home) + require.NoError(t, err) + assert.False(t, alreadyInstalled) + assert.Equal(t, filepath.Join(home, ".config", "fish", "completions", "databricks.fish"), filePath) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), "databricks completion fish | source") +} + +func TestInstallFishIdempotent(t *testing.T) { + home := t.TempDir() + + _, _, err := Install(Fish, home) + require.NoError(t, err) + + _, alreadyInstalled, err := Install(Fish, home) + require.NoError(t, err) + assert.True(t, alreadyInstalled) +} + +func TestInstallFishCreatesDirectory(t *testing.T) { + home := t.TempDir() + fishDir := filepath.Join(home, ".config", "fish", "completions") + + _, err := os.Stat(fishDir) + assert.True(t, os.IsNotExist(err)) + + _, _, err = Install(Fish, home) + require.NoError(t, err) + + _, err = os.Stat(fishDir) + assert.NoError(t, err) +} + +func TestInstallPowerShellCreatesDirectory(t *testing.T) { + home := t.TempDir() + + filePath, _, err := Install(PowerShell, home) + require.NoError(t, err) + + _, err = os.Stat(filepath.Dir(filePath)) + assert.NoError(t, err) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), "databricks completion powershell | Out-String | Invoke-Expression") +} + +func TestInstallBashShimContent(t *testing.T) { + home := t.TempDir() + + _, _, err := Install(Bash, home) + require.NoError(t, err) + + filePath := TargetFilePath(Bash, home) + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), `eval "$(databricks completion bash)"`) +} + +func TestInstallEmptyFile(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte(""), 0o644)) + + _, _, err := Install(Zsh, home) + require.NoError(t, err) + + content, err := os.ReadFile(rcPath) + require.NoError(t, err) + assert.Contains(t, string(content), BeginMarker) +} diff --git a/libs/completion/shell.go b/libs/completion/shell.go new file mode 100644 index 0000000000..dfb251cda8 --- /dev/null +++ b/libs/completion/shell.go @@ -0,0 +1,202 @@ +package completion + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// Shell represents a supported shell type. +type Shell string + +const ( + Bash Shell = "bash" + Zsh Shell = "zsh" + Fish Shell = "fish" + PowerShell Shell = "powershell" + PowerShell5 Shell = "powershell5" +) + +const ( + // BeginMarker is the start of the completion block in RC files. + BeginMarker = "# BEGIN databricks-cli completion" + // EndMarker is the end of the completion block in RC files. + EndMarker = "# END databricks-cli completion" +) + +// DisplayName returns a human-readable name for the shell. +func (s Shell) DisplayName() string { + switch s { + case PowerShell: + return "powershell (pwsh 7+)" + case PowerShell5: + return "powershell5 (Windows PowerShell 5.1)" + default: + return string(s) + } +} + +// DetectShell returns the shell to use. If flagValue is non-empty, it validates +// and returns it. Otherwise it auto-detects from the environment. +func DetectShell(flagValue string) (Shell, error) { + if flagValue != "" { + return validateShellFlag(flagValue) + } + + shellEnv := os.Getenv("SHELL") + if shellEnv != "" { + return shellFromPath(shellEnv) + } + + if runtime.GOOS == "windows" { + return detectWindowsShell() + } + + return "", errors.New("could not detect shell: $SHELL is not set. Use --shell to specify your shell") +} + +// validateShellFlag validates a user-provided --shell flag value. +func validateShellFlag(value string) (Shell, error) { + shell := Shell(strings.ToLower(value)) + + switch shell { + case Bash, Zsh, Fish, PowerShell, PowerShell5: + default: + return "", fmt.Errorf("unsupported shell %q: supported shells are bash, zsh, fish, powershell, powershell5", value) + } + + if shell == PowerShell5 && runtime.GOOS != "windows" { + return "", errors.New("--shell powershell5 is only supported on Windows") + } + + if shell == PowerShell && runtime.GOOS == "windows" { + if _, err := exec.LookPath("pwsh"); err != nil { + return "", errors.New("PowerShell 7+ (pwsh) was not found on PATH. Use --shell powershell5 for Windows PowerShell 5.1, or install pwsh from https://aka.ms/powershell") + } + } + + return shell, nil +} + +// shellFromPath extracts the shell name from a path like /bin/bash or /usr/bin/zsh. +func shellFromPath(path string) (Shell, error) { + name := filepath.Base(path) + + switch { + case strings.Contains(name, "bash"): + return Bash, nil + case strings.Contains(name, "zsh"): + return Zsh, nil + case strings.Contains(name, "fish"): + return Fish, nil + case name == "pwsh": + return PowerShell, nil + } + + return "", fmt.Errorf("unsupported shell %q: supported shells are bash, zsh, fish, powershell, powershell5", name) +} + +// detectWindowsShell attempts to find PowerShell on Windows. +func detectWindowsShell() (Shell, error) { + if _, err := exec.LookPath("pwsh"); err == nil { + return PowerShell, nil + } + if _, err := exec.LookPath("powershell.exe"); err == nil { + return PowerShell5, nil + } + return "", errors.New("could not detect shell: no supported shell found on PATH. Use --shell to specify your shell") +} + +// TargetFilePath returns the file that will be modified for the given shell. +func TargetFilePath(shell Shell, homeDir string) string { + switch shell { + case Bash: + return bashProfilePath(homeDir) + case Zsh: + return filepath.Join(homeDir, ".zshrc") + case Fish: + return filepath.Join(homeDir, ".config", "fish", "completions", "databricks.fish") + case PowerShell: + return powershellProfilePath(homeDir) + case PowerShell5: + return filepath.Join(homeDir, "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1") + default: + return "" + } +} + +// bashProfilePath returns the appropriate bash profile path for the current OS. +// On macOS, Terminal.app and iTerm2 launch login shells that read ~/.bash_profile. +// On Linux, interactive shells read ~/.bashrc. +// Falls back to the other if the primary choice doesn't exist. +func bashProfilePath(homeDir string) string { + primary := ".bashrc" + fallback := ".bash_profile" + if runtime.GOOS == "darwin" { + primary = ".bash_profile" + fallback = ".bashrc" + } + + primaryPath := filepath.Join(homeDir, primary) + if _, err := os.Stat(primaryPath); err == nil { + return primaryPath + } + + fallbackPath := filepath.Join(homeDir, fallback) + if _, err := os.Stat(fallbackPath); err == nil { + return fallbackPath + } + + // Neither exists; return the primary (it will be created). + return primaryPath +} + +// powershellProfilePath returns the pwsh 7+ profile path. +// See: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_profiles +func powershellProfilePath(homeDir string) string { + if runtime.GOOS == "windows" { + return filepath.Join(homeDir, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1") + } + return filepath.Join(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1") +} + +// ShimContent returns the completion shim block for the given shell, including markers. +func ShimContent(shell Shell) string { + var evalLine string + switch shell { + case Bash: + evalLine = `eval "$(databricks completion bash)"` + case Zsh: + evalLine = `eval "$(databricks completion zsh)"` + case Fish: + evalLine = "databricks completion fish | source" + case PowerShell, PowerShell5: + evalLine = "databricks completion powershell | Out-String | Invoke-Expression" + } + + return BeginMarker + "\n" + evalLine + "\n" + EndMarker + "\n" +} + +// homebrewCompletionPath returns the path to Homebrew-installed zsh completions +// for databricks, or empty string if not found. +func homebrewCompletionPath() string { + prefix := os.Getenv("HOMEBREW_PREFIX") + if prefix == "" { + // Check common defaults. + // See: https://docs.brew.sh/Installation + for _, p := range []string{"/opt/homebrew", "/usr/local"} { + if _, err := os.Stat(filepath.Join(p, "bin/brew")); err == nil { + prefix = p + break + } + } + } + if prefix == "" { + return "" + } + return filepath.Join(prefix, "share/zsh/site-functions/_databricks") +} diff --git a/libs/completion/shell_test.go b/libs/completion/shell_test.go new file mode 100644 index 0000000000..3f00fa54b4 --- /dev/null +++ b/libs/completion/shell_test.go @@ -0,0 +1,171 @@ +package completion + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectShellFromEnv(t *testing.T) { + tests := []struct { + name string + envShell string + expected Shell + }{ + {"bash from /bin/bash", "/bin/bash", Bash}, + {"bash from /usr/bin/bash", "/usr/bin/bash", Bash}, + {"zsh from /bin/zsh", "/bin/zsh", Zsh}, + {"zsh from /usr/bin/zsh", "/usr/bin/zsh", Zsh}, + {"fish from /usr/bin/fish", "/usr/bin/fish", Fish}, + {"pwsh from path", "/usr/local/bin/pwsh", PowerShell}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("SHELL", tt.envShell) + got, err := DetectShell("") + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestDetectShellUnsupported(t *testing.T) { + t.Setenv("SHELL", "/bin/csh") + _, err := DetectShell("") + assert.ErrorContains(t, err, "unsupported shell") + assert.ErrorContains(t, err, "supported shells are") +} + +func TestDetectShellEmptyOnUnix(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix-only test") + } + t.Setenv("SHELL", "") + _, err := DetectShell("") + assert.ErrorContains(t, err, "$SHELL is not set") +} + +func TestDetectShellFlagOverride(t *testing.T) { + t.Setenv("SHELL", "/bin/zsh") + + got, err := DetectShell("bash") + require.NoError(t, err) + assert.Equal(t, Bash, got) +} + +func TestDetectShellFlagInvalid(t *testing.T) { + _, err := DetectShell("tcsh") + assert.ErrorContains(t, err, "unsupported shell") +} + +func TestDetectShellFlagPowerShell5NonWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("non-windows test") + } + _, err := DetectShell("powershell5") + assert.ErrorContains(t, err, "only supported on Windows") +} + +func TestTargetFilePathBashDarwin(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("darwin-only test") + } + + home := t.TempDir() + // Neither file exists — should return primary (bash_profile on darwin). + got := TargetFilePath(Bash, home) + assert.Equal(t, filepath.Join(home, ".bash_profile"), got) + + // Create .bashrc — should fall back to it since .bash_profile doesn't exist. + require.NoError(t, os.WriteFile(filepath.Join(home, ".bashrc"), nil, 0o644)) + got = TargetFilePath(Bash, home) + assert.Equal(t, filepath.Join(home, ".bashrc"), got) + + // Create .bash_profile — should prefer it. + require.NoError(t, os.WriteFile(filepath.Join(home, ".bash_profile"), nil, 0o644)) + got = TargetFilePath(Bash, home) + assert.Equal(t, filepath.Join(home, ".bash_profile"), got) +} + +func TestTargetFilePathBashLinux(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux-only test") + } + + home := t.TempDir() + got := TargetFilePath(Bash, home) + assert.Equal(t, filepath.Join(home, ".bashrc"), got) +} + +func TestTargetFilePathZsh(t *testing.T) { + home := t.TempDir() + got := TargetFilePath(Zsh, home) + assert.Equal(t, filepath.Join(home, ".zshrc"), got) +} + +func TestTargetFilePathFish(t *testing.T) { + home := t.TempDir() + got := TargetFilePath(Fish, home) + assert.Equal(t, filepath.Join(home, ".config", "fish", "completions", "databricks.fish"), got) +} + +func TestTargetFilePathPowerShellUnix(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix-only test") + } + home := t.TempDir() + got := TargetFilePath(PowerShell, home) + assert.Equal(t, filepath.Join(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"), got) +} + +func TestTargetFilePathPowerShell5(t *testing.T) { + home := t.TempDir() + got := TargetFilePath(PowerShell5, home) + assert.Equal(t, filepath.Join(home, "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1"), got) +} + +func TestShimContent(t *testing.T) { + tests := []struct { + shell Shell + contains string + }{ + {Bash, `eval "$(databricks completion bash)"`}, + {Zsh, `eval "$(databricks completion zsh)"`}, + {Fish, "databricks completion fish | source"}, + {PowerShell, "databricks completion powershell | Out-String | Invoke-Expression"}, + {PowerShell5, "databricks completion powershell | Out-String | Invoke-Expression"}, + } + + for _, tt := range tests { + t.Run(string(tt.shell), func(t *testing.T) { + content := ShimContent(tt.shell) + assert.Contains(t, content, BeginMarker) + assert.Contains(t, content, EndMarker) + assert.Contains(t, content, tt.contains) + }) + } +} + +func TestDisplayName(t *testing.T) { + tests := []struct { + shell Shell + expected string + }{ + {Bash, "bash"}, + {Zsh, "zsh"}, + {Fish, "fish"}, + {PowerShell, "powershell (pwsh 7+)"}, + {PowerShell5, "powershell5 (Windows PowerShell 5.1)"}, + } + + for _, tt := range tests { + t.Run(string(tt.shell), func(t *testing.T) { + assert.Equal(t, tt.expected, tt.shell.DisplayName()) + }) + } +} diff --git a/libs/completion/status.go b/libs/completion/status.go new file mode 100644 index 0000000000..dc7f81b796 --- /dev/null +++ b/libs/completion/status.go @@ -0,0 +1,50 @@ +package completion + +import ( + "os" + "strings" +) + +// StatusResult describes the current completion installation state. +type StatusResult struct { + Installed bool // true if completions are available by any method + Method string // "marker" | "homebrew" | "file" | "" + FilePath string // the file that is/would be modified +} + +// Status checks whether shell completion is currently available. +func Status(shell Shell, homeDir string) (*StatusResult, error) { + filePath := TargetFilePath(shell, homeDir) + result := &StatusResult{FilePath: filePath} + + // Check for our marker block in the target file. + if content, err := os.ReadFile(filePath); err == nil { + if strings.Contains(string(content), BeginMarker) { + result.Installed = true + result.Method = "marker" + return result, nil + } + } + + // For fish: check if the file exists at all (could be installed by a package manager). + if shell == Fish { + if _, err := os.Stat(filePath); err == nil { + result.Installed = true + result.Method = "file" + return result, nil + } + } + + // For zsh: check Homebrew completions. + if shell == Zsh { + if p := homebrewCompletionPath(); p != "" { + if _, err := os.Stat(p); err == nil { + result.Installed = true + result.Method = "homebrew" + return result, nil + } + } + } + + return result, nil +} diff --git a/libs/completion/status_test.go b/libs/completion/status_test.go new file mode 100644 index 0000000000..ca7d867fba --- /dev/null +++ b/libs/completion/status_test.go @@ -0,0 +1,124 @@ +package completion + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatusNotInstalled(t *testing.T) { + home := t.TempDir() + // Override HOMEBREW_PREFIX so the real system Homebrew isn't detected. + t.Setenv("HOMEBREW_PREFIX", t.TempDir()) + + result, err := Status(Zsh, home) + require.NoError(t, err) + assert.False(t, result.Installed) + assert.Empty(t, result.Method) + assert.Equal(t, filepath.Join(home, ".zshrc"), result.FilePath) +} + +func TestStatusInstalledViaMarker(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte(ShimContent(Zsh)), 0o644)) + + result, err := Status(Zsh, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "marker", result.Method) +} + +func TestStatusFishFileExists(t *testing.T) { + home := t.TempDir() + fishPath := filepath.Join(home, ".config", "fish", "completions", "databricks.fish") + require.NoError(t, os.MkdirAll(filepath.Dir(fishPath), 0o755)) + // Write a file without our markers (simulating package manager install). + require.NoError(t, os.WriteFile(fishPath, []byte("# package manager completions\n"), 0o644)) + + result, err := Status(Fish, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "file", result.Method) +} + +func TestStatusFishWithMarker(t *testing.T) { + home := t.TempDir() + fishPath := filepath.Join(home, ".config", "fish", "completions", "databricks.fish") + require.NoError(t, os.MkdirAll(filepath.Dir(fishPath), 0o755)) + require.NoError(t, os.WriteFile(fishPath, []byte(ShimContent(Fish)), 0o644)) + + result, err := Status(Fish, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "marker", result.Method) +} + +func TestStatusHomebrewZsh(t *testing.T) { + home := t.TempDir() + brewPrefix := t.TempDir() + + // Create a fake brew binary so detection works. + require.NoError(t, os.MkdirAll(filepath.Join(brewPrefix, "bin"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(brewPrefix, "bin", "brew"), nil, 0o755)) + + // Create the homebrew completion file. + completionDir := filepath.Join(brewPrefix, "share", "zsh", "site-functions") + require.NoError(t, os.MkdirAll(completionDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(completionDir, "_databricks"), []byte("#compdef databricks\n"), 0o644)) + + t.Setenv("HOMEBREW_PREFIX", brewPrefix) + + result, err := Status(Zsh, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "homebrew", result.Method) +} + +func TestStatusMarkerTakesPrecedenceOverHomebrew(t *testing.T) { + home := t.TempDir() + brewPrefix := t.TempDir() + + // Set up homebrew. + require.NoError(t, os.MkdirAll(filepath.Join(brewPrefix, "bin"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(brewPrefix, "bin", "brew"), nil, 0o755)) + completionDir := filepath.Join(brewPrefix, "share", "zsh", "site-functions") + require.NoError(t, os.MkdirAll(completionDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(completionDir, "_databricks"), nil, 0o644)) + t.Setenv("HOMEBREW_PREFIX", brewPrefix) + + // Also install via marker. + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte(ShimContent(Zsh)), 0o644)) + + result, err := Status(Zsh, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "marker", result.Method) +} + +func TestStatusBash(t *testing.T) { + home := t.TempDir() + filePath := TargetFilePath(Bash, home) + require.NoError(t, os.WriteFile(filePath, []byte(ShimContent(Bash)), 0o644)) + + result, err := Status(Bash, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "marker", result.Method) +} + +func TestStatusPowerShell(t *testing.T) { + home := t.TempDir() + filePath := TargetFilePath(PowerShell, home) + require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0o755)) + require.NoError(t, os.WriteFile(filePath, []byte(ShimContent(PowerShell)), 0o644)) + + result, err := Status(PowerShell, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "marker", result.Method) +} diff --git a/libs/completion/uninstall.go b/libs/completion/uninstall.go new file mode 100644 index 0000000000..df3d089070 --- /dev/null +++ b/libs/completion/uninstall.go @@ -0,0 +1,86 @@ +package completion + +import ( + "fmt" + "os" + "strings" +) + +// Uninstall removes shell completion config. Returns the file path that was +// modified and whether it was actually installed. +func Uninstall(shell Shell, homeDir string) (filePath string, wasInstalled bool, err error) { + filePath = TargetFilePath(shell, homeDir) + + if shell == Fish { + return uninstallFish(filePath) + } + return uninstallRC(filePath) +} + +// uninstallFish handles the file-drop model: remove the file if it exists. +func uninstallFish(filePath string) (string, bool, error) { + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return filePath, false, nil + } + + if err := os.Remove(filePath); err != nil { + return filePath, false, err + } + return filePath, true, nil +} + +// uninstallRC handles the RC file model: find and remove the marker block. +func uninstallRC(filePath string) (string, bool, error) { + info, err := os.Stat(filePath) + if os.IsNotExist(err) { + return filePath, false, nil + } + if err != nil { + return filePath, false, err + } + + content, err := os.ReadFile(filePath) + if err != nil { + return filePath, false, err + } + + text := string(content) + beginIdx := strings.Index(text, BeginMarker) + if beginIdx == -1 { + return filePath, false, nil + } + + // Find the line number of BEGIN for error reporting. + beginLine := strings.Count(text[:beginIdx], "\n") + 1 + + // Look for END marker after BEGIN. + afterBegin := text[beginIdx:] + endIdx := strings.Index(afterBegin, EndMarker) + if endIdx == -1 { + return filePath, false, fmt.Errorf( + "found corrupted completion block in %s: missing end marker. Please remove the block starting at line %d manually", + filePath, beginLine, + ) + } + + // Calculate absolute end position (after the END marker line including newline). + blockEnd := beginIdx + endIdx + len(EndMarker) + if blockEnd < len(text) && text[blockEnd] == '\n' { + blockEnd++ + } + + // Include a leading newline if the block starts after one. + blockStart := beginIdx + if blockStart > 0 && text[blockStart-1] == '\n' { + blockStart-- + } + + result := text[:blockStart] + text[blockEnd:] + + // Collapse double blank lines left by removal. + for strings.Contains(result, "\n\n\n") { + result = strings.ReplaceAll(result, "\n\n\n", "\n\n") + } + + return filePath, true, os.WriteFile(filePath, []byte(result), info.Mode()) +} diff --git a/libs/completion/uninstall_test.go b/libs/completion/uninstall_test.go new file mode 100644 index 0000000000..ef1f4582ca --- /dev/null +++ b/libs/completion/uninstall_test.go @@ -0,0 +1,140 @@ +package completion + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUninstallRemovesBlock(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + content := "# before\n" + ShimContent(Zsh) + "# after\n" + require.NoError(t, os.WriteFile(rcPath, []byte(content), 0o644)) + + filePath, wasInstalled, err := Uninstall(Zsh, home) + require.NoError(t, err) + assert.True(t, wasInstalled) + assert.Equal(t, rcPath, filePath) + + result, err := os.ReadFile(rcPath) + require.NoError(t, err) + assert.NotContains(t, string(result), BeginMarker) + assert.NotContains(t, string(result), EndMarker) + assert.Contains(t, string(result), "# before") + assert.Contains(t, string(result), "# after") +} + +func TestUninstallNotInstalled(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte("# no completion here\n"), 0o644)) + + _, wasInstalled, err := Uninstall(Zsh, home) + require.NoError(t, err) + assert.False(t, wasInstalled) +} + +func TestUninstallFileDoesNotExist(t *testing.T) { + home := t.TempDir() + + _, wasInstalled, err := Uninstall(Zsh, home) + require.NoError(t, err) + assert.False(t, wasInstalled) +} + +func TestUninstallCorruptedMissingEnd(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + content := "# before\n" + BeginMarker + "\neval something\n" + require.NoError(t, os.WriteFile(rcPath, []byte(content), 0o644)) + + _, _, err := Uninstall(Zsh, home) + require.Error(t, err) + assert.ErrorContains(t, err, "corrupted completion block") + assert.ErrorContains(t, err, "missing end marker") + assert.ErrorContains(t, err, "line 2") + + // Verify file is unchanged. + result, readErr := os.ReadFile(rcPath) + require.NoError(t, readErr) + assert.Equal(t, content, string(result)) +} + +func TestUninstallCollapsesDoubleBlankLines(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + content := "# before\n\n" + ShimContent(Zsh) + "\n# after\n" + require.NoError(t, os.WriteFile(rcPath, []byte(content), 0o644)) + + _, _, err := Uninstall(Zsh, home) + require.NoError(t, err) + + result, err := os.ReadFile(rcPath) + require.NoError(t, err) + assert.NotContains(t, string(result), "\n\n\n") +} + +func TestUninstallPreservesPermissions(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte(ShimContent(Zsh)), 0o600)) + + _, _, err := Uninstall(Zsh, home) + require.NoError(t, err) + + info, err := os.Stat(rcPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestUninstallFish(t *testing.T) { + home := t.TempDir() + fishPath := filepath.Join(home, ".config", "fish", "completions", "databricks.fish") + require.NoError(t, os.MkdirAll(filepath.Dir(fishPath), 0o755)) + require.NoError(t, os.WriteFile(fishPath, []byte("# fish completions\n"), 0o644)) + + filePath, wasInstalled, err := Uninstall(Fish, home) + require.NoError(t, err) + assert.True(t, wasInstalled) + assert.Equal(t, fishPath, filePath) + + _, err = os.Stat(fishPath) + assert.True(t, os.IsNotExist(err)) +} + +func TestUninstallFishNotPresent(t *testing.T) { + home := t.TempDir() + + _, wasInstalled, err := Uninstall(Fish, home) + require.NoError(t, err) + assert.False(t, wasInstalled) +} + +func TestInstallThenUninstallRoundTrip(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + original := "# my zsh config\nexport FOO=bar\n" + require.NoError(t, os.WriteFile(rcPath, []byte(original), 0o644)) + + _, _, err := Install(Zsh, home) + require.NoError(t, err) + + content, err := os.ReadFile(rcPath) + require.NoError(t, err) + assert.Contains(t, string(content), BeginMarker) + + _, _, err = Uninstall(Zsh, home) + require.NoError(t, err) + + result, err := os.ReadFile(rcPath) + require.NoError(t, err) + // Original content should be preserved. + assert.True(t, strings.HasPrefix(string(result), "# my zsh config\n")) + assert.Contains(t, string(result), "export FOO=bar") + assert.NotContains(t, string(result), BeginMarker) +} From 489a10a4c3bde9b9accdff6d28973d7c98388560 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 23 Feb 2026 18:42:34 +0100 Subject: [PATCH 02/13] Fix uninstall newline corruption, CI failure, and PowerShell message - Fix uninstall removing the trailing newline of the line before the marker block, which could join adjacent lines - Fix acceptance test using unbound $TMPDIR on Linux CI; use mktemp -d - Use zsh in acceptance test to avoid OS-dependent bash RC file path - Suppress Homebrew detection in test with HOMEBREW_PREFIX=/nonexistent - Fix install message suggesting 'source' for PowerShell shells - Tighten uninstall tests to assert exact file content after removal - Remove unused supportedShells variable (lint) --- acceptance/cmd/completion/output.txt | 30 ++++++++++++++-------------- acceptance/cmd/completion/script | 23 +++++++++++---------- acceptance/cmd/completion/test.toml | 4 ---- cmd/completion/install.go | 9 ++++++++- libs/completion/uninstall.go | 8 +------- libs/completion/uninstall_test.go | 12 +++-------- 6 files changed, 40 insertions(+), 46 deletions(-) diff --git a/acceptance/cmd/completion/output.txt b/acceptance/cmd/completion/output.txt index 936b828b28..1cf97959b0 100644 --- a/acceptance/cmd/completion/output.txt +++ b/acceptance/cmd/completion/output.txt @@ -1,28 +1,28 @@ ->>> [CLI] completion install --shell bash -Databricks CLI completions installed for bash. -Restart your shell or run 'source [HOME]/.bash_profile' to activate. +>>> [CLI] completion install --shell zsh +Databricks CLI completions installed for zsh. +Restart your shell or run 'source [HOME]/.zshrc' to activate. # BEGIN databricks-cli completion -eval "$(databricks completion bash)" +eval "$(databricks completion zsh)" # END databricks-cli completion ->>> [CLI] completion status --shell bash -Shell: bash -File: [HOME]/.bash_profile +>>> [CLI] completion status --shell zsh +Shell: zsh +File: [HOME]/.zshrc Status: installed ->>> [CLI] completion install --shell bash -Databricks CLI completions are already installed for bash in [HOME]/.bash_profile. +>>> [CLI] completion install --shell zsh +Databricks CLI completions are already installed for zsh in [HOME]/.zshrc. # BEGIN databricks-cli completion -eval "$(databricks completion bash)" +eval "$(databricks completion zsh)" # END databricks-cli completion ->>> [CLI] completion uninstall --shell bash -Databricks CLI completions removed for bash from [HOME]/.bash_profile. +>>> [CLI] completion uninstall --shell zsh +Databricks CLI completions removed for zsh from [HOME]/.zshrc. ->>> [CLI] completion status --shell bash -Shell: bash -File: [HOME]/.bash_profile +>>> [CLI] completion status --shell zsh +Shell: zsh +File: [HOME]/.zshrc Status: not installed # bash completion V2 for databricks -*- shell-script -*- #compdef databricks diff --git a/acceptance/cmd/completion/script b/acceptance/cmd/completion/script index ab1bf03af7..dd5369711b 100644 --- a/acceptance/cmd/completion/script +++ b/acceptance/cmd/completion/script @@ -1,26 +1,29 @@ -export HOME=$TMPDIR/fakehome-$$ -mkdir -p $HOME +export HOME=$(mktemp -d) +add_repl.py "$HOME" HOME -# Test install -trace $CLI completion install --shell bash +# Prevent Homebrew detection from affecting status output. +export HOMEBREW_PREFIX=/nonexistent + +# Test install (use zsh to avoid OS-dependent bash RC file path) +trace $CLI completion install --shell zsh # Verify the RC file was created with correct content -cat $HOME/.bash_profile +cat $HOME/.zshrc # Test status shows installed -trace $CLI completion status --shell bash +trace $CLI completion status --shell zsh # Test idempotent install -trace $CLI completion install --shell bash +trace $CLI completion install --shell zsh # Verify still only one block -cat $HOME/.bash_profile +cat $HOME/.zshrc # Test uninstall -trace $CLI completion uninstall --shell bash +trace $CLI completion uninstall --shell zsh # Test status after uninstall -trace $CLI completion status --shell bash +trace $CLI completion status --shell zsh # Test shell subcommands produce output $CLI completion bash 2>&1 | head -1 diff --git a/acceptance/cmd/completion/test.toml b/acceptance/cmd/completion/test.toml index b661bdb03f..0e58300617 100644 --- a/acceptance/cmd/completion/test.toml +++ b/acceptance/cmd/completion/test.toml @@ -4,7 +4,3 @@ Cloud = false # Completion tests don't involve bundles, skip the engine matrix. [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["terraform"] - -[[Repls]] -Old = '/[^\s]*fakehome-\d+' -New = "[HOME]" diff --git a/cmd/completion/install.go b/cmd/completion/install.go index 1bf40a5ff9..d24c91d2be 100644 --- a/cmd/completion/install.go +++ b/cmd/completion/install.go @@ -40,7 +40,14 @@ func newInstallCmd() *cobra.Command { return nil } - cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions installed for %s.\nRestart your shell or run 'source %s' to activate.", shell, filePath)) + msg := fmt.Sprintf("Databricks CLI completions installed for %s.\n", shell) + switch shell { + case libcompletion.PowerShell, libcompletion.PowerShell5: + msg += "Restart your shell to activate." + default: + msg += fmt.Sprintf("Restart your shell or run 'source %s' to activate.", filePath) + } + cmdio.LogString(ctx, msg) return nil }, } diff --git a/libs/completion/uninstall.go b/libs/completion/uninstall.go index df3d089070..b6f8e36f8e 100644 --- a/libs/completion/uninstall.go +++ b/libs/completion/uninstall.go @@ -69,13 +69,7 @@ func uninstallRC(filePath string) (string, bool, error) { blockEnd++ } - // Include a leading newline if the block starts after one. - blockStart := beginIdx - if blockStart > 0 && text[blockStart-1] == '\n' { - blockStart-- - } - - result := text[:blockStart] + text[blockEnd:] + result := text[:beginIdx] + text[blockEnd:] // Collapse double blank lines left by removal. for strings.Contains(result, "\n\n\n") { diff --git a/libs/completion/uninstall_test.go b/libs/completion/uninstall_test.go index ef1f4582ca..4684c568bd 100644 --- a/libs/completion/uninstall_test.go +++ b/libs/completion/uninstall_test.go @@ -3,7 +3,6 @@ package completion import ( "os" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -23,10 +22,8 @@ func TestUninstallRemovesBlock(t *testing.T) { result, err := os.ReadFile(rcPath) require.NoError(t, err) - assert.NotContains(t, string(result), BeginMarker) - assert.NotContains(t, string(result), EndMarker) - assert.Contains(t, string(result), "# before") - assert.Contains(t, string(result), "# after") + // Assert exact content to verify line boundaries are preserved. + assert.Equal(t, "# before\n# after\n", string(result)) } func TestUninstallNotInstalled(t *testing.T) { @@ -133,8 +130,5 @@ func TestInstallThenUninstallRoundTrip(t *testing.T) { result, err := os.ReadFile(rcPath) require.NoError(t, err) - // Original content should be preserved. - assert.True(t, strings.HasPrefix(string(result), "# my zsh config\n")) - assert.Contains(t, string(result), "export FOO=bar") - assert.NotContains(t, string(result), BeginMarker) + assert.Equal(t, original, string(result)) } From 2f4b82fd74c061ec15fd3319b749e397aeb01b45 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 23 Feb 2026 18:54:14 +0100 Subject: [PATCH 03/13] Fix permission tests on Windows Windows does not honor Unix permission bits, so the 0o600 written mode reads back as 0o666. Guard the assertion with runtime.GOOS and fall back to a write-ability check on Windows. --- libs/completion/install_test.go | 10 +++++++++- libs/completion/uninstall_test.go | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/libs/completion/install_test.go b/libs/completion/install_test.go index 65c6cee200..778aa037a6 100644 --- a/libs/completion/install_test.go +++ b/libs/completion/install_test.go @@ -3,6 +3,7 @@ package completion import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -77,7 +78,14 @@ func TestInstallPreservesPermissions(t *testing.T) { info, err := os.Stat(rcPath) require.NoError(t, err) - assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + if runtime.GOOS != "windows" { + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + } else { + // Windows has different permission semantics; verify file remains writable. + err = os.WriteFile(rcPath, []byte("# writable"), 0o600) + assert.NoError(t, err) + } } func TestInstallFish(t *testing.T) { diff --git a/libs/completion/uninstall_test.go b/libs/completion/uninstall_test.go index 4684c568bd..a0ff53f784 100644 --- a/libs/completion/uninstall_test.go +++ b/libs/completion/uninstall_test.go @@ -3,6 +3,7 @@ package completion import ( "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -86,7 +87,14 @@ func TestUninstallPreservesPermissions(t *testing.T) { info, err := os.Stat(rcPath) require.NoError(t, err) - assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + if runtime.GOOS != "windows" { + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + } else { + // Windows has different permission semantics; verify file remains writable. + err = os.WriteFile(rcPath, []byte("# writable"), 0o600) + assert.NoError(t, err) + } } func TestUninstallFish(t *testing.T) { From e852ba08953024966174ef75913df100b2d75326 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 23 Feb 2026 19:01:07 +0100 Subject: [PATCH 04/13] Fix Windows shell detection fallback and fish uninstall ownership Two correctness fixes: - DetectShell: if $SHELL is set but unrecognized on Windows (e.g. powershell.exe), fall through to detectWindowsShell() instead of returning "unsupported shell". On non-Windows the error is preserved. - uninstallFish: only delete the completions file if it contains our BeginMarker. Foreign files (e.g. installed by a package manager) are left untouched and wasInstalled=false is returned. Tests: add TestDetectShellPowershellExeNonWindows, update TestUninstallFish to use CLI-managed content, add TestUninstallFishForeignFile. --- libs/completion/shell.go | 10 +++++++++- libs/completion/shell_test.go | 10 ++++++++++ libs/completion/uninstall.go | 14 ++++++++++++-- libs/completion/uninstall_test.go | 19 ++++++++++++++++++- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/libs/completion/shell.go b/libs/completion/shell.go index dfb251cda8..b6a7e7de50 100644 --- a/libs/completion/shell.go +++ b/libs/completion/shell.go @@ -49,7 +49,15 @@ func DetectShell(flagValue string) (Shell, error) { shellEnv := os.Getenv("SHELL") if shellEnv != "" { - return shellFromPath(shellEnv) + shell, err := shellFromPath(shellEnv) + if err == nil { + return shell, nil + } + // On Windows, $SHELL may point to powershell.exe which shellFromPath + // doesn't recognize. Fall through to Windows path-based detection. + if runtime.GOOS != "windows" { + return shell, err + } } if runtime.GOOS == "windows" { diff --git a/libs/completion/shell_test.go b/libs/completion/shell_test.go index 3f00fa54b4..607403de31 100644 --- a/libs/completion/shell_test.go +++ b/libs/completion/shell_test.go @@ -41,6 +41,16 @@ func TestDetectShellUnsupported(t *testing.T) { assert.ErrorContains(t, err, "supported shells are") } +func TestDetectShellPowershellExeNonWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("non-windows test") + } + // On non-Windows, powershell.exe in $SHELL is unrecognized and should error. + t.Setenv("SHELL", "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") + _, err := DetectShell("") + assert.ErrorContains(t, err, "unsupported shell") +} + func TestDetectShellEmptyOnUnix(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("unix-only test") diff --git a/libs/completion/uninstall.go b/libs/completion/uninstall.go index b6f8e36f8e..47a9ab3a5e 100644 --- a/libs/completion/uninstall.go +++ b/libs/completion/uninstall.go @@ -17,9 +17,19 @@ func Uninstall(shell Shell, homeDir string) (filePath string, wasInstalled bool, return uninstallRC(filePath) } -// uninstallFish handles the file-drop model: remove the file if it exists. +// uninstallFish handles the file-drop model: remove the file only if it +// contains our marker. This avoids deleting completions installed by a package +// manager or created by the user. func uninstallFish(filePath string) (string, bool, error) { - if _, err := os.Stat(filePath); os.IsNotExist(err) { + content, err := os.ReadFile(filePath) + if os.IsNotExist(err) { + return filePath, false, nil + } + if err != nil { + return filePath, false, err + } + + if !strings.Contains(string(content), BeginMarker) { return filePath, false, nil } diff --git a/libs/completion/uninstall_test.go b/libs/completion/uninstall_test.go index a0ff53f784..9c9032f575 100644 --- a/libs/completion/uninstall_test.go +++ b/libs/completion/uninstall_test.go @@ -101,7 +101,8 @@ func TestUninstallFish(t *testing.T) { home := t.TempDir() fishPath := filepath.Join(home, ".config", "fish", "completions", "databricks.fish") require.NoError(t, os.MkdirAll(filepath.Dir(fishPath), 0o755)) - require.NoError(t, os.WriteFile(fishPath, []byte("# fish completions\n"), 0o644)) + // Write content that includes our marker (simulating a CLI-managed file). + require.NoError(t, os.WriteFile(fishPath, []byte(ShimContent(Fish)), 0o644)) filePath, wasInstalled, err := Uninstall(Fish, home) require.NoError(t, err) @@ -112,6 +113,22 @@ func TestUninstallFish(t *testing.T) { assert.True(t, os.IsNotExist(err)) } +func TestUninstallFishForeignFile(t *testing.T) { + home := t.TempDir() + fishPath := filepath.Join(home, ".config", "fish", "completions", "databricks.fish") + require.NoError(t, os.MkdirAll(filepath.Dir(fishPath), 0o755)) + // Write content without our marker (e.g. installed by a package manager). + require.NoError(t, os.WriteFile(fishPath, []byte("# fish completions from homebrew\n"), 0o644)) + + _, wasInstalled, err := Uninstall(Fish, home) + require.NoError(t, err) + assert.False(t, wasInstalled) + + // File must be preserved. + _, err = os.Stat(fishPath) + assert.NoError(t, err) +} + func TestUninstallFishNotPresent(t *testing.T) { home := t.TempDir() From 6f62c9830806602afc8a1bd41b56b8576febf30a Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 23 Feb 2026 19:26:57 +0100 Subject: [PATCH 05/13] Fix shell detection and fish completion ownership handling. Handle PowerShell-style SHELL values without masking unsupported shells, and avoid overwriting externally managed fish completion files while improving uninstall messaging. Co-authored-by: Cursor --- cmd/completion/uninstall.go | 10 ++++++++++ libs/completion/install.go | 13 ++++++++----- libs/completion/install_test.go | 18 ++++++++++++++++++ libs/completion/shell.go | 18 +++++++----------- libs/completion/shell_test.go | 11 +++++++++++ 5 files changed, 54 insertions(+), 16 deletions(-) diff --git a/cmd/completion/uninstall.go b/cmd/completion/uninstall.go index 1855e49d6d..343faead75 100644 --- a/cmd/completion/uninstall.go +++ b/cmd/completion/uninstall.go @@ -36,6 +36,16 @@ func newUninstallCmd() *cobra.Command { } if !wasInstalled { + result, statusErr := libcompletion.Status(shell, home) + if statusErr == nil && result.Installed && result.Method != "" && result.Method != "marker" { + cmdio.LogString(ctx, fmt.Sprintf( + "Databricks CLI completions for %s appear to be installed via %s in %s. Nothing to uninstall.", + shell, + result.Method, + result.FilePath, + )) + return nil + } cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions were not installed for %s.", shell)) return nil } diff --git a/libs/completion/install.go b/libs/completion/install.go index 7139a42345..d5a01078cf 100644 --- a/libs/completion/install.go +++ b/libs/completion/install.go @@ -20,14 +20,17 @@ func Install(shell Shell, homeDir string) (filePath string, alreadyInstalled boo // installFish handles the file-drop model for fish completions. func installFish(filePath string, shell Shell) (string, bool, error) { - if _, err := os.Stat(filePath); err == nil { - content, err := os.ReadFile(filePath) - if err != nil { - return filePath, false, err - } + content, err := os.ReadFile(filePath) + if err == nil { + // Preserve existing files we don't own (e.g. package manager installs). + // If our marker is present, this is also already installed. if strings.Contains(string(content), BeginMarker) { return filePath, true, nil } + return filePath, true, nil + } + if !os.IsNotExist(err) { + return filePath, false, err } dir := filepath.Dir(filePath) diff --git a/libs/completion/install_test.go b/libs/completion/install_test.go index 778aa037a6..f3c044d149 100644 --- a/libs/completion/install_test.go +++ b/libs/completion/install_test.go @@ -101,6 +101,24 @@ func TestInstallFish(t *testing.T) { assert.Contains(t, string(content), "databricks completion fish | source") } +func TestInstallFishForeignFilePreserved(t *testing.T) { + home := t.TempDir() + filePath := filepath.Join(home, ".config", "fish", "completions", "databricks.fish") + require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0o755)) + + original := "# fish completion from package manager\n" + require.NoError(t, os.WriteFile(filePath, []byte(original), 0o644)) + + gotPath, alreadyInstalled, err := Install(Fish, home) + require.NoError(t, err) + assert.True(t, alreadyInstalled) + assert.Equal(t, filePath, gotPath) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, original, string(content)) +} + func TestInstallFishIdempotent(t *testing.T) { home := t.TempDir() diff --git a/libs/completion/shell.go b/libs/completion/shell.go index b6a7e7de50..b7336e45f2 100644 --- a/libs/completion/shell.go +++ b/libs/completion/shell.go @@ -49,15 +49,7 @@ func DetectShell(flagValue string) (Shell, error) { shellEnv := os.Getenv("SHELL") if shellEnv != "" { - shell, err := shellFromPath(shellEnv) - if err == nil { - return shell, nil - } - // On Windows, $SHELL may point to powershell.exe which shellFromPath - // doesn't recognize. Fall through to Windows path-based detection. - if runtime.GOOS != "windows" { - return shell, err - } + return shellFromPath(shellEnv) } if runtime.GOOS == "windows" { @@ -92,7 +84,7 @@ func validateShellFlag(value string) (Shell, error) { // shellFromPath extracts the shell name from a path like /bin/bash or /usr/bin/zsh. func shellFromPath(path string) (Shell, error) { - name := filepath.Base(path) + name := strings.ToLower(filepath.Base(path)) switch { case strings.Contains(name, "bash"): @@ -101,8 +93,12 @@ func shellFromPath(path string) (Shell, error) { return Zsh, nil case strings.Contains(name, "fish"): return Fish, nil - case name == "pwsh": + case name == "pwsh", name == "pwsh.exe": return PowerShell, nil + case name == "powershell", name == "powershell.exe": + if runtime.GOOS == "windows" { + return PowerShell5, nil + } } return "", fmt.Errorf("unsupported shell %q: supported shells are bash, zsh, fish, powershell, powershell5", name) diff --git a/libs/completion/shell_test.go b/libs/completion/shell_test.go index 607403de31..0319d24db2 100644 --- a/libs/completion/shell_test.go +++ b/libs/completion/shell_test.go @@ -22,6 +22,7 @@ func TestDetectShellFromEnv(t *testing.T) { {"zsh from /usr/bin/zsh", "/usr/bin/zsh", Zsh}, {"fish from /usr/bin/fish", "/usr/bin/fish", Fish}, {"pwsh from path", "/usr/local/bin/pwsh", PowerShell}, + {"pwsh.exe from path", "/usr/local/bin/pwsh.exe", PowerShell}, } for _, tt := range tests { @@ -51,6 +52,16 @@ func TestDetectShellPowershellExeNonWindows(t *testing.T) { assert.ErrorContains(t, err, "unsupported shell") } +func TestDetectShellPowershellExeWindows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("windows-only test") + } + t.Setenv("SHELL", `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`) + got, err := DetectShell("") + require.NoError(t, err) + assert.Equal(t, PowerShell5, got) +} + func TestDetectShellEmptyOnUnix(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("unix-only test") From 0ba641758bb3e131c8c4f777cf1c3fcc45c9827a Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 23 Feb 2026 22:55:43 +0100 Subject: [PATCH 06/13] Fix completion acceptance behavior across Windows and Unix. Use the shared sethome helper in acceptance tests and normalize displayed file paths so output remains stable across platforms and avoids HOME/UserHomeDir mismatches. Co-authored-by: Cursor --- acceptance/cmd/completion/output.txt | 16 +++++----------- acceptance/cmd/completion/script | 10 +++------- acceptance/cmd/completion/test.toml | 4 ++++ cmd/completion/install.go | 6 ++++-- cmd/completion/status.go | 3 ++- cmd/completion/uninstall.go | 6 ++++-- 6 files changed, 22 insertions(+), 23 deletions(-) diff --git a/acceptance/cmd/completion/output.txt b/acceptance/cmd/completion/output.txt index 1cf97959b0..34d35729f8 100644 --- a/acceptance/cmd/completion/output.txt +++ b/acceptance/cmd/completion/output.txt @@ -1,28 +1,22 @@ >>> [CLI] completion install --shell zsh Databricks CLI completions installed for zsh. -Restart your shell or run 'source [HOME]/.zshrc' to activate. -# BEGIN databricks-cli completion -eval "$(databricks completion zsh)" -# END databricks-cli completion +Restart your shell or run 'source home/.zshrc' to activate. >>> [CLI] completion status --shell zsh Shell: zsh -File: [HOME]/.zshrc +File: home/.zshrc Status: installed >>> [CLI] completion install --shell zsh -Databricks CLI completions are already installed for zsh in [HOME]/.zshrc. -# BEGIN databricks-cli completion -eval "$(databricks completion zsh)" -# END databricks-cli completion +Databricks CLI completions are already installed for zsh in home/.zshrc. >>> [CLI] completion uninstall --shell zsh -Databricks CLI completions removed for zsh from [HOME]/.zshrc. +Databricks CLI completions removed for zsh from home/.zshrc. >>> [CLI] completion status --shell zsh Shell: zsh -File: [HOME]/.zshrc +File: home/.zshrc Status: not installed # bash completion V2 for databricks -*- shell-script -*- #compdef databricks diff --git a/acceptance/cmd/completion/script b/acceptance/cmd/completion/script index dd5369711b..e46f4e261c 100644 --- a/acceptance/cmd/completion/script +++ b/acceptance/cmd/completion/script @@ -1,4 +1,6 @@ -export HOME=$(mktemp -d) +sethome "./home" + +# Track the home path for stable output across platforms. add_repl.py "$HOME" HOME # Prevent Homebrew detection from affecting status output. @@ -7,18 +9,12 @@ export HOMEBREW_PREFIX=/nonexistent # Test install (use zsh to avoid OS-dependent bash RC file path) trace $CLI completion install --shell zsh -# Verify the RC file was created with correct content -cat $HOME/.zshrc - # Test status shows installed trace $CLI completion status --shell zsh # Test idempotent install trace $CLI completion install --shell zsh -# Verify still only one block -cat $HOME/.zshrc - # Test uninstall trace $CLI completion uninstall --shell zsh diff --git a/acceptance/cmd/completion/test.toml b/acceptance/cmd/completion/test.toml index 0e58300617..e8c2fdf6df 100644 --- a/acceptance/cmd/completion/test.toml +++ b/acceptance/cmd/completion/test.toml @@ -1,3 +1,7 @@ +Ignore = [ + "home", +] + Local = true Cloud = false diff --git a/cmd/completion/install.go b/cmd/completion/install.go index d24c91d2be..f742748f2e 100644 --- a/cmd/completion/install.go +++ b/cmd/completion/install.go @@ -3,6 +3,7 @@ package completion import ( "fmt" "os" + "path/filepath" "github.com/databricks/cli/libs/cmdio" libcompletion "github.com/databricks/cli/libs/completion" @@ -34,9 +35,10 @@ func newInstallCmd() *cobra.Command { if err != nil { return err } + displayPath := filepath.ToSlash(filePath) if alreadyInstalled { - cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions are already installed for %s in %s.", shell, filePath)) + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions are already installed for %s in %s.", shell, displayPath)) return nil } @@ -45,7 +47,7 @@ func newInstallCmd() *cobra.Command { case libcompletion.PowerShell, libcompletion.PowerShell5: msg += "Restart your shell to activate." default: - msg += fmt.Sprintf("Restart your shell or run 'source %s' to activate.", filePath) + msg += fmt.Sprintf("Restart your shell or run 'source %s' to activate.", displayPath) } cmdio.LogString(ctx, msg) return nil diff --git a/cmd/completion/status.go b/cmd/completion/status.go index 4856a995b3..24af24628d 100644 --- a/cmd/completion/status.go +++ b/cmd/completion/status.go @@ -3,6 +3,7 @@ package completion import ( "fmt" "os" + "path/filepath" "github.com/databricks/cli/libs/cmdio" libcompletion "github.com/databricks/cli/libs/completion" @@ -44,7 +45,7 @@ func newStatusCmd() *cobra.Command { } cmdio.LogString(ctx, "Shell: "+shell.DisplayName()) - cmdio.LogString(ctx, "File: "+result.FilePath) + cmdio.LogString(ctx, "File: "+filepath.ToSlash(result.FilePath)) cmdio.LogString(ctx, "Status: "+statusStr) return nil }, diff --git a/cmd/completion/uninstall.go b/cmd/completion/uninstall.go index 343faead75..0a79facaea 100644 --- a/cmd/completion/uninstall.go +++ b/cmd/completion/uninstall.go @@ -3,6 +3,7 @@ package completion import ( "fmt" "os" + "path/filepath" "github.com/databricks/cli/libs/cmdio" libcompletion "github.com/databricks/cli/libs/completion" @@ -34,6 +35,7 @@ func newUninstallCmd() *cobra.Command { if err != nil { return err } + displayPath := filepath.ToSlash(filePath) if !wasInstalled { result, statusErr := libcompletion.Status(shell, home) @@ -42,7 +44,7 @@ func newUninstallCmd() *cobra.Command { "Databricks CLI completions for %s appear to be installed via %s in %s. Nothing to uninstall.", shell, result.Method, - result.FilePath, + filepath.ToSlash(result.FilePath), )) return nil } @@ -50,7 +52,7 @@ func newUninstallCmd() *cobra.Command { return nil } - cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions removed for %s from %s.", shell, filePath)) + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions removed for %s from %s.", shell, displayPath)) return nil }, } From b21f88c9f672c262e10ef60645014a4477d00218 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 23 Feb 2026 23:42:37 +0100 Subject: [PATCH 07/13] Add confirmation prompt to completion install/uninstall Before modifying shell config files, show the detected shell and target file path and ask the user to confirm. This defends against heuristic misdetection. The prompt is skipped when --yes is passed or when the operation is a no-op (already installed / not installed). In non-interactive mode without --yes, the command returns an error hinting at the flag, following the same pattern as bundle deploy. --- acceptance/cmd/completion/output.txt | 6 +-- acceptance/cmd/completion/script | 8 ++-- cmd/completion/install.go | 34 +++++++++++++++-- cmd/completion/uninstall.go | 56 ++++++++++++++++++++++------ 4 files changed, 82 insertions(+), 22 deletions(-) diff --git a/acceptance/cmd/completion/output.txt b/acceptance/cmd/completion/output.txt index 34d35729f8..7eca15e549 100644 --- a/acceptance/cmd/completion/output.txt +++ b/acceptance/cmd/completion/output.txt @@ -1,5 +1,5 @@ ->>> [CLI] completion install --shell zsh +>>> [CLI] completion install --shell zsh --yes Databricks CLI completions installed for zsh. Restart your shell or run 'source home/.zshrc' to activate. @@ -8,10 +8,10 @@ Shell: zsh File: home/.zshrc Status: installed ->>> [CLI] completion install --shell zsh +>>> [CLI] completion install --shell zsh --yes Databricks CLI completions are already installed for zsh in home/.zshrc. ->>> [CLI] completion uninstall --shell zsh +>>> [CLI] completion uninstall --shell zsh --yes Databricks CLI completions removed for zsh from home/.zshrc. >>> [CLI] completion status --shell zsh diff --git a/acceptance/cmd/completion/script b/acceptance/cmd/completion/script index e46f4e261c..78cb5b326f 100644 --- a/acceptance/cmd/completion/script +++ b/acceptance/cmd/completion/script @@ -7,16 +7,16 @@ add_repl.py "$HOME" HOME export HOMEBREW_PREFIX=/nonexistent # Test install (use zsh to avoid OS-dependent bash RC file path) -trace $CLI completion install --shell zsh +trace $CLI completion install --shell zsh --yes # Test status shows installed trace $CLI completion status --shell zsh -# Test idempotent install -trace $CLI completion install --shell zsh +# Test idempotent install (--yes is harmless when already installed) +trace $CLI completion install --shell zsh --yes # Test uninstall -trace $CLI completion uninstall --shell zsh +trace $CLI completion uninstall --shell zsh --yes # Test status after uninstall trace $CLI completion status --shell zsh diff --git a/cmd/completion/install.go b/cmd/completion/install.go index f742748f2e..8b218810eb 100644 --- a/cmd/completion/install.go +++ b/cmd/completion/install.go @@ -1,6 +1,7 @@ package completion import ( + "errors" "fmt" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( func newInstallCmd() *cobra.Command { var shellFlag string + var yes bool cmd := &cobra.Command{ Use: "install", Short: "Install shell completions", @@ -31,17 +33,40 @@ func newInstallCmd() *cobra.Command { return err } - filePath, alreadyInstalled, err := libcompletion.Install(shell, home) + filePath := libcompletion.TargetFilePath(shell, home) + displayPath := filepath.ToSlash(filePath) + + // Check if already installed — no confirmation needed. + result, err := libcompletion.Status(shell, home) if err != nil { return err } - displayPath := filepath.ToSlash(filePath) - - if alreadyInstalled { + if result.Installed && result.Method == "marker" { cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions are already installed for %s in %s.", shell, displayPath)) return nil } + // Confirm before writing. + if !yes { + if !cmdio.IsPromptSupported(ctx) { + return errors.New("use --yes to skip the confirmation prompt in non-interactive mode") + } + cmdio.LogString(ctx, "Shell: "+shell.DisplayName()) + cmdio.LogString(ctx, "File: "+displayPath) + confirmed, err := cmdio.AskYesOrNo(ctx, "Proceed?") + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + _, _, err = libcompletion.Install(shell, home) + if err != nil { + return err + } + msg := fmt.Sprintf("Databricks CLI completions installed for %s.\n", shell) switch shell { case libcompletion.PowerShell, libcompletion.PowerShell5: @@ -53,6 +78,7 @@ func newInstallCmd() *cobra.Command { return nil }, } + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") addShellFlag(cmd, &shellFlag) return cmd } diff --git a/cmd/completion/uninstall.go b/cmd/completion/uninstall.go index 0a79facaea..be396c57b1 100644 --- a/cmd/completion/uninstall.go +++ b/cmd/completion/uninstall.go @@ -1,6 +1,7 @@ package completion import ( + "errors" "fmt" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( func newUninstallCmd() *cobra.Command { var shellFlag string + var yes bool cmd := &cobra.Command{ Use: "uninstall", Short: "Uninstall shell completions", @@ -31,23 +33,54 @@ func newUninstallCmd() *cobra.Command { return err } - filePath, wasInstalled, err := libcompletion.Uninstall(shell, home) + filePath := libcompletion.TargetFilePath(shell, home) + displayPath := filepath.ToSlash(filePath) + + // Check current status to avoid a useless prompt. + result, err := libcompletion.Status(shell, home) if err != nil { return err } - displayPath := filepath.ToSlash(filePath) - if !wasInstalled { - result, statusErr := libcompletion.Status(shell, home) - if statusErr == nil && result.Installed && result.Method != "" && result.Method != "marker" { - cmdio.LogString(ctx, fmt.Sprintf( - "Databricks CLI completions for %s appear to be installed via %s in %s. Nothing to uninstall.", - shell, - result.Method, - filepath.ToSlash(result.FilePath), - )) + if !result.Installed { + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions were not installed for %s.", shell)) + return nil + } + + // Installed by another method (homebrew, package manager) — we can't uninstall it. + if result.Method != "" && result.Method != "marker" { + cmdio.LogString(ctx, fmt.Sprintf( + "Databricks CLI completions for %s appear to be installed via %s in %s. Nothing to uninstall.", + shell, + result.Method, + filepath.ToSlash(result.FilePath), + )) + return nil + } + + // Confirm before modifying. + if !yes { + if !cmdio.IsPromptSupported(ctx) { + return errors.New("use --yes to skip the confirmation prompt in non-interactive mode") + } + cmdio.LogString(ctx, "Shell: "+shell.DisplayName()) + cmdio.LogString(ctx, "File: "+displayPath) + confirmed, err := cmdio.AskYesOrNo(ctx, "Proceed?") + if err != nil { + return err + } + if !confirmed { return nil } + } + + _, wasInstalled, err := libcompletion.Uninstall(shell, home) + if err != nil { + return err + } + + if !wasInstalled { + // Race: status said installed but uninstall found nothing. cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions were not installed for %s.", shell)) return nil } @@ -56,6 +89,7 @@ func newUninstallCmd() *cobra.Command { return nil }, } + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") addShellFlag(cmd, &shellFlag) return cmd } From 18e5e4dd3dfb2bbfab33ace6bbe13b4a3a480e4f Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 24 Feb 2026 00:17:27 +0100 Subject: [PATCH 08/13] Rename --yes to --auto-approve and add status hint Align with existing CLI convention (bundle deploy/destroy use --auto-approve). The non-interactive error message now suggests 'databricks completion status' as a dry-run to preview the detected shell and target file. --- acceptance/cmd/completion/output.txt | 6 +++--- acceptance/cmd/completion/script | 8 ++++---- cmd/completion/install.go | 8 ++++---- cmd/completion/uninstall.go | 8 ++++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/acceptance/cmd/completion/output.txt b/acceptance/cmd/completion/output.txt index 7eca15e549..a769966f9a 100644 --- a/acceptance/cmd/completion/output.txt +++ b/acceptance/cmd/completion/output.txt @@ -1,5 +1,5 @@ ->>> [CLI] completion install --shell zsh --yes +>>> [CLI] completion install --shell zsh --auto-approve Databricks CLI completions installed for zsh. Restart your shell or run 'source home/.zshrc' to activate. @@ -8,10 +8,10 @@ Shell: zsh File: home/.zshrc Status: installed ->>> [CLI] completion install --shell zsh --yes +>>> [CLI] completion install --shell zsh --auto-approve Databricks CLI completions are already installed for zsh in home/.zshrc. ->>> [CLI] completion uninstall --shell zsh --yes +>>> [CLI] completion uninstall --shell zsh --auto-approve Databricks CLI completions removed for zsh from home/.zshrc. >>> [CLI] completion status --shell zsh diff --git a/acceptance/cmd/completion/script b/acceptance/cmd/completion/script index 78cb5b326f..e5b0f324d6 100644 --- a/acceptance/cmd/completion/script +++ b/acceptance/cmd/completion/script @@ -7,16 +7,16 @@ add_repl.py "$HOME" HOME export HOMEBREW_PREFIX=/nonexistent # Test install (use zsh to avoid OS-dependent bash RC file path) -trace $CLI completion install --shell zsh --yes +trace $CLI completion install --shell zsh --auto-approve # Test status shows installed trace $CLI completion status --shell zsh -# Test idempotent install (--yes is harmless when already installed) -trace $CLI completion install --shell zsh --yes +# Test idempotent install (--auto-approve is harmless when already installed) +trace $CLI completion install --shell zsh --auto-approve # Test uninstall -trace $CLI completion uninstall --shell zsh --yes +trace $CLI completion uninstall --shell zsh --auto-approve # Test status after uninstall trace $CLI completion status --shell zsh diff --git a/cmd/completion/install.go b/cmd/completion/install.go index 8b218810eb..223edfa5e5 100644 --- a/cmd/completion/install.go +++ b/cmd/completion/install.go @@ -13,7 +13,7 @@ import ( func newInstallCmd() *cobra.Command { var shellFlag string - var yes bool + var autoApprove bool cmd := &cobra.Command{ Use: "install", Short: "Install shell completions", @@ -47,9 +47,9 @@ func newInstallCmd() *cobra.Command { } // Confirm before writing. - if !yes { + if !autoApprove { if !cmdio.IsPromptSupported(ctx) { - return errors.New("use --yes to skip the confirmation prompt in non-interactive mode") + return errors.New("use --auto-approve to skip the confirmation prompt, or run 'databricks completion status' to preview the detected shell and target file") } cmdio.LogString(ctx, "Shell: "+shell.DisplayName()) cmdio.LogString(ctx, "File: "+displayPath) @@ -78,7 +78,7 @@ func newInstallCmd() *cobra.Command { return nil }, } - cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + cmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Skip confirmation prompt") addShellFlag(cmd, &shellFlag) return cmd } diff --git a/cmd/completion/uninstall.go b/cmd/completion/uninstall.go index be396c57b1..6664a78d92 100644 --- a/cmd/completion/uninstall.go +++ b/cmd/completion/uninstall.go @@ -13,7 +13,7 @@ import ( func newUninstallCmd() *cobra.Command { var shellFlag string - var yes bool + var autoApprove bool cmd := &cobra.Command{ Use: "uninstall", Short: "Uninstall shell completions", @@ -59,9 +59,9 @@ func newUninstallCmd() *cobra.Command { } // Confirm before modifying. - if !yes { + if !autoApprove { if !cmdio.IsPromptSupported(ctx) { - return errors.New("use --yes to skip the confirmation prompt in non-interactive mode") + return errors.New("use --auto-approve to skip the confirmation prompt, or run 'databricks completion status' to preview the detected shell and target file") } cmdio.LogString(ctx, "Shell: "+shell.DisplayName()) cmdio.LogString(ctx, "File: "+displayPath) @@ -89,7 +89,7 @@ func newUninstallCmd() *cobra.Command { return nil }, } - cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + cmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Skip confirmation prompt") addShellFlag(cmd, &shellFlag) return cmd } From 629fad71a9e557717e504a1924d33edbaac4279e Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 24 Feb 2026 00:21:40 +0100 Subject: [PATCH 09/13] Fix install no-op for fish foreign files and homebrew path in uninstall Three fixes from review: - Install command: early-return for any result.Installed (not just "marker"). Fish external files and Homebrew completions now get appropriate messages without a useless prompt. The alreadyInstalled return from Install() is used as a safety net for final messaging. - Uninstall command: homebrew message no longer shows the misleading RC file path. Fish external files get "installed externally in ". - installFish: both file-exists branches returned the same value; simplify to a single os.Stat check. --- cmd/completion/install.go | 17 ++++++++++++++--- cmd/completion/uninstall.go | 16 ++++++++++------ libs/completion/install.go | 8 ++------ 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/cmd/completion/install.go b/cmd/completion/install.go index 223edfa5e5..55ff53fe70 100644 --- a/cmd/completion/install.go +++ b/cmd/completion/install.go @@ -41,8 +41,15 @@ func newInstallCmd() *cobra.Command { if err != nil { return err } - if result.Installed && result.Method == "marker" { - cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions are already installed for %s in %s.", shell, displayPath)) + if result.Installed { + switch result.Method { + case "marker": + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions are already installed for %s in %s.", shell, displayPath)) + case "homebrew": + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions for %s are already provided by Homebrew.", shell)) + default: + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions for %s are already present in %s.", shell, displayPath)) + } return nil } @@ -62,10 +69,14 @@ func newInstallCmd() *cobra.Command { } } - _, _, err = libcompletion.Install(shell, home) + _, alreadyInstalled, err := libcompletion.Install(shell, home) if err != nil { return err } + if alreadyInstalled { + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions are already installed for %s in %s.", shell, displayPath)) + return nil + } msg := fmt.Sprintf("Databricks CLI completions installed for %s.\n", shell) switch shell { diff --git a/cmd/completion/uninstall.go b/cmd/completion/uninstall.go index 6664a78d92..3fd2dbacf3 100644 --- a/cmd/completion/uninstall.go +++ b/cmd/completion/uninstall.go @@ -49,12 +49,16 @@ func newUninstallCmd() *cobra.Command { // Installed by another method (homebrew, package manager) — we can't uninstall it. if result.Method != "" && result.Method != "marker" { - cmdio.LogString(ctx, fmt.Sprintf( - "Databricks CLI completions for %s appear to be installed via %s in %s. Nothing to uninstall.", - shell, - result.Method, - filepath.ToSlash(result.FilePath), - )) + switch result.Method { + case "homebrew": + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions for %s are provided by Homebrew. Nothing to uninstall.", shell)) + default: + cmdio.LogString(ctx, fmt.Sprintf( + "Databricks CLI completions for %s appear to be installed externally in %s. Nothing to uninstall.", + shell, + filepath.ToSlash(result.FilePath), + )) + } return nil } diff --git a/libs/completion/install.go b/libs/completion/install.go index d5a01078cf..4db46642d1 100644 --- a/libs/completion/install.go +++ b/libs/completion/install.go @@ -19,14 +19,10 @@ func Install(shell Shell, homeDir string) (filePath string, alreadyInstalled boo } // installFish handles the file-drop model for fish completions. +// If the file already exists (ours or external), it is not overwritten. func installFish(filePath string, shell Shell) (string, bool, error) { - content, err := os.ReadFile(filePath) + _, err := os.Stat(filePath) if err == nil { - // Preserve existing files we don't own (e.g. package manager installs). - // If our marker is present, this is also already installed. - if strings.Contains(string(content), BeginMarker) { - return filePath, true, nil - } return filePath, true, nil } if !os.IsNotExist(err) { From ea2009fd34140163ebf8381dc6d3a79216a0a88e Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 24 Feb 2026 08:00:36 +0100 Subject: [PATCH 10/13] Warn when zsh completions are installed but compinit is missing Without compinit, neither our eval shim nor Homebrew's _databricks file will be loaded by zsh. Add a shared warnIfCompinitMissing helper that checks for "compinit" in .zshrc and prints actionable guidance. The warning appears on install (after success or already-installed), status (when installed), and uninstall (when completions remain via an external method like Homebrew). --- acceptance/cmd/completion/output.txt | 12 ++++++++++++ cmd/completion/completion.go | 28 ++++++++++++++++++++++++++++ cmd/completion/install.go | 2 ++ cmd/completion/status.go | 5 +++++ cmd/completion/uninstall.go | 1 + 5 files changed, 48 insertions(+) diff --git a/acceptance/cmd/completion/output.txt b/acceptance/cmd/completion/output.txt index a769966f9a..17ed0f61eb 100644 --- a/acceptance/cmd/completion/output.txt +++ b/acceptance/cmd/completion/output.txt @@ -3,14 +3,26 @@ Databricks CLI completions installed for zsh. Restart your shell or run 'source home/.zshrc' to activate. +Warning: zsh completions require the completion system to be initialized. +Add the following to your home/.zshrc: + autoload -U compinit && compinit + >>> [CLI] completion status --shell zsh Shell: zsh File: home/.zshrc Status: installed +Warning: zsh completions require the completion system to be initialized. +Add the following to your home/.zshrc: + autoload -U compinit && compinit + >>> [CLI] completion install --shell zsh --auto-approve Databricks CLI completions are already installed for zsh in home/.zshrc. +Warning: zsh completions require the completion system to be initialized. +Add the following to your home/.zshrc: + autoload -U compinit && compinit + >>> [CLI] completion uninstall --shell zsh --auto-approve Databricks CLI completions removed for zsh from home/.zshrc. diff --git a/cmd/completion/completion.go b/cmd/completion/completion.go index fed8069466..e69c3eaa4d 100644 --- a/cmd/completion/completion.go +++ b/cmd/completion/completion.go @@ -1,7 +1,14 @@ package completion import ( + "context" + "os" + "path/filepath" + "strings" + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdio" + libcompletion "github.com/databricks/cli/libs/completion" "github.com/spf13/cobra" ) @@ -167,6 +174,27 @@ to your powershell profile. return cmd } +// warnIfCompinitMissing prints a warning when zsh completions are present but +// the user's .zshrc does not call compinit. Without compinit, neither our eval +// shim nor Homebrew's _databricks file will be loaded. +func warnIfCompinitMissing(ctx context.Context, shell libcompletion.Shell, home string) { + if shell != libcompletion.Zsh { + return + } + rcPath := libcompletion.TargetFilePath(shell, home) + content, err := os.ReadFile(rcPath) + if err != nil { + return + } + if strings.Contains(string(content), "compinit") { + return + } + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, "Warning: zsh completions require the completion system to be initialized.") + cmdio.LogString(ctx, "Add the following to your "+filepath.ToSlash(rcPath)+":") + cmdio.LogString(ctx, " autoload -U compinit && compinit") +} + // addShellFlag registers the --shell flag and its completion function on cmd. func addShellFlag(cmd *cobra.Command, target *string) { cmd.Flags().StringVar(target, "shell", "", "Shell type: bash, zsh, fish, powershell, powershell5") diff --git a/cmd/completion/install.go b/cmd/completion/install.go index 55ff53fe70..83502c2994 100644 --- a/cmd/completion/install.go +++ b/cmd/completion/install.go @@ -50,6 +50,7 @@ func newInstallCmd() *cobra.Command { default: cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions for %s are already present in %s.", shell, displayPath)) } + warnIfCompinitMissing(ctx, shell, home) return nil } @@ -86,6 +87,7 @@ func newInstallCmd() *cobra.Command { msg += fmt.Sprintf("Restart your shell or run 'source %s' to activate.", displayPath) } cmdio.LogString(ctx, msg) + warnIfCompinitMissing(ctx, shell, home) return nil }, } diff --git a/cmd/completion/status.go b/cmd/completion/status.go index 24af24628d..f5234705ff 100644 --- a/cmd/completion/status.go +++ b/cmd/completion/status.go @@ -47,6 +47,11 @@ func newStatusCmd() *cobra.Command { cmdio.LogString(ctx, "Shell: "+shell.DisplayName()) cmdio.LogString(ctx, "File: "+filepath.ToSlash(result.FilePath)) cmdio.LogString(ctx, "Status: "+statusStr) + + if result.Installed { + warnIfCompinitMissing(ctx, shell, home) + } + return nil }, } diff --git a/cmd/completion/uninstall.go b/cmd/completion/uninstall.go index 3fd2dbacf3..7e3dfcf0f7 100644 --- a/cmd/completion/uninstall.go +++ b/cmd/completion/uninstall.go @@ -59,6 +59,7 @@ func newUninstallCmd() *cobra.Command { filepath.ToSlash(result.FilePath), )) } + warnIfCompinitMissing(ctx, shell, home) return nil } From 513894865a6dca03657a205d11a795994cd7f2aa Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 24 Feb 2026 08:30:28 +0100 Subject: [PATCH 11/13] Allow install to proceed when Homebrew completions exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eval shim in .zshrc and Homebrew's _databricks in site-functions are separate files that coexist. Don't block explicit install when the user has Homebrew completions — print an informational note and proceed with the normal prompt/install flow. External fish files (no marker) still early-return since installFish cannot overwrite them. --- cmd/completion/install.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/cmd/completion/install.go b/cmd/completion/install.go index 83502c2994..dcf5fe0b04 100644 --- a/cmd/completion/install.go +++ b/cmd/completion/install.go @@ -44,14 +44,22 @@ func newInstallCmd() *cobra.Command { if result.Installed { switch result.Method { case "marker": + // Our shim is already in the RC file — nothing to do. cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions are already installed for %s in %s.", shell, displayPath)) + warnIfCompinitMissing(ctx, shell, home) + return nil case "homebrew": - cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions for %s are already provided by Homebrew.", shell)) + // Homebrew provides completions via a separate file. The user + // may still want a CLI-managed shim in .zshrc (e.g. for a + // newer binary). Inform them and proceed with install. + cmdio.LogString(ctx, fmt.Sprintf("Note: Databricks CLI completions for %s are also provided by Homebrew.", shell)) default: + // External file (e.g. fish installed by package manager) — we + // can't overwrite it, so report and exit. cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions for %s are already present in %s.", shell, displayPath)) + warnIfCompinitMissing(ctx, shell, home) + return nil } - warnIfCompinitMissing(ctx, shell, home) - return nil } // Confirm before writing. From 369f96ae84008041ee839250ef2eb308c4d67c5f Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 24 Feb 2026 14:21:47 +0100 Subject: [PATCH 12/13] Address review comments: consolidate Status with Install, regex for blank-line collapse, Sprintf padding --- cmd/completion/status.go | 6 +++--- libs/completion/install.go | 33 +++++++++++++++++++-------------- libs/completion/uninstall.go | 7 ++++--- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/cmd/completion/status.go b/cmd/completion/status.go index f5234705ff..c6d1c7e8da 100644 --- a/cmd/completion/status.go +++ b/cmd/completion/status.go @@ -44,9 +44,9 @@ func newStatusCmd() *cobra.Command { } } - cmdio.LogString(ctx, "Shell: "+shell.DisplayName()) - cmdio.LogString(ctx, "File: "+filepath.ToSlash(result.FilePath)) - cmdio.LogString(ctx, "Status: "+statusStr) + cmdio.LogString(ctx, fmt.Sprintf("%-8s %s", "Shell:", shell.DisplayName())) + cmdio.LogString(ctx, fmt.Sprintf("%-8s %s", "File:", filepath.ToSlash(result.FilePath))) + cmdio.LogString(ctx, fmt.Sprintf("%-8s %s", "Status:", statusStr)) if result.Installed { warnIfCompinitMissing(ctx, shell, home) diff --git a/libs/completion/install.go b/libs/completion/install.go index 4db46642d1..462d6522d8 100644 --- a/libs/completion/install.go +++ b/libs/completion/install.go @@ -3,14 +3,27 @@ package completion import ( "os" "path/filepath" - "strings" ) // Install configures shell completion for the given shell. homeDir is used // as the base for RC file resolution (typically os.UserHomeDir()). // Returns the file path modified and whether it was already installed. func Install(shell Shell, homeDir string) (filePath string, alreadyInstalled bool, err error) { - filePath = TargetFilePath(shell, homeDir) + status, err := Status(shell, homeDir) + if err != nil { + return TargetFilePath(shell, homeDir), false, err + } + filePath = status.FilePath + + // For fish, any existing file counts as "already installed" — we don't + // overwrite files that may have been installed by a package manager. + // For RC-based shells, only our marker block counts. + if shell == Fish && status.Installed { + return filePath, true, nil + } + if status.Method == "marker" { + return filePath, true, nil + } if shell == Fish { return installFish(filePath, shell) @@ -19,16 +32,9 @@ func Install(shell Shell, homeDir string) (filePath string, alreadyInstalled boo } // installFish handles the file-drop model for fish completions. -// If the file already exists (ours or external), it is not overwritten. +// The caller must check Status before calling this — existence checks are not +// repeated here. func installFish(filePath string, shell Shell) (string, bool, error) { - _, err := os.Stat(filePath) - if err == nil { - return filePath, true, nil - } - if !os.IsNotExist(err) { - return filePath, false, err - } - dir := filepath.Dir(filePath) if err := os.MkdirAll(dir, 0o755); err != nil { return filePath, false, err @@ -38,6 +44,8 @@ func installFish(filePath string, shell Shell) (string, bool, error) { } // installRC handles the RC file model for bash, zsh, and powershell. +// The caller must check Status before calling this — marker checks are not +// repeated here. func installRC(filePath string, shell Shell) (string, bool, error) { var content []byte var perm os.FileMode = 0o644 @@ -48,9 +56,6 @@ func installRC(filePath string, shell Shell) (string, bool, error) { if err != nil { return filePath, false, err } - if strings.Contains(string(content), BeginMarker) { - return filePath, true, nil - } } // Create parent directory if needed (e.g. for PowerShell profiles). diff --git a/libs/completion/uninstall.go b/libs/completion/uninstall.go index 47a9ab3a5e..07ad6181d5 100644 --- a/libs/completion/uninstall.go +++ b/libs/completion/uninstall.go @@ -3,9 +3,12 @@ package completion import ( "fmt" "os" + "regexp" "strings" ) +var multiBlankLine = regexp.MustCompile(`\n{3,}`) + // Uninstall removes shell completion config. Returns the file path that was // modified and whether it was actually installed. func Uninstall(shell Shell, homeDir string) (filePath string, wasInstalled bool, err error) { @@ -82,9 +85,7 @@ func uninstallRC(filePath string) (string, bool, error) { result := text[:beginIdx] + text[blockEnd:] // Collapse double blank lines left by removal. - for strings.Contains(result, "\n\n\n") { - result = strings.ReplaceAll(result, "\n\n\n", "\n\n") - } + result = multiBlankLine.ReplaceAllString(result, "\n\n") return filePath, true, os.WriteFile(filePath, []byte(result), info.Mode()) } From a5c69d21c4e719d40091eafad833edefe82bc4d8 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 24 Feb 2026 15:31:00 +0100 Subject: [PATCH 13/13] Add NEXT_CHANGELOG.md entry for completion install/uninstall/status --- NEXT_CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index becdceb332..966a82068f 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -2,6 +2,9 @@ ## Release v0.290.0 +### CLI +* Add `completion install`, `uninstall`, and `status` subcommands ([#4581](https://github.com/databricks/cli/pull/4581)) + ### Internal: ### API Changes