pushmetrics-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.
Files changed (86) hide show
  1. pm/__init__.py +1 -0
  2. pm/__main__.py +4 -0
  3. pm/_alias.py +164 -0
  4. pm/auth/__init__.py +0 -0
  5. pm/auth/device_flow.py +187 -0
  6. pm/auth/exchange.py +79 -0
  7. pm/auth/jwt_cache.py +163 -0
  8. pm/auth/secret_files.py +58 -0
  9. pm/auth/session.py +440 -0
  10. pm/auth/store.py +160 -0
  11. pm/auth/token.py +289 -0
  12. pm/cli.py +282 -0
  13. pm/client/__init__.py +68 -0
  14. pm/client/access_tokens.py +54 -0
  15. pm/client/base.py +229 -0
  16. pm/client/blocks.py +461 -0
  17. pm/client/databases.py +147 -0
  18. pm/client/favorites.py +14 -0
  19. pm/client/folders.py +10 -0
  20. pm/client/integrations.py +14 -0
  21. pm/client/organizations.py +20 -0
  22. pm/client/recipients.py +92 -0
  23. pm/client/reports.py +148 -0
  24. pm/client/roles.py +100 -0
  25. pm/client/runs.py +134 -0
  26. pm/client/saved_blocks.py +13 -0
  27. pm/client/tableau.py +108 -0
  28. pm/client/users.py +71 -0
  29. pm/client/webhooks.py +43 -0
  30. pm/client/workspaces.py +34 -0
  31. pm/commands/__init__.py +0 -0
  32. pm/commands/_block_factory.py +762 -0
  33. pm/commands/_helpers.py +178 -0
  34. pm/commands/auth.py +222 -0
  35. pm/commands/block.py +26 -0
  36. pm/commands/chart.py +20 -0
  37. pm/commands/db.py +291 -0
  38. pm/commands/email.py +13 -0
  39. pm/commands/explore.py +20 -0
  40. pm/commands/favorites.py +77 -0
  41. pm/commands/folders.py +113 -0
  42. pm/commands/help.py +643 -0
  43. pm/commands/integrations.py +95 -0
  44. pm/commands/metrics_view.py +195 -0
  45. pm/commands/organizations.py +56 -0
  46. pm/commands/recipient.py +226 -0
  47. pm/commands/reports.py +364 -0
  48. pm/commands/roles.py +102 -0
  49. pm/commands/runs.py +214 -0
  50. pm/commands/saved_blocks.py +97 -0
  51. pm/commands/sftp.py +19 -0
  52. pm/commands/slack.py +13 -0
  53. pm/commands/slides.py +216 -0
  54. pm/commands/sql.py +17 -0
  55. pm/commands/tableau.py +148 -0
  56. pm/commands/token.py +107 -0
  57. pm/commands/users.py +262 -0
  58. pm/commands/webhooks.py +132 -0
  59. pm/commands/workspace.py +156 -0
  60. pm/config.py +192 -0
  61. pm/errors.py +228 -0
  62. pm/log.py +63 -0
  63. pm/models/__init__.py +31 -0
  64. pm/models/access_tokens.py +25 -0
  65. pm/models/blocks.py +21 -0
  66. pm/models/databases.py +15 -0
  67. pm/models/favorites.py +14 -0
  68. pm/models/folders.py +14 -0
  69. pm/models/integrations.py +20 -0
  70. pm/models/organizations.py +13 -0
  71. pm/models/recipients.py +41 -0
  72. pm/models/reports.py +16 -0
  73. pm/models/roles.py +22 -0
  74. pm/models/runs.py +56 -0
  75. pm/models/saved_blocks.py +15 -0
  76. pm/models/users.py +36 -0
  77. pm/models/webhooks.py +23 -0
  78. pm/models/workspaces.py +16 -0
  79. pm/output.py +92 -0
  80. pm/types.py +130 -0
  81. pm/validators.py +499 -0
  82. pushmetrics_cli-0.1.0.dist-info/METADATA +221 -0
  83. pushmetrics_cli-0.1.0.dist-info/RECORD +86 -0
  84. pushmetrics_cli-0.1.0.dist-info/WHEEL +5 -0
  85. pushmetrics_cli-0.1.0.dist-info/entry_points.txt +2 -0
  86. pushmetrics_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,97 @@
