fp-cloud-cli 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. fp_cli/__init__.py +10 -0
  2. fp_cli/__main__.py +4 -0
  3. fp_cli/_click_compat.py +87 -0
  4. fp_cli/_context.py +332 -0
  5. fp_cli/_version.py +1 -0
  6. fp_cli/analytics.py +432 -0
  7. fp_cli/analytics_config.py +77 -0
  8. fp_cli/analytics_registry.py +83 -0
  9. fp_cli/app.py +492 -0
  10. fp_cli/auth.py +160 -0
  11. fp_cli/client.py +1738 -0
  12. fp_cli/commands/__init__.py +0 -0
  13. fp_cli/commands/_write.py +214 -0
  14. fp_cli/commands/agent_cmds.py +407 -0
  15. fp_cli/commands/alerts_cmds.py +445 -0
  16. fp_cli/commands/audits_cmds.py +1054 -0
  17. fp_cli/commands/auth_cmds.py +512 -0
  18. fp_cli/commands/errors_cmds.py +190 -0
  19. fp_cli/commands/evals_cmds.py +161 -0
  20. fp_cli/commands/events_cmds.py +159 -0
  21. fp_cli/commands/fleet_cmds.py +416 -0
  22. fp_cli/commands/guardrails_cmds.py +148 -0
  23. fp_cli/commands/incidents_cmds.py +693 -0
  24. fp_cli/commands/keys_cmds.py +407 -0
  25. fp_cli/commands/list_cmds.py +63 -0
  26. fp_cli/commands/orgs_cmds.py +319 -0
  27. fp_cli/commands/policies_cmds.py +499 -0
  28. fp_cli/commands/queries_cmds.py +378 -0
  29. fp_cli/commands/sessions_cmds.py +151 -0
  30. fp_cli/commands/settings_cmds.py +150 -0
  31. fp_cli/commands/usage_cmds.py +35 -0
  32. fp_cli/commands/users_cmds.py +404 -0
  33. fp_cli/config.py +330 -0
  34. fp_cli/dates.py +78 -0
  35. fp_cli/enforcement.py +345 -0
  36. fp_cli/errors.py +98 -0
  37. fp_cli/models.py +902 -0
  38. fp_cli/orgs.py +30 -0
  39. fp_cli/output.py +6660 -0
  40. fp_cli/permissions.py +209 -0
  41. fp_cli/policy_check.py +290 -0
  42. fp_cli/py.typed +0 -0
  43. fp_cli/select.py +322 -0
  44. fp_cli/theme.py +53 -0
  45. fp_cloud_cli-0.0.1.dist-info/METADATA +335 -0
  46. fp_cloud_cli-0.0.1.dist-info/RECORD +50 -0
  47. fp_cloud_cli-0.0.1.dist-info/WHEEL +5 -0
  48. fp_cloud_cli-0.0.1.dist-info/entry_points.txt +2 -0
  49. fp_cloud_cli-0.0.1.dist-info/licenses/LICENSE +42 -0
  50. fp_cloud_cli-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,693 @@
