From 78c3898d3ab7854f654d5d4ff7a861403fc794e5 Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Mon, 29 Jun 2026 14:43:10 +0800 Subject: [PATCH] feat: add automation cli commands --- go.mod | 2 +- go.sum | 4 +- internal/cli/automation.go | 663 +++++++++++++++++++++ internal/cli/automation_test.go | 192 ++++++ internal/cli/coverage_test.go | 24 +- internal/cli/display_columns.go | 26 + internal/cli/generic_table_test.go | 3 + internal/cli/gfstub_test.go | 3 + internal/cli/root.go | 2 + internal/cli/zz_generated_a2a_agents.go | 8 +- internal/cli/zz_generated_automations.go | 655 ++++++++++++++++++++ internal/cli/zz_generated_manifest.go | 7 + internal/cli/zz_generated_register.go | 1 + internal/cli/zz_generated_response_help.go | 6 + internal/cmd/cligen/main.go | 24 + skills/flashduty/SKILL.md | 3 +- skills/flashduty/reference/automation.md | 184 ++++++ 17 files changed, 1795 insertions(+), 12 deletions(-) create mode 100644 internal/cli/automation.go create mode 100644 internal/cli/automation_test.go create mode 100644 internal/cli/zz_generated_automations.go create mode 100644 skills/flashduty/reference/automation.md diff --git a/go.mod b/go.mod index 86a79b9..5dc2318 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/go-flashduty v0.5.4-0.20260626094421-72ee8e160e9d + github.com/flashcatcloud/go-flashduty v0.5.4-0.20260629061431-9893dd15a9d9 github.com/mattn/go-runewidth v0.0.24 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index b278801..d9cc3a5 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/flashcatcloud/go-flashduty v0.5.4-0.20260626094421-72ee8e160e9d h1:66A/X9w2RSqqVAlRjGA5lgyW2ADCwm6JnZhGYPnSCCU= -github.com/flashcatcloud/go-flashduty v0.5.4-0.20260626094421-72ee8e160e9d/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= +github.com/flashcatcloud/go-flashduty v0.5.4-0.20260629061431-9893dd15a9d9 h1:LU6lRPXRSCQ/dTIHbg3ctTqC13mdTDQMgXRja1lN+/g= +github.com/flashcatcloud/go-flashduty v0.5.4-0.20260629061431-9893dd15a9d9/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= diff --git a/internal/cli/automation.go b/internal/cli/automation.go new file mode 100644 index 0000000..7ed9f00 --- /dev/null +++ b/internal/cli/automation.go @@ -0,0 +1,663 @@ +package cli + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/flashcatcloud/go-flashduty" + "github.com/spf13/cobra" + + "github.com/flashcatcloud/flashduty-cli/internal/timeutil" +) + +const automationHTTPPostOnlyCron = "0 0 * * *" + +func newAutomationCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "automation", + Short: "Manage AI SRE Automations", + Long: "Create, list, update, delete, inspect, and trigger AI SRE Automations.", + Example: ` flashduty automation create --name "Daily SRE brief" --schedule daily --at 09:30 --prompt "Summarize yesterday's incidents" + flashduty automation create --name "Webhook triage" --http-post-trigger --prompt-file ./prompt.md + flashduty automation list --scope all --limit 20 + flashduty automation fire auttrig_123 --token "$TOKEN" --text "manual test"`, + } + + cmd.AddCommand(newAutomationCreateCmd()) + cmd.AddCommand(newAutomationListCmd()) + cmd.AddCommand(newAutomationGetCmd()) + cmd.AddCommand(newAutomationUpdateCmd()) + cmd.AddCommand(newAutomationDeleteCmd()) + cmd.AddCommand(newAutomationRunsCmd()) + cmd.AddCommand(newAutomationTemplatesCmd()) + cmd.AddCommand(newAutomationFireCmd()) + return cmd +} + +func newAutomationCreateCmd() *cobra.Command { + var ( + name string + teamID int64 + schedule string + at string + weekday string + cronExpr string + disabled bool + scheduleEnabled = true + httpPostTrigger bool + prompt string + promptFile string + environmentKind string + environmentID string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an Automation", + Long: curatedLong(`Create an AI SRE Automation. + +By default the rule is enabled. Use --disabled only when the user explicitly +asks to create it disabled. team_id=0 means personal scope; --team-id >0 creates +the rule under that team. The scope is immutable after creation. + +Schedule helpers build a 5-field cron expression. Use --cron-expr for exact +minute-level control. For HTTP POST-only rules, pass --http-post-trigger without +a schedule; the CLI sends a valid placeholder cron and disables the schedule trigger.`, "Automations", "RuleWriteCreate"), + Example: ` flashduty automation create --name "Daily SRE brief" --schedule daily --at 09:30 --prompt "Summarize yesterday's incidents" + flashduty automation create --name "Weekly noise review" --team-id 123 --schedule weekly --weekday mon --at 10:00 --prompt-file ./prompt.md + flashduty automation create --name "Webhook triage" --http-post-trigger --prompt "Handle the posted payload"`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + taskPrompt, err := resolveAutomationPrompt(cmd, prompt, promptFile) + if err != nil { + return err + } + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("--name is required") + } + if strings.TrimSpace(taskPrompt) == "" { + return fmt.Errorf("one of --prompt or --prompt-file is required") + } + + effectiveScheduleEnabled := scheduleEnabled + cron, err := resolveAutomationCreateCron(cmd, schedule, at, weekday, cronExpr, httpPostTrigger, &effectiveScheduleEnabled) + if err != nil { + return err + } + + req := &flashduty.AutomationRuleCreateRequest{ + Name: name, + TeamID: teamID, + CronExpr: cron, + Enabled: !disabled, + ScheduleTriggerEnabled: flashduty.Bool(effectiveScheduleEnabled), + HTTPPostTriggerEnabled: httpPostTrigger, + Prompt: taskPrompt, + EnvironmentKind: strings.TrimSpace(environmentKind), + EnvironmentID: strings.TrimSpace(environmentID), + } + out, _, err := ctx.Client.Automations.RuleWriteCreate(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Automation name") + cmd.Flags().Int64Var(&teamID, "team-id", 0, "Scope team ID; 0 means personal scope") + cmd.Flags().StringVar(&schedule, "schedule", "", "Schedule helper: hourly, daily, weekly, or cron") + cmd.Flags().StringVar(&at, "at", "", "Wall-clock time in HH:MM; for hourly schedules, only the minute is used") + cmd.Flags().StringVar(&weekday, "weekday", "", "Weekday for weekly schedules: sun, mon, tue, wed, thu, fri, sat, or 0-7") + cmd.Flags().StringVar(&cronExpr, "cron-expr", "", "Exact 5-field cron expression; overrides --schedule helpers") + cmd.Flags().BoolVar(&disabled, "disabled", false, "Create the Automation disabled") + cmd.Flags().BoolVar(&scheduleEnabled, "schedule-enabled", true, "Whether the schedule trigger is enabled") + cmd.Flags().BoolVar(&httpPostTrigger, "http-post-trigger", false, "Create and enable an HTTP POST trigger") + cmd.Flags().StringVar(&prompt, "prompt", "", "Task prompt sent to the AI SRE agent") + cmd.Flags().StringVar(&promptFile, "prompt-file", "", "Read task prompt from a file, or - for stdin") + cmd.Flags().StringVar(&environmentKind, "environment-kind", "", "Runtime environment kind: cloud or byoc; empty means automatic") + cmd.Flags().StringVar(&environmentID, "environment-id", "", "BYOC Runner ID when --environment-kind=byoc") + registerEnumFlag(cmd, "schedule", "hourly", "daily", "weekly", "cron") + registerEnumFlag(cmd, "environment-kind", "cloud", "byoc") + return cmd +} + +func newAutomationListCmd() *cobra.Command { + var ( + page int + limit int + scope string + keyword string + enabled bool + teamIDs []int64 + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List visible Automations", + Long: curatedLong("List Automation rules visible to the caller.", "Automations", "RuleReadList"), + Example: ` flashduty automation list --scope all --limit 20 + flashduty automation list --scope team --team-ids 123,456 + flashduty automation list --enabled=false --output-format json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + req := &flashduty.AutomationRuleListRequest{ + ListOptions: flashduty.ListOptions{Page: page, Limit: limit}, + Scope: strings.TrimSpace(scope), + Keyword: strings.TrimSpace(keyword), + TeamIDs: teamIDs, + } + if cmd.Flags().Changed("enabled") { + req.Enabled = flashduty.Bool(enabled) + } + out, _, err := ctx.Client.Automations.RuleReadList(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + + cmd.Flags().IntVar(&page, "page", 1, "Page number") + cmd.Flags().IntVar(&limit, "limit", 20, "Page size, max 100") + cmd.Flags().StringVar(&scope, "scope", "all", "Scope filter: all, personal, or team") + cmd.Flags().StringVar(&keyword, "keyword", "", "Filter by name keyword") + cmd.Flags().BoolVar(&enabled, "enabled", false, "Filter by enabled status") + cmd.Flags().Int64SliceVar(&teamIDs, "team-ids", nil, "Filter to these team IDs; does not expand access") + registerEnumFlag(cmd, "scope", "all", "personal", "team") + return cmd +} + +func newAutomationGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get an Automation", + Long: curatedLong("Get one Automation rule by ID.", "Automations", "RuleReadGet"), + Args: requireExactArg("rule_id"), + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + out, _, err := ctx.Client.Automations.RuleReadGet(cmdContext(ctx.Cmd), &flashduty.AutomationRuleIDRequest{RuleID: ctx.Args[0]}) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + return cmd +} + +func newAutomationUpdateCmd() *cobra.Command { + var ( + name string + schedule string + at string + weekday string + cronExpr string + enableRule bool + disableRule bool + enableSchedule bool + disableSchedule bool + prompt string + promptFile string + environmentKind string + environmentID string + enableHTTPPostTrigger bool + disableHTTPPostTrigger bool + rotateHTTPPostToken bool + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an Automation", + Long: curatedLong(`Update mutable fields on an Automation rule. + +The personal/team scope is intentionally not exposed here. Scope is immutable +after creation; create a new Automation if the target person/team scope needs to change.`, "Automations", "RuleWriteUpdate"), + Example: ` flashduty automation update auto_123 --name "Daily brief v2" --cron-expr "15 9 * * *" + flashduty automation update auto_123 --disable + flashduty automation update auto_123 --enable-http-post-trigger --rotate-http-post-token`, + Args: requireExactArg("rule_id"), + PreRunE: func(cmd *cobra.Command, args []string) error { + if enableRule && disableRule { + return fmt.Errorf("only one of --enable or --disable may be set") + } + if enableSchedule && disableSchedule { + return fmt.Errorf("only one of --enable-schedule or --disable-schedule may be set") + } + if enableHTTPPostTrigger && disableHTTPPostTrigger { + return fmt.Errorf("only one of --enable-http-post-trigger or --disable-http-post-trigger may be set") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + req := &flashduty.AutomationRuleUpdateRequest{RuleID: ctx.Args[0]} + changed := false + + if cmd.Flags().Changed("name") { + req.Name = flashduty.String(strings.TrimSpace(name)) + changed = true + } + if cmd.Flags().Changed("prompt") || cmd.Flags().Changed("prompt-file") { + taskPrompt, err := resolveAutomationPrompt(cmd, prompt, promptFile) + if err != nil { + return err + } + req.Prompt = flashduty.String(taskPrompt) + changed = true + } + if enableRule { + req.Enabled = flashduty.Bool(true) + changed = true + } + if disableRule { + req.Enabled = flashduty.Bool(false) + changed = true + } + if automationScheduleChanged(cmd) { + cron, err := resolveAutomationCron(schedule, at, weekday, cronExpr) + if err != nil { + return err + } + req.CronExpr = flashduty.String(cron) + changed = true + } + if enableSchedule { + req.ScheduleTriggerEnabled = flashduty.Bool(true) + changed = true + } + if disableSchedule { + req.ScheduleTriggerEnabled = flashduty.Bool(false) + changed = true + } + if cmd.Flags().Changed("environment-kind") { + req.EnvironmentKind = flashduty.String(strings.TrimSpace(environmentKind)) + changed = true + } + if cmd.Flags().Changed("environment-id") { + req.EnvironmentID = flashduty.String(strings.TrimSpace(environmentID)) + changed = true + } + if enableHTTPPostTrigger { + req.HTTPPostTriggerEnabled = flashduty.Bool(true) + changed = true + } + if disableHTTPPostTrigger { + req.HTTPPostTriggerEnabled = flashduty.Bool(false) + changed = true + } + if rotateHTTPPostToken { + req.RotateHTTPPostTriggerToken = true + changed = true + } + if !changed { + return fmt.Errorf("at least one update field is required") + } + + out, _, err := ctx.Client.Automations.RuleWriteUpdate(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "New Automation name") + cmd.Flags().StringVar(&schedule, "schedule", "", "Schedule helper: hourly, daily, weekly, or cron") + cmd.Flags().StringVar(&at, "at", "", "Wall-clock time in HH:MM; for hourly schedules, only the minute is used") + cmd.Flags().StringVar(&weekday, "weekday", "", "Weekday for weekly schedules: sun, mon, tue, wed, thu, fri, sat, or 0-7") + cmd.Flags().StringVar(&cronExpr, "cron-expr", "", "Exact 5-field cron expression; overrides --schedule helpers") + cmd.Flags().BoolVar(&enableRule, "enable", false, "Enable the Automation") + cmd.Flags().BoolVar(&disableRule, "disable", false, "Disable the Automation") + cmd.Flags().BoolVar(&enableSchedule, "enable-schedule", false, "Enable the schedule trigger") + cmd.Flags().BoolVar(&disableSchedule, "disable-schedule", false, "Disable the schedule trigger") + cmd.Flags().StringVar(&prompt, "prompt", "", "New task prompt") + cmd.Flags().StringVar(&promptFile, "prompt-file", "", "Read new task prompt from a file, or - for stdin") + cmd.Flags().StringVar(&environmentKind, "environment-kind", "", "Runtime environment kind: cloud or byoc; empty means automatic") + cmd.Flags().StringVar(&environmentID, "environment-id", "", "BYOC Runner ID when --environment-kind=byoc") + cmd.Flags().BoolVar(&enableHTTPPostTrigger, "enable-http-post-trigger", false, "Enable or create the HTTP POST trigger") + cmd.Flags().BoolVar(&disableHTTPPostTrigger, "disable-http-post-trigger", false, "Disable the HTTP POST trigger") + cmd.Flags().BoolVar(&rotateHTTPPostToken, "rotate-http-post-token", false, "Rotate the HTTP POST trigger token") + registerEnumFlag(cmd, "schedule", "hourly", "daily", "weekly", "cron") + registerEnumFlag(cmd, "environment-kind", "cloud", "byoc") + return cmd +} + +func newAutomationDeleteCmd() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an Automation", + Long: `Delete an Automation rule. + +This is a destructive operation. Prompts for confirmation in an interactive +terminal unless --force is set. In non-interactive mode the command aborts +unless --force is provided.`, + Args: requireExactArg("rule_id"), + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + if !confirmAction(ctx.Cmd, fmt.Sprintf("Are you sure you want to delete Automation %s?", ctx.Args[0])) { + _, _ = fmt.Fprintln(ctx.Writer, "Aborted.") + return nil + } + _, _, err := ctx.Client.Automations.RuleWriteDelete(cmdContext(ctx.Cmd), &flashduty.AutomationRuleIDRequest{RuleID: ctx.Args[0]}) + if err != nil { + return err + } + ctx.WriteResult(fmt.Sprintf("Deleted Automation %s.", ctx.Args[0])) + return nil + }) + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + return cmd +} + +func newAutomationRunsCmd() *cobra.Command { + var ( + page int + limit int + since string + until string + status string + triggerKind string + ) + + cmd := &cobra.Command{ + Use: "runs ", + Short: "List Automation runs", + Long: curatedLong("List run history for a rule the caller can manage.", "Automations", "RunReadList"), + Args: requireExactArg("rule_id"), + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + req := &flashduty.AutomationRunListRequest{ + ListOptions: flashduty.ListOptions{Page: page, Limit: limit}, + RuleID: ctx.Args[0], + Status: strings.TrimSpace(status), + TriggerKind: strings.TrimSpace(triggerKind), + } + if v, ok, err := automationMillisFlag(cmd, "since", since); err != nil { + return err + } else if ok { + req.StartedAfterMs = v + } + if v, ok, err := automationMillisFlag(cmd, "until", until); err != nil { + return err + } else if ok { + req.StartedBeforeMs = v + } + out, _, err := ctx.Client.Automations.RunReadList(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + + cmd.Flags().IntVar(&page, "page", 1, "Page number") + cmd.Flags().IntVar(&limit, "limit", 20, "Page size, max 100") + cmd.Flags().StringVar(&since, "since", "", "Start-time lower bound; accepts duration, date, datetime, RFC3339, or unix seconds") + cmd.Flags().StringVar(&until, "until", "", "Start-time upper bound; accepts duration, date, datetime, RFC3339, or unix seconds") + cmd.Flags().StringVar(&status, "status", "", "Run status filter") + cmd.Flags().StringVar(&triggerKind, "trigger-kind", "", "Trigger kind filter: schedule, debug, or http_post") + registerEnumFlag(cmd, "status", "queued", "running", "retrying", "succeeded", "partial", "failed", "skipped", "abandoned") + registerEnumFlag(cmd, "trigger-kind", "schedule", "debug", "http_post") + return cmd +} + +func newAutomationTemplatesCmd() *cobra.Command { + var locale string + + cmd := &cobra.Command{ + Use: "templates", + Short: "List Automation templates", + Long: curatedLong("List preset Automation templates for the requested locale.", "Automations", "TemplateReadList"), + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + out, _, err := ctx.Client.Automations.TemplateReadList(cmdContext(ctx.Cmd), &flashduty.AutomationTemplateListRequest{Locale: strings.TrimSpace(locale)}) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + + cmd.Flags().StringVar(&locale, "locale", "", "Template locale such as zh-CN or en-US") + return cmd +} + +func newAutomationFireCmd() *cobra.Command { + return buildAutomationFireCmd("fire ") +} + +func newSafariAutomationTriggerFireCmd() *cobra.Command { + return buildAutomationFireCmd("automation-triggers-{trigger_id}-fire ") +} + +func buildAutomationFireCmd(use string) *cobra.Command { + var ( + token string + text string + dedupKey string + dataJSON string + ) + + cmd := &cobra.Command{ + Use: use, + Short: "Fire an Automation HTTP POST trigger", + Long: `Trigger an Automation run through its HTTP POST trigger. + +The trigger authenticates with its one-time token, not the account app key. Pass +--token or set FLASHDUTY_AUTOMATION_TRIGGER_TOKEN. Use --dedup-key to make +retries idempotent for the same trigger.`, + Args: requireExactArg("trigger_id"), + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + out, err := runAutomationFire(ctx, ctx.Args[0], token, text, dedupKey, dataJSON) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + + cmd.Flags().StringVar(&token, "token", "", "HTTP POST trigger token; defaults to FLASHDUTY_AUTOMATION_TRIGGER_TOKEN") + cmd.Flags().StringVar(&text, "text", "", "Context text passed to this run") + cmd.Flags().StringVar(&dedupKey, "dedup-key", "", "Optional idempotency key") + cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; typed flags override its fields. Accepts inline JSON, or - to read stdin.") + return cmd +} + +func runAutomationFire(ctx *RunContext, triggerID, token, text, dedupKey, dataJSON string) (*flashduty.AutomationFireAPITriggerResponse, error) { + body, err := genAssembleBody(dataJSON, func(body map[string]any) error { + if ctx.Cmd.Flags().Changed("text") { + body["text"] = text + } + if ctx.Cmd.Flags().Changed("dedup-key") { + body["dedup_key"] = dedupKey + } + return nil + }) + if err != nil { + return nil, err + } + req := new(flashduty.AutomationFireAPITriggerRequest) + if err := genBindBody(body, req); err != nil { + return nil, err + } + token = strings.TrimSpace(token) + if token == "" { + token = strings.TrimSpace(os.Getenv("FLASHDUTY_AUTOMATION_TRIGGER_TOKEN")) + } + if token == "" { + return nil, fmt.Errorf("--token or FLASHDUTY_AUTOMATION_TRIGGER_TOKEN is required") + } + out, _, err := ctx.Client.Automations.TriggerWriteFire(cmdContext(ctx.Cmd), triggerID, token, req) + if err != nil { + return nil, err + } + return out, nil +} + +func attachSafariAutomationTriggerFire(root *cobra.Command) { + safari := genGroup(root, "safari", "AI SRE API") + genAddLeaf(safari, newSafariAutomationTriggerFireCmd()) +} + +func resolveAutomationPrompt(cmd *cobra.Command, prompt, promptFile string) (string, error) { + promptChanged := cmd.Flags().Changed("prompt") + fileChanged := cmd.Flags().Changed("prompt-file") + if promptChanged && fileChanged { + return "", fmt.Errorf("only one of --prompt or --prompt-file may be set") + } + if fileChanged { + promptFile = strings.TrimSpace(promptFile) + if promptFile == "" { + return "", fmt.Errorf("--prompt-file must not be empty") + } + var ( + b []byte + err error + ) + if promptFile == "-" { + b, err = io.ReadAll(stdinReader) + } else { + b, err = os.ReadFile(promptFile) + } + if err != nil { + return "", fmt.Errorf("failed to read prompt file: %w", err) + } + return strings.TrimSpace(string(b)), nil + } + return strings.TrimSpace(prompt), nil +} + +func resolveAutomationCreateCron(cmd *cobra.Command, schedule, at, weekday, cronExpr string, httpPostTrigger bool, scheduleEnabled *bool) (string, error) { + if httpPostTrigger && !automationScheduleChanged(cmd) && !cmd.Flags().Changed("schedule-enabled") { + *scheduleEnabled = false + return automationHTTPPostOnlyCron, nil + } + return resolveAutomationCron(schedule, at, weekday, cronExpr) +} + +func automationScheduleChanged(cmd *cobra.Command) bool { + return cmd.Flags().Changed("schedule") || + cmd.Flags().Changed("at") || + cmd.Flags().Changed("weekday") || + cmd.Flags().Changed("cron-expr") +} + +func resolveAutomationCron(schedule, at, weekday, cronExpr string) (string, error) { + cronExpr = strings.TrimSpace(cronExpr) + if cronExpr != "" { + return cronExpr, nil + } + + schedule = strings.ToLower(strings.TrimSpace(schedule)) + if schedule == "" { + schedule = "daily" + } + + switch schedule { + case "hourly": + _, minute, err := parseAutomationAt(at, 0, 0) + if err != nil { + return "", err + } + return fmt.Sprintf("%d * * * *", minute), nil + case "daily": + hour, minute, err := parseAutomationAt(at, 9, 0) + if err != nil { + return "", err + } + return fmt.Sprintf("%d %d * * *", minute, hour), nil + case "weekly": + hour, minute, err := parseAutomationAt(at, 9, 0) + if err != nil { + return "", err + } + dow, err := parseAutomationWeekday(weekday) + if err != nil { + return "", err + } + return fmt.Sprintf("%d %d * * %d", minute, hour, dow), nil + case "cron": + return "", fmt.Errorf("--cron-expr is required when --schedule=cron") + default: + return "", fmt.Errorf("invalid --schedule %q (want hourly, daily, weekly, or cron)", schedule) + } +} + +func parseAutomationAt(raw string, defaultHour, defaultMinute int) (int, int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return defaultHour, defaultMinute, nil + } + parts := strings.Split(raw, ":") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("--at must be HH:MM") + } + hour, err := strconv.Atoi(parts[0]) + if err != nil || hour < 0 || hour > 23 { + return 0, 0, fmt.Errorf("--at hour must be 0-23") + } + minute, err := strconv.Atoi(parts[1]) + if err != nil || minute < 0 || minute > 59 { + return 0, 0, fmt.Errorf("--at minute must be 0-59") + } + return hour, minute, nil +} + +func parseAutomationWeekday(raw string) (int, error) { + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == "" { + return 1, nil + } + names := map[string]int{ + "sun": 0, "sunday": 0, + "mon": 1, "monday": 1, + "tue": 2, "tuesday": 2, + "wed": 3, "wednesday": 3, + "thu": 4, "thursday": 4, + "fri": 5, "friday": 5, + "sat": 6, "saturday": 6, + } + if v, ok := names[raw]; ok { + return v, nil + } + n, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("--weekday must be sun-sat or 0-7") + } + if n < 0 || n > 7 { + return 0, fmt.Errorf("--weekday must be sun-sat or 0-7") + } + if n == 7 { + return 0, nil + } + return n, nil +} + +func automationMillisFlag(cmd *cobra.Command, name, raw string) (int64, bool, error) { + if !cmd.Flags().Changed(name) { + return 0, false, nil + } + sec, err := timeutil.Parse(raw) + if err != nil { + return 0, false, fmt.Errorf("invalid --%s: %w", name, err) + } + return sec * 1000, true, nil +} diff --git a/internal/cli/automation_test.go b/internal/cli/automation_test.go new file mode 100644 index 0000000..889cfb6 --- /dev/null +++ b/internal/cli/automation_test.go @@ -0,0 +1,192 @@ +package cli + +import ( + "bytes" + "fmt" + "strings" + "testing" +) + +func TestAutomationCreateDailyDefaultsEnabled(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + _, err := execCommand( + "automation", "create", + "--name", "Daily SRE brief", + "--team-id", "123", + "--schedule", "daily", + "--at", "08:30", + "--prompt", "Summarize yesterday's incidents", + "--json", + ) + if err != nil { + t.Fatalf("[automation-create-daily] unexpected error: %v", err) + } + if stub.lastPath != "/safari/automation/rule/create" { + t.Fatalf("[automation-create-daily] path = %q", stub.lastPath) + } + assertBody(t, stub.lastBody, "name", "Daily SRE brief") + assertBody(t, stub.lastBody, "team_id", float64(123)) + assertBody(t, stub.lastBody, "cron_expr", "30 8 * * *") + assertBody(t, stub.lastBody, "enabled", true) + assertBody(t, stub.lastBody, "schedule_trigger_enabled", true) + assertBody(t, stub.lastBody, "prompt", "Summarize yesterday's incidents") +} + +func TestAutomationCreateHTTPPostOnly(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + _, err := execCommand( + "automation", "create", + "--name", "Webhook triage", + "--http-post-trigger", + "--prompt", "Handle the posted payload", + "--json", + ) + if err != nil { + t.Fatalf("[automation-create-post-only] unexpected error: %v", err) + } + assertBody(t, stub.lastBody, "cron_expr", automationHTTPPostOnlyCron) + assertBody(t, stub.lastBody, "enabled", true) + assertBody(t, stub.lastBody, "schedule_trigger_enabled", false) + assertBody(t, stub.lastBody, "http_post_trigger_enabled", true) +} + +func TestAutomationUpdateMutableFields(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stdinReader = strings.NewReader("updated prompt\n") + + _, err := execCommand( + "automation", "update", "auto_123", + "--disable", + "--disable-schedule", + "--enable-http-post-trigger", + "--rotate-http-post-token", + "--prompt-file", "-", + "--json", + ) + if err != nil { + t.Fatalf("[automation-update] unexpected error: %v", err) + } + if stub.lastPath != "/safari/automation/rule/update" { + t.Fatalf("[automation-update] path = %q", stub.lastPath) + } + assertBody(t, stub.lastBody, "rule_id", "auto_123") + assertBody(t, stub.lastBody, "enabled", false) + assertBody(t, stub.lastBody, "schedule_trigger_enabled", false) + assertBody(t, stub.lastBody, "http_post_trigger_enabled", true) + assertBody(t, stub.lastBody, "rotate_http_post_trigger_token", true) + assertBody(t, stub.lastBody, "prompt", "updated prompt") + if _, ok := stub.lastBody["team_id"]; ok { + t.Fatalf("[automation-update] team_id must not be sent by the friendly update command: %#v", stub.lastBody) + } +} + +func TestAutomationUpdateDoesNotExposeScopeFlag(t *testing.T) { + saveAndResetGlobals(t) + newGFStub(t) + + _, err := execCommand("automation", "update", "auto_123", "--team-id", "456") + if err == nil { + t.Fatal("[automation-update-scope] expected unknown flag error") + } + if !strings.Contains(err.Error(), "unknown flag: --team-id") { + t.Fatalf("[automation-update-scope] err = %v", err) + } +} + +func TestAutomationFireSendsBearerToken(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{ + "rule_id": "auto_123", + "run_id": "run_123", + "status": "queued", + "trigger_kind": "http_post", + } + + _, err := execCommand( + "automation", "fire", "auttrig_123", + "--token", "token-123", + "--text", "manual test", + "--dedup-key", "once", + "--json", + ) + if err != nil { + t.Fatalf("[automation-fire] unexpected error: %v", err) + } + if stub.lastPath != "/safari/automation/triggers/auttrig_123/fire" { + t.Fatalf("[automation-fire] path = %q", stub.lastPath) + } + if stub.lastAuthorization != "Bearer token-123" { + t.Fatalf("[automation-fire] authorization = %q", stub.lastAuthorization) + } + assertBody(t, stub.lastBody, "text", "manual test") + assertBody(t, stub.lastBody, "dedup_key", "once") +} + +func TestSafariAutomationTriggerFirePathCommand(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + _, err := execCommand( + "safari", "automation-triggers-{trigger_id}-fire", "auttrig_123", + "--token", "token-123", + "--data", `{"text":"from data"}`, + "--json", + ) + if err != nil { + t.Fatalf("[safari-automation-trigger-fire] unexpected error: %v", err) + } + if stub.lastPath != "/safari/automation/triggers/auttrig_123/fire" { + t.Fatalf("[safari-automation-trigger-fire] path = %q", stub.lastPath) + } + if stub.lastAuthorization != "Bearer token-123" { + t.Fatalf("[safari-automation-trigger-fire] authorization = %q", stub.lastAuthorization) + } + assertBody(t, stub.lastBody, "text", "from data") +} + +func TestAutomationCronHelpers(t *testing.T) { + tests := []struct { + name string + schedule string + at string + weekday string + cron string + want string + }{ + {name: "hourly", schedule: "hourly", at: "00:15", want: "15 * * * *"}, + {name: "daily default", schedule: "daily", want: "0 9 * * *"}, + {name: "weekly", schedule: "weekly", at: "10:05", weekday: "fri", want: "5 10 * * 5"}, + {name: "cron", cron: "7 8 * * 1", want: "7 8 * * 1"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveAutomationCron(tc.schedule, tc.at, tc.weekday, tc.cron) + if err != nil { + t.Fatalf("resolveAutomationCron() unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("resolveAutomationCron() = %q, want %q", got, tc.want) + } + }) + } +} + +func assertBody(t *testing.T, body map[string]any, key string, want any) { + t.Helper() + got, ok := body[key] + if !ok { + t.Fatalf("missing body[%q] in %#v", key, body) + } + if fmt.Sprint(got) != fmt.Sprint(want) { + var buf bytes.Buffer + fmt.Fprintf(&buf, "body[%q] = %#v, want %#v\nfull body: %#v", key, got, want, body) + t.Fatal(buf.String()) + } +} diff --git a/internal/cli/coverage_test.go b/internal/cli/coverage_test.go index 0abce8c..e225b56 100644 --- a/internal/cli/coverage_test.go +++ b/internal/cli/coverage_test.go @@ -18,6 +18,12 @@ type specOpMeta struct { streaming bool // 200 body is not application/json (e.g. application/x-ndjson) } +var curatedOperationIDs = map[string]bool{ + // Uses a path parameter plus bearer trigger token instead of the app_key + // generated command runtime. Served by a hand-written path command. + "automation-trigger-write-fire": true, +} + // loadSpecOps reads every public GET/POST operation from the openapi spec // shipped in the linked go-flashduty module — the same spec cligen generates // against — recording each op's id, path, and whether its 200 response is a @@ -143,16 +149,23 @@ func TestEveryOperationHasPathCommand(t *testing.T) { // stale run). Streaming ops (200 body is not application/json) are deliberately // excluded from generation — they cannot be modeled by the typed-response // template and are served by curated commands instead — so the manifest must NOT -// contain them and they are not required to be generated. +// contain them and they are not required to be generated. A small number of +// non-streaming operations can also be curated when the generated app_key +// runtime cannot model their auth or path shape. func TestGeneratorTargetsFullSpec(t *testing.T) { ops := loadSpecOps(t) streaming := map[string]bool{} + curated := map[string]bool{} wantGenerated := map[string]bool{} for _, op := range ops { if op.streaming { streaming[op.id] = true continue } + if curatedOperationIDs[op.id] { + curated[op.id] = true + continue + } wantGenerated[op.id] = true } @@ -162,7 +175,10 @@ func TestGeneratorTargetsFullSpec(t *testing.T) { if streaming[id] { t.Errorf("manifest op %q is streaming and must not be generated (curated only)", id) } - if !wantGenerated[id] && !streaming[id] { + if curated[id] { + t.Errorf("manifest op %q is curated and must not be generated", id) + } + if !wantGenerated[id] && !streaming[id] && !curated[id] { t.Errorf("manifest op %q is not in the current spec (regenerate cligen)", id) } } @@ -171,6 +187,6 @@ func TestGeneratorTargetsFullSpec(t *testing.T) { t.Errorf("op %q has no generated command (regenerate cligen)", id) } } - t.Logf("generator targets %d/%d non-streaming spec operations (%d streaming, curated)", - len(gen), len(wantGenerated), len(streaming)) + t.Logf("generator targets %d/%d non-streaming spec operations (%d streaming, %d curated)", + len(gen), len(wantGenerated), len(streaming), len(curated)) } diff --git a/internal/cli/display_columns.go b/internal/cli/display_columns.go index f270ee9..e648506 100644 --- a/internal/cli/display_columns.go +++ b/internal/cli/display_columns.go @@ -108,6 +108,32 @@ var displayColumns = map[string][]colSpec{ {Header: "CREATOR_ID", Field: "CreatorID"}, {Header: "STATUS", Field: "Status"}, }, + "AutomationRuleItem": { + {Header: "ID", Field: "RuleID"}, + {Header: "NAME", Field: "Name", MaxWidth: 40}, + {Header: "TEAM", Field: "TeamID"}, + {Header: "ENABLED", Field: "Enabled"}, + {Header: "SCHEDULE", Field: "ScheduleTriggerEnabled"}, + {Header: "POST", Field: "HTTPPostTriggerEnabled"}, + {Header: "CRON", Field: "CronExpr"}, + {Header: "UPDATED", Field: "UpdatedAt"}, + }, + "AutomationRunItem": { + {Header: "RUN_ID", Field: "RunID"}, + {Header: "RULE_ID", Field: "RuleID"}, + {Header: "STATUS", Field: "Status"}, + {Header: "TRIGGER", Field: "TriggerKind"}, + {Header: "STARTED", Field: "StartedAt"}, + {Header: "DURATION_MS", Field: "DurationMs"}, + {Header: "ATTEMPTS", Field: "Attempts"}, + {Header: "ERROR", Field: "ErrorMessage", MaxWidth: 50}, + }, + "AutomationTemplateItem": { + {Header: "NAME", Field: "Name", MaxWidth: 40}, + {Header: "ENABLED", Field: "Enabled"}, + {Header: "DESCRIPTION", Field: "Description", MaxWidth: 50}, + {Header: "PROMPT", Field: "Prompt", MaxWidth: 80}, + }, "TeamItem": { {Header: "ID", Field: "TeamID"}, {Header: "NAME", Field: "TeamName", MaxWidth: 40}, diff --git a/internal/cli/generic_table_test.go b/internal/cli/generic_table_test.go index eb6262e..5cc279e 100644 --- a/internal/cli/generic_table_test.go +++ b/internal/cli/generic_table_test.go @@ -174,6 +174,9 @@ var displayColumnSamples = []any{ flashduty.AlertEventItem{}, flashduty.ChangeItem{}, flashduty.ChannelItem{}, + flashduty.AutomationRuleItem{}, + flashduty.AutomationRunItem{}, + flashduty.AutomationTemplateItem{}, flashduty.TeamItem{}, flashduty.MemberItem{}, flashduty.FieldItem{}, diff --git a/internal/cli/gfstub_test.go b/internal/cli/gfstub_test.go index 77afcf5..c8b0143 100644 --- a/internal/cli/gfstub_test.go +++ b/internal/cli/gfstub_test.go @@ -23,6 +23,8 @@ type gfStub struct { lastPath string // lastBody is the decoded JSON body of the most recent request. lastBody map[string]any + // lastAuthorization is the Authorization header of the most recent request. + lastAuthorization string // bodies records the decoded body of every request, in order. bodies []map[string]any // requests counts how many requests reached the stub. @@ -54,6 +56,7 @@ func newGFStub(t *testing.T) *gfStub { s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.requests++ s.lastPath = r.URL.Path + s.lastAuthorization = r.Header.Get("Authorization") s.lastBody = nil if body, err := io.ReadAll(r.Body); err == nil && len(body) > 0 { _ = json.Unmarshal(body, &s.lastBody) diff --git a/internal/cli/root.go b/internal/cli/root.go index f243fb2..753d2b9 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -130,6 +130,7 @@ func init() { // AI agent sessions (list + transcript export). rootCmd.AddCommand(newSessionCmd()) + rootCmd.AddCommand(newAutomationCmd()) // Diagnostics entry points (value-add over the raw API). rootCmd.AddCommand(newMonitQueryCmd()) @@ -146,6 +147,7 @@ func init() { // its path-is-king leaf to the (now-existing) generated `safari` group so the // operation stays reachable at safari session-export. attachSafariSessionExport(rootCmd) + attachSafariAutomationTriggerFire(rootCmd) } // Execute runs the root command. diff --git a/internal/cli/zz_generated_a2a_agents.go b/internal/cli/zz_generated_a2a_agents.go index 7ec5620..6667e48 100644 --- a/internal/cli/zz_generated_a2a_agents.go +++ b/internal/cli/zz_generated_a2a_agents.go @@ -191,7 +191,7 @@ Request fields: --auth-mode string — Authentication mode: shared (default), per_user_secret, or per_user_oauth. --auth-type string — Authentication type for the remote agent. --card-url string (required) — URL of the remote agent card. - --description string — Agent description. + --description string — Agent description. (≤2000 chars) --oauth-metadata string — JSON OAuth metadata; reserved for per_user_oauth. --secret-schema string — JSON secret schema; required when auth_mode=per_user_secret. --streaming bool — Whether the remote agent supports streaming. @@ -253,7 +253,7 @@ Response fields ('data' envelope is unwrapped — these fields are at the top le cmd.Flags().StringVar(&fAuthMode, "auth-mode", "", "Authentication mode: shared (default), per_user_secret, or per_user_oauth.") cmd.Flags().StringVar(&fAuthType, "auth-type", "", "Authentication type for the remote agent.") cmd.Flags().StringVar(&fCardURL, "card-url", "", "URL of the remote agent card. (required)") - cmd.Flags().StringVar(&fDescription, "description", "", "Agent description.") + cmd.Flags().StringVar(&fDescription, "description", "", "Agent description. (≤2000 chars)") cmd.Flags().StringVar(&fOauthMetadata, "oauth-metadata", "", "JSON OAuth metadata; reserved for per_user_oauth.") cmd.Flags().StringVar(&fSecretSchema, "secret-schema", "", "JSON secret schema; required when auth_mode=per_user_secret.") cmd.Flags().BoolVar(&fStreaming, "streaming", false, "Whether the remote agent supports streaming.") @@ -433,7 +433,7 @@ Request fields: --auth-mode string — New auth mode: shared, per_user_secret, or per_user_oauth. --auth-type string — New auth type. Omit to leave unchanged. --card-url string — New card URL. Omit to leave unchanged. - --description string — New description. Omit to leave unchanged. + --description string — New description. Omit to leave unchanged. (≤2000 chars) --oauth-metadata string — New JSON OAuth metadata. --secret-schema string — New JSON secret schema. --streaming bool — Toggle streaming support. Omit to leave unchanged. @@ -500,7 +500,7 @@ Request fields: cmd.Flags().StringVar(&fAuthMode, "auth-mode", "", "New auth mode: shared, per_user_secret, or per_user_oauth.") cmd.Flags().StringVar(&fAuthType, "auth-type", "", "New auth type. Omit to leave unchanged.") cmd.Flags().StringVar(&fCardURL, "card-url", "", "New card URL. Omit to leave unchanged.") - cmd.Flags().StringVar(&fDescription, "description", "", "New description. Omit to leave unchanged.") + cmd.Flags().StringVar(&fDescription, "description", "", "New description. Omit to leave unchanged. (≤2000 chars)") cmd.Flags().StringVar(&fOauthMetadata, "oauth-metadata", "", "New JSON OAuth metadata.") cmd.Flags().StringVar(&fSecretSchema, "secret-schema", "", "New JSON secret schema.") cmd.Flags().BoolVar(&fStreaming, "streaming", false, "Toggle streaming support. Omit to leave unchanged.") diff --git a/internal/cli/zz_generated_automations.go b/internal/cli/zz_generated_automations.go new file mode 100644 index 0000000..e034343 --- /dev/null +++ b/internal/cli/zz_generated_automations.go @@ -0,0 +1,655 @@ +// Code generated by internal/cmd/cligen; DO NOT EDIT. + +package cli + +import ( + "github.com/spf13/cobra" + + flashduty "github.com/flashcatcloud/go-flashduty" +) + +func genAutomationsRuleReadGetCmd() *cobra.Command { + var dataJSON string + var fRuleID string + cmd := &cobra.Command{ + Use: "automation-rule-get ", + Short: "Get Automation rule", + Long: `Get Automation rule. + +Get one Automation rule by ID. + +API: POST /safari/automation/rule/get (automation-rule-read-get) + +Request fields: + --rule-id string (required) — Rule ID. + +Response fields ('data' envelope is unwrapped — these fields are at the top level): + - account_id (integer) (required) — Account ID. + - can_edit (boolean) (required) — Whether the caller can manage this rule. + - created_at (integer) (required) — Creation time, Unix milliseconds. + - cron_expr (string) (required) — Normalized 5-field cron expression. + - enabled (boolean) (required) — Whether the rule is enabled. + - environment_id (string) (required) — BYOC Runner ID. + - environment_kind (string) (required) — Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc] + - http_post_token (string) — HTTP POST trigger token. Returned only on create or token rotation; save it immediately. + - http_post_trigger_enabled (boolean) (required) — Whether the HTTP POST trigger is enabled. + - http_post_trigger_id (string) — HTTP POST trigger ID. + - http_post_trigger_url (string) — HTTP POST trigger path. + - name (string) (required) — Rule name. + - owner_id (integer) (required) — Creator person ID. + - prompt (string) (required) — Task prompt. + - rule_id (string) (required) — Rule ID. + - run_scope (string) (required) — Hidden session run scope. [person, team] + - schedule_trigger_enabled (boolean) (required) — Whether the schedule trigger is enabled. + - schedule_trigger_id (string) — Schedule trigger ID. + - team_id (integer) (required) — Scope team ID; 0 means personal rule. + - updated_at (integer) (required) — Last update time, Unix milliseconds. +`, + Args: requireExactArg("rule_id"), + Example: ` flashduty safari automation-rule-get --data '{"rule_id":"auto_7NnLzY2Qp8xS4kUaV3mR6b"}'`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + body, err := genAssembleBody(dataJSON, func(body map[string]any) error { + if err := genFoldPositional(args, body, "rule_id", "string"); err != nil { + return err + } + if cmd.Flags().Changed("rule-id") { + body["rule_id"] = fRuleID + } + return nil + }) + if err != nil { + return err + } + req := new(flashduty.AutomationRuleIDRequest) + if err := genBindBody(body, req); err != nil { + return err + } + out, _, err := ctx.Client.Automations.RuleReadGet(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + cmd.Flags().StringVar(&fRuleID, "rule-id", "", "Rule ID. (required)") + cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.") + return cmd +} + +func genAutomationsRuleReadListCmd() *cobra.Command { + var dataJSON string + var fP int64 + var fLimit int64 + var fSearchAfterCtx string + var fEnabled bool + var fIncludePerson bool + var fKeyword string + var fScope string + var fTeamIDs []int + cmd := &cobra.Command{ + Use: "automation-rule-list", + Short: "List Automation rules", + Long: `List Automation rules. + +List Automation rules visible to the caller. + +API: POST /safari/automation/rule/list (automation-rule-read-list) + +Request fields: + --page int — Page number, 1-based. + --limit int — Page size. (max 100) + --search-after-ctx string + --enabled bool — Filter by enabled status. + --include-person bool — Compatibility field; when scope is empty and this is false, behaves like team scope. + --keyword string — Filter by name keyword. (≤64 chars) + --scope string — Scope filter. Defaults to all. [all, personal, team] + --team-ids []int — Filter to these team IDs; this filters results and does not expand access. + +Response fields ('data' envelope is unwrapped — these fields are at the top level): + - rules (array) (required) + - account_id (integer) (required) — Account ID. + - can_edit (boolean) (required) — Whether the caller can manage this rule. + - created_at (integer) (required) — Creation time, Unix milliseconds. + - cron_expr (string) (required) — Normalized 5-field cron expression. + - enabled (boolean) (required) — Whether the rule is enabled. + - environment_id (string) (required) — BYOC Runner ID. + - environment_kind (string) (required) — Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc] + - http_post_token (string) — HTTP POST trigger token. Returned only on create or token rotation; save it immediately. + - http_post_trigger_enabled (boolean) (required) — Whether the HTTP POST trigger is enabled. + - http_post_trigger_id (string) — HTTP POST trigger ID. + - http_post_trigger_url (string) — HTTP POST trigger path. + - name (string) (required) — Rule name. + - owner_id (integer) (required) — Creator person ID. + - prompt (string) (required) — Task prompt. + - rule_id (string) (required) — Rule ID. + - run_scope (string) (required) — Hidden session run scope. [person, team] + - schedule_trigger_enabled (boolean) (required) — Whether the schedule trigger is enabled. + - schedule_trigger_id (string) — Schedule trigger ID. + - team_id (integer) (required) — Scope team ID; 0 means personal rule. + - updated_at (integer) (required) — Last update time, Unix milliseconds. + - total (integer) (required) — Total count. +`, + Example: ` flashduty safari automation-rule-list --data '{"limit":20,"scope":"all"}'`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + body, err := genAssembleBody(dataJSON, func(body map[string]any) error { + if cmd.Flags().Changed("page") { + body["p"] = fP + } + if cmd.Flags().Changed("limit") { + body["limit"] = fLimit + } + if cmd.Flags().Changed("search-after-ctx") { + body["search_after_ctx"] = fSearchAfterCtx + } + if cmd.Flags().Changed("enabled") { + body["enabled"] = fEnabled + } + if cmd.Flags().Changed("include-person") { + body["include_person"] = fIncludePerson + } + if cmd.Flags().Changed("keyword") { + body["keyword"] = fKeyword + } + if cmd.Flags().Changed("scope") { + body["scope"] = fScope + } + if cmd.Flags().Changed("team-ids") { + body["team_ids"] = fTeamIDs + } + return nil + }) + if err != nil { + return err + } + req := new(flashduty.AutomationRuleListRequest) + if err := genBindBody(body, req); err != nil { + return err + } + out, _, err := ctx.Client.Automations.RuleReadList(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + cmd.Flags().Int64Var(&fP, "page", 0, "Page number, 1-based.") + cmd.Flags().Int64Var(&fLimit, "limit", 0, "Page size. (max 100)") + cmd.Flags().StringVar(&fSearchAfterCtx, "search-after-ctx", "", "Request field ") + cmd.Flags().BoolVar(&fEnabled, "enabled", false, "Filter by enabled status.") + cmd.Flags().BoolVar(&fIncludePerson, "include-person", false, "Compatibility field; when scope is empty and this is false, behaves like team scope.") + cmd.Flags().StringVar(&fKeyword, "keyword", "", "Filter by name keyword. (≤64 chars)") + cmd.Flags().StringVar(&fScope, "scope", "", "Scope filter. Defaults to all. [all, personal, team]") + cmd.Flags().IntSliceVar(&fTeamIDs, "team-ids", nil, "Filter to these team IDs; this filters results and does not expand access.") + cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.") + return cmd +} + +func genAutomationsRuleWriteCreateCmd() *cobra.Command { + var dataJSON string + var fCronExpr string + var fEnabled bool + var fEnvironmentID string + var fEnvironmentKind string + var fHTTPPostTriggerEnabled bool + var fName string + var fPrompt string + var fScheduleTriggerEnabled bool + var fTeamID int64 + cmd := &cobra.Command{ + Use: "automation-rule-create", + Short: "Create Automation rule", + Long: `Create Automation rule. + +Create an Automation rule with a schedule trigger and, optionally, an HTTP POST trigger. + +API: POST /safari/automation/rule/create (automation-rule-write-create) + +Request fields: + --cron-expr string (required) — Run cadence. Supports 4 fields ('hour day month weekday', minute defaults to 0) and 5 fields ('minute hour day month weekday'). The minute must be one fixed integer; 6-field seconds are not supported. The create API currently requires this field even for HTTP-POST-only rules; send a valid cron and set 'schedule_trigger_enabled=false'. + --enabled bool — Whether the rule is enabled after creation. Omitted API value is false; Chat/CLI create sends true by default unless the user asks for disabled. + --environment-id string — BYOC Runner ID. Used only when 'environment_kind=byoc'. + --environment-kind string — Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc] + --http-post-trigger-enabled bool — Whether to create and enable an HTTP POST trigger. When enabled, the response includes a one-time token. + --name string (required) — Rule name. (1-255 chars) + --prompt string (required) — Task prompt sent to the AI SRE agent on each run. (≥1 chars) + --schedule-trigger-enabled bool — Whether the schedule trigger is enabled. Defaults to true when omitted; HTTP-POST-only rules should send false. + --team-id int — Scope team ID. 0 or omitted means a personal rule; >0 means a team in the account. Immutable after creation. (min 0) + +Response fields ('data' envelope is unwrapped — these fields are at the top level): + - account_id (integer) (required) — Account ID. + - can_edit (boolean) (required) — Whether the caller can manage this rule. + - created_at (integer) (required) — Creation time, Unix milliseconds. + - cron_expr (string) (required) — Normalized 5-field cron expression. + - enabled (boolean) (required) — Whether the rule is enabled. + - environment_id (string) (required) — BYOC Runner ID. + - environment_kind (string) (required) — Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc] + - http_post_token (string) — HTTP POST trigger token. Returned only on create or token rotation; save it immediately. + - http_post_trigger_enabled (boolean) (required) — Whether the HTTP POST trigger is enabled. + - http_post_trigger_id (string) — HTTP POST trigger ID. + - http_post_trigger_url (string) — HTTP POST trigger path. + - name (string) (required) — Rule name. + - owner_id (integer) (required) — Creator person ID. + - prompt (string) (required) — Task prompt. + - rule_id (string) (required) — Rule ID. + - run_scope (string) (required) — Hidden session run scope. [person, team] + - schedule_trigger_enabled (boolean) (required) — Whether the schedule trigger is enabled. + - schedule_trigger_id (string) — Schedule trigger ID. + - team_id (integer) (required) — Scope team ID; 0 means personal rule. + - updated_at (integer) (required) — Last update time, Unix milliseconds. +`, + Example: ` flashduty safari automation-rule-create --data '{"cron_expr":"0 9 * * 1","enabled":true,"http_post_trigger_enabled":true,"name":"Weekly on-call review","prompt":"Summarize last week'\''s alert noise and escalation load.","schedule_trigger_enabled":true,"team_id":123}'`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + body, err := genAssembleBody(dataJSON, func(body map[string]any) error { + if cmd.Flags().Changed("cron-expr") { + body["cron_expr"] = fCronExpr + } + if cmd.Flags().Changed("enabled") { + body["enabled"] = fEnabled + } + if cmd.Flags().Changed("environment-id") { + body["environment_id"] = fEnvironmentID + } + if cmd.Flags().Changed("environment-kind") { + body["environment_kind"] = fEnvironmentKind + } + if cmd.Flags().Changed("http-post-trigger-enabled") { + body["http_post_trigger_enabled"] = fHTTPPostTriggerEnabled + } + if cmd.Flags().Changed("name") { + body["name"] = fName + } + if cmd.Flags().Changed("prompt") { + body["prompt"] = fPrompt + } + if cmd.Flags().Changed("schedule-trigger-enabled") { + body["schedule_trigger_enabled"] = fScheduleTriggerEnabled + } + if cmd.Flags().Changed("team-id") { + body["team_id"] = fTeamID + } + return nil + }) + if err != nil { + return err + } + req := new(flashduty.AutomationRuleCreateRequest) + if err := genBindBody(body, req); err != nil { + return err + } + out, _, err := ctx.Client.Automations.RuleWriteCreate(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + cmd.Flags().StringVar(&fCronExpr, "cron-expr", "", "Run cadence. Supports 4 fields ('hour day month weekday', minute defaults to 0) and 5 fields ('minute hour day month weekday'). The minute must be one fixed integer; 6-field seconds are not supported. The create API currently requires this field even for HTTP-POST-only rules; send a valid cron and set 'schedule_trigger_enabled=false'. (required)") + cmd.Flags().BoolVar(&fEnabled, "enabled", false, "Whether the rule is enabled after creation. Omitted API value is false; Chat/CLI create sends true by default unless the user asks for disabled.") + cmd.Flags().StringVar(&fEnvironmentID, "environment-id", "", "BYOC Runner ID. Used only when 'environment_kind=byoc'.") + cmd.Flags().StringVar(&fEnvironmentKind, "environment-kind", "", "Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc]") + cmd.Flags().BoolVar(&fHTTPPostTriggerEnabled, "http-post-trigger-enabled", false, "Whether to create and enable an HTTP POST trigger. When enabled, the response includes a one-time token.") + cmd.Flags().StringVar(&fName, "name", "", "Rule name. (required) (1-255 chars)") + cmd.Flags().StringVar(&fPrompt, "prompt", "", "Task prompt sent to the AI SRE agent on each run. (required) (≥1 chars)") + cmd.Flags().BoolVar(&fScheduleTriggerEnabled, "schedule-trigger-enabled", false, "Whether the schedule trigger is enabled. Defaults to true when omitted; HTTP-POST-only rules should send false.") + cmd.Flags().Int64Var(&fTeamID, "team-id", 0, "Scope team ID. 0 or omitted means a personal rule; >0 means a team in the account. Immutable after creation. (min 0)") + cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.") + return cmd +} + +func genAutomationsRuleWriteDeleteCmd() *cobra.Command { + var dataJSON string + var fRuleID string + cmd := &cobra.Command{ + Use: "automation-rule-delete ", + Short: "Delete Automation rule", + Long: `Delete Automation rule. + +Delete an Automation rule. + +API: POST /safari/automation/rule/delete (automation-rule-write-delete) + +Request fields: + --rule-id string (required) — Rule ID. +`, + Args: requireExactArg("rule_id"), + Example: ` flashduty safari automation-rule-delete --data '{"rule_id":"auto_7NnLzY2Qp8xS4kUaV3mR6b"}'`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + body, err := genAssembleBody(dataJSON, func(body map[string]any) error { + if err := genFoldPositional(args, body, "rule_id", "string"); err != nil { + return err + } + if cmd.Flags().Changed("rule-id") { + body["rule_id"] = fRuleID + } + return nil + }) + if err != nil { + return err + } + req := new(flashduty.AutomationRuleIDRequest) + if err := genBindBody(body, req); err != nil { + return err + } + out, _, err := ctx.Client.Automations.RuleWriteDelete(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + cmd.Flags().StringVar(&fRuleID, "rule-id", "", "Rule ID. (required)") + cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.") + return cmd +} + +func genAutomationsRuleWriteUpdateCmd() *cobra.Command { + var dataJSON string + var fRuleID string + var fName string + var fTeamID int64 + var fEnabled bool + var fCronExpr string + var fScheduleTriggerEnabled bool + var fPrompt string + var fEnvironmentKind string + var fEnvironmentID string + var fHTTPPostTriggerEnabled bool + var fRotateHTTPPostTriggerToken bool + cmd := &cobra.Command{ + Use: "automation-rule-update ", + Short: "Update Automation rule", + Long: `Update Automation rule. + +Update mutable fields on an Automation rule. The personal/team scope is immutable. + +API: POST /safari/automation/rule/update (automation-rule-write-update) + +Request fields: + --rule-id string (required) — Target rule ID. + --name string — New rule name. (≤255 chars) + --team-id int — Only the current value is accepted; personal/team scope is immutable after creation. (min 0) + --enabled bool — Whether the rule is enabled. + --cron-expr string — Run cadence. Supports 4 fields ('hour day month weekday', minute defaults to 0) and 5 fields ('minute hour day month weekday'). The minute must be one fixed integer; 6-field seconds are not supported. The create API currently requires this field even for HTTP-POST-only rules; send a valid cron and set 'schedule_trigger_enabled=false'. + --schedule-trigger-enabled bool — Whether the schedule trigger is enabled. + --prompt string — New task prompt. + --environment-kind string — Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc] + --environment-id string — BYOC Runner ID. + --http-post-trigger-enabled bool — Whether the HTTP POST trigger is enabled. Sending true creates one when missing. + --rotate-http-post-trigger-token bool — Whether to rotate the HTTP POST trigger token. The new token is returned only in this response. + +Response fields ('data' envelope is unwrapped — these fields are at the top level): + - account_id (integer) (required) — Account ID. + - can_edit (boolean) (required) — Whether the caller can manage this rule. + - created_at (integer) (required) — Creation time, Unix milliseconds. + - cron_expr (string) (required) — Normalized 5-field cron expression. + - enabled (boolean) (required) — Whether the rule is enabled. + - environment_id (string) (required) — BYOC Runner ID. + - environment_kind (string) (required) — Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc] + - http_post_token (string) — HTTP POST trigger token. Returned only on create or token rotation; save it immediately. + - http_post_trigger_enabled (boolean) (required) — Whether the HTTP POST trigger is enabled. + - http_post_trigger_id (string) — HTTP POST trigger ID. + - http_post_trigger_url (string) — HTTP POST trigger path. + - name (string) (required) — Rule name. + - owner_id (integer) (required) — Creator person ID. + - prompt (string) (required) — Task prompt. + - rule_id (string) (required) — Rule ID. + - run_scope (string) (required) — Hidden session run scope. [person, team] + - schedule_trigger_enabled (boolean) (required) — Whether the schedule trigger is enabled. + - schedule_trigger_id (string) — Schedule trigger ID. + - team_id (integer) (required) — Scope team ID; 0 means personal rule. + - updated_at (integer) (required) — Last update time, Unix milliseconds. +`, + Args: requireExactArg("rule_id"), + Example: ` flashduty safari automation-rule-update --data '{"cron_expr":"15 9 * * 1","enabled":true,"rotate_http_post_trigger_token":true,"rule_id":"auto_7NnLzY2Qp8xS4kUaV3mR6b"}'`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + body, err := genAssembleBody(dataJSON, func(body map[string]any) error { + if err := genFoldPositional(args, body, "rule_id", "string"); err != nil { + return err + } + if cmd.Flags().Changed("rule-id") { + body["rule_id"] = fRuleID + } + if cmd.Flags().Changed("name") { + body["name"] = fName + } + if cmd.Flags().Changed("team-id") { + body["team_id"] = fTeamID + } + if cmd.Flags().Changed("enabled") { + body["enabled"] = fEnabled + } + if cmd.Flags().Changed("cron-expr") { + body["cron_expr"] = fCronExpr + } + if cmd.Flags().Changed("schedule-trigger-enabled") { + body["schedule_trigger_enabled"] = fScheduleTriggerEnabled + } + if cmd.Flags().Changed("prompt") { + body["prompt"] = fPrompt + } + if cmd.Flags().Changed("environment-kind") { + body["environment_kind"] = fEnvironmentKind + } + if cmd.Flags().Changed("environment-id") { + body["environment_id"] = fEnvironmentID + } + if cmd.Flags().Changed("http-post-trigger-enabled") { + body["http_post_trigger_enabled"] = fHTTPPostTriggerEnabled + } + if cmd.Flags().Changed("rotate-http-post-trigger-token") { + body["rotate_http_post_trigger_token"] = fRotateHTTPPostTriggerToken + } + return nil + }) + if err != nil { + return err + } + req := new(flashduty.AutomationRuleUpdateRequest) + if err := genBindBody(body, req); err != nil { + return err + } + out, _, err := ctx.Client.Automations.RuleWriteUpdate(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + cmd.Flags().StringVar(&fRuleID, "rule-id", "", "Target rule ID. (required)") + cmd.Flags().StringVar(&fName, "name", "", "New rule name. (≤255 chars)") + cmd.Flags().Int64Var(&fTeamID, "team-id", 0, "Only the current value is accepted; personal/team scope is immutable after creation. (min 0)") + cmd.Flags().BoolVar(&fEnabled, "enabled", false, "Whether the rule is enabled.") + cmd.Flags().StringVar(&fCronExpr, "cron-expr", "", "Run cadence. Supports 4 fields ('hour day month weekday', minute defaults to 0) and 5 fields ('minute hour day month weekday'). The minute must be one fixed integer; 6-field seconds are not supported. The create API currently requires this field even for HTTP-POST-only rules; send a valid cron and set 'schedule_trigger_enabled=false'.") + cmd.Flags().BoolVar(&fScheduleTriggerEnabled, "schedule-trigger-enabled", false, "Whether the schedule trigger is enabled.") + cmd.Flags().StringVar(&fPrompt, "prompt", "", "New task prompt.") + cmd.Flags().StringVar(&fEnvironmentKind, "environment-kind", "", "Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc]") + cmd.Flags().StringVar(&fEnvironmentID, "environment-id", "", "BYOC Runner ID.") + cmd.Flags().BoolVar(&fHTTPPostTriggerEnabled, "http-post-trigger-enabled", false, "Whether the HTTP POST trigger is enabled. Sending true creates one when missing.") + cmd.Flags().BoolVar(&fRotateHTTPPostTriggerToken, "rotate-http-post-trigger-token", false, "Whether to rotate the HTTP POST trigger token. The new token is returned only in this response.") + cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.") + return cmd +} + +func genAutomationsRunReadListCmd() *cobra.Command { + var dataJSON string + var fP int64 + var fLimit int64 + var fSearchAfterCtx string + var fRuleID string + var fStartedAfterMs int64 + var fStartedBeforeMs int64 + var fStatus string + var fTriggerKind string + cmd := &cobra.Command{ + Use: "automation-run-list ", + Short: "List Automation runs", + Long: `List Automation runs. + +List run history for a rule the caller can manage. + +API: POST /safari/automation/run/list (automation-run-read-list) + +Request fields: + --page int — Page number, 1-based. + --limit int — Page size. (max 100) + --search-after-ctx string + --rule-id string (required) — Target rule ID. + --started-after-ms int — Start-time lower bound, Unix milliseconds. + --started-before-ms int — Start-time upper bound, Unix milliseconds. + --status string — Run status filter. [queued, running, retrying, succeeded, partial, failed, skipped, abandoned] + --trigger-kind string — Trigger kind filter. [schedule, debug, http_post] + +Response fields ('data' envelope is unwrapped — these fields are at the top level): + - runs (array) (required) + - account_id (integer) (required) — Account ID. + - attempts (integer) (required) — Attempt count. + - completed_at (integer) (required) — Completion time, Unix milliseconds. 0 means not completed. + - created_at (integer) (required) — Creation time, Unix milliseconds. + - duration_ms (integer) (required) — Duration in milliseconds. + - error_code (string) — Error code. + - error_message (string) — Error message. + - kind (string) (required) — Run kind. + - occurrence_key (string) (required) — Idempotency key for this occurrence. + - result_json (any) — Run result JSON. + - rule_id (string) (required) — Rule ID. + - run_id (string) (required) — Run ID. + - started_at (integer) (required) — Start time, Unix milliseconds. + - stats_json (any) — Run stats JSON. + - status (string) (required) — Run status. [queued, running, retrying, succeeded, partial, failed, skipped, abandoned] + - trigger_kind (string) (required) — Trigger kind. [schedule, debug, http_post] + - updated_at (integer) (required) — Last update time, Unix milliseconds. + - total (integer) (required) — Total count. +`, + Args: requireExactArg("rule_id"), + Example: ` flashduty safari automation-run-list --data '{"limit":20,"rule_id":"auto_7NnLzY2Qp8xS4kUaV3mR6b","trigger_kind":"schedule"}'`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + body, err := genAssembleBody(dataJSON, func(body map[string]any) error { + if err := genFoldPositional(args, body, "rule_id", "string"); err != nil { + return err + } + if cmd.Flags().Changed("page") { + body["p"] = fP + } + if cmd.Flags().Changed("limit") { + body["limit"] = fLimit + } + if cmd.Flags().Changed("search-after-ctx") { + body["search_after_ctx"] = fSearchAfterCtx + } + if cmd.Flags().Changed("rule-id") { + body["rule_id"] = fRuleID + } + if cmd.Flags().Changed("started-after-ms") { + body["started_after_ms"] = fStartedAfterMs + } + if cmd.Flags().Changed("started-before-ms") { + body["started_before_ms"] = fStartedBeforeMs + } + if cmd.Flags().Changed("status") { + body["status"] = fStatus + } + if cmd.Flags().Changed("trigger-kind") { + body["trigger_kind"] = fTriggerKind + } + return nil + }) + if err != nil { + return err + } + req := new(flashduty.AutomationRunListRequest) + if err := genBindBody(body, req); err != nil { + return err + } + out, _, err := ctx.Client.Automations.RunReadList(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + cmd.Flags().Int64Var(&fP, "page", 0, "Page number, 1-based.") + cmd.Flags().Int64Var(&fLimit, "limit", 0, "Page size. (max 100)") + cmd.Flags().StringVar(&fSearchAfterCtx, "search-after-ctx", "", "Request field ") + cmd.Flags().StringVar(&fRuleID, "rule-id", "", "Target rule ID. (required)") + cmd.Flags().Int64Var(&fStartedAfterMs, "started-after-ms", 0, "Start-time lower bound, Unix milliseconds.") + cmd.Flags().Int64Var(&fStartedBeforeMs, "started-before-ms", 0, "Start-time upper bound, Unix milliseconds.") + cmd.Flags().StringVar(&fStatus, "status", "", "Run status filter. [queued, running, retrying, succeeded, partial, failed, skipped, abandoned]") + cmd.Flags().StringVar(&fTriggerKind, "trigger-kind", "", "Trigger kind filter. [schedule, debug, http_post]") + cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.") + return cmd +} + +func genAutomationsTemplateReadListCmd() *cobra.Command { + var dataJSON string + var fLocale string + cmd := &cobra.Command{ + Use: "automation-template-list", + Short: "List Automation templates", + Long: `List Automation templates. + +List preset Automation templates for the requested locale. + +API: POST /safari/automation/template/list (automation-template-read-list) + +Request fields: + --locale string — Template locale such as zh-CN or en-US. Omit to detect from the request locale. (≤16 chars) + +Response fields ('data' envelope is unwrapped — these fields are at the top level): + - templates (array) (required) + - description (string) (required) — Template description. + - enabled (boolean) (required) — Whether the template is enabled. + - icon (string) (required) — Icon identifier. + - name (string) (required) — Template name. + - prompt (string) (required) — Template prompt. +`, + Example: ` flashduty safari automation-template-list --data '{"locale":"en-US"}'`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + body, err := genAssembleBody(dataJSON, func(body map[string]any) error { + if cmd.Flags().Changed("locale") { + body["locale"] = fLocale + } + return nil + }) + if err != nil { + return err + } + req := new(flashduty.AutomationTemplateListRequest) + if err := genBindBody(body, req); err != nil { + return err + } + out, _, err := ctx.Client.Automations.TemplateReadList(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + cmd.Flags().StringVar(&fLocale, "locale", "", "Template locale such as zh-CN or en-US. Omit to detect from the request locale. (≤16 chars)") + cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.") + return cmd +} + +func registerGeneratedAutomations(root *cobra.Command) { + gSafari := genGroup(root, "safari", "AI SRE API") + genAddLeaf(gSafari, genAutomationsRuleReadGetCmd()) + genAddLeaf(gSafari, genAutomationsRuleReadListCmd()) + genAddLeaf(gSafari, genAutomationsRuleWriteCreateCmd()) + genAddLeaf(gSafari, genAutomationsRuleWriteDeleteCmd()) + genAddLeaf(gSafari, genAutomationsRuleWriteUpdateCmd()) + genAddLeaf(gSafari, genAutomationsRunReadListCmd()) + genAddLeaf(gSafari, genAutomationsTemplateReadListCmd()) +} diff --git a/internal/cli/zz_generated_manifest.go b/internal/cli/zz_generated_manifest.go index 5e693d3..b0f913d 100644 --- a/internal/cli/zz_generated_manifest.go +++ b/internal/cli/zz_generated_manifest.go @@ -18,6 +18,13 @@ var generatedOpIDs = []string{ "alert-write-pipeline-upsert", "audit-read-operation-list", "audit-read-search", + "automation-rule-read-get", + "automation-rule-read-list", + "automation-rule-write-create", + "automation-rule-write-delete", + "automation-rule-write-update", + "automation-run-read-list", + "automation-template-read-list", "calEventDelete", "calEventList", "calEventUpsert", diff --git a/internal/cli/zz_generated_register.go b/internal/cli/zz_generated_register.go index e577113..626311d 100644 --- a/internal/cli/zz_generated_register.go +++ b/internal/cli/zz_generated_register.go @@ -8,6 +8,7 @@ import "github.com/spf13/cobra" // from root.go init() after curated commands so curated leaves win on conflict. func registerGenerated(root *cobra.Command) { registerGeneratedA2aAgents(root) + registerGeneratedAutomations(root) registerGeneratedMcpServers(root) registerGeneratedSessions(root) registerGeneratedSkills(root) diff --git a/internal/cli/zz_generated_response_help.go b/internal/cli/zz_generated_response_help.go index 358496d..cc33740 100644 --- a/internal/cli/zz_generated_response_help.go +++ b/internal/cli/zz_generated_response_help.go @@ -58,6 +58,12 @@ var responseHelpBySDKMethod = map[string]string{ "Applications.WriteCreate": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - application_id (string) — Auto-generated unique application ID.\n - application_name (string) — Application display name.\n - client_token (string) — Token for RUM SDK initialization.\n", "AuditLogs.OperationList": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - name (string) (required) — Stable machine-readable operation name for use as a filter.\n - name_cn (string) (required) — Human-readable Chinese label shown in the console.\n", "AuditLogs.Search": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer) (required) — ID of the account.\n - body (string) (required) — JSON-encoded request body (may be truncated at 10 KB).\n - created_at (integer) (required) — Timestamp of the operation in Unix epoch milliseconds.\n - ip (string) (required) — Client IP address of the caller.\n - is_dangerous (boolean) (required) — True if this is flagged as a high-risk operation.\n - is_write (boolean) (required) — True for mutating operations; false for read-only ones.\n - member_id (integer) (required) — ID of the member who performed the action.\n - member_name (string) (required) — Display name of the member.\n - operation (string) (required) — Stable machine-readable operation name, e.g. `template:write:create`.\n - operation_name (string) (required) — Human-readable operation label in the account's locale.\n - params (array) (required) — URL path parameters as an array of key-value pairs, or an empty array when none.\n - Key (string)\n - Value (string)\n - request_id (string) (required) — Unique request ID for correlation.\n", + "Automations.RuleReadGet": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - account_id (integer) (required) — Account ID.\n - can_edit (boolean) (required) — Whether the caller can manage this rule.\n - created_at (integer) (required) — Creation time, Unix milliseconds.\n - cron_expr (string) (required) — Normalized 5-field cron expression.\n - enabled (boolean) (required) — Whether the rule is enabled.\n - environment_id (string) (required) — BYOC Runner ID.\n - environment_kind (string) (required) — Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc]\n - http_post_token (string) — HTTP POST trigger token. Returned only on create or token rotation; save it immediately.\n - http_post_trigger_enabled (boolean) (required) — Whether the HTTP POST trigger is enabled.\n - http_post_trigger_id (string) — HTTP POST trigger ID.\n - http_post_trigger_url (string) — HTTP POST trigger path.\n - name (string) (required) — Rule name.\n - owner_id (integer) (required) — Creator person ID.\n - prompt (string) (required) — Task prompt.\n - rule_id (string) (required) — Rule ID.\n - run_scope (string) (required) — Hidden session run scope. [person, team]\n - schedule_trigger_enabled (boolean) (required) — Whether the schedule trigger is enabled.\n - schedule_trigger_id (string) — Schedule trigger ID.\n - team_id (integer) (required) — Scope team ID; 0 means personal rule.\n - updated_at (integer) (required) — Last update time, Unix milliseconds.\n", + "Automations.RuleReadList": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - rules (array) (required)\n - account_id (integer) (required) — Account ID.\n - can_edit (boolean) (required) — Whether the caller can manage this rule.\n - created_at (integer) (required) — Creation time, Unix milliseconds.\n - cron_expr (string) (required) — Normalized 5-field cron expression.\n - enabled (boolean) (required) — Whether the rule is enabled.\n - environment_id (string) (required) — BYOC Runner ID.\n - environment_kind (string) (required) — Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc]\n - http_post_token (string) — HTTP POST trigger token. Returned only on create or token rotation; save it immediately.\n - http_post_trigger_enabled (boolean) (required) — Whether the HTTP POST trigger is enabled.\n - http_post_trigger_id (string) — HTTP POST trigger ID.\n - http_post_trigger_url (string) — HTTP POST trigger path.\n - name (string) (required) — Rule name.\n - owner_id (integer) (required) — Creator person ID.\n - prompt (string) (required) — Task prompt.\n - rule_id (string) (required) — Rule ID.\n - run_scope (string) (required) — Hidden session run scope. [person, team]\n - schedule_trigger_enabled (boolean) (required) — Whether the schedule trigger is enabled.\n - schedule_trigger_id (string) — Schedule trigger ID.\n - team_id (integer) (required) — Scope team ID; 0 means personal rule.\n - updated_at (integer) (required) — Last update time, Unix milliseconds.\n - total (integer) (required) — Total count.\n", + "Automations.RuleWriteCreate": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - account_id (integer) (required) — Account ID.\n - can_edit (boolean) (required) — Whether the caller can manage this rule.\n - created_at (integer) (required) — Creation time, Unix milliseconds.\n - cron_expr (string) (required) — Normalized 5-field cron expression.\n - enabled (boolean) (required) — Whether the rule is enabled.\n - environment_id (string) (required) — BYOC Runner ID.\n - environment_kind (string) (required) — Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc]\n - http_post_token (string) — HTTP POST trigger token. Returned only on create or token rotation; save it immediately.\n - http_post_trigger_enabled (boolean) (required) — Whether the HTTP POST trigger is enabled.\n - http_post_trigger_id (string) — HTTP POST trigger ID.\n - http_post_trigger_url (string) — HTTP POST trigger path.\n - name (string) (required) — Rule name.\n - owner_id (integer) (required) — Creator person ID.\n - prompt (string) (required) — Task prompt.\n - rule_id (string) (required) — Rule ID.\n - run_scope (string) (required) — Hidden session run scope. [person, team]\n - schedule_trigger_enabled (boolean) (required) — Whether the schedule trigger is enabled.\n - schedule_trigger_id (string) — Schedule trigger ID.\n - team_id (integer) (required) — Scope team ID; 0 means personal rule.\n - updated_at (integer) (required) — Last update time, Unix milliseconds.\n", + "Automations.RuleWriteUpdate": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - account_id (integer) (required) — Account ID.\n - can_edit (boolean) (required) — Whether the caller can manage this rule.\n - created_at (integer) (required) — Creation time, Unix milliseconds.\n - cron_expr (string) (required) — Normalized 5-field cron expression.\n - enabled (boolean) (required) — Whether the rule is enabled.\n - environment_id (string) (required) — BYOC Runner ID.\n - environment_kind (string) (required) — Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc]\n - http_post_token (string) — HTTP POST trigger token. Returned only on create or token rotation; save it immediately.\n - http_post_trigger_enabled (boolean) (required) — Whether the HTTP POST trigger is enabled.\n - http_post_trigger_id (string) — HTTP POST trigger ID.\n - http_post_trigger_url (string) — HTTP POST trigger path.\n - name (string) (required) — Rule name.\n - owner_id (integer) (required) — Creator person ID.\n - prompt (string) (required) — Task prompt.\n - rule_id (string) (required) — Rule ID.\n - run_scope (string) (required) — Hidden session run scope. [person, team]\n - schedule_trigger_enabled (boolean) (required) — Whether the schedule trigger is enabled.\n - schedule_trigger_id (string) — Schedule trigger ID.\n - team_id (integer) (required) — Scope team ID; 0 means personal rule.\n - updated_at (integer) (required) — Last update time, Unix milliseconds.\n", + "Automations.RunReadList": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - runs (array) (required)\n - account_id (integer) (required) — Account ID.\n - attempts (integer) (required) — Attempt count.\n - completed_at (integer) (required) — Completion time, Unix milliseconds. 0 means not completed.\n - created_at (integer) (required) — Creation time, Unix milliseconds.\n - duration_ms (integer) (required) — Duration in milliseconds.\n - error_code (string) — Error code.\n - error_message (string) — Error message.\n - kind (string) (required) — Run kind.\n - occurrence_key (string) (required) — Idempotency key for this occurrence.\n - result_json (any) — Run result JSON.\n - rule_id (string) (required) — Rule ID.\n - run_id (string) (required) — Run ID.\n - started_at (integer) (required) — Start time, Unix milliseconds.\n - stats_json (any) — Run stats JSON.\n - status (string) (required) — Run status. [queued, running, retrying, succeeded, partial, failed, skipped, abandoned]\n - trigger_kind (string) (required) — Trigger kind. [schedule, debug, http_post]\n - updated_at (integer) (required) — Last update time, Unix milliseconds.\n - total (integer) (required) — Total count.\n", + "Automations.TemplateReadList": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - templates (array) (required)\n - description (string) (required) — Template description.\n - enabled (boolean) (required) — Whether the template is enabled.\n - icon (string) (required) — Icon identifier.\n - name (string) (required) — Template name.\n - prompt (string) (required) — Template prompt.\n", "Calendars.CalEventList": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer) — Account ID. Only present for private events.\n - cal_id (string) (required) — Calendar ID. For public events this is a locale key such as zh-cn.china.official.\n - created_at (integer) (required) — Creation timestamp (Unix seconds).\n - creator_id (integer) — Creator person ID. Only present for private events.\n - description (string) (required) — Event description.\n - end_at (string) (required) — Event end date (YYYY-MM-DD, exclusive).\n - event_id (string) (required) — Event ID.\n - is_off (boolean) (required) — Whether the event marks a non-working day.\n - start_at (string) (required) — Event start date (YYYY-MM-DD).\n - summary (string) (required) — Event summary.\n - updated_at (integer) (required) — Last update timestamp (Unix seconds).\n", "Calendars.CalEventUpsert": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - cal_id (string) (required) — Calendar ID.\n - event_id (string) (required) — Event ID (existing or newly generated).\n - summary (string) (required) — Event summary.\n", "Calendars.CalendarCreate": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - cal_id (string) (required) — ID of the newly created calendar (format cal.).\n - cal_name (string) (required) — Calendar display name.\n", diff --git a/internal/cmd/cligen/main.go b/internal/cmd/cligen/main.go index 138a9fc..2d98bc2 100644 --- a/internal/cmd/cligen/main.go +++ b/internal/cmd/cligen/main.go @@ -199,6 +199,12 @@ func collectServices(paths, schemas map[string]any) []service { if isStreamingOp(o) { continue } + // The generated command runtime is an app_key client and does not + // template path parameters. Endpoints with operation-level non-AppKey + // auth or path params need curated commands. + if needsCuratedOperation(o) { + continue + } tag, _ := tags[0].(string) byTag[tag] = append(byTag[tag], struct { path, http string @@ -264,6 +270,24 @@ func isStreamingOp(o map[string]any) bool { return !hasJSON } +func needsCuratedOperation(o map[string]any) bool { + for _, raw := range asSlice(o["parameters"]) { + if str(asMap(raw), "in") == "path" { + return true + } + } + rawSecurity, ok := o["security"] + if !ok { + return false + } + for _, raw := range asSlice(rawSecurity) { + if _, ok := asMap(raw)["AppKeyAuth"]; ok { + return false + } + } + return true +} + type specWalker struct{ schemas map[string]any } func (w *specWalker) deref(s map[string]any) map[string]any { diff --git a/skills/flashduty/SKILL.md b/skills/flashduty/SKILL.md index 093b655..ad4925f 100644 --- a/skills/flashduty/SKILL.md +++ b/skills/flashduty/SKILL.md @@ -1,7 +1,7 @@ --- name: flashduty version: "3.0" -description: "USE FIRST for Flashduty tasks — status pages, incidents, alerts, on-call, monitors, RUM, members. `fduty` CLI = the whole API. ALWAYS load this skill + read reference/.md for exact verbs & flags BEFORE running fduty. Don't guess or --help-dance." +description: "USE FIRST for Flashduty tasks — status pages, incidents, alerts, on-call, monitors, automations, RUM, members. `fduty` CLI = the whole API. ALWAYS load this skill + read reference/.md for exact verbs & flags BEFORE running fduty. Don't guess or --help-dance." allowed-tools: bash, read hidden: true # internal-only: withheld from skills.sh public discovery (Safari embeds this skill directly). --- @@ -56,6 +56,7 @@ Some asks span several commands. For those the skill ships a script that fetches | alert / 告警 / dedup 去重 / alert fields 告警字段 / alert pipeline 告警管道 | **`reference/alert.md`** | | change / 变更 / deployment 部署 / release 发布 / correlated change 变更关联 / what changed | **`reference/change.md`** | | monitor / 监控 / alert rule 告警规则 / datasource 数据源 / inspection 巡检 / rule config 规则配置 | **`reference/monit.md`** | +| automation / 自动化 / 定时 AI SRE / scheduled AI task / daily brief / weekly report / webhook trigger / POST trigger / chat-created automation | **`reference/automation.md`** | | metric/log query / 指标查询 / 日志查询 / PromQL / LogsQL / SQL / trend 趋势 / log clustering 日志聚类 / datasource RCA 数据源排查 | **`reference/monit-query.md`** | | host diagnostics / 主机诊断 / on-box / process 进程 / load 负载 / lock 锁 / slow query 慢查询 / mysql / reachability 可达性 | **`reference/monit-agent.md`** | | channel / 协作空间 / collaboration space / 频道 / integration 集成 / dispatch rule 分派规则 / escalation 升级规则 / noise reduction 降噪 / silence 静默 / inhibit 抑制 | **`reference/channel.md`** | diff --git a/skills/flashduty/reference/automation.md b/skills/flashduty/reference/automation.md new file mode 100644 index 0000000..49be1ad --- /dev/null +++ b/skills/flashduty/reference/automation.md @@ -0,0 +1,184 @@ +# fduty automation - command card + +Prereq: `SKILL.md` read. Automations create AI SRE sessions on a schedule or through an HTTP POST trigger. `create`, `update`, `delete`, and `fire` mutate or start work. If the user directly asks for that action and provides enough detail, treat it as confirmation and do not ask again. + +## Route here when + +"Automation / 自动化 / 定时任务 / 每天让 AI SRE 做 / weekly report / daily brief / webhook trigger / POST trigger / create an automation in chat" -> **automation**. This is for AI SRE Automations, not alert rules or notification templates. + +## Intent -> verb + +| want | verb | +|---|---| +| create a scheduled or HTTP POST Automation | `create` | +| list visible Automations | `list` | +| inspect one Automation | `get ` | +| update mutable fields | `update ` | +| delete an Automation | `delete --force` | +| list run history | `runs ` | +| list preset templates | `templates` | +| test/fire an HTTP POST trigger | `fire ` | + +## Scope and visibility + +- `--team-id 0` or omitted means personal scope. `--team-id ` means the Automation runs as that team and creates sessions scoped to that team. +- Creation can target personal scope or any team in the account. Do not block on local team membership guesses; let the API enforce account boundaries. +- Scope is immutable after creation. The friendly `update` command intentionally has no `--team-id`; create a new Automation if the target scope must change. +- List visibility follows the backend: the caller sees Automations they created and Automations belonging to teams they can see. + +## Scheduling + +- Default create behavior: enabled immediately. Use `--disabled` only if the user asks for a disabled Automation. +- No timezone flag is exposed by the current API. Build the requested wall-clock schedule in the account/customer timezone context. +- Helper schedules: + - `--schedule hourly --at 00:15` -> minute 15 of every hour. + - `--schedule daily --at 09:30` -> every day at 09:30. + - `--schedule weekly --weekday mon --at 10:00` -> every Monday at 10:00. +- For exact minute-level control, use `--cron-expr ' '`. +- HTTP POST-only rule: pass `--http-post-trigger` without schedule flags. The CLI sends a placeholder cron and disables the schedule trigger. + +## Hot flow - create from chat + +```bash +fduty automation create \ + --name "Daily SRE brief" \ + --team-id \ + --schedule daily \ + --at 09:30 \ + --prompt "Summarize yesterday's incidents, noisy alerts, and follow-up risks." \ + --output-format toon +``` + +If the user did not specify a team, omit `--team-id` for personal scope. If the user gives a long task prompt, put it in a temp file and pass `--prompt-file ` to avoid shell quoting issues. + +## Hot flow - create an HTTP POST trigger + +```bash +fduty automation create \ + --name "Webhook triage" \ + --http-post-trigger \ + --prompt-file ./automation-prompt.md \ + --output-format toon +``` + +The response can include `http_post_trigger_id`, `http_post_trigger_url`, and one-time `http_post_token`. Tell the user to store the token; it cannot be retrieved later. Rotate it with: + +```bash +fduty automation update --rotate-http-post-token --output-format toon +``` + +## Hot flow - exact cron + +```bash +fduty automation create \ + --name "Weekday 08:05 review" \ + --cron-expr "5 8 * * 1-5" \ + --prompt "Review open incidents and alert noise before the workday." \ + --output-format toon +``` + +## Manage and inspect + +```bash +fduty automation list --scope all --limit 20 --output-format toon +fduty automation get --output-format toon +fduty automation runs --since 7d --output-format toon + +fduty automation update --disable --output-format toon +fduty automation update --enable --cron-expr "30 9 * * *" --output-format toon +fduty automation delete --force +``` + +## Fire an HTTP POST trigger + +```bash +fduty automation fire \ + --token "$FLASHDUTY_AUTOMATION_TRIGGER_TOKEN" \ + --text "manual validation run" \ + --dedup-key "manual-$(date +%Y%m%d%H%M)" \ + --output-format toon +``` + +`--dedup-key` makes retries idempotent for the same trigger. Do not invent a token; if it is missing, ask the user for the token or rotate the trigger token through `update`. + + + +### create +Create an Automation +- `--at` string +- `--cron-expr` string +- `--disabled` bool +- `--environment-id` string +- `--environment-kind` string +- `--http-post-trigger` bool +- `--name` string +- `--prompt` string +- `--prompt-file` string +- `--schedule` string +- `--schedule-enabled` bool +- `--team-id` int64 +- `--weekday` string + +### delete +Delete an Automation +- `--force` bool + +### fire +Fire an Automation HTTP POST trigger +- `--dedup-key` string +- `--text` string +- `--token` string + +### get +Get an Automation + +### list +List visible Automations +- `--enabled` bool +- `--keyword` string +- `--limit` int +- `--page` int +- `--scope` string +- `--team-ids` int64Slice + +### runs +List Automation runs +- `--limit` int +- `--page` int +- `--since` string +- `--status` string +- `--trigger-kind` string +- `--until` string + +### templates +List Automation templates +- `--locale` string + +### update +Update an Automation +- `--at` string +- `--cron-expr` string +- `--disable` bool +- `--disable-http-post-trigger` bool +- `--disable-schedule` bool +- `--enable` bool +- `--enable-http-post-trigger` bool +- `--enable-schedule` bool +- `--environment-id` string +- `--environment-kind` string +- `--name` string +- `--prompt` string +- `--prompt-file` string +- `--rotate-http-post-token` bool +- `--schedule` string +- `--weekday` string + + + +## Gotchas + +- **Do not ask form-like follow-up questions** when the request is clear enough. Choose practical defaults: personal scope when no team is named, enabled on create, daily 09:00 for a vague daily schedule, Monday 09:00 for a vague weekly schedule. +- **Ask only when required data is missing**: task prompt, trigger token for `fire`, or an ambiguous target rule for update/delete. +- **`update` cannot move personal/team scope.** If the user asks to move scope, create a replacement Automation in the new scope and then delete or disable the old one after confirmation. +- **Use `--prompt-file` for long prompts.** Shell quoting is the most common failure when the prompt contains quotes, markdown, or JSON. +- **Delete is destructive.** In agent/non-interactive runs, pass `--force` only after the user has clearly asked to delete that rule.