diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-10 22:47:34 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-10 22:47:34 +0300 |
| commit | ea9e7c0225f6af8179890516b5ce9d511dadb234 (patch) | |
| tree | e5d64837c8823d315af40162cf5f10491a06b755 /internal/askcli | |
| parent | bd749f8e9a0eeaa5578a368996f7b93955580a39 (diff) | |
task 30: add proj prefix override for do
Diffstat (limited to 'internal/askcli')
| -rw-r--r-- | internal/askcli/completion.go | 45 | ||||
| -rw-r--r-- | internal/askcli/completion_test.go | 7 | ||||
| -rw-r--r-- | internal/askcli/dispatch.go | 7 | ||||
| -rw-r--r-- | internal/askcli/dispatch_test.go | 64 | ||||
| -rw-r--r-- | internal/askcli/task_scope.go | 70 | ||||
| -rw-r--r-- | internal/askcli/taskexec.go | 23 | ||||
| -rw-r--r-- | internal/askcli/taskexec_test.go | 42 |
7 files changed, 216 insertions, 42 deletions
diff --git a/internal/askcli/completion.go b/internal/askcli/completion.go index e7b837e..4d4b5d3 100644 --- a/internal/askcli/completion.go +++ b/internal/askcli/completion.go @@ -16,7 +16,7 @@ var askDepCompletionItems = []fishCompletionItem{ } func fishSingleSelectorCompletionContext(positional []string) bool { - positional = trimTaskScopePrefix(positional) + positional = trimTaskPrefixes(positional) if len(positional) != 1 { return false } @@ -30,7 +30,7 @@ func fishSingleSelectorCompletionContext(positional []string) bool { } func fishDepSelectorCompletionContext(positional []string) bool { - positional = trimTaskScopePrefix(positional) + positional = trimTaskPrefixes(positional) if len(positional) < 2 || positional[0] != "dep" { return false } @@ -46,7 +46,7 @@ func fishDepSelectorCompletionContext(positional []string) bool { } func fishAddDependencyModifierCompletionContext(positional []string, current string) bool { - positional = trimTaskScopePrefix(positional) + positional = trimTaskPrefixes(positional) if len(positional) == 0 || positional[0] != "add" { return false } @@ -71,6 +71,7 @@ func FishCompletionFor(binaryPath string) string { for _, item := range []fishCompletionItem{ {name: "na", description: "Run against project tasks without +agent"}, {name: "no-agent", description: "Run against project tasks without +agent"}, + {name: "proj:", description: "Run against an explicit project"}, } { writeFishCompletionLine(&b, "__do_needs_root_completion", item) } @@ -123,13 +124,12 @@ func writeFishPositionalTokensFunction(b *strings.Builder) { func writeFishCommandPositionalsFunction(b *strings.Builder) { b.WriteString("function __do_command_positionals\n") b.WriteString(" set -l positional (__do_positional_tokens)\n") - b.WriteString(" if test (count $positional) -gt 0\n") + b.WriteString(" while 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(" case na no-agent proj:*\n") + b.WriteString(" set positional $positional[2..-1]\n") + b.WriteString(" case '*'\n") + b.WriteString(" break\n") b.WriteString(" end\n") b.WriteString(" end\n") b.WriteString(" for token in $positional\n") @@ -141,17 +141,16 @@ func writeFishCommandPositionalsFunction(b *strings.Builder) { func writeFishScopePrefixFunction(b *strings.Builder) { b.WriteString("function __do_scope_prefix\n") b.WriteString(" set -l positional (__do_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(" while test (count $positional) -gt 0\n") + b.WriteString(" switch $positional[1]\n") + b.WriteString(" case na no-agent proj:*\n") + b.WriteString(" printf '%s\\n' $positional[1]\n") + b.WriteString(" set positional $positional[2..-1]\n") + b.WriteString(" case '*'\n") + b.WriteString(" return 0\n") + b.WriteString(" end\n") b.WriteString(" end\n") - b.WriteString(" return 1\n") + b.WriteString(" return 0\n") b.WriteString("end\n\n") } @@ -173,7 +172,7 @@ func writeFishNeedsCommandCompletionFunction(b *strings.Builder) { 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(" case na no-agent proj:*\n") b.WriteString(" return 0\n") b.WriteString(" end\n") b.WriteString(" end\n") @@ -264,8 +263,8 @@ func writeFishTaskSelectorFunction(b *strings.Builder, binaryPath string) { b.WriteString("\n") b.WriteString(" set -l scope_prefix (__do_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(" if test (count $scope_prefix) -gt 0\n") + b.WriteString(" set cache_key (string join ' ' $scope_prefix)\n") b.WriteString(" end\n") b.WriteString(" set -l now (date +%s)\n") b.WriteString(" if set -q __do_task_selector_cache_until; and test $__do_task_selector_cache_until -ge $now; and set -q __do_task_selector_cache_key; and test \"$__do_task_selector_cache_key\" = \"$cache_key\"\n") @@ -273,7 +272,7 @@ func writeFishTaskSelectorFunction(b *strings.Builder, binaryPath string) { b.WriteString(" return 0\n") b.WriteString(" end\n") b.WriteString(" set -l selectors\n") - b.WriteString(" if test -n \"$scope_prefix\"\n") + b.WriteString(" if test (count $scope_prefix) -gt 0\n") b.WriteString(" set selectors (command $do_bin $scope_prefix complete-aliases 2>/dev/null)\n") b.WriteString(" else\n") b.WriteString(" set selectors (command $do_bin complete-aliases 2>/dev/null)\n") diff --git a/internal/askcli/completion_test.go b/internal/askcli/completion_test.go index 5e18c09..7d85c7b 100644 --- a/internal/askcli/completion_test.go +++ b/internal/askcli/completion_test.go @@ -7,7 +7,7 @@ import ( func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { script := FishCompletion() - for _, name := range []string{"na", "no-agent"} { + for _, name := range []string{"na", "no-agent", "proj:"} { if !strings.Contains(script, " -a '"+name+"' ") { t.Fatalf("script missing scope completion for %q", name) } @@ -30,6 +30,8 @@ func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { "set -l selectors", "set selectors (command $do_bin complete-aliases 2>/dev/null)", "set selectors (command $do_bin $scope_prefix complete-aliases 2>/dev/null)", + "case na no-agent proj:*", + "set cache_key (string join ' ' $scope_prefix)", "complete -c do -n '__do_in_uuid_context' -a '(__do_task_selectors)' -d 'Task selector'", "complete -c do -n '__do_in_dep_uuid_context' -a '(__do_task_selectors)' -d 'Task selector'", "complete -c do -n '__do_in_add_dep_modifier_context' -a '(__do_add_dependency_modifiers)' -d 'Task dependency'", @@ -63,6 +65,7 @@ func TestFishSingleSelectorCompletionContext(t *testing.T) { }{ {name: "info expects selector", positional: []string{"info"}, want: true}, {name: "info expects selector with no-agent prefix", positional: []string{"na", "info"}, want: true}, + {name: "info expects selector with project prefix", positional: []string{"proj:alpha", "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}, @@ -91,6 +94,7 @@ func TestFishDepSelectorCompletionContext(t *testing.T) { }{ {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 first selector with project prefix", positional: []string{"proj:alpha", "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}, @@ -122,6 +126,7 @@ func TestFishAddDependencyModifierCompletionContext(t *testing.T) { {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 depends modifier and project prefix", positional: []string{"proj:alpha", "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}, } diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index 3167421..69310c7 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -45,8 +45,11 @@ 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) + scope, projectName, projectSet, args := parseTaskPrefixes(args) ctx = contextWithTaskScope(ctx, scope) + if projectSet { + ctx = contextWithTaskProject(ctx, projectName) + } if len(args) == 0 { args = []string{"list"} @@ -66,6 +69,8 @@ func (d *Dispatcher) Dispatch(ctx context.Context, args []string, stdin io.Reade func (d *Dispatcher) help(w io.Writer) (int, error) { _, _ = io.WriteString(w, "do - task management CLI\n") + _, _ = io.WriteString(w, "\nProject prefixes:\n") + _, _ = io.WriteString(w, " do proj:<name> <subcommand...> Run a subcommand against an explicit project\n") _, _ = io.WriteString(w, "\nScope prefixes:\n") _, _ = io.WriteString(w, " do na <subcommand...> Run a subcommand against project tasks without +agent\n") _, _ = io.WriteString(w, " do no-agent <subcommand...> Alias for do na\n") diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go index 0e13788..68f1e3b 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, "do - task management CLI") { t.Fatalf("help missing title: %s", output) } + if !strings.Contains(output, "do proj:<name> <subcommand...>") { + t.Fatalf("help missing project prefix: %s", output) + } if !strings.Contains(output, "do na <subcommand...>") || !strings.Contains(output, "do no-agent <subcommand...>") { t.Fatalf("help missing no-agent scope prefixes: %s", output) } @@ -238,6 +241,67 @@ func TestParseTaskScopePrefix(t *testing.T) { } } +func TestParseTaskPrefixes(t *testing.T) { + tests := []struct { + name string + args []string + wantScope taskScopeMode + wantProject string + wantProjectSet bool + wantRemaining []string + }{ + {name: "default", args: []string{"list"}, wantScope: taskScopeAgent, wantRemaining: []string{"list"}}, + {name: "project prefix", args: []string{"proj:alpha", "list"}, wantProject: "alpha", wantProjectSet: true, wantRemaining: []string{"list"}}, + {name: "project prefix with empty name", args: []string{"proj:", "list"}, wantProject: "", wantProjectSet: true, wantRemaining: []string{"list"}}, + {name: "scope then project", args: []string{"na", "proj:alpha", "list"}, wantScope: taskScopeNoAgent, wantProject: "alpha", wantProjectSet: true, wantRemaining: []string{"list"}}, + {name: "project then scope", args: []string{"proj:alpha", "na", "list"}, wantScope: taskScopeNoAgent, wantProject: "alpha", wantProjectSet: true, wantRemaining: []string{"list"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotScope, gotProject, gotProjectSet, gotRemaining := parseTaskPrefixes(tc.args) + if gotScope != tc.wantScope { + t.Fatalf("scope = %v, want %v", gotScope, tc.wantScope) + } + if gotProject != tc.wantProject { + t.Fatalf("project = %q, want %q", gotProject, tc.wantProject) + } + if gotProjectSet != tc.wantProjectSet { + t.Fatalf("projectSet = %t, want %t", gotProjectSet, tc.wantProjectSet) + } + if !reflect.DeepEqual(gotRemaining, tc.wantRemaining) { + t.Fatalf("remaining = %v, want %v", gotRemaining, tc.wantRemaining) + } + }) + } +} + +func TestDispatcher_ProjectPrefix_PassesProjectOverride(t *testing.T) { + var gotArgs []string + var gotProject string + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + gotArgs = append([]string(nil), args...) + gotProject, _ = taskProjectFromContext(ctx) + _, _ = io.WriteString(stdout, `[]`) + return 0, nil + }}) + + var stdout, stderr bytes.Buffer + code, err := d.Dispatch(context.Background(), []string{"proj:alpha", "list"}, 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 gotProject != "alpha" { + t.Fatalf("project override = %q, want alpha", gotProject) + } + if !reflect.DeepEqual(gotArgs, []string{"status:pending", "export"}) { + t.Fatalf("runner args = %v, want [status:pending export]", gotArgs) + } +} + 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":[]}]` diff --git a/internal/askcli/task_scope.go b/internal/askcli/task_scope.go index 42dd022..006407e 100644 --- a/internal/askcli/task_scope.go +++ b/internal/askcli/task_scope.go @@ -1,6 +1,9 @@ package askcli -import "context" +import ( + "context" + "strings" +) type taskScopeMode int @@ -11,6 +14,12 @@ const ( type taskScopeContextKey struct{} +type taskProjectContextKey struct{} + +type taskProjectContextValue struct { + project string +} + func contextWithTaskScope(ctx context.Context, scope taskScopeMode) context.Context { if scope == taskScopeAgent { return ctx @@ -29,6 +38,24 @@ func taskScopeFromContext(ctx context.Context) taskScopeMode { return scope } +func contextWithTaskProject(ctx context.Context, project string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, taskProjectContextKey{}, taskProjectContextValue{project: project}) +} + +func taskProjectFromContext(ctx context.Context) (string, bool) { + if ctx == nil { + return "", false + } + value, ok := ctx.Value(taskProjectContextKey{}).(taskProjectContextValue) + if !ok { + return "", false + } + return value.project, true +} + func taskScopeFilter(scope taskScopeMode) string { if scope == taskScopeNoAgent { return "-agent" @@ -37,13 +64,8 @@ func taskScopeFilter(scope taskScopeMode) string { } func parseTaskScopePrefix(args []string) (taskScopeMode, []string) { - if len(args) == 0 { - return taskScopeAgent, nil - } - if isTaskScopePrefix(args[0]) { - return taskScopeNoAgent, args[1:] - } - return taskScopeAgent, args + scope, _, _, remaining := parseTaskPrefixes(args) + return scope, remaining } func isTaskScopePrefix(arg string) bool { @@ -55,9 +77,35 @@ func isTaskScopePrefix(arg string) bool { } } +func isTaskProjectPrefix(arg string) bool { + return strings.HasPrefix(arg, "proj:") +} + func trimTaskScopePrefix(args []string) []string { - if len(args) == 0 || !isTaskScopePrefix(args[0]) { - return args + return trimTaskPrefixes(args) +} + +func trimTaskPrefixes(args []string) []string { + _, _, _, remaining := parseTaskPrefixes(args) + return remaining +} + +func parseTaskPrefixes(args []string) (taskScopeMode, string, bool, []string) { + scope := taskScopeAgent + projectName := "" + projectSet := false + for len(args) > 0 { + switch { + case isTaskScopePrefix(args[0]): + scope = taskScopeNoAgent + args = args[1:] + case isTaskProjectPrefix(args[0]): + projectName = args[0][len("proj:"):] + projectSet = true + args = args[1:] + default: + return scope, projectName, projectSet, args + } } - return args[1:] + return scope, projectName, projectSet, nil } diff --git a/internal/askcli/taskexec.go b/internal/askcli/taskexec.go index e3915d9..d479c34 100644 --- a/internal/askcli/taskexec.go +++ b/internal/askcli/taskexec.go @@ -35,9 +35,17 @@ func NewExecutor(commandName string) Executor { } func (e Executor) taskArgs(ctx context.Context, repoRoot string, args []string) ([]string, error) { - projectName, err := projectNameFromRoot(repoRoot) - if err != nil { - return nil, err + projectName, ok := taskProjectFromContext(ctx) + if !ok { + var err error + projectName, err = projectNameFromRoot(repoRoot) + if err != nil { + return nil, err + } + } + projectName = strings.TrimSpace(projectName) + if projectName == "" { + return nil, fmt.Errorf("project override proj:<name> requires a project name") } // rc.verbose=nothing suppresses Taskwarrior's configuration override // banner, while rc.confirmation=off keeps non-interactive commands from @@ -69,9 +77,12 @@ func (e Executor) Run(ctx context.Context, args []string, stdin io.Reader, stdou 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) + repoRoot := "" + if _, ok := taskProjectFromContext(ctx); !ok { + 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(ctx, repoRoot, args) if err != nil { diff --git a/internal/askcli/taskexec_test.go b/internal/askcli/taskexec_test.go index 4db988b..0d744dc 100644 --- a/internal/askcli/taskexec_test.go +++ b/internal/askcli/taskexec_test.go @@ -36,6 +36,19 @@ func TestExecutorTaskArgs_NoAgentScope(t *testing.T) { } } +func TestExecutorTaskArgs_ProjectOverride(t *testing.T) { + exec_ := NewExecutor("do") + ctx := contextWithTaskProject(context.Background(), "alpha") + args, err := exec_.taskArgs(ctx, "", []string{"list", "limit:1"}) + if err != nil { + t.Fatalf("taskArgs returned error: %v", err) + } + want := []string{"rc.verbose=nothing", "rc.confirmation=off", "project:alpha", "+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("do") args, err := exec_.taskArgs(context.Background(), "/tmp/work/hexai", []string{"add", "rc.verbose=nothing", "rc.verbose=new-uuid", "new task"}) @@ -117,6 +130,35 @@ func TestExecutorRun_InjectsProjectFilterAndNoAgentTag(t *testing.T) { } } +func TestExecutorRun_ProjectOverrideSkipsRepoDetection(t *testing.T) { + var gotArgs []string + exec_ := Executor{ + commandName: "do", + findBinary: func() (string, error) { return "/usr/bin/task", nil }, + detectRepoRoot: func(context.Context) (string, error) { + t.Fatal("detectRepoRoot should not be called when project override is set") + return "", 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 := contextWithTaskProject(context.Background(), "alpha") + exitCode, err := exec_.Run(ctx, []string{"list"}, strings.NewReader(""), &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:alpha", "+agent", "list"} + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("task args = %v, want %v", gotArgs, wantArgs) + } +} + func TestExecutorRun_OutsideGitRepo_IsActionable(t *testing.T) { exec_ := Executor{ commandName: "do", |
