v0.0.27 e9b466a

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	// --ext paths first so they win against installed extensions of
258	// the same name (loadOne's first-write-wins semantics).
259	for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
260		fmt.Fprintln(os.Stderr, "extension load:", e)
261	}
262	discoveryErrs := extMgr.Discover(ctx)
263	for _, e := range discoveryErrs {
264		fmt.Fprintln(os.Stderr, "extension load:", e)
265	}
266	// Wait briefly for extensions to flush their initial register_tool
267	// frames before we build the agent's tool registry. Half a second
268	// is plenty for any extension that's actually well-behaved; ones
269	// that don't send a ready frame eat the full grace and proceed.
270	// 3s is the per-extension grace period for the ready frame.
271	// Native binaries are instant; runtimes like `npx tsx` take ~1.5s
272	// from cold cache. The wait is tight only for extensions that
273	// haven't sent ready by then; ones that signalled earlier release
274	// the wait immediately.
275	extMgr.WaitForReady(3 * time.Second)
276	defer extMgr.Stop(2 * time.Second)
277
278	extToolAdapter := &extToolAdapter{mgr: extMgr}
279	r.MergeExtensionTools(extToolAdapter)
280
281	// Capture current args in a closure so BuildAgent can re-resolve
282	// after a successful login (picks up the newly stored credential).
283	wireAgentExt := func(a *core.Agent) *core.Agent {
284		if a == nil {
285			return a
286		}
287		a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string) {
288			return extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
289		}
290		a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
291		return a
292	}
293
294	buildAgent := func() (*core.Agent, string, string, error) {
295		resolved, err := Resolve(args, true)
296		if err != nil {
297			return nil, "", "", err
298		}
299		resolved.UseSandbox(sharedSandbox)
300		resolved.MergeExtensionTools(extToolAdapter)
301		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
302	}
303
304	// Rebuild agent with an explicit provider/model override.
305	buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
306		next := args
307		if providerOverride != "" {
308			next.Provider = providerOverride
309		}
310		if modelOverride != "" {
311			next.Model = modelOverride
312		}
313		resolved, err := Resolve(next, true)
314		if err != nil {
315			return nil, "", "", err
316		}
317		resolved.UseSandbox(sharedSandbox)
318		resolved.MergeExtensionTools(extToolAdapter)
319		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
320	}
321
322	var ag *core.Agent
323	if r.HasCredential() {
324		ag = wireAgentExt(r.NewAgent())
325	}
326
327	// Fire session_start once we know the manager's running.
328	extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
329
330	var sess *core.Session
331	var sessBaselineMsgs int // messages already on disk when current session opened
332	if !args.NoSess && ag != nil {
333		sess, _ = openOrCreateSession(args, r, ag, version)
334		if ag != nil {
335			sessBaselineMsgs = len(ag.Messages())
336		}
337	}
338	defer func() {
339		if sess != nil {
340			sess.Close()
341		}
342	}()
343
344	// loadSession replaces the current session with the one at path and
345	// hands its messages to the agent. Used by the /sessions picker.
346	loadSession := func(path string) error {
347		currentAg := ag // captured
348		if currentAg == nil {
349			return fmt.Errorf("no agent running; log in first")
350		}
351		newSess, msgs, err := core.OpenSession(path)
352		if err != nil {
353			return err
354		}
355		// Flush any unsaved messages to the old session before swapping.
356		if sess != nil {
357			WriteNewTranscript(currentAg, sess, sessBaselineMsgs)
358			_ = sess.Close()
359		}
360		sess = newSess
361		currentAg.SetMessages(msgs)
362		sessBaselineMsgs = len(msgs)
363		return nil
364	}
365
366	term := tui.NewProcTerm()
367
368	// Kick off the async update check so the banner can appear when the
369	// http response eventually arrives (usually <1s on cached DNS). Map
370	// agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
371	updateCh := make(chan modes.UpdateInfo, 1)
372	go func() {
373		defer close(updateCh)
374		src := <-CheckForUpdateAsync(ZotHome(), version)
375		updateCh <- modes.UpdateInfo{
376			Current:   src.Current,
377			Latest:    src.Latest,
378			Available: src.Available,
379			URL:       src.URL,
380		}
381	}()
382
383	iv = modes.NewInteractive(modes.InteractiveConfig{
384		Terminal:       term,
385		Theme:          tui.Dark,
386		Model:          r.Model,
387		Provider:       r.Provider,
388		AuthMethod:     r.AuthMethod,
389		BaseURL:        r.BaseURL,
390		Reasoning:      r.Reasoning,
391		SystemPrompt:   r.SystemPrompt,
392		Tools:          r.ToolRegistry,
393		MaxSteps:       r.MaxSteps,
394		CWD:            r.CWD,
395		ZotHome:        ZotHome(),
396		Version:        version,
397		UpdateInfoChan: updateCh,
398		Sandbox:        sharedSandbox,
399		Agent:          ag,
400		InitialInput:   args.Prompt,
401		AuthManager:    mgr,
402		BuildAgent:     buildAgent,
403		BuildAgentFor:  buildAgentFor,
404		LoadSession:    loadSession,
405		Extensions:     extMgr,
406		SkillSnapshot: func() []*skills.Skill {
407			// Re-discover so the picker reflects edits made during
408			// the session. Cheap; SKILL.md files are small.
409			userHome, _ := os.UserHomeDir()
410			list, _ := skills.Discover(ZotHome(), r.CWD, userHome)
411			return list
412		},
413		PersistModel: func(providerName, model string) {
414			// Update config.json so next launch uses the same pick.
415			cfg, _ := LoadConfig()
416			cfg.Provider = providerName
417			cfg.Model = model
418			_ = SaveConfig(cfg)
419			// Update the active session's meta so resume picks this up.
420			if sess != nil {
421				_ = sess.UpdateModel(providerName, model)
422			}
423		},
424	})
425
426	runErr := iv.Run(ctx)
427
428	// Flush final transcript to session (only if we had / ended up with an agent).
429	if finalAg := iv.Agent(); finalAg != nil && sess != nil {
430		WriteNewTranscript(finalAg, sess, sessBaselineMsgs)
431	}
432	return runErr
433}
434
435// openOrCreateSession returns a session for the run. sess may be nil
436// with a nil error if session persistence is disabled.
437func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
438	if args.NoSess {
439		return nil, nil
440	}
441	// Sweep meta-only files left over from older zot versions (and from
442	// any session that crashed before its first AppendMessage). Cheap;
443	// reads the first few bytes of each file in the cwd's session dir.
444	core.PruneEmptySessions(ZotHome(), args.CWD)
445	var (
446		s    *core.Session
447		msgs []provider.Message
448		err  error
449	)
450	switch {
451	case args.Session != "":
452		s, msgs, err = core.OpenSession(args.Session)
453	case args.Continue:
454		latest := core.LatestSession(ZotHome(), args.CWD)
455		if latest != "" {
456			s, msgs, err = core.OpenSession(latest)
457		}
458	case args.Resume:
459		picked, perr := pickSession(args.CWD)
460		if perr != nil {
461			return nil, perr
462		}
463		if picked != "" {
464			s, msgs, err = core.OpenSession(picked)
465		}
466	}
467	if err != nil {
468		return nil, err
469	}
470	if s != nil {
471		ag.SetMessages(msgs)
472		return s, nil
473	}
474	return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
475}
476
477func pickSession(cwd string) (string, error) {
478	files := core.ListSessions(ZotHome(), cwd)
479	if len(files) == 0 {
480		fmt.Fprintln(os.Stderr, "no sessions for", cwd)
481		return "", nil
482	}
483	for i, f := range files {
484		fmt.Fprintf(os.Stderr, "  %2d) %s\n", i+1, f)
485	}
486	fmt.Fprint(os.Stderr, "pick #: ")
487	rd := bufio.NewReader(os.Stdin)
488	line, _ := rd.ReadString('\n')
489	line = strings.TrimSpace(line)
490	var n int
491	if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
492		return "", fmt.Errorf("invalid selection")
493	}
494	return files[n-1], nil
495}
496
497// WriteNewTranscript appends only messages after index `from` from the
498// agent's transcript to the session.
499func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
500	if sess == nil || ag == nil {
501		return
502	}
503	msgs := ag.Messages()
504	for i := from; i < len(msgs); i++ {
505		_ = sess.AppendMessage(msgs[i])
506	}
507	cum := ag.Cost()
508	_ = sess.AppendUsage(cum, cum)
509}
510
511func readAllStdin() (string, error) {
512	fi, err := os.Stdin.Stat()
513	if err != nil {
514		return "", err
515	}
516	if (fi.Mode() & os.ModeCharDevice) != 0 {
517		return "", nil
518	}
519	b, err := io.ReadAll(os.Stdin)
520	return string(b), err
521}
522
523func printModels() {
524	fmt.Println("provider   model id                       context  max-out  reasoning  source        name")
525	for _, m := range provider.Active() {
526		reason := " "
527		if m.Reasoning {
528			reason = "✓"
529		}
530		source := m.Source
531		if source == "" {
532			source = "catalog"
533		}
534		if m.Speculative {
535			source = "speculative"
536		}
537		fmt.Printf("%-10s %-30s %8d %8d     %s        %-11s   %s\n",
538			m.Provider, m.ID, m.ContextWindow, m.MaxOutput, reason, source, m.DisplayName)
539	}
540}

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