summaryrefslogtreecommitdiff
path: root/internal/hexaiaction/cmdentry.go
blob: ca3344315237ae3229c00d3bebb4f724f22322cb (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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
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)) }
	splitRunFn     = tmux.SplitRun
	osExecutableFn = os.Executable
	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
}