Skip to content

Commit 090575a

Browse files
committed
feat: self-correcting flag/command errors, global --fields, query window notes
Callers frequently guess flag and verb names by analogy with a sibling command. The errors now carry the candidates, so the next call can be right without a separate --help round trip: - Unknown flag: lists the closest flag names, the command's own flags and the global flags (root FlagErrorFunc, inherited by curated and generated commands). Other flag errors pass through unchanged. - Unknown command: suggests siblings, then single-record verb synonyms (get/info/infos/detail), then any leaf in the whole tree whose path contains the token on hyphen-token boundaries (`silence` -> `channel silence-rule-list`, `monit-datasource` -> `monit datasource-list`), and lists the level's commands. The check now runs before cobra dispatch, so `<group> <typo> --help` errors instead of printing the group's help and exiting 0. - `--version` is accepted and prints the `version` line. --fields is now a global output flag: json/toon output of any command is projected to the named top-level fields (each row of a list, or the keys of a single record), before the 16 KiB bound is applied. A name matching no key is reported on stderr with the keys that exist; acknowledgements and raw bodies print unchanged with a note; non-projectable shapes are an error. Commands with their own --fields keep it. Windowed queries announce on stderr the window they sent, in the local timezone, with its span and its end relative to now (`note: window ..(1h, ends in 7h)`), which makes a wrong default or a timezone slip visible. This covers the curated windowed verbs, monit-query's execution window, and generated --start-time/--end-time pairs. `incident list` with only open --progress states and `alert list --active` default to 30d instead of 24h when --since is not given, since open records are often older than a day. skilldoc: the dump now carries the root's flags; the validator checks the flags of a bare `fduty --flag` example against them and takes global flags from the dump instead of a hand-kept list.
1 parent 12d2fbf commit 090575a

34 files changed

Lines changed: 1444 additions & 134 deletions

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,9 @@ flashduty config set base_url URL # Override the API endpoint
124124
| `--output-format` | `table` (default), `json`, or `toon` |
125125
| `--json` | Alias for `--output-format json` |
126126
| `--no-trunc` | Do not truncate long fields in table output |
127+
| `--fields` | Keep only these comma-separated top-level fields in `json`/`toon` output: each row of a list, or the keys of a single record |
127128
| `--base-url` | Override the API base URL |
129+
| `--version` | Print the version (same as `flashduty version`) |
128130

129131
## Output formats
130132

@@ -134,6 +136,10 @@ flashduty config set base_url URL # Override the API endpoint
134136

135137
Every structured list page is capped at 16 KiB. A page that had to be reduced is reported on stderr, and list envelopes also carry `"truncated": true` in the payload. With `"emitted_rows": N`, only the first N rows were returned: re-request with a smaller `--limit` until the rows you hold reach `total`. Without it, every row is present but long values were clipped: narrow `--fields`.
136138

139+
Commands that query a time range print the window they actually sent on stderr, in the local timezone, e.g. `note: window 2026-10-08T14:00:00+08:00..2026-10-09T14:00:00+08:00 (1d, ended now)`. stdout stays pure `json`/`toon`. `incident list --progress Triggered,Processing` and `alert list --active` default to the last 30 days when `--since` is not given, since open records can be older than a day.
140+
141+
An unknown flag or command error lists the closest valid names and the valid choices at that level.
142+
137143
## Updating
138144

139145
```bash

‎README_zh.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,9 @@ flashduty config set base_url URL # 修改 API 地址
124124
| `--output-format` | `table`(默认)、`json` 或 `toon` |
125125
| `--json` | 等同 `--output-format json` |
126126
| `--no-trunc` | 表格输出不截断长字段 |
127+
| `--fields` | `json`/`toon` 输出只保留这些顶层字段(逗号分隔):列表按行保留,单条记录保留对应 key |
127128
| `--base-url` | 覆盖 API 地址 |
129+
| `--version` | 打印版本(同 `flashduty version`) |
128130

129131
## 输出格式
130132

@@ -134,6 +136,10 @@ flashduty config set base_url URL # 修改 API 地址
134136

135137
每一页结构化列表最大 16 KiB。被裁剪的页会在 stderr 上提示,列表的返回体里也会带 `"truncated": true`。同时带 `"emitted_rows": N` 表示只返回了前 N 行:用更小的 `--limit` 重新请求,直到拿到的行数达到 `total`。不带 `emitted_rows` 表示行都在,但长字段被截断:用 `--fields` 缩小字段范围。
136138

139+
按时间范围查询的命令会在 stderr 上打印实际发出的时间窗口(本地时区),例如 `note: window 2026-10-08T14:00:00+08:00..2026-10-09T14:00:00+08:00 (1d, ended now)`;stdout 仍是纯 `json`/`toon`。`incident list --progress Triggered,Processing` 和 `alert list --active` 在没给 `--since` 时默认查最近 30 天,因为未关闭的记录可能早于一天。
140+
141+
未知 flag 或命令的报错会列出最接近的有效名称,以及该层级所有可用的选项。
142+
137143
## 升级
138144

139145
```bash

