Skip to content

Commit dada675

Browse files
authored
Merge pull request #2135 from dgageot/board/would-is-be-possible-to-run-an-existing-708d2f4f
Add --hook-* CLI flags to override agent hooks from the command line
2 parents 0fb2f95 + f2e7296 commit dada675

7 files changed

Lines changed: 337 additions & 11 deletions

File tree

cmd/root/flags.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ func addRuntimeConfigFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig)
3030
cmd.PersistentFlags().StringSliceVar(&runConfig.EnvFiles, "env-from-file", nil, "Set environment variables from file")
3131
cmd.PersistentFlags().BoolVar(&runConfig.GlobalCodeMode, "code-mode-tools", false, "Provide a single tool to call other tools via Javascript")
3232
cmd.PersistentFlags().StringVar(&runConfig.WorkingDir, "working-dir", "", "Set the working directory for the session (applies to tools and relative paths)")
33+
cmd.PersistentFlags().StringArrayVar(&runConfig.HookPreToolUse, "hook-pre-tool-use", nil, "Add a pre-tool-use hook command that runs before every tool call (repeatable)")
34+
cmd.PersistentFlags().StringArrayVar(&runConfig.HookPostToolUse, "hook-post-tool-use", nil, "Add a post-tool-use hook command that runs after every tool call (repeatable)")
35+
cmd.PersistentFlags().StringArrayVar(&runConfig.HookSessionStart, "hook-session-start", nil, "Add a session-start hook command (repeatable)")
36+
cmd.PersistentFlags().StringArrayVar(&runConfig.HookSessionEnd, "hook-session-end", nil, "Add a session-end hook command (repeatable)")
37+
cmd.PersistentFlags().StringArrayVar(&runConfig.HookOnUserInput, "hook-on-user-input", nil, "Add an on-user-input hook command (repeatable)")
3338
}
3439

