posit-cli 0.1.0__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.
posit_cli/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """posit-cli: a single command-line interface for Posit Connect."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("posit-cli")
7
+ except PackageNotFoundError: # pragma: no cover - source checkout without install
8
+ __version__ = "0.0.0+unknown"
posit_cli/__main__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Top-level ``posit`` command group."""
2
+
3
+ import click
4
+
5
+ from . import __version__
6
+ from .connect import connect
7
+
8
+
9
+ @click.group(no_args_is_help=True)
10
+ @click.version_option(version=__version__)
11
+ def cli() -> None:
12
+ """Posit command-line interface."""
13
+
14
+
15
+ cli.add_command(connect)
16
+
17
+
18
+ if __name__ == "__main__": # pragma: no cover
19
+ cli()
@@ -0,0 +1,31 @@
1
+ """The ``posit connect`` command group.
2
+
3
+ Mounts the entire ``rsconnect`` CLI (login, deploy, add, list, ...) under
4
+ ``posit connect`` so those commands come for free and track rsconnect-python
5
+ upstream, then layers on a ``gh api``-style ``posit connect api`` command.
6
+ """
7
+
8
+ import click
9
+ from rsconnect.main import cli as rsconnect_cli
10
+
11
+ from .api import api as api_cmd
12
+
13
+
14
+ _epilog = (
15
+ "Tip: prefer 'posit connect login' (OAuth, tokens stored in your OS "
16
+ "keyring) over 'posit connect server add' (stores a plaintext API key)."
17
+ )
18
+
19
+
20
+ @click.group(no_args_is_help=True, epilog=_epilog)
21
+ def connect() -> None:
22
+ """Work with Posit Connect."""
23
+
24
+
25
+ # Re-expose every rsconnect subcommand under `posit connect`. rsconnect's own
26
+ # `api` lives under its `deploy` group, so the top-level `api` name is free for
27
+ # our gh-style command added below.
28
+ for _name, _cmd in rsconnect_cli.commands.items():
29
+ connect.add_command(_cmd, name=_name)
30
+
31
+ connect.add_command(api_cmd, name="api")
@@ -0,0 +1,584 @@
1
+ """``posit connect api`` -- a ``gh api``-style raw REST client for Connect.
2
+
3
+ Backed by rsconnect-python's authenticated client (``RSConnectExecutor`` ->
4
+ ``RSConnectClient``), so it reuses the same credential store, transparently
5
+ picks OAuth ``Bearer`` vs API-``Key`` auth, and auto-refreshes OAuth tokens on
6
+ 401 -- no separate auth glue needed.
7
+ """
8
+
9
+ import json
10
+ import sys
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+ from urllib.parse import parse_qsl, urlparse
13
+
14
+ import click
15
+ import jq
16
+ from rsconnect.api import RSConnectExecutor, RSConnectException
17
+ from rsconnect.http_support import HTTPResponse
18
+
19
+ # Safety cap so a malformed/cyclic paging.next can't loop forever.
20
+ _MAX_PAGES = 10_000
21
+ # Connect's maximum page size; request it for follow-up pages to minimize
22
+ # round-trips (matches posit-sdk-py).
23
+ _MAX_PAGE_SIZE = 500
24
+
25
+
26
+ def _read_at_value(raw: str) -> str:
27
+ """Resolve a ``@file`` / ``@-`` (stdin) field value, gh-style."""
28
+ source = raw[1:]
29
+ if source == "-":
30
+ return sys.stdin.read()
31
+ with open(source, encoding="utf-8") as f:
32
+ return f.read()
33
+
34
+
35
+ def _parse_typed(value: str) -> Any:
36
+ """Parse a ``-F`` typed field value (true/false/null/number/@file/string)."""
37
+ if value.startswith("@"):
38
+ return _read_at_value(value)
39
+ if value == "true":
40
+ return True
41
+ if value == "false":
42
+ return False
43
+ if value == "null":
44
+ return None
45
+ try:
46
+ return int(value)
47
+ except ValueError:
48
+ pass
49
+ try:
50
+ return float(value)
51
+ except ValueError:
52
+ pass
53
+ return value
54
+
55
+
56
+ def _split_pairs(pairs: Tuple[str, ...], *, typed: bool) -> Dict[str, Any]:
57
+ out: Dict[str, Any] = {}
58
+ for pair in pairs:
59
+ if "=" not in pair:
60
+ raise click.BadParameter(
61
+ f"expected key=value, got {pair!r}",
62
+ param_hint="--field/--raw-field",
63
+ )
64
+ key, value = pair.split("=", 1)
65
+ out[key] = _parse_typed(value) if typed else value
66
+ return out
67
+
68
+
69
+ def _split_headers(headers: Tuple[str, ...]) -> Dict[str, str]:
70
+ out: Dict[str, str] = {}
71
+ for header in headers:
72
+ if ":" not in header:
73
+ raise click.BadParameter(f"expected key:value, got {header!r}", param_hint="--header")
74
+ key, value = header.split(":", 1)
75
+ out[key.strip()] = value.strip()
76
+ return out
77
+
78
+
79
+ @click.command(
80
+ "api",
81
+ short_help="Make an authenticated request to the Connect API.",
82
+ context_settings={"help_option_names": ["-h", "--help"]},
83
+ )
84
+ @click.argument("path")
85
+ @click.option(
86
+ "--method",
87
+ "-X",
88
+ default=None,
89
+ help="HTTP method. Defaults to GET, or POST when fields/input are given.",
90
+ )
91
+ @click.option(
92
+ "--field",
93
+ "-F",
94
+ "typed_fields",
95
+ multiple=True,
96
+ metavar="key=value",
97
+ help="Add a typed parameter (true/false/null/numbers; @file reads a file, @- reads stdin).",
98
+ )
99
+ @click.option(
100
+ "--raw-field",
101
+ "-f",
102
+ "raw_fields",
103
+ multiple=True,
104
+ metavar="key=value",
105
+ help="Add a string parameter.",
106
+ )
107
+ @click.option(
108
+ "--header",
109
+ "-H",
110
+ "headers",
111
+ multiple=True,
112
+ metavar="key:value",
113
+ help="Add a request header.",
114
+ )
115
+ @click.option(
116
+ "--input",
117
+ "input_body",
118
+ metavar="FILE",
119
+ help="Read the raw request body from a file ('-' for stdin).",
120
+ )
121
+ @click.option(
122
+ "--jq",
123
+ "-q",
124
+ "jq_filter",
125
+ default=None,
126
+ metavar="EXPR",
127
+ help="Filter the JSON response through a jq expression (like 'gh api --jq').",
128
+ )
129
+ @click.option(
130
+ "--include",
131
+ "-i",
132
+ is_flag=True,
133
+ default=False,
134
+ help="Include the HTTP response status line and headers in the output.",
135
+ )
136
+ @click.option(
137
+ "--paginate",
138
+ is_flag=True,
139
+ default=False,
140
+ help="Follow Connect's pagination and output all pages' results combined (GET only).",
141
+ )
142
+ # Credential selection -- mirrors rsconnect's own options/precedence.
143
+ @click.option("--name", "-n", default=None, help="Nickname of a saved server.")
144
+ @click.option(
145
+ "--server",
146
+ "-s",
147
+ default=None,
148
+ envvar="CONNECT_SERVER",
149
+ help="Connect server URL [env: CONNECT_SERVER].",
150
+ )
151
+ @click.option(
152
+ "--api-key",
153
+ "-k",
154
+ default=None,
155
+ envvar="CONNECT_API_KEY",
156
+ help="Connect API key [env: CONNECT_API_KEY].",
157
+ )
158
+ @click.option(
159
+ "--no-tls-verify",
160
+ "insecure",
161
+ is_flag=True,
162
+ default=False,
163
+ envvar="CONNECT_INSECURE",
164
+ help="Skip TLS certificate verification (still uses TLS) [env: CONNECT_INSECURE].",
165
+ )
166
+ @click.option(
167
+ "--cacert",
168
+ "-c",
169
+ type=click.Path(exists=True, dir_okay=False),
170
+ default=None,
171
+ envvar="CONNECT_CA_CERTIFICATE",
172
+ help="Path to trusted TLS CA certificate.",
173
+ )
174
+ def api(
175
+ path: str,
176
+ method: Optional[str],
177
+ typed_fields: Tuple[str, ...],
178
+ raw_fields: Tuple[str, ...],
179
+ headers: Tuple[str, ...],
180
+ input_body: Optional[str],
181
+ jq_filter: Optional[str],
182
+ include: bool,
183
+ paginate: bool,
184
+ name: Optional[str],
185
+ server: Optional[str],
186
+ api_key: Optional[str],
187
+ insecure: bool,
188
+ cacert: Optional[str],
189
+ ) -> None:
190
+ """Make an authenticated request to a Connect API endpoint and print the JSON response.
191
+
192
+ PATH is relative to the Connect API root, e.g. 'v1/content' or 'v1/user'
193
+ (the '/__api__' prefix is added for you). Behaves like 'gh api'.
194
+
195
+ Examples:
196
+
197
+ posit connect api v1/user
198
+
199
+ posit connect api v1/user -q .username
200
+
201
+ posit connect api v1/content -X POST -f name=my-app
202
+
203
+ posit connect api v1/content -F count=10
204
+ """
205
+ fields: Dict[str, Any] = {}
206
+ fields.update(_split_pairs(raw_fields, typed=False))
207
+ fields.update(_split_pairs(typed_fields, typed=True))
208
+
209
+ raw_body = _read_at_value("@" + input_body) if input_body else None
210
+ if raw_body is not None and fields:
211
+ raise click.UsageError("--input cannot be combined with -f/-F fields.")
212
+
213
+ # Compile the jq filter up front so a typo fails before we hit the network.
214
+ jq_program = None
215
+ if jq_filter is not None:
216
+ try:
217
+ jq_program = jq.compile(jq_filter)
218
+ except ValueError as exc:
219
+ raise click.BadParameter(str(exc), param_hint="--jq") from exc
220
+
221
+ has_payload = bool(fields) or raw_body is not None
222
+ resolved_method = (method or ("POST" if has_payload else "GET")).upper()
223
+
224
+ if paginate:
225
+ # Pagination walks read endpoints and combines bodies; showing the
226
+ # headers of just one of N responses would be misleading, and following
227
+ # pages of a write makes no sense.
228
+ if include:
229
+ raise click.UsageError("--paginate cannot be combined with --include.")
230
+ if resolved_method != "GET":
231
+ raise click.UsageError("--paginate only supports GET requests.")
232
+
233
+ request_headers = _split_headers(headers)
234
+
235
+ query_params: Optional[Dict[str, Any]] = None
236
+ body: Any = None
237
+ if raw_body is not None:
238
+ body = raw_body
239
+ elif fields:
240
+ if resolved_method == "GET":
241
+ query_params = fields
242
+ else:
243
+ # Encode the JSON body ourselves rather than handing rsconnect a dict:
244
+ # when given a Mapping/list, RSConnectClient.request *replaces* the
245
+ # headers with just Content-Type, dropping any user -H values. Passing
246
+ # an already-encoded str keeps our headers intact.
247
+ body = json.dumps(fields)
248
+ # Only default Content-Type if the user didn't set it under any casing;
249
+ # a case-sensitive setdefault would emit a duplicate header.
250
+ if not any(k.lower() == "content-type" for k in request_headers):
251
+ request_headers["Content-Type"] = "application/json"
252
+
253
+ try:
254
+ ce = RSConnectExecutor(
255
+ ctx=None,
256
+ name=name,
257
+ url=server,
258
+ api_key=api_key,
259
+ insecure=insecure,
260
+ cacert=cacert,
261
+ )
262
+ ce.setup_client()
263
+ request_path = _resolve_path(path, getattr(ce.remote_server, "url", None))
264
+ if include:
265
+ # RSConnectClient._tweak_response unwraps a 2xx JSON body to a
266
+ # dict/list, discarding the status line and headers. To show them we
267
+ # need the raw HTTPResponse, so neutralize the unwrapping for this
268
+ # request (the identity is exactly the HTTPServer base behavior).
269
+ ce.client._tweak_response = lambda response: response
270
+ if paginate:
271
+ result = _request_all_pages(
272
+ ce.client, resolved_method, request_path, query_params, body, request_headers
273
+ )
274
+ else:
275
+ result = ce.client.request(
276
+ resolved_method,
277
+ request_path,
278
+ query_params=query_params,
279
+ body=body,
280
+ headers=request_headers,
281
+ )
282
+ except RSConnectException as exc:
283
+ raise click.ClickException(str(exc)) from exc
284
+
285
+ # Like gh, bare -f/-F fields imply POST and are sent as a JSON body. A common
286
+ # mistake is reaching for them to filter a GET list, which Connect rejects as
287
+ # an unknown body field. Only when the POST was *implicit* (no -X) does a 4xx
288
+ # warrant nudging the user toward query params.
289
+ fields_implied_post = method is None and bool(fields)
290
+ _emit(result, jq_program, query_param_hint=fields_implied_post, include=include)
291
+
292
+
293
+ def _emit(
294
+ result: Any,
295
+ jq_program: Any = None,
296
+ query_param_hint: bool = False,
297
+ include: bool = False,
298
+ ) -> None:
299
+ """Print the response body, or surface an error and exit non-zero.
300
+
301
+ RSConnectClient unwraps a 2xx JSON body to a dict/list/scalar, but returns a
302
+ raw ``HTTPResponse`` for any other case -- including *successful* responses
303
+ with no JSON body (e.g. 204 No Content) as well as actual errors. So a bare
304
+ ``HTTPResponse`` must be classified by status, not treated as failure.
305
+
306
+ ``jq_program`` (when given) filters a *successful* JSON body before printing;
307
+ error responses are never filtered. ``query_param_hint`` adds a nudge toward
308
+ query params when an implicit-POST request fails with a client error.
309
+ ``include`` prepends the HTTP status line and response headers (requires the
310
+ raw HTTPResponse, which the caller arranges by bypassing _tweak_response).
311
+ """
312
+ if isinstance(result, HTTPResponse):
313
+ # status/reason/headers are only present when an actual response arrived;
314
+ # on a transport exception they're absent entirely.
315
+ status = getattr(result, "status", None)
316
+ payload = result.json_data if result.json_data is not None else result.response_body
317
+ else:
318
+ # Already-decoded 2xx JSON body (the unwrapped, non-include path).
319
+ status = 200
320
+ payload = result
321
+
322
+ success = status is not None and 200 <= status < 300
323
+
324
+ if success:
325
+ # Render the body (running jq) *before* emitting anything: a jq runtime
326
+ # failure must abort with nothing on stdout, not leak the headers first.
327
+ rendered = _render_success(payload, jq_program)
328
+ if include:
329
+ for line in _header_lines(result, status):
330
+ click.echo(line)
331
+ click.echo("") # blank line between headers and body
332
+ if rendered is not None:
333
+ click.echo(rendered)
334
+ return
335
+
336
+ # Failure: in include mode the status line + headers go to stderr too,
337
+ # matching our errors-to-stderr rule.
338
+ if include and status is not None:
339
+ for line in _header_lines(result, status):
340
+ click.echo(line, err=True)
341
+ click.echo("", err=True)
342
+
343
+ if status is None:
344
+ # Transport failure: no response or headers exist (include is moot here).
345
+ message = (
346
+ f"request failed: {result.exception}"
347
+ if result.exception is not None
348
+ else "request failed"
349
+ )
350
+ elif include:
351
+ # The status line and headers were already shown; just the body here.
352
+ message = _dumps(payload) if payload not in (None, "") else ""
353
+ else:
354
+ header = f"HTTP {status} {getattr(result, 'reason', '') or ''}".rstrip()
355
+ body = _dumps(payload) if payload not in (None, "") else ""
356
+ message = f"{header}\n{body}".rstrip()
357
+
358
+ if query_param_hint and status is not None and 400 <= status < 500:
359
+ message += (
360
+ "\n\nNote: -f/-F fields were sent as a JSON body (they imply POST). "
361
+ "For query parameters on a read, use '-X GET' or put them in the "
362
+ 'path: api "<path>?key=value".'
363
+ )
364
+ if message:
365
+ click.echo(message, err=True)
366
+ raise SystemExit(1)
367
+
368
+
369
+ def _request_all_pages(
370
+ client: Any,
371
+ method: str,
372
+ path: str,
373
+ query_params: Optional[Dict[str, Any]],
374
+ body: Any,
375
+ headers: Dict[str, str],
376
+ ) -> Any:
377
+ """Fetch every page of a Connect list endpoint and combine the results.
378
+
379
+ Connect paginates two ways (mirrored from posit-sdk-py's Paginator and
380
+ CursorPaginator):
381
+
382
+ * cursor style -- ``{paging: {cursors: {next}}, results}`` (e.g. audit logs):
383
+ follow the ``paging.cursors.next`` token until it's empty.
384
+ * page-number style -- ``{current_page, total, results}`` (e.g. users,
385
+ groups): request ``page_number`` 1..N until results are empty or the
386
+ accumulated count reaches ``total``.
387
+
388
+ Anything else (a bare array, an unrecognized shape) is treated as a single
389
+ page. Returns the combined result, or the first ``HTTPResponse`` (an error
390
+ or non-JSON body) so the caller's error path handles it.
391
+ """
392
+ base_path, params = _split_query(path, query_params)
393
+ pages: List[Any] = []
394
+ count = 0
395
+ while True:
396
+ result = client.request(
397
+ method, base_path, query_params=(params or None), body=body, headers=headers
398
+ )
399
+ if isinstance(result, HTTPResponse):
400
+ # Non-2xx or non-JSON: surface it directly (errors aren't paged).
401
+ return result
402
+ pages.append(result)
403
+
404
+ paging = result.get("paging") if isinstance(result, dict) else None
405
+ results = result.get("results") if isinstance(result, dict) else None
406
+
407
+ # Work out the next request, or None to stop.
408
+ next_target: Optional[Tuple[str, Dict[str, Any]]] = None
409
+ if isinstance(paging, dict):
410
+ # Cursor style: prefer the cursor token; fall back to the full
411
+ # paging.next URL if an endpoint only provides that form. A cursor is
412
+ # a position token, so raising the page size between pages is safe.
413
+ cursors = paging.get("cursors") or {}
414
+ token = cursors.get("next")
415
+ if token:
416
+ next_params = {**params, "next": token}
417
+ next_params.setdefault("limit", _MAX_PAGE_SIZE)
418
+ next_target = (base_path, next_params)
419
+ else:
420
+ next_url = paging.get("next")
421
+ if isinstance(next_url, str) and next_url:
422
+ next_target = _split_query(_next_page_path(next_url), None)
423
+ elif isinstance(results, list) and isinstance(result.get("current_page"), int):
424
+ # Page-number style: stop on an empty page or once we've seen `total`.
425
+ # Page size must stay *constant* across requests -- it defines the
426
+ # offset window, so changing it mid-stream would skip/duplicate rows.
427
+ count += len(results)
428
+ total = result.get("total")
429
+ if results and not (isinstance(total, int) and count >= total):
430
+ next_target = (base_path, {**params, "page_number": result["current_page"] + 1})
431
+ # else: bare array or unrecognized shape -> single page.
432
+
433
+ if next_target is None:
434
+ break
435
+ if len(pages) >= _MAX_PAGES:
436
+ # There's still a next page but we've hit the cap: fail loudly rather
437
+ # than silently returning a truncated result with exit code 0.
438
+ raise click.ClickException(
439
+ f"--paginate stopped after {_MAX_PAGES} pages; "
440
+ "possible pagination loop or an unexpectedly large result set"
441
+ )
442
+ base_path, params = next_target
443
+ return _merge_pages(pages)
444
+
445
+
446
+ def _split_query(path: str, query_params: Optional[Dict[str, Any]]) -> Tuple[str, Dict[str, Any]]:
447
+ """Split any ``?query`` off ``path`` and merge it with ``query_params``.
448
+
449
+ Pagination needs the query as a mutable dict so it can advance the cursor or
450
+ page number while preserving the caller's other filters.
451
+ """
452
+ base = path
453
+ params: Dict[str, Any] = {}
454
+ if "?" in path:
455
+ base, query = path.split("?", 1)
456
+ params.update(parse_qsl(query))
457
+ if query_params:
458
+ params.update(query_params)
459
+ return base, params
460
+
461
+
462
+ def _resolve_path(path: str, server_url: Optional[str]) -> str:
463
+ """Normalize the PATH argument to something the client can request.
464
+
465
+ A relative path keeps its existing behavior (leading slash stripped; the
466
+ client adds ``/__api__``). A full URL -- handy for pasting a ``paging.next``
467
+ link -- is reduced to its API-relative path. Because the client is bound to
468
+ a single server, a URL for a different host is rejected rather than silently
469
+ sent to the configured server.
470
+ """
471
+ if not path.startswith(("http://", "https://")):
472
+ return path.lstrip("/") # client prepends /__api__ itself
473
+ parsed = urlparse(path)
474
+ server = urlparse(server_url or "")
475
+ if server.hostname and not _same_host(parsed, server):
476
+ raise click.ClickException(
477
+ f"URL host '{parsed.netloc}' does not match the target server "
478
+ f"'{server.netloc}'; 'posit connect api' only talks to the configured server."
479
+ )
480
+ return _next_page_path(path)
481
+
482
+
483
+ def _same_host(a: Any, b: Any) -> bool:
484
+ """Whether two parsed URLs point at the same host:port.
485
+
486
+ Compares hostnames case-insensitively and effective ports (filling in the
487
+ scheme default), so e.g. ``c.example`` and ``C.EXAMPLE:443`` over https match.
488
+ """
489
+ if (a.hostname or "").lower() != (b.hostname or "").lower():
490
+ return False
491
+ return _effective_port(a) == _effective_port(b)
492
+
493
+
494
+ def _effective_port(parsed: Any) -> Optional[int]:
495
+ """The URL's port, defaulting by scheme. Malformed ports become a CLI error.
496
+
497
+ ``urlparse(...).port`` raises ``ValueError`` on a non-numeric port (e.g.
498
+ ``:abc``); surface that as a clean error rather than a traceback.
499
+ """
500
+ defaults = {"http": 80, "https": 443}
501
+ try:
502
+ return parsed.port or defaults.get(parsed.scheme)
503
+ except ValueError as exc:
504
+ raise click.ClickException(f"invalid port in URL: {exc}") from exc
505
+
506
+
507
+ def _next_page_path(next_url: str) -> str:
508
+ """Turn an absolute ``paging.next`` URL into a path the client can request.
509
+
510
+ The client prepends ``/__api__``, so strip everything up to and including
511
+ that marker and keep the remainder (with any query string).
512
+ """
513
+ parsed = urlparse(next_url)
514
+ marker = "/__api__/"
515
+ idx = parsed.path.find(marker)
516
+ rel = parsed.path[idx + len(marker) :] if idx != -1 else parsed.path.lstrip("/")
517
+ return f"{rel}?{parsed.query}" if parsed.query else rel
518
+
519
+
520
+ def _merge_pages(pages: List[Any]) -> Any:
521
+ """Combine paginated pages into a single result.
522
+
523
+ A single page is returned unchanged (so unpaged endpoints behave exactly as
524
+ without ``--paginate``). Connect's paged endpoints wrap rows in a ``results``
525
+ array; across multiple pages those arrays are concatenated. If any page
526
+ lacks a ``results`` list, the raw pages are returned rather than dropping
527
+ data silently.
528
+ """
529
+ if len(pages) == 1:
530
+ return pages[0]
531
+ merged: List[Any] = []
532
+ for page in pages:
533
+ rows = page.get("results") if isinstance(page, dict) else None
534
+ if not isinstance(rows, list):
535
+ return pages
536
+ merged.extend(rows)
537
+ return merged
538
+
539
+
540
+ def _header_lines(result: Any, status: int) -> "list[str]":
541
+ """Yield the HTTP status line and response header lines, gh/curl-style."""
542
+ raw = getattr(result, "_response", None)
543
+ version = getattr(raw, "version", 11) if raw is not None else 11
544
+ reason = getattr(result, "reason", "") or ""
545
+ lines = [f"HTTP/{version // 10}.{version % 10} {status} {reason}".rstrip()]
546
+ if raw is not None:
547
+ lines.extend(f"{name}: {value}" for name, value in raw.getheaders())
548
+ return lines
549
+
550
+
551
+ def _render_success(value: Any, jq_program: Any = None) -> Optional[str]:
552
+ """Render a successful response body, optionally filtered through jq.
553
+
554
+ Returns the text to print, or None for an empty body (e.g. 204). Mirrors
555
+ ``gh api``: with a jq filter, each result is on its own line -- strings raw
556
+ (unquoted), everything else compact JSON; without a filter, pretty-printed.
557
+
558
+ This *renders* rather than prints so the caller can emit headers and body
559
+ together only after rendering succeeds: a jq runtime failure must abort
560
+ before any output, not leak partial output (e.g. headers) to stdout.
561
+ """
562
+ if value in (None, ""):
563
+ return None
564
+ if jq_program is None:
565
+ return _dumps(value)
566
+ # A filter can compile cleanly yet fail at runtime (e.g. error(...) or a
567
+ # type mismatch against the actual payload). .all() materializes every
568
+ # result first, so a failure raises here before anything is printed --
569
+ # surface it as a clean CLI error, not a traceback.
570
+ try:
571
+ results = jq_program.input_value(value).all()
572
+ except ValueError as exc:
573
+ raise click.ClickException(f"jq: {exc}") from exc
574
+ if not results:
575
+ # Zero matches -> no body (a blank line would falsely signal output).
576
+ # This is distinct from a single empty-string result, which prints "".
577
+ return None
578
+ return "\n".join(item if isinstance(item, str) else json.dumps(item) for item in results)
579
+
580
+
581
+ def _dumps(value: Any) -> str:
582
+ if isinstance(value, (dict, list)):
583
+ return json.dumps(value, indent=2)
584
+ return str(value)
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: posit-cli
3
+ Version: 0.1.0
4
+ Summary: A single, friendly command-line interface for Posit Connect, in the spirit of gh.
5
+ Author: Posit Software, PBC
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.8
9
+ Requires-Dist: click>=8.0
10
+ Requires-Dist: jq>=1.4
11
+ Requires-Dist: keyring>=23.0
12
+ Requires-Dist: rsconnect-python<2,>=1.30
13
+ Provides-Extra: lint
14
+ Requires-Dist: ruff>=0.6; extra == 'lint'
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest>=7; extra == 'test'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # posit-cli
20
+
21
+ A friendly command-line interface for Posit products, in the spirit of [`gh`](https://cli.github.com/).
22
+
23
+ ```console
24
+ $ posit connect login https://connect.example.com # OAuth, tokens in your OS keyring
25
+ $ posit connect api v1/user -q .username # gh-api-style raw request
26
+ $ posit connect deploy streamlit ./my-app # everything rsconnect can do
27
+ ```
28
+
29
+ This project is in early-stage development and so far only supports Posit Connect's APIs.
30
+
31
+ ## Installation
32
+
33
+ `posit-cli` isn't on PyPI yet. Install the latest version straight from GitHub
34
+ with [`uv`](https://docs.astral.sh/uv/):
35
+
36
+ ```console
37
+ uv tool install git+https://github.com/posit-dev/posit-cli.git
38
+ ```
39
+
40
+ If you authenticate to GitHub over SSH, use the `git+ssh://` form instead (uv
41
+ requires the `git@` username):
42
+
43
+ ```console
44
+ uv tool install git+ssh://git@github.com/posit-dev/posit-cli.git
45
+ ```
46
+
47
+ Either way this puts the `posit` executable on your `PATH`. To upgrade later, run
48
+ `uv tool upgrade posit-cli`. See uv's
49
+ [Git authentication docs](https://docs.astral.sh/uv/concepts/authentication/git/)
50
+ for tokens and other hosts.
51
+
52
+ ## Getting started
53
+
54
+ **1. Log in.** Point `posit` at your Connect server and authenticate. This runs
55
+ an OAuth 2.1 flow in your browser and saves the tokens in your OS keyring, so
56
+ you only do it once per server:
57
+
58
+ ```console
59
+ $ posit connect login https://connect.example.com
60
+ ```
61
+
62
+ **2. Try a request.** Confirm you're connected by asking Connect who you are:
63
+
64
+ ```console
65
+ $ posit connect api v1/user -q .username
66
+ ```
67
+
68
+ **3. Deploy something.** Anything `rsconnect` can deploy, `posit` can too:
69
+
70
+ ```console
71
+ $ posit connect deploy streamlit ./my-app
72
+ ```
73
+
74
+ That's it — from here, explore `posit connect --help` for the full command set.
75
+
76
+ ## Authentication
77
+
78
+ `posit connect login` runs an OAuth 2.1 flow and stores tokens in your OS
79
+ keyring; `posit connect api` and the deploy commands reuse those credentials
80
+ automatically (including token refresh). You can also point at a server ad hoc
81
+ with `--server`/`CONNECT_SERVER` and `--api-key`/`CONNECT_API_KEY`.
82
+
83
+ ## `posit connect api`
84
+
85
+ A `gh api`-style raw REST client, authenticated with your saved credentials.
86
+ `PATH` is relative to the Connect API root (the `/__api__` prefix is added for
87
+ you):
88
+
89
+ ```console
90
+ $ posit connect api v1/user # GET, pretty-printed JSON
91
+ $ posit connect api v1/user -q .username # filter with a jq expression
92
+ $ posit connect api "v1/content?limit=3" -q '.[].name'
93
+ ```
94
+
95
+ `-q`/`--jq` runs the response through [jq](https://jqlang.github.io/jq/), like
96
+ `gh api --jq`: string results print unquoted, one per line.
97
+
98
+ `-i`/`--include` prepends the HTTP status line and response headers to the
99
+ output (handy for inspecting rate limits, request IDs, or the server version):
100
+
101
+ ```console
102
+ $ posit connect api v1/user -i
103
+ ```
104
+
105
+ `--paginate` (GET only) follows Connect's pagination and prints all pages'
106
+ results combined. It handles both of Connect's pagination styles — the
107
+ cursor style (e.g. `v1/audit_logs`) and the page-number style (e.g.
108
+ `v1/users`, `v1/groups`) — and leaves unpaginated endpoints unchanged:
109
+
110
+ ```console
111
+ $ posit connect api v1/users --paginate -q 'length' # count every user
112
+ ```
113
+
114
+ `PATH` may also be a full URL on the configured server — convenient for pasting
115
+ a `paging.next` link straight back in. A URL for a different host is rejected.
116
+
117
+ ### Query parameters vs. request body
118
+
119
+ Like `gh api`, `-f`/`-F` fields default to a **POST request body** (adding any
120
+ field flips the method to POST unless you pass `-X`). To send **query
121
+ parameters** on a read, put them in the path or force `GET`:
122
+
123
+ ```console
124
+ $ posit connect api "v1/users?page_size=5" # query params in the path
125
+ $ posit connect api v1/users -X GET -f page_size=5 # or force GET
126
+ $ posit connect api v1/content -f name=my-app # POST body (creates content)
127
+ ```
128
+
129
+ ## `posit connect *`
130
+
131
+ `posit` wraps [`rsconnect-python`](https://github.com/posit-dev/rsconnect-python):
132
+ it re-exposes the full `rsconnect` command set under `posit connect`, in addition to the `api` utility.
133
+
134
+ ## Using `posit` with Claude Code
135
+
136
+ This repo ships a skill (at [`skills/posit-cli/`](skills/posit-cli/)) that teaches
137
+ coding agents to drive `posit` — auth, `posit connect api`, and deploys. Install
138
+ it with the [`skills`](https://www.skills.sh/) CLI:
139
+
140
+ ```console
141
+ npx skills add posit-dev/posit-cli
142
+ ```
@@ -0,0 +1,9 @@
1
+ posit_cli/__init__.py,sha256=k7UhlrmiZtFUzxY8Z0owopjiPWi6i6sjgqVVzmbAOew,292
2
+ posit_cli/__main__.py,sha256=mMl5ElnnT8Z9-sJhT_ExfrLUWRuNzW_k9Zx9xuSEPrk,336
3
+ posit_cli/connect/__init__.py,sha256=99ITeAaAgC2faLHUxCZvj2QbXi7YKd8w1rkRO9Gertw,975
4
+ posit_cli/connect/api.py,sha256=5lgXcXT7SyXrGg3qboIveEZrg_IFl0DomJNghEfW9dE,21823
5
+ posit_cli-0.1.0.dist-info/METADATA,sha256=JTvrzs_pur7faoVIqx6Pm3ZmLAabIcs0ftzmxPSsdx0,4937
6
+ posit_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
7
+ posit_cli-0.1.0.dist-info/entry_points.txt,sha256=Z6JDyEoPDuTU3cbea8YSdjoShcNIlI0NRqkkBadyn_0,49
8
+ posit_cli-0.1.0.dist-info/licenses/LICENSE,sha256=_ncZcgqErlIxg3Y5Sulf751gDrrncCUl2vAihj6ibzQ,1076
9
+ posit_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ posit = posit_cli.__main__:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Posit Software, PBC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.