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 | |
| parent | 2ddb334fa671b9c425ca43c8c673c6b36c3ad0ab (diff) | |
| -rwxr-xr-x | cmd/ask/ask | bin | 3536143 -> 4321591 bytes | |||
| -rw-r--r-- | docs/usage.md | 15 | ||||
| -rw-r--r-- | integrationtests/ask_scope_test.go | 257 | ||||
| -rw-r--r-- | integrationtests/ask_test.go | 2 | ||||
| -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 | ||||
| -rw-r--r-- | internal/version.go | 2 |
14 files changed, 641 insertions, 62 deletions
diff --git a/cmd/ask/ask b/cmd/ask/ask Binary files differindex 9d4efad..a7c597a 100755 --- a/cmd/ask/ask +++ b/cmd/ask/ask diff --git a/docs/usage.md b/docs/usage.md index 8a1c1d1..8824a44 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -125,7 +125,9 @@ cat SOMEFILE.txt | hexai --tps-simulation 20 ## Task management -`ask` is a task management CLI for the current git project. It auto-scopes to `project:<repo> +agent` so all operations are confined to the current project. +`ask` is a task management CLI for the current git project. By default it auto-scopes to `project:<repo> +agent` so operations are confined to agent-managed project tasks. + +Use `ask na <subcommand...>` or `ask no-agent <subcommand...>` to run the same subcommands against project tasks without the `+agent` tag. Those prefixes keep the project scope but replace the default tag filter with `-agent`. `ask` never exposes Taskwarrior numeric task IDs. Human-facing output uses stable local alias IDs where practical, while `ask info` shows both the alias ID and the UUID. Commands that accept a task selector support either the alias ID or the UUID. @@ -139,7 +141,9 @@ cat SOMEFILE.txt | hexai --tps-simulation 20 | `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 | +| `ask na add "description"` | Create a project task without the `+agent` tag | | `ask list` | List pending tasks only (alias-ID table) | +| `ask na list` | List pending project tasks without the `+agent` tag | | `ask all` | List all tasks including completed/deleted | | `ask list +READY` | List only ready tasks | | `ask list +BLOCKED` | List blocked tasks | @@ -169,15 +173,24 @@ cat SOMEFILE.txt | hexai --tps-simulation 20 # Create a task ask add priority:H "Implement new feature" +# Create a non-agent task +ask na add "Follow up manually" + # Create a task with dependencies ask add +cli depends:0,1 "Implement dependent feature" # List tasks ask list +READY limit:5 +# List non-agent tasks +ask no-agent list + # Show alias and UUID for a task ask info 0 +# Show a non-agent task +ask na info 0 + # Start working ask start 0 diff --git a/integrationtests/ask_scope_test.go b/integrationtests/ask_scope_test.go new file mode 100644 index 0000000..ef77757 --- /dev/null +++ b/integrationtests/ask_scope_test.go @@ -0,0 +1,257 @@ +//go:build integration + +package integrationtests + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "codeberg.org/snonux/hexai/internal/askcli" +) + +func scopedAskArgs(scopePrefix string, args ...string) []string { + if strings.TrimSpace(scopePrefix) == "" { + return append([]string(nil), args...) + } + scoped := []string{scopePrefix} + return append(scoped, args...) +} + +func createTaskInScope(ctx context.Context, scopePrefix, desc string) (taskInfo, error) { + stdout, stderr, code := runAsk(ctx, scopedAskArgs(scopePrefix, "add", "+integrationtest", desc)) + if code != 0 { + return taskInfo{}, fmt.Errorf("create task failed (code %d): stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + + id := extractTaskIDFromAddOutput(stdout.String()) + if id == "" { + return taskInfo{}, fmt.Errorf("could not extract task ID from ask add output: %s", stdout.String()) + } + + info, ok := getTaskInfoInScope(ctx, scopePrefix, id) + if !ok { + return taskInfo{}, fmt.Errorf("could not resolve task ID %q after ask %s add", id, scopePrefix) + } + if info.UUID == "" { + return taskInfo{}, fmt.Errorf("ask %s info %q did not return a UUID", scopePrefix, id) + } + return info, nil +} + +func getTaskInfoInScope(ctx context.Context, scopePrefix, selector string) (taskInfo, bool) { + stdout, _, code := runAsk(ctx, scopedAskArgs(scopePrefix, "info", selector)) + if code != 0 { + return taskInfo{}, false + } + return parseTaskInfoText(stdout.String(), selector), true +} + +func exportTaskByUUID(ctx context.Context, uuid string) (askcli.TaskExport, error) { + stdout, stderr, code := runTask(ctx, []string{"uuid:" + uuid, "export"}) + if code != 0 { + return askcli.TaskExport{}, fmt.Errorf("task export failed (code %d): stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + + var tasks []askcli.TaskExport + if err := json.Unmarshal(stdout.Bytes(), &tasks); err != nil { + return askcli.TaskExport{}, fmt.Errorf("parse task export: %w", err) + } + if len(tasks) != 1 { + return askcli.TaskExport{}, fmt.Errorf("expected 1 task, got %d", len(tasks)) + } + return tasks[0], nil +} + +func hasTag(tags []string, want string) bool { + for _, tag := range tags { + if tag == want { + return true + } + } + return false +} + +func hasSelectorLine(output, want string) bool { + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + if strings.TrimSpace(line) == want { + return true + } + } + return false +} + +func TestNoAgentAddOmitsAgentTag(t *testing.T) { + for _, prefix := range []string{"na", "no-agent"} { + t.Run(prefix, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + desc := fmt.Sprintf("integration test non-agent add %s %d", prefix, time.Now().UnixNano()) + info, err := createTaskInScope(ctx, prefix, desc) + if err != nil { + t.Fatalf("failed to create no-agent task: %v", err) + } + defer deleteTask(ctx, info.UUID) + + task, err := exportTaskByUUID(ctx, info.UUID) + if err != nil { + t.Fatalf("failed to export task: %v", err) + } + if task.Description != desc { + t.Fatalf("description = %q, want %q", task.Description, desc) + } + if hasTag(task.Tags, "agent") { + t.Fatalf("tags = %v, task should not have agent tag", task.Tags) + } + if !hasTag(task.Tags, "integrationtest") { + t.Fatalf("tags = %v, task should keep explicit integrationtest tag", task.Tags) + } + if info.ID == "" { + t.Fatal("expected alias ID for no-agent task") + } + }) + } +} + +func TestNoAgentListSeparatesScopedTasks(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + agentDesc := fmt.Sprintf("integration test scoped agent list %d", time.Now().UnixNano()) + noAgentDesc := fmt.Sprintf("integration test scoped no-agent list %d", time.Now().UnixNano()) + + agentUUID, err := createTask(ctx, agentDesc) + if err != nil { + t.Fatalf("failed to create agent task: %v", err) + } + defer deleteTask(ctx, agentUUID) + + noAgentInfo, err := createTaskInScope(ctx, "na", noAgentDesc) + if err != nil { + t.Fatalf("failed to create no-agent task: %v", err) + } + defer deleteTask(ctx, noAgentInfo.UUID) + + stdout, stderr, code := runAsk(ctx, []string{"list"}) + if code != 0 { + t.Fatalf("ask list failed with code %d: stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), agentDesc) { + t.Fatalf("ask list should contain agent task %q: %s", agentDesc, stdout.String()) + } + if strings.Contains(stdout.String(), noAgentDesc) { + t.Fatalf("ask list should not contain no-agent task %q: %s", noAgentDesc, stdout.String()) + } + + for _, prefix := range []string{"na", "no-agent"} { + t.Run(prefix, func(t *testing.T) { + scopedStdout, scopedStderr, scopedCode := runAsk(ctx, []string{prefix, "list"}) + if scopedCode != 0 { + t.Fatalf("ask %s list failed with code %d: stdout=%s stderr=%s", prefix, scopedCode, scopedStdout.String(), scopedStderr.String()) + } + if !strings.Contains(scopedStdout.String(), noAgentDesc) { + t.Fatalf("ask %s list should contain no-agent task %q: %s", prefix, noAgentDesc, scopedStdout.String()) + } + if strings.Contains(scopedStdout.String(), agentDesc) { + t.Fatalf("ask %s list should not contain agent task %q: %s", prefix, agentDesc, scopedStdout.String()) + } + }) + } +} + +func TestNoAgentSelectorCommandsUseScopedTasks(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + desc := fmt.Sprintf("integration test scoped info %d", time.Now().UnixNano()) + info, err := createTaskInScope(ctx, "na", desc) + if err != nil { + t.Fatalf("failed to create no-agent task: %v", err) + } + defer deleteTask(ctx, info.UUID) + + _, stderr, code := runAsk(ctx, []string{"info", info.ID}) + if code == 0 { + t.Fatalf("ask info %s unexpectedly succeeded outside no-agent scope", info.ID) + } + if !strings.Contains(stderr.String(), "current scope") { + t.Fatalf("stderr = %q, want current-scope guidance", stderr.String()) + } + + for _, prefix := range []string{"na", "no-agent"} { + t.Run(prefix, func(t *testing.T) { + stdout, scopedStderr, scopedCode := runAsk(ctx, []string{prefix, "info", info.ID}) + if scopedCode != 0 { + t.Fatalf("ask %s info failed with code %d: stdout=%s stderr=%s", prefix, scopedCode, stdout.String(), scopedStderr.String()) + } + if !strings.Contains(stdout.String(), "UUID: "+info.UUID) { + t.Fatalf("ask %s info output missing UUID %q: %s", prefix, info.UUID, stdout.String()) + } + }) + } + + stdout, stderr, code := runAsk(ctx, []string{"na", "done", info.ID}) + if code != 0 { + t.Fatalf("ask na done failed with code %d: stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + + task, err := exportTaskByUUID(ctx, info.UUID) + if err != nil { + t.Fatalf("failed to export completed no-agent task: %v", err) + } + if task.Status != "completed" { + t.Fatalf("status = %q, want completed", task.Status) + } +} + +func TestNoAgentCompleteUUIDsUsesScopedTasks(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + agentUUID, err := createTask(ctx, fmt.Sprintf("integration test complete uuids agent %d", time.Now().UnixNano())) + if err != nil { + t.Fatalf("failed to create agent task: %v", err) + } + defer deleteTask(ctx, agentUUID) + agentAlias := mustTaskAlias(t, ctx, agentUUID) + + noAgentInfo, err := createTaskInScope(ctx, "na", fmt.Sprintf("integration test complete uuids no-agent %d", time.Now().UnixNano())) + if err != nil { + t.Fatalf("failed to create no-agent task: %v", err) + } + defer deleteTask(ctx, noAgentInfo.UUID) + + defaultStdout, defaultStderr, defaultCode := runAsk(ctx, []string{"complete-uuids"}) + if defaultCode != 0 { + t.Fatalf("ask complete-uuids failed with code %d: stdout=%s stderr=%s", defaultCode, defaultStdout.String(), defaultStderr.String()) + } + if !hasSelectorLine(defaultStdout.String(), agentAlias) || !hasSelectorLine(defaultStdout.String(), agentUUID) { + t.Fatalf("default complete-uuids should contain agent selectors: %s", defaultStdout.String()) + } + if hasSelectorLine(defaultStdout.String(), noAgentInfo.ID) || hasSelectorLine(defaultStdout.String(), noAgentInfo.UUID) { + t.Fatalf("default complete-uuids should not contain no-agent selectors: %s", defaultStdout.String()) + } + + for _, prefix := range []string{"na", "no-agent"} { + t.Run(prefix, func(t *testing.T) { + stdout, stderr, code := runAsk(ctx, []string{prefix, "complete-uuids"}) + if code != 0 { + t.Fatalf("ask %s complete-uuids failed with code %d: stdout=%s stderr=%s", prefix, code, stdout.String(), stderr.String()) + } + if !hasSelectorLine(stdout.String(), noAgentInfo.ID) || !hasSelectorLine(stdout.String(), noAgentInfo.UUID) { + t.Fatalf("ask %s complete-uuids should contain no-agent selectors: %s", prefix, stdout.String()) + } + if hasSelectorLine(stdout.String(), agentAlias) || hasSelectorLine(stdout.String(), agentUUID) { + t.Fatalf("ask %s complete-uuids should not contain agent selectors: %s", prefix, stdout.String()) + } + }) + } +} diff --git a/integrationtests/ask_test.go b/integrationtests/ask_test.go index 528021a..483dc7c 100644 --- a/integrationtests/ask_test.go +++ b/integrationtests/ask_test.go @@ -377,7 +377,7 @@ func TestAddWithDependsModifier(t *testing.T) { t.Fatalf("ask add with depends modifier failed with code %d: stdout=%s stderr=%s", code, stdout.String(), stderr.String()) } - id := strings.TrimSpace(stdout.String()) + id := extractTaskIDFromAddOutput(stdout.String()) info, ok := getTaskInfoFast(ctx, id) if !ok { t.Fatalf("ask info %q failed after add", id) 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", diff --git a/internal/version.go b/internal/version.go index 5b94bc7..627e8c6 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.1" +const Version = "0.27.2" |
