ai-stp-cli 0.0.3__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 (131) hide show
  1. ai_stp_cli/__init__.py +7 -0
  2. ai_stp_cli/__main__.py +10 -0
  3. ai_stp_cli/answer.py +31 -0
  4. ai_stp_cli/app.py +489 -0
  5. ai_stp_cli/cloud/__init__.py +5 -0
  6. ai_stp_cli/cloud/catalog.py +336 -0
  7. ai_stp_cli/cloud/client.py +418 -0
  8. ai_stp_cli/cloud/grants.py +88 -0
  9. ai_stp_cli/cloud/login.py +285 -0
  10. ai_stp_cli/cloud/owner.py +53 -0
  11. ai_stp_cli/cloud/publication.py +163 -0
  12. ai_stp_cli/cloud/reports.py +29 -0
  13. ai_stp_cli/cloud/session.py +180 -0
  14. ai_stp_cli/cloud/sync.py +33 -0
  15. ai_stp_cli/commands/__init__.py +9 -0
  16. ai_stp_cli/commands/attestations.py +184 -0
  17. ai_stp_cli/commands/auth.py +206 -0
  18. ai_stp_cli/commands/auth_status.py +18 -0
  19. ai_stp_cli/commands/cloud_auth.py +24 -0
  20. ai_stp_cli/commands/component.py +790 -0
  21. ai_stp_cli/commands/config_show.py +113 -0
  22. ai_stp_cli/commands/device.py +64 -0
  23. ai_stp_cli/commands/doctor.py +327 -0
  24. ai_stp_cli/commands/evaluation.py +102 -0
  25. ai_stp_cli/commands/grants.py +140 -0
  26. ai_stp_cli/commands/install.py +2256 -0
  27. ai_stp_cli/commands/link.py +51 -0
  28. ai_stp_cli/commands/machine_help.py +61 -0
  29. ai_stp_cli/commands/owner.py +67 -0
  30. ai_stp_cli/commands/passport.py +164 -0
  31. ai_stp_cli/commands/project.py +320 -0
  32. ai_stp_cli/commands/publication.py +171 -0
  33. ai_stp_cli/commands/registry.py +457 -0
  34. ai_stp_cli/commands/reports.py +186 -0
  35. ai_stp_cli/commands/select.py +1631 -0
  36. ai_stp_cli/commands/setup_publication.py +411 -0
  37. ai_stp_cli/commands/skill.py +69 -0
  38. ai_stp_cli/commands/sync.py +423 -0
  39. ai_stp_cli/commands/telemetry.py +101 -0
  40. ai_stp_cli/commands/toolchain.py +246 -0
  41. ai_stp_cli/commands/version.py +24 -0
  42. ai_stp_cli/config.py +471 -0
  43. ai_stp_cli/errors.py +101 -0
  44. ai_stp_cli/i18n.py +49 -0
  45. ai_stp_cli/identity.py +375 -0
  46. ai_stp_cli/local/__init__.py +5 -0
  47. ai_stp_cli/local/authoring.py +519 -0
  48. ai_stp_cli/local/bundle.py +562 -0
  49. ai_stp_cli/local/cache.py +340 -0
  50. ai_stp_cli/local/component_passports.py +718 -0
  51. ai_stp_cli/local/component_sources.py +566 -0
  52. ai_stp_cli/local/components.py +1532 -0
  53. ai_stp_cli/local/composition.py +625 -0
  54. ai_stp_cli/local/consent.py +237 -0
  55. ai_stp_cli/local/content.py +102 -0
  56. ai_stp_cli/local/database.py +950 -0
  57. ai_stp_cli/local/eligibility.py +694 -0
  58. ai_stp_cli/local/evaluation.py +379 -0
  59. ai_stp_cli/local/external_sources.py +145 -0
  60. ai_stp_cli/local/github_evidence.py +297 -0
  61. ai_stp_cli/local/graph.py +374 -0
  62. ai_stp_cli/local/harness_catalog.py +434 -0
  63. ai_stp_cli/local/harnesses.py +410 -0
  64. ai_stp_cli/local/impact.py +435 -0
  65. ai_stp_cli/local/importing.py +827 -0
  66. ai_stp_cli/local/installation.py +818 -0
  67. ai_stp_cli/local/interop_sources.py +297 -0
  68. ai_stp_cli/local/journal.py +179 -0
  69. ai_stp_cli/local/lifecycle.py +213 -0
  70. ai_stp_cli/local/managed_diff.py +217 -0
  71. ai_stp_cli/local/mcp_clients.py +221 -0
  72. ai_stp_cli/local/mcp_sources.py +402 -0
  73. ai_stp_cli/local/passports.py +421 -0
  74. ai_stp_cli/local/project_index.py +361 -0
  75. ai_stp_cli/local/project_passport.py +246 -0
  76. ai_stp_cli/local/projects.py +366 -0
  77. ai_stp_cli/local/provider_releases.py +147 -0
  78. ai_stp_cli/local/publication_sets.py +172 -0
  79. ai_stp_cli/local/report_plans.py +75 -0
  80. ai_stp_cli/local/revisions.py +268 -0
  81. ai_stp_cli/local/search.py +336 -0
  82. ai_stp_cli/local/selection.py +656 -0
  83. ai_stp_cli/local/setup_versions.py +194 -0
  84. ai_stp_cli/local/store_ports.py +739 -0
  85. ai_stp_cli/local/symbols.py +367 -0
  86. ai_stp_cli/local/sync_merge.py +123 -0
  87. ai_stp_cli/local/sync_state.py +559 -0
  88. ai_stp_cli/local/targets.py +454 -0
  89. ai_stp_cli/local/versions.py +319 -0
  90. ai_stp_cli/messages/en.json +428 -0
  91. ai_stp_cli/messages/ru.json +428 -0
  92. ai_stp_cli/output.py +140 -0
  93. ai_stp_cli/paths.py +282 -0
  94. ai_stp_cli/provider/__init__.py +10 -0
  95. ai_stp_cli/provider/bundle_corpus.py +389 -0
  96. ai_stp_cli/provider/bundle_protocol.py +188 -0
  97. ai_stp_cli/provider/conformance.py +429 -0
  98. ai_stp_cli/provider/conformance_v2.py +268 -0
  99. ai_stp_cli/provider/conformance_v3.py +309 -0
  100. ai_stp_cli/provider/invocation_v2.py +98 -0
  101. ai_stp_cli/provider/invocation_v3.py +54 -0
  102. ai_stp_cli/provider/network_launcher.py +297 -0
  103. ai_stp_cli/provider/operation_v3.py +210 -0
  104. ai_stp_cli/provider/protocol.py +165 -0
  105. ai_stp_cli/provider/protocol_v2.py +238 -0
  106. ai_stp_cli/provider/protocol_v3.py +535 -0
  107. ai_stp_cli/provider/provider-policy.toml +69 -0
  108. ai_stp_cli/provider/release.py +1178 -0
  109. ai_stp_cli/provider/status.py +48 -0
  110. ai_stp_cli/py.typed +0 -0
  111. ai_stp_cli/registry.py +2146 -0
  112. ai_stp_cli/runtime.py +15 -0
  113. ai_stp_cli/secrets.py +323 -0
  114. ai_stp_cli/skill.py +156 -0
  115. ai_stp_cli/skills/canonical/SKILL.md +161 -0
  116. ai_stp_cli/skills/projections/antigravity.md +18 -0
  117. ai_stp_cli/skills/projections/claude-code.md +18 -0
  118. ai_stp_cli/skills/projections/codex.md +18 -0
  119. ai_stp_cli/skills/projections/cursor.md +18 -0
  120. ai_stp_cli/skills/projections/grok-build.md +18 -0
  121. ai_stp_cli/skills/projections/opencode.md +18 -0
  122. ai_stp_cli/skills/projections/pi.md +18 -0
  123. ai_stp_cli/telemetry.py +239 -0
  124. ai_stp_cli/toolchain/__init__.py +309 -0
  125. ai_stp_cli/toolchain/install.py +588 -0
  126. ai_stp_cli/toolchain/mvp-full.toml +56 -0
  127. ai_stp_cli-0.0.3.dist-info/METADATA +41 -0
  128. ai_stp_cli-0.0.3.dist-info/RECORD +131 -0
  129. ai_stp_cli-0.0.3.dist-info/WHEEL +4 -0
  130. ai_stp_cli-0.0.3.dist-info/entry_points.txt +3 -0
  131. ai_stp_cli-0.0.3.dist-info/licenses/LICENSE +661 -0
