diff options
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | docs/usage.md | 1 | ||||
| -rw-r--r-- | internal/askcli/command_watch.go | 100 | ||||
| -rw-r--r-- | internal/askcli/command_watch_test.go | 269 | ||||
| -rw-r--r-- | internal/askcli/commands_registry.go | 13 | ||||
| -rw-r--r-- | internal/askcli/dispatch.go | 5 | ||||
| -rw-r--r-- | internal/askcli/dispatch_test.go | 2 |
7 files changed, 390 insertions, 2 deletions
@@ -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) } |
