diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-26 10:15:58 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-26 10:15:58 +0300 |
| commit | 0088bf27368265511d6ff3c49309df843c528d42 (patch) | |
| tree | 321becbc133e59dc2be31ec27fd1a6a69e8d7ca7 /internal/askcli/command_watch.go | |
| parent | c4ffb4b994faa3df1760cd1144a8dafcefeb991b (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/askcli/command_watch.go')
| -rw-r--r-- | internal/askcli/command_watch.go | 100 |
1 files changed, 100 insertions, 0 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 +} |