ai_stp_cli/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """The ai_stp command line interface (issue #72).
2
+
3
+ The agent is the primary consumer, so the machine contract is the product and
4
+ the human rendering is a courtesy. Everything a caller can rely on is declared
5
+ in `ai_stp_contracts`: the envelope, the stable error codes with their exit
6
+ classes, and the machine-help shape five harness projections read.
7
+ """
ai_stp_cli/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """`python -m ai_stp_cli`, equivalent to the `ai-stp` console script.
2
+
3
+ Both entrypoints call the same `run`, so neither can behave differently from the
4
+ other. The module form is what a test can invoke without depending on a console
5
+ script being on PATH.
6
+ """
7
+
8
+ from ai_stp_cli.app import run
9
+
10
+ run()
ai_stp_cli/answer.py ADDED
@@ -0,0 +1,31 @@
1
+ """What a command handler returns.
2
+
3
+ Its own module so the registry can import handlers and handlers can import this
4
+ without a cycle: the registry knows every command, a command knows only how to
5
+ answer.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+ from pydantic import BaseModel
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Answer[T: BaseModel]:
15
+ """A payload, and anything the caller must be told alongside it.
16
+
17
+ A warning does not make the call unsuccessful, so it cannot be an exception,
18
+ and machine mode forbids writing it to stderr — stdout carries exactly one
19
+ JSON object and stderr stays empty. It belongs in the envelope, which is
20
+ where this type carries it. `ADR-0058` uses it to say that a secret went to
21
+ a file rather than to the operating system store, a fact a caller on a
22
+ shared machine has to be able to see.
23
+ """
24
+
25
+ payload: T
26
+ warnings: tuple[str, ...] = ()
27
+
28
+
29
+ def with_warning[T: BaseModel](payload: T, warning: str | None) -> Answer[T]:
30
+ """An answer carrying at most one warning, which is the common case."""
31
+ return Answer(payload, () if warning is None else (warning,))
ai_stp_cli/app.py ADDED
@@ -0,0 +1,489 @@
1
+ """The parser, built from the registry, and the entrypoint (issue #72).
2
+
3
+ `ADR-0057` keeps Click a thin application layer: it parses arguments and calls a
4
+ use case, and it decides nothing about the envelope, the error codes or the exit
5
+ status. This module is where that boundary is enforced.
6
+
7
+ The parser is **constructed from the registry** rather than written beside it.
8
+ `#72` requires machine help to come from the actual command registry, and the
9
+ canonical Skill is told not to guess flags — both hold only if a command has one
10
+ declaration. Two hand-written lists would agree on the day they were written.
11
+
12
+ Nothing Click does reaches the public contract. Click's own usage text, its exit
13
+ status and its help output are all library behaviour; in machine mode each is
14
+ replaced by an envelope carrying a registered `AI_STP_*` code. That is why the
15
+ group runs with `standalone_mode=False`: Click raises instead of printing and
16
+ exiting, and this module decides what the caller actually sees.
17
+ """
18
+
19
+ import io
20
+ import sys
21
+ from collections.abc import Mapping, Sequence
22
+ from typing import Any, Final
23
+
24
+ import click
25
+ from pydantic import ValidationError
26
+
27
+ from ai_stp_cli.errors import (
28
+ CliFailure,
29
+ internal_failure,
30
+ invalid_parameters,
31
+ unknown_command,
32
+ )
33
+ from ai_stp_cli.output import (
34
+ JSON_FLAG,
35
+ new_request_id,
36
+ render_failure,
37
+ render_success,
38
+ wants_machine_mode,
39
+ )
40
+ from ai_stp_cli.registry import COMMANDS, Command
41
+ from ai_stp_contracts.machine_help import CommandParameter
42
+
43
+ PROGRAM_NAME: Final[str] = "ai-stp"
44
+
45
+ #: Click prints its own help and exits. That is right for a person and wrong for
46
+ #: a machine caller, who asked for exactly one JSON object — so in machine mode
47
+ #: the request is answered with a typed error naming the command that does
48
+ #: produce a machine-readable registry.
49
+ _HELP_FLAGS: Final[frozenset[str]] = frozenset({"--help", "-h"})
50
+
51
+ #: The Python name Click derives from `--json`.
52
+ _JSON_PARAMETER: Final[str] = JSON_FLAG.removeprefix("--")
53
+
54
+ #: `CommandPath` in the published contract allows at most four segments, so the
55
+ #: parser refuses a deeper one rather than dropping it while machine help still
56
+ #: advertises it.
57
+ MAXIMUM_PATH_DEPTH: Final[int] = 4
58
+
59
+
60
+ def _json_option() -> click.Option:
61
+ """`--json`, accepted at every level so its position never matters.
62
+
63
+ Which mode was requested is decided by `wants_machine_mode` reading argv,
64
+ not by this option: an invocation that fails to parse never reaches a
65
+ callback, and that is exactly when a machine caller most needs its envelope.
66
+ The option exists so the flag parses wherever it is written and so it shows
67
+ up in the human help.
68
+ """
69
+ return click.Option(
70
+ [JSON_FLAG],
71
+ is_flag=True,
72
+ default=False,
73
+ help="Emit exactly one JSON envelope on stdout and nothing else.",
74
+ )
75
+
76
+
77
+ def _option_for(parameter: CommandParameter) -> click.Option:
78
+ """One declared parameter, as Click sees it."""
79
+ if parameter.value_type == "boolean":
80
+ # A required flag is unusual and deliberate: `help` exists only to
81
+ # produce the machine registry, so an absent `--agent` is a mistake
82
+ # worth naming rather than a default worth guessing. Click cannot model
83
+ # "required flag", so it is declared optional here and enforced in
84
+ # `_require_declared_flags`, which can raise a registered error code
85
+ # instead of Click's usage text.
86
+ return click.Option(
87
+ [f"--{parameter.name}"], is_flag=True, default=False, help=parameter.summary
88
+ )
89
+ if parameter.choices:
90
+ click_type: click.ParamType[Any] = click.Choice(parameter.choices, case_sensitive=True)
91
+ else:
92
+ click_type = click.INT if parameter.value_type == "integer" else click.STRING
93
+ return click.Option(
94
+ [f"--{parameter.name}"],
95
+ type=click_type,
96
+ required=parameter.required,
97
+ multiple=parameter.repeatable,
98
+ help=parameter.summary,
99
+ )
100
+
101
+
102
+ def _callback_for(command: Command) -> Any:
103
+ def _invoke(**parameters: object) -> None:
104
+ context = click.get_current_context()
105
+ state: Mapping[str, object] = context.find_root().obj or {}
106
+ parameters.pop(_JSON_PARAMETER, None)
107
+ declared = _as_declared(command, parameters)
108
+ _require_declared_flags(command, declared)
109
+ answer = command.handler(declared)
110
+ render_success(
111
+ answer.payload,
112
+ machine=bool(state.get("machine")),
113
+ request_id=str(state.get("request_id") or new_request_id()),
114
+ next_actions=list(command.descriptor.next_actions),
115
+ warnings=list(answer.warnings),
116
+ )
117
+
118
+ return _invoke
119
+
120
+
121
+ def _as_declared(command: Command, parameters: Mapping[str, object]) -> Mapping[str, object]:
122
+ """Key the handler's mapping by the names the descriptor declares.
123
+
124
+ Click derives its own key from the flag it parsed, so `--plan-digest`
125
+ arrives as `plan_digest`, while `registry.py` — the single owner of the
126
+ name, and the one machine help publishes — calls it `plan-digest`. A
127
+ handler asking for the declared name would find nothing, and finding
128
+ nothing is indistinguishable from the user leaving the option out: the
129
+ command would not fail, it would quietly do something else.
130
+
131
+ Translated once, here, so that a hyphen stays a detail of the declaration
132
+ rather than something every handler has to know about its own options.
133
+ """
134
+ renamed = {
135
+ parameter.name.replace("-", "_"): parameter.name
136
+ for parameter in command.descriptor.parameters
137
+ }
138
+ return {renamed.get(name, name): value for name, value in parameters.items()}
139
+
140
+
141
+ def _require_declared_flags(command: Command, parameters: Mapping[str, object]) -> None:
142
+ """Enforce a required boolean, which Click models as a flag that defaults off.
143
+
144
+ A confirmation flag is **not** enforced here. Its absence is not a malformed
145
+ command — it is a decision the user has not made — so it carries
146
+ `AI_STP_USER_DECISION_REQUIRED` and exit class 4 rather than a validation
147
+ error and exit class 2. An agent reads those differently: one says "ask the
148
+ user", the other says "you called it wrong". The use case that knows what is
149
+ being confirmed raises it.
150
+ """
151
+ if command.descriptor.confirmation == "explicit_flag":
152
+ return
153
+ for declared in command.descriptor.parameters:
154
+ if declared.value_type != "boolean" or not declared.required:
155
+ continue
156
+ # `_as_declared` already restored the canonical registry spelling, so a
157
+ # hyphenated flag is keyed by that exact name here.
158
+ if not parameters.get(declared.name):
159
+ raise CliFailure(
160
+ "AI_STP_VALIDATION_ERROR",
161
+ f"{command.name} requires --{declared.name}",
162
+ details={"command": command.name},
163
+ next_actions=[f"{command.name} --{declared.name} {JSON_FLAG}"],
164
+ )
165
+
166
+
167
+ def _click_command(command: Command) -> click.Command:
168
+ params: list[click.Parameter] = [
169
+ _option_for(parameter) for parameter in command.descriptor.parameters
170
+ ]
171
+ params.append(_json_option())
172
+ return click.Command(
173
+ name=command.descriptor.path[-1],
174
+ params=params,
175
+ callback=_callback_for(command),
176
+ help=command.descriptor.summary,
177
+ )
178
+
179
+
180
+ def _ignore_root_options(**_parameters: object) -> None:
181
+ """The root group parses `--json` and acts on nothing else."""
182
+
183
+
184
+ def _group(name: str, help_text: str, *, epilog: str | None = None) -> click.Group:
185
+ """An intermediate group.
186
+
187
+ It takes `--json` like everything else, so the flag parses wherever it is
188
+ written. Without that, `ai-stp config --json` reports an unknown option
189
+ instead of the missing subcommand it actually is.
190
+ """
191
+ return click.Group(
192
+ name=name,
193
+ params=[_json_option()],
194
+ callback=_ignore_root_options,
195
+ help=help_text,
196
+ epilog=epilog,
197
+ )
198
+
199
+
200
+ #: What each group of commands is for, in one line.
201
+ #:
202
+ #: `--help` used to answer "Commands for component." — the group name, spelled
203
+ #: back. Every leaf is described precisely in `help --agent`, but a caller
204
+ #: reaches the group first, and a reader who does not already know what
205
+ #: `select` or `target` means learned nothing from the place built to tell
206
+ #: them. Repeating the name is not a description; it only looks like one.
207
+ _GROUP_SUMMARIES: Final[dict[tuple[str, ...], str]] = {
208
+ ("attestation",): "Sign exact test evidence with this device's key.",
209
+ ("auth",): "Sign in, inspect or remove the optional cloud session.",
210
+ ("component",): "Discover, adopt, describe and version single components.",
211
+ ("component", "passport"): "Read, enrich and validate a component's passport.",
212
+ ("component", "scaffold"): "Start a new component from a declared layout.",
213
+ ("component", "source"): "Resolve an external source to an exact, checkable identity.",
214
+ ("component", "source", "evidence"): "Recorded proof about an external source, over time.",
215
+ ("component", "template"): "Render a component's declared template.",
216
+ ("component", "version"): "List recorded versions and release the next one.",
217
+ ("config",): "Read and change this installation's settings.",
218
+ ("consent",): "Grant and withdraw consent for unverified candidates.",
219
+ ("device",): "This machine's identity in the local registry.",
220
+ ("eval",): "Score a setup against a profile before installing it.",
221
+ ("grant",): "Share a private object with another account.",
222
+ ("grant", "invitation"): "Invitations offered but not yet accepted.",
223
+ ("install",): "Plan, apply, resume and recover a setup on a target.",
224
+ ("link",): "Open the matching page on the web.",
225
+ ("owner",): "What this account has published, as its owner sees it.",
226
+ ("owner", "object"): "One owned object across all of its versions.",
227
+ ("owner", "version"): "One exact owned version.",
228
+ ("passport",): "Passports describing the developer and this device.",
229
+ ("passport", "developer"): "The developer passport this account publishes under.",
230
+ ("passport", "device"): "The passport describing this machine.",
231
+ ("project",): "Look inside a directory: projects, components and their index.",
232
+ ("provider",): "Inspect the setup manager that writes the harness.",
233
+ ("publication",): "Publish a component version through plan and confirmation.",
234
+ ("registry",): "Search the local registry and bring objects into it.",
235
+ ("registry", "port"): "Import a setup captured elsewhere into this registry.",
236
+ ("report",): "Report an object to the catalogue's moderators.",
237
+ ("select",): "Choose components and compile them into one setup.",
238
+ ("setup",): "Whole setups: import one, or publish one with its pins.",
239
+ ("setup", "import"): "Bring an existing configuration in as a setup.",
240
+ ("setup", "publish"): "Publish a setup together with the components it pins.",
241
+ ("skill",): "Install this CLI's own agent skill into a harness.",
242
+ ("sync",): "Move local revisions to and from the cloud registry.",
243
+ ("target",): "The installed state on a harness: status, drift, backups, rollback.",
244
+ ("telemetry",): "The anonymous install ping, and whether it is on.",
245
+ ("toolchain",): "Harnesses this machine can reach, and the tools they need.",
246
+ }
247
+
248
+ #: Groups worth showing by example rather than by sentence alone.
249
+ _GROUP_EXAMPLES: Final[dict[tuple[str, ...], tuple[str, ...]]] = {
250
+ ("auth",): (
251
+ "ai-stp auth login --provider google",
252
+ "ai-stp auth login --provider github",
253
+ "ai-stp auth complete",
254
+ "ai-stp auth status",
255
+ ),
256
+ ("install",): (
257
+ "ai-stp install plan --harness claude-code --setup <id> --json",
258
+ "ai-stp install apply --expected-plan-digest <digest> --json",
259
+ "ai-stp install recover --json",
260
+ ),
261
+ ("project",): (
262
+ "ai-stp project discover --root . --json",
263
+ "ai-stp project index --root . --json",
264
+ ),
265
+ }
266
+
267
+
268
+ def _group_content(path: tuple[str, ...]) -> tuple[str, str | None]:
269
+ summary = _GROUP_SUMMARIES.get(path)
270
+ if summary is None: # pragma: no cover — a new group without a line fails a test
271
+ summary = f"Commands for {' '.join(path)}."
272
+ examples = _GROUP_EXAMPLES.get(path)
273
+ if examples is None:
274
+ return summary, None
275
+ return summary, "\b\nExamples:\n" + "\n".join(f" {line}" for line in examples)
276
+
277
+
278
+ def build_group() -> click.Group:
279
+ """Assemble the whole parser from the registry.
280
+
281
+ The walk is recursive over the declared path. `ADR-0057` originally bounded
282
+ it at two levels, on the grounds that a deeper path was untested code
283
+ guarding an unreachable case, and named "the registry needs a deeper path"
284
+ as its revision condition. `passport developer init` met it.
285
+
286
+ The bound that remains is the contract's own: `CommandPath` allows at most
287
+ four segments, so a deeper declaration is refused rather than silently
288
+ dropped from the parser while staying visible in machine help.
289
+ """
290
+ root = _group(
291
+ PROGRAM_NAME,
292
+ "Manage AI harness setups through a strict machine contract.",
293
+ epilog=("First run:\n ai-stp doctor --json\n ai-stp help --agent --json"),
294
+ )
295
+
296
+ for command in sorted(COMMANDS, key=lambda item: item.name):
297
+ path = command.descriptor.path
298
+ if len(path) > MAXIMUM_PATH_DEPTH:
299
+ raise CliFailure(
300
+ "AI_STP_INTERNAL",
301
+ f"command path is deeper than the contract allows: {command.name}",
302
+ details={"depth": str(len(path)), "maximum": str(MAXIMUM_PATH_DEPTH)},
303
+ )
304
+ parent = root
305
+ for index, step in enumerate(path[:-1]):
306
+ existing = parent.commands.get(step)
307
+ if existing is None:
308
+ group_path = tuple(path[: index + 1])
309
+ help_text, epilog = _group_content(group_path)
310
+ child = _group(step, help_text, epilog=epilog)
311
+ parent.add_command(child)
312
+ parent = child
313
+ continue
314
+ if not isinstance(existing, click.Group):
315
+ raise CliFailure(
316
+ "AI_STP_INTERNAL",
317
+ f"a command and a group claim the same name: {step}",
318
+ details={"command": command.name},
319
+ )
320
+ parent = existing
321
+ parent.add_command(_click_command(command))
322
+ return root
323
+
324
+
325
+ def _dispatch(argv: list[str], machine: bool, request_id: str) -> int:
326
+ if machine and _HELP_FLAGS.intersection(argv):
327
+ raise CliFailure(
328
+ "AI_STP_VALIDATION_ERROR",
329
+ "usage text is not machine readable",
330
+ next_actions=[f"help --agent {JSON_FLAG}"],
331
+ )
332
+ if not [item for item in argv if item != JSON_FLAG] and not machine:
333
+ argv = ["--help"]
334
+ elif not [item for item in argv if item != JSON_FLAG]:
335
+ raise CliFailure(
336
+ "AI_STP_VALIDATION_ERROR",
337
+ "no command given",
338
+ next_actions=[f"help --agent {JSON_FLAG}"],
339
+ )
340
+
341
+ # With `standalone_mode=False` Click returns the status it would otherwise
342
+ # have exited with instead of calling `sys.exit`, and returns the callback's
343
+ # own value when nothing asked to exit. `--help` is the path that produces
344
+ # a status; the handlers return nothing.
345
+ outcome = build_group().main(
346
+ args=argv,
347
+ prog_name=PROGRAM_NAME,
348
+ standalone_mode=False,
349
+ obj={"machine": machine, "request_id": request_id},
350
+ )
351
+ return outcome if isinstance(outcome, int) else 0
352
+
353
+
354
+ def _use_utf8_streams() -> None:
355
+ """Make this CLI's own output encodable wherever it runs.
356
+
357
+ Machine mode writes JSON with `ensure_ascii=False` on purpose: a passport
358
+ carries the characters its author wrote rather than their escapes. On a host
359
+ whose standard streams default to a legacy code page — Windows, before UTF-8
360
+ mode became the interpreter default — writing one of those characters raises
361
+ `UnicodeEncodeError`, and the invocation dies inside its own success path
362
+ with an internal failure that says nothing about encoding.
363
+
364
+ Measured rather than anticipated: `toolchain profile` exited 70 with
365
+ `AI_STP_INTERNAL` and `details.exception = UnicodeEncodeError` on
366
+ `windows-latest`, while the same command passed on Linux and macOS.
367
+
368
+ A stream that is not a text wrapper is left alone. Tests substitute their own
369
+ objects, and reconfiguring something a caller supplied would be this
370
+ function reaching outside what it owns.
371
+ """
372
+ for stream in (sys.stdout, sys.stderr):
373
+ if isinstance(stream, io.TextIOWrapper) and (stream.encoding or "").lower() not in {
374
+ "utf-8",
375
+ "utf8",
376
+ }:
377
+ stream.reconfigure(encoding="utf-8")
378
+
379
+
380
+ def main(argv: Sequence[str] | None = None) -> int:
381
+ """Run one invocation and return its exit code without exiting the process."""
382
+ _use_utf8_streams()
383
+ arguments = list(sys.argv[1:] if argv is None else argv)
384
+ machine = wants_machine_mode(arguments)
385
+ request_id = new_request_id()
386
+
387
+ try:
388
+ return _dispatch(arguments, machine, request_id)
389
+ except CliFailure as failure:
390
+ return render_failure(failure, machine=machine, request_id=request_id)
391
+ except click.Abort:
392
+ return 130
393
+ except click.ClickException as failure:
394
+ # Covers UsageError, which is the one a caller actually hits: an unknown
395
+ # command, an unknown flag, or a missing value.
396
+ return render_failure(
397
+ _click_failure(arguments, failure), machine=machine, request_id=request_id
398
+ )
399
+ except KeyboardInterrupt:
400
+ # Not an internal failure: the user stopped it. 130 is the shell's
401
+ # convention and is outside the contract's own classes on purpose.
402
+ return 130
403
+ except ValidationError as error:
404
+ # A request model refused a value the caller supplied. That is bad
405
+ # input with a field name attached, not an internal fault, and it must
406
+ # not arrive as one.
407
+ return render_failure(invalid_parameters(error), machine=machine, request_id=request_id)
408
+ except Exception as error:
409
+ return render_failure(internal_failure(error), machine=machine, request_id=request_id)
410
+
411
+
412
+ def run() -> None:
413
+ """Console-script entrypoint."""
414
+ raise SystemExit(main())
415
+
416
+
417
+ def _auth_providers() -> tuple[str, ...]:
418
+ """The providers `auth login` declares, read from the one place they live.
419
+
420
+ Restating `google, github` here would be a second copy of a closed
421
+ vocabulary that `registry.py` already owns — the same duplication the
422
+ registry exists to prevent.
423
+ """
424
+ for command in COMMANDS:
425
+ if command.descriptor.path == ["auth", "login"]:
426
+ for parameter in command.descriptor.parameters:
427
+ if parameter.name == "provider":
428
+ return tuple(parameter.choices)
429
+ return () # pragma: no cover — `auth login` is a declared command
430
+
431
+
432
+ def _click_failure(arguments: list[str], failure: click.ClickException) -> CliFailure:
433
+ """Turn auth spelling mistakes into a safe, executable correction.
434
+
435
+ Only the mistakes that are actually about the provider. This used to end
436
+ with an unconditional "auth login requires --provider", reached by every
437
+ parse failure under `auth login` — so
438
+
439
+ ai-stp auth login --provider google --bogus --json
440
+
441
+ told the caller to supply a provider it had already supplied correctly,
442
+ and never mentioned `--bogus`. An agent following that instruction edits
443
+ the one argument that was right and loops. The envelope stays valid JSON
444
+ the whole time, which is what makes the wrong answer easy to trust.
445
+
446
+ A repair instruction has to name the argument that is wrong; when this
447
+ adapter cannot show that the provider is wrong, Click's own message about
448
+ the real failure is the better answer.
449
+ """
450
+ command_words = [item for item in arguments if item != JSON_FLAG]
451
+ if command_words[:1] != ["auth"]:
452
+ return unknown_command(failure.format_message())
453
+
454
+ providers = _auth_providers()
455
+ choices = " or ".join(providers)
456
+ allowed = ", ".join(providers)
457
+ next_actions = [f"auth login --provider {name} --json" for name in providers]
458
+
459
+ if command_words[:2] == ["auth", "login"]:
460
+ provider_index = (
461
+ command_words.index("--provider") if "--provider" in command_words else None
462
+ )
463
+ if provider_index is not None and provider_index + 1 < len(command_words):
464
+ supplied = command_words[provider_index + 1]
465
+ if supplied in providers:
466
+ # The provider is right, so this failure is about something
467
+ # else. Say what Click said rather than inventing a subject.
468
+ return unknown_command(failure.format_message())
469
+ return CliFailure(
470
+ "AI_STP_VALIDATION_ERROR",
471
+ f"invalid auth provider; expected {choices}",
472
+ details={"parameter": "provider", "allowed": allowed},
473
+ next_actions=next_actions,
474
+ )
475
+ return CliFailure(
476
+ "AI_STP_VALIDATION_ERROR",
477
+ f"auth login requires --provider with {choices}",
478
+ details={"parameter": "provider", "allowed": allowed},
479
+ next_actions=next_actions,
480
+ )
481
+
482
+ if len(command_words) >= 2 and command_words[1] in providers:
483
+ return CliFailure(
484
+ "AI_STP_VALIDATION_ERROR",
485
+ f"auth commands start with 'auth login'; choose {choices}",
486
+ details={"command": "auth login", "allowed": allowed},
487
+ next_actions=next_actions,
488
+ )
489
+ return unknown_command(failure.format_message())
@@ -0,0 +1,5 @@
1
+ """Everything that talks to the platform.
2
+
3
+ Nothing else in the CLI opens a socket. The boundary is here so the offline
4
+ contour stays offline by construction rather than by discipline.
5
+ """