summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-10 22:47:34 +0300
committerPaul Buetow <paul@buetow.org>2026-04-10 22:47:34 +0300
commitea9e7c0225f6af8179890516b5ce9d511dadb234 (patch)
treee5d64837c8823d315af40162cf5f10491a06b755 /internal
parentbd749f8e9a0eeaa5578a368996f7b93955580a39 (diff)
task 30: add proj prefix override for do
Diffstat (limited to 'internal')
-rw-r--r--internal/askcli/completion.go45
-rw-r--r--internal/askcli/completion_test.go7
-rw-r--r--internal/askcli/dispatch.go7
-rw-r--r--internal/askcli/dispatch_test.go64
-rw-r--r--internal/askcli/task_scope.go70
-rw-r--r--internal/askcli/taskexec.go23
-rw-r--r--internal/askcli/taskexec_test.go42
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",