on main help sign in
fix: non-interactive and context cancellation when SIGINT (#143)
2aa5f61Carlos Alexandro Becker
.gitignore+1
@@ -16,6 +16,7 @@
16
16
17# Go workspace file
17# Go workspace file
18go.work
18go.work
19+go.work.sum
19
20
20# IDE specific files
21# IDE specific files
21.idea/
22.idea/
cmd/root.go+3−3
@@ -6,6 +6,7 @@ import (
6 "io"
6 "io"
7 "log/slog"
7 "log/slog"
8 "os"
8 "os"
9+ "syscall"
9 "time"
10 "time"
10
11
11 tea "github.com/charmbracelet/bubbletea/v2"
12 tea "github.com/charmbracelet/bubbletea/v2"
@@ -72,9 +73,7 @@ to assist developers in writing, debugging, and understanding code directly from
72 return err
73 return err
73 }
74 }
74
75
75− // Create main context for the application
76+ ctx := cmd.Context()
76− ctx, cancel := context.WithCancel(context.Background())
77− defer cancel()
78
77
79 // Connect DB, this will also run migrations
78 // Connect DB, this will also run migrations
80 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
79 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
@@ -145,6 +144,7 @@ func Execute() {
145 context.Background(),
144 context.Background(),
146 rootCmd,
145 rootCmd,
147 fang.WithVersion(version.Version),
146 fang.WithVersion(version.Version),
147+ fang.WithNotifySignal(os.Interrupt, syscall.SIGTERM),
148 ); err != nil {
148 ); err != nil {
149 os.Exit(1)
149 os.Exit(1)
150 }
150 }
go.mod+2−2
@@ -18,9 +18,9 @@ require (
18 github.com/charlievieth/fastwalk v1.0.11
18 github.com/charlievieth/fastwalk v1.0.11
19 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198
19 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198
20 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1
20 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1
21− github.com/charmbracelet/fang v0.1.0
21+ github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
22 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
22 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
23− github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71
23+ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
24 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706
24 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706
25 github.com/charmbracelet/x/ansi v0.9.3
25 github.com/charmbracelet/x/ansi v0.9.3
26 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
26 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
go.sum+2−2
@@ -74,8 +74,8 @@ github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e59
74github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595/go.mod h1:+Tl7rePElw6OKt382t04zXwtPFoPXxAaJzNrYmtsLds=
74github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595/go.mod h1:+Tl7rePElw6OKt382t04zXwtPFoPXxAaJzNrYmtsLds=
75github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
75github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
76github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
76github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
77−github.com/charmbracelet/fang v0.1.0 h1:SlZS2crf3/zQh7Mr4+W+7QR1k+L08rrPX5rm5z3d7Wg=
77+github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
78−github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc=
78+github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674/go.mod h1:9gCUAHmVx5BwSafeyNr3GI0GgvlB1WYjL21SkPp1jyU=
79github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
79github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
80github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
80github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
81github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb h1:lswj7CYZVYbLn2OhYJsXOMRQQGdRIfyuSnh5FdVSMr0=
81github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb h1:lswj7CYZVYbLn2OhYJsXOMRQQGdRIfyuSnh5FdVSMr0=
internal/app/app.go+4−1
@@ -95,10 +95,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
95func (a *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool) error {
95func (a *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool) error {
96 slog.Info("Running in non-interactive mode")
96 slog.Info("Running in non-interactive mode")
97
97
98+ ctx, cancel := context.WithCancel(ctx)
99+ defer cancel()
100+
98 // Start spinner if not in quiet mode
101 // Start spinner if not in quiet mode
99 var spinner *format.Spinner
102 var spinner *format.Spinner
100 if !quiet {
103 if !quiet {
101− spinner = format.NewSpinner(ctx, "Generating")
104+ spinner = format.NewSpinner(ctx, cancel, "Generating")
102 spinner.Start()
105 spinner.Start()
103 }
106 }
104 // Helper function to stop spinner once
107 // Helper function to stop spinner once
internal/format/spinner.go+37−13
@@ -18,24 +18,48 @@ type Spinner struct {
18 prog *tea.Program
18 prog *tea.Program
19}
19}
20
20
21+type model struct {
22+ cancel context.CancelFunc
23+ anim anim.Anim
24+}
25+
26+func (m model) Init() tea.Cmd { return m.anim.Init() }
27+func (m model) View() string { return m.anim.View() }
28+
29+// Update implements tea.Model.
30+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
31+ switch msg := msg.(type) {
32+ case tea.KeyPressMsg:
33+ switch msg.String() {
34+ case "ctrl+c", "esc":
35+ m.cancel()
36+ return m, tea.Quit
37+ }
38+ }
39+ mm, cmd := m.anim.Update(msg)
40+ m.anim = mm.(anim.Anim)
41+ return m, cmd
42+}
43+
21// NewSpinner creates a new spinner with the given message
44// NewSpinner creates a new spinner with the given message
22−func NewSpinner(ctx context.Context, message string) *Spinner {
45+func NewSpinner(ctx context.Context, cancel context.CancelFunc, message string) *Spinner {
23 t := styles.CurrentTheme()
46 t := styles.CurrentTheme()
47+ model := model{
24− model := anim.New(anim.Settings{
48+ anim: anim.New(anim.Settings{
25− Size: 10,
49+ Size: 10,
26− Label: message,
50+ Label: message,
27− LabelColor: t.FgBase,
51+ LabelColor: t.FgBase,
28− GradColorA: t.Primary,
52+ GradColorA: t.Primary,
29− GradColorB: t.Secondary,
53+ GradColorB: t.Secondary,
30− CycleColors: true,
54+ CycleColors: true,
31− })
55+ }),
56+ cancel: cancel,
57+ }
32
58
33 prog := tea.NewProgram(
59 prog := tea.NewProgram(
34 model,
60 model,
35− tea.WithInput(nil),
36 tea.WithOutput(os.Stderr),
61 tea.WithOutput(os.Stderr),
37 tea.WithContext(ctx),
62 tea.WithContext(ctx),
38− tea.WithoutCatchPanics(),
39 )
63 )
40
64
41 return &Spinner{
65 return &Spinner{
@@ -47,13 +71,13 @@ func NewSpinner(ctx context.Context, message string) *Spinner {
47// Start begins the spinner animation
71// Start begins the spinner animation
48func (s *Spinner) Start() {
72func (s *Spinner) Start() {
49 go func() {
73 go func() {
74+ defer close(s.done)
50 _, err := s.prog.Run()
75 _, err := s.prog.Run()
51 // ensures line is cleared
76 // ensures line is cleared
52 fmt.Fprint(os.Stderr, ansi.EraseEntireLine)
77 fmt.Fprint(os.Stderr, ansi.EraseEntireLine)
53− if err != nil && !errors.Is(err, context.Canceled) {
78+ if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, tea.ErrInterrupted) {
54 fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
79 fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
55 }
80 }
56− close(s.done)
57 }()
81 }()
58}
82}
59
83
internal/lsp/transport.go+22−19
@@ -222,29 +222,32 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
222 }
222 }
223
223
224 // Wait for response
224 // Wait for response
225+ select {
226+ case <-ctx.Done():
227+ return ctx.Err()
225− resp := <-ch
228+ case resp := <-ch:
226−
227− if cfg.Options.DebugLSP {
229+ if cfg.Options.DebugLSP {
228− slog.Debug("Received response", "id", id)
230+ slog.Debug("Received response", "id", id)
229− }
230−
231− if resp.Error != nil {
232− return fmt.Errorf("request failed: %s (code: %d)", resp.Error.Message, resp.Error.Code)
233− }
231+ }
234
232
235− if result != nil {
233+ if resp.Error != nil {
236− // If result is a json.RawMessage, just copy the raw bytes
234+ return fmt.Errorf("request failed: %s (code: %d)", resp.Error.Message, resp.Error.Code)
237− if rawMsg, ok := result.(*json.RawMessage); ok {
238− *rawMsg = resp.Result
239− return nil
240 }
235 }
236+
237+ if result != nil {
238+ // If result is a json.RawMessage, just copy the raw bytes
239+ if rawMsg, ok := result.(*json.RawMessage); ok {
240+ *rawMsg = resp.Result
241+ return nil
242+ }
241− // Otherwise unmarshal into the provided type
243+ // Otherwise unmarshal into the provided type
242− if err := json.Unmarshal(resp.Result, result); err != nil {
244+ if err := json.Unmarshal(resp.Result, result); err != nil {
243− return fmt.Errorf("failed to unmarshal result: %w", err)
245+ return fmt.Errorf("failed to unmarshal result: %w", err)
246+ }
244 }
247 }
245− }
246
248
247− return nil
249+ return nil
250+ }
248}
251}
249
252
250// Notify sends a notification (a request without an ID that doesn't expect a response)
253// Notify sends a notification (a request without an ID that doesn't expect a response)
vendor/github.com/Azure/azure-sdk-for-go/sdk/azidentity/go.work.sumdeleted−60
@@ -1,60 +0,0 @@
1−github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0-beta.1 h1:ODs3brnqQM99Tq1PffODpAViYv3Bf8zOg464MU7p5ew=
2−github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0-beta.1/go.mod h1:3Ug6Qzto9anB6mGlEdgYMDF5zHQ+wwhEaYR4s17PHMw=
3−github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0 h1:fb8kj/Dh4CSwgsOzHeZY4Xh68cFVbzXx+ONXGMY//4w=
4−github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0/go.mod h1:uReU2sSxZExRPBAg3qKzmAucSi51+SP1OhohieR821Q=
5−github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
6−github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
7−github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8−github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
9−github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
10−github.com/keybase/dbus v0.0.0-20220506165403-5aa21ea2c23a/go.mod h1:YPNKjjE7Ubp9dTbnWvsP3HT+hYnY6TfXzubYTBeUxc8=
11−github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
12−github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
13−github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
14−github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
15−github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
16−github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
17−github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
18−github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
19−github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
20−github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
21−github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
22−github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
23−github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
24−github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
25−github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
26−github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
27−github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
28−golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
29−golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
30−golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
31−golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
32−golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
33−golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
34−golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
35−golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
36−golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
37−golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
38−golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
39−golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
40−golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
41−golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
42−golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
43−golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
44−golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
45−golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
46−golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
47−golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
48−golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
49−golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
50−golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
51−golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
52−golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
53−golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
54−golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
55−golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
56−golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
57−golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
58−gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
59−gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
60−gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
vendor/github.com/charmbracelet/fang/README.md+4−3
@@ -1,7 +1,7 @@
1# Fang
1# Fang
2
2
3<p>
3<p>
4− <img width="485" alt="Charm Fang" src="https://github.com/user-attachments/assets/3f34ea01-3750-4760-beb2-a1b700e110f5">
4+ <img width="485" alt="Charm Fang" src="https://github.com/user-attachments/assets/3f34ea01-3750-4760-beb2-a1b700e110f5">
5</p>
5</p>
6<p>
6<p>
7 <a href="https://github.com/charmbracelet/fang/releases"><img src="https://img.shields.io/github/release/charmbracelet/fang.svg" alt="Latest Release"></a>
7 <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 @@
12The CLI starter kit. A small, experimental library for batteries-included [Cobra][cobra] applications.
12The CLI starter kit. A small, experimental library for batteries-included [Cobra][cobra] applications.
13
13
14<p>
14<p>
15− <img width="865" alt="fang-02" src="https://github.com/user-attachments/assets/7f68ec3f-2b42-4188-a750-7e2808696132" />
15+ <img width="859" alt="The Charm Fang mascot and title treatment" src="https://github.com/user-attachments/assets/5c35e1fa-9577-4f81-a879-3ddb4d4a43f0" />
16</p>
16</p>
17
17
18## Features
18## Features
@@ -45,6 +45,7 @@ To use it, invoke `fang.Execute` passing your root `*cobra.Command`:
45package main
45package main
46
46
47import (
47import (
48+ "context"
48 "os"
49 "os"
49
50
50 "github.com/charmbracelet/fang"
51 "github.com/charmbracelet/fang"
@@ -56,7 +57,7 @@ func main() {
56 Use: "example",
57 Use: "example",
57 Short: "A simple example program!",
58 Short: "A simple example program!",
58 }
59 }
59− if err := fang.Execute(context.TODO(), cmd); err != nil {
60+ if err := fang.Execute(context.Background(), cmd); err != nil {
60 os.Exit(1)
61 os.Exit(1)
61 }
62 }
62}
63}
vendor/github.com/charmbracelet/fang/fang.go+91−28
@@ -4,11 +4,14 @@ package fang
4import (
4import (
5 "context"
5 "context"
6 "fmt"
6 "fmt"
7+ "io"
7 "os"
8 "os"
9+ "os/signal"
8 "runtime/debug"
10 "runtime/debug"
9
11
10 "github.com/charmbracelet/colorprofile"
12 "github.com/charmbracelet/colorprofile"
11 "github.com/charmbracelet/lipgloss/v2"
13 "github.com/charmbracelet/lipgloss/v2"
14+ "github.com/charmbracelet/x/term"
12 mango "github.com/muesli/mango-cobra"
15 mango "github.com/muesli/mango-cobra"
13 "github.com/muesli/roff"
16 "github.com/muesli/roff"
14 "github.com/spf13/cobra"
17 "github.com/spf13/cobra"
@@ -16,12 +19,24 @@ import (
16
19
17const shaLen = 7
20const shaLen = 7
18
21
22+// ErrorHandler handles an error, printing them to the given [io.Writer].
23+//
24+// Note that this will only be used if the STDERR is a terminal, and should
25+// be used for styling only.
26+type ErrorHandler = func(w io.Writer, styles Styles, err error)
27+
28+// ColorSchemeFunc gets a [lipgloss.LightDarkFunc] and returns a [ColorScheme].
29+type ColorSchemeFunc = func(lipgloss.LightDarkFunc) ColorScheme
30+
19type settings struct {
31type settings struct {
20 completions bool
32 completions bool
21 manpages bool
33 manpages bool
34+ skipVersion bool
22 version string
35 version string
23 commit string
36 commit string
24− theme *ColorScheme
37+ colorscheme ColorSchemeFunc
38+ errHandler ErrorHandler
39+ signals []os.Signal
25}
40}
26
41
27// Option changes fang settings.
42// Option changes fang settings.
@@ -41,10 +56,21 @@ func WithoutManpage() Option {
41 }
56 }
42}
57}
43
58
59+// WithColorSchemeFunc sets a function that return colorscheme.
60+func WithColorSchemeFunc(cs ColorSchemeFunc) Option {
61+ return func(s *settings) {
62+ s.colorscheme = cs
63+ }
64+}
65+
44// WithTheme sets the colorscheme.
66// WithTheme sets the colorscheme.
67+//
68+// Deprecated: use [WithColorSchemeFunc] instead.
45func WithTheme(theme ColorScheme) Option {
69func WithTheme(theme ColorScheme) Option {
46 return func(s *settings) {
70 return func(s *settings) {
71+ s.colorscheme = func(lipgloss.LightDarkFunc) ColorScheme {
47− s.theme = &theme
72+ return theme
73+ }
48 }
74 }
49}
75}
50
76
@@ -55,6 +81,13 @@ func WithVersion(version string) Option {
55 }
81 }
56}
82}
57
83
84+// WithoutVersion skips the `-v`/`--version` functionality.
85+func WithoutVersion() Option {
86+ return func(s *settings) {
87+ s.skipVersion = true
88+ }
89+}
90+
58// WithCommit sets the commit SHA.
91// WithCommit sets the commit SHA.
59func WithCommit(commit string) Option {
92func WithCommit(commit string) Option {
60 return func(s *settings) {
93 return func(s *settings) {
@@ -62,30 +95,45 @@ func WithCommit(commit string) Option {
62 }
95 }
63}
96}
64
97
98+// WithErrorHandler sets the error handler.
99+func WithErrorHandler(handler ErrorHandler) Option {
100+ return func(s *settings) {
101+ s.errHandler = handler
102+ }
103+}
104+
105+// WithNotifySignal sets the signals that should interrupt the execution of the
106+// program.
107+func WithNotifySignal(signals ...os.Signal) Option {
108+ return func(s *settings) {
109+ s.signals = signals
110+ }
111+}
112+
65// Execute applies fang to the command and executes it.
113// Execute applies fang to the command and executes it.
66func Execute(ctx context.Context, root *cobra.Command, options ...Option) error {
114func Execute(ctx context.Context, root *cobra.Command, options ...Option) error {
67 opts := settings{
115 opts := settings{
68 manpages: true,
116 manpages: true,
69 completions: true,
117 completions: true,
118+ colorscheme: DefaultColorScheme,
119+ errHandler: DefaultErrorHandler,
70 }
120 }
121+
71 for _, option := range options {
122 for _, option := range options {
72 option(&opts)
123 option(&opts)
73 }
124 }
74
125
75− if opts.theme == nil {
126+ helpFunc := func(c *cobra.Command, _ []string) {
76− isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stderr)
127+ w := colorprofile.NewWriter(c.OutOrStdout(), os.Environ())
77− t := DefaultTheme(isDark)
128+ helpFn(c, w, makeStyles(mustColorscheme(opts.colorscheme)))
78− opts.theme = &t
79 }
129 }
80
130
81− styles := makeStyles(*opts.theme)
82−
83− root.SetHelpFunc(func(c *cobra.Command, _ []string) {
84− w := colorprofile.NewWriter(c.OutOrStdout(), os.Environ())
85− helpFn(c, w, styles)
86− })
87 root.SilenceUsage = true
131 root.SilenceUsage = true
88 root.SilenceErrors = true
132 root.SilenceErrors = true
133+ if !opts.skipVersion {
134+ root.Version = buildVersion(opts)
135+ }
136+ root.SetHelpFunc(helpFunc)
89
137
90 if opts.manpages {
138 if opts.manpages {
91 root.AddCommand(&cobra.Command{
139 root.AddCommand(&cobra.Command{
@@ -108,34 +156,49 @@ func Execute(ctx context.Context, root *cobra.Command, options ...Option) error
108 })
156 })
109 }
157 }
110
158
111− if opts.completions {
159+ if !opts.completions {
112− root.InitDefaultCompletionCmd()
113− } else {
114 root.CompletionOptions.DisableDefaultCmd = true
160 root.CompletionOptions.DisableDefaultCmd = true
115 }
161 }
116
162
117− if opts.version == "" {
163+ if len(opts.signals) > 0 {
118− if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" {
164+ var cancel context.CancelFunc
119− opts.version = info.Main.Version
165+ ctx, cancel = signal.NotifyContext(ctx, opts.signals...)
120− opts.commit = getKey(info, "vcs.revision")
166+ defer cancel()
121− } else {
122− opts.version = "unknown (built from source)"
123− }
124− }
125− if len(opts.commit) >= shaLen {
126− opts.version += " (" + opts.commit[:shaLen] + ")"
127 }
167 }
128
168
129− root.Version = opts.version
130−
131 if err := root.ExecuteContext(ctx); err != nil {
169 if err := root.ExecuteContext(ctx); err != nil {
170+ if w, ok := root.ErrOrStderr().(term.File); ok {
171+ // if stderr is not a tty, simply print the error without any
172+ // styling or going through an [ErrorHandler]:
173+ if !term.IsTerminal(w.Fd()) {
174+ _, _ = fmt.Fprintln(w, err.Error())
175+ return err //nolint:wrapcheck
176+ }
177+ }
132 w := colorprofile.NewWriter(root.ErrOrStderr(), os.Environ())
178 w := colorprofile.NewWriter(root.ErrOrStderr(), os.Environ())
133− writeError(w, styles, err)
179+ opts.errHandler(w, makeStyles(mustColorscheme(opts.colorscheme)), err)
134 return err //nolint:wrapcheck
180 return err //nolint:wrapcheck
135 }
181 }
136 return nil
182 return nil
137}
183}
138
184
185+func buildVersion(opts settings) string {
186+ commit := opts.commit
187+ version := opts.version
188+ if version == "" {
189+ if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" {
190+ version = info.Main.Version
191+ commit = getKey(info, "vcs.revision")
192+ } else {
193+ version = "unknown (built from source)"
194+ }
195+ }
196+ if len(commit) >= shaLen {
197+ version += " (" + commit[:shaLen] + ")"
198+ }
199+ return version
200+}
201+
139func getKey(info *debug.BuildInfo, key string) string {
202func getKey(info *debug.BuildInfo, key string) string {
140 if info == nil {
203 if info == nil {
141 return ""
204 return ""
vendor/github.com/charmbracelet/fang/help.go+208−90
@@ -3,7 +3,10 @@ package fang
3import (
3import (
4 "cmp"
4 "cmp"
5 "fmt"
5 "fmt"
6+ "io"
7+ "iter"
6 "os"
8 "os"
9+ "reflect"
7 "regexp"
10 "regexp"
8 "strconv"
11 "strconv"
9 "strings"
12 "strings"
@@ -20,6 +23,7 @@ import (
20const (
23const (
21 minSpace = 10
24 minSpace = 10
22 shortPad = 2
25 shortPad = 2
26+ longPad = 4
23)
27)
24
28
25var width = sync.OnceValue(func() int {
29var width = sync.OnceValue(func() int {
@@ -45,65 +49,95 @@ func helpFn(c *cobra.Command, w *colorprofile.Writer, styles Styles) {
45 blockWidth = max(blockWidth, lipgloss.Width(ex))
49 blockWidth = max(blockWidth, lipgloss.Width(ex))
46 }
50 }
47 blockWidth = min(width()-padding, blockWidth+padding)
51 blockWidth = min(width()-padding, blockWidth+padding)
52+ blockStyle := styles.Codeblock.Base.Width(blockWidth)
48
53
49− styles.Codeblock.Base = styles.Codeblock.Base.Width(blockWidth)
54+ // if the color profile is ascii or notty, or if the block has no
55+ // background color set, remove the vertical padding.
56+ if w.Profile <= colorprofile.Ascii || reflect.DeepEqual(blockStyle.GetBackground(), lipgloss.NoColor{}) {
57+ blockStyle = blockStyle.PaddingTop(0).PaddingBottom(0)
58+ }
50
59
51 _, _ = fmt.Fprintln(w, styles.Title.Render("usage"))
60 _, _ = fmt.Fprintln(w, styles.Title.Render("usage"))
52− _, _ = fmt.Fprintln(w, styles.Codeblock.Base.Render(usage))
61+ _, _ = fmt.Fprintln(w, blockStyle.Render(usage))
53 if len(examples) > 0 {
62 if len(examples) > 0 {
54− cw := styles.Codeblock.Base.GetWidth() - styles.Codeblock.Base.GetHorizontalPadding()
63+ cw := blockStyle.GetWidth() - blockStyle.GetHorizontalPadding()
55 _, _ = fmt.Fprintln(w, styles.Title.Render("examples"))
64 _, _ = fmt.Fprintln(w, styles.Title.Render("examples"))
56 for i, example := range examples {
65 for i, example := range examples {
57 if lipgloss.Width(example) > cw {
66 if lipgloss.Width(example) > cw {
58 examples[i] = ansi.Truncate(example, cw, "…")
67 examples[i] = ansi.Truncate(example, cw, "…")
59 }
68 }
60 }
69 }
61− _, _ = fmt.Fprintln(w, styles.Codeblock.Base.Render(strings.Join(examples, "\n")))
70+ _, _ = fmt.Fprintln(w, blockStyle.Render(strings.Join(examples, "\n")))
62 }
71 }
63
72
73+ groups, groupKeys := evalGroups(c)
64 cmds, cmdKeys := evalCmds(c, styles)
74 cmds, cmdKeys := evalCmds(c, styles)
65 flags, flagKeys := evalFlags(c, styles)
75 flags, flagKeys := evalFlags(c, styles)
66 space := calculateSpace(cmdKeys, flagKeys)
76 space := calculateSpace(cmdKeys, flagKeys)
67
77
68− leftPadding := 4
69− if len(cmds) > 0 {
70− _, _ = fmt.Fprintln(w, styles.Title.Render("commands"))
71− for _, k := range cmdKeys {
78+ for _, groupID := range groupKeys {
72− _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
79+ group := cmds[groupID]
73− lipgloss.Left,
80+ if len(group) == 0 {
74− lipgloss.NewStyle().PaddingLeft(leftPadding).Render(k),
81+ continue
75− strings.Repeat(" ", space-lipgloss.Width(k)),
76− cmds[k],
77− ))
78 }
82 }
83+ renderGroup(w, styles, space, groups[groupID], func(yield func(string, string) bool) {
84+ for _, k := range cmdKeys {
85+ cmds, ok := group[k]
86+ if !ok {
87+ continue
88+ }
89+ if !yield(k, cmds) {
90+ return
91+ }
92+ }
93+ })
79 }
94 }
80
95
81 if len(flags) > 0 {
96 if len(flags) > 0 {
82− _, _ = fmt.Fprintln(w, styles.Title.Render("flags"))
97+ renderGroup(w, styles, space, "flags", func(yield func(string, string) bool) {
83− for _, k := range flagKeys {
98+ for _, k := range flagKeys {
84− _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
85− lipgloss.Left,
86− lipgloss.NewStyle().PaddingLeft(leftPadding).Render(k),
87− strings.Repeat(" ", space-lipgloss.Width(k)),
88− flags[k],
99+ if !yield(k, flags[k]) {
89− ))
100+ return
101+ }
102+ }
90− }
103+ })
91 }
104 }
92
105
93 _, _ = fmt.Fprintln(w)
106 _, _ = fmt.Fprintln(w)
94}
107}
95
108
109+// DefaultErrorHandler is the default [ErrorHandler] implementation.
96−func writeError(w *colorprofile.Writer, styles Styles, err error) {
110+func DefaultErrorHandler(w io.Writer, styles Styles, err error) {
97 _, _ = fmt.Fprintln(w, styles.ErrorHeader.String())
111 _, _ = fmt.Fprintln(w, styles.ErrorHeader.String())
98 _, _ = fmt.Fprintln(w, styles.ErrorText.Render(err.Error()+"."))
112 _, _ = fmt.Fprintln(w, styles.ErrorText.Render(err.Error()+"."))
99 _, _ = fmt.Fprintln(w)
113 _, _ = fmt.Fprintln(w)
114+ if isUsageError(err) {
100− _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
115+ _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
101− lipgloss.Left,
116+ lipgloss.Left,
102− styles.ErrorText.UnsetWidth().Render("Try"),
117+ styles.ErrorText.UnsetWidth().Render("Try"),
103− styles.Program.Flag.Render("--help"),
118+ styles.Program.Flag.Render(" --help "),
104− styles.ErrorText.UnsetWidth().UnsetMargins().UnsetTransform().PaddingLeft(1).Render("for usage."),
119+ styles.ErrorText.UnsetWidth().UnsetMargins().UnsetTransform().Render("for usage."),
105− ))
120+ ))
106− _, _ = fmt.Fprintln(w)
121+ _, _ = fmt.Fprintln(w)
122+ }
123+}
124+
125+// XXX: this is a hack to detect usage errors.
126+// See: https://github.com/spf13/cobra/pull/2266
127+func isUsageError(err error) bool {
128+ s := err.Error()
129+ for _, prefix := range []string{
130+ "flag needs an argument:",
131+ "unknown flag:",
132+ "unknown shorthand flag:",
133+ "unknown command",
134+ "invalid argument",
135+ } {
136+ if strings.HasPrefix(s, prefix) {
137+ return true
138+ }
139+ }
140+ return false
107}
141}
108
142
109func writeLongShort(w *colorprofile.Writer, styles Styles, longShort string) {
143func writeLongShort(w *colorprofile.Writer, styles Styles, longShort string) {
@@ -118,8 +152,10 @@ var otherArgsRe = regexp.MustCompile(`(\[.*\])`)
118
152
119// styleUsage stylized styleUsage line for a given command.
153// styleUsage stylized styleUsage line for a given command.
120func styleUsage(c *cobra.Command, styles Program, complete bool) string {
154func styleUsage(c *cobra.Command, styles Program, complete bool) string {
121− // XXX: maybe use c.UseLine() here?
122 u := c.Use
155 u := c.Use
156+ if complete {
157+ u = c.UseLine()
158+ }
123 hasArgs := strings.Contains(u, "[args]")
159 hasArgs := strings.Contains(u, "[args]")
124 hasFlags := strings.Contains(u, "[flags]") || strings.Contains(u, "[--flags]") || c.HasFlags() || c.HasPersistentFlags() || c.HasAvailableFlags()
160 hasFlags := strings.Contains(u, "[flags]") || strings.Contains(u, "[--flags]") || c.HasFlags() || c.HasPersistentFlags() || c.HasAvailableFlags()
125 hasCommands := strings.Contains(u, "[command]") || c.HasAvailableSubCommands()
161 hasCommands := strings.Contains(u, "[command]") || c.HasAvailableSubCommands()
@@ -139,34 +175,38 @@ func styleUsage(c *cobra.Command, styles Program, complete bool) string {
139
175
140 u = strings.TrimSpace(u)
176 u = strings.TrimSpace(u)
141
177
142− useLine := []string{
178+ useLine := []string{}
143− styles.Name.Render(u),
144− }
145− if !complete {
179+ if complete {
180+ parts := strings.Fields(u)
181+ useLine = append(useLine, styles.Name.Render(parts[0]))
182+ if len(parts) > 1 {
146− useLine[0] = styles.Command.Render(u)
183+ useLine = append(useLine, styles.Command.Render(" "+strings.Join(parts[1:], " ")))
184+ }
185+ } else {
186+ useLine = append(useLine, styles.Command.Render(u))
147 }
187 }
148 if hasCommands {
188 if hasCommands {
149 useLine = append(
189 useLine = append(
150 useLine,
190 useLine,
151− styles.DimmedArgument.Render("[command]"),
191+ styles.DimmedArgument.Render(" [command]"),
152 )
192 )
153 }
193 }
154 if hasArgs {
194 if hasArgs {
155 useLine = append(
195 useLine = append(
156 useLine,
196 useLine,
157− styles.DimmedArgument.Render("[args]"),
197+ styles.DimmedArgument.Render(" [args]"),
158 )
198 )
159 }
199 }
160 for _, arg := range otherArgs {
200 for _, arg := range otherArgs {
161 useLine = append(
201 useLine = append(
162 useLine,
202 useLine,
163− styles.DimmedArgument.Render(arg),
203+ styles.DimmedArgument.Render(" "+arg),
164 )
204 )
165 }
205 }
166 if hasFlags {
206 if hasFlags {
167 useLine = append(
207 useLine = append(
168 useLine,
208 useLine,
169− styles.DimmedArgument.Render("[--flags]"),
209+ styles.DimmedArgument.Render(" [--flags]"),
170 )
210 )
171 }
211 }
172 return lipgloss.JoinHorizontal(lipgloss.Left, useLine...)
212 return lipgloss.JoinHorizontal(lipgloss.Left, useLine...)
@@ -180,19 +220,21 @@ func styleExamples(c *cobra.Command, styles Styles) []string {
180 }
220 }
181 usage := []string{}
221 usage := []string{}
182 examples := strings.Split(c.Example, "\n")
222 examples := strings.Split(c.Example, "\n")
223+ var indent bool
183 for i, line := range examples {
224 for i, line := range examples {
184 line = strings.TrimSpace(line)
225 line = strings.TrimSpace(line)
185 if (i == 0 || i == len(examples)-1) && line == "" {
226 if (i == 0 || i == len(examples)-1) && line == "" {
186 continue
227 continue
187 }
228 }
188− s := styleExample(c, line, styles.Codeblock)
229+ s := styleExample(c, line, indent, styles.Codeblock)
189 usage = append(usage, s)
230 usage = append(usage, s)
231+ indent = len(line) > 1 && (line[len(line)-1] == '\\' || line[len(line)-1] == '|')
190 }
232 }
191
233
192 return usage
234 return usage
193}
235}
194
236
195−func styleExample(c *cobra.Command, line string, styles Codeblock) string {
237+func styleExample(c *cobra.Command, line string, indent bool, styles Codeblock) string {
196 if strings.HasPrefix(line, "# ") {
238 if strings.HasPrefix(line, "# ") {
197 return lipgloss.JoinHorizontal(
239 return lipgloss.JoinHorizontal(
198 lipgloss.Left,
240 lipgloss.Left,
@@ -200,66 +242,110 @@ func styleExample(c *cobra.Command, line string, styles Codeblock) string {
200 )
242 )
201 }
243 }
202
244
203− args := strings.Fields(line)
204− var nextIsFlag bool
205 var isQuotedString bool
245 var isQuotedString bool
246+ var foundProgramName bool
247+ var isRedirecting bool
248+ programName := c.Root().Name()
249+ args := strings.Fields(line)
250+ var cleanArgs []string
206 for i, arg := range args {
251 for i, arg := range args {
207− if i == 0 {
252+ isQuoteStart := arg[0] == '"' || arg[0] == '\''
253+ isQuoteEnd := arg[len(arg)-1] == '"' || arg[len(arg)-1] == '\''
254+ isFlag := arg[0] == '-'
255+
256+ switch i {
257+ case 0:
258+ args[i] = ""
259+ if indent {
260+ args[i] = styles.Program.DimmedArgument.Render(" ")
261+ indent = false
262+ }
263+ default:
208− args[i] = styles.Program.Name.Render(arg)
264+ args[i] = styles.Program.DimmedArgument.Render(" ")
209− continue
210 }
265 }
211
266
212− quoteStart := arg[0] == '"'
267+ if isRedirecting {
213− quoteEnd := arg[len(arg)-1] == '"'
214− flagStart := arg[0] == '-'
215− if i == 1 && !quoteStart && !flagStart {
216− args[i] = styles.Program.Command.Render(arg)
268+ args[i] += styles.Program.DimmedArgument.Render(arg)
269+ isRedirecting = false
217 continue
270 continue
218 }
271 }
219− if quoteStart {
272+
220− isQuotedString = true
273+ switch arg {
221− }
274+ case "\\":
222− if isQuotedString {
275+ if i == len(args)-1 {
223− args[i] = styles.Program.QuotedString.Render(arg)
276+ args[i] += styles.Program.DimmedArgument.Render(arg)
224− if quoteEnd {
277+ continue
225− isQuotedString = false
226 }
278 }
279+ case "|", "||", "-", "&", "&&":
280+ args[i] += styles.Program.DimmedArgument.Render(arg)
227 continue
281 continue
228 }
282 }
229− if nextIsFlag {
283+
284+ if isRedirect(arg) {
230− args[i] = styles.Program.Flag.Render(arg)
285+ args[i] += styles.Program.DimmedArgument.Render(arg)
286+ isRedirecting = true
231 continue
287 continue
232 }
288 }
233− var dashes string
289+
290+ if !foundProgramName { //nolint:nestif
291+ if isQuotedString {
292+ args[i] += styles.Program.QuotedString.Render(arg)
293+ isQuotedString = !isQuoteEnd
294+ continue
295+ }
234− if strings.HasPrefix(arg, "-") {
296+ if left, right, ok := strings.Cut(arg, "="); ok {
235− dashes = "-"
297+ args[i] += styles.Program.Flag.Render(left + "=")
298+ if right[0] == '"' {
299+ isQuotedString = true
300+ args[i] += styles.Program.QuotedString.Render(right)
301+ continue
302+ }
303+ args[i] += styles.Program.Argument.Render(right)
304+ continue
305+ }
306+
307+ if arg == programName {
308+ args[i] += styles.Program.Name.Render(arg)
309+ foundProgramName = true
310+ continue
311+ }
236 }
312 }
237− if strings.HasPrefix(arg, "--") {
313+
238− dashes = "--"
314+ if !isQuoteStart && !isQuotedString && !isFlag {
315+ cleanArgs = append(cleanArgs, arg)
316+ }
317+
318+ if !isQuoteStart && !isFlag && isSubCommand(c, cleanArgs, arg) {
319+ args[i] += styles.Program.Command.Render(arg)
320+ continue
321+ }
322+ isQuotedString = isQuotedString || isQuoteStart
323+ if isQuotedString {
324+ args[i] += styles.Program.QuotedString.Render(arg)
325+ isQuotedString = !isQuoteEnd
326+ continue
239 }
327 }
240 // handle a flag
328 // handle a flag
241− if dashes != "" {
329+ if isFlag {
242 name, value, ok := strings.Cut(arg, "=")
330 name, value, ok := strings.Cut(arg, "=")
243− name = strings.TrimPrefix(name, dashes)
244 // it is --flag=value
331 // it is --flag=value
245 if ok {
332 if ok {
246− args[i] = lipgloss.JoinHorizontal(
333+ args[i] += lipgloss.JoinHorizontal(
247 lipgloss.Left,
334 lipgloss.Left,
248− styles.Program.Flag.Render(dashes+name+"="),
335+ styles.Program.Flag.Render(name+"="),
249− styles.Program.Argument.UnsetPadding().Render(value),
336+ styles.Program.Argument.Render(value),
250 )
337 )
251 continue
338 continue
252 }
339 }
253 // it is either --bool-flag or --flag value
340 // it is either --bool-flag or --flag value
254− args[i] = lipgloss.JoinHorizontal(
341+ args[i] += lipgloss.JoinHorizontal(
255 lipgloss.Left,
342 lipgloss.Left,
256− styles.Program.Flag.Render(dashes+name),
343+ styles.Program.Flag.Render(name),
257 )
344 )
258− // if the flag is not a bool flag, next arg continues current flag
259− nextIsFlag = !isFlagBool(c, name)
260 continue
345 continue
261 }
346 }
347+
262− args[i] = styles.Program.Argument.Render(arg)
348+ args[i] += styles.Program.Argument.Render(arg)
263 }
349 }
264
350
265 return lipgloss.JoinHorizontal(
351 return lipgloss.JoinHorizontal(
@@ -284,8 +370,7 @@ func evalFlags(c *cobra.Command, styles Styles) (map[string]string, []string) {
284 } else {
370 } else {
285 parts = append(
371 parts = append(
286 parts,
372 parts,
287− styles.Program.Flag.Render("-"+f.Shorthand),
373+ styles.Program.Flag.Render("-"+f.Shorthand+" --"+f.Name),
288− styles.Program.Flag.Render("--"+f.Name),
289 )
374 )
290 }
375 }
291 key := lipgloss.JoinHorizontal(lipgloss.Left, parts...)
376 key := lipgloss.JoinHorizontal(lipgloss.Left, parts...)
@@ -303,22 +388,50 @@ func evalFlags(c *cobra.Command, styles Styles) (map[string]string, []string) {
303 return flags, keys
388 return flags, keys
304}
389}
305
390
391+// result is map[groupID]map[styled cmd name]styled cmd help, and the keys in
392+// the order they are defined.
306−func evalCmds(c *cobra.Command, styles Styles) (map[string]string, []string) {
393+func evalCmds(c *cobra.Command, styles Styles) (map[string](map[string]string), []string) {
307 padStyle := lipgloss.NewStyle().PaddingLeft(0) //nolint:mnd
394 padStyle := lipgloss.NewStyle().PaddingLeft(0) //nolint:mnd
308 keys := []string{}
395 keys := []string{}
309− cmds := map[string]string{}
396+ cmds := map[string]map[string]string{}
310 for _, sc := range c.Commands() {
397 for _, sc := range c.Commands() {
311 if sc.Hidden {
398 if sc.Hidden {
312 continue
399 continue
313 }
400 }
401+ if _, ok := cmds[sc.GroupID]; !ok {
402+ cmds[sc.GroupID] = map[string]string{}
403+ }
314 key := padStyle.Render(styleUsage(sc, styles.Program, false))
404 key := padStyle.Render(styleUsage(sc, styles.Program, false))
315 help := styles.FlagDescription.Render(sc.Short)
405 help := styles.FlagDescription.Render(sc.Short)
316− cmds[key] = help
406+ cmds[sc.GroupID][key] = help
317 keys = append(keys, key)
407 keys = append(keys, key)
318 }
408 }
319 return cmds, keys
409 return cmds, keys
320}
410}
321
411
412+func evalGroups(c *cobra.Command) (map[string]string, []string) {
413+ // make sure the default group is the first
414+ ids := []string{""}
415+ groups := map[string]string{"": "commands"}
416+ for _, g := range c.Groups() {
417+ groups[g.ID] = g.Title
418+ ids = append(ids, g.ID)
419+ }
420+ return groups, ids
421+}
422+
423+func renderGroup(w io.Writer, styles Styles, space int, name string, items iter.Seq2[string, string]) {
424+ _, _ = fmt.Fprintln(w, styles.Title.Render(name))
425+ for key, help := range items {
426+ _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
427+ lipgloss.Left,
428+ lipgloss.NewStyle().PaddingLeft(longPad).Render(key),
429+ strings.Repeat(" ", space-lipgloss.Width(key)),
430+ help,
431+ ))
432+ }
433+}
434+
322func calculateSpace(k1, k2 []string) int {
435func calculateSpace(k1, k2 []string) int {
323 const spaceBetween = 2
436 const spaceBetween = 2
324 space := minSpace
437 space := minSpace
@@ -328,13 +441,18 @@ func calculateSpace(k1, k2 []string) int {
328 return space
441 return space
329}
442}
330
443
331−func isFlagBool(c *cobra.Command, name string) bool {
444+func isSubCommand(c *cobra.Command, args []string, word string) bool {
332− flag := c.Flags().Lookup(name)
445+ cmd, _, _ := c.Root().Traverse(args)
333− if flag == nil && len(name) == 1 {
446+ return cmd != nil && cmd.Name() == word
334− flag = c.Flags().ShorthandLookup(name)
447+}
335− }
448+
336− if flag == nil {
449+var redirectPrefixes = []string{">", "<", "&>", "2>", "1>", ">>", "2>>"}
450+
451+func isRedirect(s string) bool {
452+ for _, p := range redirectPrefixes {
453+ if strings.HasPrefix(s, p) {
337− return false
454+ return true
455+ }
338 }
456 }
339− return flag.Value.Type() == "bool"
457+ return false
340}
458}
vendor/github.com/charmbracelet/fang/theme.go+39−13
@@ -2,10 +2,12 @@ package fang
2
2
3import (
3import (
4 "image/color"
4 "image/color"
5+ "os"
5 "strings"
6 "strings"
6
7
7 "github.com/charmbracelet/lipgloss/v2"
8 "github.com/charmbracelet/lipgloss/v2"
8 "github.com/charmbracelet/x/exp/charmtone"
9 "github.com/charmbracelet/x/exp/charmtone"
10+ "github.com/charmbracelet/x/term"
9 "golang.org/x/text/cases"
11 "golang.org/x/text/cases"
10 "golang.org/x/text/language"
12 "golang.org/x/text/language"
11)
13)
@@ -31,8 +33,14 @@ type ColorScheme struct {
31}
33}
32
34
33// DefaultTheme is the default colorscheme.
35// DefaultTheme is the default colorscheme.
36+//
37+// Deprecated: use [DefaultColorScheme] instead.
34func DefaultTheme(isDark bool) ColorScheme {
38func DefaultTheme(isDark bool) ColorScheme {
35− c := lipgloss.LightDark(isDark)
39+ return DefaultColorScheme(lipgloss.LightDark(isDark))
40+}
41+
42+// DefaultColorScheme is the default colorscheme.
43+func DefaultColorScheme(c lipgloss.LightDarkFunc) ColorScheme {
36 return ColorScheme{
44 return ColorScheme{
37 Base: c(charmtone.Charcoal, charmtone.Ash),
45 Base: c(charmtone.Charcoal, charmtone.Ash),
38 Title: charmtone.Charple,
46 Title: charmtone.Charple,
@@ -45,7 +53,7 @@ func DefaultTheme(isDark bool) ColorScheme {
45 Argument: c(charmtone.Charcoal, charmtone.Ash),
53 Argument: c(charmtone.Charcoal, charmtone.Ash),
46 Description: c(charmtone.Charcoal, charmtone.Ash), // flag and command descriptions
54 Description: c(charmtone.Charcoal, charmtone.Ash), // flag and command descriptions
47 FlagDefault: c(charmtone.Smoke, charmtone.Squid), // flag default values in descriptions
55 FlagDefault: c(charmtone.Smoke, charmtone.Squid), // flag default values in descriptions
48− QuotedString: c(charmtone.Charcoal, charmtone.Ash),
56+ QuotedString: c(charmtone.Coral, charmtone.Salmon),
49 ErrorHeader: [2]color.Color{
57 ErrorHeader: [2]color.Color{
50 charmtone.Butter,
58 charmtone.Butter,
51 charmtone.Cherry,
59 charmtone.Cherry,
@@ -53,6 +61,26 @@ func DefaultTheme(isDark bool) ColorScheme {
53 }
61 }
54}
62}
55
63
64+// AnsiColorScheme is a ANSI colorscheme.
65+func AnsiColorScheme(c lipgloss.LightDarkFunc) ColorScheme {
66+ base := c(lipgloss.Black, lipgloss.White)
67+ return ColorScheme{
68+ Base: base,
69+ Title: lipgloss.Blue,
70+ Description: base,
71+ Comment: c(lipgloss.BrightWhite, lipgloss.BrightBlack),
72+ Flag: lipgloss.Magenta,
73+ FlagDefault: lipgloss.BrightMagenta,
74+ Command: lipgloss.Cyan,
75+ QuotedString: lipgloss.Green,
76+ Argument: base,
77+ Help: base,
78+ Dash: base,
79+ ErrorHeader: [2]color.Color{lipgloss.Black, lipgloss.Red},
80+ ErrorDetails: lipgloss.Red,
81+ }
82+}
83+
56// Styles represents all the styles used.
84// Styles represents all the styles used.
57type Styles struct {
85type Styles struct {
58 Text lipgloss.Style
86 Text lipgloss.Style
@@ -84,6 +112,14 @@ type Program struct {
84 QuotedString lipgloss.Style
112 QuotedString lipgloss.Style
85}
113}
86
114
115+func mustColorscheme(cs func(lipgloss.LightDarkFunc) ColorScheme) ColorScheme {
116+ var isDark bool
117+ if term.IsTerminal(os.Stdout.Fd()) {
118+ isDark = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
119+ }
120+ return cs(lipgloss.LightDark(isDark))
121+}
122+
87func makeStyles(cs ColorScheme) Styles {
123func makeStyles(cs ColorScheme) Styles {
88 //nolint:mnd
124 //nolint:mnd
89 return Styles{
125 return Styles{
@@ -98,8 +134,7 @@ func makeStyles(cs ColorScheme) Styles {
98 Foreground(cs.Description).
134 Foreground(cs.Description).
99 Transform(titleFirstWord),
135 Transform(titleFirstWord),
100 FlagDefault: lipgloss.NewStyle().
136 FlagDefault: lipgloss.NewStyle().
101− Foreground(cs.FlagDefault).
137+ Foreground(cs.FlagDefault),
102− PaddingLeft(1),
103 Codeblock: Codeblock{
138 Codeblock: Codeblock{
104 Base: lipgloss.NewStyle().
139 Base: lipgloss.NewStyle().
105 Background(cs.Codeblock).
140 Background(cs.Codeblock).
@@ -116,23 +151,18 @@ func makeStyles(cs ColorScheme) Styles {
116 Background(cs.Codeblock).
151 Background(cs.Codeblock).
117 Foreground(cs.Program),
152 Foreground(cs.Program),
118 Flag: lipgloss.NewStyle().
153 Flag: lipgloss.NewStyle().
119− PaddingLeft(1).
120 Background(cs.Codeblock).
154 Background(cs.Codeblock).
121 Foreground(cs.Flag),
155 Foreground(cs.Flag),
122 Argument: lipgloss.NewStyle().
156 Argument: lipgloss.NewStyle().
123− PaddingLeft(1).
124 Background(cs.Codeblock).
157 Background(cs.Codeblock).
125 Foreground(cs.Argument),
158 Foreground(cs.Argument),
126 DimmedArgument: lipgloss.NewStyle().
159 DimmedArgument: lipgloss.NewStyle().
127− PaddingLeft(1).
128 Background(cs.Codeblock).
160 Background(cs.Codeblock).
129 Foreground(cs.DimmedArgument),
161 Foreground(cs.DimmedArgument),
130 Command: lipgloss.NewStyle().
162 Command: lipgloss.NewStyle().
131− PaddingLeft(1).
132 Background(cs.Codeblock).
163 Background(cs.Codeblock).
133 Foreground(cs.Command),
164 Foreground(cs.Command),
134 QuotedString: lipgloss.NewStyle().
165 QuotedString: lipgloss.NewStyle().
135− PaddingLeft(1).
136 Background(cs.Codeblock).
166 Background(cs.Codeblock).
137 Foreground(cs.QuotedString),
167 Foreground(cs.QuotedString),
138 },
168 },
@@ -141,18 +171,14 @@ func makeStyles(cs ColorScheme) Styles {
141 Name: lipgloss.NewStyle().
171 Name: lipgloss.NewStyle().
142 Foreground(cs.Program),
172 Foreground(cs.Program),
143 Argument: lipgloss.NewStyle().
173 Argument: lipgloss.NewStyle().
144− PaddingLeft(1).
145 Foreground(cs.Argument),
174 Foreground(cs.Argument),
146 DimmedArgument: lipgloss.NewStyle().
175 DimmedArgument: lipgloss.NewStyle().
147− PaddingLeft(1).
148 Foreground(cs.DimmedArgument),
176 Foreground(cs.DimmedArgument),
149 Flag: lipgloss.NewStyle().
177 Flag: lipgloss.NewStyle().
150− PaddingLeft(1).
151 Foreground(cs.Flag),
178 Foreground(cs.Flag),
152 Command: lipgloss.NewStyle().
179 Command: lipgloss.NewStyle().
153 Foreground(cs.Command),
180 Foreground(cs.Command),
154 QuotedString: lipgloss.NewStyle().
181 QuotedString: lipgloss.NewStyle().
155− PaddingLeft(1).
156 Foreground(cs.QuotedString),
182 Foreground(cs.QuotedString),
157 },
183 },
158 Span: lipgloss.NewStyle().
184 Span: lipgloss.NewStyle().
vendor/modules.txt+3−3
@@ -260,8 +260,8 @@ github.com/charmbracelet/bubbletea/v2
260# github.com/charmbracelet/colorprofile v0.3.1
260# github.com/charmbracelet/colorprofile v0.3.1
261## explicit; go 1.23.0
261## explicit; go 1.23.0
262github.com/charmbracelet/colorprofile
262github.com/charmbracelet/colorprofile
263−# github.com/charmbracelet/fang v0.1.0
263+# github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
264−## explicit; go 1.23.0
264+## explicit; go 1.24.0
265github.com/charmbracelet/fang
265github.com/charmbracelet/fang
266# github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
266# github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
267## explicit; go 1.23.0
267## explicit; go 1.23.0
@@ -269,7 +269,7 @@ github.com/charmbracelet/glamour/v2
269github.com/charmbracelet/glamour/v2/ansi
269github.com/charmbracelet/glamour/v2/ansi
270github.com/charmbracelet/glamour/v2/internal/autolink
270github.com/charmbracelet/glamour/v2/internal/autolink
271github.com/charmbracelet/glamour/v2/styles
271github.com/charmbracelet/glamour/v2/styles
272−# github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb
272+# github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb
273## explicit; go 1.24.2
273## explicit; go 1.24.2
274github.com/charmbracelet/lipgloss/v2
274github.com/charmbracelet/lipgloss/v2
275github.com/charmbracelet/lipgloss/v2/table
275github.com/charmbracelet/lipgloss/v2/table