Merge remote-tracking branch 'origin/main' into small-fixes

Kujtim Hoxha created

Change summary

.gitignore                                                          |   1 
cmd/root.go                                                         |   6 
go.mod                                                              |   4 
go.sum                                                              |   4 
internal/app/app.go                                                 |   5 
internal/format/spinner.go                                          |  50 
internal/llm/tools/bash.go                                          | 127 
internal/lsp/transport.go                                           |  41 
internal/shell/command_block_test.go                                | 123 
internal/shell/shell.go                                             |  82 
internal/tui/components/chat/editor/editor.go                       |   5 
internal/tui/components/completions/completions.go                  |  23 
main.go                                                             |  18 
vendor/github.com/Azure/azure-sdk-for-go/sdk/azidentity/go.work.sum |  60 
vendor/github.com/charmbracelet/fang/README.md                      |   7 
vendor/github.com/charmbracelet/fang/fang.go                        | 119 
vendor/github.com/charmbracelet/fang/help.go                        | 298 
vendor/github.com/charmbracelet/fang/theme.go                       |  52 
vendor/modules.txt                                                  |   6 
19 files changed, 751 insertions(+), 280 deletions(-)

Detailed changes

.gitignore 🔗

@@ -16,6 +16,7 @@
 
 # Go workspace file
 go.work
+go.work.sum
 
 # IDE specific files
 .idea/

cmd/root.go 🔗

@@ -73,7 +73,6 @@ to assist developers in writing, debugging, and understanding code directly from
 			return err
 		}
 
-		// Use the context from the command which includes signal handling
 		ctx := cmd.Context()
 
 		// Connect DB, this will also run migrations
@@ -154,11 +153,12 @@ func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
 	}()
 }
 
