From 161f78c95248eb320c67e74d52e2a60f5cf02561 Mon Sep 17 00:00:00 2001 From: Amolith Date: Tue, 4 Nov 2025 07:14:06 -0700 Subject: [PATCH] feat(config): add trailer_style option Assisted-by: Claude Sonnet 4.5 via Crush --- 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(-) create mode 100644 internal/config/attribution_migration_test.go diff --git a/README.md b/README.md index 9cea011349eb8f6269811e56bf24e613c8f5dfe1..c3934eaffd9553ffe2772e5d771d2c4f8416e8c9 100644 --- a/README.md +++ b/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 ` 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 ` + - `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 diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index de47ed749278bb9e62e78eb9b59f7f49af472327..32f0472a87ce7629744e6b7302fe5ea8ed4920ba 100644 --- a/internal/agent/common_test.go +++ b/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), diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index a58fa00fdd0c4fa149098de4fbf01a8d1ecd4017..925395a29480d4df6ac23da203763134ba77eab7 100644 --- a/internal/agent/coordinator.go +++ b/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), diff --git a/internal/agent/tools/bash.go b/internal/agent/tools/bash.go index 39f24c06cff17d9bfe2ba071dbb458c4d7eca848..c3f0bc8cd24a6c4ff7c6f775e357c90b3dc99802 100644 --- a/internal/agent/tools/bash.go +++ b/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 diff --git a/internal/agent/tools/bash.tpl b/internal/agent/tools/bash.tpl index a1a5053f11765d9c228bb2fc036f8fb707ed8658..222facc989d3e62f25a037dc36686e30ec82e90f 100644 --- a/internal/agent/tools/bash.tpl +++ b/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 -{{ end }} +{{- end}} + EOF )" diff --git a/internal/config/attribution_migration_test.go b/internal/config/attribution_migration_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6f54b170ad4e4eb815e7cf22793584cb2ba4c9e4 --- /dev/null +++ b/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) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 184584824fec9952bae8bbe965fdd0674d469c6e..e2570ffae9ff1fbb077a62cf44c25216b61db2ff 100644 --- a/internal/config/config.go +++ b/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 { diff --git a/internal/config/load.go b/internal/config/load.go index d8081039fb592d4c301c39a8a2d88bd02ab15d0d..a7be6b7e2b0846229dd685b13a06580193b48792 100644 --- a/internal/config/load.go +++ b/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 + } } } diff --git a/schema.json b/schema.json index 0910781fd6a02129898e06d46f5bea81236f03f4..e8b3d0c0c54e9c48501ef2c70d47074c4aed6be1 100644 --- a/schema.json +++ b/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",