v0.0.43 e2f2092

cli.go RawHistoryFolder

1package agent
2
3import (
4	"bufio"
5	"context"
6	"encoding/json"
7	"fmt"
8	"io"
9	"os"
10	"strings"
11	"time"
12
13	"github.com/patriceckhart/zot/internal/agent/extensions"
14	"github.com/patriceckhart/zot/internal/agent/modes"
15	"github.com/patriceckhart/zot/internal/auth"
16	"github.com/patriceckhart/zot/internal/core"
17	"github.com/patriceckhart/zot/internal/extproto"
18	"github.com/patriceckhart/zot/internal/provider"
19	"github.com/patriceckhart/zot/internal/skills"
20	"github.com/patriceckhart/zot/internal/tui"
21)
22
23// interactiveExtHooks is a tiny adapter that lets the extension
24// manager call back into the Interactive instance built later in
25// runInteractive. The forward-declared *modes.Interactive is filled
26// in immediately after manager construction.
27type interactiveExtHooks struct {
28	ivPtr **modes.Interactive
29}
30
31func (h *interactiveExtHooks) iv() *modes.Interactive {
32	if h == nil || h.ivPtr == nil {
33		return nil
34	}
35	return *h.ivPtr
36}
37
38func (h *interactiveExtHooks) Notify(extName, level, message string) {
39	if iv := h.iv(); iv != nil {
40		iv.Notify(extName, level, message)
41	}
42}
43func (h *interactiveExtHooks) Submit(text string) {
44	if iv := h.iv(); iv != nil {
45		iv.Submit(text)
46	}
47}
48func (h *interactiveExtHooks) Insert(text string) {
49	if iv := h.iv(); iv != nil {
50		iv.Insert(text)
51	}
52}
53func (h *interactiveExtHooks) Display(extName, text string) {
54	if iv := h.iv(); iv != nil {
55		iv.Display(extName, text)
56	}
57}
58
59// extToolAdapter bridges *extensions.Manager to the
60// ExtensionToolSource interface declared in build.go (kept narrow to
61// avoid a build->extensions import cycle). One adapter instance per
62// run; used at every Resolve point so re-built agents pick up the
63// same set of extension tools.
64type extToolAdapter struct {
65	mgr *extensions.Manager
66}
67
68func (a *extToolAdapter) Tools() []ExtensionToolInfo {
69	infos := a.mgr.Tools()
70	out := make([]ExtensionToolInfo, len(infos))
71	for i, t := range infos {
72		out[i] = ExtensionToolInfo{
73			Extension:   t.Extension,
74			Name:        t.Name,
75			Description: t.Description,
76			Schema:      t.Schema,
77		}
78	}
79	return out
80}
81
82func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
83	return extensions.NewTool(a.mgr, extensions.ToolInfo{
84		Extension:   info.Extension,
85		Name:        info.Name,
86		Description: info.Description,
87		Schema:      info.Schema,
88	})
89}
90
91// fanoutAgentEvent translates a core.AgentEvent into the wire-format
92// EventFromHost and pushes it through the extension manager. Only
93// the events that have a clear extension-facing meaning are
94// forwarded; internal-only ones (text_delta, tool_progress) are
95// dropped to keep the per-extension stream sane.
96func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
97	if mgr == nil {
98		return
99	}
100	switch e := ev.(type) {
101	case core.EvTurnStart:
102		mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
103	case core.EvToolCall:
104		mgr.EmitEvent(extproto.EventFromHost{
105			Event: "tool_call", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args,
106		})
107	case core.EvAssistantMessage:
108		// Concat the visible text portions of the message; binary
109		// blocks (tool_use, etc.) are skipped because subscribers
110		// usually want a string they can grep / display.
111		var text string
112		for _, c := range e.Message.Content {
113			if tb, ok := c.(provider.TextBlock); ok {
114				text += tb.Text
115			}
116		}
117		mgr.EmitEvent(extproto.EventFromHost{Event: "assistant_message", Text: text})
118	case core.EvTurnEnd:
119		ev := extproto.EventFromHost{Event: "turn_end", Stop: string(e.Stop)}
120		if e.Err != nil {
121			ev.Error = e.Err.Error()
122		}
123		mgr.EmitEvent(ev)
124	}
125}
126
127// Run is the top-level entrypoint for the zot binary.
128func Run(rawArgs []string, version string) error {
129	// Subcommand router: `zot bot ...` is handled separately so the
130	// generic flag parser doesn't reject "bot" as a positional arg.
131	if handled, err := runBotCommand(rawArgs, version); handled {
132		return err
133	}
134	if handled, err := runExtCommand(rawArgs); handled {
135		return err
136	}
137	// `zot rpc` is shorthand for `zot --rpc` so third-party apps can
138	// spawn the binary with a clean argv. Strip the leading 'rpc'
139	// token and let the rest flow through the normal arg parser.
140	if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
141		rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
142	}
143
144	args, err := ParseArgs(rawArgs)
145	if err != nil {
146		PrintHelp(version)
147		return err
148	}
149	if args.Help {
150		PrintHelp(version)
151		return nil
152	}
153	if args.Version {
154		fmt.Println("zot", version)
155		return nil
156	}
157	// Model catalog: load any cached discovery data before we inspect
158	// the model list (list-models, print/json, interactive).
159	LoadCachedModels()
160
161	if args.ListModels {
162		printModels()
163		return nil
164	}
165
166	ctx := context.Background()
167
168	// Kick an async refresh of the live model catalog. The first run of
169	// zot hits the network; subsequent runs within CacheTTL do nothing.
170	RefreshModelsAsync()
171
172	switch args.Mode {
173	case ModePrint:
174		return runPrintMode(ctx, args, version)
175	case ModeJSON:
176		return runJSONMode(ctx, args, version)
177	case ModeRPC:
178		return runRPCMode(ctx, args, version)
179	default:
180		return runInteractive(ctx, args, version)
181	}
182}
183
184// ---- print / json modes: require credentials, run single-shot ----
185
186func runPrintMode(ctx context.Context, args Args, version string) error {
187	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")
189	}
190	r, err := Resolve(args, true)
191	if err != nil {
192		return err
193	}
194	ag := r.NewAgent()
195	sess, _ := openOrCreateSession(args, r, ag, version)
196	defer sess.Close()
197
198	prompt := args.Prompt
199	if prompt == "" {
200		piped, _ := readAllStdin()
201		prompt = strings.TrimSpace(piped)
202	}
203	if prompt == "" {
204		return fmt.Errorf("print mode requires a prompt (arg or stdin)")
205	}
206
207	start := len(ag.Messages())
208	err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
209	WriteNewTranscript(ag, sess, start)
210	return err
211}
212
213func runJSONMode(ctx context.Context, args Args, version string) error {
214	if args.NoYolo {
215		fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
216	}
217	r, err := Resolve(args, true)
218	if err != nil {
219		return err
220	}
221	ag := r.NewAgent()
222	sess, _ := openOrCreateSession(args, r, ag, version)
223	defer sess.Close()
224
225	prompt := args.Prompt
226	if prompt == "" {
227		piped, _ := readAllStdin()
228		prompt = strings.TrimSpace(piped)
229	}
230	if prompt == "" {
231		return fmt.Errorf("json mode requires a prompt (arg or stdin)")
232	}
233
234	start := len(ag.Messages())
235	err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
236	WriteNewTranscript(ag, sess, start)
237	return err
238}
239
240// ---- interactive mode: opens the TUI even without credentials ----
241
242func runInteractive(ctx context.Context, args Args, version string) error {
243	// Resolve WITHOUT requiring credentials.
244	r, err := Resolve(args, false)
245	if err != nil {
246		return err
247	}
248
249	authStore := AuthStoreFor()
250	mgr := auth.NewManager(authStore)
251	defer mgr.Close()
252
253	// Keep the sandbox pointer stable across agent rebuilds (login / model
254	// switch). The Interactive UI toggles the lock via this pointer, and
255	// rebuilt tool instances must share the same one so the lock sticks.
256	sharedSandbox := r.Sandbox
257
258	// Build the extension manager BEFORE the agent so we can fold
259	// extension-defined tools into the registry. Forward-declare iv so
260	// the host hooks adapter can dereference it after construction.
261	var iv *modes.Interactive
262	extHooks := &interactiveExtHooks{ivPtr: &iv}
263	extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
264	// --ext paths first so they win against installed extensions of
265	// the same name (loadOne's first-write-wins semantics).
266	for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
267		fmt.Fprintln(os.Stderr, "extension load:", e)
268	}
269	// --no-ext skips the global + project-local discovery scan;
270	// explicit --ext paths above are still honoured so you can run
271	// "only this extension" with --no-ext --ext ./x.
272	if !args.NoExt {
273		for _, e := range extMgr.Discover(ctx) {
274			fmt.Fprintln(os.Stderr, "extension load:", e)
275		}
276	}
277	// Wait briefly for extensions to flush their initial register_tool
278	// frames before we build the agent's tool registry. Half a second
279	// is plenty for any extension that's actually well-behaved; ones
280	// that don't send a ready frame eat the full grace and proceed.
281	// 3s is the per-extension grace period for the ready frame.
282	// Native binaries are instant; runtimes like `npx tsx` take ~1.5s
283	// from cold cache. The wait is tight only for extensions that
284	// haven't sent ready by then; ones that signalled earlier release
285	// the wait immediately.
286	extMgr.WaitForReady(3 * time.Second)
287	defer extMgr.Stop(2 * time.Second)
288
289	extToolAdapter := &extToolAdapter{mgr: extMgr}
290	r.MergeExtensionTools(extToolAdapter)
291
292	// Confirmation gate: when --no-yolo is on, the agent must ask
293	// the user before every tool call. In interactive mode the TUI
294	// provides the Confirmer; in print/json/rpc modes there's no
295	// way to prompt, so the gate is constructed with a nil inner
296	// which auto-refuses every call with a helpful reason.
297	var confirmGate *core.ConfirmGate
298	if args.NoYolo {
299		confirmGate = core.NewConfirmGate(nil) // set below for interactive
300	}
301
302	// Capture current args in a closure so BuildAgent can re-resolve
303	// after a successful login (picks up the newly stored credential).
304	wireAgentExt := func(a *core.Agent) *core.Agent {
305		if a == nil {
306			return a
307		}
308		a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
309			// Confirm gate runs FIRST: if the user refused, we don't
310			// waste extension-intercept time or let guards see the call.
311			if confirmGate != nil {
312				ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
313				if !ok {
314					return false, reason, nil
315				}
316			}
317			r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
318			if r.Block {
319				return false, r.Reason, nil
320			}
321			return true, "", r.ModifiedArgs
322		}
323		a.BeforeTurn = func(step int) (bool, string) {
324			r := extMgr.InterceptTurnStart(ctx, step)
325			return !r.Block, r.Reason
326		}
327		a.BeforeAssistantMessage = func(text string) (bool, string, string) {
328			r := extMgr.InterceptAssistantMessage(ctx, text)
329			if r.Block {
330				return false, r.Reason, ""
331			}
332			return true, "", r.ReplaceText
333		}
334		a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
335		return a
336	}
337
338	buildAgent := func() (*core.Agent, string, string, error) {
339		resolved, err := Resolve(args, true)
340		if err != nil {
341			return nil, "", "", err
342		}
343		resolved.UseSandbox(sharedSandbox)
344		resolved.MergeExtensionTools(extToolAdapter)
345		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
346	}
347
348	// Rebuild agent with an explicit provider/model override.
349	buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
350		next := args
351		if providerOverride != "" {
352			next.Provider = providerOverride
353		}
354		if modelOverride != "" {
355			next.Model = modelOverride
356		}
357		resolved, err := Resolve(next, true)
358		if err != nil {
359			return nil, "", "", err
360		}
361		resolved.UseSandbox(sharedSandbox)
362		resolved.MergeExtensionTools(extToolAdapter)
363		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
364	}
365
366	var ag *core.Agent
367	if r.HasCredential() {
368		ag = wireAgentExt(r.NewAgent())
369	}
370
371	// /reload-ext callback: after the manager has respawned every
372	// extension, re-resolve the tool registry (built-ins + freshly-
373	// registered extension tools) and swap it onto the current
374	// agent in-place. The current agent may have been replaced by a
375	// /model swap since spawn, so re-read the live `ag` on each
376	// invocation.
377	extMgr.SetOnReload(func() {
378		current := ag
379		if current == nil {
380			return
381		}
382		resolved, err := Resolve(args, true)
383		if err != nil {
384			return
385		}
386		resolved.UseSandbox(sharedSandbox)
387		resolved.MergeExtensionTools(extToolAdapter)
388		current.SetTools(resolved.ToolRegistry)
389	})
390
391	// Fire session_start once we know the manager's running.
392	extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
393
394	var sess *core.Session
395	var sessBaselineMsgs int // messages already on disk when current session opened
396	if !args.NoSess && ag != nil {
397		sess, _ = openOrCreateSession(args, r, ag, version)
398		if ag != nil {
399			sessBaselineMsgs = len(ag.Messages())
400		}
401	}
402	defer func() {
403		if sess != nil {
404			sess.Close()
405		}
406	}()
407
408	// loadSession replaces the current session with the one at path and
409	// hands its messages to the agent. Used by the /sessions picker.
410	loadSession := func(path string) error {
411		currentAg := ag // captured
412		if currentAg == nil {
413			return fmt.Errorf("no agent running; log in first")
414		}
415		newSess, msgs, err := core.OpenSession(path)
416		if err != nil {
417			return err
418		}
419		// Flush any unsaved messages to the old session before swapping.
420		if sess != nil {
421			WriteNewTranscript(currentAg, sess, sessBaselineMsgs)
422			_ = sess.Close()
423		}
424		sess = newSess
425		currentAg.SetMessages(msgs)
426		sessBaselineMsgs = len(msgs)
427		return nil
428	}
429
430	term := tui.NewProcTerm()
431
432	// Kick off the async update check so the banner can appear when the
433	// http response eventually arrives (usually <1s on cached DNS). Map
434	// agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
435	updateCh := make(chan modes.UpdateInfo, 1)
436	go func() {
437		defer close(updateCh)
438		src := <-CheckForUpdateAsync(ZotHome(), version)
439		updateCh <- modes.UpdateInfo{
440			Current:   src.Current,
441			Latest:    src.Latest,
442			Available: src.Available,
443			URL:       src.URL,
444		}
445	}()
446
447	// Changelog: when the running version differs from the last
448	// version whose release notes the user dismissed, fetch the
449	// release body from GitHub and have the TUI show it once. On
450	// first-ever launch (no prior LastChangelogShown), seed the
451	// stored version silently — don't dump release notes at someone
452	// who just installed.
453	changelogCh := make(chan modes.ChangelogPayload, 1)
454	go func() {
455		defer close(changelogCh)
456		cfg, _ := LoadConfig()
457		if cfg.LastChangelogShown == "" {
458			SeedChangelogVersion(version)
459			return
460		}
461		if !ShouldShowChangelog(version, cfg) {
462			return
463		}
464		info := <-FetchChangelogAsync(version)
465		if info.Body == "" {
466			return
467		}
468		changelogCh <- modes.ChangelogPayload{
469			Version: info.Version,
470			Body:    info.Body,
471			URL:     info.URL,
472		}
473	}()
474
475	iv = modes.NewInteractive(modes.InteractiveConfig{
476		Terminal:       term,
477		Theme:          tui.Dark,
478		Model:          r.Model,
479		Provider:       r.Provider,
480		AuthMethod:     r.AuthMethod,
481		BaseURL:        r.BaseURL,
482		Reasoning:      r.Reasoning,
483		SystemPrompt:   r.SystemPrompt,
484		Tools:          r.ToolRegistry,
485		MaxSteps:       r.MaxSteps,
486		CWD:            r.CWD,
487		ZotHome:        ZotHome(),
488		Version:        version,
489		UpdateInfoChan: updateCh,
490		Sandbox:        sharedSandbox,
491		Agent:          ag,
492		InitialInput:   args.Prompt,
493		AuthManager:    mgr,
494		BuildAgent:     buildAgent,
495		BuildAgentFor:  buildAgentFor,
496		LoadSession:    loadSession,
497		Extensions:     extMgr,
498		ChangelogChan:  changelogCh,
499		OnChangelogDismiss: func() {
500			_ = MarkChangelogShown(version)
501		},
502		SkillSnapshot: func() []*skills.Skill {
503			if args.NoSkill {
504				// --no-skill: nothing for the picker to show.
505				return nil
506			}
507			// Re-discover so the picker reflects edits made during
508			// the session. Cheap; SKILL.md files are small. Filter
509			// out built-in skills — they're hidden from user-facing
510			// surfaces because they're implementation detail; the
511			// model still sees them through the system-prompt
512			// manifest and the skill tool. User skills only appear
513			// when --with-skills is set; without it the picker shows
514			// nothing but the model still has the built-ins.
515			userHome, _ := os.UserHomeDir()
516			list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
517			return skills.VisibleSkills(list)
518		},
519		NoYolo:      args.NoYolo,
520		ConfirmGate: confirmGate,
521		PersistModel: func(providerName, model string) {
522			// Update config.json so next launch uses the same pick.
523			cfg, _ := LoadConfig()
524			cfg.Provider = providerName
525			cfg.Model = model
526			_ = SaveConfig(cfg)
527			// Update the active session's meta so resume picks this up.
528			if sess != nil {
529				_ = sess.UpdateModel(providerName, model)
530			}
531		},
532	})
533
534	// Bind the interactive TUI as the Confirmer. We deferred this
535	// until now because the gate is constructed before the TUI
536	// (the BeforeToolExecute closure captures it). SetConfirmer
537	// is mutex-guarded on the gate so this is safe.
538	if confirmGate != nil {
539		confirmGate.SetConfirmer(iv)
540	}
541
542	runErr := iv.Run(ctx)
543
544	// Flush final transcript to session (only if we had / ended up with an agent).
545	if finalAg := iv.Agent(); finalAg != nil && sess != nil {
546		WriteNewTranscript(finalAg, sess, sessBaselineMsgs)
547	}
548	return runErr
549}
550
551// openOrCreateSession returns a session for the run. sess may be nil
552// with a nil error if session persistence is disabled.
553func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
554	if args.NoSess {
555		return nil, nil
556	}
557	// Sweep meta-only files left over from older zot versions (and from
558	// any session that crashed before its first AppendMessage). Cheap;
559	// reads the first few bytes of each file in the cwd's session dir.
560	core.PruneEmptySessions(ZotHome(), args.CWD)
561	var (
562		s    *core.Session
563		msgs []provider.Message
564		err  error
565	)
566	switch {
567	case args.Session != "":
568		s, msgs, err = core.OpenSession(args.Session)
569	case args.Continue:
570		latest := core.LatestSession(ZotHome(), args.CWD)
571		if latest != "" {
572			s, msgs, err = core.OpenSession(latest)
573		}
574	case args.Resume:
575		picked, perr := pickSession(args.CWD)
576		if perr != nil {
577			return nil, perr
578		}
579		if picked != "" {
580			s, msgs, err = core.OpenSession(picked)
581		}
582	}
583	if err != nil {
584		return nil, err
585	}
586	if s != nil {
587		ag.SetMessages(msgs)
588		return s, nil
589	}
590	return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
591}
592
593func pickSession(cwd string) (string, error) {
594	files := core.ListSessions(ZotHome(), cwd)
595	if len(files) == 0 {
596		fmt.Fprintln(os.Stderr, "no sessions for", cwd)
597		return "", nil
598	}
599	for i, f := range files {
600		fmt.Fprintf(os.Stderr, "  %2d) %s\n", i+1, f)
601	}
602	fmt.Fprint(os.Stderr, "pick #: ")
603	rd := bufio.NewReader(os.Stdin)
604	line, _ := rd.ReadString('\n')
605	line = strings.TrimSpace(line)
606	var n int
607	if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
608		return "", fmt.Errorf("invalid selection")
609	}
610	return files[n-1], nil
611}
612
613// WriteNewTranscript appends only messages after index `from` from the
614// agent's transcript to the session.
615func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
616	if sess == nil || ag == nil {
617		return
618	}
619	msgs := ag.Messages()
620	for i := from; i < len(msgs); i++ {
621		_ = sess.AppendMessage(msgs[i])
622	}
623	cum := ag.Cost()
624	_ = sess.AppendUsage(cum, cum)
625}
626
627func readAllStdin() (string, error) {
628	fi, err := os.Stdin.Stat()
629	if err != nil {
630		return "", err
631	}
632	if (fi.Mode() & os.ModeCharDevice) != 0 {
633		return "", nil
634	}
635	b, err := io.ReadAll(os.Stdin)
636	return string(b), err
637}
638
639func printModels() {
640	fmt.Println("provider   model id                       context  max-out  reasoning  source        name")
641	for _, m := range provider.Active() {
642		reason := " "
643		if m.Reasoning {
644			reason = "✓"
645		}
646		source := m.Source
647		if source == "" {
648			source = "catalog"
649		}
650		if m.Speculative {
651			source = "speculative"
652		}
653		fmt.Printf("%-10s %-30s %8d %8d     %s        %-11s   %s\n",
654			m.Provider, m.ID, m.ContextWindow, m.MaxOutput, reason, source, m.DisplayName)
655	}
656}

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