diff --git a/README.md b/README.md index a809fef..0ab5bb6 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,9 @@ flashduty config set base_url URL # Override the API endpoint | `--output-format` | `table` (default), `json`, or `toon` | | `--json` | Alias for `--output-format json` | | `--no-trunc` | Do not truncate long fields in table output | +| `--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 | | `--base-url` | Override the API base URL | +| `--version` | Print the version (same as `flashduty version`) | ## Output formats @@ -134,6 +136,10 @@ flashduty config set base_url URL # Override the API endpoint 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`. +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. + +An unknown flag or command error lists the closest valid names and the valid choices at that level. + ## Updating ```bash diff --git a/README_zh.md b/README_zh.md index 59e2b34..40e7359 100644 --- a/README_zh.md +++ b/README_zh.md @@ -124,7 +124,9 @@ flashduty config set base_url URL # 修改 API 地址 | `--output-format` | `table`(默认)、`json` 或 `toon` | | `--json` | 等同 `--output-format json` | | `--no-trunc` | 表格输出不截断长字段 | +| `--fields` | `json`/`toon` 输出只保留这些顶层字段(逗号分隔):列表按行保留,单条记录保留对应 key | | `--base-url` | 覆盖 API 地址 | +| `--version` | 打印版本(同 `flashduty version`) | ## 输出格式 @@ -134,6 +136,10 @@ flashduty config set base_url URL # 修改 API 地址 每一页结构化列表最大 16 KiB。被裁剪的页会在 stderr 上提示,列表的返回体里也会带 `"truncated": true`。同时带 `"emitted_rows": N` 表示只返回了前 N 行:用更小的 `--limit` 重新请求,直到拿到的行数达到 `total`。不带 `emitted_rows` 表示行都在,但长字段被截断:用 `--fields` 缩小字段范围。 +按时间范围查询的命令会在 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 天,因为未关闭的记录可能早于一天。 + +未知 flag 或命令的报错会列出最接近的有效名称,以及该层级所有可用的选项。 + ## 升级 ```bash diff --git a/internal/cli/alert.go b/internal/cli/alert.go index fba4669..5bac32f 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -10,7 +10,6 @@ import ( "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" - "github.com/flashcatcloud/flashduty-cli/internal/timeutil" ) func newAlertCmd() *cobra.Command { @@ -38,13 +37,13 @@ func newAlertListCmd() *cobra.Command { return fmt.Errorf("--active and --recovered are mutually exclusive") } - startTime, err := timeutil.Parse(since) - if err != nil { - return fmt.Errorf("invalid --since: %w", err) + why := "" + if active && !cmd.Flags().Changed("since") { + since, why = openStateSince, "default --since "+openStateSince+" because of --active" } - endTime, err := timeutil.Parse(until) + startTime, endTime, err := parseWindow(cmd, since, until, why) if err != nil { - return fmt.Errorf("invalid --until: %w", err) + return err } req := &flashduty.AlertListRequest{ @@ -126,7 +125,7 @@ func newAlertListCmd() *cobra.Command { cmd.Flags().StringVar(&alertKeys, "alert-keys", "", "Comma-separated alert deduplication keys") cmd.Flags().BoolVar(&asc, "asc", false, "Sort oldest first by start time (default newest first)") cmd.Flags().BoolVar(&byUpdatedAt, "by-updated-at", false, "Apply --since/--until to the last-updated time instead of the start time") - cmd.Flags().StringVar(&since, "since", "24h", "Start time") + cmd.Flags().StringVar(&since, "since", "24h", "Start time. Defaults to 30d with --active") cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") cmd.Flags().IntVar(&page, "page", 1, "Page number") diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index 968f8b9..0721fa5 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -8,7 +8,6 @@ import ( "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" - "github.com/flashcatcloud/flashduty-cli/internal/timeutil" ) func newAlertEventCmd() *cobra.Command { @@ -32,13 +31,9 @@ func newAlertEventListCmd() *cobra.Command { return fmt.Errorf("--fields must name at least one field") } - startTime, err := timeutil.Parse(since) + startTime, endTime, err := parseWindow(cmd, since, until, "") if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) - if err != nil { - return fmt.Errorf("invalid --until: %w", err) + return err } input := &flashduty.AlertEventGlobalListRequest{ diff --git a/internal/cli/audit.go b/internal/cli/audit.go index 6845508..ddc78e9 100644 --- a/internal/cli/audit.go +++ b/internal/cli/audit.go @@ -7,7 +7,6 @@ import ( "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" - "github.com/flashcatcloud/flashduty-cli/internal/timeutil" ) func newAuditCmd() *cobra.Command { @@ -28,13 +27,9 @@ func newAuditSearchCmd() *cobra.Command { 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"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { - startTime, err := timeutil.Parse(since) + startTime, endTime, err := parseWindow(cmd, since, until, "") if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) - if err != nil { - return fmt.Errorf("invalid --until: %w", err) + return err } input := &flashduty.AuditSearchRequest{ diff --git a/internal/cli/change.go b/internal/cli/change.go index 37ee1c0..28252f9 100644 --- a/internal/cli/change.go +++ b/internal/cli/change.go @@ -8,7 +8,6 @@ import ( "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" - "github.com/flashcatcloud/flashduty-cli/internal/timeutil" ) func newChangeCmd() *cobra.Command { @@ -31,13 +30,9 @@ func newChangeListCmd() *cobra.Command { Long: curatedLong("List changes recorded in the change feed. Time window must be < 31 days; --limit max is 100.", "Changes", "List"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { - startTime, err := timeutil.Parse(since) + startTime, endTime, err := parseWindow(cmd, since, until, "") if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) - if err != nil { - return fmt.Errorf("invalid --until: %w", err) + return err } // The legacy SDK clamped non-positive paging to sane defaults diff --git a/internal/cli/command.go b/internal/cli/command.go index 5ef5166..238e3b0 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -3,7 +3,6 @@ package cli import ( "fmt" "io" - "strings" "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" @@ -41,7 +40,7 @@ func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) erro Cmd: cmd, Args: args, Writer: cmd.OutOrStdout(), - Printer: newPrinter(cmd.OutOrStdout()), + Printer: newPrinter(cmd), Format: currentOutputFormat(), } return fn(ctx) @@ -127,26 +126,13 @@ func (ctx *RunContext) WriteResultJSON(data any, humanMessage string) error { // Pairing this validator with a real RunE (below) makes the group Runnable, // so cobra actually calls ValidateArgs with the leftover token and this // function gets a chance to reject it instead of it being silently dropped. +// Cobra still answers --help before ValidateArgs, which is why Execute also +// runs rejectUnknownSubcommand ahead of dispatch. func groupUnknownSubcommand(cmd *cobra.Command, args []string) error { if len(args) == 0 { return nil } - // Mirrors cobra's own default (see (*Command).findSuggestions): a group - // built via newGroupCmd never sets this itself, so the zero value would - // otherwise disable the Levenshtein half of SuggestionsFor. - if cmd.SuggestionsMinimumDistance <= 0 { - cmd.SuggestionsMinimumDistance = 2 - } - suggestion := "" - if names := cmd.SuggestionsFor(args[0]); len(names) > 0 { - var b strings.Builder - b.WriteString("\n\nDid you mean this?\n") - for _, name := range names { - fmt.Fprintf(&b, "\t%s\n", name) - } - suggestion = b.String() - } - return fmt.Errorf("unknown command %q for %q%s", args[0], cmd.CommandPath(), suggestion) + return unknownCommandError(cmd, args[0]) } // newGroupCmd creates a command-group node: Use/Short (with Long/Example diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index e366006..184016c 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -73,9 +73,7 @@ func execCommand(args ...string) (string, error) { buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) - rootCmd.SetArgs(args) - - err := rootCmd.Execute() + err := executeArgs(args) // Reset the persistent flags cobra parsed so subsequent calls within the // same test process do not carry stale values. @@ -97,9 +95,7 @@ func execCommandSplit(args ...string) (stdout, stderr string, err error) { errBuf := new(bytes.Buffer) rootCmd.SetOut(outBuf) rootCmd.SetErr(errBuf) - rootCmd.SetArgs(args) - - err = rootCmd.Execute() + err = executeArgs(args) rootCmd.SetArgs(nil) rootCmd.SetOut(nil) diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index dc696bb..3607ea0 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -316,7 +316,7 @@ func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) { stub := newGFStub(t) stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1} - out, err := execCommand("incident", "list", "--fields", "incident_id,title", "--output-format", "json") + out, _, err := execCommandSplit("incident", "list", "--fields", "incident_id,title", "--output-format", "json") if err != nil { t.Fatalf("execCommand: %v", err) } @@ -466,7 +466,7 @@ func TestFieldsProjectionJSON(t *testing.T) { stub.data = map[string]any{"items": []any{tc.data}, "total": 1} args := append(append([]string(nil), tc.cmd...), "--fields", strings.Join(tc.fields, ","), "--output-format", "json") - out, err := execCommand(args...) + out, _, err := execCommandSplit(args...) if err != nil { t.Fatalf("execCommand: %v", err) } diff --git a/internal/cli/gen_support.go b/internal/cli/gen_support.go index 91e1e59..834e9f9 100644 --- a/internal/cli/gen_support.go +++ b/internal/cli/gen_support.go @@ -9,6 +9,7 @@ import ( "reflect" "strconv" "strings" + "time" "github.com/spf13/cobra" @@ -349,6 +350,22 @@ func printGenericResult(ctx *RunContext, data any) error { return err } if ctx.Structured() { + // Size the payload as it will print: project first, then bound and + // print through the unprojecting printer so --fields runs once. + if fields := requestedFields(); len(fields) > 0 { + proj, err := projectOutput(ctx.Cmd, data, fields) + if err != nil { + return err + } + noteFieldsMissing(ctx.Cmd, proj) + fieldsApplied = true + data = proj.out + if pp, ok := ctx.Printer.(projectingPrinter); ok { + projected := *ctx + projected.Printer = pp.inner + ctx = &projected + } + } return printBoundedGenericResult(ctx, data) } return renderGenericTable(ctx, data) @@ -570,6 +587,16 @@ func genParseTimeFlagAlias(cmd *cobra.Command, name, alias, raw, aliasRaw string return genParseTimeFlag(cmd, name, raw) } +// genNoteWindow announces a generated verb's resolved --start-time/--end-time +// window (see noteWindow), when both flags were given. A side left to --data +// or to the server's default was not resolved by the CLI, so nothing is +// announced rather than half a window. +func genNoteWindow(cmd *cobra.Command, start, end int64, both bool) { + if both { + noteWindow(cmd.ErrOrStderr(), time.Unix(start, 0), time.Unix(end, 0), "") + } +} + // genGroup finds an existing subcommand named `name` under parent, or creates a // group command with that name. This lets generated commands attach to the same // group a curated command already owns (partial-coverage services) and lets a diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 3ba4498..ffb2065 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -18,7 +18,6 @@ import ( "golang.org/x/term" "github.com/flashcatcloud/flashduty-cli/internal/output" - "github.com/flashcatcloud/flashduty-cli/internal/timeutil" ) func newIncidentCmd() *cobra.Command { @@ -86,13 +85,13 @@ func newIncidentListCmd() *cobra.Command { Long: curatedLong("List incidents matching the given filters. The --since/--until window must be < 31 days; --limit max is 100. In json/toon mode, rows default to the compact fields incident_id,num,title,incident_severity,progress,start_time,channel_id,detail_url; pass --fields to choose a different projection.\n\nSee also: flashduty insight for aggregated metrics (MTTA, MTTR, noise reduction), flashduty insight incident-list for metric-rich filtered incident rows, and flashduty insight incident-export for CSV incident exports.", "Incidents", "List"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { - startTime, err := timeutil.Parse(since) - if err != nil { - return fmt.Errorf("invalid --since: %w", err) + why := "" + if !cmd.Flags().Changed("since") && onlyOpenProgress(progress) { + since, why = openStateSince, "default --since "+openStateSince+" because --progress lists only open states" } - endTime, err := timeutil.Parse(until) + startTime, endTime, err := parseWindow(cmd, since, until, why) if err != nil { - return fmt.Errorf("invalid --until: %w", err) + return err } req := &flashduty.ListIncidentsRequest{ @@ -210,7 +209,7 @@ func newIncidentListCmd() *cobra.Command { cmd.Flags().BoolVar(&isMyTeam, "is-my-team", false, "Only incidents in channels owned by your teams") cmd.Flags().BoolVar(&isRare, "is-rare", false, "Only outlier (rare) incidents") cmd.Flags().BoolVar(&isSnoozed, "is-snoozed", false, "Only snoozed incidents") - cmd.Flags().StringVar(&since, "since", "24h", "Start time (duration, date, datetime, or unix timestamp; --since→--until window must be < 31 days)") + cmd.Flags().StringVar(&since, "since", "24h", "Start time (duration, date, datetime, or unix timestamp; --since→--until window must be < 31 days). Defaults to 30d when --progress lists only open states (Triggered, Processing)") cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") cmd.Flags().IntVar(&page, "page", 1, "Page number") diff --git a/internal/cli/insight.go b/internal/cli/insight.go index 97a3c99..155034f 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -10,7 +10,6 @@ import ( "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" - "github.com/flashcatcloud/flashduty-cli/internal/timeutil" ) func newInsightCmd() *cobra.Command { @@ -38,13 +37,9 @@ func newInsightTopAlertsCmd() *cobra.Command { Long: curatedLong("Query the top-K noisiest alert sources grouped by a label dimension over a time window.", "Analytics", "TopkAlertsByLabel"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { - startTime, err := timeutil.Parse(since) + startTime, endTime, err := parseWindow(cmd, since, until, "") if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) - if err != nil { - return fmt.Errorf("invalid --until: %w", err) + return err } result, _, err := ctx.Client.Analytics.TopkAlertsByLabel(cmdContext(ctx.Cmd), &flashduty.InsightTopkAlertByLabelRequest{ @@ -94,13 +89,9 @@ func newInsightIncidentsCmd() *cobra.Command { Long: curatedLong("List incidents with per-incident performance metrics (MTTA, MTTR, notifications) over a time window. In json/toon mode, rows default to the compact fields incident_id,title,severity,channel_name,seconds_to_ack,seconds_to_close,notifications; pass --fields to choose a different projection.", "Analytics", "IncidentList"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { - startTime, err := timeutil.Parse(since) + startTime, endTime, err := parseWindow(cmd, since, until, "") if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) - if err != nil { - return fmt.Errorf("invalid --until: %w", err) + return err } req := &flashduty.InsightIncidentListRequest{ diff --git a/internal/cli/monit_query.go b/internal/cli/monit_query.go index 4b240ba..40900c5 100644 --- a/internal/cli/monit_query.go +++ b/internal/cli/monit_query.go @@ -51,6 +51,7 @@ Query tools are '.query' where '' is one of 'prometheus', 'mysql', ' if cmd.Flags().Changed("account-id") { req.AccountID = uint64(accountID) } + noteToolWindow(cmd.ErrOrStderr(), params) out, _, err := ctx.Client.DataSources.ToolsInvoke(cmdContext(ctx.Cmd), req) if err != nil { return err diff --git a/internal/cli/oncall.go b/internal/cli/oncall.go index fbcc7e2..1bba0ba 100644 --- a/internal/cli/oncall.go +++ b/internal/cli/oncall.go @@ -9,7 +9,6 @@ import ( "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" - "github.com/flashcatcloud/flashduty-cli/internal/timeutil" ) func newOncallCmd() *cobra.Command { @@ -36,13 +35,9 @@ func newOncallWhoCmd() *cobra.Command { Long: curatedLong("Show who is currently on call across schedules within a time window, optionally filtered by team or schedule name. The table output already resolves person_ids to display names; when you have raw person_ids elsewhere, batch-resolve them with 'flashduty person infos ...' (NOT by paginating 'flashduty member list' — person_id and member_id are different id namespaces).", "Schedules", "List"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { - startTime, err := timeutil.Parse(since) + startTime, endTime, err := parseWindow(cmd, since, until, "") if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) - if err != nil { - return fmt.Errorf("invalid --until: %w", err) + return err } req := &flashduty.ScheduleListRequest{ @@ -111,13 +106,9 @@ func newOncallScheduleListCmd() *cobra.Command { Long: curatedLong("List on-call schedules within a time window, optionally filtered by team or schedule name.", "Schedules", "List"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { - startTime, err := timeutil.Parse(since) - if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) + startTime, endTime, err := parseWindow(cmd, since, until, "") if err != nil { - return fmt.Errorf("invalid --until: %w", err) + return err } req := &flashduty.ScheduleListRequest{ @@ -190,13 +181,9 @@ func newOncallScheduleGetCmd() *cobra.Command { return fmt.Errorf("invalid schedule_id %q: %w", ctx.Args[0], err) } - startTime, err := timeutil.Parse(since) - if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) + startTime, endTime, err := parseWindow(cmd, since, until, "") if err != nil { - return fmt.Errorf("invalid --until: %w", err) + return err } s, _, err := ctx.Client.Schedules.Info(cmdContext(ctx.Cmd), &flashduty.ScheduleInfoRequest{ diff --git a/internal/cli/outputfields.go b/internal/cli/outputfields.go new file mode 100644 index 0000000..c6efe8d --- /dev/null +++ b/internal/cli/outputfields.go @@ -0,0 +1,162 @@ +package cli + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/flashcatcloud/flashduty-cli/internal/output" +) + +// The global --fields flag projects structured (json/toon) output to the named +// top-level fields, on every command, at the one place structured records are +// printed (RunContext.Printer). A command that declares its own --fields +// (incident list, alert-event list, … — those carry default projections and +// size bounds wired to it, or give the flag another meaning entirely, like +// monit rule-update-fields) shadows the global flag, so flagFields stays empty +// for it and this projection never runs on top of the command's own. + +// projection is the result of projectOutput: the projected value, the +// requested fields that matched no key, and every key the unprojected rows +// carried (for the note that names what does exist). +type projection struct { + out any + missing []string + keys []string +} + +// projectOutput reduces data to fields: each row of a top-level array, each +// row of a list envelope (rows under items/docs/list next to scalar paging +// keys, which are kept — see listEnvelopeKey), or the keys of a single record. +// A field some rows carry is emitted on every row (null where absent, so rows +// stay uniform); a field no row carries is left out and reported in missing — +// an omitempty key can legitimately be absent from a whole page, so that is +// not an error. Output of any other shape cannot be projected and is an error. +// +// It works on the decoded JSON form rather than on SDK structs (as +// projectFields does for the curated verbs that declare --fields), because it +// serves every command, generated ones included, whose results reach it as +// arbitrary SDK types. +func projectOutput(cmd *cobra.Command, data any, fields []string) (projection, error) { + generic, err := genericStructured(data) + if err != nil { + return projection{}, err + } + switch v := generic.(type) { + case []any: + rows, ok := objectRows(v) + if !ok { + return projection{}, fieldsShapeError(cmd, "a list of non-object values") + } + return projectRows(rows, fields), nil + case map[string]any: + if key, ok := listEnvelopeKey(v); ok { + if rows, ok := objectRows(v[key].([]any)); ok { + p := projectRows(rows, fields) + v[key] = p.out + p.out = v + return p, nil + } + } + p := projectRows([]map[string]any{v}, fields) + p.out = p.out.([]any)[0] + return p, nil + case nil: + return projection{}, nil + default: + return projection{}, fieldsShapeError(cmd, fmt.Sprintf("a single %T value", v)) + } +} + +func projectRows(rows []map[string]any, fields []string) projection { + seen := map[string]bool{} + for _, row := range rows { + for k := range row { + seen[k] = true + } + } + var p projection + for k := range seen { + p.keys = append(p.keys, k) + } + sort.Strings(p.keys) + var kept []string + for _, f := range fields { + switch { + case seen[f]: + kept = append(kept, f) + case len(rows) > 0: // an empty page proves nothing about a name + p.missing = append(p.missing, f) + } + } + out := make([]any, len(rows)) + for i, row := range rows { + r := make(map[string]any, len(kept)) + for _, f := range kept { + r[f] = row[f] + } + out[i] = r + } + p.out = out + return p +} + +func fieldsShapeError(cmd *cobra.Command, shape string) error { + return fmt.Errorf("--fields keeps named fields of an object or of each row in a list, but %q printed %s; drop --fields", cmd.CommandPath(), shape) +} + +// noteFieldsMissing names the requested fields that matched no key, with the +// keys the output does carry, so a typo is told apart from an absent value. +func noteFieldsMissing(cmd *cobra.Command, p projection) { + if len(p.missing) > 0 { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "note: --fields %s matched no key in the output (keys present: %s)\n", + strings.Join(p.missing, ","), strings.Join(p.keys, ", ")) + } +} + +// fieldsApplied records that the global --fields projected something during +// the current run; the root resets it before each command runs. +var fieldsApplied bool + +// noteFieldsNotApplied runs after every command (root PersistentPostRun) and +// says so when --fields was given but projected nothing: table output, an +// acknowledgement, a raw (file/CSV) body, or a command that prints its own +// text (version, config show). One hook covers every output path, so no +// command can ignore the flag silently. +func noteFieldsNotApplied(cmd *cobra.Command) { + if flagFields != "" && !fieldsApplied { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "note: --fields does not apply to the output of %q; printed unchanged\n", cmd.CommandPath()) + } +} + +// requestedFields is the global --fields list when it applies: set, and the +// output is structured. +func requestedFields() []string { + if !currentOutputFormat().Structured() { + return nil + } + return parseStringSlice(flagFields) +} + +// projectingPrinter applies the global --fields to every structured record a +// command prints. +type projectingPrinter struct { + inner output.Printer + cmd *cobra.Command +} + +func (p projectingPrinter) Print(data any, columns []output.Column) error { + fields := requestedFields() + if len(fields) == 0 { + return p.inner.Print(data, columns) + } + proj, err := projectOutput(p.cmd, data, fields) + if err != nil { + return err + } + noteFieldsMissing(p.cmd, proj) + fieldsApplied = true + return p.inner.Print(proj.out, nil) +} diff --git a/internal/cli/outputfields_test.go b/internal/cli/outputfields_test.go new file mode 100644 index 0000000..5dd9e59 --- /dev/null +++ b/internal/cli/outputfields_test.go @@ -0,0 +1,220 @@ +package cli + +import ( + "encoding/json" + "strings" + "testing" +) + +func decodeJSON(t *testing.T, s string) any { + t.Helper() + var v any + if err := json.Unmarshal([]byte(s), &v); err != nil { + t.Fatalf("stdout is not pure JSON: %v\n%s", err, s) + } + return v +} + +func keysOf(m map[string]any) []string { + var out []string + for k := range m { + out = append(out, k) + } + return out +} + +// TestGlobalFieldsGeneratedList: the global --fields projects every row of a +// generated list envelope and keeps the envelope's own keys. +func TestGlobalFieldsGeneratedList(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{ + map[string]any{"member_id": 100, "member_name": "Alice", "email": "a@example.com"}, + map[string]any{"member_id": 200, "member_name": "Bob"}, + }, "total": 2} + + stdout, stderr, err := execCommandSplit("member", "list", "--fields", "member_id,email", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + env, ok := decodeJSON(t, stdout).(map[string]any) + if !ok || env["total"] != float64(2) { + t.Fatalf("envelope keys lost: %s", stdout) + } + items := env["items"].([]any) + for _, it := range items { + row := it.(map[string]any) + if len(row) != 2 || row["member_name"] != nil { + t.Errorf("row not projected to member_id,email: %v", row) + } + } + if _, ok := items[1].(map[string]any)["email"]; !ok { + t.Errorf("every row should carry every projected key: %v", items[1]) + } + if stderr != "" { + t.Errorf("no note expected, got %q", stderr) + } +} + +// TestGlobalFieldsCuratedDetail: a curated single-record verb that had no +// --fields of its own now projects its record. +func TestGlobalFieldsCuratedDetail(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = alertRow() + + stdout, _, err := execCommandSplit("alert", "get", "alert-1", "--fields", "alert_id,title", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + rec := decodeJSON(t, stdout).(map[string]any) + if len(rec) != 2 || rec["alert_id"] == nil || rec["title"] == nil { + t.Fatalf("want exactly alert_id,title, got keys %v", keysOf(rec)) + } +} + +// TestGlobalFieldsUnmatchedNamesNoted: a name matching no key is reported on +// stderr with the keys that exist; stdout stays the projection of the rest. +func TestGlobalFieldsUnmatchedNamesNoted(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = alertRow() + + stdout, stderr, err := execCommandSplit("alert", "get", "alert-1", "--fields", "alert_id,alert_titel", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if !strings.Contains(stderr, "note: --fields alert_titel matched no key in the output (keys present: ") || !strings.Contains(stderr, "title") { + t.Errorf("want unmatched-field note listing present keys, got %q", stderr) + } + if rec := decodeJSON(t, stdout).(map[string]any); len(rec) != 1 { + t.Errorf("want only alert_id, got %v", keysOf(rec)) + } +} + +// TestGlobalFieldsTableModeIgnored: table output is unchanged by --fields. +func TestGlobalFieldsTableModeIgnored(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{map[string]any{"member_id": 100, "member_name": "Alice"}}, "total": 1} + + withFields, _, err := execCommandSplit("member", "list", "--fields", "member_id") + if err != nil { + t.Fatal(err) + } + saveAndResetGlobals(t) + stub = newGFStub(t) + stub.data = map[string]any{"items": []any{map[string]any{"member_id": 100, "member_name": "Alice"}}, "total": 1} + without, _, err := execCommandSplit("member", "list") + if err != nil { + t.Fatal(err) + } + if withFields != without { + t.Fatalf("table output changed by --fields:\n%s\nvs\n%s", withFields, without) + } +} + +// TestGlobalFieldsOnAcknowledgement: a write whose output is an +// acknowledgement prints it unchanged and says --fields did not apply. +func TestGlobalFieldsOnAcknowledgement(t *testing.T) { + saveAndResetGlobals(t) + newGFStub(t) + + stdout, stderr, err := execCommandSplit("channel", "disable", "1", "--fields", "x", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if !strings.Contains(stdout, `"message"`) { + t.Errorf("acknowledgement should print unchanged, got %q", stdout) + } + if !strings.Contains(stderr, `note: --fields does not apply to the output of "flashduty channel disable"`) { + t.Errorf("want not-applied note, got %q", stderr) + } +} + +// TestGlobalFieldsOnSelfPrintedOutput: commands that print their own output +// (version, config show) and table mode say --fields did not apply instead of +// ignoring it silently. +func TestGlobalFieldsOnSelfPrintedOutput(t *testing.T) { + for _, args := range [][]string{ + {"version", "--fields", "version", "--output-format", "json"}, + {"config", "show", "--fields", "base_url"}, + {"member", "list", "--fields", "member_id"}, + } { + saveAndResetGlobals(t) + newGFStub(t) + _, stderr, err := execCommandSplit(args...) + if err != nil { + t.Fatalf("%v: %v", args, err) + } + if !strings.Contains(stderr, "note: --fields does not apply to the output of") { + t.Errorf("%v: want not-applied note, got %q", args, stderr) + } + } +} + +// TestLocalFieldsFlagShadowsGlobal: a command whose own --fields is a request +// field sends it and does not project its output. +func TestLocalFieldsFlagShadowsGlobal(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = []any{map[string]any{"message": "ok"}} + + stdout, _, err := execCommandSplit("monit", "rule-update-fields", "--data", `{"ids":[1],"enabled":false}`, "--fields", "enabled", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if got := stub.bodyStrings("fields"); len(got) != 1 || got[0] != "enabled" { + t.Errorf("request field fields = %v, want [enabled]", got) + } + if !strings.Contains(stdout, `"message": "ok"`) { + t.Errorf("output must not be projected by the request-field flag: %s", stdout) + } +} + +// TestProjectOutputShapes covers the projection directly, including the shape +// that cannot be projected. +func TestProjectOutputShapes(t *testing.T) { + saveAndResetGlobals(t) + cmd := rootCmd + p, err := projectOutput(cmd, []map[string]any{{"a": 1, "b": 2}, {"a": 3}}, []string{"a", "z"}) + if err != nil || len(p.missing) != 1 || p.missing[0] != "z" || strings.Join(p.keys, ",") != "a,b" { + t.Fatalf("rows: %+v err=%v", p, err) + } + if rows := p.out.([]any); len(rows) != 2 || len(rows[0].(map[string]any)) != 1 { + t.Fatalf("rows not projected: %v", p.out) + } + + // A record whose own field is named items is a record, not an envelope. + p, err = projectOutput(cmd, map[string]any{"id": 1, "items": []any{map[string]any{"x": 1}}}, []string{"id"}) + if err != nil || p.out.(map[string]any)["id"] == nil { + t.Fatalf("record with an items field: %+v err=%v", p, err) + } + + // An empty page reports nothing missing. + p, err = projectOutput(cmd, map[string]any{"items": []any{}, "total": 0}, []string{"a"}) + if err != nil || len(p.missing) != 0 { + t.Fatalf("empty page: %+v err=%v", p, err) + } + + if _, err := projectOutput(cmd, []string{"x", "y"}, []string{"a"}); err == nil || !strings.Contains(err.Error(), "drop --fields") { + t.Fatalf("list of scalars should be a shape error, got %v", err) + } +} + +// TestGlobalFieldsGeneratedNoteNamesOriginalKeys: on the generated-command +// path the unmatched-field note is printed once and lists the keys of the +// unprojected rows. +func TestGlobalFieldsGeneratedNoteNamesOriginalKeys(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{map[string]any{"member_id": 100, "member_name": "Alice"}}, "total": 1} + + _, stderr, err := execCommandSplit("member", "list", "--fields", "member_id,membr_name", "--output-format", "json") + if err != nil { + t.Fatal(err) + } + if strings.Count(stderr, "matched no key") != 1 || !strings.Contains(stderr, "member_name") { + t.Fatalf("want one note listing member_name, got %q", stderr) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index cc9f097..b72528d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -32,6 +32,7 @@ var ( flagAppKey string flagBaseURL string flagOutputFormat string + flagFields string ) var ( @@ -60,6 +61,7 @@ var rootCmd = &cobra.Command{ } updateNotice = nil updateCheckWarning = "" + fieldsApplied = false if cmd.CommandPath() == cmd.Root().Name()+" update" { return nil } @@ -88,6 +90,7 @@ var rootCmd = &cobra.Command{ return nil }, PersistentPostRun: func(cmd *cobra.Command, _ []string) { + noteFieldsNotApplied(cmd) if updateCheckWarning != "" { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\n%s\n", updateCheckWarning) } @@ -105,8 +108,13 @@ func init() { rootCmd.PersistentFlags().BoolVar(&flagNoTrunc, "no-trunc", false, "Do not truncate table output") rootCmd.PersistentFlags().StringVar(&flagAppKey, "app-key", "", "Override app key") rootCmd.PersistentFlags().StringVar(&flagBaseURL, "base-url", "", "Override base URL") + rootCmd.PersistentFlags().StringVar(&flagFields, "fields", "", "Comma-separated top-level fields to keep in json/toon output (per row of a list, or of a single record); ignored in table mode") _ = rootCmd.PersistentFlags().MarkHidden("app-key") registerEnumFlag(rootCmd, "output-format", "table", "json", "toon") + rootCmd.SetFlagErrorFunc(flagErrorWithCandidates) + // --version prints the same line as the version command. + rootCmd.SetVersionTemplate("flashduty version {{.Version}}\n") + setRootVersion() rootCmd.AddCommand(newVersionCmd()) rootCmd.AddCommand(newLoginCmd()) @@ -171,16 +179,26 @@ func init() { // to it, and cobra derives completion scripts from the root's name. func Execute(name string) error { if name == rootCmd.Name() { - return rootCmd.Execute() + return executeArgs(os.Args[1:]) } renameCommandText(rootCmd, name) rootCmd.Use = name - if err := rootCmd.Execute(); err != nil { + if err := executeArgs(os.Args[1:]); err != nil { return errors.New(renameCLI(err.Error(), name)) } return nil } +// executeArgs runs the command tree on args, rejecting an unknown subcommand +// before cobra dispatch (see rejectUnknownSubcommand). +func executeArgs(args []string) error { + if err := rejectUnknownSubcommand(rootCmd, args); err != nil { + return err + } + rootCmd.SetArgs(args) + return rootCmd.Execute() +} + // renameCommandText rewrites the CLI name in cmd's help text and flag usages, // recursively. func renameCommandText(cmd *cobra.Command, name string) { @@ -315,12 +333,13 @@ func marshalStructured(v any) ([]byte, error) { return json.MarshalIndent(output.NullUnsetInstants(v), "", " ") } -// newPrinter creates a Printer based on global flags. -func newPrinter(w io.Writer) output.Printer { - if w == nil { - w = os.Stdout +// newPrinter creates the Printer for cmd's stdout based on global flags, +// applying the global --fields projection to structured output. +func newPrinter(cmd *cobra.Command) output.Printer { + return projectingPrinter{ + inner: output.NewPrinter(currentOutputFormat(), flagNoTrunc, cmd.OutOrStdout()), + cmd: cmd, } - return output.NewPrinter(currentOutputFormat(), flagNoTrunc, w) } // cmdContext returns the command's context. diff --git a/internal/cli/suggest.go b/internal/cli/suggest.go new file mode 100644 index 0000000..0e080d6 --- /dev/null +++ b/internal/cli/suggest.go @@ -0,0 +1,304 @@ +package cli + +import ( + "errors" + "fmt" + "slices" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Unknown-flag and unknown-command errors list the valid candidates, so a +// caller who guessed a name (a human from memory, an agent or script by +// analogy with a sibling verb) corrects it from the error alone instead of a +// separate --help round trip. + +// maxTreeSuggestions caps the whole-tree command matches listed in one error. +const maxTreeSuggestions = 8 + +// singleRecordVerbs are the interchangeable spellings of "show one record" +// across command groups: curated verbs say get/detail, path-derived generated +// verbs say info/infos. A guessed spelling is swapped for the others when +// looking for a sibling. +var singleRecordVerbs = []string{"get", "info", "infos", "detail", "show", "describe"} + +// flagErrorWithCandidates is the root FlagErrorFunc (cobra hands it to every +// descendant). It extends pflag's unknown-flag error with the closest flag +// names and the command's full flag list; every other flag error (bad value, +// missing value) passes through unchanged. +func flagErrorWithCandidates(cmd *cobra.Command, err error) error { + var notExist *pflag.NotExistError + if !errors.As(err, ¬Exist) { + return err + } + // A flag is global when it is the root's persistent flag itself; a + // command's own flag of the same name (e.g. a --fields with its own + // meaning) shadows it and is listed as the command's. + rootFlags := cmd.Root().PersistentFlags() + var local, global []string + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if !f.Hidden && f.Name != "help" && rootFlags.Lookup(f.Name) != f { + local = append(local, f.Name) + } + }) + rootFlags.VisitAll(func(f *pflag.Flag) { + if !f.Hidden && cmd.Flags().Lookup(f.Name) == f { + global = append(global, f.Name) + } + }) + sort.Strings(local) + sort.Strings(global) + + var b strings.Builder + fmt.Fprintf(&b, "%s for %q\n", err, cmd.CommandPath()) + if close := closeNames(notExist.GetSpecifiedName(), append(append([]string{}, local...), global...)); len(close) > 0 { + fmt.Fprintf(&b, "\nDid you mean this?\n\t--%s\n", strings.Join(close, "\n\t--")) + } + if len(local) > 0 { + fmt.Fprintf(&b, "Flags: --%s\n", strings.Join(local, ", --")) + } + fmt.Fprintf(&b, "Global flags: --%s", strings.Join(global, ", --")) + return errors.New(b.String()) +} + +// closeNames returns the candidates that look like a misspelling or a +// near-synonym of name: within edit distance 2 (1 for names under 6 +// characters, where 2 edits reach unrelated short flags), a prefix either way, +// or the same first hyphen-token (--channel-id vs --channel). +func closeNames(name string, candidates []string) []string { + if len(name) < 2 { + return nil + } + maxDist := 2 + if len(name) < 6 { + maxDist = 1 + } + seen := map[string]bool{} + var out []string + first := strings.SplitN(name, "-", 2)[0] + for _, c := range candidates { + if seen[c] || c == name { + continue + } + if levenshtein(name, c) <= maxDist || strings.HasPrefix(c, name) || strings.HasPrefix(name, c) || + strings.SplitN(c, "-", 2)[0] == first { + seen[c] = true + out = append(out, c) + } + } + sort.Strings(out) + return out +} + +// unknownCommandError reports token as an unknown subcommand of the group cmd. +// Candidates come from, in order: cobra's sibling suggestions plus +// single-record verb synonyms; failing those, every leaf in the whole tree +// whose path contains token on hyphen-token boundaries (so `silence` finds +// `channel silence-rule-list` and `monit-datasource` finds +// `monit datasource-list`). The group's own subcommands are always listed. +func unknownCommandError(cmd *cobra.Command, token string) error { + // Mirrors cobra's own default (see (*Command).findSuggestions): a group + // never sets this itself, so the zero value would otherwise disable the + // Levenshtein half of SuggestionsFor. + if cmd.SuggestionsMinimumDistance <= 0 { + cmd.SuggestionsMinimumDistance = 2 + } + var paths []string + for _, name := range append(cmd.SuggestionsFor(token), synonymSiblings(cmd, token)...) { + paths = appendUnique(paths, cmd.CommandPath()+" "+name) + } + more := 0 + if len(paths) == 0 { + paths, more = treeMatches(cmd.Root(), token) + } + + var b strings.Builder + fmt.Fprintf(&b, "unknown command %q for %q", token, cmd.CommandPath()) + if len(paths) > 0 { + b.WriteString("\n\nDid you mean this?\n") + for _, p := range paths { + fmt.Fprintf(&b, "\t%s\n", p) + } + if more > 0 { + fmt.Fprintf(&b, "\t(+%d more)\n", more) + } + } else { + b.WriteString("\n") + } + var names []string + for _, c := range cmd.Commands() { + if c.IsAvailableCommand() { + names = append(names, c.Name()) + } + } + fmt.Fprintf(&b, "Available commands: %s", strings.Join(names, ", ")) + return errors.New(b.String()) +} + +// synonymSiblings swaps a single-record verb token in token (get, info, +// detail, …) for each other spelling and returns the swaps that name an +// existing subcommand of cmd: `schedule get` → info, +// `channel escalate-rule-get` → escalate-rule-info. +func synonymSiblings(cmd *cobra.Command, token string) []string { + parts := strings.Split(token, "-") + var out []string + for i, p := range parts { + if !slices.Contains(singleRecordVerbs, p) { + continue + } + for _, alt := range singleRecordVerbs { + if alt == p { + continue + } + swapped := append(append(append([]string{}, parts[:i]...), alt), parts[i+1:]...) + name := strings.Join(swapped, "-") + for _, c := range cmd.Commands() { + if c.IsAvailableCommand() && (c.Name() == name || c.HasAlias(name)) { + out = appendUnique(out, c.Name()) + } + } + } + } + return out +} + +// treeMatches returns the runnable leaves under root whose hyphen-joined path +// contains token as a whole run of hyphen-tokens, shortest path first, capped +// at maxTreeSuggestions; more is the number left out. +func treeMatches(root *cobra.Command, token string) (paths []string, more int) { + needle := "-" + strings.ToLower(token) + "-" + var walk func(c *cobra.Command, rel []string) + walk = func(c *cobra.Command, rel []string) { + for _, child := range c.Commands() { + if !child.IsAvailableCommand() { + continue + } + p := append(append([]string{}, rel...), child.Name()) + if child.HasSubCommands() { + walk(child, p) + continue + } + if strings.Contains("-"+strings.Join(p, "-")+"-", needle) { + paths = append(paths, root.CommandPath()+" "+strings.Join(p, " ")) + } + } + } + walk(root, nil) + sort.SliceStable(paths, func(i, j int) bool { + if len(paths[i]) != len(paths[j]) { + return len(paths[i]) < len(paths[j]) + } + return paths[i] < paths[j] + }) + if len(paths) > maxTreeSuggestions { + more = len(paths) - maxTreeSuggestions + paths = paths[:maxTreeSuggestions] + } + return paths, more +} + +// rejectUnknownSubcommand fails when args resolve to a command group (root +// included) followed by a positional token that names none of its +// subcommands. It runs before cobra dispatch because cobra answers --help +// before any Args validator: `fduty alert lsit --help` would otherwise print +// the alert help and exit 0, and the root would get cobra's own +// sibling-only suggestion. A token after an unknown flag is left to cobra so +// the flag error is the one reported. +func rejectUnknownSubcommand(root *cobra.Command, args []string) error { + // cobra adds these lazily inside Execute; add them now so they resolve. + root.InitDefaultHelpCmd() + root.InitDefaultCompletionCmd(args...) + if len(args) > 0 && (args[0] == cobra.ShellCompRequestCmd || args[0] == cobra.ShellCompNoDescRequestCmd) { + return nil + } + // Find's own error is cobra's root-level unknown-command check; the + // resolved command and leftover args are still returned with it. + cmd, rest, _ := root.Find(args) + if cmd == nil || !cmd.HasSubCommands() { + return nil + } + token, ok := firstPositional(cmd, rest) + if !ok { + return nil + } + return unknownCommandError(cmd, token) +} + +// firstPositional returns the first non-flag token of args as parsed by cmd's +// flags (a non-bool flag consumes the next token as its value). ok is false +// when there is none, or when an unknown flag comes first. +func firstPositional(cmd *cobra.Command, args []string) (string, bool) { + lookup := func(name string, short bool) *pflag.Flag { + for _, fs := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} { + if short { + if f := fs.ShorthandLookup(name); f != nil { + return f + } + } else if f := fs.Lookup(name); f != nil { + return f + } + } + if !short && (name == "help" || (name == "version" && cmd.Root().Version != "")) { + return &pflag.Flag{Name: name, NoOptDefVal: "true"} + } + if short && name == "h" { + return &pflag.Flag{Name: "help", NoOptDefVal: "true"} + } + return nil + } + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--": + return "", false + case strings.HasPrefix(a, "-") && len(a) > 1: + if strings.Contains(a, "=") { + continue + } + name, short := strings.TrimPrefix(a, "--"), !strings.HasPrefix(a, "--") + if short { + name = a[len(a)-1:] + } + f := lookup(name, short) + if f == nil { + return "", false + } + if f.NoOptDefVal == "" { + i++ + } + default: + return a, true + } + } + return "", false +} + +func levenshtein(a, b string) int { + prev := make([]int, len(b)+1) + for j := range prev { + prev[j] = j + } + for i := 1; i <= len(a); i++ { + cur := make([]int, len(b)+1) + cur[0] = i + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost) + } + prev = cur + } + return prev[len(b)] +} + +func appendUnique(list []string, s string) []string { + if slices.Contains(list, s) { + return list + } + return append(list, s) +} diff --git a/internal/cli/suggest_test.go b/internal/cli/suggest_test.go new file mode 100644 index 0000000..87f9226 --- /dev/null +++ b/internal/cli/suggest_test.go @@ -0,0 +1,203 @@ +package cli + +import ( + "strings" + "testing" +) + +// TestUnknownFlagListsCandidates: an unknown flag names its closest matches and +// the command's full flag list, on curated and generated commands alike. +func TestUnknownFlagListsCandidates(t *testing.T) { + cases := []struct { + name string + args []string + want []string + notWant []string + }{ + { + name: "near synonym on curated list", + args: []string{"alert", "list", "--channel-id", "1"}, + want: []string{ + `unknown flag: --channel-id for "flashduty alert list"`, + "Did you mean this?\n\t--channel\n", + "Flags: --active, --alert-ids,", + "Global flags: --base-url, --json, --no-trunc, --output-format", + }, + }, + { + name: "global --fields listed where no local one shadows it", + args: []string{"alert", "get", "a-1", "--feilds", "x"}, + want: []string{"Global flags: --base-url, --fields, --json, --no-trunc, --output-format", "\t--fields\n"}, + }, + { + name: "no close match still lists the flags", + args: []string{"incident", "comment", "inc-1", "--content", "x"}, + want: []string{`unknown flag: --content for "flashduty incident comment"`, "--comment-file"}, + notWant: []string{"Did you mean"}, + }, + { + name: "generated command", + args: []string{"member", "list", "--team-ids", "1"}, + want: []string{`unknown flag: --team-ids for "flashduty member list"`, "Flags: --asc, --data,"}, + }, + { + name: "hidden flags are not offered", + args: []string{"incident", "list", "--channel-idz", "1"}, + want: []string{"\t--channel\n"}, + notWant: []string{"--channel-id\n", "--app-key"}, + }, + { + name: "a command's own --fields is offered as its flag", + args: []string{"incident", "list", "--field", "x"}, + want: []string{"Did you mean this?\n\t--fields\n", "--channel, --closer-ids", "--asc, --channel", "Global flags: --base-url, --json, --no-trunc, --output-format"}, + notWant: []string{"Global flags: --base-url, --fields"}, + }, + { + name: "root", + args: []string{"--release-notes"}, + want: []string{`unknown flag: --release-notes for "flashduty"`, "Flags: --version"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + _, err := execCommand(tc.args...) + if err == nil { + t.Fatal("expected an error") + } + for _, w := range tc.want { + if !strings.Contains(err.Error(), w) { + t.Errorf("error missing %q:\n%s", w, err) + } + } + for _, nw := range tc.notWant { + if strings.Contains(err.Error(), nw) { + t.Errorf("error should not contain %q:\n%s", nw, err) + } + } + }) + } +} + +// TestFlagValueErrorUnchanged: only unknown-flag errors are extended; a bad +// value for a known flag keeps pflag's message. +func TestFlagValueErrorUnchanged(t *testing.T) { + saveAndResetGlobals(t) + _, err := execCommand("alert", "list", "--limit", "abc") + if err == nil || !strings.HasPrefix(err.Error(), `invalid argument "abc" for "--limit" flag`) || strings.Contains(err.Error(), "Global flags") { + t.Fatalf("want pflag's invalid-argument error unchanged, got %v", err) + } +} + +// TestUnknownCommandSuggestions: an unknown command is matched against its +// siblings, single-record verb synonyms, and then the whole tree. +func TestUnknownCommandSuggestions(t *testing.T) { + cases := []struct { + name string + args []string + want []string + notWant []string + }{ + { + name: "root token found deeper in the tree", + args: []string{"silence", "--help"}, + want: []string{`unknown command "silence" for "flashduty"`, "\tflashduty channel silence-rule-list\n", "Available commands: "}, + }, + { + name: "hyphen-joined group and verb", + args: []string{"monit-datasource", "list"}, + want: []string{"\tflashduty monit datasource-list\n"}, + }, + { + name: "single-record synonym", + args: []string{"schedule", "get", "1"}, + want: []string{`unknown command "get" for "flashduty schedule"`, "\tflashduty schedule info\n", "Available commands: by-person, create,"}, + notWant: []string{"silence"}, + }, + { + name: "synonym inside a hyphenated verb", + args: []string{"channel", "escalate-rule-get", "1"}, + want: []string{"\tflashduty channel escalate-rule-info\n"}, + }, + { + name: "typo with --help is still an error", + args: []string{"alert", "lsit", "--help"}, + want: []string{`unknown command "lsit" for "flashduty alert"`, "\tflashduty alert list\n"}, + }, + { + name: "value-taking global flag before the token", + args: []string{"--output-format", "json", "alert", "lsit"}, + want: []string{`unknown command "lsit" for "flashduty alert"`}, + }, + { + name: "no match lists the level's commands", + args: []string{"escalationz"}, + want: []string{`unknown command "escalationz" for "flashduty"`, "Available commands: "}, + notWant: []string{"Did you mean"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + out, err := execCommand(tc.args...) + if err == nil { + t.Fatalf("expected an error, got output:\n%s", out) + } + for _, w := range tc.want { + if !strings.Contains(err.Error(), w) { + t.Errorf("error missing %q:\n%s", w, err) + } + } + for _, nw := range tc.notWant { + if strings.Contains(err.Error(), nw) { + t.Errorf("error should not contain %q:\n%s", nw, err) + } + } + }) + } +} + +// TestKnownInvocationsStillDispatch: the pre-dispatch check leaves real +// commands, group help, cobra's lazily-added commands and an unknown flag +// (which cobra reports) alone. +func TestKnownInvocationsStillDispatch(t *testing.T) { + for _, args := range [][]string{ + {"alert"}, + {"alert", "--help"}, + {"--json", "alert", "--help"}, + {"help", "alert"}, + {"completion", "bash"}, + {"__complete", "alert", "l"}, + {"version"}, + {"--version"}, + } { + saveAndResetGlobals(t) + if out, err := execCommand(args...); err != nil { + t.Errorf("%v: unexpected error %v (output %q)", args, err, out) + } + } + + saveAndResetGlobals(t) + _, err := execCommand("alert", "--bogus", "lsit") + if err == nil || !strings.Contains(err.Error(), "unknown flag: --bogus") { + t.Errorf("an unknown flag before the token should be the reported error, got %v", err) + } +} + +// TestVersionFlagMatchesVersionCommand: --version prints the version +// command's line. +func TestVersionFlagMatchesVersionCommand(t *testing.T) { + saveAndResetGlobals(t) + viaCmd, err := execCommand("version") + if err != nil { + t.Fatal(err) + } + saveAndResetGlobals(t) + viaFlag, err := execCommand("--version") + if err != nil { + t.Fatal(err) + } + if viaCmd != viaFlag || !strings.HasPrefix(viaFlag, "flashduty version ") { + t.Fatalf("--version %q != version %q", viaFlag, viaCmd) + } +} diff --git a/internal/cli/template.go b/internal/cli/template.go index fe4fdcf..3d4e6a5 100644 --- a/internal/cli/template.go +++ b/internal/cli/template.go @@ -246,7 +246,7 @@ func newTemplateVariablesCmd() *cobra.Command { {Header: "EXAMPLE", MaxWidth: 40, Field: func(v any) string { return v.(templateVariable).Example }}, } - return newPrinter(cmd.OutOrStdout()).Print(vars, cols) + return newPrinter(cmd).Print(vars, cols) }, } @@ -279,7 +279,7 @@ func newTemplateFunctionsCmd() *cobra.Command { {Header: "DESCRIPTION", MaxWidth: 60, Field: func(v any) string { return v.(templateFunction).Description }}, } - return newPrinter(cmd.OutOrStdout()).Print(funcs, cols) + return newPrinter(cmd).Print(funcs, cols) }, } diff --git a/internal/cli/version.go b/internal/cli/version.go index 08ad9ff..1979ed1 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -17,6 +17,13 @@ func SetVersionInfo(version, commit, date string) { versionStr = version commitStr = commit dateStr = date + setRootVersion() +} + +// setRootVersion makes cobra register --version on the root, printing the +// same line as the version command. +func setRootVersion() { + rootCmd.Version = fmt.Sprintf("%s (%s) built %s", versionStr, commitStr, dateStr) } func newVersionCmd() *cobra.Command { diff --git a/internal/cli/window.go b/internal/cli/window.go new file mode 100644 index 0000000..3c0e1d9 --- /dev/null +++ b/internal/cli/window.go @@ -0,0 +1,158 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/flashcatcloud/flashduty-cli/internal/timeutil" +) + +// openStateSince is the --since a list verb applies, when --since was not +// given, to a query restricted to still-open records (incidents in progress, +// active alerts). Open records can be weeks old, so the usual 24h default +// would hide most of them; 30d is the widest window the list endpoints +// accept (< 31 days). +const openStateSince = "30d" + +// onlyOpenProgress reports whether an incident --progress filter names only +// open states (Triggered, Processing) — the query whose answer the 24h default +// would silently cut to the incidents opened today. +func onlyOpenProgress(progress string) bool { + states := parseStringSlice(progress) + if len(states) == 0 { + return false + } + for _, s := range states { + if !strings.EqualFold(s, "Triggered") && !strings.EqualFold(s, "Processing") { + return false + } + } + return true +} + +// nowFn is the clock the window note measures "ago"/"in" against; tests pin it. +var nowFn = time.Now + +// parseWindow parses a curated verb's --since/--until pair into unix seconds +// and announces the effective window on stderr (see noteWindow). why, when +// non-empty, says which default produced the window. +// +// A defaulted lookback --since ("24h", "30d") spans back from --until, not +// from now: with --until moved, a now-relative default would stretch the +// window past the server's 31-day cap (future --until) or invert it (past +// --until). With --until at its "now" default the two readings coincide. +func parseWindow(cmd *cobra.Command, since, until, why string) (start, end int64, err error) { + end, err = timeutil.Parse(until) + if err != nil { + return 0, 0, fmt.Errorf("invalid --until: %w", err) + } + if span, ok := timeutil.Lookback(since); ok && !cmd.Flags().Changed("since") { + start = end - int64(span/time.Second) + } else if start, err = timeutil.Parse(since); err != nil { + return 0, 0, fmt.Errorf("invalid --since: %w", err) + } + noteWindow(cmd.ErrOrStderr(), time.Unix(start, 0), time.Unix(end, 0), why) + return start, end, nil +} + +// noteWindow writes the query window a command actually sent, in the +// process's local timezone, with its span and where its end sits relative to +// now. A list that comes back short or empty can then be told apart from one +// whose window was not the intended one — a default the caller didn't know +// about, or an epoch converted in the wrong timezone (which lands the window +// in the future: "ends in 7h"). stderr keeps stdout byte-identical for +// json/toon consumers. +func noteWindow(w io.Writer, start, end time.Time, why string) { + detail := formatSpan(end.Sub(start)) + ", " + relativeToNow(end, "ended", "ends") + if why != "" { + detail += "; " + why + } + _, _ = fmt.Fprintf(w, "note: window %s..%s (%s)\n", formatLocal(start), formatLocal(end), detail) +} + +// noteInstant is noteWindow for a single evaluation timestamp. +func noteInstant(w io.Writer, at time.Time) { + _, _ = fmt.Fprintf(w, "note: instant %s (%s)\n", formatLocal(at), relativeToNow(at, "", "")) +} + +// noteToolWindow announces the window of a datasource query tool from its +// params' execution block ({"kind","from_ms","to_ms"}). Params without an +// execution window (diagnostic tools) produce no note; the params themselves +// are never altered. +func noteToolWindow(w io.Writer, params json.RawMessage) { + if len(params) == 0 { + return + } + var p struct { + Execution *struct { + Kind string `json:"kind"` + FromMS *json.Number `json:"from_ms"` + ToMS *json.Number `json:"to_ms"` + } `json:"execution"` + } + dec := json.NewDecoder(bytes.NewReader(params)) + dec.UseNumber() + if err := dec.Decode(&p); err != nil || p.Execution == nil || p.Execution.ToMS == nil { + return + } + to, err := p.Execution.ToMS.Int64() + if err != nil { + return + } + if p.Execution.FromMS == nil { + noteInstant(w, time.UnixMilli(to)) + return + } + from, err := p.Execution.FromMS.Int64() + if err != nil { + return + } + noteWindow(w, time.UnixMilli(from), time.UnixMilli(to), "") +} + +func formatLocal(t time.Time) string { + return t.In(time.Local).Format(time.RFC3339) +} + +// relativeToNow renders t against nowFn: "3m ago" / "in 2h" (or, with +// verbs, "ended 3m ago" / "ends in 2h"); within a minute of now it is "now". +func relativeToNow(t time.Time, past, future string) string { + d := t.Sub(nowFn()) + switch { + case d.Abs() < time.Minute: + return strings.TrimSpace(past + " now") + case d < 0: + return strings.TrimSpace(past + " " + formatSpan(-d) + " ago") + default: + return strings.TrimSpace(future + " in " + formatSpan(d)) + } +} + +// formatSpan renders d compactly, to the minute: 30d, 1d2h, 1h, 1h30m, 45m. +func formatSpan(d time.Duration) string { + d = d.Round(time.Minute) + if d < time.Minute { + return "0m" + } + days := int(d / (24 * time.Hour)) + d -= time.Duration(days) * 24 * time.Hour + hours := int(d / time.Hour) + mins := int((d - time.Duration(hours)*time.Hour) / time.Minute) + var b strings.Builder + if days > 0 { + fmt.Fprintf(&b, "%dd", days) + } + if hours > 0 { + fmt.Fprintf(&b, "%dh", hours) + } + if mins > 0 { + fmt.Fprintf(&b, "%dm", mins) + } + return b.String() +} diff --git a/internal/cli/window_test.go b/internal/cli/window_test.go new file mode 100644 index 0000000..5cefe2c --- /dev/null +++ b/internal/cli/window_test.go @@ -0,0 +1,182 @@ +package cli + +import ( + "bytes" + "strconv" + "strings" + "testing" + "time" +) + +// pinClock fixes nowFn and the local timezone (+08:00) for the test. +func pinClock(t *testing.T, now time.Time) { + t.Helper() + origNow, origLocal := nowFn, time.Local + nowFn = func() time.Time { return now } + time.Local = time.FixedZone("CST", 8*3600) + t.Cleanup(func() { nowFn, time.Local = origNow, origLocal }) +} + +func TestNoteWindowText(t *testing.T) { + now := time.Date(2026, 10, 9, 6, 0, 0, 0, time.UTC) // 14:00 +08:00 + pinClock(t, now) + cases := []struct { + name string + start, end time.Time + why string + want string + }{ + { + name: "ends now, with default reason", + start: now.Add(-30 * 24 * time.Hour), end: now, + why: "default --since 30d because of --active", + want: "note: window 2026-09-09T14:00:00+08:00..2026-10-09T14:00:00+08:00 (30d, ended now; default --since 30d because of --active)\n", + }, + { + name: "past", + start: now.Add(-time.Hour - 3*time.Minute), end: now.Add(-3 * time.Minute), + want: "note: window 2026-10-09T12:57:00+08:00..2026-10-09T13:57:00+08:00 (1h, ended 3m ago)\n", + }, + { + name: "future end exposes a timezone slip", + start: now.Add(7 * time.Hour), end: now.Add(8*time.Hour + 30*time.Minute), + want: "note: window 2026-10-09T21:00:00+08:00..2026-10-09T22:30:00+08:00 (1h30m, ends in 8h30m)\n", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + noteWindow(&buf, tc.start, tc.end, tc.why) + if buf.String() != tc.want { + t.Fatalf("got %q\nwant %q", buf.String(), tc.want) + } + }) + } +} + +func TestNoteToolWindow(t *testing.T) { + now := time.Date(2026, 10, 9, 6, 0, 0, 0, time.UTC) + pinClock(t, now) + from, to := now.Add(-time.Hour).UnixMilli(), now.UnixMilli() + cases := []struct { + name, params, want string + }{ + {"window", `{"expr":"x","execution":{"kind":"window","from_ms":` + itoa(from) + `,"to_ms":` + itoa(to) + `}}`, + "note: window 2026-10-09T13:00:00+08:00..2026-10-09T14:00:00+08:00 (1h, ended now)\n"}, + {"instant", `{"expr":"up","execution":{"kind":"instant","to_ms":` + itoa(to) + `}}`, + "note: instant 2026-10-09T14:00:00+08:00 (now)\n"}, + {"diagnostic tool, no execution", `{"table":"t"}`, ""}, + {"no params", ``, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + noteToolWindow(&buf, []byte(tc.params)) + if buf.String() != tc.want { + t.Fatalf("got %q\nwant %q", buf.String(), tc.want) + } + }) + } +} + +func itoa(n int64) string { return strconv.FormatInt(n, 10) } + +// TestGeneratedWindowNote: a generated verb with a --start-time/--end-time +// pair announces the window when both flags are given. A window that came in +// through --data is the caller's literal body, not a value the CLI resolved, +// so it is not announced. +func TestGeneratedWindowNote(t *testing.T) { + for _, tc := range []struct { + args []string + want bool + }{ + {[]string{"insight", "account", "--start-time", "2h", "--end-time", "1h"}, true}, + {[]string{"insight", "account", "--since", "2h", "--until", "now"}, true}, + {[]string{"insight", "account", "--data", `{"start_time":1791540000,"end_time":1791543600}`}, false}, + } { + saveAndResetGlobals(t) + newGFStub(t) + _, stderr, err := execCommandSplit(append(tc.args, "--output-format", "json")...) + if err != nil { + t.Fatalf("%v: %v", tc.args, err) + } + if got := strings.Contains(stderr, "note: window "); got != tc.want { + t.Errorf("%v: window note = %v, want %v (stderr %q)", tc.args, got, tc.want, stderr) + } + } +} + +// TestMonitQueryWindowNote: monit-query echoes the execution window of its +// params on stderr, leaving stdout untouched. +func TestMonitQueryWindowNote(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"datasource_id": 1, "tool": "prometheus.query", "data": map[string]any{}} + stdout, stderr, err := execCommandSplit("monit-query", "1", "--tool", "prometheus.query", + "--params", `{"expr":"up","execution":{"kind":"range","from_ms":1791540000000,"to_ms":1791543600000,"max_data_points":10}}`, + "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if !strings.Contains(stderr, "note: window ") || !strings.Contains(stderr, "(1h, ") { + t.Errorf("want a 1h window note, got %q", stderr) + } + if strings.Contains(stdout, "note:") { + t.Errorf("stdout must stay pure, got %q", stdout) + } +} + +// TestOpenStateDefaultWindow: incident list with only open --progress states, +// and alert list --active, default to a 30d window when --since is not set; +// every other combination keeps the 24h default. The window is announced on +// stderr either way, and stdout stays pure JSON. +func TestOpenStateDefaultWindow(t *testing.T) { + const day = int64(24 * 3600) + cases := []struct { + name string + args []string + wantSpan int64 + wantWhy bool + }{ + {"incident unfiltered", []string{"incident", "list"}, day, false}, + {"incident triggered", []string{"incident", "list", "--progress", "Triggered"}, 30 * day, true}, + {"incident open states", []string{"incident", "list", "--progress", "Triggered,Processing"}, 30 * day, true}, + {"incident closed", []string{"incident", "list", "--progress", "Closed"}, day, false}, + {"incident mixed", []string{"incident", "list", "--progress", "Triggered,Closed"}, day, false}, + {"incident explicit since wins", []string{"incident", "list", "--progress", "Triggered", "--since", "2h"}, 2 * 3600, false}, + {"alert active", []string{"alert", "list", "--active"}, 30 * day, true}, + {"alert unfiltered", []string{"alert", "list"}, day, false}, + {"alert active explicit since", []string{"alert", "list", "--active", "--since", "1h"}, 3600, false}, + // A defaulted --since spans back from --until, so a moved --until + // neither stretches the window past 30d nor inverts it. + {"incident triggered future until", []string{"incident", "list", "--progress", "Triggered", "--until", "+1d"}, 30 * day, true}, + {"alert active future until", []string{"alert", "list", "--active", "--until", "+1d"}, 30 * day, true}, + {"incident past until", []string{"incident", "list", "--until", "2d"}, day, false}, + {"incident explicit since and until", []string{"incident", "list", "--since", "3d", "--until", "1d"}, 2 * day, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{}, "total": 0} + stdout, stderr, err := execCommandSplit(append(tc.args, "--output-format", "json")...) + if err != nil { + t.Fatalf("execCommand: %v", err) + } + start, _ := stub.lastBody["start_time"].(float64) + end, _ := stub.lastBody["end_time"].(float64) + if span := int64(end - start); span < tc.wantSpan-5 || span > tc.wantSpan+5 { + t.Errorf("window span = %ds, want %ds (body %v)", span, tc.wantSpan, stub.lastBody) + } + if !strings.Contains(stderr, "note: window ") { + t.Errorf("stderr should announce the window, got %q", stderr) + } + if got := strings.Contains(stderr, "default --since 30d"); got != tc.wantWhy { + t.Errorf("default reason in note = %v, want %v: %q", got, tc.wantWhy, stderr) + } + if strings.Contains(stdout, "note:") { + t.Errorf("stdout must stay pure structured output, got %q", stdout) + } + }) + } +} diff --git a/internal/cli/zz_generated_alerts.go b/internal/cli/zz_generated_alerts.go index 0293d00..7af61d4 100644 --- a/internal/cli/zz_generated_alerts.go +++ b/internal/cli/zz_generated_alerts.go @@ -84,6 +84,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("page") { body["p"] = fP @@ -597,6 +598,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("page") { body["p"] = fP diff --git a/internal/cli/zz_generated_analytics.go b/internal/cli/zz_generated_analytics.go index 19f0657..06d50af 100644 --- a/internal/cli/zz_generated_analytics.go +++ b/internal/cli/zz_generated_analytics.go @@ -110,6 +110,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("aggregate-unit") { body["aggregate_unit"] = fAggregateUnit @@ -320,6 +321,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("aggregate-unit") { body["aggregate_unit"] = fAggregateUnit @@ -521,6 +523,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("aggregate-unit") { body["aggregate_unit"] = fAggregateUnit @@ -731,6 +734,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("aggregate-unit") { body["aggregate_unit"] = fAggregateUnit @@ -909,6 +913,7 @@ Request fields: if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("aggregate-unit") { body["aggregate_unit"] = fAggregateUnit @@ -1309,6 +1314,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("page") { body["p"] = fP @@ -1491,6 +1497,7 @@ Request fields: if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("aggregate-unit") { body["aggregate_unit"] = fAggregateUnit @@ -1673,6 +1680,7 @@ Request fields: if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("aggregate-unit") { body["aggregate_unit"] = fAggregateUnit @@ -1866,6 +1874,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("aggregate-unit") { body["aggregate_unit"] = fAggregateUnit diff --git a/internal/cli/zz_generated_audit_logs.go b/internal/cli/zz_generated_audit_logs.go index 675d21a..ce23b32 100644 --- a/internal/cli/zz_generated_audit_logs.go +++ b/internal/cli/zz_generated_audit_logs.go @@ -112,6 +112,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if okEndTime { body["end_time"] = vEndTime diff --git a/internal/cli/zz_generated_changes.go b/internal/cli/zz_generated_changes.go index 34ce6c5..aa47937 100644 --- a/internal/cli/zz_generated_changes.go +++ b/internal/cli/zz_generated_changes.go @@ -102,6 +102,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("page") { body["p"] = fP diff --git a/internal/cli/zz_generated_incidents.go b/internal/cli/zz_generated_incidents.go index b27b37c..eb86589 100644 --- a/internal/cli/zz_generated_incidents.go +++ b/internal/cli/zz_generated_incidents.go @@ -208,6 +208,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("page") { body["p"] = fP @@ -1826,6 +1827,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; if err != nil { return err } + genNoteWindow(cmd, vStartTime, vEndTime, okStartTime && okEndTime) body, err := genAssembleBody(dataJSON, func(body map[string]any) error { if cmd.Flags().Changed("page") { body["p"] = fP diff --git a/internal/cmd/cligen/main.go b/internal/cmd/cligen/main.go index a219808..bb55684 100644 --- a/internal/cmd/cligen/main.go +++ b/internal/cmd/cligen/main.go @@ -1281,6 +1281,21 @@ func emitCmd(fn string, s service, o specOp, mi methodInfo) string { } b.WriteString("\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n") } + // A --start-time/--end-time pair (the one aliased to --since/--until) + // echoes the window it resolved, like the curated windowed verbs. + var startWire, endWire string + for wire, alias := range timeAlias { + switch alias { + case "since": + startWire = wire + case "until": + endWire = wire + } + } + if startWire != "" && endWire != "" { + fmt.Fprintf(&b, "\t\t\t\tgenNoteWindow(cmd, %s, %s, %s && %s)\n", + parsedTimeVar(startWire), parsedTimeVar(endWire), okTimeVar(startWire), okTimeVar(endWire)) + } // body assembly. The positional argument folds in first (after --data, before // the typed flags) so an explicitly-set flag for the same field overrides it, // matching genAssembleBody's --data-then-flags overlay order. genFoldPositional diff --git a/internal/skilldoc/build.go b/internal/skilldoc/build.go index 2d87c25..759f85a 100644 --- a/internal/skilldoc/build.go +++ b/internal/skilldoc/build.go @@ -21,6 +21,21 @@ import ( // re-derived here; they live verbatim in Long, which cligen authored. func Build(root *cobra.Command) Dump { var d Dump + // cobra adds --help/--version lazily at execution; add them now so the + // dump lists every flag the root accepts. + root.InitDefaultHelpFlag() + root.InitDefaultVersionFlag() + root.PersistentFlags().VisitAll(func(f *pflag.Flag) { + fl := flagOf(f) + fl.Persistent = true + d.Root = append(d.Root, fl) + }) + root.LocalNonPersistentFlags().VisitAll(func(f *pflag.Flag) { + fl := flagOf(f) + // Not inherited, but cobra gives every command its own --help. + fl.Persistent = f.Name == "help" + d.Root = append(d.Root, fl) + }) walk(root, nil, &d) sort.Slice(d.Commands, func(i, j int) bool { return d.Commands[i].Path < d.Commands[j].Path @@ -77,17 +92,24 @@ func command(c *cobra.Command, path []string) Command { if len(path) > 0 { cmd.Group = path[0] } - c.Flags().VisitAll(func(f *pflag.Flag) { - if f.Hidden { + // LocalFlags, not Flags: once cobra has merged the root's persistent flags + // into a command (it does so on execution), Flags() lists them too, and a + // global flag is not a property of the command's own card entry. + c.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden || f.Name == "help" { return } - cmd.Flags = append(cmd.Flags, Flag{ - Name: f.Name, - Type: f.Value.Type(), - Default: f.DefValue, - Usage: f.Usage, - Required: f.Annotations[cobra.BashCompOneRequiredFlag] != nil, - }) + cmd.Flags = append(cmd.Flags, flagOf(f)) }) return cmd } + +func flagOf(f *pflag.Flag) Flag { + return Flag{ + Name: f.Name, + Type: f.Value.Type(), + Default: f.DefValue, + Usage: f.Usage, + Required: f.Annotations[cobra.BashCompOneRequiredFlag] != nil, + } +} diff --git a/internal/skilldoc/generate.go b/internal/skilldoc/generate.go index 20cf8d2..a16dfca 100644 --- a/internal/skilldoc/generate.go +++ b/internal/skilldoc/generate.go @@ -205,7 +205,7 @@ type flagRow struct { func flagRows(c Command, parsed map[string]parsedFlag) []flagRow { var rows []flagRow for _, f := range c.Flags { - if globalFlags[f.Name] { + if f.Name == "data" { continue } row := flagRow{name: f.Name, typ: f.Type} diff --git a/internal/skilldoc/model.go b/internal/skilldoc/model.go index 4c2aaf3..aa4de04 100644 --- a/internal/skilldoc/model.go +++ b/internal/skilldoc/model.go @@ -12,6 +12,10 @@ package skilldoc // contract shared between the dump oracle, the validator, and the generator. type Dump struct { Commands []Command `json:"commands"` + // Root is every flag the bare root command accepts: the persistent + // (global) flags, which every command inherits, plus root-only flags such + // as --version. + Root []Flag `json:"root"` } // Command is one runnable leaf of the CLI tree. @@ -38,4 +42,6 @@ type Flag struct { Default string `json:"default"` Usage string `json:"usage"` Required bool `json:"required"` + // Persistent marks a root flag every command inherits (Dump.Root only). + Persistent bool `json:"persistent,omitempty"` } diff --git a/internal/skilldoc/validate.go b/internal/skilldoc/validate.go index 4c77bfe..c1e11d1 100644 --- a/internal/skilldoc/validate.go +++ b/internal/skilldoc/validate.go @@ -20,17 +20,6 @@ type Issue struct { Detail string } -// globalFlags are always-valid persistent flags that any command accepts; the -// validator never flags them as unknown. Kept in one place to stay DRY. -var globalFlags = map[string]bool{ - "output-format": true, - "json": true, - "no-trunc": true, - "app-key": true, - "base-url": true, - "data": true, -} - // Validate checks every harvested `fduty …` example in docs against the dump: // an example whose leading words resolve to no command path yields an // unknown-command issue; an example flag absent from its command's flag set @@ -159,11 +148,23 @@ func lineOf(body string, off int) int { type commandIndex struct { flags map[string]map[string]bool paths []string + // global is the flags every command accepts (the root's persistent flags + // and --help); root is every flag the bare root command accepts. + global map[string]bool + root map[string]bool } func indexDump(d Dump) commandIndex { idx := commandIndex{ - flags: make(map[string]map[string]bool), + flags: make(map[string]map[string]bool), + global: make(map[string]bool), + root: make(map[string]bool), + } + for _, f := range d.Root { + idx.root[f.Name] = true + if f.Persistent { + idx.global[f.Name] = true + } } for _, c := range d.Commands { set := make(map[string]bool, len(c.Flags)) @@ -191,7 +192,13 @@ func validateExample(idx commandIndex, docPath string, ex Example) []Issue { // resolve is a genuine wrong command name (e.g. `statuspage`) and is // reported. words := leadingWords(ex.Tokens) - if len(words) == 0 || anyPlaceholder(words) { + if len(words) == 0 { + // A bare `fduty` mention is prose, unless it carries flags: then it + // is a root invocation (`fduty --version`), and its flags must be + // ones the root accepts. + return unknownFlags(docPath, ex, "fduty", idx.root) + } + if anyPlaceholder(words) { return nil } return []Issue{{ @@ -202,6 +209,19 @@ func validateExample(idx commandIndex, docPath string, ex Example) []Issue { }} } + valid := make(map[string]bool, len(idx.global)+len(flagSet)) + for name := range idx.global { + valid[name] = true + } + for name := range flagSet { + valid[name] = true + } + return unknownFlags(docPath, ex, path, valid) +} + +// unknownFlags reports every flag of ex that is not in valid; path names the +// command in the issue. +func unknownFlags(docPath string, ex Example, path string, valid map[string]bool) []Issue { var issues []Issue for _, tok := range ex.Tokens { name, isFlag := flagName(tok) @@ -215,7 +235,7 @@ func validateExample(idx commandIndex, docPath string, ex Example) []Issue { // never emits the bare requireExactArg/requireArgs form that would make // passing the flag fail. So a folded name is just a flag like any other // here: fall through to the flagSet check below. - if globalFlags[name] || flagSet[name] { + if valid[name] { continue } issues = append(issues, Issue{ diff --git a/internal/skilldoc/validate_test.go b/internal/skilldoc/validate_test.go index 9d30174..debc157 100644 --- a/internal/skilldoc/validate_test.go +++ b/internal/skilldoc/validate_test.go @@ -3,17 +3,26 @@ package skilldoc import "testing" // validatorDump is a minimal dump fixture: one status-page leaf with flags -// {type, title} plus the data flag. +// {type, title} plus the data flag, under a root with the global +// --output-format and --help and the root-only --version. func validatorDump() Dump { - return Dump{Commands: []Command{ - { - Path: "status-page change-create", - Group: "status-page", - Flags: []Flag{ - {Name: "type"}, {Name: "title"}, {Name: "data"}, + return Dump{ + Commands: []Command{ + { + Path: "status-page change-create", + Group: "status-page", + Flags: []Flag{ + {Name: "type"}, {Name: "title"}, {Name: "data"}, + }, }, }, - }} + Root: []Flag{ + {Name: "output-format", Persistent: true}, + {Name: "json", Persistent: true}, + {Name: "help", Persistent: true}, + {Name: "version"}, + }, + } } func TestValidate_UnknownCommandAndFlag(t *testing.T) { @@ -101,6 +110,33 @@ func TestValidate_GlobalFlagsAllowed(t *testing.T) { } } +// A bare `fduty` that carries flags is a root invocation, not prose: its flags +// are checked against the flags the root accepts. Root-only flags (--version) +// are valid there but not on a subcommand. +func TestValidate_RootInvocationFlags(t *testing.T) { + d := validatorDump() + docs := []Doc{ + {Path: "version", Body: "Check `fduty --version` first.\n"}, + {Path: "help", Body: "Run `fduty --help`.\n"}, + {Path: "bogus", Body: "Run `fduty --release-notes`.\n"}, + {Path: "leaf-version", Body: "```bash\nfduty status-page change-create --version\n```\n"}, + } + byDoc := map[string][]Issue{} + for _, is := range Validate(d, docs) { + byDoc[is.Doc] = append(byDoc[is.Doc], is) + } + for _, ok := range []string{"version", "help"} { + if n := len(byDoc[ok]); n != 0 { + t.Errorf("%s: want 0 issues, got %+v", ok, byDoc[ok]) + } + } + for _, bad := range []string{"bogus", "leaf-version"} { + if n := len(byDoc[bad]); n != 1 || byDoc[bad][0].Kind != "unknown-flag" { + t.Errorf("%s: want 1 unknown-flag, got %+v", bad, byDoc[bad]) + } + } +} + // Prose mentions of the binary — a bare `fduty` word or a templated // `fduty ` — are documentation references, not runnable examples, // and must not be flagged. A genuine wrong command name (no placeholder) must @@ -111,6 +147,8 @@ func TestValidate_SkipsBareAndTemplatedMentions(t *testing.T) { {Path: "bare", Body: "The `fduty` CLI is the interface. Each `fduty` subprocess gets auth.\n"}, {Path: "tmpl", Body: "Derive it then run `fduty --help`.\n"}, {Path: "drift", Body: "```bash\nfduty statuspage list\n```\n"}, + {Path: "root-bogus", Body: "Run `fduty --bogus` first.\n"}, + {Path: "root-json", Body: "Run `fduty --json`.\n"}, } byDoc := map[string][]Issue{} for _, is := range Validate(d, docs) { @@ -125,4 +163,11 @@ func TestValidate_SkipsBareAndTemplatedMentions(t *testing.T) { if n := len(byDoc["drift"]); n != 1 || byDoc["drift"][0].Kind != "unknown-command" { t.Errorf("drift `statuspage`: want 1 unknown-command, got %+v", byDoc["drift"]) } + // A bare `fduty` followed by flags is a root invocation, not prose. + if n := len(byDoc["root-bogus"]); n != 1 || byDoc["root-bogus"][0].Kind != "unknown-flag" { + t.Errorf("`fduty --bogus`: want 1 unknown-flag, got %+v", byDoc["root-bogus"]) + } + if n := len(byDoc["root-json"]); n != 0 { + t.Errorf("`fduty --json`: want 0 issues, got %+v", byDoc["root-json"]) + } } diff --git a/internal/timeutil/parse.go b/internal/timeutil/parse.go index b4035c4..3693cb8 100644 --- a/internal/timeutil/parse.go +++ b/internal/timeutil/parse.go @@ -84,6 +84,21 @@ func Parse(s string) (int64, error) { return 0, fmt.Errorf("unable to parse time %q: expected duration (24h), RFC3339 (2006-01-02T15:04:05Z07:00), date (2006-01-02), datetime (2006-01-02 15:04:05), or unix timestamp in seconds or milliseconds", s) } +// Lookback reports the span of a past-relative duration ("24h", "30d") — the +// form Parse reads as "now minus duration". ok is false for every other form +// (absolute times, "now", "+" future offsets). +func Lookback(s string) (d time.Duration, ok bool) { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "+") { + return 0, false + } + d, err := time.ParseDuration(expandDays(s)) + if err != nil || d < 0 { + return 0, false + } + return d, true +} + // msThreshold is the magnitude cutoff separating a unix timestamp in seconds // from one in milliseconds: 100,000,000,000 as seconds is the year 5138, so // any input at or above it is treated as milliseconds instead. diff --git a/internal/timeutil/parse_test.go b/internal/timeutil/parse_test.go index a5ecdf2..99d3630 100644 --- a/internal/timeutil/parse_test.go +++ b/internal/timeutil/parse_test.go @@ -250,3 +250,28 @@ func TestParse(t *testing.T) { }) } } + +// TestLookback: only past-relative durations are lookbacks; "+" future +// offsets (which Atoi-based day expansion would otherwise accept), "now", +// dates and unix timestamps are not. +func TestLookback(t *testing.T) { + cases := map[string]struct { + want time.Duration + ok bool + }{ + "24h": {24 * time.Hour, true}, + "30d": {30 * 24 * time.Hour, true}, + " 90m ": {90 * time.Minute, true}, + "now": {0, false}, + "+1d": {0, false}, + "+24h": {0, false}, + "2026-04-01": {0, false}, + "1712000000": {0, false}, + } + for in, tc := range cases { + got, ok := Lookback(in) + if got != tc.want || ok != tc.ok { + t.Errorf("Lookback(%q) = %v, %v; want %v, %v", in, got, ok, tc.want, tc.ok) + } + } +}