diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-27 12:20:09 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-27 12:20:09 +0200 |
| commit | 2ddb334fa671b9c425ca43c8c673c6b36c3ad0ab (patch) | |
| tree | cfff7b2b8397ebf4fad9b81a91d54563ea66d208 | |
| parent | c8c57f0d5821ae0542cb613c87e6ed2ea66e1e0a (diff) | |
release: v0.27.1v0.27.1
| -rw-r--r-- | docs/usage.md | 2 | ||||
| -rw-r--r-- | integrationtests/ask_test.go | 22 | ||||
| -rw-r--r-- | internal/askcli/command_add.go | 2 | ||||
| -rw-r--r-- | internal/askcli/command_info.go | 14 | ||||
| -rw-r--r-- | internal/askcli/command_info_add_test.go | 56 | ||||
| -rw-r--r-- | internal/askcli/dispatch.go | 2 | ||||
| -rw-r--r-- | internal/askcli/formatter.go | 5 | ||||
| -rw-r--r-- | internal/askcli/formatter_test.go | 7 | ||||
| -rw-r--r-- | internal/askcli/render_task_list.go | 13 | ||||
| -rw-r--r-- | internal/askcli/render_task_list_test.go | 17 | ||||
| -rw-r--r-- | internal/askcli/taskexport.go | 16 | ||||
| -rw-r--r-- | internal/version.go | 2 |
12 files changed, 130 insertions, 28 deletions
diff --git a/docs/usage.md b/docs/usage.md index c2fe055..8a1c1d1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -135,7 +135,7 @@ cat SOMEFILE.txt | hexai --tps-simulation 20 | Subcommand | Description | |---|---| -| `ask add "description"` | Create a new task and print its alias ID | +| `ask add "description"` | Create a new task and print `created task <alias-id>` | | `ask add depends:<id\|uuid>,<id\|uuid> "description"` | Create task with inline dependencies | | `ask add priority:H "description"` | Create task with priority | | `ask add +tag "description"` | Create task with tag | diff --git a/integrationtests/ask_test.go b/integrationtests/ask_test.go index d1e0145..528021a 100644 --- a/integrationtests/ask_test.go +++ b/integrationtests/ask_test.go @@ -117,13 +117,13 @@ func runTaskWithStdin(ctx context.Context, args []string, stdin string) (stdout, } // createTask creates a new task via ask add and returns its UUID. -// ask add prints the human-facing alias ID, so we resolve the created UUID via ask info. +// ask add prints a human-facing created-task message, so we resolve the created UUID via ask info. func createTask(ctx context.Context, desc string) (string, error) { stdout, stderr, code := runAsk(ctx, []string{"add", "+integrationtest", desc}) if code != 0 { return "", fmt.Errorf("create task failed (code %d): stdout=%s stderr=%s", code, stdout.String(), stderr.String()) } - id := strings.TrimSpace(stdout.String()) + id := extractTaskIDFromAddOutput(stdout.String()) if id == "" { return "", fmt.Errorf("could not extract task ID from ask add output: %s", stdout.String()) } @@ -137,6 +137,16 @@ func createTask(ctx context.Context, desc string) (string, error) { return info.UUID, nil } +func extractTaskIDFromAddOutput(output string) string { + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "created task ") { + return strings.TrimSpace(strings.TrimPrefix(line, "created task ")) + } + } + return strings.TrimSpace(output) +} + func deleteTask(ctx context.Context, uuid string) { runTaskWithStdin(ctx, []string{"uuid:" + uuid, "delete"}, "yes\n") } @@ -298,7 +308,7 @@ func TestAdd(t *testing.T) { } } -// TestAddReturnsAlias verifies that ask add outputs the human-facing alias ID. +// TestAddReturnsAlias verifies that ask add outputs the human-facing alias ID in its creation message. func TestAddReturnsAlias(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -307,7 +317,8 @@ func TestAddReturnsAlias(t *testing.T) { if code != 0 { t.Fatalf("ask add failed with code %d", code) } - id := strings.TrimSpace(stdout.String()) + rawOutput := strings.TrimSpace(stdout.String()) + id := extractTaskIDFromAddOutput(rawOutput) info, ok := getTaskInfoFast(ctx, id) if !ok { t.Fatalf("ask info %q failed after add", id) @@ -317,6 +328,9 @@ func TestAddReturnsAlias(t *testing.T) { if id == "" { t.Fatal("ask add returned an empty task ID") } + if rawOutput != "created task "+id { + t.Fatalf("ask add output = %q, want %q", rawOutput, "created task "+id) + } if uuidFormatRx.MatchString(id) { t.Fatalf("ask add output %q leaked a UUID, want alias ID", id) } diff --git a/internal/askcli/command_add.go b/internal/askcli/command_add.go index c28650e..b94fe89 100644 --- a/internal/askcli/command_add.go +++ b/internal/askcli/command_add.go @@ -50,7 +50,7 @@ func (d *Dispatcher) handleAdd(ctx context.Context, args []string, stdout, stder fmt.Fprintf(stderr, "error: failed to assign task alias: %v\n", err) return 1, nil } - _, _ = io.WriteString(stdout, displayTaskAlias(uuid, aliases)+"\n") + _, _ = io.WriteString(stdout, FormatCreatedTask(displayTaskAlias(uuid, aliases))) return 0, nil } diff --git a/internal/askcli/command_info.go b/internal/askcli/command_info.go index 646ff8a..ba2e37d 100644 --- a/internal/askcli/command_info.go +++ b/internal/askcli/command_info.go @@ -15,8 +15,14 @@ func (d *Dispatcher) handleInfo(ctx context.Context, args []string, stdout, stde writeInfoError(stderr, err) return code, nil } + allUUIDs := append([]string{tasks[0].UUID}, tasks[0].Depends...) + aliases, err := ensureTaskAliasesForUUIDs(allUUIDs) + if err != nil { + fmt.Fprintf(stderr, "error: failed to load task aliases: %v\n", err) + return 1, nil + } if d.jsonOutput { - data, err := json.Marshal(tasks) + data, err := json.Marshal(withTaskIDs(tasks, aliases)) if err != nil { fmt.Fprintf(stderr, "error: failed to marshal JSON: %v\n", err) return 1, nil @@ -24,12 +30,6 @@ func (d *Dispatcher) handleInfo(ctx context.Context, args []string, stdout, stde _, _ = stdout.Write(data) _, _ = io.WriteString(stdout, "\n") } else { - allUUIDs := append([]string{tasks[0].UUID}, tasks[0].Depends...) - aliases, err := ensureTaskAliasesForUUIDs(allUUIDs) - if err != nil { - fmt.Fprintf(stderr, "error: failed to load task aliases: %v\n", err) - return 1, nil - } _, _ = io.WriteString(stdout, FormatTaskInfo(tasks[0], displayTaskAlias(tasks[0].UUID, aliases), aliases)) } return 0, nil diff --git a/internal/askcli/command_info_add_test.go b/internal/askcli/command_info_add_test.go index 3dc83ed..74b2380 100644 --- a/internal/askcli/command_info_add_test.go +++ b/internal/askcli/command_info_add_test.go @@ -3,6 +3,7 @@ package askcli import ( "bytes" "context" + "encoding/json" "io" "path/filepath" "strings" @@ -136,6 +137,53 @@ func TestHandleInfo_AliasSelector(t *testing.T) { } } +func TestHandleInfo_JSONIncludesAliasID(t *testing.T) { + dir := t.TempDir() + oldRoot := taskAliasCacheRoot + oldNow := nowTaskAliasCache + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } + defer func() { + taskAliasCacheRoot = oldRoot + nowTaskAliasCache = oldNow + }() + + writeTaskAliasCacheForTest(t, taskAliasCache{ + NextID: 1, + Entries: []taskAliasCacheEntry{ + {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()}, + }, + }) + + jsonData := `[{"uuid":"test-uuid","description":"Test task","status":"pending","priority":"H","tags":["cli"],"urgency":15.0,"depends":[]}]` + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + if len(args) > 0 && strings.HasPrefix(args[0], "uuid:test-uuid") { + _, _ = io.WriteString(stdout, jsonData) + } + return 0, nil + }}) + + var stdout, stderr bytes.Buffer + code, _ := d.Dispatch(context.Background(), []string{"--json", "info", "0"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("info code = %d, want 0", code) + } + + var parsed []map[string]any + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &parsed); err != nil { + t.Fatalf("failed to parse JSON output: %v", err) + } + if len(parsed) != 1 { + t.Fatalf("parsed len = %d, want 1", len(parsed)) + } + if got := parsed[0]["id"]; got != "0" { + t.Fatalf("json id = %#v, want 0", got) + } + if got := parsed[0]["uuid"]; got != "test-uuid" { + t.Fatalf("json uuid = %#v, want test-uuid", got) + } +} + func TestHandleInfo_NumericID(t *testing.T) { d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { return 0, nil @@ -231,8 +279,8 @@ func TestHandleAdd_Success(t *testing.T) { if code != 0 { t.Fatalf("add code = %d, want 0", code) } - if got := strings.TrimSpace(stdout.String()); got != "1" { - t.Fatalf("stdout = %q, want alias 1", stdout.String()) + if got := strings.TrimSpace(stdout.String()); got != "created task 1" { + t.Fatalf("stdout = %q, want created task 1", stdout.String()) } cache := readTaskAliasCacheSnapshot(t) entry := findTaskAliasEntry(t, cache, "abc-123-def") @@ -406,8 +454,8 @@ func TestHandleAdd_WithDependencies(t *testing.T) { if code != 0 { t.Fatalf("add code = %d, want 0: stderr=%s", code, stderr.String()) } - if got := strings.TrimSpace(stdout.String()); got != "2" { - t.Fatalf("stdout = %q, want alias 2", stdout.String()) + if got := strings.TrimSpace(stdout.String()); got != "created task 2" { + t.Fatalf("stdout = %q, want created task 2", stdout.String()) } if len(capturedAddArgs) < 6 { t.Fatalf("capturedAddArgs = %v, want add invocation with dependency modifier", capturedAddArgs) diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index c2c7aab..d03c340 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -60,7 +60,7 @@ func (d *Dispatcher) Dispatch(ctx context.Context, args []string, stdin io.Reade 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 [mods...] [depends:<id|uuid>,...] <description...> Create a new task and print its ID\n") + _, _ = io.WriteString(w, " ask add [mods...] [depends:<id|uuid>,...] <description...> Create a new task and print created task <id>\n") _, _ = io.WriteString(w, " ask list [filters] List active tasks (default)\n") _, _ = io.WriteString(w, " ask ready List READY tasks (not blocked)\n") _, _ = io.WriteString(w, " ask all [filters] List all tasks including completed/deleted\n") diff --git a/internal/askcli/formatter.go b/internal/askcli/formatter.go index d4c1e27..734b4a5 100644 --- a/internal/askcli/formatter.go +++ b/internal/askcli/formatter.go @@ -168,6 +168,11 @@ func FormatSuccess(alias string) string { return fmt.Sprintf("ok %s\n", alias) } +// FormatCreatedTask returns the success string written to stdout after ask add creates a task. +func FormatCreatedTask(alias string) string { + return fmt.Sprintf("created task %s\n", alias) +} + // FormatError formats error output using the optional task identifier when available. func FormatError(err error, taskID string) string { if taskID != "" { diff --git a/internal/askcli/formatter_test.go b/internal/askcli/formatter_test.go index 2921555..3a9239a 100644 --- a/internal/askcli/formatter_test.go +++ b/internal/askcli/formatter_test.go @@ -195,6 +195,13 @@ func TestFormatSuccess(t *testing.T) { } } +func TestFormatCreatedTask(t *testing.T) { + output := FormatCreatedTask("sp") + if output != "created task sp\n" { + t.Fatalf("FormatCreatedTask = %q, want created task message", output) + } +} + func TestFormatError(t *testing.T) { err := &testError{msg: "something went wrong"} output := FormatError(err, "0") diff --git a/internal/askcli/render_task_list.go b/internal/askcli/render_task_list.go index 33487e5..177bd81 100644 --- a/internal/askcli/render_task_list.go +++ b/internal/askcli/render_task_list.go @@ -9,8 +9,14 @@ import ( var taskListAliasLoader = ensureTaskAliases func renderTaskList(tasks []TaskExport, stdout, stderr io.Writer, jsonOutput bool) (int, error) { + aliases, err := taskListAliasLoader(tasks) + if err != nil { + fmt.Fprintf(stderr, "error: failed to load task aliases: %v\n", err) + return 1, nil + } + if jsonOutput { - data, err := json.Marshal(tasks) + data, err := json.Marshal(withTaskIDs(tasks, aliases)) if err != nil { fmt.Fprintf(stderr, "error: failed to marshal JSON: %v\n", err) return 1, nil @@ -20,11 +26,6 @@ func renderTaskList(tasks []TaskExport, stdout, stderr io.Writer, jsonOutput boo return 0, nil } - aliases, err := taskListAliasLoader(tasks) - if err != nil { - fmt.Fprintf(stderr, "error: failed to load task aliases: %v\n", err) - return 1, nil - } _, _ = io.WriteString(stdout, FormatTaskListForWidth(tasks, aliases, detectTaskListTerminalWidth(stdout))) return 0, nil } diff --git a/internal/askcli/render_task_list_test.go b/internal/askcli/render_task_list_test.go index 623055e..ff47153 100644 --- a/internal/askcli/render_task_list_test.go +++ b/internal/askcli/render_task_list_test.go @@ -10,6 +10,13 @@ import ( ) func TestRenderTaskList_JSONOutput(t *testing.T) { + oldLoader := taskListAliasLoader + defer func() { taskListAliasLoader = oldLoader }() + + taskListAliasLoader = func(tasks []TaskExport) (map[string]string, error) { + return map[string]string{"uuid-json": "sq"}, nil + } + tasks := []TaskExport{{ UUID: "uuid-json", Description: "JSON task", @@ -26,12 +33,16 @@ func TestRenderTaskList_JSONOutput(t *testing.T) { if code != 0 { t.Fatalf("renderTaskList code = %d, want 0", code) } - var parsed []TaskExport + var parsed []taskExportWithID if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &parsed); err != nil { t.Fatalf("failed to parse JSON output: %v", err) } - if !reflect.DeepEqual(parsed, tasks) { - t.Fatalf("rendered tasks = %#v, want %#v", parsed, tasks) + want := []taskExportWithID{{ + ID: "sq", + TaskExport: tasks[0], + }} + if !reflect.DeepEqual(parsed, want) { + t.Fatalf("rendered tasks = %#v, want %#v", parsed, want) } if stderr.Len() != 0 { t.Fatalf("unexpected stderr = %q", stderr.String()) diff --git a/internal/askcli/taskexport.go b/internal/askcli/taskexport.go index d633d3c..be24117 100644 --- a/internal/askcli/taskexport.go +++ b/internal/askcli/taskexport.go @@ -34,3 +34,19 @@ func ParseTaskExport(r io.Reader) ([]TaskExport, error) { } return tasks, nil } + +type taskExportWithID struct { + ID string `json:"id,omitempty"` + TaskExport +} + +func withTaskIDs(tasks []TaskExport, aliases map[string]string) []taskExportWithID { + withIDs := make([]taskExportWithID, len(tasks)) + for i := range withIDs { + withIDs[i] = taskExportWithID{ + ID: displayTaskAlias(tasks[i].UUID, aliases), + TaskExport: tasks[i], + } + } + return withIDs +} diff --git a/internal/version.go b/internal/version.go index 99fe0c9..5b94bc7 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,4 +1,4 @@ // Package internal provides the Hexai semantic version identifier used by CLI and LSP binaries. package internal -const Version = "0.27.0" +const Version = "0.27.1" |