3540
func setupWorkingDirectory(workingDir string) error {

docs/configuration/hooks/index.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,38 @@ hooks:
242242
<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>
243243

244244
</div>
245+
246+
## CLI Flags
247+
248+
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.
249+
250+
| Flag | Description |
251+
| ----------------------- | --------------------------------------- |
252+
| `--hook-pre-tool-use` | Run a command before every tool call |
253+
| `--hook-post-tool-use` | Run a command after every tool call |
254+
| `--hook-session-start` | Run a command when a session starts |
255+
| `--hook-session-end` | Run a command when a session ends |
256+
| `--hook-on-user-input` | Run a command when waiting for input |
257+
258+
All flags are repeatable — pass multiple to register multiple hooks.
259+
260+
```bash
261+
# Add a session-start hook
262+
$ docker agent run agent.yaml --hook-session-start "./scripts/setup-env.sh"
263+
264+
# Combine multiple hooks
265+
$ docker agent run agent.yaml \
266+
--hook-pre-tool-use "./scripts/validate.sh" \
267+
--hook-post-tool-use "./scripts/log.sh"
268+
269+
# Add hooks to an agent from a registry
270+
$ docker agent run agentcatalog/coder \
271+
--hook-pre-tool-use "./audit.sh"
272+
```
273+
274+
<div class="callout callout-info">
275+
<div class="callout-title">ℹ️ Merging behavior
276+
</div>
277+
<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>
278+
279+
</div>

docs/features/cli/index.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,21 @@ Launch the interactive TUI with an agent configuration.
2525
$ docker agent run [config] [message...] [flags]
2626
```
2727

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

3944
```bash
4045
# Examples
@@ -46,6 +51,10 @@ $ docker agent run agent.yaml --model "dev=openai/gpt-4o,reviewer=anthropic/clau
4651
$ docker agent run agent.yaml --session -1 # resume last session
4752
$ docker agent run agent.yaml --prompt-file ./context.md # include file as context
4853

54+
# Add hooks from the command line
55+
$ docker agent run agent.yaml --hook-session-start "./scripts/setup-env.sh"
56+
$ docker agent run agent.yaml --hook-pre-tool-use "./scripts/validate.sh" --hook-post-tool-use "./scripts/log.sh"
57+
4958
# Queue multiple messages (processed in sequence)
5059
$ docker agent run agent.yaml "question 1" "question 2" "question 3"
5160
```

pkg/config/hooks.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package config
2+
3+
import (
4+
"strings"
5+
6+
"github.com/docker/docker-agent/pkg/config/latest"
7+
)
8+
9+
// HooksFromCLI builds a HooksConfig from CLI flag values.
10+
// Each string is treated as a shell command to run.
11+
// Empty strings are silently skipped.
12+
func HooksFromCLI(preToolUse, postToolUse, sessionStart, sessionEnd, onUserInput []string) *latest.HooksConfig {
13+
hooks := &latest.HooksConfig{}
14+
15+
if len(preToolUse) > 0 {
16+
var defs []latest.HookDefinition
17+
for _, cmd := range preToolUse {
18+
if strings.TrimSpace(cmd) == "" {
19+
continue
20+
}
21+
defs = append(defs, latest.HookDefinition{Type: "command", Command: cmd})
22+
}
23+
if len(defs) > 0 {
24+
hooks.PreToolUse = []latest.HookMatcherConfig{{Hooks: defs}}
25+
}
26+
}
27+
28+
if len(postToolUse) > 0 {
29+
var defs []latest.HookDefinition
30+
for _, cmd := range postToolUse {
31+
if strings.TrimSpace(cmd) == "" {
32+
continue
33+
}
34+
defs = append(defs, latest.HookDefinition{Type: "command", Command: cmd})
35+
}
36+
if len(defs) > 0 {
37+
hooks.PostToolUse = []latest.HookMatcherConfig{{Hooks: defs}}
38+
}
39+
}
40+
41+
for _, cmd := range sessionStart {
42+
if strings.TrimSpace(cmd) != "" {
43+
hooks.SessionStart = append(hooks.SessionStart, latest.HookDefinition{Type: "command", Command: cmd})
44+
}
45+
}
46+
for _, cmd := range sessionEnd {
47+
if strings.TrimSpace(cmd) != "" {
48+
hooks.SessionEnd = append(hooks.SessionEnd, latest.HookDefinition{Type: "command", Command: cmd})
49+
}
50+
}
51+
for _, cmd := range onUserInput {
52+
if strings.TrimSpace(cmd) != "" {
53+
hooks.OnUserInput = append(hooks.OnUserInput, latest.HookDefinition{Type: "command", Command: cmd})
54+
}
55+
}
56+
57+
if hooks.IsEmpty() {
58+
return nil
59+
}
60+
61+
return hooks
62+
}
63+
64+
// MergeHooks merges CLI hooks into an existing HooksConfig.
65+
// CLI hooks are appended after any hooks already defined in the config.
66+
// When both are non-nil and non-empty, a new merged object is returned
67+
// without mutating either input.
68+
func MergeHooks(base, cli *latest.HooksConfig) *latest.HooksConfig {
69+
if cli == nil || cli.IsEmpty() {
70+
return base
71+
}
72+
if base == nil || base.IsEmpty() {
73+
return cli
74+
}
75+
76+
merged := &latest.HooksConfig{
77+
PreToolUse: append(append([]latest.HookMatcherConfig{}, base.PreToolUse...), cli.PreToolUse...),
78+
PostToolUse: append(append([]latest.HookMatcherConfig{}, base.PostToolUse...), cli.PostToolUse...),
79+
SessionStart: append(append([]latest.HookDefinition{}, base.SessionStart...), cli.SessionStart...),
80+
SessionEnd: append(append([]latest.HookDefinition{}, base.SessionEnd...), cli.SessionEnd...),
81+
OnUserInput: append(append([]latest.HookDefinition{}, base.OnUserInput...), cli.OnUserInput...),
82+
}
83+
return merged
84+
}
85+
86+
// CLIHooks returns a HooksConfig derived from the runtime config's CLI hook flags,
87+
// or nil if no hook flags were specified.
88+
func (runConfig *RuntimeConfig) CLIHooks() *latest.HooksConfig {
89+
return HooksFromCLI(
90+
runConfig.HookPreToolUse,
91+
runConfig.HookPostToolUse,
92+
runConfig.HookSessionStart,
93+
runConfig.HookSessionEnd,
94+
runConfig.HookOnUserInput,
95+
)
96+
}

pkg/config/hooks_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/docker/docker-agent/pkg/config/latest"
10+
)
11+
12+
func TestHooksFromCLI_Empty(t *testing.T) {
13+
hooks := HooksFromCLI(nil, nil, nil, nil, nil)
14+
assert.Nil(t, hooks)
15+
}
16+
17+
func TestHooksFromCLI_SkipsEmptyCommands(t *testing.T) {
18+
// All empty/whitespace-only commands should be filtered out
19+
hooks := HooksFromCLI([]string{""}, []string{" "}, []string{""}, []string{" \t"}, nil)
20+
assert.Nil(t, hooks)
21+
}
22+
23+
func TestHooksFromCLI_MixedEmptyAndValid(t *testing.T) {
24+
hooks := HooksFromCLI([]string{"", "echo pre", " "}, nil, []string{"echo start", ""}, nil, nil)
25+
require.NotNil(t, hooks)
26+
27+
require.Len(t, hooks.PreToolUse, 1)
28+
require.Len(t, hooks.PreToolUse[0].Hooks, 1)
29+
assert.Equal(t, "echo pre", hooks.PreToolUse[0].Hooks[0].Command)
30+
31+
require.Len(t, hooks.SessionStart, 1)
32+
assert.Equal(t, "echo start", hooks.SessionStart[0].Command)
33+
}
34+
35+
func TestHooksFromCLI_PreToolUse(t *testing.T) {
36+
hooks := HooksFromCLI([]string{"echo pre1", "echo pre2"}, nil, nil, nil, nil)
37+
require.NotNil(t, hooks)
38+
39+
require.Len(t, hooks.PreToolUse, 1)
40+
require.Len(t, hooks.PreToolUse[0].Hooks, 2)
41+
assert.Equal(t, "command", hooks.PreToolUse[0].Hooks[0].Type)
42+
assert.Equal(t, "echo pre1", hooks.PreToolUse[0].Hooks[0].Command)
43+
assert.Equal(t, "echo pre2", hooks.PreToolUse[0].Hooks[1].Command)
44+
// Matcher is empty string, which matches all tools by default
45+
assert.Empty(t, hooks.PreToolUse[0].Matcher)
46+
}
47+
48+
func TestHooksFromCLI_AllTypes(t *testing.T) {
49+
hooks := HooksFromCLI(
50+
[]string{"pre-cmd"},
51+
[]string{"post-cmd"},
52+
[]string{"start-cmd"},
53+
[]string{"end-cmd"},
54+
[]string{"input-cmd"},
55+
)
56+
require.NotNil(t, hooks)
57+
58+
assert.Len(t, hooks.PreToolUse, 1)
59+
assert.Len(t, hooks.PostToolUse, 1)
60+
assert.Len(t, hooks.SessionStart, 1)
61+
assert.Len(t, hooks.SessionEnd, 1)
62+
assert.Len(t, hooks.OnUserInput, 1)
63+
64+
assert.Equal(t, "pre-cmd", hooks.PreToolUse[0].Hooks[0].Command)
65+
assert.Equal(t, "post-cmd", hooks.PostToolUse[0].Hooks[0].Command)
66+
assert.Equal(t, "start-cmd", hooks.SessionStart[0].Command)
67+
assert.Equal(t, "end-cmd", hooks.SessionEnd[0].Command)
68+
assert.Equal(t, "input-cmd", hooks.OnUserInput[0].Command)
69+
}
70+
71+
func TestMergeHooks_BothNil(t *testing.T) {
72+
assert.Nil(t, MergeHooks(nil, nil))
73+
}
74+
75+
func TestMergeHooks_CLINil(t *testing.T) {
76+
base := &latest.HooksConfig{
77+
SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo base"}},
78+
}
79+
result := MergeHooks(base, nil)
80+
assert.Equal(t, base, result)
81+
}
82+
83+
func TestMergeHooks_BaseNil(t *testing.T) {
84+
cli := &latest.HooksConfig{
85+
SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo cli"}},
86+
}
87+
result := MergeHooks(nil, cli)
88+
assert.Equal(t, cli, result)
89+
}
90+
91+
func TestMergeHooks_BothNonNil(t *testing.T) {
92+
base := &latest.HooksConfig{
93+
SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo base"}},
94+
PreToolUse: []latest.HookMatcherConfig{{
95+
Matcher: "shell",
96+
Hooks: []latest.HookDefinition{{Type: "command", Command: "echo base-pre"}},
97+
}},
98+
}
99+
cli := &latest.HooksConfig{
100+
SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo cli"}},
101+
PreToolUse: []latest.HookMatcherConfig{{
102+
Hooks: []latest.HookDefinition{{Type: "command", Command: "echo cli-pre"}},
103+
}},
104+
}
105+
106+
result := MergeHooks(base, cli)
107+
require.NotNil(t, result)
108+
109+
// Session start hooks should be merged
110+
require.Len(t, result.SessionStart, 2)
111+
assert.Equal(t, "echo base", result.SessionStart[0].Command)
112+
assert.Equal(t, "echo cli", result.SessionStart[1].Command)
113+
114+
// Pre tool use matchers should be merged
115+
require.Len(t, result.PreToolUse, 2)
116+
assert.Equal(t, "shell", result.PreToolUse[0].Matcher)
117+
assert.Equal(t, "echo base-pre", result.PreToolUse[0].Hooks[0].Command)
118+
assert.Empty(t, result.PreToolUse[1].Matcher)
119+
assert.Equal(t, "echo cli-pre", result.PreToolUse[1].Hooks[0].Command)
120+
}
121+
122+
func TestMergeHooks_DoesNotMutateOriginals(t *testing.T) {
123+
base := &latest.HooksConfig{
124+
SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo base"}},
125+
}
126+
cli := &latest.HooksConfig{
127+
SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo cli"}},
128+
}
129+
130+
result := MergeHooks(base, cli)
131+
132+
// Originals should not be mutated
133+
assert.Len(t, base.SessionStart, 1)
134+
assert.Len(t, cli.SessionStart, 1)
135+
assert.Len(t, result.SessionStart, 2)
136+
}
137+
138+
func TestRuntimeConfig_CLIHooks(t *testing.T) {
139+
rc := &RuntimeConfig{}
140+
assert.Nil(t, rc.CLIHooks())
141+
142+
rc.HookSessionStart = []string{"echo start"}
143+
hooks := rc.CLIHooks()
144+
require.NotNil(t, hooks)
145+
assert.Len(t, hooks.SessionStart, 1)
146+
assert.Equal(t, "echo start", hooks.SessionStart[0].Command)
147+
}
148+
149+
func TestRuntimeConfig_Clone_CopiesHooks(t *testing.T) {
150+
rc := &RuntimeConfig{}
151+
rc.HookPreToolUse = []string{"pre"}
152+
rc.HookPostToolUse = []string{"post"}
153+
rc.HookSessionStart = []string{"start"}
154+
rc.HookSessionEnd = []string{"end"}
155+
rc.HookOnUserInput = []string{"input"}
156+
157+
clone := rc.Clone()
158+
assert.Equal(t, rc.HookPreToolUse, clone.HookPreToolUse)
159+
assert.Equal(t, rc.HookPostToolUse, clone.HookPostToolUse)
160+
assert.Equal(t, rc.HookSessionStart, clone.HookSessionStart)
161+
assert.Equal(t, rc.HookSessionEnd, clone.HookSessionEnd)
162+
assert.Equal(t, rc.HookOnUserInput, clone.HookOnUserInput)
163+
164+
// Mutating clone should not affect original
165+
clone.HookPreToolUse[0] = "changed"
166+
assert.Equal(t, "pre", rc.HookPreToolUse[0])
167+
}

pkg/config/runtime.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ type Config struct {
2323
DefaultModel *latest.ModelConfig
2424
GlobalCodeMode bool
2525
WorkingDir string
26+
27+
// Hook overrides from CLI flags
28+
HookPreToolUse []string
29+
HookPostToolUse []string
30+
HookSessionStart []string
31+
HookSessionEnd []string
32+
HookOnUserInput []string
2633
}
2734

2835
func (runConfig *RuntimeConfig) Clone() *RuntimeConfig {
@@ -31,6 +38,11 @@ func (runConfig *RuntimeConfig) Clone() *RuntimeConfig {
3138
}
3239
clone.EnvFiles = slices.Clone(runConfig.EnvFiles)
3340
clone.DefaultModel = runConfig.DefaultModel.Clone()
41+
clone.HookPreToolUse = slices.Clone(runConfig.HookPreToolUse)
42+
clone.HookPostToolUse = slices.Clone(runConfig.HookPostToolUse)
43+
clone.HookSessionStart = slices.Clone(runConfig.HookSessionStart)
44+
clone.HookSessionEnd = slices.Clone(runConfig.HookSessionEnd)
45+
clone.HookOnUserInput = slices.Clone(runConfig.HookOnUserInput)
3446
return clone
3547
}
3648

0 commit comments

Comments
 (0)