pipefy-cli 0.3.0a1__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 (40) hide show
  1. pipefy_cli/__init__.py +7 -0
  2. pipefy_cli/_docs.py +3 -0
  3. pipefy_cli/auth.py +225 -0
  4. pipefy_cli/commands/__init__.py +5 -0
  5. pipefy_cli/commands/_common.py +359 -0
  6. pipefy_cli/commands/agent.py +358 -0
  7. pipefy_cli/commands/ai_automation.py +318 -0
  8. pipefy_cli/commands/attachment.py +122 -0
  9. pipefy_cli/commands/audit.py +63 -0
  10. pipefy_cli/commands/auth.py +519 -0
  11. pipefy_cli/commands/automation.py +529 -0
  12. pipefy_cli/commands/card.py +439 -0
  13. pipefy_cli/commands/email.py +169 -0
  14. pipefy_cli/commands/export.py +61 -0
  15. pipefy_cli/commands/field.py +113 -0
  16. pipefy_cli/commands/field_condition.py +201 -0
  17. pipefy_cli/commands/graphql.py +110 -0
  18. pipefy_cli/commands/introspect.py +110 -0
  19. pipefy_cli/commands/label.py +109 -0
  20. pipefy_cli/commands/member.py +126 -0
  21. pipefy_cli/commands/org.py +60 -0
  22. pipefy_cli/commands/phase.py +264 -0
  23. pipefy_cli/commands/pipe.py +167 -0
  24. pipefy_cli/commands/portal.py +871 -0
  25. pipefy_cli/commands/record.py +219 -0
  26. pipefy_cli/commands/relation.py +173 -0
  27. pipefy_cli/commands/report_org.py +241 -0
  28. pipefy_cli/commands/report_pipe.py +290 -0
  29. pipefy_cli/commands/table.py +151 -0
  30. pipefy_cli/commands/usage.py +100 -0
  31. pipefy_cli/commands/webhook.py +141 -0
  32. pipefy_cli/main.py +132 -0
  33. pipefy_cli/output/__init__.py +8 -0
  34. pipefy_cli/output/json_renderer.py +24 -0
  35. pipefy_cli/output/rich_renderer.py +81 -0
  36. pipefy_cli/settings.py +66 -0
  37. pipefy_cli-0.3.0a1.dist-info/METADATA +104 -0
  38. pipefy_cli-0.3.0a1.dist-info/RECORD +40 -0
  39. pipefy_cli-0.3.0a1.dist-info/WHEEL +4 -0
  40. pipefy_cli-0.3.0a1.dist-info/entry_points.txt +2 -0
