mammoth-cli 1.0.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 (150) hide show
  1. mammoth_cli/__init__.py +14 -0
  2. mammoth_cli/__main__.py +27 -0
  3. mammoth_cli/app.py +565 -0
  4. mammoth_cli/bundled_skill/mammoth-cli/SKILL.md +109 -0
  5. mammoth_cli/bundled_skill/mammoth-cli/references/auth.md +27 -0
  6. mammoth_cli/bundled_skill/mammoth-cli/references/input.md +24 -0
  7. mammoth_cli/bundled_skill/mammoth-cli/references/jobs-drafts.md +35 -0
  8. mammoth_cli/bundled_skill/mammoth-cli/references/machine-output.md +30 -0
  9. mammoth_cli/bundled_skill/mammoth-cli/references/recovery.md +28 -0
  10. mammoth_cli/bundled_skill/mammoth-cli/references/safety.md +26 -0
  11. mammoth_cli/commands/__init__.py +29 -0
  12. mammoth_cli/commands/activity.py +123 -0
  13. mammoth_cli/commands/addon.py +173 -0
  14. mammoth_cli/commands/agent.py +199 -0
  15. mammoth_cli/commands/ai.py +274 -0
  16. mammoth_cli/commands/annotation.py +166 -0
  17. mammoth_cli/commands/auth.py +567 -0
  18. mammoth_cli/commands/automation.py +245 -0
  19. mammoth_cli/commands/batch.py +204 -0
  20. mammoth_cli/commands/billing.py +462 -0
  21. mammoth_cli/commands/browse.py +151 -0
  22. mammoth_cli/commands/capability.py +43 -0
  23. mammoth_cli/commands/client_app.py +233 -0
  24. mammoth_cli/commands/completion.py +82 -0
  25. mammoth_cli/commands/config.py +309 -0
  26. mammoth_cli/commands/connector.py +464 -0
  27. mammoth_cli/commands/context.py +205 -0
  28. mammoth_cli/commands/dashboard.py +419 -0
  29. mammoth_cli/commands/data_app.py +239 -0
  30. mammoth_cli/commands/dataset.py +332 -0
  31. mammoth_cli/commands/doctor.py +90 -0
  32. mammoth_cli/commands/external_key.py +163 -0
  33. mammoth_cli/commands/file.py +243 -0
  34. mammoth_cli/commands/folder.py +224 -0
  35. mammoth_cli/commands/job.py +147 -0
  36. mammoth_cli/commands/notification.py +201 -0
  37. mammoth_cli/commands/parameter.py +284 -0
  38. mammoth_cli/commands/project.py +327 -0
  39. mammoth_cli/commands/registry.py +587 -0
  40. mammoth_cli/commands/report.py +61 -0
  41. mammoth_cli/commands/schedule.py +214 -0
  42. mammoth_cli/commands/schema.py +271 -0
  43. mammoth_cli/commands/skill.py +59 -0
  44. mammoth_cli/commands/snippet.py +200 -0
  45. mammoth_cli/commands/support.py +800 -0
  46. mammoth_cli/commands/template.py +134 -0
  47. mammoth_cli/commands/trash.py +115 -0
  48. mammoth_cli/commands/user.py +190 -0
  49. mammoth_cli/commands/view.py +1143 -0
  50. mammoth_cli/commands/view_ops.py +596 -0
  51. mammoth_cli/commands/webhook.py +235 -0
  52. mammoth_cli/commands/workflow.py +340 -0
  53. mammoth_cli/commands/workspace.py +359 -0
  54. mammoth_cli/context/__init__.py +1 -0
  55. mammoth_cli/context/credentials.py +223 -0
  56. mammoth_cli/context/endpoint.py +57 -0
  57. mammoth_cli/context/profiles.py +370 -0
  58. mammoth_cli/context/resolver.py +240 -0
  59. mammoth_cli/contracts/__init__.py +1 -0
  60. mammoth_cli/contracts/auth.py +32 -0
  61. mammoth_cli/errors/__init__.py +1 -0
  62. mammoth_cli/errors/envelope.py +144 -0
  63. mammoth_cli/manifest/__init__.py +1 -0
  64. mammoth_cli/manifest/loader.py +96 -0
  65. mammoth_cli/messages/__init__.py +1 -0
  66. mammoth_cli/output/__init__.py +1 -0
  67. mammoth_cli/output/envelope.py +42 -0
  68. mammoth_cli/output/normalize.py +84 -0
  69. mammoth_cli/output/policy.py +71 -0
  70. mammoth_cli/output/render.py +83 -0
  71. mammoth_cli/py.typed +0 -0
  72. mammoth_cli/runtime/__init__.py +0 -0
  73. mammoth_cli/runtime/confirm.py +124 -0
  74. mammoth_cli/runtime/executor.py +126 -0
  75. mammoth_cli/runtime/input_loader.py +140 -0
  76. mammoth_cli/runtime/invocation.py +87 -0
  77. mammoth_cli/runtime/options.py +172 -0
  78. mammoth_cli/runtime/session.py +82 -0
  79. mammoth_cli/runtime/strict.py +240 -0
  80. mammoth_cli/runtime/validate.py +120 -0
  81. mammoth_cli/services/__init__.py +1 -0
  82. mammoth_cli/services/argspec.py +301 -0
  83. mammoth_cli/services/coerce.py +172 -0
  84. mammoth_cli/services/conditions.py +85 -0
  85. mammoth_cli/services/dispatch.py +63 -0
  86. mammoth_cli/services/factory.py +45 -0
  87. mammoth_cli/services/input_fields.py +44 -0
  88. mammoth_cli/services/mapping.py +74 -0
  89. mammoth_cli/services/openapi_types.py +166 -0
  90. mammoth_cli/services/positionals.py +408 -0
  91. mammoth_cli/services/protocol.py +123 -0
  92. mammoth_cli/services/sdk_service.py +274 -0
  93. mammoth_cli/services/testing.py +153 -0
  94. mammoth_cli/services/type_system.py +427 -0
  95. mammoth_cli/skills/__init__.py +1 -0
  96. mammoth_cli/skills/installer.py +321 -0
  97. mammoth_cli/testing.py +55 -0
  98. mammoth_cli-1.0.0.dist-info/METADATA +92 -0
  99. mammoth_cli-1.0.0.dist-info/RECORD +150 -0
  100. mammoth_cli-1.0.0.dist-info/WHEEL +4 -0
  101. mammoth_cli-1.0.0.dist-info/entry_points.txt +3 -0
  102. mammoth_cli-1.0.0.dist-info/licenses/LICENSE +23 -0
  103. spec/manifests/_sdk_introspection.json +1459 -0
  104. spec/manifests/commands/activity.yaml +72 -0
  105. spec/manifests/commands/addon.yaml +239 -0
  106. spec/manifests/commands/agent.yaml +192 -0
  107. spec/manifests/commands/ai.yaml +152 -0
  108. spec/manifests/commands/annotation.yaml +191 -0
  109. spec/manifests/commands/auth.yaml +101 -0
  110. spec/manifests/commands/automation.yaml +273 -0
  111. spec/manifests/commands/batch.yaml +262 -0
  112. spec/manifests/commands/billing.yaml +813 -0
  113. spec/manifests/commands/browse.yaml +145 -0
  114. spec/manifests/commands/capability.yaml +74 -0
  115. spec/manifests/commands/client-app.yaml +193 -0
  116. spec/manifests/commands/completion.yaml +68 -0
  117. spec/manifests/commands/config.yaml +134 -0
  118. spec/manifests/commands/connector.yaml +944 -0
  119. spec/manifests/commands/context.yaml +101 -0
  120. spec/manifests/commands/dashboard.yaml +3595 -0
  121. spec/manifests/commands/data-app.yaml +485 -0
  122. spec/manifests/commands/dataset.yaml +580 -0
  123. spec/manifests/commands/doctor.yaml +35 -0
  124. spec/manifests/commands/external-key.yaml +151 -0
  125. spec/manifests/commands/file.yaml +341 -0
  126. spec/manifests/commands/folder.yaml +334 -0
  127. spec/manifests/commands/job.yaml +151 -0
  128. spec/manifests/commands/notification.yaml +188 -0
  129. spec/manifests/commands/parameter.yaml +527 -0
  130. spec/manifests/commands/project.yaml +669 -0
  131. spec/manifests/commands/report.yaml +36 -0
  132. spec/manifests/commands/schedule.yaml +194 -0
  133. spec/manifests/commands/schema.yaml +74 -0
  134. spec/manifests/commands/skill.yaml +167 -0
  135. spec/manifests/commands/snippet.yaml +311 -0
  136. spec/manifests/commands/support.yaml +1805 -0
  137. spec/manifests/commands/template.yaml +191 -0
  138. spec/manifests/commands/trash.yaml +105 -0
  139. spec/manifests/commands/user.yaml +280 -0
  140. spec/manifests/commands/version.yaml +35 -0
  141. spec/manifests/commands/view.yaml +4232 -0
  142. spec/manifests/commands/webhook.yaml +261 -0
  143. spec/manifests/commands/workflow.yaml +622 -0
  144. spec/manifests/commands/workspace.yaml +705 -0
  145. spec/manifests/openapi-operations.yaml +8853 -0
  146. spec/manifests/schema-v1.json +212 -0
  147. spec/manifests/sdk-catalog.source.yaml +4069 -0
  148. spec/manifests/sdk-methods.yaml +6971 -0
  149. spec/openapi/metadata.json +10 -0
  150. spec/openapi/openapi.json +1 -0
