feat(config): add trailer_style option

Amolith created

Assisted-by: Claude Sonnet 4.5 via Crush

Change summary

README.md                                     |  7 +
internal/agent/common_test.go                 |  9 +
internal/agent/coordinator.go                 | 10 +
internal/agent/tools/bash.go                  |  8 +
internal/agent/tools/bash.tpl                 | 16 ++-
internal/config/attribution_migration_test.go | 95 +++++++++++++++++++++
internal/config/config.go                     | 23 ++++
internal/config/load.go                       |  9 +
schema.json                                   | 14 ++
9 files changed, 174 insertions(+), 17 deletions(-)

Detailed changes

README.md 🔗

@@ -370,14 +370,17 @@ it creates. You can customize this behavior with the `attribution` option:
   "$schema": "https://charm.land/crush.json",
   "options": {
     "attribution": {
-      "co_authored_by": true,
+      "trailer_style": "co-authored-by",
       "generated_with": true
     }
   }
 }
 ```
 
-- `co_authored_by`: When true (default), adds `Co-Authored-By: Crush <crush@charm.land>` to commit messages
+- `trailer_style`: Controls the attribution trailer added to commit messages (default: `co-authored-by`)
+  - `co-authored-by`: Adds `Co-Authored-By: Crush <crush@charm.land>`
+  - `assisted-by`: Adds `Assisted-by: [Model Name] via Crush` (includes the model name)
+  - `none`: No attribution trailer
 - `generated_with`: When true (default), adds `💘 Generated with Crush` line to commit messages and PR descriptions
 
 ### Local Models

internal/agent/common_test.go 🔗

@@ -187,8 +187,15 @@ func coderAgent(r *recorder.Recorder, env fakeEnv, large, small fantasy.Language
 	if err != nil {
 		return nil, err
 	}
+
+	// Get the model name for the bash tool
+	modelName := large.Model() // fallback to ID if Name not available
+	if model := cfg.GetModel(large.Provider(), large.Model()); model != nil {
+		modelName = model.Name
+	}
+
 	allTools := []fantasy.AgentTool{
-		tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution),
+		tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
 		tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
 		tools.NewEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
 		tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, env.workingDir),

internal/agent/coordinator.go 🔗

@@ -360,8 +360,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		allTools = append(allTools, agenticFetchTool)
 	}
 
+	// Get the model name for the agent
+	modelName := ""
+	if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
+		if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
+			modelName = model.Name
+		}
+	}
+
 	allTools = append(allTools,
-		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution),
+		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
 		tools.NewJobOutputTool(),
 		tools.NewJobKillTool(),
 		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),

internal/agent/tools/bash.go 🔗

@@ -63,6 +63,7 @@ type bashDescriptionData struct {
 	BannedCommands  string
 	MaxOutputLength int
 	Attribution     config.Attribution
+	ModelName       string
 }
 
 var bannedCommands = []string{
@@ -138,13 +139,14 @@ var bannedCommands = []string{
 	"ufw",
 }
 
-func bashDescription(attribution *config.Attribution) string {
+func bashDescription(attribution *config.Attribution, modelName string) string {
 	bannedCommandsStr := strings.Join(bannedCommands, ", ")
 	var out bytes.Buffer
 	if err := bashDescriptionTpl.Execute(&out, bashDescriptionData{
 		BannedCommands:  bannedCommandsStr,
 		MaxOutputLength: MaxOutputLength,
 		Attribution:     *attribution,
+		ModelName:       modelName,
 	}); err != nil {
 		// this should never happen.
 		panic("failed to execute bash description template: " + err.Error())
@@ -184,10 +186,10 @@ func blockFuncs() []shell.BlockFunc {
 	}
 }
 
-func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution) fantasy.AgentTool {
+func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution, modelName string) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		BashToolName,
-		string(bashDescription(attribution)),
+		string(bashDescription(attribution, modelName)),
 		func(ctx context.Context, params BashParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 			if params.Command == "" {
 				return fantasy.NewTextErrorResponse("missing command"), nil

internal/agent/tools/bash.tpl 🔗

@@ -60,15 +60,21 @@ When user asks to create git commit:
    - Use clear language, accurate reflection ("add"=new feature, "update"=enhancement, "fix"=bug fix)
    - Avoid generic messages, review draft
 
-4. Create commit with Crush signature using HEREDOC:
+4. Create commit{{ if or (eq .Attribution.TrailerStyle "assisted-by") (eq .Attribution.TrailerStyle "co-authored-by")}} with attribution{{ end }} using HEREDOC:
    git commit -m "$(cat <<'EOF'
    Commit message here.
-{{ if .Attribution.GeneratedWith}}
+
+{{- if .Attribution.GeneratedWith}}
    💘 Generated with Crush
-{{ end }}
-{{ if .Attribution.CoAuthoredBy}}
+{{- end}}
+{{- if eq .Attribution.TrailerStyle "assisted-by"}}
+
+   Assisted-by: {{ .ModelName }} via Crush
+{{- else if eq .Attribution.TrailerStyle "co-authored-by"}}
+
    Co-Authored-By: Crush <crush@charm.land>
-{{ end }}
+{{- end}}
+
    EOF
    )"
 

internal/config/attribution_migration_test.go 🔗

@@ -0,0 +1,95 @@
+package config
+
+import (
+	"io"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestAttributionMigration(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name             string
+		configJSON       string
+		expectedTrailer  TrailerStyle
+		expectedGenerate bool
+	}{
+		{
+			name: "old setting co_authored_by=true migrates to co-authored-by",
+			configJSON: `{
+				"options": {
+					"attribution": {
+						"co_authored_by": true,
+						"generated_with": false
+					}
+				}
+			}`,
+			expectedTrailer:  TrailerStyleCoAuthoredBy,
+			expectedGenerate: false,
+		},
+		{
+			name: "old setting co_authored_by=false migrates to none",
+			configJSON: `{
+				"options": {
+					"attribution": {
+						"co_authored_by": false,
+						"generated_with": true
+					}
+				}
+			}`,
+			expectedTrailer:  TrailerStyleNone,
+			expectedGenerate: true,
+		},
+		{
+			name: "new setting takes precedence over old setting",
+			configJSON: `{
+				"options": {
+					"attribution": {
+						"trailer_style": "assisted-by",
+						"co_authored_by": true,
+						"generated_with": false
+					}
+				}
+			}`,
+			expectedTrailer:  TrailerStyleAssistedBy,
+			expectedGenerate: false,
+		},
+		{
+			name: "default when neither setting present",
+			configJSON: `{
+				"options": {
+					"attribution": {
+						"generated_with": true
+					}
+				}
+			}`,
+			expectedTrailer:  TrailerStyleCoAuthoredBy,
+			expectedGenerate: true,
+		},
+		{
+			name: "default when attribution is null",
+			configJSON: `{
+				"options": {}
+			}`,
+			expectedTrailer:  TrailerStyleCoAuthoredBy,
+			expectedGenerate: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			cfg, err := loadFromReaders([]io.Reader{strings.NewReader(tt.configJSON)})
+			require.NoError(t, err)
+
+			cfg.setDefaults(t.TempDir(), "")
+
+			require.Equal(t, tt.expectedTrailer, cfg.Options.Attribution.TrailerStyle)
+			require.Equal(t, tt.expectedGenerate, cfg.Options.Attribution.GeneratedWith)
+		})
+	}
+}

internal/config/config.go 🔗

@@ -14,6 +14,7 @@ import (
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
+	"github.com/invopop/jsonschema"
 	"github.com/tidwall/sjson"
 )
 
@@ -166,9 +167,27 @@ type Permissions struct {
 	SkipRequests bool     `json:"-"`                                                                                                                              // Automatically accept all permissions (YOLO mode)
 }
 
+type TrailerStyle string
+
+const (
+	TrailerStyleNone         TrailerStyle = "none"
+	TrailerStyleCoAuthoredBy TrailerStyle = "co-authored-by"
+	TrailerStyleAssistedBy   TrailerStyle = "assisted-by"
+)
+
 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"`
