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
4 changes: 2 additions & 2 deletions .claude/skills/shelli-auto-detector/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,8 @@ shelli read openclaw --strip-ansi
| `npm test` | Bash | Exits with status |
| `npm run dev` + "watch for errors" | shelli | Long-running |
| `openclaw tui` | shelli | TUI with two-step submit |
| `vim file.txt` | Bash (not shelli) | Full-screen TUI, use `sed`/`Edit` |
| `htop` | Bash (not shelli) | Full-screen TUI, use `ps aux` |
| `vim file.txt` | shelli (--tui) | Full-screen TUI, use TUI mode with snapshot |
| `htop` | shelli (--tui) | Full-screen TUI, use TUI mode with snapshot |

## Proactive Suggestions

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ shelli daemon --stopped-ttl 1h

## Escape Sequences

When using `send --raw`, escape sequences are interpreted:
When using `send`, escape sequences are always interpreted:

| Sequence | Character | Description |
|----------|-----------|-------------|
Expand All @@ -425,16 +425,16 @@ When using `send --raw`, escape sequences are interpreted:

```bash
# Interrupt a long-running command
shelli send myshell "\x03" --raw
shelli send myshell "\x03"

# Send EOF to close stdin
shelli send myshell "\x04" --raw
shelli send myshell "\x04"

# Tab completion
shelli send myshell "doc\t" --raw
shelli send myshell "doc\t"

# Answer a yes/no prompt without newline, then send newline
shelli send myshell "y" --raw
shelli send myshell "y"
shelli send myshell "" # just newline
```

Expand Down
29 changes: 16 additions & 13 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ var createCmd = &cobra.Command{
}

var (
createCmdFlag string
createJsonFlag bool
createEnvFlag []string
createCwdFlag string
createColsFlag int
createRowsFlag int
createTUIFlag bool
createCmdFlag string
createJsonFlag bool
createEnvFlag []string
createCwdFlag string
createColsFlag int
createRowsFlag int
createTUIFlag bool
createIfNotExistsFlag bool
)

