v0.4.7 2ff82fa

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	reportSkillDiagnostics(os.Stderr, resolved.SkillDiagnostics)
222
223	if ok, cerr := spec.configured(ZotHome()); cerr != nil {
224		return cerr
225	} else if !ok {
226		return fmt.Errorf("%s is not configured — run `zot %s setup` first", spec.name, spec.subcommand)
227	}
228	adapter, err := spec.newAdapter(ZotHome())
229	if err != nil {
230		return err
231	}
232
233	agent := resolved.NewAgent()
234
235	// Session: optional, same model as the tui. Persist so DMs build on
236	// prior context. --no-session disables.
237	var sess *core.Session
238	if !args.NoSess {
239		s, _, serr := openOrCreateSessionForBot(args, resolved, agent, version)
240		if serr == nil {
241			sess = s
242			agent.OnMessageAppended = func(msg provider.Message) {
243				_ = sess.AppendMessage(msg)
244			}
245			agent.OnUsage = func(u provider.Usage) {
246				_ = sess.AppendUsage(u, u)
247			}
248			agent.OnTranscriptCompacted = func(msgs []provider.Message) {
249				_ = sess.AppendCompaction(msgs)
250			}
251			defer sess.Close()
252		} else {
253			fmt.Fprintln(os.Stderr, "session:", serr)
254		}
255	}
256
257	var runner *bot.Runner
258	runner = bot.NewRunner(adapter, agent, bot.Config{
259		ZotHome:    ZotHome(),
260		Provider:   resolved.Provider,
261		Model:      resolved.Model,
262		AuthMethod: resolved.AuthMethod,
263		CWD:        args.CWD,
264		RefreshCreds: func() error {
265			// Re-run the same resolver the tui uses so we pick up
266			// refreshed oauth tokens, re-logins, and model switches.
267			// Only the provider client is swapped — tools, sandbox,
268			// system prompt, and transcript stay with the existing agent.
269			next, err := Resolve(args, true)
270			if err != nil {
271				return err
272			}
273			agent.Client = next.NewClient()
274			agent.Model = next.Model
275			runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
276			return nil
277		},
278	})
279
280	// Record our pid so `zot <sub> status` / `zot <sub> stop` can find us,
281	// regardless of whether we were started directly or via `start`.
282	_ = bot.WritePIDFile(spec.pidPath(ZotHome()), os.Getpid())
283	defer bot.RemovePIDFile(spec.pidPath(ZotHome()))
284
285	// Translate sigterm/sigint into a context cancel so the bot's goroutines
286	// and the currently-running turn wind down cleanly.
287	ctx, cancel := context.WithCancel(context.Background())
288	sigc := make(chan os.Signal, 1)
289	signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
290	go func() {
291		<-sigc
292		cancel()
293	}()
294	defer cancel()
295	return runner.Run(ctx)
296}
297
298// openOrCreateSessionForBot reuses the same logic as interactive mode
299// but never prompts (no TTY picker); falls back to latest or new.
300func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, []any, error) {
301	if args.Continue {
302		if latest := core.LatestSession(ZotHome(), args.CWD); latest != "" {
303			s, msgs, err := core.OpenSession(latest)
304			if err != nil {
305				return nil, nil, err
306			}
307			ag.SetMessages(msgs)
308			bindAgentSession(ag, s)
309			return s, nil, nil
310		}
311	}
312	s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
313	if err == nil {
314		bindAgentSession(ag, s)
315	}
316	return s, nil, err
317}
318
319// maskToken returns "123456:ABC...xyz" so copies of zot telegram-bot status can be
320// pasted into bug reports without leaking the full token.
321func maskToken(tok string) string {
322	if len(tok) <= 10 {
323		return "<hidden>"
324	}
325	// telegram tokens look like "123456789:ABCD..." — keep the id, mask the body.
326	i := strings.IndexByte(tok, ':')
327	if i < 0 {
328		return tok[:4] + "..." + tok[len(tok)-4:]
329	}
330	body := tok[i+1:]
331	if len(body) < 8 {
332		return tok[:i+1] + "<hidden>"
333	}
334	return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]
335}
336
337// telegramSpec wires the existing Telegram setup/status/reset flows
338// into the generic dispatcher. Bodies are the pre-refactor botSetup,
339// botStatus, botReset, printBotHelp — unchanged.
340func telegramSpec() *botSpec {
341	return &botSpec{
342		name:       "telegram",
343		subcommand: "telegram-bot",
344		aliases:    []string{"tg"},
345		pidPath:    telegram.PIDPath,
346		logPath:    telegram.LogPath,
347		configured: func(zotHome string) (bool, error) {
348			cfg, err := telegram.LoadConfig(zotHome)
349			if err != nil {
350				return false, err
351			}
352			return cfg.BotToken != "", nil
353		},
354		printHelp: printTelegramBotHelp,
355		setup:     telegramBotSetup,
356		status:    telegramBotStatus,
357		reset:     telegramBotReset,
358		newAdapter: func(zotHome string) (bot.BotAdapter, error) {
359			cfg, err := telegram.LoadConfig(zotHome)
360			if err != nil {
361				return nil, err
362			}
363			return telegram.NewAdapter(
364				telegram.NewClient(cfg.BotToken),
365				&cfg,
366				func(c telegram.Config) error { return telegram.SaveConfig(zotHome, c) },
367			), nil
368		},
369	}
370}
371
372// printTelegramBotHelp prints usage for `zot telegram-bot`.
373func printTelegramBotHelp() {
374	fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge
375
376usage:
377  zot telegram-bot setup                       paste a BotFather token, verify, save
378  zot telegram-bot status                      show bridge config and whether it's running
379  zot telegram-bot run [flags]                 run in the foreground (ctrl+c to stop)
380  zot telegram-bot start [flags]               launch in background, detach, return immediately
381  zot telegram-bot stop                        sigterm the running background bot, sigkill if needed
382  zot telegram-bot logs [--follow]             tail the background bot's log file
383  zot telegram-bot reset                       forget token + allowed user
384
385setup flow:
386  1. talk to @BotFather on telegram, /newbot, copy the token
387  2. run "zot telegram-bot setup" and paste the token
388  3. run "zot telegram-bot start" (background) or "zot telegram-bot run" (foreground)
389  4. send /start to your bot from telegram; the first sender claims it
390
391while the bot is running, dm it anything and the message is forwarded
392to the agent the same way it would be from the tui. image attachments
393(photos or image/* documents) are passed to vision-capable models.
394telegram commands the bot handles directly: /help, /status, /stop.
395
396config & state:
397  $ZOT_HOME/bot.json       # bot token + paired user (mode 0600)
398  $ZOT_HOME/bot.pid        # pid of the running bot (written by run/start)
399  $ZOT_HOME/logs/bot.log   # stdout+stderr from "zot telegram-bot start"
400`)
401}
402
403var (
404	botTokenIsTerminal   = term.IsTerminal
405	botTokenReadPassword = term.ReadPassword
406)
407
408func readBotToken(in *os.File, out io.Writer) (string, error) {
409	if botTokenIsTerminal(int(in.Fd())) {
410		value, err := botTokenReadPassword(int(in.Fd()))
411		fmt.Fprintln(out)
412		if err != nil {
413			return "", fmt.Errorf("read telegram bot token: %w", err)
414		}
415		return string(value), nil
416	}
417
418	value, err := bufio.NewReader(in).ReadString('\n')
419	if err != nil && !errors.Is(err, io.EOF) {
420		return "", fmt.Errorf("read telegram bot token: %w", err)
421	}
422	return value, nil
423}
424
425// telegramBotSetup interactively reads a bot token without echoing it on a
426// terminal, verifies it via getMe, and saves it.
427func telegramBotSetup(_ []string) error {
428	cfg, err := telegram.LoadConfig(ZotHome())
429	if err != nil {
430		return err
431	}
432
433	fmt.Print("telegram bot token (from @BotFather): ")
434	line, err := readBotToken(os.Stdin, os.Stdout)
435	if err != nil {
436		return err
437	}
438	token := strings.TrimSpace(line)
439	if token == "" {
440		return fmt.Errorf("no token provided")
441	}
442
443	client := telegram.NewClient(token)
444	me, err := client.GetMe(context.Background())
445	if err != nil {
446		return fmt.Errorf("token rejected by telegram: %w", err)
447	}
448	cfg.BotToken = token
449	cfg.BotUsername = me.Username
450	cfg.BotID = me.ID
451	// Any stored pairing might be for a different bot; clear it.
452	cfg.AllowedUserID = 0
453	cfg.LastUpdateID = 0
454	if err := telegram.SaveConfig(ZotHome(), cfg); err != nil {
455		return err
456	}
457	fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZotHome()))
458	fmt.Println("next: run `zot telegram-bot run`, then send /start to your bot from telegram.")
459	return nil
460}
461
462// telegramBotStatus prints the current bot config without the token, plus
463// whether the background process is alive.
464func telegramBotStatus() error {
465	cfg, err := telegram.LoadConfig(ZotHome())
466	if err != nil {
467		return err
468	}
469	if cfg.BotToken == "" {
470		fmt.Println("telegram: not configured (run `zot telegram-bot setup`)")
471		return nil
472	}
473	maskedTok := maskToken(cfg.BotToken)
474	fmt.Printf("telegram bot: @%s (id=%d)\n", cfg.BotUsername, cfg.BotID)
475	fmt.Printf("token:        %s\n", maskedTok)
476	if cfg.AllowedUserID == 0 {
477		fmt.Println("paired with:  (unpaired — send /start from telegram to claim)")
478	} else {
479		fmt.Printf("paired with:  telegram user id %d\n", cfg.AllowedUserID)
480	}
481	fmt.Printf("last update:  %d\n", cfg.LastUpdateID)
482	fmt.Printf("config file:  %s\n", telegram.ConfigPath(ZotHome()))
483
484	pid, alive, _ := telegram.IsRunning(ZotHome())
485	switch {
486	case alive:
487		fmt.Printf("process:      running (pid %d)\n", pid)
488	case pid > 0:
489		fmt.Printf("process:      stopped (stale pid %d in %s)\n", pid, telegram.PIDPath(ZotHome()))
490	default:
491		fmt.Println("process:      stopped")
492	}
493	logPath := telegram.LogPath(ZotHome())
494	if fi, err := os.Stat(logPath); err == nil {
495		fmt.Printf("log file:     %s (%d bytes)\n", logPath, fi.Size())
496	}
497	return nil
498}
499
500// telegramBotReset wipes the on-disk bot.json entry.
501func telegramBotReset() error {
502	p := telegram.ConfigPath(ZotHome())
503	if _, err := os.Stat(p); os.IsNotExist(err) {
504		fmt.Println("no bot config to reset")
505		return nil
506	}
507	if err := os.Remove(p); err != nil {
508		return err
509	}
510	fmt.Println("removed", p)
511	return nil
512}

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