+	TrailerStyle  TrailerStyle `json:"trailer_style,omitempty" jsonschema:"description=Style of attribution trailer to add to commits,enum=none,enum=co-authored-by,enum=assisted-by,default=co-authored-by"`
+	CoAuthoredBy  *bool        `json:"co_authored_by,omitempty" jsonschema:"description=Deprecated: use trailer_style instead"`
+	GeneratedWith bool         `json:"generated_with,omitempty" jsonschema:"description=Add Generated with Crush line to commit messages and issues and PRs,default=true"`
+}
+
+// JSONSchemaExtend marks the co_authored_by field as deprecated in the schema.
+func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) {
+	if schema.Properties != nil {
+		if prop, ok := schema.Properties.Get("co_authored_by"); ok {
+			prop.Deprecated = true
+		}
+	}
 }
 
 type Options struct {

internal/config/load.go 🔗

@@ -363,9 +363,16 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 
 	if c.Options.Attribution == nil {
 		c.Options.Attribution = &Attribution{
-			CoAuthoredBy:  true,
+			TrailerStyle:  TrailerStyleCoAuthoredBy,
 			GeneratedWith: true,
 		}
+	} else if c.Options.Attribution.TrailerStyle == "" {
+		// Migrate deprecated co_authored_by or apply default
+		if c.Options.Attribution.CoAuthoredBy != nil && !*c.Options.Attribution.CoAuthoredBy {
+			c.Options.Attribution.TrailerStyle = TrailerStyleNone
+		} else {
+			c.Options.Attribution.TrailerStyle = TrailerStyleCoAuthoredBy
+		}
 	}
 }
 

schema.json 🔗

@@ -5,10 +5,20 @@
   "$defs": {
     "Attribution": {
       "properties": {
+        "trailer_style": {
+          "type": "string",
+          "enum": [
+            "none",
+            "co-authored-by",
+            "assisted-by"
+          ],
+          "description": "Style of attribution trailer to add to commits",
+          "default": "co-authored-by"
+        },
         "co_authored_by": {
           "type": "boolean",
-          "description": "Add Co-Authored-By trailer to commit messages",
-          "default": true
+          "description": "Deprecated: use trailer_style instead",
+          "deprecated": true
         },
         "generated_with": {
           "type": "boolean",