From b0392db09b960e70caf73db41cc74c9182733935 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 26 Mar 2026 23:10:50 +0200 Subject: release: v0.26.1 --- Magefile.go | 65 +++++++++++++----- README.md | 4 +- assets/ask.fish | 132 ------------------------------------- docs/buildandinstall.md | 2 +- docs/fish-completion.md | 22 +++++-- internal/askcli/command_fish.go | 22 +++++++ internal/askcli/completion.go | 26 ++++++-- internal/askcli/completion_test.go | 40 ++++++----- internal/askcli/dispatch.go | 3 + internal/askcli/dispatch_test.go | 46 ++++++++++++- internal/version.go | 2 +- 11 files changed, 180 insertions(+), 184 deletions(-) delete mode 100644 assets/ask.fish create mode 100644 internal/askcli/command_fish.go diff --git a/Magefile.go b/Magefile.go index 0d52d30..65748fd 100644 --- a/Magefile.go +++ b/Magefile.go @@ -5,6 +5,7 @@ package main import ( "fmt" + "io" "os" "path/filepath" "regexp" @@ -118,22 +119,19 @@ func Install() error { if err := os.MkdirAll(bin, 0o755); err != nil { return err } - if err := sh.RunV("cp", "-v", "./ask", bin+"/"); err != nil { - return err - } - if err := sh.RunV("cp", "-v", "./hexai-lsp-server", bin+"/"); err != nil { - return err - } - if err := sh.RunV("cp", "-v", "./hexai", bin+"/"); err != nil { - return err - } - if err := sh.RunV("cp", "-v", "./hexai-tmux-action", bin+"/"); err != nil { - return err - } - if err := sh.RunV("cp", "-v", "./hexai-tmux-edit", bin+"/"); err != nil { - return err + for _, name := range []string{ + "ask", + "hexai-lsp-server", + "hexai", + "hexai-tmux-action", + "hexai-tmux-edit", + "hexai-mcp-server", + } { + if err := atomicInstallBinary(filepath.Join(".", name), bin); err != nil { + return err + } } - return sh.RunV("cp", "-v", "./hexai-mcp-server", bin+"/") + return nil } // RunTmuxAction runs the hexai-tmux-action TUI via go run (reads stdin). @@ -219,6 +217,43 @@ func totalCoveragePercent(profile string) (float64, bool) { return f, true } +func atomicInstallBinary(src, dstDir string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + info, err := in.Stat() + if err != nil { + return err + } + tmp, err := os.CreateTemp(dstDir, filepath.Base(src)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := io.Copy(tmp, in); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(info.Mode() & os.ModePerm); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + dst := filepath.Join(dstDir, filepath.Base(src)) + if err := os.Rename(tmpPath, dst); err != nil { + return err + } + fmt.Printf("installed %s -> %s\n", src, dst) + return nil +} + // Test runs the test suite. func Test() error { if err := sh.RunV("go", "clean", "-testcache"); err != nil { diff --git a/README.md b/README.md index acecf0e..1252804 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ It has got improved capabilities for Go code understanding (for example, create - Auto-scopes to `project: +agent` (derived from git repo root) - Never exposes numeric task IDs — uses UUIDs only - Machine-friendly output: UUID-only tables, suppressed decorative text - - Subcommands: `ask add`, `ask list`, `ask info`, `ask annotate`, `ask start`, `ask stop`, `ask done`, `ask priority`, `ask tag`, `ask dep`, `ask urgency`, `ask modify`, `ask denotate`, `ask delete`, `ask help` - - Fish completion asset: [`assets/ask.fish`](assets/ask.fish) + - Subcommands: `ask add`, `ask list`, `ask info`, `ask annotate`, `ask start`, `ask stop`, `ask done`, `ask priority`, `ask tag`, `ask dep`, `ask urgency`, `ask modify`, `ask denotate`, `ask delete`, `ask fish`, `ask help` + - Fish completion generator: `ask fish` * Parallel completions and CLI responses from multiple providers/models for side-by-side comparison * **MCP server for prompt/runbook management** (`hexai-mcp-server`) - **⚠️ DEPRECATED/EXPERIMENTAL** - Create, update, delete, and retrieve prompts via MCP protocol diff --git a/assets/ask.fish b/assets/ask.fish deleted file mode 100644 index 46cb87d..0000000 --- a/assets/ask.fish +++ /dev/null @@ -1,132 +0,0 @@ -# Fish completion for ask. -# Install as ~/.config/fish/completions/ask.fish or $XDG_CONFIG_HOME/fish/completions/ask.fish. - -function __ask_needs_root_completion - set -l tokens (commandline -opc) - if test (count $tokens) -le 1 - return 0 - end - for token in $tokens[2..-1] - if not string match -qr '^-' -- $token - return 1 - end - end - return 0 -end - -function __ask_in_dep_context - set -l tokens (commandline -opc) - if test (count $tokens) -lt 2 - return 1 - end - set -l seen_dep 0 - for token in $tokens[2..-1] - if string match -qr '^-' -- $token - continue - end - if test $seen_dep -eq 0 - if test $token = dep - set seen_dep 1 - else - return 1 - end - else - return 1 - end - end - test $seen_dep -eq 1 -end - -function __ask_in_uuid_context - set -l tokens (commandline -opc) - set -l positional - for token in $tokens[2..-1] - if string match -qr '^-' -- $token - continue - end - set -a positional $token - end - if test (count $positional) -eq 0 - return 1 - end - if test (count $positional) -gt 2 - return 1 - end - switch $positional[1] - case info annotate start stop done priority tag modify denotate delete - return 0 - case '*' - return 1 - end - return 1 -end - -function __ask_in_dep_uuid_context - set -l tokens (commandline -opc) - set -l positional - for token in $tokens[2..-1] - if string match -qr '^-' -- $token - continue - end - set -a positional $token - end - if test (count $positional) -lt 2 - return 1 - end - if test $positional[1] != dep - return 1 - end - switch $positional[2] - case add rm - if test (count $positional) -le 4 - return 0 - end - case list - if test (count $positional) -le 3 - return 0 - end - case '*' - return 1 - end - return 1 -end - -function __ask_task_uuids - set -l now (date +%s) - if set -q __ask_task_uuid_cache_until; and test $__ask_task_uuid_cache_until -ge $now - printf '%s\n' $__ask_task_uuid_cache - return 0 - end - set -l uuids (command ask complete-uuids 2>/dev/null) - if test $status -ne 0 - return 1 - end - set -g __ask_task_uuid_cache $uuids - set -g __ask_task_uuid_cache_until (math $now + 2) - printf '%s\n' $uuids -end - -complete -c ask -f -complete -c ask -s j -l json -d 'Emit JSON output' -complete -c ask -n '__ask_needs_root_completion' -a 'add' -d 'Create a new task' -complete -c ask -n '__ask_needs_root_completion' -a 'list' -d 'List active tasks' -complete -c ask -n '__ask_needs_root_completion' -a 'all' -d 'List all tasks' -complete -c ask -n '__ask_needs_root_completion' -a 'ready' -d 'List READY tasks' -complete -c ask -n '__ask_needs_root_completion' -a 'info' -d 'Show task details' -complete -c ask -n '__ask_needs_root_completion' -a 'annotate' -d 'Add an annotation' -complete -c ask -n '__ask_needs_root_completion' -a 'start' -d 'Start a task' -complete -c ask -n '__ask_needs_root_completion' -a 'stop' -d 'Stop a task' -complete -c ask -n '__ask_needs_root_completion' -a 'done' -d 'Mark a task complete' -complete -c ask -n '__ask_needs_root_completion' -a 'priority' -d 'Set priority' -complete -c ask -n '__ask_needs_root_completion' -a 'tag' -d 'Add or remove a tag' -complete -c ask -n '__ask_needs_root_completion' -a 'dep' -d 'Manage dependencies' -complete -c ask -n '__ask_needs_root_completion' -a 'urgency' -d 'List tasks sorted by urgency' -complete -c ask -n '__ask_needs_root_completion' -a 'modify' -d 'Modify task fields' -complete -c ask -n '__ask_needs_root_completion' -a 'denotate' -d 'Remove an annotation' -complete -c ask -n '__ask_needs_root_completion' -a 'delete' -d 'Delete a task' -complete -c ask -n '__ask_needs_root_completion' -a 'help' -d 'Show help' -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' -complete -c ask -n '__ask_in_uuid_context' -a '(__ask_task_uuids)' -d 'Task UUID' -complete -c ask -n '__ask_in_dep_uuid_context' -a '(__ask_task_uuids)' -d 'Task UUID' diff --git a/docs/buildandinstall.md b/docs/buildandinstall.md index 7e63ada..a707faa 100644 --- a/docs/buildandinstall.md +++ b/docs/buildandinstall.md @@ -11,7 +11,7 @@ Hexai uses Mage for developer tasks. Install Mage, then run targets like build, - In restricted sandboxes/CI (no sockets), skip network-based tests: - `HEXAI_TEST_SKIP_NET=1 go test ./... -cover` - Install binaries to `GOPATH/bin`: `mage install` -- Install Fish completions: `install -Dm644 assets/ask.fish ~/.config/fish/completions/ask.fish` +- Load Fish completions in the current shell: `~/go/bin/ask fish | source` Note: `mage lint` uses `golangci-lint`. Install via `mage devinstall` if needed. diff --git a/docs/fish-completion.md b/docs/fish-completion.md index 10966e9..195d29a 100644 --- a/docs/fish-completion.md +++ b/docs/fish-completion.md @@ -1,21 +1,31 @@ # Fish Completion -Hexai ships a Fish completion file for the `ask` task-management CLI at [`assets/ask.fish`](../assets/ask.fish). +The `ask` task-management CLI embeds its Fish completion script in the binary and prints it with `ask fish`. It completes the top-level `ask` subcommands and the nested `ask dep` operations. It also completes task UUIDs for UUID-taking commands by reading the current project from `ask all --json` and filtering out completed and deleted tasks. The script preserves the global `--json` flag. -Install it into Fish's completion directory: +Load it into the current Fish session: ```sh -install -Dm644 assets/ask.fish ~/.config/fish/completions/ask.fish +ask fish | source ``` -If you use a custom XDG config directory, copy it to: +If you installed with `mage install` and `~/go/bin` is not on your `PATH` yet, use: ```sh -$XDG_CONFIG_HOME/fish/completions/ask.fish +~/go/bin/ask fish | source ``` -The completion file is static and ships with the repository, so it does not require a build step. +To enable it automatically for new Fish sessions, add this to your Fish config or a file in `~/.config/fish/conf.d/`: + +```fish +set -l ask_bin ~/go/bin/ask + +if test -x $ask_bin + $ask_bin fish | source +end +``` + +No external `ask.fish` file is required. diff --git a/internal/askcli/command_fish.go b/internal/askcli/command_fish.go new file mode 100644 index 0000000..89a8457 --- /dev/null +++ b/internal/askcli/command_fish.go @@ -0,0 +1,22 @@ +package askcli + +import ( + "fmt" + "io" + "os" +) + +func (d Dispatcher) handleFish(args []string, stdout, stderr io.Writer) (int, error) { + if len(args) != 1 { + fmt.Fprintln(stderr, "usage: ask fish") + return 1, nil + } + binaryPath, err := os.Executable() + if err != nil { + binaryPath = "ask" + } + if _, err := io.WriteString(stdout, FishCompletionFor(binaryPath)); err != nil { + return 1, err + } + return 0, nil +} diff --git a/internal/askcli/completion.go b/internal/askcli/completion.go index 6033415..5684e33 100644 --- a/internal/askcli/completion.go +++ b/internal/askcli/completion.go @@ -26,6 +26,7 @@ var askRootCompletionItems = []fishCompletionItem{ {name: "modify", description: "Modify task fields"}, {name: "denotate", description: "Remove an annotation"}, {name: "delete", description: "Delete a task"}, + {name: "fish", description: "Emit Fish shell completion script"}, {name: "help", description: "Show help"}, } @@ -49,10 +50,14 @@ var askUUIDCompletionItems = []fishCompletionItem{ } func FishCompletion() string { + return FishCompletionFor("ask") +} + +func FishCompletionFor(binaryPath string) string { var b strings.Builder writeFishPreamble(&b) writeFishContextFunctions(&b) - writeFishTaskUUIDFunction(&b) + writeFishTaskUUIDFunction(&b, binaryPath) b.WriteString("complete -c ask -f\n") b.WriteString("complete -c ask -s j -l json -d 'Emit JSON output'\n") for _, item := range askRootCompletionItems { @@ -68,8 +73,7 @@ func FishCompletion() string { func writeFishPreamble(b *strings.Builder) { b.WriteString("# Fish completion for ask.\n") - b.WriteString("# Install as ~/.config/fish/completions/ask.fish or") - b.WriteString(" $XDG_CONFIG_HOME/fish/completions/ask.fish.\n\n") + b.WriteString("# Source with: ask fish | source\n\n") } func writeFishContextFunctions(b *strings.Builder) { @@ -177,14 +181,17 @@ func writeFishDepUUIDContextFunction(b *strings.Builder) { b.WriteString("end\n\n") } -func writeFishTaskUUIDFunction(b *strings.Builder) { +func writeFishTaskUUIDFunction(b *strings.Builder, binaryPath string) { b.WriteString("function __ask_task_uuids\n") + b.WriteString(" set -l ask_bin ") + b.WriteString(quoteFishString(binaryPath)) + b.WriteString("\n") b.WriteString(" set -l now (date +%s)\n") b.WriteString(" if set -q __ask_task_uuid_cache_until; and test $__ask_task_uuid_cache_until -ge $now\n") b.WriteString(" printf '%s\\n' $__ask_task_uuid_cache\n") b.WriteString(" return 0\n") b.WriteString(" end\n") - b.WriteString(" set -l uuids (command ask complete-uuids 2>/dev/null)\n") + b.WriteString(" set -l uuids (command $ask_bin complete-uuids 2>/dev/null)\n") b.WriteString(" if test $status -ne 0\n") b.WriteString(" return 1\n") b.WriteString(" end\n") @@ -211,3 +218,12 @@ func writeFishUUIDCompletionLine(b *strings.Builder, condition, description stri b.WriteString(strings.ReplaceAll(description, "'", "\\'")) b.WriteString("'\n") } + +func quoteFishString(value string) string { + replacer := strings.NewReplacer( + "\\", "\\\\", + "\"", "\\\"", + "$", "\\$", + ) + return `"` + replacer.Replace(value) + `"` +} diff --git a/internal/askcli/completion_test.go b/internal/askcli/completion_test.go index 5cab89b..ad25cbd 100644 --- a/internal/askcli/completion_test.go +++ b/internal/askcli/completion_test.go @@ -1,42 +1,25 @@ package askcli import ( - "os" - "path/filepath" - "runtime" "strings" "testing" ) -func TestFishCompletion_MatchesAsset(t *testing.T) { - _, file, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("runtime.Caller failed") - } - assetPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "assets", "ask.fish")) - want, err := os.ReadFile(assetPath) - if err != nil { - t.Fatalf("read asset: %v", err) - } - got := FishCompletion() - if got != string(want) { - t.Fatalf("fish completion asset mismatch\n--- got ---\n%s\n--- want ---\n%s", got, string(want)) - } -} - func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { script := FishCompletion() - for _, name := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "help"} { + 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) } } for _, line := range []string{ + "# Source with: ask fish | source", "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_task_uuids", - "set -l uuids (command ask complete-uuids 2>/dev/null)", + `set -l ask_bin "ask"`, + "set -l uuids (command $ask_bin complete-uuids 2>/dev/null)", "complete -c ask -n '__ask_in_uuid_context' -a '(__ask_task_uuids)' -d 'Task UUID'", "complete -c ask -n '__ask_in_dep_uuid_context' -a '(__ask_task_uuids)' -d 'Task UUID'", } { @@ -47,4 +30,19 @@ func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { if strings.Contains(script, "ask export") { t.Fatalf("script should not advertise non-existent export command") } + if strings.Contains(script, "assets/ask.fish") { + t.Fatalf("script should not reference a static asset") + } +} + +func TestFishCompletionFor_EmbedsBinaryPath(t *testing.T) { + script := FishCompletionFor(`/tmp/ask "$HOME"`) + for _, line := range []string{ + `set -l ask_bin "/tmp/ask \"\$HOME\""`, + "set -l uuids (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 bc4715c..e828989 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -74,6 +74,8 @@ func (d Dispatcher) Dispatch(ctx context.Context, args []string, stdin io.Reader return d.handleDenotate(ctx, args, stdout, stderr) case "delete": return d.handleDelete(ctx, args, stdin, stdout, stderr) + case "fish": + return d.handleFish(args, stdout, stderr) case "help": return d.help(stdout) case "complete-uuids": @@ -104,6 +106,7 @@ func (d Dispatcher) help(w io.Writer) (int, error) { io.WriteString(w, " ask modify Modify task fields\n") io.WriteString(w, " ask denotate \"text\" Remove annotation\n") io.WriteString(w, " ask delete Delete task\n") + io.WriteString(w, " ask fish Emit Fish shell completion script\n") return 0, nil } diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go index 9a1bf43..040f305 100644 --- a/internal/askcli/dispatch_test.go +++ b/internal/askcli/dispatch_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "io" + "os" "strings" "testing" ) @@ -28,6 +29,9 @@ func TestDispatcher_Help(t *testing.T) { if !strings.Contains(output, "ask all") { t.Fatalf("help missing all subcommand: %s", output) } + if !strings.Contains(output, "ask fish") { + t.Fatalf("help missing fish subcommand: %s", output) + } } func TestDispatcher_UnknownSubcommand(t *testing.T) { @@ -72,13 +76,53 @@ func TestDispatcher_LongHelp(t *testing.T) { var stdout bytes.Buffer d.Dispatch(context.Background(), []string{"help"}, nil, &stdout, io.Discard) output := stdout.String() - for _, sub := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete"} { + for _, sub := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "fish"} { if !strings.Contains(output, "ask "+sub) { t.Errorf("help missing subcommand: ask %s", sub) } } } +func TestDispatcher_FishSubcommand(t *testing.T) { + d := NewDispatcher(nil) + var stdout, stderr bytes.Buffer + code, err := d.Dispatch(context.Background(), []string{"fish"}, nil, &stdout, &stderr) + if err != nil { + t.Fatalf("fish returned error: %v", err) + } + if code != 0 { + t.Fatalf("fish code = %d, want 0", code) + } + exe, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable: %v", err) + } + if got := stdout.String(); got != FishCompletionFor(exe) { + t.Fatalf("fish output mismatch\n--- got ---\n%s\n--- want ---\n%s", got, FishCompletionFor(exe)) + } + if stderr.Len() != 0 { + t.Fatalf("fish wrote unexpected stderr: %q", stderr.String()) + } +} + +func TestDispatcher_FishSubcommandRejectsExtraArgs(t *testing.T) { + d := NewDispatcher(nil) + var stdout, stderr bytes.Buffer + code, err := d.Dispatch(context.Background(), []string{"fish", "extra"}, nil, &stdout, &stderr) + if err != nil { + t.Fatalf("fish extra args returned error: %v", err) + } + if code != 1 { + t.Fatalf("fish extra args code = %d, want 1", code) + } + if stdout.Len() != 0 { + t.Fatalf("fish extra args wrote unexpected stdout: %q", stdout.String()) + } + if got := stderr.String(); !strings.Contains(got, "usage: ask fish") { + t.Fatalf("fish extra args stderr = %q, want usage", got) + } +} + func TestDispatcher_AllSubcommandsReachExecutor(t *testing.T) { subcommands := []string{} subcommandArgs := map[string][]string{ diff --git a/internal/version.go b/internal/version.go index 0056bf1..68ad39a 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.26.0" +const Version = "0.26.1" -- cgit v1.2.3