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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
go 1.26.0

require (
github.com/flashcatcloud/go-flashduty v0.15.15
github.com/flashcatcloud/go-flashduty v0.15.16
github.com/mattn/go-runewidth v0.0.30
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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.15.15 h1:jMgWfpuXqdyHArSvUtf9ATwFFB7s49hgN7Rv+c9PxAY=
github.com/flashcatcloud/go-flashduty v0.15.15/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
github.com/flashcatcloud/go-flashduty v0.15.16 h1:h04Z0NGxB4ujlfKliVPD8QhgLgBObeAoRMz9rXJpnes=
github.com/flashcatcloud/go-flashduty v0.15.16/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
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.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
Expand Down
137 changes: 137 additions & 0 deletions internal/cli/integration_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package cli

import (
"encoding/json"
"reflect"
"testing"
)

func integrationContractFixture() map[string]any {
return map[string]any{
"integration_id": 42, "data_source_id": 42, "account_id": 100, "exclusive_data_source_id": 0,
"name": "Contract fixture", "description": "", "team_id": 7,
"category": "im", "plugin_type": "slack_app", "plugin_type_name": "Slack", "plugin_id": 9,
"ref_id": "a_100", "status": "enabled", "created_at": 1700000000, "updated_at": 1700000001, "last_time": 0,
"creator_id": 11, "updated_by": 12, "no_editable": false, "editable": true,
"settings": map[string]any{"headers": map[string]any{"Authorization": "fixture-not-a-real-secret"}},
"integration_key": "fixture-not-a-real-key",
}
}

func TestIntegrationListPreservesConfigurationAndPermissions(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
fixture := integrationContractFixture()
stub.data = map[string]any{"p": 1, "limit": 100, "total": 1, "items": []any{fixture}}
out, err := execCommand("integration", "list", "--json")
if err != nil {
t.Fatal(err)
}
var result map[string]any
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatal(err)
}
items, ok := result["items"].([]any)
if !ok || len(items) != 1 {
t.Fatalf("unexpected list output: %s", out)
}
item := items[0].(map[string]any)
for field := range fixture {
if _, ok := item[field]; !ok {
t.Errorf("list dropped backend field %s", field)
}
}
for _, field := range []string{"settings", "integration_key", "editable", "no_editable"} {
if !reflect.DeepEqual(item[field], fixture[field]) {
t.Errorf("%s changed: got %#v, want %#v", field, item[field], fixture[field])
}
}
if stub.lastPath != "/integration/list" {
t.Fatalf("request path = %s", stub.lastPath)
}
}

func TestIntegrationInfoPreservesConfigurationAndTestResult(t *testing.T) {
for _, withTest := range []bool{false, true} {
t.Run(map[bool]string{false: "absent test", true: "failed test"}[withTest], func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
fixture := integrationContractFixture()
for _, field := range []string{"account_id", "data_source_id", "exclusive_data_source_id"} {
delete(fixture, field)
}
if withTest {
fixture["test"] = map[string]any{"success": false, "message": "fixture plugin test failed"}
}
stub.data = fixture
out, err := execCommand("integration", "info", "42", "--json")
if err != nil {
t.Fatal(err)
}
var result map[string]any
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatal(err)
}
for _, field := range []string{"settings", "integration_key", "plugin_id", "editable", "creator_id", "updated_by", "no_editable"} {
if _, ok := result[field]; !ok {
t.Errorf("info dropped backend field %s", field)
}
}
if !reflect.DeepEqual(result["settings"], fixture["settings"]) || result["integration_key"] != fixture["integration_key"] {
t.Errorf("info changed configuration or key: %s", out)
}
if !reflect.DeepEqual(result["test"], fixture["test"]) {
t.Errorf("test result changed: %s", out)
}
if !withTest {
if _, ok := result["test"]; ok {
t.Errorf("absent test was fabricated: %s", out)
}
}
})
}
}

