summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-26 23:10:50 +0200
committerPaul Buetow <paul@buetow.org>2026-03-26 23:10:50 +0200
commitb0392db09b960e70caf73db41cc74c9182733935 (patch)
treeb570c76c311cc02545ec545620ec87f7da19df39
parent5fd23315835d29f3551db2a1d10218deb95cd3de (diff)
release: v0.26.1v0.26.1
-rw-r--r--Magefile.go65
-rw-r--r--README.md4
-rw-r--r--assets/ask.fish132
-rw-r--r--docs/buildandinstall.md2
-rw-r--r--docs/fish-completion.md22
-rw-r--r--internal/askcli/command_fish.go22
-rw-r--r--internal/askcli/completion.go26
-rw-r--r--internal/askcli/completion_test.go40
-rw-r--r--internal/askcli/dispatch.go3
-rw-r--r--internal/askcli/dispatch_test.go46
-rw-r--r--internal/version.go2
11 files changed, 180 insertions, 184 deletions
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:<repo> +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 <uuid> <args...> Modify task fields\n")
io.WriteString(w, " ask denotate <uuid> \"text\" Remove annotation\n")
io.WriteString(w, " ask delete <uuid> 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"