diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index b8b0283..8d05b15 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -21,15 +21,19 @@ func newAlertEventCmd() *cobra.Command { } func newAlertEventListCmd() *cobra.Command { - var severity, channel, integrationType, since, until string + var severity, channel, integrationType, since, until, fields string var limit, page int cmd := &cobra.Command{ Use: "list", Short: "List alert events globally", - Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type.", "Alerts", "EventReadList"), + Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type. In json/toon mode, output defaults to compact event fields; use --fields to choose a different projection.", "Alerts", "EventReadList"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { + if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 { + return fmt.Errorf("--fields must name at least one field") + } + startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -77,6 +81,21 @@ func newAlertEventListCmd() *cobra.Command { {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEventItem).Title }}, } + if ctx.Structured() { + fieldNames := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"} + if fields != "" { + fieldNames = parseStringSlice(fields) + } + proj, err := projectFields(result.Items, fieldNames) + if err != nil { + return err + } + if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil { + return err + } + return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total)) + } + return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) }) }, @@ -90,6 +109,7 @@ func newAlertEventListCmd() *cobra.Command { cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results") cmd.Flags().IntVar(&page, "page", 1, "Page number") + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. event_id,alert_id,event_severity,event_status,event_time,title); ignored in table mode. Defaults to these compact event fields. Long strings are truncated as needed to keep structured output below 16 KiB.") return cmd } diff --git a/internal/cli/fieldproject.go b/internal/cli/fieldproject.go index e421397..7bcb12c 100644 --- a/internal/cli/fieldproject.go +++ b/internal/cli/fieldproject.go @@ -5,6 +5,12 @@ import ( "reflect" "sort" "strings" + "unicode/utf8" +) + +const ( + compactListOutputLimit = 16 * 1024 + compactDetailOutputLimit = 8 * 1024 ) // projectFields reduces each struct element of items to a map containing only @@ -63,6 +69,89 @@ func projectFields(items any, fields []string) ([]map[string]any, error) { return out, nil } +// boundProjectedOutput keeps the new agent-oriented projections below their +// command budget without changing the selected keys. Short values remain byte +// identical; when retained strings alone would overflow the actual JSON/TOON +// encoding, they are shortened fairly and marked with "...". If keys and +// non-string values alone exceed the budget, the command fails with a small +// error instead of emitting an oversized payload. +func boundProjectedOutput(data any, maxBytes int) error { + var rows []map[string]any + switch value := data.(type) { + case map[string]any: + rows = []map[string]any{value} + case []map[string]any: + rows = value + default: + return fmt.Errorf("internal error: unsupported projected output %T", data) + } + + encoded, err := marshalStructured(data) + if err != nil { + return err + } + if len(encoded)+1 < maxBytes { + return nil + } + + stringCount := 0 + for _, row := range rows { + for _, value := range row { + if _, ok := value.(string); ok { + stringCount++ + } + } + } + if stringCount == 0 { + return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes) + } + + fieldLimit := maxBytes / stringCount + for { + for _, row := range rows { + for key, value := range row { + if text, ok := value.(string); ok { + row[key] = truncateUTF8Bytes(text, fieldLimit) + } + } + } + + encoded, err = marshalStructured(data) + if err != nil { + return err + } + if len(encoded)+1 < maxBytes { + return nil + } + if fieldLimit == 0 { + return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes) + } + fieldLimit /= 2 + } +} + +func truncateUTF8Bytes(value string, maxBytes int) string { + if len(value) <= maxBytes { + return value + } + if maxBytes <= 0 { + return "" + } + if maxBytes <= 3 { + end := maxBytes + for end > 0 && !utf8.ValidString(value[:end]) { + end-- + } + return value[:end] + } + + end := maxBytes - 3 + for end > 0 && !utf8.ValidString(value[:end]) { + end-- + } + return value[:end] + "..." +} + // jsonTagIndex maps each exported field's json tag name (leading component, sans // `,omitempty`) to its index in the struct. Fields tagged `json:"-"`, untagged // fields, and embedded/anonymous fields are skipped — only declared, named, diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index 6dc4535..01d847b 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -1,9 +1,13 @@ package cli import ( + "bytes" "encoding/json" "strings" "testing" + "unicode/utf8" + + "github.com/spf13/cobra" ) // incidentRow / alertRow are multi-field stub payloads with the nested blobs @@ -26,6 +30,48 @@ func incidentRow() map[string]any { } } +func TestBoundProjectedOutputCapsStructuredFormats(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + flagOutputFormat = format + rows := []map[string]any{{ + "incident_id": "inc-1", + "title": strings.Repeat("数据库故障", 2000), + }} + + if err := boundProjectedOutput(rows, 512); err != nil { + t.Fatalf("bound projected output: %v", err) + } + encoded, err := marshalStructured(rows) + if err != nil { + t.Fatalf("marshal bounded output: %v", err) + } + if len(encoded)+1 >= 512 { + t.Fatalf("bounded %s output is %d bytes, want <512", format, len(encoded)+1) + } + title := rows[0]["title"].(string) + if !utf8.ValidString(title) || !strings.HasSuffix(title, "...") { + t.Fatalf("truncated title = %q, want valid UTF-8 with marker", title) + } + }) + } +} + +func TestBoundProjectedOutputRejectsIrreducibleMetadata(t *testing.T) { + saveAndResetGlobals(t) + flagOutputFormat = "json" + rows := make([]map[string]any, 200) + for i := range rows { + rows[i] = map[string]any{"count": i} + } + + err := boundProjectedOutput(rows, 512) + if err == nil || !strings.Contains(err.Error(), "request fewer rows or fields") { + t.Fatalf("irreducible output error = %v, want bounded guidance", err) + } +} + func alertRow() map[string]any { return map[string]any{ "alert_id": "al-1", @@ -46,6 +92,27 @@ func alertRow() map[string]any { // path: incident list in json/toon mode must not dump the full nested SDK row // when --fields is omitted, while an explicit --fields still wins. func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format+" long title stays bounded", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + row := incidentRow() + row["title"] = strings.Repeat("数据库故障", 5000) + stub.data = map[string]any{"items": []any{row}, "total": 1} + + out, err := execCommand("incident", "list", "--output-format", format) + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Fatalf("bounded %s incident list is %d bytes, want <%d", format, len([]byte(out)), compactListOutputLimit) + } + if !utf8.ValidString(out) || !strings.Contains(out, "...") { + t.Fatalf("bounded %s incident list must retain valid UTF-8 and show truncation", format) + } + }) + } + t.Run("json default", func(t *testing.T) { saveAndResetGlobals(t) stub := newGFStub(t) @@ -307,3 +374,165 @@ func TestFieldsUnknownFieldErrors(t *testing.T) { }) } } + +func TestIncidentSimilarStructuredProjection(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + items := make([]any, 20) + for i := range items { + row := incidentRow() + row["incident_id"] = "similar-" + string(rune('a'+i)) + row["close_time"] = 1712000060 + row["ack_time"] = 1712000030 + row["alert_cnt"] = 3 + row["title"] = strings.Repeat("very long incident title ", 2000) + row["root_cause"] = strings.Repeat("disk exhaustion details ", 2000) + row["score"] = 0.9 + row["description"] = strings.Repeat("large description ", 100) + row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}} + items[i] = row + } + stub.data = map[string]any{"items": items, "total": len(items)} + + out, err := execCommand("incident", "similar", "inc-1", "--limit", "20", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if len(out) >= 16*1024 { + t.Fatalf("compact similar output is %d bytes, want <16 KiB", len(out)) + } + + var rows []map[string]json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil { + t.Fatalf("parse compact similar json: %v\n%s", err, out) + } + want := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "close_time", "ack_time", "alert_cnt", "root_cause", "score"} + if len(rows) != len(items) { + t.Fatalf("got %d rows, want %d", len(rows), len(items)) + } + for _, row := range rows { + if len(row) != len(want) { + t.Fatalf("got keys %v, want exactly %v", row, want) + } + for _, field := range want { + if _, ok := row[field]; !ok { + t.Errorf("projected row missing %q", field) + } + } + if _, ok := row["description"]; ok { + t.Errorf("projected row includes description: %v", row) + } + } + +} + +func TestIncidentDetailFieldsProjection(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + row := incidentRow() + row["description"] = strings.Repeat("large description ", 500) + row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}} + row["ai_summary"] = strings.Repeat("long AI summary ", 4000) + row["root_cause"] = strings.Repeat("long root cause ", 4000) + row["resolution"] = strings.Repeat("long resolution ", 4000) + stub.data = row + + fields := []string{"incident_id", "title", "incident_severity", "progress", "ai_summary", "root_cause", "resolution", "alert_cnt", "start_time", "channel_id"} + out, err := execCommand("incident", "detail", "inc-1", "--fields", strings.Join(fields, ","), "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if len(out) >= 8*1024 { + t.Fatalf("projected detail output is %d bytes, want <8 KiB", len(out)) + } + var detail map[string]json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &detail); err != nil { + t.Fatalf("parse projected detail json: %v\n%s", err, out) + } + if len(detail) != len(fields) { + t.Fatalf("projected detail keys = %v, want exactly %v", detail, fields) + } + for _, field := range fields { + if detail[field] == nil { + t.Errorf("projected detail missing %q: %v", field, detail) + } + } + if _, ok := detail["description"]; ok { + t.Errorf("projected detail includes description: %v", detail) + } + +} + +func TestAlertEventListStructuredProjection(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + items := make([]any, 30) + for i := range items { + items[i] = map[string]any{ + "event_id": "event-" + string(rune('a'+i)), + "alert_id": "alert-1", + "event_severity": "Warning", + "event_status": "Triggered", + "event_time": 1712000000, + "title": strings.Repeat("very long alert event title ", 2000), + "description": strings.Repeat("large description ", 100), + "labels": map[string]any{"host": "web-01"}, + "images": []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}}, + } + } + stub.data = map[string]any{"items": items, "total": len(items)} + + out, err := execCommand("alert-event", "list", "--limit", "30", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if len(out) >= 16*1024 { + t.Fatalf("compact alert-event output is %d bytes, want <16 KiB", len(out)) + } + var rows []map[string]json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil { + t.Fatalf("parse compact alert-event json: %v\n%s", err, out) + } + want := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"} + if len(rows) != len(items) { + t.Fatalf("got %d rows, want %d", len(rows), len(items)) + } + for _, row := range rows { + if len(row) != len(want) { + t.Fatalf("got keys %v, want exactly %v", row, want) + } + for _, field := range want { + if _, ok := row[field]; !ok { + t.Errorf("projected row missing %q", field) + } + } + } + +} + +func TestStructuredFieldsEmptyErrors(t *testing.T) { + cases := []struct { + name string + cmd func() *cobra.Command + args []string + }{ + {"incident similar", newIncidentSimilarCmd, []string{"inc-1", "--fields", ""}}, + {"incident detail", newIncidentDetailCmd, []string{"inc-1", "--fields", ""}}, + {"alert-event list", newAlertEventListCmd, []string{"--fields", ""}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + newGFStub(t) + flagOutputFormat = "json" + cmd := tc.cmd() + cmd.SetOut(new(bytes.Buffer)) + cmd.SetErr(cmd.OutOrStderr()) + cmd.SetArgs(tc.args) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--fields") { + t.Fatalf("empty --fields error = %v, want --fields validation", err) + } + }) + } +} diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 4685a1a..da684fe 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -126,6 +126,9 @@ func newIncidentListCmd() *cobra.Command { if err != nil { return err } + if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil { + return err + } return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total)) } @@ -572,6 +575,7 @@ func newIncidentAlertsCmd() *cobra.Command { } func newIncidentSimilarCmd() *cobra.Command { + var fields string var limit int cmd := &cobra.Command{ @@ -581,6 +585,10 @@ func newIncidentSimilarCmd() *cobra.Command { Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { + if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 { + return errors.New("--fields must name at least one field") + } + result, _, err := ctx.Client.Incidents.PastList(cmdContext(ctx.Cmd), &flashduty.ListPastIncidentsRequest{ IncidentID: ctx.Args[0], Limit: flashduty.Int64(int64(limit)), @@ -594,12 +602,28 @@ func newIncidentSimilarCmd() *cobra.Command { return nil } + if ctx.Structured() { + fieldNames := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "close_time", "ack_time", "alert_cnt", "root_cause", "score"} + if fields != "" { + fieldNames = parseStringSlice(fields) + } + proj, err := projectFields(result.Items, fieldNames) + if err != nil { + return err + } + if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil { + return err + } + return ctx.Printer.Print(proj, nil) + } + return ctx.Printer.Print(result.Items, pastIncidentColumns()) }) }, } cmd.Flags().IntVar(&limit, "limit", 5, "Max results") + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,start_time); ignored in table mode. Defaults to a compact incident summary. Long strings are truncated as needed to keep structured output below 16 KiB.") return cmd } @@ -1308,13 +1332,19 @@ func resolveFeedOperators(rc *RunContext, items []flashduty.IncidentFeedItem) ma } func newIncidentDetailCmd() *cobra.Command { - return &cobra.Command{ + var fields string + + cmd := &cobra.Command{ Use: "detail ", Short: "View full incident detail with AI summary", Long: curatedLong("View full incident detail, including the AI summary, root cause, and resolution.", "Incidents", "Info"), Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { + if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 { + return errors.New("--fields must name at least one field") + } + fullID, candidates, err := resolveIncidentArg(ctx, ctx.Args[0]) if err != nil { return err @@ -1331,6 +1361,16 @@ func newIncidentDetailCmd() *cobra.Command { } if ctx.Structured() { + if fields != "" { + proj, err := projectFields([]flashduty.IncidentInfo{*result}, parseStringSlice(fields)) + if err != nil { + return err + } + if err := boundProjectedOutput(proj[0], compactDetailOutputLimit); err != nil { + return err + } + return ctx.Printer.Print(proj[0], nil) + } return ctx.Printer.Print(result, nil) } @@ -1339,6 +1379,8 @@ func newIncidentDetailCmd() *cobra.Command { }) }, } + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,root_cause); ignored in table mode. Projected strings are truncated as needed to keep output below 8 KiB; omit --fields for full detail.") + return cmd } func printIncidentFullDetail(w io.Writer, inc *flashduty.IncidentInfo) { diff --git a/internal/cli/incident_summary_script_test.go b/internal/cli/incident_summary_script_test.go new file mode 100644 index 0000000..2eeab13 --- /dev/null +++ b/internal/cli/incident_summary_script_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestIncidentSummaryScriptCompactOutput(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Bash fixture is unavailable on Windows") + } + + root, err := filepath.Abs("../..") + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + script := filepath.Join(root, "skills", "flashduty", "scripts", "incident-summary.sh") + log := filepath.Join(t.TempDir(), "fduty.log") + bin := filepath.Join(t.TempDir(), "fduty") + if err := os.WriteFile(bin, []byte("#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> \"$FDUTY_LOG\"\nprintf 'compact result\\n'\n"), 0o755); err != nil { + t.Fatalf("write fake fduty: %v", err) + } + t.Setenv("FDUTY_LOG", log) + t.Setenv("PATH", filepath.Dir(bin)+string(os.PathListSeparator)+os.Getenv("PATH")) + + output, err := exec.Command("bash", script, "inc-1").CombinedOutput() + if err != nil { + t.Fatalf("run incident summary: %v\n%s", err, output) + } + invocations, err := os.ReadFile(log) + if err != nil { + t.Fatalf("read fake fduty log: %v", err) + } + lines := strings.FieldsFunc(strings.TrimSpace(string(invocations)), func(r rune) bool { return r == '\n' }) + if len(lines) != 6 { + t.Fatalf("fduty calls = %d, want 6:\n%s", len(lines), invocations) + } + wantDetail := "incident detail inc-1 --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon" + if lines[0] != wantDetail { + t.Fatalf("detail call = %q, want compact projection %q", lines[0], wantDetail) + } + if strings.Contains(strings.Join(lines[1:], "\n"), "--output-format toon") { + t.Fatalf("non-detail reads force raw toon instead of their compact defaults:\n%s", invocations) + } +} diff --git a/internal/skilldoc/incident_comment_guidance_test.go b/internal/skilldoc/incident_comment_guidance_test.go new file mode 100644 index 0000000..2dc7062 --- /dev/null +++ b/internal/skilldoc/incident_comment_guidance_test.go @@ -0,0 +1,76 @@ +package skilldoc + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestIncidentCardCommentWorkflow(t *testing.T) { + card, err := os.ReadFile(filepath.Join("..", "..", "skills", "flashduty", "reference", "incident.md")) + if err != nil { + t.Fatal(err) + } + + body := string(card) + quotedHeredoc := "COMMENT=$(cat <<'FDUTY_COMMENT_7F3A9C2E_EOF'" + commentCommand := `fduty incident comment "$ID" --comment "$COMMENT"` + timelineCommand := `fduty incident timeline "$ID" --output-format toon` + + previous := -1 + for _, requirement := range []string{quotedHeredoc, commentCommand, timelineCommand} { + position := strings.Index(body, requirement) + if position == -1 { + t.Errorf("incident card is missing %q", requirement) + continue + } + if position <= previous { + t.Errorf("incident card must place %q after the previous workflow step", requirement) + } + previous = position + } + + if !strings.Contains(body, "After every comment write, read back every target and verify the intended comment is present before reporting success.") { + t.Error("incident card must require content-fidelity verification after every comment write") + } + if !strings.Contains(body, "choose a fresh delimiter that is absent as a full line in the intended comment") { + t.Error("incident card must require a collision-free heredoc delimiter") + } + if strings.Contains(body, `fduty incident comment --comment "Root cause identified: DB failover. Fix deploying."`) { + t.Error("incident card must not retain the unsafe inline comment example") + } +} + +func TestIncidentCommentQuotedHeredocPreservesMarkdown(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Bash fixture is unavailable on Windows") + } + + script := `fduty() { + printf '%s' "$5" +} + +ID=64b64ca26f84f00000000000 +COMMENT=$(cat <<'FDUTY_COMMENT_7F3A9C2E_EOF' +## Investigation +Use ` + "`kubectl get pod`" + ` to inspect the restart. +COMMENT_EOF +The follow-up is still pending. +FDUTY_COMMENT_7F3A9C2E_EOF +) +fduty incident comment "$ID" --comment "$COMMENT" +` + + output, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("Bash fixture failed: %v\n%s", err, output) + } + + want := "## Investigation\nUse `kubectl get pod` to inspect the restart.\nCOMMENT_EOF\nThe follow-up is still pending." + if got := string(output); got != want { + t.Errorf("comment content changed:\nwant: %q\n got: %q", want, got) + } +} diff --git a/internal/skilldoc/incident_projection_guidance_test.go b/internal/skilldoc/incident_projection_guidance_test.go new file mode 100644 index 0000000..b96bf28 --- /dev/null +++ b/internal/skilldoc/incident_projection_guidance_test.go @@ -0,0 +1,50 @@ +package skilldoc + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIncidentCardAvoidsUnboundedStructuredHotFlows(t *testing.T) { + card, err := os.ReadFile(filepath.Join("..", "..", "skills", "flashduty", "reference", "incident.md")) + if err != nil { + t.Fatal(err) + } + + body := string(card) + for description, command := range map[string]string{ + "triage list": "incident list --severity Critical --progress Triggered --since 4h --fields incident_id,title,incident_severity,progress,start_time,channel_id --output-format toon", + "triage detail": "incident detail --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon", + "summary detail": `incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon`, + } { + if !strings.Contains(body, command) { + t.Errorf("incident %s must project structured output to the fields needed by the workflow", description) + } + } + + for _, command := range []string{ + "incident alerts --output-format toon", + `incident alerts "$ID" --output-format toon`, + "incident post-mortem-list --channel-ids --output-format toon", + "change list --since 24h --output-format toon", + "incident timeline --output-format toon", + } { + if strings.Contains(body, command) { + t.Errorf("incident hot flow must use the compact default instead of %q", command) + } + } + + _, summary, found := strings.Cut(body, "## Hot flow — full fault analysis (read-only summary)") + if !found { + t.Fatal("incident card is missing the full fault analysis section") + } + summary, _, found = strings.Cut(summary, "## Hot flow — resolve, document, and merge duplicates") + if !found { + t.Fatal("incident card is missing the section after full fault analysis") + } + if strings.Contains(summary, `incident timeline "$ID" --output-format toon`) { + t.Error("full fault analysis must use timeline's compact default; structured timeline is reserved for comment read-back") + } +} diff --git a/skills/flashduty/SKILL.md b/skills/flashduty/SKILL.md index 4670b2d..0ca9823 100644 --- a/skills/flashduty/SKILL.md +++ b/skills/flashduty/SKILL.md @@ -15,6 +15,7 @@ hidden: true # internal-only: withheld from skills.sh public discovery (Safari - **Auth.** Set your Flashduty app key once — `export FLASHDUTY_APP_KEY=` — or pass `--app-key ` per call. Then just call the verb. - **No curl for the API.** The CLI is the only supported path to Flashduty — never hand-roll an HTTP call. - **If `fduty: command not found`** (rare — it is normally on PATH at startup): install from the Flashduty CDN into a user-writable dir (no sudo, no hang), then tell the user — don't work around it: `curl -sSL https://static.flashcat.cloud/flashduty-cli/install.sh | FLASHDUTY_INSTALL_DIR="$HOME/.local/bin" INSTALLED_NAME=fduty sh && export PATH="$HOME/.local/bin:$PATH"`. +- **If `jq` is missing** and you genuinely need JSON filtering, it is fine to install it into a user-writable dir the same way (`$HOME/.local/bin`) and continue. But first ask whether you can avoid `jq` entirely by using `--output-format toon`, `--fields`, a returned `total`, or a server-side aggregation verb (`insight`, `rule-counter-*`, etc.). ## Data model — 3 layers @@ -24,6 +25,10 @@ hidden: true # internal-only: withheld from skills.sh public discovery (Safari Append `--output-format toon` to read commands: it drops the per-row repeated keys that JSON emits, so lists cost far fewer tokens. Use `--json` only to pipe into `jq`. Bare output is a human table — don't parse it. +**Count the cheap way.** If a list response already returns `total`, trust that authoritative server count — do not page N times just to count rows. When the question is "how many by status/progress/severity", issue the narrowest server-side filter per bucket and read each response's `total`; for account-wide trends switch to aggregation verbs such as `insight *`, `rule-counter-status`, `rule-counter-node`, or `rule-counter-total`. + +**Shape the payload before you fetch it.** For ID scans, counts, or "find the matching row" tasks, prefer `--fields` projections and compact list verbs over full detail dumps. Huge raw JSON dumps are a last resort, not a default. + **Empty result = authoritative not-found.** A filter returning `[]` means no such entity in scope — report it (optionally the 1–2 closest names) and stop. Do **not** brute-force (no shifted-keyword re-queries, no widening past caps, no full-dump grep). Never infer "feature not enabled" from an empty list, and never fabricate data absent from tool output. **A result you did not fetch is "unknown", never "empty".** You may report a command's result — including "returned empty" or any count/list/finding — **only if that exact command appears in your tool-call history this turn**. If you did not run it, the honest answer is "未查询 / not queried", followed by the command to run. Writing "`incident similar` 返回空" or "无变更" for a command you never executed is fabrication, not a summary. @@ -73,3 +78,4 @@ Some asks span several commands. For those the skill ships a script that fetches | RUM / real user monitoring / 真实用户监控 / frontend 前端 / application 应用 / issue | **`reference/rum.md`** | | sourcemap / source map / source mapping / symbolication / deobfuscate / stack enrich / dSYM / miniprogram source map | **`reference/sourcemap.md`** | | status page / 状态页 / public incident 公开事件 / public timeline 公开时间线 / maintenance window 维护窗口 / subscriber 订阅者 | **`reference/status-page.md`** | +| AI-SRE platform / customize / 安装配置 MCP server (connector) 连接器 / install mcp / skill upload 上传技能 / A2A agent / session export 会话导出 | **`reference/safari.md`** | diff --git a/skills/flashduty/reference/alert.md b/skills/flashduty/reference/alert.md index 9bd12b6..c7c9ee3 100644 --- a/skills/flashduty/reference/alert.md +++ b/skills/flashduty/reference/alert.md @@ -34,8 +34,12 @@ fduty alert get --output-format toon fduty alert events --output-format toon # 4. view state transitions (mute/severity changes/operator actions) fduty alert feed --output-format toon +# 5. for a time-window view across alerts, alert-event list is compact by default +fduty alert-event list --channel --since 1h --limit 30 --output-format toon ``` +Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened. + ## Hot flow — merge noisy alerts into an existing incident ```bash @@ -134,6 +138,8 @@ View alert timeline - **All alert verbs are positional except `list` and the two-ID `merge` flag.** Every verb with `` in its `use` form takes that ID as the first bare argument — do NOT pass `--alert-id`. The single exception: `merge` takes the first alert ID positionally AND requires `--incident-id` as a flag (two different IDs, different roles). - **`alert get` vs `alert info`, `alert events` vs `alert-event list`:** both pairs exist; prefer `get`/`events` (shorter, no extra flag); `info`/`event-list` accept `--alert-id` as a flag override for scripting. - **No server-side title filter on `list`.** To search by title, use `--json` and pipe to `jq`: `fduty alert list --json | jq '.[] | select(.title | test("disk";"i"))'` +- **If `list` returns a `total`, use it.** Do not paginate page 1/2/3... just to count alerts. Ask the narrowest question (`--active`, `--recovered`, `--severity`, `--channel`, `--since`) and read the server-reported total for that bucket. +- **Use `--fields` when hunting IDs, not full rows.** If the task is "find alert IDs / titles / channels / severities", project only those fields first, then drill into one alert with `get` / `events`. Dumping every field for 100 alerts wastes tokens and hides the one row you need. - **`list` time window cap is 31 days**; `--limit` max is 100. For broader queries use `insight` domain. - **`pipeline-upsert` fully replaces** the existing pipeline — always fetch current config with `pipeline-info` first and include unchanged rules in the new body. - **Empty `list` result is authoritative** — report "no alerts match" and stop; do not widen filters or retry with alternate keywords. diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index d7f5246..c976d17 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -42,16 +42,14 @@ Prereq: `SKILL.md` read. Read verbs are free. **Mutating verbs notify responders ## Hot flow — triage an active incident ```bash -# 1. Find unacknowledged critical incidents (last 4h). -# toon/json list output is compact by default: -# incident_id,title,incident_severity,progress,start_time,channel_id -fduty incident list --severity Critical --progress Triggered --since 4h --output-format toon +# 1. Find unacknowledged critical incidents (last 4h) +fduty incident list --severity Critical --progress Triggered --since 4h --fields incident_id,title,incident_severity,progress,start_time,channel_id --output-format toon # 2. Get AI summary + full detail (use the 24-char incident_id from step 1) -fduty incident detail --output-format toon +fduty incident detail --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon # 3. See contributing alerts -fduty incident alerts --output-format toon +fduty incident alerts # 4. Check for prior similar incidents (channel-backed only; see Gotchas) fduty incident similar --limit 5 --output-format toon @@ -59,13 +57,25 @@ fduty incident similar --limit 5 --output-format toon # 5. Acknowledge ownership fduty incident ack -# 6. Post a status comment -fduty incident comment --comment "Root cause identified: DB failover. Fix deploying." +# 6. Post a status comment safely, then read it back +ID= +# Before running, choose a fresh delimiter that is absent as a full line in the intended comment. +COMMENT=$(cat <<'FDUTY_COMMENT_7F3A9C2E_EOF' +Root cause identified: DB failover. +Fix deploying. +FDUTY_COMMENT_7F3A9C2E_EOF +) +fduty incident comment "$ID" --comment "$COMMENT" +fduty incident timeline "$ID" --output-format toon # 7. Resolve with root-cause note fduty incident resolve --root-cause "DB primary failover delay" --resolution "Failover completed; latency normal." ``` +Projected `similar` lists stay below 16 KiB, and projected `detail --fields` output stays below 8 KiB. A trailing `...` means a long retained string was shortened; omit `--fields` only when the full unbounded detail is explicitly required. + +After every comment write, read back every target and verify the intended comment is present before reporting success. `Commented on ...` proves acceptance, not content fidelity. + > `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. ## Hot flow — full fault analysis (read-only summary) @@ -82,10 +92,10 @@ If you fetch the pieces by hand instead, run **all six** — they are cheap read ```bash ID= # 24-char id from `incident list` -fduty incident detail "$ID" # ① 详情 + AI summary + alert counts + channel_id +fduty incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon # ① 详情 + AI summary + alert counts + channel fduty incident alerts "$ID" # ② contributing alerts (detail's embedded alerts are empty here) fduty incident timeline "$ID" # ④ timeline (or `incident feed "$ID"` for the paginated view) -fduty incident similar "$ID" --limit 5 # ⑤ similar past incidents (channel-backed; see Gotchas) +fduty incident similar "$ID" --limit 5 --output-format toon # ⑤ similar past incidents (channel-backed; see Gotchas; compact by default) fduty incident post-mortem-list --channel-ids # ⑥ post-mortems for this incident's channel fduty change list --since 24h # ③ correlated changes — by shared labels + time; see reference/change.md ``` @@ -105,7 +115,7 @@ fduty incident reset \ --resolution "Increased memory limit; deployed hot patch" # Review the event timeline -fduty incident timeline --output-format toon +fduty incident timeline ``` @@ -165,6 +175,7 @@ Execute custom action ### detail View full incident detail with AI summary +- `--fields` string ### disable-merge [...] Disable incident merge @@ -330,6 +341,7 @@ Add incident responder ### similar Find similar incidents +- `--fields` string - `--limit` int ### snooze [ ...] @@ -426,6 +438,8 @@ List war rooms - **24-char `incident_id` vs 6-char `num`**: positional-id verbs (`ack`, `close`, `resolve`, `detail`, `alerts`, `timeline`, `merge`, `reassign`, `comment`, `reset`, …) require the full ObjectID. Passing a 6-char num 400s. Use `incident info --num ` to resolve, or `incident list --query ` and read `incident_id`. - **`similar` only works on channel-backed incidents** (those with a real `channel_id`). Manually created incidents with no channel return HTTP 400 "Channel not found" — this is expected, not transient. Fall back to `incident list --query ""` for text search. - **`update` vs `reset`**: `update ` edits title/description/severity/custom fields. `reset ` additionally supports `--impact`, `--root-cause`, `--resolution` (the AI narrative fields). Use `reset` for post-incident write-back. +- **If `list` returns a `total`, use it instead of page-walking.** For "how many incidents are Triggered / Processing / Closed", run one filtered `incident list --progress ...` per bucket and read the returned `total`. Do not fetch page 1/2/3 just to derive counts the server already computed. +- **Use `--fields` to keep list scans compact.** When the goal is to identify matching incidents or collect IDs/numbers/titles, project only the needed columns first, then fetch one target incident with `detail` / `alerts` / `timeline`. - **`--list` window cap**: `--since`/`--until` window must be < 31 days; `--limit` max 100. Empty result is authoritative — do not widen filters or retry. - **`merge` is irreversible**: source incidents are absorbed into target permanently. Always list and confirm both IDs before running. - **`remove --force`** bypasses the interactive confirmation prompt — never pass `--force` unless the user has explicitly said so. diff --git a/skills/flashduty/reference/monit.md b/skills/flashduty/reference/monit.md index 84e20a9..c2d5f55 100644 --- a/skills/flashduty/reference/monit.md +++ b/skills/flashduty/reference/monit.md @@ -355,6 +355,9 @@ Invoke target tools - **`query-rows` has no time flags.** There is no `--time-start` / `--time-end` / `--operation`. Embed all time range and bucketing inside `--expr`. Passing those flags is a silent no-op or error. - **`query-diagnose` time window via `--data`**, not flags. Pass `{"time_range":{"start":,"end":},...}`. Window wider than 6 hours is rejected server-side. Omitting `time_range` defaults to the last 15 minutes. - **`rule_configs` and nested arrays require `--data`.** The queries, thresholds, enabled_times, and labels objects cannot be expressed as flat flags — pass them as inline JSON via `--data '{"rule_configs":{...}}'`. Typed scalar flags (`--name`, `--enabled`, `--cron-pattern`, `--ds-type`) override matching `--data` keys. +- **`folder-id 0` is not a universal "all rules" sentinel.** If the API says "Folder not found", believe it. For global inventory use `rule-counter-status` / `rule-counter-node` first, then run `rule-list-basic` against real folder IDs only. +- **"全量规则 / full rules" means exported monitor alert-rule definitions.** The concrete verb is `rule-export --ids ...`, usually after `rule-list-basic` selected the IDs. It does not mean dumping incidents or alerts. +- **For rule counts, prefer the counter verbs over list pagination.** `rule-counter-status`, `rule-counter-node`, and `rule-counter-total` are the authoritative aggregation surfaces; do not infer counts by walking `rule-list-basic` pages. - **`tools-catalog` / `tools-invoke` `--target-locator` is required and not guessable.** If the user has not provided a host or IP, ask — do not invent one. Tool names in `invoke` must come from the `tools-catalog` response — never hallucinate them. - **`rule-delete-batch` and `datasource-delete` are irreversible.** Confirm IDs with `rule-list-basic` / `datasource-info` first. - **`rule-audit-detail --id` takes the audit record ID**, not the rule ID. Get audit record IDs from `rule-audits --id ` first; passing the rule ID returns HTTP 400. diff --git a/skills/flashduty/reference/safari.md b/skills/flashduty/reference/safari.md new file mode 100644 index 0000000..5802813 --- /dev/null +++ b/skills/flashduty/reference/safari.md @@ -0,0 +1,331 @@ +# fduty safari — command card + +Prereq: `SKILL.md` read. This is the **AI-SRE platform self-management** group: install/configure the account's own **MCP servers (connectors)**, **skills**, and **A2A agents**, plus inspect **sessions**. Mutating verbs (`create`, `update`, `delete`, `upload`) change account configuration — confirm before running. `delete` is **irreversible**. + +> Registering an MCP server is THIS group (`fduty safari mcp-server-create`) — **not** a tool search. A tool search only discovers callable tools on servers already connected to you; it can neither register nor configure one. + +## Route here when + +"安装 / 添加 / 配置 MCP / connector / 连接器 / install mcp / add mcp server / 上传 skill / 自定义 skill / skill upload / A2A agent / customize / AI-SRE 平台配置 / session 导出" → **safari**. Key IDs: **`server_id`** (`mcp_…`) from `mcp-server-list`; **`skill_id`** from `skill-list`; **`agent_id`** from `a2a-agent-list`; **`session_id`** (`sess_…`). + +## Intent → verb + +| want | verb | +|---|---| +| list MCP servers / connectors | `mcp-server-list` | +| install / register an MCP server | `mcp-server-create` | +| change an MCP server's config | `mcp-server-update` | +| turn an MCP server on / off | `mcp-server-enable` / `mcp-server-disable` | +| inspect one MCP server (+ live tool probe) | `mcp-server-get` | +| remove an MCP server | `mcp-server-delete` | +| list / upload / update a skill | `skill-list` / `skill-upload` / `skill-update` | +| enable / disable / delete a skill | `skill-enable` / `skill-disable` / `skill-delete` | +| list / create / update an A2A agent | `a2a-agent-list` / `a2a-agent-create` / `a2a-agent-update` | +| enable / disable / delete an A2A agent | `a2a-agent-enable` / `a2a-agent-disable` / `a2a-agent-delete` | +| list / get / export / delete a session | `session-list` / `session-get` / `session-export` / `session-delete` | + +## Hot flow — install an MCP server + +Pass the nested `env` / `headers` objects through `--data` (they have no scalar flags); `args` has a repeatable `--args` flag but is shown via `--data` below for one-line copy-paste. + +```bash +# stdio (local process): command + args + secrets via env +fduty safari mcp-server-create --data '{"server_name":"GitHub Tools","transport":"stdio","description":"Read issues and pull requests from GitHub.","command":"npx","args":["-y","@modelcontextprotocol/server-github"],"env":{"GITHUB_TOKEN":"ghp_xxx"},"team_id":0,"status":"enabled"}' + +# remote (streamable-http) with per-user OAuth — oauth_metadata stays empty (auto-discovered + DCR at runtime) +fduty safari mcp-server-create --data '{"server_name":"Aliyun OpenAPI","transport":"streamable-http","description":"Alibaba Cloud OpenAPI MCP.","url":"https://openapi-mcp.example.com/mcp","auth_mode":"per_user_oauth","team_id":0,"status":"enabled"}' + +# confirm it registered, then inspect its live tool catalogue +fduty safari mcp-server-list --output-format toon +fduty safari mcp-server-get --data '{"server_id":"mcp_xxx"}' +``` + + + +### a2a-agent-create +Create A2A agent +- `--agent-name` string (required) — Agent display name. (≤128 chars) +- `--allow-insecure-oauth-http` bool — Allow non-loopback HTTP OAuth discovery/metadata endpoints for this agent instead of requiring HTTPS. Defaults to false. +- `--allow-insecure-tls-skip-verify` bool — Skip TLS certificate verification when connecting to this agent's endpoint (self-signed/private certs). Defaults to false. +- `--auth-mode` string — Authentication mode: 'shared' (default) shares one credential across all users; 'per_user_secret' requires 'secret_schema.header_name'; 'per_user_oauth' runs per-user OAuth. +- `--auth-type` string — Authentication type for reaching the remote agent: 'none', 'api_key', or 'bearer'. +- `--card-url` string (required) — URL of the remote agent card. Must be an absolute 'http' or 'https' URL with a non-empty host; reachability is enforced by the execution environment, not at creation time. +- `--environment-id` string — BYOC runner ID. Required when 'environment_kind=byoc'; the runner must belong to the account or a team the caller belongs to. +- `--environment-kind` string — Execution environment binding. Omit or send empty for automatic routing; 'byoc' pins the agent to a specific runner given by 'environment_id'. 'cloud' is not accepted — configured A2A agents need a persistent runner, not a disposable cloud sandbox. · enum: byoc +- `--instructions` string (required) — Natural-language instructions for the remote agent. Required — a deprecated 'description' field is still accepted for legacy clients and, if both are sent, must exactly match 'instructions'. (≤2000 chars) +- `--oauth-metadata` string — JSON-encoded OAuth metadata; populated by the OAuth discovery flow for 'per_user_oauth' mode. +- `--secret-schema` string — JSON-encoded secret schema, e.g. '{"header_name":"X-Api-Key"}'; required when 'auth_mode=per_user_secret'. +- `--streaming` bool — Whether the remote agent supports streaming. +- `--team-id` int64 — Team scope: 0 = account-wide; >0 = team. Creating at account scope requires the owner/admin role; creating into a team requires actual membership in that team. +- body-only (`--data`): auth_config (object) + +### a2a-agent-delete +Delete A2A agent +- `` (positional, required) string — Target agent ID. + +### a2a-agent-disable +Disable A2A agent +- `` (positional, required) string — Target agent ID. + +### a2a-agent-enable +Enable A2A agent +- `` (positional, required) string — Target agent ID. + +### a2a-agent-get +Get A2A agent detail +- `` (positional, required) string — Target agent ID. + +### a2a-agent-list +List A2A agents +- `--include-account` bool — Include account-scoped (team_id=0) rows. Defaults to true. +- `--limit` int64 — Page size. +- `--offset` int64 — Row offset for pagination. +- `--query` string — Case-insensitive substring search across agent name, instructions, card URL, agent ID, and the resolved card name. (≤128 chars) +- `--scope` string — Visibility scope: 'all' (account-scope plus the caller's visible teams), 'account' (account-scope only), or 'team' (team-scoped rows across the caller's visible teams). · enum: all | account | team +- `--team-ids` intSlice — Filter to these team IDs; empty = the caller's visible set. + +### a2a-agent-update +Update A2A agent +- `` (positional, required) string — Target agent ID. +- `--agent-name` string — New display name. Omit to leave unchanged. (≤128 chars) +- `--allow-insecure-oauth-http` bool — Toggle non-loopback HTTP OAuth discovery for this agent. Omit to leave unchanged. +- `--allow-insecure-tls-skip-verify` bool — Toggle TLS certificate verification skipping for this agent. Omit to leave unchanged. +- `--auth-mode` string — New auth mode: shared, per_user_secret, or per_user_oauth. Changing it always rewrites secret_schema together with it. +- `--auth-type` string — New auth type. Omit to leave unchanged. +- `--card-url` string — New card URL. Omit to leave unchanged. +- `--environment-id` string — New BYOC runner ID. Required alongside 'environment_kind=byoc'. Omit to leave unchanged. +- `--environment-kind` string — New execution environment binding: empty for automatic, 'byoc' for a specific runner. 'cloud' is rejected. Omit to leave unchanged. +- `--instructions` string — New instructions. Omit to leave unchanged. A deprecated 'description' field is also accepted; if both are sent they must match. (≤2000 chars) +- `--oauth-metadata` string — New JSON OAuth metadata. If omitted while auth_mode changes, it is cleared to empty. +- `--secret-schema` string — New JSON secret schema. +- `--streaming` bool — Toggle streaming support. Omit to leave unchanged. +- `--team-id` int64 — Reassign team scope. Omit to leave unchanged. Reassigning requires rights on the destination team; if the team changes without also sending a new environment binding, the existing runner binding must remain selectable by the caller or the update is rejected. +- body-only (`--data`): auth_config (object) + +### automation-rule-create +Create Automation rule +- `--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. A cron that sets both day-of-month and day-of-week is rejected. 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. · enum: 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) +- `--oncall-incident-channel-ids` intSlice — On-call integration IDs to watch. Creating or enabling this trigger requires at least one valid ID. +- `--oncall-incident-severities` stringSlice — Incident severities to watch. Supported values are Critical, Warning, and Info; creating or enabling this trigger requires at least one value. · enum: Critical | Warning | Info +- `--oncall-incident-trigger-enabled` bool — Whether the On-call incident trigger is enabled. +- `--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` int64 — Scope team ID. 0 or omitted means a personal rule; >0 means a team in the account. Immutable after creation. (min 0) +- `--timezone` string — IANA timezone 'cron_expr' is evaluated in, e.g. 'Asia/Shanghai'. Must be a timezone name loadable by the server; an invalid value is rejected. Defaults to the caller's member timezone, then the account timezone, then UTC when omitted. + +### automation-rule-delete +Delete Automation rule +- `` (positional, required) string — Rule ID. + +### automation-rule-get +Get Automation rule +- `` (positional, required) string — Rule ID. + +### automation-rule-list +List Automation rules +- `--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) +- `--limit` int64 — Page size. (max 100) +- `--page` int64 — Page number, 1-based. +- `--scope` string — Scope filter: 'all' (own personal + accessible team rules), 'personal', or 'team'; default 'all'. · enum: all | personal | team +- `--search-after-ctx` string +- `--team-ids` intSlice — Filter to these team IDs; this narrows results and does not expand access. + +### automation-rule-run +Run Automation rule +- `` (positional, required) string — Rule ID. + +### automation-rule-update +Update Automation rule +- `--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. +- `--enabled` bool — Whether the rule is enabled. +- `--environment-id` string — BYOC Runner ID. +- `--environment-kind` string — Runtime environment kind. Omit or send an empty value for automatic selection. · enum: cloud | byoc +- `--http-post-trigger-enabled` bool — Whether the HTTP POST trigger is enabled. Sending true creates one when missing. +- `--name` string — New rule name. (≤255 chars) +- `--oncall-incident-channel-ids` intSlice — On-call integration IDs to watch. Creating or enabling this trigger requires at least one valid ID. +- `--oncall-incident-severities` stringSlice — Incident severities to watch. Supported values are Critical, Warning, and Info; creating or enabling this trigger requires at least one value. · enum: Critical | Warning | Info +- `--oncall-incident-trigger-enabled` bool — Whether the On-call incident trigger is enabled. +- `--prompt` string — New task prompt. +- `--rotate-http-post-trigger-token` bool — Whether to rotate the HTTP POST trigger token. The new token is returned only in this response. +- `` (positional, required) string — Target rule ID. +- `--schedule-trigger-enabled` bool — Whether the schedule trigger is enabled. +- `--team-id` int64 — Only the current value is accepted; personal/team scope is immutable after creation. (min 0) + +### automation-run-list +List Automation runs +- `--limit` int64 — Page size. (max 100) +- `--page` int64 — Page number, 1-based. +- `` (positional, required) string — Target rule ID. +- `--search-after-ctx` string +- `--started-after-ms` int64 — Start-time lower bound, Unix milliseconds. +- `--started-before-ms` int64 — Start-time upper bound, Unix milliseconds. +- `--status` string — Run status filter. · enum: queued | running | retrying | succeeded | partial | failed | skipped | abandoned +- `--trigger-kind` string — Trigger kind filter. · enum: schedule | debug | manual | http_post | oncall_incident + +### automation-template-list +List Automation templates +- `--locale` string — Template locale such as zh-CN or en-US. Omit to detect from the request locale. (≤16 chars) + +### automation-triggers-{trigger_id}-fire +Fire an Automation HTTP POST trigger +- `--text` string +- `--token` string + +### mcp-server-create +Create MCP server +- `--allow-insecure-oauth-http` bool — Allow this server's OAuth token exchange over plaintext HTTP. Testing use only; defaults to false. +- `--allow-insecure-tls-skip-verify` bool — Skip TLS certificate verification when connecting to this server. Testing use only; defaults to false. +- `--args` stringSlice — Command arguments (stdio transport). +- `--auth-mode` string — Authentication mode: shared (default), per_user_secret, or per_user_oauth. +- `--call-timeout` int64 — Tool-call timeout in seconds. 0 = default (60s). +- `--command` string — Executable command (stdio transport). +- `--connect-timeout` int64 — Connection timeout in seconds. 0 = default (10s). +- `--description` string (required) — Server description. (1-1024 chars) +- `--environment-id` string — Runner ID; required when environment_kind is byoc. +- `--environment-kind` string — Pin the server to a specific BYOC runner ('environment_id' required). Omit or send empty for automatic selection; 'cloud' is not supported for MCP servers. · enum: byoc +- `--oauth-metadata` string — JSON OAuth metadata; reserved for per_user_oauth. +- `--secret-schema` string — JSON secret schema; required when auth_mode=per_user_secret. +- `--server-name` string (required) — MCP server name, unique within the account. (1-255 chars) +- `--source-template-name` string — Marketplace template name when created from a connector template. +- `--status` string — Initial status. · enum: enabled | disabled +- `--team-id` int64 — Team scope: 0 = account-wide; >0 = team. +- `--transport` string (required) — Transport protocol. · enum: stdio | sse | streamable-http +- `--url` string — Server URL (sse / streamable-http transport). +- body-only (`--data`): env (object); headers (object) + +### mcp-server-delete +Delete MCP server +- `` (positional, required) string — Target MCP server ID. + +### mcp-server-disable +Disable MCP server +- `` (positional, required) string — Target MCP server ID. + +### mcp-server-enable +Enable MCP server +- `` (positional, required) string — Target MCP server ID. + +### mcp-server-get +Get MCP server detail +- `` (positional, required) string — Target MCP server ID. + +### mcp-server-list +List MCP servers +- `--include-account` bool — Include account-scoped (team_id=0) rows. Defaults to true. +- `--limit` int64 — Page size. +- `--page` int64 — Page number, 1-based. +- `--query` string — Case-insensitive substring search across name, description, AI-generated description, server ID, transport, URL, command, and source template name. (≤128 chars) +- `--scope` string — Restrict results to a scope: 'account' for account-wide rows only, 'team' for the caller's own visible team rows only, or omit (defaults to 'all') for both, subject to team_ids/include_account. · enum: all | account | team +- `--search-after-ctx` string +- `--team-ids` intSlice — Filter to these team IDs; empty = the caller's visible set. + +### mcp-server-update +Update MCP server +- `--allow-insecure-oauth-http` bool — Allow OAuth token exchange over plaintext HTTP. Omit to leave unchanged. +- `--allow-insecure-tls-skip-verify` bool — Skip TLS certificate verification. Omit to leave unchanged. +- `--args` stringSlice — Command arguments (stdio transport). +- `--auth-mode` string — Authentication mode: shared (default), per_user_secret, or per_user_oauth. +- `--call-timeout` int64 — Tool-call timeout in seconds. 0 = default (60s). +- `--command` string — Executable command (stdio transport). +- `--connect-timeout` int64 — Connection timeout in seconds. 0 = default (10s). +- `--description` string — New description. (1-1024 chars) +- `--environment-id` string — Runner ID paired with environment_kind=byoc. Omit (null) to leave the current binding unchanged. +- `--environment-kind` string — Reassign the runner binding: 'byoc' (with environment_id) or empty string to reset to automatic selection. Omit (null) to leave the current binding unchanged. +- `--oauth-metadata` string — JSON OAuth metadata; reserved for per_user_oauth. +- `--secret-schema` string — JSON secret schema; required when auth_mode=per_user_secret. +- `` (positional, required) string — Target MCP server ID. +- `--server-name` string — New name. (1-255 chars) +- `--team-id` int64 — Reassign team scope: 0 = account-wide; >0 = team. Omit to leave unchanged. +- `--transport` string — Transport protocol. · enum: stdio | sse | streamable-http +- `--url` string — Server URL (sse / streamable-http transport). +- body-only (`--data`): env (object); headers (object) + +### session-delete +Delete session +- `` (positional, required) string — Target session ID. (≥1 chars) + +### session-export +Stream a session's full event transcript as NDJSON +- `--include-subagents` bool + +### session-get +Get session detail +- `--limit` int64 — Page size for events; takes precedence over 'num_recent_events'. 0 uses the server default (100). (0-1000) +- `--num-recent-events` int64 — Legacy page size: number of most-recent events to return. Superseded by 'limit' when both are set; 0 uses the server default (100). (0-1000) +- `--search-after-ctx` string — Opaque keyset cursor from a previous response; pass it back to fetch the next older page. (≤4096 chars) +- `` (positional, required) string — Target session ID. (≥1 chars) +- `--share-token` string — Share token for accessing a session through its share link. Omit it for normal account-authorized access. (≤512 chars) + +### session-list +List sessions +- `--app-name` string (required) — Agent app whose sessions to list. · enum: ask-ai | support | support-website | support-flashcat | ai-sre | template-assistant | swe +- `--asc` bool — Ascending order when true; applies only when 'orderby' is set. +- `--entry-kinds` stringSlice — Restrict to sessions produced by these surfaces; empty returns every kind. · enum: web | im | api | automation +- `--include-subagent-sessions` bool — Include subagent-dispatched sessions in the list. +- `--keyword` string — Filter by session-name keyword. (≤64 chars) +- `--limit` int64 — Page size, 1–100. (1-100) +- `--orderby` string — Sort field. · enum: created_at | updated_at +- `--page` int64 — Page number, 1-based. (min 1) +- `--scope` string — Visibility scope: 'all' (own personal + accessible team sessions), 'personal', or 'team'; default 'all'. · enum: all | personal | team +- `--search-after-ctx` string +- `--status` string — Archive bucket: active (default) returns un-archived, archived returns archived, all returns both. · enum: active | archived | all +- `--team-ids` intSlice — Optional explicit team filter; intersects with 'scope' and never expands access. + +### skill-delete +Delete skill +- `` (positional, required) string — Target skill ID. + +### skill-disable +Disable skill +- `` (positional, required) string — Target skill ID. + +### skill-enable +Enable skill +- `` (positional, required) string — Target skill ID. + +### skill-get +Get skill detail +- `` (positional, required) string — Target skill ID. + +### skill-list +List skills +- `--include-account` bool — Include account-scoped (team_id=0) rows. Defaults to true. Ignored when 'scope' is 'account' or 'team'. +- `--limit` int64 — Page size. +- `--page` int64 — Page number, 1-based. +- `--query` string — Free-text search across skill name, description, English description, skill ID, marketplace source template name, and author. (≤128 chars) +- `--scope` string — Restrict results to 'all' (default), 'account'-only (team_id=0), or 'team'-only (excludes account-scoped rows). Overrides 'include_account' when set. · enum: all | account | team +- `--search-after-ctx` string +- `--team-ids` intSlice — Filter to these team IDs; empty = the caller's visible set. + +### skill-update +Update skill +- `--description` string — New description. Cannot contain '<' or '>'. Sending an empty string leaves the current value unchanged — there is no way to clear it via this field. (≤1024 chars) +- `--description-en` string — New English description. Cannot contain '<' or '>'. Omit to leave unchanged; send an empty string to explicitly clear it. (≤1024 chars) +- `` (positional, required) string — Target skill ID. +- `--team-id` int64 — Reassign team scope: 0 = account-wide; >0 = team. Omit to leave unchanged. + +### skill-upload +Upload skill + + + +## Key concepts + +- **Transport ⇒ which fields matter.** `stdio` uses `command` + `args` + `env`; `sse` / `streamable-http` use `url` + `headers`. The nested `env` / `headers` objects have no scalar flags — pass them through `--data '{...}'`; typed scalar flags (`--server-name`, `--url`, `--args`, …) override matching `--data` keys. +- **Scope (`team_id`).** `0` = account-wide (every team sees it); `>0` = that team only. Same field on every safari verb. +- **Auth mode (`auth_mode`).** `shared` (default) = one credential for everyone, stored on the server. `per_user_secret` = each user supplies a secret matching `secret_schema` (which must carry a `header_name`). `per_user_oauth` = each user authorizes the server via OAuth. +- **`per_user_oauth` needs no OAuth config up front.** Create it with an **empty `oauth_metadata`** — that is the normal, complete state, not a missing prerequisite. The runtime **auto-discovers the OAuth server and dynamically registers a client (DCR)** the first time a user authorizes; you do **not** collect `authorization_url` / `client_id` / `client_secret` / `scopes`. Only pass `oauth_metadata` as a rare fallback, when the endpoint advertises no discovery document. + +## Gotchas + +- **`mcp-server-create` requires `server_name`, `description`, `transport`.** A `stdio` server also needs `command`; a remote (`sse` / `streamable-http`) server needs `url`. +- **`env` / `headers` go through `--data`** — there are no `--env` / `--header` scalar flags for them (`args` does have a repeatable `--args` flag). +- **Don't reach for a tool search to install or configure a server** — that only finds tools on already-connected servers. Registration and configuration are `mcp-server-create` / `mcp-server-update`. +- **`delete` is irreversible** — prefer `disable` to park a server / skill / agent without destroying it. `list` first to confirm the id. diff --git a/skills/flashduty/scripts/incident-summary.sh b/skills/flashduty/scripts/incident-summary.sh index f9a52ac..2833e17 100644 --- a/skills/flashduty/scripts/incident-summary.sh +++ b/skills/flashduty/scripts/incident-summary.sh @@ -8,8 +8,8 @@ # usage: bash incident-summary.sh # # Section ⑥ lists recent post-mortems account-wide. To scope them to THIS incident's -# channel, read its channel_id (fduty incident info --incident-id --output-format -# toon | grep '^channel_id:') and re-run: fduty incident post-mortem-list --channel-ids +# channel, read channel_id from the projected detail section and re-run: +# fduty incident post-mortem-list --channel-ids # # Note: errexit (-e) is intentionally NOT set — every section must run even if one # command fails, so the summary stays as complete as possible. Each command's own @@ -22,14 +22,12 @@ if [ -z "$ID" ]; then exit 2 fi -# Print each command's DEFAULT renderer (a curated table/summary that projects the -# summary-relevant fields), NOT --output-format toon: toon dumps the full raw objects -# — every empty field plus heavy blobs like a change's labels.steps — which overflowed -# the output cap and forced repeated paging. For these read verbs the lean default IS -# the field projection a fault summary needs (id/severity/status/title/channel/times/…). +# Project detail explicitly because its default table includes unbounded narrative +# fields. The other read verbs use their compact default renderers; raw toon would +# dump every empty field plus heavy blobs like a change's labels.steps. run() { echo "===== fduty $* ====="; fduty "$@" 2>&1; echo; } -run incident detail "$ID" # ① 详情 + AI summary + alert counts + channel +run incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon # ① 详情 + AI summary + alert counts + channel run incident alerts "$ID" # ② contributing alerts run incident timeline "$ID" # ④ timeline run incident similar "$ID" --limit 5 # ⑤ similar past incidents (channel-backed)