Skip to content

Commit fa62e53

Browse files
pieternclaude
andauthored
Consolidate cmdio capability detection into capabilities struct (#4298)
## Changes Replace scattered interactive/prompt booleans in libs/cmdio with a single capabilities struct that encapsulates all terminal capability detection (TTY status, color support, prompt support, Git Bash detection). Changes: - Add Capabilities struct with methods SupportsInteractive(), SupportsPrompt(), SupportsColor() - Move IsTTY to tty.go with FakeTTY test helper - Replace cmdio.IsTTY(w) with cmdio.SupportsColor(ctx, w) for color decisions - Replace stdin TTY check with IsPromptSupported() in aitools install No behavioral changes. Respects NO_COLOR and TERM=dumb throughout. ## Tests All tests pass. Manually confirmed that I still see prompts and colors. --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent d81cb0b commit fa62e53

File tree

15 files changed

+458
-121
lines changed

15 files changed

+458
-121
lines changed

cmd/pipelines/root/io.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
package root
33

44
import (
5+
"context"
6+
57
"github.com/databricks/cli/libs/cmdio"
68
"github.com/databricks/cli/libs/env"
79
"github.com/databricks/cli/libs/flags"
@@ -38,17 +40,16 @@ func OutputType(cmd *cobra.Command) flags.Output {
3840
return *f
3941
}
4042

41-
func (f *outputFlag) initializeIO(cmd *cobra.Command) error {
43+
func (f *outputFlag) initializeIO(ctx context.Context, cmd *cobra.Command) (context.Context, error) {
4244
var headerTemplate, template string
4345
if cmd.Annotations != nil {
4446
// rely on zeroval being an empty string
4547
template = cmd.Annotations["template"]
4648
headerTemplate = cmd.Annotations["headerTemplate"]
4749
}
4850

49-
ctx := cmd.Context()
5051
cmdIO := cmdio.NewIO(ctx, f.output, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), headerTemplate, template)
5152
ctx = cmdio.InContext(ctx, cmdIO)
5253
cmd.SetContext(ctx)
53-
return nil
54+
return ctx, nil
5455
}

cmd/pipelines/root/logger.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,14 @@ type logFlags struct {
2828
debug bool
2929
}
3030

31-
func (f *logFlags) makeLogHandler(opts slog.HandlerOptions) (slog.Handler, error) {
31+
func (f *logFlags) makeLogHandler(ctx context.Context, opts slog.HandlerOptions) (slog.Handler, error) {
3232
switch f.output {
3333
case flags.OutputJSON:
3434
return slog.NewJSONHandler(f.file.Writer(), &opts), nil
3535
case flags.OutputText:
3636
w := f.file.Writer()
3737
return handler.NewFriendlyHandler(w, &handler.Options{
38-
Color: cmdio.IsTTY(w),
38+
Color: cmdio.SupportsColor(ctx, w),
3939
Level: opts.Level,
4040
ReplaceAttr: opts.ReplaceAttr,
4141
}), nil
@@ -66,7 +66,7 @@ func (f *logFlags) initializeContext(ctx context.Context) (context.Context, erro
6666
return nil, err
6767
}
6868

69-
handler, err := f.makeLogHandler(opts)
69+
handler, err := f.makeLogHandler(ctx, opts)
7070
if err != nil {
7171
return nil, err
7272
}

cmd/pipelines/root/root.go

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,18 @@ func New(ctx context.Context) *cobra.Command {
4545
initProgressLoggerFlag(cmd, logFlags)
4646

4747
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
48+
var err error
49+
4850
ctx := cmd.Context()
4951

52+
// Configure command IO
53+
ctx, err = outputFlag.initializeIO(ctx, cmd)
54+
if err != nil {
55+
return err
56+
}
57+
5058
// Configure default logger.
51-
ctx, err := logFlags.initializeContext(ctx)
59+
ctx, err = logFlags.initializeContext(ctx)
5260
if err != nil {
5361
return err
5462
}
@@ -58,17 +66,6 @@ func New(ctx context.Context) *cobra.Command {
5866
slog.String("version", build.GetInfo().Version),
5967
slog.String("args", strings.Join(os.Args, ", ")))
6068

61-
// set context, so that initializeIO can have the current context
62-
cmd.SetContext(ctx)
63-
64-
// Configure command IO
65-
err = outputFlag.initializeIO(cmd)
66-
if err != nil {
67-
return err
68-
}
69-
// get the context back
70-
ctx = cmd.Context()
71-
7269
// Configure our user agent with the command that's about to be executed.
7370
ctx = withCommandInUserAgent(ctx, cmd)
7471
ctx = withCommandExecIdInUserAgent(ctx)

cmd/root/io.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package root
22

33
import (
4+
"context"
5+
46
"github.com/databricks/cli/libs/cmdio"
57
"github.com/databricks/cli/libs/env"
68
"github.com/databricks/cli/libs/flags"
@@ -37,17 +39,16 @@ func OutputType(cmd *cobra.Command) flags.Output {
3739
return *f
3840
}
3941

40-
func (f *outputFlag) initializeIO(cmd *cobra.Command) error {
42+
func (f *outputFlag) initializeIO(ctx context.Context, cmd *cobra.Command) (context.Context, error) {
4143
var headerTemplate, template string
4244
if cmd.Annotations != nil {
4345
// rely on zeroval being an empty string
4446
template = cmd.Annotations["template"]
4547
headerTemplate = cmd.Annotations["headerTemplate"]
4648
}
4749

48-
ctx := cmd.Context()
4950
cmdIO := cmdio.NewIO(ctx, f.output, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), headerTemplate, template)
5051
ctx = cmdio.InContext(ctx, cmdIO)
5152
cmd.SetContext(ctx)
52-
return nil
53+
return ctx, nil
5354
}

cmd/root/logger.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@ type logFlags struct {
2727
debug bool
2828
}
2929

30-
func (f *logFlags) makeLogHandler(opts slog.HandlerOptions) (slog.Handler, error) {
30+
func (f *logFlags) makeLogHandler(ctx context.Context, opts slog.HandlerOptions) (slog.Handler, error) {
3131
switch f.output {
3232
case flags.OutputJSON:
3333
return slog.NewJSONHandler(f.file.Writer(), &opts), nil
3434
case flags.OutputText:
3535
w := f.file.Writer()
3636
return handler.NewFriendlyHandler(w, &handler.Options{
37-
Color: cmdio.IsTTY(w),
37+
Color: cmdio.SupportsColor(ctx, w),
3838
Level: opts.Level,
3939
ReplaceAttr: opts.ReplaceAttr,
4040
}), nil
@@ -65,7 +65,7 @@ func (f *logFlags) initializeContext(ctx context.Context) (context.Context, erro
6565
return nil, err
6666
}
6767

68-
handler, err := f.makeLogHandler(opts)
68+
handler, err := f.makeLogHandler(ctx, opts)
6969
if err != nil {
7070
return nil, err
7171
}

cmd/root/root.go

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,18 @@ func New(ctx context.Context) *cobra.Command {
5151
initProgressLoggerFlag(cmd, logFlags)
5252

5353
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
54+
var err error
55+
5456
ctx := cmd.Context()
5557

58+
// Configure command IO
59+
ctx, err = outputFlag.initializeIO(ctx, cmd)
60+
if err != nil {
61+
return err
62+
}
63+
5664
// Configure default logger.
57-
ctx, err := logFlags.initializeContext(ctx)
65+
ctx, err = logFlags.initializeContext(ctx)
5866
if err != nil {
5967
return err
6068
}
@@ -64,17 +72,6 @@ func New(ctx context.Context) *cobra.Command {
6472
slog.String("version", build.GetInfo().Version),
6573
slog.String("args", strings.Join(os.Args, ", ")))
6674

67-
// set context, so that initializeIO can have the current context
68-
cmd.SetContext(ctx)
69-
70-
// Configure command IO
71-
err = outputFlag.initializeIO(cmd)
72-
if err != nil {
73-
return err
74-
}
75-
// get the context back
76-
ctx = cmd.Context()
77-
7875
// Configure our user agent with the command that's about to be executed.
7976
ctx = withCommandInUserAgent(ctx, cmd)
8077
ctx = withCommandExecIdInUserAgent(ctx)

cmd/workspace/apps/logs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ via --source APP|SYSTEM. Use --output-file to mirror the stream to a local file
145145
}
146146

147147
outputFormat := root.OutputType(cmd)
148-
colorizeLogs := outputPath == "" && outputFormat == flags.OutputText && cmdio.IsTTY(cmd.OutOrStdout())
148+
colorizeLogs := outputPath == "" && outputFormat == flags.OutputText && cmdio.SupportsColor(ctx, cmd.OutOrStdout())
149149

150150
sourceMap, err := buildSourceFilter(sourceFilters)
151151
if err != nil {

experimental/aitools/cmd/install.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func newInstallCmd() *cobra.Command {
2828
func runInstall(ctx context.Context) error {
2929
// Check for non-interactive mode with agent detection
3030
// If running in an AI agent, install automatically without prompts
31-
if !cmdio.IsTTY(os.Stdin) {
31+
if !cmdio.IsPromptSupported(ctx) {
3232
if os.Getenv("CLAUDECODE") != "" {
3333
if err := agents.InstallClaude(); err != nil {
3434
return err

libs/cmdio/capabilities.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package cmdio
2+
3+
import (
4+
"context"
5+
"io"
6+
"strings"
7+
8+
"github.com/databricks/cli/libs/env"
9+
)
10+
11+
// Capabilities represents terminal I/O capabilities detected from environment.
12+
type Capabilities struct {
13+
// Raw TTY detection results
14+
stdinIsTTY bool
15+
stdoutIsTTY bool
16+
stderrIsTTY bool
17+
18+
// Environment flags
19+
color bool // Color output is enabled (NO_COLOR not set and TERM not dumb)
20+
isGitBash bool // Git Bash on Windows
21+
}
22+
23+
// newCapabilities detects terminal capabilities from context and I/O streams.
24+
func newCapabilities(ctx context.Context, in io.Reader, out, err io.Writer) Capabilities {
25+
return Capabilities{
26+
stdinIsTTY: isTTY(in),
27+
stdoutIsTTY: isTTY(out),
28+
stderrIsTTY: isTTY(err),
29+
color: env.Get(ctx, "NO_COLOR") == "" && env.Get(ctx, "TERM") != "dumb",
30+
isGitBash: detectGitBash(ctx),
31+
}
32+
}
33+
34+
// SupportsInteractive returns true if terminal supports interactive features (colors, spinners).
35+
func (c Capabilities) SupportsInteractive() bool {
36+
return c.stderrIsTTY && c.color
37+
}
38+
39+
// SupportsPrompt returns true if terminal supports user prompting.
40+
func (c Capabilities) SupportsPrompt() bool {
41+
return c.SupportsInteractive() && c.stdinIsTTY && c.stdoutIsTTY && !c.isGitBash
42+
}
43+
44+
// SupportsColor returns true if the given writer supports colored output.
45+
// This checks both TTY status and environment variables (NO_COLOR, TERM=dumb).
46+
func (c Capabilities) SupportsColor(w io.Writer) bool {
47+
return isTTY(w) && c.color
48+
}
49+
50+
// detectGitBash returns true if running in Git Bash on Windows (has broken promptui support).
51+
// We do not allow prompting in Git Bash on Windows.
52+
// Likely due to fact that Git Bash does not correctly support ANSI escape sequences,
53+
// we cannot use promptui package there.
54+
// See known issues:
55+
// - https://github.com/manifoldco/promptui/issues/208
56+
// - https://github.com/chzyer/readline/issues/191
57+
func detectGitBash(ctx context.Context) bool {
58+
// Check if the MSYSTEM environment variable is set to "MINGW64"
59+
msystem := env.Get(ctx, "MSYSTEM")
60+
if strings.EqualFold(msystem, "MINGW64") {
61+
// Check for typical Git Bash env variable for prompts
62+
ps1 := env.Get(ctx, "PS1")
63+
return strings.Contains(ps1, "MINGW") || strings.Contains(ps1, "MSYSTEM")
64+
}
65+
66+
return false
67+
}

0 commit comments

Comments
 (0)