@@ -0,0 +1,14 @@
1
+ """Mammoth CLI — a typed command-line interface for the Mammoth Analytics platform.
2
+
3
+ All Mammoth transport goes through the public ``mammoth-io`` SDK. The CLI does
4
+ not implement a second HTTP client and does not call private SDK members.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __version__ = "1.0.0"
10
+
11
+ # The versioned machine output envelope contract.
12
+ SCHEMA_VERSION = 1
13
+
14
+ __all__ = ["__version__", "SCHEMA_VERSION"]
@@ -0,0 +1,27 @@
1
+ """Module and console entry point for the Mammoth CLI.
2
+
3
+ This module exposes ``main`` so that both the ``mammoth`` console script and
4
+ ``python -m mammoth_cli`` start the same Typer application. Keeping the entry
5
+ point here (rather than in ``app``) gives the package a stable, importable
6
+ launch symbol that does not change when the application wiring moves.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from mammoth_cli.app import app
14
+
15
+
16
+ def main() -> Any:
17
+ """Run the Mammoth CLI application.
18
+
19
+ Returns:
20
+ The value returned by the Typer application, which the runtime uses as
21
+ the process exit code.
22
+ """
23
+ return app()
24
+
25
+
26
+ if __name__ == "__main__": # pragma: no cover
27
+ main()
mammoth_cli/app.py ADDED
@@ -0,0 +1,565 @@
1
+ """Root Typer application and manifest-driven command registration.
2
+
3
+ The command tree is built from the reviewed command manifests so the registered
4
+ surface can never drift from the parity records: every non-alias command record
5
+ becomes exactly one Typer command at its manifest ``command_path``, and nothing
6
+ else is registered.
7
+
8
+ Every command exposes the global machine-output and agent-mode options
9
+ (``--output``, ``--no-input``, ``--no-progress``, ``--color``, ``--profile``,
10
+ ``--project`` and the timeout family) so an autonomous agent gets deterministic,
11
+ promptless behavior on any command without reading source. Command-specific
12
+ positionals and options are added per family as each handler is implemented.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import inspect
18
+ import sys
19
+ from collections.abc import Callable, Sequence
20
+ from functools import cache
21
+ from typing import Annotated, Any
22
+
23
+ import typer
24
+ from typer._click import exceptions as _typer_click_exceptions
25
+ from typer.core import TyperGroup
26
+
27
+ from mammoth_cli import __version__
28
+ from mammoth_cli.commands import BESPOKE
29
+ from mammoth_cli.commands.registry import HANDLERS
30
+ from mammoth_cli.errors.envelope import EXIT_USAGE, CliError, not_implemented_error
31
+ from mammoth_cli.manifest.loader import command_by_id, load_commands
32
+ from mammoth_cli.output.policy import COLOR_MODES, MACHINE_OUTPUTS, VALID_OUTPUTS
33
+ from mammoth_cli.runtime import executor, validate
34
+ from mammoth_cli.runtime.invocation import Invocation
35
+ from mammoth_cli.runtime.strict import validate_extra_args
36
+ from mammoth_cli.services.positionals import PositionalSpec, resolve_positionals
37
+
38
+ OUTPUT_MODES = VALID_OUTPUTS
39
+
40
+ # Typer (pinned >=0.27,<0.28) ships a *vendored* click fork (``typer._click``)
41
+ # and does NOT depend on the external ``click`` package. Command resolution
42
+ # raises that fork's ``UsageError``/``Abort``, so the interceptor keys on the
43
+ # vendored classes only -- importing the external ``click`` here would add a
44
+ # phantom dependency that is absent from a clean wheel install.
45
+ _USAGE_ERRORS: tuple[type[BaseException], ...] = (_typer_click_exceptions.UsageError,)
46
+ _ABORT_ERRORS: tuple[type[BaseException], ...] = (_typer_click_exceptions.Abort,)
47
+
48
+
49
+ def _output_mode_from_argv(argv: Sequence[str] | None) -> str:
50
+ """Recover the requested ``--output`` mode from raw argv tokens.
51
+
52
+ Used when a Click ``UsageError`` is raised *before* the per-command option
53
+ is parsed (an unknown command, an unexpected argument, a bad option value),
54
+ so the top-level error renderer can still honor the machine-output contract.
55
+ Defaults to the same ``"table"`` default the ``--output`` option declares.
56
+ """
57
+ tokens = list(argv) if argv is not None else sys.argv[1:]
58
+ mode = "table"
59
+ index = 0
60
+ while index < len(tokens):
61
+ token = tokens[index]
62
+ if token in ("--output", "-o"):
63
+ if index + 1 < len(tokens):
64
+ mode = tokens[index + 1]
65
+ index += 2
66
+ continue
67
+ if token.startswith("--output="):
68
+ mode = token.split("=", 1)[1]
69
+ elif token.startswith("-o") and len(token) > 2:
70
+ mode = token[2:]
71
+ index += 1
72
+ return mode
73
+
74
+
75
+ class _EnvelopeGroup(TyperGroup):
76
+ """Root group that renders Click usage errors as the machine error envelope.
77
+
78
+ Click's standalone error handling prints a human ``Usage: ... Error: ...``
79
+ message and exits, even under ``--output json``: an agent driving the CLI
80
+ then receives un-parseable prose (or, for a leaf that also parents
81
+ subcommands, a raw ``No such command`` error) instead of the stable JSON
82
+ envelope every handler-level failure emits. This override intercepts every
83
+ usage error (Typer's vendored ``UsageError``) raised anywhere in the command
84
+ tree and, when a machine output was requested, emits the same envelope
85
+ contract. Human output is unchanged (Click's own rendering is reused).
86
+
87
+ The interception runs Click's machinery with ``standalone_mode=False`` so
88
+ usage errors propagate here rather than being printed by Click, then restores
89
+ the ``SystemExit`` contract the console entry point and the test runner rely
90
+ on.
91
+ """
92
+
93
+ def main(self, *args: Any, **kwargs: Any) -> Any:
94
+ if not kwargs.get("standalone_mode", True):
95
+ return super().main(*args, **kwargs)
96
+ kwargs["standalone_mode"] = False
97
+ try:
98
+ result = super().main(*args, **kwargs)
99
+ except _USAGE_ERRORS as error:
100
+ argv = kwargs.get("args")
101
+ if argv is None:
102
+ argv = args[0] if args else None
103
+ self._render_usage_error(error, argv)
104
+ raise SystemExit(getattr(error, "exit_code", EXIT_USAGE)) from None
105
+ except _ABORT_ERRORS:
106
+ typer.echo("Aborted!", err=True)
107
+ raise SystemExit(1) from None
108
+ # Under standalone_mode=False a normal return is either None (success) or
109
+ # an int exit code (a click Exit, e.g. --help/--version or our own
110
+ # CliError path). Re-raise SystemExit so both the console script and the
111
+ # in-process test runner observe the exit status as before.
112
+ raise SystemExit(result if isinstance(result, int) else 0)
113
+
114
+ def _render_usage_error(self, error: Any, argv: Sequence[str] | None) -> None:
115
+ """Emit a usage error as the JSON envelope (machine) or Click prose (human).
116
+
117
+ A *missing required argument* is reported with the stable
118
+ ``missing_argument`` code that the handler-level ``_require_*`` helpers
119
+ also raise, so the machine error contract is identical whether a required
120
+ positional is now enforced natively by Typer (a Click-layer
121
+ ``MissingParameter``, the common case since positionals are declared with
122
+ their real requiredness) or by a handler that reads it from
123
+ ``extra_args``. Every other usage error (unknown command, bad option or
124
+ argument value, an id read as a subcommand) stays ``usage_error``.
125
+ """
126
+ if _output_mode_from_argv(argv) in MACHINE_OUTPUTS:
127
+ missing = isinstance(error, _typer_click_exceptions.MissingParameter)
128
+ executor.emit_error(
129
+ CliError(
130
+ code="missing_argument" if missing else "usage_error",
131
+ message=error.format_message(),
132
+ exit_status=EXIT_USAGE,
133
+ hint="Check the command schema with 'mammoth schema get'.",
134
+ ),
135
+ machine=True,
136
+ )
137
+ else:
138
+ error.show()
139
+
140
+
141
+ def _version_callback(value: bool) -> None:
142
+ if value:
143
+ typer.echo(__version__)
144
+ raise typer.Exit(0)
145
+
146
+
147
+ def _shared_option_params() -> list[inspect.Parameter]:
148
+ """Build the global-option parameters shared by every command.
149
+
150
+ These are the machine-output and agent-mode options every command exposes.
151
+ They are declared once, as :class:`inspect.Parameter`s, so a dynamically
152
+ synthesized command signature can splice them in after any positionals while
153
+ keeping the option contract in exactly one place.
154
+ """
155
+ p = inspect.Parameter
156
+
157
+ def opt(name: str, default: Any, annotation: Any) -> inspect.Parameter:
158
+ return p(name, p.POSITIONAL_OR_KEYWORD, default=default, annotation=annotation)
159
+
160
+ return [
161
+ opt(
162
+ "output",
163
+ "table",
164
+ Annotated[
165
+ str,
166
+ typer.Option(
167
+ "--output", "-o", help="Output format.", metavar="|".join(OUTPUT_MODES)
168
+ ),
169
+ ],
170
+ ),
171
+ opt(
172
+ "profile",
173
+ None,
174
+ Annotated[str | None, typer.Option("--profile", help="Credential profile name.")],
175
+ ),
176
+ opt(
177
+ "project",
178
+ None,
179
+ Annotated[int | None, typer.Option("--project", help="Active project id override.")],
180
+ ),
181
+ opt(
182
+ "timeout",
183
+ None,
184
+ Annotated[float | None, typer.Option("--timeout", help="Per-request timeout seconds.")],
185
+ ),
186
+ opt(
187
+ "job_timeout",
188
+ None,
189
+ Annotated[
190
+ float | None, typer.Option("--job-timeout", help="Job wait timeout seconds.")
191
+ ],
192
+ ),
193
+ opt(
194
+ "pipeline_timeout",
195
+ None,
196
+ Annotated[
197
+ float | None,
198
+ typer.Option("--pipeline-timeout", help="Pipeline wait timeout seconds."),
199
+ ],
200
+ ),
201
+ opt(
202
+ "color",
203
+ "auto",
204
+ Annotated[
205
+ str, typer.Option("--color", help="Color policy.", metavar="|".join(COLOR_MODES))
206
+ ],
207
+ ),
208
+ opt(
209
+ "no_input",
210
+ False,
211
+ Annotated[bool, typer.Option("--no-input", help="Never prompt; fail instead.")],
212
+ ),
213
+ opt(
214
+ "no_progress",
215
+ False,
216
+ Annotated[bool, typer.Option("--no-progress", help="Never render progress.")],
217
+ ),
218
+ opt(
219
+ "debug",
220
+ False,
221
+ Annotated[bool, typer.Option("--debug", help="Emit diagnostic detail to stderr.")],
222
+ ),
223
+ opt(
224
+ "yes",
225
+ False,
226
+ Annotated[
227
+ bool, typer.Option("--yes", "-y", help="Confirm a mutation without prompting.")
228
+ ],
229
+ ),
230
+ opt(
231
+ "confirm",
232
+ None,
233
+ Annotated[
234
+ str | None,
235
+ typer.Option(
236
+ "--confirm", help="Exact target name required for high-impact actions."
237
+ ),
238
+ ],
239
+ ),
240
+ opt(
241
+ "input_file",
242
+ None,
243
+ Annotated[
244
+ str | None,
245
+ typer.Option(
246
+ "--input", help="Strict JSON/YAML request document, or '-' for stdin."
247
+ ),
248
+ ],
249
+ ),
250
+ opt(
251
+ "input_format",
252
+ None,
253
+ Annotated[
254
+ str | None,
255
+ typer.Option("--input-format", help="Required for stdin: json or yaml."),
256
+ ],
257
+ ),
258
+ ]
259
+
260
+
261
+ _SHARED_OPTION_PARAMS = _shared_option_params()
262
+
263
+
264
+ def _positional_param(spec: PositionalSpec) -> inspect.Parameter:
265
+ """Build a Typer ``Argument`` parameter for one positional spec.
266
+
267
+ The argument is declared with the spec's native scalar type (``int`` or
268
+ ``str``) and its native requiredness, so ``--help`` shows the truth: a
269
+ required id renders as ``DATASET_ID`` / ``<int>`` (not an optional
270
+ ``[DATASET_ID]`` / ``<str>``) and Typer enforces both presence and type at
271
+ parse time. A missing or ill-typed *required* positional raises a Click-layer
272
+ usage error that :class:`_EnvelopeGroup` renders as the stable JSON envelope
273
+ under a machine ``--output`` (``missing_argument`` for an absent one,
274
+ ``usage_error`` for a bad value), preserving the machine error contract. An
275
+ *optional* positional keeps a ``None`` default and an ``Optional`` annotation;
276
+ the handler fills it from ``--input`` or resolved context when omitted.
277
+ """
278
+ if spec.required:
279
+ return inspect.Parameter(
280
+ spec.name,
281
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
282
+ annotation=Annotated[spec.type, typer.Argument(help=spec.help, metavar=spec.metavar)],
283
+ )
284
+ return inspect.Parameter(
285
+ spec.name,
286
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
287
+ default=None,
288
+ annotation=Annotated[
289
+ spec.type | None, typer.Argument(help=spec.help, metavar=spec.metavar)
290
+ ],
291
+ )
292
+
293
+
294
+ def _build_leaf(command_id: str, *, is_group_callback: bool = False) -> Callable[..., None]:
295
+ """Return a Typer callback bound to one manifest command id.
296
+
297
+ Every callback shares the same global-option signature so ``--help`` for any
298
+ command advertises the machine-output and agent-mode contract, and declares
299
+ one native Typer ``Argument`` per positional derived for the command (see
300
+ :func:`mammoth_cli.services.positionals.resolve_positionals`), so its
301
+ ``--help`` shows an Arguments panel and the parsed values route into
302
+ :attr:`Invocation.positionals` — a single, code-derived source of truth for
303
+ the command's positional shape.
304
+
305
+ The parsed positionals are also mirrored, in declared order, into
306
+ :attr:`Invocation.extra_args` ahead of any genuine surplus tokens. Handlers
307
+ may read them positionally (``extra_args``) or by name
308
+ (:meth:`Invocation.positional`); both views come from the same declaration,
309
+ so they cannot drift, and the surplus/id checks in
310
+ :mod:`mammoth_cli.runtime.strict` and :mod:`mammoth_cli.runtime.validate`
311
+ (which align a command's derived positionals against ``extra_args``) keep
312
+ working unchanged.
313
+
314
+ When ``is_group_callback`` is set the command sits at a node that also has
315
+ subcommands; the callback then only runs when no subcommand is invoked and
316
+ declares no positionals.
317
+ """
318
+ positionals: tuple[PositionalSpec, ...] = (
319
+ () if is_group_callback else resolve_positionals(command_id)
320
+ )
321
+ positional_names = tuple(spec.name for spec in positionals)
322
+
323
+ def leaf(**params: Any) -> None:
324
+ ctx: typer.Context = params.pop("ctx")
325
+ if is_group_callback and ctx.invoked_subcommand is not None:
326
+ return
327
+ resolved = {name: params.pop(name) for name in positional_names}
328
+ present = {name: value for name, value in resolved.items() if value is not None}
329
+ # Mirror the bound positionals (in declared order) into extra_args ahead
330
+ # of any genuine surplus tokens, so handlers reading extra_args by index
331
+ # and the derived-positional surplus/id checks both keep working.
332
+ mirrored = [str(resolved[name]) for name in positional_names if resolved[name] is not None]
333
+ invocation = Invocation(
334
+ command_id=command_id,
335
+ positionals=present,
336
+ extra_args=[*mirrored, *ctx.args],
337
+ **params,
338
+ )
339
+ _execute(invocation)
340
+
341
+ signature_params = [
342
+ inspect.Parameter("ctx", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=typer.Context),
343
+ *(_positional_param(spec) for spec in positionals),
344
+ *_SHARED_OPTION_PARAMS,
345
+ ]
346
+ leaf.__signature__ = inspect.Signature(signature_params) # type: ignore[attr-defined]
347
+ leaf.__annotations__ = {param.name: param.annotation for param in signature_params}
348
+ leaf.__name__ = "cmd_" + command_id.replace(".", "_").replace("-", "_")
349
+ return leaf
350
+
351
+
352
+ def _execute(invocation: Invocation) -> None:
353
+ """Run one command: dispatch to its handler and render the envelope."""
354
+
355
+ def producer() -> tuple[Any, dict[str, Any]]:
356
+ validate.validate_invocation(invocation)
357
+ validate_extra_args(invocation.command_id, invocation.extra_args)
358
+ validate.validate_positional_ids(invocation.command_id, invocation.extra_args)
359
+ handler = HANDLERS.get(invocation.command_id)
360
+ if handler is None:
361
+ record = command_by_id(invocation.command_id)
362
+ sdk_symbol = record.get("sdk_symbol", "") if record else ""
363
+ raise not_implemented_error(invocation.command_id, sdk_symbol)
364
+ return handler(invocation)
365
+
366
+ executor.run(invocation.command_id, invocation.output, producer)
367
+
368
+
369
+ def _command_help(command_id: str, record: dict[str, Any] | None) -> str | None:
370
+ """Build a command's user-facing ``--help`` summary.
371
+
372
+ Kept deliberately distinct from ``known_restrictions`` (internal review and
373
+ planning notes), which used to be shown here verbatim and leaked plan-document
374
+ prose -- e.g. "specified in plan 02's ... contract" -- into the user-facing
375
+ ``--help``. Instead the summary is the backing handler's docstring first line
376
+ (actionable field guidance such as "``columns``/``mapping`` required"),
377
+ followed by the manifest's runnable ``agent_example``, which already encodes
378
+ every required positional and ``--input`` field. ``known_restrictions``
379
+ remains available to machines through ``schema get``.
380
+ """
381
+ parts: list[str] = []
382
+ handler = HANDLERS.get(command_id) or BESPOKE.get(command_id)
383
+ doc = inspect.getdoc(handler) if handler is not None else None
384
+ if doc:
385
+ # First non-empty line, with RST inline-code backticks flattened to plain
386
+ # quotes so the help reads as prose rather than reStructuredText.
387
+ summary = doc.strip().splitlines()[0].strip().replace("``", "'")
388
+ if summary:
389
+ parts.append(summary)
390
+ example = (record or {}).get("agent_example")
391
+ if example:
392
+ parts.append(f"Example: {example}")
393
+ return "\n\n".join(parts) or None
394
+
395
+
396
+ def build_app() -> typer.Typer:
397
+ """Construct the full Typer command tree from the command manifests."""
398
+ root = typer.Typer(
399
+ name="mammoth",
400
+ help="Command-line interface for the Mammoth Analytics platform.",
401
+ no_args_is_help=True,
402
+ add_completion=True,
403
+ cls=_EnvelopeGroup,
404
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
405
+ )
406
+
407
+ @root.callback()
408
+ def _root(
409
+ version: bool = typer.Option(
410
+ False, "--version", callback=_version_callback, is_eager=True, help="Show version."
411
+ ),
412
+ ) -> None:
413
+ """Root callback holding eager global options."""
414
+
415
+ records = [r for r in load_commands() if r.get("disposition") != "alias"]
416
+ path_to_command: dict[tuple[str, ...], str] = {
417
+ tuple(r["command_path"].split()): r["command_id"] for r in records
418
+ }
419
+ all_paths = set(path_to_command)
420
+ # A "container command" sits at a node that also has deeper subcommands
421
+ # (e.g. `dataset file-settings`, which also has `... undo` / `... update`).
422
+ containers = {
423
+ tokens
424
+ for tokens in all_paths
425
+ if any(other != tokens and other[: len(tokens)] == tokens for other in all_paths)
426
+ }
427
+
428
+ groups: dict[tuple[str, ...], typer.Typer] = {(): root}
429
+
430
+ def _make_group(tokens: tuple[str, ...]) -> typer.Typer:
431
+ command_id = path_to_command.get(tokens)
432
+ if command_id is not None:
433
+ # This node is both a group and an invocable command.
434
+ sub = typer.Typer(
435
+ no_args_is_help=False,
436
+ invoke_without_command=True,
437
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
438
+ )
439
+ sub.callback()(_build_leaf(command_id, is_group_callback=True))
440
+ else:
441
+ sub = typer.Typer(
442
+ no_args_is_help=True,
443
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
444
+ )
445
+ return sub
446
+
447
+ def _group_for(tokens: tuple[str, ...]) -> typer.Typer:
448
+ if tokens in groups:
449
+ return groups[tokens]
450
+ parent = _group_for(tokens[:-1])
451
+ sub = _make_group(tokens)
452
+ parent.add_typer(sub, name=tokens[-1])
453
+ groups[tokens] = sub
454
+ return sub
455
+
456
+ # Ensure every container group exists (parents before children).
457
+ for tokens in sorted(containers, key=len):
458
+ _group_for(tokens)
459
+
460
+ # Register every non-container command as a leaf under its parent group.
461
+ # A command_id with a bespoke, fully-typed callback overrides the generic
462
+ # leaf at the same registered name and path; the manifest-driven surface
463
+ # is otherwise unchanged.
464
+ for tokens, command_id in sorted(path_to_command.items()):
465
+ if tokens in containers:
466
+ continue
467
+ record = command_by_id(command_id)
468
+ callback = BESPOKE.get(command_id) or _build_leaf(command_id)
469
+ _group_for(tokens[:-1]).command(
470
+ name=tokens[-1],
471
+ help=_command_help(command_id, record),
472
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
473
+ )(callback)
474
+
475
+ return root
476
+
477
+
478
+ def registered_command_paths() -> set[str]:
479
+ """Return every registered command path by walking the built Typer tree.
480
+
481
+ Container commands (a node that is both a group and invocable) are counted
482
+ by their own path as well as their subcommands.
483
+ """
484
+ paths: set[str] = set()
485
+
486
+ def walk(instance: typer.Typer, prefix: tuple[str, ...]) -> None:
487
+ for command in instance.registered_commands:
488
+ name = command.name or (command.callback.__name__ if command.callback else None)
489
+ if name:
490
+ paths.add(" ".join((*prefix, name)))
491
+ for group in instance.registered_groups:
492
+ sub = group.typer_instance
493
+ if sub is None or not group.name:
494
+ continue
495
+ node = (*prefix, group.name)
496
+ if getattr(sub.info, "invoke_without_command", False) and sub.registered_callback:
497
+ paths.add(" ".join(node))
498
+ walk(sub, node)
499
+
500
+ walk(build_app(), ())
501
+ return paths
502
+
503
+
504
+ @cache
505
+ def _root_click_command() -> Any:
506
+ """Build (once) and cache the fully resolved Click command tree.
507
+
508
+ ``typer.main.get_command`` walks and converts every registered Typer
509
+ sub-app into its Click representation; over the full manifest-driven tree
510
+ that is expensive enough that recomputing it per lookup made a full sweep
511
+ over every command (see the contract test) take minutes. Cached because
512
+ the tree is fixed for the process lifetime (:data:`app` is built once at
513
+ import time).
514
+ """
515
+ return typer.main.get_command(app)
516
+
517
+
518
+ def command_option_names(command_path: str) -> set[str]:
519
+ """Return every declared option flag for one registered command path.
520
+
521
+ Walks the built Click command tree by ``command_path`` tokens (for example
522
+ ``"view transform bulk-replace"``) and collects each parameter's option
523
+ strings (``"--output"``, ``"-o"``, ...). This is a structural check: it
524
+ reads the already-parsed command declaration, so it is fast and immune to
525
+ the rendered-width and terminal-detection nondeterminism of asserting on
526
+ rendered ``--help`` text. Command nodes are walked duck-typed (via their
527
+ ``commands`` mapping) rather than by an ``isinstance`` check against
528
+ ``click``'s public types, since Typer's pinned version resolves its command
529
+ tree through its own vendored click fork (``typer._click``), whose classes
530
+ are not the public ``click`` package's.
531
+
532
+ Args:
533
+ command_path: The space-separated manifest command path.
534
+
535
+ Returns:
536
+ The union of every parameter's primary and secondary option strings
537
+ declared on that command (or group callback).
538
+
539
+ Raises:
540
+ ValueError: When ``command_path`` does not resolve to a registered
541
+ command.
542
+ """
543
+ command: Any = _root_click_command()
544
+ for token in command_path.split():
545
+ subcommands: dict[str, Any] | None = getattr(command, "commands", None)
546
+ if subcommands is None or token not in subcommands:
547
+ raise ValueError(f"'{command_path}' is not a registered command path.")
548
+ command = subcommands[token]
549
+ names: set[str] = set()
550
+ for param in command.params:
551
+ names.update(getattr(param, "opts", ()))
552
+ names.update(getattr(param, "secondary_opts", ()))
553
+ return names
554
+
555
+
556
+ app = build_app()
557
+
558
+
559
+ def main() -> Any:
560
+ """Console-script entry point."""
561
+ return app()
562
+
563
+
564
+ if __name__ == "__main__": # pragma: no cover
565
+ main()