1
+ """Incident triage: incidents list/count/show/ack/assign/resolve/close/archive/clear/comment*/subscribe*/open.
2
+
3
+ Incidents live under /api/issues but the triage workflow is distinct, so it
4
+ gets its own top-level group. The id IS the handle (incidents have no human name), so the
5
+ action commands take it directly; the boxed views show a short id + the alert label.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import List, Optional
12
+
13
+ import typer
14
+
15
+ from .. import client as api
16
+ from .. import output
17
+ from .._context import GLOBALS_EPILOG, AppState, require_auth, validate_limit
18
+ from ..errors import ApiError, ForbiddenError, NotFoundError
19
+ from . import _write
20
+
21
+ _SEVERITIES = ("info", "warning", "critical")
22
+ # Mirrors `issue_sync::ISSUE_STATES` on the server. `closed` is the second
23
+ # TERMINAL state (won't-fix); missing it here would reject `--state closed`
24
+ # client-side with exit 2 for a state the server accepts.
25
+ _STATES = ("firing", "acknowledged", "resolved", "closed")
26
+ _UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
27
+
28
+
29
+ def _validate_states(value: Optional[str]) -> None:
30
+ """Reject an unknown ``--state`` value up front (exit 2) rather than letting the server
31
+ silently drop it and return a confusing set. Accepts a CSV of firing/acknowledged/resolved/closed."""
32
+ if value is None:
33
+ return
34
+ for s in value.split(","):
35
+ s = s.strip()
36
+ if s and s not in _STATES:
37
+ raise typer.BadParameter(
38
+ f"'{s}' is not a valid state. Choose from: {', '.join(_STATES)} (CSV).",
39
+ param_hint="--state",
40
+ )
41
+
42
+
43
+ def _fail(state: AppState, exc: Exception, *, incident_id: str = "") -> None:
44
+ """Re-raise as a typed error for the central chokepoint to render (JSON envelope under
45
+ ``--json`` on stdout, red box otherwise). A 404 — or a **malformed (non-UUID) id**, which the
46
+ server's path extractor answers with a 400 rather than a 404 — becomes the friendlier
47
+ ``no issue <id>`` (exit 6); every other ApiError/ForbiddenError keeps the server's message
48
+ and exit code."""
49
+ # EXACTLY 400, not a range. This read `>= 500` on the belief that the server answers a
50
+ # malformed id with a 500, and it does not: axum's path extractor rejects it at 400 with a
51
+ # plain-text body, which the dashboard turns into the generic "upstream returned non-JSON
52
+ # response". So the remap never fired — `fp issues show not-a-uuid` exited 1 carrying that
53
+ # internal phrase while `fp audits show not-a-uuid` exited 6 with a usable message, and
54
+ # anything branching on exit 6 to mean not-found silently took the wrong arm.
55
+ #
56
+ # `>= 400` is the obvious fix and it is WRONG, which is why this is spelled out. Issue ids
57
+ # are not required to be UUIDs — `fp issues assign i1 --assignee ...` is a documented call —
58
+ # so a non-UUID id reaches real handlers and collects real 4xx answers. A 422 "a@x.com is
59
+ # not an operator" would then be rewritten as "no issue i1", replacing the one sentence that
60
+ # explains the failure with a claim that is false. Only 400 means "the router refused to
61
+ # parse this id"; every other 4xx got past the extractor and has something to say.
62
+ status = getattr(exc, "status", None) or 0
63
+ malformed = bool(incident_id) and not _UUID_RE.match(incident_id) and status == 400
64
+ if incident_id and (malformed or isinstance(exc, NotFoundError)):
65
+ raise NotFoundError(
66
+ f"no issue {incident_id}", hint="run `fp issues list` to see open issues"
67
+ )
68
+ raise exc
69
+
70
+
71
+ def incidents_list(
72
+ ctx: typer.Context,
73
+ state_filter: Optional[str] = typer.Option(None, "--state", help="Filter by state(s): firing, acknowledged, resolved, closed (CSV)."),
74
+ alert_id: Optional[str] = typer.Option(None, "--alert-id", help="Only incidents for this alert."),
75
+ limit: int = typer.Option(50, "--limit", "-n", help="Max incidents to return."),
76
+ show_id: bool = typer.Option(False, "--show-id", help="Show the full incident id instead of the short form (always full in --json)."),
77
+ ) -> None:
78
+ """List incidents in a boxed table (newest-opened first).
79
+
80
+ Shows `id · title · source · severity · state · opened · assignees` — the id is the handle the
81
+ action commands take (short by default, full with `--show-id`); `title` is the issue's own
82
+ identifying line (every issue has one, unlike `alert_name`); `source` is where it came from
83
+ (`manual`/`alert`/`audit`) and trails the alert name when there is one; severity is
84
+ colour-coded, state as `● firing`/`● acknowledged`/`○ resolved`, `opened` the compact age.
85
+ A footer breaks down the state distribution. Needs `issues:read`. With `--json`:
86
+ `{"issues": [{id, title, source, source_finding_id, alert_name, alert_severity, state,
87
+ opened_at, assignees, ...}]}`.
88
+
89
+ Example:
90
+
91
+ * `fp issues list --state firing`
92
+ """
93
+ state: AppState = ctx.obj
94
+ _validate_states(state_filter)
95
+ validate_limit(limit)
96
+ cctx = require_auth(state)
97
+ incidents = api.list_incidents(cctx, state=state_filter, alert_id=alert_id, limit=limit)
98
+ if state.json:
99
+ output.emit_json({"issues": incidents})
100
+ return
101
+ output.render_incidents(incidents, show_id=show_id)
102
+ output.incidents_footer(incidents)
103
+
104
+
105
+ def incidents_count(
106
+ ctx: typer.Context,
107
+ state_filter: Optional[str] = typer.Option(None, "--state", help="Filter by state(s) (CSV)."),
108
+ ) -> None:
109
+ """Count incidents (optionally by state) as a compact stat card.
110
+
111
+ With no `--state` the server counts the open ones (firing + acknowledged). Needs
112
+ `issues:read`. With `--json`: `{"count": N}`.
113
+
114
+ Example:
115
+
116
+ * `fp issues count --state firing`
117
+ """
118
+ state: AppState = ctx.obj
119
+ _validate_states(state_filter)
120
+ cctx = require_auth(state)
121
+ count = api.count_incidents(cctx, state=state_filter)
122
+ if state.json:
123
+ output.emit_json({"count": count})
124
+ else:
125
+ output.render_incident_count(count, state=state_filter)
126
+
127
+
128
+ def incidents_show(
129
+ ctx: typer.Context,
130
+ incident_id: str = typer.Argument(..., help="Incident id."),
131
+ ) -> None:
132
+ """Show one incident in full — a stack of cards: identity, comments, subscribers, activity.
133
+
134
+ The identity card is headed by the issue's own title and shows severity · state · source ·
135
+ opened, who acknowledged/is assigned, and the breach; empty sections are omitted. Not-found →
136
+ `✗ no incident <short id>`, exit 6. Needs `issues:read`. With `--json`: the full `Incident`
137
+ (including `title`, `source`, `source_finding_id`, and untouched
138
+ comments/subscribers/activity).
139
+
140
+ Example:
141
+
142
+ * `fp issues show <id>`
143
+ """
144
+ state: AppState = ctx.obj
145
+ cctx = require_auth(state)
146
+ try:
147
+ incident = api.get_incident(cctx, incident_id)
148
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
149
+ _fail(state, exc, incident_id=incident_id)
150
+ if state.json:
151
+ output.emit_json(incident)
152
+ return
153
+ output.render_incident_show(incident)
154
+
155
+
156
+ def incidents_ack(
157
+ ctx: typer.Context,
158
+ incident_id: str = typer.Argument(..., help="Incident id."),
159
+ ) -> None:
160
+ """Acknowledge an incident (no confirm — it isn't destructive).
161
+
162
+ Needs `issues:read` (ack rides on read). With `--json`: `{"acknowledged": true, "id": "<id>"}`.
163
+
164
+ Example:
165
+
166
+ * `fp issues ack <id>`
167
+ """
168
+ state: AppState = ctx.obj
169
+ cctx = require_auth(state)
170
+ try:
171
+ api.ack_incident(cctx, incident_id)
172
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
173
+ _fail(state, exc, incident_id=incident_id)
174
+ _write.record_action("incident_acked", resource="incident", success=True)
175
+ if state.json:
176
+ output.emit_json({"acknowledged": True, "id": incident_id})
177
+ else:
178
+ output.incident_acked(incident_id)
179
+
180
+
181
+ def incidents_assign(
182
+ ctx: typer.Context,
183
+ incident_id: str = typer.Argument(..., help="Incident id."),
184
+ assignee: Optional[List[str]] = typer.Option(None, "--assignee", help="Operator email to assign (repeatable; omit to clear all)."),
185
+ ) -> None:
186
+ """Set an incident's assignees (replaces the list; omit `--assignee` to clear).
187
+
188
+ Needs `issues:create`. The server rejects the whole call if any email is not an operator —
189
+ that's surfaced as a clean `✗ <message>`. With `--json`: `{"assignees": [...], "id": "<id>"}`.
190
+
191
+ Example:
192
+
193
+ * `fp issues assign <id> --assignee a@example.com --assignee b@example.com`
194
+ """
195
+ state: AppState = ctx.obj
196
+ cctx = require_auth(state)
197
+ assignees = assignee or []
198
+ try:
199
+ api.assign_incident(cctx, incident_id, assignees)
200
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
201
+ _fail(state, exc, incident_id=incident_id)
202
+ _write.record_action("incident_assigned", resource="incident", success=True)
203
+ if state.json:
204
+ output.emit_json({"assignees": assignees, "id": incident_id})
205
+ else:
206
+ output.incident_assigned(incident_id, assignees)
207
+
208
+
209
+ def incidents_resolve(
210
+ ctx: typer.Context,
211
+ incident_id: str = typer.Argument(..., help="Incident id."),
212
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt. The prompt only appears on an interactive terminal: under --json, or with stdin redirected, this command proceeds without asking."),
213
+ ) -> None:
214
+ """Resolve (close) an incident, after a calm confirm.
215
+
216
+ Needs `issues:close`. With `--json`: `{"resolved": true, "id": "<id>"}` (or `{cancelled: true}`
217
+ on a declined prompt).
218
+
219
+ Example:
220
+
221
+ * `fp issues resolve <id> --yes`
222
+ """
223
+ state: AppState = ctx.obj
224
+ cctx = require_auth(state)
225
+ if _write.should_prompt(state, yes):
226
+ alert_name = None
227
+ try:
228
+ alert_name = api.get_incident(cctx, incident_id).alert_name
229
+ except NotFoundError as exc:
230
+ _fail(state, exc, incident_id=incident_id)
231
+ except (ApiError, ForbiddenError):
232
+ alert_name = None
233
+ if not output.confirm_incident_resolve(incident_id, alert_name):
234
+ if state.json:
235
+ output.emit_json({"cancelled": True})
236
+ else:
237
+ output.cancelled_plain("nothing changed")
238
+ return
239
+ try:
240
+ api.resolve_incident(cctx, incident_id)
241
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
242
+ _fail(state, exc, incident_id=incident_id)
243
+ _write.record_action("incident_resolved", resource="incident", success=True)
244
+ if state.json:
245
+ output.emit_json({"resolved": True, "id": incident_id})
246
+ else:
247
+ output.incident_resolved(incident_id)
248
+
249
+
250
+ def incidents_close(
251
+ ctx: typer.Context,
252
+ incident_id: str = typer.Argument(..., help="Issue id."),
253
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt. The prompt only appears on an interactive terminal: under --json, or with stdin redirected, this command proceeds without asking."),
254
+ ) -> None:
255
+ """Close an issue — "we're done with it", not "we fixed it".
256
+
257
+ The difference from `resolve` is what happens next. A **resolved** issue REOPENS if its
258
+ audit finding recurs: someone claimed a fix and the fix did not hold, which is worth
259
+ knowing. A **closed** issue does not — closing records a decision (won't fix, not a
260
+ problem, stale), and a recurrence is not news about a decision.
261
+
262
+ For an issue that came from an audit, this also marks the finding `dismissed`. It does
263
+ NOT write the finding's org-wide suppression: closing one issue never hides that pattern
264
+ in your other audits. Use `fp audits finding-status <id> --action dismiss` for that.
265
+
266
+ Needs `issues:close`. Exits 9 (conflict) if the issue already ended — an issue ends once,
267
+ and closing must not overwrite the record that someone believed they had fixed it.
268
+
269
+ With `--json`: `{"closed": true, "id": "<id>"}` (or `{cancelled: true}` on a declined
270
+ prompt).
271
+
272
+ Example:
273
+
274
+ * `fp issues close <id> --yes`
275
+ """
276
+ state: AppState = ctx.obj
277
+ cctx = require_auth(state)
278
+ if _write.should_prompt(state, yes):
279
+ alert_name = None
280
+ try:
281
+ alert_name = api.get_incident(cctx, incident_id).alert_name
282
+ except NotFoundError as exc:
283
+ _fail(state, exc, incident_id=incident_id)
284
+ except (ApiError, ForbiddenError):
285
+ alert_name = None
286
+ if not output.confirm_incident_close(incident_id, alert_name):
287
+ if state.json:
288
+ output.emit_json({"cancelled": True})
289
+ else:
290
+ output.cancelled_plain("nothing changed")
291
+ return
292
+ try:
293
+ api.close_incident(cctx, incident_id)
294
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
295
+ _fail(state, exc, incident_id=incident_id)
296
+ _write.record_action("incident_closed", resource="incident", success=True)
297
+ if state.json:
298
+ output.emit_json({"closed": True, "id": incident_id})
299
+ else:
300
+ output.incident_closed(incident_id)
301
+
302
+
303
+ def incidents_archive(
304
+ ctx: typer.Context,
305
+ incident_id: str = typer.Argument(..., help="Issue id."),
306
+ ) -> None:
307
+ """Take an issue off the issues board, keeping its history.
308
+
309
+ Archiving is separate from state: it does not resolve or close anything, and an issue
310
+ that has already ended keeps the record of HOW it ended. It works on a live issue too —
311
+ you should not have to triage something in order to stop looking at it — because the
312
+ safety net is on the other side: a fresh alert breach or a recurring audit finding puts
313
+ a live issue back on the board automatically. Archive can hide a problem; it cannot keep
314
+ hiding one that is still happening. A closed (won't-fix) issue is the exception and stays
315
+ hidden, since nobody asked for it back.
316
+
317
+ No confirmation: archiving destroys nothing and `fp issues unarchive` undoes it.
318
+
319
+ Needs `issues:close`. With `--json`: `{"archived": true, "id": "<id>"}`.
320
+
321
+ Example:
322
+
323
+ * `fp issues archive <id>`
324
+ """
325
+ _set_archived(ctx, incident_id, True)
326
+
327
+
328
+ def incidents_unarchive(
329
+ ctx: typer.Context,
330
+ incident_id: str = typer.Argument(..., help="Issue id."),
331
+ ) -> None:
332
+ """Put an archived issue back on the issues board. See `fp issues archive`.
333
+
334
+ Needs `issues:close`. With `--json`: `{"archived": false, "id": "<id>"}`.
335
+
336
+ Example:
337
+
338
+ * `fp issues unarchive <id>`
339
+ """
340
+ _set_archived(ctx, incident_id, False)
341
+
342
+
343
+ def _set_archived(ctx: typer.Context, incident_id: str, archived: bool) -> None:
344
+ """Shared body of archive/unarchive. Two commands rather than one with a flag, so each
345
+ reads as the verb it is and neither can be invoked meaning the opposite."""
346
+ state: AppState = ctx.obj
347
+ cctx = require_auth(state)
348
+ try:
349
+ api.set_incident_archived(cctx, incident_id, archived)
350
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
351
+ _fail(state, exc, incident_id=incident_id)
352
+ _write.record_action(
353
+ "incident_archived" if archived else "incident_unarchived",
354
+ resource="incident", success=True,
355
+ )
356
+ if state.json:
357
+ output.emit_json({"archived": archived, "id": incident_id})
358
+ else:
359
+ output.incident_archived(incident_id, archived)
360
+
361
+
362
+ def incidents_clear(
363
+ ctx: typer.Context,
364
+ audit_id: Optional[str] = typer.Option(None, "--audit", help="Clear only the issues this audit raised."),
365
+ all_audits: bool = typer.Option(False, "--all-audits", help="Clear every issue any audit raised. Alert and hand-opened issues are left alone."),
366
+ everything: bool = typer.Option(False, "--everything", help="Clear every open issue in the workspace, whatever opened it."),
367
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print what would be cleared and change nothing."),
368
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt. The prompt only appears on an interactive terminal: under --json, or with stdin redirected, this command proceeds without asking."),
369
+ ) -> None:
370
+ """Resolve every open issue in a scope — a fresh start after changing your agents.
371
+
372
+ Exactly one of `--audit <id>`, `--all-audits` or `--everything` is required: the three
373
+ have very different blast radii, so there is deliberately no default.
374
+
375
+ This is the same thing as resolving each issue by hand, including what it does NOT do.
376
+ **Nothing is deleted, and nothing is suppressed.** A pattern your agent changes genuinely
377
+ fixed stays gone; one they did not comes back on the next audit run and REOPENS the issue
378
+ it was raised under — a cleared board is not a quiet one. If you want a pattern silenced
379
+ for good, that is `fp audits finding-status <id> --action mute`, which is a different and
380
+ much bigger hammer.
381
+
382
+ Run it with `--dry-run` first: the count comes from the server, taken with the same scope
383
+ predicate the write uses, so it is the real size of the scope rather than a client-side
384
+ guess. It is a count, not a lease — the confirmed write re-runs that predicate, so an
385
+ issue that entered the scope since the preview is cleared along with the rest, which is
386
+ what clearing a SCOPE means. The line printed at the end is what actually changed.
387
+
388
+ Needs `issues:close` AND `audits:write` — clearing resolves the audit findings behind the
389
+ issues, so a key that cannot touch one finding cannot resolve all of them at once.
390
+
391
+ With `--json`: `{"issues": n, "findings": n, "dry_run": bool, "scope": "..."}` (or
392
+ `{cancelled: true}` on a declined prompt).
393
+
394
+ Examples:
395
+
396
+ * `fp issues clear --all-audits --dry-run`
397
+ * `fp issues clear --audit <id> --yes`
398
+ """
399
+ state: AppState = ctx.obj
400
+ picked = [bool(audit_id), all_audits, everything]
401
+ if sum(picked) != 1:
402
+ raise typer.BadParameter(
403
+ "choose exactly one of --audit <id>, --all-audits, or --everything.",
404
+ param_hint="--all-audits",
405
+ )
406
+ if audit_id:
407
+ scope, label = "audit", "this audit"
408
+ elif all_audits:
409
+ scope, label = "all_audits", "every audit"
410
+ else:
411
+ scope, label = "everything", "the whole workspace"
412
+
413
+ cctx = require_auth(state)
414
+
415
+ # The dry run is also the preview behind the prompt, so a `--dry-run` call and a
416
+ # confirmed clear count the same rows the same way.
417
+ try:
418
+ preview = api.clear_issues(cctx, scope=scope, audit_id=audit_id, dry_run=True)
419
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
420
+ _fail(state, exc)
421
+ return
422
+ issues = int(preview.get("issues") or 0)
423
+ findings = int(preview.get("findings") or 0)
424
+
425
+ if dry_run:
426
+ if state.json:
427
+ output.emit_json(preview)
428
+ else:
429
+ output.issues_cleared(issues, findings, dry_run=True)
430
+ return
431
+
432
+ if issues == 0:
433
+ # Nothing to do is not a failure, and prompting to confirm zero rows is noise.
434
+ if state.json:
435
+ output.emit_json({"issues": 0, "findings": 0, "dry_run": False, "scope": scope})
436
+ else:
437
+ output.issues_cleared(0, 0)
438
+ return
439
+
440
+ if _write.should_prompt(state, yes) and not output.confirm_issues_clear(label, issues, findings):
441
+ if state.json:
442
+ output.emit_json({"cancelled": True})
443
+ else:
444
+ output.cancelled_plain("nothing changed")
445
+ return
446
+
447
+ try:
448
+ result = api.clear_issues(cctx, scope=scope, audit_id=audit_id)
449
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
450
+ _fail(state, exc)
451
+ return
452
+ # `mode` and `count` are both on _SAFE_PROP_KEYS, and both are shape: a closed enum we
453
+ # authored and an integer. Never an audit id or an issue title.
454
+ _write.record_action(
455
+ "issues_cleared", resource="incident", success=True, destructive=True,
456
+ mode=scope, count=int(result.get("issues") or 0),
457
+ )
458
+ if state.json:
459
+ output.emit_json(result)
460
+ else:
461
+ output.issues_cleared(int(result.get("issues") or 0), int(result.get("findings") or 0))
462
+
463
+
464
+ def incidents_comment_list(
465
+ ctx: typer.Context,
466
+ incident_id: str = typer.Argument(..., help="Incident id."),
467
+ ) -> None:
468
+ """List an incident's comments in a boxed table.
469
+
470
+ Shows `author · when · body` (the body wraps; a deleted comment shows a dim `(deleted)`).
471
+ Needs `issues:read`. With `--json`: `{"comments": [{id, author_email, body, created_at, ...}]}`.
472
+ """
473
+ state: AppState = ctx.obj
474
+ cctx = require_auth(state)
475
+ try:
476
+ comments = api.list_incident_comments(cctx, incident_id)
477
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
478
+ _fail(state, exc, incident_id=incident_id)
479
+ if state.json:
480
+ output.emit_json({"comments": comments})
481
+ return
482
+ output.render_incident_comments(comments)
483
+
484
+
485
+ def incidents_comment_add(
486
+ ctx: typer.Context,
487
+ incident_id: str = typer.Argument(..., help="Incident id."),
488
+ body: Optional[str] = typer.Option(None, "--body", help="Comment text (or use --file/-)."),
489
+ file: Optional[str] = typer.Option(None, "--file", help="Read the comment body from a file, or `-` for stdin."),
490
+ ) -> None:
491
+ """Add a comment to an incident (rendered as a green "comment added" card).
492
+
493
+ Needs `issues:read` (commenting rides on read). Provide exactly one of `--body` or `--file`/stdin. With `--json`: the
494
+ created comment.
495
+
496
+ Example:
497
+
498
+ * `fp issues comment-add <id> --body "looking into it"`
499
+ """
500
+ state: AppState = ctx.obj
501
+ if (body is None) == (file is None):
502
+ raise typer.BadParameter("Provide exactly one of --body or --file.")
503
+ text = body if body is not None else _write.read_text_arg(file)
504
+ cctx = require_auth(state)
505
+ try:
506
+ comment = api.create_incident_comment(cctx, incident_id, text)
507
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
508
+ _fail(state, exc, incident_id=incident_id)
509
+ _write.record_action("incident_comment_added", resource="incident", success=True)
510
+ if state.json:
511
+ output.emit_json(comment)
512
+ else:
513
+ output.render_incident_comment_added(comment)
514
+
515
+
516
+ def incidents_comment_delete(
517
+ ctx: typer.Context,
518
+ incident_id: str = typer.Argument(..., help="Incident id."),
519
+ comment_id: str = typer.Argument(..., help="Comment id."),
520
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt. The prompt only appears on an interactive terminal: under --json, or with stdin redirected, this command proceeds without asking."),
521
+ ) -> None:
522
+ """Delete an incident comment, after an amber preview + confirm.
523
+
524
+ Resolves the comment first (so an unknown comment id → `✗ no comment …`, exit 6), previews it,
525
+ then confirms. Needs `issues:read` to delete your own comment, `issues:close` to moderate others'. With `--json`: `{"deleted": true, "id": "<comment_id>"}`
526
+ (or `{cancelled: true}` on a declined prompt).
527
+ """
528
+ state: AppState = ctx.obj
529
+ cctx = require_auth(state)
530
+ try:
531
+ comments = api.list_incident_comments(cctx, incident_id)
532
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
533
+ _fail(state, exc, incident_id=incident_id)
534
+ match = next((c for c in comments if c.id == comment_id), None)
535
+ if match is None:
536
+ raise NotFoundError(f"no comment {comment_id}")
537
+ if _write.should_prompt(state, yes):
538
+ output.render_incident_comment_delete_preview(match)
539
+ if not output.confirm_incident_comment_delete():
540
+ if state.json:
541
+ output.emit_json({"cancelled": True})
542
+ else:
543
+ output.cancelled_plain("nothing deleted")
544
+ return
545
+ try:
546
+ api.delete_incident_comment(cctx, incident_id, comment_id)
547
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
548
+ _fail(state, exc, incident_id=incident_id)
549
+ _write.record_action("incident_comment_deleted", resource="incident", success=True, destructive=True)
550
+ if state.json:
551
+ output.emit_json({"deleted": True, "id": comment_id})
552
+ else:
553
+ output.incident_comment_deleted()
554
+
555
+
556
+ def incidents_subscribers(
557
+ ctx: typer.Context,
558
+ incident_id: str = typer.Argument(..., help="Incident id."),
559
+ ) -> None:
560
+ """List who is subscribed to an incident in a boxed table.
561
+
562
+ Shows `email · source · subscribed`. Needs `issues:read`. With `--json`:
563
+ `{"subscribers": [{email, source, subscribed_at, ...}]}`.
564
+ """
565
+ state: AppState = ctx.obj
566
+ cctx = require_auth(state)
567
+ try:
568
+ subs = api.list_incident_subscribers(cctx, incident_id)
569
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
570
+ _fail(state, exc, incident_id=incident_id)
571
+ if state.json:
572
+ output.emit_json({"subscribers": subs})
573
+ return
574
+ output.render_incident_subscribers(subs)
575
+
576
+
577
+ def incidents_subscribe(
578
+ ctx: typer.Context,
579
+ incident_id: str = typer.Argument(..., help="Incident id."),
580
+ email: Optional[str] = typer.Option(None, "--email", help="Email to subscribe (default: you)."),
581
+ ) -> None:
582
+ """Subscribe to an incident's notifications.
583
+
584
+ With `--json`: `{"subscribed": true, "id": "<id>"}`.
585
+ """
586
+ state: AppState = ctx.obj
587
+ cctx = require_auth(state)
588
+ try:
589
+ api.subscribe_incident(cctx, incident_id, email)
590
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
591
+ _fail(state, exc, incident_id=incident_id)
592
+ _write.record_action("incident_subscribed", resource="incident", success=True)
593
+ if state.json:
594
+ output.emit_json({"subscribed": True, "id": incident_id})
595
+ else:
596
+ output.incident_subscribed(incident_id, email)
597
+
598
+
599
+ def incidents_unsubscribe(
600
+ ctx: typer.Context,
601
+ incident_id: str = typer.Argument(..., help="Incident id."),
602
+ email: Optional[str] = typer.Option(None, "--email", help="Email to unsubscribe (default: you)."),
603
+ ) -> None:
604
+ """Unsubscribe from an incident's notifications.
605
+
606
+ With `--json`: `{"unsubscribed": true, "id": "<id>"}`.
607
+ """
608
+ state: AppState = ctx.obj
609
+ cctx = require_auth(state)
610
+ try:
611
+ api.unsubscribe_incident(cctx, incident_id, email)
612
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
613
+ _fail(state, exc, incident_id=incident_id)
614
+ _write.record_action("incident_unsubscribed", resource="incident", success=True)
615
+ if state.json:
616
+ output.emit_json({"unsubscribed": True, "id": incident_id})
617
+ else:
618
+ output.incident_unsubscribed(incident_id, email)
619
+
620
+
621
+ def incidents_open(
622
+ ctx: typer.Context,
623
+ summary: str = typer.Option(..., "--summary", help="Short description of the incident."),
624
+ title: Optional[str] = typer.Option(None, "--title", help="Short title. Required unless --alert-id is given."),
625
+ alert_id: Optional[str] = typer.Option(None, "--alert-id", help="Link to an alert (inherits its severity)."),
626
+ severity: Optional[str] = typer.Option(None, "--severity", help=f"Severity for a standalone incident: {', '.join(_SEVERITIES)}."),
627
+ ) -> None:
628
+ """Open a manual incident (standalone, or linked to an alert) — rendered as a green card.
629
+
630
+ Needs `issues:create`. `--title` is required for a standalone incident (there's no parent
631
+ alert whose name it could borrow) and optional with `--alert-id`, where it defaults to the
632
+ alert's name. A missing `--title` or an invalid `--severity` → exit 2. With `--json`:
633
+ `{id, newly_opened, state}`.
634
+
635
+ Examples:
636
+
637
+ * `fp issues open --title "checkout 500s" --summary "manual page" --severity critical`
638
+ * `fp issues open --alert-id <id> --summary "paging on this again"`
639
+ """
640
+ state: AppState = ctx.obj
641
+ if severity and severity not in _SEVERITIES:
642
+ raise typer.BadParameter(f"severity must be one of: {', '.join(_SEVERITIES)}.")
643
+ if not alert_id and not (title or "").strip():
644
+ raise typer.BadParameter("--title is required for a standalone issue (or pass --alert-id).")
645
+ cctx = require_auth(state)
646
+ try:
647
+ result = api.open_incident(cctx, summary=summary, alert_id=alert_id, severity=severity, title=title)
648
+ except (ApiError, ForbiddenError, NotFoundError) as exc:
649
+ _fail(state, exc, incident_id=alert_id or "")
650
+ _write.record_action("incident_opened", resource="incident", success=True)
651
+ if state.json:
652
+ output.emit_json(result)
653
+ return
654
+ # The open response is minimal ({id, newly_opened, state}); re-fetch the canonical incident
655
+ # for a richer card (its real severity — esp. a linked incident inheriting the alert's).
656
+ inc = None
657
+ try:
658
+ inc = api.get_incident(cctx, str(result.get("id", "")))
659
+ except Exception:
660
+ inc = None
661
+ sev = (inc.alert_severity if inc else None) or severity or ""
662
+ st = (inc.state if inc else None) or str(result.get("state", "") or "")
663
+ # Prefer the server's stored title — on the linked path it may have been
664
+ # defaulted to the alert's name rather than anything we sent.
665
+ hero = (inc.title if inc else None) or str(result.get("title", "") or "") or (title or "")
666
+ output.render_incident_opened(summary=summary, severity=sev, state=st, title=hero)
667
+
668
+
669
+ def register(app: typer.Typer) -> None:
670
+ inc = typer.Typer(
671
+ no_args_is_help=True,
672
+ rich_markup_mode="markdown",
673
+ context_settings={"help_option_names": ["-h", "--help"]},
674
+ help="Triage issues (list / count / show / ack / assign / resolve / close / archive / unarchive / clear / comment-* / subscribe* / open).",
675
+ )
676
+ inc.command("list", epilog=GLOBALS_EPILOG)(incidents_list)
677
+ inc.command("count", epilog=GLOBALS_EPILOG)(incidents_count)
678
+ inc.command("show", epilog=GLOBALS_EPILOG)(incidents_show)
679
+ inc.command("ack", epilog=GLOBALS_EPILOG)(incidents_ack)
680
+ inc.command("assign", epilog=GLOBALS_EPILOG)(incidents_assign)
681
+ inc.command("resolve", epilog=GLOBALS_EPILOG)(incidents_resolve)
682
+ inc.command("close", epilog=GLOBALS_EPILOG)(incidents_close)
683
+ inc.command("archive", epilog=GLOBALS_EPILOG)(incidents_archive)
684
+ inc.command("unarchive", epilog=GLOBALS_EPILOG)(incidents_unarchive)
685
+ inc.command("clear", epilog=GLOBALS_EPILOG)(incidents_clear)
686
+ inc.command("comment-list", epilog=GLOBALS_EPILOG)(incidents_comment_list)
687
+ inc.command("comment-add", epilog=GLOBALS_EPILOG)(incidents_comment_add)
688
+ inc.command("comment-delete", epilog=GLOBALS_EPILOG)(incidents_comment_delete)
689
+ inc.command("subscribers", epilog=GLOBALS_EPILOG)(incidents_subscribers)
690
+ inc.command("subscribe", epilog=GLOBALS_EPILOG)(incidents_subscribe)
691
+ inc.command("unsubscribe", epilog=GLOBALS_EPILOG)(incidents_unsubscribe)
692
+ inc.command("open", epilog=GLOBALS_EPILOG)(incidents_open)
693
+ app.add_typer(inc, name="issues")