Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion actions/kill.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
32 changes: 23 additions & 9 deletions actions/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
41 changes: 37 additions & 4 deletions actions/send_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}

Expand All @@ -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
Expand All @@ -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"},
Expand Down Expand Up @@ -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
{
Expand All @@ -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
}
Expand All @@ -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)
}
})
}
}
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
1 change: 1 addition & 0 deletions domain/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
52 changes: 51 additions & 1 deletion integration/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"testing"
"time"


"github.com/AxeForging/yoink/domain"
)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
16 changes: 8 additions & 8 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,20 +86,20 @@ func main() {
Aliases: []string{"w"},
ArgsUsage: "<id|alias>...",
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,
},
{
Expand All @@ -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,
},
{
Expand Down
Loading
Loading