diff --git a/go.mod b/go.mod index bf5274c..f592cc3 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/log v0.4.2 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect @@ -41,6 +42,7 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect @@ -74,6 +76,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/go.sum b/go.sum index a77dac7..8bfbb8e 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxD github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= @@ -70,6 +72,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= diff --git a/internal/cmd/agent.go b/internal/cmd/agent.go index d0130fa..10107a4 100644 --- a/internal/cmd/agent.go +++ b/internal/cmd/agent.go @@ -11,6 +11,7 @@ import ( "github.com/jmgilman/headjack/internal/config" "github.com/jmgilman/headjack/internal/instance" "github.com/jmgilman/headjack/internal/keychain" + "github.com/jmgilman/headjack/internal/slogger" ) var agentCmd = &cobra.Command{ @@ -249,7 +250,7 @@ func runAgentCmd(cmd *cobra.Command, args []string) error { } if flags.detached { - fmt.Printf("Created session %s in instance %s (detached)\n", session.Name, inst.ID) + slogger.L(cmd.Context()).Info("created session", "session", session.Name, "instance", inst.ID, "mode", "detached") return nil } diff --git a/internal/cmd/context.go b/internal/cmd/context.go index 382a992..f0389da 100644 --- a/internal/cmd/context.go +++ b/internal/cmd/context.go @@ -2,9 +2,11 @@ package cmd import ( "context" + "log/slog" "github.com/jmgilman/headjack/internal/config" "github.com/jmgilman/headjack/internal/instance" + "github.com/jmgilman/headjack/internal/slogger" ) type contextKey string @@ -56,3 +58,9 @@ func ManagerFromContext(ctx context.Context) *instance.Manager { } return mgr } + +// LoggerFromContext retrieves the logger from context. +// Returns a discarding logger if none is set (never returns nil). +func LoggerFromContext(ctx context.Context) *slog.Logger { + return slogger.FromContext(ctx) +} diff --git a/internal/cmd/exec.go b/internal/cmd/exec.go index 013b3c6..2270759 100644 --- a/internal/cmd/exec.go +++ b/internal/cmd/exec.go @@ -121,7 +121,7 @@ func runExecCmd(cmd *cobra.Command, args []string) error { } if flags.detached { - fmt.Printf("Created session %s in instance %s (detached)\n", session.Name, inst.ID) + fmt.Printf("Created session %s in %s (detached)\n", session.Name, inst.ID) return nil } diff --git a/internal/cmd/ps.go b/internal/cmd/ps.go index f1ea2ae..bbe5582 100644 --- a/internal/cmd/ps.go +++ b/internal/cmd/ps.go @@ -81,11 +81,7 @@ func listInstances(cmd *cobra.Command) error { } if len(instances) == 0 { - if all { - fmt.Println("No instances found") - } else { - fmt.Println("No instances found for this repository") - } + fmt.Println("No instances found") return nil } @@ -133,7 +129,7 @@ func listSessions(cmd *cobra.Command, branch string) error { } if len(sessions) == 0 { - fmt.Printf("No sessions found for instance %s\n", branch) + fmt.Println("No sessions found") return nil } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 18e0037..3e348bb 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -20,6 +20,7 @@ import ( "github.com/jmgilman/headjack/internal/git" "github.com/jmgilman/headjack/internal/instance" "github.com/jmgilman/headjack/internal/multiplexer" + "github.com/jmgilman/headjack/internal/slogger" ) // baseDeps lists the external binaries that must always be available. @@ -40,6 +41,9 @@ var appConfig *config.Config // configLoader is used for accessing agent-specific configuration. var configLoader *config.Loader +// verbosity controls log level: 0=error, 1=info, 2+=debug. +var verbosity int + var rootCmd = &cobra.Command{ Use: "headjack", Short: "Spawn isolated LLM coding agents", @@ -50,6 +54,9 @@ Each agent runs in its own VM-isolated container with a dedicated git worktree, enabling safe parallel development across multiple branches.`, SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Initialize logger first so it's available for dependency/manager errors + logger := slogger.New(slogger.Config{Verbosity: verbosity}) + if err := checkDependencies(); err != nil { return err } @@ -60,6 +67,7 @@ enabling safe parallel development across multiple branches.`, // Store dependencies in context for subcommands ctx := cmd.Context() + ctx = slogger.WithLogger(ctx, logger) ctx = WithConfig(ctx, appConfig) ctx = WithLoader(ctx, configLoader) ctx = WithManager(ctx, mgr) @@ -76,6 +84,8 @@ func Execute() error { func init() { cobra.OnInitialize(initConfig) + rootCmd.PersistentFlags().CountVarP(&verbosity, "verbose", "v", + "increase verbosity (-v for info, -vv for debug)") } func initConfig() { diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 60c2305..f643310 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -3,6 +3,7 @@ package cmd import ( "errors" "fmt" + "log/slog" "github.com/spf13/cobra" @@ -10,6 +11,7 @@ import ( "github.com/jmgilman/headjack/internal/devcontainer" "github.com/jmgilman/headjack/internal/instance" "github.com/jmgilman/headjack/internal/prompt" + "github.com/jmgilman/headjack/internal/slogger" ) var runCmd = &cobra.Command{ @@ -110,7 +112,7 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br if startErr := mgr.Start(cmd.Context(), inst.ID); startErr != nil { return nil, fmt.Errorf("start stopped instance: %w", startErr) } - fmt.Printf("Restarted instance %s for branch %s\n", inst.ID, inst.Branch) + slogger.L(cmd.Context()).Debug("restarted stopped instance", slog.String("id", inst.ID), slog.String("branch", inst.Branch)) // Refresh the instance to get updated status inst, err = mgr.GetByBranch(cmd.Context(), repoPath, branch) if err != nil { @@ -135,7 +137,7 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br return nil, fmt.Errorf("create instance: %w", err) } - fmt.Printf("Created instance %s for branch %s\n", inst.ID, inst.Branch) + slogger.L(cmd.Context()).Debug("created new instance", slog.String("id", inst.ID), slog.String("branch", inst.Branch)) return inst, nil } @@ -189,7 +191,7 @@ func buildCreateConfig(cmd *cobra.Command, repoPath, branch string, flags *runFl return cfg, errors.New("failed to create devcontainer runtime") } - fmt.Println("Detected devcontainer.json, using devcontainer mode") + slogger.L(cmd.Context()).Debug("detected devcontainer.json, using devcontainer mode") cfg.WorkspaceFolder = repoPath cfg.Runtime = dcRuntime diff --git a/internal/container/common.go b/internal/container/common.go index 84d9f1a..780e784 100644 --- a/internal/container/common.go +++ b/internal/container/common.go @@ -3,6 +3,7 @@ package container import ( "context" "fmt" + "log/slog" "os" "os/signal" "strings" @@ -12,6 +13,7 @@ import ( "golang.org/x/term" "github.com/jmgilman/headjack/internal/exec" + "github.com/jmgilman/headjack/internal/slogger" ) // containerParser handles runtime-specific JSON parsing for container inspect and list operations. @@ -48,6 +50,9 @@ func cliError(operation string, result *exec.Result, err error) error { // Run creates and starts a new container. func (r *baseRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, error) { + log := slogger.L(ctx) + log.Debug("running container", slog.String("name", cfg.Name), slog.String("image", cfg.Image)) + args := buildRunArgs(cfg) result, err := r.exec.Run(ctx, &exec.RunOptions{ @@ -64,6 +69,7 @@ func (r *baseRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, erro // Container ID is returned on stdout containerID := strings.TrimSpace(string(result.Stdout)) + log.Debug("container started", slog.String("id", containerID)) return &Container{ ID: containerID, @@ -76,6 +82,9 @@ func (r *baseRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, erro // Exec executes a command in a running container. func (r *baseRuntime) Exec(ctx context.Context, id string, cfg *ExecConfig) error { + log := slogger.L(ctx) + log.Debug("executing in container", slog.String("id", id), slog.Any("command", cfg.Command)) + // Verify container exists and is running container, err := r.Get(ctx, id) if err != nil { @@ -103,6 +112,9 @@ func (r *baseRuntime) Exec(ctx context.Context, id string, cfg *ExecConfig) erro // Stop stops a running container gracefully. func (r *baseRuntime) Stop(ctx context.Context, id string) error { + log := slogger.L(ctx) + log.Debug("stopping container", slog.String("id", id)) + // Verify container exists c, err := r.Get(ctx, id) if err != nil { @@ -111,6 +123,7 @@ func (r *baseRuntime) Stop(ctx context.Context, id string) error { // No-op if already stopped if c.Status == StatusStopped { + log.Debug("container already stopped", slog.String("id", id)) return nil } @@ -127,6 +140,9 @@ func (r *baseRuntime) Stop(ctx context.Context, id string) error { // Start starts a stopped container. func (r *baseRuntime) Start(ctx context.Context, id string) error { + log := slogger.L(ctx) + log.Debug("starting container", slog.String("id", id)) + // Verify container exists c, err := r.Get(ctx, id) if err != nil { @@ -135,6 +151,7 @@ func (r *baseRuntime) Start(ctx context.Context, id string) error { // No-op if already running if c.Status == StatusRunning { + log.Debug("container already running", slog.String("id", id)) return nil } diff --git a/internal/git/repository.go b/internal/git/repository.go index e55c80c..ad4b9be 100644 --- a/internal/git/repository.go +++ b/internal/git/repository.go @@ -4,9 +4,11 @@ import ( "bufio" "context" "fmt" + "log/slog" "strings" "github.com/jmgilman/headjack/internal/exec" + "github.com/jmgilman/headjack/internal/slogger" ) type repository struct { @@ -70,6 +72,9 @@ func (r *repository) remoteBranchExists(ctx context.Context, branch string) (boo } func (r *repository) CreateWorktree(ctx context.Context, path, branch string) error { + log := slogger.L(ctx) + log.Debug("creating worktree", slog.String("path", path), slog.String("branch", branch)) + // Check if branch already exists exists, err := r.BranchExists(ctx, branch) if err != nil { @@ -79,9 +84,11 @@ func (r *repository) CreateWorktree(ctx context.Context, path, branch string) er var args []string if exists { // Use existing branch + log.Debug("using existing branch", slog.String("branch", branch)) args = []string{"worktree", "add", path, branch} } else { // Create new branch from HEAD + log.Debug("creating new branch from HEAD", slog.String("branch", branch)) args = []string{"worktree", "add", "-b", branch, path} } @@ -106,6 +113,9 @@ func (r *repository) CreateWorktree(ctx context.Context, path, branch string) er } func (r *repository) RemoveWorktree(ctx context.Context, path string) error { + log := slogger.L(ctx) + log.Debug("removing worktree", slog.String("path", path)) + result, err := r.exec.Run(ctx, &exec.RunOptions{ Name: "git", Args: []string{"worktree", "remove", path}, diff --git a/internal/instance/manager.go b/internal/instance/manager.go index f5c4c82..c066aa0 100644 --- a/internal/instance/manager.go +++ b/internal/instance/manager.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "log/slog" "path/filepath" "regexp" "strings" @@ -18,6 +19,7 @@ import ( "github.com/jmgilman/headjack/internal/logging" "github.com/jmgilman/headjack/internal/multiplexer" "github.com/jmgilman/headjack/internal/names" + "github.com/jmgilman/headjack/internal/slogger" ) // containerNamePrefix is the prefix for all managed containers. @@ -131,6 +133,9 @@ func (m *Manager) Executor() exec.Executor { // Create creates a new instance for the given repository and branch. func (m *Manager) Create(ctx context.Context, repoPath string, cfg *CreateConfig) (*Instance, error) { + log := slogger.L(ctx) + log.Debug("creating instance", slog.String("repo", repoPath), slog.String("branch", cfg.Branch)) + // Open the repository repo, err := m.git.Open(ctx, repoPath) if err != nil { @@ -138,6 +143,7 @@ func (m *Manager) Create(ctx context.Context, repoPath string, cfg *CreateConfig } repoID := repo.Identifier() + log.Debug("opened repository", slog.String("id", repoID), slog.String("root", repo.Root())) // Check if instance already exists for this branch _, err = m.catalog.GetByRepoBranch(ctx, repoID, cfg.Branch) @@ -178,6 +184,7 @@ func (m *Manager) Create(ctx context.Context, repoPath string, cfg *CreateConfig } // Create worktree + log.Debug("creating worktree", slog.String("path", worktreePath), slog.String("branch", cfg.Branch)) if wtErr := repo.CreateWorktree(ctx, worktreePath, cfg.Branch); wtErr != nil { cleanup() return nil, fmt.Errorf("create worktree: %w", wtErr) @@ -190,6 +197,7 @@ func (m *Manager) Create(ctx context.Context, repoPath string, cfg *CreateConfig runCfg := m.buildRunConfig(cfg, containerName, worktreePath) // Create container + log.Debug("creating container", slog.String("name", containerName), slog.String("image", cfg.Image)) c, err := runtime.Run(ctx, runCfg) if err != nil { // Cleanup worktree on container failure @@ -350,6 +358,9 @@ func (m *Manager) List(ctx context.Context, filter ListFilter) ([]Instance, erro // Stop stops an instance's container. func (m *Manager) Stop(ctx context.Context, id string) error { + log := slogger.L(ctx) + log.Debug("stopping instance", slog.String("id", id)) + entry, err := m.catalog.Get(ctx, id) if err != nil { if errors.Is(err, catalog.ErrNotFound) { @@ -372,6 +383,9 @@ func (m *Manager) Stop(ctx context.Context, id string) error { // Start starts a stopped instance's container. func (m *Manager) Start(ctx context.Context, id string) error { + log := slogger.L(ctx) + log.Debug("starting instance", slog.String("id", id)) + entry, err := m.catalog.Get(ctx, id) if err != nil { if errors.Is(err, catalog.ErrNotFound) { @@ -384,6 +398,7 @@ func (m *Manager) Start(ctx context.Context, id string) error { return errors.New("instance has no container") } + log.Debug("starting container", slog.String("container", entry.ContainerID)) if err := m.runtime.Start(ctx, entry.ContainerID); err != nil { return fmt.Errorf("start container: %w", err) } @@ -531,6 +546,9 @@ func (m *Manager) stopContainerWithRetry(ctx context.Context, containerID string // Remove removes an instance completely (container, worktree, catalog entry). func (m *Manager) Remove(ctx context.Context, id string) error { + log := slogger.L(ctx) + log.Debug("removing instance", slog.String("id", id)) + entry, err := m.catalog.Get(ctx, id) if err != nil { if errors.Is(err, catalog.ErrNotFound) { @@ -539,6 +557,7 @@ func (m *Manager) Remove(ctx context.Context, id string) error { return fmt.Errorf("get catalog entry: %w", err) } + log.Debug("shutting down container", slog.String("container", entry.ContainerID)) if err := m.shutdownContainer(ctx, entry, shutdownContainerOpts{RemoveContainer: true}); err != nil { return err } @@ -725,6 +744,9 @@ func resolveSessionName(sessions []catalog.Session, name string) (string, error) // The session is created in detached mode within the container's multiplexer. // If cfg.Name is empty, a unique name is auto-generated. func (m *Manager) CreateSession(ctx context.Context, instanceID string, cfg *CreateSessionConfig) (*Session, error) { + log := slogger.L(ctx) + log.Debug("creating session", slog.String("instance", instanceID), slog.String("type", cfg.Type)) + entry, err := m.getRunningInstance(ctx, instanceID) if err != nil { return nil, err @@ -966,6 +988,9 @@ func (m *Manager) ListSessions(ctx context.Context, instanceID string) ([]Sessio // KillSession terminates a session and removes it from the catalog. func (m *Manager) KillSession(ctx context.Context, instanceID, sessionName string) error { + log := slogger.L(ctx) + log.Debug("killing session", slog.String("instance", instanceID), slog.String("session", sessionName)) + entry, err := m.catalog.Get(ctx, instanceID) if err != nil { if errors.Is(err, catalog.ErrNotFound) { @@ -1011,6 +1036,9 @@ func (m *Manager) KillSession(ctx context.Context, instanceID, sessionName strin // AttachSession attaches to an existing session, updating the last accessed timestamp. // This is a blocking operation that takes over the terminal. func (m *Manager) AttachSession(ctx context.Context, instanceID, sessionName string) error { + log := slogger.L(ctx) + log.Debug("attaching to session", slog.String("instance", instanceID), slog.String("session", sessionName)) + entry, err := m.catalog.Get(ctx, instanceID) if err != nil { if errors.Is(err, catalog.ErrNotFound) { diff --git a/internal/instance/manager_test.go b/internal/instance/manager_test.go index 2388b4a..1c7e71f 100644 --- a/internal/instance/manager_test.go +++ b/internal/instance/manager_test.go @@ -112,6 +112,7 @@ func TestManager_Create(t *testing.T) { t.Run("returns ErrAlreadyExists for duplicate branch", func(t *testing.T) { repo := &gitmocks.RepositoryMock{ IdentifierFunc: func() string { return testRepoID }, + RootFunc: func() string { return testRepoPath }, } opener := &gitmocks.OpenerMock{ OpenFunc: func(ctx context.Context, path string) (git.Repository, error) { diff --git a/internal/slogger/slogger.go b/internal/slogger/slogger.go new file mode 100644 index 0000000..d7fe067 --- /dev/null +++ b/internal/slogger/slogger.go @@ -0,0 +1,84 @@ +// Package slogger provides structured logging for the Headjack CLI using +// Go's slog with charmbracelet/log as the handler for pleasant terminal output. +package slogger + +import ( + "context" + "io" + "log/slog" + "os" + + charmlog "github.com/charmbracelet/log" +) + +type contextKey string + +const loggerKey contextKey = "logger" + +// Config holds logger configuration. +type Config struct { + // Verbosity controls log level: + // 0 (default) -> Error only + // 1 (-v) -> Info level + // 2+ (-vv) -> Debug level + Verbosity int + + // Output is the writer for log output. Defaults to os.Stderr. + Output io.Writer +} + +// New creates a new slog.Logger with charmbracelet/log as the handler. +func New(cfg Config) *slog.Logger { + output := cfg.Output + if output == nil { + output = os.Stderr + } + + // Map verbosity to charm log level + var level charmlog.Level + switch { + case cfg.Verbosity >= 2: + level = charmlog.DebugLevel + case cfg.Verbosity == 1: + level = charmlog.InfoLevel + default: + level = charmlog.ErrorLevel + } + + // Create charm log handler with slog-compatible options + handler := charmlog.NewWithOptions(output, charmlog.Options{ + Level: level, + ReportTimestamp: false, // CLI doesn't need timestamps + ReportCaller: false, // Keep output clean + }) + + return slog.New(handler) +} + +// WithLogger adds a logger to the context. +func WithLogger(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, loggerKey, logger) +} + +// FromContext retrieves the logger from context. +// Returns a discarding logger if none is set (never returns nil). +func FromContext(ctx context.Context) *slog.Logger { + if logger, ok := ctx.Value(loggerKey).(*slog.Logger); ok && logger != nil { + return logger + } + // Return a discarding logger as fallback + return slog.New(discardHandler{}) +} + +// L is a convenience alias for FromContext. +func L(ctx context.Context) *slog.Logger { + return FromContext(ctx) +} + +// discardHandler is a slog.Handler that discards all log records. +type discardHandler struct{} + +func (discardHandler) Enabled(context.Context, slog.Level) bool { return false } +func (discardHandler) Handle(context.Context, slog.Record) error { return nil } +func (d discardHandler) WithAttrs([]slog.Attr) slog.Handler { return d } +func (d discardHandler) WithGroup(string) slog.Handler { return d }