func TestIntegrationCreatePreservesKeyAndPluginTest(t *testing.T) {
for _, mode := range []string{"data", "flags"} {
t.Run(mode, func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = map[string]any{"integration_id": 42, "integration_key": "fixture-key", "test": map[string]any{"success": false, "message": "fixture plugin test failed"}}
args := []string{"integration", "create", "--json", "--data", `{"plugin_type":"standard.alert","integration_key":"00000000000000000000000000000000100","is_test":true}`}
if mode == "flags" {
args = []string{"integration", "create", "--json", "--plugin-type", "standard.alert", "--integration-key", "00000000000000000000000000000000100", "--is-test"}
}
out, err := execCommand(args...)
if err != nil {
t.Fatal(err)
}
if stub.lastBody["integration_key"] != "00000000000000000000000000000000100" || stub.lastBody["is_test"] != true {
t.Errorf("create dropped key or test flag: %#v", stub.lastBody)
}
var result map[string]any
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result["test"], stub.data.(map[string]any)["test"]) {
t.Errorf("create lost failed plugin test: %s", out)
}
})
}
}

func TestIntegrationUpdatePreservesTestAndExplicitEmptyHeaders(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
_, err := execCommand("integration", "update", "42", "--team-id", "0", "--is-test", "--data", `{"settings":{"headers":{}}}`)
if err != nil {
t.Fatal(err)
}
if stub.lastBody["is_test"] != true || stub.lastBody["team_id"] != float64(0) {
t.Errorf("update lost test flag or explicit team clear: %#v", stub.lastBody)
}
want := map[string]any{"headers": map[string]any{}}
if !reflect.DeepEqual(stub.lastBody["settings"], want) {
t.Errorf("update changed explicit empty headers: %#v", stub.lastBody)
}
}
5 changes: 3 additions & 2 deletions skills/flashduty/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: flashduty
version: "3.0"
description: "USE FIRST for Flashduty tasks — status pages, incidents, alerts, on-call, monitors, automations, RUM, sourcemaps, members. `flashduty` CLI = the whole API. ALWAYS load this skill + read reference/<domain>.md for exact verbs & flags BEFORE running flashduty. Don't guess or --help-dance."
description: "USE FIRST for Flashduty tasks — status pages, incidents, alerts, integrations, on-call, monitors, automations, RUM, sourcemaps, members. `flashduty` CLI = the whole API. ALWAYS load this skill + read reference/<domain>.md for exact verbs & flags BEFORE running flashduty. 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).
---
Expand Down Expand Up @@ -76,7 +76,8 @@ Some asks span several commands. For those the skill ships a script that fetches
| datasource 数据源 = a system Flashmonit queries (Prometheus / Loki / SQL / SLS) / connect a datasource 连接数据源 / SLS project / logstore / Redis / MongoDB / Kafka / database overview 数据库概览 / locks 锁 / slowlog 慢日志 | **`reference/monit-datasource.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`** |
| channel / 协作空间 / collaboration space / 频道 / integration 集成 / 告警来源 alert source / alert grouping 告警分组 | **`reference/channel.md`** |
| public integration / 公共集成 / 集成中心 / integration 集成 / alert source 告警来源 / integration key 集成 Key / IM bot / webhook configuration | **`reference/integration.md`** |
| channel / 协作空间 / collaboration space / 频道 / channel-owned dedicated integration 协作空间专属集成 / alert grouping 告警分组 | **`reference/channel.md`** |
| dispatch rule 分派策略 / 分派规则 / escalation rule 升级规则 / notify layers 通知层级 / who gets paged | **`reference/escalation.md`** |
| silence 静默 / 屏蔽 / inhibit 抑制 / drop rule 丢弃 / noise reduction 降噪 / maintenance silence 维护窗口静默 | **`reference/noise.md`** |
| enrichment / 数据加工 / 富化 / label mapping 字段映射 / extraction 提取 / mapping schema 集成 schema | **`reference/enrichment.md`** |
Expand Down
2 changes: 1 addition & 1 deletion skills/flashduty/reference/channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,5 +133,5 @@ Escalation, silence, inhibit and unsubscribe rules live on their own cards: `ref

- **`channel-id` can be passed positionally or via `--channel-id` — both work** on every verb of this card (`info`, `infos`, `update`, `delete`, `disable`, `enable`). The fence heading `### verb <channel-id>` shows the shorter positional form; the flag is an equally valid alternative, and if both are given the flag value wins.
- **`channel create` requires `--channel-name` and `--team-id`** even though they are not marked `required` in the flag list — the server rejects the request without them.
- **`--plugin-ids` subscribes integrations that already exist; no `flashduty` verb creates or configures one.** Creating an integration (email, webhook, an alert source) and reading its inbound address/token live in the console only — they are not part of the open API this CLI speaks, so they are absent from the command tree by design. When the user asks how to *connect* something, answer from the product documentation and point at the console; do not dump and grep the command tree looking for a verb that cannot exist. `channel info` likewise reports the channel's own fields, not integration credentials.
- **`--plugin-ids` subscribes integrations that already exist.** Public integration creation, configuration, and inbound keys are available through the `integration` commands; read `reference/integration.md`. Channel-owned dedicated integrations are outside that public API surface and are managed in the console. `channel info` reports the channel's own fields, not integration credentials.
- **`delete` on a channel is irreversible** — all rules within it (escalation, silence, inhibit, drop) are also removed. Confirm the `channel-id` against `list` before proceeding.
107 changes: 107 additions & 0 deletions skills/flashduty/reference/integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# flashduty integration — command card

