summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-26 10:15:58 +0300
committerPaul Buetow <paul@buetow.org>2026-05-26 10:15:58 +0300
commit0088bf27368265511d6ff3c49309df843c528d42 (patch)
tree321becbc133e59dc2be31ec27fd1a6a69e8d7ca7 /internal
parentc4ffb4b994faa3df1760cd1144a8dafcefeb991b (diff)
feat(askcli): add watch subcommand
Implements `ask watch [subcommand...]` which re-runs a read-only subcommand every 2 seconds and redraws output when it changes, similar to gnu-watch. Safety features: - Restricted to read-only subcommands (list, all, ready, info, urgency, help, dep list) - Recursive watch is blocked - Captures stderr so error messages are visible in the watched display Also adds readOnly field to commandEntry registry for maintainability.
Diffstat (limited to 'internal')
-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
5 files changed, 388 insertions, 1 deletions
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)
}