summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--docs/usage.md1
-rw-r--r--internal/askcli/command_watch.go100
-rw-r--r--internal/askcli/command_watch_test.go269
-rw-r--r--internal/askcli/commands_registry.go13
-rw-r--r--internal/askcli/dispatch.go5
-rw-r--r--internal/askcli/dispatch_test.go2
7 files changed, 390 insertions, 2 deletions
diff --git a/README.md b/README.md
index 61e20f0..069cb37 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ It has got improved capabilities for Go code understanding (for example, create
- Never exposes numeric task IDs; human-facing output uses stable alias IDs
- `ask info` hides raw UUIDs unless `HEXAI_DEBUG` is set
- Machine-friendly output with 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 fish`, `ask help`
+ - Subcommands: `ask add`, `ask list`, `ask info`, `ask annotate`, `ask start`, `ask stop`, `ask done`, `ask priority`, `ask tag`, `ask dep`, `ask urgency`, `ask watch`, `ask modify`, `ask denotate`, `ask delete`, `ask fish`, `ask help`
- Fish shell completions: run `ask fish | source` in a session, or use a `conf.d` snippet (see [Fish shell completion](docs/fish-completion.md)); installs do not write files under `fish/completions/`
* 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**
diff --git a/docs/usage.md b/docs/usage.md
index 40e8e82..06ec2f1 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -170,6 +170,7 @@ You can combine the prefixes in either order, for example `ask proj:hexai na lis
| `ask dep rm <id\|uuid> <dep-id\|dep-uuid>` | Remove dependency |
| `ask dep list <id\|uuid>` | List dependencies |
| `ask urgency` | List tasks by urgency |
+| `ask watch [subcommand...]` | Re-run a read-only subcommand every 2s and redraw when output changes; defaults to `ask list` |
| `ask modify <id\|uuid> <args...>` | General-purpose modify |
| `ask denotate <id\|uuid> "text"` | Remove annotation |
| `ask delete <id\|uuid>` | Delete a task |
diff --git a/internal/askcli/command_watch.go b/internal/askcli/command_watch.go
new file mode 100644
index 0000000..33b3bbd
--- /dev/null
+++ b/internal/askcli/command_watch.go
@@ -0,0 +1,100 @@
+package askcli
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "time"
+)
+
+const (
+ watchInterval = 2 * time.Second
+ ansiClearScreen = "\033[2J\033[H"
+)
+
+type watchTicker interface {
+ C() <-chan time.Time
+ Stop()
+}
+
+type realWatchTicker struct {
+ ticker *time.Ticker
+}
+
+func (t realWatchTicker) C() <-chan time.Time {
+ return t.ticker.C
+}
+
+func (t realWatchTicker) Stop() {
+ t.ticker.Stop()
+}
+
+var newWatchTicker = func(interval time.Duration) watchTicker {
+ return realWatchTicker{ticker: time.NewTicker(interval)}
+}
+
+func (d *Dispatcher) handleWatch(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) {
+ watchArgs := args[1:]
+ if len(watchArgs) == 0 {
+ watchArgs = []string{"list"}
+ }
+ if !watchCommandAllowed(watchArgs) {
+ _, _ = io.WriteString(stderr, "error: ask watch supports read-only subcommands: list, all, ready, info, dep list, urgency, help\n")
+ return 1, nil
+ }
+
+ ticker := newWatchTicker(watchInterval)
+ defer ticker.Stop()
+
+ var lastOutput []byte
+ for {
+ output, code, err := d.watchOutput(ctx, watchArgs)
+ if err != nil {
+ return code, err
+ }
+ if !bytes.Equal(output, lastOutput) {
+ _, _ = io.WriteString(stdout, ansiClearScreen)
+ _, _ = stdout.Write(output)
+ lastOutput = bytes.Clone(output)
+ }
+ if code != 0 {
+ return code, nil
+ }
+
+ select {
+ case <-ctx.Done():
+ return 0, nil
+ case <-ticker.C():
+ }
+ }
+}
+
+func watchCommandAllowed(args []string) bool {
+ entry, ok := commandRegistry.get(args[0])
+ if !ok {
+ return false
+ }
+ if entry.readOnly {
+ return true
+ }
+ // dep is not read-only as a whole, but dep list is.
+ if args[0] == "dep" {
+ return len(args) >= 2 && args[1] == "list"
+ }
+ return false
+}
+
+func (d *Dispatcher) watchOutput(ctx context.Context, args []string) ([]byte, int, error) {
+ var stdout, stderr bytes.Buffer
+ code, err := d.dispatchCommand(ctx, append([]string(nil), args...), nil, &stdout, &stderr)
+ if err != nil {
+ return nil, code, fmt.Errorf("watch %s: %w", args[0], err)
+ }
+ // Combine stdout and stderr so warnings or errors emitted by the
+ // watched subcommand are visible in the watched display.
+ out := make([]byte, 0, stdout.Len()+stderr.Len())
+ out = append(out, stdout.Bytes()...)
+ out = append(out, stderr.Bytes()...)
+ return out, code, nil
+}
diff --git a/internal/askcli/command_watch_test.go b/internal/askcli/command_watch_test.go
new file mode 100644
index 0000000..77d0fda
--- /dev/null
+++ b/internal/askcli/command_watch_test.go
@@ -0,0 +1,269 @@
+package askcli
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+)
+
+type fakeWatchTicker struct {
+ ch <-chan time.Time
+ stopped bool
+}
+
+func (t *fakeWatchTicker) C() <-chan time.Time {
+ return t.ch
+}
+
+func (t *fakeWatchTicker) Stop() {
+ t.stopped = true
+}
+
+func TestHandleWatch_ForwardsNonZeroCode(t *testing.T) {
+ ticks := make(chan time.Time)
+ oldTicker := newWatchTicker
+ newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
+ t.Cleanup(func() { newWatchTicker = oldTicker })
+
+ ctx := context.Background()
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ return 1, nil
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, err := d.Dispatch(ctx, []string{"watch", "urgency"}, nil, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("watch returned error: %v", err)
+ }
+ if code != 1 {
+ t.Fatalf("watch code = %d, want 1", code)
+ }
+ if stdout.Len() != 0 {
+ t.Fatalf("expected no output, got %q", stdout.String())
+ }
+}
+
+func TestHandleWatch_ForwardsInnerError(t *testing.T) {
+ ctx := context.Background()
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ return 1, errors.New("boom")
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, err := d.Dispatch(ctx, []string{"watch", "urgency"}, nil, &stdout, &stderr)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "boom") {
+ t.Fatalf("error = %v, want boom", err)
+ }
+ if code != 1 {
+ t.Fatalf("watch code = %d, want 1", code)
+ }
+}
+
+func TestHandleWatch_DrawsStderrOnNonZero(t *testing.T) {
+ ticks := make(chan time.Time)
+ oldTicker := newWatchTicker
+ newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
+ t.Cleanup(func() { newWatchTicker = oldTicker })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ callCount := 0
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ callCount++
+ if len(args) >= 2 && args[0] == "started" && args[1] == "export" {
+ if callCount == 1 {
+ cancel()
+ return 1, nil
+ }
+ }
+ _, _ = io.WriteString(stdout, "[]")
+ return 0, nil
+ }})
+
+ var out bytes.Buffer
+ code, err := d.Dispatch(ctx, []string{"watch", "info"}, nil, &out, &bytes.Buffer{})
+ if err != nil {
+ t.Fatalf("watch returned error: %v", err)
+ }
+ if code != 1 {
+ t.Fatalf("watch code = %d, want 1", code)
+ }
+ if !strings.Contains(out.String(), ansiClearScreen) {
+ t.Fatalf("stdout missing clear screen: %q", out.String())
+ }
+ if !strings.Contains(out.String(), "no started task found") {
+ t.Fatalf("stdout missing expected error message: %q", out.String())
+ }
+}
+
+func TestHandleWatch_DefaultsToListAndRedrawsOnChange(t *testing.T) {
+ dir := t.TempDir()
+ oldRoot := taskAliasCacheRoot
+ oldNow := nowTaskAliasCache
+ taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
+ nowTaskAliasCache = func() time.Time { return time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) }
+ t.Cleanup(func() {
+ taskAliasCacheRoot = oldRoot
+ nowTaskAliasCache = oldNow
+ })
+
+ ticks := make(chan time.Time, 1)
+ ticks <- time.Now()
+ fakeTicker := &fakeWatchTicker{ch: ticks}
+ oldTicker := newWatchTicker
+ newWatchTicker = func(interval time.Duration) watchTicker {
+ if interval != watchInterval {
+ t.Fatalf("watch interval = %s, want %s", interval, watchInterval)
+ }
+ return fakeTicker
+ }
+ t.Cleanup(func() { newWatchTicker = oldTicker })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ 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...))
+ if len(calls) == 1 {
+ _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"Task 1","status":"pending","priority":"M","tags":["agent"],"urgency":3,"depends":[]}]`)
+ return 0, nil
+ }
+ _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"Task 1 updated","status":"pending","priority":"M","tags":["agent"],"urgency":3,"depends":[]}]`)
+ cancel()
+ return 0, nil
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, err := d.Dispatch(ctx, []string{"watch"}, nil, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("watch returned error: %v", err)
+ }
+ if code != 0 {
+ t.Fatalf("watch code = %d, want 0: stderr=%s", code, stderr.String())
+ }
+ wantCalls := [][]string{{"status:pending", "export"}, {"status:pending", "export"}}
+ if !reflect.DeepEqual(calls, wantCalls) {
+ t.Fatalf("runner calls = %#v, want %#v", calls, wantCalls)
+ }
+ if got := strings.Count(stdout.String(), "\033[2J\033[H"); got != 2 {
+ t.Fatalf("clear count = %d, want 2; output=%q", got, stdout.String())
+ }
+ if !strings.Contains(stdout.String(), "Task 1 updated") {
+ t.Fatalf("stdout missing updated task: %q", stdout.String())
+ }
+ if !fakeTicker.stopped {
+ t.Fatal("watch ticker was not stopped")
+ }
+}
+
+func TestHandleWatch_DoesNotRedrawUnchangedOutput(t *testing.T) {
+ dir := t.TempDir()
+ oldRoot := taskAliasCacheRoot
+ oldNow := nowTaskAliasCache
+ taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
+ nowTaskAliasCache = func() time.Time { return time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) }
+ t.Cleanup(func() {
+ taskAliasCacheRoot = oldRoot
+ nowTaskAliasCache = oldNow
+ })
+
+ ticks := make(chan time.Time, 1)
+ ticks <- time.Now()
+ oldTicker := newWatchTicker
+ newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
+ t.Cleanup(func() { newWatchTicker = oldTicker })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ runCount := 0
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ runCount++
+ _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"Task 1","status":"pending","priority":"M","tags":["agent"],"urgency":3,"depends":[]}]`)
+ if runCount == 2 {
+ cancel()
+ }
+ return 0, nil
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, err := d.Dispatch(ctx, []string{"watch", "list"}, nil, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("watch returned error: %v", err)
+ }
+ if code != 0 {
+ t.Fatalf("watch code = %d, want 0: stderr=%s", code, stderr.String())
+ }
+ if got := strings.Count(stdout.String(), "\033[2J\033[H"); got != 1 {
+ t.Fatalf("clear count = %d, want 1; output=%q", got, stdout.String())
+ }
+}
+
+func TestHandleWatch_ForwardsSubcommandArgs(t *testing.T) {
+ ticks := make(chan time.Time)
+ oldTicker := newWatchTicker
+ newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
+ t.Cleanup(func() { newWatchTicker = oldTicker })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ var gotArgs []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...)
+ cancel()
+ _, _ = io.WriteString(stdout, `[]`)
+ return 0, nil
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, err := d.Dispatch(ctx, []string{"watch", "ready", "limit:2"}, nil, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("watch returned error: %v", err)
+ }
+ if code != 0 {
+ t.Fatalf("watch code = %d, want 0: stderr=%s", code, stderr.String())
+ }
+ if want := []string{"+READY", "limit:2", "export"}; !reflect.DeepEqual(gotArgs, want) {
+ t.Fatalf("runner args = %v, want %v", gotArgs, want)
+ }
+}
+
+func TestHandleWatch_RejectsUnsafeSubcommands(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ }{
+ {name: "recursive watch", args: []string{"watch", "watch", "list"}},
+ {name: "implicit add typo", args: []string{"watch", "typo"}},
+ {name: "write command", args: []string{"watch", "start", "0"}},
+ {name: "write dependency command", args: []string{"watch", "dep", "add", "0", "1"}},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ t.Fatalf("runner should not be called for unsafe watch command")
+ return 0, nil
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, err := d.Dispatch(context.Background(), tc.args, nil, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("watch returned error: %v", err)
+ }
+ if code != 1 {
+ t.Fatalf("watch code = %d, want 1", code)
+ }
+ if !strings.Contains(stderr.String(), "read-only subcommands") {
+ t.Fatalf("stderr = %q, want read-only subcommand error", stderr.String())
+ }
+ if stdout.Len() != 0 {
+ t.Fatalf("stdout = %q, want empty", stdout.String())
+ }
+ })
+ }
+}
diff --git a/internal/askcli/commands_registry.go b/internal/askcli/commands_registry.go
index 1c2e60d..f999ca3 100644
--- a/internal/askcli/commands_registry.go
+++ b/internal/askcli/commands_registry.go
@@ -21,6 +21,7 @@ type commandEntry struct {
handler commandHandler
includeInCompletion bool
singleSelector bool
+ readOnly bool
}
type commandTable struct {
@@ -82,18 +83,21 @@ var commandRegistry = newCommandTable([]commandEntry{
description: "List active tasks",
handler: wrapSimpleCommand((*Dispatcher).handleList),
includeInCompletion: true,
+ readOnly: true,
},
{
name: "all",
description: "List all tasks",
handler: wrapSimpleCommand((*Dispatcher).handleAll),
includeInCompletion: true,
+ readOnly: true,
},
{
name: "ready",
description: "List READY tasks",
handler: wrapSimpleCommand((*Dispatcher).handleReady),
includeInCompletion: true,
+ readOnly: true,
},
{
name: "info",
@@ -101,6 +105,7 @@ var commandRegistry = newCommandTable([]commandEntry{
handler: wrapSimpleCommand((*Dispatcher).handleInfo),
includeInCompletion: true,
singleSelector: true,
+ readOnly: true,
},
{
name: "annotate",
@@ -176,11 +181,18 @@ var commandRegistry = newCommandTable([]commandEntry{
description: "List tasks sorted by urgency",
handler: wrapSimpleCommand((*Dispatcher).handleUrgency),
includeInCompletion: true,
+ readOnly: true,
},
})
func init() {
commandRegistry.add(commandEntry{
+ name: "watch",
+ description: "Repeatedly run a subcommand and redraw when output changes",
+ handler: wrapSimpleCommand((*Dispatcher).handleWatch),
+ includeInCompletion: true,
+ })
+ commandRegistry.add(commandEntry{
name: "fish",
description: "Emit Fish shell completion script",
handler: wrapSimpleCommand((*Dispatcher).handleFish),
@@ -189,6 +201,7 @@ func init() {
commandRegistry.add(commandEntry{
name: "help",
description: "Show help",
+ readOnly: true,
handler: func(d *Dispatcher, ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
_ = ctx
_ = args
diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go
index cd2aae7..2cdaa93 100644
--- a/internal/askcli/dispatch.go
+++ b/internal/askcli/dispatch.go
@@ -54,6 +54,10 @@ func (d *Dispatcher) Dispatch(ctx context.Context, args []string, stdin io.Reade
if len(args) == 0 {
args = []string{"list"}
}
+ return d.dispatchCommand(ctx, args, stdin, stdout, stderr)
+}
+
+func (d *Dispatcher) dispatchCommand(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
subcommand := args[0]
entry, ok := commandRegistry.get(subcommand)
if !ok {
@@ -90,6 +94,7 @@ func (d *Dispatcher) help(w io.Writer) (int, error) {
_, _ = io.WriteString(w, " ask dep rm <id|uuid> <dep> Remove dependency\n")
_, _ = io.WriteString(w, " ask dep list <id|uuid> List dependencies\n")
_, _ = io.WriteString(w, " ask urgency List tasks sorted by urgency\n")
+ _, _ = io.WriteString(w, " ask watch [subcommand...] Re-run a read-only subcommand every 2s and redraw on changes\n")
_, _ = io.WriteString(w, " ask modify <id|uuid> <args...> Modify task fields\n")
_, _ = io.WriteString(w, " ask denotate <id|uuid> \"text\" Remove annotation\n")
_, _ = io.WriteString(w, " ask delete <id|uuid> Delete a task\n")
diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go
index 20062b0..986b5e1 100644
--- a/internal/askcli/dispatch_test.go
+++ b/internal/askcli/dispatch_test.go
@@ -203,7 +203,7 @@ 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", "fish"} {
+ for _, sub := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "watch", "modify", "denotate", "delete", "fish"} {
if !strings.Contains(output, "ask "+sub) {
t.Errorf("help missing subcommand: ask %s", sub)
}