diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-19 09:26:26 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-19 09:26:26 +0200 |
| commit | 15bc73d103259b492f8b77a422f8649bdf3d7c24 (patch) | |
| tree | 58a834d1cab371ce9acc2b25780524223c06980c | |
| parent | 5642eaf74a4a70e5c82646bef3e0dd42846baea8 (diff) | |
Add ask Taskwarrior wrapper
| -rw-r--r-- | Magefile.go | 40 | ||||
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | cmd/ask/main.go | 46 | ||||
| -rw-r--r-- | cmd/ask/main_test.go | 52 | ||||
| -rw-r--r-- | cmd/hexai/task_command.go | 95 | ||||
| -rw-r--r-- | cmd/hexai/task_command_test.go | 128 | ||||
| -rw-r--r-- | docs/buildandinstall.md | 3 | ||||
| -rw-r--r-- | docs/usage.md | 16 | ||||
| -rw-r--r-- | internal/taskproxy/run.go | 129 | ||||
| -rw-r--r-- | internal/taskproxy/run_test.go | 141 |
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. @@ -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) + } +} |
