diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-19 09:10:05 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-19 09:10:05 +0200 |
| commit | b90e06b41ee0cd6a6bf420462c21b38ae2a788c1 (patch) | |
| tree | fa76c532b91a7186af8853733580b46ae27be95b /cmd | |
| parent | 0918aad469ac2ff5513a7131661f1106e5ec851c (diff) | |
Add Taskwarrior task proxy subcommand
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/hexai/main.go | 9 | ||||
| -rw-r--r-- | cmd/hexai/task_command.go | 105 | ||||
| -rw-r--r-- | cmd/hexai/task_command_test.go | 85 |
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) + } +} |