Prereq: read `SKILL.md`. These commands manage **public/account-level integrations**, including supported alert, change, IM, and webhook types. They do not manage channel-owned dedicated integrations (`c_` references); use the console for those. For channel configuration and subscriptions, read `reference/channel.md`.

## Read before changing configuration

- Start with `type-list` and `list`. Create only a `plugin_type` whose type entry has `supports_api_create: true`.
- List/info return stored `settings` and decrypted `integration_key` without masking. Prefer `--fields` projections for discovery; do not paste secrets into a report or unrelated tool. An integration key authenticates inbound pushes; it is not the account App Key used by this CLI.
- `editable` describes object permission; writes also require Integrations Manage. `no_editable: true` marks a read-only integration. Do not infer write authorization from read access.
- Before update/delete or key rotation, get per-target confirmation and save `info` to the protected workspace backup described in `SKILL.md`. Confirm references before deletion. Rotating the key invalidates the old key immediately; update the sender configuration as part of the approved change.
- Metadata-only updates preserve omitted fields. `--team-id 0` deliberately clears team ownership. Submitted `settings` replace type-specific configuration; known sensitive fields omitted or set to `******` preserve their stored value. An explicit `headers: {}` clears headers.
- `--is-test` on create, or on update with `settings`, returns plugin test results. Inspect `test.success` and `test.message`: HTTP success is not plugin-test success. An absent `test` means no test result was returned, not a successful test.

## Discovery

```bash
flashduty integration type-list --category event.alert --output-format toon
flashduty integration list --fields integration_id,name,plugin_type,team_id,editable,no_editable --output-format toon
flashduty integration info 42 --fields integration_id,name,plugin_type,team_id,editable,no_editable --output-format toon
```

## Approved configuration changes

Use the exact type-specific settings provided for the target integration; do not guess credential keys. `--integration-key` is optional on create; omitting it generates a key. If provided, it must satisfy the account suffix, length, and uniqueness checks.

```bash
# Only after confirmation; inspect the returned integration_id/key and test result.
flashduty integration create --plugin-type standard.alert --name "Production alerts" --is-test --data '{"settings":{}}' --json
# Only after backing up integration 42 and confirming this rename.
flashduty integration update 42 --name "Production alerts (primary)" --json
```

Do not add `--is-test` to a metadata-only update expecting a plugin test; settings must be submitted for an update test. Disable/enable preserve configuration. Delete may fail with `ReferenceExist` when other objects still reference the integration.

<!-- GENERATED:integration START · 由 flashduty __dump-commands 同步 · 勿手改 fence 内 -->

### create
Create integration
- `--description` string — Free-form description, at most 499 characters. (≤499 chars)
- `--integration-key` string — Optional pre-generated key: 35 characters, ending with the account ID modulo 1000 padded to three digits; it must not already be in use. Omit to generate a key. (35-35 chars)
- `--is-test` bool — Run the plugin test during creation; false when omitted. Inspect the returned 'test.success' for the test outcome.
- `--name` string — Integration name, 2–49 characters when nonempty; defaults to the integration type name when omitted or empty. (2-49 chars)
- `--plugin-type` string (required) — Integration type. Must be one listed by 'POST /integration/type/list' with 'supports_api_create: true'.
- `--team-id` int64 — Owning team ID; omit or use '0' for no team. A nonzero ID must belong to the current account. (min 0)
- body-only (`--data`): settings (object)
- response: single object (`data` unwrapped to the top level) — fields: integration_id (integer); integration_key (string); test (object)

