fp-cloud-cli 0.0.1b1__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 +64 -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 +1694 -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 +472 -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 +891 -0
  38. fp_cli/orgs.py +30 -0
  39. fp_cli/output.py +6593 -0
  40. fp_cli/permissions.py +208 -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.1b1.dist-info/METADATA +335 -0
  46. fp_cloud_cli-0.0.1b1.dist-info/RECORD +50 -0
  47. fp_cloud_cli-0.0.1b1.dist-info/WHEEL +5 -0
  48. fp_cloud_cli-0.0.1b1.dist-info/entry_points.txt +2 -0
  49. fp_cloud_cli-0.0.1b1.dist-info/licenses/LICENSE +42 -0
  50. fp_cloud_cli-0.0.1b1.dist-info/top_level.txt +1 -0
fp_cli/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """FailproofAI Cloud CLI — a command-line client for the FailproofAI Cloud API.
2
+
3
+ The query layer lives in :mod:`fp_cli.client` as pure functions that take a
4
+ ``ClientContext`` and return plain dataclasses. They never print and never import
5
+ Typer/Rich, so a future MCP server can wrap them with zero duplication.
6
+ """
7
+
8
+ from ._version import __version__
9
+
10
+ __all__ = ["__version__"]
fp_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .app import main_entry
2
+
3
+ if __name__ == "__main__":
4
+ main_entry()
@@ -0,0 +1,64 @@
1
+ """The Click that Typer is actually running. Import Click through here, never directly.
2
+
3
+ Typer 0.26 vendored Click into ``typer._click`` and dropped its dependency on the pip
4
+ ``click`` distribution. The vendored classes are *different class objects* from pip
5
+ Click's, so every place we hand Click an object, or ask Click about one, breaks when
6
+ the two disagree — and each break is silent, because the code still imports, still
7
+ compiles, and every happy path still passes:
8
+
9
+ * ``ClickException`` — Typer's command runner catches only *its* Click's exception
10
+ class. Errors subclassing pip Click's escape **uncaught**: a typed failure that
11
+ should print ``✗ …`` and exit 5 exits **1 with an empty stderr**, and the
12
+ ``rich_format_error`` hook in ``app.py`` (reached only *after* that catch) never
13
+ runs. Same for the ``UsageError``/``BadParameter`` we raise by hand.
14
+ * ``Abort`` — ``typer.prompt`` raises its own Click's ``Abort`` on closed stdin, so a
15
+ pip-Click ``except click.Abort`` stops matching and the clean "no TTY, pass a slug"
16
+ usage error becomes a bare abort.
17
+ * Options — Typer 0.26+ has no ``Option`` class in its vendored Click *at all*: every
18
+ option in a Typer-built tree is a ``typer.core.TyperOption``, subclassing
19
+ ``Parameter`` directly. ``isinstance(param, click.Option)`` is then quietly always
20
+ False, which emptied the telemetry flag catalog (``analytics_registry``) while its
21
+ own anti-drift test stayed green — the test asked the same broken question.
22
+
23
+ So: resolve the Click that Typer imported, and speak that one.
24
+ ``tests/test_click_compat.py`` asserts the package never imports ``click`` directly
25
+ again, and that our errors are still the class Typer catches.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ try:
31
+ # typer >= 0.26. pip `click` may well still be installed — it is simply not the
32
+ # Click in play, so binding to it here would reintroduce the whole class of bug.
33
+ from typer._click import ClickException, Command, Parameter
34
+ from typer._click.exceptions import Abort, BadParameter, UsageError
35
+ except ImportError: # typer < 0.26 drives the pip `click` distribution directly
36
+ from click import ( # type: ignore[assignment]
37
+ Abort,
38
+ BadParameter,
39
+ ClickException,
40
+ Command,
41
+ Parameter,
42
+ UsageError,
43
+ )
44
+
45
+ __all__ = [
46
+ "Abort",
47
+ "BadParameter",
48
+ "ClickException",
49
+ "Command",
50
+ "Parameter",
51
+ "UsageError",
52
+ "is_option",
53
+ ]
54
+
55
+
56
+ def is_option(param: Parameter) -> bool:
57
+ """True if ``param`` is an option (``--flag``), not a positional argument.
58
+
59
+ Class identity cannot answer this across both Clicks — the vendored one has no
60
+ ``Option`` class to test against — so read the ``param_type_name`` that Click sets
61
+ on every parameter (``"option"`` / ``"argument"``). That holds for pip Click's
62
+ ``Option`` and for ``TyperOption`` alike.
63
+ """
64
+ return getattr(param, "param_type_name", None) == "option"
fp_cli/_context.py ADDED
@@ -0,0 +1,332 @@
1
+ """Shared command-layer state and helpers.
2
+
3
+ Kept separate from ``app.py`` so command modules can import these without a
4
+ circular dependency (``app.py`` imports the command modules).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import math
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import List, Optional, Sequence, Tuple
14
+
15
+ from . import _click_compat as click # the Click Typer is running; see _click_compat
16
+ from . import config as cfgmod
17
+ from . import dates as _dates
18
+
19
+ # `AuthMode` is DEFINED in `client.py`, not here, even though it is this layer that
20
+ # resolves it: the import runs `_context` -> `client` (below), and `client` needs the
21
+ # enum at runtime for its bearer-vs-cookie branch — defining it here would make that
22
+ # a cycle. Re-exported so `from ._context import AuthMode` reads naturally beside
23
+ # AppState, which is where most callers want it.
24
+ from .client import AuthMode, ClientContext
25
+ from .errors import AuthError, KeyModeUnsupportedError
26
+
27
+
28
+ @dataclass
29
+ class AppState:
30
+ json: bool
31
+ base_url: Optional[str]
32
+ token: Optional[str]
33
+ timeout: float
34
+ config: cfgmod.CliConfig
35
+ insecure: bool = False
36
+ org: Optional[str] = None # active tenant slug (flag > env > config)
37
+ # The EXPLICITLY-supplied tenant (global `--org` or `FP_ORG`), before
38
+ # the saved-config fallback. `login` uses this so a *saved* tenant never
39
+ # silently bypasses the interactive org picker — only an explicit choice does.
40
+ # Key mode reuses it as the ONLY org it will ever send (see `build_context`).
41
+ org_explicit: Optional[str] = None
42
+ # The bearer credential in key mode. NEVER written to `cli.json` — see
43
+ # `resolve_auth`.
44
+ api_key: Optional[str] = None
45
+ # Which credential this invocation carries. Defaults to NONE so an AppState
46
+ # built by another path (tests, embedders) is never silently treated as key
47
+ # mode; the transport reads it directly, and everything that is not API_KEY
48
+ # takes the cookie path.
49
+ auth_mode: AuthMode = AuthMode.NONE
50
+
51
+
52
+ def resolve_auth(
53
+ *,
54
+ api_key: Optional[str],
55
+ api_key_on_cli: bool,
56
+ token: Optional[str],
57
+ token_on_cli: bool,
58
+ saved_token: Optional[str],
59
+ ) -> Tuple[AuthMode, Optional[str], Optional[str]]:
60
+ """Resolve the one credential this invocation uses → ``(mode, api_key, token)``.
61
+
62
+ Precedence, highest first::
63
+
64
+ --api-key AND --token -> usage error, exit 2 (never guess)
65
+ --api-key -> key mode
66
+ --token -> session mode
67
+ FP_API_KEY -> key mode (a key env var beats a token env var)
68
+ FP_TOKEN -> session mode
69
+ cli.json session_token -> session mode
70
+ nothing -> AuthMode.NONE (require_auth then exits 4)
71
+
72
+ ``*_on_cli`` distinguishes a flag from its env var, which the *values* cannot:
73
+ an explicit ``--token`` has to beat ``FP_API_KEY`` while
74
+ ``FP_API_KEY`` beats ``FP_TOKEN``. Click's
75
+ ``ctx.get_parameter_source`` is the only thing that knows the difference.
76
+
77
+ ``--api-key ""`` (an unset CI variable spelled out) means "no override": the mode
78
+ stays KEY with an empty credential, so `require_auth` raises instead of quietly
79
+ acting as whichever human is logged in on this machine. That mirrors the
80
+ established ``--token ""`` rule exactly.
81
+
82
+ The key is returned for this process only and is never persisted: a session token
83
+ expires in ~24h, which bounds the blast radius of a leaked `cli.json`; an API key
84
+ is valid until someone revokes it. There is also no honest revocation path from
85
+ here — `keys disable` needs `keys:disable`, which a scoped CI key will not hold —
86
+ so a "clear the saved key" command could not actually revoke anything.
87
+ """
88
+ if api_key_on_cli and token_on_cli:
89
+ raise click.UsageError(
90
+ "--api-key and --token are mutually exclusive: --api-key authenticates as an "
91
+ "API key against /v1, --token as a signed-in user session. Pass exactly one."
92
+ )
93
+ if api_key_on_cli:
94
+ return AuthMode.API_KEY, api_key, None
95
+ if token_on_cli:
96
+ return AuthMode.SESSION, None, token
97
+ # Neither flag was given: whatever Click resolved came from the environment.
98
+ # (Click treats an empty env var as unset, so `FP_API_KEY=""` falls
99
+ # through to the next rung rather than becoming an empty-credential key mode.)
100
+ if api_key is not None:
101
+ return AuthMode.API_KEY, api_key, None
102
+ if token is not None:
103
+ return AuthMode.SESSION, None, token
104
+ if saved_token:
105
+ return AuthMode.SESSION, None, saved_token
106
+ return AuthMode.NONE, None, None
107
+
108
+
109
+ def deny_in_key_mode(state: "AppState", command: str, reason: str) -> None:
110
+ """Fail ``command`` with exit 2 when this invocation carries an API key.
111
+
112
+ Called as the FIRST statement of every command an API key cannot perform, so the
113
+ CLI never opens a connection it already knows will fail — a 401/403 from the
114
+ server would be a much worse explanation than the real one.
115
+ """
116
+ if state.auth_mode is AuthMode.API_KEY:
117
+ raise KeyModeUnsupportedError(
118
+ f"`fp {command}` does not work with an API key — {reason}.",
119
+ hint="drop --api-key / FP_API_KEY and sign in with fp login",
120
+ )
121
+
122
+
123
+ def resolved_base_url(state: AppState) -> str:
124
+ """The effective dashboard URL.
125
+
126
+ The app callback already resolves flag/env > saved config > the public
127
+ default (`config.DEFAULT_BASE_URL`), so `state.base_url` is normally set.
128
+ This falls back to the same default for any AppState built by another path
129
+ (tests, embedders), so the CLI always has a URL to talk to.
130
+ """
131
+ return state.base_url or cfgmod.DEFAULT_BASE_URL
132
+
133
+
134
+ def _org_header(state: AppState) -> Optional[str]:
135
+ """The tenant slug to send as ``X-AgentEye-Org``.
136
+
137
+ In KEY mode only an EXPLICIT ``--org`` / ``FP_ORG`` is ever sent. The saved
138
+ `cli.json` org belongs to whichever human logged in on this machine and has no
139
+ bearing on which org a CI key was minted for; sending it would silently ask for
140
+ another tenant's data and get a 403 that reads like a missing permission.
141
+
142
+ (The reverse trap is real too and `whoami` is the pre-flight for it: an
143
+ instance-scoped key with no `--org` resolves server-side to the DEFAULT org and
144
+ answers with that org's data, no error anywhere.)
145
+ """
146
+ if state.auth_mode is AuthMode.API_KEY:
147
+ return state.org_explicit
148
+ return state.org
149
+
150
+
151
+ def build_context(state: AppState) -> ClientContext:
152
+ return ClientContext(
153
+ base_url=resolved_base_url(state),
154
+ token=state.token,
155
+ timeout=state.timeout,
156
+ verify=not state.insecure,
157
+ org=_org_header(state),
158
+ api_key=state.api_key,
159
+ auth_mode=state.auth_mode,
160
+ )
161
+
162
+
163
+ def require_auth(state: AppState) -> ClientContext:
164
+ """Return a client context, or raise if no URL is set / not authenticated."""
165
+ base = resolved_base_url(state) # the URL is the prerequisite — check it first
166
+ if state.auth_mode is AuthMode.API_KEY:
167
+ # `--api-key ""` lands here: key mode, no credential. It must NOT fall back
168
+ # to a saved cookie session (see `resolve_auth`), so this is an auth failure.
169
+ if not state.api_key:
170
+ raise AuthError(
171
+ "No API key supplied. Pass --api-key <key> or set FP_API_KEY."
172
+ )
173
+ # No local expiry check: an API key carries no expiry the CLI can see, and the
174
+ # server is the only authority on whether it is still live.
175
+ return ClientContext(
176
+ base_url=base,
177
+ api_key=state.api_key,
178
+ auth_mode=AuthMode.API_KEY,
179
+ timeout=state.timeout,
180
+ verify=not state.insecure,
181
+ org=_org_header(state),
182
+ )
183
+ if not state.token:
184
+ # A pre-move file with no usable session in it (expired, or only ever
185
+ # held a base_url) reaches here after adoption declined to carry
186
+ # anything. Name it, so the upgrade is not blamed for the logout.
187
+ if cfgmod.legacy_install_detected():
188
+ raise AuthError(
189
+ "Not logged in. Run fp login.\n"
190
+ f"The config moved to {cfgmod.config_path()}; "
191
+ f"{cfgmod.legacy_config_path()} held no usable session to carry "
192
+ "over. It is left in place — remove it when convenient."
193
+ )
194
+ raise AuthError("Not logged in. Run fp login.")
195
+ # Only enforce local expiry when the token came from the stored config; an
196
+ # explicit --token / env override has no known expiry, so trust it.
197
+ if state.token == state.config.session_token and cfgmod.is_expired(state.config):
198
+ raise AuthError("Session expired. Run fp login.")
199
+ return ClientContext(
200
+ base_url=base,
201
+ token=state.token,
202
+ timeout=state.timeout,
203
+ verify=not state.insecure,
204
+ org=_org_header(state),
205
+ )
206
+
207
+
208
+ def resolve_dates(
209
+ since: Optional[str], ts_from: Optional[str], ts_to: Optional[str]
210
+ ) -> Tuple[Optional[str], Optional[str]]:
211
+ try:
212
+ return _dates.resolve_range(since, ts_from, ts_to)
213
+ except ValueError as exc:
214
+ raise click.BadParameter(str(exc))
215
+
216
+
217
+ def resolve_fields(raw: Optional[str], model_cls: type) -> Optional[List[str]]:
218
+ """Parse a ``--fields`` CSV into a validated list of model field names (or None).
219
+
220
+ Field names must match the dataclass (and thus the ``--json``) keys of the
221
+ model; unknown names raise a usage error listing the valid set.
222
+ """
223
+ if not raw:
224
+ return None
225
+ valid = [f.name for f in dataclasses.fields(model_cls)]
226
+ chosen = [f.strip() for f in raw.split(",") if f.strip()]
227
+ unknown = [f for f in chosen if f not in valid]
228
+ if unknown:
229
+ raise click.BadParameter(
230
+ f"unknown field(s): {', '.join(unknown)}. Valid fields: {', '.join(valid)}"
231
+ )
232
+ return chosen
233
+
234
+
235
+ def collect_multi(values: Optional[Sequence[str]]) -> Optional[List[str]]:
236
+ """Normalize a repeatable + comma-separated CLI option into one flat, de-duplicated list.
237
+
238
+ The single reusable helper behind every multi-value filter. Typer hands us a list with
239
+ one entry per repeated flag (``--env prod --env staging`` → ``["prod", "staging"]``), and
240
+ each entry may itself be a comma-separated group (``--env prod,staging`` → ``["prod,staging"]``).
241
+ This splits every entry on commas, trims surrounding whitespace (so ``--env "prod, staging"``
242
+ works), drops empties (so a trailing comma ``--env prod,`` adds nothing), and de-duplicates
243
+ while preserving first-seen order. Returns ``None`` when nothing usable remains, so an unset
244
+ or blank option stays ``None`` and the client drops the param (unchanged single-value path).
245
+
246
+ A single value still yields the obvious one-item list (``--env prod`` → ``["prod"]``), which
247
+ the client serializes back to a bare ``environment=prod`` — fully backward compatible.
248
+ """
249
+ if not values:
250
+ return None
251
+ out: List[str] = []
252
+ seen: set = set()
253
+ for raw in values:
254
+ for part in str(raw).split(","):
255
+ v = part.strip()
256
+ if v and v not in seen:
257
+ seen.add(v)
258
+ out.append(v)
259
+ return out or None
260
+
261
+
262
+ def validate_choice(
263
+ value: Optional[str], allowed: Sequence[str], *, flag: str
264
+ ) -> Optional[str]:
265
+ """Return ``value`` if it's None or one of ``allowed``; else a usage error (exit 2).
266
+
267
+ Keeps enum-style flags (``--status``) failing fast client-side with a clear
268
+ message, consistent with ``--order``/``--source``/``--kind`` — rather than the
269
+ server's opaque HTTP 400 (exit 1).
270
+ """
271
+ if value is None or value in allowed:
272
+ return value
273
+ raise click.BadParameter(
274
+ f"'{value}' is not valid for {flag}. Choose one of: {', '.join(allowed)}.",
275
+ param_hint=flag,
276
+ )
277
+
278
+
279
+ def validate_limit(limit: Optional[int], *, flag: str = "--limit") -> None:
280
+ """Reject a non-positive row limit up front with a clean usage error (exit 2), rather
281
+ than passing ``0``/negative through to the server, which silently defaults/clamps it to
282
+ a confusing result. Mirrors ``validate_choice`` / ``validate_score_filters``."""
283
+ if limit is not None and limit <= 0:
284
+ raise click.BadParameter("must be a positive integer (at least 1).", param_hint=flag)
285
+
286
+
287
+ def validate_score_filters(values: Optional[Sequence[str]]) -> None:
288
+ """Validate ``--score`` values are ``KEY:MIN..MAX`` (either bound optional).
289
+
290
+ Without this a malformed value (no ``:`` or no ``..``) is silently sent and
291
+ dropped server-side, returning the UNFILTERED set — a silent footgun. Raises a
292
+ usage error (exit 2) on a bad value. Accepts e.g. ``helpfulness:0.5..0.8``,
293
+ ``x:..0.5``, ``y:0.9..``.
294
+ """
295
+ for v in values or []:
296
+ key, sep, rng = v.partition(":")
297
+ ok = bool(sep) and bool(key.strip()) and ".." in rng
298
+ if ok:
299
+ lo, _, hi = rng.partition("..")
300
+ # Both bounds empty (`helpfulness:..`) is meaningless: the server silently
301
+ # drops it and returns the UNFILTERED set, so reject it client-side too.
302
+ if lo == "" and hi == "":
303
+ ok = False
304
+ for bound in (lo, hi):
305
+ if bound != "":
306
+ try:
307
+ # `float("nan")`/`float("inf")` succeed but the server silently
308
+ # drops the filter — reject non-finite bounds too.
309
+ if not math.isfinite(float(bound)):
310
+ ok = False
311
+ break
312
+ except ValueError:
313
+ ok = False
314
+ break
315
+ if not ok:
316
+ raise click.BadParameter(
317
+ f"'{v}' is not a valid score filter. Use KEY:MIN..MAX (either bound "
318
+ "optional), e.g. helpfulness:0.5..0.8, tool_efficiency:..0.3, factuality:0.9..",
319
+ param_hint="--score",
320
+ )
321
+
322
+
323
+ # Appended to every subcommand's --help so an agent that jumps straight to
324
+ # `fp <command> -h` still learns the argument order + where the global options go.
325
+ GLOBALS_EPILOG = (
326
+ "Argument order: `fp [GLOBAL OPTIONS] <command> [<subcommand>] [ARGS] [OPTIONS]`. "
327
+ "**Global** options come *before* the command — `--json`, `--base-url`, `--token`, "
328
+ "`--api-key`, `--insecure`/`--secure`, `--timeout`, `--quiet`, `--no-color`. A command's (or "
329
+ "subcommand's) own options come *after* it. "
330
+ "e.g. `fp --json keys create ci-bot --permission-set read-only` — `--json` is global, "
331
+ "`keys` the command, `create` the subcommand, `--permission-set` its option."
332
+ )
fp_cli/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1b1"