summaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-19 09:26:26 +0200
committerPaul Buetow <paul@buetow.org>2026-03-19 09:26:26 +0200
commit15bc73d103259b492f8b77a422f8649bdf3d7c24 (patch)
tree58a834d1cab371ce9acc2b25780524223c06980c /cmd
parent5642eaf74a4a70e5c82646bef3e0dd42846baea8 (diff)
Add ask Taskwarrior wrapper
Diffstat (limited to 'cmd')
-rw-r--r--cmd/ask/main.go46
-rw-r--r--cmd/ask/main_test.go52
-rw-r--r--cmd/hexai/task_command.go95
-rw-r--r--cmd/hexai/task_command_test.go128
4 files changed, 101 insertions, 220 deletions
diff --git a/cmd/ask/main.go b/cmd/ask/main.go
new file mode 100644
index 0000000..73d3e04
--- /dev/null
+++ b/cmd/ask/main.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+
+ "codeberg.org/snonux/hexai/internal/taskproxy"
+)
+
+type taskRunner func(context.Context, []string, io.Reader, io.Writer, io.Writer) (int, error)
+
+type askRunner struct {
+ runTask taskRunner
+}
+
+func newAskRunner() askRunner {
+ return askRunner{runTask: runAskTask}
+}
+
+func main() {
+ if exitCode := newAskRunner().run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); exitCode != 0 {
+ os.Exit(exitCode)
+ }
+}
+
+func (r askRunner) run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+ runner := normalizeAskRunner(r)
+ exitCode, err := runner.runTask(context.Background(), args, stdin, stdout, stderr)
+ if err != nil {
+ fmt.Fprintln(stderr, err)
+ }
+ return exitCode
+}
+
+func normalizeAskRunner(r askRunner) askRunner {
+ if r.runTask == nil {
+ r.runTask = runAskTask
+ }
+ return r
+}
+
+func runAskTask(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ return taskproxy.NewRunner("ask").Run(ctx, args, stdin, stdout, stderr)
+}
diff --git a/cmd/ask/main_test.go b/cmd/ask/main_test.go
new file mode 100644
index 0000000..65551cb
--- /dev/null
+++ b/cmd/ask/main_test.go
@@ -0,0 +1,52 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestAskRunnerRun_ForwardsArgs(t *testing.T) {
+ var gotArgs []string
+ var stdout bytes.Buffer
+ runner := askRunner{
+ runTask: func(_ context.Context, args []string, stdin io.Reader, out, errOut io.Writer) (int, error) {
+ gotArgs = append([]string(nil), args...)
+ _, _ = io.WriteString(out, "task output")
+ return 0, nil
+ },
+ }
+
+ exitCode := runner.run([]string{"list", "limit:1"}, strings.NewReader(""), &stdout, &bytes.Buffer{})
+ if exitCode != 0 {
+ t.Fatalf("exitCode = %d, want 0", exitCode)
+ }
+ wantArgs := []string{"list", "limit:1"}
+ if !reflect.DeepEqual(gotArgs, wantArgs) {
+ t.Fatalf("args = %v, want %v", gotArgs, wantArgs)
+ }
+ if stdout.String() != "task output" {
+ t.Fatalf("stdout = %q, want %q", stdout.String(), "task output")
+ }
+}
+
+func TestAskRunnerRun_WritesErrorToStderr(t *testing.T) {
+ var stderr bytes.Buffer
+ runner := askRunner{
+ runTask: func(context.Context, []string, io.Reader, io.Writer, io.Writer) (int, error) {
+ return 1, errors.New("ask: must be run inside a git repository")
+ },
+ }
+
+ exitCode := runner.run([]string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &stderr)
+ if exitCode != 1 {
+ t.Fatalf("exitCode = %d, want 1", exitCode)
+ }
+ if !strings.Contains(stderr.String(), "must be run inside a git repository") {
+ t.Fatalf("stderr = %q, want actionable repo guidance", stderr.String())
+ }
+}
diff --git a/cmd/hexai/task_command.go b/cmd/hexai/task_command.go
index efd3a92..a942f71 100644
--- a/cmd/hexai/task_command.go
+++ b/cmd/hexai/task_command.go
@@ -2,104 +2,15 @@ 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,
- }
-}
+ "codeberg.org/snonux/hexai/internal/taskproxy"
+)
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)
+ code, err := taskproxy.NewRunner("hexai task").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
index 498ac65..2cdf0bc 100644
--- a/cmd/hexai/task_command_test.go
+++ b/cmd/hexai/task_command_test.go
@@ -2,11 +2,6 @@ package main
import (
"bytes"
- "context"
- "errors"
- "io"
- "os/exec"
- "reflect"
"strings"
"testing"
)
@@ -17,126 +12,3 @@ func TestRunTaskSubcommandIfRequested_SkipsNonTaskArgs(t *testing.T) {
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)
- }
-}
-
-func TestTaskRunnerRun_PreservesStdoutAndStderr(t *testing.T) {
- var stdout bytes.Buffer
- var stderr bytes.Buffer
- 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, out, errOut io.Writer) error {
- _, _ = io.WriteString(out, "task stdout")
- _, _ = io.WriteString(errOut, "task stderr")
- return nil
- },
- }
-
- exitCode, err := runner.run(context.Background(), []string{"list"}, strings.NewReader(""), &stdout, &stderr)
- if err != nil {
- t.Fatalf("run returned error: %v", err)
- }
- if exitCode != 0 {
- t.Fatalf("exitCode = %d, want 0", exitCode)
- }
- if stdout.String() != "task stdout" {
- t.Fatalf("stdout = %q, want %q", stdout.String(), "task stdout")
- }
- if stderr.String() != "task stderr" {
- t.Fatalf("stderr = %q, want %q", stderr.String(), "task stderr")
- }
-}
-
-func TestTaskRunnerRun_TaskLookupFailure_IsActionable(t *testing.T) {
- runner := taskRunner{
- findTaskBinary: func() (string, error) { return "", errors.New("not found") },
- }
-
- 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(), "Taskwarrior binary lookup failed") {
- t.Fatalf("expected actionable task lookup error, got %v", err)
- }
-}
-
-func TestTaskRunnerRun_EmptyRepoName_IsActionable(t *testing.T) {
- runner := taskRunner{
- findTaskBinary: func() (string, error) { return "/usr/bin/task", nil },
- detectRepoRoot: func(context.Context) (string, error) { 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(), "could not derive project name") {
- t.Fatalf("expected actionable project-name error, got %v", err)
- }
-}