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
37 changes: 13 additions & 24 deletions internal/skilldoc/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type Doc struct {
type Issue struct {
Doc string
Line int
Kind string // "unknown-command" | "unknown-flag" | "positional-as-flag" | "stale-fence"
Kind string // "unknown-command" | "unknown-flag" | "stale-fence"
Detail string
}

Expand Down Expand Up @@ -104,27 +104,23 @@ func lineOf(body string, off int) int {
return strings.Count(body[:off], "\n") + 1
}

// commandIndex maps a command path to its set of declared flag names and to the
// set of flags cligen folded into required positionals, and carries the sorted
// list of paths for longest-prefix resolution.
// commandIndex maps a command path to its set of declared flag names, and
// carries the sorted list of paths for longest-prefix resolution.
type commandIndex struct {
flags map[string]map[string]bool
folded map[string]map[string]bool
paths []string
flags map[string]map[string]bool
paths []string
}

func indexDump(d Dump) commandIndex {
idx := commandIndex{
flags: make(map[string]map[string]bool),
folded: make(map[string]map[string]bool),
flags: make(map[string]map[string]bool),
}
for _, c := range d.Commands {
set := make(map[string]bool, len(c.Flags))
for _, f := range c.Flags {
set[f.Name] = true
}
idx.flags[c.Path] = set
idx.folded[c.Path] = foldedFlagNames(positionalsOf(c.Use))
idx.paths = append(idx.paths, c.Path)
}
// Longest paths first so resolveCommand prefers the most specific match.
Expand Down Expand Up @@ -156,26 +152,19 @@ func validateExample(idx commandIndex, docPath string, ex Example) []Issue {
}}
}

folded := idx.folded[path]
var issues []Issue
for _, tok := range ex.Tokens {
name, isFlag := flagName(tok)
if !isFlag || HasPlaceholder(name) {
continue
}
// cligen folded this field into a required positional: the flag is still
// registered (so it is in flagSet) but passing it as a flag fails the
// binary's Args check. Catch it before the flagSet pass would wave it
// through — this is the exact misuse only a live run surfaced before.
if folded[name] {
issues = append(issues, Issue{
Doc: docPath,
Line: ex.Line,
Kind: "positional-as-flag",
Detail: "--" + name + " is folded into a required positional of `" + path + "` — pass it as a bare argument, not a flag",
})
continue
}
// A field cligen folds into a required positional keeps a same-named flag
// registered as a genuine alternative source: every generated command
// with such a fold uses requireBodyFieldOrExactArg/requireBodyFieldOrArgs
// (internal/cli/args.go), which explicitly accepts the flag alone — cligen
// never emits the bare requireExactArg/requireArgs form that would make
// passing the flag fail. So a folded name is just a flag like any other
// here: fall through to the flagSet check below.
if globalFlags[name] || flagSet[name] {
continue
}
Expand Down
31 changes: 18 additions & 13 deletions internal/skilldoc/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,14 @@ func TestValidate_UnknownCommandAndFlag(t *testing.T) {
}
}

