v0.0.24 83ae236

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

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