Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/root/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions docs/configuration/hooks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,38 @@ hooks:
<p>Hooks run synchronously and can slow down agent execution. Keep hook scripts fast and efficient. Consider using <code>suppress_output: true</code> for logging hooks to reduce noise.</p>

</div>

## 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"
```

<div class="callout callout-info">
<div class="callout-title">ℹ️ Merging behavior
</div>
<p>CLI hooks are <strong>appended</strong> 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 <code>matcher: "*"</code>).</p>

</div>
29 changes: 19 additions & 10 deletions docs/features/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,21 @@ Launch the interactive TUI with an agent configuration.
$ docker agent run [config] [message...] [flags]
```

| Flag | Description |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `-a, --agent &lt;name&gt;` | Run a specific agent from the config |
| `--yolo` | Auto-approve all tool calls |
| `--model &lt;ref&gt;` | Override model(s). Use `provider/model` for all agents, or `agent=provider/model` for specific agents. Comma-separate multiple overrides. |
| `--session &lt;id&gt;` | Resume a previous session. Supports relative refs (`-1` = last, `-2` = second to last) |
| `--prompt-file &lt;path&gt;` | Include file contents as additional system context (repeatable) |
| `-d, --debug` | Enable debug logging |
| `--log-file &lt;path&gt;` | Custom debug log location |
| `-o, --otel` | Enable OpenTelemetry tracing |
| Flag | Description |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `-a, --agent &lt;name&gt;` | Run a specific agent from the config |
| `--yolo` | Auto-approve all tool calls |
| `--model &lt;ref&gt;` | Override model(s). Use `provider/model` for all agents, or `agent=provider/model` for specific agents. Comma-separate multiple overrides. |
| `--session &lt;id&gt;` | Resume a previous session. Supports relative refs (`-1` = last, `-2` = second to last) |
| `--prompt-file &lt;path&gt;` | Include file contents as additional system context (repeatable) |
| `--hook-pre-tool-use &lt;cmd&gt;` | Add a pre-tool-use hook command (repeatable). See [Hooks]({{ '/configuration/hooks/' | relative_url }}). |
| `--hook-post-tool-use &lt;cmd&gt;` | Add a post-tool-use hook command (repeatable) |
| `--hook-session-start &lt;cmd&gt;` | Add a session-start hook command (repeatable) |
| `--hook-session-end &lt;cmd&gt;` | Add a session-end hook command (repeatable) |
| `--hook-on-user-input &lt;cmd&gt;` | Add an on-user-input hook command (repeatable) |
| `-d, --debug` | Enable debug logging |
| `--log-file &lt;path&gt;` | Custom debug log location |
| `-o, --otel` | Enable OpenTelemetry tracing |

```bash
# Examples
Expand All @@ -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"
```
Expand Down
96 changes: 96 additions & 0 deletions pkg/config/hooks.go
Original file line number Diff line number Diff line change
@@ -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,
)
}
167 changes: 167 additions & 0 deletions pkg/config/hooks_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
12 changes: 12 additions & 0 deletions pkg/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
Loading
Loading