v0.0.33 b9e7517

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

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