summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-27 07:14:16 +0200
committerPaul Buetow <paul@buetow.org>2026-03-27 07:14:16 +0200
commitf146fbc4cc25bcdeab19a0c1055c839776cebff4 (patch)
tree775bdf98d930b7f0fdd5bfe5ef0a31027b8e685d /internal
parent1d1d267c56b66af87b66b74c079cb211b9cf6d90 (diff)
askcli: support add depends selectors
Diffstat (limited to 'internal')
-rw-r--r--internal/askcli/command_info_add.go77
-rw-r--r--internal/askcli/command_info_add_test.go118
-rw-r--r--internal/askcli/completion.go81
-rw-r--r--internal/askcli/completion_test.go25
-rw-r--r--internal/askcli/dispatch.go2
5 files changed, 275 insertions, 28 deletions
diff --git a/internal/askcli/command_info_add.go b/internal/askcli/command_info_add.go
index 1daacfb..737b0d0 100644
--- a/internal/askcli/command_info_add.go
+++ b/internal/askcli/command_info_add.go
@@ -88,14 +88,30 @@ func (d Dispatcher) handleAdd(ctx context.Context, args []string, stdout, stderr
io.WriteString(stderr, "error: ask add requires a description\n")
return 1, nil
}
- modifiers, description := parseAddArgs(args[1:])
+ modifiers, description, dependencySelectors, err := parseAddArgs(args[1:])
+ if err != nil {
+ writeInfoError(stderr, err)
+ return 1, nil
+ }
+ if strings.TrimSpace(description) == "" {
+ io.WriteString(stderr, "error: ask add requires a description\n")
+ return 1, nil
+ }
+ dependencyUUIDs, code, err := d.resolveAddDependencyUUIDs(ctx, dependencySelectors, stderr)
+ if err != nil {
+ writeInfoError(stderr, err)
+ return code, nil
+ }
var outBuf bytes.Buffer
// rc.verbose=nothing keeps Taskwarrior quiet by default. rc.verbose=new-uuid
// then re-enables the UUID-only confirmation we parse below.
taskArgs := []string{"add", "rc.verbose=nothing", "rc.verbose=new-uuid"}
taskArgs = append(taskArgs, modifiers...)
+ if len(dependencyUUIDs) > 0 {
+ taskArgs = append(taskArgs, "depends:"+strings.Join(dependencyUUIDs, ","))
+ }
taskArgs = append(taskArgs, description)
- code, err := d.runner.Run(ctx, taskArgs, nil, &outBuf, stderr)
+ code, err = d.runner.Run(ctx, taskArgs, nil, &outBuf, stderr)
if code != 0 {
return code, err
}
@@ -113,6 +129,18 @@ func (d Dispatcher) handleAdd(ctx context.Context, args []string, stdout, stderr
return 0, nil
}
+func (d Dispatcher) resolveAddDependencyUUIDs(ctx context.Context, selectors []string, stderr io.Writer) ([]string, int, error) {
+ dependencies := make([]string, 0, len(selectors))
+ for _, selector := range selectors {
+ resolved, _, code, err := d.resolveTaskSelector(ctx, selector, stderr)
+ if err != nil {
+ return nil, code, err
+ }
+ dependencies = append(dependencies, resolved.UUID)
+ }
+ return dependencies, 0, nil
+}
+
// extractUUIDFromAddOutput parses the UUID from taskwarrior's
// "Created task <uuid>." output (produced when rc.verbose=new-uuid is set).
func extractUUIDFromAddOutput(output string) string {
@@ -127,16 +155,25 @@ func extractUUIDFromAddOutput(output string) string {
return ""
}
-// parseAddArgs splits args into taskwarrior modifier tokens and the description.
+// parseAddArgs splits args into taskwarrior modifier tokens, the description,
+// and optional dependency selectors introduced by depends:<id>[,<id>...]
+// modifier tokens.
// Modifier tokens are args that start with "priority:", "+", or "-" AND contain
// no spaces (tags and priority flags cannot have spaces). The first arg that is
-// not a modifier begins the description; all remaining args are joined with spaces.
-// If all args are modifiers the description is empty.
-func parseAddArgs(args []string) (modifiers []string, description string) {
+// not a modifier begins the description; all remaining args are joined with
+// spaces. Dependency selectors must be provided before the description via one
+// or more depends: selectors.
+func parseAddArgs(args []string) (modifiers []string, description string, dependencySelectors []string, err error) {
for i, arg := range args {
- isModifier := !strings.Contains(arg, " ") &&
- (strings.HasPrefix(arg, "priority:") || strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-"))
- if isModifier {
+ if strings.HasPrefix(arg, "depends:") {
+ selectors, err := parseAddDependencySelectors(arg)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ dependencySelectors = append(dependencySelectors, selectors...)
+ continue
+ }
+ if isAddModifier(arg) {
modifiers = append(modifiers, arg)
} else {
description = strings.Join(args[i:], " ")
@@ -146,3 +183,25 @@ func parseAddArgs(args []string) (modifiers []string, description string) {
// All args were modifiers; no description provided.
return
}
+
+func parseAddDependencySelectors(arg string) ([]string, error) {
+ raw := strings.TrimSpace(strings.TrimPrefix(arg, "depends:"))
+ if raw == "" {
+ return nil, fmt.Errorf("ask add depends:<id|uuid>[,<id|uuid>...] requires at least one dependency ID or UUID")
+ }
+ parts := strings.Split(raw, ",")
+ selectors := make([]string, 0, len(parts))
+ for _, part := range parts {
+ selector := strings.TrimSpace(part)
+ if selector == "" {
+ return nil, fmt.Errorf("ask add dependency selector list contains an empty item")
+ }
+ selectors = append(selectors, selector)
+ }
+ return selectors, nil
+}
+
+func isAddModifier(arg string) bool {
+ return !strings.Contains(arg, " ") &&
+ (strings.HasPrefix(arg, "priority:") || strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-"))
+}
diff --git a/internal/askcli/command_info_add_test.go b/internal/askcli/command_info_add_test.go
index 9261a73..b8d911c 100644
--- a/internal/askcli/command_info_add_test.go
+++ b/internal/askcli/command_info_add_test.go
@@ -275,6 +275,22 @@ func TestHandleAdd_MissingDescription(t *testing.T) {
}
}
+func TestHandleAdd_DependsModifierWithoutSelectors(t *testing.T) {
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ t.Fatalf("runner should not be called when depends: has no selectors: %v", args)
+ return 0, nil
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, _ := d.Dispatch(context.Background(), []string{"add", "depends:", "New", "task"}, nil, &stdout, &stderr)
+ if code != 1 {
+ t.Fatalf("add code = %d, want 1", code)
+ }
+ if got := stderr.String(); !strings.Contains(got, "ask add depends:<id|uuid>[,<id|uuid>...] requires at least one dependency ID or UUID") {
+ t.Fatalf("stderr = %q, want depends: selector error", got)
+ }
+}
+
func makeAddRunner(onAdd func(args []string, stdout io.Writer)) *spyRunner {
return &spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
onAdd(args, stdout)
@@ -353,6 +369,60 @@ func TestHandleAdd_WithPriorityAndTag(t *testing.T) {
}
}
+func TestHandleAdd_WithDependencies(t *testing.T) {
+ now := useIsolatedTaskAliasCache(t)
+ writeTaskAliasCacheForTest(t, taskAliasCache{
+ NextID: 2,
+ Entries: []taskAliasCacheEntry{
+ {UUID: "dep-uuid-1", Alias: "0", CreatedAt: now},
+ {UUID: "dep-uuid-2", Alias: "1", CreatedAt: now},
+ },
+ })
+
+ var capturedAddArgs []string
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ switch {
+ case len(args) >= 2 && args[0] == "uuid:dep-uuid-1" && args[1] == "export":
+ io.WriteString(stdout, `[{"uuid":"dep-uuid-1","description":"Dependency one","status":"pending","priority":"M","tags":[],"urgency":0,"depends":[]}]`)
+ case len(args) >= 2 && args[0] == "uuid:dep-uuid-2" && args[1] == "export":
+ io.WriteString(stdout, `[{"uuid":"dep-uuid-2","description":"Dependency two","status":"pending","priority":"M","tags":[],"urgency":0,"depends":[]}]`)
+ case len(args) >= 1 && args[0] == "add":
+ capturedAddArgs = append([]string(nil), args...)
+ io.WriteString(stdout, "Created task created-uuid.")
+ default:
+ t.Fatalf("unexpected runner args: %v", args)
+ }
+ return 0, nil
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, _ := d.Dispatch(
+ context.Background(),
+ []string{"add", "+cli", "depends:0,1", "New", "task"},
+ nil,
+ &stdout,
+ &stderr,
+ )
+ 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 len(capturedAddArgs) < 6 {
+ t.Fatalf("capturedAddArgs = %v, want add invocation with dependency modifier", capturedAddArgs)
+ }
+ if capturedAddArgs[3] != "+cli" {
+ t.Fatalf("capturedAddArgs[3] = %q, want +cli", capturedAddArgs[3])
+ }
+ if capturedAddArgs[4] != "depends:dep-uuid-1,dep-uuid-2" {
+ t.Fatalf("capturedAddArgs[4] = %q, want dependency modifier", capturedAddArgs[4])
+ }
+ if capturedAddArgs[5] != "New task" {
+ t.Fatalf("capturedAddArgs[5] = %q, want joined description", capturedAddArgs[5])
+ }
+}
+
func TestExtractUUIDFromAddOutput(t *testing.T) {
if uuid := extractUUIDFromAddOutput("Created task abc-123-def."); uuid != "abc-123-def" {
t.Fatalf("got %q, want abc-123-def", uuid)
@@ -366,40 +436,52 @@ func TestExtractUUIDFromAddOutput(t *testing.T) {
}
func TestParseAddArgs(t *testing.T) {
- mods, desc := parseAddArgs([]string{"priority:H", "+cli", "Fix bug"})
- if desc != "Fix bug" || len(mods) != 2 {
- t.Fatalf("parseAddArgs([\"priority:H\", \"+cli\", \"Fix bug\"]) = mods=%v, desc=%q, want mods=[priority:H, +cli], desc=\"Fix bug\"", mods, desc)
+ mods, desc, deps, err := parseAddArgs([]string{"priority:H", "+cli", "Fix bug"})
+ if err != nil || desc != "Fix bug" || len(mods) != 2 || len(deps) != 0 {
+ t.Fatalf("parseAddArgs([\"priority:H\", \"+cli\", \"Fix bug\"]) = mods=%v, desc=%q, deps=%v, err=%v", mods, desc, deps, err)
}
- mods, desc = parseAddArgs([]string{"Multi", "word", "description"})
- if desc != "Multi word description" || len(mods) != 0 {
- t.Fatalf("parseAddArgs([\"Multi\", \"word\", \"description\"]) = mods=%v, desc=%q, want mods=[], desc=\"Multi word description\"", mods, desc)
+ mods, desc, deps, err = parseAddArgs([]string{"Multi", "word", "description"})
+ if err != nil || desc != "Multi word description" || len(mods) != 0 || len(deps) != 0 {
+ t.Fatalf("parseAddArgs([\"Multi\", \"word\", \"description\"]) = mods=%v, desc=%q, deps=%v, err=%v", mods, desc, deps, err)
}
- mods, desc = parseAddArgs([]string{"-deprecated", "Old task"})
- if desc != "Old task" || len(mods) != 1 || mods[0] != "-deprecated" {
- t.Fatalf("parseAddArgs([\"-deprecated\", \"Old task\"]) = mods=%v, desc=%q", mods, desc)
+ mods, desc, deps, err = parseAddArgs([]string{"-deprecated", "Old task"})
+ if err != nil || desc != "Old task" || len(mods) != 1 || mods[0] != "-deprecated" || len(deps) != 0 {
+ t.Fatalf("parseAddArgs([\"-deprecated\", \"Old task\"]) = mods=%v, desc=%q, deps=%v, err=%v", mods, desc, deps, err)
}
// An arg starting with "+" but containing spaces is NOT a modifier — it is
// the start of the description. This prevents agents from quoting tag+desc
// together (e.g. "+code-quality Fix foo") and having them land in the wrong
// place.
- mods, desc = parseAddArgs([]string{"+code-quality Fix foo bar"})
- if desc != "+code-quality Fix foo bar" || len(mods) != 0 {
- t.Fatalf("space-containing +arg should be description, got mods=%v, desc=%q", mods, desc)
+ mods, desc, deps, err = parseAddArgs([]string{"+code-quality Fix foo bar"})
+ if err != nil || desc != "+code-quality Fix foo bar" || len(mods) != 0 || len(deps) != 0 {
+ t.Fatalf("space-containing +arg should be description, got mods=%v, desc=%q, deps=%v, err=%v", mods, desc, deps, err)
}
// Same issue when mixed: a proper tag precedes a space-containing arg.
- mods, desc = parseAddArgs([]string{"+cli", "+code-quality Fix foo bar"})
- if desc != "+code-quality Fix foo bar" || len(mods) != 1 || mods[0] != "+cli" {
- t.Fatalf("mixed case: mods=%v, desc=%q", mods, desc)
+ mods, desc, deps, err = parseAddArgs([]string{"+cli", "+code-quality Fix foo bar"})
+ if err != nil || desc != "+code-quality Fix foo bar" || len(mods) != 1 || mods[0] != "+cli" || len(deps) != 0 {
+ t.Fatalf("mixed case: mods=%v, desc=%q, deps=%v, err=%v", mods, desc, deps, err)
}
// All-modifier args (no description) should return empty description, not a
// duplicate of the modifiers.
- mods, desc = parseAddArgs([]string{"+cli", "+agent"})
- if desc != "" || len(mods) != 2 {
- t.Fatalf("all-modifier case: mods=%v, desc=%q, want empty desc", mods, desc)
+ mods, desc, deps, err = parseAddArgs([]string{"+cli", "+agent"})
+ if err != nil || desc != "" || len(mods) != 2 || len(deps) != 0 {
+ t.Fatalf("all-modifier case: mods=%v, desc=%q, deps=%v, err=%v", mods, desc, deps, err)
+ }
+
+ mods, desc, deps, err = parseAddArgs([]string{"+cli", "depends:0,1", "Fix", "bug"})
+ if err != nil || desc != "Fix bug" || len(mods) != 1 || mods[0] != "+cli" || len(deps) != 2 || deps[0] != "0" || deps[1] != "1" {
+ t.Fatalf("depends case: mods=%v, desc=%q, deps=%v, err=%v", mods, desc, deps, err)
+ }
+
+ if _, _, _, err = parseAddArgs([]string{"depends:", "Fix", "bug"}); err == nil {
+ t.Fatalf("parseAddArgs should reject empty depends: modifier")
+ }
+ if _, _, _, err = parseAddArgs([]string{"depends:0,,1", "Fix", "bug"}); err == nil {
+ t.Fatalf("parseAddArgs should reject empty selector entries")
}
}
diff --git a/internal/askcli/completion.go b/internal/askcli/completion.go
index 04dbb25..1e292d5 100644
--- a/internal/askcli/completion.go
+++ b/internal/askcli/completion.go
@@ -90,6 +90,14 @@ func fishDepSelectorCompletionContext(positional []string) bool {
}
}
+func fishAddDependencyModifierCompletionContext(positional []string, current string) bool {
+ if len(positional) == 0 || positional[0] != "add" {
+ return false
+ }
+ current = strings.TrimSpace(current)
+ return current == "depends" || strings.HasPrefix(current, "depends:")
+}
+
func FishCompletion() string {
return FishCompletionFor("ask")
}
@@ -99,6 +107,7 @@ func FishCompletionFor(binaryPath string) string {
writeFishPreamble(&b)
writeFishContextFunctions(&b)
writeFishTaskSelectorFunction(&b, binaryPath)
+ writeFishAddDependencyModifierFunction(&b)
b.WriteString("complete -c ask -f\n")
b.WriteString("complete -c ask -s j -l json -d 'Emit JSON output'\n")
for _, item := range askRootCompletionItems {
@@ -109,6 +118,7 @@ func FishCompletionFor(binaryPath string) string {
}
writeFishUUIDCompletionLine(&b, "__ask_in_uuid_context", "Task selector")
writeFishUUIDCompletionLine(&b, "__ask_in_dep_uuid_context", "Task selector")
+ writeFishFunctionCompletionLine(&b, "__ask_in_add_dep_modifier_context", "__ask_add_dependency_modifiers", "Task dependency")
return b.String()
}
@@ -122,6 +132,7 @@ func writeFishContextFunctions(b *strings.Builder) {
writeFishDepContextFunction(b)
writeFishUUIDContextFunction(b)
writeFishDepUUIDContextFunction(b)
+ writeFishAddDependencyModifierContextFunction(b)
}
func writeFishNeedsRootCompletionFunction(b *strings.Builder) {
@@ -224,6 +235,30 @@ func writeFishDepUUIDContextFunction(b *strings.Builder) {
b.WriteString("end\n\n")
}
+func writeFishAddDependencyModifierContextFunction(b *strings.Builder) {
+ b.WriteString("function __ask_in_add_dep_modifier_context\n")
+ b.WriteString(" set -l tokens (commandline -opc)\n")
+ b.WriteString(" set -l positional\n")
+ b.WriteString(" for token in $tokens[2..-1]\n")
+ b.WriteString(" if string match -qr '^-' -- $token\n")
+ b.WriteString(" continue\n")
+ b.WriteString(" end\n")
+ b.WriteString(" set -a positional $token\n")
+ b.WriteString(" end\n")
+ b.WriteString(" if test (count $positional) -lt 1\n")
+ b.WriteString(" return 1\n")
+ b.WriteString(" end\n")
+ b.WriteString(" if test $positional[1] != add\n")
+ b.WriteString(" return 1\n")
+ b.WriteString(" end\n")
+ b.WriteString(" set -l current (commandline -ct)\n")
+ b.WriteString(" if test $current = depends\n")
+ b.WriteString(" return 0\n")
+ b.WriteString(" end\n")
+ b.WriteString(" string match -qr '^depends:' -- $current\n")
+ b.WriteString("end\n\n")
+}
+
func writeFishTaskSelectorFunction(b *strings.Builder, binaryPath string) {
b.WriteString("function __ask_task_selectors\n")
b.WriteString(" set -l ask_bin ")
@@ -244,6 +279,42 @@ func writeFishTaskSelectorFunction(b *strings.Builder, binaryPath string) {
b.WriteString("end\n\n")
}
+func writeFishAddDependencyModifierFunction(b *strings.Builder) {
+ b.WriteString("function __ask_add_dependency_modifiers\n")
+ b.WriteString(" set -l current (commandline -ct)\n")
+ b.WriteString(" if test $current = depends\n")
+ b.WriteString(" printf '%s\\n' 'depends:'\n")
+ b.WriteString(" return 0\n")
+ b.WriteString(" end\n")
+ b.WriteString(" if not string match -qr '^depends:' -- $current\n")
+ b.WriteString(" return 1\n")
+ b.WriteString(" end\n")
+ b.WriteString(" set -l raw (string sub -s 9 -- $current)\n")
+ b.WriteString(" set -l partial $raw\n")
+ b.WriteString(" set -l chosen\n")
+ b.WriteString(" if string match -q '*,*' -- $raw\n")
+ b.WriteString(" set -l pieces (string split ',' -- $raw)\n")
+ b.WriteString(" set partial $pieces[-1]\n")
+ b.WriteString(" if test (count $pieces) -gt 1\n")
+ b.WriteString(" set chosen $pieces[1..-2]\n")
+ b.WriteString(" end\n")
+ b.WriteString(" end\n")
+ b.WriteString(" for selector in (__ask_task_selectors)\n")
+ b.WriteString(" if contains -- $selector $chosen\n")
+ b.WriteString(" continue\n")
+ b.WriteString(" end\n")
+ b.WriteString(" if not string match -q -- \"$partial*\" $selector\n")
+ b.WriteString(" continue\n")
+ b.WriteString(" end\n")
+ b.WriteString(" if test (count $chosen) -eq 0\n")
+ b.WriteString(" printf 'depends:%s\\n' $selector\n")
+ b.WriteString(" else\n")
+ b.WriteString(" printf 'depends:%s,%s\\n' (string join ',' $chosen) $selector\n")
+ b.WriteString(" end\n")
+ b.WriteString(" end\n")
+ b.WriteString("end\n\n")
+}
+
func writeFishCompletionLine(b *strings.Builder, condition string, item fishCompletionItem) {
b.WriteString("complete -c ask -n '")
b.WriteString(condition)
@@ -262,6 +333,16 @@ func writeFishUUIDCompletionLine(b *strings.Builder, condition, description stri
b.WriteString("'\n")
}
+func writeFishFunctionCompletionLine(b *strings.Builder, condition, functionName, description string) {
+ b.WriteString("complete -c ask -n '")
+ b.WriteString(condition)
+ b.WriteString("' -a '(")
+ b.WriteString(functionName)
+ b.WriteString(")' -d '")
+ b.WriteString(strings.ReplaceAll(description, "'", "\\'"))
+ b.WriteString("'\n")
+}
+
func quoteFishString(value string) string {
replacer := strings.NewReplacer(
"\\", "\\\\",
diff --git a/internal/askcli/completion_test.go b/internal/askcli/completion_test.go
index 14afdf5..fe18ddc 100644
--- a/internal/askcli/completion_test.go
+++ b/internal/askcli/completion_test.go
@@ -18,10 +18,12 @@ func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) {
"complete -c ask -n '__ask_in_dep_context' -a 'rm' -d 'Remove a dependency'",
"complete -c ask -n '__ask_in_dep_context' -a 'list' -d 'List dependencies'",
"function __ask_task_selectors",
+ "function __ask_add_dependency_modifiers",
`set -l ask_bin "ask"`,
"set -l selectors (command $ask_bin complete-uuids 2>/dev/null)",
"complete -c ask -n '__ask_in_uuid_context' -a '(__ask_task_selectors)' -d 'Task selector'",
"complete -c ask -n '__ask_in_dep_uuid_context' -a '(__ask_task_selectors)' -d 'Task selector'",
+ "complete -c ask -n '__ask_in_add_dep_modifier_context' -a '(__ask_add_dependency_modifiers)' -d 'Task dependency'",
} {
if !strings.Contains(script, line) {
t.Fatalf("script missing dep completion line %q", line)
@@ -88,6 +90,29 @@ func TestFishDepSelectorCompletionContext(t *testing.T) {
}
}
+func TestFishAddDependencyModifierCompletionContext(t *testing.T) {
+ testCases := []struct {
+ name string
+ positional []string
+ current string
+ want bool
+ }{
+ {name: "add without depends modifier", positional: []string{"add", "task"}, current: "task", want: false},
+ {name: "add with depends keyword prefix", positional: []string{"add"}, current: "depends", want: true},
+ {name: "add with depends modifier", positional: []string{"add", "+cli"}, current: "depends:0", want: true},
+ {name: "add with comma continuation", positional: []string{"add", "+cli"}, current: "depends:0,", want: true},
+ {name: "non add command", positional: []string{"dep", "add"}, current: "depends:0", want: false},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := fishAddDependencyModifierCompletionContext(tc.positional, tc.current); got != tc.want {
+ t.Fatalf("fishAddDependencyModifierCompletionContext(%v, %q) = %t, want %t", tc.positional, tc.current, got, tc.want)
+ }
+ })
+ }
+}
+
func TestFishCompletionFor_EmbedsBinaryPath(t *testing.T) {
script := FishCompletionFor(`/tmp/ask "$HOME"`)
for _, line := range []string{
diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go
index 12407e3..510f2cd 100644
--- a/internal/askcli/dispatch.go
+++ b/internal/askcli/dispatch.go
@@ -88,7 +88,7 @@ func (d Dispatcher) Dispatch(ctx context.Context, args []string, stdin io.Reader
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 and print its ID\n")
+ io.WriteString(w, " ask add [mods...] [depends:<id|uuid>,...] <description...> Create a new task and print its 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")