@@ -0,0 +1,58 @@
1+package util
2+
3+import (
4+ "context"
5+ "io"
6+ "strings"
7+
8+ tea "charm.land/bubbletea/v2"
9+ "mvdan.cc/sh/v3/interp"
10+ "mvdan.cc/sh/v3/syntax"
11+)
12+
13+// shellCommand wraps a shell interpreter to implement tea.ExecCommand.
14+type shellCommand struct {
15+ ctx context.Context
16+ file *syntax.File
17+ stdin io.Reader
18+ stdout io.Writer
19+ stderr io.Writer
20+}
21+
22+func (s *shellCommand) SetStdin(r io.Reader) {
23+ s.stdin = r
24+}
25+
26+func (s *shellCommand) SetStdout(w io.Writer) {
27+ s.stdout = w
28+}
29+
30+func (s *shellCommand) SetStderr(w io.Writer) {
31+ s.stderr = w
32+}
33+
34+func (s *shellCommand) Run() error {
35+ runner, err := interp.New(
36+ interp.StdIO(s.stdin, s.stdout, s.stderr),
37+ )
38+ if err != nil {
39+ return err
40+ }
41+ return runner.Run(s.ctx, s.file)
42+}
43+
44+// ExecShell executes a shell command string using tea.Exec.
45+// The command is parsed and executed via mvdan.cc/sh/v3/interp, allowing
46+// proper handling of shell syntax like quotes and arguments.
47+func ExecShell(ctx context.Context, cmdStr string, callback tea.ExecCallback) tea.Cmd {
48+ parsed, err := syntax.NewParser().Parse(strings.NewReader(cmdStr), "")
49+ if err != nil {
50+ return ReportError(err)
51+ }
52+
53+ cmd := &shellCommand{
54+ ctx: ctx,
55+ file: parsed,
56+ }
57+ return tea.Exec(cmd, callback)
58+}