Skip to content

Commit bbd4c20

Browse files
authored
feat: Phase 4 usability and interface improvements (#6)
14 improvements across CLI, MCP, and daemon targeting better UX, correctness, and developer ergonomics per consolidated code review plan. - Show partial output on timeout instead of discarding (exec, read MCP) - Add cursor+snapshot/follow mutual exclusion validation (CLI + MCP) - Include cursor positions in info response - Snapshot no longer updates global read position (peek semantics) - Idempotent create with --if-not-exists / if_not_exists parameter - Stable list ordering by creation time - Better daemon startup error messages with socket path and hints - MCP settle_ms: 0 now means "don't wait" (use *int to distinguish) - Improved kill command help explaining destructive behavior - SocketPath() now returns error instead of silently failing - MemoryStorage.LoadMeta deep copies Cursors map and StoppedAt pointer - Search display respects --strip-ansi flag (was always stripping) - Remove redundant flock from FileStorage (Go mutex is sufficient) - Update stale docs: remove --raw references, update TUI guidance - Add quick-start examples to root --help
1 parent 7d035ba commit bbd4c20

File tree

14 files changed

+228
-97
lines changed

14 files changed

+228
-97
lines changed

.claude/skills/shelli-auto-detector/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,8 @@ shelli read openclaw --strip-ansi
209209
| `npm test` | Bash | Exits with status |
210210
| `npm run dev` + "watch for errors" | shelli | Long-running |
211211
| `openclaw tui` | shelli | TUI with two-step submit |
212-
| `vim file.txt` | Bash (not shelli) | Full-screen TUI, use `sed`/`Edit` |
213-
| `htop` | Bash (not shelli) | Full-screen TUI, use `ps aux` |
212+
| `vim file.txt` | shelli (--tui) | Full-screen TUI, use TUI mode with snapshot |
213+
| `htop` | shelli (--tui) | Full-screen TUI, use TUI mode with snapshot |
214214

215215
## Proactive Suggestions
216216

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ shelli daemon --stopped-ttl 1h
398398

399399
## Escape Sequences
400400

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

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

426426
```bash
427427
# Interrupt a long-running command
428-
shelli send myshell "\x03" --raw
428+
shelli send myshell "\x03"
429429

430430
# Send EOF to close stdin
431-
shelli send myshell "\x04" --raw
431+
shelli send myshell "\x04"
432432

433433
# Tab completion
434-
shelli send myshell "doc\t" --raw
434+
shelli send myshell "doc\t"
435435

436436
# Answer a yes/no prompt without newline, then send newline
437-
shelli send myshell "y" --raw
437+
shelli send myshell "y"
438438
shelli send myshell "" # just newline
439439
```
440440

cmd/create.go

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ var createCmd = &cobra.Command{
1616
}
1717

1818
var (
19-
createCmdFlag string
20-
createJsonFlag bool
21-
createEnvFlag []string
22-
createCwdFlag string
23-
createColsFlag int
24-
createRowsFlag int
25-
createTUIFlag bool
19+
createCmdFlag string
20+
createJsonFlag bool
21+
createEnvFlag []string
22+
createCwdFlag string
23+
createColsFlag int
24+
createRowsFlag int
25+
createTUIFlag bool
26+
createIfNotExistsFlag bool
2627
)
2728

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

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

4648
data, err := client.Create(name, daemon.CreateOptions{
47-
Command: createCmdFlag,
48-
Env: createEnvFlag,
49-
Cwd: createCwdFlag,
50-
Cols: createColsFlag,
51-
Rows: createRowsFlag,
52-
TUIMode: createTUIFlag,
49+
Command: createCmdFlag,
50+
Env: createEnvFlag,
51+
Cwd: createCwdFlag,
52+
Cols: createColsFlag,
53+
Rows: createRowsFlag,
54+
TUIMode: createTUIFlag,
55+
IfNotExists: createIfNotExistsFlag,
5356
})
5457
if err != nil {
5558
return err

cmd/exec.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"encoding/json"
55
"fmt"
6+
"os"
67
"strings"
78

89
"github.com/schovi/shelli/internal/ansi"
@@ -74,7 +75,10 @@ func runExec(cmd *cobra.Command, args []string) error {
7475
TimeoutSec: execTimeoutFlag,
7576
})
7677
if err != nil {
77-
return err
78+
if result == nil || result.Output == "" {
79+
return err
80+
}
81+
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
7882
}
7983

8084
output := result.Output

cmd/info.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ func runInfo(cmd *cobra.Command, args []string) error {
5454
fmt.Printf("Buffer: %d bytes\n", info.BytesBuffered)
5555
fmt.Printf("ReadPos: %d\n", info.ReadPosition)
5656
fmt.Printf("Size: %dx%d\n", info.Cols, info.Rows)
57+
if len(info.Cursors) > 0 {
58+
fmt.Printf("Cursors:\n")
59+
for name, pos := range info.Cursors {
60+
fmt.Printf(" %s: %d\n", name, pos)
61+
}
62+
}
5763
}
5864
return nil
5965
}

