feat(config): add trailer_style option

Amolith created

Replace co_authored_by boolean with trailer_style enum supporting:
none, co-authored-by, and assisted-by.

The assisted-by option follows Fedora's AI Contribution Policy which
requires transparency about AI assistance via 'Assisted-by: [Model
Name]' commit message trailers.

https://docs.fedoraproject.org/en-US/council/policy/
ai-contribution-policy/

Assisted-by: Claude Sonnet 4.5 via Crush

Change summary

README.md                     |  7 +++++--
internal/agent/common_test.go |  2 +-
internal/agent/coordinator.go |  8 +++++++-
internal/agent/tools/bash.go  |  8 +++++---
internal/agent/tools/bash.tpl |  4 +++-
internal/config/config.go     | 12 ++++++++++--
internal/config/load.go       |  2 +-
schema.json                   | 13 +++++++++----
8 files changed, 41 insertions(+), 15 deletions(-)

Detailed changes

README.md 🔗

@@ -345,14 +345,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
 
 ### Custom Providers

internal/agent/common_test.go 🔗

@@ -176,7 +176,7 @@ func coderAgent(r *recorder.Recorder, env fakeEnv, large, small fantasy.Language
 		return nil, err
 	}
 	allTools := []fantasy.AgentTool{
-		tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution),
+		tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, large.Model()),
 		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 🔗

@@ -327,8 +327,14 @@ 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 {
+		modelName = modelCfg.Model
+	}
+
 	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 🔗

@@ -66,7 +66,9 @@ When user asks to create git commit:
 {{ if .Attribution.GeneratedWith}}
    💘 Generated with Crush
 {{ end }}
-{{ if .Attribution.CoAuthoredBy}}
+{{ 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 }}
    EOF

internal/config/config.go 🔗

@@ -166,9 +166,17 @@ 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"`
+	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 {

internal/config/load.go 🔗

@@ -353,7 +353,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 
 	if c.Options.Attribution == nil {
 		c.Options.Attribution = &Attribution{
-			CoAuthoredBy:  true,
+			TrailerStyle:  TrailerStyleCoAuthoredBy,
 			GeneratedWith: true,
 		}
 	}

schema.json 🔗

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