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}