summaryrefslogtreecommitdiff
path: root/internal/hexaiaction/cmdentry.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-07 11:36:38 +0300
committerPaul Buetow <paul@buetow.org>2025-09-07 11:36:38 +0300
commitfb06bfa9dc7140f987bdbad59467a84610221fbb (patch)
treeb8ebf92c121a32eed0dc5e193c5877c5d0b94e6b /internal/hexaiaction/cmdentry.go
parent0d424adfc64da1c61296c66a99162ec68cc4f8d0 (diff)
refactor: move hexai-action to cmd/hexai-action; extract orchestration into internal/hexaiaction; move tests; update Magefile and docs
Diffstat (limited to 'internal/hexaiaction/cmdentry.go')
-rw-r--r--internal/hexaiaction/cmdentry.go168
1 files changed, 168 insertions, 0 deletions
diff --git a/internal/hexaiaction/cmdentry.go b/internal/hexaiaction/cmdentry.go
new file mode 100644
index 0000000..1947390
--- /dev/null
+++ b/internal/hexaiaction/cmdentry.go
@@ -0,0 +1,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
+}