summaryrefslogtreecommitdiff
path: root/internal/hexaiaction/cmdentry.go
blob: cf72495226add218cff6840362613065b1c13f27 (plain)
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
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-tmux-action.
type Options struct {
    Infile       string
    Outfile      string
    UIChild      bool
    TmuxTarget   string
    TmuxSplit    string // "v" or "h"
    TmuxPercent  int    // 1-100
}

// RunCommand is the CLI orchestrator used by cmd/hexai-tmux-action. It runs in tmux
// split-pane mode by default, or child mode when -ui-child is set.
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)
    }
    // Always use tmux path
    return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent)
}

// seams for unit tests
var isTTYFn = func(fd uintptr) bool { return term.IsTerminal(int(fd)) }
var splitRunFn = tmux.SplitRun
var osExecutableFn = os.Executable
var runFn = Run

// 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-tmux-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-tmux-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-tmux-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-tmux-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-tmux-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
}

// echoThrough no longer used in tmux-only flow, but kept for potential reuse.
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
}