tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/cli/gen.py ADDED
@@ -0,0 +1,690 @@
1
+ """`OperationSpec` → `click.Command`. One factory, zero per-command modules.
2
+
3
+ This is where the registry pays for itself: the argument list, the flags, the
4
+ help text, the example epilogue, the pagination flags, the dry-run
5
+ short-circuit, the policy check and the rendering all come from the spec, so
6
+ none of them can be forgotten for one command and remembered for another —
7
+ which is exactly how v1 ended up honouring `--dry-run` in 9 commands and
8
+ ignoring it in 12 (COR-17).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import types
15
+ import typing
16
+ import uuid
17
+ from collections.abc import Callable, Iterator, Sequence
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Literal
20
+
21
+ import click
22
+ import msgspec
23
+
24
+ from tlgr.cli import errors as cli_errors
25
+ from tlgr.cli import params as ptypes
26
+ from tlgr.cli import render as renderer
27
+ from tlgr.cli.confirm import confirm
28
+ from tlgr.cli.globals import (
29
+ CliState,
30
+ add_global_options,
31
+ env_bool,
32
+ resolve_account,
33
+ state_from,
34
+ )
35
+ from tlgr.core.errors import (
36
+ EXIT_EMPTY,
37
+ EXIT_INDETERMINATE,
38
+ DaemonError,
39
+ PermissionError_,
40
+ UsageError,
41
+ )
42
+ from tlgr.core.pagination import DATE_OFFSET_KINDS
43
+ from tlgr.models.base import UNSET
44
+ from tlgr.models.peer import PeerRef
45
+ from tlgr.ops._params import cli_meta
46
+ from tlgr.ops._spec import OperationSpec, Surface
47
+ from tlgr.registry import REGISTRY, policy_allows
48
+
49
+ __all__ = [
50
+ "LocalContext",
51
+ "build_click_tree",
52
+ "build_command",
53
+ "run_live",
54
+ "run_op",
55
+ "set_dispatcher",
56
+ ]
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Execution
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ @dataclass
65
+ class LocalContext:
66
+ """The `OpContext` a `Surface.LOCAL` operation runs against."""
67
+
68
+ account: str = ""
69
+ dry_run: bool = False
70
+ request_id: str = ""
71
+ warnings: list[str] = field(default_factory=list)
72
+ command_tree: Callable[[Sequence[str], bool], dict[str, Any] | None] | None = None
73
+ #: `--limit`/`--cursor`/`--all` are transport-level and never request
74
+ #: fields (registry lint L5), so a local paginated operation reads them
75
+ #: off the context exactly as a daemon-side one does.
76
+ limit: int | None = None
77
+ cursor: str | None = None
78
+ fetch_all: bool = False
79
+
80
+ def warn(self, message: str) -> None:
81
+ self.warnings.append(message)
82
+
83
+ def emit(self, event_type: str, payload: dict[str, Any], **kwargs: Any) -> None:
84
+ """No-op: a local operation runs in the CLI, where there is no bus."""
85
+
86
+ def mark_already(self) -> None:
87
+ """No-op: a local operation has no envelope meta to flag."""
88
+
89
+ def mark_indeterminate(self, reason: str = "") -> None:
90
+ """No-op: a local operation answers from data it already holds."""
91
+
92
+
93
+ Dispatcher = Callable[[OperationSpec, msgspec.Struct, CliState], dict[str, Any]]
94
+ StreamDispatcher = Callable[[OperationSpec, msgspec.Struct, CliState], Iterator[dict[str, Any]]]
95
+ _dispatch: Dispatcher | None = None
96
+ _stream_dispatch: StreamDispatcher | None = None
97
+
98
+
99
+ def set_dispatcher(
100
+ dispatcher: Dispatcher | None, stream_dispatcher: StreamDispatcher | None = None
101
+ ) -> None:
102
+ """Install the daemon transport.
103
+
104
+ Stage A registers only local operations; the daemon surface arrives with
105
+ the transport, and until then asking for it is a daemon error rather than
106
+ a traceback.
107
+
108
+ The stream dispatcher is separate because a live stream cannot be folded
109
+ into an envelope: `watch` that only prints when it ends is not a watch.
110
+ """
111
+ global _dispatch, _stream_dispatch
112
+ _dispatch = dispatcher
113
+ _stream_dispatch = stream_dispatcher
114
+
115
+
116
+ def run_live(spec: OperationSpec, request: msgspec.Struct, state: CliState) -> int:
117
+ """Drive a `live-stream` operation, printing frames as they arrive."""
118
+ if state.enable_commands and not policy_allows(state.enable_commands, spec.id):
119
+ raise PermissionError_(
120
+ f"operation {spec.id!r} is not enabled (add it to --enable-commands to allow it)",
121
+ )
122
+ if _stream_dispatch is None:
123
+ raise DaemonError(f"{spec.id} streams from the daemon, and no transport is wired up")
124
+ state.account = resolve_account(state) if spec.needs_account else state.account
125
+ frames = _stream_dispatch(spec, request, state)
126
+ return renderer.render_stream(
127
+ frames,
128
+ fmt=state.fmt,
129
+ results_only=state.results_only,
130
+ select=state.select,
131
+ )
132
+
133
+
134
+ def _command_tree(path: Sequence[str], include_hidden: bool) -> dict[str, Any] | None:
135
+ """Describe the Click tree for `tlgr schema`, imported lazily.
136
+
137
+ `cli/__init__` imports this module, so reaching back into it has to happen
138
+ at call time rather than at import time.
139
+ """
140
+ from tlgr.cli.introspect import describe
141
+
142
+ return describe(tuple(path), include_hidden=include_hidden)
143
+
144
+
145
+ def run_op(spec: OperationSpec, request: msgspec.Struct, state: CliState) -> dict[str, Any]:
146
+ """Execute one operation and return its response envelope."""
147
+ if state.enable_commands and not policy_allows(state.enable_commands, spec.id):
148
+ raise PermissionError_(
149
+ f"operation {spec.id!r} is not enabled (add it to --enable-commands to allow it)",
150
+ )
151
+
152
+ # A local operation must not go looking for an account at all: reading the
153
+ # active alias to attach it to `tlgr schema` would be a lie about what ran.
154
+ account = resolve_account(state) if spec.needs_account else state.account
155
+ request_id = uuid.uuid4().hex
156
+
157
+ # The dry-run short-circuit lives here, before any implementation runs, so
158
+ # an operation cannot forget to honour it.
159
+ if state.dry_run and spec.mutating:
160
+ return {
161
+ "ok": True,
162
+ "op": spec.id,
163
+ "account": account or None,
164
+ "result": {"dry_run": True, "would": spec.id, "request": msgspec.to_builtins(request)},
165
+ "meta": {"request_id": request_id, "dry_run": True},
166
+ }
167
+
168
+ if spec.surface is Surface.DAEMON:
169
+ if _dispatch is None:
170
+ raise DaemonError(
171
+ f"{spec.id} runs in the daemon, and this build has no transport wired up yet"
172
+ )
173
+ return _dispatch(spec, request, state)
174
+
175
+ context = LocalContext(
176
+ account=account,
177
+ dry_run=state.dry_run,
178
+ request_id=request_id,
179
+ command_tree=_command_tree,
180
+ limit=state.limit,
181
+ cursor=state.cursor,
182
+ fetch_all=state.fetch_all,
183
+ )
184
+ result = asyncio.run(spec.impl(context, request))
185
+ body = msgspec.to_builtins(result)
186
+ envelope: dict[str, Any] = {
187
+ "ok": True,
188
+ "op": spec.id,
189
+ "result": body,
190
+ "meta": {"request_id": request_id, "warnings": context.warnings},
191
+ }
192
+ if spec.paginated is not None and isinstance(body, dict):
193
+ # The same projection the daemon does (`daemon/dispatch._envelope`).
194
+ # Without it a paginated *local* operation would hand back the page
195
+ # object where every other one hands back the items, and `--select`
196
+ # would need a different path depending on where the op happened to
197
+ # run — which is exactly the kind of difference the registry exists to
198
+ # remove.
199
+ envelope["result"] = body.get("items", [])
200
+ envelope["page"] = {
201
+ "has_more": bool(body.get("has_more")),
202
+ "next_cursor": body.get("next_cursor"),
203
+ "total": body.get("total"),
204
+ }
205
+ if account:
206
+ envelope["account"] = account
207
+ return envelope
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Field → Click parameter
212
+ # ---------------------------------------------------------------------------
213
+
214
+
215
+ def _unwrap(annotation: Any) -> tuple[Any, bool, bool]:
216
+ """Return (base type, optional?, unset?) for a request annotation."""
217
+ optional = False
218
+ unset = False
219
+ origin = typing.get_origin(annotation)
220
+ if origin is typing.Annotated:
221
+ return _unwrap(typing.get_args(annotation)[0])
222
+ if origin is typing.Union or origin is types.UnionType:
223
+ args = list(typing.get_args(annotation))
224
+ if type(None) in args:
225
+ optional = True
226
+ args = [a for a in args if a is not type(None)]
227
+ if msgspec.UnsetType in args:
228
+ unset = True
229
+ args = [a for a in args if a is not msgspec.UnsetType]
230
+ base, inner_optional, inner_unset = _unwrap(args[0]) if args else (str, False, False)
231
+ return base, optional or inner_optional, unset or inner_unset
232
+ return annotation, optional, unset
233
+
234
+
235
+ def _click_type(base: Any, kind: str, choices: Sequence[str]) -> Any:
236
+ if choices:
237
+ return click.Choice(list(choices))
238
+ typed = ptypes.for_kind(kind)
239
+ if typed is not None:
240
+ return typed
241
+ if base is PeerRef:
242
+ return ptypes.PEER
243
+ if typing.get_origin(base) is Literal:
244
+ return click.Choice([str(v) for v in typing.get_args(base)])
245
+ if base is int:
246
+ return click.INT
247
+ if base is float:
248
+ return click.FLOAT
249
+ if base is bool:
250
+ return click.BOOL
251
+ return click.STRING
252
+
253
+
254
+ def _flag_name(name: str) -> str:
255
+ return "--" + name.replace("_", "-")
256
+
257
+
258
+ def _describe(annotation: Any) -> str:
259
+ if typing.get_origin(annotation) is typing.Annotated:
260
+ for extra in typing.get_args(annotation)[1:]:
261
+ if isinstance(extra, msgspec.Meta) and extra.description:
262
+ return str(extra.description)
263
+ return ""
264
+
265
+
266
+ def _meta_of(annotation: Any) -> dict[str, Any]:
267
+ if typing.get_origin(annotation) is typing.Annotated:
268
+ for extra in typing.get_args(annotation)[1:]:
269
+ if isinstance(extra, msgspec.Meta):
270
+ return cli_meta(extra)
271
+ return {}
272
+
273
+
274
+ @dataclass(frozen=True)
275
+ class _Field:
276
+ name: str
277
+ annotation: Any
278
+ default: Any
279
+ required: bool
280
+ cli: dict[str, Any]
281
+ base: Any
282
+ optional: bool
283
+ unset: bool
284
+ container: Any
285
+
286
+
287
+ def _fields(request: type[msgspec.Struct]) -> list[_Field]:
288
+ hints = typing.get_type_hints(request, include_extras=True)
289
+ info = msgspec.inspect.type_info(request)
290
+ out: list[_Field] = []
291
+ for spec_field in getattr(info, "fields", ()):
292
+ annotation = hints[spec_field.name]
293
+ base, optional, unset = _unwrap(annotation)
294
+ container = typing.get_origin(base)
295
+ if container in (list, tuple, set):
296
+ args = typing.get_args(base)
297
+ base = args[0] if args else str
298
+ default = spec_field.default
299
+ if default is msgspec.NODEFAULT:
300
+ factory = spec_field.default_factory
301
+ default = factory() if factory is not msgspec.NODEFAULT else None
302
+ out.append(
303
+ _Field(
304
+ name=spec_field.name,
305
+ annotation=annotation,
306
+ default=None if default is UNSET else default,
307
+ required=spec_field.required,
308
+ cli=_meta_of(annotation),
309
+ base=base,
310
+ optional=optional,
311
+ unset=unset,
312
+ container=container,
313
+ )
314
+ )
315
+ return out
316
+
317
+
318
+ def _parameter(f: _Field) -> click.Parameter:
319
+ """One request field as one Click parameter (the §4.2 table)."""
320
+ help_text = _describe(f.annotation)
321
+ kind = str(f.cli.get("kind", ""))
322
+ choices = f.cli.get("choices") or ()
323
+ click_type = _click_type(f.base, kind, choices)
324
+
325
+ if f.cli.get("role") == "arg":
326
+ variadic = bool(f.cli.get("variadic"))
327
+ return click.Argument(
328
+ [f.name],
329
+ required=bool(f.cli.get("required", True)) and not variadic,
330
+ nargs=-1 if variadic else 1,
331
+ type=click_type,
332
+ # Click appends the `...` that says "repeatable" itself, but only
333
+ # when it derives the metavar; an explicit one loses it.
334
+ metavar=(f.cli.get("metavar") or None) if not variadic else None,
335
+ )
336
+
337
+ flags = list(f.cli.get("flags") or [])
338
+ if f.base is bool and not f.cli.get("count"):
339
+ # False default → a plain flag. True or tri-state → a paired flag,
340
+ # because "leave it alone" and "turn it off" are different requests.
341
+ # A default of True, or a tri-state, needs the negative half: "leave
342
+ # it alone" and "turn it off" are different requests, and only a
343
+ # paired flag can say both.
344
+ paired = f.default is True or (f.optional and f.default is None)
345
+ negative = f"--no-{f.name.replace('_', '-')}"
346
+ if not flags:
347
+ flags = [_flag_name(f.name)]
348
+ if paired and not any("/" in flag for flag in flags):
349
+ flags = [f"{flags[0]}/{negative}", *flags[1:]]
350
+ return click.Option(
351
+ [*flags, f.name],
352
+ is_flag=True,
353
+ default=f.default if not f.optional else None,
354
+ help=help_text or None,
355
+ hidden=bool(f.cli.get("hidden")),
356
+ )
357
+
358
+ if not flags:
359
+ flags = [_flag_name(f.name)]
360
+ # Click wants the long option first so it can derive the parameter name;
361
+ # `opt("-n", "--limit")` puts the short one first for readability.
362
+ ordered = sorted(flags, key=lambda flag: not flag.startswith("--"))
363
+ return click.Option(
364
+ [*ordered, f.name],
365
+ type=click_type,
366
+ default=f.default if not (f.unset or f.optional) else None,
367
+ required=f.required and not f.optional,
368
+ multiple=f.container in (list, tuple, set),
369
+ metavar=f.cli.get("metavar") or None,
370
+ envvar=f.cli.get("envvar") or None,
371
+ show_default=f.default not in (None, "", [], False),
372
+ help=help_text or None,
373
+ hidden=bool(f.cli.get("hidden")),
374
+ count=bool(f.cli.get("count")),
375
+ )
376
+
377
+
378
+ def _secret_options(f: _Field) -> list[click.Parameter]:
379
+ """A secret never gets a value-taking flag (STYLE §3)."""
380
+ base = f.name.replace("_", "-")
381
+ label = f.name.replace("_", " ")
382
+ return [
383
+ click.Option(
384
+ [f"--{base}-env", f"{f.name}_env"],
385
+ metavar="VAR",
386
+ default=None,
387
+ help=f"Read the {label} from this environment variable.",
388
+ ),
389
+ click.Option(
390
+ [f"--{base}-stdin", f"{f.name}_stdin"],
391
+ is_flag=True,
392
+ default=False,
393
+ help=f"Read the {label} from stdin.",
394
+ ),
395
+ click.Option(
396
+ [f"--{base}-file", f"{f.name}_file"],
397
+ metavar="PATH",
398
+ default=None,
399
+ help=f"Read the {label} from this file.",
400
+ ),
401
+ ]
402
+
403
+
404
+ def _pagination_options(
405
+ spec: OperationSpec, declared: frozenset[str] = frozenset()
406
+ ) -> list[click.Parameter]:
407
+ options: list[click.Parameter] = [
408
+ click.Option(
409
+ ["-n", "--limit", "limit"], type=int, default=None, help="Maximum items to return."
410
+ ),
411
+ click.Option(
412
+ ["--cursor", "cursor"], default=None, metavar="TOKEN", help="Continue a page."
413
+ ),
414
+ click.Option(["--all", "fetch_all"], is_flag=True, default=False, help="Walk every page."),
415
+ ]
416
+ if spec.paginated in DATE_OFFSET_KINDS:
417
+ # Injected only when the op does not declare them itself. An op that
418
+ # does needs them *inside* its request — the injected pair is
419
+ # transport-level and would be dropped on the way to the daemon, so
420
+ # two parameters with one name would silently shadow the real one.
421
+ options += [
422
+ click.Option(
423
+ ["--since", "since"], type=ptypes.DATETIME, default=None, help="Only after this."
424
+ )
425
+ for name in ("since",)
426
+ if name not in declared
427
+ ]
428
+ options += [
429
+ click.Option(
430
+ ["--until", "until"], type=ptypes.DATETIME, default=None, help="Only before this."
431
+ )
432
+ for name in ("until",)
433
+ if name not in declared
434
+ ]
435
+ return options
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # Command assembly
440
+ # ---------------------------------------------------------------------------
441
+
442
+
443
+ def _build_request(spec: OperationSpec, fields: list[_Field], values: dict[str, Any]) -> Any:
444
+ kwargs: dict[str, Any] = {}
445
+ for f in fields:
446
+ if f.cli.get("secret"):
447
+ from tlgr.ops._params import read_secret
448
+
449
+ secret = read_secret(
450
+ f.name,
451
+ env=values.get(f"{f.name}_env"),
452
+ stdin=bool(values.get(f"{f.name}_stdin")),
453
+ file=values.get(f"{f.name}_file"),
454
+ default_env=str(f.cli.get("envvar") or ""),
455
+ )
456
+ if secret is not None:
457
+ kwargs[f.name] = secret
458
+ continue
459
+
460
+ if f.name not in values:
461
+ continue
462
+ value = values[f.name]
463
+ if value is None:
464
+ # An absent option on an Unset field sends nothing at all, which
465
+ # is what makes "leave alone" expressible.
466
+ continue
467
+ if f.container in (list, tuple, set):
468
+ if not value:
469
+ continue
470
+ value = list(value) if f.container is not tuple else tuple(value)
471
+ kwargs[f.name] = value
472
+ try:
473
+ request = spec.request(**kwargs)
474
+ # Constructing a Struct does not run msgspec's constraints (ge, le,
475
+ # pattern, min_length) — only decoding does. Round-tripping is what
476
+ # makes `--limit-hint 500` fail in the CLI instead of in the daemon.
477
+ return msgspec.convert(msgspec.to_builtins(request), type=spec.request)
478
+ except (msgspec.ValidationError, TypeError, ValueError) as exc:
479
+ field = None
480
+ message = str(exc)
481
+ if " - at `$." in message:
482
+ field = message.split(" - at `$.", 1)[1].rstrip("`")
483
+ raise UsageError(message, field=field) from exc
484
+
485
+
486
+ def build_command(
487
+ spec: OperationSpec, *, name: str | None = None, hidden: bool = False
488
+ ) -> click.Command:
489
+ """Turn one spec into one Click command."""
490
+ fields = _fields(spec.request)
491
+ parameters: list[click.Parameter] = []
492
+ positional = sorted(
493
+ (f for f in fields if f.cli.get("role") == "arg"), key=lambda f: int(f.cli.get("pos", 0))
494
+ )
495
+ for f in positional:
496
+ parameters.append(_parameter(f))
497
+ for f in fields:
498
+ if f.cli.get("role") == "arg":
499
+ continue
500
+ parameters.extend(_secret_options(f) if f.cli.get("secret") else [_parameter(f)])
501
+
502
+ if spec.paginated is not None:
503
+ parameters.extend(_pagination_options(spec, frozenset(f.name for f in fields)))
504
+ parameters.append(
505
+ # `-n` belongs to --limit here, so --dry-run gets no short form.
506
+ click.Option(["--dry-run"], is_flag=True, default=None, help="Do not actually do it.")
507
+ )
508
+ else:
509
+ parameters.append(
510
+ click.Option(
511
+ ["--dry-run", "-n"], is_flag=True, default=None, help="Do not actually do it."
512
+ )
513
+ )
514
+
515
+ def callback(**values: Any) -> None:
516
+ ctx = click.get_current_context()
517
+ state = state_from(ctx, values)
518
+ if spec.destructive and not state.dry_run:
519
+ confirm(
520
+ f"{spec.summary} — this cannot be undone.",
521
+ force=state.force,
522
+ no_input=state.no_input,
523
+ hint=f"pass --yes to confirm {spec.id}",
524
+ )
525
+ request = _build_request(spec, fields, values)
526
+ if "live-stream" in spec.tags:
527
+ # No envelope: the frames *are* the output, and they arrive over
528
+ # minutes or hours.
529
+ ctx.exit(run_live(spec, request, state))
530
+ envelope = run_op(spec, request, state)
531
+ # A schema document is JSON whether or not anybody asked: there is no
532
+ # table shape for it, and v1 printed JSON here unconditionally. When
533
+ # nobody asked for --json it is also printed bare, which is the exact
534
+ # document v1 wrote; the envelope appears only once JSON is requested.
535
+ json_only = "json-only" in spec.tags
536
+ fmt = "json" if json_only else state.fmt
537
+ if "text" in spec.tags and fmt != "json":
538
+ # An op tagged `text` produces one blob meant to be piped or
539
+ # pasted — a completion script, a key, a certificate. A key/value
540
+ # table of one very long cell is technically a rendering and
541
+ # practically unusable, so the `text` field is printed verbatim.
542
+ body = envelope.get("result") or {}
543
+ click.echo(body.get("text", "") if isinstance(body, dict) else body)
544
+ return
545
+ renderer.render(
546
+ envelope,
547
+ fmt=fmt,
548
+ results_only=state.results_only or (json_only and state.fmt != "json"),
549
+ select=state.select,
550
+ spec_columns=spec.columns,
551
+ headers=spec.headers,
552
+ columns=state.columns,
553
+ wide=state.wide,
554
+ no_header=state.no_header,
555
+ )
556
+ # An operation that could not *establish* its answer still returns
557
+ # the partial result; the non-zero status is what makes a caller
558
+ # gating on it fail closed rather than read "unknown" as "no".
559
+ if (envelope.get("meta") or {}).get("indeterminate"):
560
+ ctx.exit(EXIT_INDETERMINATE)
561
+ result = envelope.get("result")
562
+ if spec.empty_exit == EXIT_EMPTY and not result:
563
+ ctx.exit(EXIT_EMPTY)
564
+
565
+ command = OpCommand(
566
+ spec,
567
+ name=name or spec.verb,
568
+ params=parameters,
569
+ callback=callback,
570
+ help=_help_text(spec),
571
+ short_help=spec.summary,
572
+ epilog=_epilog(spec),
573
+ hidden=hidden or bool(spec.deprecated),
574
+ )
575
+ return add_global_options(command)
576
+
577
+
578
+ class OpCommand(click.Command):
579
+ """A generated command that reports its own failures.
580
+
581
+ Errors are caught here rather than in the root group so that the new
582
+ rules — the `{"ok": false, "error": {...}}` envelope, JSON usage errors
583
+ (UX-02), the exit code from the §7.2 table — apply to registry-generated
584
+ commands only. Every unmigrated v1 command keeps v1's error output until
585
+ its own group PR moves it.
586
+ """
587
+
588
+ def __init__(self, spec: OperationSpec, *args: Any, **kwargs: Any) -> None:
589
+ super().__init__(*args, **kwargs)
590
+ self.spec = spec
591
+
592
+ def _wants_json(self, ctx: click.Context | None, args: Sequence[str] = ()) -> bool:
593
+ if "--json" in args:
594
+ return True
595
+ if "--plain" in args:
596
+ return False
597
+ obj = (ctx.obj if ctx is not None else None) or {}
598
+ return bool(obj.get("json") or obj.get("use_json")) or env_bool("TLGR_JSON")
599
+
600
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
601
+ try:
602
+ return super().parse_args(ctx, list(args))
603
+ except click.UsageError as exc:
604
+ if not self._wants_json(ctx, args):
605
+ raise
606
+ # v1 let Click print English to stderr and exited 2 with no JSON
607
+ # at all, which is UX-02: an agent got nothing to parse.
608
+ ctx.exit(cli_errors.handle(exc, use_json=True, op=self.spec.id))
609
+ raise
610
+
611
+ def invoke(self, ctx: click.Context) -> Any:
612
+ try:
613
+ return super().invoke(ctx)
614
+ except (click.exceptions.Exit, click.Abort):
615
+ raise
616
+ except KeyboardInterrupt as exc:
617
+ ctx.exit(cli_errors.handle(exc, use_json=False, op=self.spec.id))
618
+ except BaseException as exc:
619
+ use_json = self._wants_json(ctx) or bool(ctx.params.get("use_json"))
620
+ ctx.exit(cli_errors.handle(exc, use_json=use_json, op=self.spec.id))
621
+
622
+
623
+ def _help_text(spec: OperationSpec) -> str:
624
+ body = spec.summary.rstrip(".") + "."
625
+ if spec.description:
626
+ body += "\n\n" + spec.description
627
+ if spec.deprecated:
628
+ body += f"\n\nDEPRECATED: {spec.deprecated}"
629
+ return body
630
+
631
+
632
+ def _epilog(spec: OperationSpec) -> str:
633
+ if not spec.example_args:
634
+ return ""
635
+ return f"Example:\n\n tlgr {spec.example_args}"
636
+
637
+
638
+ # ---------------------------------------------------------------------------
639
+ # The tree
640
+ # ---------------------------------------------------------------------------
641
+
642
+
643
+ def _place(root: dict[str, Any], path: Sequence[str], command: click.Command) -> None:
644
+ """Attach *command* at *path*, creating the groups it needs on the way."""
645
+ *groups, leaf = path
646
+ container: Any = root
647
+ walked: list[str] = []
648
+ for name in groups:
649
+ walked.append(name)
650
+ existing = (
651
+ container.get(name) if isinstance(container, dict) else container.commands.get(name)
652
+ )
653
+ if existing is None or not isinstance(existing, click.Group):
654
+ existing = click.Group(name=name, help=f"{' '.join(walked)} operations.")
655
+ if isinstance(container, dict):
656
+ container[name] = existing
657
+ else:
658
+ container.add_command(existing, name)
659
+ container = existing
660
+ command.name = leaf
661
+ if isinstance(container, dict):
662
+ container[leaf] = command
663
+ else:
664
+ container.add_command(command, leaf)
665
+
666
+
667
+ def build_click_tree(
668
+ registry: dict[str, OperationSpec] | None = None,
669
+ ) -> dict[str, click.Command]:
670
+ """Every registered operation as a nested Click tree, keyed by top-level name.
671
+
672
+ Canonical paths are visible. Aliases are hidden duplicates — they exist so
673
+ that a habit keeps working, not so that `--help` lists the same command
674
+ twice. `legacy_paths` stay visible: they are the v1 paths the docs name,
675
+ and §12.4 promises none of them disappears.
676
+ """
677
+ specs = (registry or REGISTRY).values()
678
+ root: dict[str, Any] = {}
679
+
680
+ for spec in sorted(specs, key=lambda s: s.id):
681
+ _place(root, spec.path, build_command(spec))
682
+ for spec in sorted(specs, key=lambda s: s.id):
683
+ for alias in spec.aliases:
684
+ _place(root, tuple(alias.split(".")), build_command(spec, hidden=True))
685
+ for legacy in spec.legacy_paths:
686
+ path = tuple(legacy.replace(".", " ").split())
687
+ if path == spec.path:
688
+ continue
689
+ _place(root, path, build_command(spec))
690
+ return root