on main help sign in
Merge branch 'main' into feature/log-stdout
commitd9640db747fc4522ecae933cfa8bc9fafd2d168a authorTai Groot <tai@taigrr.com> dateMon 14 Jul 2025, 15:40 (1 year ago) parentbdeece20b95320
51 files changed+2019−715
.gitignore+1 cmd/root.go+3−20 go.mod+3−3 go.sum+4−4 internal/app/app.go+43−26 internal/app/lsp.go+1−4 internal/config/config.go+32−1 internal/config/load.go+2−33 internal/config/resolve.go+1−1 internal/format/format.go−91 internal/format/spinner.go+37−13 internal/fur/client/client.go+1−1 internal/llm/agent/agent.go+2−2 internal/llm/prompt/prompt.go+48−5 internal/llm/prompt/prompt_test.go+113 internal/llm/tools/bash.go+117−10 internal/lsp/transport.go+22−19 internal/lsp/watcher/watcher.go+55−98 internal/shell/command_block_test.go+123 internal/shell/shell.go+75−7 internal/tui/components/chat/editor/editor.go+3−2 internal/tui/components/chat/messages/renderer.go+4−3 internal/tui/components/chat/splash/keys.go+2−2 internal/tui/components/chat/splash/splash.go+1 internal/tui/components/completions/completions.go+16−7 internal/tui/components/dialogs/permissions/keys.go−4 internal/tui/exp/diffview/diffview.go+4−2 internal/tui/page/chat/chat.go−15 internal/tui/tui.go+3 internal/tui/util/util.go+2 vendor/github.com/Azure/azure-sdk-for-go/sdk/azidentity/go.work.sum−60 vendor/github.com/charmbracelet/fang/README.md+4−3 vendor/github.com/charmbracelet/fang/fang.go+91−28 vendor/github.com/charmbracelet/fang/help.go+208−90 vendor/github.com/charmbracelet/fang/theme.go+39−13 vendor/github.com/mark3labs/mcp-go/client/client.go+101−1 vendor/github.com/mark3labs/mcp-go/client/http.go+6−1 vendor/github.com/mark3labs/mcp-go/client/sampling.go+20 vendor/github.com/mark3labs/mcp-go/client/stdio.go+19−3 vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go+4 vendor/github.com/mark3labs/mcp-go/client/transport/interface.go+18−2 vendor/github.com/mark3labs/mcp-go/client/transport/sse.go+6 vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go+191−13 vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go+228−115 vendor/github.com/mark3labs/mcp-go/mcp/tools.go+105−1 vendor/github.com/mark3labs/mcp-go/mcp/types.go+21 vendor/github.com/mark3labs/mcp-go/mcp/utils.go+21 vendor/github.com/mark3labs/mcp-go/server/sampling.go+37 vendor/github.com/mark3labs/mcp-go/server/stdio.go+174−6 vendor/github.com/mark3labs/mcp-go/server/streamable_http.go+4−2 vendor/modules.txt+4−4
.gitignore+1
@@ -16,6 +16,7 @@
1616
1717# Go workspace file
1818go.work
19+go.work.sum
1920
2021# IDE specific files
2122.idea/
cmd/root.go+3−20
@@ -12,7 +12,6 @@ import (
1212 "github.com/charmbracelet/crush/internal/app"
1313 "github.com/charmbracelet/crush/internal/config"
1414 "github.com/charmbracelet/crush/internal/db"
15− "github.com/charmbracelet/crush/internal/format"
1615 "github.com/charmbracelet/crush/internal/llm/agent"
1716 "github.com/charmbracelet/crush/internal/log"
1817 "github.com/charmbracelet/crush/internal/tui"
@@ -52,14 +51,8 @@ to assist developers in writing, debugging, and understanding code directly from
5251 debug, _ := cmd.Flags().GetBool("debug")
5352 cwd, _ := cmd.Flags().GetString("cwd")
5453 prompt, _ := cmd.Flags().GetString("prompt")
55− outputFormat, _ := cmd.Flags().GetString("output-format")
5654 quiet, _ := cmd.Flags().GetBool("quiet")
5755
58− // Validate format option
59− if !format.IsValid(outputFormat) {
60− return fmt.Errorf("invalid format option: %s\n%s", outputFormat, format.GetHelpText())
61− }
62−
6356 if cwd != "" {
6457 err := os.Chdir(cwd)
6558 if err != nil {
@@ -79,9 +72,7 @@ to assist developers in writing, debugging, and understanding code directly from
7972 return err
8073 }
8174
82− // Create main context for the application
83− ctx, cancel := context.WithCancel(context.Background())
84− defer cancel()
75+ ctx := cmd.Context()
8576
8677 // Connect DB, this will also run migrations
8778 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
@@ -109,7 +100,7 @@ to assist developers in writing, debugging, and understanding code directly from
109100 // Non-interactive mode
110101 if prompt != "" {
111102 // Run non-interactive flow using the App method
112− return app.RunNonInteractive(ctx, prompt, outputFormat, quiet)
103+ return app.RunNonInteractive(ctx, prompt, quiet)
113104 }
114105
115106 // Set up the TUI
@@ -152,6 +143,7 @@ func Execute() {
152143 context.Background(),
153144 rootCmd,
154145 fang.WithVersion(version.Version),
146+ fang.WithNotifySignal(os.Interrupt),
155147 ); err != nil {
156148 os.Exit(1)
157149 }
@@ -164,17 +156,8 @@ func init() {
164156 rootCmd.Flags().BoolP("debug", "d", false, "Debug")
165157 rootCmd.Flags().StringP("prompt", "p", "", "Prompt to run in non-interactive mode")
166158
167− // Add format flag with validation logic
168− rootCmd.Flags().StringP("output-format", "f", format.Text.String(),
169− "Output format for non-interactive mode (text, json)")
170−
171159 // Add quiet flag to hide spinner in non-interactive mode
172160 rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
173−
174− // Register custom validation for the format flag
175− rootCmd.RegisterFlagCompletionFunc("output-format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
176− return format.SupportedFormats, cobra.ShellCompDirectiveNoFileComp
177− })
178161}
179162
180163func maybePrependStdin(prompt string) (string, error) {
go.mod+3−3
@@ -18,9 +18,9 @@ require (
1818 github.com/charlievieth/fastwalk v1.0.11
1919 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198
2020 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
2222 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
2424 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706
2525 github.com/charmbracelet/x/ansi v0.9.3
2626 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
@@ -29,7 +29,7 @@ require (
2929 github.com/fsnotify/fsnotify v1.8.0
3030 github.com/google/uuid v1.6.0
3131 github.com/joho/godotenv v1.5.1
32− github.com/mark3labs/mcp-go v0.32.0
32+ github.com/mark3labs/mcp-go v0.33.0
3333 github.com/muesli/termenv v0.16.0
3434 github.com/ncruces/go-sqlite3 v0.25.0
3535 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
go.sum+4−4
@@ -74,8 +74,8 @@ github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e59
7474github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595/go.mod h1:+Tl7rePElw6OKt382t04zXwtPFoPXxAaJzNrYmtsLds=
7575github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
7676github.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=
78−github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc=
77+github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
78+github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674/go.mod h1:9gCUAHmVx5BwSafeyNr3GI0GgvlB1WYjL21SkPp1jyU=
7979github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
8080github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
8181github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb h1:lswj7CYZVYbLn2OhYJsXOMRQQGdRIfyuSnh5FdVSMr0=
@@ -165,8 +165,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
165165github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
166166github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
167167github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
168−github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8=
169−github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
168+github.com/mark3labs/mcp-go v0.33.0 h1:naxhjnTIs/tyPZmWUZFuG0lDmdA6sUyYGGf3gsHvTCc=
169+github.com/mark3labs/mcp-go v0.33.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
170170github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
171171github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
172172github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
internal/app/app.go+43−26
@@ -92,16 +92,26 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
9292}
9393
9494// RunNonInteractive handles the execution flow when a prompt is provided via CLI flag.
95−func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat string, quiet bool) error {
95+func (a *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool) error {
9696 slog.Info("Running in non-interactive mode")
9797
98+ ctx, cancel := context.WithCancel(ctx)
99+ defer cancel()
100+
98101 // Start spinner if not in quiet mode
99102 var spinner *format.Spinner
100103 if !quiet {
101− spinner = format.NewSpinner(ctx, "Generating")
104+ spinner = format.NewSpinner(ctx, cancel, "Generating")
102105 spinner.Start()
103− defer spinner.Stop()
104106 }
107+ // Helper function to stop spinner once
108+ stopSpinner := func() {
109+ if !quiet && spinner != nil {
110+ spinner.Stop()
111+ spinner = nil
112+ }
113+ }
114+ defer stopSpinner()
105115
106116 const maxPromptLengthForTitle = 100
107117 titlePrefix := "Non-interactive: "
@@ -128,35 +138,42 @@ func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat
128138 return fmt.Errorf("failed to start agent processing stream: %w", err)
129139 }
130140
131− result := <-done
141+ messageEvents := a.Messages.Subscribe(ctx)
142+ readBts := 0
132143
133− // Stop spinner before printing output
134− if !quiet && spinner != nil {
135− spinner.Stop()
136− }
144+ for {
145+ select {
146+ case result := <-done:
147+ stopSpinner()
148+
149+ if result.Error != nil {
150+ if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, agent.ErrRequestCancelled) {
151+ slog.Info("Agent processing cancelled", "session_id", sess.ID)
152+ return nil
153+ }
154+ return fmt.Errorf("agent processing failed: %w", result.Error)
155+ }
156+
157+ part := result.Message.Content().String()[readBts:]
158+ fmt.Println(part)
137159
138− if result.Error != nil {
139− if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, agent.ErrRequestCancelled) {
140− slog.Info("Agent processing cancelled", "session_id", sess.ID)
160+ slog.Info("Non-interactive run completed", "session_id", sess.ID)
141161 return nil
142− }
143− return fmt.Errorf("agent processing failed: %w", result.Error)
144− }
145162
146− // Get the text content from the response
147− content := "No content available"
148− if result.Message.Content().String() != "" {
149− content = result.Message.Content().String()
150− }
163+ case event := <-messageEvents:
164+ msg := event.Payload
165+ if msg.SessionID == sess.ID && msg.Role == message.Assistant && len(msg.Parts) > 0 {
166+ stopSpinner()
167+ part := msg.Content().String()[readBts:]
168+ fmt.Print(part)
169+ readBts += len(part)
170+ }
151171
152− out, err := format.FormatOutput(content, outputFormat)
153− if err != nil {
154− return err
172+ case <-ctx.Done():
173+ stopSpinner()
174+ return ctx.Err()
175+ }
155176 }
156−
157− fmt.Println(out)
158− slog.Info("Non-interactive run completed", "session_id", sess.ID)
159− return nil
160177}
161178
162179func (app *App) UpdateAgentModel() error {
internal/app/lsp.go+1−4
@@ -59,11 +59,8 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
5959 // Create a child context that can be canceled when the app is shutting down
6060 watchCtx, cancelFunc := context.WithCancel(ctx)
6161
62− // Create a context with the server name for better identification
63− watchCtx = context.WithValue(watchCtx, "serverName", name)
64−
6562 // Create the workspace watcher
66− workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
63+ workspaceWatcher := watcher.NewWorkspaceWatcher(name, lspClient)
6764
6865 // Store the cancel function to be called during cleanup
6966 app.cancelFuncsMutex.Lock()
internal/config/config.go+32−1
@@ -72,7 +72,7 @@ type ProviderConfig struct {
7272 Disable bool `json:"disable,omitempty"`
7373
7474 // Extra headers to send with each request to the provider.
75− ExtraHeaders map[string]string
75+ ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
7676
7777 // Used to pass extra parameters to the provider.
7878 ExtraParams map[string]string `json:"-"`
@@ -371,3 +371,34 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
371371 c.Providers[providerID] = providerConfig
372372 return nil
373373}
374+
375+func (c *Config) SetupAgents() {
376+ agents := map[string]Agent{
377+ "coder": {
378+ ID: "coder",
379+ Name: "Coder",
380+ Description: "An agent that helps with executing coding tasks.",
381+ Model: SelectedModelTypeLarge,
382+ ContextPaths: c.Options.ContextPaths,
383+ // All tools allowed
384+ },
385+ "task": {
386+ ID: "task",
387+ Name: "Task",
388+ Description: "An agent that helps with searching for context and finding implementation details.",
389+ Model: SelectedModelTypeLarge,
390+ ContextPaths: c.Options.ContextPaths,
391+ AllowedTools: []string{
392+ "glob",
393+ "grep",
394+ "ls",
395+ "sourcegraph",
396+ "view",
397+ },
398+ // NO MCPs or LSPs by default
399+ AllowedMCP: map[string][]string{},
400+ AllowedLSP: []string{},
401+ },
402+ }
403+ c.Agents = agents
404+}
internal/config/load.go+2−33
@@ -83,37 +83,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
8383 if err := cfg.configureSelectedModels(providers); err != nil {
8484 return nil, fmt.Errorf("failed to configure selected models: %w", err)
8585 }
86−
87− // TODO: remove the agents concept from the config
88− agents := map[string]Agent{
89− "coder": {
90− ID: "coder",
91− Name: "Coder",
92− Description: "An agent that helps with executing coding tasks.",
93− Model: SelectedModelTypeLarge,
94− ContextPaths: cfg.Options.ContextPaths,
95− // All tools allowed
96− },
97− "task": {
98− ID: "task",
99− Name: "Task",
100− Description: "An agent that helps with searching for context and finding implementation details.",
101− Model: SelectedModelTypeLarge,
102− ContextPaths: cfg.Options.ContextPaths,
103− AllowedTools: []string{
104− "glob",
105− "grep",
106− "ls",
107− "sourcegraph",
108− "view",
109− },
110− // NO MCPs or LSPs by default
111− AllowedMCP: map[string][]string{},
112− AllowedLSP: []string{},
113− },
114− }
115− cfg.Agents = agents
116−
86+ cfg.SetupAgents()
11787 return cfg, nil
11888}
11989
@@ -331,6 +301,7 @@ func (cfg *Config) defaultModelSelection(knownProviders []provider.Provider) (la
331301 defaultSmallModel := cfg.GetModel(string(p.ID), p.DefaultSmallModelID)
332302 if defaultSmallModel == nil {
333303 err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
304+ return
334305 }
335306 smallModel = SelectedModel{
336307 Provider: string(p.ID),
@@ -387,8 +358,6 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e
387358 large.Provider = largeModelSelected.Provider
388359 }
389360 model := cfg.GetModel(large.Provider, large.Model)
390− slog.Info("Configuring selected large model", "provider", large.Provider, "model", large.Model)
391− slog.Info("Model configured", "model", model)
392361 if model == nil {
393362 large = defaultLarge
394363 // override the model type to large
internal/config/resolve.go+1−1
@@ -44,7 +44,7 @@ func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
4444
4545 if strings.HasPrefix(value, "$(") && strings.HasSuffix(value, ")") {
4646 command := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
47− ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
47+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
4848 defer cancel()
4949
5050 stdout, _, err := r.shell.Exec(ctx, command)
internal/format/format.godeleted−91
@@ -1,91 +0,0 @@
1−package format
2−
3−import (
4− "encoding/json"
5− "fmt"
6− "strings"
7−)
8−
9−// OutputFormat represents the output format type for non-interactive mode
10−type OutputFormat string
11−
12−const (
13− // Text format outputs the AI response as plain text.
14− Text OutputFormat = "text"
15−
16− // JSON format outputs the AI response wrapped in a JSON object.
17− JSON OutputFormat = "json"
18−)
19−
20−// String returns the string representation of the OutputFormat
21−func (f OutputFormat) String() string {
22− return string(f)
23−}
24−
25−// SupportedFormats is a list of all supported output formats as strings
26−var SupportedFormats = []string{
27− string(Text),
28− string(JSON),
29−}
30−
31−// Parse converts a string to an OutputFormat
32−func Parse(s string) (OutputFormat, error) {
33− s = strings.ToLower(strings.TrimSpace(s))
34−
35− switch s {
36− case string(Text):
37− return Text, nil
38− case string(JSON):
39− return JSON, nil
40− default:
41− return "", fmt.Errorf("invalid format: %s", s)
42− }
43−}
44−
45−// IsValid checks if the provided format string is supported
46−func IsValid(s string) bool {
47− _, err := Parse(s)
48− return err == nil
49−}
50−
51−// GetHelpText returns a formatted string describing all supported formats
52−func GetHelpText() string {
53− return fmt.Sprintf(`Supported output formats:
54−- %s: Plain text output (default)
55−- %s: Output wrapped in a JSON object`,
56− Text, JSON)
57−}
58−
59−// FormatOutput formats the AI response according to the specified format
60−func FormatOutput(content string, formatStr string) (string, error) {
61− format, err := Parse(formatStr)
62− if err != nil {
63− format = Text
64− }
65−
66− switch format {
67− case JSON:
68− return formatAsJSON(content)
69− case Text:
70− fallthrough
71− default:
72− return content, nil
73− }
74−}
75−
76−// formatAsJSON wraps the content in a simple JSON object
77−func formatAsJSON(content string) (string, error) {
78− // Use the JSON package to properly escape the content
79− response := struct {
80− Response string `json:"response"`
81− }{
82− Response: content,
83− }
84−
85− jsonBytes, err := json.MarshalIndent(response, "", " ")
86− if err != nil {
87− return "", fmt.Errorf("failed to marshal output into JSON: %w", err)
88− }
89−
90− return string(jsonBytes), nil
91−}
internal/format/spinner.go+37−13
@@ -18,24 +18,48 @@ type Spinner struct {
1818 prog *tea.Program
1919}
2020
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+
2144// 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 {
2346 t := styles.CurrentTheme()
24− model := anim.New(anim.Settings{
25− Size: 10,
26− Label: message,
27− LabelColor: t.FgBase,
28− GradColorA: t.Primary,
29− GradColorB: t.Secondary,
30− CycleColors: true,
31− })
47+ model := model{
48+ anim: anim.New(anim.Settings{
49+ Size: 10,
50+ Label: message,
51+ LabelColor: t.FgBase,
52+ GradColorA: t.Primary,
53+ GradColorB: t.Secondary,
54+ CycleColors: true,
55+ }),
56+ cancel: cancel,
57+ }
3258
3359 prog := tea.NewProgram(
3460 model,
35− tea.WithInput(nil),
3661 tea.WithOutput(os.Stderr),
3762 tea.WithContext(ctx),
38− tea.WithoutCatchPanics(),
3963 )
4064
4165 return &Spinner{
@@ -47,13 +71,13 @@ func NewSpinner(ctx context.Context, message string) *Spinner {
4771// Start begins the spinner animation
4872func (s *Spinner) Start() {
4973 go func() {
74+ defer close(s.done)
5075 _, err := s.prog.Run()
5176 // ensures line is cleared
5277 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) {
5479 fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
5580 }
56− close(s.done)
5781 }()
5882}
5983
internal/fur/client/client.go+1−1
@@ -10,7 +10,7 @@ import (
1010 "github.com/charmbracelet/crush/internal/fur/provider"
1111)
1212
13−const defaultURL = "https://fur.charmcli.dev"
13+const defaultURL = "https://fur.charm.sh"
1414
1515// Client represents a client for the fur service.
1616type Client struct {
internal/llm/agent/agent.go+2−2
@@ -149,7 +149,7 @@ func NewAgent(
149149 }
150150 opts := []provider.ProviderClientOption{
151151 provider.WithModel(agentCfg.Model),
152− provider.WithSystemMessage(prompt.GetPrompt(promptID, providerCfg.ID)),
152+ provider.WithSystemMessage(prompt.GetPrompt(promptID, providerCfg.ID, config.Get().Options.ContextPaths...)),
153153 }
154154 agentProvider, err := provider.NewProvider(*providerCfg, opts...)
155155 if err != nil {
@@ -827,7 +827,7 @@ func (a *agent) UpdateModel() error {
827827
828828 opts := []provider.ProviderClientOption{
829829 provider.WithModel(a.agentCfg.Model),
830− provider.WithSystemMessage(prompt.GetPrompt(promptID, currentProviderCfg.ID)),
830+ provider.WithSystemMessage(prompt.GetPrompt(promptID, currentProviderCfg.ID, cfg.Options.ContextPaths...)),
831831 }
832832
833833 newProvider, err := provider.NewProvider(*currentProviderCfg, opts...)
internal/llm/prompt/prompt.go+48−5
@@ -5,6 +5,9 @@ import (
55 "path/filepath"
66 "strings"
77 "sync"
8+
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/env"
811)
912
1013type PromptID string
@@ -21,7 +24,7 @@ func GetPrompt(promptID PromptID, provider string, contextPaths ...string) strin
2124 basePrompt := ""
2225 switch promptID {
2326 case PromptCoder:
24− basePrompt = CoderPrompt(provider)
27+ basePrompt = CoderPrompt(provider, contextPaths...)
2528 case PromptTitle:
2629 basePrompt = TitlePrompt()
2730 case PromptTask:
@@ -38,6 +41,32 @@ func getContextFromPaths(workingDir string, contextPaths []string) string {
3841 return processContextPaths(workingDir, contextPaths)
3942}
4043
44+// expandPath expands ~ and environment variables in file paths
45+func expandPath(path string) string {
46+ // Handle tilde expansion
47+ if strings.HasPrefix(path, "~/") {
48+ homeDir, err := os.UserHomeDir()
49+ if err == nil {
50+ path = filepath.Join(homeDir, path[2:])
51+ }
52+ } else if path == "~" {
53+ homeDir, err := os.UserHomeDir()
54+ if err == nil {
55+ path = homeDir
56+ }
57+ }
58+
59+ // Handle environment variable expansion using the same pattern as config
60+ if strings.HasPrefix(path, "$") {
61+ resolver := config.NewEnvironmentVariableResolver(env.New())
62+ if expanded, err := resolver.ResolveValue(path); err == nil {
63+ path = expanded
64+ }
65+ }
66+
67+ return path
68+}
69+
4170func processContextPaths(workDir string, paths []string) string {
4271 var (
4372 wg sync.WaitGroup
@@ -53,8 +82,23 @@ func processContextPaths(workDir string, paths []string) string {
5382 go func(p string) {
5483 defer wg.Done()
5584
56− if strings.HasSuffix(p, "/") {
57− filepath.WalkDir(filepath.Join(workDir, p), func(path string, d os.DirEntry, err error) error {
85+ // Expand ~ and environment variables before processing
86+ p = expandPath(p)
87+
88+ // Use absolute path if provided, otherwise join with workDir
89+ fullPath := p
90+ if !filepath.IsAbs(p) {
91+ fullPath = filepath.Join(workDir, p)
92+ }
93+
94+ // Check if the path is a directory using os.Stat
95+ info, err := os.Stat(fullPath)
96+ if err != nil {
97+ return // Skip if path doesn't exist or can't be accessed
98+ }
99+
100+ if info.IsDir() {
101+ filepath.WalkDir(fullPath, func(path string, d os.DirEntry, err error) error {
58102 if err != nil {
59103 return err
60104 }
@@ -78,8 +122,7 @@ func processContextPaths(workDir string, paths []string) string {
78122 return nil
79123 })
80124 } else {
81− fullPath := filepath.Join(workDir, p)
82−
125+ // It's a file, process it directly
83126 // Check if we've already processed this file (case-insensitive)
84127 lowerPath := strings.ToLower(fullPath)
85128
internal/llm/prompt/prompt_test.gonew file+113
@@ -0,0 +1,113 @@
1+package prompt
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestExpandPath(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ input string
14+ expected func() string
15+ }{
16+ {
17+ name: "regular path unchanged",
18+ input: "/absolute/path",
19+ expected: func() string {
20+ return "/absolute/path"
21+ },
22+ },
23+ {
24+ name: "tilde expansion",
25+ input: "~/documents",
26+ expected: func() string {
27+ home, _ := os.UserHomeDir()
28+ return filepath.Join(home, "documents")
29+ },
30+ },
31+ {
32+ name: "tilde only",
33+ input: "~",
34+ expected: func() string {
35+ home, _ := os.UserHomeDir()
36+ return home
37+ },
38+ },
39+ {
40+ name: "environment variable expansion",
41+ input: "$HOME",
42+ expected: func() string {
43+ return os.Getenv("HOME")
44+ },
45+ },
46+ {
47+ name: "relative path unchanged",
48+ input: "relative/path",
49+ expected: func() string {
50+ return "relative/path"
51+ },
52+ },
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ result := expandPath(tt.input)
58+ expected := tt.expected()
59+
60+ // Skip test if environment variable is not set
61+ if strings.HasPrefix(tt.input, "$") && expected == "" {
62+ t.Skip("Environment variable not set")
63+ }
64+
65+ if result != expected {
66+ t.Errorf("expandPath(%q) = %q, want %q", tt.input, result, expected)
67+ }
68+ })
69+ }
70+}
71+
72+func TestProcessContextPaths(t *testing.T) {
73+ // Create a temporary directory and file for testing
74+ tmpDir := t.TempDir()
75+ testFile := filepath.Join(tmpDir, "test.txt")
76+ testContent := "test content"
77+
78+ err := os.WriteFile(testFile, []byte(testContent), 0o644)
79+ if err != nil {
80+ t.Fatalf("Failed to create test file: %v", err)
81+ }
82+
83+ // Test with absolute path to file
84+ result := processContextPaths("", []string{testFile})
85+ expected := "# From:" + testFile + "\n" + testContent
86+
87+ if result != expected {
88+ t.Errorf("processContextPaths with absolute path failed.\nGot: %q\nWant: %q", result, expected)
89+ }
90+
91+ // Test with directory path (should process all files in directory)
92+ result = processContextPaths("", []string{tmpDir})
93+ if !strings.Contains(result, testContent) {
94+ t.Errorf("processContextPaths with directory path failed to include file content")
95+ }
96+
97+ // Test with tilde expansion (if we can create a file in home directory)
98+ tmpDir = t.TempDir()
99+ t.Setenv("HOME", tmpDir)
100+ homeTestFile := filepath.Join(tmpDir, "crush_test_file.txt")
101+ err = os.WriteFile(homeTestFile, []byte(testContent), 0o644)
102+ if err == nil {
103+ defer os.Remove(homeTestFile) // Clean up
104+
105+ tildeFile := "~/crush_test_file.txt"
106+ result = processContextPaths("", []string{tildeFile})
107+ expected = "# From:" + homeTestFile + "\n" + testContent
108+
109+ if result != expected {
110+ t.Errorf("processContextPaths with tilde expansion failed.\nGot: %q\nWant: %q", result, expected)
111+ }
112+ }
113+}
internal/llm/tools/bash.go+117−10
@@ -4,6 +4,7 @@ import (
44 "context"
55 "encoding/json"
66 "fmt"
7+ "log/slog"
78 "runtime"
89 "strings"
910 "time"
@@ -41,9 +42,74 @@ const (
4142)
4243
4344var bannedCommands = []string{
44− "alias", "curl", "curlie", "wget", "axel", "aria2c",
45− "nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
46− "http-prompt", "chrome", "firefox", "safari",
45+ // Network/Download tools
46+ "alias",
47+ "aria2c",
48+ "axel",
49+ "chrome",
50+ "curl",
51+ "curlie",
52+ "firefox",
53+ "http-prompt",
54+ "httpie",
55+ "links",
56+ "lynx",
57+ "nc",
58+ "safari",
59+ "telnet",
60+ "w3m",
61+ "wget",
62+ "xh",
63+
64+ // System administration
65+ "doas",
66+ "su",
67+ "sudo",
68+
69+ // Package managers
70+ "apk",
71+ "apt",
72+ "apt-cache",
73+ "apt-get",
74+ "dnf",
75+ "dpkg",
76+ "emerge",
77+ "home-manager",
78+ "makepkg",
79+ "opkg",
80+ "pacman",
81+ "paru",
82+ "pkg",
83+ "pkg_add",
84+ "pkg_delete",
85+ "portage",
86+ "rpm",
87+ "yay",
88+ "yum",
89+ "zypper",
90+
91+ // System modification
92+ "at",
93+ "batch",
94+ "chkconfig",
95+ "crontab",
96+ "fdisk",
97+ "mkfs",
98+ "mount",
99+ "parted",
100+ "service",
101+ "systemctl",
102+ "umount",
103+
104+ // Network configuration
105+ "firewall-cmd",
106+ "ifconfig",
107+ "ip",
108+ "iptables",
109+ "netstat",
110+ "pfctl",
111+ "route",
112+ "ufw",
47113}
48114
49115// getSafeReadOnlyCommands returns platform-appropriate safe commands
@@ -244,7 +310,42 @@ Important:
244310- Never update git config`, bannedCommandsStr, MaxOutputLength)
245311}
246312
313+func blockFuncs() []shell.BlockFunc {
314+ return []shell.BlockFunc{
315+ shell.CommandsBlocker(bannedCommands),
316+ shell.ArgumentsBlocker([][]string{
317+ // System package managers
318+ {"apk", "add"},
319+ {"apt", "install"},
320+ {"apt-get", "install"},
321+ {"dnf", "install"},
322+ {"emerge"},
323+ {"pacman", "-S"},
324+ {"pkg", "install"},
325+ {"yum", "install"},
326+ {"zypper", "install"},
327+
328+ // Language-specific package managers
329+ {"brew", "install"},
330+ {"cargo", "install"},
331+ {"gem", "install"},
332+ {"go", "install"},
333+ {"npm", "install", "-g"},
334+ {"npm", "install", "--global"},
335+ {"pip", "install", "--user"},
336+ {"pip3", "install", "--user"},
337+ {"pnpm", "add", "-g"},
338+ {"pnpm", "add", "--global"},
339+ {"yarn", "global", "add"},
340+ }),
341+ }
342+}
343+
247344func NewBashTool(permission permission.Service, workingDir string) BaseTool {
345+ // Set up command blocking on the persistent shell
346+ persistentShell := shell.GetPersistentShell(workingDir)
347+ persistentShell.SetBlockFuncs(blockFuncs())
348+
248349 return &bashTool{
249350 permissions: permission,
250351 workingDir: workingDir,
@@ -289,13 +390,6 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
289390 return NewTextErrorResponse("missing command"), nil
290391 }
291392
292− baseCmd := strings.Fields(params.Command)[0]
293− for _, banned := range bannedCommands {
294− if strings.EqualFold(baseCmd, banned) {
295− return NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", baseCmd)), nil
296− }
297− }
298−
299393 isSafeReadOnly := false
300394 cmdLower := strings.ToLower(params.Command)
301395
@@ -349,7 +443,20 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
349443 stdout = truncateOutput(stdout)
350444 stderr = truncateOutput(stderr)
351445
446+ slog.Info("Bash command executed",
447+ "command", params.Command,
448+ "stdout", stdout,
449+ "stderr", stderr,
450+ "exit_code", exitCode,
451+ "interrupted", interrupted,
452+ "err", err,
453+ )
454+
352455 errorMessage := stderr
456+ if errorMessage == "" && err != nil {
457+ errorMessage = err.Error()
458+ }
459+
353460 if interrupted {
354461 if errorMessage != "" {
355462 errorMessage += "\n"
internal/lsp/transport.go+22−19
@@ -222,29 +222,32 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
222222 }
223223
224224 // Wait for response
225− resp := <-ch
226−
227− if cfg.Options.DebugLSP {
228− 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− }
225+ select {
226+ case <-ctx.Done():
227+ return ctx.Err()
228+ case resp := <-ch:
229+ if cfg.Options.DebugLSP {
230+ slog.Debug("Received response", "id", id)
231+ }
234232
235− if result != nil {
236− // If result is a json.RawMessage, just copy the raw bytes
237− if rawMsg, ok := result.(*json.RawMessage); ok {
238− *rawMsg = resp.Result
239− return nil
233+ if resp.Error != nil {
234+ return fmt.Errorf("request failed: %s (code: %d)", resp.Error.Message, resp.Error.Code)
240235 }
241− // Otherwise unmarshal into the provided type
242− if err := json.Unmarshal(resp.Result, result); err != nil {
243− return fmt.Errorf("failed to unmarshal result: %w", err)
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+ }
243+ // Otherwise unmarshal into the provided type
244+ if err := json.Unmarshal(resp.Result, result); err != nil {
245+ return fmt.Errorf("failed to unmarshal result: %w", err)
246+ }
244247 }
245− }
246248
247− return nil
249+ return nil
250+ }
248251}
249252
250253// Notify sends a notification (a request without an ID that doesn't expect a response)
internal/lsp/watcher/watcher.go+55−98
@@ -21,6 +21,7 @@ import (
2121// WorkspaceWatcher manages LSP file watching
2222type WorkspaceWatcher struct {
2323 client *lsp.Client
24+ name string
2425 workspacePath string
2526
2627 debounceTime time.Duration
@@ -33,8 +34,9 @@ type WorkspaceWatcher struct {
3334}
3435
3536// NewWorkspaceWatcher creates a new workspace watcher
36−func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
37+func NewWorkspaceWatcher(name string, client *lsp.Client) *WorkspaceWatcher {
3738 return &WorkspaceWatcher{
39+ name: name,
3840 client: client,
3941 debounceTime: 300 * time.Millisecond,
4042 debounceMap: make(map[string]*time.Timer),
@@ -95,7 +97,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
9597 }
9698
9799 // Determine server type for specialized handling
98− serverName := getServerNameFromContext(ctx)
100+ serverName := w.name
99101 slog.Debug("Server type detected", "serverName", serverName)
100102
101103 // Check if this server has sent file watchers
@@ -325,17 +327,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
325327 cfg := config.Get()
326328 w.workspacePath = workspacePath
327329
328− // Store the watcher in the context for later use
329− ctx = context.WithValue(ctx, "workspaceWatcher", w)
330−
331− // If the server name isn't already in the context, try to detect it
332− if _, ok := ctx.Value("serverName").(string); !ok {
333− serverName := getServerNameFromContext(ctx)
334− ctx = context.WithValue(ctx, "serverName", serverName)
335− }
336−
337− serverName := getServerNameFromContext(ctx)
338− slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName)
330+ slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", w.name)
339331
340332 // Register handler for file watcher registrations from the server
341333 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
@@ -697,40 +689,6 @@ func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, chan
697689 return w.client.DidChangeWatchedFiles(ctx, params)
698690}
699691
700−// getServerNameFromContext extracts the server name from the context
701−// This is a best-effort function that tries to identify which LSP server we're dealing with
702−func getServerNameFromContext(ctx context.Context) string {
703− // First check if the server name is directly stored in the context
704− if serverName, ok := ctx.Value("serverName").(string); ok && serverName != "" {
705− return strings.ToLower(serverName)
706− }
707−
708− // Otherwise, try to extract server name from the client command path
709− if w, ok := ctx.Value("workspaceWatcher").(*WorkspaceWatcher); ok && w != nil && w.client != nil && w.client.Cmd != nil {
710− path := strings.ToLower(w.client.Cmd.Path)
711−
712− // Extract server name from path
713− if strings.Contains(path, "typescript") || strings.Contains(path, "tsserver") || strings.Contains(path, "vtsls") {
714− return "typescript"
715− } else if strings.Contains(path, "gopls") {
716− return "gopls"
717− } else if strings.Contains(path, "rust-analyzer") {
718− return "rust-analyzer"
719− } else if strings.Contains(path, "pyright") || strings.Contains(path, "pylsp") || strings.Contains(path, "python") {
720− return "python"
721− } else if strings.Contains(path, "clangd") {
722− return "clangd"
723− } else if strings.Contains(path, "jdtls") || strings.Contains(path, "java") {
724− return "java"
725− }
726−
727− // Return the base name as fallback
728− return filepath.Base(path)
729− }
730−
731− return "unknown"
732−}
733−
734692// shouldPreloadFiles determines if we should preload files for a specific language server
735693// Some servers work better with preloaded files, others don't need it
736694func shouldPreloadFiles(serverName string) bool {
@@ -884,64 +842,63 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
884842 }
885843
886844 // Check if this path should be watched according to server registrations
887− if watched, _ := w.isPathWatched(path); watched {
888− // Get server name for specialized handling
889− serverName := getServerNameFromContext(ctx)
845+ if watched, _ := w.isPathWatched(path); !watched {
846+ return
847+ }
890848
891− // Check if the file is a high-priority file that should be opened immediately
892− // This helps with project initialization for certain language servers
893− if isHighPriorityFile(path, serverName) {
894− if cfg.Options.DebugLSP {
895− slog.Debug("Opening high-priority file", "path", path, "serverName", serverName)
896− }
897− if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
898− slog.Error("Error opening high-priority file", "path", path, "error", err)
899− }
900− return
901− }
849+ serverName := w.name
902850
903− // For non-high-priority files, we'll use different strategies based on server type
904− if shouldPreloadFiles(serverName) {
905− // For servers that benefit from preloading, open files but with limits
851+ // Get server name for specialized handling
852+ // Check if the file is a high-priority file that should be opened immediately
853+ // This helps with project initialization for certain language servers
854+ if isHighPriorityFile(path, serverName) {
855+ if cfg.Options.DebugLSP {
856+ slog.Debug("Opening high-priority file", "path", path, "serverName", serverName)
857+ }
858+ if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
859+ slog.Error("Error opening high-priority file", "path", path, "error", err)
860+ }
861+ return
862+ }
906863
907− // Check file size - for preloading we're more conservative
908− if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
909− if cfg.Options.DebugLSP {
910− slog.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
911− }
912− return
913− }
864+ // For non-high-priority files, we'll use different strategies based on server type
865+ if !shouldPreloadFiles(serverName) {
866+ return
867+ }
868+ // For servers that benefit from preloading, open files but with limits
914869
915− // Check file extension for common source files
916− ext := strings.ToLower(filepath.Ext(path))
870+ // Check file size - for preloading we're more conservative
871+ if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
872+ if cfg.Options.DebugLSP {
873+ slog.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
874+ }
875+ return
876+ }
917877
918− // Only preload source files for the specific language
919− shouldOpen := false
878+ // Check file extension for common source files
879+ ext := strings.ToLower(filepath.Ext(path))
920880
921− switch serverName {
922− case "typescript", "typescript-language-server", "tsserver", "vtsls":
923− shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
924− case "gopls":
925− shouldOpen = ext == ".go"
926− case "rust-analyzer":
927− shouldOpen = ext == ".rs"
928− case "python", "pyright", "pylsp":
929− shouldOpen = ext == ".py"
930− case "clangd":
931− shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
932− case "java", "jdtls":
933− shouldOpen = ext == ".java"
934− default:
935− // For unknown servers, be conservative
936− shouldOpen = false
937− }
881+ // Only preload source files for the specific language
882+ var shouldOpen bool
883+ switch serverName {
884+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
885+ shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
886+ case "gopls":
887+ shouldOpen = ext == ".go"
888+ case "rust-analyzer":
889+ shouldOpen = ext == ".rs"
890+ case "python", "pyright", "pylsp":
891+ shouldOpen = ext == ".py"
892+ case "clangd":
893+ shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
894+ case "java", "jdtls":
895+ shouldOpen = ext == ".java"
896+ }
938897
939− if shouldOpen {
940− // Don't need to check if it's already open - the client.OpenFile handles that
941− if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
942− slog.Error("Error opening file", "path", path, "error", err)
943− }
944− }
898+ if shouldOpen {
899+ // Don't need to check if it's already open - the client.OpenFile handles that
900+ if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
901+ slog.Error("Error opening file", "path", path, "error", err)
945902 }
946903 }
947904}
internal/shell/command_block_test.gonew file+123
@@ -0,0 +1,123 @@
1+package shell
2+
3+import (
4+ "context"
5+ "os"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestCommandBlocking(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ blockFuncs []BlockFunc
14+ command string
15+ shouldBlock bool
16+ }{
17+ {
18+ name: "block simple command",
19+ blockFuncs: []BlockFunc{
20+ func(args []string) bool {
21+ return len(args) > 0 && args[0] == "curl"
22+ },
23+ },
24+ command: "curl https://example.com",
25+ shouldBlock: true,
26+ },
27+ {
28+ name: "allow non-blocked command",
29+ blockFuncs: []BlockFunc{
30+ func(args []string) bool {
31+ return len(args) > 0 && args[0] == "curl"
32+ },
33+ },
34+ command: "echo hello",
35+ shouldBlock: false,
36+ },
37+ {
38+ name: "block subcommand",
39+ blockFuncs: []BlockFunc{
40+ func(args []string) bool {
41+ return len(args) >= 2 && args[0] == "brew" && args[1] == "install"
42+ },
43+ },
44+ command: "brew install wget",
45+ shouldBlock: true,
46+ },
47+ {
48+ name: "allow different subcommand",
49+ blockFuncs: []BlockFunc{
50+ func(args []string) bool {
51+ return len(args) >= 2 && args[0] == "brew" && args[1] == "install"
52+ },
53+ },
54+ command: "brew list",
55+ shouldBlock: false,
56+ },
57+ {
58+ name: "block npm global install with -g",
59+ blockFuncs: []BlockFunc{
60+ ArgumentsBlocker([][]string{
61+ {"npm", "install", "-g"},
62+ {"npm", "install", "--global"},
63+ }),
64+ },
65+ command: "npm install -g typescript",
66+ shouldBlock: true,
67+ },
68+ {
69+ name: "block npm global install with --global",
70+ blockFuncs: []BlockFunc{
71+ ArgumentsBlocker([][]string{
72+ {"npm", "install", "-g"},
73+ {"npm", "install", "--global"},
74+ }),
75+ },
76+ command: "npm install --global typescript",
77+ shouldBlock: true,
78+ },
79+ {
80+ name: "allow npm local install",
81+ blockFuncs: []BlockFunc{
82+ ArgumentsBlocker([][]string{
83+ {"npm", "install", "-g"},
84+ {"npm", "install", "--global"},
85+ }),
86+ },
87+ command: "npm install typescript",
88+ shouldBlock: false,
89+ },
90+ }
91+
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ // Create a temporary directory for each test
95+ tmpDir, err := os.MkdirTemp("", "shell-test-*")
96+ if err != nil {
97+ t.Fatalf("Failed to create temp dir: %v", err)
98+ }
99+ defer os.RemoveAll(tmpDir)
100+
101+ shell := NewShell(&Options{
102+ WorkingDir: tmpDir,
103+ BlockFuncs: tt.blockFuncs,
104+ })
105+
106+ _, _, err = shell.Exec(context.Background(), tt.command)
107+
108+ if tt.shouldBlock {
109+ if err == nil {
110+ t.Errorf("Expected command to be blocked, but it was allowed")
111+ } else if !strings.Contains(err.Error(), "not allowed for security reasons") {
112+ t.Errorf("Expected security error, got: %v", err)
113+ }
114+ } else {
115+ // For non-blocked commands, we might get other errors (like command not found)
116+ // but we shouldn't get the security error
117+ if err != nil && strings.Contains(err.Error(), "not allowed for security reasons") {
118+ t.Errorf("Command was unexpectedly blocked: %v", err)
119+ }
120+ }
121+ })
122+ }
123+}
internal/shell/shell.go+75−7
@@ -44,12 +44,16 @@ type noopLogger struct{}
4444
4545func (noopLogger) InfoPersist(msg string, keysAndValues ...interface{}) {}
4646
47+// BlockFunc is a function that determines if a command should be blocked
48+type BlockFunc func(args []string) bool
49+
4750// Shell provides cross-platform shell execution with optional state persistence
4851type Shell struct {
49− env []string
50− cwd string
51− mu sync.Mutex
52− logger Logger
52+ env []string
53+ cwd string
54+ mu sync.Mutex
55+ logger Logger
56+ blockFuncs []BlockFunc
5357}
5458
5559// Options for creating a new shell
@@ -57,6 +61,7 @@ type Options struct {
5761 WorkingDir string
5862 Env []string
5963 Logger Logger
64+ BlockFuncs []BlockFunc
6065}
6166
6267// NewShell creates a new shell instance with the given options
@@ -81,9 +86,10 @@ func NewShell(opts *Options) *Shell {
8186 }
8287
8388 return &Shell{
84− cwd: cwd,
85− env: env,
86− logger: logger,
89+ cwd: cwd,
90+ env: env,
91+ logger: logger,
92+ blockFuncs: opts.BlockFuncs,
8793 }
8894}
8995
@@ -152,6 +158,13 @@ func (s *Shell) SetEnv(key, value string) {
152158 s.env = append(s.env, keyPrefix+value)
153159}
154160
161+// SetBlockFuncs sets the command block functions for the shell
162+func (s *Shell) SetBlockFuncs(blockFuncs []BlockFunc) {
163+ s.mu.Lock()
164+ defer s.mu.Unlock()
165+ s.blockFuncs = blockFuncs
166+}
167+
155168// Windows-specific commands that should use native shell
156169var windowsNativeCommands = map[string]bool{
157170 "dir": true,
@@ -203,6 +216,60 @@ func (s *Shell) determineShellType(command string) ShellType {
203216 return ShellTypePOSIX
204217}
205218
219+// CommandsBlocker creates a BlockFunc that blocks exact command matches
220+func CommandsBlocker(bannedCommands []string) BlockFunc {
221+ bannedSet := make(map[string]bool)
222+ for _, cmd := range bannedCommands {
223+ bannedSet[cmd] = true
224+ }
225+
226+ return func(args []string) bool {
227+ if len(args) == 0 {
228+ return false
229+ }
230+ return bannedSet[args[0]]
231+ }
232+}
233+
234+// ArgumentsBlocker creates a BlockFunc that blocks specific subcommands
235+func ArgumentsBlocker(blockedSubCommands [][]string) BlockFunc {
236+ return func(args []string) bool {
237+ for _, blocked := range blockedSubCommands {
238+ if len(args) >= len(blocked) {
239+ match := true
240+ for i, part := range blocked {
241+ if args[i] != part {
242+ match = false
243+ break
244+ }
245+ }
246+ if match {
247+ return true
248+ }
249+ }
250+ }
251+ return false
252+ }
253+}
254+
255+func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
256+ return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
257+ return func(ctx context.Context, args []string) error {
258+ if len(args) == 0 {
259+ return next(ctx, args)
260+ }
261+
262+ for _, blockFunc := range s.blockFuncs {
263+ if blockFunc(args) {
264+ return fmt.Errorf("command is not allowed for security reasons: %s", strings.Join(args, " "))
265+ }
266+ }
267+
268+ return next(ctx, args)
269+ }
270+ }
271+}
272+
206273// execWindows executes commands using native Windows shells (cmd.exe or PowerShell)
207274func (s *Shell) execWindows(ctx context.Context, command string, shell string) (string, string, error) {
208275 var cmd *exec.Cmd
@@ -291,6 +358,7 @@ func (s *Shell) execPOSIX(ctx context.Context, command string) (string, string,
291358 interp.Interactive(false),
292359 interp.Env(expand.ListEnviron(s.env...)),
293360 interp.Dir(s.cwd),
361+ interp.ExecHandlers(s.blockHandler()),
294362 )
295363 if err != nil {
296364 return "", "", fmt.Errorf("could not run command: %w", err)
internal/tui/components/chat/editor/editor.go+3−2
@@ -361,8 +361,9 @@ func (m *editorCmp) startCompletions() tea.Msg {
361361 })
362362 }
363363
364− x := m.textarea.Cursor().X + m.x + 1
365− y := m.textarea.Cursor().Y + m.y + 1
364+ cur := m.textarea.Cursor()
365+ x := cur.X + m.x // adjust for padding
366+ y := cur.Y + m.y + 1
366367 return completions.OpenCompletionsMsg{
367368 Completions: completionItems,
368369 X: x,
internal/tui/components/chat/messages/renderer.go+4−3
@@ -207,6 +207,7 @@ func (br bashRenderer) Render(v *toolCallCmp) string {
207207 }
208208
209209 cmd := strings.ReplaceAll(params.Command, "\n", " ")
210+ cmd = strings.ReplaceAll(cmd, "\t", " ")
210211 args := newParamBuilder().addMain(cmd).build()
211212
212213 return br.renderWithParams(v, "Bash", args, func() string {
@@ -578,8 +579,8 @@ func renderParamList(nested bool, paramsWidth int, params ...string) string {
578579 return ""
579580 }
580581 mainParam := params[0]
581− if paramsWidth-3 >= 0 && len(mainParam) > paramsWidth {
582− mainParam = mainParam[:paramsWidth-3] + "…"
582+ if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
583+ mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
583584 }
584585
585586 if len(params) == 1 {
@@ -649,7 +650,7 @@ func joinHeaderBody(header, body string) string {
649650 return header
650651 }
651652 body = t.S().Base.PaddingLeft(2).Render(body)
652− return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
653+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
653654}
654655
655656func renderPlainContent(v *toolCallCmp, content string) string {
internal/tui/components/chat/splash/keys.go+2−2
@@ -30,11 +30,11 @@ func DefaultKeyMap() KeyMap {
3030 key.WithHelp("↑", "previous item"),
3131 ),
3232 Yes: key.NewBinding(
33− key.WithKeys("y"),
33+ key.WithKeys("y", "Y"),
3434 key.WithHelp("y", "yes"),
3535 ),
3636 No: key.NewBinding(
37− key.WithKeys("n"),
37+ key.WithKeys("n", "N"),
3838 key.WithHelp("n", "no"),
3939 ),
4040 Tab: key.NewBinding(
internal/tui/components/chat/splash/splash.go+1
@@ -313,6 +313,7 @@ func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
313313 return util.ReportError(err)
314314 }
315315 }
316+ cfg.SetupAgents()
316317 return nil
317318}
318319
internal/tui/components/completions/completions.go+16−7
@@ -9,6 +9,8 @@ import (
99 "github.com/charmbracelet/lipgloss/v2"
1010)
1111
12+const maxCompletionsHeight = 10
13+
1214type Completion struct {
1315 Title string // The title of the completion item
1416 Value any // The value of the completion item
@@ -43,7 +45,7 @@ type Completions interface {
4345type completionsCmp struct {
4446 width int
4547 height int // Height of the completions component`
46− x int // X position for the completions popup\
48+ x int // X position for the completions popup
4749 y int // Y position for the completions popup
4850 open bool // Indicates if the completions are open
4951 keyMap KeyMap
@@ -150,18 +152,25 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
150152 if !c.open {
151153 return c, nil // If completions are not open, do nothing
152154 }
153− cmd := c.list.Filter(msg.Query)
154− c.height = max(min(10, len(c.list.Items())), 1)
155− return c, tea.Batch(
156− cmd,
157− c.list.SetSize(c.width, c.height),
158− )
155+ var cmds []tea.Cmd
156+ cmds = append(cmds, c.list.Filter(msg.Query))
157+ itemsLen := len(c.list.Items())
158+ c.height = max(min(maxCompletionsHeight, itemsLen), 1)
159+ cmds = append(cmds, c.list.SetSize(c.width, c.height))
160+ if itemsLen == 0 {
161+ // Close completions if no items match the query
162+ cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
163+ }
164+ return c, tea.Batch(cmds...)
159165 }
160166 return c, nil
161167}
162168
163169// View implements Completions.
164170func (c *completionsCmp) View() string {
171+ if !c.open {
172+ return ""
173+ }
165174 if len(c.list.Items()) == 0 {
166175 return c.style().Render("No completions found")
167176 }
internal/tui/components/dialogs/permissions/keys.go−4
@@ -109,9 +109,5 @@ func (k KeyMap) ShortHelp() []key.Binding {
109109 key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
110110 key.WithHelp("shift+←↓↑→", "scroll"),
111111 ),
112− key.NewBinding(
113− key.WithKeys("shift+h", "shift+j", "shift+k", "shift+l"),
114− key.WithHelp("shift+hjkl", "scroll"),
115− ),
116112 }
117113}
internal/tui/exp/diffview/diffview.go+4−2
@@ -365,7 +365,8 @@ func (dv *DiffView) renderUnified() string {
365365 shouldWrite := func() bool { return printedLines >= 0 }
366366
367367 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
368− content = strings.TrimSuffix(in, "\n")
368+ content = strings.ReplaceAll(in, "\r\n", "\n")
369+ content = strings.TrimSuffix(content, "\n")
369370 content = dv.hightlightCode(content, ls.Code.GetBackground())
370371 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
371372 content = ansi.Truncate(content, dv.codeWidth, "…")
@@ -488,7 +489,8 @@ func (dv *DiffView) renderSplit() string {
488489 shouldWrite := func() bool { return printedLines >= 0 }
489490
490491 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
491− content = strings.TrimSuffix(in, "\n")
492+ content = strings.ReplaceAll(in, "\r\n", "\n")
493+ content = strings.TrimSuffix(content, "\n")
492494 content = dv.hightlightCode(content, ls.Code.GetBackground())
493495 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
494496 content = ansi.Truncate(content, dv.codeWidth, "…")
internal/tui/page/chat/chat.go−15
@@ -2,7 +2,6 @@ package chat
22
33import (
44 "context"
5− "runtime"
65 "time"
76
87 "github.com/charmbracelet/bubbles/v2/help"
@@ -615,26 +614,12 @@ func (a *chatPage) Help() help.KeyMap {
615614 fullList = append(fullList, []key.Binding{v})
616615 }
617616 case a.isOnboarding && a.splash.IsShowingAPIKey():
618− var pasteKey key.Binding
619− if runtime.GOOS != "darwin" {
620− pasteKey = key.NewBinding(
621− key.WithKeys("ctrl+v"),
622− key.WithHelp("ctrl+v", "paste API key"),
623− )
624− } else {
625− pasteKey = key.NewBinding(
626− key.WithKeys("cmd+v"),
627− key.WithHelp("cmd+v", "paste API key"),
628− )
629− }
630617 shortList = append(shortList,
631618 // Go back
632619 key.NewBinding(
633620 key.WithKeys("esc"),
634621 key.WithHelp("esc", "back"),
635622 ),
636− // Paste
637− pasteKey,
638623 // Quit
639624 key.NewBinding(
640625 key.WithKeys("ctrl+c"),
internal/tui/tui.go+3
@@ -327,6 +327,9 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
327327 // If the commands dialog is already open, close it
328328 return util.CmdHandler(dialogs.CloseDialogMsg{})
329329 }
330+ if a.dialog.HasDialogs() {
331+ return nil // Don't open commands dialog if another dialog is active
332+ }
330333 return util.CmdHandler(dialogs.OpenDialogMsg{
331334 Model: commands.NewCommandDialog(a.selectedSessionID),
332335 })
internal/tui/util/util.go+2
@@ -1,6 +1,7 @@
11package util
22
33import (
4+ "log/slog"
45 "time"
56
67 tea "github.com/charmbracelet/bubbletea/v2"
@@ -22,6 +23,7 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
2223}
2324
2425func ReportError(err error) tea.Cmd {
26+ slog.Error("Error reported", "error", err)
2527 return CmdHandler(InfoMsg{
2628 Type: InfoTypeError,
2729 Msg: err.Error(),
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 @@
11# Fang
22
33<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">
55</p>
66<p>
77 <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 @@
1212The CLI starter kit. A small, experimental library for batteries-included [Cobra][cobra] applications.
1313
1414<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" />
1616</p>
1717
1818## Features
@@ -45,6 +45,7 @@ To use it, invoke `fang.Execute` passing your root `*cobra.Command`:
4545package main
4646
4747import (
48+ "context"
4849 "os"
4950
5051 "github.com/charmbracelet/fang"
@@ -56,7 +57,7 @@ func main() {
5657 Use: "example",
5758 Short: "A simple example program!",
5859 }
59− if err := fang.Execute(context.TODO(), cmd); err != nil {
60+ if err := fang.Execute(context.Background(), cmd); err != nil {
6061 os.Exit(1)
6162 }
6263}
vendor/github.com/charmbracelet/fang/fang.go+91−28
@@ -4,11 +4,14 @@ package fang
44import (
55 "context"
66 "fmt"
7+ "io"
78 "os"
9+ "os/signal"
810 "runtime/debug"
911
1012 "github.com/charmbracelet/colorprofile"
1113 "github.com/charmbracelet/lipgloss/v2"
14+ "github.com/charmbracelet/x/term"
1215 mango "github.com/muesli/mango-cobra"
1316 "github.com/muesli/roff"
1417 "github.com/spf13/cobra"
@@ -16,12 +19,24 @@ import (
1619
1720const shaLen = 7
1821
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+
1931type settings struct {
2032 completions bool
2133 manpages bool
34+ skipVersion bool
2235 version string
2336 commit string
24− theme *ColorScheme
37+ colorscheme ColorSchemeFunc
38+ errHandler ErrorHandler
39+ signals []os.Signal
2540}
2641
2742// Option changes fang settings.
@@ -41,10 +56,21 @@ func WithoutManpage() Option {
4156 }
4257}
4358
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+
4466// WithTheme sets the colorscheme.
67+//
68+// Deprecated: use [WithColorSchemeFunc] instead.
4569func WithTheme(theme ColorScheme) Option {
4670 return func(s *settings) {
47− s.theme = &theme
71+ s.colorscheme = func(lipgloss.LightDarkFunc) ColorScheme {
72+ return theme
73+ }
4874 }
4975}
5076
@@ -55,6 +81,13 @@ func WithVersion(version string) Option {
5581 }
5682}
5783
84+// WithoutVersion skips the `-v`/`--version` functionality.
85+func WithoutVersion() Option {
86+ return func(s *settings) {
87+ s.skipVersion = true
88+ }
89+}
90+
5891// WithCommit sets the commit SHA.
5992func WithCommit(commit string) Option {
6093 return func(s *settings) {
@@ -62,30 +95,45 @@ func WithCommit(commit string) Option {
6295 }
6396}
6497
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+
65113// Execute applies fang to the command and executes it.
66114func Execute(ctx context.Context, root *cobra.Command, options ...Option) error {
67115 opts := settings{
68116 manpages: true,
69117 completions: true,
118+ colorscheme: DefaultColorScheme,
119+ errHandler: DefaultErrorHandler,
70120 }
121+
71122 for _, option := range options {
72123 option(&opts)
73124 }
74125
75− if opts.theme == nil {
76− isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stderr)
77− t := DefaultTheme(isDark)
78− opts.theme = &t
126+ helpFunc := func(c *cobra.Command, _ []string) {
127+ w := colorprofile.NewWriter(c.OutOrStdout(), os.Environ())
128+ helpFn(c, w, makeStyles(mustColorscheme(opts.colorscheme)))
79129 }
80130
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− })
87131 root.SilenceUsage = true
88132 root.SilenceErrors = true
133+ if !opts.skipVersion {
134+ root.Version = buildVersion(opts)
135+ }
136+ root.SetHelpFunc(helpFunc)
89137
90138 if opts.manpages {
91139 root.AddCommand(&cobra.Command{
@@ -108,34 +156,49 @@ func Execute(ctx context.Context, root *cobra.Command, options ...Option) error
108156 })
109157 }
110158
111− if opts.completions {
112− root.InitDefaultCompletionCmd()
113− } else {
159+ if !opts.completions {
114160 root.CompletionOptions.DisableDefaultCmd = true
115161 }
116162
117− if opts.version == "" {
118− if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" {
119− opts.version = info.Main.Version
120− opts.commit = getKey(info, "vcs.revision")
121− } else {
122− opts.version = "unknown (built from source)"
123− }
124− }
125− if len(opts.commit) >= shaLen {
126− opts.version += " (" + opts.commit[:shaLen] + ")"
163+ if len(opts.signals) > 0 {
164+ var cancel context.CancelFunc
165+ ctx, cancel = signal.NotifyContext(ctx, opts.signals...)
166+ defer cancel()
127167 }
128168
129− root.Version = opts.version
130−
131169 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+ }
132178 w := colorprofile.NewWriter(root.ErrOrStderr(), os.Environ())
133− writeError(w, styles, err)
179+ opts.errHandler(w, makeStyles(mustColorscheme(opts.colorscheme)), err)
134180 return err //nolint:wrapcheck
135181 }
136182 return nil
137183}
138184
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+
139202func getKey(info *debug.BuildInfo, key string) string {
140203 if info == nil {
141204 return ""
vendor/github.com/charmbracelet/fang/help.go+208−90
@@ -3,7 +3,10 @@ package fang
33import (
44 "cmp"
55 "fmt"
6+ "io"
7+ "iter"
68 "os"
9+ "reflect"
710 "regexp"
811 "strconv"
912 "strings"
@@ -20,6 +23,7 @@ import (
2023const (
2124 minSpace = 10
2225 shortPad = 2
26+ longPad = 4
2327)
2428
2529var width = sync.OnceValue(func() int {
@@ -45,65 +49,95 @@ func helpFn(c *cobra.Command, w *colorprofile.Writer, styles Styles) {
4549 blockWidth = max(blockWidth, lipgloss.Width(ex))
4650 }
4751 blockWidth = min(width()-padding, blockWidth+padding)
52+ blockStyle := styles.Codeblock.Base.Width(blockWidth)
4853
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+ }
5059
5160 _, _ = fmt.Fprintln(w, styles.Title.Render("usage"))
52− _, _ = fmt.Fprintln(w, styles.Codeblock.Base.Render(usage))
61+ _, _ = fmt.Fprintln(w, blockStyle.Render(usage))
5362 if len(examples) > 0 {
54− cw := styles.Codeblock.Base.GetWidth() - styles.Codeblock.Base.GetHorizontalPadding()
63+ cw := blockStyle.GetWidth() - blockStyle.GetHorizontalPadding()
5564 _, _ = fmt.Fprintln(w, styles.Title.Render("examples"))
5665 for i, example := range examples {
5766 if lipgloss.Width(example) > cw {
5867 examples[i] = ansi.Truncate(example, cw, "…")
5968 }
6069 }
61− _, _ = fmt.Fprintln(w, styles.Codeblock.Base.Render(strings.Join(examples, "\n")))
70+ _, _ = fmt.Fprintln(w, blockStyle.Render(strings.Join(examples, "\n")))
6271 }
6372
73+ groups, groupKeys := evalGroups(c)
6474 cmds, cmdKeys := evalCmds(c, styles)
6575 flags, flagKeys := evalFlags(c, styles)
6676 space := calculateSpace(cmdKeys, flagKeys)
6777
68− leftPadding := 4
69− if len(cmds) > 0 {
70− _, _ = fmt.Fprintln(w, styles.Title.Render("commands"))
71− for _, k := range cmdKeys {
72− _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
73− lipgloss.Left,
74− lipgloss.NewStyle().PaddingLeft(leftPadding).Render(k),
75− strings.Repeat(" ", space-lipgloss.Width(k)),
76− cmds[k],
77− ))
78+ for _, groupID := range groupKeys {
79+ group := cmds[groupID]
80+ if len(group) == 0 {
81+ continue
7882 }
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+ })
7994 }
8095
8196 if len(flags) > 0 {
82− _, _ = fmt.Fprintln(w, styles.Title.Render("flags"))
83− 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],
89− ))
90− }
97+ renderGroup(w, styles, space, "flags", func(yield func(string, string) bool) {
98+ for _, k := range flagKeys {
99+ if !yield(k, flags[k]) {
100+ return
101+ }
102+ }
103+ })
91104 }
92105
93106 _, _ = fmt.Fprintln(w)
94107}
95108
96−func writeError(w *colorprofile.Writer, styles Styles, err error) {
109+// DefaultErrorHandler is the default [ErrorHandler] implementation.
110+func DefaultErrorHandler(w io.Writer, styles Styles, err error) {
97111 _, _ = fmt.Fprintln(w, styles.ErrorHeader.String())
98112 _, _ = fmt.Fprintln(w, styles.ErrorText.Render(err.Error()+"."))
99113 _, _ = fmt.Fprintln(w)
100− _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
101− lipgloss.Left,
102− styles.ErrorText.UnsetWidth().Render("Try"),
103− styles.Program.Flag.Render("--help"),
104− styles.ErrorText.UnsetWidth().UnsetMargins().UnsetTransform().PaddingLeft(1).Render("for usage."),
105− ))
106− _, _ = fmt.Fprintln(w)
114+ if isUsageError(err) {
115+ _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
116+ lipgloss.Left,
117+ styles.ErrorText.UnsetWidth().Render("Try"),
118+ styles.Program.Flag.Render(" --help "),
119+ styles.ErrorText.UnsetWidth().UnsetMargins().UnsetTransform().Render("for usage."),
120+ ))
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
107141}
108142
109143func writeLongShort(w *colorprofile.Writer, styles Styles, longShort string) {
@@ -118,8 +152,10 @@ var otherArgsRe = regexp.MustCompile(`(\[.*\])`)
118152
119153// styleUsage stylized styleUsage line for a given command.
120154func styleUsage(c *cobra.Command, styles Program, complete bool) string {
121− // XXX: maybe use c.UseLine() here?
122155 u := c.Use
156+ if complete {
157+ u = c.UseLine()
158+ }
123159 hasArgs := strings.Contains(u, "[args]")
124160 hasFlags := strings.Contains(u, "[flags]") || strings.Contains(u, "[--flags]") || c.HasFlags() || c.HasPersistentFlags() || c.HasAvailableFlags()
125161 hasCommands := strings.Contains(u, "[command]") || c.HasAvailableSubCommands()
@@ -139,34 +175,38 @@ func styleUsage(c *cobra.Command, styles Program, complete bool) string {
139175
140176 u = strings.TrimSpace(u)
141177
142− useLine := []string{
143− styles.Name.Render(u),
144− }
145− if !complete {
146− useLine[0] = styles.Command.Render(u)
178+ useLine := []string{}
179+ if complete {
180+ parts := strings.Fields(u)
181+ useLine = append(useLine, styles.Name.Render(parts[0]))
182+ if len(parts) > 1 {
183+ useLine = append(useLine, styles.Command.Render(" "+strings.Join(parts[1:], " ")))
184+ }
185+ } else {
186+ useLine = append(useLine, styles.Command.Render(u))
147187 }
148188 if hasCommands {
149189 useLine = append(
150190 useLine,
151− styles.DimmedArgument.Render("[command]"),
191+ styles.DimmedArgument.Render(" [command]"),
152192 )
153193 }
154194 if hasArgs {
155195 useLine = append(
156196 useLine,
157− styles.DimmedArgument.Render("[args]"),
197+ styles.DimmedArgument.Render(" [args]"),
158198 )
159199 }
160200 for _, arg := range otherArgs {
161201 useLine = append(
162202 useLine,
163− styles.DimmedArgument.Render(arg),
203+ styles.DimmedArgument.Render(" "+arg),
164204 )
165205 }
166206 if hasFlags {
167207 useLine = append(
168208 useLine,
169− styles.DimmedArgument.Render("[--flags]"),
209+ styles.DimmedArgument.Render(" [--flags]"),
170210 )
171211 }
172212 return lipgloss.JoinHorizontal(lipgloss.Left, useLine...)
@@ -180,19 +220,21 @@ func styleExamples(c *cobra.Command, styles Styles) []string {
180220 }
181221 usage := []string{}
182222 examples := strings.Split(c.Example, "\n")
223+ var indent bool
183224 for i, line := range examples {
184225 line = strings.TrimSpace(line)
185226 if (i == 0 || i == len(examples)-1) && line == "" {
186227 continue
187228 }
188− s := styleExample(c, line, styles.Codeblock)
229+ s := styleExample(c, line, indent, styles.Codeblock)
189230 usage = append(usage, s)
231+ indent = len(line) > 1 && (line[len(line)-1] == '\\' || line[len(line)-1] == '|')
190232 }
191233
192234 return usage
193235}
194236
195−func styleExample(c *cobra.Command, line string, styles Codeblock) string {
237+func styleExample(c *cobra.Command, line string, indent bool, styles Codeblock) string {
196238 if strings.HasPrefix(line, "# ") {
197239 return lipgloss.JoinHorizontal(
198240 lipgloss.Left,
@@ -200,66 +242,110 @@ func styleExample(c *cobra.Command, line string, styles Codeblock) string {
200242 )
201243 }
202244
203− args := strings.Fields(line)
204− var nextIsFlag bool
205245 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
206251 for i, arg := range args {
207− if i == 0 {
208− args[i] = styles.Program.Name.Render(arg)
209− continue
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:
264+ args[i] = styles.Program.DimmedArgument.Render(" ")
210265 }
211266
212− quoteStart := arg[0] == '"'
213− quoteEnd := arg[len(arg)-1] == '"'
214− flagStart := arg[0] == '-'
215− if i == 1 && !quoteStart && !flagStart {
216− args[i] = styles.Program.Command.Render(arg)
267+ if isRedirecting {
268+ args[i] += styles.Program.DimmedArgument.Render(arg)
269+ isRedirecting = false
217270 continue
218271 }
219− if quoteStart {
220− isQuotedString = true
221− }
222− if isQuotedString {
223− args[i] = styles.Program.QuotedString.Render(arg)
224− if quoteEnd {
225− isQuotedString = false
272+
273+ switch arg {
274+ case "\\":
275+ if i == len(args)-1 {
276+ args[i] += styles.Program.DimmedArgument.Render(arg)
277+ continue
226278 }
279+ case "|", "||", "-", "&", "&&":
280+ args[i] += styles.Program.DimmedArgument.Render(arg)
227281 continue
228282 }
229− if nextIsFlag {
230− args[i] = styles.Program.Flag.Render(arg)
283+
284+ if isRedirect(arg) {
285+ args[i] += styles.Program.DimmedArgument.Render(arg)
286+ isRedirecting = true
231287 continue
232288 }
233− var dashes string
234− if strings.HasPrefix(arg, "-") {
235− dashes = "-"
289+
290+ if !foundProgramName { //nolint:nestif
291+ if isQuotedString {
292+ args[i] += styles.Program.QuotedString.Render(arg)
293+ isQuotedString = !isQuoteEnd
294+ continue
295+ }
296+ if left, right, ok := strings.Cut(arg, "="); ok {
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+ }
236312 }
237− if strings.HasPrefix(arg, "--") {
238− dashes = "--"
313+
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
239327 }
240328 // handle a flag
241− if dashes != "" {
329+ if isFlag {
242330 name, value, ok := strings.Cut(arg, "=")
243− name = strings.TrimPrefix(name, dashes)
244331 // it is --flag=value
245332 if ok {
246− args[i] = lipgloss.JoinHorizontal(
333+ args[i] += lipgloss.JoinHorizontal(
247334 lipgloss.Left,
248− styles.Program.Flag.Render(dashes+name+"="),
249− styles.Program.Argument.UnsetPadding().Render(value),
335+ styles.Program.Flag.Render(name+"="),
336+ styles.Program.Argument.Render(value),
250337 )
251338 continue
252339 }
253340 // it is either --bool-flag or --flag value
254− args[i] = lipgloss.JoinHorizontal(
341+ args[i] += lipgloss.JoinHorizontal(
255342 lipgloss.Left,
256− styles.Program.Flag.Render(dashes+name),
343+ styles.Program.Flag.Render(name),
257344 )
258− // if the flag is not a bool flag, next arg continues current flag
259− nextIsFlag = !isFlagBool(c, name)
260345 continue
261346 }
262− args[i] = styles.Program.Argument.Render(arg)
347+
348+ args[i] += styles.Program.Argument.Render(arg)
263349 }
264350
265351 return lipgloss.JoinHorizontal(
@@ -284,8 +370,7 @@ func evalFlags(c *cobra.Command, styles Styles) (map[string]string, []string) {
284370 } else {
285371 parts = append(
286372 parts,
287− styles.Program.Flag.Render("-"+f.Shorthand),
288− styles.Program.Flag.Render("--"+f.Name),
373+ styles.Program.Flag.Render("-"+f.Shorthand+" --"+f.Name),
289374 )
290375 }
291376 key := lipgloss.JoinHorizontal(lipgloss.Left, parts...)
@@ -303,22 +388,50 @@ func evalFlags(c *cobra.Command, styles Styles) (map[string]string, []string) {
303388 return flags, keys
304389}
305390
306−func evalCmds(c *cobra.Command, styles Styles) (map[string]string, []string) {
391+// result is map[groupID]map[styled cmd name]styled cmd help, and the keys in
392+// the order they are defined.
393+func evalCmds(c *cobra.Command, styles Styles) (map[string](map[string]string), []string) {
307394 padStyle := lipgloss.NewStyle().PaddingLeft(0) //nolint:mnd
308395 keys := []string{}
309− cmds := map[string]string{}
396+ cmds := map[string]map[string]string{}
310397 for _, sc := range c.Commands() {
311398 if sc.Hidden {
312399 continue
313400 }
401+ if _, ok := cmds[sc.GroupID]; !ok {
402+ cmds[sc.GroupID] = map[string]string{}
403+ }
314404 key := padStyle.Render(styleUsage(sc, styles.Program, false))
315405 help := styles.FlagDescription.Render(sc.Short)
316− cmds[key] = help
406+ cmds[sc.GroupID][key] = help
317407 keys = append(keys, key)
318408 }
319409 return cmds, keys
320410}
321411
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+
322435func calculateSpace(k1, k2 []string) int {
323436 const spaceBetween = 2
324437 space := minSpace
@@ -328,13 +441,18 @@ func calculateSpace(k1, k2 []string) int {
328441 return space
329442}
330443
331−func isFlagBool(c *cobra.Command, name string) bool {
332− flag := c.Flags().Lookup(name)
333− if flag == nil && len(name) == 1 {
334− flag = c.Flags().ShorthandLookup(name)
335− }
336− if flag == nil {
337− return false
444+func isSubCommand(c *cobra.Command, args []string, word string) bool {
445+ cmd, _, _ := c.Root().Traverse(args)
446+ return cmd != nil && cmd.Name() == word
447+}
448+
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) {
454+ return true
455+ }
338456 }
339− return flag.Value.Type() == "bool"
457+ return false
340458}
vendor/github.com/charmbracelet/fang/theme.go+39−13
@@ -2,10 +2,12 @@ package fang
22
33import (
44 "image/color"
5+ "os"
56 "strings"
67
78 "github.com/charmbracelet/lipgloss/v2"
89 "github.com/charmbracelet/x/exp/charmtone"
10+ "github.com/charmbracelet/x/term"
911 "golang.org/x/text/cases"
1012 "golang.org/x/text/language"
1113)
@@ -31,8 +33,14 @@ type ColorScheme struct {
3133}
3234
3335// DefaultTheme is the default colorscheme.
36+//
37+// Deprecated: use [DefaultColorScheme] instead.
3438func 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 {
3644 return ColorScheme{
3745 Base: c(charmtone.Charcoal, charmtone.Ash),
3846 Title: charmtone.Charple,
@@ -45,7 +53,7 @@ func DefaultTheme(isDark bool) ColorScheme {
4553 Argument: c(charmtone.Charcoal, charmtone.Ash),
4654 Description: c(charmtone.Charcoal, charmtone.Ash), // flag and command descriptions
4755 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),
4957 ErrorHeader: [2]color.Color{
5058 charmtone.Butter,
5159 charmtone.Cherry,
@@ -53,6 +61,26 @@ func DefaultTheme(isDark bool) ColorScheme {
5361 }
5462}
5563
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+
5684// Styles represents all the styles used.
5785type Styles struct {
5886 Text lipgloss.Style
@@ -84,6 +112,14 @@ type Program struct {
84112 QuotedString lipgloss.Style
85113}
86114
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+
87123func makeStyles(cs ColorScheme) Styles {
88124 //nolint:mnd
89125 return Styles{
@@ -98,8 +134,7 @@ func makeStyles(cs ColorScheme) Styles {
98134 Foreground(cs.Description).
99135 Transform(titleFirstWord),
100136 FlagDefault: lipgloss.NewStyle().
101− Foreground(cs.FlagDefault).
102− PaddingLeft(1),
137+ Foreground(cs.FlagDefault),
103138 Codeblock: Codeblock{
104139 Base: lipgloss.NewStyle().
105140 Background(cs.Codeblock).
@@ -116,23 +151,18 @@ func makeStyles(cs ColorScheme) Styles {
116151 Background(cs.Codeblock).
117152 Foreground(cs.Program),
118153 Flag: lipgloss.NewStyle().
119− PaddingLeft(1).
120154 Background(cs.Codeblock).
121155 Foreground(cs.Flag),
122156 Argument: lipgloss.NewStyle().
123− PaddingLeft(1).
124157 Background(cs.Codeblock).
125158 Foreground(cs.Argument),
126159 DimmedArgument: lipgloss.NewStyle().
127− PaddingLeft(1).
128160 Background(cs.Codeblock).
129161 Foreground(cs.DimmedArgument),
130162 Command: lipgloss.NewStyle().
131− PaddingLeft(1).
132163 Background(cs.Codeblock).
133164 Foreground(cs.Command),
134165 QuotedString: lipgloss.NewStyle().
135− PaddingLeft(1).
136166 Background(cs.Codeblock).
137167 Foreground(cs.QuotedString),
138168 },
@@ -141,18 +171,14 @@ func makeStyles(cs ColorScheme) Styles {
141171 Name: lipgloss.NewStyle().
142172 Foreground(cs.Program),
143173 Argument: lipgloss.NewStyle().
144− PaddingLeft(1).
145174 Foreground(cs.Argument),
146175 DimmedArgument: lipgloss.NewStyle().
147− PaddingLeft(1).
148176 Foreground(cs.DimmedArgument),
149177 Flag: lipgloss.NewStyle().
150− PaddingLeft(1).
151178 Foreground(cs.Flag),
152179 Command: lipgloss.NewStyle().
153180 Foreground(cs.Command),
154181 QuotedString: lipgloss.NewStyle().
155− PaddingLeft(1).
156182 Foreground(cs.QuotedString),
157183 },
158184 Span: lipgloss.NewStyle().
vendor/github.com/mark3labs/mcp-go/client/client.go+101−1
@@ -22,6 +22,7 @@ type Client struct {
2222 requestID atomic.Int64
2323 clientCapabilities mcp.ClientCapabilities
2424 serverCapabilities mcp.ServerCapabilities
25+ samplingHandler SamplingHandler
2526}
2627
2728type ClientOption func(*Client)
@@ -33,6 +34,21 @@ func WithClientCapabilities(capabilities mcp.ClientCapabilities) ClientOption {
3334 }
3435}
3536
37+// WithSamplingHandler sets the sampling handler for the client.
38+// When set, the client will declare sampling capability during initialization.
39+func WithSamplingHandler(handler SamplingHandler) ClientOption {
40+ return func(c *Client) {
41+ c.samplingHandler = handler
42+ }
43+}
44+
45+// WithSession assumes a MCP Session has already been initialized
46+func WithSession() ClientOption {
47+ return func(c *Client) {
48+ c.initialized = true
49+ }
50+}
51+
3652// NewClient creates a new MCP client with the given transport.
3753// Usage:
3854//
@@ -71,6 +87,12 @@ func (c *Client) Start(ctx context.Context) error {
7187 handler(notification)
7288 }
7389 })
90+
91+ // Set up request handler for bidirectional communication (e.g., sampling)
92+ if bidirectional, ok := c.transport.(transport.BidirectionalInterface); ok {
93+ bidirectional.SetRequestHandler(c.handleIncomingRequest)
94+ }
95+
7496 return nil
7597}
7698
@@ -127,6 +149,12 @@ func (c *Client) Initialize(
127149 ctx context.Context,
128150 request mcp.InitializeRequest,
129151) (*mcp.InitializeResult, error) {
152+ // Merge client capabilities with sampling capability if handler is configured
153+ capabilities := request.Params.Capabilities
154+ if c.samplingHandler != nil {
155+ capabilities.Sampling = &struct{}{}
156+ }
157+
130158 // Ensure we send a params object with all required fields
131159 params := struct {
132160 ProtocolVersion string `json:"protocolVersion"`
@@ -135,7 +163,7 @@ func (c *Client) Initialize(
135163 }{
136164 ProtocolVersion: request.Params.ProtocolVersion,
137165 ClientInfo: request.Params.ClientInfo,
138− Capabilities: request.Params.Capabilities, // Will be empty struct if not set
166+ Capabilities: capabilities,
139167 }
140168
141169 response, err := c.sendRequest(ctx, "initialize", params)
@@ -398,6 +426,64 @@ func (c *Client) Complete(
398426 return &result, nil
399427}
400428
429+// handleIncomingRequest processes incoming requests from the server.
430+// This is the main entry point for server-to-client requests like sampling.
431+func (c *Client) handleIncomingRequest(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) {
432+ switch request.Method {
433+ case string(mcp.MethodSamplingCreateMessage):
434+ return c.handleSamplingRequestTransport(ctx, request)
435+ default:
436+ return nil, fmt.Errorf("unsupported request method: %s", request.Method)
437+ }
438+}
439+
440+// handleSamplingRequestTransport handles sampling requests at the transport level.
441+func (c *Client) handleSamplingRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) {
442+ if c.samplingHandler == nil {
443+ return nil, fmt.Errorf("no sampling handler configured")
444+ }
445+
446+ // Parse the request parameters
447+ var params mcp.CreateMessageParams
448+ if request.Params != nil {
449+ paramsBytes, err := json.Marshal(request.Params)
450+ if err != nil {
451+ return nil, fmt.Errorf("failed to marshal params: %w", err)
452+ }
453+ if err := json.Unmarshal(paramsBytes, &params); err != nil {
454+ return nil, fmt.Errorf("failed to unmarshal params: %w", err)
455+ }
456+ }
457+
458+ // Create the MCP request
459+ mcpRequest := mcp.CreateMessageRequest{
460+ Request: mcp.Request{
461+ Method: string(mcp.MethodSamplingCreateMessage),
462+ },
463+ CreateMessageParams: params,
464+ }
465+
466+ // Call the sampling handler
467+ result, err := c.samplingHandler.CreateMessage(ctx, mcpRequest)
468+ if err != nil {
469+ return nil, err
470+ }
471+
472+ // Marshal the result
473+ resultBytes, err := json.Marshal(result)
474+ if err != nil {
475+ return nil, fmt.Errorf("failed to marshal result: %w", err)
476+ }
477+
478+ // Create the transport response
479+ response := &transport.JSONRPCResponse{
480+ JSONRPC: mcp.JSONRPC_VERSION,
481+ ID: request.ID,
482+ Result: json.RawMessage(resultBytes),
483+ }
484+
485+ return response, nil
486+}
401487func listByPage[T any](
402488 ctx context.Context,
403489 client *Client,
@@ -432,3 +518,17 @@ func (c *Client) GetServerCapabilities() mcp.ServerCapabilities {
432518func (c *Client) GetClientCapabilities() mcp.ClientCapabilities {
433519 return c.clientCapabilities
434520}
521+
522+// GetSessionId returns the session ID of the transport.
523+// If the transport does not support sessions, it returns an empty string.
524+func (c *Client) GetSessionId() string {
525+ if c.transport == nil {
526+ return ""
527+ }
528+ return c.transport.GetSessionId()
529+}
530+
531+// IsInitialized returns true if the client has been initialized.
532+func (c *Client) IsInitialized() bool {
533+ return c.initialized
534+}
vendor/github.com/mark3labs/mcp-go/client/http.go+6−1
@@ -13,5 +13,10 @@ func NewStreamableHttpClient(baseURL string, options ...transport.StreamableHTTP
1313 if err != nil {
1414 return nil, fmt.Errorf("failed to create SSE transport: %w", err)
1515 }
16− return NewClient(trans), nil
16+ clientOptions := make([]ClientOption, 0)
17+ sessionID := trans.GetSessionId()
18+ if sessionID != "" {
19+ clientOptions = append(clientOptions, WithSession())
20+ }
21+ return NewClient(trans, clientOptions...), nil
1722}
vendor/github.com/mark3labs/mcp-go/client/sampling.gonew file+20
@@ -0,0 +1,20 @@
1+package client
2+
3+import (
4+ "context"
5+
6+ "github.com/mark3labs/mcp-go/mcp"
7+)
8+
9+// SamplingHandler defines the interface for handling sampling requests from servers.
10+// Clients can implement this interface to provide LLM sampling capabilities to servers.
11+type SamplingHandler interface {
12+ // CreateMessage handles a sampling request from the server and returns the generated message.
13+ // The implementation should:
14+ // 1. Validate the request parameters
15+ // 2. Optionally prompt the user for approval (human-in-the-loop)
16+ // 3. Select an appropriate model based on preferences
17+ // 4. Generate the response using the selected model
18+ // 5. Return the result with model information and stop reason
19+ CreateMessage(ctx context.Context, request mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error)
20+}
vendor/github.com/mark3labs/mcp-go/client/stdio.go+19−3
@@ -19,10 +19,26 @@ func NewStdioMCPClient(
1919 env []string,
2020 args ...string,
2121) (*Client, error) {
22+ return NewStdioMCPClientWithOptions(command, env, args)
23+}
24+
25+// NewStdioMCPClientWithOptions creates a new stdio-based MCP client that communicates with a subprocess.
26+// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication.
27+// Optional configuration functions can be provided to customize the transport before it starts,
28+// such as setting a custom command function.
29+//
30+// NOTICE: NewStdioMCPClientWithOptions automatically starts the underlying transport.
31+// Don't call the Start method manually.
32+// This is for backward compatibility.
33+func NewStdioMCPClientWithOptions(
34+ command string,
35+ env []string,
36+ args []string,
37+ opts ...transport.StdioOption,
38+) (*Client, error) {
39+ stdioTransport := transport.NewStdioWithOptions(command, env, args, opts...)
2240
23− stdioTransport := transport.NewStdio(command, env, args...)
24− err := stdioTransport.Start(context.Background())
25− if err != nil {
41+ if err := stdioTransport.Start(context.Background()); err != nil {
2642 return nil, fmt.Errorf("failed to start stdio transport: %w", err)
2743 }
2844
vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go+4
@@ -68,3 +68,7 @@ func (c *InProcessTransport) SetNotificationHandler(handler func(notification mc
6868func (*InProcessTransport) Close() error {
6969 return nil
7070}
71+
72+func (c *InProcessTransport) GetSessionId() string {
73+ return ""
74+}
vendor/github.com/mark3labs/mcp-go/client/transport/interface.go+18−2
@@ -29,6 +29,22 @@ type Interface interface {
2929
3030 // Close the connection.
3131 Close() error
32+
33+ // GetSessionId returns the session ID of the transport.
34+ GetSessionId() string
35+}
36+
37+// RequestHandler defines a function that handles incoming requests from the server.
38+type RequestHandler func(ctx context.Context, request JSONRPCRequest) (*JSONRPCResponse, error)
39+
40+// BidirectionalInterface extends Interface to support incoming requests from the server.
41+// This is used for features like sampling where the server can send requests to the client.
42+type BidirectionalInterface interface {
43+ Interface
44+
45+ // SetRequestHandler sets the handler for incoming requests from the server.
46+ // The handler should process the request and return a response.
47+ SetRequestHandler(handler RequestHandler)
3248}
3349
3450type JSONRPCRequest struct {
@@ -41,10 +57,10 @@ type JSONRPCRequest struct {
4157type JSONRPCResponse struct {
4258 JSONRPC string `json:"jsonrpc"`
4359 ID mcp.RequestId `json:"id"`
44− Result json.RawMessage `json:"result"`
60+ Result json.RawMessage `json:"result,omitempty"`
4561 Error *struct {
4662 Code int `json:"code"`
4763 Message string `json:"message"`
4864 Data json.RawMessage `json:"data"`
49− } `json:"error"`
65+ } `json:"error,omitempty"`
5066}
vendor/github.com/mark3labs/mcp-go/client/transport/sse.go+6
@@ -428,6 +428,12 @@ func (c *SSE) Close() error {
428428 return nil
429429}
430430
431+// GetSessionId returns the session ID of the transport.
432+// Since SSE does not maintain a session ID, it returns an empty string.
433+func (c *SSE) GetSessionId() string {
434+ return ""
435+}
436+
431437// SendNotification sends a JSON-RPC notification to the server without expecting a response.
432438func (c *SSE) SendNotification(ctx context.Context, notification mcp.JSONRPCNotification) error {
433439 if c.endpoint == nil {
vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go+191−13
@@ -23,6 +23,7 @@ type Stdio struct {
2323 env []string
2424
2525 cmd *exec.Cmd
26+ cmdFunc CommandFunc
2627 stdin io.WriteCloser
2728 stdout *bufio.Reader
2829 stderr io.ReadCloser
@@ -31,6 +32,28 @@ type Stdio struct {
3132 done chan struct{}
3233 onNotification func(mcp.JSONRPCNotification)
3334 notifyMu sync.RWMutex
35+ onRequest RequestHandler
36+ requestMu sync.RWMutex
37+ ctx context.Context
38+ ctxMu sync.RWMutex
39+}
40+
41+// StdioOption defines a function that configures a Stdio transport instance.
42+// Options can be used to customize the behavior of the transport before it starts,
43+// such as setting a custom command function.
44+type StdioOption func(*Stdio)
45+
46+// CommandFunc is a factory function that returns a custom exec.Cmd used to launch the MCP subprocess.
47+// It can be used to apply sandboxing, custom environment control, working directories, etc.
48+type CommandFunc func(ctx context.Context, command string, env []string, args []string) (*exec.Cmd, error)
49+
50+// WithCommandFunc sets a custom command factory function for the stdio transport.
51+// The CommandFunc is responsible for constructing the exec.Cmd used to launch the subprocess,
52+// allowing control over attributes like environment, working directory, and system-level sandboxing.
53+func WithCommandFunc(f CommandFunc) StdioOption {
54+ return func(s *Stdio) {
55+ s.cmdFunc = f
56+ }
3457}
3558
3659// NewIO returns a new stdio-based transport using existing input, output, and
@@ -44,6 +67,7 @@ func NewIO(input io.Reader, output io.WriteCloser, logging io.ReadCloser) *Stdio
4467
4568 responses: make(map[string]chan *JSONRPCResponse),
4669 done: make(chan struct{}),
70+ ctx: context.Background(),
4771 }
4872}
4973
@@ -55,20 +79,43 @@ func NewStdio(
5579 env []string,
5680 args ...string,
5781) *Stdio {
82+ return NewStdioWithOptions(command, env, args)
83+}
5884
59− client := &Stdio{
85+// NewStdioWithOptions creates a new stdio transport to communicate with a subprocess.
86+// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication.
87+// Returns an error if the subprocess cannot be started or the pipes cannot be created.
88+// Optional configuration functions can be provided to customize the transport before it starts,
89+// such as setting a custom command factory.
90+func NewStdioWithOptions(
91+ command string,
92+ env []string,
93+ args []string,
94+ opts ...StdioOption,
95+) *Stdio {
96+ s := &Stdio{
6097 command: command,
6198 args: args,
6299 env: env,
63100
64101 responses: make(map[string]chan *JSONRPCResponse),
65102 done: make(chan struct{}),
103+ ctx: context.Background(),
104+ }
105+
106+ for _, opt := range opts {
107+ opt(s)
66108 }
67109
68− return client
110+ return s
69111}
70112
71113func (c *Stdio) Start(ctx context.Context) error {
114+ // Store the context for use in request handling
115+ c.ctxMu.Lock()
116+ c.ctx = ctx
117+ c.ctxMu.Unlock()
118+
72119 if err := c.spawnCommand(ctx); err != nil {
73120 return err
74121 }
@@ -83,18 +130,25 @@ func (c *Stdio) Start(ctx context.Context) error {
83130 return nil
84131}
85132
86−// spawnCommand spawns a new process running c.command.
133+// spawnCommand spawns a new process running the configured command, args, and env.
134+// If an (optional) cmdFunc custom command factory function was configured, it will be used to construct the subprocess;
135+// otherwise, the default behavior uses exec.CommandContext with the merged environment.
136+// Initializes stdin, stdout, and stderr pipes for JSON-RPC communication.
87137func (c *Stdio) spawnCommand(ctx context.Context) error {
88138 if c.command == "" {
89139 return nil
90140 }
91141
92− cmd := exec.CommandContext(ctx, c.command, c.args...)
93−
94− mergedEnv := os.Environ()
95− mergedEnv = append(mergedEnv, c.env...)
142+ var cmd *exec.Cmd
143+ var err error
96144
97− cmd.Env = mergedEnv
145+ // Standard behavior if no command func present.
146+ if c.cmdFunc == nil {
147+ cmd = exec.CommandContext(ctx, c.command, c.args...)
148+ cmd.Env = append(os.Environ(), c.env...)
149+ } else if cmd, err = c.cmdFunc(ctx, c.command, c.env, c.args); err != nil {
150+ return err
151+ }
98152
99153 stdin, err := cmd.StdinPipe()
100154 if err != nil {
@@ -148,6 +202,12 @@ func (c *Stdio) Close() error {
148202 return nil
149203}
150204
205+// GetSessionId returns the session ID of the transport.
206+// Since stdio does not maintain a session ID, it returns an empty string.
207+func (c *Stdio) GetSessionId() string {
208+ return ""
209+}
210+
151211// SetNotificationHandler sets the handler function to be called when a notification is received.
152212// Only one handler can be set at a time; setting a new one replaces the previous handler.
153213func (c *Stdio) SetNotificationHandler(
@@ -158,6 +218,14 @@ func (c *Stdio) SetNotificationHandler(
158218 c.onNotification = handler
159219}
160220
221+// SetRequestHandler sets the handler function to be called when a request is received from the server.
222+// This enables bidirectional communication for features like sampling.
223+func (c *Stdio) SetRequestHandler(handler RequestHandler) {
224+ c.requestMu.Lock()
225+ defer c.requestMu.Unlock()
226+ c.onRequest = handler
227+}
228+
161229// readResponses continuously reads and processes responses from the server's stdout.
162230// It handles both responses to requests and notifications, routing them appropriately.
163231// Runs until the done channel is closed or an error occurs reading from stdout.
@@ -175,13 +243,18 @@ func (c *Stdio) readResponses() {
175243 return
176244 }
177245
178− var baseMessage JSONRPCResponse
246+ // First try to parse as a generic message to check for ID field
247+ var baseMessage struct {
248+ JSONRPC string `json:"jsonrpc"`
249+ ID *mcp.RequestId `json:"id,omitempty"`
250+ Method string `json:"method,omitempty"`
251+ }
179252 if err := json.Unmarshal([]byte(line), &baseMessage); err != nil {
180253 continue
181254 }
182255
183− // Handle notification
184− if baseMessage.ID.IsNil() {
256+ // If it has a method but no ID, it's a notification
257+ if baseMessage.Method != "" && baseMessage.ID == nil {
185258 var notification mcp.JSONRPCNotification
186259 if err := json.Unmarshal([]byte(line), &notification); err != nil {
187260 continue
@@ -194,15 +267,30 @@ func (c *Stdio) readResponses() {
194267 continue
195268 }
196269
270+ // If it has a method and an ID, it's an incoming request
271+ if baseMessage.Method != "" && baseMessage.ID != nil {
272+ var request JSONRPCRequest
273+ if err := json.Unmarshal([]byte(line), &request); err == nil {
274+ c.handleIncomingRequest(request)
275+ continue
276+ }
277+ }
278+
279+ // Otherwise, it's a response to our request
280+ var response JSONRPCResponse
281+ if err := json.Unmarshal([]byte(line), &response); err != nil {
282+ continue
283+ }
284+
197285 // Create string key for map lookup
198− idKey := baseMessage.ID.String()
286+ idKey := response.ID.String()
199287
200288 c.mu.RLock()
201289 ch, exists := c.responses[idKey]
202290 c.mu.RUnlock()
203291
204292 if exists {
205− ch <- &baseMessage
293+ ch <- &response
206294 c.mu.Lock()
207295 delete(c.responses, idKey)
208296 c.mu.Unlock()
@@ -281,6 +369,96 @@ func (c *Stdio) SendNotification(
281369 return nil
282370}
283371
372+// handleIncomingRequest processes incoming requests from the server.
373+// It calls the registered request handler and sends the response back to the server.
374+func (c *Stdio) handleIncomingRequest(request JSONRPCRequest) {
375+ c.requestMu.RLock()
376+ handler := c.onRequest
377+ c.requestMu.RUnlock()
378+
379+ if handler == nil {
380+ // Send error response if no handler is configured
381+ errorResponse := JSONRPCResponse{
382+ JSONRPC: mcp.JSONRPC_VERSION,
383+ ID: request.ID,
384+ Error: &struct {
385+ Code int `json:"code"`
386+ Message string `json:"message"`
387+ Data json.RawMessage `json:"data"`
388+ }{
389+ Code: mcp.METHOD_NOT_FOUND,
390+ Message: "No request handler configured",
391+ },
392+ }
393+ c.sendResponse(errorResponse)
394+ return
395+ }
396+
397+ // Handle the request in a goroutine to avoid blocking
398+ go func() {
399+ c.ctxMu.RLock()
400+ ctx := c.ctx
401+ c.ctxMu.RUnlock()
402+
403+ // Check if context is already cancelled before processing
404+ select {
405+ case <-ctx.Done():
406+ errorResponse := JSONRPCResponse{
407+ JSONRPC: mcp.JSONRPC_VERSION,
408+ ID: request.ID,
409+ Error: &struct {
410+ Code int `json:"code"`
411+ Message string `json:"message"`
412+ Data json.RawMessage `json:"data"`
413+ }{
414+ Code: mcp.INTERNAL_ERROR,
415+ Message: ctx.Err().Error(),
416+ },
417+ }
418+ c.sendResponse(errorResponse)
419+ return
420+ default:
421+ }
422+
423+ response, err := handler(ctx, request)
424+
425+ if err != nil {
426+ errorResponse := JSONRPCResponse{
427+ JSONRPC: mcp.JSONRPC_VERSION,
428+ ID: request.ID,
429+ Error: &struct {
430+ Code int `json:"code"`
431+ Message string `json:"message"`
432+ Data json.RawMessage `json:"data"`
433+ }{
434+ Code: mcp.INTERNAL_ERROR,
435+ Message: err.Error(),
436+ },
437+ }
438+ c.sendResponse(errorResponse)
439+ return
440+ }
441+
442+ if response != nil {
443+ c.sendResponse(*response)
444+ }
445+ }()
446+}
447+
448+// sendResponse sends a response back to the server.
449+func (c *Stdio) sendResponse(response JSONRPCResponse) {
450+ responseBytes, err := json.Marshal(response)
451+ if err != nil {
452+ fmt.Printf("Error marshaling response: %v\n", err)
453+ return
454+ }
455+ responseBytes = append(responseBytes, '\n')
456+
457+ if _, err := c.stdin.Write(responseBytes); err != nil {
458+ fmt.Printf("Error writing response: %v\n", err)
459+ }
460+}
461+
284462// Stderr returns a reader for the stderr output of the subprocess.
285463// This can be used to capture error messages or logs from the subprocess.
286464func (c *Stdio) Stderr() io.Reader {
vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go+228−115
@@ -17,10 +17,24 @@ import (
1717 "time"
1818
1919 "github.com/mark3labs/mcp-go/mcp"
20+ "github.com/mark3labs/mcp-go/util"
2021)
2122
2223type StreamableHTTPCOption func(*StreamableHTTP)
2324
25+// WithContinuousListening enables receiving server-to-client notifications when no request is in flight.
26+// In particular, if you want to receive global notifications from the server (like ToolListChangedNotification),
27+// you should enable this option.
28+//
29+// It will establish a standalone long-live GET HTTP connection to the server.
30+// https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#listening-for-messages-from-the-server
31+// NOTICE: Even enabled, the server may not support this feature.
32+func WithContinuousListening() StreamableHTTPCOption {
33+ return func(sc *StreamableHTTP) {
34+ sc.getListeningEnabled = true
35+ }
36+}
37+
2438// WithHTTPClient sets a custom HTTP client on the StreamableHTTP transport.
2539func WithHTTPBasicClient(client *http.Client) StreamableHTTPCOption {
2640 return func(sc *StreamableHTTP) {
@@ -54,6 +68,19 @@ func WithHTTPOAuth(config OAuthConfig) StreamableHTTPCOption {
5468 }
5569}
5670
71+func WithLogger(logger util.Logger) StreamableHTTPCOption {
72+ return func(sc *StreamableHTTP) {
73+ sc.logger = logger
74+ }
75+}
76+
77+// WithSession creates a client with a pre-configured session
78+func WithSession(sessionID string) StreamableHTTPCOption {
79+ return func(sc *StreamableHTTP) {
80+ sc.sessionID.Store(sessionID)
81+ }
82+}
83+
5784// StreamableHTTP implements Streamable HTTP transport.
5885//
5986// It transmits JSON-RPC messages over individual HTTP requests. One message per request.
@@ -64,19 +91,22 @@ func WithHTTPOAuth(config OAuthConfig) StreamableHTTPCOption {
6491//
6592// The current implementation does not support the following features:
6693// - batching
67−// - continuously listening for server notifications when no request is in flight
68−// (https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#listening-for-messages-from-the-server)
6994// - resuming stream
7095// (https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#resumability-and-redelivery)
7196// - server -> client request
7297type StreamableHTTP struct {
73− serverURL *url.URL
74− httpClient *http.Client
75− headers map[string]string
76− headerFunc HTTPHeaderFunc
98+ serverURL *url.URL
99+ httpClient *http.Client
100+ headers map[string]string
101+ headerFunc HTTPHeaderFunc
102+ logger util.Logger
103+ getListeningEnabled bool
77104
78105 sessionID atomic.Value // string
79106
107+ initialized chan struct{}
108+ initializedOnce sync.Once
109+
80110 notificationHandler func(mcp.JSONRPCNotification)
81111 notifyMu sync.RWMutex
82112
@@ -95,15 +125,19 @@ func NewStreamableHTTP(serverURL string, options ...StreamableHTTPCOption) (*Str
95125 }
96126
97127 smc := &StreamableHTTP{
98− serverURL: parsedURL,
99− httpClient: &http.Client{},
100− headers: make(map[string]string),
101− closed: make(chan struct{}),
128+ serverURL: parsedURL,
129+ httpClient: &http.Client{},
130+ headers: make(map[string]string),
131+ closed: make(chan struct{}),
132+ logger: util.DefaultLogger(),
133+ initialized: make(chan struct{}),
102134 }
103135 smc.sessionID.Store("") // set initial value to simplify later usage
104136
105137 for _, opt := range options {
106− opt(smc)
138+ if opt != nil {
139+ opt(smc)
140+ }
107141 }
108142
109143 // If OAuth is configured, set the base URL for metadata discovery
@@ -118,7 +152,20 @@ func NewStreamableHTTP(serverURL string, options ...StreamableHTTPCOption) (*Str
118152
119153// Start initiates the HTTP connection to the server.
120154func (c *StreamableHTTP) Start(ctx context.Context) error {
121− // For Streamable HTTP, we don't need to establish a persistent connection
155+ // For Streamable HTTP, we don't need to establish a persistent connection by default
156+ if c.getListeningEnabled {
157+ go func() {
158+ select {
159+ case <-c.initialized:
160+ ctx, cancel := c.contextAwareOfClientClose(ctx)
161+ defer cancel()
162+ c.listenForever(ctx)
163+ case <-c.closed:
164+ return
165+ }
166+ }()
167+ }
168+
122169 return nil
123170}
124171
@@ -142,13 +189,13 @@ func (c *StreamableHTTP) Close() error {
142189 defer cancel()
143190 req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.serverURL.String(), nil)
144191 if err != nil {
145− fmt.Printf("failed to create close request\n: %v", err)
192+ c.logger.Errorf("failed to create close request: %v", err)
146193 return
147194 }
148195 req.Header.Set(headerKeySessionID, sessionId)
149196 res, err := c.httpClient.Do(req)
150197 if err != nil {
151− fmt.Printf("failed to send close request\n: %v", err)
198+ c.logger.Errorf("failed to send close request: %v", err)
152199 return
153200 }
154201 res.Body.Close()
@@ -185,77 +232,29 @@ func (c *StreamableHTTP) SendRequest(
185232 request JSONRPCRequest,
186233) (*JSONRPCResponse, error) {
187234
188− // Create a combined context that could be canceled when the client is closed
189− newCtx, cancel := context.WithCancel(ctx)
190− defer cancel()
191− go func() {
192− select {
193− case <-c.closed:
194− cancel()
195− case <-newCtx.Done():
196− // The original context was canceled, no need to do anything
197− }
198− }()
199− ctx = newCtx
200−
201235 // Marshal request
202236 requestBody, err := json.Marshal(request)
203237 if err != nil {
204238 return nil, fmt.Errorf("failed to marshal request: %w", err)
205239 }
206240
207− // Create HTTP request
208− req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.serverURL.String(), bytes.NewReader(requestBody))
209− if err != nil {
210− return nil, fmt.Errorf("failed to create request: %w", err)
211− }
212−
213− // Set headers
214− req.Header.Set("Content-Type", "application/json")
215− req.Header.Set("Accept", "application/json, text/event-stream")
216− sessionID := c.sessionID.Load()
217− if sessionID != "" {
218− req.Header.Set(headerKeySessionID, sessionID.(string))
219− }
220− for k, v := range c.headers {
221− req.Header.Set(k, v)
222− }
223−
224− // Add OAuth authorization if configured
225− if c.oauthHandler != nil {
226− authHeader, err := c.oauthHandler.GetAuthorizationHeader(ctx)
227− if err != nil {
228− // If we get an authorization error, return a specific error that can be handled by the client
229− if err.Error() == "no valid token available, authorization required" {
230− return nil, &OAuthAuthorizationRequiredError{
231− Handler: c.oauthHandler,
232− }
233− }
234− return nil, fmt.Errorf("failed to get authorization header: %w", err)
235− }
236− req.Header.Set("Authorization", authHeader)
237− }
238−
239− if c.headerFunc != nil {
240− for k, v := range c.headerFunc(ctx) {
241− req.Header.Set(k, v)
242− }
243− }
241+ ctx, cancel := c.contextAwareOfClientClose(ctx)
242+ defer cancel()
244243
245− // Send request
246− resp, err := c.httpClient.Do(req)
244+ resp, err := c.sendHTTP(ctx, http.MethodPost, bytes.NewReader(requestBody), "application/json, text/event-stream")
247245 if err != nil {
248− return nil, fmt.Errorf("failed to send request: %w", err)
246+ if errors.Is(err, ErrSessionTerminated) && request.Method == string(mcp.MethodInitialize) {
247+ // If the request is initialize, should not return a SessionTerminated error
248+ // It should be a genuine endpoint-routing issue.
249+ // ( Fall through to return StatusCode checking. )
250+ } else {
251+ return nil, fmt.Errorf("failed to send request: %w", err)
252+ }
249253 }
250254 defer resp.Body.Close()
251255
252256 // Check if we got an error response
253257 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
254− // handle session closed
255− if resp.StatusCode == http.StatusNotFound {
256− c.sessionID.CompareAndSwap(sessionID, "")
257− return nil, fmt.Errorf("session terminated (404). need to re-initialize")
258− }
259258
260259 // Handle OAuth unauthorized error
261260 if resp.StatusCode == http.StatusUnauthorized && c.oauthHandler != nil {
@@ -279,6 +278,10 @@ func (c *StreamableHTTP) SendRequest(
279278 if sessionID := resp.Header.Get(headerKeySessionID); sessionID != "" {
280279 c.sessionID.Store(sessionID)
281280 }
281+
282+ c.initializedOnce.Do(func() {
283+ close(c.initialized)
284+ })
282285 }
283286
284287 // Handle different response types
@@ -300,16 +303,77 @@ func (c *StreamableHTTP) SendRequest(
300303
301304 case "text/event-stream":
302305 // Server is using SSE for streaming responses
303− return c.handleSSEResponse(ctx, resp.Body)
306+ return c.handleSSEResponse(ctx, resp.Body, false)
304307
305308 default:
306309 return nil, fmt.Errorf("unexpected content type: %s", resp.Header.Get("Content-Type"))
307310 }
308311}
309312
313+func (c *StreamableHTTP) sendHTTP(
314+ ctx context.Context,
315+ method string,
316+ body io.Reader,
317+ acceptType string,
318+) (resp *http.Response, err error) {
319+
320+ // Create HTTP request
321+ req, err := http.NewRequestWithContext(ctx, method, c.serverURL.String(), body)
322+ if err != nil {
323+ return nil, fmt.Errorf("failed to create request: %w", err)
324+ }
325+
326+ // Set headers
327+ req.Header.Set("Content-Type", "application/json")
328+ req.Header.Set("Accept", acceptType)
329+ sessionID := c.sessionID.Load().(string)
330+ if sessionID != "" {
331+ req.Header.Set(headerKeySessionID, sessionID)
332+ }
333+ for k, v := range c.headers {
334+ req.Header.Set(k, v)
335+ }
336+
337+ // Add OAuth authorization if configured
338+ if c.oauthHandler != nil {
339+ authHeader, err := c.oauthHandler.GetAuthorizationHeader(ctx)
340+ if err != nil {
341+ // If we get an authorization error, return a specific error that can be handled by the client
342+ if err.Error() == "no valid token available, authorization required" {
343+ return nil, &OAuthAuthorizationRequiredError{
344+ Handler: c.oauthHandler,
345+ }
346+ }
347+ return nil, fmt.Errorf("failed to get authorization header: %w", err)
348+ }
349+ req.Header.Set("Authorization", authHeader)
350+ }
351+
352+ if c.headerFunc != nil {
353+ for k, v := range c.headerFunc(ctx) {
354+ req.Header.Set(k, v)
355+ }
356+ }
357+
358+ // Send request
359+ resp, err = c.httpClient.Do(req)
360+ if err != nil {
361+ return nil, fmt.Errorf("failed to send request: %w", err)
362+ }
363+
364+ // universal handling for session terminated
365+ if resp.StatusCode == http.StatusNotFound {
366+ c.sessionID.CompareAndSwap(sessionID, "")
367+ return nil, ErrSessionTerminated
368+ }
369+
370+ return resp, nil
371+}
372+
310373// handleSSEResponse processes an SSE stream for a specific request.
311374// It returns the final result for the request once received, or an error.
312−func (c *StreamableHTTP) handleSSEResponse(ctx context.Context, reader io.ReadCloser) (*JSONRPCResponse, error) {
375+// If ignoreResponse is true, it won't return when a response messge is received. This is for continuous listening.
376+func (c *StreamableHTTP) handleSSEResponse(ctx context.Context, reader io.ReadCloser, ignoreResponse bool) (*JSONRPCResponse, error) {
313377
314378 // Create a channel for this specific request
315379 responseChan := make(chan *JSONRPCResponse, 1)
@@ -328,7 +392,7 @@ func (c *StreamableHTTP) handleSSEResponse(ctx context.Context, reader io.ReadCl
328392
329393 var message JSONRPCResponse
330394 if err := json.Unmarshal([]byte(data), &message); err != nil {
331− fmt.Printf("failed to unmarshal message: %v\n", err)
395+ c.logger.Errorf("failed to unmarshal message: %v", err)
332396 return
333397 }
334398
@@ -336,7 +400,7 @@ func (c *StreamableHTTP) handleSSEResponse(ctx context.Context, reader io.ReadCl
336400 if message.ID.IsNil() {
337401 var notification mcp.JSONRPCNotification
338402 if err := json.Unmarshal([]byte(data), &notification); err != nil {
339− fmt.Printf("failed to unmarshal notification: %v\n", err)
403+ c.logger.Errorf("failed to unmarshal notification: %v", err)
340404 return
341405 }
342406 c.notifyMu.RLock()
@@ -347,7 +411,9 @@ func (c *StreamableHTTP) handleSSEResponse(ctx context.Context, reader io.ReadCl
347411 return
348412 }
349413
350− responseChan <- &message
414+ if !ignoreResponse {
415+ responseChan <- &message
416+ }
351417 })
352418 }()
353419
@@ -393,7 +459,7 @@ func (c *StreamableHTTP) readSSE(ctx context.Context, reader io.ReadCloser, hand
393459 case <-ctx.Done():
394460 return
395461 default:
396− fmt.Printf("SSE stream error: %v\n", err)
462+ c.logger.Errorf("SSE stream error: %v", err)
397463 return
398464 }
399465 }
@@ -432,44 +498,10 @@ func (c *StreamableHTTP) SendNotification(ctx context.Context, notification mcp.
432498 }
433499
434500 // Create HTTP request
435− req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.serverURL.String(), bytes.NewReader(requestBody))
436− if err != nil {
437− return fmt.Errorf("failed to create request: %w", err)
438− }
439−
440− // Set headers
441− req.Header.Set("Content-Type", "application/json")
442− req.Header.Set("Accept", "application/json, text/event-stream")
443− if sessionID := c.sessionID.Load(); sessionID != "" {
444− req.Header.Set(headerKeySessionID, sessionID.(string))
445− }
446− for k, v := range c.headers {
447− req.Header.Set(k, v)
448− }
449−
450− // Add OAuth authorization if configured
451− if c.oauthHandler != nil {
452− authHeader, err := c.oauthHandler.GetAuthorizationHeader(ctx)
453− if err != nil {
454− // If we get an authorization error, return a specific error that can be handled by the client
455− if errors.Is(err, ErrOAuthAuthorizationRequired) {
456− return &OAuthAuthorizationRequiredError{
457− Handler: c.oauthHandler,
458− }
459− }
460− return fmt.Errorf("failed to get authorization header: %w", err)
461− }
462− req.Header.Set("Authorization", authHeader)
463− }
464−
465− if c.headerFunc != nil {
466− for k, v := range c.headerFunc(ctx) {
467− req.Header.Set(k, v)
468− }
469− }
501+ ctx, cancel := c.contextAwareOfClientClose(ctx)
502+ defer cancel()
470503
471− // Send request
472− resp, err := c.httpClient.Do(req)
504+ resp, err := c.sendHTTP(ctx, http.MethodPost, bytes.NewReader(requestBody), "application/json, text/event-stream")
473505 if err != nil {
474506 return fmt.Errorf("failed to send request: %w", err)
475507 }
@@ -513,3 +545,84 @@ func (c *StreamableHTTP) GetOAuthHandler() *OAuthHandler {
513545func (c *StreamableHTTP) IsOAuthEnabled() bool {
514546 return c.oauthHandler != nil
515547}
548+
549+func (c *StreamableHTTP) listenForever(ctx context.Context) {
550+ c.logger.Infof("listening to server forever")
551+ for {
552+ err := c.createGETConnectionToServer(ctx)
553+ if errors.Is(err, ErrGetMethodNotAllowed) {
554+ // server does not support listening
555+ c.logger.Errorf("server does not support listening")
556+ return
557+ }
558+
559+ select {
560+ case <-ctx.Done():
561+ return
562+ default:
563+ }
564+
565+ if err != nil {
566+ c.logger.Errorf("failed to listen to server. retry in 1 second: %v", err)
567+ }
568+ time.Sleep(retryInterval)
569+ }
570+}
571+
572+var (
573+ ErrSessionTerminated = fmt.Errorf("session terminated (404). need to re-initialize")
574+ ErrGetMethodNotAllowed = fmt.Errorf("GET method not allowed")
575+
576+ retryInterval = 1 * time.Second // a variable is convenient for testing
577+)
578+
579+func (c *StreamableHTTP) createGETConnectionToServer(ctx context.Context) error {
580+
581+ resp, err := c.sendHTTP(ctx, http.MethodGet, nil, "text/event-stream")
582+ if err != nil {
583+ return fmt.Errorf("failed to send request: %w", err)
584+ }
585+ defer resp.Body.Close()
586+
587+ // Check if we got an error response
588+ if resp.StatusCode == http.StatusMethodNotAllowed {
589+ return ErrGetMethodNotAllowed
590+ }
591+
592+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
593+ body, _ := io.ReadAll(resp.Body)
594+ return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, body)
595+ }
596+
597+ // handle SSE response
598+ contentType := resp.Header.Get("Content-Type")
599+ if contentType != "text/event-stream" {
600+ return fmt.Errorf("unexpected content type: %s", contentType)
601+ }
602+
603+ // When ignoreResponse is true, the function will never return expect context is done.
604+ // NOTICE: Due to the ambiguity of the specification, other SDKs may use the GET connection to transfer the response
605+ // messages. To be more compatible, we should handle this response, however, as the transport layer is message-based,
606+ // currently, there is no convenient way to handle this response.
607+ // So we ignore the response here. It's not a bug, but may be not compatible with other SDKs.
608+ _, err = c.handleSSEResponse(ctx, resp.Body, true)
609+ if err != nil {
610+ return fmt.Errorf("failed to handle SSE response: %w", err)
611+ }
612+
613+ return nil
614+}
615+
616+func (c *StreamableHTTP) contextAwareOfClientClose(ctx context.Context) (context.Context, context.CancelFunc) {
617+ newCtx, cancel := context.WithCancel(ctx)
618+ go func() {
619+ select {
620+ case <-c.closed:
621+ cancel()
622+ case <-newCtx.Done():
623+ // The original context was canceled
624+ cancel()
625+ }
626+ }()
627+ return newCtx, cancel
628+}
vendor/github.com/mark3labs/mcp-go/mcp/tools.go+105−1
@@ -945,7 +945,20 @@ func PropertyNames(schema map[string]any) PropertyOption {
945945 }
946946}
947947
948−// Items defines the schema for array items
948+// Items defines the schema for array items.
949+// Accepts any schema definition for maximum flexibility.
950+//
951+// Example:
952+//
953+// Items(map[string]any{
954+// "type": "object",
955+// "properties": map[string]any{
956+// "name": map[string]any{"type": "string"},
957+// "age": map[string]any{"type": "number"},
958+// },
959+// })
960+//
961+// For simple types, use ItemsString(), ItemsNumber(), ItemsBoolean() instead.
949962func Items(schema any) PropertyOption {
950963 return func(schemaMap map[string]any) {
951964 schemaMap["items"] = schema
@@ -972,3 +985,94 @@ func UniqueItems(unique bool) PropertyOption {
972985 schema["uniqueItems"] = unique
973986 }
974987}
988+
989+// WithStringItems configures an array's items to be of type string.
990+//
991+// Supported options: Description(), DefaultString(), Enum(), MaxLength(), MinLength(), Pattern()
992+// Note: Options like Required() are not valid for item schemas and will be ignored.
993+//
994+// Examples:
995+//
996+// mcp.WithArray("tags", mcp.WithStringItems())
997+// mcp.WithArray("colors", mcp.WithStringItems(mcp.Enum("red", "green", "blue")))
998+// mcp.WithArray("names", mcp.WithStringItems(mcp.MinLength(1), mcp.MaxLength(50)))
999+//
1000+// Limitations: Only supports simple string arrays. Use Items() for complex objects.
1001+func WithStringItems(opts ...PropertyOption) PropertyOption {
1002+ return func(schema map[string]any) {
1003+ itemSchema := map[string]any{
1004+ "type": "string",
1005+ }
1006+
1007+ for _, opt := range opts {
1008+ opt(itemSchema)
1009+ }
1010+
1011+ schema["items"] = itemSchema
1012+ }
1013+}
1014+
1015+// WithStringEnumItems configures an array's items to be of type string with a specified enum.
1016+// Example:
1017+//
1018+// mcp.WithArray("priority", mcp.WithStringEnumItems([]string{"low", "medium", "high"}))
1019+//
1020+// Limitations: Only supports string enums. Use WithStringItems(Enum(...)) for more flexibility.
1021+func WithStringEnumItems(values []string) PropertyOption {
1022+ return func(schema map[string]any) {
1023+ schema["items"] = map[string]any{
1024+ "type": "string",
1025+ "enum": values,
1026+ }
1027+ }
1028+}
1029+
1030+// WithNumberItems configures an array's items to be of type number.
1031+//
1032+// Supported options: Description(), DefaultNumber(), Min(), Max(), MultipleOf()
1033+// Note: Options like Required() are not valid for item schemas and will be ignored.
1034+//
1035+// Examples:
1036+//
1037+// mcp.WithArray("scores", mcp.WithNumberItems(mcp.Min(0), mcp.Max(100)))
1038+// mcp.WithArray("prices", mcp.WithNumberItems(mcp.Min(0)))
1039+//
1040+// Limitations: Only supports simple number arrays. Use Items() for complex objects.
1041+func WithNumberItems(opts ...PropertyOption) PropertyOption {
1042+ return func(schema map[string]any) {
1043+ itemSchema := map[string]any{
1044+ "type": "number",
1045+ }
1046+
1047+ for _, opt := range opts {
1048+ opt(itemSchema)
1049+ }
1050+
1051+ schema["items"] = itemSchema
1052+ }
1053+}
1054+
1055+// WithBooleanItems configures an array's items to be of type boolean.
1056+//
1057+// Supported options: Description(), DefaultBool()
1058+// Note: Options like Required() are not valid for item schemas and will be ignored.
1059+//
1060+// Examples:
1061+//
1062+// mcp.WithArray("flags", mcp.WithBooleanItems())
1063+// mcp.WithArray("permissions", mcp.WithBooleanItems(mcp.Description("User permissions")))
1064+//
1065+// Limitations: Only supports simple boolean arrays. Use Items() for complex objects.
1066+func WithBooleanItems(opts ...PropertyOption) PropertyOption {
1067+ return func(schema map[string]any) {
1068+ itemSchema := map[string]any{
1069+ "type": "boolean",
1070+ }
1071+
1072+ for _, opt := range opts {
1073+ opt(itemSchema)
1074+ }
1075+
1076+ schema["items"] = itemSchema
1077+ }
1078+}
vendor/github.com/mark3labs/mcp-go/mcp/types.go+21
@@ -763,6 +763,11 @@ const (
763763
764764/* Sampling */
765765
766+const (
767+ // MethodSamplingCreateMessage allows servers to request LLM completions from clients
768+ MethodSamplingCreateMessage MCPMethod = "sampling/createMessage"
769+)
770+
766771// CreateMessageRequest is a request from the server to sample an LLM via the
767772// client. The client has full discretion over which model to select. The client
768773// should also inform the user before beginning sampling, to allow them to inspect
@@ -865,6 +870,22 @@ type AudioContent struct {
865870
866871func (AudioContent) isContent() {}
867872
873+// ResourceLink represents a link to a resource that the client can access.
874+type ResourceLink struct {
875+ Annotated
876+ Type string `json:"type"` // Must be "resource_link"
877+ // The URI of the resource.
878+ URI string `json:"uri"`
879+ // The name of the resource.
880+ Name string `json:"name"`
881+ // The description of the resource.
882+ Description string `json:"description"`
883+ // The MIME type of the resource.
884+ MIMEType string `json:"mimeType"`
885+}
886+
887+func (ResourceLink) isContent() {}
888+
868889// EmbeddedResource represents the contents of a resource, embedded into a prompt or tool call result.
869890//
870891// It is up to the client how best to render embedded resources for the
vendor/github.com/mark3labs/mcp-go/mcp/utils.go+21
@@ -222,6 +222,17 @@ func NewAudioContent(data, mimeType string) AudioContent {
222222 }
223223}
224224
225+// Helper function to create a new ResourceLink
226+func NewResourceLink(uri, name, description, mimeType string) ResourceLink {
227+ return ResourceLink{
228+ Type: "resource_link",
229+ URI: uri,
230+ Name: name,
231+ Description: description,
232+ MIMEType: mimeType,
233+ }
234+}
235+
225236// Helper function to create a new EmbeddedResource
226237func NewEmbeddedResource(resource ResourceContents) EmbeddedResource {
227238 return EmbeddedResource{
@@ -476,6 +487,16 @@ func ParseContent(contentMap map[string]any) (Content, error) {
476487 }
477488 return NewAudioContent(data, mimeType), nil
478489
490+ case "resource_link":
491+ uri := ExtractString(contentMap, "uri")
492+ name := ExtractString(contentMap, "name")
493+ description := ExtractString(contentMap, "description")
494+ mimeType := ExtractString(contentMap, "mimeType")
495+ if uri == "" || name == "" {
496+ return nil, fmt.Errorf("resource_link uri or name is missing")
497+ }
498+ return NewResourceLink(uri, name, description, mimeType), nil
499+
479500 case "resource":
480501 resourceMap := ExtractMap(contentMap, "resource")
481502 if resourceMap == nil {
vendor/github.com/mark3labs/mcp-go/server/sampling.gonew file+37
@@ -0,0 +1,37 @@
1+package server
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/mark3labs/mcp-go/mcp"
8+)
9+
10+// EnableSampling enables sampling capabilities for the server.
11+// This allows the server to send sampling requests to clients that support it.
12+func (s *MCPServer) EnableSampling() {
13+ s.capabilitiesMu.Lock()
14+ defer s.capabilitiesMu.Unlock()
15+}
16+
17+// RequestSampling sends a sampling request to the client.
18+// The client must have declared sampling capability during initialization.
19+func (s *MCPServer) RequestSampling(ctx context.Context, request mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) {
20+ session := ClientSessionFromContext(ctx)
21+ if session == nil {
22+ return nil, fmt.Errorf("no active session")
23+ }
24+
25+ // Check if the session supports sampling requests
26+ if samplingSession, ok := session.(SessionWithSampling); ok {
27+ return samplingSession.RequestSampling(ctx, request)
28+ }
29+
30+ return nil, fmt.Errorf("session does not support sampling")
31+}
32+
33+// SessionWithSampling extends ClientSession to support sampling requests.
34+type SessionWithSampling interface {
35+ ClientSession
36+ RequestSampling(ctx context.Context, request mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error)
37+}
vendor/github.com/mark3labs/mcp-go/server/stdio.go+174−6
@@ -9,6 +9,7 @@ import (
99 "log"
1010 "os"
1111 "os/signal"
12+ "sync"
1213 "sync/atomic"
1314 "syscall"
1415
@@ -51,10 +52,21 @@ func WithStdioContextFunc(fn StdioContextFunc) StdioOption {
5152
5253// stdioSession is a static client session, since stdio has only one client.
5354type stdioSession struct {
54− notifications chan mcp.JSONRPCNotification
55− initialized atomic.Bool
56− loggingLevel atomic.Value
57− clientInfo atomic.Value // stores session-specific client info
55+ notifications chan mcp.JSONRPCNotification
56+ initialized atomic.Bool
57+ loggingLevel atomic.Value
58+ clientInfo atomic.Value // stores session-specific client info
59+ writer io.Writer // for sending requests to client
60+ requestID atomic.Int64 // for generating unique request IDs
61+ mu sync.RWMutex // protects writer
62+ pendingRequests map[int64]chan *samplingResponse // for tracking pending sampling requests
63+ pendingMu sync.RWMutex // protects pendingRequests
64+}
65+
66+// samplingResponse represents a response to a sampling request
67+type samplingResponse struct {
68+ result *mcp.CreateMessageResult
69+ err error
5870}
5971
6072func (s *stdioSession) SessionID() string {
@@ -100,14 +112,86 @@ func (s *stdioSession) GetLogLevel() mcp.LoggingLevel {
100112 return level.(mcp.LoggingLevel)
101113}
102114
115+// RequestSampling sends a sampling request to the client and waits for the response.
116+func (s *stdioSession) RequestSampling(ctx context.Context, request mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) {
117+ s.mu.RLock()
118+ writer := s.writer
119+ s.mu.RUnlock()
120+
121+ if writer == nil {
122+ return nil, fmt.Errorf("no writer available for sending requests")
123+ }
124+
125+ // Generate a unique request ID
126+ id := s.requestID.Add(1)
127+
128+ // Create a response channel for this request
129+ responseChan := make(chan *samplingResponse, 1)
130+ s.pendingMu.Lock()
131+ s.pendingRequests[id] = responseChan
132+ s.pendingMu.Unlock()
133+
134+ // Cleanup function to remove the pending request
135+ cleanup := func() {
136+ s.pendingMu.Lock()
137+ delete(s.pendingRequests, id)
138+ s.pendingMu.Unlock()
139+ }
140+ defer cleanup()
141+
142+ // Create the JSON-RPC request
143+ jsonRPCRequest := struct {
144+ JSONRPC string `json:"jsonrpc"`
145+ ID int64 `json:"id"`
146+ Method string `json:"method"`
147+ Params mcp.CreateMessageParams `json:"params"`
148+ }{
149+ JSONRPC: mcp.JSONRPC_VERSION,
150+ ID: id,
151+ Method: string(mcp.MethodSamplingCreateMessage),
152+ Params: request.CreateMessageParams,
153+ }
154+
155+ // Marshal and send the request
156+ requestBytes, err := json.Marshal(jsonRPCRequest)
157+ if err != nil {
158+ return nil, fmt.Errorf("failed to marshal sampling request: %w", err)
159+ }
160+ requestBytes = append(requestBytes, '\n')
161+
162+ if _, err := writer.Write(requestBytes); err != nil {
163+ return nil, fmt.Errorf("failed to write sampling request: %w", err)
164+ }
165+
166+ // Wait for the response or context cancellation
167+ select {
168+ case <-ctx.Done():
169+ return nil, ctx.Err()
170+ case response := <-responseChan:
171+ if response.err != nil {
172+ return nil, response.err
173+ }
174+ return response.result, nil
175+ }
176+}
177+
178+// SetWriter sets the writer for sending requests to the client.
179+func (s *stdioSession) SetWriter(writer io.Writer) {
180+ s.mu.Lock()
181+ defer s.mu.Unlock()
182+ s.writer = writer
183+}
184+
103185var (
104186 _ ClientSession = (*stdioSession)(nil)
105187 _ SessionWithLogging = (*stdioSession)(nil)
106188 _ SessionWithClientInfo = (*stdioSession)(nil)
189+ _ SessionWithSampling = (*stdioSession)(nil)
107190)
108191
109192var stdioSessionInstance = stdioSession{
110− notifications: make(chan mcp.JSONRPCNotification, 100),
193+ notifications: make(chan mcp.JSONRPCNotification, 100),
194+ pendingRequests: make(map[int64]chan *samplingResponse),
111195}
112196
113197// NewStdioServer creates a new stdio server wrapper around an MCPServer.
@@ -224,6 +308,9 @@ func (s *StdioServer) Listen(
224308 defer s.server.UnregisterSession(ctx, stdioSessionInstance.SessionID())
225309 ctx = s.server.WithContext(ctx, &stdioSessionInstance)
226310
311+ // Set the writer for sending requests to the client
312+ stdioSessionInstance.SetWriter(stdout)
313+
227314 // Add in any custom context.
228315 if s.contextFunc != nil {
229316 ctx = s.contextFunc(ctx)
@@ -256,7 +343,29 @@ func (s *StdioServer) processMessage(
256343 return s.writeResponse(response, writer)
257344 }
258345
259− // Handle the message using the wrapped server
346+ // Check if this is a response to a sampling request
347+ if s.handleSamplingResponse(rawMessage) {
348+ return nil
349+ }
350+
351+ // Check if this is a tool call that might need sampling (and thus should be processed concurrently)
352+ var baseMessage struct {
353+ Method string `json:"method"`
354+ }
355+ if json.Unmarshal(rawMessage, &baseMessage) == nil && baseMessage.Method == "tools/call" {
356+ // Process tool calls concurrently to avoid blocking on sampling requests
357+ go func() {
358+ response := s.server.HandleMessage(ctx, rawMessage)
359+ if response != nil {
360+ if err := s.writeResponse(response, writer); err != nil {
361+ s.errLogger.Printf("Error writing tool response: %v", err)
362+ }
363+ }
364+ }()
365+ return nil
366+ }
367+
368+ // Handle other messages synchronously
260369 response := s.server.HandleMessage(ctx, rawMessage)
261370
262371 // Only write response if there is one (not for notifications)
@@ -269,6 +378,65 @@ func (s *StdioServer) processMessage(
269378 return nil
270379}
271380
381+// handleSamplingResponse checks if the message is a response to a sampling request
382+// and routes it to the appropriate pending request channel.
383+func (s *StdioServer) handleSamplingResponse(rawMessage json.RawMessage) bool {
384+ return stdioSessionInstance.handleSamplingResponse(rawMessage)
385+}
386+
387+// handleSamplingResponse handles incoming sampling responses for this session
388+func (s *stdioSession) handleSamplingResponse(rawMessage json.RawMessage) bool {
389+ // Try to parse as a JSON-RPC response
390+ var response struct {
391+ JSONRPC string `json:"jsonrpc"`
392+ ID json.Number `json:"id"`
393+ Result json.RawMessage `json:"result,omitempty"`
394+ Error *struct {
395+ Code int `json:"code"`
396+ Message string `json:"message"`
397+ } `json:"error,omitempty"`
398+ }
399+
400+ if err := json.Unmarshal(rawMessage, &response); err != nil {
401+ return false
402+ }
403+ // Parse the ID as int64
404+ idInt64, err := response.ID.Int64()
405+ if err != nil || (response.Result == nil && response.Error == nil) {
406+ return false
407+ }
408+
409+ // Look for a pending request with this ID
410+ s.pendingMu.RLock()
411+ responseChan, exists := s.pendingRequests[idInt64]
412+ s.pendingMu.RUnlock()
413+
414+ if !exists {
415+ return false
416+ } // Parse and send the response
417+ samplingResp := &samplingResponse{}
418+
419+ if response.Error != nil {
420+ samplingResp.err = fmt.Errorf("sampling request failed: %s", response.Error.Message)
421+ } else {
422+ var result mcp.CreateMessageResult
423+ if err := json.Unmarshal(response.Result, &result); err != nil {
424+ samplingResp.err = fmt.Errorf("failed to unmarshal sampling response: %w", err)
425+ } else {
426+ samplingResp.result = &result
427+ }
428+ }
429+
430+ // Send the response (non-blocking)
431+ select {
432+ case responseChan <- samplingResp:
433+ default:
434+ // Channel is full or closed, ignore
435+ }
436+
437+ return true
438+}
439+
272440// writeResponse marshals and writes a JSON-RPC response message followed by a newline.
273441// Returns an error if marshaling or writing fails.
274442func (s *StdioServer) writeResponse(
vendor/github.com/mark3labs/mcp-go/server/streamable_http.go+4−2
@@ -40,7 +40,9 @@ func WithEndpointPath(endpointPath string) StreamableHTTPOption {
4040// to StatelessSessionIdManager.
4141func WithStateLess(stateLess bool) StreamableHTTPOption {
4242 return func(s *StreamableHTTPServer) {
43− s.sessionIdManager = &StatelessSessionIdManager{}
43+ if stateLess {
44+ s.sessionIdManager = &StatelessSessionIdManager{}
45+ }
4446 }
4547}
4648
@@ -374,7 +376,7 @@ func (s *StreamableHTTPServer) handleGet(w http.ResponseWriter, r *http.Request)
374376 w.Header().Set("Content-Type", "text/event-stream")
375377 w.Header().Set("Cache-Control", "no-cache")
376378 w.Header().Set("Connection", "keep-alive")
377− w.WriteHeader(http.StatusAccepted)
379+ w.WriteHeader(http.StatusOK)
378380
379381 flusher, ok := w.(http.Flusher)
380382 if !ok {
vendor/modules.txt+4−4
@@ -260,8 +260,8 @@ github.com/charmbracelet/bubbletea/v2
260260# github.com/charmbracelet/colorprofile v0.3.1
261261## explicit; go 1.23.0
262262github.com/charmbracelet/colorprofile
263−# github.com/charmbracelet/fang v0.1.0
264−## explicit; go 1.23.0
263+# github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
264+## explicit; go 1.24.0
265265github.com/charmbracelet/fang
266266# github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
267267## explicit; go 1.23.0
@@ -269,7 +269,7 @@ github.com/charmbracelet/glamour/v2
269269github.com/charmbracelet/glamour/v2/ansi
270270github.com/charmbracelet/glamour/v2/internal/autolink
271271github.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
273273## explicit; go 1.24.2
274274github.com/charmbracelet/lipgloss/v2
275275github.com/charmbracelet/lipgloss/v2/table
@@ -403,7 +403,7 @@ github.com/kylelemons/godebug/pretty
403403# github.com/lucasb-eyer/go-colorful v1.2.0
404404## explicit; go 1.12
405405github.com/lucasb-eyer/go-colorful
406−# github.com/mark3labs/mcp-go v0.32.0
406+# github.com/mark3labs/mcp-go v0.33.0
407407## explicit; go 1.23
408408github.com/mark3labs/mcp-go/client
409409github.com/mark3labs/mcp-go/client/transport