‎internal/cli/alert.go‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"github.com/spf13/cobra"
1111

1212
"github.com/flashcatcloud/flashduty-cli/internal/output"
13-
"github.com/flashcatcloud/flashduty-cli/internal/timeutil"
1413
)
1514

1615
func newAlertCmd() *cobra.Command {
@@ -38,13 +37,13 @@ func newAlertListCmd() *cobra.Command {
3837
return fmt.Errorf("--active and --recovered are mutually exclusive")
3938
}
4039

41-
startTime, err := timeutil.Parse(since)
42-
if err != nil {
43-
return fmt.Errorf("invalid --since: %w", err)
40+
why := ""
41+
if active && !cmd.Flags().Changed("since") {
42+
since, why = openStateSince, "default --since "+openStateSince+" because of --active"
4443
}
45-
endTime, err := timeutil.Parse(until)
44+
startTime, endTime, err := parseWindow(cmd, since, until, why)
4645
if err != nil {
47-
return fmt.Errorf("invalid --until: %w", err)
46+
return err
4847
}
4948

5049
req := &flashduty.AlertListRequest{
@@ -126,7 +125,7 @@ func newAlertListCmd() *cobra.Command {
126125
cmd.Flags().StringVar(&alertKeys, "alert-keys", "", "Comma-separated alert deduplication keys")
127126
cmd.Flags().BoolVar(&asc, "asc", false, "Sort oldest first by start time (default newest first)")
128127
cmd.Flags().BoolVar(&byUpdatedAt, "by-updated-at", false, "Apply --since/--until to the last-updated time instead of the start time")
129-
cmd.Flags().StringVar(&since, "since", "24h", "Start time")
128+
cmd.Flags().StringVar(&since, "since", "24h", "Start time. Defaults to 30d with --active")
130129
cmd.Flags().StringVar(&until, "until", "now", "End time")
131130
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)")
132131
cmd.Flags().IntVar(&page, "page", 1, "Page number")

‎internal/cli/alert_event.go‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"github.com/spf13/cobra"
99

1010
"github.com/flashcatcloud/flashduty-cli/internal/output"
11-
"github.com/flashcatcloud/flashduty-cli/internal/timeutil"
1211
)
1312

1413
func newAlertEventCmd() *cobra.Command {
@@ -32,13 +31,9 @@ func newAlertEventListCmd() *cobra.Command {
3231
return fmt.Errorf("--fields must name at least one field")
3332
}
3433

35-
startTime, err := timeutil.Parse(since)
34+
startTime, endTime, err := parseWindow(cmd, since, until, "")
3635
if err != nil {
37-
return fmt.Errorf("invalid --since: %w", err)
38-
}
39-
endTime, err := timeutil.Parse(until)
40-
if err != nil {
41-
return fmt.Errorf("invalid --until: %w", err)
36+
return err
4237
}
4338

4439
input := &flashduty.AlertEventGlobalListRequest{

‎internal/cli/audit.go‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"github.com/spf13/cobra"
88

99
"github.com/flashcatcloud/flashduty-cli/internal/output"
10-
"github.com/flashcatcloud/flashduty-cli/internal/timeutil"
1110
)
1211

1312
func newAuditCmd() *cobra.Command {
@@ -28,13 +27,9 @@ func newAuditSearchCmd() *cobra.Command {
2827
Long: curatedLong("Search audit logs within a time window, optionally filtered by person, operation type, write/read, risk level and request ID. The --since/--until window must be < 90 days; --limit max is 99.", "AuditLogs", "Search"),
2928
RunE: func(cmd *cobra.Command, args []string) error {
3029
return runCommand(cmd, args, func(ctx *RunContext) error {
31-
startTime, err := timeutil.Parse(since)
30+
startTime, endTime, err := parseWindow(cmd, since, until, "")
3231
if err != nil {
33-
return fmt.Errorf("invalid --since: %w", err)
34-
}
35-
endTime, err := timeutil.Parse(until)
36-
if err != nil {
37-
return fmt.Errorf("invalid --until: %w", err)
32+
return err
3833
}
3934

4035
input := &flashduty.AuditSearchRequest{

‎internal/cli/change.go‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"github.com/spf13/cobra"
99

1010
"github.com/flashcatcloud/flashduty-cli/internal/output"
11-
"github.com/flashcatcloud/flashduty-cli/internal/timeutil"
1211
)
1312

1413
func newChangeCmd() *cobra.Command {
@@ -31,13 +30,9 @@ func newChangeListCmd() *cobra.Command {
3130
Long: curatedLong("List changes recorded in the change feed. Time window must be < 31 days; --limit max is 100.", "Changes", "List"),
3231
RunE: func(cmd *cobra.Command, args []string) error {
3332
return runCommand(cmd, args, func(ctx *RunContext) error {
34-
startTime, err := timeutil.Parse(since)
33+
startTime, endTime, err := parseWindow(cmd, since, until, "")
3534
if err != nil {
36-
return fmt.Errorf("invalid --since: %w", err)
37-
}
38-
endTime, err := timeutil.Parse(until)
39-
if err != nil {
40-
return fmt.Errorf("invalid --until: %w", err)
35+
return err
4136
}
4237

4338
// The legacy SDK clamped non-positive paging to sane defaults

‎internal/cli/command.go‎

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package cli
33
import (
44
"fmt"
55
"io"
6-
"strings"
76

87
"github.com/flashcatcloud/go-flashduty"
98
"github.com/spf13/cobra"
@@ -41,7 +40,7 @@ func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) erro
4140
Cmd: cmd,
4241
Args: args,
4342
Writer: cmd.OutOrStdout(),
44-
Printer: newPrinter(cmd.OutOrStdout()),
43+
Printer: newPrinter(cmd),
4544
Format: currentOutputFormat(),
4645
}
4746
return fn(ctx)
@@ -81,6 +80,7 @@ func (ctx *RunContext) PrintTotal(items any, cols []output.Column, total int) er
8180

8281
// WriteResult prints a success message as plain text or JSON.
8382
func (ctx *RunContext) WriteResult(message string) {
83+
noteFieldsNotApplied(ctx.Cmd)
8484
writeResult(ctx.Writer, message)
8585
}
8686

@@ -89,6 +89,7 @@ func (ctx *RunContext) WriteResult(message string) {
8989
// shell redirection (`> file.csv`) captures the bytes verbatim instead of the
9090
// canned "OK: POST ..." acknowledgment.
9191
func (ctx *RunContext) WriteRaw(body []byte) error {
92+
noteFieldsNotApplied(ctx.Cmd)
9293
_, err := ctx.Writer.Write(body)
9394
return err
9495
}
@@ -101,6 +102,7 @@ func (ctx *RunContext) WriteResultJSON(data any, humanMessage string) error {
101102
_, _ = fmt.Fprintln(ctx.Writer, humanMessage)
102103
return nil
103104
}
105+
noteFieldsNotApplied(ctx.Cmd)
104106
out, err := marshalStructured(data)
105107
if err != nil {
106108
return fmt.Errorf("failed to marshal output: %w", err)
@@ -127,26 +129,13 @@ func (ctx *RunContext) WriteResultJSON(data any, humanMessage string) error {
127129
// Pairing this validator with a real RunE (below) makes the group Runnable,
128130
// so cobra actually calls ValidateArgs with the leftover token and this
129131
// function gets a chance to reject it instead of it being silently dropped.
132+
// Cobra still answers --help before ValidateArgs, which is why Execute also
133+
// runs rejectUnknownSubcommand ahead of dispatch.
130134
func groupUnknownSubcommand(cmd *cobra.Command, args []string) error {
131135
if len(args) == 0 {
132136
return nil
133137
}
134-
// Mirrors cobra's own default (see (*Command).findSuggestions): a group
135-
// built via newGroupCmd never sets this itself, so the zero value would
136-
// otherwise disable the Levenshtein half of SuggestionsFor.
137-
if cmd.SuggestionsMinimumDistance <= 0 {
138-
cmd.SuggestionsMinimumDistance = 2
139-
}
140-
suggestion := ""
141-
if names := cmd.SuggestionsFor(args[0]); len(names) > 0 {
142-
var b strings.Builder
143-
b.WriteString("\n\nDid you mean this?\n")
144-
for _, name := range names {
145-
fmt.Fprintf(&b, "\t%s\n", name)
146-
}
147-
suggestion = b.String()
148-
}
149-
return fmt.Errorf("unknown command %q for %q%s", args[0], cmd.CommandPath(), suggestion)
138+
return unknownCommandError(cmd, args[0])
150139
}
151140

152141
// newGroupCmd creates a command-group node: Use/Short (with Long/Example

‎internal/cli/command_test.go‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,7 @@ func execCommand(args ...string) (string, error) {
7373
buf := new(bytes.Buffer)
7474
rootCmd.SetOut(buf)
7575
rootCmd.SetErr(buf)
76-
rootCmd.SetArgs(args)
77-
78-
err := rootCmd.Execute()
76+
err := executeArgs(args)
7977

8078
// Reset the persistent flags cobra parsed so subsequent calls within the
8179
// same test process do not carry stale values.
@@ -97,9 +95,7 @@ func execCommandSplit(args ...string) (stdout, stderr string, err error) {
9795
errBuf := new(bytes.Buffer)
9896
rootCmd.SetOut(outBuf)
9997
rootCmd.SetErr(errBuf)
100-
rootCmd.SetArgs(args)
101-
102-
err = rootCmd.Execute()
98+
err = executeArgs(args)
10399

104100
rootCmd.SetArgs(nil)
105101
rootCmd.SetOut(nil)

‎internal/cli/fieldproject_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) {
316316
stub := newGFStub(t)
317317
stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1}
318318

319-
out, err := execCommand("incident", "list", "--fields", "incident_id,title", "--output-format", "json")
319+
out, _, err := execCommandSplit("incident", "list", "--fields", "incident_id,title", "--output-format", "json")
320320
if err != nil {
321321
t.Fatalf("execCommand: %v", err)
322322
}
@@ -466,7 +466,7 @@ func TestFieldsProjectionJSON(t *testing.T) {
466466
stub.data = map[string]any{"items": []any{tc.data}, "total": 1}
467467

468468
args := append(append([]string(nil), tc.cmd...), "--fields", strings.Join(tc.fields, ","), "--output-format", "json")
469-
out, err := execCommand(args...)
469+
out, _, err := execCommandSplit(args...)
470470
if err != nil {
471471
t.Fatalf("execCommand: %v", err)
472472
}

‎internal/cli/gen_support.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"reflect"
1010
"strconv"
1111
"strings"
12+
"time"
1213

1314
"github.com/spf13/cobra"
1415

@@ -349,6 +350,21 @@ func printGenericResult(ctx *RunContext, data any) error {
349350
return err
350351
}
351352
if ctx.Structured() {
353+
// Size the payload as it will print: project first, then bound and
354+
// print through the unprojecting printer so --fields runs once.
355+
if fields := requestedFields(); len(fields) > 0 {
356+
proj, err := projectOutput(ctx.Cmd, data, fields)
357+
if err != nil {
358+
return err
359+
}
360+
noteFieldsMissing(ctx.Cmd, proj)
361+
data = proj.out
362+
if pp, ok := ctx.Printer.(projectingPrinter); ok {
363+
projected := *ctx
364+
projected.Printer = pp.inner
365+
ctx = &projected
366+
}
367+
}
352368
return printBoundedGenericResult(ctx, data)
353369
}
354370
return renderGenericTable(ctx, data)
@@ -570,6 +586,16 @@ func genParseTimeFlagAlias(cmd *cobra.Command, name, alias, raw, aliasRaw string
570586
return genParseTimeFlag(cmd, name, raw)
571587
}
572588

589+
// genNoteWindow announces a generated verb's resolved --start-time/--end-time
590+
// window (see noteWindow), when both flags were given. A side left to --data
591+
// or to the server's default was not resolved by the CLI, so nothing is
592+
// announced rather than half a window.
593+
func genNoteWindow(cmd *cobra.Command, start, end int64, both bool) {
594+
if both {
595+
noteWindow(cmd.ErrOrStderr(), time.Unix(start, 0), time.Unix(end, 0), "")
596+
}
597+
}
598+
573599
// genGroup finds an existing subcommand named `name` under parent, or creates a
574600
// group command with that name. This lets generated commands attach to the same
575601
// group a curated command already owns (partial-coverage services) and lets a

0 commit comments

Comments
 (0)