func init() {
Expand All @@ -33,6 +34,7 @@ func init() {
createCmd.Flags().IntVar(&createColsFlag, "cols", 80, "Terminal columns")
createCmd.Flags().IntVar(&createRowsFlag, "rows", 24, "Terminal rows")
createCmd.Flags().BoolVar(&createTUIFlag, "tui", false, "Enable TUI mode (auto-truncate buffer on frame boundaries)")
createCmd.Flags().BoolVar(&createIfNotExistsFlag, "if-not-exists", false, "Return existing session if already running instead of error")
}

func runCreate(cmd *cobra.Command, args []string) error {
Expand All @@ -44,12 +46,13 @@ func runCreate(cmd *cobra.Command, args []string) error {
}

data, err := client.Create(name, daemon.CreateOptions{
Command: createCmdFlag,
Env: createEnvFlag,
Cwd: createCwdFlag,
Cols: createColsFlag,
Rows: createRowsFlag,
TUIMode: createTUIFlag,
Command: createCmdFlag,
Env: createEnvFlag,
Cwd: createCwdFlag,
Cols: createColsFlag,
Rows: createRowsFlag,
TUIMode: createTUIFlag,
IfNotExists: createIfNotExistsFlag,
})
if err != nil {
return err
Expand Down
6 changes: 5 additions & 1 deletion cmd/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"

"github.com/schovi/shelli/internal/ansi"
Expand Down Expand Up @@ -74,7 +75,10 @@ func runExec(cmd *cobra.Command, args []string) error {
TimeoutSec: execTimeoutFlag,
})
if err != nil {
return err
if result == nil || result.Output == "" {
return err
}
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
}

output := result.Output
Expand Down
6 changes: 6 additions & 0 deletions cmd/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ func runInfo(cmd *cobra.Command, args []string) error {
fmt.Printf("Buffer: %d bytes\n", info.BytesBuffered)
fmt.Printf("ReadPos: %d\n", info.ReadPosition)
fmt.Printf("Size: %dx%d\n", info.Cols, info.Rows)
if len(info.Cursors) > 0 {
fmt.Printf("Cursors:\n")
for name, pos := range info.Cursors {
fmt.Printf(" %s: %d\n", name, pos)
}
}
}
return nil
}
Expand Down
9 changes: 7 additions & 2 deletions cmd/kill.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ func init() {
var killCmd = &cobra.Command{
Use: "kill <name>",
Short: "Kill a session",
Args: cobra.ExactArgs(1),
RunE: runKill,
Long: `Kill a session: terminates the process (if running) and permanently deletes all stored output.

To stop a session but keep output accessible for later reading, use 'stop' instead.

This is a destructive operation and cannot be undone.`,
Args: cobra.ExactArgs(1),
RunE: runKill,
}

func runKill(cmd *cobra.Command, args []string) error {
Expand Down
12 changes: 10 additions & 2 deletions cmd/read.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ func runRead(cmd *cobra.Command, args []string) error {
return fmt.Errorf("--wait and --settle are mutually exclusive")
}

if readCursorFlag != "" && (readSnapshotFlag || readFollowFlag) {
return fmt.Errorf("--cursor cannot be combined with --snapshot or --follow")
}

if readSnapshotFlag {
if readFollowFlag || readAllFlag || hasWait {
return fmt.Errorf("--snapshot cannot be combined with --follow, --all, or --wait")
Expand Down Expand Up @@ -136,9 +140,13 @@ func runRead(cmd *cobra.Command, args []string) error {
output = daemon.LimitLines(output, headLines, tailLines)
}
if readCursorFlag != "" {
client.ReadWithCursor(name, "new", readCursorFlag, 0, 0)
if _, _, advErr := client.ReadWithCursor(name, "new", readCursorFlag, 0, 0); advErr != nil {
return fmt.Errorf("advance cursor: %w", advErr)
}
} else {
client.Read(name, "new", 0, 0)
if _, _, advErr := client.Read(name, "new", 0, 0); advErr != nil {
return fmt.Errorf("advance read position: %w", advErr)
}
}
}
} else {
Expand Down
9 changes: 8 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ import (
var rootCmd = &cobra.Command{
Use: "shelli",
Short: "Shell Interactive - session manager for AI agents",
Long: `shelli (Shell Interactive) enables AI agents to interact with persistent interactive shell sessions (REPLs, SSH, database CLIs, etc.)`,
Long: `shelli (Shell Interactive) enables AI agents to interact with persistent interactive shell sessions (REPLs, SSH, database CLIs, etc.)

Quick start:
shelli create myshell # Start a shell session
shelli exec myshell "echo hello" # Run command and get output
shelli read myshell # Read new output
shelli stop myshell # Stop (keeps output)
shelli kill myshell # Kill (deletes everything)`,
}

func Execute() {
Expand Down
18 changes: 15 additions & 3 deletions cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,25 @@ func runSearch(cmd *cobra.Command, args []string) error {

startLine := match.LineNumber - len(match.Before)
for j, line := range match.Before {
fmt.Printf("%4d: %s\n", startLine+j, ansi.Strip(line))
display := line
if searchStripAnsiFlag {
display = ansi.Strip(line)
}
fmt.Printf("%4d: %s\n", startLine+j, display)
}

fmt.Printf(">%3d: %s\n", match.LineNumber, ansi.Strip(match.Line))
display := match.Line
if searchStripAnsiFlag {
display = ansi.Strip(match.Line)
}
fmt.Printf(">%3d: %s\n", match.LineNumber, display)

for j, line := range match.After {
fmt.Printf("%4d: %s\n", match.LineNumber+1+j, ansi.Strip(line))
display := line
if searchStripAnsiFlag {
display = ansi.Strip(line)
}
fmt.Printf("%4d: %s\n", match.LineNumber+1+j, display)
}
}

Expand Down
81 changes: 51 additions & 30 deletions internal/daemon/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,16 @@ func (c *Client) EnsureDaemon() error {
}
}

return fmt.Errorf("daemon failed to start")
sockPath := ""
if sp, err := SocketPath(); err == nil {
sockPath = sp
}
if sockPath != "" {
if _, err := os.Stat(sockPath); err == nil {
return fmt.Errorf("daemon failed to start within %s. Stale socket found at %s. Try: rm %s && shelli daemon", DaemonStartTimeout, sockPath, sockPath)
}
}
return fmt.Errorf("daemon failed to start within %s. Socket: %s. Try: rm %s && shelli daemon", DaemonStartTimeout, sockPath, sockPath)
}

func (c *Client) Ping() bool {
Expand All @@ -59,12 +68,13 @@ func (c *Client) Ping() bool {
}

type CreateOptions struct {
Command string
Env []string
Cwd string
Cols int
Rows int
TUIMode bool
Command string
Env []string
Cwd string
Cols int
Rows int
TUIMode bool
IfNotExists bool
}

func (c *Client) Create(name string, opts CreateOptions) (map[string]interface{}, error) {
Expand All @@ -73,14 +83,15 @@ func (c *Client) Create(name string, opts CreateOptions) (map[string]interface{}
}

resp, err := c.send(Request{
Action: "create",
Name: name,
Command: opts.Command,
Env: opts.Env,
Cwd: opts.Cwd,
Cols: opts.Cols,
Rows: opts.Rows,
TUIMode: opts.TUIMode,
Action: "create",
Name: name,
Command: opts.Command,
Env: opts.Env,
Cwd: opts.Cwd,
Cols: opts.Cols,
Rows: opts.Rows,
TUIMode: opts.TUIMode,
IfNotExists: opts.IfNotExists,
})
if err != nil {
return nil, err
Expand Down Expand Up @@ -241,17 +252,18 @@ type SearchResponse struct {
}

type InfoResponse struct {
Name string `json:"name"`
State string `json:"state"`
PID int `json:"pid"`
Command string `json:"command"`
CreatedAt string `json:"created_at"`
StoppedAt string `json:"stopped_at,omitempty"`
BytesBuffered int64 `json:"bytes_buffered"`
ReadPosition int64 `json:"read_position"`
Cols int `json:"cols"`
Rows int `json:"rows"`
Uptime float64 `json:"uptime_seconds,omitempty"`
Name string `json:"name"`
State string `json:"state"`
PID int `json:"pid"`
Command string `json:"command"`
CreatedAt string `json:"created_at"`
StoppedAt string `json:"stopped_at,omitempty"`
BytesBuffered int64 `json:"bytes_buffered"`
ReadPosition int64 `json:"read_position"`
Cols int `json:"cols"`
Rows int `json:"rows"`
Uptime float64 `json:"uptime_seconds,omitempty"`
Cursors map[string]int64 `json:"cursors,omitempty"`
}

func (c *Client) Clear(name string) error {
Expand Down Expand Up @@ -385,6 +397,7 @@ type ExecOptions struct {
SettleMs int
WaitPattern string
TimeoutSec int
SettleSet bool
}

type ExecResult struct {
Expand All @@ -404,7 +417,7 @@ func (c *Client) Exec(name string, opts ExecOptions) (*ExecResult, error) {
}

settleMs := opts.SettleMs
if opts.WaitPattern == "" && settleMs == 0 {
if opts.WaitPattern == "" && settleMs == 0 && !opts.SettleSet {
settleMs = 500
}

Expand All @@ -423,17 +436,25 @@ func (c *Client) Exec(name string, opts ExecOptions) (*ExecResult, error) {
SizeFunc: func() (int, error) { return c.Size(name) },
},
)

result := &ExecResult{Input: opts.Input, Output: output, Position: pos}
if err != nil {
return nil, err
return result, err
}

return &ExecResult{Input: opts.Input, Output: output, Position: pos}, nil
return result, nil
}

func (c *Client) send(req Request) (*Response, error) {
sockPath := SocketPath()
var sockPath string
if c.customSocketPath != "" {
sockPath = c.customSocketPath
} else {
var err error
sockPath, err = SocketPath()
if err != nil {
return nil, err
}
}
conn, err := net.Dial("unix", sockPath)
if err != nil {
Expand Down
Loading