1
+ """Saved block (template) commands.
2
+
3
+ Backend only allows updating `parameters_schema`. The `create` flow on the FE
4
+ is composite (create block, then create saved_block wrapper) — out of scope
5
+ for Phase 6.1. Use `ls`, `update --schema`, or `delete`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ import click
12
+
13
+ from pm._alias import AliasedGroup
14
+ from pm.client import PMClient
15
+ from pm.commands._helpers import (
16
+ confirm_delete,
17
+ json_option,
18
+ parse_data,
19
+ report_deleted,
20
+ yes_option,
21
+ limit_option,
22
+ name_filter_option,
23
+ select_rows,
24
+ )
25
+ from pm.output import emit
26
+
27
+
28
+ @click.group(name="saved-blocks", cls=AliasedGroup, aliases={"ls": "list"})
29
+ def saved_blocks() -> None:
30
+ """Saved blocks (reusable block templates)."""
31
+
32
+
33
+ @saved_blocks.command(name="list")
34
+ @name_filter_option(matches="the saved block's name")
35
+ @limit_option
36
+ @json_option
37
+ @click.pass_context
38
+ def ls(ctx: click.Context, name_filter: Optional[str], limit: Optional[int]) -> None:
39
+ """List saved blocks in the current workspace."""
40
+ client = PMClient.from_profile(ctx.obj["profile"])
41
+ items = select_rows(
42
+ client.saved_blocks.list(),
43
+ name=name_filter,
44
+ fields=(lambda r: (r.get("block") or {}).get("name") if isinstance(r.get("block"), dict) else None,),
45
+ limit=limit,
46
+ )
47
+ client.persist()
48
+ emit(
49
+ items,
50
+ json_output=ctx.obj["json"],
51
+ columns=[
52
+ ("UID", "uid"),
53
+ ("BLOCK_ID", "block_id"),
54
+ ("NAME", lambda r: (r.get("block") or {}).get("name", "") if isinstance(r.get("block"), dict) else ""),
55
+ ("TYPE", lambda r: (r.get("block") or {}).get("type", "") if isinstance(r.get("block"), dict) else ""),
56
+ ],
57
+ )
58
+
59
+
60
+ @saved_blocks.command(name="update")
61
+ @click.argument("uid")
62
+ @click.option(
63
+ "--schema",
64
+ "schema_str",
65
+ default=None,
66
+ help="The new parameters schema, as a JSON object.",
67
+ )
68
+ @json_option
69
+ @click.pass_context
70
+ def update(ctx: click.Context, uid: str, schema_str: Optional[str]) -> None:
71
+ """Replace a saved block's parameters schema.
72
+
73
+ The parameters schema is the only field of a saved block that can be
74
+ changed.
75
+ """
76
+ if not schema_str:
77
+ raise click.UsageError("Pass --schema '<json>'.")
78
+ parameters_schema = parse_data(schema_str)
79
+ client = PMClient.from_profile(ctx.obj["profile"])
80
+ sb = client.saved_blocks.update(uid, {"parameters_schema": parameters_schema})
81
+ client.persist()
82
+ emit(sb, json_output=ctx.obj["json"])
83
+
84
+
85
+ @saved_blocks.command(name="delete")
86
+ @click.argument("uid")
87
+ @yes_option
88
+ @json_option
89
+ @click.pass_context
90
+ def delete(ctx: click.Context, uid: str, yes: bool) -> None:
91
+ """Delete a saved block."""
92
+ if not yes:
93
+ confirm_delete("saved block", uid)
94
+ client = PMClient.from_profile(ctx.obj["profile"])
95
+ client.saved_blocks.delete(uid)
96
+ client.persist()
97
+ report_deleted(ctx, "saved block", uid)
pm/commands/sftp.py ADDED
@@ -0,0 +1,19 @@
1
+ """`pm sftp` — create + send SFTP upload blocks.
2
+
3
+ pm-tool introduces SFTP as a NEW agent capability (no direct LLM tool
4
+ exists today). Per codex R3 #5 the agent-side mode should still suppress
5
+ this from `single_tool` help/dispatch until the email/slack soak proves
6
+ out — but the human-facing CLI exposes it fully here.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pm.commands._block_factory import make_block_group
11
+
12
+
13
+ sftp = make_block_group(
14
+ "sftp",
15
+ "sftp",
16
+ help_text="SFTP upload blocks.\n\nBlock type: sftp.",
17
+ crud_verbs=("create", "list", "get"),
18
+ execute_verb="send",
19
+ )
pm/commands/slack.py ADDED
@@ -0,0 +1,13 @@
1
+ """`pm slack` — create + send Slack message blocks."""
2
+ from __future__ import annotations
3
+
4
+ from pm.commands._block_factory import make_block_group
5
+
6
+
7
+ slack = make_block_group(
8
+ "slack",
9
+ "slack",
10
+ help_text="Slack message blocks.\n\nBlock type: slack.",
11
+ crud_verbs=("create", "list", "get"),
12
+ execute_verb="send",
13
+ )
pm/commands/slides.py ADDED
@@ -0,0 +1,216 @@
1
+ """`pm slides` — create + run Google Slides blocks + advanced verbs.
2
+
3
+ A Slides block points at a Google Slides template via integration_id +
4
+ template_id; running it renders the template with placeholder data and
5
+ produces a new presentation.
6
+
7
+ NOTE: the backend stores these as ``block.type = "google_slides"`` — see
8
+ `prefect/queryme/noteflow/template_block/google_slides.py`. The CLI group
9
+ name is the short `slides`; the bound block_type is the full backend
10
+ name so create/run dispatch correctly.
11
+
12
+ Three custom verbs ship here:
13
+
14
+ - ``parse-template <uid>`` — hits the dedicated
15
+ ``POST /api/v1/block/<uid>/parse_slides_template`` endpoint. Parses a
16
+ master deck into template partials + sample composition_json,
17
+ persists them onto the block, returns them. Run this once after
18
+ creating a slides block from a master deck.
19
+
20
+ - ``export <uid> [--filename name.pdf]`` — convenience wrapper over
21
+ ``pm slides run``. Sets ``action="export_pdf"`` in the block's
22
+ content_json (temporarily, for this run only) so the executor renders
23
+ the existing presentation to PDF and uploads to S3.
24
+
25
+ - ``batch-update <uid> --stdin`` — convenience wrapper over
26
+ ``pm slides run``. Reads a JSON array of Google Slides batchUpdate
27
+ request objects from stdin and dispatches via
28
+ ``action="batch_update"``. Advanced escape hatch for arbitrary slide
29
+ mutations.
30
+
31
+ The ``export`` and ``batch-update`` verbs use a content-json-override
32
+ helper because the backend's run endpoint shallow-merges request.json
33
+ into block fields; partial overrides into content_json have to
34
+ fetch+merge+serialize CLI-side.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import sys
40
+ from typing import Any, Dict, Optional
41
+
42
+ import click
43
+
44
+ from pm.client import PMClient
45
+ from pm.commands._block_factory import make_block_group
46
+ from pm.commands._helpers import json_option
47
+ from pm.output import emit
48
+
49
+
50
+ # ── Helper: run with content_json overrides ──────────────────────────
51
+
52
+
53
+ def _run_with_content_overrides(
54
+ client: PMClient,
55
+ uid: str,
56
+ *,
57
+ overrides: Dict[str, Any],
58
+ ) -> Dict[str, Any]:
59
+ """Fetch the block, merge ``overrides`` into its content_json, and run.
60
+
61
+ The backend's `/run` handler shallow-merges request.json into block
62
+ fields (FAB's ``_merge_update_item``). For partial overrides INTO
63
+ content_json (not full content_json replacement), the CLI has to do
64
+ the merge itself: fetch → parse content_json → update → re-serialize
65
+ → send the merged content_json as a top-level field.
66
+
67
+ The merge is in-memory on the backend (the run handler reverts
68
+ block.content_json after dispatch), so the override is per-run only.
69
+ """
70
+ block = client.blocks.get(uid)
71
+ raw = block.content_json or "{}"
72
+ if isinstance(raw, str):
73
+ try:
74
+ current = json.loads(raw) if raw else {}
75
+ except json.JSONDecodeError as exc:
76
+ raise click.ClickException(
77
+ f"Block {uid} has invalid content_json — cannot merge "
78
+ f"overrides ({exc.msg})",
79
+ )
80
+ elif isinstance(raw, dict):
81
+ current = dict(raw)
82
+ else:
83
+ current = {}
84
+ current.update(overrides)
85
+ return client.blocks.execute_existing(
86
+ uid,
87
+ verb="run",
88
+ block_properties={
89
+ "content_json": json.dumps(current),
90
+ },
91
+ )
92
+
93
+
94
+ # ── parse-template ───────────────────────────────────────────────────
95
+
96
+
97
+ @click.command(name="parse-template")
98
+ @click.argument("uid")
99
+ @json_option
100
+ @click.pass_context
101
+ def parse_template(ctx: click.Context, uid: str) -> None:
102
+ """Read a master deck into reusable slide templates.
103
+
104
+ The block must name its source_file_id and integration_id. Run this
105
+ once after pm slides create; it returns the template definition and
106
+ a sample composition to start from.
107
+ """
108
+ client = PMClient.from_profile(ctx.obj["profile"])
109
+ result = client.blocks.parse_slides_template(uid)
110
+ client.persist()
111
+ emit(result, json_output=ctx.obj["json"])
112
+
113
+
114
+ # ── export ───────────────────────────────────────────────────────────
115
+
116
+
117
+ @click.command(name="export")
118
+ @click.argument("uid")
119
+ @click.option(
120
+ "--filename",
121
+ default=None,
122
+ help="Name of the PDF, e.g. q4-report.pdf (default: the presentation's name).",
123
+ )
124
+ @json_option
125
+ @click.pass_context
126
+ def export(
127
+ ctx: click.Context,
128
+ uid: str,
129
+ filename: Optional[str],
130
+ ) -> None:
131
+ """Export the block's presentation as a PDF.
132
+
133
+ Returns the download URL. The block must have been run at least once
134
+ so that it has a presentation.
135
+ """
136
+ overrides: Dict[str, Any] = {
137
+ "action": "export_pdf",
138
+ }
139
+ if filename:
140
+ overrides["export_options"] = {
141
+ "pdf_filename": filename,
142
+ }
143
+ client = PMClient.from_profile(ctx.obj["profile"])
144
+ result = _run_with_content_overrides(client, uid, overrides=overrides)
145
+ client.persist()
146
+ emit(result, json_output=ctx.obj["json"])
147
+
148
+
149
+ # ── batch-update ─────────────────────────────────────────────────────
150
+
151
+
152
+ @click.command(name="batch-update")
153
+ @click.argument("uid")
154
+ @click.option(
155
+ "--stdin",
156
+ "use_stdin",
157
+ is_flag=True,
158
+ required=True,
159
+ help=(
160
+ "Read the JSON body from stdin: an array of Google Slides "
161
+ "batchUpdate request objects."
162
+ ),
163
+ )
164
+ @json_option
165
+ @click.pass_context
166
+ def batch_update(
167
+ ctx: click.Context,
168
+ uid: str,
169
+ use_stdin: bool,
170
+ ) -> None:
171
+ """Apply Google Slides batchUpdate requests to the deck.
172
+
173
+ For edits the template flow does not cover. Requests are forwarded to
174
+ the Google Slides API; destructive request types are refused.
175
+ """
176
+ raw = sys.stdin.read()
177
+ if not raw.strip():
178
+ raise click.UsageError("--stdin was given but stdin was empty.")
179
+ try:
180
+ requests_list = json.loads(raw)
181
+ except json.JSONDecodeError as exc:
182
+ raise click.ClickException(f"--stdin: invalid JSON ({exc.msg})")
183
+ if not isinstance(requests_list, list):
184
+ raise click.ClickException(
185
+ f"--stdin must be a JSON array of batchUpdate request objects, "
186
+ f"got {type(requests_list).__name__}",
187
+ )
188
+
189
+ overrides: Dict[str, Any] = {
190
+ "action": "batch_update",
191
+ # The backend reads this list from `content.get("requests", [])`
192
+ # (google_slides.py:149). Previously sent under `requests_list`
193
+ # which silently dropped every batch update — codex catch.
194
+ "requests": requests_list,
195
+ }
196
+ client = PMClient.from_profile(ctx.obj["profile"])
197
+ result = _run_with_content_overrides(client, uid, overrides=overrides)
198
+ client.persist()
199
+ emit(result, json_output=ctx.obj["json"])
200
+
201
+
202
+ # ── Group ────────────────────────────────────────────────────────────
203
+
204
+
205
+ slides = make_block_group(
206
+ "slides",
207
+ "google_slides",
208
+ crud_verbs=("create", "list", "get"),
209
+ execute_verb="run",
210
+ help_text="Google Slides blocks.\n\nBlock type: google_slides.",
211
+ custom_verbs={
212
+ "parse-template": parse_template,
213
+ "export": export,
214
+ "batch-update": batch_update,
215
+ },
216
+ )
pm/commands/sql.py ADDED
@@ -0,0 +1,17 @@
1
+ """`pm sql` — create + run SQL blocks.
2
+
3
+ Per pm-tool plan §3.8. Domain group with the standard CRUD discovery
4
+ (``list`` / ``get``) plus the typed ``create`` and ``run`` verbs.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pm.commands._block_factory import make_block_group
9
+
10
+
11
+ sql = make_block_group(
12
+ "sql",
13
+ "sql",
14
+ help_text="SQL query blocks.\n\nBlock type: sql.",
15
+ crud_verbs=("create", "list", "get"),
16
+ execute_verb="run",
17
+ )
pm/commands/tableau.py ADDED
@@ -0,0 +1,148 @@
1
+ """`pm tableau` — create + run Tableau view blocks + discovery verbs.
2
+
3
+ A Tableau block points at a Tableau view via an integration_id + view_id;
4
+ running it exports/downloads the rendered view as PNG/PDF (or returns an
5
+ embed URL).
6
+
7
+ Standard create + run + results come from the factory. Two custom verbs
8
+ hit the Tableau integration API directly (no block needed) for discovery
9
+ before creating a block:
10
+
11
+ - ``pm tableau search`` — list views (default) or workbooks (``--workbooks``)
12
+ available through a Tableau integration, with optional ``--keyword`` and
13
+ ``--workbook-id`` filters.
14
+ - ``pm tableau view`` — fetch one view's metadata by ``--view-id`` (the
15
+ natural follow-up to search: drill into details before creating a block).
16
+
17
+ ``run`` help is overridden per codex R3 #6 so agents don't mistake it for
18
+ report execution.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import Optional
23
+
24
+ import click
25
+
26
+ from pm.client import PMClient
27
+ from pm.commands._block_factory import make_block_group
28
+ from pm.commands._helpers import json_option
29
+ from pm.output import emit
30
+
31
+
32
+ # ── search ────────────────────────────────────────────────────────────
33
+
34
+
35
+ @click.command(name="search")
36
+ @click.option(
37
+ "--integration",
38
+ "integration_uid",
39
+ required=True,
40
+ help="Tableau integration uid (from pm integration list).",
41
+ )
42
+ @click.option(
43
+ "--keyword",
44
+ default=None,
45
+ help="Only views (or workbooks) whose name contains this text.",
46
+ )
47
+ @click.option(
48
+ "--workbook-id",
49
+ default=None,
50
+ help="Only views in this Tableau workbook id. Ignored with --workbooks.",
51
+ )
52
+ @click.option(
53
+ "--workbooks",
54
+ is_flag=True,
55
+ help="Search workbooks instead of views.",
56
+ )
57
+ @json_option
58
+ @click.pass_context
59
+ def search(
60
+ ctx: click.Context,
61
+ integration_uid: str,
62
+ keyword: Optional[str],
63
+ workbook_id: Optional[str],
64
+ workbooks: bool,
65
+ ) -> None:
66
+ """Search the views or workbooks of a Tableau site.
67
+
68
+ Each result has an id, name and content URL; views also carry their
69
+ workbook id.
70
+ """
71
+ client = PMClient.from_profile(ctx.obj["profile"])
72
+ if workbooks:
73
+ results = client.tableau.list_workbooks(
74
+ integration_uid,
75
+ keyword=keyword,
76
+ )
77
+ else:
78
+ results = client.tableau.list_views(
79
+ integration_uid,
80
+ keyword=keyword,
81
+ workbook_id=workbook_id,
82
+ )
83
+ client.persist()
84
+ emit(
85
+ results,
86
+ json_output=ctx.obj["json"],
87
+ columns=[
88
+ ("ID", lambda r: r.get("id", "")),
89
+ ("NAME", lambda r: r.get("name", "")),
90
+ ("WORKBOOK", lambda r: r.get("workbook_id") or r.get("contentUrl") or ""),
91
+ ],
92
+ )
93
+
94
+
95
+ # ── view ──────────────────────────────────────────────────────────────
96
+
97
+
98
+ @click.command(name="view")
99
+ @click.option(
100
+ "--integration",
101
+ "integration_uid",
102
+ required=True,
103
+ help="Tableau integration uid (from pm integration list).",
104
+ )
105
+ @click.option(
106
+ "--view-id",
107
+ required=True,
108
+ help="Tableau view id (from pm tableau search).",
109
+ )
110
+ @json_option
111
+ @click.pass_context
112
+ def view(
113
+ ctx: click.Context,
114
+ integration_uid: str,
115
+ view_id: str,
116
+ ) -> None:
117
+ """Show one Tableau view.
118
+
119
+ The follow-up to tableau search before creating a Tableau block.
120
+ """
121
+ client = PMClient.from_profile(ctx.obj["profile"])
122
+ result = client.tableau.get_view(integration_uid, view_id)
123
+ client.persist()
124
+ emit(result, json_output=ctx.obj["json"])
125
+
126
+
127
+ # ── Group ────────────────────────────────────────────────────────────
128
+
129
+
130
+ tableau = make_block_group(
131
+ "tableau",
132
+ "tableau",
133
+ crud_verbs=("create", "list", "get"),
134
+ execute_verb="run",
135
+ help_text="Tableau view blocks.\n\nBlock type: tableau.",
136
+ verb_help={
137
+ "run": (
138
+ "Export a Tableau view as PNG, PDF or an embed URL.\n\n"
139
+ "Pass UID to render a saved Tableau block, or --stdin with a "
140
+ "body holding integration_id and view_id to render once "
141
+ "without saving."
142
+ ),
143
+ },
144
+ custom_verbs={
145
+ "search": search,
146
+ "view": view,
147
+ },
148
+ )
pm/commands/token.py ADDED
@@ -0,0 +1,107 @@
1
+ """Personal Access Token commands (`pm token create|list|revoke`).
2
+
3
+ Tokens authenticate the CLI / any programmatic client via
4
+ ``Authorization: Bearer pmpat_...`` (exchanged for a short-lived JWT). Manage
5
+ them here while logged in (cookie or token auth both work); store a created
6
+ token with `pm login --token`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ import click
13
+
14
+ from pm._alias import AliasedGroup
15
+ from pm.client import PMClient
16
+ from pm.commands._helpers import info, json_option, limit_option, name_filter_option, select_rows
17
+ from pm.output import emit
18
+
19
+
20
+ @click.group(
21
+ name="token",
22
+ cls=AliasedGroup,
23
+ aliases={
24
+ "ls": "list",
25
+ "rm": "revoke",
26
+ "delete": "revoke",
27
+ },
28
+ )
29
+ def token() -> None:
30
+ """Personal access tokens for API / CLI authentication."""
31
+
32
+
33
+ @token.command(name="list")
34
+ @name_filter_option
35
+ @limit_option
36
+ @json_option
37
+ @click.pass_context
38
+ def ls(ctx: click.Context, name_filter: Optional[str], limit: Optional[int]) -> None:
39
+ """List your tokens in the current workspace."""
40
+ profile = ctx.obj["profile"]
41
+ json_output = ctx.obj["json"]
42
+
43
+ client = PMClient.from_profile(profile)
44
+ items = select_rows(client.access_tokens.list(), name=name_filter, limit=limit)
45
+ client.persist()
46
+
47
+ emit(
48
+ items,
49
+ json_output=json_output,
50
+ columns=[
51
+ ("ID", "id"),
52
+ ("NAME", "name"),
53
+ ("PREFIX", "token_prefix"),
54
+ ("CREATED", "created_on"),
55
+ ("LAST_USED", "last_used_on"),
56
+ ("EXPIRES", "expires_on"),
57
+ ],
58
+ )
59
+
60
+
61
+ @token.command(name="create")
62
+ @click.argument("name")
63
+ @click.option(
64
+ "--expires-on",
65
+ default=None,
66
+ help="Expiry date, ISO 8601, e.g. 2026-12-31 or 2026-12-31T23:59:59 (default: none).",
67
+ )
68
+ @json_option
69
+ @click.pass_context
70
+ def create(ctx: click.Context, name: str, expires_on: str) -> None:
71
+ """Create a token; its value is shown once.
72
+
73
+ The token value goes to stdout so it can be captured or piped; the
74
+ reminder goes to stderr.
75
+ """
76
+ profile = ctx.obj["profile"]
77
+ json_output = ctx.obj["json"]
78
+
79
+ client = PMClient.from_profile(profile)
80
+ raw_token, meta = client.access_tokens.create(name, expires_on=expires_on)
81
+ client.persist()
82
+
83
+ if json_output:
84
+ payload = meta.model_dump()
85
+ payload["token"] = raw_token
86
+ emit(payload, json_output=True)
87
+ return
88
+
89
+ click.echo(raw_token)
90
+ info(ctx, "Copy your access token now. You won't be able to see this again.")
91
+ info(ctx, "Use it with: pm login --token (paste it at the hidden prompt). "
92
+ "In CI, set PM_API_TOKEN from the secret store.")
93
+
94
+
95
+ @token.command(name="revoke")
96
+ @click.argument("token_id")
97
+ @json_option
98
+ @click.pass_context
99
+ def revoke(ctx: click.Context, token_id: str) -> None:
100
+ """Revoke a token by its numeric ID (from pm token list)."""
101
+ profile = ctx.obj["profile"]
102
+
103
+ client = PMClient.from_profile(profile)
104
+ client.access_tokens.revoke(token_id)
105
+ client.persist()
106
+
107
+ info(ctx, f"Revoked token {token_id}.")