From 8a54ee5307f19e8caabd4d2f7137e76647f90bc3 Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Thu, 21 May 2026 16:18:02 +0000 Subject: [PATCH 1/2] cli: handle Ctrl+C in interactive prompts cleanly When the user pressed Ctrl+C in `databricks aitools install` (or any other huh/bubbletea-backed prompt), the CLI printed an unfriendly `Error: user aborted` / `Error: ^C` and exited 1. Treat the cancel as a deliberate user action: print `cancelled` and exit 130 (POSIX SIGINT convention), so shell scripts can distinguish a user cancel from a real failure. The detection covers both prompt families used by the CLI: cmdio's TUI prompts (now-exported `cmdio.ErrInterrupted`) and the huh forms under `cmd/aitools` (`huh.ErrUserAborted`). Main reads the new `root.IsInterrupted` predicate to pick the exit code. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + cmd/root/root.go | 51 ++++++++++++++++++++++++++++------------ cmd/root/root_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++ libs/cmdio/io.go | 10 ++++---- libs/cmdio/prompt.go | 2 +- libs/cmdio/select.go | 2 +- main.go | 3 +++ 7 files changed, 102 insertions(+), 21 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index f2e569c609c..b6f8c5240f2 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -5,6 +5,7 @@ ### Notable Changes ### CLI +* Ctrl+C in an interactive prompt now prints `cancelled` and exits 130 instead of `Error: user aborted` / `Error: ^C` and exit 1. Applies to both the `huh` prompts in `databricks aitools` and the bubbletea-based prompts in `libs/cmdio`. ### Bundles * The error reported when a direct-only resource (catalogs, external locations, vector search endpoints) is used with the terraform engine now also suggests setting `bundle.engine: direct` in `databricks.yml`, in addition to the `DATABRICKS_BUNDLE_ENGINE` environment variable ([#5295](https://github.com/databricks/cli/pull/5295)). diff --git a/cmd/root/root.go b/cmd/root/root.go index 6b6de2a9baa..674fe909ac2 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/charmbracelet/huh" "github.com/databricks/cli/internal/build" "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/cmdctx" @@ -22,6 +23,18 @@ import ( "github.com/spf13/cobra" ) +// ExitInterrupted is the exit code main.go uses when the user cancelled an +// interactive prompt with Ctrl+C. Matches the POSIX 128+SIGINT convention so +// shell scripts can distinguish a user cancel from a genuine command failure. +const ExitInterrupted = 130 + +// IsInterrupted reports whether err indicates the user cancelled an +// interactive prompt with Ctrl+C. Covers both cmdio's TUI prompts and the +// huh library used by aitools. main.go reads this to pick an exit code. +func IsInterrupted(err error) bool { + return errors.Is(err, cmdio.ErrInterrupted) || errors.Is(err, huh.ErrUserAborted) +} + func New(ctx context.Context) *cobra.Command { cmd := &cobra.Command{ Use: "databricks", @@ -144,7 +157,13 @@ Stack Trace: // Run the command cmd, err = cmd.ExecuteContextC(ctx) - if err != nil && !errors.Is(err, ErrAlreadyPrinted) { + interrupted := IsInterrupted(err) + switch { + case err == nil, errors.Is(err, ErrAlreadyPrinted): + // nothing to print + case interrupted: + fmt.Fprintln(cmd.ErrOrStderr(), "cancelled") + default: if cmdctx.HasConfigUsed(cmd.Context()) { cfg := cmdctx.ConfigUsed(cmd.Context()) err = auth.EnrichAuthError(cmd.Context(), cfg, err) @@ -152,30 +171,32 @@ Stack Trace: fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s\n", err.Error()) } + exitCode := 0 + switch { + case err == nil: + case interrupted: + exitCode = ExitInterrupted + default: + exitCode = 1 + } + // Log exit status and error // We only log if logger initialization succeeded and is stored in command // context if logger, ok := log.FromContext(cmd.Context()); ok { - if err == nil { - logger.Info("completed execution", - slog.String("exit_code", "0")) - } else if errors.Is(err, ErrAlreadyPrinted) { - logger.Debug("failed execution", - slog.String("exit_code", "1"), - ) - } else { + switch { + case err == nil: + logger.Info("completed execution", slog.Int("exit_code", exitCode)) + case errors.Is(err, ErrAlreadyPrinted), interrupted: + logger.Debug("failed execution", slog.Int("exit_code", exitCode)) + default: logger.Info("failed execution", - slog.String("exit_code", "1"), + slog.Int("exit_code", exitCode), slog.String("error", err.Error()), ) } } - exitCode := 0 - if err != nil { - exitCode = 1 - } - commandStr := commandString(cmd) ctx = cmd.Context() diff --git a/cmd/root/root_test.go b/cmd/root/root_test.go index 44a07073984..5c5c88406e1 100644 --- a/cmd/root/root_test.go +++ b/cmd/root/root_test.go @@ -2,9 +2,13 @@ package root import ( "bytes" + "errors" + "fmt" "testing" + "github.com/charmbracelet/huh" "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/config" "github.com/spf13/cobra" @@ -77,6 +81,56 @@ func TestExecuteNoEnrichmentWithoutConfigUsed(t *testing.T) { assert.NotContains(t, output, "Next steps:") } +func TestIsInterrupted(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"random error", errors.New("boom"), false}, + {"cmdio interrupt", cmdio.ErrInterrupted, true}, + {"huh aborted", huh.ErrUserAborted, true}, + {"wrapped cmdio interrupt", fmt.Errorf("prompt: %w", cmdio.ErrInterrupted), true}, + {"wrapped huh aborted", fmt.Errorf("form: %w", huh.ErrUserAborted), true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, IsInterrupted(tc.err)) + }) + } +} + +func TestExecuteInterruptPrintsCancelled(t *testing.T) { + tests := []struct { + name string + err error + }{ + {"cmdio interrupt", cmdio.ErrInterrupted}, + {"huh aborted", huh.ErrUserAborted}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stderr := &bytes.Buffer{} + cmd := &cobra.Command{ + Use: "test", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { return tc.err }, + } + cmd.SetErr(stderr) + + err := Execute(t.Context(), cmd) + require.Error(t, err) + assert.True(t, IsInterrupted(err)) + + output := stderr.String() + assert.Equal(t, "cancelled\n", output) + assert.NotContains(t, output, "Error:") + }) + } +} + func TestExecuteErrAlreadyPrintedNotEnriched(t *testing.T) { ctx := t.Context() stderr := &bytes.Buffer{} diff --git a/libs/cmdio/io.go b/libs/cmdio/io.go index a25062fcd3f..054ad16c58b 100644 --- a/libs/cmdio/io.go +++ b/libs/cmdio/io.go @@ -11,9 +11,11 @@ import ( "github.com/databricks/cli/libs/flags" ) -// errCtrlC is returned when the user cancels a TUI prompt with Ctrl+C. The -// "^C" string matches the historical wire format; goldens depend on it. -var errCtrlC = errors.New("^C") +// ErrInterrupted is returned when the user cancels a TUI prompt with Ctrl+C. +// The "^C" string matches the historical wire format; goldens depend on it. +// Callers above the cobra layer (cmd/root) recognise this sentinel to print +// "cancelled" and exit with code 130 instead of an error stack. +var ErrInterrupted = errors.New("^C") // runTUI runs a tea.Program through cmdIO's tea program slot so spinners and // pagers can't fight a prompt for the terminal. Blocks until the model quits. @@ -21,7 +23,7 @@ func (c *cmdIO) runTUI(m tea.Model) (tea.Model, error) { p := tea.NewProgram(m, tea.WithInput(c.in), tea.WithOutput(c.err), - // Ctrl+C is delivered as a key event so the model can return errCtrlC. + // Ctrl+C is delivered as a key event so the model can return ErrInterrupted. tea.WithoutSignalHandler(), ) c.acquireTeaProgram(p) diff --git a/libs/cmdio/prompt.go b/libs/cmdio/prompt.go index 8c8ca1a3a39..ca6faf42822 100644 --- a/libs/cmdio/prompt.go +++ b/libs/cmdio/prompt.go @@ -239,7 +239,7 @@ func (c *cmdIO) runPromptModel(m *promptModel) (string, error) { pm := final.(*promptModel) switch { case pm.cancelled: - return "", errCtrlC + return "", ErrInterrupted case pm.deleted: return "", io.EOF } diff --git a/libs/cmdio/select.go b/libs/cmdio/select.go index 2d481139bae..b861eb93bc6 100644 --- a/libs/cmdio/select.go +++ b/libs/cmdio/select.go @@ -521,7 +521,7 @@ func (c *cmdIO) runSelectModel(m *selectModel) (int, error) { } sm := final.(*selectModel) if sm.cancelled { - return 0, errCtrlC + return 0, ErrInterrupted } return sm.originalIndex(), nil } diff --git a/main.go b/main.go index e81dde6946b..49ddbee3991 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,9 @@ func main() { ctx := context.Background() err := root.Execute(ctx, cmd.New(ctx)) if err != nil { + if root.IsInterrupted(err) { + os.Exit(root.ExitInterrupted) + } os.Exit(1) } } From 95b20c8cacf7836ad34ea82951e0c26c5df6a0ac Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Thu, 21 May 2026 22:13:43 +0000 Subject: [PATCH 2/2] review: address ace-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert drive-by slog.String → slog.Int change on the exit_code log field so JSON-log consumers see the same wire format as before. The refactor was unrelated to the Ctrl+C fix. - Give interrupts their own Info log line ("cancelled execution") so users running --log-level=info don't lose Ctrl+C visibility (it previously fell into the default Info branch; the first pass folded it into Debug with ErrAlreadyPrinted). - Extract ExitCodeFor(err) as the single source of truth for the process exit code. Execute and main.go now both go through it, removing the two-switch divergence risk. - Add TestExitCodeFor covering nil / random / ErrAlreadyPrinted / interrupted (direct + wrapped) so the exit-130 contract is locked in by a unit test. - Broaden the changelog to mention databricks apps init (also a huh caller) alongside aitools. - Add a one-liner comment explaining the intentional precedence of ErrAlreadyPrinted over interrupted in the print switch. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 2 +- cmd/root/root.go | 37 ++++++++++++++++++++++++------------- cmd/root/root_test.go | 21 +++++++++++++++++++++ main.go | 7 +------ 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index b6f8c5240f2..b0e1a5121be 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -5,7 +5,7 @@ ### Notable Changes ### CLI -* Ctrl+C in an interactive prompt now prints `cancelled` and exits 130 instead of `Error: user aborted` / `Error: ^C` and exit 1. Applies to both the `huh` prompts in `databricks aitools` and the bubbletea-based prompts in `libs/cmdio`. +* Ctrl+C in an interactive prompt now prints `cancelled` and exits 130 instead of `Error: user aborted` / `Error: ^C` and exit 1. Applies to all `huh`-based prompts (e.g. `databricks aitools`, `databricks apps init`) and the bubbletea-based prompts in `libs/cmdio`. ### Bundles * The error reported when a direct-only resource (catalogs, external locations, vector search endpoints) is used with the terraform engine now also suggests setting `bundle.engine: direct` in `databricks.yml`, in addition to the `DATABRICKS_BUNDLE_ENGINE` environment variable ([#5295](https://github.com/databricks/cli/pull/5295)). diff --git a/cmd/root/root.go b/cmd/root/root.go index 674fe909ac2..3e4c87c8d7c 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -8,6 +8,7 @@ import ( "os" "runtime" "runtime/debug" + "strconv" "strings" "time" @@ -35,6 +36,20 @@ func IsInterrupted(err error) bool { return errors.Is(err, cmdio.ErrInterrupted) || errors.Is(err, huh.ErrUserAborted) } +// ExitCodeFor maps the result of Execute to a process exit code. +// 0 = success, ExitInterrupted (130) = user Ctrl+C, 1 = any other error. +// Single source of truth shared between Execute's telemetry and main.go. +func ExitCodeFor(err error) int { + switch { + case err == nil: + return 0 + case IsInterrupted(err): + return ExitInterrupted + default: + return 1 + } +} + func New(ctx context.Context) *cobra.Command { cmd := &cobra.Command{ Use: "databricks", @@ -160,7 +175,8 @@ Stack Trace: interrupted := IsInterrupted(err) switch { case err == nil, errors.Is(err, ErrAlreadyPrinted): - // nothing to print + // ErrAlreadyPrinted wins over interrupted: a subcommand that + // printed its own cancel message should not be overridden. case interrupted: fmt.Fprintln(cmd.ErrOrStderr(), "cancelled") default: @@ -171,14 +187,7 @@ Stack Trace: fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s\n", err.Error()) } - exitCode := 0 - switch { - case err == nil: - case interrupted: - exitCode = ExitInterrupted - default: - exitCode = 1 - } + exitCode := ExitCodeFor(err) // Log exit status and error // We only log if logger initialization succeeded and is stored in command @@ -186,12 +195,14 @@ Stack Trace: if logger, ok := log.FromContext(cmd.Context()); ok { switch { case err == nil: - logger.Info("completed execution", slog.Int("exit_code", exitCode)) - case errors.Is(err, ErrAlreadyPrinted), interrupted: - logger.Debug("failed execution", slog.Int("exit_code", exitCode)) + logger.Info("completed execution", slog.String("exit_code", strconv.Itoa(exitCode))) + case interrupted: + logger.Info("cancelled execution", slog.String("exit_code", strconv.Itoa(exitCode))) + case errors.Is(err, ErrAlreadyPrinted): + logger.Debug("failed execution", slog.String("exit_code", strconv.Itoa(exitCode))) default: logger.Info("failed execution", - slog.Int("exit_code", exitCode), + slog.String("exit_code", strconv.Itoa(exitCode)), slog.String("error", err.Error()), ) } diff --git a/cmd/root/root_test.go b/cmd/root/root_test.go index 5c5c88406e1..bb97e0a552b 100644 --- a/cmd/root/root_test.go +++ b/cmd/root/root_test.go @@ -131,6 +131,27 @@ func TestExecuteInterruptPrintsCancelled(t *testing.T) { } } +func TestExitCodeFor(t *testing.T) { + tests := []struct { + name string + err error + want int + }{ + {"nil", nil, 0}, + {"random error", errors.New("boom"), 1}, + {"already printed", ErrAlreadyPrinted, 1}, + {"cmdio interrupt", cmdio.ErrInterrupted, ExitInterrupted}, + {"huh aborted", huh.ErrUserAborted, ExitInterrupted}, + {"wrapped cmdio interrupt", fmt.Errorf("prompt: %w", cmdio.ErrInterrupted), ExitInterrupted}, + {"wrapped huh aborted", fmt.Errorf("form: %w", huh.ErrUserAborted), ExitInterrupted}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, ExitCodeFor(tc.err)) + }) + } +} + func TestExecuteErrAlreadyPrintedNotEnriched(t *testing.T) { ctx := t.Context() stderr := &bytes.Buffer{} diff --git a/main.go b/main.go index 49ddbee3991..ccb886f8d97 100644 --- a/main.go +++ b/main.go @@ -15,10 +15,5 @@ import ( func main() { ctx := context.Background() err := root.Execute(ctx, cmd.New(ctx)) - if err != nil { - if root.IsInterrupted(err) { - os.Exit(root.ExitInterrupted) - } - os.Exit(1) - } + os.Exit(root.ExitCodeFor(err)) }