Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |

## 输出格式

Expand All @@ -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
Expand Down
13 changes: 6 additions & 7 deletions internal/cli/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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")
Expand Down
9 changes: 2 additions & 7 deletions internal/cli/alert_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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{
Expand Down
9 changes: 2 additions & 7 deletions internal/cli/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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{
Expand Down
9 changes: 2 additions & 7 deletions internal/cli/change.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
22 changes: 4 additions & 18 deletions internal/cli/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cli
import (
"fmt"
"io"
"strings"

"github.com/flashcatcloud/go-flashduty"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 2 additions & 6 deletions internal/cli/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/fieldproject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
27 changes: 27 additions & 0 deletions internal/cli/gen_support.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"reflect"
"strconv"
"strings"
"time"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
13 changes: 6 additions & 7 deletions internal/cli/incident.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <team|responder|channel> 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{
Expand Down Expand Up @@ -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")
Expand Down
17 changes: 4 additions & 13 deletions internal/cli/insight.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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{
Expand Down
1 change: 1 addition & 0 deletions internal/cli/monit_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Query tools are '<type>.query' where '<type>' 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
Expand Down
Loading
Loading