// A field cligen folded into a required positional is still a registered flag,
// but passing it as `--flag` fails the binary's Args check. The validator must
// catch this misuse (kind "positional-as-flag") — the exact error that only a
// live run surfaced before Use was threaded into the oracle. Passing the field
// positionally must stay clean, and the same flag name on a command where it is
// NOT folded (two required ids) must remain valid.
func TestValidate_FoldedPositionalAsFlag(t *testing.T) {
// A field cligen folds into a required positional keeps a same-named flag
// registered as a genuine alternative source: every generated command with
// such a fold uses requireBodyFieldOrExactArg/requireBodyFieldOrArgs
// (internal/cli/args.go), which explicitly accepts the flag alone. So passing
// that flag — with or without the positional also present — must validate
// clean; only a flag that is not registered on the command at all is an
// actual defect.
func TestValidate_FoldedFlagIsValidAlternative(t *testing.T) {
d := Dump{Commands: []Command{
{ // single required id → cligen folds page-id into a positional
Path: "status-page change-active-list",
Expand All @@ -67,23 +68,27 @@ func TestValidate_FoldedPositionalAsFlag(t *testing.T) {
},
}}
docs := []Doc{
{Path: "bad", Body: "```bash\nfduty status-page change-active-list --page-id 5\n```\n"},
{Path: "good", Body: "```bash\nfduty status-page change-active-list 5 --type incident\n```\n"},
{Path: "flag-alone", Body: "```bash\nfduty status-page change-active-list --page-id 5 --type incident\n```\n"},
{Path: "positional", Body: "```bash\nfduty status-page change-active-list 5 --type incident\n```\n"},
{Path: "twoid", Body: "```bash\nfduty status-page change-timeline-create --page-id 5 --change-id 9\n```\n"},
{Path: "unknown-on-folder", Body: "```bash\nfduty status-page change-active-list --page-id 5 --bogus x\n```\n"},
}
byDoc := map[string][]Issue{}
for _, is := range Validate(d, docs) {
byDoc[is.Doc] = append(byDoc[is.Doc], is)
}
if n := len(byDoc["bad"]); n != 1 || byDoc["bad"][0].Kind != "positional-as-flag" {
t.Errorf("bad: want 1 positional-as-flag, got %+v", byDoc["bad"])
if n := len(byDoc["flag-alone"]); n != 0 {
t.Errorf("flag-alone: folded flag used without the positional want 0 issues, got %+v", byDoc["flag-alone"])
}
if n := len(byDoc["good"]); n != 0 {
t.Errorf("good: positional usage want 0 issues, got %+v", byDoc["good"])
if n := len(byDoc["positional"]); n != 0 {
t.Errorf("positional: positional usage want 0 issues, got %+v", byDoc["positional"])
}
if n := len(byDoc["twoid"]); n != 0 {
t.Errorf("twoid: --page-id on non-folding command want 0 issues, got %+v", byDoc["twoid"])
}
if n := len(byDoc["unknown-on-folder"]); n != 1 || byDoc["unknown-on-folder"][0].Kind != "unknown-flag" {
t.Errorf("unknown-on-folder: want 1 unknown-flag, got %+v", byDoc["unknown-on-folder"])
}
}

func TestValidate_GlobalFlagsAllowed(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion skills/flashduty/reference/calendar.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Update calendar

## Key concepts

- **`is-off` (bool, required on event-upsert):** `true` = mark as non-working day (holiday/closure); `false` = override to working day (make-up workday / 補班). This is the only enum-like field — it must be explicit; the server rejects a missing value.
- **`is-off` (bool, required on event-upsert):** `true` = mark as non-working day (holiday/closure); `false` = override to working day (make-up workday / 補班). This is the only enum-like field — it must be explicit; the CLI rejects a missing value before any request is sent.
- **`end-at` is exclusive:** a single-day event on 2026-01-17 needs `--start-at 2026-01-17 --end-at 2026-01-18`.
- **`workdays` integers:** 0 = Sunday, 1 = Monday … 6 = Saturday. Standard Mon–Fri = `1,2,3,4,5`.
- **Calendar kinds:** `personal` (editable, default filter) vs `region.official.holiday` (read-only, browsable). The returned `kind` field can also be `religion.holiday`.
Expand Down
5 changes: 2 additions & 3 deletions skills/flashduty/reference/channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,18 +296,17 @@ Update channel
## Key concepts

- **`--auto-resolve-mode`** enum: `trigger` (timer resets on each new alert trigger) | `update` (timer resets on any alert update).
- **Alert grouping `group.method`**: `i` = intelligent (embedding similarity), `p` = pattern (label equality), `n` = none. Set via `--data '{"group":{"method":"p","equals":[["service","env"]],"time_window":300}}'` on `create`/`update`.
- **Alert grouping `group.method`**: `i` = intelligent (embedding similarity), `p` = pattern (label equality), `n` = none. **`group.time_window` is in minutes** (default cap 1440 = 24h; extended accounts may allow up to 43200 = 30 days). Set via `--data '{"group":{"method":"p","equals":[["service","env"]],"time_window":30}}'` on `create`/`update`.
- **Rule status**: `enabled` | `disabled` — apply to escalation, inhibit, silence, and drop rules alike.
- **Inhibit `--equals`**: label keys that must be **equal** between the source (high-priority) and target (suppressed) alert to form a pair (e.g. `--equals service,env`).
- **Silence time windows**: `time_filter` (one-off, unix seconds, mutually exclusive) vs `time_filters` (recurring weekly HH:MM windows). Pass via `--data`.
- **Escalation `layers`** (required via `--data` on create/update): each layer needs `target` (with `person_ids`/`team_ids`/`schedule_to_role_ids`/`emails` + `by` OR `webhooks`) and optionally `notify_step`, `max_times`, `escalate_window`, `force_escalate`.

## Gotchas

- **Positional trap**: `channel-id` is **positional** on `info`, `infos`, `update`, `delete`, `disable`, `enable`, `escalate-rule-list`, `inhibit-rule-create`, `inhibit-rule-list`, `silence-rule-create`, `silence-rule-list`, `unsubscribe-rule-create`, `unsubscribe-rule-list`. It is a **flag** (`--channel-id`) on all `escalate-rule-*`, `inhibit-rule-update/delete/enable/disable`, `silence-rule-update/delete/enable/disable`, `unsubscribe-rule-update/delete/enable/disable`. When in doubt, the fence heading `### verb <channel-id>` = positional; heading without `<…>` = flag.
- **`channel-id` can be passed positionally or via `--channel-id` — both work.** On `info`, `infos`, `update`, `delete`, `disable`, `enable`, `escalate-rule-list`, `inhibit-rule-create`, `inhibit-rule-list`, `silence-rule-create`, `silence-rule-list`, `unsubscribe-rule-create`, `unsubscribe-rule-list`, the fence heading `### verb <channel-id>` shows the shorter positional form, but the matching `--channel-id` flag is accepted too. On all `escalate-rule-*` (except `-list`), `inhibit-rule-update/delete/enable/disable`, `silence-rule-update/delete/enable/disable`, `unsubscribe-rule-update/delete/enable/disable` there is no positional — `--channel-id` is the only way in. If both positional and flag are given anywhere, the flag value wins.
- **`escalate-rule-create` needs `layers` via `--data`** — it is required and cannot be expressed as a flat flag. Omitting it returns a validation error.
- **`rule-id` is a MongoDB ObjectID string**, not an integer. Retrieve it from `escalate-rule-list`, `inhibit-rule-list`, `silence-rule-list`, or `unsubscribe-rule-list` before any update/delete/enable/disable.
- **`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.
- **`delete` on a channel is irreversible** — all rules within it are also removed. Confirm the `channel-id` against `list` before proceeding.
- **Empty rule list is authoritative** — if `escalate-rule-list` / `silence-rule-list` / etc. returns no rows, no rules exist; do not widen the query.

Expand Down
2 changes: 1 addition & 1 deletion skills/flashduty/reference/field.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Update field

## Gotchas

- **`delete`, `info`, `update` take `<field-id>` as a POSITIONAL first argument**, not `--field-id`. Example: `fduty field delete <field-id>`, not `--field-id <field-id>`.
- **`delete`, `info`, `update` take `<field-id>` positionally or via `--field-id`** — both work, e.g. `fduty field delete <field-id>` or `fduty field delete --field-id <field-id>`. If both are given, the flag wins.
- **`--options` replaces the whole list on `update`** — omitting it leaves options unchanged, but a partial list silently drops the missing values. Always pass the full desired set.
- **`--field-name` is the machine key** (`[a-zA-Z0-9_]`, starts with letter/underscore, ≤40 chars). It is the stable identifier for downstream enrichment rules — choose it carefully; it cannot be renamed.
- **`delete` is permanent and cascades** — any enrichment rules that reference the field by `field_name` will lose their target. Confirm the name against `field list` before deleting.
Expand Down
8 changes: 4 additions & 4 deletions skills/flashduty/reference/member.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# fduty member — command card

Prereq: `SKILL.md` read. `invite` sends invitation emails immediately (up to 20 per call). `delete` is **irreversible** — it removes the member from the organization; default safety check rejects deletes when the member is referenced by escalation rules or schedules (pass `--is-force` to bypass). `role-update` **replaces** all role assignments atomically; `role-grant`/`role-revoke` are additive/subtractive.
Prereq: `SKILL.md` read. `invite` sends invitation emails immediately (up to 20 per call). `delete` is **irreversible** — it removes the member from the organization. Default safety check rejects deletes when the member is referenced by escalation rules, schedules, team membership, etc. (pass `--is-force` to bypass). A member provisioned via SSO cannot be deleted at all, even with `--is-force` — disable SSO management for them first. `role-update` **replaces** all role assignments atomically; `role-grant`/`role-revoke` are additive/subtractive.

## Route here when

Expand Down Expand Up @@ -124,10 +124,10 @@ Update member roles

- **Resolving a `person_id` → name: use `fduty person infos <person_id> …`, NOT `member list`.** `schedule`/`oncall`/`incident`/`alert` output returns `person_id`s, a **different namespace from `member_id`**. `fduty person infos` (the sibling `person` group) batch-resolves any number of `person_id`s to `person_name` in one call (rows under `.items[]`). Matching `member list` rows on `member_id == <person_id>` is wrong, and paginating the full roster to find them silently misses people on later pages.
- **`invite` members array is body-only — use `--data`.** Individual members cannot be passed as flat flags; the `members` array (with nested `role_ids`, `email`, `phone`, etc.) lives only in the JSON body. Up to 20 members per call.
- **`info-reset <member-id>` is POSITIONAL.** Pass the member ID as the first bare argument, not `--member-id`: `fduty member info-reset <member_id> --member-name "New Name"`. The `--member-id` flag exists but the positional form is required per the `use` field.
- **`role-grant / role-revoke / role-update` — role IDs are POSITIONAL.** All three verbs take role IDs as positional args: `fduty member role-grant <role_id> [<role_id2>...] --member-id <member_id>`. The `--role-ids` flag also exists but the positional form is authoritative.
- **`info-reset <member-id>` can be passed positionally or via `--member-id`** — both work: `fduty member info-reset <member_id> --member-name "New Name"` or `fduty member info-reset --member-id <member_id> --member-name "New Name"`. If both are given, the flag wins.
- **`role-grant` / `role-revoke` / `role-update` — role IDs can be passed positionally or via `--role-ids`.** Positional is shorter: `fduty member role-grant <role_id> [<role_id2>...] --member-id <member_id>`, or pass `--role-ids <role_id>,<role_id2>` instead. If both are given, the flag wins.
- **`role-update` is a full replacement.** List current roles with `member list` first; omitting a role removes it.
- **`delete` default is safe** (checks escalation rules / schedules). If it rejects with a reference error, review those references before using `--is-force`.
- **`delete` default is safe** (checks escalation rules / schedules / team membership). If it rejects with a reference error, review those references before using `--is-force`. An SSO-provisioned member rejects unconditionally — `--is-force` does not override that check.
- **Empty `member list` result is authoritative** — if `--query` returns nothing the member does not exist; do not widen the query.

## Worked example
Expand Down
4 changes: 2 additions & 2 deletions skills/flashduty/reference/role.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ Create or update a role

## Gotchas

- **`delete`, `disable`, `enable`, `info` take `<role-id>` as a POSITIONAL arg**, not `--role-id`: `fduty role delete <role-id>`. The flag form is silently ignored.
- **`member-grant` / `member-revoke`: `<member-id>` is POSITIONAL (one or more space-separated); `--role-id` is a flag** — easy to flip. Example: `fduty role member-grant 123 456 --role-id 7`.
- **`delete`, `disable`, `enable`, `info` take `<role-id>` positionally or via `--role-id`** — both work: `fduty role delete <role-id>` or `fduty role delete --role-id <role-id>`. If both are given, the flag wins.
- **`member-grant` / `member-revoke`: `<member-id>` is POSITIONAL (one or more space-separated); `--role-id` is a flag** — easy to flip. Example: `fduty role member-grant 123 456 --role-id 7`. Member IDs can also be passed via `--member-ids` instead of the positional (same fold-then-override rule).
- **`upsert --permission-ids` replaces the full set** on update — omitting it clears all permissions. Always read `permission-list --role-ids <id> --with-all` first to get the current set before modifying.
- **`upsert` with no `--role-id` (or `--role-id 0`) creates; with `--role-id N` updates** — the verb doubles as create and update; check for an existing role with `list` to avoid accidental duplicates.
- **`delete` is irreversible** — members who had this role lose its permissions immediately. Prefer `disable` to park a role without destroying it.
Expand Down
11 changes: 9 additions & 2 deletions skills/flashduty/reference/rum.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ Prereq: `SKILL.md` read. Read verbs are free. `application-create` / `applicatio
| list front-end error issues (with time window) | `issue-list` |
| full detail of one error issue | `issue-info` |
| mark issue resolved / label cause | `issue-update` |
| run raw SQL-style queries over RUM data | `data-query` |
| top values for one facet field, by occurrence count | `facet-count` |
| browse facet-enabled RUM fields | `facet-list` |
| browse all RUM field definitions | `field-list` |
| send a test alert to an app's webhook | `application-webhook-test` |
| session replay metadata (app/device/views for a session) | `session-replay-metadata` |
| page through a session's replay segments | `session-replay-segments` |

## Hot flow — triage front-end errors

Expand Down Expand Up @@ -190,7 +197,7 @@ List session replay segments

**`--type` (application-create / update) — closed enum:**
`browser` · `ios` · `android` · `react-native` · `flutter` · `kotlin-multiplatform` · `roku` · `unity`
No `miniprogram` / `wechat` — unsupported, do not guess a value.
No `miniprogram` / `wechat` — you cannot create an application with these; do not guess a value. (Session/view `source` on `session-replay-metadata` does include `miniprogram` — that enum describes what recorded the data, not what you can create.)

**Issue `--status` (issue-update / issue-list `--statuses`):**
`for_review` → `reviewed` → `ignored` | `resolved`
Expand All @@ -205,7 +212,7 @@ Regression: a `resolved` issue that recurs gets a `regression{}` object on its r

- **`issue-list` time flags are MILLISECOND epoch, both required.** Use `--start-time` / `--end-time` (NOT `--since`/`--until`, NOT seconds). Max range 183 days. Example: `$(date +%s)000` converts a seconds epoch to ms.
- **`application_id` ≠ `issue_id`.** `issue_id` comes from `issue-list` — never pass an `application_id` where `issue_id` is expected.
- **`application-create` positional:** `use` is `application-create <team-id>` — pass the team id as the first bare arg, NOT `--team-id`. Same pattern: `application-delete`, `application-info`, `application-infos`, `application-update`, `issue-info`, `issue-update` all take their primary id as positional. `application-list` and `issue-list` are all-flags.
- **`application-create <team-id>` can be passed positionally or via `--team-id`** — both work; positional is shorter. Same pattern on `application-delete`, `application-info`, `application-update`, `issue-info`, `issue-update`: each takes its primary id either as the bare positional shown in the fence heading, or via the matching `--application-id`/`--issue-id` flag. `application-infos` only has the plural `--application-ids` as its flag alternative (comma-separated, vs space-separated positionals). If both positional and flag are given, the flag wins. `application-list` and `issue-list` are all-flags.
- **`alerting` and `tracing` are nested objects** — configure them via `--data '{"alerting":{...},"tracing":{...}}'`; there are no flat flags for their sub-fields. Scalar flags (`--application-name`, `--type`, …) override matching `--data` keys.
- **Application records hold CONFIG only** — no traffic volume, error-rate, or session-count fields. For trend data, query `monit` RUM series.
- **Empty `issue-list` is authoritative** — a filter returning no items means no matching issues, not a missing feature. Do not widen the query or guess.
Expand Down
Loading
Loading