From f2e7296e6919ba2608866c5a79e767003465b389 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Mon, 16 Mar 2026 20:06:47 +0100 Subject: [PATCH] Add --hook-* CLI flags to override agent hooks from the command line Add five new repeatable flags (--hook-pre-tool-use, --hook-post-tool-use, --hook-session-start, --hook-session-end, --hook-on-user-input) that let users attach lifecycle hooks to any agent without modifying its YAML config. CLI hooks are merged (appended) with any hooks already defined in the agent configuration. Empty/whitespace-only commands are silently skipped. - Add hook string-slice fields to RuntimeConfig and CLI flag registration - Add HooksFromCLI, MergeHooks helpers with comprehensive tests - Hoist CLIHooks() computation before the agent loop in teamloader - Document new flags in CLI reference and hooks configuration pages Assisted-By: docker-agent --- cmd/root/flags.go | 5 + docs/configuration/hooks/index.md | 35 +++++++ docs/features/cli/index.md | 29 ++++-- pkg/config/hooks.go | 96 +++++++++++++++++ pkg/config/hooks_test.go | 167 ++++++++++++++++++++++++++++++ pkg/config/runtime.go | 12 +++ pkg/teamloader/teamloader.go | 4 +- 7 files changed, 337 insertions(+), 11 deletions(-) create mode 100644 pkg/config/hooks.go create mode 100644 pkg/config/hooks_test.go diff --git a/cmd/root/flags.go b/cmd/root/flags.go index ea352b35d..de1ba4115 100644 --- a/cmd/root/flags.go +++ b/cmd/root/flags.go @@ -30,6 +30,11 @@ func addRuntimeConfigFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig) cmd.PersistentFlags().StringSliceVar(&runConfig.EnvFiles, "env-from-file", nil, "Set environment variables from file") cmd.PersistentFlags().BoolVar(&runConfig.GlobalCodeMode, "code-mode-tools", false, "Provide a single tool to call other tools via Javascript") cmd.PersistentFlags().StringVar(&runConfig.WorkingDir, "working-dir", "", "Set the working directory for the session (applies to tools and relative paths)") + cmd.PersistentFlags().StringArrayVar(&runConfig.HookPreToolUse, "hook-pre-tool-use", nil, "Add a pre-tool-use hook command that runs before every tool call (repeatable)") + cmd.PersistentFlags().StringArrayVar(&runConfig.HookPostToolUse, "hook-post-tool-use", nil, "Add a post-tool-use hook command that runs after every tool call (repeatable)") + cmd.PersistentFlags().StringArrayVar(&runConfig.HookSessionStart, "hook-session-start", nil, "Add a session-start hook command (repeatable)") + cmd.PersistentFlags().StringArrayVar(&runConfig.HookSessionEnd, "hook-session-end", nil, "Add a session-end hook command (repeatable)") + cmd.PersistentFlags().StringArrayVar(&runConfig.HookOnUserInput, "hook-on-user-input", nil, "Add an on-user-input hook command (repeatable)") } func setupWorkingDirectory(workingDir string) error { diff --git a/docs/configuration/hooks/index.md b/docs/configuration/hooks/index.md index b7b17adc0..d7669727e 100644 --- a/docs/configuration/hooks/index.md +++ b/docs/configuration/hooks/index.md @@ -242,3 +242,38 @@ hooks:

Hooks run synchronously and can slow down agent execution. Keep hook scripts fast and efficient. Consider using suppress_output: true for logging hooks to reduce noise.

+ +## CLI Flags + +You can add hooks from the command line without modifying the agent's YAML file. This is useful for one-off debugging, audit logging, or layering hooks onto an existing agent. + +| Flag | Description | +| ----------------------- | --------------------------------------- | +| `--hook-pre-tool-use` | Run a command before every tool call | +| `--hook-post-tool-use` | Run a command after every tool call | +| `--hook-session-start` | Run a command when a session starts | +| `--hook-session-end` | Run a command when a session ends | +| `--hook-on-user-input` | Run a command when waiting for input | + +All flags are repeatable — pass multiple to register multiple hooks. + +```bash +# Add a session-start hook +$ docker agent run agent.yaml --hook-session-start "./scripts/setup-env.sh" + +# Combine multiple hooks +$ docker agent run agent.yaml \ + --hook-pre-tool-use "./scripts/validate.sh" \ + --hook-post-tool-use "./scripts/log.sh" + +# Add hooks to an agent from a registry +$ docker agent run agentcatalog/coder \ + --hook-pre-tool-use "./audit.sh" +``` + +
+
ℹ️ Merging behavior +
+

CLI hooks are appended to any hooks already defined in the agent's YAML config. They don't replace existing hooks. Pre/post-tool-use hooks added via CLI match all tools (equivalent to matcher: "*").

+ +
diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index ec4af5d52..d0915f9db 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -25,16 +25,21 @@ Launch the interactive TUI with an agent configuration. $ docker agent run [config] [message...] [flags] ``` -| Flag | Description | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `-a, --agent <name>` | Run a specific agent from the config | -| `--yolo` | Auto-approve all tool calls | -| `--model <ref>` | Override model(s). Use `provider/model` for all agents, or `agent=provider/model` for specific agents. Comma-separate multiple overrides. | -| `--session <id>` | Resume a previous session. Supports relative refs (`-1` = last, `-2` = second to last) | -| `--prompt-file <path>` | Include file contents as additional system context (repeatable) | -| `-d, --debug` | Enable debug logging | -| `--log-file <path>` | Custom debug log location | -| `-o, --otel` | Enable OpenTelemetry tracing | +| Flag | Description | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `-a, --agent <name>` | Run a specific agent from the config | +| `--yolo` | Auto-approve all tool calls | +| `--model <ref>` | Override model(s). Use `provider/model` for all agents, or `agent=provider/model` for specific agents. Comma-separate multiple overrides. | +| `--session <id>` | Resume a previous session. Supports relative refs (`-1` = last, `-2` = second to last) | +| `--prompt-file <path>` | Include file contents as additional system context (repeatable) | +| `--hook-pre-tool-use <cmd>` | Add a pre-tool-use hook command (repeatable). See [Hooks]({{ '/configuration/hooks/' | relative_url }}). | +| `--hook-post-tool-use <cmd>` | Add a post-tool-use hook command (repeatable) | +| `--hook-session-start <cmd>` | Add a session-start hook command (repeatable) | +| `--hook-session-end <cmd>` | Add a session-end hook command (repeatable) | +| `--hook-on-user-input <cmd>` | Add an on-user-input hook command (repeatable) | +| `-d, --debug` | Enable debug logging | +| `--log-file <path>` | Custom debug log location | +| `-o, --otel` | Enable OpenTelemetry tracing | ```bash # Examples @@ -46,6 +51,10 @@ $ docker agent run agent.yaml --model "dev=openai/gpt-4o,reviewer=anthropic/clau $ docker agent run agent.yaml --session -1 # resume last session $ docker agent run agent.yaml --prompt-file ./context.md # include file as context +# Add hooks from the command line +$ docker agent run agent.yaml --hook-session-start "./scripts/setup-env.sh" +$ docker agent run agent.yaml --hook-pre-tool-use "./scripts/validate.sh" --hook-post-tool-use "./scripts/log.sh" + # Queue multiple messages (processed in sequence) $ docker agent run agent.yaml "question 1" "question 2" "question 3" ``` diff --git a/pkg/config/hooks.go b/pkg/config/hooks.go new file mode 100644 index 000000000..a1ef127c1 --- /dev/null +++ b/pkg/config/hooks.go @@ -0,0 +1,96 @@ +package config + +import ( + "strings" + + "github.com/docker/docker-agent/pkg/config/latest" +) + +// HooksFromCLI builds a HooksConfig from CLI flag values. +// Each string is treated as a shell command to run. +// Empty strings are silently skipped. +func HooksFromCLI(preToolUse, postToolUse, sessionStart, sessionEnd, onUserInput []string) *latest.HooksConfig { + hooks := &latest.HooksConfig{} + + if len(preToolUse) > 0 { + var defs []latest.HookDefinition + for _, cmd := range preToolUse { + if strings.TrimSpace(cmd) == "" { + continue + } + defs = append(defs, latest.HookDefinition{Type: "command", Command: cmd}) + } + if len(defs) > 0 { + hooks.PreToolUse = []latest.HookMatcherConfig{{Hooks: defs}} + } + } + + if len(postToolUse) > 0 { + var defs []latest.HookDefinition + for _, cmd := range postToolUse { + if strings.TrimSpace(cmd) == "" { + continue + } + defs = append(defs, latest.HookDefinition{Type: "command", Command: cmd}) + } + if len(defs) > 0 { + hooks.PostToolUse = []latest.HookMatcherConfig{{Hooks: defs}} + } + } + + for _, cmd := range sessionStart { + if strings.TrimSpace(cmd) != "" { + hooks.SessionStart = append(hooks.SessionStart, latest.HookDefinition{Type: "command", Command: cmd}) + } + } + for _, cmd := range sessionEnd { + if strings.TrimSpace(cmd) != "" { + hooks.SessionEnd = append(hooks.SessionEnd, latest.HookDefinition{Type: "command", Command: cmd}) + } + } + for _, cmd := range onUserInput { + if strings.TrimSpace(cmd) != "" { + hooks.OnUserInput = append(hooks.OnUserInput, latest.HookDefinition{Type: "command", Command: cmd}) + } + } + + if hooks.IsEmpty() { + return nil + } + + return hooks +} + +// MergeHooks merges CLI hooks into an existing HooksConfig. +// CLI hooks are appended after any hooks already defined in the config. +// When both are non-nil and non-empty, a new merged object is returned +// without mutating either input. +func MergeHooks(base, cli *latest.HooksConfig) *latest.HooksConfig { + if cli == nil || cli.IsEmpty() { + return base + } + if base == nil || base.IsEmpty() { + return cli + } + + merged := &latest.HooksConfig{ + PreToolUse: append(append([]latest.HookMatcherConfig{}, base.PreToolUse...), cli.PreToolUse...), + PostToolUse: append(append([]latest.HookMatcherConfig{}, base.PostToolUse...), cli.PostToolUse...), + SessionStart: append(append([]latest.HookDefinition{}, base.SessionStart...), cli.SessionStart...), + SessionEnd: append(append([]latest.HookDefinition{}, base.SessionEnd...), cli.SessionEnd...), + OnUserInput: append(append([]latest.HookDefinition{}, base.OnUserInput...), cli.OnUserInput...), + } + return merged +} + +// CLIHooks returns a HooksConfig derived from the runtime config's CLI hook flags, +// or nil if no hook flags were specified. +func (runConfig *RuntimeConfig) CLIHooks() *latest.HooksConfig { + return HooksFromCLI( + runConfig.HookPreToolUse, + runConfig.HookPostToolUse, + runConfig.HookSessionStart, + runConfig.HookSessionEnd, + runConfig.HookOnUserInput, + ) +} diff --git a/pkg/config/hooks_test.go b/pkg/config/hooks_test.go new file mode 100644 index 000000000..1edcabfca --- /dev/null +++ b/pkg/config/hooks_test.go @@ -0,0 +1,167 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/config/latest" +) + +func TestHooksFromCLI_Empty(t *testing.T) { + hooks := HooksFromCLI(nil, nil, nil, nil, nil) + assert.Nil(t, hooks) +} + +func TestHooksFromCLI_SkipsEmptyCommands(t *testing.T) { + // All empty/whitespace-only commands should be filtered out + hooks := HooksFromCLI([]string{""}, []string{" "}, []string{""}, []string{" \t"}, nil) + assert.Nil(t, hooks) +} + +func TestHooksFromCLI_MixedEmptyAndValid(t *testing.T) { + hooks := HooksFromCLI([]string{"", "echo pre", " "}, nil, []string{"echo start", ""}, nil, nil) + require.NotNil(t, hooks) + + require.Len(t, hooks.PreToolUse, 1) + require.Len(t, hooks.PreToolUse[0].Hooks, 1) + assert.Equal(t, "echo pre", hooks.PreToolUse[0].Hooks[0].Command) + + require.Len(t, hooks.SessionStart, 1) + assert.Equal(t, "echo start", hooks.SessionStart[0].Command) +} + +func TestHooksFromCLI_PreToolUse(t *testing.T) { + hooks := HooksFromCLI([]string{"echo pre1", "echo pre2"}, nil, nil, nil, nil) + require.NotNil(t, hooks) + + require.Len(t, hooks.PreToolUse, 1) + require.Len(t, hooks.PreToolUse[0].Hooks, 2) + assert.Equal(t, "command", hooks.PreToolUse[0].Hooks[0].Type) + assert.Equal(t, "echo pre1", hooks.PreToolUse[0].Hooks[0].Command) + assert.Equal(t, "echo pre2", hooks.PreToolUse[0].Hooks[1].Command) + // Matcher is empty string, which matches all tools by default + assert.Empty(t, hooks.PreToolUse[0].Matcher) +} + +func TestHooksFromCLI_AllTypes(t *testing.T) { + hooks := HooksFromCLI( + []string{"pre-cmd"}, + []string{"post-cmd"}, + []string{"start-cmd"}, + []string{"end-cmd"}, + []string{"input-cmd"}, + ) + require.NotNil(t, hooks) + + assert.Len(t, hooks.PreToolUse, 1) + assert.Len(t, hooks.PostToolUse, 1) + assert.Len(t, hooks.SessionStart, 1) + assert.Len(t, hooks.SessionEnd, 1) + assert.Len(t, hooks.OnUserInput, 1) + + assert.Equal(t, "pre-cmd", hooks.PreToolUse[0].Hooks[0].Command) + assert.Equal(t, "post-cmd", hooks.PostToolUse[0].Hooks[0].Command) + assert.Equal(t, "start-cmd", hooks.SessionStart[0].Command) + assert.Equal(t, "end-cmd", hooks.SessionEnd[0].Command) + assert.Equal(t, "input-cmd", hooks.OnUserInput[0].Command) +} + +func TestMergeHooks_BothNil(t *testing.T) { + assert.Nil(t, MergeHooks(nil, nil)) +} + +func TestMergeHooks_CLINil(t *testing.T) { + base := &latest.HooksConfig{ + SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo base"}}, + } + result := MergeHooks(base, nil) + assert.Equal(t, base, result) +} + +func TestMergeHooks_BaseNil(t *testing.T) { + cli := &latest.HooksConfig{ + SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo cli"}}, + } + result := MergeHooks(nil, cli) + assert.Equal(t, cli, result) +} + +func TestMergeHooks_BothNonNil(t *testing.T) { + base := &latest.HooksConfig{ + SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo base"}}, + PreToolUse: []latest.HookMatcherConfig{{ + Matcher: "shell", + Hooks: []latest.HookDefinition{{Type: "command", Command: "echo base-pre"}}, + }}, + } + cli := &latest.HooksConfig{ + SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo cli"}}, + PreToolUse: []latest.HookMatcherConfig{{ + Hooks: []latest.HookDefinition{{Type: "command", Command: "echo cli-pre"}}, + }}, + } + + result := MergeHooks(base, cli) + require.NotNil(t, result) + + // Session start hooks should be merged + require.Len(t, result.SessionStart, 2) + assert.Equal(t, "echo base", result.SessionStart[0].Command) + assert.Equal(t, "echo cli", result.SessionStart[1].Command) + + // Pre tool use matchers should be merged + require.Len(t, result.PreToolUse, 2) + assert.Equal(t, "shell", result.PreToolUse[0].Matcher) + assert.Equal(t, "echo base-pre", result.PreToolUse[0].Hooks[0].Command) + assert.Empty(t, result.PreToolUse[1].Matcher) + assert.Equal(t, "echo cli-pre", result.PreToolUse[1].Hooks[0].Command) +} + +func TestMergeHooks_DoesNotMutateOriginals(t *testing.T) { + base := &latest.HooksConfig{ + SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo base"}}, + } + cli := &latest.HooksConfig{ + SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo cli"}}, + } + + result := MergeHooks(base, cli) + + // Originals should not be mutated + assert.Len(t, base.SessionStart, 1) + assert.Len(t, cli.SessionStart, 1) + assert.Len(t, result.SessionStart, 2) +} + +func TestRuntimeConfig_CLIHooks(t *testing.T) { + rc := &RuntimeConfig{} + assert.Nil(t, rc.CLIHooks()) + + rc.HookSessionStart = []string{"echo start"} + hooks := rc.CLIHooks() + require.NotNil(t, hooks) + assert.Len(t, hooks.SessionStart, 1) + assert.Equal(t, "echo start", hooks.SessionStart[0].Command) +} + +func TestRuntimeConfig_Clone_CopiesHooks(t *testing.T) { + rc := &RuntimeConfig{} + rc.HookPreToolUse = []string{"pre"} + rc.HookPostToolUse = []string{"post"} + rc.HookSessionStart = []string{"start"} + rc.HookSessionEnd = []string{"end"} + rc.HookOnUserInput = []string{"input"} + + clone := rc.Clone() + assert.Equal(t, rc.HookPreToolUse, clone.HookPreToolUse) + assert.Equal(t, rc.HookPostToolUse, clone.HookPostToolUse) + assert.Equal(t, rc.HookSessionStart, clone.HookSessionStart) + assert.Equal(t, rc.HookSessionEnd, clone.HookSessionEnd) + assert.Equal(t, rc.HookOnUserInput, clone.HookOnUserInput) + + // Mutating clone should not affect original + clone.HookPreToolUse[0] = "changed" + assert.Equal(t, "pre", rc.HookPreToolUse[0]) +} diff --git a/pkg/config/runtime.go b/pkg/config/runtime.go index b08ba31ba..ff3d3eb35 100644 --- a/pkg/config/runtime.go +++ b/pkg/config/runtime.go @@ -23,6 +23,13 @@ type Config struct { DefaultModel *latest.ModelConfig GlobalCodeMode bool WorkingDir string + + // Hook overrides from CLI flags + HookPreToolUse []string + HookPostToolUse []string + HookSessionStart []string + HookSessionEnd []string + HookOnUserInput []string } func (runConfig *RuntimeConfig) Clone() *RuntimeConfig { @@ -31,6 +38,11 @@ func (runConfig *RuntimeConfig) Clone() *RuntimeConfig { } clone.EnvFiles = slices.Clone(runConfig.EnvFiles) clone.DefaultModel = runConfig.DefaultModel.Clone() + clone.HookPreToolUse = slices.Clone(runConfig.HookPreToolUse) + clone.HookPostToolUse = slices.Clone(runConfig.HookPostToolUse) + clone.HookSessionStart = slices.Clone(runConfig.HookSessionStart) + clone.HookSessionEnd = slices.Clone(runConfig.HookSessionEnd) + clone.HookOnUserInput = slices.Clone(runConfig.HookOnUserInput) return clone } diff --git a/pkg/teamloader/teamloader.go b/pkg/teamloader/teamloader.go index c538e9066..d17845cf2 100644 --- a/pkg/teamloader/teamloader.go +++ b/pkg/teamloader/teamloader.go @@ -145,6 +145,8 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c expander := js.NewJsExpander(env) + cliHooks := runConfig.CLIHooks() + for _, agentConfig := range cfg.Agents { // Merge CLI prompt files with agent config prompt files, deduplicating promptFiles := slices.Concat(agentConfig.AddPromptFiles, loadOpts.promptFiles) @@ -171,7 +173,7 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c agent.WithMaxConsecutiveToolCalls(agentConfig.MaxConsecutiveToolCalls), agent.WithNumHistoryItems(agentConfig.NumHistoryItems), agent.WithCommands(expander.ExpandCommands(ctx, agentConfig.Commands)), - agent.WithHooks(agentConfig.Hooks), + agent.WithHooks(config.MergeHooks(agentConfig.Hooks, cliHooks)), } models, thinkingConfigured, err := getModelsForAgent(ctx, cfg, &agentConfig, autoModel, runConfig)