v0.0.25 5dbbcb9

cli.go RawHistoryFolder

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

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