detached e1c1e0e

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 {
183183
184184 // ---- print / json modes: require credentials, run single-shot ----
185185
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+
186248 func runPrintMode(ctx context.Context, args Args, version string) error {
187249 if args.NoYolo {
188250 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 {
191253 if err != nil {
192254 return err
193255 }
256+ extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
257+ defer stopExt()
258+
194259 ag := r.NewAgent()
260+ wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
195261 sess, _ := openOrCreateSession(args, r, ag, version)
196262 defer sess.Close()
197263
@@ -218,7 +284,11 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
218284 if err != nil {
219285 return err
220286 }
287+ extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
288+ defer stopExt()
289+
221290 ag := r.NewAgent()
291+ wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
222292 sess, _ := openOrCreateSession(args, r, ag, version)
223293 defer sess.Close()
224294
@@ -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 err254 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 err285 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 {
9797 decision core.ConfirmDecision
9898 }{
9999 {
100- label: "yes (run this call)",
100+ label: "yes (run this call)",
101101 decision: core.ConfirmDecision{Allow: true},
102102 },
103103 {
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)",
105105 decision: core.ConfirmDecision{Allow: true, RememberTool: true},
106106 },
107107 {
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)",
109109 decision: core.ConfirmDecision{Allow: true, RememberAll: true},
110110 },
111111 {
112- label: "no (refuse and let the model try something else)",
112+ label: "no (refuse and let the model try something else)",
113113 decision: core.ConfirmDecision{Allow: false, Reason: "user declined"},
114114 },
115115 }
@@ -97,19 +97,19 @@ var confirmOptions = []struct {
97 decision core.ConfirmDecision97 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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close