diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-26 10:52:01 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-26 10:52:01 +0300 |
| commit | db8c5659cb54cbfd0ac1e59888059d45b4fe4d3d (patch) | |
| tree | 892486e1afda33b144d504bf9152928f2188cd7a | |
| parent | 327fc7deb16768b9eac4cec57ca143f24dfae4f6 (diff) | |
feat(askcli): add projects subcommand
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | docs/usage.md | 1 | ||||
| -rw-r--r-- | internal/askcli/command_projects.go | 67 | ||||
| -rw-r--r-- | internal/askcli/command_projects_test.go | 144 | ||||
| -rw-r--r-- | internal/askcli/commands_registry.go | 7 | ||||
| -rw-r--r-- | internal/askcli/dispatch.go | 1 | ||||
| -rw-r--r-- | internal/askcli/dispatch_test.go | 2 | ||||
| -rw-r--r-- | internal/askcli/taskexport.go | 1 |
8 files changed, 223 insertions, 2 deletions
@@ -22,7 +22,7 @@ It has got improved capabilities for Go code understanding (for example, create - Never exposes numeric task IDs; human-facing output uses stable alias IDs - `ask info` hides raw UUIDs unless `HEXAI_DEBUG` is set - Machine-friendly output with suppressed decorative text - - Subcommands: `ask add`, `ask list`, `ask info`, `ask annotate`, `ask start`, `ask stop`, `ask done`, `ask priority`, `ask tag`, `ask dep`, `ask urgency`, `ask watch`, `ask modify`, `ask denotate`, `ask delete`, `ask fish`, `ask help` + - Subcommands: `ask add`, `ask list`, `ask info`, `ask annotate`, `ask start`, `ask stop`, `ask done`, `ask priority`, `ask tag`, `ask dep`, `ask urgency`, `ask watch`, `ask projects`, `ask modify`, `ask denotate`, `ask delete`, `ask fish`, `ask help` - Fish shell completions: run `ask fish | source` in a session, or use a `conf.d` snippet (see [Fish shell completion](docs/fish-completion.md)); installs do not write files under `fish/completions/` * 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** diff --git a/docs/usage.md b/docs/usage.md index 06ec2f1..c6c55db 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -171,6 +171,7 @@ You can combine the prefixes in either order, for example `ask proj:hexai na lis | `ask dep list <id\|uuid>` | List dependencies | | `ask urgency` | List tasks by urgency | | `ask watch [subcommand...]` | Re-run a read-only subcommand every 2s and redraw when output changes; defaults to `ask list` | +| `ask projects` | List projects with pending, not-yet-started tasks | | `ask modify <id\|uuid> <args...>` | General-purpose modify | | `ask denotate <id\|uuid> "text"` | Remove annotation | | `ask delete <id\|uuid>` | Delete a task | diff --git a/internal/askcli/command_projects.go b/internal/askcli/command_projects.go new file mode 100644 index 0000000..b370f45 --- /dev/null +++ b/internal/askcli/command_projects.go @@ -0,0 +1,67 @@ +package askcli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "sort" +) + +var ( + projectsFindTaskBinary = findTaskBinary + projectsRunTaskCommand = runTaskCommand +) + +func (d *Dispatcher) handleProjects(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) { + _ = args + + taskPath, err := projectsFindTaskBinary() + if err != nil { + return 1, fmt.Errorf("ask projects: task binary lookup failed: %w", err) + } + + scopeFilter := taskScopeFilter(taskScopeFromContext(ctx)) + cmdArgs := append([]string{"rc.verbose=nothing", "rc.confirmation=off", scopeFilter, "status:pending", "export"}, args[1:]...) + var outBuf bytes.Buffer + err = projectsRunTaskCommand(ctx, taskPath, cmdArgs, nil, &outBuf, stderr) + if err != nil { + return exitCodeFor(err), fmt.Errorf("ask projects: task export failed: %w", err) + } + + tasks, err := ParseTaskExport(&outBuf) + if err != nil { + fmt.Fprintf(stderr, "error: failed to parse task data: %v\n", err) + return 1, nil + } + + projectSet := make(map[string]struct{}) + for _, task := range tasks { + if task.Status == "pending" && task.Start == "" && task.Project != "" { + projectSet[task.Project] = struct{}{} + } + } + + projects := make([]string, 0, len(projectSet)) + for p := range projectSet { + projects = append(projects, p) + } + sort.Strings(projects) + + if d.jsonOutput { + data, err := json.Marshal(projects) + if err != nil { + fmt.Fprintf(stderr, "error: failed to marshal JSON: %v\n", err) + return 1, nil + } + _, _ = stdout.Write(data) + _, _ = io.WriteString(stdout, "\n") + return 0, nil + } + + for _, p := range projects { + _, _ = io.WriteString(stdout, p+"\n") + } + return 0, nil +} diff --git a/internal/askcli/command_projects_test.go b/internal/askcli/command_projects_test.go new file mode 100644 index 0000000..3059afb --- /dev/null +++ b/internal/askcli/command_projects_test.go @@ -0,0 +1,144 @@ +package askcli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "testing" +) + +func TestHandleProjects_ListsUniqueProjects(t *testing.T) { + oldFind := projectsFindTaskBinary + oldRun := projectsRunTaskCommand + t.Cleanup(func() { + projectsFindTaskBinary = oldFind + projectsRunTaskCommand = oldRun + }) + + projectsFindTaskBinary = func() (string, error) { return "task", nil } + projectsRunTaskCommand = func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + tasks := []TaskExport{ + {UUID: "1", Project: "hexai", Status: "pending", Urgency: 1}, + {UUID: "2", Project: "dtail", Status: "pending", Urgency: 2, Start: "2026-01-01T00:00:00Z"}, + {UUID: "3", Project: "hexai", Status: "pending", Urgency: 3}, + {UUID: "4", Project: "", Status: "pending", Urgency: 4}, + } + _, _ = io.WriteString(stdout, taskExportJSON(tasks)) + return nil + } + + ctx := context.Background() + d := NewDispatcher(nil) + var stdout, stderr bytes.Buffer + code, err := d.Dispatch(ctx, []string{"projects"}, nil, &stdout, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if code != 0 { + t.Fatalf("unexpected exit code: %d", code) + } + + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + if len(lines) != 1 || lines[0] != "hexai" { + t.Fatalf("expected [hexai], got %q", lines) + } +} + +func TestHandleProjects_JSONOutput(t *testing.T) { + oldFind := projectsFindTaskBinary + oldRun := projectsRunTaskCommand + t.Cleanup(func() { + projectsFindTaskBinary = oldFind + projectsRunTaskCommand = oldRun + }) + + projectsFindTaskBinary = func() (string, error) { return "task", nil } + projectsRunTaskCommand = func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + tasks := []TaskExport{ + {UUID: "1", Project: "hexai", Status: "pending", Urgency: 1}, + {UUID: "2", Project: "dtail", Status: "pending", Urgency: 2}, + } + _, _ = io.WriteString(stdout, taskExportJSON(tasks)) + return nil + } + + ctx := context.Background() + d := NewDispatcher(nil) + var stdout, stderr bytes.Buffer + code, err := d.Dispatch(ctx, []string{"--json", "projects"}, nil, &stdout, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if code != 0 { + t.Fatalf("unexpected exit code: %d", code) + } + + if !strings.Contains(stdout.String(), `"dtail"`) || !strings.Contains(stdout.String(), `"hexai"`) { + t.Fatalf("unexpected JSON output: %s", stdout.String()) + } +} + +func TestHandleProjects_EmptyResult(t *testing.T) { + oldFind := projectsFindTaskBinary + oldRun := projectsRunTaskCommand + t.Cleanup(func() { + projectsFindTaskBinary = oldFind + projectsRunTaskCommand = oldRun + }) + + projectsFindTaskBinary = func() (string, error) { return "task", nil } + projectsRunTaskCommand = func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + _, _ = io.WriteString(stdout, "[]") + return nil + } + + ctx := context.Background() + d := NewDispatcher(nil) + var stdout, stderr bytes.Buffer + code, err := d.Dispatch(ctx, []string{"projects"}, nil, &stdout, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if code != 0 { + t.Fatalf("unexpected exit code: %d", code) + } + if stdout.String() != "" { + t.Fatalf("expected no output, got %q", stdout.String()) + } +} + +func TestHandleProjects_ForwardsTaskExportError(t *testing.T) { + oldFind := projectsFindTaskBinary + oldRun := projectsRunTaskCommand + t.Cleanup(func() { + projectsFindTaskBinary = oldFind + projectsRunTaskCommand = oldRun + }) + + projectsFindTaskBinary = func() (string, error) { return "task", nil } + projectsRunTaskCommand = func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + return fmt.Errorf("some error") + } + + ctx := context.Background() + d := NewDispatcher(nil) + var stdout, stderr bytes.Buffer + code, err := d.Dispatch(ctx, []string{"projects"}, nil, &stdout, &stderr) + if err == nil { + t.Fatal("expected error") + } + if code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } +} + +func taskExportJSON(tasks []TaskExport) string { + data, err := json.Marshal(tasks) + if err != nil { + panic(err) + } + return string(data) +} diff --git a/internal/askcli/commands_registry.go b/internal/askcli/commands_registry.go index f999ca3..b288fd4 100644 --- a/internal/askcli/commands_registry.go +++ b/internal/askcli/commands_registry.go @@ -187,6 +187,13 @@ var commandRegistry = newCommandTable([]commandEntry{ func init() { commandRegistry.add(commandEntry{ + name: "projects", + description: "List projects with pending, not-yet-started tasks", + handler: wrapSimpleCommand((*Dispatcher).handleProjects), + includeInCompletion: true, + readOnly: true, + }) + commandRegistry.add(commandEntry{ name: "watch", description: "Repeatedly run a subcommand and redraw when output changes", handler: wrapSimpleCommand((*Dispatcher).handleWatch), diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index 2cdaa93..f6c503b 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -95,6 +95,7 @@ func (d *Dispatcher) help(w io.Writer) (int, error) { _, _ = io.WriteString(w, " ask dep list <id|uuid> List dependencies\n") _, _ = io.WriteString(w, " ask urgency List tasks sorted by urgency\n") _, _ = io.WriteString(w, " ask watch [subcommand...] Re-run a read-only subcommand every 2s and redraw on changes\n") + _, _ = io.WriteString(w, " ask projects List projects with pending, not-yet-started tasks\n") _, _ = io.WriteString(w, " ask modify <id|uuid> <args...> Modify task fields\n") _, _ = io.WriteString(w, " ask denotate <id|uuid> \"text\" Remove annotation\n") _, _ = io.WriteString(w, " ask delete <id|uuid> Delete a task\n") diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go index 986b5e1..c7c7086 100644 --- a/internal/askcli/dispatch_test.go +++ b/internal/askcli/dispatch_test.go @@ -203,7 +203,7 @@ func TestDispatcher_LongHelp(t *testing.T) { var stdout bytes.Buffer d.Dispatch(context.Background(), []string{"help"}, nil, &stdout, io.Discard) output := stdout.String() - for _, sub := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "watch", "modify", "denotate", "delete", "fish"} { + for _, sub := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "watch", "projects", "modify", "denotate", "delete", "fish"} { if !strings.Contains(output, "ask "+sub) { t.Errorf("help missing subcommand: ask %s", sub) } diff --git a/internal/askcli/taskexport.go b/internal/askcli/taskexport.go index be24117..ff9a499 100644 --- a/internal/askcli/taskexport.go +++ b/internal/askcli/taskexport.go @@ -9,6 +9,7 @@ import ( // TaskExport mirrors the JSON structure returned by Taskwarrior export commands. type TaskExport struct { UUID string `json:"uuid"` + Project string `json:"project,omitempty"` Description string `json:"description"` Status string `json:"status"` Priority string `json:"priority"` |
