main ece7682

cli.go RawHistoryFolder

1package app
2
3import (
4	"fmt"
5	"strings"
6
7	"github.com/bnema/vev/internal/domain"
8	"github.com/bnema/vev/internal/ports"
9	"github.com/bnema/vev/internal/protocol"
10)
11
12// cmdKind identifies which sub-command the CLI parsed.
13type cmdKind int
14
15const (
16	kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent
17	kindList
18	kindHost
19	kindKill
20	kindCmd
21	kindEnv
22	kindDaemon
23	kindDaemonLauncher
24	kindUIDriver
25	kindUIRemoteCleanup
26	kindDaemonStop
27	kindWebDaemon
28	kindWebRenew
29	kindWebServe
30	kindBrokerMuxStdio
31	kindBrokerMuxQUICBootstrap
32	kindBrokerMuxQUICProxy
33	kindProductionBrokerServe
34	kindProductionBrokerLauncher
35	kindBrokerReady
36	kindHelp
37	kindVersion
38)
39
40// command is the parsed CLI invocation: what to do, plus the attach intent
41// and session name where relevant.
42type command struct {
43	kind         cmdKind
44	intent       uint8
45	name         string
46	remoteTarget string
47	listHost     string
48	listAll      bool
49	hostAction   string
50	hostTarget   string
51	// hostTransport is the `host add` carriage: hostTransportQUIC or hostTransportSSH.
52	hostTransport string
53	killAll       bool
54	killSessions  bool
55	cmd           cmdInvocation
56	env           envInvocation
57	brokerServe   brokerServeOptions
58	brokerMux     brokerMuxOptions
59	brokerReady   brokerReadyOptions
60	uiDriver      uiDriverOptions
61	ui            interactiveUIOptions
62	web           webOptions
63}
64
65// usageError is a user-facing argument error; the app prints it (with usage)
66// rather than a stack of wrapped internals.
67type usageError struct{ msg string }
68
69func (e *usageError) Error() string { return e.msg }
70
71func usagef(format string, args ...any) error {
72	return &usageError{msg: fmt.Sprintf(format, args...)}
73}
74
75const usageText = `vev — a terminal multiplexer
76
77usage:
78  vev                 attach to (or create) an ephemeral session
79  vev new <name>      create and attach to a named session
80  vev attach <name>   attach to an existing session (alias: a)
81  vev attach user@host[:session]
82                      attach through SSH to a remote vev daemon
83  vev ls              list local sessions
84  vev ls <host>       list sessions on a known remote host
85  vev ls --all        list local and remote sessions
86  vev host add [--transport quic|ssh] <host>
87                      add a pinned remote host (default transport: quic)
88  vev host rm <host>  remove a pinned remote host
89  vev host list       list known remote hosts
90  vev kill <name>     kill a session
91  vev kill --sessions kill all sessions (vev keeps running)
92  vev kill --all      stop everything: every vev window, the broker, and the
93                      daemon (named sessions come back on the next start)
94  vev cmd <command>   run a control command (vev cmd --help)
95  vev env <fish|sh>   print shell code that refreshes desktop variables
96  vev --ui-observe    expose passive observation for this interactive client
97                      (optional: --ui-socket PATH)
98  vev --ui-control    expose observation and input control for this client
99                      (optional: --ui-socket PATH)
100  vev --web-daemon    start the private web terminal or print its current link
101  vev --web-renew-token  revoke web access and print a fresh link
102  Web options: --web-listen IP:PORT --web-origin http(s)://HOST[:PORT]
103  vev --help          show this help
104  vev --version       show version`
105
106// parseArgs turns the raw argv tail into a command. It is deliberately a
107// pure function so the full dispatch table can be unit-tested without any
108// I/O.
109func parseArgs(args []string) (command, error) {
110	if len(args) > 0 && args[0] == "--ui-driver" {
111		options, err := parseUIDriverArgs(args[1:])
112		if err != nil {
113			return command{}, err
114		}
115		return command{kind: kindUIDriver, uiDriver: options}, nil
116	}
117	ui, args, err := parseLeadingUIFlags(args)
118	if err != nil {
119		return command{}, err
120	}
121	if len(args) == 0 {
122		return command{kind: kindAttach, intent: protocol.IntentEphemeral, ui: ui}, nil
123	}
124	switch args[0] {
125	case "new":
126		return parseNewArgs(args[1:], ui)
127	case "attach", "a":
128		return parseAttachArgs(args[1:], ui)
129	}
130	if ui.enabled() {
131		return command{}, usagef("UI flags are only valid for an attach command")
132	}
133	return parseSubcommand(args)
134}
135
136// parseLeadingUIFlags consumes the interactive UI flags placed before the
137// subcommand and returns the remaining arguments.
138func parseLeadingUIFlags(args []string) (interactiveUIOptions, []string, error) {
139	end := 0
140	for end < len(args) {
141		switch args[end] {
142		case "--ui-observe", "--ui-control":
143			end++
144		case "--ui-socket":
145			if end+1 >= len(args) || args[end+1] == "" {
146				return interactiveUIOptions{}, nil, usagef("`--ui-socket` requires a path")
147			}
148			end += 2
149		default:
150			ui, err := parseInteractiveUIFlags(args[:end], interactiveUIOptions{})
151			return ui, args[end:], err
152		}
153	}
154	ui, err := parseInteractiveUIFlags(args, interactiveUIOptions{})
155	return ui, nil, err
156}
157
158// parseSubcommand parses every subcommand that does not accept UI flags.
159func parseSubcommand(args []string) (command, error) {
160	switch args[0] {
161	case "--web-daemon", "--web-serve":
162		return parseWebArgs(args)
163	case "--web-renew-token":
164		return parseNoArgs(args, kindWebRenew)
165	case "--daemon":
166		return command{kind: kindDaemon}, nil
167	case "--daemon-launcher":
168		return command{kind: kindDaemonLauncher}, nil
169	case productionBrokerServeCommand:
170		return parseProductionBrokerServeArgs(args[1:])
171	case productionBrokerLauncherCommand:
172		return parseProductionBrokerLauncherArgs(args[1:])
173	case brokerReadyCommand:
174		options, err := parseBrokerReadyArgs(args[1:])
175		return command{kind: kindBrokerReady, brokerReady: options}, err
176	case ports.BrokerMuxStdioCommand:
177		return parseBrokerMuxArgs(ports.BrokerMuxStdioCommand, kindBrokerMuxStdio, args[1:])
178	case ports.BrokerMuxQUICBootstrapCommand:
179		return parseBrokerMuxArgs(ports.BrokerMuxQUICBootstrapCommand, kindBrokerMuxQUICBootstrap, args[1:])
180	case brokerMuxQUICProxyCommand:
181		return parseBrokerMuxArgs(brokerMuxQUICProxyCommand, kindBrokerMuxQUICProxy, args[1:])
182	case uiRemoteCleanupCommand:
183		return parseNoArgs(args, kindUIRemoteCleanup)
184	case daemonStopCommand:
185		return parseNoArgs(args, kindDaemonStop)
186	case "ls", "list":
187		return parseListArgs(args[1:])
188	case "host":
189		return parseHostArgs(args[1:])
190	case "env":
191		invocation, err := parseEnvArgs(args[1:])
192		if err != nil {
193			return command{}, err
194		}
195		return command{kind: kindEnv, env: invocation}, nil
196	case "cmd":
197		invocation, err := parseCmdArgs(args[1:])
198		if err != nil {
199			return command{}, err
200		}
201		return command{kind: kindCmd, cmd: invocation}, nil
202	case "kill":
203		return parseKillArgs(args[1:])
204	case "-h", "--help", "help":
205		return command{kind: kindHelp}, nil
206	case "--version", "version":
207		return command{kind: kindVersion}, nil
208	default:
209		return command{}, usagef("unknown command %q", args[0])
210	}
211}
212
213// parseNoArgs accepts a flag-style subcommand that takes no arguments.
214func parseNoArgs(args []string, kind cmdKind) (command, error) {
215	if len(args) != 1 {
216		return command{}, usagef("`%s` does not accept arguments", args[0])
217	}
218	return command{kind: kind}, nil
219}
220
221// parseNewArgs parses `new <name> [user@host] [UI flags]`.
222func parseNewArgs(args []string, ui interactiveUIOptions) (command, error) {
223	if len(args) == 0 || args[0] == "" {
224		return command{}, usagef("`new` requires a session name")
225	}
226	name, args := args[0], args[1:]
227	var remoteTarget string
228	if len(args) > 0 && !strings.HasPrefix(args[0], "--") {
229		remoteTarget, args = args[0], args[1:]
230		if err := domain.ValidateRemoteHostTarget(remoteTarget); err != nil {
231			return command{}, err
232		}
233	}
234	ui, err := parseInteractiveUIFlags(args, ui)
235	if err != nil {
236		return command{}, err
237	}
238	if err := domain.ValidateSessionName(name); err != nil {
239		return command{}, err
240	}
241	return command{kind: kindAttach, intent: protocol.IntentNew, name: name, remoteTarget: remoteTarget, ui: ui}, nil
242}
243
244// parseAttachArgs parses `attach <name|user@host[:session]> [UI flags]`.
245func parseAttachArgs(args []string, ui interactiveUIOptions) (command, error) {
246	if len(args) == 0 || args[0] == "" {
247		return command{}, usagef("`attach` requires a session name")
248	}
249	ui, err := parseInteractiveUIFlags(args[1:], ui)
250	if err != nil {
251		return command{}, err
252	}
253	cmd := command{kind: kindAttach, intent: protocol.IntentAttach, name: args[0], ui: ui}
254	target, session, ok := parseRemoteAttachTarget(args[0])
255	if !ok {
256		return cmd, nil
257	}
258	if err := domain.ValidateRemoteHostTarget(target); err != nil {
259		return command{}, err
260	}
261	if session == "" {
262		cmd.intent = protocol.IntentEphemeral
263	} else if err := domain.ValidateSessionName(session); err != nil {
264		return command{}, err
265	}
266	cmd.remoteTarget = target
267	cmd.name = session
268	return cmd, nil
269}
270
271// parseKillArgs parses `kill <name>`, `kill -- <name>`, `kill --sessions`, and
272// `kill --all`.
273func parseKillArgs(args []string) (command, error) {
274	if len(args) == 0 || args[0] == "" {
275		return command{}, usagef("`kill` requires a session name, --sessions, or --all")
276	}
277	if args[0] == "--" {
278		if len(args) != 2 || args[1] == "" {
279			return command{}, usagef("`kill --` requires a session name")
280		}
281		return command{kind: kindKill, name: args[1]}, nil
282	}
283	if len(args) > 1 {
284		return command{}, usagef("`kill` accepts exactly one session name, --sessions, or --all")
285	}
286	switch args[0] {
287	case "--sessions":
288		return command{kind: kindKill, killSessions: true}, nil
289	case "--all":
290		return command{kind: kindKill, killAll: true}, nil
291	}
292	if strings.HasPrefix(args[0], "-") {
293		return command{}, usagef("unknown flag %q for `kill`; use `kill -- NAME` for a dashed session name", args[0])
294	}
295	return command{kind: kindKill, name: args[0]}, nil
296}
297
298func parseListArgs(args []string) (command, error) {
299	if len(args) == 0 {
300		return command{kind: kindList}, nil
301	}
302	if len(args) > 1 {
303		return command{}, usagef("`ls` accepts at most one host or --all")
304	}
305	switch args[0] {
306	case "--all":
307		return command{kind: kindList, listAll: true}, nil
308	default:
309		if strings.HasPrefix(args[0], "-") {
310			return command{}, usagef("unknown flag %q for `ls`", args[0])
311		}
312		if err := domain.ValidateRemoteHostTarget(args[0]); err != nil {
313			return command{}, err
314		}
315		return command{kind: kindList, listHost: args[0]}, nil
316	}
317}
318
319func parseHostArgs(args []string) (command, error) {
320	if len(args) == 0 {
321		return command{}, usagef("`host` requires add, rm, or list")
322	}
323	switch args[0] {
324	case hostActionAdd:
325		return parseHostAddArgs(args[1:])
326	case hostActionRm:
327		if len(args) < 2 || args[1] == "" {
328			return command{}, usagef("`host rm` requires a host target")
329		}
330		if strings.HasPrefix(args[1], "-") {
331			return command{}, usagef("unknown flag %q for `host rm`", args[1])
332		}
333		if len(args) > 2 {
334			return command{}, usagef("`host rm` accepts exactly one host target")
335		}
336		if err := domain.ValidateRemoteHostTarget(args[1]); err != nil {
337			return command{}, err
338		}
339		return command{kind: kindHost, hostAction: hostActionRm, hostTarget: args[1]}, nil
340	case hostActionList:
341		if len(args) > 1 {
342			if strings.HasPrefix(args[1], "-") {
343				return command{}, usagef("unknown flag %q for `host list`", args[1])
344			}
345			return command{}, usagef("`host list` accepts no arguments")
346		}
347		return command{kind: kindHost, hostAction: hostActionList}, nil
348	default:
349		return command{}, usagef("unknown host action %q", args[0])
350	}
351}
352
353// parseHostAddArgs parses `host add [--transport quic|ssh] <host>`.
354func parseHostAddArgs(args []string) (command, error) {
355	transport, target := "", ""
356	for i := 0; i < len(args); i++ {
357		arg := args[i]
358		switch {
359		case arg == "--transport" || strings.HasPrefix(arg, "--transport="):
360			if transport != "" {
361				return command{}, usagef("`host add` accepts --transport once")
362			}
363			value, hasValue := strings.CutPrefix(arg, "--transport=")
364			if !hasValue {
365				if i+1 >= len(args) {
366					return command{}, usagef("`--transport` requires a value (%s or %s)", hostTransportQUIC, hostTransportSSH)
367				}
368				i++
369				value = args[i]
370			}
371			switch value {
372			case hostTransportQUIC, hostTransportSSH:
373				transport = value
374			default:
375				return command{}, usagef("invalid transport %q (want %q or %q)", value, hostTransportQUIC, hostTransportSSH)
376			}
377		case strings.HasPrefix(arg, "-"):
378			return command{}, usagef("unknown flag %q for `host add`", arg)
379		case target != "":
380			return command{}, usagef("`host add` accepts exactly one host target")
381		default:
382			target = arg
383		}
384	}
385	if target == "" {
386		return command{}, usagef("`host add` requires a host target")
387	}
388	if err := domain.ValidateRemoteHostTarget(target); err != nil {
389		return command{}, err
390	}
391	if transport == "" {
392		transport = hostTransportQUIC
393	}
394	return command{kind: kindHost, hostAction: hostActionAdd, hostTarget: target, hostTransport: transport}, nil
395}

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 12

Tags 11

↑↓ moveenter openesc close