termd/internal/cli/cli.go
2026-07-02 22:13:49 -03:00

267 lines
7.1 KiB
Go

// Package cli implements the termd subcommands as functions over the REST
// client, writing to an io.Writer — so tests can run them in-process against
// a test server.
package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"strconv"
"strings"
"termd/internal/api"
"termd/internal/client"
"termd/internal/keymap"
)
func printJSON(out io.Writer, v any) error {
enc := json.NewEncoder(out)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(v)
}
// New implements `termd new [flags] [command args...]`.
func New(ctx context.Context, c *client.Client, args []string, out io.Writer) error {
fs := flag.NewFlagSet("new", flag.ContinueOnError)
fs.SetOutput(out)
cols := fs.Int("cols", 80, "terminal width")
rows := fs.Int("rows", 24, "terminal height")
cwd := fs.String("cwd", "", "working directory")
termName := fs.String("term", "", "TERM value (default xterm-256color)")
var envs envFlags
fs.Var(&envs, "env", "extra K=V environment entry (repeatable)")
asJSON := fs.Bool("json", false, "print the full API response")
if err := fs.Parse(args); err != nil {
return err
}
info, err := c.Create(ctx, api.CreateSessionRequest{
Command: fs.Args(),
Cwd: *cwd,
Env: envs.m,
Cols: *cols,
Rows: *rows,
Term: *termName,
})
if err != nil {
return err
}
if *asJSON {
return printJSON(out, info)
}
_, err = fmt.Fprintln(out, info.ID)
return err
}
type envFlags struct{ m map[string]string }
func (e *envFlags) String() string { return "" }
func (e *envFlags) Set(v string) error {
k, val, ok := strings.Cut(v, "=")
if !ok {
return fmt.Errorf("want K=V, got %q", v)
}
if e.m == nil {
e.m = make(map[string]string)
}
e.m[k] = val
return nil
}
// Ls implements `termd ls`.
func Ls(ctx context.Context, c *client.Client, args []string, out io.Writer) error {
fs := flag.NewFlagSet("ls", flag.ContinueOnError)
fs.SetOutput(out)
asJSON := fs.Bool("json", false, "print the full API response")
if err := fs.Parse(args); err != nil {
return err
}
list, err := c.List(ctx)
if err != nil {
return err
}
if *asJSON {
return printJSON(out, list)
}
tw := newTabWriter(out)
fmt.Fprintln(tw, "ID\tPID\tSIZE\tSTATE\tTITLE\tCOMMAND")
for _, s := range list.Sessions {
state := "running"
if s.Exited {
state = fmt.Sprintf("exited(%d)", deref(s.ExitCode))
}
fmt.Fprintf(tw, "%s\t%d\t%dx%d\t%s\t%s\t%s\n",
s.ID, s.PID, s.Cols, s.Rows, state, s.Title, strings.Join(s.Command, " "))
}
return tw.Flush()
}
func deref(p *int) int {
if p == nil {
return 0
}
return *p
}
// Info implements `termd info <id>`.
func Info(ctx context.Context, c *client.Client, args []string, out io.Writer) error {
fs := flag.NewFlagSet("info", flag.ContinueOnError)
fs.SetOutput(out)
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return fmt.Errorf("usage: termd info <id>")
}
info, err := c.Info(ctx, fs.Arg(0))
if err != nil {
return err
}
return printJSON(out, info)
}
// Kill implements `termd kill <id>`.
func Kill(ctx context.Context, c *client.Client, args []string, out io.Writer) error {
fs := flag.NewFlagSet("kill", flag.ContinueOnError)
fs.SetOutput(out)
sig := fs.String("signal", "HUP", "signal to send (HUP, TERM, KILL, INT, ...)")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return fmt.Errorf("usage: termd kill [--signal SIG] <id>")
}
return c.Delete(ctx, fs.Arg(0), *sig)
}
// Send implements `termd send <id> <arg>...` — tmux send-keys style: an arg
// that parses as a key name is sent as that key, anything else as literal
// text. -l forces all args literal.
func Send(ctx context.Context, c *client.Client, args []string, out io.Writer) error {
fs := flag.NewFlagSet("send", flag.ContinueOnError)
fs.SetOutput(out)
literal := fs.Bool("l", false, "treat all arguments as literal text")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 2 {
return fmt.Errorf("usage: termd send [-l] <id> <keys-or-text>...")
}
id := fs.Arg(0)
var items []api.InputItem
for _, a := range fs.Args()[1:] {
a := a
if !*literal {
if _, err := keymap.Parse(a); err == nil {
items = append(items, api.InputItem{Key: &a})
continue
}
}
items = append(items, api.InputItem{Text: &a})
}
_, err := c.Input(ctx, id, items)
return err
}
// Screen implements `termd screen <id>`.
func Screen(ctx context.Context, c *client.Client, args []string, out io.Writer) error {
fs := flag.NewFlagSet("screen", flag.ContinueOnError)
fs.SetOutput(out)
asJSON := fs.Bool("json", false, "print the full screen state as JSON")
raw := fs.Bool("raw", false, "print the styled ANSI rendering")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return fmt.Errorf("usage: termd screen [--json|--raw] <id>")
}
scr, err := c.Screen(ctx, fs.Arg(0), *raw)
if err != nil {
return err
}
switch {
case *asJSON:
return printJSON(out, scr)
case *raw:
_, err := io.WriteString(out, scr.Raw+"\n")
return err
default:
_, err := io.WriteString(out, strings.Join(scr.Lines, "\n")+"\n")
return err
}
}
// Resize implements `termd resize <id> <cols>x<rows>`.
func Resize(ctx context.Context, c *client.Client, args []string, out io.Writer) error {
fs := flag.NewFlagSet("resize", flag.ContinueOnError)
fs.SetOutput(out)
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 2 {
return fmt.Errorf("usage: termd resize <id> <cols>x<rows>")
}
colsStr, rowsStr, ok := strings.Cut(fs.Arg(1), "x")
if !ok {
return fmt.Errorf("want <cols>x<rows>, got %q", fs.Arg(1))
}
cols, err := strconv.Atoi(colsStr)
if err != nil {
return fmt.Errorf("bad cols %q: %w", colsStr, err)
}
rows, err := strconv.Atoi(rowsStr)
if err != nil {
return fmt.Errorf("bad rows %q: %w", rowsStr, err)
}
return c.Resize(ctx, fs.Arg(0), cols, rows)
}
// Mouse implements `termd mouse <id> <type> <x>,<y>`.
func Mouse(ctx context.Context, c *client.Client, args []string, out io.Writer) error {
fs := flag.NewFlagSet("mouse", flag.ContinueOnError)
fs.SetOutput(out)
button := fs.String("button", "", "left|middle|right|wheel-up|wheel-down (default left, or wheel-up for scroll)")
var mods modFlags
fs.Var(&mods, "mod", "modifier: ctrl, alt or shift (repeatable)")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 3 {
return fmt.Errorf("usage: termd mouse [--button B] [--mod M]... <id> <click|press|release|move|drag|scroll> <x>,<y>")
}
id, typ, at := fs.Arg(0), fs.Arg(1), fs.Arg(2)
xStr, yStr, ok := strings.Cut(at, ",")
if !ok {
return fmt.Errorf("want <x>,<y>, got %q", at)
}
x, err := strconv.Atoi(xStr)
if err != nil {
return fmt.Errorf("bad x %q: %w", xStr, err)
}
y, err := strconv.Atoi(yStr)
if err != nil {
return fmt.Errorf("bad y %q: %w", yStr, err)
}
b := *button
if b == "" {
if typ == "scroll" {
b = "wheel-up"
} else if typ != "move" {
b = "left"
}
}
return c.Mouse(ctx, id, api.MouseRequest{
Type: typ, Button: b, X: x, Y: y, Modifiers: mods.v,
})
}
type modFlags struct{ v []string }
func (m *modFlags) String() string { return strings.Join(m.v, ",") }
func (m *modFlags) Set(v string) error {
m.v = append(m.v, v)
return nil
}