fix(cli): load extensions in print and json modes too
runPrintMode and runJSONMode never constructed the extension
manager, so --ext and installed extensions were silently ignored in
non-interactive flows. Only the interactive TUI and rpc mode were
loading them. The symptom: 'zot -e ~/path/to/weather -p "..."'
would spawn nothing, no log, and the model had no weather tool.
Added shared helpers used by both print and json:
- setupNonInteractiveExtensions: same --ext + Discover sequence
as interactive, plus the session_start event and MergeExtensionTools.
- wireNonInteractiveAgentExtHooks: same BeforeToolExecute /
BeforeTurn / BeforeAssistantMessage / OnEvent plumbing so guard
extensions, event interceptors, and extension-contributed tools
work identically in one-shot runs.
- nonInteractiveExtHooks: minimal HostHooks impl. Notify goes to
stderr so extensions can still log; Submit / Insert / Display
are no-ops because there's no TUI to steer.
Verified end-to-end:
zot -e ~/Developer/zot/examples/extensions/weather \
-p 'use the weather tool for Berlin'
-> 'Berlin: 16°C, fog. (deterministic demo)'
Before the fix, the same command silently fell back to bash/curl
suggestions because no tool was ever registered.- commit
- e1c1e0e609773383c6d4f234cfab0d46eadbb738
- author
- patriceckhart · · 2026-04-19 18:00 UTC
- parent
- 1be3f85
- signature
- unsigned
- refs
- tag: v0.0.48
2 files · +74 -4
M internal/agent/cli.go +70 -0
| @@ -183,6 +183,68 @@ func Run(rawArgs []string, version string) error { | |||
| 183 | 183 | ||
| 184 | 184 | // ---- print / json modes: require credentials, run single-shot ---- | |
| 185 | 185 | ||
| 186 | + | // nonInteractiveExtHooks is the HostHooks impl used by print / json | |
| 187 | + | // modes. They have no TUI, so notify / display go to stderr and | |
| 188 | + | // submit / insert are no-ops (the extension can't steer a | |
| 189 | + | // single-shot run once it's in flight anyway). | |
| 190 | + | type nonInteractiveExtHooks struct{} | |
| 191 | + | ||
| 192 | + | func (nonInteractiveExtHooks) Notify(ext, level, message string) { | |
| 193 | + | fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message) | |
| 194 | + | } | |
| 195 | + | func (nonInteractiveExtHooks) Submit(string) {} | |
| 196 | + | func (nonInteractiveExtHooks) Insert(string) {} | |
| 197 | + | func (nonInteractiveExtHooks) Display(string, string) {} | |
| 198 | + | ||
| 199 | + | // setupNonInteractiveExtensions loads --ext paths and (unless | |
| 200 | + | // --no-ext) runs discovery. Returns the manager so the caller can | |
| 201 | + | // wire tools into the resolved registry, and a cleanup closure to | |
| 202 | + | // defer. Mirrors the interactive-mode setup minus the TUI hooks. | |
| 203 | + | func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) { | |
| 204 | + | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{}) | |
| 205 | + | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | |
| 206 | + | fmt.Fprintln(os.Stderr, "extension load:", e) | |
| 207 | + | } | |
| 208 | + | if !args.NoExt { | |
| 209 | + | for _, e := range extMgr.Discover(ctx) { | |
| 210 | + | fmt.Fprintln(os.Stderr, "extension load:", e) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | extMgr.WaitForReady(3 * time.Second) | |
| 214 | + | r.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | |
| 215 | + | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | |
| 216 | + | return extMgr, func() { extMgr.Stop(2 * time.Second) } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute | |
| 220 | + | // / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the | |
| 221 | + | // interactive path wires up, so extensions get their normal | |
| 222 | + | // event-intercept surface in print / json / rpc flows too. | |
| 223 | + | func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr *extensions.Manager) { | |
| 224 | + | if ag == nil || extMgr == nil { | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 228 | + | res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | |
| 229 | + | if res.Block { | |
| 230 | + | return false, res.Reason, nil | |
| 231 | + | } | |
| 232 | + | return true, "", res.ModifiedArgs | |
| 233 | + | } | |
| 234 | + | ag.BeforeTurn = func(step int) (bool, string) { | |
| 235 | + | res := extMgr.InterceptTurnStart(ctx, step) | |
| 236 | + | return !res.Block, res.Reason | |
| 237 | + | } | |
| 238 | + | ag.BeforeAssistantMessage = func(text string) (bool, string, string) { | |
| 239 | + | res := extMgr.InterceptAssistantMessage(ctx, text) | |
| 240 | + | if res.Block { | |
| 241 | + | return false, res.Reason, "" | |
| 242 | + | } | |
| 243 | + | return true, "", res.ReplaceText | |
| 244 | + | } | |
| 245 | + | ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) } | |
| 246 | + | } | |
| 247 | + | ||
| 186 | 248 | func runPrintMode(ctx context.Context, args Args, version string) error { | |
| 187 | 249 | if args.NoYolo { | |
| 188 | 250 | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation") | |
| @@ -191,7 +253,11 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||
| 191 | 253 | if err != nil { | |
| 192 | 254 | return err | |
| 193 | 255 | } | |
| 256 | + | extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version) | |
| 257 | + | defer stopExt() | |
| 258 | + | ||
| 194 | 259 | ag := r.NewAgent() | |
| 260 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 195 | 261 | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 196 | 262 | defer sess.Close() | |
| 197 | 263 | ||
| @@ -218,7 +284,11 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||
| 218 | 284 | if err != nil { | |
| 219 | 285 | return err | |
| 220 | 286 | } | |
| 287 | + | extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version) | |
| 288 | + | defer stopExt() | |
| 289 | + | ||
| 221 | 290 | ag := r.NewAgent() | |
| 291 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 222 | 292 | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 223 | 293 | defer sess.Close() | |
| 224 | 294 | ||
| @@ -183,6 +183,68 @@ func Run(rawArgs []string, version string) error { | |||||
| 183 | 183 | ||||
| 184 | // ---- print / json modes: require credentials, run single-shot ---- | 184 | // ---- print / json modes: require credentials, run single-shot ---- | ||
| 185 | 185 | ||||
| 186 | + | // nonInteractiveExtHooks is the HostHooks impl used by print / json | |||
| 187 | + | // modes. They have no TUI, so notify / display go to stderr and | |||
| 188 | + | // submit / insert are no-ops (the extension can't steer a | |||
| 189 | + | // single-shot run once it's in flight anyway). | |||
| 190 | + | type nonInteractiveExtHooks struct{} | |||
| 191 | + | ||||
| 192 | + | func (nonInteractiveExtHooks) Notify(ext, level, message string) { | |||
| 193 | + | fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message) | |||
| 194 | + | } | |||
| 195 | + | func (nonInteractiveExtHooks) Submit(string) {} | |||
| 196 | + | func (nonInteractiveExtHooks) Insert(string) {} | |||
| 197 | + | func (nonInteractiveExtHooks) Display(string, string) {} | |||
| 198 | + | ||||
| 199 | + | // setupNonInteractiveExtensions loads --ext paths and (unless | |||
| 200 | + | // --no-ext) runs discovery. Returns the manager so the caller can | |||
| 201 | + | // wire tools into the resolved registry, and a cleanup closure to | |||
| 202 | + | // defer. Mirrors the interactive-mode setup minus the TUI hooks. | |||
| 203 | + | func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) { | |||
| 204 | + | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{}) | |||
| 205 | + | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | |||
| 206 | + | fmt.Fprintln(os.Stderr, "extension load:", e) | |||
| 207 | + | } | |||
| 208 | + | if !args.NoExt { | |||
| 209 | + | for _, e := range extMgr.Discover(ctx) { | |||
| 210 | + | fmt.Fprintln(os.Stderr, "extension load:", e) | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | extMgr.WaitForReady(3 * time.Second) | |||
| 214 | + | r.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | |||
| 215 | + | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | |||
| 216 | + | return extMgr, func() { extMgr.Stop(2 * time.Second) } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute | |||
| 220 | + | // / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the | |||
| 221 | + | // interactive path wires up, so extensions get their normal | |||
| 222 | + | // event-intercept surface in print / json / rpc flows too. | |||
| 223 | + | func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr *extensions.Manager) { | |||
| 224 | + | if ag == nil || extMgr == nil { | |||
| 225 | + | return | |||
| 226 | + | } | |||
| 227 | + | ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |||
| 228 | + | res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | |||
| 229 | + | if res.Block { | |||
| 230 | + | return false, res.Reason, nil | |||
| 231 | + | } | |||
| 232 | + | return true, "", res.ModifiedArgs | |||
| 233 | + | } | |||
| 234 | + | ag.BeforeTurn = func(step int) (bool, string) { | |||
| 235 | + | res := extMgr.InterceptTurnStart(ctx, step) | |||
| 236 | + | return !res.Block, res.Reason | |||
| 237 | + | } | |||
| 238 | + | ag.BeforeAssistantMessage = func(text string) (bool, string, string) { | |||
| 239 | + | res := extMgr.InterceptAssistantMessage(ctx, text) | |||
| 240 | + | if res.Block { | |||
| 241 | + | return false, res.Reason, "" | |||
| 242 | + | } | |||
| 243 | + | return true, "", res.ReplaceText | |||
| 244 | + | } | |||
| 245 | + | ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) } | |||
| 246 | + | } | |||
| 247 | + | ||||
| 186 | func runPrintMode(ctx context.Context, args Args, version string) error { | 248 | func runPrintMode(ctx context.Context, args Args, version string) error { | ||
| 187 | if args.NoYolo { | 249 | if args.NoYolo { | ||
| 188 | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation") | 250 | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation") | ||
| @@ -191,7 +253,11 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||||
| 191 | if err != nil { | 253 | if err != nil { | ||
| 192 | return err | 254 | return err | ||
| 193 | } | 255 | } | ||
| 256 | + | extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version) | |||
| 257 | + | defer stopExt() | |||
| 258 | + | ||||
| 194 | ag := r.NewAgent() | 259 | ag := r.NewAgent() | ||
| 260 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |||
| 195 | sess, _ := openOrCreateSession(args, r, ag, version) | 261 | sess, _ := openOrCreateSession(args, r, ag, version) | ||
| 196 | defer sess.Close() | 262 | defer sess.Close() | ||
| 197 | 263 | ||||
| @@ -218,7 +284,11 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||||
| 218 | if err != nil { | 284 | if err != nil { | ||
| 219 | return err | 285 | return err | ||
| 220 | } | 286 | } | ||
| 287 | + | extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version) | |||
| 288 | + | defer stopExt() | |||
| 289 | + | ||||
| 221 | ag := r.NewAgent() | 290 | ag := r.NewAgent() | ||
| 291 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |||
| 222 | sess, _ := openOrCreateSession(args, r, ag, version) | 292 | sess, _ := openOrCreateSession(args, r, ag, version) | ||
| 223 | defer sess.Close() | 293 | defer sess.Close() | ||
| 224 | 294 | ||||
M internal/agent/modes/confirm_dialog.go +4 -4
| @@ -97,19 +97,19 @@ var confirmOptions = []struct { | |||
| 97 | 97 | decision core.ConfirmDecision | |
| 98 | 98 | }{ | |
| 99 | 99 | { | |
| 100 | - | label: "yes (run this call)", | |
| 100 | + | label: "yes (run this call)", | |
| 101 | 101 | decision: core.ConfirmDecision{Allow: true}, | |
| 102 | 102 | }, | |
| 103 | 103 | { | |
| 104 | - | label: "yes, always this tool (skip prompts for this tool for the rest of the session)", | |
| 104 | + | label: "yes, always this tool (skip prompts for this tool for the rest of the session)", | |
| 105 | 105 | decision: core.ConfirmDecision{Allow: true, RememberTool: true}, | |
| 106 | 106 | }, | |
| 107 | 107 | { | |
| 108 | - | label: "yes, always (skip all prompts for the rest of the session)", | |
| 108 | + | label: "yes, always (skip all prompts for the rest of the session)", | |
| 109 | 109 | decision: core.ConfirmDecision{Allow: true, RememberAll: true}, | |
| 110 | 110 | }, | |
| 111 | 111 | { | |
| 112 | - | label: "no (refuse and let the model try something else)", | |
| 112 | + | label: "no (refuse and let the model try something else)", | |
| 113 | 113 | decision: core.ConfirmDecision{Allow: false, Reason: "user declined"}, | |
| 114 | 114 | }, | |
| 115 | 115 | } | |
| @@ -97,19 +97,19 @@ var confirmOptions = []struct { | |||||
| 97 | decision core.ConfirmDecision | 97 | decision core.ConfirmDecision | ||
| 98 | }{ | 98 | }{ | ||
| 99 | { | 99 | { | ||
| 100 | - | label: "yes (run this call)", | 100 | + | label: "yes (run this call)", |
| 101 | decision: core.ConfirmDecision{Allow: true}, | 101 | decision: core.ConfirmDecision{Allow: true}, | ||
| 102 | }, | 102 | }, | ||
| 103 | { | 103 | { | ||
| 104 | - | label: "yes, always this tool (skip prompts for this tool for the rest of the session)", | 104 | + | label: "yes, always this tool (skip prompts for this tool for the rest of the session)", |
| 105 | decision: core.ConfirmDecision{Allow: true, RememberTool: true}, | 105 | decision: core.ConfirmDecision{Allow: true, RememberTool: true}, | ||
| 106 | }, | 106 | }, | ||
| 107 | { | 107 | { | ||
| 108 | - | label: "yes, always (skip all prompts for the rest of the session)", | 108 | + | label: "yes, always (skip all prompts for the rest of the session)", |
| 109 | decision: core.ConfirmDecision{Allow: true, RememberAll: true}, | 109 | decision: core.ConfirmDecision{Allow: true, RememberAll: true}, | ||
| 110 | }, | 110 | }, | ||
| 111 | { | 111 | { | ||
| 112 | - | label: "no (refuse and let the model try something else)", | 112 | + | label: "no (refuse and let the model try something else)", |
| 113 | decision: core.ConfirmDecision{Allow: false, Reason: "user declined"}, | 113 | decision: core.ConfirmDecision{Allow: false, Reason: "user declined"}, | ||
| 114 | }, | 114 | }, | ||
| 115 | } | 115 | } | ||