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.
- ai_stp_cli/__init__.py +7 -0
- ai_stp_cli/__main__.py +10 -0
- ai_stp_cli/answer.py +31 -0
- ai_stp_cli/app.py +489 -0
- ai_stp_cli/cloud/__init__.py +5 -0
- ai_stp_cli/cloud/catalog.py +336 -0
- ai_stp_cli/cloud/client.py +418 -0
- ai_stp_cli/cloud/grants.py +88 -0
- ai_stp_cli/cloud/login.py +285 -0
- ai_stp_cli/cloud/owner.py +53 -0
- ai_stp_cli/cloud/publication.py +163 -0
- ai_stp_cli/cloud/reports.py +29 -0
- ai_stp_cli/cloud/session.py +180 -0
- ai_stp_cli/cloud/sync.py +33 -0
- ai_stp_cli/commands/__init__.py +9 -0
- ai_stp_cli/commands/attestations.py +184 -0
- ai_stp_cli/commands/auth.py +206 -0
- ai_stp_cli/commands/auth_status.py +18 -0
- ai_stp_cli/commands/cloud_auth.py +24 -0
- ai_stp_cli/commands/component.py +790 -0
- ai_stp_cli/commands/config_show.py +113 -0
- ai_stp_cli/commands/device.py +64 -0
- ai_stp_cli/commands/doctor.py +327 -0
- ai_stp_cli/commands/evaluation.py +102 -0
- ai_stp_cli/commands/grants.py +140 -0
- ai_stp_cli/commands/install.py +2256 -0
- ai_stp_cli/commands/link.py +51 -0
- ai_stp_cli/commands/machine_help.py +61 -0
- ai_stp_cli/commands/owner.py +67 -0
- ai_stp_cli/commands/passport.py +164 -0
- ai_stp_cli/commands/project.py +320 -0
- ai_stp_cli/commands/publication.py +171 -0
- ai_stp_cli/commands/registry.py +457 -0
- ai_stp_cli/commands/reports.py +186 -0
- ai_stp_cli/commands/select.py +1631 -0
- ai_stp_cli/commands/setup_publication.py +411 -0
- ai_stp_cli/commands/skill.py +69 -0
- ai_stp_cli/commands/sync.py +423 -0
- ai_stp_cli/commands/telemetry.py +101 -0
- ai_stp_cli/commands/toolchain.py +246 -0
- ai_stp_cli/commands/version.py +24 -0
- ai_stp_cli/config.py +471 -0
- ai_stp_cli/errors.py +101 -0
- ai_stp_cli/i18n.py +49 -0
- ai_stp_cli/identity.py +375 -0
- ai_stp_cli/local/__init__.py +5 -0
- ai_stp_cli/local/authoring.py +519 -0
- ai_stp_cli/local/bundle.py +562 -0
- ai_stp_cli/local/cache.py +340 -0
- ai_stp_cli/local/component_passports.py +718 -0
- ai_stp_cli/local/component_sources.py +566 -0
- ai_stp_cli/local/components.py +1532 -0
- ai_stp_cli/local/composition.py +625 -0
- ai_stp_cli/local/consent.py +237 -0
- ai_stp_cli/local/content.py +102 -0
- ai_stp_cli/local/database.py +950 -0
- ai_stp_cli/local/eligibility.py +694 -0
- ai_stp_cli/local/evaluation.py +379 -0
- ai_stp_cli/local/external_sources.py +145 -0
- ai_stp_cli/local/github_evidence.py +297 -0
- ai_stp_cli/local/graph.py +374 -0
- ai_stp_cli/local/harness_catalog.py +434 -0
- ai_stp_cli/local/harnesses.py +410 -0
- ai_stp_cli/local/impact.py +435 -0
- ai_stp_cli/local/importing.py +827 -0
- ai_stp_cli/local/installation.py +818 -0
- ai_stp_cli/local/interop_sources.py +297 -0
- ai_stp_cli/local/journal.py +179 -0
- ai_stp_cli/local/lifecycle.py +213 -0
- ai_stp_cli/local/managed_diff.py +217 -0
- ai_stp_cli/local/mcp_clients.py +221 -0
- ai_stp_cli/local/mcp_sources.py +402 -0
- ai_stp_cli/local/passports.py +421 -0
- ai_stp_cli/local/project_index.py +361 -0
- ai_stp_cli/local/project_passport.py +246 -0
- ai_stp_cli/local/projects.py +366 -0
- ai_stp_cli/local/provider_releases.py +147 -0
- ai_stp_cli/local/publication_sets.py +172 -0
- ai_stp_cli/local/report_plans.py +75 -0
- ai_stp_cli/local/revisions.py +268 -0
- ai_stp_cli/local/search.py +336 -0
- ai_stp_cli/local/selection.py +656 -0
- ai_stp_cli/local/setup_versions.py +194 -0
- ai_stp_cli/local/store_ports.py +739 -0
- ai_stp_cli/local/symbols.py +367 -0
- ai_stp_cli/local/sync_merge.py +123 -0
- ai_stp_cli/local/sync_state.py +559 -0
- ai_stp_cli/local/targets.py +454 -0
- ai_stp_cli/local/versions.py +319 -0
- ai_stp_cli/messages/en.json +428 -0
- ai_stp_cli/messages/ru.json +428 -0
- ai_stp_cli/output.py +140 -0
- ai_stp_cli/paths.py +282 -0
- ai_stp_cli/provider/__init__.py +10 -0
- ai_stp_cli/provider/bundle_corpus.py +389 -0
- ai_stp_cli/provider/bundle_protocol.py +188 -0
- ai_stp_cli/provider/conformance.py +429 -0
- ai_stp_cli/provider/conformance_v2.py +268 -0
- ai_stp_cli/provider/conformance_v3.py +309 -0
- ai_stp_cli/provider/invocation_v2.py +98 -0
- ai_stp_cli/provider/invocation_v3.py +54 -0
- ai_stp_cli/provider/network_launcher.py +297 -0
- ai_stp_cli/provider/operation_v3.py +210 -0
- ai_stp_cli/provider/protocol.py +165 -0
- ai_stp_cli/provider/protocol_v2.py +238 -0
- ai_stp_cli/provider/protocol_v3.py +535 -0
- ai_stp_cli/provider/provider-policy.toml +69 -0
- ai_stp_cli/provider/release.py +1178 -0
- ai_stp_cli/provider/status.py +48 -0
- ai_stp_cli/py.typed +0 -0
- ai_stp_cli/registry.py +2146 -0
- ai_stp_cli/runtime.py +15 -0
- ai_stp_cli/secrets.py +323 -0
- ai_stp_cli/skill.py +156 -0
- ai_stp_cli/skills/canonical/SKILL.md +161 -0
- ai_stp_cli/skills/projections/antigravity.md +18 -0
- ai_stp_cli/skills/projections/claude-code.md +18 -0
- ai_stp_cli/skills/projections/codex.md +18 -0
- ai_stp_cli/skills/projections/cursor.md +18 -0
- ai_stp_cli/skills/projections/grok-build.md +18 -0
- ai_stp_cli/skills/projections/opencode.md +18 -0
- ai_stp_cli/skills/projections/pi.md +18 -0
- ai_stp_cli/telemetry.py +239 -0
- ai_stp_cli/toolchain/__init__.py +309 -0
- ai_stp_cli/toolchain/install.py +588 -0
- ai_stp_cli/toolchain/mvp-full.toml +56 -0
- ai_stp_cli-0.0.3.dist-info/METADATA +41 -0
- ai_stp_cli-0.0.3.dist-info/RECORD +131 -0
- ai_stp_cli-0.0.3.dist-info/WHEEL +4 -0
- ai_stp_cli-0.0.3.dist-info/entry_points.txt +3 -0
- 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())
|