summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-19 09:10:05 +0200
committerPaul Buetow <paul@buetow.org>2026-03-19 09:10:05 +0200
commitb90e06b41ee0cd6a6bf420462c21b38ae2a788c1 (patch)
treefa76c532b91a7186af8853733580b46ae27be95b
parent0918aad469ac2ff5513a7131661f1106e5ec851c (diff)
Add Taskwarrior task proxy subcommand
-rw-r--r--cmd/hexai/main.go9
-rw-r--r--cmd/hexai/task_command.go105
-rw-r--r--cmd/hexai/task_command_test.go85
3 files changed, 199 insertions, 0 deletions
diff --git a/cmd/hexai/main.go b/cmd/hexai/main.go
index e71a204..b14ee37 100644
--- a/cmd/hexai/main.go
+++ b/cmd/hexai/main.go
@@ -58,6 +58,15 @@ func main() {
if finalPath == "" {
finalPath = configPath
}
+ if handled, exitCode, err := runTaskSubcommandIfRequested(fs.Args(), os.Stdin, os.Stdout, os.Stderr); handled {
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ }
+ if exitCode != 0 {
+ os.Exit(exitCode)
+ }
+ return
+ }
ctx := context.Background()
if finalPath != "" {
ctx = hexaicli.WithCLIConfigPath(ctx, finalPath)
diff --git a/cmd/hexai/task_command.go b/cmd/hexai/task_command.go
new file mode 100644
index 0000000..efd3a92
--- /dev/null
+++ b/cmd/hexai/task_command.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+type taskBinaryFinder func() (string, error)
+
+type repoTopLevelDetector func(context.Context) (string, error)
+
+type taskCommandRunner func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error
+
+type taskRunner struct {
+ findTaskBinary taskBinaryFinder
+ detectRepoRoot repoTopLevelDetector
+ runCommand taskCommandRunner
+}
+
+func newTaskRunner() taskRunner {
+ return taskRunner{
+ findTaskBinary: findTaskBinary,
+ detectRepoRoot: detectRepoRoot,
+ runCommand: runTaskCommand,
+ }
+}
+
+func runTaskSubcommandIfRequested(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, int, error) {
+ if len(args) == 0 || args[0] != "task" {
+ return false, 0, nil
+ }
+ code, err := newTaskRunner().run(context.Background(), args[1:], stdin, stdout, stderr)
+ return true, code, err
+}
+
+func (r taskRunner) run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ runner := normalizeTaskRunner(r)
+ taskPath, err := runner.findTaskBinary()
+ if err != nil {
+ return 1, fmt.Errorf("hexai task: Taskwarrior binary lookup failed: %w", err)
+ }
+ repoRoot, err := runner.detectRepoRoot(ctx)
+ if err != nil {
+ return 1, fmt.Errorf("hexai task: must be run inside a git repository so project:<repo> can be derived: %w", err)
+ }
+ projectName := filepath.Base(strings.TrimSpace(repoRoot))
+ if projectName == "" || projectName == "." || projectName == string(filepath.Separator) {
+ return 1, fmt.Errorf("hexai task: could not derive project name from git root %q", repoRoot)
+ }
+ taskArgs := append([]string{"project:" + projectName, "+agent"}, args...)
+ if err := runner.runCommand(ctx, taskPath, taskArgs, stdin, stdout, stderr); err != nil {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ return exitErr.ExitCode(), nil
+ }
+ return 1, fmt.Errorf("hexai task: failed to run Taskwarrior: %w", err)
+ }
+ return 0, nil
+}
+
+func normalizeTaskRunner(r taskRunner) taskRunner {
+ if r.findTaskBinary == nil {
+ r.findTaskBinary = findTaskBinary
+ }
+ if r.detectRepoRoot == nil {
+ r.detectRepoRoot = detectRepoRoot
+ }
+ if r.runCommand == nil {
+ r.runCommand = runTaskCommand
+ }
+ return r
+}
+
+func findTaskBinary() (string, error) {
+ path, err := exec.LookPath("task")
+ if err != nil {
+ return "", fmt.Errorf("Taskwarrior binary 'task' not found in PATH; install Taskwarrior and retry")
+ }
+ return path, nil
+}
+
+func detectRepoRoot(ctx context.Context) (string, error) {
+ out, err := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel").Output()
+ if err != nil {
+ return "", fmt.Errorf("must be run inside a git repository so project:<repo> can be derived")
+ }
+ root := strings.TrimSpace(string(out))
+ if root == "" {
+ return "", fmt.Errorf("git returned an empty repository root")
+ }
+ return root, nil
+}
+
+func runTaskCommand(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Stdin = stdin
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+ return cmd.Run()
+}
diff --git a/cmd/hexai/task_command_test.go b/cmd/hexai/task_command_test.go
new file mode 100644
index 0000000..2900910
--- /dev/null
+++ b/cmd/hexai/task_command_test.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "os/exec"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestRunTaskSubcommandIfRequested_SkipsNonTaskArgs(t *testing.T) {
+ handled, exitCode, err := runTaskSubcommandIfRequested([]string{"hello"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
+ if handled || exitCode != 0 || err != nil {
+ t.Fatalf("expected non-task args to be ignored, got handled=%v exitCode=%d err=%v", handled, exitCode, err)
+ }
+}
+
+func TestTaskRunnerRun_InjectsProjectFilterAndAgentTag(t *testing.T) {
+ var gotName string
+ var gotArgs []string
+ runner := taskRunner{
+ findTaskBinary: func() (string, error) { return "/usr/bin/task", nil },
+ detectRepoRoot: func(context.Context) (string, error) { return "/tmp/work/hexai", nil },
+ runCommand: func(_ context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
+ gotName = name
+ gotArgs = append([]string(nil), args...)
+ return nil
+ },
+ }
+
+ exitCode, err := runner.run(context.Background(), []string{"list", "limit:1"}, strings.NewReader("in"), &bytes.Buffer{}, &bytes.Buffer{})
+ if err != nil {
+ t.Fatalf("run returned error: %v", err)
+ }
+ if exitCode != 0 {
+ t.Fatalf("exitCode = %d, want 0", exitCode)
+ }
+ if gotName != "/usr/bin/task" {
+ t.Fatalf("task binary = %q, want /usr/bin/task", gotName)
+ }
+ wantArgs := []string{"project:hexai", "+agent", "list", "limit:1"}
+ if !reflect.DeepEqual(gotArgs, wantArgs) {
+ t.Fatalf("task args = %v, want %v", gotArgs, wantArgs)
+ }
+}
+
+func TestTaskRunnerRun_OutsideGitRepo_IsActionable(t *testing.T) {
+ runner := taskRunner{
+ findTaskBinary: func() (string, error) { return "/usr/bin/task", nil },
+ detectRepoRoot: func(context.Context) (string, error) { return "", errors.New("git failed") },
+ runCommand: func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error {
+ t.Fatal("runCommand should not be called when repo detection fails")
+ return nil
+ },
+ }
+
+ exitCode, err := runner.run(context.Background(), []string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
+ if exitCode != 1 {
+ t.Fatalf("exitCode = %d, want 1", exitCode)
+ }
+ if err == nil || !strings.Contains(err.Error(), "must be run inside a git repository") {
+ t.Fatalf("expected actionable git-repo error, got %v", err)
+ }
+}
+
+func TestTaskRunnerRun_PreservesTaskwarriorExitCode(t *testing.T) {
+ runner := taskRunner{
+ findTaskBinary: func() (string, error) { return "/usr/bin/task", nil },
+ detectRepoRoot: func(context.Context) (string, error) { return "/tmp/work/hexai", nil },
+ runCommand: func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error {
+ return exec.Command("sh", "-c", "exit 7").Run()
+ },
+ }
+
+ exitCode, err := runner.run(context.Background(), []string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
+ if err != nil {
+ t.Fatalf("expected nil error for subprocess exit, got %v", err)
+ }
+ if exitCode != 7 {
+ t.Fatalf("exitCode = %d, want 7", exitCode)
+ }
+}