summaryrefslogtreecommitdiff
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
parent0d424adfc64da1c61296c66a99162ec68cc4f8d0 (diff)
refactor: move hexai-action to cmd/hexai-action; extract orchestration into internal/hexaiaction; move tests; update Magefile and docs
-rw-r--r--Magefile.go12
-rw-r--r--TODO.md8
-rw-r--r--cmd/hexai-action/main.go33
-rw-r--r--cmd/internal/hexai-action/main.go238
-rw-r--r--cmd/internal/hexai-action/main_test.go355
-rw-r--r--docs/testing.md2
-rw-r--r--internal/hexaiaction/cmdentry.go168
-rw-r--r--internal/hexaiaction/cmdentry_test.go152
-rwxr-xr-xscripts/coverage.sh3
9 files changed, 365 insertions, 606 deletions
diff --git a/Magefile.go b/Magefile.go
index bd55ef6..16ff7c0 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -43,8 +43,8 @@ func BuildHexaiCLI() error {
// BuildHexaiAction builds the hexai-action TUI binary.
func BuildHexaiAction() error {
- printCoverage()
- return sh.RunV("go", "build", "-o", "hexai-action", "cmd/internal/hexai-action/main.go")
+ printCoverage()
+ return sh.RunV("go", "build", "-o", "hexai-action", "cmd/hexai-action/main.go")
}
// Dev runs tests, vet, lint, then builds with race for both binaries.
@@ -57,7 +57,7 @@ func Dev() error {
if err := sh.RunV("go", "build", "-race", "-o", "hexai", "cmd/hexai/main.go"); err != nil {
return err
}
- return sh.RunV("go", "build", "-race", "-o", "hexai-action", "cmd/internal/hexai-action/main.go")
+ return sh.RunV("go", "build", "-race", "-o", "hexai-action", "cmd/hexai-action/main.go")
}
// Run launches the LSP server via go run (useful during development).
@@ -102,9 +102,9 @@ func Install() error {
// RunAction runs the hexai-action TUI via go run (reads stdin).
func RunAction() error {
- printCoverage()
- mg.Deps(Dev)
- return sh.RunV("go", "run", "cmd/internal/hexai-action/main.go")
+ printCoverage()
+ mg.Deps(Dev)
+ return sh.RunV("go", "run", "cmd/hexai-action/main.go")
}
// printCoverage prints a warning if an existing coverage profile shows total < coverateThreshold.
diff --git a/TODO.md b/TODO.md
index b32cec2..7f3a839 100644
--- a/TODO.md
+++ b/TODO.md
@@ -69,7 +69,7 @@ Helix configuration after change
- Optional: users can force tmux pane with `:pipe hexai-action -tmux` or disable with `:pipe hexai-action -no-tmux` if auto-detection does not fit their setup.
Migration plan
-1) Implement tmux package and integrate auto-mode in `cmd/internal/hexai-action/main.go`.
+1) Implement tmux package and integrate auto-mode in `cmd/hexai-action/main.go`.
2) Keep legacy flags (`-infile`, `-outfile`) for compatibility and tests.
3) Update README and docs to show new Helix keybinding and describe flags.
4) Mark shell scripts (`llminputs/ai`, `llminputs/hx.hexai-action-prompt`) as deprecated in repo notes; retain them temporarily.
@@ -78,7 +78,7 @@ Migration plan
Testing plan
- Unit tests:
- `internal/tmux`: mock `exec.Command` via a small command-runner interface; verify command assembly and availability checks.
- - `cmd/internal/hexai-action`: tests for parent decision logic (TTY vs pipe; tmux available vs not) using injectable detectors.
+ - `internal/hexaiaction`: tests for parent decision logic (TTY vs pipe; tmux available vs not) using injectable detectors.
- hexaiaction: existing tests continue to pass; add tests for non-interactive fallback behavior when no TTY.
- Integration tests (manual or scripted):
- Run under tmux: verify a pane opens, TUI choice is applied, stdout contains result.
@@ -93,7 +93,7 @@ Edge cases and mitigations
Implementation steps (incremental)
1) Add `internal/tmux` with `Available`, `SplitRun`, and helpers.
-2) Add TTY detection helper to `cmd/internal/hexai-action` and wire flags (`-tmux`, `-no-tmux`, `-ui-child`).
+2) Add TTY detection helper to `internal/hexaiaction` and wire flags (`-tmux`, `-no-tmux`, `-ui-child`).
3) Parent flow: detect mode, manage temp files, spawn child via tmux when selected, wait/print result.
4) Child flow: reuse existing `hexaiaction.Run` to keep logic centralized; ensure outfile atomic write.
5) Docs: update README with new Helix config and flags.
@@ -101,7 +101,7 @@ Implementation steps (incremental)
Notes on code organization
- Place all tmux-related code under `internal/tmux` and keep functions well under 50 lines.
-- Keep command entrypoint (`cmd/internal/hexai-action/main.go`) small and focused on wiring/mode selection.
+- Keep command entrypoint (`cmd/hexai-action/main.go`) small and focused on wiring/mode selection.
- Avoid duplication across `hexaiaction` and `tmux`; IO/file and action logic remain in `hexaiaction`.
Outcome
diff --git a/cmd/hexai-action/main.go b/cmd/hexai-action/main.go
new file mode 100644
index 0000000..b796cbd
--- /dev/null
+++ b/cmd/hexai-action/main.go
@@ -0,0 +1,33 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+
+ "codeberg.org/snonux/hexai/internal/hexaiaction"
+)
+
+func main() {
+ infile := flag.String("infile", "", "Read input from this file instead of stdin")
+ outfile := flag.String("outfile", "", "Write output to this file instead of stdout")
+ forceTmux := flag.Bool("tmux", false, "Force running the UI in a tmux split-pane (auto if not set)")
+ noTmux := flag.Bool("no-tmux", false, "Disable tmux mode even if available")
+ uiChild := flag.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically")
+ tmuxTarget := flag.String("tmux-target", "", "tmux split target (advanced)")
+ tmuxSplit := flag.String("tmux-split", "v", "tmux split orientation: v or h")
+ tmuxPercent := flag.Int("tmux-percent", 33, "tmux split size percentage (1-100)")
+ flag.Parse()
+
+ opts := hexaiaction.Options{
+ Infile: *infile, Outfile: *outfile,
+ ForceTmux: *forceTmux, NoTmux: *noTmux, UIChild: *uiChild,
+ TmuxTarget: *tmuxTarget, TmuxSplit: *tmuxSplit, TmuxPercent: *tmuxPercent,
+ }
+ if err := hexaiaction.RunCommand(context.Background(), opts, os.Stdin, os.Stdout, os.Stderr); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
diff --git a/cmd/internal/hexai-action/main.go b/cmd/internal/hexai-action/main.go
deleted file mode 100644
index b8ba524..0000000
--- a/cmd/internal/hexai-action/main.go
+++ /dev/null
@@ -1,238 +0,0 @@
-package main
-
-import (
- "context"
- "flag"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "time"
-
- "codeberg.org/snonux/hexai/internal/hexaiaction"
- "codeberg.org/snonux/hexai/internal/tmux"
- "golang.org/x/term"
-)
-
-func main() {
- infile := flag.String("infile", "", "Read input from this file instead of stdin")
- outfile := flag.String("outfile", "", "Write output to this file instead of stdout")
- // Tmux/UI flags
- forceTmux := flag.Bool("tmux", false, "Force running the UI in a tmux split-pane (auto if not set)")
- noTmux := flag.Bool("no-tmux", false, "Disable tmux mode even if available")
- uiChild := flag.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically")
- tmuxTarget := flag.String("tmux-target", "", "tmux split target (advanced)")
- tmuxSplit := flag.String("tmux-split", "v", "tmux split orientation: v or h")
- tmuxPercent := flag.Int("tmux-percent", 33, "tmux split size percentage (1-100)")
- flag.Parse()
-
- // Child mode: run TUI and write atomically to -outfile
- if *uiChild {
- if err := runChild(*infile, *outfile); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- return
- }
-
- // Parent mode: decide inline vs tmux
- if shouldRunInTmux(*forceTmux, *noTmux) {
- if err := runInTmuxParent(*tmuxTarget, *tmuxSplit, *tmuxPercent); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- return
- }
-
- // Inline path: only if we have a TTY for UI; otherwise echo input
- if isTTY(os.Stdout.Fd()) && isTTY(os.Stdin.Fd()) {
- in, out, closeIn, closeOut, err := openIO(*infile, *outfile)
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- defer closeIn()
- defer closeOut()
- if err := hexaiactionRun(context.Background(), in, out, os.Stderr); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- return
- }
-
- // Fallback: no TTY and tmux not available; echo input to output
- if err := echoThrough(*infile, *outfile); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
-}
-
-// 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 to outfile.
-var hexaiactionRun = hexaiaction.Run
-
-func runChild(infile, outfile string) error {
- if outfile == "" {
- // No atomic handoff needed; just run normally to stdout
- in, out, closeIn, closeOut, err := openIO(infile, "")
- if err != nil {
- return err
- }
- defer closeIn()
- defer closeOut()
- return hexaiactionRun(context.Background(), in, out, os.Stderr)
- }
- tmp := outfile + ".tmp"
- in, out, closeIn, closeOut, err := openIO(infile, tmp)
- if err != nil {
- return err
- }
- defer closeIn()
- if err := hexaiactionRun(context.Background(), in, out, os.Stderr); err != nil {
- // On error, try to echo input to tmp to avoid blocking
- closeOut()
- if copyErr := echoThrough(infile, tmp); copyErr != nil {
- return fmt.Errorf("hexai-action child: %v; echo failed: %v", err, copyErr)
- }
- } else {
- closeOut()
- }
- return os.Rename(tmp, outfile)
-}
-
-var isTTYFn = isTTY
-var tmuxAvailableFn = tmux.Available
-var splitRunFn = tmux.SplitRun
-var osExecutableFn = os.Executable
-
-func shouldRunInTmux(forceTmux, noTmux bool) bool {
- if noTmux {
- return false
- }
- if forceTmux {
- return true
- }
- // Auto: prefer tmux when stdio are not TTYs (Helix :pipe scenario)
- if !(isTTYFn(os.Stdin.Fd()) && isTTYFn(os.Stdout.Fd())) && tmuxAvailableFn() {
- return true
- }
- return false
-}
-
-func isTTY(fd uintptr) bool { return term.IsTerminal(int(fd)) }
-
-func runInTmuxParent(target, split string, percent int) error {
- // Prepare temp files
- 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")
- // Read stdin and persist to inPath
- if err := persistStdin(inPath); err != nil {
- return err
- }
- // Build child argv
- exe, err := osExecutableFn()
- if err != nil {
- return err
- }
- argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath}
- // Spawn tmux split
- opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent}
- if err := splitRunFn(opts, argv); err != nil {
- return err
- }
- // Wait for outfile to appear
- if err := waitForFile(outPath, 60*time.Second); err != nil {
- return err
- }
- // Print to stdout
- return catFileToStdout(outPath)
-}
-
-func persistStdin(path string) error {
- f, err := os.Create(path)
- if err != nil {
- return err
- }
- defer func() { _ = f.Close() }()
- if _, err := io.Copy(f, os.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 catFileToStdout(path string) error {
- f, err := os.Open(path)
- if err != nil {
- return err
- }
- defer func() { _ = f.Close() }()
- _, err = io.Copy(os.Stdout, f)
- return err
-}
-
-func echoThrough(infile, outfile string) error {
- // Read from infile or stdin and write to outfile or stdout
- var in io.Reader = os.Stdin
- var out io.Writer = os.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
-}
diff --git a/cmd/internal/hexai-action/main_test.go b/cmd/internal/hexai-action/main_test.go
deleted file mode 100644
index 16bb2ed..0000000
--- a/cmd/internal/hexai-action/main_test.go
+++ /dev/null
@@ -1,355 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "codeberg.org/snonux/hexai/internal/tmux"
-)
-
-func TestShouldRunInTmux_Preferences(t *testing.T) {
- // no-tmux overrides
- if shouldRunInTmux(false, true) {
- t.Fatal("expected false when no-tmux is set")
- }
- // force tmux overrides
- if !shouldRunInTmux(true, false) {
- t.Fatal("expected true when -tmux is set")
- }
-}
-
-func TestShouldRunInTmux_Auto(t *testing.T) {
- oldIsTTY := isTTYFn
- oldAvail := tmuxAvailableFn
- t.Cleanup(func() { isTTYFn = oldIsTTY; tmuxAvailableFn = oldAvail })
- // Simulate Helix :pipe (no TTY) and tmux available
- isTTYFn = func(_ uintptr) bool { return false }
- tmuxAvailableFn = func() bool { return true }
- if !shouldRunInTmux(false, false) {
- t.Fatal("expected true when not TTY and tmux available")
- }
- // Simulate TTY present: prefer inline
- isTTYFn = func(_ uintptr) bool { return true }
- if shouldRunInTmux(false, false) {
- t.Fatal("expected false when TTY present")
- }
- // Simulate tmux not available
- isTTYFn = func(_ uintptr) bool { return false }
- tmuxAvailableFn = func() bool { return false }
- if shouldRunInTmux(false, false) {
- t.Fatal("expected false when tmux unavailable")
- }
-}
-
-func TestPersistStdin_WritesFile(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "in.txt")
- // Point os.Stdin to a temp file with content
- src := filepath.Join(dir, "src.txt")
- if err := os.WriteFile(src, []byte("hello world"), 0o600); err != nil {
- t.Fatalf("write src: %v", err)
- }
- f, err := os.Open(src)
- if err != nil { t.Fatalf("open src: %v", err) }
- old := os.Stdin
- os.Stdin = f
- t.Cleanup(func() { os.Stdin = old; _ = f.Close() })
- if err := persistStdin(path); err != nil {
- t.Fatalf("persistStdin error: %v", err)
- }
- b, err := os.ReadFile(path)
- if err != nil { t.Fatalf("read out: %v", err) }
- if string(b) != "hello world" {
- t.Fatalf("unexpected content %q", string(b))
- }
-}
-
-func TestEchoThrough(t *testing.T) {
- dir := t.TempDir()
- in := filepath.Join(dir, "in.txt")
- out := filepath.Join(dir, "out.txt")
- if err := os.WriteFile(in, []byte("hello"), 0o600); err != nil {
- t.Fatalf("write in: %v", err)
- }
- if err := echoThrough(in, out); err != nil {
- t.Fatalf("echoThrough: %v", err)
- }
- b, _ := os.ReadFile(out)
- if string(b) != "hello" {
- t.Fatalf("unexpected: %q", string(b))
- }
-}
-
-func TestEchoThrough_StdinStdout(t *testing.T) {
- // set stdin
- rIn, wIn, _ := os.Pipe()
- _, _ = wIn.Write([]byte("PIPE"))
- _ = wIn.Close()
- oldIn := os.Stdin
- os.Stdin = rIn
- defer func() { os.Stdin = oldIn; _ = rIn.Close() }()
- // capture stdout
- r, w, _ := os.Pipe()
- oldOut := os.Stdout
- os.Stdout = w
- defer func() { os.Stdout = oldOut; _ = r.Close(); _ = w.Close() }()
- if err := echoThrough("", ""); err != nil { t.Fatalf("echoThrough: %v", err) }
- _ = w.Close()
- data, _ := io.ReadAll(r)
- if string(data) != "PIPE" {
- t.Fatalf("stdout: %q", string(data))
- }
-}
-
-func TestWaitForFile(t *testing.T) {
- dir := t.TempDir()
- p := filepath.Join(dir, "x")
- go func() {
- // create shortly after
- f, _ := os.Create(p)
- defer f.Close()
- f.WriteString("ok")
- }()
- if err := waitForFile(p, 2_000_000_000); err != nil { // 2s
- t.Fatalf("waitForFile: %v", err)
- }
-}
-
-func TestCatFileToStdout(t *testing.T) {
- dir := t.TempDir()
- p := filepath.Join(dir, "f")
- if err := os.WriteFile(p, []byte("abc"), 0o600); err != nil {
- t.Fatalf("write: %v", err)
- }
- // capture stdout
- old := os.Stdout
- r, w, _ := os.Pipe()
- os.Stdout = w
- defer func() { os.Stdout = old; _ = r.Close(); _ = w.Close() }()
- if err := catFileToStdout(p); err != nil {
- t.Fatalf("catFileToStdout: %v", err)
- }
- _ = w.Close()
- buf, _ := io.ReadAll(r)
- if string(buf) != "abc" {
- t.Fatalf("stdout = %q", string(buf))
- }
-}
-
-func TestRunInTmuxParent_Stubbed(t *testing.T) {
- dir := t.TempDir()
- // set stdin content
- src := filepath.Join(dir, "stdin.txt")
- _ = os.WriteFile(src, []byte("input"), 0o600)
- f, _ := os.Open(src)
- oldStdin := os.Stdin
- os.Stdin = f
- defer func() { os.Stdin = oldStdin; _ = f.Close() }()
-
- // capture stdout
- oldStdout := os.Stdout
- r, w, _ := os.Pipe()
- os.Stdout = w
- defer func() { os.Stdout = oldStdout; _ = r.Close(); _ = w.Close() }()
-
- // stub seams
- oldExec := osExecutableFn
- oldSplit := splitRunFn
- oldRun := hexaiactionRun
- osExecutableFn = func() (string, error) { return "/bin/hexai-action", nil }
- splitRunFn = func(opts tmux.SplitOpts, argv []string) error {
- // find -outfile path and write content to simulate child
- for i := 0; i < len(argv)-1; i++ {
- if argv[i] == "-outfile" && i+1 < len(argv) {
- _ = os.WriteFile(argv[i+1], []byte("OUT:"+strings.Join(argv, ",")), 0o600)
- break
- }
- }
- return nil
- }
- // Ensure child mode won't try to run the real TUI if invoked in tests.
- hexaiactionRun = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error {
- _, _ = io.WriteString(w, "child-stub")
- return nil
- }
- defer func() { osExecutableFn = oldExec; splitRunFn = oldSplit; hexaiactionRun = oldRun }()
-
- if err := runInTmuxParent("", "v", 33); err != nil {
- t.Fatalf("runInTmuxParent: %v", err)
- }
- _ = w.Close()
- got, _ := io.ReadAll(r)
- if !strings.HasPrefix(string(got), "OUT:") {
- t.Fatalf("unexpected stdout: %q", string(got))
- }
-}
-
-func TestRunChild_StubbedOutfile(t *testing.T) {
- dir := t.TempDir()
- in := filepath.Join(dir, "in.txt")
- out := filepath.Join(dir, "out.txt")
- _ = os.WriteFile(in, []byte("sel"), 0o600)
- old := hexaiactionRun
- hexaiactionRun = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error {
- _, _ = io.WriteString(w, "RESULT")
- return nil
- }
- defer func() { hexaiactionRun = old }()
- if err := runChild(in, out); err != nil {
- t.Fatalf("runChild: %v", err)
- }
- b, _ := os.ReadFile(out)
- if string(b) != "RESULT" {
- t.Fatalf("unexpected outfile: %q", string(b))
- }
-}
-
-func TestRunChild_StubbedStdout(t *testing.T) {
- dir := t.TempDir()
- in := filepath.Join(dir, "in.txt")
- _ = os.WriteFile(in, []byte("sel"), 0o600)
- // capture stdout
- oldStdout := os.Stdout
- r, w, _ := os.Pipe()
- os.Stdout = w
- defer func() { os.Stdout = oldStdout; _ = r.Close(); _ = w.Close() }()
- old := hexaiactionRun
- hexaiactionRun = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error {
- _, _ = io.WriteString(w, "STDOUT-RESULT")
- return nil
- }
- defer func() { hexaiactionRun = old }()
- if err := runChild(in, ""); err != nil {
- t.Fatalf("runChild: %v", err)
- }
- _ = w.Close()
- data, _ := io.ReadAll(r)
- if string(data) != "STDOUT-RESULT" {
- t.Fatalf("stdout: %q", string(data))
- }
-}
-
-func TestRunChild_ErrorFallback(t *testing.T) {
- dir := t.TempDir()
- in := filepath.Join(dir, "in.txt")
- out := filepath.Join(dir, "out.txt")
- _ = os.WriteFile(in, []byte("INPUT"), 0o600)
- old := hexaiactionRun
- hexaiactionRun = func(_ context.Context, _ io.Reader, _ io.Writer, _ io.Writer) error {
- return fmt.Errorf("boom")
- }
- defer func() { hexaiactionRun = old }()
- if err := runChild(in, out); err != nil {
- t.Fatalf("runChild: %v", err)
- }
- b, _ := os.ReadFile(out)
- if string(b) != "INPUT" {
- t.Fatalf("expected fallback echo, got %q", string(b))
- }
-}
-
-func TestWaitForFile_Timeout(t *testing.T) {
- dir := t.TempDir()
- p := filepath.Join(dir, "nope")
- if err := waitForFile(p, 10_000_000); err == nil { // 10ms
- t.Fatal("expected timeout error")
- }
-}
-
-func TestOpenIO_InfileOutfile(t *testing.T) {
- dir := t.TempDir()
- in := filepath.Join(dir, "i")
- out := filepath.Join(dir, "o")
- _ = os.WriteFile(in, []byte("X"), 0o600)
- r, w, ci, co, err := openIO(in, out)
- if err != nil { t.Fatalf("openIO: %v", err) }
- defer ci(); defer co()
- if _, err := io.Copy(w, r); err != nil { t.Fatalf("copy: %v", err) }
- b, _ := os.ReadFile(out)
- if string(b) != "X" { t.Fatalf("got %q", string(b)) }
-}
-
-func TestRunInTmuxParent_ExecutableError(t *testing.T) {
- old := osExecutableFn
- osExecutableFn = func() (string, error) { return "", fmt.Errorf("no exe") }
- defer func() { osExecutableFn = old }()
- // set stdin content
- r, w, _ := os.Pipe()
- _, _ = w.Write([]byte("x"))
- _ = w.Close()
- oldIn := os.Stdin
- os.Stdin = r
- defer func() { os.Stdin = oldIn; _ = r.Close() }()
- if err := runInTmuxParent("", "v", 33); err == nil {
- t.Fatal("expected error from missing executable")
- }
-}
-
-func TestRunInTmuxParent_SplitError(t *testing.T) {
- oldExec := osExecutableFn
- osExecutableFn = func() (string, error) { return "/bin/hexai-action", nil }
- oldSplit := splitRunFn
- splitRunFn = func(_ tmux.SplitOpts, _ []string) error { return fmt.Errorf("split failed") }
- defer func() { osExecutableFn = oldExec; splitRunFn = oldSplit }()
- // set stdin
- r, w, _ := os.Pipe()
- _, _ = w.Write([]byte("x"))
- _ = w.Close()
- oldIn := os.Stdin
- os.Stdin = r
- defer func() { os.Stdin = oldIn; _ = r.Close() }()
- if err := runInTmuxParent("", "v", 33); err == nil {
- t.Fatal("expected split error")
- }
-}
-
-func TestEchoThrough_OutfileError(t *testing.T) {
- dir := t.TempDir()
- in := filepath.Join(dir, "i.txt")
- _ = os.WriteFile(in, []byte("x"), 0o600)
- // Outfile inside non-existent subdir -> Create should fail
- out := filepath.Join(dir, "nope", "out.txt")
- if err := echoThrough(in, out); err == nil {
- t.Fatal("expected echoThrough outfile error")
- }
-}
-
-func TestPersistStdin_Error(t *testing.T) {
- // Parent directory missing -> Create should fail
- dir := t.TempDir()
- p := filepath.Join(dir, "missing", "x.txt")
- // set stdin to something
- r, w, _ := os.Pipe()
- _, _ = w.Write([]byte("x"))
- _ = w.Close()
- old := os.Stdin
- os.Stdin = r
- defer func() { os.Stdin = old; _ = r.Close() }()
- if err := persistStdin(p); err == nil {
- t.Fatal("expected persistStdin error")
- }
-}
-
-func TestCatFileToStdout_Error(t *testing.T) {
- if err := catFileToStdout("/nonexistent/path/file.txt"); err == nil {
- t.Fatal("expected error for missing file")
- }
-}
-
-func TestOpenIO_Errors(t *testing.T) {
- // Non-existent infile
- if _, _, _, _, err := openIO("/definitely/missing/file.txt", ""); err == nil {
- t.Fatal("expected infile error")
- }
- // Outfile in missing dir
- dir := t.TempDir()
- out := filepath.Join(dir, "nope", "x.txt")
- if _, _, _, _, err := openIO("", out); err == nil {
- t.Fatal("expected outfile error")
- }
-}
diff --git a/docs/testing.md b/docs/testing.md
index 17dd4b3..86d88b1 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -21,7 +21,7 @@ Suggested additions:
- The `HEXAI_TEST_SKIP_NET=1` env var disables any tests that require network access, keeping runs deterministic in CI/sandboxes.
- Package-specific runs:
- - `HEXAI_TEST_SKIP_NET=1 go test ./cmd/internal/hexai-action -cover`
+ - `HEXAI_TEST_SKIP_NET=1 go test ./internal/hexaiaction -cover`
- `HEXAI_TEST_SKIP_NET=1 go test ./internal/hexaiaction -cover`
Notes
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
+}
diff --git a/internal/hexaiaction/cmdentry_test.go b/internal/hexaiaction/cmdentry_test.go
new file mode 100644
index 0000000..8525f7d
--- /dev/null
+++ b/internal/hexaiaction/cmdentry_test.go
@@ -0,0 +1,152 @@
+package hexaiaction
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "codeberg.org/snonux/hexai/internal/tmux"
+)
+
+func TestShouldRunInTmux_Preferences(t *testing.T) {
+ if shouldRunInTmux(false, true) { t.Fatal("expected false when no-tmux is set") }
+ if !shouldRunInTmux(true, false) { t.Fatal("expected true when -tmux is set") }
+}
+
+func TestShouldRunInTmux_Auto(t *testing.T) {
+ oldIsTTY := isTTYFn
+ oldAvail := tmuxAvailableFn
+ t.Cleanup(func() { isTTYFn = oldIsTTY; tmuxAvailableFn = oldAvail })
+ isTTYFn = func(_ uintptr) bool { return false }
+ tmuxAvailableFn = func() bool { return true }
+ if !shouldRunInTmux(false, false) { t.Fatal("expected true when not TTY and tmux available") }
+ isTTYFn = func(_ uintptr) bool { return true }
+ if shouldRunInTmux(false, false) { t.Fatal("expected false when TTY present") }
+ isTTYFn = func(_ uintptr) bool { return false }
+ tmuxAvailableFn = func() bool { return false }
+ if shouldRunInTmux(false, false) { t.Fatal("expected false when tmux unavailable") }
+}
+
+func TestPersistStdin_WritesFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "in.txt")
+ // Point stdin to content
+ src := filepath.Join(dir, "src.txt")
+ if err := os.WriteFile(src, []byte("hello world"), 0o600); err != nil { t.Fatalf("write src: %v", err) }
+ f, _ := os.Open(src)
+ defer f.Close()
+ if err := persistStdin(path, f); err != nil { t.Fatalf("persistStdin: %v", err) }
+ b, _ := os.ReadFile(path)
+ if string(b) != "hello world" { t.Fatalf("unexpected content %q", string(b)) }
+}
+
+func TestEchoThrough(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "in.txt")
+ out := filepath.Join(dir, "out.txt")
+ _ = os.WriteFile(in, []byte("hello"), 0o600)
+ if err := echoThrough(in, out, os.Stdin, os.Stdout); err != nil { t.Fatalf("echoThrough: %v", err) }
+ b, _ := os.ReadFile(out)
+ if string(b) != "hello" { t.Fatalf("unexpected: %q", string(b)) }
+}
+
+func TestEchoThrough_StdinStdout(t *testing.T) {
+ // set stdin
+ rIn, wIn, _ := os.Pipe()
+ _, _ = wIn.Write([]byte("PIPE"))
+ _ = wIn.Close()
+ // capture stdout
+ r, w, _ := os.Pipe()
+ if err := echoThrough("", "", rIn, w); err != nil { t.Fatalf("echoThrough: %v", err) }
+ _ = w.Close()
+ data, _ := io.ReadAll(r)
+ if string(data) != "PIPE" { t.Fatalf("stdout: %q", string(data)) }
+}
+
+func TestRunInTmuxParent_Stubbed(t *testing.T) {
+ dir := t.TempDir()
+ // set stdin content
+ r, w, _ := os.Pipe()
+ _, _ = w.Write([]byte("input"))
+ _ = w.Close()
+ // capture stdout
+ rout, wout, _ := os.Pipe()
+ oldExec := osExecutableFn
+ oldSplit := splitRunFn
+ osExecutableFn = func() (string, error) { return "/bin/hexai-action", nil }
+ splitRunFn = func(opts tmux.SplitOpts, argv []string) error {
+ for i := 0; i < len(argv)-1; i++ {
+ if argv[i] == "-outfile" && i+1 < len(argv) {
+ _ = os.WriteFile(argv[i+1], []byte("OUT:"+strings.Join(argv, ",")), 0o600)
+ break
+ }
+ }
+ return nil
+ }
+ t.Cleanup(func() { osExecutableFn = oldExec; splitRunFn = oldSplit })
+ if err := runInTmuxParent(r, wout, "", "v", 33); err != nil { t.Fatalf("runInTmuxParent: %v", err) }
+ _ = wout.Close()
+ got, _ := io.ReadAll(rout)
+ if !strings.HasPrefix(string(got), "OUT:") { t.Fatalf("unexpected stdout: %q", string(got)) }
+ _ = dir
+}
+
+func TestRunInTmuxParent_ExecutableError(t *testing.T) {
+ old := osExecutableFn
+ osExecutableFn = func() (string, error) { return "", fmt.Errorf("no exe") }
+ t.Cleanup(func() { osExecutableFn = old })
+ r, w, _ := os.Pipe(); _, _ = w.Write([]byte("x")); _ = w.Close()
+ if err := runInTmuxParent(r, io.Discard, "", "v", 33); err == nil { t.Fatal("expected error from missing executable") }
+}
+
+func TestRunInTmuxParent_SplitError(t *testing.T) {
+ oldExec := osExecutableFn
+ osExecutableFn = func() (string, error) { return "/bin/hexai-action", nil }
+ oldSplit := splitRunFn
+ splitRunFn = func(_ tmux.SplitOpts, _ []string) error { return fmt.Errorf("split failed") }
+ t.Cleanup(func() { osExecutableFn = oldExec; splitRunFn = oldSplit })
+ r, w, _ := os.Pipe(); _, _ = w.Write([]byte("x")); _ = w.Close()
+ if err := runInTmuxParent(r, io.Discard, "", "v", 33); err == nil { t.Fatal("expected split error") }
+}
+
+func TestRunChild_StdoutAndOutfile(t *testing.T) {
+ // Outfile mode
+ dir := t.TempDir()
+ in := filepath.Join(dir, "in.txt")
+ out := filepath.Join(dir, "out.txt")
+ _ = os.WriteFile(in, []byte("sel"), 0o600)
+ oldRun := runFn
+ runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { _, _ = io.WriteString(w, "RESULT"); return nil }
+ t.Cleanup(func(){ runFn = oldRun })
+ if err := runChild(context.Background(), in, out, io.Discard, io.Discard); err != nil { t.Fatalf("runChild: %v", err) }
+ b, _ := os.ReadFile(out)
+ if len(b) == 0 { t.Fatalf("expected some output") }
+ // Stdout mode
+ r, w, _ := os.Pipe()
+ if err := runChild(context.Background(), in, "", w, io.Discard); err != nil { t.Fatalf("runChild: %v", err) }
+ _ = w.Close(); buf, _ := io.ReadAll(r)
+ if len(buf) == 0 { t.Fatalf("expected stdout output") }
+}
+
+func TestWaitForFile_Timeout(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "nope")
+ if err := waitForFile(p, 10*time.Millisecond); err == nil { t.Fatal("expected timeout error") }
+}
+
+func TestOpenIO_InfileOutfile(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "i"); out := filepath.Join(dir, "o")
+ _ = os.WriteFile(in, []byte("X"), 0o600)
+ r, w, ci, co, err := openIO(in, out)
+ if err != nil { t.Fatalf("openIO: %v", err) }
+ defer ci(); defer co()
+ if _, err := io.Copy(w, r); err != nil { t.Fatalf("copy: %v", err) }
+ b, _ := os.ReadFile(out)
+ if string(b) != "X" { t.Fatalf("got %q", string(b)) }
+}
diff --git a/scripts/coverage.sh b/scripts/coverage.sh
index 1492f0f..c57c838 100755
--- a/scripts/coverage.sh
+++ b/scripts/coverage.sh
@@ -8,7 +8,7 @@ pkgs=("$@")
if [ ${#pkgs[@]} -eq 0 ]; then
pkgs=(
"codeberg.org/snonux/hexai/internal/tmux"
- "codeberg.org/snonux/hexai/cmd/internal/hexai-action"
+ "codeberg.org/snonux/hexai/internal/hexaiaction"
)
fi
@@ -28,4 +28,3 @@ done
echo
echo "Hint: combine coverage across all packages with:"
echo " go test ./... -coverprofile=cover.out && go tool cover -func=cover.out"
-