diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-27 22:39:50 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-27 22:39:50 +0200 |
| commit | 1575c64b7d40f4a7b462609242bd72885157a383 (patch) | |
| tree | 31e21bf96769759f16eb4375e27273c824b104c8 /internal/askcli | |
| parent | 2ddb334fa671b9c425ca43c8c673c6b36c3ad0ab (diff) | |
Diffstat (limited to 'internal/askcli')
| -rw-r--r-- | internal/askcli/completion.go | 153 | ||||
| -rw-r--r-- | internal/askcli/completion_test.go | 17 | ||||
| -rw-r--r-- | internal/askcli/dispatch.go | 5 | ||||
| -rw-r--r-- | internal/askcli/dispatch_test.go | 94 | ||||
| -rw-r--r-- | internal/askcli/task_scope.go | 63 | ||||
| -rw-r--r-- | internal/askcli/task_selector.go | 2 | ||||
| -rw-r--r-- | internal/askcli/task_selector_test.go | 4 | ||||
| -rw-r--r-- | internal/askcli/taskexec.go | 23 | ||||
| -rw-r--r-- | internal/askcli/taskexec_test.go | 66 |
9 files changed, 368 insertions, 59 deletions
diff --git a/internal/askcli/completion.go b/internal/askcli/completion.go index 889bbc8..a396029 100644 --- a/internal/askcli/completion.go +++ b/internal/askcli/completion.go @@ -16,6 +16,7 @@ var askDepCompletionItems = []fishCompletionItem{ } func fishSingleSelectorCompletionContext(positional []string) bool { + positional = trimTaskScopePrefix(positional) if len(positional) != 1 { return false } @@ -29,6 +30,7 @@ func fishSingleSelectorCompletionContext(positional []string) bool { } func fishDepSelectorCompletionContext(positional []string) bool { + positional = trimTaskScopePrefix(positional) if len(positional) < 2 || positional[0] != "dep" { return false } @@ -44,6 +46,7 @@ func fishDepSelectorCompletionContext(positional []string) bool { } func fishAddDependencyModifierCompletionContext(positional []string, current string) bool { + positional = trimTaskScopePrefix(positional) if len(positional) == 0 || positional[0] != "add" { return false } @@ -65,9 +68,15 @@ func FishCompletionFor(binaryPath string) string { 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 []fishCompletionItem{ + {name: "na", description: "Run against project tasks without +agent"}, + {name: "no-agent", description: "Run against project tasks without +agent"}, + } { + writeFishCompletionLine(&b, "__ask_needs_root_completion", item) + } for _, entry := range commandRegistry.rootCompletionEntries() { item := fishCompletionItem{name: entry.name, description: entry.description} - writeFishCompletionLine(&b, "__ask_needs_root_completion", item) + writeFishCompletionLine(&b, "__ask_needs_command_completion", item) } for _, item := range askDepCompletionItems { writeFishCompletionLine(&b, "__ask_in_dep_context", item) @@ -84,63 +93,110 @@ func writeFishPreamble(b *strings.Builder) { } func writeFishContextFunctions(b *strings.Builder) { + writeFishPositionalTokensFunction(b) + writeFishCommandPositionalsFunction(b) + writeFishScopePrefixFunction(b) writeFishNeedsRootCompletionFunction(b) + writeFishNeedsCommandCompletionFunction(b) writeFishDepContextFunction(b) writeFishUUIDContextFunction(b) writeFishDepUUIDContextFunction(b) writeFishAddDependencyModifierContextFunction(b) } +func writeFishPositionalTokensFunction(b *strings.Builder) { + b.WriteString("function __ask_positional_tokens\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(" for token in $positional\n") + b.WriteString(" printf '%s\\n' $token\n") + b.WriteString(" end\n") + b.WriteString("end\n\n") +} + +func writeFishCommandPositionalsFunction(b *strings.Builder) { + b.WriteString("function __ask_command_positionals\n") + b.WriteString(" set -l positional (__ask_positional_tokens)\n") + b.WriteString(" if test (count $positional) -gt 0\n") + b.WriteString(" switch $positional[1]\n") + b.WriteString(" case na no-agent\n") + b.WriteString(" for token in $positional[2..-1]\n") + b.WriteString(" printf '%s\\n' $token\n") + b.WriteString(" end\n") + b.WriteString(" return 0\n") + b.WriteString(" end\n") + b.WriteString(" end\n") + b.WriteString(" for token in $positional\n") + b.WriteString(" printf '%s\\n' $token\n") + b.WriteString(" end\n") + b.WriteString("end\n\n") +} + +func writeFishScopePrefixFunction(b *strings.Builder) { + b.WriteString("function __ask_scope_prefix\n") + b.WriteString(" set -l positional (__ask_positional_tokens)\n") + b.WriteString(" if test (count $positional) -eq 0\n") + b.WriteString(" return 1\n") + b.WriteString(" end\n") + b.WriteString(" switch $positional[1]\n") + b.WriteString(" case na no-agent\n") + b.WriteString(" printf '%s\\n' $positional[1]\n") + b.WriteString(" return 0\n") + b.WriteString(" case '*'\n") + b.WriteString(" return 1\n") + b.WriteString(" end\n") + b.WriteString(" return 1\n") + b.WriteString("end\n\n") +} + func writeFishNeedsRootCompletionFunction(b *strings.Builder) { b.WriteString("function __ask_needs_root_completion\n") - b.WriteString(" set -l tokens (commandline -opc)\n") - b.WriteString(" if test (count $tokens) -le 1\n") + b.WriteString(" set -l positional (__ask_positional_tokens)\n") + b.WriteString(" if test (count $positional) -eq 0\n") b.WriteString(" return 0\n") b.WriteString(" end\n") - b.WriteString(" for token in $tokens[2..-1]\n") - b.WriteString(" if not string match -qr '^-' -- $token\n") - b.WriteString(" return 1\n") + b.WriteString(" return 1\n") + b.WriteString("end\n\n") +} + +func writeFishNeedsCommandCompletionFunction(b *strings.Builder) { + b.WriteString("function __ask_needs_command_completion\n") + b.WriteString(" set -l positional (__ask_positional_tokens)\n") + b.WriteString(" if test (count $positional) -eq 0\n") + b.WriteString(" return 0\n") + b.WriteString(" end\n") + b.WriteString(" if test (count $positional) -eq 1\n") + b.WriteString(" switch $positional[1]\n") + b.WriteString(" case na no-agent\n") + b.WriteString(" return 0\n") b.WriteString(" end\n") b.WriteString(" end\n") - b.WriteString(" return 0\n") + b.WriteString(" return 1\n") b.WriteString("end\n\n") } func writeFishDepContextFunction(b *strings.Builder) { b.WriteString("function __ask_in_dep_context\n") - b.WriteString(" set -l tokens (commandline -opc)\n") - b.WriteString(" if test (count $tokens) -lt 2\n") + b.WriteString(" set -l positional (__ask_command_positionals)\n") + b.WriteString(" if test (count $positional) -lt 1\n") b.WriteString(" return 1\n") b.WriteString(" end\n") - b.WriteString(" set -l seen_dep 0\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(" if test $seen_dep -eq 0\n") - b.WriteString(" if test $token = dep\n") - b.WriteString(" set seen_dep 1\n") - b.WriteString(" else\n") - b.WriteString(" return 1\n") - b.WriteString(" end\n") - b.WriteString(" else\n") - b.WriteString(" return 1\n") - b.WriteString(" end\n") + b.WriteString(" if test $positional[1] != dep\n") + b.WriteString(" return 1\n") b.WriteString(" end\n") - b.WriteString(" test $seen_dep -eq 1\n") + b.WriteString(" test (count $positional) -eq 1\n") b.WriteString("end\n\n") } func writeFishUUIDContextFunction(b *strings.Builder) { b.WriteString("function __ask_in_uuid_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(" set -l positional (__ask_command_positionals)\n") b.WriteString(" if test (count $positional) -eq 0\n") b.WriteString(" return 1\n") b.WriteString(" end\n") @@ -161,14 +217,7 @@ func writeFishUUIDContextFunction(b *strings.Builder) { func writeFishDepUUIDContextFunction(b *strings.Builder) { b.WriteString("function __ask_in_dep_uuid_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(" set -l positional (__ask_command_positionals)\n") b.WriteString(" if test (count $positional) -lt 2\n") b.WriteString(" return 1\n") b.WriteString(" end\n") @@ -193,14 +242,7 @@ func writeFishDepUUIDContextFunction(b *strings.Builder) { 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(" set -l positional (__ask_command_positionals)\n") b.WriteString(" if test (count $positional) -lt 1\n") b.WriteString(" return 1\n") b.WriteString(" end\n") @@ -220,17 +262,28 @@ func writeFishTaskSelectorFunction(b *strings.Builder, binaryPath string) { b.WriteString(" set -l ask_bin ") b.WriteString(quoteFishString(binaryPath)) b.WriteString("\n") + b.WriteString(" set -l scope_prefix (__ask_scope_prefix)\n") + b.WriteString(" set -l cache_key default\n") + b.WriteString(" if test -n \"$scope_prefix\"\n") + b.WriteString(" set cache_key $scope_prefix\n") + b.WriteString(" end\n") b.WriteString(" set -l now (date +%s)\n") - b.WriteString(" if set -q __ask_task_selector_cache_until; and test $__ask_task_selector_cache_until -ge $now\n") + b.WriteString(" if set -q __ask_task_selector_cache_until; and test $__ask_task_selector_cache_until -ge $now; and set -q __ask_task_selector_cache_key; and test \"$__ask_task_selector_cache_key\" = \"$cache_key\"\n") b.WriteString(" printf '%s\\n' $__ask_task_selector_cache\n") b.WriteString(" return 0\n") b.WriteString(" end\n") - b.WriteString(" set -l selectors (command $ask_bin complete-uuids 2>/dev/null)\n") + b.WriteString(" set -l selectors\n") + b.WriteString(" if test -n \"$scope_prefix\"\n") + b.WriteString(" set selectors (command $ask_bin $scope_prefix complete-uuids 2>/dev/null)\n") + b.WriteString(" else\n") + b.WriteString(" set selectors (command $ask_bin complete-uuids 2>/dev/null)\n") + b.WriteString(" end\n") b.WriteString(" if test $status -ne 0\n") b.WriteString(" return 1\n") b.WriteString(" end\n") b.WriteString(" set -g __ask_task_selector_cache $selectors\n") b.WriteString(" set -g __ask_task_selector_cache_until (math $now + 2)\n") + b.WriteString(" set -g __ask_task_selector_cache_key $cache_key\n") b.WriteString(" printf '%s\\n' $selectors\n") b.WriteString("end\n\n") } diff --git a/internal/askcli/completion_test.go b/internal/askcli/completion_test.go index 9762975..f43fa32 100644 --- a/internal/askcli/completion_test.go +++ b/internal/askcli/completion_test.go @@ -7,6 +7,11 @@ import ( func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { script := FishCompletion() + for _, name := range []string{"na", "no-agent"} { + if !strings.Contains(script, " -a '"+name+"' ") { + t.Fatalf("script missing scope completion for %q", name) + } + } for _, name := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "fish", "help"} { if !strings.Contains(script, " -a '"+name+"' ") { t.Fatalf("script missing root completion for %q", name) @@ -17,10 +22,14 @@ func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { "complete -c ask -n '__ask_in_dep_context' -a 'add' -d 'Add a dependency'", "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_command_positionals", + "function __ask_scope_prefix", "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)", + "set -l selectors", + "set selectors (command $ask_bin complete-uuids 2>/dev/null)", + "set selectors (command $ask_bin $scope_prefix 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'", @@ -49,9 +58,11 @@ func TestFishSingleSelectorCompletionContext(t *testing.T) { want bool }{ {name: "info expects selector", positional: []string{"info"}, want: true}, + {name: "info expects selector with no-agent prefix", positional: []string{"na", "info"}, want: true}, {name: "annotate expects selector", positional: []string{"annotate"}, want: true}, {name: "priority expects selector", positional: []string{"priority"}, want: true}, {name: "delete expects selector", positional: []string{"delete"}, want: true}, + {name: "delete expects selector with alias prefix", positional: []string{"no-agent", "delete"}, want: true}, {name: "annotate stops after selector", positional: []string{"annotate", "0"}, want: false}, {name: "priority stops after selector", positional: []string{"priority", "0"}, want: false}, {name: "modify stops after selector", positional: []string{"modify", "0"}, want: false}, @@ -75,6 +86,7 @@ func TestFishDepSelectorCompletionContext(t *testing.T) { want bool }{ {name: "dep add first selector", positional: []string{"dep", "add"}, want: true}, + {name: "dep add first selector with no-agent prefix", positional: []string{"na", "dep", "add"}, want: true}, {name: "dep add second selector", positional: []string{"dep", "add", "0"}, want: true}, {name: "dep add stops after second selector", positional: []string{"dep", "add", "0", "1"}, want: false}, {name: "dep rm first selector", positional: []string{"dep", "rm"}, want: true}, @@ -105,6 +117,7 @@ func TestFishAddDependencyModifierCompletionContext(t *testing.T) { {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 depends modifier and no-agent prefix", positional: []string{"na", "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}, } @@ -122,7 +135,7 @@ func TestFishCompletionFor_EmbedsBinaryPath(t *testing.T) { script := FishCompletionFor(`/tmp/ask "$HOME"`) for _, line := range []string{ `set -l ask_bin "/tmp/ask \"\$HOME\""`, - "set -l selectors (command $ask_bin complete-uuids 2>/dev/null)", + "set selectors (command $ask_bin complete-uuids 2>/dev/null)", } { if !strings.Contains(script, line) { t.Fatalf("script missing %q", line) diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index d03c340..974ceea 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -45,6 +45,8 @@ func parseGlobalFlags(args []string) ([]string, bool) { func (d *Dispatcher) Dispatch(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { args, jsonOutput := parseGlobalFlags(args) d.jsonOutput = jsonOutput + scope, args := parseTaskScopePrefix(args) + ctx = contextWithTaskScope(ctx, scope) if len(args) == 0 { args = []string{"list"} @@ -59,6 +61,9 @@ 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, "\nScope prefixes:\n") + _, _ = io.WriteString(w, " ask na <subcommand...> Run a subcommand against project tasks without +agent\n") + _, _ = io.WriteString(w, " ask no-agent <subcommand...> Alias for ask na\n") _, _ = io.WriteString(w, "\nSubcommands:\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") diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go index 91f4784..cc5854d 100644 --- a/internal/askcli/dispatch_test.go +++ b/internal/askcli/dispatch_test.go @@ -26,6 +26,9 @@ func TestDispatcher_Help(t *testing.T) { if !strings.Contains(output, "ask - task management CLI") { t.Fatalf("help missing title: %s", output) } + if !strings.Contains(output, "ask na <subcommand...>") || !strings.Contains(output, "ask no-agent <subcommand...>") { + t.Fatalf("help missing no-agent scope prefixes: %s", output) + } if !strings.Contains(output, "ask list") { t.Fatalf("help missing list subcommand: %s", output) } @@ -164,6 +167,97 @@ func TestParseGlobalFlags(t *testing.T) { } } +func TestParseTaskScopePrefix(t *testing.T) { + tests := []struct { + name string + args []string + wantScope taskScopeMode + wantArgs []string + }{ + {name: "default scope", args: []string{"list"}, wantScope: taskScopeAgent, wantArgs: []string{"list"}}, + {name: "na prefix", args: []string{"na", "list"}, wantScope: taskScopeNoAgent, wantArgs: []string{"list"}}, + {name: "no-agent prefix", args: []string{"no-agent", "info", "0"}, wantScope: taskScopeNoAgent, wantArgs: []string{"info", "0"}}, + {name: "empty args", args: nil, wantScope: taskScopeAgent, wantArgs: nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotScope, gotArgs := parseTaskScopePrefix(tc.args) + if gotScope != tc.wantScope { + t.Fatalf("scope = %v, want %v", gotScope, tc.wantScope) + } + if !reflect.DeepEqual(gotArgs, tc.wantArgs) { + t.Fatalf("args = %v, want %v", gotArgs, tc.wantArgs) + } + }) + } +} + +func TestDispatcher_NoAgentPrefix_StripsScopePrefix(t *testing.T) { + taskJSONFor := func(uuid string) string { + return `[{"uuid":"` + uuid + `","description":"Test","status":"pending","priority":"M","tags":[],"urgency":10,"depends":[]}]` + } + + tests := []struct { + name string + args []string + wantCalls [][]string + }{ + { + name: "na defaults to list", + args: []string{"na"}, + wantCalls: [][]string{{"status:pending", "export"}}, + }, + { + name: "na list", + args: []string{"na", "list"}, + wantCalls: [][]string{{"status:pending", "export"}}, + }, + { + name: "no-agent info", + args: []string{"no-agent", "info", "test-uuid"}, + wantCalls: [][]string{{"uuid:test-uuid", "export"}}, + }, + { + name: "no-agent add", + args: []string{"no-agent", "add", "new task description"}, + wantCalls: [][]string{{"add", "rc.verbose=nothing", "rc.verbose=new-uuid", "new task description"}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var calls [][]string + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + calls = append(calls, append([]string(nil), args...)) + switch strings.Join(args, " ") { + case "status:pending export": + _, _ = io.WriteString(stdout, taskJSONFor("test-uuid")) + case "uuid:test-uuid export": + _, _ = io.WriteString(stdout, taskJSONFor("test-uuid")) + case "add rc.verbose=nothing rc.verbose=new-uuid new task description": + _, _ = io.WriteString(stdout, "Created task task-uuid-abc.\n") + default: + t.Fatalf("unexpected runner args: %v", args) + } + return 0, nil + }}) + + var stdout, stderr bytes.Buffer + code, err := d.Dispatch(context.Background(), tc.args, nil, &stdout, &stderr) + if err != nil { + t.Fatalf("Dispatch returned error: %v", err) + } + if code != 0 { + t.Fatalf("Dispatch code = %d, want 0", code) + } + if !reflect.DeepEqual(calls, tc.wantCalls) { + t.Fatalf("runner calls = %#v, want %#v", calls, tc.wantCalls) + } + }) + } +} + func TestDispatcher_AllSubcommandsReachExecutor(t *testing.T) { dir := t.TempDir() oldRoot := taskAliasCacheRoot diff --git a/internal/askcli/task_scope.go b/internal/askcli/task_scope.go new file mode 100644 index 0000000..42dd022 --- /dev/null +++ b/internal/askcli/task_scope.go @@ -0,0 +1,63 @@ +package askcli + +import "context" + +type taskScopeMode int + +const ( + taskScopeAgent taskScopeMode = iota + taskScopeNoAgent +) + +type taskScopeContextKey struct{} + +func contextWithTaskScope(ctx context.Context, scope taskScopeMode) context.Context { + if scope == taskScopeAgent { + return ctx + } + return context.WithValue(ctx, taskScopeContextKey{}, scope) +} + +func taskScopeFromContext(ctx context.Context) taskScopeMode { + if ctx == nil { + return taskScopeAgent + } + scope, ok := ctx.Value(taskScopeContextKey{}).(taskScopeMode) + if !ok { + return taskScopeAgent + } + return scope +} + +func taskScopeFilter(scope taskScopeMode) string { + if scope == taskScopeNoAgent { + return "-agent" + } + return "+agent" +} + +func parseTaskScopePrefix(args []string) (taskScopeMode, []string) { + if len(args) == 0 { + return taskScopeAgent, nil + } + if isTaskScopePrefix(args[0]) { + return taskScopeNoAgent, args[1:] + } + return taskScopeAgent, args +} + +func isTaskScopePrefix(arg string) bool { + switch arg { + case "na", "no-agent": + return true + default: + return false + } +} + +func trimTaskScopePrefix(args []string) []string { + if len(args) == 0 || !isTaskScopePrefix(args[0]) { + return args + } + return args[1:] +} diff --git a/internal/askcli/task_selector.go b/internal/askcli/task_selector.go index 82dc06f..033781c 100644 --- a/internal/askcli/task_selector.go +++ b/internal/askcli/task_selector.go @@ -28,7 +28,7 @@ func (d *Dispatcher) resolveTaskSelector(ctx context.Context, selector string, s tasks, code, err := d.exportTasks(ctx, []string{"uuid:" + resolved.UUID, "export"}, stderr) if err != nil { if resolved.UsedAlias && strings.Contains(err.Error(), "task not found") { - return resolvedTaskSelector{}, nil, 1, fmt.Errorf("alias %q is stale: task %s was not found", selector, resolved.UUID) + return resolvedTaskSelector{}, nil, 1, fmt.Errorf("alias %q did not resolve to a task in the current scope", selector) } return resolvedTaskSelector{}, nil, code, err } diff --git a/internal/askcli/task_selector_test.go b/internal/askcli/task_selector_test.go index e882a0b..8ced941 100644 --- a/internal/askcli/task_selector_test.go +++ b/internal/askcli/task_selector_test.go @@ -123,8 +123,8 @@ func TestHandleInfo_StaleAlias(t *testing.T) { if code != 1 { t.Fatalf("info code = %d, want 1 for stale alias", code) } - if !strings.Contains(stderr.String(), `alias "0" is stale`) { - t.Fatalf("stderr = %q, want stale alias message", stderr.String()) + if !strings.Contains(stderr.String(), `alias "0" did not resolve to a task in the current scope`) { + t.Fatalf("stderr = %q, want current-scope alias message", stderr.String()) } } diff --git a/internal/askcli/taskexec.go b/internal/askcli/taskexec.go index b188230..a1ede37 100644 --- a/internal/askcli/taskexec.go +++ b/internal/askcli/taskexec.go @@ -34,7 +34,7 @@ func NewExecutor(commandName string) Executor { } } -func (e Executor) taskArgs(repoRoot string, args []string) ([]string, error) { +func (e Executor) taskArgs(ctx context.Context, repoRoot string, args []string) ([]string, error) { projectName, err := projectNameFromRoot(repoRoot) if err != nil { return nil, err @@ -42,7 +42,24 @@ func (e Executor) taskArgs(repoRoot string, args []string) ([]string, error) { // rc.verbose=nothing suppresses Taskwarrior's configuration override // banner, while rc.confirmation=off keeps non-interactive commands from // prompting when stdin is unavailable. - return append([]string{"rc.verbose=nothing", "rc.confirmation=off", "project:" + projectName, "+agent"}, args...), nil + if len(args) > 0 && args[0] == "add" { + return addTaskArgs(projectName, taskScopeFromContext(ctx), args), nil + } + scopeFilter := taskScopeFilter(taskScopeFromContext(ctx)) + return append([]string{"rc.verbose=nothing", "rc.confirmation=off", "project:" + projectName, scopeFilter}, args...), nil +} + +func addTaskArgs(projectName string, scope taskScopeMode, args []string) []string { + taskArgs := []string{"rc.verbose=nothing", "rc.confirmation=off", "project:" + projectName, "add"} + nextArg := 1 + for nextArg < len(args) && strings.HasPrefix(args[nextArg], "rc.") { + taskArgs = append(taskArgs, args[nextArg]) + nextArg++ + } + if scope == taskScopeAgent { + taskArgs = append(taskArgs, "+agent") + } + return append(taskArgs, args[nextArg:]...) } // Run delegates CLI arguments to Taskwarrior, enforcing agent defaults and error handling. @@ -56,7 +73,7 @@ func (e Executor) Run(ctx context.Context, args []string, stdin io.Reader, stdou 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) + taskArgs, err := executor.taskArgs(ctx, repoRoot, args) if err != nil { return 1, fmt.Errorf("%s: %w", executor.label(), err) } diff --git a/internal/askcli/taskexec_test.go b/internal/askcli/taskexec_test.go index 2492aae..90da61a 100644 --- a/internal/askcli/taskexec_test.go +++ b/internal/askcli/taskexec_test.go @@ -13,7 +13,7 @@ import ( func TestExecutorTaskArgs(t *testing.T) { exec_ := NewExecutor("ask") - args, err := exec_.taskArgs("/tmp/work/hexai", []string{"list", "limit:1"}) + args, err := exec_.taskArgs(context.Background(), "/tmp/work/hexai", []string{"list", "limit:1"}) if err != nil { t.Fatalf("taskArgs returned error: %v", err) } @@ -23,6 +23,44 @@ func TestExecutorTaskArgs(t *testing.T) { } } +func TestExecutorTaskArgs_NoAgentScope(t *testing.T) { + exec_ := NewExecutor("ask") + ctx := contextWithTaskScope(context.Background(), taskScopeNoAgent) + args, err := exec_.taskArgs(ctx, "/tmp/work/hexai", []string{"list", "limit:1"}) + if err != nil { + t.Fatalf("taskArgs returned error: %v", err) + } + want := []string{"rc.verbose=nothing", "rc.confirmation=off", "project:hexai", "-agent", "list", "limit:1"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("task args = %v, want %v", args, want) + } +} + +func TestExecutorTaskArgs_AddDefaultScope(t *testing.T) { + exec_ := NewExecutor("ask") + args, err := exec_.taskArgs(context.Background(), "/tmp/work/hexai", []string{"add", "rc.verbose=nothing", "rc.verbose=new-uuid", "new task"}) + if err != nil { + t.Fatalf("taskArgs returned error: %v", err) + } + want := []string{"rc.verbose=nothing", "rc.confirmation=off", "project:hexai", "add", "rc.verbose=nothing", "rc.verbose=new-uuid", "+agent", "new task"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("task args = %v, want %v", args, want) + } +} + +func TestExecutorTaskArgs_AddNoAgentScope(t *testing.T) { + exec_ := NewExecutor("ask") + ctx := contextWithTaskScope(context.Background(), taskScopeNoAgent) + args, err := exec_.taskArgs(ctx, "/tmp/work/hexai", []string{"add", "rc.verbose=nothing", "rc.verbose=new-uuid", "new task"}) + if err != nil { + t.Fatalf("taskArgs returned error: %v", err) + } + want := []string{"rc.verbose=nothing", "rc.confirmation=off", "project:hexai", "add", "rc.verbose=nothing", "rc.verbose=new-uuid", "new task"} + 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 @@ -53,6 +91,32 @@ func TestExecutorRun_InjectsProjectFilterAndAgentTag(t *testing.T) { } } +func TestExecutorRun_InjectsProjectFilterAndNoAgentTag(t *testing.T) { + 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 { + gotArgs = append([]string(nil), args...) + return nil + }, + } + + ctx := contextWithTaskScope(context.Background(), taskScopeNoAgent) + exitCode, err := exec_.Run(ctx, []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) + } + wantArgs := []string{"rc.verbose=nothing", "rc.confirmation=off", "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", |
