Detailed changes
@@ -26,6 +26,8 @@
need of a temporary directory. This directory does not need to be removed.
- **JSON tags**: Use snake_case for JSON field names
- **File permissions**: Use octal notation (0o755, 0o644) for file permissions
+- **Log messages**: Log messages must start with a capital letter (e.g., "Failed to save session" not "failed to save session")
+ - This is enforced by `task lint:log` which runs as part of `task lint`
- **Comments**: End comments in periods unless comments are at the end of the line.
## Testing with Mock Providers
@@ -23,10 +23,16 @@ tasks:
lint:
desc: Run base linters
cmds:
+ - task: lint:log
- golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m
env:
GOEXPERIMENT: null
+ lint:log:
+ desc: Check that log messages start with capital letters
+ cmds:
+ - ./scripts/check_log_capitalization.sh
+
lint:fix:
desc: Run base linters and fix issues
cmds:
@@ -802,22 +802,22 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
resp, err := agent.Stream(ctx, streamCall)
if err == nil {
// We successfully generated a title with the small model.
- slog.Info("generated title with small model")
+ slog.Debug("Generated title with small model")
} else {
// It didn't work. Let's try with the big model.
- slog.Error("error generating title with small model; trying big model", "err", err)
+ slog.Error("Error generating title with small model; trying big model", "err", err)
model = largeModel
agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
resp, err = agent.Stream(ctx, streamCall)
if err == nil {
- slog.Info("generated title with large model")
+ slog.Debug("Generated title with large model")
} else {
// Welp, the large model didn't work either. Use the default
// session name and return.
- slog.Error("error generating title with large model", "err", err)
+ slog.Error("Error generating title with large model", "err", err)
saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
if saveErr != nil {
- slog.Error("failed to save session title and usage", "error", saveErr)
+ slog.Error("Failed to save session title and usage", "error", saveErr)
}
return
}
@@ -826,10 +826,10 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
if resp == nil {
// Actually, we didn't get a response so we can't. Use the default
// session name and return.
- slog.Error("response is nil; can't generate title")
+ slog.Error("Response is nil; can't generate title")
saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
if saveErr != nil {
- slog.Error("failed to save session title and usage", "error", saveErr)
+ slog.Error("Failed to save session title and usage", "error", saveErr)
}
return
}
@@ -843,7 +843,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
title = strings.TrimSpace(title)
if title == "" {
- slog.Warn("empty title; using fallback")
+ slog.Debug("Empty title; using fallback")
title = defaultSessionName
}
@@ -878,7 +878,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
// concurrent session updates.
saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
if saveErr != nil {
- slog.Error("failed to save session title and usage", "error", saveErr)
+ slog.Error("Failed to save session title and usage", "error", saveErr)
return
}
}
@@ -921,25 +921,25 @@ func (a *sessionAgent) Cancel(sessionID string) {
// fully completes (including error handling that may access the DB).
// The defer in processRequest will clean up the entry.
if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil {
- slog.Info("Request cancellation initiated", "session_id", sessionID)
+ slog.Debug("Request cancellation initiated", "session_id", sessionID)
cancel()
}
// Also check for summarize requests.
if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil {
- slog.Info("Summarize cancellation initiated", "session_id", sessionID)
+ slog.Debug("Summarize cancellation initiated", "session_id", sessionID)
cancel()
}
if a.QueuedPrompts(sessionID) > 0 {
- slog.Info("Clearing queued prompts", "session_id", sessionID)
+ slog.Debug("Clearing queued prompts", "session_id", sessionID)
a.messageQueue.Del(sessionID)
}
}
func (a *sessionAgent) ClearQueue(sessionID string) {
if a.QueuedPrompts(sessionID) > 0 {
- slog.Info("Clearing queued prompts", "session_id", sessionID)
+ slog.Debug("Clearing queued prompts", "session_id", sessionID)
a.messageQueue.Del(sessionID)
}
}
@@ -1099,7 +1099,7 @@ func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Mes
if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResult.Output); ok {
decoded, err := base64.StdEncoding.DecodeString(media.Data)
if err != nil {
- slog.Warn("failed to decode media data", "error", err)
+ slog.Warn("Failed to decode media data", "error", err)
textParts = append(textParts, part)
continue
}
@@ -151,7 +151,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
- slog.Info("Token needs to be refreshed", "provider", providerCfg.ID)
+ slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
return nil, err
}
@@ -176,18 +176,18 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
if c.isUnauthorized(originalErr) {
switch {
case providerCfg.OAuthToken != nil:
- slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
+ slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
return nil, originalErr
}
- slog.Info("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
+ slog.Debug("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
return run()
case strings.Contains(providerCfg.APIKeyTemplate, "$"):
- slog.Info("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
+ slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
return nil, originalErr
}
- slog.Info("Retrying request with refreshed API key", "provider", providerCfg.ID)
+ slog.Debug("Retrying request with refreshed API key", "provider", providerCfg.ID)
return run()
}
}
@@ -428,7 +428,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
}
if len(agent.AllowedMCP) == 0 {
// No MCPs allowed
- slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
+ slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
break
}
@@ -49,7 +49,7 @@ var Enabled = sync.OnceValue(func() bool {
var Embedded = sync.OnceValue(func() catwalk.Provider {
var provider catwalk.Provider
if err := json.Unmarshal(embedded, &provider); err != nil {
- slog.Error("could not use embedded provider data", "err", err)
+ slog.Error("Could not use embedded provider data", "err", err)
}
return provider
})
@@ -140,7 +140,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
for name, m := range cfg.MCP {
if m.Disabled {
updateState(name, StateDisabled, nil, nil, Counts{})
- slog.Debug("skipping disabled mcp", "name", name)
+ slog.Debug("Skipping disabled MCP", "name", name)
continue
}
@@ -162,7 +162,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
err = fmt.Errorf("panic: %v", v)
}
updateState(name, StateError, err, nil, Counts{})
- slog.Error("panic in mcp client initialization", "error", err, "name", name)
+ slog.Error("Panic in MCP client initialization", "error", err, "name", name)
}
}()
@@ -174,7 +174,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
tools, err := getTools(ctx, session)
if err != nil {
- slog.Error("error listing tools", "error", err)
+ slog.Error("Error listing tools", "error", err)
updateState(name, StateError, err, nil, Counts{})
session.Close()
return
@@ -182,7 +182,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
prompts, err := getPrompts(ctx, session)
if err != nil {
- slog.Error("error listing prompts", "error", err)
+ slog.Error("Error listing prompts", "error", err)
updateState(name, StateError, err, nil, Counts{})
session.Close()
return
@@ -277,7 +277,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
transport, err := createTransport(mcpCtx, m, resolver)
if err != nil {
updateState(name, StateError, err, nil, Counts{})
- slog.Error("error creating mcp client", "error", err, "name", name)
+ slog.Error("Error creating MCP client", "error", err, "name", name)
cancel()
cancelTimer.Stop()
return nil, err
@@ -319,7 +319,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
}
cancelTimer.Stop()
- slog.Info("MCP client initialized", "name", name)
+ slog.Debug("MCP client initialized", "name", name)
return session, nil
}
@@ -49,7 +49,7 @@ func GetPromptMessages(ctx context.Context, clientName, promptName string, args
func RefreshPrompts(ctx context.Context, name string) {
session, ok := sessions.Get(name)
if !ok {
- slog.Warn("refresh prompts: no session", "name", name)
+ slog.Warn("Refresh prompts: no session", "name", name)
return
}
@@ -111,7 +111,7 @@ func RunTool(ctx context.Context, name, toolName string, input string) (ToolResu
func RefreshTools(ctx context.Context, name string) {
session, ok := sessions.Get(name)
if !ok {
- slog.Warn("refresh tools: no session", "name", name)
+ slog.Warn("Refresh tools: no session", "name", name)
return
}
@@ -135,7 +135,7 @@ func (app *App) Config() *config.Config {
// RunNonInteractive runs the application in non-interactive mode with the
// given prompt, printing to stdout.
-func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, quiet bool) error {
+func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
slog.Info("Running in non-interactive mode")
ctx, cancel := context.WithCancel(ctx)
@@ -160,10 +160,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
}
stderrTTY = term.IsTerminal(os.Stderr.Fd())
stdinTTY = term.IsTerminal(os.Stdin.Fd())
-
progress = app.config.Options.Progress == nil || *app.config.Options.Progress
- if !quiet && stderrTTY {
+ if !hideSpinner && stderrTTY {
t := styles.CurrentTheme()
// Detect background color to set the appropriate color for the
@@ -188,7 +187,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
// Helper function to stop spinner once.
stopSpinner := func() {
- if !quiet && spinner != nil {
+ if !hideSpinner && spinner != nil {
spinner.Stop()
spinner = nil
}
@@ -245,6 +244,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
messageEvents := app.Messages.Subscribe(ctx)
messageReadBytes := make(map[string]int)
+ var printed bool
defer func() {
if progress && stderrTTY {
@@ -268,7 +268,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
stopSpinner()
if result.err != nil {
if errors.Is(result.err, context.Canceled) || errors.Is(result.err, agent.ErrRequestCancelled) {
- slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
+ slog.Debug("Non-interactive: agent processing cancelled", "session_id", sess.ID)
return nil
}
return fmt.Errorf("agent processing failed: %w", result.err)
@@ -294,7 +294,11 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
if readBytes == 0 {
part = strings.TrimLeft(part, " \t")
}
- fmt.Fprint(output, part)
+ // Ignore initial whitespace-only messages.
+ if printed || strings.TrimSpace(part) != "" {
+ printed = true
+ fmt.Fprint(output, part)
+ }
messageReadBytes[msg.ID] = len(content)
}
@@ -433,20 +437,20 @@ func setupSubscriber[T any](
select {
case event, ok := <-subCh:
if !ok {
- slog.Debug("subscription channel closed", "name", name)
+ slog.Debug("Subscription channel closed", "name", name)
return
}
var msg tea.Msg = event
select {
case outputCh <- msg:
case <-time.After(2 * time.Second):
- slog.Warn("message dropped due to slow consumer", "name", name)
+ slog.Debug("Message dropped due to slow consumer", "name", name)
case <-ctx.Done():
- slog.Debug("subscription cancelled", "name", name)
+ slog.Debug("Subscription cancelled", "name", name)
return
}
case <-ctx.Done():
- slog.Debug("subscription cancelled", "name", name)
+ slog.Debug("Subscription cancelled", "name", name)
return
}
}
@@ -511,7 +515,7 @@ func (app *App) Subscribe(program *tea.Program) {
// Shutdown performs a graceful shutdown of the application.
func (app *App) Shutdown() {
start := time.Now()
- defer func() { slog.Info("Shutdown took " + time.Since(start).String()) }()
+ defer func() { slog.Debug("Shutdown took " + time.Since(start).String()) }()
// First, cancel all agents and wait for them to finish. This must complete
// before closing the DB so agents can finish writing their state.
@@ -140,7 +140,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
}
- slog.Info("LSP client initialized", "name", name)
+ slog.Debug("LSP client initialized", "name", name)
// Add to map with mutex protection before starting goroutine
app.LSPClients.Set(name, lspClient)
@@ -8,6 +8,7 @@ import (
"os/signal"
"strings"
+ "charm.land/log/v2"
"github.com/charmbracelet/crush/internal/event"
"github.com/spf13/cobra"
)
@@ -29,9 +30,13 @@ crush run "What is this code doing?" <<< prrr.go
# Run in quiet mode (hide the spinner)
crush run --quiet "Generate a README for this project"
+
+# Run in verbose mode
+crush run --verbose "Generate a README for this project"
`,
RunE: func(cmd *cobra.Command, args []string) error {
quiet, _ := cmd.Flags().GetBool("quiet")
+ verbose, _ := cmd.Flags().GetBool("verbose")
largeModel, _ := cmd.Flags().GetString("model")
smallModel, _ := cmd.Flags().GetString("small-model")
@@ -49,6 +54,10 @@ crush run --quiet "Generate a README for this project"
return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
}
+ if verbose {
+ slog.SetDefault(slog.New(log.New(os.Stderr)))
+ }
+
prompt := strings.Join(args, " ")
prompt, err = MaybePrependStdin(prompt)
@@ -64,7 +73,7 @@ crush run --quiet "Generate a README for this project"
event.SetNonInteractive(true)
event.AppInitialized()
- return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet)
+ return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose)
},
PostRun: func(cmd *cobra.Command, args []string) {
event.AppExited()
@@ -73,6 +82,7 @@ crush run --quiet "Generate a README for this project"
func init() {
runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner")
+ runCmd.Flags().BoolP("verbose", "v", false, "Show logs")
runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider")
}
@@ -317,7 +317,7 @@ func (m MCPConfig) ResolvedHeaders() map[string]string {
var err error
m.Headers[e], err = resolver.ResolveValue(v)
if err != nil {
- slog.Error("error resolving header variable", "error", err, "variable", e, "value", v)
+ slog.Error("Error resolving header variable", "error", err, "variable", e, "value", v)
continue
}
}
@@ -840,7 +840,7 @@ func resolveEnvs(envs map[string]string) []string {
var err error
envs[e], err = resolver.ResolveValue(v)
if err != nil {
- slog.Error("error resolving environment variable", "error", err, "variable", e, "value", v)
+ slog.Error("Error resolving environment variable", "error", err, "variable", e, "value", v)
continue
}
}
@@ -144,20 +144,20 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
}
if commonIgnorePatterns().MatchesPath(relPath) {
- slog.Debug("ignoring common pattern", "path", relPath)
+ slog.Debug("Ignoring common pattern", "path", relPath)
return true
}
parentDir := filepath.Dir(path)
ignoreParser := dl.getIgnore(parentDir)
if ignoreParser.MatchesPath(relPath) {
- slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
+ slog.Debug("Ignoring dir pattern", "path", relPath, "dir", parentDir)
return true
}
// For directories, also check with trailing slash (gitignore convention)
if ignoreParser.MatchesPath(relPath + "/") {
- slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
+ slog.Debug("Ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
return true
}
@@ -166,7 +166,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
}
if homeIgnore().MatchesPath(relPath) {
- slog.Debug("ignoring home dir pattern", "path", relPath)
+ slog.Debug("Ignoring home dir pattern", "path", relPath)
return true
}
@@ -177,7 +177,7 @@ func (dl *directoryLister) checkParentIgnores(path string) bool {
parent := filepath.Dir(filepath.Dir(path))
for parent != "." && path != "." {
if dl.getIgnore(parent).MatchesPath(path) {
- slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
+ slog.Debug("Ignoring parent dir pattern", "path", path, "dir", parent)
return true
}
if parent == dl.rootPath {
@@ -210,7 +210,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int
found := csync.NewSlice[string]()
dl := NewDirectoryLister(initialPath)
- slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
+ slog.Debug("Listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
conf := fastwalk.Config{
Follow: true,
@@ -12,7 +12,7 @@ var homedir, homedirErr = os.UserHomeDir()
func init() {
if homedirErr != nil {
- slog.Error("failed to get user home directory", "error", homedirErr)
+ slog.Error("Failed to get user home directory", "error", homedirErr)
}
}
@@ -317,12 +317,12 @@ func (c *Client) HandlesFile(path string) bool {
// Check if file is within working directory.
absPath, err := filepath.Abs(path)
if err != nil {
- slog.Debug("cannot resolve path", "name", c.name, "file", path, "error", err)
+ slog.Debug("Cannot resolve path", "name", c.name, "file", path, "error", err)
return false
}
relPath, err := filepath.Rel(c.workDir, absPath)
if err != nil || strings.HasPrefix(relPath, "..") {
- slog.Debug("file outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
+ slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
return false
}
@@ -339,11 +339,11 @@ func (c *Client) HandlesFile(path string) bool {
suffix = "." + suffix
}
if strings.HasSuffix(name, suffix) || filetype == string(kind) {
- slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
+ slog.Debug("Handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
return true
}
}
- slog.Debug("doesn't handle file", "name", c.name, "file", name)
+ slog.Debug("Doesn't handle file", "name", c.name, "file", name)
return false
}
@@ -203,7 +203,7 @@ func (s *service) List(ctx context.Context) ([]Session, error) {
func (s service) fromDBItem(item db.Session) Session {
todos, err := unmarshalTodos(item.Todos.String)
if err != nil {
- slog.Error("failed to unmarshal todos", "session_id", item.ID, "error", err)
+ slog.Error("Failed to unmarshal todos", "session_id", item.ID, "error", err)
}
return Session{
ID: item.ID,
@@ -168,7 +168,7 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
return chunk
},
}); err != nil {
- slog.Error("failed to encode image for kitty graphics", "err", err)
+ slog.Error("Failed to encode image for kitty graphics", "err", err)
return uiutil.InfoMsg{
Type: uiutil.InfoTypeError,
Msg: "failed to encode image",
@@ -27,7 +27,7 @@ func (m *UI) loadPromptHistory() tea.Cmd {
messages, err = m.com.App.Messages.ListAllUserMessages(ctx)
}
if err != nil {
- slog.Error("failed to load prompt history", "error", err)
+ slog.Error("Failed to load prompt history", "error", err)
return promptHistoryLoadedMsg{messages: nil}
}
@@ -330,7 +330,7 @@ func (m *UI) loadCustomCommands() tea.Cmd {
return func() tea.Msg {
customCommands, err := commands.LoadCustomCommands(m.com.Config())
if err != nil {
- slog.Error("failed to load custom commands", "error", err)
+ slog.Error("Failed to load custom commands", "error", err)
}
return userCommandsLoadedMsg{Commands: customCommands}
}
@@ -341,7 +341,7 @@ func (m *UI) loadMCPrompts() tea.Cmd {
return func() tea.Msg {
prompts, err := commands.LoadMCPPrompts()
if err != nil {
- slog.Error("failed to load mcp prompts", "error", err)
+ slog.Error("Failed to load MCP prompts", "error", err)
}
if prompts == nil {
// flag them as loaded even if there is none or an error
@@ -683,7 +683,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case uv.KittyGraphicsEvent:
if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
- slog.Warn("unexpected Kitty graphics response",
+ slog.Warn("Unexpected Kitty graphics response",
"response", string(msg.Payload),
"options", msg.Options)
}
@@ -0,0 +1,5 @@
+#!/bin/bash
+if grep -rE 'slog\.(Error|Info|Warn|Debug|Fatal|Print|Println|Printf)\(["\"][a-z]' --include="*.go" . 2>/dev/null; then
+ echo "❌ Log messages must start with a capital letter. Found lowercase logs above."
+ exit 1
+fi