feat: add attribution settings to config and bash tool (#1025)

Amolith created

* feat: add attribution settings to config and bash tool
* fix(readme): move ephemeral data block back to cfg section

Closes: #445

Change summary

README.md                   | 33 +++++++++++++++++
internal/config/config.go   | 22 +++++++----
internal/llm/agent/agent.go |  2 
internal/llm/tools/bash.go  | 72 +++++++++++++++++++++++++++++---------
schema.json                 | 20 ++++++++++
5 files changed, 123 insertions(+), 26 deletions(-)

Detailed changes

README.md 🔗

@@ -173,6 +173,39 @@ $HOME/.local/share/crush/crush.json
 %LOCALAPPDATA%\crush\crush.json
 ```
 
+### Attribution Settings
+
+By default, Crush adds attribution information to git commits and pull requests it creates. You can customize this behavior with the `attribution` option:
+
+```json
+{
+  "$schema": "https://charm.land/crush.json",
+  "options": {
+    "attribution": {
+      "co_authored_by": true,
+      "generated_with": true
+    }
+  }
+}
+```
+
+- `co_authored_by`: When true (default), adds `Co-Authored-By: Crush <crush@charm.land>` to commit messages
+- `generated_with`: When true (default), adds `💘 Generated with Crush` line to commit messages and PR descriptions
+
+To disable all attribution, set both options to false:
+
+```json
+{
+  "$schema": "https://charm.land/crush.json",
+  "options": {
+    "attribution": {
+      "co_authored_by": false,
+      "generated_with": false
+    }
+  }
+}
+```
+
 ### LSPs
 
 Crush can use LSPs for additional context to help inform its decisions, just

internal/config/config.go 🔗

@@ -138,15 +138,21 @@ type Permissions struct {
 	SkipRequests bool     `json:"-"`                                                                                                                              // Automatically accept all permissions (YOLO mode)
 }
 
+type Attribution struct {
+	CoAuthoredBy  bool `json:"co_authored_by,omitempty" jsonschema:"description=Add Co-Authored-By trailer to commit messages,default=true"`
+	GeneratedWith bool `json:"generated_with,omitempty" jsonschema:"description=Add Generated with Crush line to commit messages and issues and PRs,default=true"`
+}
+
 type Options struct {
-	ContextPaths              []string    `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
-	TUI                       *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
-	Debug                     bool        `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
-	DebugLSP                  bool        `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`
-	DisableAutoSummarize      bool        `json:"disable_auto_summarize,omitempty" jsonschema:"description=Disable automatic conversation summarization,default=false"`
-	DataDirectory             string      `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd
-	DisabledTools             []string    `json:"disabled_tools" jsonschema:"description=Tools to disable"`
-	DisableProviderAutoUpdate bool        `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
+	ContextPaths              []string     `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
+	TUI                       *TUIOptions  `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
+	Debug                     bool         `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
+	DebugLSP                  bool         `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`
+	DisableAutoSummarize      bool         `json:"disable_auto_summarize,omitempty" jsonschema:"description=Disable automatic conversation summarization,default=false"`
+	DataDirectory             string       `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd
+	DisabledTools             []string     `json:"disabled_tools" jsonschema:"description=Tools to disable"`
+	DisableProviderAutoUpdate bool         `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
+	Attribution               *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
 }
 
 type MCPs map[string]MCPConfig

internal/llm/agent/agent.go 🔗