-func Execute(ctx context.Context) {
+func Execute() {
 	if err := fang.Execute(
-		ctx,
+		context.Background(),
 		rootCmd,
 		fang.WithVersion(version.Version),
+		fang.WithNotifySignal(os.Interrupt),
 	); err != nil {
 		os.Exit(1)
 	}

go.mod 🔗

@@ -18,9 +18,9 @@ require (
 	github.com/charlievieth/fastwalk v1.0.11
 	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198
 	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1
-	github.com/charmbracelet/fang v0.1.0
+	github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
 	github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
-	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71
+	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
 	github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706
 	github.com/charmbracelet/x/ansi v0.9.3
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3

go.sum 🔗

@@ -74,8 +74,8 @@ github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e59
 github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595/go.mod h1:+Tl7rePElw6OKt382t04zXwtPFoPXxAaJzNrYmtsLds=
 github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
 github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
-github.com/charmbracelet/fang v0.1.0 h1:SlZS2crf3/zQh7Mr4+W+7QR1k+L08rrPX5rm5z3d7Wg=
-github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc=
+github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
+github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674/go.mod h1:9gCUAHmVx5BwSafeyNr3GI0GgvlB1WYjL21SkPp1jyU=
 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
 github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb h1:lswj7CYZVYbLn2OhYJsXOMRQQGdRIfyuSnh5FdVSMr0=

internal/app/app.go 🔗

@@ -95,10 +95,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 func (a *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool) error {
 	slog.Info("Running in non-interactive mode")
 
+	ctx, cancel := context.WithCancel(ctx)
+	defer cancel()
+
 	// Start spinner if not in quiet mode
 	var spinner *format.Spinner
 	if !quiet {
-		spinner = format.NewSpinner(ctx, "Generating")
+		spinner = format.NewSpinner(ctx, cancel, "Generating")
 		spinner.Start()
 	}
 	// Helper function to stop spinner once

internal/format/spinner.go 🔗

@@ -18,24 +18,48 @@ type Spinner struct {
 	prog *tea.Program
 }
 
+type model struct {
+	cancel context.CancelFunc
+	anim   anim.Anim
+}
+
+func (m model) Init() tea.Cmd { return m.anim.Init() }
+func (m model) View() string  { return m.anim.View() }
+
+// Update implements tea.Model.
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch msg.String() {
+		case "ctrl+c", "esc":
+			m.cancel()
+			return m, tea.Quit
+		}
+	}
+	mm, cmd := m.anim.Update(msg)
+	m.anim = mm.(anim.Anim)
+	return m, cmd
+}
+
 // NewSpinner creates a new spinner with the given message
-func NewSpinner(ctx context.Context, message string) *Spinner {
+func NewSpinner(ctx context.Context, cancel context.CancelFunc, message string) *Spinner {
 	t := styles.CurrentTheme()
-	model := anim.New(anim.Settings{
-		Size:        10,
-		Label:       message,
-		LabelColor:  t.FgBase,
-		GradColorA:  t.Primary,
-		GradColorB:  t.Secondary,
-		CycleColors: true,
-	})
+	model := model{
+		anim: anim.New(anim.Settings{
+			Size:        10,
+			Label:       message,
+			LabelColor:  t.FgBase,
+			GradColorA:  t.Primary,
+			GradColorB:  t.Secondary,
+			CycleColors: true,
+		}),
+		cancel: cancel,
+	}
 
 	prog := tea.NewProgram(
 		model,
-		tea.WithInput(nil),
 		tea.WithOutput(os.Stderr),
 		tea.WithContext(ctx),
-		tea.WithoutCatchPanics(),
 	)
 
 	return &Spinner{
@@ -47,13 +71,13 @@ func NewSpinner(ctx context.Context, message string) *Spinner {
 // Start begins the spinner animation
 func (s *Spinner) Start() {
 	go func() {
+		defer close(s.done)
 		_, err := s.prog.Run()
 		// ensures line is cleared
 		fmt.Fprint(os.Stderr, ansi.EraseEntireLine)
-		if err != nil && !errors.Is(err, context.Canceled) {
+		if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, tea.ErrInterrupted) {
 			fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
 		}
-		close(s.done)
 	}()
 }
 

internal/llm/tools/bash.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"log/slog"
 	"runtime"
 	"strings"
 	"time"
@@ -41,9 +42,74 @@ const (
 )
 
 var bannedCommands = []string{
-	"alias", "curl", "curlie", "wget", "axel", "aria2c",
-	"nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
-	"http-prompt", "chrome", "firefox", "safari",
+	// Network/Download tools
+	"alias",
+	"aria2c",
+	"axel",
+	"chrome",
+	"curl",
+	"curlie",
+	"firefox",
+	"http-prompt",
+	"httpie",
+	"links",
+	"lynx",
+	"nc",
+	"safari",
+	"telnet",
+	"w3m",
+	"wget",
+	"xh",
+
+	// System administration
+	"doas",
+	"su",
+	"sudo",
+
+	// Package managers
+	"apk",
+	"apt",
+	"apt-cache",
+	"apt-get",
+	"dnf",
+	"dpkg",
+	"emerge",
+	"home-manager",
+	"makepkg",
+	"opkg",
+	"pacman",
+	"paru",
+	"pkg",
+	"pkg_add",
+	"pkg_delete",
+	"portage",
+	"rpm",
+	"yay",
+	"yum",
+	"zypper",
+
+	// System modification
+	"at",
+	"batch",
+	"chkconfig",
+	"crontab",
+	"fdisk",
+	"mkfs",
+	"mount",
+	"parted",
+	"service",
+	"systemctl",
+	"umount",
+
+	// Network configuration
+	"firewall-cmd",
+	"ifconfig",
+	"ip",
+	"iptables",
+	"netstat",
+	"pfctl",
+	"route",
+	"ufw",
 }
 
 // getSafeReadOnlyCommands returns platform-appropriate safe commands
@@ -244,7 +310,42 @@ Important:
 - Never update git config`, bannedCommandsStr, MaxOutputLength)
 }
 
+func blockFuncs() []shell.BlockFunc {
+	return []shell.BlockFunc{
+		shell.CommandsBlocker(bannedCommands),
+		shell.ArgumentsBlocker([][]string{
+			// System package managers
+			{"apk", "add"},
+			{"apt", "install"},
+			{"apt-get", "install"},
+			{"dnf", "install"},
+			{"emerge"},
+			{"pacman", "-S"},
+			{"pkg", "install"},
+			{"yum", "install"},
+			{"zypper", "install"},
+
+			// Language-specific package managers
+			{"brew", "install"},
+			{"cargo", "install"},
+			{"gem", "install"},
+			{"go", "install"},
+			{"npm", "install", "-g"},
+			{"npm", "install", "--global"},
+			{"pip", "install", "--user"},
+			{"pip3", "install", "--user"},
+			{"pnpm", "add", "-g"},
+			{"pnpm", "add", "--global"},
+			{"yarn", "global", "add"},
+		}),
+	}
+}
+
 func NewBashTool(permission permission.Service, workingDir string) BaseTool {
+	// Set up command blocking on the persistent shell
+	persistentShell := shell.GetPersistentShell(workingDir)
+	persistentShell.SetBlockFuncs(blockFuncs())
+
 	return &bashTool{
 		permissions: permission,
 		workingDir:  workingDir,
@@ -289,13 +390,6 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
 		return NewTextErrorResponse("missing command"), nil
 	}
 
-	baseCmd := strings.Fields(params.Command)[0]
-	for _, banned := range bannedCommands {
-		if strings.EqualFold(baseCmd, banned) {
-			return NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", baseCmd)), nil
-		}
-	}
-
 	isSafeReadOnly := false
 	cmdLower := strings.ToLower(params.Command)
 
@@ -349,7 +443,20 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
 	stdout = truncateOutput(stdout)
 	stderr = truncateOutput(stderr)
 
+	slog.Info("Bash command executed",
+		"command", params.Command,
+		"stdout", stdout,
+		"stderr", stderr,
+		"exit_code", exitCode,
+		"interrupted", interrupted,
+		"err", err,
+	)
+
 	errorMessage := stderr
+	if errorMessage == "" && err != nil {
+		errorMessage = err.Error()
+	}
+
 	if interrupted {
 		if errorMessage != "" {
 			errorMessage += "\n"

internal/lsp/transport.go 🔗

@@ -222,29 +222,32 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
 	}
 
 	// Wait for response
-	resp := <-ch
-
-	if cfg.Options.DebugLSP {
-		slog.Debug("Received response", "id", id)
-	}
-
-	if resp.Error != nil {
-		return fmt.Errorf("request failed: %s (code: %d)", resp.Error.Message, resp.Error.Code)
-	}
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case resp := <-ch:
+		if cfg.Options.DebugLSP {
+			slog.Debug("Received response", "id", id)
+		}
 
-	if result != nil {
-		// If result is a json.RawMessage, just copy the raw bytes
-		if rawMsg, ok := result.(*json.RawMessage); ok {
-			*rawMsg = resp.Result
-			return nil
+		if resp.Error != nil {
+			return fmt.Errorf("request failed: %s (code: %d)", resp.Error.Message, resp.Error.Code)
 		}
-		// Otherwise unmarshal into the provided type
-		if err := json.Unmarshal(resp.Result, result); err != nil {
-			return fmt.Errorf("failed to unmarshal result: %w", err)
+
+		if result != nil {
+			// If result is a json.RawMessage, just copy the raw bytes
+			if rawMsg, ok := result.(*json.RawMessage); ok {
+				*rawMsg = resp.Result
+				return nil
+			}
+			// Otherwise unmarshal into the provided type
+			if err := json.Unmarshal(resp.Result, result); err != nil {
+				return fmt.Errorf("failed to unmarshal result: %w", err)
+			}
 		}
-	}
 
-	return nil
+		return nil
+	}
 }
 
 // Notify sends a notification (a request without an ID that doesn't expect a response)

internal/shell/command_block_test.go 🔗

@@ -0,0 +1,123 @@
+package shell
+
+import (
+	"context"
+	"os"
+	"strings"
+	"testing"
+)
+
+func TestCommandBlocking(t *testing.T) {
+	tests := []struct {
+		name        string
+		blockFuncs  []BlockFunc
+		command     string
+		shouldBlock bool
+	}{
+		{
+			name: "block simple command",
+			blockFuncs: []BlockFunc{
+				func(args []string) bool {
+					return len(args) > 0 && args[0] == "curl"
+				},
+			},
+			command:     "curl https://example.com",
+			shouldBlock: true,
+		},
+		{
+			name: "allow non-blocked command",
+			blockFuncs: []BlockFunc{
+				func(args []string) bool {
+					return len(args) > 0 && args[0] == "curl"
+				},
+			},
+			command:     "echo hello",
+			shouldBlock: false,
+		},
+		{
+			name: "block subcommand",
+			blockFuncs: []BlockFunc{
+				func(args []string) bool {
+					return len(args) >= 2 && args[0] == "brew" && args[1] == "install"
+				},
+			},
+			command:     "brew install wget",
+			shouldBlock: true,
+		},
+		{
+			name: "allow different subcommand",
+			blockFuncs: []BlockFunc{
+				func(args []string) bool {
+					return len(args) >= 2 && args[0] == "brew" && args[1] == "install"
+				},
+			},
+			command:     "brew list",
+			shouldBlock: false,
+		},
+		{
+			name: "block npm global install with -g",
+			blockFuncs: []BlockFunc{
+				ArgumentsBlocker([][]string{
+					{"npm", "install", "-g"},
+					{"npm", "install", "--global"},
+				}),
+			},
+			command:     "npm install -g typescript",
+			shouldBlock: true,
+		},
+		{
+			name: "block npm global install with --global",
+			blockFuncs: []BlockFunc{
+				ArgumentsBlocker([][]string{
+					{"npm", "install", "-g"},
+					{"npm", "install", "--global"},
+				}),
+			},
+			command:     "npm install --global typescript",
+			shouldBlock: true,
+		},
+		{
+			name: "allow npm local install",
+			blockFuncs: []BlockFunc{
+				ArgumentsBlocker([][]string{
+					{"npm", "install", "-g"},
+					{"npm", "install", "--global"},
+				}),
+			},
+			command:     "npm install typescript",
+			shouldBlock: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Create a temporary directory for each test
+			tmpDir, err := os.MkdirTemp("", "shell-test-*")
+			if err != nil {
+				t.Fatalf("Failed to create temp dir: %v", err)
+			}
+			defer os.RemoveAll(tmpDir)
+
+			shell := NewShell(&Options{
+				WorkingDir: tmpDir,
+				BlockFuncs: tt.blockFuncs,
+			})
+
+			_, _, err = shell.Exec(context.Background(), tt.command)
+
+			if tt.shouldBlock {
+				if err == nil {
+					t.Errorf("Expected command to be blocked, but it was allowed")
+				} else if !strings.Contains(err.Error(), "not allowed for security reasons") {
+					t.Errorf("Expected security error, got: %v", err)
+				}
+			} else {
+				// For non-blocked commands, we might get other errors (like command not found)
+				// but we shouldn't get the security error
+				if err != nil && strings.Contains(err.Error(), "not allowed for security reasons") {
+					t.Errorf("Command was unexpectedly blocked: %v", err)
+				}
+			}
+		})
+	}
+}

internal/shell/shell.go 🔗

@@ -44,12 +44,16 @@ type noopLogger struct{}
 
 func (noopLogger) InfoPersist(msg string, keysAndValues ...interface{}) {}
 
+// BlockFunc is a function that determines if a command should be blocked
+type BlockFunc func(args []string) bool
+
 // Shell provides cross-platform shell execution with optional state persistence
 type Shell struct {
-	env    []string
-	cwd    string
-	mu     sync.Mutex
-	logger Logger
+	env        []string
+	cwd        string
+	mu         sync.Mutex
+	logger     Logger
+	blockFuncs []BlockFunc
 }
 
 // Options for creating a new shell
@@ -57,6 +61,7 @@ type Options struct {
 	WorkingDir string
 	Env        []string
 	Logger     Logger
+	BlockFuncs []BlockFunc
 }
 
 // NewShell creates a new shell instance with the given options
@@ -81,9 +86,10 @@ func NewShell(opts *Options) *Shell {
 	}
 
 	return &Shell{
-		cwd:    cwd,
-		env:    env,
-		logger: logger,
+		cwd:        cwd,
+		env:        env,
+		logger:     logger,
+		blockFuncs: opts.BlockFuncs,
 	}
 }
 
@@ -152,6 +158,13 @@ func (s *Shell) SetEnv(key, value string) {
 	s.env = append(s.env, keyPrefix+value)
 }
 
+// SetBlockFuncs sets the command block functions for the shell
+func (s *Shell) SetBlockFuncs(blockFuncs []BlockFunc) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.blockFuncs = blockFuncs
+}
+
 // Windows-specific commands that should use native shell
 var windowsNativeCommands = map[string]bool{
 	"dir":      true,
@@ -203,6 +216,60 @@ func (s *Shell) determineShellType(command string) ShellType {
 	return ShellTypePOSIX
 }
 
+// CommandsBlocker creates a BlockFunc that blocks exact command matches
+func CommandsBlocker(bannedCommands []string) BlockFunc {
+	bannedSet := make(map[string]bool)
+	for _, cmd := range bannedCommands {
+		bannedSet[cmd] = true
+	}
+
+	return func(args []string) bool {
+		if len(args) == 0 {
+			return false
+		}
+		return bannedSet[args[0]]
+	}
+}
+
+// ArgumentsBlocker creates a BlockFunc that blocks specific subcommands
+func ArgumentsBlocker(blockedSubCommands [][]string) BlockFunc {
+	return func(args []string) bool {
+		for _, blocked := range blockedSubCommands {
+			if len(args) >= len(blocked) {
+				match := true
+				for i, part := range blocked {
+					if args[i] != part {
+						match = false
+						break
+					}
+				}
+				if match {
+					return true
+				}
+			}
+		}
+		return false
+	}
+}
+
+func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
+	return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
+		return func(ctx context.Context, args []string) error {
+			if len(args) == 0 {
+				return next(ctx, args)
+			}
+
+			for _, blockFunc := range s.blockFuncs {
+				if blockFunc(args) {
+					return fmt.Errorf("command is not allowed for security reasons: %s", strings.Join(args, " "))
+				}
+			}
+
+			return next(ctx, args)
+		}
+	}
+}
+
 // execWindows executes commands using native Windows shells (cmd.exe or PowerShell)
 func (s *Shell) execWindows(ctx context.Context, command string, shell string) (string, string, error) {
 	var cmd *exec.Cmd
@@ -291,6 +358,7 @@ func (s *Shell) execPOSIX(ctx context.Context, command string) (string, string,
 		interp.Interactive(false),
 		interp.Env(expand.ListEnviron(s.env...)),
 		interp.Dir(s.cwd),
+		interp.ExecHandlers(s.blockHandler()),
 	)
 	if err != nil {
 		return "", "", fmt.Errorf("could not run command: %w", err)

internal/tui/components/chat/editor/editor.go 🔗

@@ -364,8 +364,9 @@ func (m *editorCmp) startCompletions() tea.Msg {
 		})
 	}
 
-	x := m.textarea.Cursor().X + m.x + 1
-	y := m.textarea.Cursor().Y + m.y + 1
+	cur := m.textarea.Cursor()
+	x := cur.X + m.x // adjust for padding
+	y := cur.Y + m.y + 1
 	return completions.OpenCompletionsMsg{
 		Completions: completionItems,
 		X:           x,

internal/tui/components/completions/completions.go 🔗

@@ -9,6 +9,8 @@ import (
 	"github.com/charmbracelet/lipgloss/v2"
 )
 
+const maxCompletionsHeight = 10
+
 type Completion struct {
 	Title string // The title of the completion item
 	Value any    // The value of the completion item
@@ -43,7 +45,7 @@ type Completions interface {
 type completionsCmp struct {
 	width  int
 	height int  // Height of the completions component`
-	x      int  // X position for the completions popup\
+	x      int  // X position for the completions popup
 	y      int  // Y position for the completions popup
 	open   bool // Indicates if the completions are open
 	keyMap KeyMap
@@ -150,18 +152,25 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if !c.open {
 			return c, nil // If completions are not open, do nothing
 		}
-		cmd := c.list.Filter(msg.Query)
-		c.height = max(min(10, len(c.list.Items())), 1)
-		return c, tea.Batch(
-			cmd,
-			c.list.SetSize(c.width, c.height),
-		)
+		var cmds []tea.Cmd
+		cmds = append(cmds, c.list.Filter(msg.Query))
+		itemsLen := len(c.list.Items())
+		c.height = max(min(maxCompletionsHeight, itemsLen), 1)
+		cmds = append(cmds, c.list.SetSize(c.width, c.height))
+		if itemsLen == 0 {
+			// Close completions if no items match the query
+			cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
+		}
+		return c, tea.Batch(cmds...)
 	}
 	return c, nil
 }
 
 // View implements Completions.
 func (c *completionsCmp) View() string {
+	if !c.open {
+		return ""
+	}
 	if len(c.list.Items()) == 0 {
 		return c.style().Render("No completions found")
 	}

main.go 🔗

@@ -1,13 +1,10 @@
 package main
 
 import (
-	"context"
 	"fmt"
 	"log/slog"
 	"net/http"
 	"os"
-	"os/signal"
-	"syscall"
 
 	_ "net/http/pprof" // profiling
 
@@ -22,19 +19,6 @@ func main() {
 		slog.Error("Application terminated due to unhandled panic")
 	})
 
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-
-	sigChan := make(chan os.Signal, 1)
-	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT)
-
-	// Start signal handler in a goroutine
-	go func() {
-		sig := <-sigChan
-		slog.Info("Received signal, initiating graceful shutdown", "signal", sig)
-		cancel()
-	}()
-
 	if os.Getenv("CRUSH_PROFILE") != "" {
 		go func() {
 			slog.Info("Serving pprof at localhost:6060")
@@ -44,5 +28,5 @@ func main() {
 		}()
 	}
 
-	cmd.Execute(ctx)
+	cmd.Execute()
 }

vendor/github.com/Azure/azure-sdk-for-go/sdk/azidentity/go.work.sum 🔗

@@ -1,60 +0,0 @@
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0-beta.1 h1:ODs3brnqQM99Tq1PffODpAViYv3Bf8zOg464MU7p5ew=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0-beta.1/go.mod h1:3Ug6Qzto9anB6mGlEdgYMDF5zHQ+wwhEaYR4s17PHMw=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0 h1:fb8kj/Dh4CSwgsOzHeZY4Xh68cFVbzXx+ONXGMY//4w=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0/go.mod h1:uReU2sSxZExRPBAg3qKzmAucSi51+SP1OhohieR821Q=
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
-github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/keybase/dbus v0.0.0-20220506165403-5aa21ea2c23a/go.mod h1:YPNKjjE7Ubp9dTbnWvsP3HT+hYnY6TfXzubYTBeUxc8=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
-github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
-golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
-golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
-golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
-golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
-golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
-golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
-golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
-golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
-golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
-golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
-golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
-golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
-golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
-golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
-golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
-golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

vendor/github.com/charmbracelet/fang/README.md 🔗

@@ -1,7 +1,7 @@
 # Fang
 
 <p>
-    <img width="485" alt="Charm Fang" src="https://github.com/user-attachments/assets/3f34ea01-3750-4760-beb2-a1b700e110f5">   
+    <img width="485" alt="Charm Fang" src="https://github.com/user-attachments/assets/3f34ea01-3750-4760-beb2-a1b700e110f5">
 </p>
 <p>
     <a href="https://github.com/charmbracelet/fang/releases"><img src="https://img.shields.io/github/release/charmbracelet/fang.svg" alt="Latest Release"></a>
@@ -12,7 +12,7 @@
 The CLI starter kit. A small, experimental library for batteries-included [Cobra][cobra] applications.
 
 <p>
-    <img width="865" alt="fang-02" src="https://github.com/user-attachments/assets/7f68ec3f-2b42-4188-a750-7e2808696132" />
+    <img width="859" alt="The Charm Fang mascot and title treatment" src="https://github.com/user-attachments/assets/5c35e1fa-9577-4f81-a879-3ddb4d4a43f0" />
 </p>
 
 ## Features
@@ -45,6 +45,7 @@ To use it, invoke `fang.Execute` passing your root `*cobra.Command`:
 package main
 
 import (
+	"context"
 	"os"
 
 	"github.com/charmbracelet/fang"
@@ -56,7 +57,7 @@ func main() {
 		Use:   "example",
 		Short: "A simple example program!",
 	}
-	if err := fang.Execute(context.TODO(), cmd); err != nil {
+	if err := fang.Execute(context.Background(), cmd); err != nil {
 		os.Exit(1)
 	}
 }

vendor/github.com/charmbracelet/fang/fang.go 🔗

@@ -4,11 +4,14 @@ package fang
 import (
 	"context"
 	"fmt"
+	"io"
 	"os"
+	"os/signal"
 	"runtime/debug"
 
 	"github.com/charmbracelet/colorprofile"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/term"
 	mango "github.com/muesli/mango-cobra"
 	"github.com/muesli/roff"
 	"github.com/spf13/cobra"
@@ -16,12 +19,24 @@ import (
 
 const shaLen = 7
 
+// ErrorHandler handles an error, printing them to the given [io.Writer].
+//
+// Note that this will only be used if the STDERR is a terminal, and should
+// be used for styling only.
+type ErrorHandler = func(w io.Writer, styles Styles, err error)
+
+// ColorSchemeFunc gets a [lipgloss.LightDarkFunc] and returns a [ColorScheme].
+type ColorSchemeFunc = func(lipgloss.LightDarkFunc) ColorScheme
+
 type settings struct {
 	completions bool
 	manpages    bool
+	skipVersion bool
 	version     string
 	commit      string
-	theme       *ColorScheme
+	colorscheme ColorSchemeFunc
+	errHandler  ErrorHandler
+	signals     []os.Signal
 }
 
 // Option changes fang settings.
@@ -41,10 +56,21 @@ func WithoutManpage() Option {
 	}
 }
 
+// WithColorSchemeFunc sets a function that return colorscheme.
+func WithColorSchemeFunc(cs ColorSchemeFunc) Option {
+	return func(s *settings) {
+		s.colorscheme = cs
+	}
+}
+
 // WithTheme sets the colorscheme.
+//
+// Deprecated: use [WithColorSchemeFunc] instead.
 func WithTheme(theme ColorScheme) Option {
 	return func(s *settings) {
-		s.theme = &theme
+		s.colorscheme = func(lipgloss.LightDarkFunc) ColorScheme {
+			return theme
+		}
 	}
 }
 
@@ -55,6 +81,13 @@ func WithVersion(version string) Option {
 	}
 }
 
+// WithoutVersion skips the `-v`/`--version` functionality.
+func WithoutVersion() Option {
+	return func(s *settings) {
+		s.skipVersion = true
+	}
+}
+
 // WithCommit sets the commit SHA.
 func WithCommit(commit string) Option {
 	return func(s *settings) {
@@ -62,30 +95,45 @@ func WithCommit(commit string) Option {
 	}
 }
 
+// WithErrorHandler sets the error handler.
+func WithErrorHandler(handler ErrorHandler) Option {
+	return func(s *settings) {
+		s.errHandler = handler
+	}
+}
+
+// WithNotifySignal sets the signals that should interrupt the execution of the
+// program.
+func WithNotifySignal(signals ...os.Signal) Option {
+	return func(s *settings) {
+		s.signals = signals
+	}
+}
+
 // Execute applies fang to the command and executes it.
 func Execute(ctx context.Context, root *cobra.Command, options ...Option) error {
 	opts := settings{
 		manpages:    true,
 		completions: true,
+		colorscheme: DefaultColorScheme,
+		errHandler:  DefaultErrorHandler,
 	}
+
 	for _, option := range options {
 		option(&opts)
 	}
 
-	if opts.theme == nil {
-		isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stderr)
-		t := DefaultTheme(isDark)
-		opts.theme = &t
+	helpFunc := func(c *cobra.Command, _ []string) {
+		w := colorprofile.NewWriter(c.OutOrStdout(), os.Environ())
+		helpFn(c, w, makeStyles(mustColorscheme(opts.colorscheme)))
 	}
 
-	styles := makeStyles(*opts.theme)
-
-	root.SetHelpFunc(func(c *cobra.Command, _ []string) {
-		w := colorprofile.NewWriter(c.OutOrStdout(), os.Environ())
-		helpFn(c, w, styles)
-	})
 	root.SilenceUsage = true
 	root.SilenceErrors = true
+	if !opts.skipVersion {
+		root.Version = buildVersion(opts)
+	}
+	root.SetHelpFunc(helpFunc)
 
 	if opts.manpages {
 		root.AddCommand(&cobra.Command{
@@ -108,34 +156,49 @@ func Execute(ctx context.Context, root *cobra.Command, options ...Option) error
 		})
 	}
 
-	if opts.completions {
-		root.InitDefaultCompletionCmd()
-	} else {
+	if !opts.completions {
 		root.CompletionOptions.DisableDefaultCmd = true
 	}
 
-	if opts.version == "" {
-		if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" {
-			opts.version = info.Main.Version
-			opts.commit = getKey(info, "vcs.revision")
-		} else {
-			opts.version = "unknown (built from source)"
-		}
-	}
-	if len(opts.commit) >= shaLen {
-		opts.version += " (" + opts.commit[:shaLen] + ")"
+	if len(opts.signals) > 0 {
+		var cancel context.CancelFunc
+		ctx, cancel = signal.NotifyContext(ctx, opts.signals...)
+		defer cancel()
 	}
 
-	root.Version = opts.version
-
 	if err := root.ExecuteContext(ctx); err != nil {
+		if w, ok := root.ErrOrStderr().(term.File); ok {
+			// if stderr is not a tty, simply print the error without any
+			// styling or going through an [ErrorHandler]:
+			if !term.IsTerminal(w.Fd()) {
+				_, _ = fmt.Fprintln(w, err.Error())
+				return err //nolint:wrapcheck
+			}
+		}
 		w := colorprofile.NewWriter(root.ErrOrStderr(), os.Environ())
-		writeError(w, styles, err)
+		opts.errHandler(w, makeStyles(mustColorscheme(opts.colorscheme)), err)
 		return err //nolint:wrapcheck
 	}
 	return nil
 }
 
+func buildVersion(opts settings) string {
+	commit := opts.commit
+	version := opts.version
+	if version == "" {
+		if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" {
+			version = info.Main.Version
+			commit = getKey(info, "vcs.revision")
+		} else {
+			version = "unknown (built from source)"
+		}
+	}
+	if len(commit) >= shaLen {
+		version += " (" + commit[:shaLen] + ")"
+	}
+	return version
+}
+
 func getKey(info *debug.BuildInfo, key string) string {
 	if info == nil {
 		return ""

vendor/github.com/charmbracelet/fang/help.go 🔗

@@ -3,7 +3,10 @@ package fang
 import (
 	"cmp"
 	"fmt"
+	"io"
+	"iter"
 	"os"
+	"reflect"
 	"regexp"
 	"strconv"
 	"strings"
@@ -20,6 +23,7 @@ import (
 const (
 	minSpace = 10
 	shortPad = 2
+	longPad  = 4
 )
 
 var width = sync.OnceValue(func() int {
@@ -45,65 +49,95 @@ func helpFn(c *cobra.Command, w *colorprofile.Writer, styles Styles) {
 		blockWidth = max(blockWidth, lipgloss.Width(ex))
 	}
 	blockWidth = min(width()-padding, blockWidth+padding)
+	blockStyle := styles.Codeblock.Base.Width(blockWidth)
 
-	styles.Codeblock.Base = styles.Codeblock.Base.Width(blockWidth)
+	// if the color profile is ascii or notty, or if the block has no
+	// background color set, remove the vertical padding.
+	if w.Profile <= colorprofile.Ascii || reflect.DeepEqual(blockStyle.GetBackground(), lipgloss.NoColor{}) {
+		blockStyle = blockStyle.PaddingTop(0).PaddingBottom(0)
+	}
 
 	_, _ = fmt.Fprintln(w, styles.Title.Render("usage"))
-	_, _ = fmt.Fprintln(w, styles.Codeblock.Base.Render(usage))
+	_, _ = fmt.Fprintln(w, blockStyle.Render(usage))
 	if len(examples) > 0 {
-		cw := styles.Codeblock.Base.GetWidth() - styles.Codeblock.Base.GetHorizontalPadding()
+		cw := blockStyle.GetWidth() - blockStyle.GetHorizontalPadding()
 		_, _ = fmt.Fprintln(w, styles.Title.Render("examples"))
 		for i, example := range examples {
 			if lipgloss.Width(example) > cw {
 				examples[i] = ansi.Truncate(example, cw, "…")
 			}
 		}
-		_, _ = fmt.Fprintln(w, styles.Codeblock.Base.Render(strings.Join(examples, "\n")))
+		_, _ = fmt.Fprintln(w, blockStyle.Render(strings.Join(examples, "\n")))
 	}
 
+	groups, groupKeys := evalGroups(c)
 	cmds, cmdKeys := evalCmds(c, styles)
 	flags, flagKeys := evalFlags(c, styles)
 	space := calculateSpace(cmdKeys, flagKeys)
 
-	leftPadding := 4
-	if len(cmds) > 0 {
-		_, _ = fmt.Fprintln(w, styles.Title.Render("commands"))
-		for _, k := range cmdKeys {
-			_, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				lipgloss.NewStyle().PaddingLeft(leftPadding).Render(k),
-				strings.Repeat(" ", space-lipgloss.Width(k)),
-				cmds[k],
-			))
+	for _, groupID := range groupKeys {
+		group := cmds[groupID]
+		if len(group) == 0 {
+			continue
 		}
+		renderGroup(w, styles, space, groups[groupID], func(yield func(string, string) bool) {
+			for _, k := range cmdKeys {
+				cmds, ok := group[k]
+				if !ok {
+					continue
+				}
+				if !yield(k, cmds) {
+					return
+				}
+			}
+		})
 	}
 
 	if len(flags) > 0 {
-		_, _ = fmt.Fprintln(w, styles.Title.Render("flags"))
-		for _, k := range flagKeys {
-			_, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				lipgloss.NewStyle().PaddingLeft(leftPadding).Render(k),
-				strings.Repeat(" ", space-lipgloss.Width(k)),
-				flags[k],
-			))
-		}
+		renderGroup(w, styles, space, "flags", func(yield func(string, string) bool) {
+			for _, k := range flagKeys {
+				if !yield(k, flags[k]) {
+					return
+				}
+			}
+		})
 	}
 
 	_, _ = fmt.Fprintln(w)
 }
 
-func writeError(w *colorprofile.Writer, styles Styles, err error) {
+// DefaultErrorHandler is the default [ErrorHandler] implementation.
+func DefaultErrorHandler(w io.Writer, styles Styles, err error) {
 	_, _ = fmt.Fprintln(w, styles.ErrorHeader.String())
 	_, _ = fmt.Fprintln(w, styles.ErrorText.Render(err.Error()+"."))
 	_, _ = fmt.Fprintln(w)
-	_, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
-		lipgloss.Left,
-		styles.ErrorText.UnsetWidth().Render("Try"),
-		styles.Program.Flag.Render("--help"),
-		styles.ErrorText.UnsetWidth().UnsetMargins().UnsetTransform().PaddingLeft(1).Render("for usage."),
-	))
-	_, _ = fmt.Fprintln(w)
+	if isUsageError(err) {
+		_, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			styles.ErrorText.UnsetWidth().Render("Try"),
+			styles.Program.Flag.Render(" --help "),
+			styles.ErrorText.UnsetWidth().UnsetMargins().UnsetTransform().Render("for usage."),
+		))
+		_, _ = fmt.Fprintln(w)
+	}
+}
+
+// XXX: this is a hack to detect usage errors.
+// See: https://github.com/spf13/cobra/pull/2266
+func isUsageError(err error) bool {
+	s := err.Error()
+	for _, prefix := range []string{
+		"flag needs an argument:",
+		"unknown flag:",
+		"unknown shorthand flag:",
+		"unknown command",
+		"invalid argument",
+	} {
+		if strings.HasPrefix(s, prefix) {
+			return true
+		}
+	}
+	return false
 }
 
 func writeLongShort(w *colorprofile.Writer, styles Styles, longShort string) {
@@ -118,8 +152,10 @@ var otherArgsRe = regexp.MustCompile(`(\[.*\])`)
 
 // styleUsage stylized styleUsage line for a given command.
 func styleUsage(c *cobra.Command, styles Program, complete bool) string {
-	// XXX: maybe use c.UseLine() here?
 	u := c.Use
+	if complete {
+		u = c.UseLine()
+	}
 	hasArgs := strings.Contains(u, "[args]")
 	hasFlags := strings.Contains(u, "[flags]") || strings.Contains(u, "[--flags]") || c.HasFlags() || c.HasPersistentFlags() || c.HasAvailableFlags()
 	hasCommands := strings.Contains(u, "[command]") || c.HasAvailableSubCommands()
@@ -139,34 +175,38 @@ func styleUsage(c *cobra.Command, styles Program, complete bool) string {
 
 	u = strings.TrimSpace(u)
 
-	useLine := []string{
-		styles.Name.Render(u),
-	}
-	if !complete {
-		useLine[0] = styles.Command.Render(u)
+	useLine := []string{}
+	if complete {
+		parts := strings.Fields(u)
+		useLine = append(useLine, styles.Name.Render(parts[0]))
+		if len(parts) > 1 {
+			useLine = append(useLine, styles.Command.Render(" "+strings.Join(parts[1:], " ")))
+		}
+	} else {
+		useLine = append(useLine, styles.Command.Render(u))
 	}
 	if hasCommands {
 		useLine = append(
 			useLine,
-			styles.DimmedArgument.Render("[command]"),
+			styles.DimmedArgument.Render(" [command]"),
 		)
 	}
 	if hasArgs {
 		useLine = append(
 			useLine,
-			styles.DimmedArgument.Render("[args]"),
+			styles.DimmedArgument.Render(" [args]"),
 		)
 	}
 	for _, arg := range otherArgs {
 		useLine = append(
 			useLine,
-			styles.DimmedArgument.Render(arg),
+			styles.DimmedArgument.Render(" "+arg),
 		)
 	}
 	if hasFlags {
 		useLine = append(
 			useLine,
-			styles.DimmedArgument.Render("[--flags]"),
+			styles.DimmedArgument.Render(" [--flags]"),
 		)
 	}
 	return lipgloss.JoinHorizontal(lipgloss.Left, useLine...)
@@ -180,19 +220,21 @@ func styleExamples(c *cobra.Command, styles Styles) []string {
 	}
 	usage := []string{}
 	examples := strings.Split(c.Example, "\n")
+	var indent bool
 	for i, line := range examples {
 		line = strings.TrimSpace(line)
 		if (i == 0 || i == len(examples)-1) && line == "" {
 			continue
 		}
-		s := styleExample(c, line, styles.Codeblock)
+		s := styleExample(c, line, indent, styles.Codeblock)
 		usage = append(usage, s)
+		indent = len(line) > 1 && (line[len(line)-1] == '\\' || line[len(line)-1] == '|')
 	}
 
 	return usage
 }
 
-func styleExample(c *cobra.Command, line string, styles Codeblock) string {
+func styleExample(c *cobra.Command, line string, indent bool, styles Codeblock) string {
 	if strings.HasPrefix(line, "# ") {
 		return lipgloss.JoinHorizontal(
 			lipgloss.Left,
@@ -200,66 +242,110 @@ func styleExample(c *cobra.Command, line string, styles Codeblock) string {
 		)
 	}
 
-	args := strings.Fields(line)
-	var nextIsFlag bool
 	var isQuotedString bool
+	var foundProgramName bool
+	var isRedirecting bool
+	programName := c.Root().Name()
+	args := strings.Fields(line)
+	var cleanArgs []string
 	for i, arg := range args {
-		if i == 0 {
-			args[i] = styles.Program.Name.Render(arg)
-			continue
+		isQuoteStart := arg[0] == '"' || arg[0] == '\''
+		isQuoteEnd := arg[len(arg)-1] == '"' || arg[len(arg)-1] == '\''
+		isFlag := arg[0] == '-'
+
+		switch i {
+		case 0:
+			args[i] = ""
+			if indent {
+				args[i] = styles.Program.DimmedArgument.Render("  ")
+				indent = false
+			}
+		default:
+			args[i] = styles.Program.DimmedArgument.Render(" ")
 		}
 
-		quoteStart := arg[0] == '"'
-		quoteEnd := arg[len(arg)-1] == '"'
-		flagStart := arg[0] == '-'
-		if i == 1 && !quoteStart && !flagStart {
-			args[i] = styles.Program.Command.Render(arg)
+		if isRedirecting {
+			args[i] += styles.Program.DimmedArgument.Render(arg)
+			isRedirecting = false
 			continue
 		}
-		if quoteStart {
-			isQuotedString = true
-		}
-		if isQuotedString {
-			args[i] = styles.Program.QuotedString.Render(arg)
-			if quoteEnd {
-				isQuotedString = false
+
+		switch arg {
+		case "\\":
+			if i == len(args)-1 {
+				args[i] += styles.Program.DimmedArgument.Render(arg)
+				continue
 			}
+		case "|", "||", "-", "&", "&&":
+			args[i] += styles.Program.DimmedArgument.Render(arg)
 			continue
 		}
-		if nextIsFlag {
-			args[i] = styles.Program.Flag.Render(arg)
+
+		if isRedirect(arg) {
+			args[i] += styles.Program.DimmedArgument.Render(arg)
+			isRedirecting = true
 			continue
 		}
-		var dashes string
-		if strings.HasPrefix(arg, "-") {
-			dashes = "-"
+
+		if !foundProgramName { //nolint:nestif
+			if isQuotedString {
+				args[i] += styles.Program.QuotedString.Render(arg)
+				isQuotedString = !isQuoteEnd
+				continue
+			}
+			if left, right, ok := strings.Cut(arg, "="); ok {
+				args[i] += styles.Program.Flag.Render(left + "=")
+				if right[0] == '"' {
+					isQuotedString = true
+					args[i] += styles.Program.QuotedString.Render(right)
+					continue
+				}
+				args[i] += styles.Program.Argument.Render(right)
+				continue
+			}
+
+			if arg == programName {
+				args[i] += styles.Program.Name.Render(arg)
+				foundProgramName = true
+				continue
+			}
 		}
-		if strings.HasPrefix(arg, "--") {
-			dashes = "--"
+
+		if !isQuoteStart && !isQuotedString && !isFlag {
+			cleanArgs = append(cleanArgs, arg)
+		}
+
+		if !isQuoteStart && !isFlag && isSubCommand(c, cleanArgs, arg) {
+			args[i] += styles.Program.Command.Render(arg)
+			continue
+		}
+		isQuotedString = isQuotedString || isQuoteStart
+		if isQuotedString {
+			args[i] += styles.Program.QuotedString.Render(arg)
+			isQuotedString = !isQuoteEnd
+			continue
 		}
 		// handle a flag
-		if dashes != "" {
+		if isFlag {
 			name, value, ok := strings.Cut(arg, "=")
-			name = strings.TrimPrefix(name, dashes)
 			// it is --flag=value
 			if ok {
-				args[i] = lipgloss.JoinHorizontal(
+				args[i] += lipgloss.JoinHorizontal(
 					lipgloss.Left,
-					styles.Program.Flag.Render(dashes+name+"="),
-					styles.Program.Argument.UnsetPadding().Render(value),
+					styles.Program.Flag.Render(name+"="),
+					styles.Program.Argument.Render(value),
 				)
 				continue
 			}
 			// it is either --bool-flag or --flag value
-			args[i] = lipgloss.JoinHorizontal(
+			args[i] += lipgloss.JoinHorizontal(
 				lipgloss.Left,
-				styles.Program.Flag.Render(dashes+name),
+				styles.Program.Flag.Render(name),
 			)
-			// if the flag is not a bool flag, next arg continues current flag
-			nextIsFlag = !isFlagBool(c, name)
 			continue
 		}
-		args[i] = styles.Program.Argument.Render(arg)
+
+		args[i] += styles.Program.Argument.Render(arg)
 	}
 
 	return lipgloss.JoinHorizontal(
@@ -284,8 +370,7 @@ func evalFlags(c *cobra.Command, styles Styles) (map[string]string, []string) {
 		} else {
 			parts = append(
 				parts,
-				styles.Program.Flag.Render("-"+f.Shorthand),
-				styles.Program.Flag.Render("--"+f.Name),
+				styles.Program.Flag.Render("-"+f.Shorthand+" --"+f.Name),
 			)
 		}
 		key := lipgloss.JoinHorizontal(lipgloss.Left, parts...)
@@ -303,22 +388,50 @@ func evalFlags(c *cobra.Command, styles Styles) (map[string]string, []string) {
 	return flags, keys
 }
 
-func evalCmds(c *cobra.Command, styles Styles) (map[string]string, []string) {
+// result is map[groupID]map[styled cmd name]styled cmd help, and the keys in
+// the order they are defined.
+func evalCmds(c *cobra.Command, styles Styles) (map[string](map[string]string), []string) {
 	padStyle := lipgloss.NewStyle().PaddingLeft(0) //nolint:mnd
 	keys := []string{}
-	cmds := map[string]string{}
+	cmds := map[string]map[string]string{}
 	for _, sc := range c.Commands() {
 		if sc.Hidden {
 			continue
 		}
+		if _, ok := cmds[sc.GroupID]; !ok {
+			cmds[sc.GroupID] = map[string]string{}
+		}
 		key := padStyle.Render(styleUsage(sc, styles.Program, false))
 		help := styles.FlagDescription.Render(sc.Short)
-		cmds[key] = help
+		cmds[sc.GroupID][key] = help
 		keys = append(keys, key)
 	}
 	return cmds, keys
 }
 
+func evalGroups(c *cobra.Command) (map[string]string, []string) {
+	// make sure the default group is the first
+	ids := []string{""}
+	groups := map[string]string{"": "commands"}
+	for _, g := range c.Groups() {
+		groups[g.ID] = g.Title
+		ids = append(ids, g.ID)
+	}
+	return groups, ids
+}
+
+func renderGroup(w io.Writer, styles Styles, space int, name string, items iter.Seq2[string, string]) {
+	_, _ = fmt.Fprintln(w, styles.Title.Render(name))
+	for key, help := range items {
+		_, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			lipgloss.NewStyle().PaddingLeft(longPad).Render(key),
+			strings.Repeat(" ", space-lipgloss.Width(key)),
+			help,
+		))
+	}
+}
+
 func calculateSpace(k1, k2 []string) int {
 	const spaceBetween = 2
 	space := minSpace
@@ -328,13 +441,18 @@ func calculateSpace(k1, k2 []string) int {
 	return space
 }
 
-func isFlagBool(c *cobra.Command, name string) bool {
-	flag := c.Flags().Lookup(name)
-	if flag == nil && len(name) == 1 {
-		flag = c.Flags().ShorthandLookup(name)
-	}
-	if flag == nil {
-		return false
+func isSubCommand(c *cobra.Command, args []string, word string) bool {
+	cmd, _, _ := c.Root().Traverse(args)
+	return cmd != nil && cmd.Name() == word
+}
+
+var redirectPrefixes = []string{">", "<", "&>", "2>", "1>", ">>", "2>>"}
+
+func isRedirect(s string) bool {
+	for _, p := range redirectPrefixes {
+		if strings.HasPrefix(s, p) {
+			return true
+		}
 	}
-	return flag.Value.Type() == "bool"
+	return false
 }

vendor/github.com/charmbracelet/fang/theme.go 🔗

@@ -2,10 +2,12 @@ package fang
 
 import (
 	"image/color"
+	"os"
 	"strings"
 
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/exp/charmtone"
+	"github.com/charmbracelet/x/term"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 )
@@ -31,8 +33,14 @@ type ColorScheme struct {
 }
 
 // DefaultTheme is the default colorscheme.
+//
+// Deprecated: use [DefaultColorScheme] instead.
 func DefaultTheme(isDark bool) ColorScheme {
-	c := lipgloss.LightDark(isDark)
+	return DefaultColorScheme(lipgloss.LightDark(isDark))
+}
+
+// DefaultColorScheme is the default colorscheme.
+func DefaultColorScheme(c lipgloss.LightDarkFunc) ColorScheme {
 	return ColorScheme{
 		Base:           c(charmtone.Charcoal, charmtone.Ash),
 		Title:          charmtone.Charple,
@@ -45,7 +53,7 @@ func DefaultTheme(isDark bool) ColorScheme {
 		Argument:       c(charmtone.Charcoal, charmtone.Ash),
 		Description:    c(charmtone.Charcoal, charmtone.Ash), // flag and command descriptions
 		FlagDefault:    c(charmtone.Smoke, charmtone.Squid),  // flag default values in descriptions
-		QuotedString:   c(charmtone.Charcoal, charmtone.Ash),
+		QuotedString:   c(charmtone.Coral, charmtone.Salmon),
 		ErrorHeader: [2]color.Color{
 			charmtone.Butter,
 			charmtone.Cherry,
@@ -53,6 +61,26 @@ func DefaultTheme(isDark bool) ColorScheme {
 	}
 }
 
+// AnsiColorScheme is a ANSI colorscheme.
+func AnsiColorScheme(c lipgloss.LightDarkFunc) ColorScheme {
+	base := c(lipgloss.Black, lipgloss.White)
+	return ColorScheme{
+		Base:         base,
+		Title:        lipgloss.Blue,
+		Description:  base,
+		Comment:      c(lipgloss.BrightWhite, lipgloss.BrightBlack),
+		Flag:         lipgloss.Magenta,
+		FlagDefault:  lipgloss.BrightMagenta,
+		Command:      lipgloss.Cyan,
+		QuotedString: lipgloss.Green,
+		Argument:     base,
+		Help:         base,
+		Dash:         base,
+		ErrorHeader:  [2]color.Color{lipgloss.Black, lipgloss.Red},
+		ErrorDetails: lipgloss.Red,
+	}
+}
+
 // Styles represents all the styles used.
 type Styles struct {
 	Text            lipgloss.Style
@@ -84,6 +112,14 @@ type Program struct {
 	QuotedString   lipgloss.Style
 }
 
+func mustColorscheme(cs func(lipgloss.LightDarkFunc) ColorScheme) ColorScheme {
+	var isDark bool
+	if term.IsTerminal(os.Stdout.Fd()) {
+		isDark = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
+	}
+	return cs(lipgloss.LightDark(isDark))
+}
+
 func makeStyles(cs ColorScheme) Styles {
 	//nolint:mnd
 	return Styles{
@@ -98,8 +134,7 @@ func makeStyles(cs ColorScheme) Styles {
 			Foreground(cs.Description).
 			Transform(titleFirstWord),
 		FlagDefault: lipgloss.NewStyle().
-			Foreground(cs.FlagDefault).
-			PaddingLeft(1),
+			Foreground(cs.FlagDefault),
 		Codeblock: Codeblock{
 			Base: lipgloss.NewStyle().
 				Background(cs.Codeblock).
@@ -116,23 +151,18 @@ func makeStyles(cs ColorScheme) Styles {
 					Background(cs.Codeblock).
 					Foreground(cs.Program),
 				Flag: lipgloss.NewStyle().
-					PaddingLeft(1).
 					Background(cs.Codeblock).
 					Foreground(cs.Flag),
 				Argument: lipgloss.NewStyle().
-					PaddingLeft(1).
 					Background(cs.Codeblock).
 					Foreground(cs.Argument),
 				DimmedArgument: lipgloss.NewStyle().
-					PaddingLeft(1).
 					Background(cs.Codeblock).
 					Foreground(cs.DimmedArgument),
 				Command: lipgloss.NewStyle().
-					PaddingLeft(1).
 					Background(cs.Codeblock).
 					Foreground(cs.Command),
 				QuotedString: lipgloss.NewStyle().
-					PaddingLeft(1).
 					Background(cs.Codeblock).
 					Foreground(cs.QuotedString),
 			},
@@ -141,18 +171,14 @@ func makeStyles(cs ColorScheme) Styles {
 			Name: lipgloss.NewStyle().
 				Foreground(cs.Program),
 			Argument: lipgloss.NewStyle().
-				PaddingLeft(1).
 				Foreground(cs.Argument),
 			DimmedArgument: lipgloss.NewStyle().
-				PaddingLeft(1).
 				Foreground(cs.DimmedArgument),
 			Flag: lipgloss.NewStyle().
-				PaddingLeft(1).
 				Foreground(cs.Flag),
 			Command: lipgloss.NewStyle().
 				Foreground(cs.Command),
 			QuotedString: lipgloss.NewStyle().
-				PaddingLeft(1).
 				Foreground(cs.QuotedString),
 		},
 		Span: lipgloss.NewStyle().

vendor/modules.txt 🔗

@@ -260,8 +260,8 @@ github.com/charmbracelet/bubbletea/v2
 # github.com/charmbracelet/colorprofile v0.3.1
 ## explicit; go 1.23.0
 github.com/charmbracelet/colorprofile
-# github.com/charmbracelet/fang v0.1.0
-## explicit; go 1.23.0
+# github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
+## explicit; go 1.24.0
 github.com/charmbracelet/fang
 # github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
 ## explicit; go 1.23.0
@@ -269,7 +269,7 @@ github.com/charmbracelet/glamour/v2
 github.com/charmbracelet/glamour/v2/ansi
 github.com/charmbracelet/glamour/v2/internal/autolink
 github.com/charmbracelet/glamour/v2/styles
-# github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb
+# github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb
 ## explicit; go 1.24.2
 github.com/charmbracelet/lipgloss/v2
 github.com/charmbracelet/lipgloss/v2/table