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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """tlgr — Full Telegram account control CLI."""
2
+
3
+ __version__ = "2.0.1"
tlgr/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running as python -m tlgr."""
2
+
3
+ from tlgr.cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -0,0 +1,45 @@
1
+ """Registry-based actions for the Gateway pipeline.
2
+
3
+ Every action is an async function registered via ``@register_action``.
4
+ Actions receive an :class:`~tlgr.gateway.event.Event`, the action's config
5
+ from YAML, a :class:`~tlgr.jobs.client.JobClient`, and an optional
6
+ :class:`~tlgr.processors.ProcessorChain`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Awaitable, Callable
12
+ from typing import Any
13
+
14
+ from tlgr.gateway.event import Event
15
+ from tlgr.jobs.client import JobClient
16
+ from tlgr.processors import ProcessorChain
17
+
18
+ ActionFunc = Callable[
19
+ [Event, Any, JobClient, ProcessorChain | None],
20
+ Awaitable[None],
21
+ ]
22
+
23
+ _REGISTRY: dict[str, ActionFunc] = {}
24
+
25
+
26
+ def register_action(name: str):
27
+ """Decorator that registers an action function under *name*."""
28
+
29
+ def decorator(func: ActionFunc) -> ActionFunc:
30
+ _REGISTRY[name] = func
31
+ return func
32
+
33
+ return decorator
34
+
35
+
36
+ def get_action(name: str) -> ActionFunc | None:
37
+ return _REGISTRY.get(name)
38
+
39
+
40
+ def list_actions() -> list[str]:
41
+ return list(_REGISTRY.keys())
42
+
43
+
44
+ # Import built-in action modules so they self-register.
45
+ from tlgr.actions import forward, reply # noqa: E402, F401
@@ -0,0 +1,74 @@
1
+ """Forward action — relay messages to one or more destinations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Any
8
+
9
+ from telethon import errors
10
+
11
+ from tlgr.actions import register_action
12
+ from tlgr.filters.message import is_forwardable
13
+ from tlgr.gateway.event import Event
14
+ from tlgr.jobs.client import JobClient
15
+ from tlgr.processors import ProcessorChain
16
+
17
+ log = logging.getLogger("tlgr.actions.forward")
18
+
19
+
20
+ @register_action("forward")
21
+ async def action_forward(
22
+ event: Event,
23
+ config: Any,
24
+ client: JobClient,
25
+ chain: ProcessorChain | None = None,
26
+ ) -> None:
27
+ if event.source != "telegram":
28
+ log.warning("forward action only supports telegram events")
29
+ return
30
+
31
+ message = event.raw.message
32
+
33
+ ok, reason = is_forwardable(message)
34
+ if not ok:
35
+ log.debug("message not forwardable: %s", reason)
36
+ return
37
+
38
+ if isinstance(config, str):
39
+ destinations = [config]
40
+ drop_author = False
41
+ elif isinstance(config, dict):
42
+ to = config.get("to", [])
43
+ destinations = to if isinstance(to, list) else [to]
44
+ drop_author = config.get("drop_author", False)
45
+ else:
46
+ log.warning("invalid forward config: %r", config)
47
+ return
48
+
49
+ for i, dest_ref in enumerate(destinations):
50
+ try:
51
+ dest_id = await client.resolve_chat(dest_ref)
52
+
53
+ if chain:
54
+ original = message.text or getattr(message, "message", "") or ""
55
+ transformed = chain.apply(original) if original else ""
56
+ if message.media:
57
+ await client.client.send_file(dest_id, message.media, caption=transformed)
58
+ else:
59
+ await client.client.send_message(dest_id, transformed)
60
+ else:
61
+ await client.client.forward_messages(
62
+ dest_id,
63
+ message,
64
+ drop_author=drop_author,
65
+ )
66
+ except errors.ChatWriteForbiddenError:
67
+ log.warning("cannot write to %s", dest_ref)
68
+ except errors.ChannelPrivateError:
69
+ log.warning("channel %s is private", dest_ref)
70
+ except Exception as e:
71
+ log.error("forward to %s failed: %s", dest_ref, e)
72
+
73
+ if i < len(destinations) - 1:
74
+ await asyncio.sleep(0.3)
tlgr/actions/reply.py ADDED
@@ -0,0 +1,32 @@
1
+ """Reply action — send a static text reply to the triggering message."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from tlgr.actions import register_action
9
+ from tlgr.gateway.event import Event
10
+ from tlgr.jobs.client import JobClient
11
+ from tlgr.processors import ProcessorChain
12
+
13
+ log = logging.getLogger("tlgr.actions.reply")
14
+
15
+
16
+ @register_action("reply")
17
+ async def action_reply(
18
+ event: Event,
19
+ config: Any,
20
+ client: JobClient,
21
+ chain: ProcessorChain | None = None,
22
+ ) -> None:
23
+ if event.source != "telegram":
24
+ log.warning("reply action only supports telegram events")
25
+ return
26
+
27
+ reply_text = str(config) if isinstance(config, str) else config.get("text", str(config))
28
+
29
+ if chain:
30
+ reply_text = chain.apply(reply_text)
31
+
32
+ await event.raw.reply(reply_text)
tlgr/cli/__init__.py ADDED
@@ -0,0 +1,259 @@
1
+ """CLI entry point using Click with nested command groups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ import click
9
+
10
+ from tlgr import __version__
11
+ from tlgr.core.errors import TlgrError, emit_error, exit_code_for
12
+
13
+
14
+ def _env_bool(key: str) -> bool:
15
+ return os.environ.get(key, "").lower() in ("1", "true", "yes", "y", "on")
16
+
17
+
18
+ def _env_or(key: str, fallback: str) -> str:
19
+ return os.environ.get(key, "") or fallback
20
+
21
+
22
+ def _registry_op(cmd_name: str, rest: list[str]) -> str | None:
23
+ """The canonical op id an invocation resolves to, if the registry owns it."""
24
+ from tlgr.registry import ALIASES
25
+
26
+ candidates = [cmd_name]
27
+ if rest and not rest[0].startswith("-"):
28
+ candidates.append(f"{cmd_name}.{rest[0]}")
29
+ for candidate in reversed(candidates):
30
+ found = ALIASES.get(candidate)
31
+ if found is not None:
32
+ return found
33
+ return None
34
+
35
+
36
+ class TlgrGroup(click.Group):
37
+ """Custom group that handles errors, sandboxing, and output formatting."""
38
+
39
+ def invoke(self, ctx: click.Context) -> None:
40
+ try:
41
+ super().invoke(ctx)
42
+ except TlgrError as e:
43
+ use_json = ctx.params.get("json") or (ctx.obj and ctx.obj.get("json"))
44
+ emit_error(e, use_json=bool(use_json))
45
+ sys.exit(exit_code_for(e))
46
+ except (click.ClickException, click.exceptions.Exit, SystemExit):
47
+ raise
48
+ except KeyboardInterrupt:
49
+ sys.exit(130)
50
+ except Exception as e:
51
+ use_json = ctx.params.get("json") or (ctx.obj and ctx.obj.get("json"))
52
+ emit_error(e, use_json=bool(use_json))
53
+ sys.exit(1)
54
+
55
+ def resolve_command(self, ctx: click.Context, args: list[str]) -> tuple:
56
+ """Override to enforce --enable-commands before dispatching."""
57
+ cmd_name, cmd, rest = super().resolve_command(ctx, args)
58
+
59
+ enabled = ctx.params.get("enable_commands") or ""
60
+ enabled = enabled.strip()
61
+
62
+ # A registry-generated command enforces the allowlist itself, by
63
+ # canonical op id, so `--enable-commands agent.exit-codes` also allows
64
+ # the `exit-codes` alias (SEC-04). The path matching below is v1's and
65
+ # stays only for the groups that are still hand-written.
66
+ if enabled and cmd_name and _registry_op(cmd_name, rest) is not None:
67
+ return cmd_name, cmd, rest
68
+
69
+ if enabled and cmd_name:
70
+ allow = {p.strip().lower() for p in enabled.split(",") if p.strip()}
71
+ if allow and "*" not in allow and "all" not in allow:
72
+ name_l = cmd_name.lower()
73
+ group_allowed = name_l in allow
74
+ has_sub_rules = any(a.startswith(f"{name_l}.") for a in allow)
75
+ if not group_allowed and not has_sub_rules:
76
+ click.echo(
77
+ f"Error: command {cmd_name!r} is not enabled "
78
+ f"(set --enable-commands to allow it)",
79
+ err=True,
80
+ )
81
+ sys.exit(2)
82
+ if has_sub_rules and isinstance(cmd, click.Group) and rest:
83
+ sub_name = rest[0] if rest else None
84
+ if sub_name and not group_allowed:
85
+ full_path = f"{name_l}.{sub_name.lower()}"
86
+ if full_path not in allow:
87
+ click.echo(
88
+ f"Error: command {full_path!r} is not enabled "
89
+ f"(set --enable-commands to allow it)",
90
+ err=True,
91
+ )
92
+ sys.exit(2)
93
+
94
+ return cmd_name, cmd, rest
95
+
96
+
97
+ @click.group(cls=TlgrGroup)
98
+ @click.version_option(__version__, prog_name="tlgr")
99
+ @click.option(
100
+ "--json",
101
+ "use_json",
102
+ is_flag=True,
103
+ default=_env_bool("TLGR_JSON"),
104
+ help="Output JSON to stdout.",
105
+ )
106
+ @click.option(
107
+ "--plain",
108
+ "use_plain",
109
+ is_flag=True,
110
+ default=_env_bool("TLGR_PLAIN"),
111
+ help="Output stable TSV for piping.",
112
+ )
113
+ @click.option(
114
+ "--account",
115
+ "-a",
116
+ default=_env_or("TLGR_ACCOUNT", ""),
117
+ help="Account alias to use.",
118
+ )
119
+ @click.option(
120
+ "--enable-commands",
121
+ default=_env_or("TLGR_ENABLE_COMMANDS", ""),
122
+ help="Comma-separated allowlist of commands (e.g. 'message.send,chat.list').",
123
+ )
124
+ @click.option(
125
+ "--results-only",
126
+ is_flag=True,
127
+ default=False,
128
+ help="In JSON mode, emit only the primary result (strip envelope).",
129
+ )
130
+ @click.option(
131
+ "--select",
132
+ "select_fields",
133
+ default=None,
134
+ help="In JSON mode, select comma-separated fields (supports dot paths).",
135
+ )
136
+ @click.option(
137
+ "--dry-run",
138
+ "-n",
139
+ is_flag=True,
140
+ default=False,
141
+ help="Preview destructive operations without executing.",
142
+ )
143
+ @click.option(
144
+ "--force",
145
+ "-y",
146
+ is_flag=True,
147
+ default=False,
148
+ help="Skip confirmations for destructive commands.",
149
+ )
150
+ @click.option(
151
+ "--flood-wait-max",
152
+ type=int,
153
+ default=None,
154
+ help="Max seconds to auto-sleep on rate limit (default from config).",
155
+ )
156
+ @click.option(
157
+ "--no-input",
158
+ is_flag=True,
159
+ default=False,
160
+ help="Never prompt; fail instead (CI/agent mode).",
161
+ )
162
+ @click.option(
163
+ "--verbose",
164
+ "-v",
165
+ is_flag=True,
166
+ default=False,
167
+ help="Enable verbose logging to stderr.",
168
+ )
169
+ @click.pass_context
170
+ def cli(
171
+ ctx: click.Context,
172
+ use_json: bool,
173
+ use_plain: bool,
174
+ account: str | None,
175
+ enable_commands: str,
176
+ results_only: bool,
177
+ select_fields: str | None,
178
+ dry_run: bool,
179
+ flood_wait_max: int | None,
180
+ force: bool,
181
+ no_input: bool,
182
+ verbose: bool,
183
+ ) -> None:
184
+ """tlgr — Full Telegram account control CLI."""
185
+ ctx.ensure_object(dict)
186
+
187
+ # TLGR_AUTO_JSON: default to JSON when stdout is piped and env var is set
188
+ if _env_bool("TLGR_AUTO_JSON") and not use_json and not use_plain:
189
+ if not sys.stdout.isatty():
190
+ use_json = True
191
+
192
+ if use_json and use_plain:
193
+ click.echo("Error: cannot combine --json and --plain", err=True)
194
+ sys.exit(2)
195
+
196
+ if use_json:
197
+ ctx.obj["fmt"] = "json"
198
+ elif use_plain:
199
+ ctx.obj["fmt"] = "plain"
200
+ else:
201
+ ctx.obj["fmt"] = "human"
202
+
203
+ ctx.obj["json"] = use_json
204
+ ctx.obj["account"] = account or ""
205
+ ctx.obj["enable_commands"] = enable_commands
206
+ ctx.obj["results_only"] = results_only
207
+ ctx.obj["select"] = select_fields
208
+ ctx.obj["dry_run"] = dry_run
209
+ # Every command threads this through its own request body now: the
210
+ # generated dispatcher passes `flood_wait_max` on every `/v1/op` call, so
211
+ # the transport-level default the hand-written v1 commands needed (COR-15)
212
+ # went with them.
213
+ ctx.obj["flood_wait_max"] = flood_wait_max
214
+ ctx.obj["force"] = force
215
+ ctx.obj["no_input"] = no_input
216
+ ctx.obj["verbose"] = verbose
217
+
218
+ if verbose:
219
+ import logging
220
+
221
+ logging.basicConfig(
222
+ level=logging.DEBUG, stream=sys.stderr, format="%(levelname)s: %(message)s"
223
+ )
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Import and register sub-groups
228
+ # ---------------------------------------------------------------------------
229
+
230
+ from tlgr.cli.gen import build_click_tree # noqa: E402
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # The generated tree
234
+ # ---------------------------------------------------------------------------
235
+
236
+
237
+ def build_cli() -> click.Group:
238
+ """Install the transport and attach the generated command tree.
239
+
240
+ Every command comes from the registry now. PR-12 deleted the last
241
+ hand-written group, and with it the merge that let a v1 command and a
242
+ generated one share a name: there is one source for what `tlgr` can do,
243
+ so there is nothing left to reconcile.
244
+ """
245
+ import tlgr.ops # noqa: F401 — importing it is what populates the registry
246
+ from tlgr.cli.gen import set_dispatcher
247
+ from tlgr.transport import make_dispatcher, make_stream_dispatcher
248
+
249
+ # Installing the transport here, rather than importing it in `gen.py`, is
250
+ # what keeps `cli/gen.py` testable with a fake dispatcher and keeps the
251
+ # daemon out of the CLI's import graph.
252
+ set_dispatcher(make_dispatcher(), make_stream_dispatcher())
253
+
254
+ for name, command in build_click_tree().items():
255
+ cli.add_command(command, name)
256
+ return cli
257
+
258
+
259
+ build_cli()
tlgr/cli/confirm.py ADDED
@@ -0,0 +1,55 @@
1
+ """One confirmation path for every destructive operation.
2
+
3
+ v1 asked in some commands, ignored `--no-input` in others, and would block
4
+ forever on a prompt in a pipeline (COR-16). The rules here are absolute:
5
+
6
+ * on a TTY, a destructive op prompts unless `--yes`;
7
+ * off a TTY it *requires* `--yes` and otherwise fails with USAGE (exit 2);
8
+ * `--no-input` never prompts and never blocks, whatever the TTY says.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from typing import Any
15
+
16
+ import click
17
+
18
+ from tlgr.core.errors import UsageError
19
+
20
+ __all__ = ["confirm"]
21
+
22
+
23
+ def _is_tty(stream: Any = None) -> bool:
24
+ stream = stream or sys.stdin
25
+ try:
26
+ return bool(stream.isatty())
27
+ except (AttributeError, ValueError):
28
+ return False
29
+
30
+
31
+ def confirm(
32
+ prompt: str,
33
+ *,
34
+ force: bool = False,
35
+ no_input: bool = False,
36
+ tty: bool | None = None,
37
+ hint: str = "",
38
+ ) -> bool:
39
+ """Ask, or decide without asking. Raises UsageError when it cannot ask."""
40
+ if force:
41
+ return True
42
+
43
+ interactive = _is_tty() if tty is None else tty
44
+ if no_input or not interactive:
45
+ raise UsageError(
46
+ f"{prompt} — refusing to continue without --yes"
47
+ + (f" ({hint})" if hint else "")
48
+ + (
49
+ ". stdin is not a terminal, so there is nobody to ask."
50
+ if not interactive
51
+ else ". --no-input was given."
52
+ ),
53
+ field="yes",
54
+ )
55
+ return bool(click.confirm(prompt, default=False))
tlgr/cli/errors.py ADDED
@@ -0,0 +1,84 @@
1
+ """Turning an exception into output and an exit status, once.
2
+
3
+ Two rules from §9:
4
+
5
+ * in JSON mode the error object goes to **stdout**, so an agent parsing
6
+ stdout always gets JSON, and a one-line summary goes to stderr;
7
+ * a Click usage error is formatted the same way as any other error in JSON
8
+ mode — v1 let Click print its own English to stderr and exited 2 with no
9
+ JSON at all, which is UX-02.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from typing import Any
16
+
17
+ import click
18
+
19
+ from tlgr.core.errors import (
20
+ EXIT_CANCELLED,
21
+ EXIT_USAGE,
22
+ classify,
23
+ error_body_dict,
24
+ exit_code_for,
25
+ )
26
+
27
+ __all__ = ["emit", "exit_status", "handle"]
28
+
29
+
30
+ def _usage_body(exc: click.UsageError) -> dict[str, Any]:
31
+ body: dict[str, Any] = {
32
+ "code": "USAGE",
33
+ "message": exc.format_message(),
34
+ "error": exc.format_message(),
35
+ "exit_code": EXIT_USAGE,
36
+ }
37
+ if exc.ctx is not None:
38
+ body["usage"] = exc.ctx.get_usage()
39
+ body["command"] = exc.ctx.command_path
40
+ parameter = getattr(exc, "param", None)
41
+ if parameter is not None and getattr(parameter, "name", None):
42
+ body["field"] = parameter.name
43
+ return body
44
+
45
+
46
+ def body_for(exc: BaseException) -> dict[str, Any]:
47
+ """The error object printed in JSON mode."""
48
+ if isinstance(exc, click.UsageError):
49
+ return _usage_body(exc)
50
+ return error_body_dict(classify(exc))
51
+
52
+
53
+ def exit_status(exc: BaseException) -> int:
54
+ if isinstance(exc, KeyboardInterrupt):
55
+ return EXIT_CANCELLED
56
+ if isinstance(exc, click.UsageError):
57
+ return exc.exit_code or EXIT_USAGE
58
+ return exit_code_for(exc) if hasattr(exc, "exit_code") else classify(exc).exit_code
59
+
60
+
61
+ def emit(exc: BaseException, *, use_json: bool = False, op: str = "") -> None:
62
+ """Write the error out: JSON to stdout when asked, a line to stderr always."""
63
+ body = body_for(exc)
64
+ if use_json:
65
+ import json
66
+
67
+ envelope: dict[str, Any] = {"ok": False, "error": body}
68
+ if op:
69
+ envelope["op"] = op
70
+ json.dump(envelope, sys.stdout, default=str, ensure_ascii=False)
71
+ sys.stdout.write("\n")
72
+ sys.stdout.flush()
73
+
74
+ message = body.get("message") or str(exc)
75
+ hint = body.get("hint") or getattr(exc, "hint", "")
76
+ print(f"Error: {message}", file=sys.stderr)
77
+ if hint:
78
+ print(f" {hint}", file=sys.stderr)
79
+
80
+
81
+ def handle(exc: BaseException, *, use_json: bool = False, op: str = "") -> int:
82
+ """Emit *exc* and return the status the process should exit with."""
83
+ emit(exc, use_json=use_json, op=op)
84
+ return exit_status(exc)