@@ -184,7 +184,7 @@ func NewAgent(
 
 		cwd := cfg.WorkingDir()
 		allTools := []tools.BaseTool{
-			tools.NewBashTool(permissions, cwd),
+			tools.NewBashTool(permissions, cwd, cfg.Options.Attribution),
 			tools.NewDownloadTool(permissions, cwd),
 			tools.NewEditTool(lspClients, permissions, history, cwd),
 			tools.NewMultiEditTool(lspClients, permissions, history, cwd),

internal/llm/tools/bash.go 🔗

@@ -7,6 +7,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/shell"
 )
@@ -30,6 +31,7 @@ type BashResponseMetadata struct {
 type bashTool struct {
 	permissions permission.Service
 	workingDir  string
+	attribution *config.Attribution
 }
 
 const (
@@ -114,8 +116,53 @@ var bannedCommands = []string{
 	"ufw",
 }
 
-func bashDescription() string {
+func (b *bashTool) bashDescription() string {
 	bannedCommandsStr := strings.Join(bannedCommands, ", ")
+
+	// Build attribution text based on settings
+	var attributionStep, attributionExample, prAttribution string
+
+	// Default to true if attribution is nil (backward compatibility)
+	generatedWith := b.attribution == nil || b.attribution.GeneratedWith
+	coAuthoredBy := b.attribution == nil || b.attribution.CoAuthoredBy
+
+	// Build PR attribution
+	if generatedWith {
+		prAttribution = "💘 Generated with Crush"
+	}
+
+	if generatedWith || coAuthoredBy {
+		attributionParts := []string{}
+		if generatedWith {
+			attributionParts = append(attributionParts, "💘 Generated with Crush")
+		}
+		if coAuthoredBy {
+			attributionParts = append(attributionParts, "Co-Authored-By: Crush <crush@charm.land>")
+		}
+
+		if len(attributionParts) > 0 {
+			attributionStep = fmt.Sprintf("4. Create the commit with a message ending with:\n%s", strings.Join(attributionParts, "\n"))
+
+			attributionText := strings.Join(attributionParts, "\n ")
+			attributionExample = fmt.Sprintf(`<example>
+git commit -m "$(cat <<'EOF'
+ Commit message here.
+
+ %s
+ EOF
+)"</example>`, attributionText)
+		}
+	}
+
+	if attributionStep == "" {
+		attributionStep = "4. Create the commit with your commit message."
+		attributionExample = `<example>
+git commit -m "$(cat <<'EOF'
+ Commit message here.
+ EOF
+)"</example>`
+	}
+
 	return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
 
 CROSS-PLATFORM SHELL SUPPORT:
@@ -190,20 +237,10 @@ When the user asks you to create a new git commit, follow these steps carefully:
 - Review the draft message to ensure it accurately reflects the changes and their purpose
 </commit_analysis>
 
-4. Create the commit with a message ending with:
-💘 Generated with Crush
-Co-Authored-By: Crush <crush@charm.land>
+%s
 
 - In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
-<example>
-git commit -m "$(cat <<'EOF'
- Commit message here.
-
- 💘 Generated with Crush
- Co-Authored-By: 💘 Crush <crush@charm.land>
- EOF
- )"
-</example>
+%s
 
 5. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
 
@@ -262,14 +299,14 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF'
 ## Test plan
 [Checklist of TODOs for testing the pull request...]
 
-💘 Generated with Crush
+%s
 EOF
 )"
 </example>
 
 Important:
 - Return an empty response - the user will see the gh output directly
-- Never update git config`, bannedCommandsStr, MaxOutputLength)
+- Never update git config`, bannedCommandsStr, MaxOutputLength, attributionStep, attributionExample, prAttribution)
 }
 
 func blockFuncs() []shell.BlockFunc {
@@ -304,7 +341,7 @@ func blockFuncs() []shell.BlockFunc {
 	}
 }
 
-func NewBashTool(permission permission.Service, workingDir string) BaseTool {
+func NewBashTool(permission permission.Service, workingDir string, attribution *config.Attribution) BaseTool {
 	// Set up command blocking on the persistent shell
 	persistentShell := shell.GetPersistentShell(workingDir)
 	persistentShell.SetBlockFuncs(blockFuncs())
@@ -312,6 +349,7 @@ func NewBashTool(permission permission.Service, workingDir string) BaseTool {
 	return &bashTool{
 		permissions: permission,
 		workingDir:  workingDir,
+		attribution: attribution,
 	}
 }
 
@@ -322,7 +360,7 @@ func (b *bashTool) Name() string {
 func (b *bashTool) Info() ToolInfo {
 	return ToolInfo{
 		Name:        BashToolName,
-		Description: bashDescription(),
+		Description: b.bashDescription(),
 		Parameters: map[string]any{
 			"command": map[string]any{
 				"type":        "string",

schema.json 🔗

@@ -3,6 +3,22 @@
   "$id": "https://github.com/charmbracelet/crush/internal/config/config",
   "$ref": "#/$defs/Config",
   "$defs": {
+    "Attribution": {
+      "properties": {
+        "co_authored_by": {
+          "type": "boolean",
+          "description": "Add Co-Authored-By trailer to commit messages",
+          "default": true
+        },
+        "generated_with": {
+          "type": "boolean",
+          "description": "Add Generated with Crush line to commit messages and issues and PRs",
+          "default": true
+        }
+      },
+      "additionalProperties": false,
+      "type": "object"
+    },
     "Config": {
       "properties": {
         "$schema": {
@@ -300,6 +316,10 @@
           "type": "boolean",
           "description": "Disable providers auto-update",
           "default": false
+        },
+        "attribution": {
+          "$ref": "#/$defs/Attribution",
+          "description": "Attribution settings for generated content"
         }
       },
       "additionalProperties": false,