1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
|
package hexaiaction
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
"codeberg.org/snonux/hexai/internal/tmux"
"golang.org/x/term"
)
// Options configures the command-line orchestration for hexai-action.
type Options struct {
Infile string
Outfile string
ForceTmux bool
NoTmux bool
UIChild bool
TmuxTarget string
TmuxSplit string // "v" or "h"
TmuxPercent int // 1-100
}
// RunCommand is the CLI orchestrator used by cmd/hexai-action. It decides whether
// to run inline, in a tmux split pane, or in child mode; then delegates to Run.
func RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error {
if opts.UIChild {
return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr)
}
if shouldRunInTmux(opts.ForceTmux, opts.NoTmux) {
return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent)
}
// Inline path: only if we have a TTY for UI; otherwise echo input
if isTTYFn(os.Stdout.Fd()) && isTTYFn(os.Stdin.Fd()) {
in, out, closeIn, closeOut, err := openIO(opts.Infile, opts.Outfile)
if err != nil { return err }
defer closeIn(); defer closeOut()
return Run(ctx, in, out, stderr)
}
// Fallback: echo
return echoThrough(opts.Infile, opts.Outfile, stdin, stdout)
}
// seams for unit tests
var isTTYFn = func(fd uintptr) bool { return term.IsTerminal(int(fd)) }
var tmuxAvailableFn = tmux.Available
var splitRunFn = tmux.SplitRun
var osExecutableFn = os.Executable
var runFn = Run
func shouldRunInTmux(forceTmux, noTmux bool) bool {
if noTmux { return false }
if forceTmux { return true }
if !(isTTYFn(os.Stdin.Fd()) && isTTYFn(os.Stdout.Fd())) && tmuxAvailableFn() { return true }
return false
}
// openIO returns readers/writers for infile/outfile flags with deferred closers.
func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) {
in := io.Reader(os.Stdin)
out := io.Writer(os.Stdout)
closeIn := func() {}
closeOut := func() {}
if path := infile; path != "" {
f, err := os.Open(path)
if err != nil { return nil, nil, func(){}, func(){}, fmt.Errorf("hexai-action: cannot open infile: %w", err) }
in = f
closeIn = func() { _ = f.Close() }
}
if path := outfile; path != "" {
f, err := os.Create(path)
if err != nil { return nil, nil, func(){}, func(){}, fmt.Errorf("hexai-action: cannot open outfile: %w", err) }
out = f
closeOut = func() { _ = f.Close() }
}
return in, out, closeIn, closeOut, nil
}
// runChild runs the interactive flow and writes the final output atomically when outfile is set.
func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Writer) error {
if outfile == "" {
// No atomic handoff needed; just run normally to provided stdout
var in io.Reader = os.Stdin
if infile != "" {
f, err := os.Open(infile)
if err != nil { return err }
defer func(){ _ = f.Close() }()
in = f
}
return runFn(ctx, in, stdout, stderr)
}
tmp := outfile + ".tmp"
in, out, closeIn, closeOut, err := openIO(infile, tmp)
if err != nil { return err }
defer closeIn()
if err := runFn(ctx, in, out, stderr); err != nil {
closeOut()
if copyErr := echoThrough(infile, tmp, os.Stdin, stdout); copyErr != nil {
return fmt.Errorf("hexai-action child: %v; echo failed: %v", err, copyErr)
}
} else {
closeOut()
}
return os.Rename(tmp, outfile)
}
func runInTmuxParent(stdin io.Reader, stdout io.Writer, target, split string, percent int) error {
dir, err := os.MkdirTemp("", "hexai-action-")
if err != nil { return err }
defer func() { _ = os.RemoveAll(dir) }()
inPath := filepath.Join(dir, "input.txt")
outPath := filepath.Join(dir, "reply.txt")
if err := persistStdin(inPath, stdin); err != nil { return err }
exe, err := osExecutableFn()
if err != nil { return err }
argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath}
opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent}
if err := splitRunFn(opts, argv); err != nil { return err }
if err := waitForFile(outPath, 60*time.Second); err != nil { return err }
return catFileTo(stdout, outPath)
}
func persistStdin(path string, stdin io.Reader) error {
f, err := os.Create(path)
if err != nil { return err }
defer func() { _ = f.Close() }()
if _, err := io.Copy(f, stdin); err != nil { return err }
return f.Sync()
}
func waitForFile(path string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
if _, err := os.Stat(path); err == nil { return nil }
if time.Now().After(deadline) { return fmt.Errorf("hexai-action: timeout waiting for reply file") }
time.Sleep(200 * time.Millisecond)
}
}
func catFileTo(w io.Writer, path string) error {
f, err := os.Open(path)
if err != nil { return err }
defer func() { _ = f.Close() }()
_, err = io.Copy(w, f)
return err
}
func echoThrough(infile, outfile string, stdin io.Reader, stdout io.Writer) error {
var in io.Reader = stdin
var out io.Writer = stdout
if infile != "" {
f, err := os.Open(infile)
if err != nil { return err }
defer func() { _ = f.Close() }()
in = f
}
if outfile != "" {
f, err := os.Create(outfile)
if err != nil { return err }
defer func() { _ = f.Close() }()
out = f
}
_, err := io.Copy(out, in)
return err
}
|