v0.3.40 dad7bb4

botcmd.go RawHistoryFolder

1package agent
2
3import (
4	"bufio"
5	"context"
6	"errors"
7	"fmt"
8	"io"
9	"os"
10	"os/exec"
11	"os/signal"
12	"path/filepath"
13	"strings"
14	"syscall"
15	"time"
16
17	"github.com/patriceckhart/zot/packages/agent/modes/bot"
18	"github.com/patriceckhart/zot/packages/agent/modes/telegram"
19	"github.com/patriceckhart/zot/packages/core"
20	"github.com/patriceckhart/zot/packages/provider"
21	"golang.org/x/term"
22)
23
24// detachChild configures cmd to run in its own process group so tty
25// signals sent to the parent (SIGINT, SIGHUP on logout) don't also
26// reach the detached bot. Platform-specific: setsid on unix, a noop
27// on windows (Go's spawn path already detaches when no console is
28// inherited). See botcmd_unix.go and botcmd_windows.go.
29var detachChild func(cmd *exec.Cmd)
30
31// runBotCommand dispatches `zot <protocol>-bot ...` subcommands via
32// the botSpec registry. Returns true if rawArgs begins with a
33// recognised subcommand, false otherwise. The short alias "tg" (and
34// "mx" for matrix) are also accepted.
35func runBotCommand(rawArgs []string, version string) (handled bool, err error) {
36	if len(rawArgs) == 0 {
37		return false, nil
38	}
39	spec := specFor(rawArgs[0])
40	if spec == nil {
41		return false, nil
42	}
43	sub := ""
44	var tail []string
45	if len(rawArgs) >= 2 {
46		sub = rawArgs[1]
47		tail = rawArgs[2:]
48	}
49	switch sub {
50	case "", "help", "-h", "--help":
51		spec.printHelp()
52		return true, nil
53	case "setup":
54		return true, spec.setup(tail)
55	case "status":
56		return true, spec.status()
57	case "reset":
58		return true, spec.reset()
59	case "run":
60		return true, botRun(spec, tail, version)
61	case "start":
62		return true, botStart(spec, tail)
63	case "stop":
64		return true, botStop(spec)
65	case "logs":
66		return true, botLogs(spec, tail)
67	default:
68		spec.printHelp()
69		return true, fmt.Errorf("unknown %s subcommand %q", spec.subcommand, sub)
70	}
71}
72
73// botStart launches `zot <spec.subcommand> run` as a detached child
74// process, writes its pid to the spec's pid file, and returns
75// immediately. Stdout/stderr of the child are redirected to the spec's
76// log file.
77func botStart(spec *botSpec, rawTail []string) error {
78	// Refuse to start if another bot is already running.
79	if pid, alive, _ := bot.IsRunningAt(spec.pidPath(ZotHome())); alive {
80		return fmt.Errorf("%s is already running (pid %d); use `zot %s stop` first", spec.name, pid, spec.subcommand)
81	}
82	_ = bot.RemovePIDFile(spec.pidPath(ZotHome())) // clear any stale pid file
83
84	if ok, cerr := spec.configured(ZotHome()); cerr != nil {
85		return cerr
86	} else if !ok {
87		return fmt.Errorf("%s is not configured — run `zot %s setup` first", spec.name, spec.subcommand)
88	}
89
90	self, err := os.Executable()
91	if err != nil {
92		return fmt.Errorf("locate zot binary: %w", err)
93	}
94
95	logPath := spec.logPath(ZotHome())
96	if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
97		return err
98	}
99	logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
100	if err != nil {
101		return fmt.Errorf("open log file: %w", err)
102	}
103	defer logFile.Close()
104
105	// Refuse to start from a `go run` temp binary: Go deletes the
106	// binary when `go run` exits, which breaks the detached child.
107	// Users hit cryptic tls / exec errors on that path; fail clearly.
108	if strings.Contains(self, string(os.PathSeparator)+"go-build") ||
109		strings.Contains(self, string(os.PathSeparator)+"go-tmp") {
110		return fmt.Errorf("detected `go run` temp binary at %s — run `make install` (or copy ./bin/zot to your PATH) and use the installed binary for `start`", self)
111	}
112
113	// Child argv: same flags the user passed to `zot <sub> start`,
114	// mapped to `zot <sub> run`. Preserves --provider, --model, --cwd, etc.
115	args := append([]string{spec.subcommand, "run"}, rawTail...)
116	cmd := exec.Command(self, args...)
117	cmd.Stdout = logFile
118	cmd.Stderr = logFile
119	cmd.Stdin = nil
120	// Detach: new session / new process group so terminal signals
121	// don't reach the child. Impl lives in botcmd_unix.go /
122	// botcmd_windows.go because Setsid is posix-only.
123	detachChild(cmd)
124
125	if err := cmd.Start(); err != nil {
126		return fmt.Errorf("spawn: %w", err)
127	}
128	if err := bot.WritePIDFile(spec.pidPath(ZotHome()), cmd.Process.Pid); err != nil {
129		_ = cmd.Process.Kill()
130		return fmt.Errorf("write pid: %w", err)
131	}
132	// Don't wait() — detach. OS will reparent the child to init when we exit.
133	go func() { _ = cmd.Process.Release() }()
134
135	fmt.Printf("started zot %s as pid %d (logs: %s)\n", spec.subcommand, cmd.Process.Pid, logPath)
136	fmt.Printf("use `zot %s logs -f` to tail, `zot %s stop` to stop.\n", spec.subcommand, spec.subcommand)
137	return nil
138}
139
140// botStop sends SIGTERM to the running bot (SIGKILL if it doesn't
141// exit within 5s) and cleans up the pid file.
142func botStop(spec *botSpec) error {
143	pid, alive, err := bot.IsRunningAt(spec.pidPath(ZotHome()))
144	if err != nil {
145		return err
146	}
147	if !alive {
148		if pid > 0 {
149			_ = bot.RemovePIDFile(spec.pidPath(ZotHome()))
150			fmt.Printf("no live process; cleared stale pid %d\n", pid)
151			return nil
152		}
153		fmt.Printf("%s is not running\n", spec.name)
154		return nil
155	}
156	if err := bot.StopProcess(pid, 5*time.Second); err != nil {
157		return fmt.Errorf("stop pid %d: %w", pid, err)
158	}
159	_ = bot.RemovePIDFile(spec.pidPath(ZotHome()))
160	fmt.Printf("stopped pid %d\n", pid)
161	return nil
162}
163
164// botLogs prints (or tails with --follow) the bot log file.
165func botLogs(spec *botSpec, rawTail []string) error {
166	follow := false
167	for _, a := range rawTail {
168		if a == "-f" || a == "--follow" {
169			follow = true
170		}
171	}
172	p := spec.logPath(ZotHome())
173	f, err := os.Open(p)
174	if errors.Is(err, os.ErrNotExist) {
175		fmt.Println("no log file at", p)
176		return nil
177	}
178	if err != nil {
179		return err
180	}
181	defer f.Close()
182
183	if _, err := io.Copy(os.Stdout, f); err != nil {
184		return err
185	}
186	if !follow {
187		return nil
188	}
189
190	// Naive tail -f: poll for new bytes until ctrl+c.
191	sigc := make(chan os.Signal, 1)
192	signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
193	defer signal.Stop(sigc)
194	for {
195		select {
196		case <-sigc:
197			return nil
198		case <-time.After(500 * time.Millisecond):
199			if _, err := io.Copy(os.Stdout, f); err != nil {
200				return err
201			}
202		}
203	}
204}
205
206// botRun starts the polling loop in the foreground. Ctrl+C stops it.
207func botRun(spec *botSpec, rawTail []string, version string) error {
208	// Parse only a small subset of flags relevant to bot run. We reuse
209	// the main args parser so --provider/--model/--cwd/--api-key/--reasoning
210	// behave the same as in the tui.
211	args, err := ParseArgs(rawTail)
212	if err != nil {
213		return err
214	}
215
216	// Bot mode always requires credentials (can't pop a /login dialog).
217	resolved, err := Resolve(args, true)
218	if err != nil {
219		return err
220	}
221
222	if ok, cerr := spec.configured(ZotHome()); cerr != nil {
223		return cerr
224	} else if !ok {
225		return fmt.Errorf("%s is not configured — run `zot %s setup` first", spec.name, spec.subcommand)
226	}
227	adapter, err := spec.newAdapter(ZotHome())
228	if err != nil {
229		return err
230	}
231
232	agent := resolved.NewAgent()
233
234	// Session: optional, same model as the tui. Persist so DMs build on
235	// prior context. --no-session disables.
236	var sess *core.Session
237	if !args.NoSess {
238		s, _, serr := openOrCreateSessionForBot(args, resolved, agent, version)
239		if serr == nil {
240			sess = s
241			agent.OnMessageAppended = func(msg provider.Message) {
242				_ = sess.AppendMessage(msg)
243			}
244			agent.OnUsage = func(u provider.Usage) {
245				_ = sess.AppendUsage(u, u)
246			}
247			agent.OnTranscriptCompacted = func(msgs []provider.Message) {
248				_ = sess.AppendCompaction(msgs)
249			}
250			defer sess.Close()
251		} else {
252			fmt.Fprintln(os.Stderr, "session:", serr)
253		}
254	}
255
256	var runner *bot.Runner
257	runner = bot.NewRunner(adapter, agent, bot.Config{
258		ZotHome:    ZotHome(),
259		Provider:   resolved.Provider,
260		Model:      resolved.Model,
261		AuthMethod: resolved.AuthMethod,
262		CWD:        args.CWD,
263		RefreshCreds: func() error {
264			// Re-run the same resolver the tui uses so we pick up
265			// refreshed oauth tokens, re-logins, and model switches.
266			// Only the provider client is swapped — tools, sandbox,
267			// system prompt, and transcript stay with the existing agent.
268			next, err := Resolve(args, true)
269			if err != nil {
270				return err
271			}
272			agent.Client = next.NewClient()
273			agent.Model = next.Model
274			runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
275			return nil
276		},
277	})
278
279	// Record our pid so `zot <sub> status` / `zot <sub> stop` can find us,
280	// regardless of whether we were started directly or via `start`.
281	_ = bot.WritePIDFile(spec.pidPath(ZotHome()), os.Getpid())
282	defer bot.RemovePIDFile(spec.pidPath(ZotHome()))
283
284	// Translate sigterm/sigint into a context cancel so the bot's goroutines
285	// and the currently-running turn wind down cleanly.
286	ctx, cancel := context.WithCancel(context.Background())
287	sigc := make(chan os.Signal, 1)
288	signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
289	go func() {
290		<-sigc
291		cancel()
292	}()
293	defer cancel()
294	return runner.Run(ctx)
295}
296
297// openOrCreateSessionForBot reuses the same logic as interactive mode
298// but never prompts (no TTY picker); falls back to latest or new.
299func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, []any, error) {
300	if args.Continue {
301		if latest := core.LatestSession(ZotHome(), args.CWD); latest != "" {
302			s, msgs, err := core.OpenSession(latest)
303			if err != nil {
304				return nil, nil, err
305			}
306			ag.SetMessages(msgs)
307			return s, nil, nil
308		}
309	}
310	s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
311	return s, nil, err
312}
313
314// maskToken returns "123456:ABC...xyz" so copies of zot telegram-bot status can be
315// pasted into bug reports without leaking the full token.
316func maskToken(tok string) string {
317	if len(tok) <= 10 {
318		return "<hidden>"
319	}
320	// telegram tokens look like "123456789:ABCD..." — keep the id, mask the body.
321	i := strings.IndexByte(tok, ':')
322	if i < 0 {
323		return tok[:4] + "..." + tok[len(tok)-4:]
324	}
325	body := tok[i+1:]
326	if len(body) < 8 {
327		return tok[:i+1] + "<hidden>"
328	}
329	return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]
330}
331
332// telegramSpec wires the existing Telegram setup/status/reset flows
333// into the generic dispatcher. Bodies are the pre-refactor botSetup,
334// botStatus, botReset, printBotHelp — unchanged.
335func telegramSpec() *botSpec {
336	return &botSpec{
337		name:       "telegram",
338		subcommand: "telegram-bot",
339		aliases:    []string{"tg"},
340		pidPath:    telegram.PIDPath,
341		logPath:    telegram.LogPath,
342		configured: func(zotHome string) (bool, error) {
343			cfg, err := telegram.LoadConfig(zotHome)
344			if err != nil {
345				return false, err
346			}
347			return cfg.BotToken != "", nil
348		},
349		printHelp: printTelegramBotHelp,
350		setup:     telegramBotSetup,
351		status:    telegramBotStatus,
352		reset:     telegramBotReset,
353		newAdapter: func(zotHome string) (bot.BotAdapter, error) {
354			cfg, err := telegram.LoadConfig(zotHome)
355			if err != nil {
356				return nil, err
357			}
358			return telegram.NewAdapter(
359				telegram.NewClient(cfg.BotToken),
360				&cfg,
361				func(c telegram.Config) error { return telegram.SaveConfig(zotHome, c) },
362			), nil
363		},
364	}
365}
366
367// printTelegramBotHelp prints usage for `zot telegram-bot`.
368func printTelegramBotHelp() {
369	fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge
370
371usage:
372  zot telegram-bot setup                       paste a BotFather token, verify, save
373  zot telegram-bot status                      show bridge config and whether it's running
374  zot telegram-bot run [flags]                 run in the foreground (ctrl+c to stop)
375  zot telegram-bot start [flags]               launch in background, detach, return immediately
376  zot telegram-bot stop                        sigterm the running background bot, sigkill if needed
377  zot telegram-bot logs [--follow]             tail the background bot's log file
378  zot telegram-bot reset                       forget token + allowed user
379
380setup flow:
381  1. talk to @BotFather on telegram, /newbot, copy the token
382  2. run "zot telegram-bot setup" and paste the token
383  3. run "zot telegram-bot start" (background) or "zot telegram-bot run" (foreground)
384  4. send /start to your bot from telegram; the first sender claims it
385
386while the bot is running, dm it anything and the message is forwarded
387to the agent the same way it would be from the tui. image attachments
388(photos or image/* documents) are passed to vision-capable models.
389telegram commands the bot handles directly: /help, /status, /stop.
390
391config & state:
392  $ZOT_HOME/bot.json       # bot token + paired user (mode 0600)
393  $ZOT_HOME/bot.pid        # pid of the running bot (written by run/start)
394  $ZOT_HOME/logs/bot.log   # stdout+stderr from "zot telegram-bot start"
395`)
396}
397
398var (
399	botTokenIsTerminal   = term.IsTerminal
400	botTokenReadPassword = term.ReadPassword
401)
402
403func readBotToken(in *os.File, out io.Writer) (string, error) {
404	if botTokenIsTerminal(int(in.Fd())) {
405		value, err := botTokenReadPassword(int(in.Fd()))
406		fmt.Fprintln(out)
407		if err != nil {
408			return "", fmt.Errorf("read telegram bot token: %w", err)
409		}
410		return string(value), nil
411	}
412
413	value, err := bufio.NewReader(in).ReadString('\n')
414	if err != nil && !errors.Is(err, io.EOF) {
415		return "", fmt.Errorf("read telegram bot token: %w", err)
416	}
417	return value, nil
418}
419
420// telegramBotSetup interactively reads a bot token without echoing it on a
421// terminal, verifies it via getMe, and saves it.
422func telegramBotSetup(_ []string) error {
423	cfg, err := telegram.LoadConfig(ZotHome())
424	if err != nil {
425		return err
426	}
427
428	fmt.Print("telegram bot token (from @BotFather): ")
429	line, err := readBotToken(os.Stdin, os.Stdout)
430	if err != nil {
431		return err
432	}
433	token := strings.TrimSpace(line)
434	if token == "" {
435		return fmt.Errorf("no token provided")
436	}
437
438	client := telegram.NewClient(token)
439	me, err := client.GetMe(context.Background())
440	if err != nil {
441		return fmt.Errorf("token rejected by telegram: %w", err)
442	}
443	cfg.BotToken = token
444	cfg.BotUsername = me.Username
445	cfg.BotID = me.ID
446	// Any stored pairing might be for a different bot; clear it.
447	cfg.AllowedUserID = 0
448	cfg.LastUpdateID = 0
449	if err := telegram.SaveConfig(ZotHome(), cfg); err != nil {
450		return err
451	}
452	fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZotHome()))
453	fmt.Println("next: run `zot telegram-bot run`, then send /start to your bot from telegram.")
454	return nil
455}
456
457// telegramBotStatus prints the current bot config without the token, plus
458// whether the background process is alive.
459func telegramBotStatus() error {
460	cfg, err := telegram.LoadConfig(ZotHome())
461	if err != nil {
462		return err
463	}
464	if cfg.BotToken == "" {
465		fmt.Println("telegram: not configured (run `zot telegram-bot setup`)")
466		return nil
467	}
468	maskedTok := maskToken(cfg.BotToken)
469	fmt.Printf("telegram bot: @%s (id=%d)\n", cfg.BotUsername, cfg.BotID)
470	fmt.Printf("token:        %s\n", maskedTok)
471	if cfg.AllowedUserID == 0 {
472		fmt.Println("paired with:  (unpaired — send /start from telegram to claim)")
473	} else {
474		fmt.Printf("paired with:  telegram user id %d\n", cfg.AllowedUserID)
475	}
476	fmt.Printf("last update:  %d\n", cfg.LastUpdateID)
477	fmt.Printf("config file:  %s\n", telegram.ConfigPath(ZotHome()))
478
479	pid, alive, _ := telegram.IsRunning(ZotHome())
480	switch {
481	case alive:
482		fmt.Printf("process:      running (pid %d)\n", pid)
483	case pid > 0:
484		fmt.Printf("process:      stopped (stale pid %d in %s)\n", pid, telegram.PIDPath(ZotHome()))
485	default:
486		fmt.Println("process:      stopped")
487	}
488	logPath := telegram.LogPath(ZotHome())
489	if fi, err := os.Stat(logPath); err == nil {
490		fmt.Printf("log file:     %s (%d bytes)\n", logPath, fi.Size())
491	}
492	return nil
493}
494
495// telegramBotReset wipes the on-disk bot.json entry.
496func telegramBotReset() error {
497	p := telegram.ConfigPath(ZotHome())
498	if _, err := os.Stat(p); os.IsNotExist(err) {
499		fmt.Println("no bot config to reset")
500		return nil
501	}
502	if err := os.Remove(p); err != nil {
503		return err
504	}
505	fmt.Println("removed", p)
506	return nil
507}

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