diff --git a/EXAMPLES.md b/EXAMPLES.md index 58063e6..04e58e2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -198,15 +198,15 @@ You want to script interactions with a conversational CLI (Claude, ChatGPT CLI, yoink run --alias claude "claude" sleep 3 -# Send a prompt with realistic typing -yoink send claude "Explain this error: ConnectionRefused on port 5432" --type --delay 30 +# Send a prompt with realistic typing and submit it with TUI-style Enter +yoink send claude "Explain this error: ConnectionRefused on port 5432" --type --delay 30 --submit # Wait for response to appear, then read it sleep 10 yoink snapshot claude # Send follow-up -yoink send claude "How do I fix it?" --type +yoink send claude "How do I fix it?" --type --submit # Navigate through output yoink send claude --key pageup @@ -635,6 +635,7 @@ yoink clean | Run in background | `yoink run --alias name "command"` | | Check new output | `yoink log name --new` | | Send input | `yoink send name "text"` | +| Submit TUI prompt | `yoink send name "text" --submit` | | Send secret input | `yoink send-redacted name "$SECRET"` | | Send key combo | `yoink send name --key ctrl+c` | | Wait with timeout | `yoink wait name --timeout 60` | diff --git a/README.md b/README.md index 030de76..fc66f88 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ yoink send deploy "hello world" --type --delay 100 # Send without pressing Enter (useful for prompts that read a single key) yoink send deploy "y" --no-enter +# Submit text with carriage-return Enter (useful for TUIs like Claude) +yoink send claude "say only OK" --submit + # Send a named key sequence (arrow keys, ctrl combos, function keys, etc.) yoink send deploy --key up yoink send deploy --key ctrl+c @@ -94,7 +97,7 @@ yoink runs a lightweight daemon that holds PTY (pseudo-terminal) sessions open f - **log**: Shows buffered output (last 1000 lines kept per process). Use `--new` to see only lines since the last `--new` call - **wait**: Polls a process until it finishes, streaming new output to stdout on each cycle. Exits 0 on success, propagates the process exit code on failure, or exits 124 on timeout (matches GNU `timeout` convention). Flags: `--timeout` (seconds, 0=forever), `--poll` (seconds, default 2), `--message` (custom timeout error) - **snapshot** (`snap`): Renders the current VT100 screen of a process as plain text. Unlike `log`, this correctly handles TUI applications (htop, vim, Claude, etc.) by replaying their escape sequences through a VT100 emulator and returning only the rendered content. Works on both running and finished processes -- **send**: Writes text to the process stdin (fire-and-forget). Flags: `--type` (character by character), `--delay` (ms between chars, default 50), `--no-enter` (omit trailing newline), `--key` (send a named key sequence — e.g. `up`, `down`, `ctrl+c`, `f1`, `pageup`; implies `--no-enter`) +- **send**: Writes text to the process stdin (fire-and-forget). Flags: `--type` (character by character), `--delay` (ms between chars, default 50), `--no-enter` (omit trailing newline), `--submit` (append carriage-return Enter for TUIs like Claude), `--key` (send a named key sequence — e.g. `up`, `down`, `ctrl+c`, `f1`, `pageup`; implies `--no-enter`) - **send-redacted**: Same as send, but input is masked in logs. Supports the same flags - **attach**: Bridges your terminal to the process PTY for full interactive control - **kill**: Sends SIGTERM (or SIGKILL with --force) diff --git a/actions/kill.go b/actions/kill.go index e5ba257..096c26e 100644 --- a/actions/kill.go +++ b/actions/kill.go @@ -43,6 +43,6 @@ func (a *KillAction) Execute(c *cli.Context) error { if c.Bool("force") { sig = "SIGKILL" } - fmt.Printf("Sent %s to process %d\n", sig, id) + fmt.Printf("Sent %s to process %d\n", sig, responseID(resp.ID, id)) return nil } diff --git a/actions/send.go b/actions/send.go index 369df75..3a64216 100644 --- a/actions/send.go +++ b/actions/send.go @@ -9,23 +9,29 @@ import ( "github.com/urfave/cli" ) -// resolveInput returns the input string and whether no-enter should be implied, +// resolveInput returns the input string and trailing-enter behavior, // handling both plain text and --key mode. -func resolveInput(c *cli.Context) (input string, noEnter bool, err error) { +func resolveInput(c *cli.Context) (input string, noEnter, submit bool, err error) { keyName := c.String("key") hasText := c.NArg() >= 2 if keyName != "" && hasText { - return "", false, fmt.Errorf("--key and text input are mutually exclusive") + return "", false, false, fmt.Errorf("--key and text input are mutually exclusive") + } + if c.Bool("no-enter") && c.Bool("submit") { + return "", false, false, fmt.Errorf("--no-enter and --submit are mutually exclusive") } if keyName != "" { + if c.Bool("submit") { + return "", false, false, fmt.Errorf("--key and --submit are mutually exclusive") + } seq, err := services.ResolveKey(keyName) if err != nil { - return "", false, err + return "", false, false, err } - return seq, true, nil // key sequences never get a trailing newline + return seq, true, false, nil // key sequences never get a trailing newline } - return strings.Join(c.Args()[1:], " "), c.Bool("no-enter"), nil + return strings.Join(c.Args()[1:], " "), c.Bool("no-enter"), c.Bool("submit"), nil } // SendAction sends input text to a running process. @@ -52,7 +58,7 @@ func (a *SendAction) Execute(c *cli.Context) error { id, alias := parseProcessRef(c.Args().First()) - input, noEnter, err := resolveInput(c) + input, noEnter, submit, err := resolveInput(c) if err != nil { return err } @@ -66,6 +72,7 @@ func (a *SendAction) Execute(c *cli.Context) error { TypeMode: c.Bool("type"), TypeDelay: c.Int("delay"), NoEnter: noEnter, + Submit: submit, }) if err != nil { return err @@ -75,9 +82,16 @@ func (a *SendAction) Execute(c *cli.Context) error { } if a.redacted { - fmt.Printf("Sent redacted input to process %d\n", id) + fmt.Printf("Sent redacted input to process %d\n", responseID(resp.ID, id)) } else { - fmt.Printf("Sent to process %d: %s\n", id, input) + fmt.Printf("Sent to process %d: %s\n", responseID(resp.ID, id), input) } return nil } + +func responseID(respID, fallbackID int) int { + if respID != 0 { + return respID + } + return fallbackID +} diff --git a/actions/send_test.go b/actions/send_test.go index bb59b8f..ee5810a 100644 --- a/actions/send_test.go +++ b/actions/send_test.go @@ -7,12 +7,13 @@ import ( "github.com/urfave/cli" ) -func newSendContext(t *testing.T, args []string, key string, noEnter bool) *cli.Context { +func newSendContext(t *testing.T, args []string, key string, noEnter, submit bool) *cli.Context { t.Helper() app := cli.NewApp() set := flag.NewFlagSet("send", flag.ContinueOnError) set.String("key", "", "") set.Bool("no-enter", false, "") + set.Bool("submit", false, "") if err := set.Parse(args); err != nil { t.Fatalf("flag parse error: %v", err) } @@ -26,6 +27,11 @@ func newSendContext(t *testing.T, args []string, key string, noEnter bool) *cli. t.Fatalf("set no-enter flag: %v", err) } } + if submit { + if err := set.Set("submit", "true"); err != nil { + t.Fatalf("set submit flag: %v", err) + } + } return cli.NewContext(app, set, nil) } @@ -35,8 +41,10 @@ func TestResolveInput(t *testing.T) { args []string // positional args (alias + optional text) key string noEnter bool + submit bool wantInput string wantNoEnter bool + wantSubmit bool wantErr bool }{ // Plain text: returns text, respects --no-enter @@ -59,6 +67,14 @@ func TestResolveInput(t *testing.T) { wantInput: "hello world", wantNoEnter: false, }, + { + name: "plain text with submit", + args: []string{"myproc", "hello"}, + submit: true, + wantInput: "hello", + wantNoEnter: false, + wantSubmit: true, + }, { name: "no text args returns empty string", args: []string{"myproc"}, @@ -96,6 +112,20 @@ func TestResolveInput(t *testing.T) { key: "up", wantErr: true, }, + { + name: "--no-enter and --submit are mutually exclusive", + args: []string{"myproc", "sometext"}, + noEnter: true, + submit: true, + wantErr: true, + }, + { + name: "--key and --submit are mutually exclusive", + args: []string{"myproc"}, + key: "enter", + submit: true, + wantErr: true, + }, // Error: unknown key { @@ -108,11 +138,11 @@ func TestResolveInput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx := newSendContext(t, tt.args, tt.key, tt.noEnter) - input, noEnter, err := resolveInput(ctx) + ctx := newSendContext(t, tt.args, tt.key, tt.noEnter, tt.submit) + input, noEnter, submit, err := resolveInput(ctx) if tt.wantErr { if err == nil { - t.Errorf("resolveInput() expected error, got input=%q noEnter=%v", input, noEnter) + t.Errorf("resolveInput() expected error, got input=%q noEnter=%v submit=%v", input, noEnter, submit) } return } @@ -125,6 +155,9 @@ func TestResolveInput(t *testing.T) { if noEnter != tt.wantNoEnter { t.Errorf("noEnter = %v, want %v", noEnter, tt.wantNoEnter) } + if submit != tt.wantSubmit { + t.Errorf("submit = %v, want %v", submit, tt.wantSubmit) + } }) } } diff --git a/docs/README.md b/docs/README.md index 28b39cc..5b91bcd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ yoink snapshot 1 --> yoinkd --> VT100 emulator framebuf yoink send 1 "y" --> yoinkd --> write to PTY fd (whole string + newline) yoink send 1 "y" --type --> yoinkd --> write to PTY fd char by char with delay yoink send 1 "y" --no-enter --> yoinkd --> write to PTY fd without trailing newline +yoink send 1 "y" --submit --> yoinkd --> write to PTY fd with carriage-return Enter yoink send 1 --key up --> yoinkd --> write ESC[A to PTY fd (named key sequence) yoink attach 1 --> yoinkd --> bridge terminal <-> PTY ``` diff --git a/domain/types.go b/domain/types.go index cd554b5..b1c85f4 100644 --- a/domain/types.go +++ b/domain/types.go @@ -67,6 +67,7 @@ type DaemonRequest struct { TypeMode bool `json:"type_mode,omitempty"` TypeDelay int `json:"type_delay,omitempty"` // milliseconds between characters NoEnter bool `json:"no_enter,omitempty"` + Submit bool `json:"submit,omitempty"` } // DaemonResponse is sent from daemon to client over the Unix socket. diff --git a/flags.go b/flags.go index 107b909..d32ed24 100644 --- a/flags.go +++ b/flags.go @@ -61,6 +61,11 @@ var noEnterFlag = cli.BoolFlag{ Usage: "Do not send a newline after the input", } +var submitFlag = cli.BoolFlag{ + Name: "submit", + Usage: "Submit text with carriage-return Enter (CR), useful for TUIs like Claude", +} + var keyFlag = cli.StringFlag{ Name: "key, k", Usage: "Send a named key sequence instead of text (e.g. up, down, ctrl+c, f1, pageup)", diff --git a/integration/cli_test.go b/integration/cli_test.go index 7ae2835..f63dead 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -13,7 +13,6 @@ import ( "testing" "time" - "github.com/AxeForging/yoink/domain" ) @@ -232,6 +231,26 @@ func TestIntegration_KillAndClean(t *testing.T) { } } +func TestIntegration_KillAliasPrintsResolvedID(t *testing.T) { + env := newTestEnv(t) + + env.sendDaemonRequest(domain.DaemonRequest{ + Type: domain.ReqRun, + Command: "sleep 300", + Alias: "sleepy", + }) + + time.Sleep(300 * time.Millisecond) + + out, err := env.run("kill", "sleepy") + if err != nil { + t.Fatalf("kill by alias failed: %v\noutput: %s", err, out) + } + if !strings.Contains(out, "process 1") { + t.Fatalf("expected kill output to include resolved process ID, got: %q", out) + } +} + func TestIntegration_MultipleProcesses(t *testing.T) { env := newTestEnv(t) @@ -516,6 +535,37 @@ func TestIntegration_SendNoEnter(t *testing.T) { } } +func TestIntegration_SendSubmitUsesCarriageReturn(t *testing.T) { + env := newTestEnv(t) + + env.sendDaemonRequest(domain.DaemonRequest{ + Type: domain.ReqRun, + Command: "sh -c 'stty raw -echo; dd bs=1 count=2 2>/dev/null | od -An -tx1; sleep 1'", + Alias: "raw-reader", + }) + + time.Sleep(300 * time.Millisecond) + + out, err := env.run("send", "raw-reader", "x", "--submit") + if err != nil { + t.Fatalf("send --submit failed: %v\noutput: %s", err, out) + } + if !strings.Contains(out, "process 1") { + t.Fatalf("expected send output to include resolved process ID, got: %q", out) + } + + time.Sleep(500 * time.Millisecond) + + logResp := env.sendDaemonRequest(domain.DaemonRequest{ + Type: domain.ReqLog, + Alias: "raw-reader", + Lines: 20, + }) + if !strings.Contains(logResp.Output, "78 0d") { + t.Errorf("expected --submit to send CR after text, got: %q", logResp.Output) + } +} + func TestIntegration_Snapshot_PlainProcess(t *testing.T) { env := newTestEnv(t) diff --git a/main.go b/main.go index 8f98e55..21298b4 100644 --- a/main.go +++ b/main.go @@ -86,20 +86,20 @@ func main() { Aliases: []string{"w"}, ArgsUsage: "...", Usage: "Wait for one or more processes to finish, streaming output (exit 124 on timeout)", - Flags: []cli.Flag{timeoutFlag, pollFlag, messageFlag}, - Action: waitAction.Execute, + Flags: []cli.Flag{timeoutFlag, pollFlag, messageFlag}, + Action: waitAction.Execute, }, { Name: "send", Aliases: []string{"s"}, Usage: "Send input text to a running process", - Flags: []cli.Flag{typeModeFlag, typeDelayFlag, noEnterFlag, keyFlag}, + Flags: []cli.Flag{typeModeFlag, typeDelayFlag, noEnterFlag, submitFlag, keyFlag}, Action: sendAction.Execute, }, { - Name: "send-redacted", - Usage: "Send input to a process (redacted in logs)", - Flags: []cli.Flag{typeModeFlag, typeDelayFlag, noEnterFlag}, + Name: "send-redacted", + Usage: "Send input to a process (redacted in logs)", + Flags: []cli.Flag{typeModeFlag, typeDelayFlag, noEnterFlag, submitFlag}, Action: sendRedactedAction.Execute, }, { @@ -122,8 +122,8 @@ func main() { Action: killAction.Execute, }, { - Name: "clean", - Usage: "Remove finished/failed processes from the list", + Name: "clean", + Usage: "Remove finished/failed processes from the list", Action: cleanAction.Execute, }, { diff --git a/services/daemon_service.go b/services/daemon_service.go index 7bfc141..26cfe78 100644 --- a/services/daemon_service.go +++ b/services/daemon_service.go @@ -156,7 +156,7 @@ func (ds *DaemonServer) handleLog(conn net.Conn, req domain.DaemonRequest) { ds.sendResponse(conn, domain.DaemonResponse{Error: err.Error()}) return } - ds.sendResponse(conn, domain.DaemonResponse{Success: true, Output: output}) + ds.sendResponse(conn, domain.DaemonResponse{Success: true, ID: req.ID, Output: output}) } func (ds *DaemonServer) handleSend(conn net.Conn, req domain.DaemonRequest) { @@ -166,12 +166,13 @@ func (ds *DaemonServer) handleSend(conn net.Conn, req domain.DaemonRequest) { TypeMode: req.TypeMode, TypeDelay: time.Duration(req.TypeDelay) * time.Millisecond, NoEnter: req.NoEnter, + Submit: req.Submit, } if err := ds.pm.SendInput(req.ID, req.Input, opts); err != nil { ds.sendResponse(conn, domain.DaemonResponse{Error: err.Error()}) return } - ds.sendResponse(conn, domain.DaemonResponse{Success: true}) + ds.sendResponse(conn, domain.DaemonResponse{Success: true, ID: req.ID}) } func (ds *DaemonServer) handleAttach(conn net.Conn, req domain.DaemonRequest) { @@ -182,7 +183,7 @@ func (ds *DaemonServer) handleAttach(conn net.Conn, req domain.DaemonRequest) { return } - ds.sendResponse(conn, domain.DaemonResponse{Success: true}) + ds.sendResponse(conn, domain.DaemonResponse{Success: true, ID: req.ID}) ds.pm.AttachOutput(req.ID, conn) //nolint:errcheck defer ds.pm.DetachOutput(req.ID) @@ -208,7 +209,7 @@ func (ds *DaemonServer) handleSnapshot(conn net.Conn, req domain.DaemonRequest) ds.sendResponse(conn, domain.DaemonResponse{Error: err.Error()}) return } - ds.sendResponse(conn, domain.DaemonResponse{Success: true, Output: output}) + ds.sendResponse(conn, domain.DaemonResponse{Success: true, ID: req.ID, Output: output}) } func (ds *DaemonServer) handleKill(conn net.Conn, req domain.DaemonRequest) { @@ -217,7 +218,7 @@ func (ds *DaemonServer) handleKill(conn net.Conn, req domain.DaemonRequest) { ds.sendResponse(conn, domain.DaemonResponse{Error: err.Error()}) return } - ds.sendResponse(conn, domain.DaemonResponse{Success: true}) + ds.sendResponse(conn, domain.DaemonResponse{Success: true, ID: req.ID}) } func (ds *DaemonServer) handleClean(conn net.Conn) { diff --git a/services/daemon_service_test.go b/services/daemon_service_test.go index 3d2ad3a..22b4ece 100644 --- a/services/daemon_service_test.go +++ b/services/daemon_service_test.go @@ -376,6 +376,9 @@ func TestDaemon_OperationsByAlias(t *testing.T) { if !sendResp.Success { t.Fatalf("send via alias failed: %s", sendResp.Error) } + if sendResp.ID != 1 { + t.Errorf("expected send response to include resolved ID 1, got %d", sendResp.ID) + } time.Sleep(500 * time.Millisecond) @@ -388,6 +391,9 @@ func TestDaemon_OperationsByAlias(t *testing.T) { if !logResp.Success { t.Fatalf("log via alias failed: %s", logResp.Error) } + if logResp.ID != 1 { + t.Errorf("expected log response to include resolved ID 1, got %d", logResp.ID) + } if !strings.Contains(logResp.Output, "alias-test-input") { t.Errorf("expected 'alias-test-input' in output, got: %q", logResp.Output) } @@ -400,6 +406,9 @@ func TestDaemon_OperationsByAlias(t *testing.T) { if !killResp.Success { t.Fatalf("kill via alias failed: %s", killResp.Error) } + if killResp.ID != 1 { + t.Errorf("expected kill response to include resolved ID 1, got %d", killResp.ID) + } } func TestDaemon_DuplicateAlias(t *testing.T) { diff --git a/services/process_manager.go b/services/process_manager.go index 37948aa..899df74 100644 --- a/services/process_manager.go +++ b/services/process_manager.go @@ -241,6 +241,7 @@ type SendOptions struct { TypeMode bool // send character by character TypeDelay time.Duration // delay between characters in type mode NoEnter bool // omit the trailing newline + Submit bool // append carriage-return Enter instead of newline } // SendInput writes text to the process PTY. @@ -285,6 +286,10 @@ func (pm *ProcessManager) SendInput(id int, input string, opts SendOptions) erro } } + if opts.Submit { + _, err := mp.pty.Write([]byte("\r")) + return err + } if !opts.NoEnter { _, err := mp.pty.Write([]byte("\n")) return err diff --git a/services/process_manager_test.go b/services/process_manager_test.go index ff29a7d..d69da13 100644 --- a/services/process_manager_test.go +++ b/services/process_manager_test.go @@ -583,6 +583,24 @@ func TestProcessManager_NoEnter_NoLineEcho(t *testing.T) { waitForOutput(t, pm, id, "got-partial", 5*time.Second) } +func TestProcessManager_SubmitUsesCarriageReturn(t *testing.T) { + pm := NewProcessManager(100) + defer pm.Shutdown() + + id, err := pm.Spawn("sh -c 'stty raw -echo; dd bs=1 count=2 2>/dev/null | od -An -tx1; sleep 1'", "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + time.Sleep(200 * time.Millisecond) + + if err := pm.SendInput(id, "x", SendOptions{Submit: true}); err != nil { + t.Fatalf("SendInput (submit) failed: %v", err) + } + + waitForOutput(t, pm, id, "78 0d", 5*time.Second) +} + func TestProcessManager_TypeMode_WithRedaction(t *testing.T) { pm := NewProcessManager(100) defer pm.Shutdown()