pipefy_cli/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Typer CLI entry surface for Pipefy (pipefy)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.3.0-alpha.1"
6
+
7
+ __all__ = ["__version__"]
pipefy_cli/_docs.py ADDED
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ DOCS_CLI_AUTH_REF = "docs/cli/auth.md"
pipefy_cli/auth.py ADDED
@@ -0,0 +1,225 @@
1
+ """Build an authenticated :class:`PipefyClient` from CLI configuration.
2
+
3
+ The precedence chain lives in :func:`pipefy_auth.resolve_pipefy_auth`; this
4
+ module collapses the CLI's two static-token surfaces (``--token`` flag and
5
+ ``PIPEFY_TOKEN`` env var) into a single value before calling the resolver, and
6
+ translates resolver failures into Typer exits. The flag-vs-env distinction
7
+ survives only as a diagnostic label for ``pipefy auth status``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ from dataclasses import asdict, dataclass, field
15
+ from typing import Any, Final, Literal, assert_never
16
+
17
+ import typer
18
+ from pipefy_auth import (
19
+ OidcClient,
20
+ RefreshError,
21
+ ResolvedAuth,
22
+ ServiceAccount,
23
+ ServiceAccountAuth,
24
+ StaticTokenAuth,
25
+ StoredSessionAuth,
26
+ build_httpx_auth,
27
+ detect_pipefy_auth_methods,
28
+ ensure_fresh_session,
29
+ missing_auth_message,
30
+ resolve_pipefy_auth,
31
+ )
32
+ from pipefy_sdk import (
33
+ PipefyClient,
34
+ PipefySettings,
35
+ )
36
+
37
+ from pipefy_cli._docs import DOCS_CLI_AUTH_REF
38
+
39
+ # Display labels for ``pipefy auth status``. The resolver knows the
40
+ # static-token method; the CLI restores the flag-vs-env distinction here.
41
+ FLAG_TOKEN_SOURCE: Final = "flag-token"
42
+ ENV_TOKEN_SOURCE: Final = "env-token"
43
+
44
+ # Locked JSON wire schema for ``pipefy auth status``: the auth-method names,
45
+ # with the static-token method split into the CLI's flag-vs-env surfaces, plus
46
+ # an explicit ``"none"`` sentinel for when no method resolved. Produced by
47
+ # :func:`detect_cli_auth_methods` / :func:`to_display_source`; ``commands.auth``
48
+ # renders it.
49
+ DisplaySource = Literal[
50
+ "flag-token",
51
+ "env-token",
52
+ "service-account",
53
+ "stored-session",
54
+ "none",
55
+ ]
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class BearerToken:
60
+ """Static bearer token plus the surface that produced it (``--token`` or env)."""
61
+
62
+ value: str = field(repr=False)
63
+ source: Literal["flag", "env"]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class AuthContext:
68
+ """Auth inputs for a single CLI invocation.
69
+
70
+ Each field maps to one auth method (bearer-token, service-account,
71
+ stored-session). Built once at startup from the loaded
72
+ :class:`pipefy_auth.AuthSettings` plus the per-invocation ``--token`` /
73
+ ``PIPEFY_TOKEN`` resolution.
74
+
75
+ ``oidc_client`` is ``None`` only when ``AuthSettings.disable_stored_session``
76
+ is set (env: PIPEFY_DISABLE_STORED_SESSION); the stored-session method is
77
+ then skipped end-to-end. Otherwise ``auth_url`` defaults to the prod IdP
78
+ and the client is always present.
79
+ """
80
+
81
+ bearer_token: BearerToken | None
82
+ service_account: ServiceAccount | None
83
+ oidc_client: OidcClient | None
84
+
85
+
86
+ _cached_signature: str | None = None
87
+ # One-shot CLIs reuse this; long-lived programmatic use should call
88
+ # ``clear_authenticated_client_cache`` between logical sessions (tests reset via fixture).
89
+ _cached_client: PipefyClient | None = None
90
+
91
+
92
+ def clear_authenticated_client_cache() -> None:
93
+ """Drop the in-process client cache (tests and rare reload scenarios)."""
94
+ global _cached_signature, _cached_client
95
+ _cached_signature = None
96
+ _cached_client = None
97
+
98
+
99
+ def _resolver_kwargs(auth: AuthContext) -> dict[str, Any]:
100
+ """Map an :class:`AuthContext` onto the keyword inputs the resolver takes."""
101
+ return {
102
+ "static_token": auth.bearer_token.value if auth.bearer_token else None,
103
+ "service_account": auth.service_account,
104
+ "oidc_client": auth.oidc_client,
105
+ }
106
+
107
+
108
+ def _resolve(auth: AuthContext) -> ResolvedAuth | None:
109
+ return resolve_pipefy_auth(**_resolver_kwargs(auth))
110
+
111
+
112
+ def to_display_source(
113
+ resolved: ResolvedAuth, bearer: BearerToken | None
114
+ ) -> DisplaySource:
115
+ """Map a resolved auth method to its locked ``auth status`` wire value.
116
+
117
+ The static-token method splits into the flag-vs-env distinction the CLI
118
+ surfaces; the other methods map to their resolver wire name unchanged.
119
+ """
120
+ match resolved:
121
+ case StaticTokenAuth():
122
+ return (
123
+ FLAG_TOKEN_SOURCE
124
+ if bearer and bearer.source == "flag"
125
+ else ENV_TOKEN_SOURCE
126
+ )
127
+ case ServiceAccountAuth():
128
+ return "service-account"
129
+ case StoredSessionAuth():
130
+ return "stored-session"
131
+ case _:
132
+ assert_never(resolved)
133
+
134
+
135
+ def detect_cli_auth_methods(auth: AuthContext) -> list[ResolvedAuth]:
136
+ """Return the detected auth methods for a CLI invocation, precedence-first.
137
+
138
+ The non-short-circuiting view of the chain: every configured method, not
139
+ just the winner. ``pipefy auth status`` renders each via
140
+ :func:`to_display_source` and treats the first as the active source.
141
+ """
142
+ return detect_pipefy_auth_methods(**_resolver_kwargs(auth))
143
+
144
+
145
+ def _cache_key(
146
+ pipefy_settings: PipefySettings,
147
+ auth: AuthContext,
148
+ ) -> str:
149
+ """SHA-256 digest of every input that could change the cached client.
150
+
151
+ Hashed (not stored as plaintext) so the dump's secrets — the bearer
152
+ token, the service-account ``client_secret`` — don't linger in module
153
+ state for the process lifetime. Adding a new field to
154
+ :class:`PipefySettings` or :class:`AuthContext` automatically participates
155
+ in the key without touching this function. The resolved method is omitted:
156
+ it is a pure function of the auth fields above, so it adds no distinction.
157
+ """
158
+ payload = json.dumps(
159
+ {
160
+ "settings": pipefy_settings.model_dump(mode="json"),
161
+ "auth": asdict(auth),
162
+ },
163
+ sort_keys=True,
164
+ default=str,
165
+ )
166
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
167
+
168
+
169
+ def get_authenticated_client(
170
+ pipefy_settings: PipefySettings,
171
+ auth: AuthContext,
172
+ ) -> PipefyClient:
173
+ """Return a facade client using the highest-precedence available auth source.
174
+
175
+ Raises:
176
+ typer.Exit: Code 2 when no auth source resolves, or when refreshing a
177
+ stored session fails.
178
+ """
179
+ global _cached_signature, _cached_client
180
+
181
+ resolved = _resolve(auth)
182
+ if resolved is None:
183
+ typer.echo(f"{missing_auth_message()} See {DOCS_CLI_AUTH_REF}.", err=True)
184
+ raise typer.Exit(2)
185
+
186
+ # Stored-session: warm up eagerly so refresh failures surface as a clean
187
+ # exit(2) with a "run `pipefy auth login` again" hint instead of leaking
188
+ # out as a transport error on the first GraphQL call.
189
+ if isinstance(resolved, StoredSessionAuth):
190
+ try:
191
+ ensure_fresh_session(
192
+ issuer=resolved.oidc_client.issuer_url,
193
+ client_id=resolved.oidc_client.client_id,
194
+ )
195
+ except RefreshError as exc:
196
+ typer.echo(
197
+ f"Stored Pipefy session could not be refreshed: {exc}. "
198
+ "Run `pipefy auth login` to sign in again.",
199
+ err=True,
200
+ )
201
+ raise typer.Exit(2) from exc
202
+
203
+ key = _cache_key(pipefy_settings, auth)
204
+ if _cached_client is not None and _cached_signature == key:
205
+ return _cached_client
206
+
207
+ client = PipefyClient(
208
+ pipefy_settings, auth=build_httpx_auth(resolved), surface="cli"
209
+ )
210
+ _cached_signature = key
211
+ _cached_client = client
212
+ return client
213
+
214
+
215
+ __all__ = [
216
+ "AuthContext",
217
+ "BearerToken",
218
+ "DisplaySource",
219
+ "ENV_TOKEN_SOURCE",
220
+ "FLAG_TOKEN_SOURCE",
221
+ "clear_authenticated_client_cache",
222
+ "detect_cli_auth_methods",
223
+ "get_authenticated_client",
224
+ "to_display_source",
225
+ ]
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from pipefy_cli.commands.card import card_app
4
+
5
+ __all__ = ["card_app"]
@@ -0,0 +1,359 @@
1
+ """Shared Typer helpers for domain command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import math
8
+ import sys
9
+ from collections.abc import Awaitable, Callable
10
+ from typing import Any, TypeVar
11
+
12
+ import typer
13
+ from gql.transport.exceptions import TransportError, TransportQueryError
14
+ from pipefy_sdk import PipefyClient, PipefySettings, stream_bytes
15
+ from pipefy_sdk.exceptions import PipefyError
16
+ from pipefy_sdk.label_color import normalize_label_color
17
+ from pipefy_sdk.report_filter_preflight import prepare_report_cards_filter
18
+
19
+ from pipefy_cli.auth import (
20
+ AuthContext,
21
+ BearerToken,
22
+ get_authenticated_client,
23
+ )
24
+ from pipefy_cli.output import render_json, render_rich
25
+
26
+ _T = TypeVar("_T")
27
+ _R = TypeVar("_R")
28
+
29
+ DEFAULT_EXPORT_MAX_BYTES = 50 * 1024 * 1024
30
+
31
+ # Pipefy occasionally encodes ids with a leading hyphen (e.g. table id "-ZocGcM0").
32
+ # Click parses tokens starting with "-" as short options by default, which breaks
33
+ # every `<sub> get/update/delete <ID>` command. Setting ignore_unknown_options on
34
+ # the command relaxes the parser so the dashed token is consumed as the positional
35
+ # id. Tokens starting with ``--`` must still be rejected (they look like long
36
+ # options); use :func:`validate_positional_id` on those arguments.
37
+ ID_POSITIONAL_CONTEXT_SETTINGS = {"ignore_unknown_options": True}
38
+
39
+
40
+ def validate_positional_id(value: str) -> str:
41
+ """Reject typoed long-option tokens mistakenly captured as a resource id."""
42
+ if value.startswith("--"):
43
+ raise typer.BadParameter(
44
+ f"unknown option-like value {value!r}; if an id starts with '-', pass it after '--'"
45
+ )
46
+ return value
47
+
48
+
49
+ def resource_id_argument(*, help: str) -> Any:
50
+ """Typer ``Argument`` for resource ids when ``ignore_unknown_options`` is enabled."""
51
+ return typer.Argument(..., help=help, callback=validate_positional_id)
52
+
53
+
54
+ def validate_optional_resource_id(value: str | None, label: str) -> str | None:
55
+ if value is None:
56
+ return None
57
+ cleaned = value.strip()
58
+ if not cleaned:
59
+ raise typer.BadParameter(
60
+ f"Invalid '{label}': provide a non-empty string or positive integer."
61
+ )
62
+ if cleaned.startswith("-") and cleaned[1:].isdigit():
63
+ raise typer.BadParameter(f"Invalid '{label}': provide a positive integer.")
64
+ if cleaned.isdigit() and int(cleaned) <= 0:
65
+ raise typer.BadParameter(f"Invalid '{label}': provide a positive integer.")
66
+ return cleaned
67
+
68
+
69
+ _CARDS_PAGE_SIZE_MIN = 1
70
+ _CARDS_PAGE_SIZE_MAX = 500
71
+
72
+
73
+ def validate_cards_page_size(first: int | None) -> int | None:
74
+ if first is None:
75
+ return None
76
+ if first < _CARDS_PAGE_SIZE_MIN or first > _CARDS_PAGE_SIZE_MAX:
77
+ raise typer.BadParameter(
78
+ f"--first must be between {_CARDS_PAGE_SIZE_MIN} and "
79
+ f"{_CARDS_PAGE_SIZE_MAX} (inclusive)."
80
+ )
81
+ return first
82
+
83
+
84
+ def validate_label_name_cli(name: str) -> str:
85
+ nm = name.strip()
86
+ if not nm:
87
+ raise typer.BadParameter("--name must be non-empty.")
88
+ return nm
89
+
90
+
91
+ def prepare_report_cards_filter_cli(
92
+ filter_obj: dict[str, Any] | None,
93
+ ) -> dict[str, Any] | None:
94
+ """Normalize and validate ``ReportCardsFilter`` before auth or network I/O."""
95
+ try:
96
+ return prepare_report_cards_filter(filter_obj)
97
+ except ValueError as exc:
98
+ raise typer.BadParameter(str(exc)) from exc
99
+
100
+
101
+ def normalize_label_color_cli(color: str) -> str:
102
+ """Normalize label ``color`` before auth or network I/O."""
103
+ try:
104
+ return normalize_label_color(color)
105
+ except ValueError as exc:
106
+ raise typer.BadParameter(str(exc)) from exc
107
+
108
+
109
+ def export_poll_max_rounds(
110
+ poll_timeout_seconds: float, *, delay_seconds: float = 2.0
111
+ ) -> int:
112
+ """Map a wall-clock export poll budget to loop iterations (``delay_seconds`` per round).
113
+
114
+ Args:
115
+ poll_timeout_seconds: Maximum time to wait for export state ``done``.
116
+ delay_seconds: Sleep between status polls.
117
+ """
118
+ if poll_timeout_seconds <= 0:
119
+ raise ValueError("poll_timeout_seconds must be positive")
120
+ return max(1, math.ceil(poll_timeout_seconds / delay_seconds))
121
+
122
+
123
+ async def poll_export_until_done(
124
+ fetch: Callable[[str], Awaitable[dict[str, Any] | None]],
125
+ export_id: str,
126
+ unwrap: Callable[[dict[str, Any]], dict[str, Any]],
127
+ *,
128
+ max_rounds: int,
129
+ delay_seconds: float = 2.0,
130
+ ) -> str:
131
+ """Poll an export job until ``state == done`` and return ``fileURL``.
132
+
133
+ Args:
134
+ fetch: Async callable taking export id and returning the raw GraphQL payload dict.
135
+ export_id: Export job id from the start mutation.
136
+ unwrap: Extract the export status node dict from ``fetch``'s return value.
137
+ max_rounds: Maximum poll iterations before timing out.
138
+ delay_seconds: Delay between polls.
139
+ """
140
+ eid = str(export_id)
141
+ for _ in range(max_rounds):
142
+ raw = await fetch(eid)
143
+ node = unwrap(raw or {}) or {}
144
+ state = str(node.get("state") or "")
145
+ if state in ("failed", "error"):
146
+ raise ValueError(f"Export failed (state={state!r}).")
147
+ if state == "done":
148
+ url = node.get("fileURL") or node.get("fileUrl")
149
+ if isinstance(url, str) and url.strip():
150
+ return url.strip()
151
+ raise ValueError("Export is done but fileURL is missing.")
152
+ await asyncio.sleep(delay_seconds)
153
+ raise ValueError(
154
+ f"Timed out waiting for export {eid} after {max_rounds * delay_seconds:.0f}s."
155
+ )
156
+
157
+
158
+ async def write_export_csv_to_stdout(
159
+ *,
160
+ export_id: str,
161
+ poll_fetch: Callable[[str], Awaitable[dict[str, Any] | None]],
162
+ unwrap_status: Callable[[dict[str, Any]], dict[str, Any]],
163
+ poll_timeout_seconds: float,
164
+ max_bytes: int = DEFAULT_EXPORT_MAX_BYTES,
165
+ ) -> None:
166
+ """Poll an export job until ``done`` and stream the CSV body to stdout.
167
+
168
+ Args:
169
+ export_id: Export job id (from the start mutation).
170
+ poll_fetch: Async callable that fetches the export status payload by id.
171
+ unwrap_status: Extract the export status node dict from the raw payload.
172
+ poll_timeout_seconds: Wall-clock budget for polling before raising ``ValueError``.
173
+ max_bytes: Hard cap on the download size enforced by :func:`stream_bytes`.
174
+ """
175
+ max_rounds = export_poll_max_rounds(poll_timeout_seconds)
176
+ url = await poll_export_until_done(
177
+ poll_fetch,
178
+ export_id,
179
+ unwrap_status,
180
+ max_rounds=max_rounds,
181
+ )
182
+ async for chunk in stream_bytes(url, max_bytes=max_bytes):
183
+ sys.stdout.buffer.write(chunk)
184
+ sys.stdout.buffer.flush()
185
+
186
+
187
+ def run_pipefy_client_coroutine(
188
+ ctx: typer.Context,
189
+ coro_factory: Callable[[PipefyClient], Awaitable[_T]],
190
+ *,
191
+ value_error_exit_code: int | None = None,
192
+ ) -> _T:
193
+ """Run ``asyncio.run`` on a coroutine built with a configured :class:`PipefyClient`.
194
+
195
+ Args:
196
+ ctx: Typer context (resolves settings/token from the root app).
197
+ coro_factory: Async callable receiving an authenticated client.
198
+ value_error_exit_code: When set, map ``ValueError`` from the factory to this exit code (stderr message).
199
+
200
+ Returns:
201
+ The coroutine result.
202
+
203
+ Raises:
204
+ typer.Exit: On :class:`PipefyError` (exit code 1), optional ``ValueError`` mapping, or ``BrokenPipeError`` (exit 0).
205
+ """
206
+ pipefy_settings, auth = settings_and_auth_from_ctx(ctx)
207
+
208
+ async def _run() -> _T:
209
+ client = get_authenticated_client(pipefy_settings, auth)
210
+ return await coro_factory(client)
211
+
212
+ try:
213
+ return asyncio.run(_run())
214
+ except PipefyError as exc:
215
+ typer.echo(str(exc), err=True)
216
+ raise typer.Exit(1) from exc
217
+ except ValueError as exc:
218
+ if value_error_exit_code is None:
219
+ raise
220
+ typer.echo(str(exc), err=True)
221
+ raise typer.Exit(value_error_exit_code) from exc
222
+ except BrokenPipeError:
223
+ raise typer.Exit(0) from None
224
+ except TransportQueryError as exc:
225
+ typer.echo(_format_transport_query_error(exc), err=True)
226
+ raise typer.Exit(1) from exc
227
+ except TransportError as exc:
228
+ typer.echo(f"Pipefy transport error: {exc}", err=True)
229
+ raise typer.Exit(1) from exc
230
+
231
+
232
+ def _format_transport_query_error(exc: TransportQueryError) -> str:
233
+ """Render a GraphQL transport error as a clean single-line message for the CLI.
234
+
235
+ Falls back to ``str(exc)`` when the structured ``errors`` payload is missing or empty.
236
+ """
237
+ errors = getattr(exc, "errors", None) or []
238
+ if not errors:
239
+ return str(exc)
240
+ first = errors[0] if isinstance(errors[0], dict) else {"message": str(errors[0])}
241
+ message = first.get("message") or str(exc)
242
+ code = (first.get("extensions") or {}).get("code")
243
+ return f"{message} ({code})" if code else message
244
+
245
+
246
+ def format_card_get_transport_query_error(exc: TransportQueryError) -> str:
247
+ """Like :func:`_format_transport_query_error` with a hint for missing/deleted cards."""
248
+ base = _format_transport_query_error(exc)
249
+ errors = getattr(exc, "errors", None) or []
250
+ if errors and isinstance(errors[0], dict):
251
+ code = (errors[0].get("extensions") or {}).get("code")
252
+ if code == "PERMISSION_DENIED":
253
+ return f"{base} The card may have been deleted or is not visible to this token."
254
+ return base
255
+
256
+
257
+ def settings_and_token(
258
+ ctx: typer.Context,
259
+ ) -> tuple[PipefySettings, BearerToken | None]:
260
+ """Resolve root CLI context object into settings and optional bearer token."""
261
+ root = ctx.find_root()
262
+ obj = root.obj
263
+ return obj["pipefy_settings"], obj.get("token")
264
+
265
+
266
+ def settings_and_auth_from_ctx(
267
+ ctx: typer.Context,
268
+ ) -> tuple[PipefySettings, AuthContext]:
269
+ """Resolve root ``ctx.obj`` into the (settings, auth) pair the client boundary needs."""
270
+ obj = ctx.find_root().obj
271
+ auth_settings = obj["auth_settings"]
272
+ auth = AuthContext(
273
+ bearer_token=obj.get("token"),
274
+ service_account=auth_settings.to_service_account(),
275
+ oidc_client=auth_settings.to_oidc_client(),
276
+ )
277
+ return obj["pipefy_settings"], auth
278
+
279
+
280
+ def authenticated_client_from_ctx(ctx: typer.Context) -> PipefyClient:
281
+ """Build a :class:`PipefyClient` using the same auth path as ``run_cli_command``."""
282
+ pipefy_settings, auth = settings_and_auth_from_ctx(ctx)
283
+ return get_authenticated_client(pipefy_settings, auth)
284
+
285
+
286
+ def parse_json_value(raw: str | None, option_name: str) -> Any:
287
+ """Parse a JSON value from a CLI string option (empty input returns ``None``)."""
288
+ if raw is None or raw.strip() == "":
289
+ return None
290
+ try:
291
+ return json.loads(raw)
292
+ except json.JSONDecodeError as exc:
293
+ raise typer.BadParameter(f"Invalid JSON for {option_name}: {exc}") from exc
294
+
295
+
296
+ def parse_json_object(raw: str | None, option_name: str) -> dict[str, Any] | None:
297
+ """Parse a JSON object from a CLI string option (empty input returns ``None``)."""
298
+ if raw is None or raw.strip() == "":
299
+ return None
300
+ parsed = parse_json_value(raw, option_name)
301
+ if not isinstance(parsed, dict):
302
+ raise typer.BadParameter(f"{option_name} must be a JSON object")
303
+ return parsed
304
+
305
+
306
+ def confirm_destructive(*, yes: bool, description: str, verb: str = "delete") -> None:
307
+ """Prompt before a destructive action unless ``yes`` is True."""
308
+ if yes:
309
+ return
310
+ if not typer.confirm(f"Permanently {verb} {description}?"):
311
+ raise typer.Abort()
312
+
313
+
314
+ def run_cli_command(
315
+ ctx: typer.Context,
316
+ json_out: bool,
317
+ coro_factory: Callable[[PipefyClient], Awaitable[_R]],
318
+ *,
319
+ exit_code_2_on_value_error: bool = True,
320
+ format_transport_query_error: Callable[[TransportQueryError], str] | None = None,
321
+ ) -> None:
322
+ """Run an async coroutine factory with a configured client and render the result.
323
+
324
+ Args:
325
+ ctx: Typer context (resolves settings/token from the root app).
326
+ json_out: When True, print JSON; otherwise Rich rendering.
327
+ coro_factory: Async callable receiving ``PipefyClient`` and returning renderable data.
328
+ exit_code_2_on_value_error: Map ``ValueError`` to process exit code 2 (stderr).
329
+ format_transport_query_error: Optional override for GraphQL transport errors
330
+ (defaults to a single-line formatter).
331
+ """
332
+ pipefy_settings, auth = settings_and_auth_from_ctx(ctx)
333
+ transport_fmt = format_transport_query_error or _format_transport_query_error
334
+
335
+ async def _run() -> _R:
336
+ client = get_authenticated_client(pipefy_settings, auth)
337
+ return await coro_factory(client)
338
+
339
+ try:
340
+ data = asyncio.run(_run())
341
+ except PipefyError as exc:
342
+ typer.echo(str(exc), err=True)
343
+ raise typer.Exit(1) from exc
344
+ except TransportQueryError as exc:
345
+ typer.echo(transport_fmt(exc), err=True)
346
+ raise typer.Exit(1) from exc
347
+ except TransportError as exc:
348
+ typer.echo(f"Pipefy transport error: {exc}", err=True)
349
+ raise typer.Exit(1) from exc
350
+ except ValueError as exc:
351
+ if exit_code_2_on_value_error:
352
+ typer.echo(str(exc), err=True)
353
+ raise typer.Exit(2) from exc
354
+ raise
355
+
356
+ if json_out:
357
+ render_json(data)
358
+ else:
359
+ render_rich(data)