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

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