cmd/kill.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ func init() {
1717
var killCmd = &cobra.Command{
1818
Use: "kill <name>",
1919
Short: "Kill a session",
20-
Args: cobra.ExactArgs(1),
21-
RunE: runKill,
20+
Long: `Kill a session: terminates the process (if running) and permanently deletes all stored output.
21+
22+
To stop a session but keep output accessible for later reading, use 'stop' instead.
23+
24+
This is a destructive operation and cannot be undone.`,
25+
Args: cobra.ExactArgs(1),
26+
RunE: runKill,
2227
}
2328

2429
func runKill(cmd *cobra.Command, args []string) error {

cmd/read.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ func runRead(cmd *cobra.Command, args []string) error {
8989
return fmt.Errorf("--wait and --settle are mutually exclusive")
9090
}
9191

92+
if readCursorFlag != "" && (readSnapshotFlag || readFollowFlag) {
93+
return fmt.Errorf("--cursor cannot be combined with --snapshot or --follow")
94+
}
95+
9296
if readSnapshotFlag {
9397
if readFollowFlag || readAllFlag || hasWait {
9498
return fmt.Errorf("--snapshot cannot be combined with --follow, --all, or --wait")
@@ -136,9 +140,13 @@ func runRead(cmd *cobra.Command, args []string) error {
136140
output = daemon.LimitLines(output, headLines, tailLines)
137141
}
138142
if readCursorFlag != "" {
139-
client.ReadWithCursor(name, "new", readCursorFlag, 0, 0)
143+
if _, _, advErr := client.ReadWithCursor(name, "new", readCursorFlag, 0, 0); advErr != nil {
144+
return fmt.Errorf("advance cursor: %w", advErr)
145+
}
140146
} else {
141-
client.Read(name, "new", 0, 0)
147+
if _, _, advErr := client.Read(name, "new", 0, 0); advErr != nil {
148+
return fmt.Errorf("advance read position: %w", advErr)
149+
}
142150
}
143151
}
144152
} else {

cmd/root.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@ import (
99
var rootCmd = &cobra.Command{
1010
Use: "shelli",
1111
Short: "Shell Interactive - session manager for AI agents",
12-
Long: `shelli (Shell Interactive) enables AI agents to interact with persistent interactive shell sessions (REPLs, SSH, database CLIs, etc.)`,
12+
Long: `shelli (Shell Interactive) enables AI agents to interact with persistent interactive shell sessions (REPLs, SSH, database CLIs, etc.)
13+
14+
Quick start:
15+
shelli create myshell # Start a shell session
16+
shelli exec myshell "echo hello" # Run command and get output
17+
shelli read myshell # Read new output
18+
shelli stop myshell # Stop (keeps output)
19+
shelli kill myshell # Kill (deletes everything)`,
1320
}
1421

1522
func Execute() {

cmd/search.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,25 @@ func runSearch(cmd *cobra.Command, args []string) error {
9595

9696
startLine := match.LineNumber - len(match.Before)
9797
for j, line := range match.Before {
98-
fmt.Printf("%4d: %s\n", startLine+j, ansi.Strip(line))
98+
display := line
99+
if searchStripAnsiFlag {
100+
display = ansi.Strip(line)
101+
}
102+
fmt.Printf("%4d: %s\n", startLine+j, display)
99103
}
100104

101-
fmt.Printf(">%3d: %s\n", match.LineNumber, ansi.Strip(match.Line))
105+
display := match.Line
106+
if searchStripAnsiFlag {
107+
display = ansi.Strip(match.Line)
108+
}
109+
fmt.Printf(">%3d: %s\n", match.LineNumber, display)
102110

103111
for j, line := range match.After {
104-
fmt.Printf("%4d: %s\n", match.LineNumber+1+j, ansi.Strip(line))
112+
display := line
113+
if searchStripAnsiFlag {
114+
display = ansi.Strip(line)
115+
}
116+
fmt.Printf("%4d: %s\n", match.LineNumber+1+j, display)
105117
}
106118
}
107119

internal/daemon/client.go

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,16 @@ func (c *Client) EnsureDaemon() error {
5050
}
5151
}
5252

53-
return fmt.Errorf("daemon failed to start")
53+
sockPath := ""
54+
if sp, err := SocketPath(); err == nil {
55+
sockPath = sp
56+
}
57+
if sockPath != "" {
58+
if _, err := os.Stat(sockPath); err == nil {
59+
return fmt.Errorf("daemon failed to start within %s. Stale socket found at %s. Try: rm %s && shelli daemon", DaemonStartTimeout, sockPath, sockPath)
60+
}
61+
}
62+
return fmt.Errorf("daemon failed to start within %s. Socket: %s. Try: rm %s && shelli daemon", DaemonStartTimeout, sockPath, sockPath)
5463
}
5564

5665
func (c *Client) Ping() bool {
@@ -59,12 +68,13 @@ func (c *Client) Ping() bool {
5968
}
6069

6170
type CreateOptions struct {
62-
Command string
63-
Env []string
64-
Cwd string
65-
Cols int
66-
Rows int
67-
TUIMode bool
71+
Command string
72+
Env []string
73+
Cwd string
74+
Cols int
75+
Rows int
76+
TUIMode bool
77+
IfNotExists bool
6878
}
6979

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

7585
resp, err := c.send(Request{
76-
Action: "create",
77-
Name: name,
78-
Command: opts.Command,
79-
Env: opts.Env,
80-
Cwd: opts.Cwd,
81-
Cols: opts.Cols,
82-
Rows: opts.Rows,
83-
TUIMode: opts.TUIMode,
86+
Action: "create",
87+
Name: name,
88+
Command: opts.Command,
89+
Env: opts.Env,
90+
Cwd: opts.Cwd,
91+
Cols: opts.Cols,
92+
Rows: opts.Rows,
93+
TUIMode: opts.TUIMode,
94+
IfNotExists: opts.IfNotExists,
8495
})
8596
if err != nil {
8697
return nil, err
@@ -241,17 +252,18 @@ type SearchResponse struct {
241252
}
242253

243254
type InfoResponse struct {
244-
Name string `json:"name"`
245-
State string `json:"state"`
246-
PID int `json:"pid"`
247-
Command string `json:"command"`
248-
CreatedAt string `json:"created_at"`
249-
StoppedAt string `json:"stopped_at,omitempty"`
250-
BytesBuffered int64 `json:"bytes_buffered"`
251-
ReadPosition int64 `json:"read_position"`
252-
Cols int `json:"cols"`
253-
Rows int `json:"rows"`
254-
Uptime float64 `json:"uptime_seconds,omitempty"`
255+
Name string `json:"name"`
256+
State string `json:"state"`
257+
PID int `json:"pid"`
258+
Command string `json:"command"`
259+
CreatedAt string `json:"created_at"`
260+
StoppedAt string `json:"stopped_at,omitempty"`
261+
BytesBuffered int64 `json:"bytes_buffered"`
262+
ReadPosition int64 `json:"read_position"`
263+
Cols int `json:"cols"`
264+
Rows int `json:"rows"`
265+
Uptime float64 `json:"uptime_seconds,omitempty"`
266+
Cursors map[string]int64 `json:"cursors,omitempty"`
255267
}
256268

257269
func (c *Client) Clear(name string) error {
@@ -385,6 +397,7 @@ type ExecOptions struct {
385397
SettleMs int
386398
WaitPattern string
387399
TimeoutSec int
400+
SettleSet bool
388401
}
389402

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

406419
settleMs := opts.SettleMs
407-
if opts.WaitPattern == "" && settleMs == 0 {
420+
if opts.WaitPattern == "" && settleMs == 0 && !opts.SettleSet {
408421
settleMs = 500
409422
}
410423

@@ -423,17 +436,25 @@ func (c *Client) Exec(name string, opts ExecOptions) (*ExecResult, error) {
423436
SizeFunc: func() (int, error) { return c.Size(name) },
424437
},
425438
)
439+
440+
result := &ExecResult{Input: opts.Input, Output: output, Position: pos}
426441
if err != nil {
427-
return nil, err
442+
return result, err
428443
}
429444

430-
return &ExecResult{Input: opts.Input, Output: output, Position: pos}, nil
445+
return result, nil
431446
}
432447

433448
func (c *Client) send(req Request) (*Response, error) {
434-
sockPath := SocketPath()
449+
var sockPath string
435450
if c.customSocketPath != "" {
436451
sockPath = c.customSocketPath
452+
} else {
453+
var err error
454+
sockPath, err = SocketPath()
455+
if err != nil {
456+
return nil, err
457+
}
437458
}
438459
conn, err := net.Dial("unix", sockPath)
439460
if err != nil {

0 commit comments

Comments
 (0)