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}