### delete <integration-id>
Delete integration
- `<integration-id>` (positional, required) int64 — Integration ID. (min 1)

### disable <integration-id>
Disable integration
- `<integration-id>` (positional, required) int64 — Integration ID. (min 1)

### enable <integration-id>
Enable integration
- `<integration-id>` (positional, required) int64 — Integration ID. (min 1)

### info <integration-id>
Get integration detail
- `<integration-id>` (positional, required) int64 — Integration ID. (min 1)
- response: single object (`data` unwrapped to the top level) — fields: category (string); created_at (string); creator_id (integer); description (string); editable (boolean); integration_id (integer); integration_key (string); last_time (string); name (string); no_editable (boolean); plugin_id (integer); plugin_type (string); plugin_type_name (string); ref_id (string); settings (object); status (string); team_id (integer); test (object); updated_at (string); updated_by (integer)

### key-rotate <integration-id>
Rotate integration key
- `<integration-id>` (positional, required) int64 — Integration ID. (min 1)
- response: single object (`data` unwrapped to the top level) — fields: integration_key (string)

### list
List integrations
- `--asc` bool — Sort ascending when true, descending when false.
- `--category` string — Filter by category. Accepts a comma-separated list.
- `--is-my-team` bool — Limit the result to integrations owned by your teams.
- `--limit` int64 — Page size. Defaults to 100, maximum 100. (1-100)
- `--name` string — Filter by integration name.
- `--orderby` string — Sort field. Defaults to 'created_at'; 'plugin_type' is sorted by the underlying plugin. · enum: created_at | updated_at | name | plugin_type | status
- `--page` int64 — Page number, 1-based. (min 1)
- `--plugin-type` string — Filter by integration type. Accepts a comma-separated list.
- `--ref-ids` stringSlice — Filter by source reference IDs. Each value must start with 'c_' (channel), 'a_' (account) or 'w_'.
- `--search-after-ctx` string
- `--status` string — Filter by status. Accepts a comma-separated list.
- `--team-ids` intSlice — Filter by team IDs. With 'is_my_team', the values narrow that set further.
- `--type` string — Deprecated. Merged into 'plugin_type' when both are set.
- response: `{items: [...], limit, p, total}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — items fields: account_id (integer); category (string); created_at (string); creator_id (integer); data_source_id (integer); description (string); editable (boolean); exclusive_data_source_id (integer); integration_id (integer); integration_key (string); last_time (string); name (string); no_editable (boolean); plugin_id (integer); plugin_type (string); plugin_type_name (string); ref_id (string); settings (object); status (string); team_id (integer); updated_at (string); updated_by (integer)

### type-list
List integration types
- `--asc` bool — Sort ascending when 'true' (the default); descending when 'false'.
- `--category` string — Filter by category. Accepts a comma-separated list, for example 'event.alert,event.change'.
- `--limit` int64 — Page size. Defaults to 20, maximum 100. (1-100)
- `--orderby` string — Sort field. When omitted, types are returned in console ranking order. · enum: id | created_at | updated_at | name | type
- `--page` int64 — Page number, 1-based. (min 1)
- `--search-after-ctx` string
- response: `{items: [...], limit, p, total}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — items fields: category (string); plugin_type (string); plugin_type_logo_url (string); plugin_type_name (string); status (string); supports_api_create (boolean)

### update <integration-id>
Update integration
- `--description` string — New description, at most 499 characters. (≤499 chars)
- `<integration-id>` (positional, required) int64 — Integration ID. (min 1)
- `--is-test` bool — Run the plugin test when submitting 'settings'; no test is performed for a metadata-only update.
- `--name` string — New name, 2–49 characters. (2-49 chars)
- `--team-id` int64 — New owning team ID; '0' clears the team assignment. (min 0)
- body-only (`--data`): settings (object)
- response: same shape as `info <integration-id>` above

<!-- GENERATED:integration END -->
Loading