summaryrefslogtreecommitdiff
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
parent5642eaf74a4a70e5c82646bef3e0dd42846baea8 (diff)
Add ask Taskwarrior wrapper
-rw-r--r--Magefile.go40
-rw-r--r--README.md4
-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
-rw-r--r--docs/buildandinstall.md3
-rw-r--r--docs/usage.md16
-rw-r--r--internal/taskproxy/run.go129
-rw-r--r--internal/taskproxy/run_test.go141
10 files changed, 419 insertions, 235 deletions
diff --git a/Magefile.go b/Magefile.go
index 2850150..c87f63a 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -24,72 +24,81 @@ var (
// Build builds binaries.
func Build() error {
- mg.Deps(BuildHexaiLSP, BuildHexaiCLI, BuildHexaiTmuxAction, BuildHexaiTmuxEdit, BuildHexaiMCPServer)
+ mg.Deps(BuildAsk, BuildHexaiLSP, BuildHexaiCLI, BuildHexaiTmuxAction, BuildHexaiTmuxEdit, BuildHexaiMCPServer)
printCoverage()
return nil
}
+// BuildAsk builds the Taskwarrior proxy wrapper.
+func BuildAsk() error {
+ printCoverage()
+ return sh.RunV("go", "build", "-o", "ask", "./cmd/ask")
+}
+
// BuildHexaiLSP builds the LSP server binary.
func BuildHexaiLSP() error {
printCoverage()
- return sh.RunV("go", "build", "-o", "hexai-lsp-server", "cmd/hexai-lsp-server/main.go")
+ return sh.RunV("go", "build", "-o", "hexai-lsp-server", "./cmd/hexai-lsp-server")
}
// BuildHexaiCLI builds the CLI binary.
func BuildHexaiCLI() error {
printCoverage()
- return sh.RunV("go", "build", "-o", "hexai", "cmd/hexai/main.go")
+ return sh.RunV("go", "build", "-o", "hexai", "./cmd/hexai")
}
// BuildHexaiTmuxAction builds the hexai-tmux-action TUI binary.
func BuildHexaiTmuxAction() error {
printCoverage()
- return sh.RunV("go", "build", "-o", "hexai-tmux-action", "cmd/hexai-tmux-action/main.go")
+ return sh.RunV("go", "build", "-o", "hexai-tmux-action", "./cmd/hexai-tmux-action")
}
// BuildHexaiTmuxEdit builds the hexai-tmux-edit popup editor binary.
func BuildHexaiTmuxEdit() error {
printCoverage()
- return sh.RunV("go", "build", "-o", "hexai-tmux-edit", "cmd/hexai-tmux-edit/main.go")
+ return sh.RunV("go", "build", "-o", "hexai-tmux-edit", "./cmd/hexai-tmux-edit")
}
// BuildHexaiMCPServer builds the MCP server binary (DEPRECATED - experimental, not actively maintained).
func BuildHexaiMCPServer() error {
printCoverage()
- return sh.RunV("go", "build", "-o", "hexai-mcp-server", "cmd/hexai-mcp-server/main.go")
+ return sh.RunV("go", "build", "-o", "hexai-mcp-server", "./cmd/hexai-mcp-server")
}
// Dev runs tests, vet, lint, then builds with race for all binaries.
func Dev() error {
printCoverage()
mg.Deps(Test, Vet, Lint)
- if err := sh.RunV("go", "build", "-race", "-o", "hexai-lsp-server", "cmd/hexai-lsp-server/main.go"); err != nil {
+ if err := sh.RunV("go", "build", "-race", "-o", "ask", "./cmd/ask"); err != nil {
return err
}
- if err := sh.RunV("go", "build", "-race", "-o", "hexai", "cmd/hexai/main.go"); err != nil {
+ if err := sh.RunV("go", "build", "-race", "-o", "hexai-lsp-server", "./cmd/hexai-lsp-server"); err != nil {
return err
}
- if err := sh.RunV("go", "build", "-race", "-o", "hexai-tmux-action", "cmd/hexai-tmux-action/main.go"); err != nil {
+ if err := sh.RunV("go", "build", "-race", "-o", "hexai", "./cmd/hexai"); err != nil {
return err
}
- if err := sh.RunV("go", "build", "-race", "-o", "hexai-tmux-edit", "cmd/hexai-tmux-edit/main.go"); err != nil {
+ if err := sh.RunV("go", "build", "-race", "-o", "hexai-tmux-action", "./cmd/hexai-tmux-action"); err != nil {
return err
}
- return sh.RunV("go", "build", "-race", "-o", "hexai-mcp-server", "cmd/hexai-mcp-server/main.go")
+ if err := sh.RunV("go", "build", "-race", "-o", "hexai-tmux-edit", "./cmd/hexai-tmux-edit"); err != nil {
+ return err
+ }
+ return sh.RunV("go", "build", "-race", "-o", "hexai-mcp-server", "./cmd/hexai-mcp-server")
}
// Run launches the LSP server via go run (useful during development).
func Run() error {
printCoverage()
mg.Deps(Dev)
- return sh.RunV("go", "run", "cmd/hexai-lsp-server/main.go")
+ return sh.RunV("go", "run", "./cmd/hexai-lsp-server")
}
// RunCLI runs the CLI with a small test input.
func RunCLI() error {
printCoverage()
mg.Deps(Dev)
- cmd := "echo 'test' | go run cmd/hexai/main.go"
+ cmd := "echo 'test' | go run ./cmd/hexai"
return sh.RunV("bash", "-lc", cmd)
}
@@ -109,6 +118,9 @@ func Install() error {
if err := os.MkdirAll(bin, 0o755); err != nil {
return err
}
+ if err := sh.RunV("cp", "-v", "./ask", bin+"/"); err != nil {
+ return err
+ }
if err := sh.RunV("cp", "-v", "./hexai-lsp-server", bin+"/"); err != nil {
return err
}
@@ -128,7 +140,7 @@ func Install() error {
func RunTmuxAction() error {
printCoverage()
mg.Deps(Dev)
- return sh.RunV("go", "run", "cmd/hexai-tmux-action/main.go")
+ return sh.RunV("go", "run", "./cmd/hexai-tmux-action")
}
// printCoverage prints a warning if an existing coverage profile shows total < coverateThreshold.
diff --git a/README.md b/README.md
index 4dcc881..84e97ef 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,10 @@ It has got improved capabilities for Go code understanding (for example, create
* LSP in-editor chat with the LLM
* Stand-alone command line tool for LLM interaction
- Includes `--tps-simulation` to preview how fast a model would feel by streaming placeholder text or piped stdin at a chosen token-per-second rate
+* Taskwarrior proxy for agent-managed project work
+ - Preferred entrypoint: `ask`
+ - Compatibility alias: `hexai task`
+ - Automatically scopes Taskwarrior commands to `project:<repo> +agent`
* Parallel completions and CLI responses from multiple providers/models for side-by-side comparison
* **MCP server for prompt/runbook management** (`hexai-mcp-server`) - **⚠️ DEPRECATED/EXPERIMENTAL**
- Create, update, delete, and retrieve prompts via MCP protocol
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)
- }
-}
diff --git a/docs/buildandinstall.md b/docs/buildandinstall.md
index 2240be0..3d70a4a 100644
--- a/docs/buildandinstall.md
+++ b/docs/buildandinstall.md
@@ -3,7 +3,7 @@
Hexai uses Mage for developer tasks. Install Mage, then run targets like build, dev, test, and install.
- Install Mage: `go install github.com/magefile/mage@latest`
-- Build binaries: `mage build` (produces `hexai`, `hexai-lsp-server`, `hexai-tmux-action`, and `hexai-tmux-edit`)
+- Build binaries: `mage build` (produces `ask`, `hexai`, `hexai-lsp-server`, `hexai-tmux-action`, and `hexai-tmux-edit`)
- Dev build (+ tests, vet, lint): `mage dev`
- Run tests: `mage test`
- Run tests with coverage: `go test ./... -cover`
@@ -18,6 +18,7 @@ Note: `mage lint` uses `golangci-lint`. Install via `mage devinstall` if needed.
Either use the Mage method as mentioned above, or install directly with:
+- Taskwarrior proxy: `go install codeberg.org/snonux/hexai/cmd/ask@latest`
- CLI: `go install codeberg.org/snonux/hexai/cmd/hexai@latest`
- LSP: `go install codeberg.org/snonux/hexai/cmd/hexai-lsp-server@latest`
- Action runner: `go install codeberg.org/snonux/hexai/cmd/hexai-tmux-action@latest`
diff --git a/docs/usage.md b/docs/usage.md
index 8404969..571956b 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -123,6 +123,22 @@ hexai --tps-simulation 12-18
cat SOMEFILE.txt | hexai --tps-simulation 20
```
+## Taskwarrior proxy
+
+Use `ask` when you want Taskwarrior automatically scoped to the current git repo and the `+agent` tag.
+
+- `ask list`
+- `ask add priority:H "Implement X"`
+- `ask uuid:1234 annotate "Delivered Y"`
+- `ask 42 done`
+
+`ask` pre-sets `project:<repo> +agent` and then forwards the remaining arguments to Taskwarrior unchanged, so normal reports, filters, UUID selectors, and commands still work. It must be run inside a git repository so the project name can be derived from the repo root.
+
+Compatibility note: `hexai task ...` still works and behaves the same way, but `ask ...` is the shorter preferred entrypoint for agent-managed project tasks.
+
+- `hexai task list`
+- `hexai task uuid:1234 annotate "Delivered Y"`
+
## Hexai Action (TUI)
`hexai-tmux-action` runs code actions over a selection or diagnostics+selection piped from stdin, or read from a file.
diff --git a/internal/taskproxy/run.go b/internal/taskproxy/run.go
new file mode 100644
index 0000000..7654b7b
--- /dev/null
+++ b/internal/taskproxy/run.go
@@ -0,0 +1,129 @@
+package taskproxy
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+type binaryFinder func() (string, error)
+
+type repoTopLevelDetector func(context.Context) (string, error)
+
+type commandRunner func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error
+
+type Runner struct {
+ CommandName string
+ findTaskBinary binaryFinder
+ detectRepoRoot repoTopLevelDetector
+ runCommand commandRunner
+}
+
+func NewRunner(commandName string) Runner {
+ return Runner{
+ CommandName: strings.TrimSpace(commandName),
+ findTaskBinary: findTaskBinary,
+ detectRepoRoot: detectRepoRoot,
+ runCommand: runTaskCommand,
+ }
+}
+
+func (r Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ runner := normalizeRunner(r)
+ taskPath, err := runner.findTaskBinary()
+ if err != nil {
+ return 1, fmt.Errorf("%s: Taskwarrior binary lookup failed: %w", runner.commandLabel(), err)
+ }
+ repoRoot, err := runner.detectRepoRoot(ctx)
+ if err != nil {
+ return 1, fmt.Errorf("%s: must be run inside a git repository so project:<repo> can be derived: %w", runner.commandLabel(), err)
+ }
+ taskArgs, err := runner.taskArgs(repoRoot, args)
+ if err != nil {
+ return 1, fmt.Errorf("%s: %w", runner.commandLabel(), err)
+ }
+ if err := runner.runCommand(ctx, taskPath, taskArgs, stdin, stdout, stderr); err != nil {
+ return runner.exitCodeFor(err)
+ }
+ return 0, nil
+}
+
+func normalizeRunner(r Runner) Runner {
+ if r.CommandName == "" {
+ r.CommandName = "task"
+ }
+ if r.findTaskBinary == nil {
+ r.findTaskBinary = findTaskBinary
+ }
+ if r.detectRepoRoot == nil {
+ r.detectRepoRoot = detectRepoRoot
+ }
+ if r.runCommand == nil {
+ r.runCommand = runTaskCommand
+ }
+ return r
+}
+
+func (r Runner) commandLabel() string {
+ label := strings.TrimSpace(r.CommandName)
+ if label == "" {
+ return "task"
+ }
+ return label
+}
+
+func (r Runner) taskArgs(repoRoot string, args []string) ([]string, error) {
+ projectName, err := projectNameFromRoot(repoRoot)
+ if err != nil {
+ return nil, err
+ }
+ return append([]string{"project:" + projectName, "+agent"}, args...), nil
+}
+
+func (r Runner) exitCodeFor(err error) (int, error) {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ return exitErr.ExitCode(), nil
+ }
+ return 1, fmt.Errorf("%s: failed to run Taskwarrior: %w", r.commandLabel(), err)
+}
+
+func projectNameFromRoot(repoRoot string) (string, error) {
+ projectName := filepath.Base(strings.TrimSpace(repoRoot))
+ if projectName == "" || projectName == "." || projectName == string(filepath.Separator) {
+ return "", fmt.Errorf("could not derive project name from git root %q", repoRoot)
+ }
+ return projectName, nil
+}
+
+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/internal/taskproxy/run_test.go b/internal/taskproxy/run_test.go
new file mode 100644
index 0000000..15c5fbb
--- /dev/null
+++ b/internal/taskproxy/run_test.go
@@ -0,0 +1,141 @@
+package taskproxy
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "os/exec"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestRunnerRun_InjectsProjectFilterAndAgentTag(t *testing.T) {
+ var gotName string
+ var gotArgs []string
+ runner := Runner{
+ CommandName: "ask",
+ 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 TestRunnerRun_OutsideGitRepo_IsActionable(t *testing.T) {
+ runner := Runner{
+ CommandName: "ask",
+ 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 TestRunnerRun_PreservesTaskwarriorExitCode(t *testing.T) {
+ runner := Runner{
+ CommandName: "ask",
+ 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 TestRunnerRun_PreservesStdoutAndStderr(t *testing.T) {
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ runner := Runner{
+ CommandName: "ask",
+ 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 TestRunnerRun_TaskLookupFailure_IsActionable(t *testing.T) {
+ runner := Runner{
+ CommandName: "ask",
+ 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 TestRunnerRun_EmptyRepoName_IsActionable(t *testing.T) {
+ runner := Runner{
+ CommandName: "ask",
+ 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)
+ }
+}