detached 6b53f65

feat(cli): add vev cmd control subcommand with registry-driven help

commit
6b53f65fec39161468f070320a3f4f4ad6389166
author
brice · · 2026-07-24 04:10 UTC
parent
42775eb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +521 -1
A internal/app/cmd.go +190 -0
@@ -0,0 +1,190 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "sort"
10+ "strings"
11+ "text/tabwriter"
12+
13+ "github.com/bnema/vev/internal/adapters/ipc"
14+ "github.com/bnema/vev/internal/ports"
15+ commandusecase "github.com/bnema/vev/internal/usecase/command"
16+)
17+
18+type cmdInvocation struct {
19+ slug string
20+ args []string
21+ session string
22+ self bool
23+ jsonOut bool
24+ help bool
25+}
26+
27+type exitCoded struct {
28+ code int
29+ err error
30+}
31+
32+func (e *exitCoded) Error() string { return e.err.Error() }
33+func (e *exitCoded) Unwrap() error { return e.err }
34+
35+func exitCode(err error) int {
36+ if err == nil {
37+ return 0
38+ }
39+ if coded, ok := errors.AsType[*exitCoded](err); ok {
40+ return coded.code
41+ }
42+ if _, ok := errors.AsType[*usageError](err); ok {
43+ return 2
44+ }
45+ return 1
46+}
47+
48+// ExitCode maps application outcomes to the CLI contract.
49+func ExitCode(err error) int { return exitCode(err) }
50+
51+func parseCmdArgs(args []string) (cmdInvocation, error) {
52+ invocation := cmdInvocation{}
53+ index := 0
54+ for index < len(args) && invocation.slug == "" {
55+ switch args[index] {
56+ case "--help", "-h":
57+ invocation.help = true
58+ index++
59+ case "-s":
60+ if index+1 >= len(args) || args[index+1] == "" {
61+ return cmdInvocation{}, usagef("`cmd -s` requires a session name")
62+ }
63+ invocation.session = args[index+1]
64+ index += 2
65+ case "--self":
66+ invocation.self = true
67+ index++
68+ case "--json":
69+ invocation.jsonOut = true
70+ index++
71+ default:
72+ if strings.HasPrefix(args[index], "-") {
73+ return cmdInvocation{}, usagef("unknown flag %q for `cmd`", args[index])
74+ }
75+ invocation.slug = args[index]
76+ index++
77+ }
78+ }
79+ if invocation.slug == "" {
80+ invocation.help = true
81+ return invocation, nil
82+ }
83+ cmd, ok := commandusecase.BySlug(invocation.slug)
84+ if !ok || !cmd.Scriptable {
85+ return cmdInvocation{}, usagef("unknown command %q; see `vev cmd --help`", invocation.slug)
86+ }
87+ for _, arg := range args[index:] {
88+ switch arg {
89+ case "--help", "-h":
90+ invocation.help = true
91+ return invocation, nil
92+ case "--json":
93+ invocation.jsonOut = true
94+ default:
95+ invocation.args = append(invocation.args, arg)
96+ }
97+ }
98+ return invocation, nil
99+}
100+
101+func cmdHelp(invocation cmdInvocation) string {
102+ if invocation.slug != "" {
103+ cmd, _ := commandusecase.BySlug(invocation.slug)
104+ return fmt.Sprintf("usage: vev cmd [-s <session>] [--self] %s\n\n%s", cmd.Usage, cmd.Desc)
105+ }
106+ var out strings.Builder
107+ out.WriteString("usage: vev cmd [-s <session>] [--self] <command> [args]\n\ncommands:\n")
108+ writer := tabwriter.NewWriter(&out, 0, 4, 2, ' ', 0)
109+ commands := commandusecase.Registry()
110+ sort.Slice(commands, func(i, j int) bool { return commands[i].Slug < commands[j].Slug })
111+ for _, cmd := range commands {
112+ if cmd.Scriptable {
113+ _, _ = fmt.Fprintf(writer, " %s\t%s\n", cmd.Usage, cmd.Desc)
114+ }
115+ }
116+ _ = writer.Flush()
117+ return out.String()
118+}
119+
120+var errDaemonUnreachable = errors.New("daemon not running")
121+
122+type cmdDeps struct {
123+ stdout io.Writer
124+ getenv func(string) string
125+ dial func(context.Context, string) (ports.Transport, error)
126+}
127+
128+func runCmd(ctx context.Context, invocation cmdInvocation) error {
129+ return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial})
130+}
131+
132+func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
133+ if invocation.help {
134+ _, err := fmt.Fprintln(deps.stdout, cmdHelp(invocation))
135+ return err
136+ }
137+ request := ports.CommandRequest{
138+ Version: ports.ProtocolVersion,
139+ Slug: invocation.slug,
140+ Args: invocation.args,
141+ TargetSession: invocation.session,
142+ JSON: invocation.jsonOut,
143+ }
144+ if identity, ok := parseVEVEnv(deps.getenv("VEV")); ok {
145+ if request.TargetSession == "" {
146+ request.TargetSession = identity.session
147+ }
148+ if invocation.self || invocation.session == "" {
149+ request.TargetTab = identity.tab
150+ request.TargetPane = identity.pane
151+ }
152+ } else if invocation.self {
153+ return usagef("--self requires running inside a vev pane")
154+ }
155+
156+ transport, err := deps.dial(ctx, ipc.SocketDir())
157+ if err != nil {
158+ return &exitCoded{code: 3, err: fmt.Errorf("%w: %v", errDaemonUnreachable, err)}
159+ }
160+ defer func() { _ = transport.Close() }()
161+
162+ frame := ports.Frame{Type: ports.MsgCommand, Payload: ports.MarshalCommandRequest(request)}
163+ if err := transport.Send(frame); err != nil {
164+ return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
165+ }
166+ reply, err := transport.Recv()
167+ if err != nil {
168+ return &exitCoded{code: 3, err: fmt.Errorf("reading command reply: %w", err)}
169+ }
170+ if reply.Type != ports.MsgCommandResult {
171+ return fmt.Errorf("unexpected command reply type %d", reply.Type)
172+ }
173+ result, err := ports.UnmarshalCommandResult(reply.Payload)
174+ if err != nil {
175+ return fmt.Errorf("decoding command reply: %w", err)
176+ }
177+ if !result.OK {
178+ return errors.New(result.Text)
179+ }
180+ if result.Output != "" {
181+ if _, err := io.WriteString(deps.stdout, result.Output); err != nil {
182+ return err
183+ }
184+ if !strings.HasSuffix(result.Output, "\n") {
185+ _, err = fmt.Fprintln(deps.stdout)
186+ return err
187+ }
188+ }
189+ return nil
190+}
@@ -0,0 +1,190 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "sort"
10+ "strings"
11+ "text/tabwriter"
12+
13+ "github.com/bnema/vev/internal/adapters/ipc"
14+ "github.com/bnema/vev/internal/ports"
15+ commandusecase "github.com/bnema/vev/internal/usecase/command"
16+)
17+
18+type cmdInvocation struct {
19+ slug string
20+ args []string
21+ session string
22+ self bool
23+ jsonOut bool
24+ help bool
25+}
26+
27+type exitCoded struct {
28+ code int
29+ err error
30+}
31+
32+func (e *exitCoded) Error() string { return e.err.Error() }
33+func (e *exitCoded) Unwrap() error { return e.err }
34+
35+func exitCode(err error) int {
36+ if err == nil {
37+ return 0
38+ }
39+ if coded, ok := errors.AsType[*exitCoded](err); ok {
40+ return coded.code
41+ }
42+ if _, ok := errors.AsType[*usageError](err); ok {
43+ return 2
44+ }
45+ return 1
46+}
47+
48+// ExitCode maps application outcomes to the CLI contract.
49+func ExitCode(err error) int { return exitCode(err) }
50+
51+func parseCmdArgs(args []string) (cmdInvocation, error) {
52+ invocation := cmdInvocation{}
53+ index := 0
54+ for index < len(args) && invocation.slug == "" {
55+ switch args[index] {
56+ case "--help", "-h":
57+ invocation.help = true
58+ index++
59+ case "-s":
60+ if index+1 >= len(args) || args[index+1] == "" {
61+ return cmdInvocation{}, usagef("`cmd -s` requires a session name")
62+ }
63+ invocation.session = args[index+1]
64+ index += 2
65+ case "--self":
66+ invocation.self = true
67+ index++
68+ case "--json":
69+ invocation.jsonOut = true
70+ index++
71+ default:
72+ if strings.HasPrefix(args[index], "-") {
73+ return cmdInvocation{}, usagef("unknown flag %q for `cmd`", args[index])
74+ }
75+ invocation.slug = args[index]
76+ index++
77+ }
78+ }
79+ if invocation.slug == "" {
80+ invocation.help = true
81+ return invocation, nil
82+ }
83+ cmd, ok := commandusecase.BySlug(invocation.slug)
84+ if !ok || !cmd.Scriptable {
85+ return cmdInvocation{}, usagef("unknown command %q; see `vev cmd --help`", invocation.slug)
86+ }
87+ for _, arg := range args[index:] {
88+ switch arg {
89+ case "--help", "-h":
90+ invocation.help = true
91+ return invocation, nil
92+ case "--json":
93+ invocation.jsonOut = true
94+ default:
95+ invocation.args = append(invocation.args, arg)
96+ }
97+ }
98+ return invocation, nil
99+}
100+
101+func cmdHelp(invocation cmdInvocation) string {
102+ if invocation.slug != "" {
103+ cmd, _ := commandusecase.BySlug(invocation.slug)
104+ return fmt.Sprintf("usage: vev cmd [-s <session>] [--self] %s\n\n%s", cmd.Usage, cmd.Desc)
105+ }
106+ var out strings.Builder
107+ out.WriteString("usage: vev cmd [-s <session>] [--self] <command> [args]\n\ncommands:\n")
108+ writer := tabwriter.NewWriter(&out, 0, 4, 2, ' ', 0)
109+ commands := commandusecase.Registry()
110+ sort.Slice(commands, func(i, j int) bool { return commands[i].Slug < commands[j].Slug })
111+ for _, cmd := range commands {
112+ if cmd.Scriptable {
113+ _, _ = fmt.Fprintf(writer, " %s\t%s\n", cmd.Usage, cmd.Desc)
114+ }
115+ }
116+ _ = writer.Flush()
117+ return out.String()
118+}
119+
120+var errDaemonUnreachable = errors.New("daemon not running")
121+
122+type cmdDeps struct {
123+ stdout io.Writer
124+ getenv func(string) string
125+ dial func(context.Context, string) (ports.Transport, error)
126+}
127+
128+func runCmd(ctx context.Context, invocation cmdInvocation) error {
129+ return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial})
130+}
131+
132+func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
133+ if invocation.help {
134+ _, err := fmt.Fprintln(deps.stdout, cmdHelp(invocation))
135+ return err
136+ }
137+ request := ports.CommandRequest{
138+ Version: ports.ProtocolVersion,
139+ Slug: invocation.slug,
140+ Args: invocation.args,
141+ TargetSession: invocation.session,
142+ JSON: invocation.jsonOut,
143+ }
144+ if identity, ok := parseVEVEnv(deps.getenv("VEV")); ok {
145+ if request.TargetSession == "" {
146+ request.TargetSession = identity.session
147+ }
148+ if invocation.self || invocation.session == "" {
149+ request.TargetTab = identity.tab
150+ request.TargetPane = identity.pane
151+ }
152+ } else if invocation.self {
153+ return usagef("--self requires running inside a vev pane")
154+ }
155+
156+ transport, err := deps.dial(ctx, ipc.SocketDir())
157+ if err != nil {
158+ return &exitCoded{code: 3, err: fmt.Errorf("%w: %v", errDaemonUnreachable, err)}
159+ }
160+ defer func() { _ = transport.Close() }()
161+
162+ frame := ports.Frame{Type: ports.MsgCommand, Payload: ports.MarshalCommandRequest(request)}
163+ if err := transport.Send(frame); err != nil {
164+ return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
165+ }
166+ reply, err := transport.Recv()
167+ if err != nil {
168+ return &exitCoded{code: 3, err: fmt.Errorf("reading command reply: %w", err)}
169+ }
170+ if reply.Type != ports.MsgCommandResult {
171+ return fmt.Errorf("unexpected command reply type %d", reply.Type)
172+ }
173+ result, err := ports.UnmarshalCommandResult(reply.Payload)
174+ if err != nil {
175+ return fmt.Errorf("decoding command reply: %w", err)
176+ }
177+ if !result.OK {
178+ return errors.New(result.Text)
179+ }
180+ if result.Output != "" {
181+ if _, err := io.WriteString(deps.stdout, result.Output); err != nil {
182+ return err
183+ }
184+ if !strings.HasSuffix(result.Output, "\n") {
185+ _, err = fmt.Fprintln(deps.stdout)
186+ return err
187+ }
188+ }
189+ return nil
190+}
A internal/app/cmd_test.go +194 -0
@@ -0,0 +1,194 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "reflect"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+func TestParseCmdArgs(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ args []string
18+ want cmdInvocation
19+ wantErr bool
20+ }{
21+ {"bare cmd is help", []string{"cmd"}, cmdInvocation{help: true}, false},
22+ {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},
23+ {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
24+ {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
25+ {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
26+ {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
27+ {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false},
28+ {"passthrough args", []string{"cmd", "toast", "-l", "warn", "hello"}, cmdInvocation{slug: "toast", args: []string{"-l", "warn", "hello"}}, false},
29+ {"unknown slug", []string{"cmd", "frobnicate"}, cmdInvocation{}, true},
30+ {"non-scriptable slug", []string{"cmd", "detach"}, cmdInvocation{}, true},
31+ {"session missing value", []string{"cmd", "-s"}, cmdInvocation{}, true},
32+ {"unknown leading flag", []string{"cmd", "--wat"}, cmdInvocation{}, true},
33+ }
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ got, err := parseArgs(tt.args)
37+ if tt.wantErr {
38+ var usage *usageError
39+ if !errors.As(err, &usage) {
40+ t.Fatalf("parseArgs(%q) error = %v, want usage error", tt.args, err)
41+ }
42+ return
43+ }
44+ if err != nil {
45+ t.Fatalf("parseArgs(%q): %v", tt.args, err)
46+ }
47+ if got.kind != kindCmd || !reflect.DeepEqual(got.cmd, tt.want) {
48+ t.Fatalf("parseArgs(%q) = kind %v, cmd %#v, want %#v", tt.args, got.kind, got.cmd, tt.want)
49+ }
50+ })
51+ }
52+}
53+
54+func TestExitCodes(t *testing.T) {
55+ tests := []struct {
56+ name string
57+ err error
58+ want int
59+ }{
60+ {"success", nil, 0},
61+ {"command", errors.New("command failed"), 1},
62+ {"usage", &usageError{msg: "bad args"}, 2},
63+ {"unreachable", &exitCoded{code: 3, err: errDaemonUnreachable}, 3},
64+ }
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ if got := ExitCode(tt.err); got != tt.want {
68+ t.Fatalf("ExitCode(%v) = %d, want %d", tt.err, got, tt.want)
69+ }
70+ })
71+ }
72+}
73+
74+func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
75+ called := false
76+ out := new(strings.Builder)
77+ err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{
78+ stdout: out,
79+ getenv: func(string) string { return "" },
80+ dial: func(context.Context, string) (ports.Transport, error) {
81+ called = true
82+ return nil, errors.New("must not dial")
83+ },
84+ })
85+ if err != nil {
86+ t.Fatalf("runCmdWithDeps(help): %v", err)
87+ }
88+ if called {
89+ t.Fatal("help dialed daemon")
90+ }
91+ for _, want := range []string{"usage: vev cmd", "split-right", "list-panes"} {
92+ if !strings.Contains(out.String(), want) {
93+ t.Fatalf("help %q missing %q", out.String(), want)
94+ }
95+ }
96+ if strings.Contains(out.String(), "session-picker") {
97+ t.Fatalf("help leaked non-scriptable command: %q", out.String())
98+ }
99+}
100+
101+func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
102+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}}
103+ out := new(strings.Builder)
104+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{
105+ stdout: out,
106+ getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
107+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
108+ })
109+ if err != nil {
110+ t.Fatalf("runCmdWithDeps: %v", err)
111+ }
112+ if len(transport.sent) != 1 || transport.recvCalls != 1 || transport.closeCalls != 1 {
113+ t.Fatalf("transport calls: sent=%d recv=%d close=%d", len(transport.sent), transport.recvCalls, transport.closeCalls)
114+ }
115+ req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
116+ if err != nil {
117+ t.Fatalf("decode request: %v", err)
118+ }
119+ if transport.sent[0].Type != ports.MsgCommand || req.Slug != "split-right" || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
120+ t.Fatalf("request = type %d %+v", transport.sent[0].Type, req)
121+ }
122+ if out.String() != "done\n" {
123+ t.Fatalf("output = %q", out.String())
124+ }
125+}
126+
127+func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
128+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
129+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{
130+ stdout: io.Discard,
131+ getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
132+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
133+ })
134+ if err != nil {
135+ t.Fatalf("runCmdWithDeps: %v", err)
136+ }
137+ req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
138+ if err != nil {
139+ t.Fatalf("decode request: %v", err)
140+ }
141+ if req.TargetSession != "current" || req.TargetTab != "" || req.TargetPane != "" {
142+ t.Fatalf("explicit targeting request = %+v", req)
143+ }
144+}
145+
146+func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
147+ dials := 0
148+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
149+ stdout: io.Discard,
150+ getenv: func(string) string { return "" },
151+ dial: func(context.Context, string) (ports.Transport, error) {
152+ dials++
153+ return nil, errors.New("unreachable")
154+ },
155+ })
156+ if ExitCode(err) != 3 || dials != 1 {
157+ t.Fatalf("error=%v code=%d dials=%d, want unreachable/3 and one dial", err, ExitCode(err), dials)
158+ }
159+}
160+
161+func TestRunCmdDaemonErrorHasExitOne(t *testing.T) {
162+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"})}}
163+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
164+ stdout: io.Discard,
165+ getenv: func(string) string { return "" },
166+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
167+ })
168+ if err == nil || ExitCode(err) != 1 || !strings.Contains(err.Error(), "no live sessions") {
169+ t.Fatalf("error=%v code=%d", err, ExitCode(err))
170+ }
171+}
172+
173+type cmdTestTransport struct {
174+ sent []ports.Frame
175+ recv ports.Frame
176+ recvErr error
177+ recvCalls int
178+ closeCalls int
179+}
180+
181+func (t *cmdTestTransport) Send(frame ports.Frame) error {
182+ t.sent = append(t.sent, frame)
183+ return nil
184+}
185+
186+func (t *cmdTestTransport) Recv() (ports.Frame, error) {
187+ t.recvCalls++
188+ return t.recv, t.recvErr
189+}
190+
191+func (t *cmdTestTransport) Close() error {
192+ t.closeCalls++
193+ return nil
194+}
@@ -0,0 +1,194 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "reflect"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+func TestParseCmdArgs(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ args []string
18+ want cmdInvocation
19+ wantErr bool
20+ }{
21+ {"bare cmd is help", []string{"cmd"}, cmdInvocation{help: true}, false},
22+ {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},
23+ {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
24+ {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
25+ {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
26+ {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
27+ {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false},
28+ {"passthrough args", []string{"cmd", "toast", "-l", "warn", "hello"}, cmdInvocation{slug: "toast", args: []string{"-l", "warn", "hello"}}, false},
29+ {"unknown slug", []string{"cmd", "frobnicate"}, cmdInvocation{}, true},
30+ {"non-scriptable slug", []string{"cmd", "detach"}, cmdInvocation{}, true},
31+ {"session missing value", []string{"cmd", "-s"}, cmdInvocation{}, true},
32+ {"unknown leading flag", []string{"cmd", "--wat"}, cmdInvocation{}, true},
33+ }
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ got, err := parseArgs(tt.args)
37+ if tt.wantErr {
38+ var usage *usageError
39+ if !errors.As(err, &usage) {
40+ t.Fatalf("parseArgs(%q) error = %v, want usage error", tt.args, err)
41+ }
42+ return
43+ }
44+ if err != nil {
45+ t.Fatalf("parseArgs(%q): %v", tt.args, err)
46+ }
47+ if got.kind != kindCmd || !reflect.DeepEqual(got.cmd, tt.want) {
48+ t.Fatalf("parseArgs(%q) = kind %v, cmd %#v, want %#v", tt.args, got.kind, got.cmd, tt.want)
49+ }
50+ })
51+ }
52+}
53+
54+func TestExitCodes(t *testing.T) {
55+ tests := []struct {
56+ name string
57+ err error
58+ want int
59+ }{
60+ {"success", nil, 0},
61+ {"command", errors.New("command failed"), 1},
62+ {"usage", &usageError{msg: "bad args"}, 2},
63+ {"unreachable", &exitCoded{code: 3, err: errDaemonUnreachable}, 3},
64+ }
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ if got := ExitCode(tt.err); got != tt.want {
68+ t.Fatalf("ExitCode(%v) = %d, want %d", tt.err, got, tt.want)
69+ }
70+ })
71+ }
72+}
73+
74+func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
75+ called := false
76+ out := new(strings.Builder)
77+ err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{
78+ stdout: out,
79+ getenv: func(string) string { return "" },
80+ dial: func(context.Context, string) (ports.Transport, error) {
81+ called = true
82+ return nil, errors.New("must not dial")
83+ },
84+ })
85+ if err != nil {
86+ t.Fatalf("runCmdWithDeps(help): %v", err)
87+ }
88+ if called {
89+ t.Fatal("help dialed daemon")
90+ }
91+ for _, want := range []string{"usage: vev cmd", "split-right", "list-panes"} {
92+ if !strings.Contains(out.String(), want) {
93+ t.Fatalf("help %q missing %q", out.String(), want)
94+ }
95+ }
96+ if strings.Contains(out.String(), "session-picker") {
97+ t.Fatalf("help leaked non-scriptable command: %q", out.String())
98+ }
99+}
100+
101+func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
102+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}}
103+ out := new(strings.Builder)
104+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{
105+ stdout: out,
106+ getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
107+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
108+ })
109+ if err != nil {
110+ t.Fatalf("runCmdWithDeps: %v", err)
111+ }
112+ if len(transport.sent) != 1 || transport.recvCalls != 1 || transport.closeCalls != 1 {
113+ t.Fatalf("transport calls: sent=%d recv=%d close=%d", len(transport.sent), transport.recvCalls, transport.closeCalls)
114+ }
115+ req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
116+ if err != nil {
117+ t.Fatalf("decode request: %v", err)
118+ }
119+ if transport.sent[0].Type != ports.MsgCommand || req.Slug != "split-right" || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
120+ t.Fatalf("request = type %d %+v", transport.sent[0].Type, req)
121+ }
122+ if out.String() != "done\n" {
123+ t.Fatalf("output = %q", out.String())
124+ }
125+}
126+
127+func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
128+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
129+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{
130+ stdout: io.Discard,
131+ getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
132+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
133+ })
134+ if err != nil {
135+ t.Fatalf("runCmdWithDeps: %v", err)
136+ }
137+ req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
138+ if err != nil {
139+ t.Fatalf("decode request: %v", err)
140+ }
141+ if req.TargetSession != "current" || req.TargetTab != "" || req.TargetPane != "" {
142+ t.Fatalf("explicit targeting request = %+v", req)
143+ }
144+}
145+
146+func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
147+ dials := 0
148+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
149+ stdout: io.Discard,
150+ getenv: func(string) string { return "" },
151+ dial: func(context.Context, string) (ports.Transport, error) {
152+ dials++
153+ return nil, errors.New("unreachable")
154+ },
155+ })
156+ if ExitCode(err) != 3 || dials != 1 {
157+ t.Fatalf("error=%v code=%d dials=%d, want unreachable/3 and one dial", err, ExitCode(err), dials)
158+ }
159+}
160+
161+func TestRunCmdDaemonErrorHasExitOne(t *testing.T) {
162+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"})}}
163+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
164+ stdout: io.Discard,
165+ getenv: func(string) string { return "" },
166+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
167+ })
168+ if err == nil || ExitCode(err) != 1 || !strings.Contains(err.Error(), "no live sessions") {
169+ t.Fatalf("error=%v code=%d", err, ExitCode(err))
170+ }
171+}
172+
173+type cmdTestTransport struct {
174+ sent []ports.Frame
175+ recv ports.Frame
176+ recvErr error
177+ recvCalls int
178+ closeCalls int
179+}
180+
181+func (t *cmdTestTransport) Send(frame ports.Frame) error {
182+ t.sent = append(t.sent, frame)
183+ return nil
184+}
185+
186+func (t *cmdTestTransport) Recv() (ports.Frame, error) {
187+ t.recvCalls++
188+ return t.recv, t.recvErr
189+}
190+
191+func (t *cmdTestTransport) Close() error {
192+ t.closeCalls++
193+ return nil
194+}
M internal/app/run.go +11 -0
@@ -58,6 +58,7 @@ const (
5858 kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent
5959 kindList
6060 kindKill
61+ kindCmd
6162 kindDaemon
6263 kindStdio
6364 kindUDPBootstrap
@@ -75,6 +76,7 @@ type command struct {
7576 remoteTarget string
7677 killAll bool
7778 killDaemon bool
79+ cmd cmdInvocation
7880 }
7981
8082 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -99,6 +101,7 @@ usage:
99101 vev kill <name> kill a session
100102 vev kill --all kill all sessions and stop the daemon
101103 vev kill --daemon stop the active vev daemon
104+ vev cmd <command> run a control command (vev cmd --help)
102105 vev --help show this help
103106 vev --version show version`
104107
@@ -193,6 +196,12 @@ func parseArgs(args []string) (command, error) {
193196 return cmd, nil
194197 case "ls", "list":
195198 return command{kind: kindList}, nil
199+ case "cmd":
200+ invocation, err := parseCmdArgs(args[1:])
201+ if err != nil {
202+ return command{}, err
203+ }
204+ return command{kind: kindCmd, cmd: invocation}, nil
196205 case "kill":
197206 if len(args) < 2 || args[1] == "" {
198207 return command{}, usagef("`kill` requires a session name, --all, or --daemon")
@@ -244,6 +253,8 @@ func dispatch(ctx context.Context, cmd command) error {
244253 return runList(ctx)
245254 case kindKill:
246255 return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)
256+ case kindCmd:
257+ return runCmd(ctx, cmd.cmd)
247258 case kindAttach:
248259 return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)
249260 default:
@@ -58,6 +58,7 @@ const (
58 kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent58 kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent
59 kindList59 kindList
60 kindKill60 kindKill
61+ kindCmd
61 kindDaemon62 kindDaemon
62 kindStdio63 kindStdio
63 kindUDPBootstrap64 kindUDPBootstrap
@@ -75,6 +76,7 @@ type command struct {
75 remoteTarget string76 remoteTarget string
76 killAll bool77 killAll bool
77 killDaemon bool78 killDaemon bool
79+ cmd cmdInvocation
78 }80 }
79 81
80 // usageError is a user-facing argument error; the app prints it (with usage)82 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -99,6 +101,7 @@ usage:
99 vev kill <name> kill a session101 vev kill <name> kill a session
100 vev kill --all kill all sessions and stop the daemon102 vev kill --all kill all sessions and stop the daemon
101 vev kill --daemon stop the active vev daemon103 vev kill --daemon stop the active vev daemon
104+ vev cmd <command> run a control command (vev cmd --help)
102 vev --help show this help105 vev --help show this help
103 vev --version show version`106 vev --version show version`
104 107
@@ -193,6 +196,12 @@ func parseArgs(args []string) (command, error) {
193 return cmd, nil196 return cmd, nil
194 case "ls", "list":197 case "ls", "list":
195 return command{kind: kindList}, nil198 return command{kind: kindList}, nil
199+ case "cmd":
200+ invocation, err := parseCmdArgs(args[1:])
201+ if err != nil {
202+ return command{}, err
203+ }
204+ return command{kind: kindCmd, cmd: invocation}, nil
196 case "kill":205 case "kill":
197 if len(args) < 2 || args[1] == "" {206 if len(args) < 2 || args[1] == "" {
198 return command{}, usagef("`kill` requires a session name, --all, or --daemon")207 return command{}, usagef("`kill` requires a session name, --all, or --daemon")
@@ -244,6 +253,8 @@ func dispatch(ctx context.Context, cmd command) error {
244 return runList(ctx)253 return runList(ctx)
245 case kindKill:254 case kindKill:
246 return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)255 return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)
256+ case kindCmd:
257+ return runCmd(ctx, cmd.cmd)
247 case kindAttach:258 case kindAttach:
248 return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)259 return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)
249 default:260 default:
A internal/app/vevenv.go +87 -0
@@ -0,0 +1,87 @@
1+package app
2+
3+import "strings"
4+
5+type vevEnv struct {
6+ session string
7+ tab string
8+ pane string
9+}
10+
11+// parseVEVEnv strictly parses the identity injected into pane PTYs. All three
12+// unique fields are required and components use the daemon's uppercase %XX
13+// escaping.
14+func parseVEVEnv(value string) (vevEnv, bool) {
15+ if value == "" {
16+ return vevEnv{}, false
17+ }
18+ var out vevEnv
19+ seen := make(map[string]bool, 3)
20+ for part := range strings.SplitSeq(value, ",") {
21+ key, encoded, found := strings.Cut(part, "=")
22+ if !found || seen[key] {
23+ return vevEnv{}, false
24+ }
25+ decoded, ok := unescapeVEVComponent(encoded)
26+ if !ok || decoded == "" {
27+ return vevEnv{}, false
28+ }
29+ switch key {
30+ case "session":
31+ out.session = decoded
32+ case "tab":
33+ out.tab = decoded
34+ case "pane":
35+ out.pane = decoded
36+ default:
37+ return vevEnv{}, false
38+ }
39+ seen[key] = true
40+ }
41+ if len(seen) != 3 || out.session == "" || out.tab == "" || out.pane == "" {
42+ return vevEnv{}, false
43+ }
44+ return out, true
45+}
46+
47+func unescapeVEVComponent(value string) (string, bool) {
48+ var out strings.Builder
49+ for i := 0; i < len(value); i++ {
50+ if value[i] != '%' {
51+ if !isUnescapedVEVByte(value[i]) {
52+ return "", false
53+ }
54+ out.WriteByte(value[i])
55+ continue
56+ }
57+ if i+2 >= len(value) {
58+ return "", false
59+ }
60+ hi, highOK := upperHexValue(value[i+1])
61+ lo, lowOK := upperHexValue(value[i+2])
62+ if !highOK || !lowOK {
63+ return "", false
64+ }
65+ out.WriteByte(hi<<4 | lo)
66+ i += 2
67+ }
68+ return out.String(), true
69+}
70+
71+func isUnescapedVEVByte(value byte) bool {
72+ return value >= 'A' && value <= 'Z' ||
73+ value >= 'a' && value <= 'z' ||
74+ value >= '0' && value <= '9' ||
75+ value == '.' || value == '_' || value == '-'
76+}
77+
78+func upperHexValue(value byte) (byte, bool) {
79+ switch {
80+ case value >= '0' && value <= '9':
81+ return value - '0', true
82+ case value >= 'A' && value <= 'F':
83+ return value - 'A' + 10, true
84+ default:
85+ return 0, false
86+ }
87+}
@@ -0,0 +1,87 @@
1+package app
2+
3+import "strings"
4+
5+type vevEnv struct {
6+ session string
7+ tab string
8+ pane string
9+}
10+
11+// parseVEVEnv strictly parses the identity injected into pane PTYs. All three
12+// unique fields are required and components use the daemon's uppercase %XX
13+// escaping.
14+func parseVEVEnv(value string) (vevEnv, bool) {
15+ if value == "" {
16+ return vevEnv{}, false
17+ }
18+ var out vevEnv
19+ seen := make(map[string]bool, 3)
20+ for part := range strings.SplitSeq(value, ",") {
21+ key, encoded, found := strings.Cut(part, "=")
22+ if !found || seen[key] {
23+ return vevEnv{}, false
24+ }
25+ decoded, ok := unescapeVEVComponent(encoded)
26+ if !ok || decoded == "" {
27+ return vevEnv{}, false
28+ }
29+ switch key {
30+ case "session":
31+ out.session = decoded
32+ case "tab":
33+ out.tab = decoded
34+ case "pane":
35+ out.pane = decoded
36+ default:
37+ return vevEnv{}, false
38+ }
39+ seen[key] = true
40+ }
41+ if len(seen) != 3 || out.session == "" || out.tab == "" || out.pane == "" {
42+ return vevEnv{}, false
43+ }
44+ return out, true
45+}
46+
47+func unescapeVEVComponent(value string) (string, bool) {
48+ var out strings.Builder
49+ for i := 0; i < len(value); i++ {
50+ if value[i] != '%' {
51+ if !isUnescapedVEVByte(value[i]) {
52+ return "", false
53+ }
54+ out.WriteByte(value[i])
55+ continue
56+ }
57+ if i+2 >= len(value) {
58+ return "", false
59+ }
60+ hi, highOK := upperHexValue(value[i+1])
61+ lo, lowOK := upperHexValue(value[i+2])
62+ if !highOK || !lowOK {
63+ return "", false
64+ }
65+ out.WriteByte(hi<<4 | lo)
66+ i += 2
67+ }
68+ return out.String(), true
69+}
70+
71+func isUnescapedVEVByte(value byte) bool {
72+ return value >= 'A' && value <= 'Z' ||
73+ value >= 'a' && value <= 'z' ||
74+ value >= '0' && value <= '9' ||
75+ value == '.' || value == '_' || value == '-'
76+}
77+
78+func upperHexValue(value byte) (byte, bool) {
79+ switch {
80+ case value >= '0' && value <= '9':
81+ return value - '0', true
82+ case value >= 'A' && value <= 'F':
83+ return value - 'A' + 10, true
84+ default:
85+ return 0, false
86+ }
87+}
A internal/app/vevenv_test.go +38 -0
@@ -0,0 +1,38 @@
1+package app
2+
3+import "testing"
4+
5+func TestParseVEVEnv(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ env string
9+ session string
10+ tab string
11+ pane string
12+ ok bool
13+ }{
14+ {"normal", "session=dev,tab=t_abc,pane=p_def", "dev", "t_abc", "p_def", true},
15+ {"escaped components", "session=a%20b,tab=t%2Cx,pane=p%3Dy", "a b", "t,x", "p=y", true},
16+ {"empty", "", "", "", "", false},
17+ {"missing pane", "session=dev,tab=t_abc", "", "", "", false},
18+ {"garbage", "not-a-vev-value", "", "", "", false},
19+ {"unknown key", "session=dev,tab=t_abc,pane=p_def,extra=x", "", "", "", false},
20+ {"duplicate key", "session=dev,session=other,tab=t_abc,pane=p_def", "", "", "", false},
21+ {"bad escape", "session=dev%2,tab=t_abc,pane=p_def", "", "", "", false},
22+ {"lowercase escape is invalid", "session=dev%2fwork,tab=t_abc,pane=p_def", "", "", "", false},
23+ {"unescaped byte is invalid", "session=dev work,tab=t_abc,pane=p_def", "", "", "", false},
24+ {"raw UTF-8 is invalid", "session=dév,tab=t_abc,pane=p_def", "", "", "", false},
25+ {"empty component", "session=dev,tab=,pane=p_def", "", "", "", false},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ got, ok := parseVEVEnv(tt.env)
30+ if ok != tt.ok {
31+ t.Fatalf("parseVEVEnv(%q) ok = %v, want %v", tt.env, ok, tt.ok)
32+ }
33+ if ok && (got.session != tt.session || got.tab != tt.tab || got.pane != tt.pane) {
34+ t.Fatalf("parseVEVEnv(%q) = %+v", tt.env, got)
35+ }
36+ })
37+ }
38+}
@@ -0,0 +1,38 @@
1+package app
2+
3+import "testing"
4+
5+func TestParseVEVEnv(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ env string
9+ session string
10+ tab string
11+ pane string
12+ ok bool
13+ }{
14+ {"normal", "session=dev,tab=t_abc,pane=p_def", "dev", "t_abc", "p_def", true},
15+ {"escaped components", "session=a%20b,tab=t%2Cx,pane=p%3Dy", "a b", "t,x", "p=y", true},
16+ {"empty", "", "", "", "", false},
17+ {"missing pane", "session=dev,tab=t_abc", "", "", "", false},
18+ {"garbage", "not-a-vev-value", "", "", "", false},
19+ {"unknown key", "session=dev,tab=t_abc,pane=p_def,extra=x", "", "", "", false},
20+ {"duplicate key", "session=dev,session=other,tab=t_abc,pane=p_def", "", "", "", false},
21+ {"bad escape", "session=dev%2,tab=t_abc,pane=p_def", "", "", "", false},
22+ {"lowercase escape is invalid", "session=dev%2fwork,tab=t_abc,pane=p_def", "", "", "", false},
23+ {"unescaped byte is invalid", "session=dev work,tab=t_abc,pane=p_def", "", "", "", false},
24+ {"raw UTF-8 is invalid", "session=dév,tab=t_abc,pane=p_def", "", "", "", false},
25+ {"empty component", "session=dev,tab=,pane=p_def", "", "", "", false},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ got, ok := parseVEVEnv(tt.env)
30+ if ok != tt.ok {
31+ t.Fatalf("parseVEVEnv(%q) ok = %v, want %v", tt.env, ok, tt.ok)
32+ }
33+ if ok && (got.session != tt.session || got.tab != tt.tab || got.pane != tt.pane) {
34+ t.Fatalf("parseVEVEnv(%q) = %+v", tt.env, got)
35+ }
36+ })
37+ }
38+}
M main.go +1 -1
@@ -11,7 +11,7 @@ import (
1111 func main() {
1212 if err := app.Run(os.Args[1:]); err != nil {
1313 fmt.Fprintln(os.Stderr, cliError(err))
14- os.Exit(1)
14+ os.Exit(app.ExitCode(err))
1515 }
1616 }
1717
@@ -11,7 +11,7 @@ import (
11 func main() {11 func main() {
12 if err := app.Run(os.Args[1:]); err != nil {12 if err := app.Run(os.Args[1:]); err != nil {
13 fmt.Fprintln(os.Stderr, cliError(err))13 fmt.Fprintln(os.Stderr, cliError(err))
14- os.Exit(1)14+ os.Exit(app.ExitCode(err))
15 }15 }
16 }16 }
17 17

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