summaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-26 18:16:11 +0300
committerPaul Buetow <paul@buetow.org>2026-04-26 18:16:11 +0300
commitc4b872c5ec54340b1e62d7578ace400340573ce2 (patch)
treed224707b3dd25ac68693d44f084dbe85a0edd685 /cmd
parentf77d73244fa6585af0456fd374988b8b04b72646 (diff)
test: bring every package above the 80% coverage target
Per-package coverage was below the AGENTS.md target in six packages: cmd/ask 0.0% -> 83.3% cmd/hexai-tmux-edit 10.0% -> 93.3% cmd/hexai-tmux-action 27.8% -> 95.7% cmd/hexai-mcp-server 41.9% -> 88.2% internal/taskproxy 61.8% -> 98.2% internal/filelock 77.3% -> 100.0% The four cmd packages each had a main() that mixed flag parsing, struct construction, and runtime delegation, so nothing called from a test hit those statements. Each main() is now a one-line wrapper around a testable runMain(args, stdin, stdout, stderr) int that uses flag.NewFlagSet (instead of the global flag.Parse) so tests can drive it repeatedly. The deprecation banner in hexai-mcp-server is now a package-level constant, kept identical, so tests can assert on it directly without redirecting os.Stderr. The internal packages got new tests for paths that were previously unreachable: filelock's retry-then-success and non-EWOULDBLOCK error branches, and taskproxy's NewRunner / findTaskBinary / detectRepoRoot / runTaskCommand helpers (the ones that shell out to git and task). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'cmd')
-rw-r--r--cmd/ask/main.go31
-rw-r--r--cmd/ask/main_test.go69
-rw-r--r--cmd/hexai-mcp-server/main.go53
-rw-r--r--cmd/hexai-mcp-server/main_test.go107
-rw-r--r--cmd/hexai-tmux-action/main.go36
-rw-r--r--cmd/hexai-tmux-action/main_test.go77
-rw-r--r--cmd/hexai-tmux-edit/main.go25
-rw-r--r--cmd/hexai-tmux-edit/main_test.go65
8 files changed, 389 insertions, 74 deletions
diff --git a/cmd/ask/main.go b/cmd/ask/main.go
index afab992..fbd5bb0 100644
--- a/cmd/ask/main.go
+++ b/cmd/ask/main.go
@@ -3,20 +3,33 @@ package main
import (
"context"
"fmt"
+ "io"
"os"
"codeberg.org/snonux/hexai/internal/askcli"
)
-func main() {
- d := askcli.NewDispatcher(nil)
- code, err := d.Dispatch(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr)
+// dispatcher is the minimal interface runMain depends on; it matches
+// (*askcli.Dispatcher).Dispatch so a real dispatcher satisfies it directly.
+type dispatcher interface {
+ Dispatch(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error)
+}
+
+// dispatcherFactory is a test seam: override to inject a fake dispatcher so
+// runMain can be exercised without a real `task` binary on PATH.
+var dispatcherFactory = func() dispatcher {
+ return askcli.NewDispatcher(nil)
+}
+
+func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) }
+
+// runMain dispatches the command and returns the process exit code; errors
+// are printed to stderr. The dispatcher's exit code is returned regardless
+// of err so callers see Taskwarrior's own exit code on failure paths.
+func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+ code, err := dispatcherFactory().Dispatch(context.Background(), args, stdin, stdout, stderr)
if err != nil {
- // Print the internal error so callers get a useful diagnostic message.
- fmt.Fprintln(os.Stderr, err)
- os.Exit(code)
- }
- if code != 0 {
- os.Exit(code)
+ fmt.Fprintln(stderr, err)
}
+ return code
}
diff --git a/cmd/ask/main_test.go b/cmd/ask/main_test.go
index db6b436..643dde4 100644
--- a/cmd/ask/main_test.go
+++ b/cmd/ask/main_test.go
@@ -3,7 +3,9 @@ package main
import (
"bytes"
"context"
+ "errors"
"io"
+ "strings"
"testing"
"codeberg.org/snonux/hexai/internal/askcli"
@@ -49,3 +51,70 @@ type spyRunner struct {
func (s *spyRunner) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
return s.runFn(ctx, args, stdin, stdout, stderr)
}
+
+// fakeDispatcher captures Dispatch arguments and returns canned values; used
+// to exercise runMain without a real Taskwarrior binary on PATH.
+type fakeDispatcher struct {
+ gotArgs []string
+ code int
+ err error
+}
+
+func (f *fakeDispatcher) Dispatch(_ context.Context, args []string, _ io.Reader, _, _ io.Writer) (int, error) {
+ f.gotArgs = append([]string(nil), args...)
+ return f.code, f.err
+}
+
+// Driving runMain through a fake dispatcher proves the wiring (args
+// forwarded, exit code returned) without touching the real ask CLI.
+func TestRunMain_DelegatesAndReturnsCode(t *testing.T) {
+ old := dispatcherFactory
+ t.Cleanup(func() { dispatcherFactory = old })
+
+ fake := &fakeDispatcher{code: 0}
+ dispatcherFactory = func() dispatcher { return fake }
+
+ var stdout, stderr bytes.Buffer
+ got := runMain([]string{"list", "limit:1"}, nil, &stdout, &stderr)
+ if got != 0 {
+ t.Fatalf("runMain code = %d, want 0", got)
+ }
+ if len(fake.gotArgs) != 2 || fake.gotArgs[0] != "list" || fake.gotArgs[1] != "limit:1" {
+ t.Fatalf("Dispatch args = %v", fake.gotArgs)
+ }
+ if stderr.Len() != 0 {
+ t.Fatalf("stderr should be empty on success, got %q", stderr.String())
+ }
+}
+
+// The default dispatcherFactory must return a working real dispatcher (this
+// is the path main() uses in production); fakes used elsewhere don't cover
+// it, so verify it explicitly.
+func TestDispatcherFactory_DefaultReturnsRealDispatcher(t *testing.T) {
+ d := dispatcherFactory()
+ if d == nil {
+ t.Fatal("default dispatcherFactory returned nil")
+ }
+ if _, ok := d.(*askcli.Dispatcher); !ok {
+ t.Fatalf("default dispatcherFactory returned %T, want *askcli.Dispatcher", d)
+ }
+}
+
+// On a dispatcher error, runMain must print the error to stderr AND surface
+// the dispatcher's exit code so the shell sees Taskwarrior's own status.
+func TestRunMain_PrintsErrorAndPropagatesExitCode(t *testing.T) {
+ old := dispatcherFactory
+ t.Cleanup(func() { dispatcherFactory = old })
+
+ fake := &fakeDispatcher{code: 7, err: errors.New("dispatch boom")}
+ dispatcherFactory = func() dispatcher { return fake }
+
+ var stdout, stderr bytes.Buffer
+ got := runMain(nil, nil, &stdout, &stderr)
+ if got != 7 {
+ t.Fatalf("runMain code = %d, want 7", got)
+ }
+ if !strings.Contains(stderr.String(), "dispatch boom") {
+ t.Fatalf("stderr missing error text: %q", stderr.String())
+ }
+}
diff --git a/cmd/hexai-mcp-server/main.go b/cmd/hexai-mcp-server/main.go
index b32c18c..ac88178 100644
--- a/cmd/hexai-mcp-server/main.go
+++ b/cmd/hexai-mcp-server/main.go
@@ -29,10 +29,10 @@ var (
runBackfill = hexaimcp.RunBackfill
)
-// printDeprecationWarning outputs a deprecation notice to stderr explaining
-// that hexai-mcp-server is experimental and not actively maintained.
-func printDeprecationWarning() {
- warning := `
+// deprecationWarning is the notice runMain emits on every startup so users
+// see this binary is experimental. Kept as a constant (not printf'd) so
+// tests can assert on its contents directly.
+const deprecationWarning = `
⚠️ DEPRECATION NOTICE ⚠️
hexai-mcp-server is currently EXPERIMENTAL and NOT ACTIVELY MAINTAINED.
@@ -50,8 +50,6 @@ Use at your own risk.
────────────────────────────────────────────────────────────────────────
`
- fmt.Fprintln(os.Stderr, warning)
-}
// mcpOptions holds the parsed command-line flags for the MCP server.
type mcpOptions struct {
@@ -64,22 +62,34 @@ type mcpOptions struct {
showVersion bool
}
-func main() {
- printDeprecationWarning()
+func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) }
+
+// runMain prints the deprecation warning, parses flags, and delegates to
+// run. It returns the process exit code: 2 for flag-parse errors (matching
+// stdlib `flag.ExitOnError`), 1 for state-dir or run failures, 0 on success.
+// Pulling this out of main keeps it testable without touching package-level
+// flag state.
+func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+ fmt.Fprint(stderr, deprecationWarning)
defaultLog, err := defaultLogPath()
if err != nil {
- fmt.Fprintf(os.Stderr, "error: %v\n", err)
- os.Exit(1)
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return 1
+ }
+
+ fs := flag.NewFlagSet("hexai-mcp-server", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ logPath := fs.String("log", defaultLog, "path to log file (optional)")
+ configPath := fs.String("config", "", "path to config file (optional)")
+ promptsDir := fs.String("prompts-dir", "", "path to prompts directory (optional)")
+ slashCommandSync := fs.Bool("slashcommand-sync", false, "enable slash command sync")
+ slashCommandDir := fs.String("slashcommand-dir", "", "directory for slash command files")
+ syncAll := fs.Bool("sync-all", false, "backfill all existing prompts and exit")
+ showVersion := fs.Bool("version", false, "print version and exit")
+ if err := fs.Parse(args); err != nil {
+ return 2
}
- logPath := flag.String("log", defaultLog, "path to log file (optional)")
- configPath := flag.String("config", "", "path to config file (optional)")
- promptsDir := flag.String("prompts-dir", "", "path to prompts directory (optional)")
- slashCommandSync := flag.Bool("slashcommand-sync", false, "enable slash command sync")
- slashCommandDir := flag.String("slashcommand-dir", "", "directory for slash command files")
- syncAll := flag.Bool("sync-all", false, "backfill all existing prompts and exit")
- showVersion := flag.Bool("version", false, "print version and exit")
- flag.Parse()
opts := mcpOptions{
logPath: *logPath,
@@ -90,10 +100,11 @@ func main() {
syncAll: *syncAll,
showVersion: *showVersion,
}
- if err := run(opts, os.Stdin, os.Stdout, os.Stderr); err != nil {
- fmt.Fprintf(os.Stderr, "error: %v\n", err)
- os.Exit(1)
+ if err := run(opts, stdin, stdout, stderr); err != nil {
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return 1
}
+ return 0
}
// run executes the MCP server logic with the given options and I/O streams.
diff --git a/cmd/hexai-mcp-server/main_test.go b/cmd/hexai-mcp-server/main_test.go
index 3d85fbb..33f662d 100644
--- a/cmd/hexai-mcp-server/main_test.go
+++ b/cmd/hexai-mcp-server/main_test.go
@@ -4,7 +4,6 @@ import (
"bytes"
"errors"
"io"
- "os"
"strings"
"testing"
@@ -12,31 +11,12 @@ import (
"codeberg.org/snonux/hexai/internal/hexaimcp"
)
-func TestPrintDeprecationWarning(t *testing.T) {
- r, w, err := os.Pipe()
- if err != nil {
- t.Fatalf("failed to create pipe: %v", err)
- }
-
- oldStderr := os.Stderr
- os.Stderr = w
- defer func() { os.Stderr = oldStderr }()
-
- printDeprecationWarning()
-
- if err := w.Close(); err != nil {
- t.Fatalf("failed to close pipe writer: %v", err)
- }
-
- b, err := io.ReadAll(r)
- if err != nil {
- t.Fatalf("failed to read pipe: %v", err)
- }
-
- output := string(b)
+// The deprecation banner is unconditional: it must reach stderr on every
+// invocation so users notice this binary is unmaintained.
+func TestDeprecationWarning_Content(t *testing.T) {
for _, want := range []string{"DEPRECATION NOTICE", "EXPERIMENTAL", "NOT ACTIVELY MAINTAINED"} {
- if !strings.Contains(output, want) {
- t.Errorf("expected %q in output, got %q", want, output)
+ if !strings.Contains(deprecationWarning, want) {
+ t.Errorf("expected %q in deprecationWarning", want)
}
}
}
@@ -168,3 +148,80 @@ func TestRun_MCPServerError(t *testing.T) {
t.Fatalf("expected server error, got: %v", err)
}
}
+
+// runMain version path: -version must short-circuit and write the version
+// to stdout (not stderr, so it stays scriptable). Stderr still gets the
+// deprecation banner — that's correct since the binary is leaving anyway.
+func TestRunMain_VersionFlag(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ code := runMain([]string{"-version"}, nil, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("runMain code = %d, want 0", code)
+ }
+ if got := strings.TrimSpace(stdout.String()); got != internal.Version {
+ t.Fatalf("stdout = %q, want version %q", got, internal.Version)
+ }
+ if !strings.Contains(stderr.String(), "DEPRECATION NOTICE") {
+ t.Fatalf("stderr missing deprecation banner: %q", stderr.String())
+ }
+}
+
+// runMain --sync-all path: forwards parsed options to runBackfill and
+// returns 0 on success.
+func TestRunMain_SyncAllSuccess(t *testing.T) {
+ old := runBackfill
+ t.Cleanup(func() { runBackfill = old })
+
+ var gotLog string
+ runBackfill = func(logPath string, _ string, _ hexaimcp.MCPOverrides) error {
+ gotLog = logPath
+ return nil
+ }
+
+ var stdout, stderr bytes.Buffer
+ code := runMain([]string{"-sync-all", "-log", "/tmp/sync.log"}, nil, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("runMain code = %d, want 0; stderr=%q", code, stderr.String())
+ }
+ if gotLog != "/tmp/sync.log" {
+ t.Fatalf("logPath forwarded = %q, want /tmp/sync.log", gotLog)
+ }
+}
+
+// runMain run-error path: when the underlying server fails, runMain must
+// return 1 (the production exit code) and write the error to stderr.
+func TestRunMain_ServerErrorReturnsOne(t *testing.T) {
+ old := runMCP
+ t.Cleanup(func() { runMCP = old })
+ runMCP = func(string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error {
+ return errors.New("mcp boom")
+ }
+
+ var stdout, stderr bytes.Buffer
+ code := runMain(nil, nil, &stdout, &stderr)
+ if code != 1 {
+ t.Fatalf("runMain code = %d, want 1", code)
+ }
+ if !strings.Contains(stderr.String(), "mcp boom") {
+ t.Fatalf("stderr missing error: %q", stderr.String())
+ }
+}
+
+// Bad flag must yield exit 2 without ever invoking the server stub.
+func TestRunMain_BadFlagReturnsTwo(t *testing.T) {
+ old := runMCP
+ t.Cleanup(func() { runMCP = old })
+ called := false
+ runMCP = func(string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error {
+ called = true
+ return nil
+ }
+ var stdout, stderr bytes.Buffer
+ code := runMain([]string{"--bogus"}, nil, &stdout, &stderr)
+ if code != 2 {
+ t.Fatalf("runMain code = %d, want 2", code)
+ }
+ if called {
+ t.Fatal("runMCP must not be called on flag-parse failure")
+ }
+}
diff --git a/cmd/hexai-tmux-action/main.go b/cmd/hexai-tmux-action/main.go
index a0f240b..e2f50eb 100644
--- a/cmd/hexai-tmux-action/main.go
+++ b/cmd/hexai-tmux-action/main.go
@@ -16,26 +16,38 @@ import (
// the real tmux action.
var runCommand = hexaiaction.RunCommand
-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")
- uiChild := flag.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically")
+func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) }
+
+// runMain parses command-line flags from args, builds actionOptions, and
+// delegates to run. It returns the process exit code: 2 for flag-parse
+// errors (matching stdlib `flag.ExitOnError`), 1 for runtime failures, 0 on
+// success. Splitting the body out of main keeps it testable without
+// touching package-level flag state.
+func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+ fs := flag.NewFlagSet("hexai-tmux-action", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ infile := fs.String("infile", "", "Read input from this file instead of stdin")
+ outfile := fs.String("outfile", "", "Write output to this file instead of stdout")
+ uiChild := fs.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically")
defaultPath := appconfig.DefaultConfigPath()
- configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath))
- tmuxTarget := flag.String("tmux-target", "", "tmux popup target pane (advanced)")
- tmuxPopupWidth := flag.String("tmux-popup-width", "60%", "tmux popup width, e.g. 60% or 120")
- tmuxPopupHeight := flag.String("tmux-popup-height", "50%", "tmux popup height, e.g. 50% or 30")
- flag.Parse()
+ configPath := fs.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath))
+ tmuxTarget := fs.String("tmux-target", "", "tmux popup target pane (advanced)")
+ tmuxPopupWidth := fs.String("tmux-popup-width", "60%", "tmux popup width, e.g. 60% or 120")
+ tmuxPopupHeight := fs.String("tmux-popup-height", "50%", "tmux popup height, e.g. 50% or 30")
+ if err := fs.Parse(args); err != nil {
+ return 2
+ }
opts := actionOptions{
infile: *infile, outfile: *outfile,
uiChild: *uiChild, configPath: *configPath,
tmuxTarget: *tmuxTarget, tmuxPopupWidth: *tmuxPopupWidth, tmuxPopupHeight: *tmuxPopupHeight,
}
- if err := run(opts, os.Stdin, os.Stdout, os.Stderr); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
+ if err := run(opts, stdin, stdout, stderr); err != nil {
+ fmt.Fprintln(stderr, err)
+ return 1
}
+ return 0
}
// actionOptions holds the parsed command-line flags for hexai-tmux-action.
diff --git a/cmd/hexai-tmux-action/main_test.go b/cmd/hexai-tmux-action/main_test.go
index c2cc95f..e1c02e1 100644
--- a/cmd/hexai-tmux-action/main_test.go
+++ b/cmd/hexai-tmux-action/main_test.go
@@ -1,9 +1,11 @@
package main
import (
+ "bytes"
"context"
"errors"
"io"
+ "strings"
"testing"
"codeberg.org/snonux/hexai/internal/hexaiaction"
@@ -61,3 +63,78 @@ func TestRun_Error(t *testing.T) {
t.Fatalf("expected error, got: %v", err)
}
}
+
+// runMain happy path: every flag is forwarded into hexaiaction.Options and
+// the stub returns 0. The captured Options confirm the field-by-field
+// mapping that main relies on.
+func TestRunMain_FlagsForwardedToHexaiaction(t *testing.T) {
+ old := runCommand
+ t.Cleanup(func() { runCommand = old })
+
+ var got hexaiaction.Options
+ runCommand = func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error {
+ got = opts
+ return nil
+ }
+
+ args := []string{
+ "-infile", "in.txt",
+ "-outfile", "out.txt",
+ "-tmux-target", "%2",
+ "-tmux-popup-width", "70%",
+ "-tmux-popup-height", "40%",
+ "-ui-child",
+ }
+ var stderr bytes.Buffer
+ code := runMain(args, nil, &bytes.Buffer{}, &stderr)
+ if code != 0 {
+ t.Fatalf("runMain code = %d, want 0; stderr=%q", code, stderr.String())
+ }
+ if got.Infile != "in.txt" || got.Outfile != "out.txt" {
+ t.Fatalf("infile/outfile mismatch: %+v", got)
+ }
+ if got.TmuxTarget != "%2" || got.TmuxPopupWidth != "70%" || got.TmuxPopupHeight != "40%" {
+ t.Fatalf("tmux flags mismatch: %+v", got)
+ }
+ if !got.UIChild {
+ t.Fatal("expected UIChild=true")
+ }
+}
+
+// On runCommand failure, runMain returns 1 (the production exit code) and
+// writes the error message to stderr so users see what went wrong.
+func TestRunMain_RuntimeErrorReturnsOne(t *testing.T) {
+ old := runCommand
+ t.Cleanup(func() { runCommand = old })
+ runCommand = func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error {
+ return errors.New("action exploded")
+ }
+
+ var stderr bytes.Buffer
+ code := runMain(nil, nil, &bytes.Buffer{}, &stderr)
+ if code != 1 {
+ t.Fatalf("runMain code = %d, want 1", code)
+ }
+ if !strings.Contains(stderr.String(), "action exploded") {
+ t.Fatalf("stderr missing error: %q", stderr.String())
+ }
+}
+
+// Bad flag must yield exit 2 without ever invoking runCommand.
+func TestRunMain_BadFlagReturnsTwo(t *testing.T) {
+ old := runCommand
+ t.Cleanup(func() { runCommand = old })
+ called := false
+ runCommand = func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error {
+ called = true
+ return nil
+ }
+ var stderr bytes.Buffer
+ code := runMain([]string{"--bogus"}, nil, &bytes.Buffer{}, &stderr)
+ if code != 2 {
+ t.Fatalf("runMain code = %d, want 2", code)
+ }
+ if called {
+ t.Fatal("runCommand must not be called on flag-parse failure")
+ }
+}
diff --git a/cmd/hexai-tmux-edit/main.go b/cmd/hexai-tmux-edit/main.go
index 6d0e75e..6177008 100644
--- a/cmd/hexai-tmux-edit/main.go
+++ b/cmd/hexai-tmux-edit/main.go
@@ -14,6 +14,7 @@ package main
import (
"flag"
"fmt"
+ "io"
"os"
"strings"
@@ -24,18 +25,28 @@ import (
// runTmuxEdit is the seam for testing: override in tests to avoid real tmux.
var runTmuxEdit = tmuxedit.Run
-func main() {
+func main() { os.Exit(runMain(os.Args[1:], os.Stderr)) }
+
+// runMain parses flags from args and runs the tmux edit popup. It returns
+// the process exit code; flag errors return 2 (matching stdlib convention),
+// runtime failures return 1.
+func runMain(args []string, stderr io.Writer) int {
defaultPath := appconfig.DefaultConfigPath()
- configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath))
- agent := flag.String("agent", "", "AI agent name (auto-detected if omitted)")
- pane := flag.String("pane", "", "tmux target pane ID (e.g. %%5)")
- flag.Parse()
+ fs := flag.NewFlagSet("hexai-tmux-edit", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ configPath := fs.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath))
+ agent := fs.String("agent", "", "AI agent name (auto-detected if omitted)")
+ pane := fs.String("pane", "", "tmux target pane ID (e.g. %5)")
+ if err := fs.Parse(args); err != nil {
+ return 2
+ }
opts := buildOptions(*configPath, *agent, *pane)
if err := runTmuxEdit(opts); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
+ fmt.Fprintln(stderr, err)
+ return 1
}
+ return 0
}
// buildOptions constructs tmuxedit.Options from the parsed flag values,
diff --git a/cmd/hexai-tmux-edit/main_test.go b/cmd/hexai-tmux-edit/main_test.go
index fc2364c..6881556 100644
--- a/cmd/hexai-tmux-edit/main_test.go
+++ b/cmd/hexai-tmux-edit/main_test.go
@@ -1,7 +1,9 @@
package main
import (
+ "bytes"
"errors"
+ "strings"
"testing"
"codeberg.org/snonux/hexai/internal/tmuxedit"
@@ -57,3 +59,66 @@ func TestRunTmuxEdit_Error(t *testing.T) {
t.Fatalf("expected error, got: %v", err)
}
}
+
+// runMain happy path: flags parse, runTmuxEdit returns nil, exit code 0.
+// We capture the resolved Options to confirm flags map onto fields correctly.
+func TestRunMain_FlagsForwardedToTmuxedit(t *testing.T) {
+ old := runTmuxEdit
+ t.Cleanup(func() { runTmuxEdit = old })
+
+ var got tmuxedit.Options
+ runTmuxEdit = func(opts tmuxedit.Options) error {
+ got = opts
+ return nil
+ }
+
+ var stderr bytes.Buffer
+ code := runMain([]string{"-config", " /tmp/cfg.toml ", "-agent", "claude", "-pane", "%9"}, &stderr)
+ if code != 0 {
+ t.Fatalf("runMain code = %d, want 0", code)
+ }
+ if got.ConfigPath != "/tmp/cfg.toml" || got.Agent != "claude" || got.Pane != "%9" {
+ t.Fatalf("unexpected opts: %+v", got)
+ }
+ if stderr.Len() != 0 {
+ t.Fatalf("stderr should be empty on success, got %q", stderr.String())
+ }
+}
+
+// runMain reports tmuxedit.Run failures by writing to stderr and returning 1
+// — the production exit code that the shipped binary uses.
+func TestRunMain_RunErrorReturnsOne(t *testing.T) {
+ old := runTmuxEdit
+ t.Cleanup(func() { runTmuxEdit = old })
+ runTmuxEdit = func(tmuxedit.Options) error { return errors.New("boom") }
+
+ var stderr bytes.Buffer
+ code := runMain(nil, &stderr)
+ if code != 1 {
+ t.Fatalf("runMain code = %d, want 1", code)
+ }
+ if !strings.Contains(stderr.String(), "boom") {
+ t.Fatalf("stderr missing error: %q", stderr.String())
+ }
+}
+
+// Unknown flags must yield exit 2 (the convention used by stdlib `flag` when
+// ExitOnError aborts) without ever invoking runTmuxEdit.
+func TestRunMain_BadFlagReturnsTwo(t *testing.T) {
+ old := runTmuxEdit
+ t.Cleanup(func() { runTmuxEdit = old })
+ called := false
+ runTmuxEdit = func(tmuxedit.Options) error {
+ called = true
+ return nil
+ }
+
+ var stderr bytes.Buffer
+ code := runMain([]string{"--no-such-flag"}, &stderr)
+ if code != 2 {
+ t.Fatalf("runMain code = %d, want 2", code)
+ }
+ if called {
+ t.Fatal("runTmuxEdit must not be called on flag-parse failure")
+ }
+}