From 0a08f88a924f5ce45d055ac1f1ff30e21a2abfa9 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Mon, 1 Jun 2026 15:22:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20shell=20tab-completion=20=E2=80=94?= =?UTF-8?q?=20enum=20flag=20values=20+=20install.sh=20auto-setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobra already ships a `completion` subcommand, but (a) flag values never tab-completed and (b) the generated script was never installed, so users saw nothing on Tab. - Add internal/cli/completion.go: registerEnumFlag helper + shared severityEnum; wire it onto every closed-set flag (--output-format, incident --severity/--progress, alert/alert-event --severity, postmortem --status, monit-query --ds-type, template --type, statuspage --type). - install.sh: best-effort, non-intrusive auto-install of completion for the user's $SHELL (fish/bash user dirs; zsh only into a writable site-functions dir, else print copy-pasteable setup steps). Never edits ~/.zshrc. Handles a renamed binary via a `|`-delimited sed rewrite of the Cobra-baked root name. - Document --output-format/toon in README.md and skills/flashduty-shared/SKILL.md (previously only table/JSON were documented). Verified: go build/vet/test green, gofmt clean, sh -n + shellcheck clean, __complete returns the enum values for every wired flag, and install.sh writes correct per-shell scripts (default + renamed). --- README.md | 11 ++- install.sh | 120 +++++++++++++++++++++++++++++++ internal/cli/alert.go | 1 + internal/cli/alert_event.go | 1 + internal/cli/completion.go | 16 +++++ internal/cli/incident.go | 4 ++ internal/cli/monit_query.go | 2 + internal/cli/postmortem.go | 1 + internal/cli/root.go | 1 + internal/cli/status_page.go | 1 + internal/cli/template.go | 1 + skills/flashduty-shared/SKILL.md | 11 ++- 12 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 internal/cli/completion.go diff --git a/README.md b/README.md index 2b364be..e87e094 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ flashduty config set base_url URL # Override API endpoint | Flag | Description | |------|-------------| -| `--json` | Output as JSON instead of table | +| `--output-format` | Output format: `table` (default), `json`, or `toon` (compact, fewer tokens) | +| `--json` | Output as JSON (alias for `--output-format json`) | | `--no-trunc` | Do not truncate long fields in table output | | `--base-url` | Override the API base URL | @@ -272,12 +273,18 @@ inc_def456 High memory usage Warning Processing Staging 2026 Showing 2 results (page 1, total 2). ``` -**JSON (`--json`):** Machine-parseable, full data, no truncation. +**JSON (`--json` / `--output-format json`):** Machine-parseable, full data, no truncation. ```bash flashduty incident list --json | jq '.[].title' ``` +**TOON (`--output-format toon`):** Token-Oriented Object Notation — full data, no truncation, but drops the per-row repeated keys that JSON emits for uniform arrays, so list output costs materially fewer tokens. Preferred for LLM/agent consumption. Not directly `jq`-able; use `--json` when you need to pipe into `jq`. + +```bash +flashduty incident list --output-format toon +``` + **No truncation (`--no-trunc`):** Table with full field content. --- diff --git a/install.sh b/install.sh index a33d9e6..4da7abf 100755 --- a/install.sh +++ b/install.sh @@ -109,6 +109,122 @@ resolve_version() { echo "${version}" } +# --- shell completion (best-effort, non-intrusive) --- + +# The user's interactive shell decides which completion script we need — +# completion varies by shell, not by OS/arch. Empty when it's not one we support. +detect_shell() { + case "$(basename "${SHELL:-}" 2>/dev/null)" in + bash) echo "bash" ;; + zsh) echo "zsh" ;; + fish) echo "fish" ;; + *) echo "" ;; + esac +} + +# Emit the completion script for the shell named in $1. Cobra bakes the root +# command name "flashduty" into the script (#compdef / complete -c / function +# names); when installed under a different name, rewrite every occurrence so the +# completion binds to the actual command (the runtime dispatch line already uses +# the typed command word, so it needs no rewrite). The `|` sed delimiter is safe +# because a binary name can't contain it, and the rewrite is a no-op for the +# default "flashduty". +gen_completion() { + "${BIN}" completion "$1" | sed "s|flashduty|${INSTALLED_NAME}|g" +} + +# Install completion for the current shell into a directory the shell already +# auto-loads, without ever editing the user's rc files. zsh has no guaranteed +# writable fpath dir, so it only succeeds when a standard site-functions dir is +# already writable (e.g. a Homebrew install); otherwise we point at the binary's +# own per-shell setup instructions. +setup_completion() { + [ "${OS}" = "Windows" ] && return 0 + sh_name=$(detect_shell) + [ -z "${sh_name}" ] && return 0 + "${BIN}" completion "${sh_name}" >/dev/null 2>&1 || return 0 + + case "${sh_name}" in + fish) + dir="${XDG_CONFIG_HOME:-${HOME}/.config}/fish/completions" + mkdir -p "${dir}" 2>/dev/null || true + if [ -w "${dir}" ]; then + gen_completion fish > "${dir}/${INSTALLED_NAME}.fish" && { + info "Installed fish completion to ${dir}/${INSTALLED_NAME}.fish (restart fish to load)" + return 0 + } + fi + ;; + bash) + dir="${XDG_DATA_HOME:-${HOME}/.local/share}/bash-completion/completions" + mkdir -p "${dir}" 2>/dev/null || true + if [ -w "${dir}" ]; then + gen_completion bash > "${dir}/${INSTALLED_NAME}" && { + info "Installed bash completion to ${dir}/${INSTALLED_NAME} (needs the bash-completion package; restart bash to load)" + return 0 + } + fi + ;; + zsh) + for dir in \ + "${HOMEBREW_PREFIX:-/opt/homebrew}/share/zsh/site-functions" \ + "/usr/local/share/zsh/site-functions" \ + "/usr/share/zsh/site-functions"; do + if [ -d "${dir}" ] && [ -w "${dir}" ]; then + gen_completion zsh > "${dir}/_${INSTALLED_NAME}" && { + info "Installed zsh completion to ${dir}/_${INSTALLED_NAME}" + info " Run 'rm -f ~/.zcompdump*' and restart zsh to load." + return 0 + } + fi + done + ;; + esac + + # Couldn't auto-install into an auto-loaded dir (the common zsh case: no + # writable fpath dir, and we never edit ~/.zshrc). Print the exact, + # copy-pasteable steps so the user can finish setup in one go. + print_manual_completion "${sh_name}" +} + +# Print a concrete, copy-pasteable recipe to enable completion for $1, used when +# setup_completion can't drop the script into an auto-loaded directory. Plain +# stdout (no "[flashduty]" prefix) so the commands paste cleanly. +print_manual_completion() { + name="${INSTALLED_NAME}" + info "Shell completion was not auto-installed. To enable it for $1, run:" + case "$1" in + zsh) + cat < ~/.zsh/completions/_${name} + echo 'fpath=(~/.zsh/completions \$fpath)' >> ~/.zshrc # one-time + rm -f ~/.zcompdump* && exec zsh + +EOF + ;; + bash) + cat < ~/.local/share/bash-completion/completions/${name} + # requires the bash-completion package; then restart bash + +EOF + ;; + fish) + cat < ~/.config/fish/completions/${name}.fish + # then restart fish + +EOF + ;; + esac +} + # --- main --- main() { @@ -201,6 +317,10 @@ main() { info " export PATH=\"${INSTALL_DIR}:\$PATH\"" ;; esac + # Best-effort shell completion; never fail the install over it. + BIN="${INSTALL_DIR}/${INSTALLED_NAME}" + setup_completion || true + info "Run '${INSTALLED_NAME} version' to verify" } diff --git a/internal/cli/alert.go b/internal/cli/alert.go index 85078a1..8f50c8b 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -99,6 +99,7 @@ func newAlertListCmd() *cobra.Command { cmd.Flags().StringVar(&severity, "severity", "", "Filter: Critical,Warning,Info") cmd.Flags().BoolVar(&active, "active", false, "Show active only") + registerEnumFlag(cmd, "severity", severityEnum...) cmd.Flags().BoolVar(&recovered, "recovered", false, "Show recovered only") cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs") cmd.Flags().BoolVar(&muted, "muted", false, "Show ever-muted only") diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index c8e49c6..f2111b6 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -83,6 +83,7 @@ func newAlertEventListCmd() *cobra.Command { cmd.Flags().StringVar(&severity, "severity", "", "Filter: Critical,Warning,Info (comma-separated)") cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs") + registerEnumFlag(cmd, "severity", severityEnum...) cmd.Flags().StringVar(&integrationType, "integration-type", "", "Comma-separated integration types") cmd.Flags().StringVar(&since, "since", "1h", "Start time") cmd.Flags().StringVar(&until, "until", "now", "End time") diff --git a/internal/cli/completion.go b/internal/cli/completion.go new file mode 100644 index 0000000..2c9a155 --- /dev/null +++ b/internal/cli/completion.go @@ -0,0 +1,16 @@ +package cli + +import "github.com/spf13/cobra" + +// severityEnum is the closed set of incident/alert severities, shared by every +// --severity flag. +var severityEnum = []string{"Critical", "Warning", "Info"} + +// registerEnumFlag makes 's value tab-complete to a fixed set and +// suppresses the default filename completion. The error only fires on an +// unknown flag name (a programmer error), so it is ignored. +func registerEnumFlag(cmd *cobra.Command, flag string, values ...string) { + _ = cmd.RegisterFlagCompletionFunc(flag, func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return values, cobra.ShellCompDirectiveNoFileComp + }) +} diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 54d4012..e1b5ed8 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -121,6 +121,8 @@ func newIncidentListCmd() *cobra.Command { cmd.Flags().StringVar(&progress, "progress", "", "Filter: Triggered,Processing,Closed") cmd.Flags().StringVar(&severity, "severity", "", "Filter: Critical,Warning,Info") + registerEnumFlag(cmd, "progress", "Triggered", "Processing", "Closed") + registerEnumFlag(cmd, "severity", severityEnum...) cmd.Flags().Int64Var(&channelID, "channel", 0, "Filter by channel ID") cmd.Flags().StringVar(&query, "query", "", "Free-text search across title/labels/content (also resolves a 24-char incident ID or 6-char incident num to a direct lookup)") cmd.Flags().StringVar(&nums, "nums", "", "Comma-separated short incident ids (num, the 6-char id shown in the UI) to filter by") @@ -338,6 +340,7 @@ func newIncidentCreateCmd() *cobra.Command { cmd.Flags().StringVar(&title, "title", "", "Incident title (required, 3-200 chars)") cmd.Flags().StringVar(&severity, "severity", "", "Severity: Critical, Warning, Info (required)") cmd.Flags().Int64Var(&channelID, "channel", 0, "Channel ID") + registerEnumFlag(cmd, "severity", severityEnum...) cmd.Flags().StringVar(&description, "description", "", "Description (max 6144 chars)") cmd.Flags().IntSliceVar(&assign, "assign", nil, "Person IDs to assign (use 'flashduty member list' to look up IDs)") @@ -429,6 +432,7 @@ func newIncidentUpdateCmd() *cobra.Command { cmd.Flags().StringVar(&description, "description", "", "New description") cmd.Flags().StringVar(&severity, "severity", "", "New severity: Critical, Warning, Info") cmd.Flags().StringArrayVar(&fieldFlags, "field", nil, "Custom field: key=value (repeatable)") + registerEnumFlag(cmd, "severity", severityEnum...) return cmd } diff --git a/internal/cli/monit_query.go b/internal/cli/monit_query.go index a37c40d..499ea37 100644 --- a/internal/cli/monit_query.go +++ b/internal/cli/monit_query.go @@ -71,6 +71,7 @@ func newMonitQueryDiagnoseCmd() *cobra.Command { cmd.Flags().StringVar(&dsType, "ds-type", "", "Datasource type: prometheus|victorialogs|loki|mysql (required)") cmd.Flags().StringVar(&dsName, "ds-name", "", "Datasource name as configured (required)") + registerEnumFlag(cmd, "ds-type", "prometheus", "victorialogs", "loki", "mysql") cmd.Flags().StringVar(&timeStart, "time-start", "15m", "Window start (relative '15m'/'1h', unix seconds, or 'now')") cmd.Flags().StringVar(&timeEnd, "time-end", "now", "Window end (relative, unix seconds, or 'now'; span capped at 6h)") cmd.Flags().StringVar(&inputQuery, "input-query", "", "Filter-only log query OR matrix PromQL (required)") @@ -134,6 +135,7 @@ func newMonitQueryRowsCmd() *cobra.Command { cmd.Flags().StringVar(&dsType, "ds-type", "", "Datasource type (required)") cmd.Flags().StringVar(&dsName, "ds-name", "", "Datasource name (required)") + registerEnumFlag(cmd, "ds-type", "prometheus", "victorialogs", "loki", "mysql") cmd.Flags().StringVar(&expr, "expr", "", "Query expression (required)") cmd.Flags().StringSliceVar(&argsKV, "args", nil, "Arg entries KEY=VALUE (repeatable; values must be strings per monit-query contract)") diff --git a/internal/cli/postmortem.go b/internal/cli/postmortem.go index 0e9baac..1b18d3d 100644 --- a/internal/cli/postmortem.go +++ b/internal/cli/postmortem.go @@ -88,6 +88,7 @@ func newPostmortemListCmd() *cobra.Command { cmd.Flags().StringVar(&status, "status", "", "Filter: drafting or published") cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs") + registerEnumFlag(cmd, "status", "drafting", "published") cmd.Flags().StringVar(&team, "team", "", "Comma-separated team IDs") cmd.Flags().StringVar(&since, "since", "", "Created after (time filter)") cmd.Flags().StringVar(&until, "until", "", "Created before (time filter)") diff --git a/internal/cli/root.go b/internal/cli/root.go index a35dd87..e8a4afd 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -73,6 +73,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&flagAppKey, "app-key", "", "Override app key") rootCmd.PersistentFlags().StringVar(&flagBaseURL, "base-url", "", "Override base URL") _ = rootCmd.PersistentFlags().MarkHidden("app-key") + registerEnumFlag(rootCmd, "output-format", "table", "json", "toon") rootCmd.AddCommand(newVersionCmd()) rootCmd.AddCommand(newLoginCmd()) diff --git a/internal/cli/status_page.go b/internal/cli/status_page.go index 8571dc3..2cf9ed9 100644 --- a/internal/cli/status_page.go +++ b/internal/cli/status_page.go @@ -127,6 +127,7 @@ func newStatusPageChangesCmd() *cobra.Command { cmd.Flags().Int64Var(&pageID, "page-id", 0, "Page ID (required)") cmd.Flags().StringVar(&changeType, "type", "", "Change type: incident or maintenance (required)") + registerEnumFlag(cmd, "type", "incident", "maintenance") _ = cmd.MarkFlagRequired("page-id") _ = cmd.MarkFlagRequired("type") diff --git a/internal/cli/template.go b/internal/cli/template.go index 9861f2f..2acac10 100644 --- a/internal/cli/template.go +++ b/internal/cli/template.go @@ -285,6 +285,7 @@ func newTemplateFunctionsCmd() *cobra.Command { } cmd.Flags().StringVar(&funcType, "type", "all", "Filter: custom, sprig, or all") + registerEnumFlag(cmd, "type", "custom", "sprig", "all") return cmd } diff --git a/skills/flashduty-shared/SKILL.md b/skills/flashduty-shared/SKILL.md index 5ff4f8c..d648e2c 100644 --- a/skills/flashduty-shared/SKILL.md +++ b/skills/flashduty-shared/SKILL.md @@ -1,7 +1,7 @@ --- name: flashduty-shared version: 1.0.0 -description: "Flashduty CLI foundation: authentication (login, app_key, config), the 3-layer noise reduction model (Alert Event to Alert to Incident), global flags (--json, --no-trunc), output modes (table, JSON, vertical detail), pagination (--limit, --page), time parsing (relative, absolute, unix, future durations), reference data lookups (member, team, channel, field, escalation-rule), and safety rules. Prerequisite for all other flashduty-* skills. Use when setting up flashduty-cli, encountering auth errors, looking up IDs, or needing to understand the Flashduty data model." +description: "Flashduty CLI foundation: authentication (login, app_key, config), the 3-layer noise reduction model (Alert Event to Alert to Incident), global flags (--output-format, --json, --no-trunc), output modes (table, JSON, TOON, vertical detail), pagination (--limit, --page), time parsing (relative, absolute, unix, future durations), reference data lookups (member, team, channel, field, escalation-rule), and safety rules. Prerequisite for all other flashduty-* skills. Use when setting up flashduty-cli, encountering auth errors, looking up IDs, or needing to understand the Flashduty data model." metadata: requires: bins: ["flashduty"] @@ -107,7 +107,8 @@ These flags are available on **every** command via Cobra `PersistentFlags`: | Flag | Type | Default | Effect | |------|------|---------|--------| -| `--json` | bool | `false` | Output as JSON instead of table | +| `--output-format` | string | `table` | Output format: `table`, `json`, or `toon` (compact, fewer tokens) | +| `--json` | bool | `false` | Output as JSON (alias for `--output-format json`) | | `--no-trunc` | bool | `false` | Do not truncate long values in table output | | `--app-key` | string | `""` | Override app key (hidden flag) | | `--base-url` | string | `""` | Override base URL | @@ -124,10 +125,14 @@ Human-readable aligned columns. Long values are truncated with `...` unless `--n Showing 20 results (page 1, total 142). ``` -### JSON (`--json`) +### JSON (`--json` / `--output-format json`) Machine-readable full output. No truncation. Suitable for piping to `jq`. Success messages are wrapped as `{"message": "..."}`. +### TOON (`--output-format toon`) + +Token-Oriented Object Notation — machine-readable full output, no truncation, encoded via the same path as the Flashduty MCP server. For uniform arrays (list commands) it emits the field keys once as a header instead of repeating them on every row, so list output costs materially fewer tokens than JSON. **Preferred when an LLM/agent reads the output.** Not directly `jq`-able — use `--json` when you need `jq` field selection. + ### Vertical Detail Used automatically for single-item lookups (e.g., `flashduty incident get ` with one ID). Displays key-value pairs vertically instead of a table row.