summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-22 19:39:33 +0200
committerPaul Buetow <paul@buetow.org>2026-03-22 19:39:33 +0200
commit3f06d7dadb83d78f0913b1c1c9a9297826e107b1 (patch)
tree0b2bf1466c3cd6d949abd00d70c6f07b4588200a
parent487ee8b3262ca1845b931d1f0b9df9966fbedea3 (diff)
Scaffold internal/askcli package: dispatch, taskexec, taskexport, formatter
-rw-r--r--internal/askcli/dispatch.go67
-rw-r--r--internal/askcli/dispatch_test.go83
-rw-r--r--internal/askcli/formatter.go77
-rw-r--r--internal/askcli/formatter_test.go143
-rw-r--r--internal/askcli/taskexec.go129
-rw-r--r--internal/askcli/taskexec_test.go153
-rw-r--r--internal/askcli/taskexport.go68
-rw-r--r--internal/askcli/taskexport_test.go146
8 files changed, 866 insertions, 0 deletions
diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go
new file mode 100644
index 0000000..b764618
--- /dev/null
+++ b/internal/askcli/dispatch.go
@@ -0,0 +1,67 @@
+package askcli
+
+import (
+ "context"
+ "fmt"
+ "io"
+)
+
+type Runner interface {
+ Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error)
+}
+
+type Dispatcher struct {
+ runner Runner
+}
+
+func NewDispatcher(runner Runner) *Dispatcher {
+ if runner == nil {
+ e := NewExecutor("ask")
+ runner = &e
+ }
+ return &Dispatcher{runner: runner}
+}
+
+func (d Dispatcher) Dispatch(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ if len(args) == 0 {
+ return d.help(stdout)
+ }
+ subcommand := args[0]
+ switch subcommand {
+ case "add", "list", "info", "annotate", "start", "stop", "done",
+ "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "export":
+ return d.runner.Run(ctx, args, stdin, stdout, stderr)
+ default:
+ return d.unknownCommand(stderr, subcommand)
+ }
+}
+
+func (d Dispatcher) help(w io.Writer) (int, error) {
+ io.WriteString(w, "ask - task management CLI\n")
+ io.WriteString(w, "\nSubcommands:\n")
+ io.WriteString(w, " ask add \"description\" Create a new task\n")
+ io.WriteString(w, " ask list [filters] List tasks (UUID-only output)\n")
+ io.WriteString(w, " ask info <uuid> Show task details\n")
+ io.WriteString(w, " ask annotate <uuid> \"note\" Add annotation to task\n")
+ io.WriteString(w, " ask start <uuid> Start working on task\n")
+ io.WriteString(w, " ask stop <uuid> Stop working on task\n")
+ io.WriteString(w, " ask done <uuid> Mark task complete\n")
+ io.WriteString(w, " ask priority <uuid> <P> Set priority (H/M/L)\n")
+ io.WriteString(w, " ask tag <uuid> +/-<tag> Add or remove tag\n")
+ io.WriteString(w, " ask dep add <uuid> <dep-uuid> Add dependency\n")
+ io.WriteString(w, " ask dep rm <uuid> <dep-uuid> Remove dependency\n")
+ io.WriteString(w, " ask dep list <uuid> List dependencies\n")
+ io.WriteString(w, " ask urgency List tasks sorted by urgency\n")
+ io.WriteString(w, " ask modify <uuid> <args...> Modify task fields\n")
+ io.WriteString(w, " ask denotate <uuid> \"text\" Remove annotation\n")
+ io.WriteString(w, " ask delete <uuid> Delete task\n")
+ io.WriteString(w, " ask export Raw JSON export\n")
+ io.WriteString(w, "\nFilters:\n")
+ io.WriteString(w, " +READY +BLOCKED +<tag> started limit:N sort:priority-,urgency-\n")
+ return 0, nil
+}
+
+func (d Dispatcher) unknownCommand(w io.Writer, subcommand string) (int, error) {
+ fmt.Fprintf(w, "ask: unknown subcommand %q\n", subcommand)
+ return 1, nil
+}
diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go
new file mode 100644
index 0000000..f4c27a4
--- /dev/null
+++ b/internal/askcli/dispatch_test.go
@@ -0,0 +1,83 @@
+package askcli
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "strings"
+ "testing"
+)
+
+func TestDispatcher_Help(t *testing.T) {
+ d := NewDispatcher(nil)
+ var stdout bytes.Buffer
+ code, err := d.Dispatch(context.Background(), []string{}, nil, &stdout, io.Discard)
+ if code != 0 {
+ t.Fatalf("help exit code = %d, want 0", code)
+ }
+ if err != nil {
+ t.Fatalf("help returned error: %v", err)
+ }
+ output := stdout.String()
+ if !strings.Contains(output, "ask - task management CLI") {
+ t.Fatalf("help missing title: %s", output)
+ }
+ if !strings.Contains(output, "ask list") {
+ t.Fatalf("help missing list subcommand: %s", output)
+ }
+ if !strings.Contains(output, "Filters:") {
+ t.Fatalf("help missing Filters section: %s", output)
+ }
+}
+
+func TestDispatcher_UnknownSubcommand(t *testing.T) {
+ d := NewDispatcher(nil)
+ var stderr bytes.Buffer
+ code, err := d.Dispatch(context.Background(), []string{"foobar"}, nil, io.Discard, &stderr)
+ if code != 1 {
+ t.Fatalf("unknown subcommand exit code = %d, want 1", code)
+ }
+ if err != nil {
+ t.Fatalf("unknown subcommand returned unexpected error: %v", err)
+ }
+ output := stderr.String()
+ if !strings.Contains(output, "unknown subcommand") {
+ t.Fatalf("unknown subcommand output missing: %s", output)
+ }
+}
+
+func TestDispatcher_LongHelp(t *testing.T) {
+ d := NewDispatcher(nil)
+ var stdout bytes.Buffer
+ d.Dispatch(context.Background(), []string{}, nil, &stdout, io.Discard)
+ output := stdout.String()
+ for _, sub := range []string{"add", "list", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "export"} {
+ if !strings.Contains(output, "ask "+sub) {
+ t.Errorf("help missing subcommand: ask %s", sub)
+ }
+ }
+}
+
+func TestDispatcher_AllSubcommandsReachExecutor(t *testing.T) {
+ subcommands := []string{"add", "list", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "export"}
+ for _, sub := range subcommands {
+ var stdout, stderr bytes.Buffer
+ calls := 0
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout_, stderr_ io.Writer) (int, error) {
+ calls++
+ return 0, nil
+ }})
+ code, _ := d.Dispatch(context.Background(), []string{sub}, nil, &stdout, &stderr)
+ if code != 0 {
+ t.Errorf("subcommand %q code = %d, want 0", sub, code)
+ }
+ }
+}
+
+type spyRunner struct {
+ runFn func(context.Context, []string, io.Reader, io.Writer, io.Writer) (int, error)
+}
+
+func (s *spyRunner) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ return s.runFn(ctx, args, stdin, stdout, stderr)
+}
diff --git a/internal/askcli/formatter.go b/internal/askcli/formatter.go
new file mode 100644
index 0000000..e210dc7
--- /dev/null
+++ b/internal/askcli/formatter.go
@@ -0,0 +1,77 @@
+package askcli
+
+import (
+ "fmt"
+ "io"
+ "strings"
+)
+
+func FormatTaskList(tasks []TaskExport) string {
+ var b strings.Builder
+ io.WriteString(&b, "UUID | Priority | Status | Tags | Description | Urgency\n")
+ io.WriteString(&b, strings.Repeat("-", 120)+"\n")
+ for _, t := range tasks {
+ tags := strings.Join(t.Tags, ",")
+ if tags == "" {
+ tags = "-"
+ }
+ desc := t.Description
+ if len(desc) > 50 {
+ desc = desc[:47] + "..."
+ }
+ fmt.Fprintf(&b, "%s | %s | %s | %s | %s | %.1f\n", t.UUID, t.Priority, t.Status, tags, desc, t.Urgency)
+ }
+ return b.String()
+}
+
+func FormatTaskInfo(t TaskExport) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "UUID: %s\n", t.UUID)
+ fmt.Fprintf(&b, "Description: %s\n", t.Description)
+ fmt.Fprintf(&b, "Status: %s\n", t.Status)
+ fmt.Fprintf(&b, "Priority: %s\n", t.Priority)
+ fmt.Fprintf(&b, "Urgency: %.1f\n", t.Urgency)
+ if len(t.Tags) > 0 {
+ fmt.Fprintf(&b, "Tags: %s\n", strings.Join(t.Tags, ", "))
+ }
+ if t.Start != "" {
+ fmt.Fprintf(&b, "Started: %s\n", t.Start)
+ }
+ if len(t.Depends) > 0 {
+ fmt.Fprintf(&b, "Depends: %s\n", strings.Join(t.Depends, ", "))
+ }
+ if len(t.Annotations) > 0 {
+ io.WriteString(&b, "Annotations:\n")
+ for _, a := range t.Annotations {
+ fmt.Fprintf(&b, " - %s (added %s)\n", a.Description, a.Entry)
+ }
+ }
+ return b.String()
+}
+
+func FormatSuccess(uuid string) string {
+ return fmt.Sprintf("ok %s\n", uuid)
+}
+
+func FormatError(err error, uuid string) string {
+ if uuid != "" {
+ return fmt.Sprintf("error %s: %v\n", uuid, err)
+ }
+ return fmt.Sprintf("error: %v\n", err)
+}
+
+func IsNumericID(s string) bool {
+ if s == "" {
+ return false
+ }
+ for _, c := range s {
+ if c < '0' || c > '9' {
+ return false
+ }
+ }
+ return true
+}
+
+func RejectNumericID() string {
+ return "error: use UUID, not numeric task ID\n"
+}
diff --git a/internal/askcli/formatter_test.go b/internal/askcli/formatter_test.go
new file mode 100644
index 0000000..394ea91
--- /dev/null
+++ b/internal/askcli/formatter_test.go
@@ -0,0 +1,143 @@
+package askcli
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestFormatTaskList(t *testing.T) {
+ tasks := []TaskExport{
+ {UUID: "uuid-1", Description: "Short task", Status: "pending", Priority: "H", Tags: []string{"cli"}, Urgency: 15.0},
+ {UUID: "uuid-2", Description: strings.Repeat("a", 100), Status: "completed", Priority: "M", Tags: []string{"agent", "test"}, Urgency: 5.0},
+ {UUID: "uuid-3", Description: "No tags task", Status: "waiting", Priority: "L", Tags: []string{}, Urgency: 8.0},
+ }
+ output := FormatTaskList(tasks)
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ if len(lines) < 3 {
+ t.Fatalf("FormatTaskList produced too few lines: %d", len(lines))
+ }
+ if !strings.Contains(lines[0], "UUID") || !strings.Contains(lines[0], "Priority") {
+ t.Fatalf("header missing UUID or Priority column: %s", lines[0])
+ }
+ if !strings.Contains(lines[2], "uuid-1") {
+ t.Fatalf("first task line missing uuid-1: %s", lines[2])
+ }
+ if strings.Contains(lines[2], "...") {
+ t.Fatalf("long description should be truncated with ...: %s", lines[2])
+ }
+}
+
+func TestFormatTaskInfo(t *testing.T) {
+ task := TaskExport{
+ UUID: "test-uuid",
+ Description: "Test description",
+ Status: "pending",
+ Priority: "H",
+ Tags: []string{"cli", "agent"},
+ Start: "2026-03-22T10:00:00Z",
+ Urgency: 15.5,
+ Depends: []string{"dep-1", "dep-2"},
+ Annotations: []struct {
+ Description string `json:"description"`
+ Entry string `json:"entry"`
+ }{
+ {Description: "First note", Entry: "2026-03-22T11:00:00Z"},
+ },
+ }
+ output := FormatTaskInfo(task)
+ if !strings.Contains(output, "test-uuid") {
+ t.Fatalf("FormatTaskInfo missing UUID: %s", output)
+ }
+ if !strings.Contains(output, "H") {
+ t.Fatalf("FormatTaskInfo missing priority H: %s", output)
+ }
+ if !strings.Contains(output, "cli, agent") {
+ t.Fatalf("FormatTaskInfo missing tags: %s", output)
+ }
+ if !strings.Contains(output, "dep-1") {
+ t.Fatalf("FormatTaskInfo missing depends: %s", output)
+ }
+ if !strings.Contains(output, "First note") {
+ t.Fatalf("FormatTaskInfo missing annotation: %s", output)
+ }
+}
+
+func TestFormatSuccess(t *testing.T) {
+ output := FormatSuccess("test-uuid")
+ if !strings.Contains(output, "ok") || !strings.Contains(output, "test-uuid") {
+ t.Fatalf("FormatSuccess = %q, want ok + uuid", output)
+ }
+}
+
+func TestFormatError(t *testing.T) {
+ err := &testError{msg: "something went wrong"}
+ output := FormatError(err, "uuid-123")
+ if !strings.Contains(output, "error") || !strings.Contains(output, "uuid-123") || !strings.Contains(output, "something went wrong") {
+ t.Fatalf("FormatError = %q, want error + uuid + message", output)
+ }
+}
+
+func TestFormatError_NoUUID(t *testing.T) {
+ err := &testError{msg: "generic error"}
+ output := FormatError(err, "")
+ if !strings.Contains(output, "error") || !strings.Contains(output, "generic error") {
+ t.Fatalf("FormatError = %q, want error + message", output)
+ }
+}
+
+func TestIsNumericID(t *testing.T) {
+ if !IsNumericID("123") {
+ t.Error("IsNumericID(\"123\") = false, want true")
+ }
+ if !IsNumericID("0") {
+ t.Error("IsNumericID(\"0\") = false, want true")
+ }
+ if IsNumericID("uuid-123") {
+ t.Error("IsNumericID(\"uuid-123\") = true, want false")
+ }
+ if IsNumericID("abc") {
+ t.Error("IsNumericID(\"abc\") = true, want false")
+ }
+ if IsNumericID("12a") {
+ t.Error("IsNumericID(\"12a\") = true, want false")
+ }
+ if IsNumericID("") {
+ t.Error("IsNumericID(\"\") = true, want false")
+ }
+}
+
+func TestRejectNumericID(t *testing.T) {
+ output := RejectNumericID()
+ if !strings.Contains(output, "use UUID") {
+ t.Fatalf("RejectNumericID = %q, want use UUID message", output)
+ }
+}
+
+func TestFormatTaskInfo_NoOptionalFields(t *testing.T) {
+ task := TaskExport{
+ UUID: "simple-uuid",
+ Description: "Simple task",
+ Status: "pending",
+ Priority: "M",
+ Tags: []string{},
+ Urgency: 0,
+ }
+ output := FormatTaskInfo(task)
+ if !strings.Contains(output, "simple-uuid") {
+ t.Fatalf("FormatTaskInfo missing UUID: %s", output)
+ }
+ if strings.Contains(output, "Tags:") {
+ t.Fatalf("FormatTaskInfo should not contain Tags: for empty tags: %s", output)
+ }
+ if strings.Contains(output, "Depends:") {
+ t.Fatalf("FormatTaskInfo should not contain Depends: for empty depends: %s", output)
+ }
+}
+
+type testError struct {
+ msg string
+}
+
+func (e *testError) Error() string {
+ return e.msg
+}
diff --git a/internal/askcli/taskexec.go b/internal/askcli/taskexec.go
new file mode 100644
index 0000000..9c9aa69
--- /dev/null
+++ b/internal/askcli/taskexec.go
@@ -0,0 +1,129 @@
+package askcli
+
+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 Executor struct {
+ commandName string
+ findBinary binaryFinder
+ detectRepoRoot repoTopLevelDetector
+ runCommand commandRunner
+}
+
+func NewExecutor(commandName string) Executor {
+ return Executor{
+ commandName: strings.TrimSpace(commandName),
+ findBinary: findTaskBinary,
+ detectRepoRoot: detectRepoRoot,
+ runCommand: runTaskCommand,
+ }
+}
+
+func (e Executor) 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 (e Executor) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ executor := normalizeExecutor(e)
+ taskPath, err := executor.findBinary()
+ if err != nil {
+ return 1, fmt.Errorf("%s: task binary lookup failed: %w", executor.label(), err)
+ }
+ repoRoot, err := executor.detectRepoRoot(ctx)
+ if err != nil {
+ return 1, fmt.Errorf("%s: must be run inside a git repository: %w", executor.label(), err)
+ }
+ taskArgs, err := executor.taskArgs(repoRoot, args)
+ if err != nil {
+ return 1, fmt.Errorf("%s: %w", executor.label(), err)
+ }
+ if err := executor.runCommand(ctx, taskPath, taskArgs, stdin, stdout, stderr); err != nil {
+ return exitCodeFor(err), nil
+ }
+ return 0, nil
+}
+
+func (e Executor) label() string {
+ label := strings.TrimSpace(e.commandName)
+ if label == "" {
+ return "ask"
+ }
+ return label
+}
+
+func normalizeExecutor(e Executor) Executor {
+ if e.commandName == "" {
+ e.commandName = "ask"
+ }
+ if e.findBinary == nil {
+ e.findBinary = findTaskBinary
+ }
+ if e.detectRepoRoot == nil {
+ e.detectRepoRoot = detectRepoRoot
+ }
+ if e.runCommand == nil {
+ e.runCommand = runTaskCommand
+ }
+ return e
+}
+
+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("task binary 'task' not found in PATH; install task 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 name 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()
+}
+
+func exitCodeFor(err error) int {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ return exitErr.ExitCode()
+ }
+ return 1
+}
diff --git a/internal/askcli/taskexec_test.go b/internal/askcli/taskexec_test.go
new file mode 100644
index 0000000..4450a28
--- /dev/null
+++ b/internal/askcli/taskexec_test.go
@@ -0,0 +1,153 @@
+package askcli
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "os/exec"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestExecutorTaskArgs(t *testing.T) {
+ exec_ := NewExecutor("ask")
+ args, err := exec_.taskArgs("/tmp/work/hexai", []string{"list", "limit:1"})
+ if err != nil {
+ t.Fatalf("taskArgs returned error: %v", err)
+ }
+ want := []string{"project:hexai", "+agent", "list", "limit:1"}
+ if !reflect.DeepEqual(args, want) {
+ t.Fatalf("task args = %v, want %v", args, want)
+ }
+}
+
+func TestExecutorRun_InjectsProjectFilterAndAgentTag(t *testing.T) {
+ var gotName string
+ var gotArgs []string
+ exec_ := Executor{
+ commandName: "ask",
+ findBinary: 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 := exec_.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 TestExecutorRun_OutsideGitRepo_IsActionable(t *testing.T) {
+ exec_ := Executor{
+ commandName: "ask",
+ findBinary: 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 := exec_.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 TestExecutorRun_PreservesTaskwarriorExitCode(t *testing.T) {
+ exec_ := Executor{
+ commandName: "ask",
+ findBinary: 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 := exec_.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 TestExecutorRun_PreservesStdoutAndStderr(t *testing.T) {
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ exec_ := Executor{
+ commandName: "ask",
+ findBinary: 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 := exec_.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 TestExecutorRun_TaskLookupFailure_IsActionable(t *testing.T) {
+ exec_ := Executor{
+ commandName: "ask",
+ findBinary: func() (string, error) { return "", errors.New("not found") },
+ }
+
+ exitCode, err := exec_.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(), "task binary lookup failed") {
+ t.Fatalf("expected actionable task lookup error, got %v", err)
+ }
+}
+
+func TestExecutorRun_EmptyRepoName_IsActionable(t *testing.T) {
+ exec_ := Executor{
+ commandName: "ask",
+ findBinary: func() (string, error) { return "/usr/bin/task", nil },
+ detectRepoRoot: func(context.Context) (string, error) { return "/", nil },
+ }
+
+ exitCode, err := exec_.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/internal/askcli/taskexport.go b/internal/askcli/taskexport.go
new file mode 100644
index 0000000..27fd3f1
--- /dev/null
+++ b/internal/askcli/taskexport.go
@@ -0,0 +1,68 @@
+package askcli
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+)
+
+type TaskExport struct {
+ UUID string `json:"uuid"`
+ Description string `json:"description"`
+ Status string `json:"status"`
+ Priority string `json:"priority"`
+ Tags []string `json:"tags"`
+ Start string `json:"start,omitempty"`
+ Urgency float64 `json:"urgency"`
+ Depends []string `json:"depends"`
+ Annotations []struct {
+ Description string `json:"description"`
+ Entry string `json:"entry"`
+ } `json:"annotations"`
+}
+
+func ParseTaskExport(r io.Reader) ([]TaskExport, error) {
+ data, err := io.ReadAll(r)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read task export data: %w", err)
+ }
+ var tasks []TaskExport
+ if err := json.Unmarshal(data, &tasks); err != nil {
+ return nil, fmt.Errorf("failed to parse task export JSON: %w", err)
+ }
+ return tasks, nil
+}
+
+func MustParseTaskExport(data []byte) []TaskExport {
+ var tasks []TaskExport
+ if err := json.Unmarshal(data, &tasks); err != nil {
+ panic(fmt.Sprintf("failed to parse task export JSON: %v", err))
+ }
+ return tasks
+}
+
+func ExtractUUIDFromOutput(output string) string {
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ for _, line := range lines {
+ if strings.HasPrefix(line, "Created task ") {
+ parts := strings.Fields(line)
+ if len(parts) >= 3 {
+ return strings.TrimSuffix(parts[2], ".")
+ }
+ }
+ }
+ fields := strings.Fields(output)
+ for i, f := range fields {
+ if f == "uuid" && i+1 < len(fields) {
+ return fields[i+1]
+ }
+ if strings.HasPrefix(f, "Created task") {
+ parts := strings.Split(f, " ")
+ if len(parts) >= 2 {
+ return strings.TrimSuffix(parts[1], ".")
+ }
+ }
+ }
+ return strings.TrimSpace(output)
+}
diff --git a/internal/askcli/taskexport_test.go b/internal/askcli/taskexport_test.go
new file mode 100644
index 0000000..9474b99
--- /dev/null
+++ b/internal/askcli/taskexport_test.go
@@ -0,0 +1,146 @@
+package askcli
+
+import (
+ "encoding/json"
+ "io"
+ "strings"
+ "testing"
+)
+
+func TestParseTaskExport_ValidJSON(t *testing.T) {
+ data := `[{"uuid":"abc123","description":"Test task","status":"pending","priority":"M","tags":["cli"],"urgency":10.5,"depends":[]}]`
+ tasks, err := ParseTaskExport(strings.NewReader(data))
+ if err != nil {
+ t.Fatalf("ParseTaskExport returned error: %v", err)
+ }
+ if len(tasks) != 1 {
+ t.Fatalf("len(tasks) = %d, want 1", len(tasks))
+ }
+ if tasks[0].UUID != "abc123" {
+ t.Fatalf("tasks[0].UUID = %q, want %q", tasks[0].UUID, "abc123")
+ }
+ if tasks[0].Urgency != 10.5 {
+ t.Fatalf("tasks[0].Urgency = %f, want %f", tasks[0].Urgency, 10.5)
+ }
+}
+
+func TestParseTaskExport_InvalidJSON(t *testing.T) {
+ _, err := ParseTaskExport(strings.NewReader("not json"))
+ if err == nil {
+ t.Fatal("expected error for invalid JSON, got nil")
+ }
+}
+
+func TestMustParseTaskExport_Panics(t *testing.T) {
+ defer func() {
+ if r := recover(); r == nil {
+ t.Fatal("MustParseTaskExport should panic on invalid JSON")
+ }
+ }()
+ MustParseTaskExport([]byte("not json"))
+}
+
+func TestMustParseTaskExport_ValidJSON(t *testing.T) {
+ data := []byte(`[{"uuid":"xyz789","description":"Another task","status":"completed","priority":"H","tags":["agent"],"urgency":15.0,"depends":["dep1"]}]`)
+ tasks := MustParseTaskExport(data)
+ if len(tasks) != 1 {
+ t.Fatalf("len(tasks) = %d, want 1", len(tasks))
+ }
+ if tasks[0].UUID != "xyz789" {
+ t.Fatalf("tasks[0].UUID = %q, want %q", tasks[0].UUID, "xyz789")
+ }
+}
+
+func TestExtractUUIDFromOutput_CreatedTask(t *testing.T) {
+ output := "Created task 123.\nUUID: abc-123-def"
+ uuid := ExtractUUIDFromOutput(output)
+ if uuid != "123" {
+ t.Fatalf("ExtractUUIDFromOutput = %q, want %q", uuid, "123")
+ }
+}
+
+func TestExtractUUIDFromOutput_UUIDField(t *testing.T) {
+ output := "Some text\nuuid abc-123-def\nmore text"
+ uuid := ExtractUUIDFromOutput(output)
+ if uuid != "abc-123-def" {
+ t.Fatalf("ExtractUUIDFromOutput = %q, want %q", uuid, "abc-123-def")
+ }
+}
+
+func TestExtractUUIDFromOutput_PlainText(t *testing.T) {
+ output := "abc-456-xyz"
+ uuid := ExtractUUIDFromOutput(output)
+ if uuid != output {
+ t.Fatalf("ExtractUUIDFromOutput = %q, want %q", uuid, output)
+ }
+}
+
+func TestTaskExport_JSONRoundTrip(t *testing.T) {
+ original := TaskExport{
+ UUID: "test-uuid",
+ Description: "Test description",
+ Status: "pending",
+ Priority: "H",
+ Tags: []string{"cli", "agent"},
+ Urgency: 12.5,
+ Depends: []string{"dep1", "dep2"},
+ }
+ data, err := json.Marshal(original)
+ if err != nil {
+ t.Fatalf("json.Marshal returned error: %v", err)
+ }
+ var parsed TaskExport
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("json.Unmarshal returned error: %v", err)
+ }
+ if parsed.UUID != original.UUID {
+ t.Fatalf("parsed.UUID = %q, want %q", parsed.UUID, original.UUID)
+ }
+ if parsed.Priority != original.Priority {
+ t.Fatalf("parsed.Priority = %q, want %q", parsed.Priority, original.Priority)
+ }
+ if len(parsed.Depends) != len(original.Depends) {
+ t.Fatalf("len(parsed.Depends) = %d, want %d", len(parsed.Depends), len(original.Depends))
+ }
+}
+
+func TestParseTaskExport_EmptyInput(t *testing.T) {
+ _, err := ParseTaskExport(strings.NewReader("[]"))
+ if err != nil {
+ t.Fatalf("ParseTaskExport returned error for empty array: %v", err)
+ }
+}
+
+func TestParseTaskExport_MultipleTasks(t *testing.T) {
+ data := `[{"uuid":"a1","description":"Task 1","status":"pending","priority":"H","tags":[],"urgency":10,"depends":[]},{"uuid":"b2","description":"Task 2","status":"completed","priority":"M","tags":["cli"],"urgency":5,"depends":["a1"]}]`
+ tasks, err := ParseTaskExport(strings.NewReader(data))
+ if err != nil {
+ t.Fatalf("ParseTaskExport returned error: %v", err)
+ }
+ if len(tasks) != 2 {
+ t.Fatalf("len(tasks) = %d, want 2", len(tasks))
+ }
+ if tasks[0].UUID != "a1" || tasks[1].UUID != "b2" {
+ t.Fatalf("unexpected task order or UUIDs: %v", tasks)
+ }
+}
+
+func TestExtractUUIDFromOutput_NilOutput(t *testing.T) {
+ uuid := ExtractUUIDFromOutput("")
+ if uuid != "" {
+ t.Fatalf("ExtractUUIDFromOutput = %q, want empty string", uuid)
+ }
+}
+
+func TestParseTaskExport_ReadError(t *testing.T) {
+ _, err := ParseTaskExport(&errReader{})
+ if err == nil {
+ t.Fatal("expected error for read failure, got nil")
+ }
+}
+
+type errReader struct{}
+
+func (r *errReader) Read(p []byte) (int, error) {
+ return 0, io.EOF
+}