adopt-cli 0.3.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.
- adopt_cli/__init__.py +11 -0
- adopt_cli/_build_info.py +6 -0
- adopt_cli/commands/__init__.py +1 -0
- adopt_cli/commands/agent.py +193 -0
- adopt_cli/commands/boundary.py +188 -0
- adopt_cli/commands/coverage.py +85 -0
- adopt_cli/commands/detect.py +135 -0
- adopt_cli/commands/doctor.py +99 -0
- adopt_cli/commands/freshness.py +60 -0
- adopt_cli/commands/identity.py +113 -0
- adopt_cli/commands/init.py +268 -0
- adopt_cli/commands/interchange.py +92 -0
- adopt_cli/commands/policy.py +164 -0
- adopt_cli/commands/store.py +98 -0
- adopt_cli/commands/version.py +37 -0
- adopt_cli/config.py +162 -0
- adopt_cli/json_out.py +65 -0
- adopt_cli/main.py +150 -0
- adopt_cli/py.typed +0 -0
- adopt_cli/store_option.py +134 -0
- adopt_cli-0.3.0.dist-info/METADATA +27 -0
- adopt_cli-0.3.0.dist-info/RECORD +26 -0
- adopt_cli-0.3.0.dist-info/WHEEL +4 -0
- adopt_cli-0.3.0.dist-info/entry_points.txt +2 -0
- adopt_cli-0.3.0.dist-info/licenses/LICENSE +201 -0
- adopt_cli-0.3.0.dist-info/licenses/NOTICE +39 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""`adopt probe manifest validate` and `adopt envelope validate` -- contracts §14.
|
|
2
|
+
|
|
3
|
+
Both exit `3` on a violation, because both refusals are `policy` category and
|
|
4
|
+
`02` §13 maps `policy` to `3`. Neither command opens a store to do its work: a
|
|
5
|
+
capability manifest and an outbound envelope are documents, and validating one
|
|
6
|
+
must be possible in a CI job with no `.adopt/` directory anywhere.
|
|
7
|
+
|
|
8
|
+
**The envelope command is the exception that proves the boundary rule.** It needs
|
|
9
|
+
a `BoundaryView`, and the boundary is *stored* -- so `--scope` reads the declared
|
|
10
|
+
one. Without a scope it validates against the default metadata-only boundary,
|
|
11
|
+
which is the strictest one that exists: that fallback can only ever reject more
|
|
12
|
+
than the real boundary would, never less, so a caller who forgets `--scope` gets
|
|
13
|
+
a conservative answer rather than a permissive one.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Annotated, Any
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
from adopt_cli.json_out import emit
|
|
24
|
+
from adopt_cli.store_option import open_configured_store
|
|
25
|
+
from adopt_detect import DEFAULT_OUTBOUND_CATEGORIES, BoundaryView
|
|
26
|
+
from adopt_obs import AdoptError, ErrorCode, now
|
|
27
|
+
from adopt_policy import validate_capability_manifest, validate_envelope
|
|
28
|
+
from adopt_scope import ScopePath
|
|
29
|
+
|
|
30
|
+
__all__ = ["envelope_app", "probe_app"]
|
|
31
|
+
|
|
32
|
+
probe_app = typer.Typer(name="probe", help="Probe definitions and their capability manifests.")
|
|
33
|
+
manifest_app = typer.Typer(name="manifest", help="Capability manifests.")
|
|
34
|
+
probe_app.add_typer(manifest_app)
|
|
35
|
+
envelope_app = typer.Typer(name="envelope", help="Outbound envelopes.")
|
|
36
|
+
|
|
37
|
+
FileArgument = Annotated[Path, typer.Argument(help="The document to validate.")]
|
|
38
|
+
ScopeOption = Annotated[
|
|
39
|
+
str | None,
|
|
40
|
+
typer.Option(
|
|
41
|
+
"--scope",
|
|
42
|
+
help="firm/engagement/system[/environment], to validate against the boundary "
|
|
43
|
+
"declared for that scope. Omitted, the strictest default boundary is used.",
|
|
44
|
+
),
|
|
45
|
+
]
|
|
46
|
+
StoreOption = Annotated[Path | None, typer.Option("--store", help="Store path override.")]
|
|
47
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Emit the strict JSON envelope only.")]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_document(path: Path, *, as_yaml: bool) -> Any:
|
|
51
|
+
"""Parse a YAML or JSON document, refusing rather than guessing.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
AdoptError: ``MANIFEST_INVALID`` when the file is unreadable or malformed.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
text = path.read_text(encoding="utf-8")
|
|
58
|
+
except OSError as error:
|
|
59
|
+
raise AdoptError(
|
|
60
|
+
ErrorCode.MANIFEST_INVALID,
|
|
61
|
+
message=f"cannot read {path}",
|
|
62
|
+
hint="Check the path.",
|
|
63
|
+
) from error
|
|
64
|
+
try:
|
|
65
|
+
return yaml.safe_load(text) if as_yaml else json.loads(text)
|
|
66
|
+
except (yaml.YAMLError, json.JSONDecodeError) as error:
|
|
67
|
+
raise AdoptError(
|
|
68
|
+
ErrorCode.MANIFEST_INVALID,
|
|
69
|
+
message=f"{path} is not a well-formed document: {error}",
|
|
70
|
+
hint="A document that cannot be parsed is refused rather than partially "
|
|
71
|
+
"validated -- an unparseable declaration is not a permissive one.",
|
|
72
|
+
) from error
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _default_boundary() -> BoundaryView:
|
|
76
|
+
"""The strictest boundary: metadata-only, nothing else permitted.
|
|
77
|
+
|
|
78
|
+
Used when no scope is named. It cannot be more permissive than a declared
|
|
79
|
+
boundary, because `metadata_only` is the floor every boundary starts from
|
|
80
|
+
(contracts §8 rule 1) -- so validating against it is conservative by
|
|
81
|
+
construction rather than by luck.
|
|
82
|
+
"""
|
|
83
|
+
return BoundaryView(
|
|
84
|
+
boundary_id="",
|
|
85
|
+
system_id="",
|
|
86
|
+
environment_id=None,
|
|
87
|
+
tier="T0",
|
|
88
|
+
archetype=None,
|
|
89
|
+
knowledge_plane_location="customer",
|
|
90
|
+
control_plane_location="vendor",
|
|
91
|
+
permitted_outbound_categories=DEFAULT_OUTBOUND_CATEGORIES,
|
|
92
|
+
unavailable_capabilities=(),
|
|
93
|
+
contractual_approval_ref=None,
|
|
94
|
+
declared_at=now(),
|
|
95
|
+
decline_recommended=False,
|
|
96
|
+
archetype_floor_violated=False,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _boundary_for(scope: str | None, store: Path | None) -> BoundaryView:
|
|
101
|
+
if scope is None:
|
|
102
|
+
return _default_boundary()
|
|
103
|
+
parsed = ScopePath.parse(scope)
|
|
104
|
+
with open_configured_store(store) as handle:
|
|
105
|
+
resolved = handle.scope().resolve(parsed)
|
|
106
|
+
if resolved.system is None:
|
|
107
|
+
raise AdoptError(
|
|
108
|
+
ErrorCode.SCOPE_VIOLATION,
|
|
109
|
+
message="--scope must name at least firm/engagement/system",
|
|
110
|
+
hint="A boundary is declared for a system.",
|
|
111
|
+
)
|
|
112
|
+
environment_id = None if resolved.environment is None else resolved.environment.id
|
|
113
|
+
row = handle.boundary().current(system_id=resolved.system.id, environment_id=environment_id)
|
|
114
|
+
if row is None:
|
|
115
|
+
raise AdoptError(
|
|
116
|
+
ErrorCode.SCOPE_VIOLATION,
|
|
117
|
+
message=f"no boundary is declared for {scope}",
|
|
118
|
+
hint="Run `adopt init` or `adopt boundary --scope ...` first. Validating "
|
|
119
|
+
"against a boundary that was never declared would mean inventing what "
|
|
120
|
+
"the client agreed to.",
|
|
121
|
+
)
|
|
122
|
+
return BoundaryView.of(row, archetype=None)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@manifest_app.command("validate")
|
|
126
|
+
def validate_manifest(file: FileArgument, json_output: JsonOption = False) -> None:
|
|
127
|
+
"""Validate a probe capability manifest. Exits `3` on a violation."""
|
|
128
|
+
verdict = validate_capability_manifest(_read_document(file, as_yaml=True))
|
|
129
|
+
emit(
|
|
130
|
+
{
|
|
131
|
+
"valid": True,
|
|
132
|
+
"violations": [],
|
|
133
|
+
"probe_id": verdict.probe_id,
|
|
134
|
+
"declared_hosts": list(verdict.declared_hosts),
|
|
135
|
+
"side_effect_policy": verdict.side_effect_policy,
|
|
136
|
+
# Returned, never judged: expiry enforcement is item 8 (PRD F11.5).
|
|
137
|
+
"approval_expires_at": verdict.approval_expires_at,
|
|
138
|
+
},
|
|
139
|
+
as_json=json_output,
|
|
140
|
+
title="adopt probe manifest validate",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@envelope_app.command("validate")
|
|
145
|
+
def validate_outbound(
|
|
146
|
+
file: FileArgument,
|
|
147
|
+
scope: ScopeOption = None,
|
|
148
|
+
store: StoreOption = None,
|
|
149
|
+
json_output: JsonOption = False,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Validate an outbound envelope against the boundary. Exits `3` on a violation."""
|
|
152
|
+
document = _read_document(file, as_yaml=False)
|
|
153
|
+
if not isinstance(document, dict):
|
|
154
|
+
raise AdoptError(
|
|
155
|
+
ErrorCode.MANIFEST_INVALID,
|
|
156
|
+
message=f"{file} is a {type(document).__name__}, not a JSON object",
|
|
157
|
+
hint="Contracts §8 gives the envelope shape.",
|
|
158
|
+
)
|
|
159
|
+
validate_envelope(document, _boundary_for(scope, store))
|
|
160
|
+
emit(
|
|
161
|
+
{"valid": True, "violations": []},
|
|
162
|
+
as_json=json_output,
|
|
163
|
+
title="adopt envelope validate",
|
|
164
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""`adopt store info | doctor | migrate` -- contracts §14.
|
|
2
|
+
|
|
3
|
+
**A coverage gap, closed here.** `02` §14 defines this command group and `01`
|
|
4
|
+
F16.1 lists it among the verbs, and **no sprint task in `05` ever created it** --
|
|
5
|
+
S2 built `adopt_store.doctor` as a library and nothing gave it a command line.
|
|
6
|
+
It surfaces at S8 because Final Output Validation item 7 runs `adopt store info`
|
|
7
|
+
to prove the OSS path needs no Postgres. Recorded as a gap rather than folded in
|
|
8
|
+
silently, the way S0's missing `error_registry_sync.py` and S6's missing
|
|
9
|
+
`adopt init` were.
|
|
10
|
+
|
|
11
|
+
All three emit the one envelope §14 declares -- `{schema_version, counts{},
|
|
12
|
+
findings[]}` -- because a group whose subcommands each invented a shape would
|
|
13
|
+
make the contract three contracts.
|
|
14
|
+
|
|
15
|
+
**`info` and `doctor` open read-only.** Looking must not repair: §8's incident
|
|
16
|
+
card says rebuilding a disagreeing cache first destroys the evidence of whatever
|
|
17
|
+
wrote it. Only `migrate` opens for writing, and it is the only one that can.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Annotated
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
|
|
25
|
+
from adopt_cli.json_out import emit
|
|
26
|
+
from adopt_cli.store_option import open_configured_store, open_for_migration
|
|
27
|
+
from adopt_obs import ExitCode
|
|
28
|
+
|
|
29
|
+
__all__ = ["app"]
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
name="store",
|
|
33
|
+
help="Inspect, diagnose and migrate a store. Looking never repairs.",
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
StoreOption = Annotated[
|
|
38
|
+
Path | None,
|
|
39
|
+
typer.Option("--store", help="Store path. Defaults to the resolved ADOPT_STORE_PATH."),
|
|
40
|
+
]
|
|
41
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Emit the strict JSON envelope only.")]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def info(store: StoreOption = None, json_output: JsonOption = False) -> None:
|
|
46
|
+
"""Schema version and row counts. Reads; never writes."""
|
|
47
|
+
with open_configured_store(store, read_only=True) as handle:
|
|
48
|
+
payload = {
|
|
49
|
+
"schema_version": handle.schema_version,
|
|
50
|
+
"counts": handle.counts(),
|
|
51
|
+
"findings": [],
|
|
52
|
+
}
|
|
53
|
+
emit(payload, as_json=json_output, title="adopt store info")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def doctor(store: StoreOption = None, json_output: JsonOption = False) -> None:
|
|
58
|
+
"""Report every finding in the store, and change nothing.
|
|
59
|
+
|
|
60
|
+
Exit `4` with findings -- degraded success, not failure. The store opened,
|
|
61
|
+
the checks ran, and their answers are trustworthy; what needs a human is
|
|
62
|
+
what they found.
|
|
63
|
+
"""
|
|
64
|
+
with open_configured_store(store, read_only=True) as handle:
|
|
65
|
+
findings = handle.doctor()
|
|
66
|
+
payload = {
|
|
67
|
+
"schema_version": handle.schema_version,
|
|
68
|
+
"counts": handle.counts(),
|
|
69
|
+
"findings": [
|
|
70
|
+
{
|
|
71
|
+
"code": finding.code.value,
|
|
72
|
+
"table": finding.table,
|
|
73
|
+
"subject_id": finding.subject_id,
|
|
74
|
+
"detail": finding.detail,
|
|
75
|
+
}
|
|
76
|
+
for finding in findings
|
|
77
|
+
],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
emit(payload, as_json=json_output, title="adopt store doctor")
|
|
81
|
+
if findings:
|
|
82
|
+
raise typer.Exit(ExitCode.DEGRADED_WITH_FINDINGS)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def migrate(store: StoreOption = None, json_output: JsonOption = False) -> None:
|
|
87
|
+
"""Apply pending forward migrations, then report the resulting version.
|
|
88
|
+
|
|
89
|
+
Forward-only, always: implementation spec §7.4 states the schema has no
|
|
90
|
+
rollback, and recovery is older code against a newer store.
|
|
91
|
+
"""
|
|
92
|
+
with open_for_migration(store) as handle:
|
|
93
|
+
payload = {
|
|
94
|
+
"schema_version": handle.schema_version,
|
|
95
|
+
"counts": handle.counts(),
|
|
96
|
+
"findings": [],
|
|
97
|
+
}
|
|
98
|
+
emit(payload, as_json=json_output, title="adopt store migrate")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""`adopt version` -- contracts §14.
|
|
2
|
+
|
|
3
|
+
Output: ``{version, schema_version, export_version, sbom_sha256, build_id}``.
|
|
4
|
+
|
|
5
|
+
`sbom_sha256` and `build_id` are **build facts, not configuration**: they are
|
|
6
|
+
injected by the release job and are absent from a development checkout, where
|
|
7
|
+
they render as `null`. Reporting a fabricated build id would make the field
|
|
8
|
+
useless for the thing it exists for -- tying a binary in the field back to the
|
|
9
|
+
artifact that was signed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from importlib import metadata
|
|
13
|
+
from typing import Any, Final
|
|
14
|
+
|
|
15
|
+
from adopt_cli._build_info import BUILD_ID, SBOM_SHA256
|
|
16
|
+
from adopt_const import EXPORT_VERSION, SCHEMA_VERSION
|
|
17
|
+
|
|
18
|
+
__all__ = ["build_payload"]
|
|
19
|
+
|
|
20
|
+
_DISTRIBUTION: Final[str] = "adopt-cli"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _package_version() -> str:
|
|
24
|
+
try:
|
|
25
|
+
return metadata.version(_DISTRIBUTION)
|
|
26
|
+
except metadata.PackageNotFoundError: # pragma: no cover -- source checkout only
|
|
27
|
+
return "0.0.0+unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_payload() -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"version": _package_version(),
|
|
33
|
+
"schema_version": SCHEMA_VERSION,
|
|
34
|
+
"export_version": EXPORT_VERSION,
|
|
35
|
+
"sbom_sha256": SBOM_SHA256,
|
|
36
|
+
"build_id": BUILD_ID,
|
|
37
|
+
}
|
adopt_cli/config.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Configuration resolution: flag > environment > project file > user file > default.
|
|
2
|
+
|
|
3
|
+
Every key reports **where its value came from**, because "it works on my
|
|
4
|
+
machine" is almost always a resolution-order question, and answering it by
|
|
5
|
+
reading code takes an order of magnitude longer than reading it off
|
|
6
|
+
``adopt doctor``.
|
|
7
|
+
|
|
8
|
+
Secrets are marked in the registry and are reported by **presence and source
|
|
9
|
+
only, never by value**. A secret is read once at process start into a typed
|
|
10
|
+
object; it never enters a store, a trace, an error message or a log line.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import tomllib
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Final
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"REGISTRY",
|
|
23
|
+
"ConfigKey",
|
|
24
|
+
"Resolution",
|
|
25
|
+
"Source",
|
|
26
|
+
"load_config_file",
|
|
27
|
+
"project_config_path",
|
|
28
|
+
"resolve_all",
|
|
29
|
+
"user_config_path",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Source(StrEnum):
|
|
34
|
+
"""Where a resolved value came from. Order here is resolution order."""
|
|
35
|
+
|
|
36
|
+
FLAG = "flag"
|
|
37
|
+
ENV = "env"
|
|
38
|
+
PROJECT_FILE = "project-file"
|
|
39
|
+
USER_FILE = "user-file"
|
|
40
|
+
DEFAULT = "default"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ConfigKey:
|
|
45
|
+
name: str
|
|
46
|
+
default: str | None
|
|
47
|
+
description: str
|
|
48
|
+
is_secret: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Resolution:
|
|
53
|
+
key: str
|
|
54
|
+
value: str | None
|
|
55
|
+
source: Source
|
|
56
|
+
is_secret: bool
|
|
57
|
+
|
|
58
|
+
def render(self) -> dict[str, str | None]:
|
|
59
|
+
"""The `doctor` shape from contracts §14: ``{key, value, source}``.
|
|
60
|
+
|
|
61
|
+
A secret renders as presence, never as value. There is no verbosity
|
|
62
|
+
flag that reveals it: a flag that can print a secret will eventually be
|
|
63
|
+
used in CI with logs attached to a ticket.
|
|
64
|
+
"""
|
|
65
|
+
shown = ("<set>" if self.value else "<unset>") if self.is_secret else self.value
|
|
66
|
+
return {"key": self.key, "value": shown, "source": str(self.source)}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
#: The configuration registry from implementation spec §3.
|
|
70
|
+
#:
|
|
71
|
+
#: Feature flags are typed accessors and **default off**, without exception.
|
|
72
|
+
#: New behaviour arrives behind a flag that is off until it has earned being on.
|
|
73
|
+
REGISTRY: Final[tuple[ConfigKey, ...]] = (
|
|
74
|
+
ConfigKey("ADOPT_STORE_PATH", ".adopt/store.db", "Canonical SQLite store location."),
|
|
75
|
+
ConfigKey(
|
|
76
|
+
"ADOPT_RUNTIME_PATH",
|
|
77
|
+
".adopt/runtime.db",
|
|
78
|
+
"Runtime annex: agent-run idempotency and in-client audit. Never exported.",
|
|
79
|
+
),
|
|
80
|
+
ConfigKey(
|
|
81
|
+
"ADOPT_OFFLINE",
|
|
82
|
+
"1",
|
|
83
|
+
"Offline is the default posture. Network egress requires an explicit opt-in.",
|
|
84
|
+
),
|
|
85
|
+
ConfigKey("ADOPT_ADAPTER", None, "Configured model adapter id. No default: none is required."),
|
|
86
|
+
ConfigKey("ADOPT_ADAPTER_ENDPOINT", None, "OpenAI-compatible local endpoint, when configured."),
|
|
87
|
+
ConfigKey(
|
|
88
|
+
"ADOPT_MODEL",
|
|
89
|
+
None,
|
|
90
|
+
"Model identifier. Never hard-coded, including as a default -- a default here is "
|
|
91
|
+
"how a tool aimed at every lab acquires a house lab.",
|
|
92
|
+
),
|
|
93
|
+
ConfigKey("ADOPT_LOG_LEVEL", "info", "Minimum emitted log level."),
|
|
94
|
+
ConfigKey("ADOPT_LOG_FORMAT", "json", "Log rendering. JSON is the only supported sink format."),
|
|
95
|
+
ConfigKey("ADOPT_SCRATCH_DIR", None, "Scratch directory for bundle and export work."),
|
|
96
|
+
ConfigKey(
|
|
97
|
+
"ADOPT_PROMPTS_DIR",
|
|
98
|
+
"prompts",
|
|
99
|
+
"Immutable prompt versions (AI spec §5). `03` §1.2 places the directory at the "
|
|
100
|
+
"repository root, which is not inside any package -- so its location is "
|
|
101
|
+
"configuration rather than a path a module can compute, and `doctor` reports "
|
|
102
|
+
"where it resolved from.",
|
|
103
|
+
),
|
|
104
|
+
ConfigKey("ADOPT_FEATURE_AGENT_DISAMBIGUATION", "0", "Archetype disambiguation pass. Off."),
|
|
105
|
+
ConfigKey("ADOPT_FEATURE_DBOS_BACKEND", "0", "DBOS workflow backend. Off."),
|
|
106
|
+
ConfigKey("ADOPT_FEATURE_POSTGRES_STORE", "0", "Postgres store realization. Off."),
|
|
107
|
+
ConfigKey("ADOPT_FEATURE_VECTOR_INDEX", "0", "Vector index behind the VectorIndex seam. Off."),
|
|
108
|
+
ConfigKey("ADOPT_API_KEY", None, "Provider credential, when an adapter is configured.", True),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def project_config_path(cwd: Path | None = None) -> Path:
|
|
113
|
+
return (cwd or Path.cwd()) / ".adopt" / "config.toml"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def user_config_path(home: Path | None = None) -> Path:
|
|
117
|
+
return (home or Path.home()) / ".config" / "adopt" / "config.toml"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_config_file(path: Path) -> dict[str, str]:
|
|
121
|
+
"""Read a config file, tolerating absence but not malformation.
|
|
122
|
+
|
|
123
|
+
A missing file is normal. A malformed file is not silently ignored: a
|
|
124
|
+
typo'd config that is quietly skipped produces the exact "it works on my
|
|
125
|
+
machine" failure this module exists to answer.
|
|
126
|
+
"""
|
|
127
|
+
if not path.exists():
|
|
128
|
+
return {}
|
|
129
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
130
|
+
return {str(k).upper(): str(v) for k, v in data.items() if not isinstance(v, dict)}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_all(
|
|
134
|
+
*,
|
|
135
|
+
flags: Mapping[str, str] | None = None,
|
|
136
|
+
env: Mapping[str, str] | None = None,
|
|
137
|
+
project: Mapping[str, str] | None = None,
|
|
138
|
+
user: Mapping[str, str] | None = None,
|
|
139
|
+
) -> list[Resolution]:
|
|
140
|
+
"""Resolve every registered key, recording the winning source.
|
|
141
|
+
|
|
142
|
+
All four layers are injectable so the resolution order can be tested
|
|
143
|
+
without touching the real environment or the real filesystem.
|
|
144
|
+
"""
|
|
145
|
+
layers: tuple[tuple[Source, Mapping[str, str]], ...] = (
|
|
146
|
+
(Source.FLAG, flags or {}),
|
|
147
|
+
(Source.ENV, os.environ if env is None else env),
|
|
148
|
+
(Source.PROJECT_FILE, project or {}),
|
|
149
|
+
(Source.USER_FILE, user or {}),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
resolutions: list[Resolution] = []
|
|
153
|
+
for key in REGISTRY:
|
|
154
|
+
value: str | None = key.default
|
|
155
|
+
source = Source.DEFAULT
|
|
156
|
+
for candidate_source, layer in layers:
|
|
157
|
+
found = layer.get(key.name)
|
|
158
|
+
if found:
|
|
159
|
+
value, source = found, candidate_source
|
|
160
|
+
break
|
|
161
|
+
resolutions.append(Resolution(key.name, value, source, key.is_secret))
|
|
162
|
+
return resolutions
|
adopt_cli/json_out.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Output rendering. `--json` suppresses every human affordance.
|
|
2
|
+
|
|
3
|
+
Two renderings, one source. The JSON envelope is a contract from 0.3.0: command
|
|
4
|
+
names, flags, JSON keys and exit codes are additive-only thereafter, exactly as
|
|
5
|
+
for the schema. The human rendering is free to change.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
__all__ = ["emit", "emit_error"]
|
|
16
|
+
|
|
17
|
+
_stdout = Console(file=sys.stdout, highlight=False, soft_wrap=True)
|
|
18
|
+
_stderr = Console(file=sys.stderr, highlight=False, soft_wrap=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def emit(payload: dict[str, Any], *, as_json: bool, title: str = "") -> None:
|
|
22
|
+
"""Render a command result.
|
|
23
|
+
|
|
24
|
+
In JSON mode nothing but the envelope reaches stdout -- no banner, no
|
|
25
|
+
colour, no progress. A caller piping into `jq` must not have to strip
|
|
26
|
+
anything.
|
|
27
|
+
"""
|
|
28
|
+
if as_json:
|
|
29
|
+
print(json.dumps(payload, indent=2, sort_keys=False, default=str))
|
|
30
|
+
return
|
|
31
|
+
_render_human(payload, title)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _render_human(payload: dict[str, Any], title: str) -> None:
|
|
35
|
+
if title:
|
|
36
|
+
_stdout.print(f"[bold]{title}[/bold]")
|
|
37
|
+
for key, value in payload.items():
|
|
38
|
+
if isinstance(value, list) and value and isinstance(value[0], dict):
|
|
39
|
+
table = Table(title=key, title_justify="left", show_lines=False)
|
|
40
|
+
for column in value[0]:
|
|
41
|
+
table.add_column(column)
|
|
42
|
+
for row in value:
|
|
43
|
+
table.add_row(*[str(row.get(c, "")) for c in value[0]])
|
|
44
|
+
_stdout.print(table)
|
|
45
|
+
elif isinstance(value, list):
|
|
46
|
+
_stdout.print(f"{key}: {', '.join(str(v) for v in value) or '(none)'}")
|
|
47
|
+
elif isinstance(value, dict):
|
|
48
|
+
_stdout.print(f"{key}:")
|
|
49
|
+
for inner_key, inner_value in value.items():
|
|
50
|
+
_stdout.print(f" {inner_key}: {inner_value}")
|
|
51
|
+
else:
|
|
52
|
+
_stdout.print(f"{key}: {value}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def emit_error(envelope: dict[str, Any], *, as_json: bool) -> None:
|
|
56
|
+
"""Render the one documented error envelope to stderr."""
|
|
57
|
+
if as_json:
|
|
58
|
+
print(json.dumps(envelope, indent=2, default=str), file=sys.stderr)
|
|
59
|
+
return
|
|
60
|
+
error = envelope.get("error", {})
|
|
61
|
+
_stderr.print(f"[bold red]{error.get('code')}[/bold red] ({error.get('category')})")
|
|
62
|
+
if error.get("message"):
|
|
63
|
+
_stderr.print(str(error["message"]))
|
|
64
|
+
if error.get("hint"):
|
|
65
|
+
_stderr.print(f"[dim]hint: {error['hint']}[/dim]")
|
adopt_cli/main.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The `adopt` CLI entry point.
|
|
2
|
+
|
|
3
|
+
Three postures are set here and inherited by every command added later:
|
|
4
|
+
|
|
5
|
+
* **`--json` on every command.** The JSON envelope is a contract from 0.3.0.
|
|
6
|
+
* **Offline by default.** Network egress requires `--allow-network`. In offline
|
|
7
|
+
mode the process opens no socket other than to a configured local adapter
|
|
8
|
+
endpoint.
|
|
9
|
+
* **Typed errors map to stable exit codes.** `0` success, `1` operational
|
|
10
|
+
failure, `2` usage error, `3` policy refusal, `4` degraded success with
|
|
11
|
+
findings. The mapping lives in `adopt_obs.errors` -- a second copy here would
|
|
12
|
+
be a second place to get it wrong.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Annotated
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from adopt_cli.commands import agent as agent_commands
|
|
22
|
+
from adopt_cli.commands import boundary as boundary_command
|
|
23
|
+
from adopt_cli.commands import coverage as coverage_commands
|
|
24
|
+
from adopt_cli.commands import detect as detect_command
|
|
25
|
+
from adopt_cli.commands import doctor as doctor_command
|
|
26
|
+
from adopt_cli.commands import freshness as freshness_commands
|
|
27
|
+
from adopt_cli.commands import identity as identity_commands
|
|
28
|
+
from adopt_cli.commands import init as init_command
|
|
29
|
+
from adopt_cli.commands import interchange as interchange_commands
|
|
30
|
+
from adopt_cli.commands import policy as policy_commands
|
|
31
|
+
from adopt_cli.commands import store as store_commands
|
|
32
|
+
from adopt_cli.commands import version as version_command
|
|
33
|
+
from adopt_cli.json_out import emit, emit_error
|
|
34
|
+
from adopt_obs import AdoptError, ExitCode, get_logger
|
|
35
|
+
|
|
36
|
+
__all__ = ["app", "main"]
|
|
37
|
+
|
|
38
|
+
app = typer.Typer(
|
|
39
|
+
name="adopt",
|
|
40
|
+
help="Adoption-Phase Platform CLI. Offline by default; no telemetry, ever.",
|
|
41
|
+
no_args_is_help=True,
|
|
42
|
+
add_completion=False,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
app.add_typer(identity_commands.app)
|
|
46
|
+
app.add_typer(coverage_commands.app)
|
|
47
|
+
app.add_typer(freshness_commands.app)
|
|
48
|
+
app.add_typer(store_commands.app)
|
|
49
|
+
app.add_typer(policy_commands.probe_app)
|
|
50
|
+
app.add_typer(policy_commands.envelope_app)
|
|
51
|
+
app.add_typer(agent_commands.app)
|
|
52
|
+
|
|
53
|
+
# `init`, `detect` and `boundary` are bare commands, not groups: contracts §14
|
|
54
|
+
# names them `adopt init [path]`, `adopt detect [path]` and `adopt boundary`.
|
|
55
|
+
app.command("init")(init_command.init)
|
|
56
|
+
app.command("detect")(detect_command.detect)
|
|
57
|
+
app.command("boundary")(boundary_command.boundary)
|
|
58
|
+
|
|
59
|
+
# Registered as bare commands rather than a group: contracts §14 names them
|
|
60
|
+
# `adopt export DIR` and `adopt import DIR`. `import` is a Python keyword, so the
|
|
61
|
+
# function is `import_` and the command name is given explicitly -- the CLI
|
|
62
|
+
# surface is the contract, not the identifier that happens to implement it.
|
|
63
|
+
app.command("export")(interchange_commands.export)
|
|
64
|
+
app.command("import")(interchange_commands.import_)
|
|
65
|
+
|
|
66
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Emit the strict JSON envelope only.")]
|
|
67
|
+
NetworkOption = Annotated[
|
|
68
|
+
bool,
|
|
69
|
+
typer.Option(
|
|
70
|
+
"--allow-network",
|
|
71
|
+
help="Permit network egress for this invocation. Offline is the default posture.",
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.callback()
|
|
77
|
+
def _root(ctx: typer.Context, allow_network: NetworkOption = False) -> None:
|
|
78
|
+
"""Set the process posture before any command runs."""
|
|
79
|
+
ctx.ensure_object(dict)
|
|
80
|
+
ctx.obj["allow_network"] = allow_network
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command()
|
|
84
|
+
def version(json_output: JsonOption = False) -> None:
|
|
85
|
+
"""Report the binary, schema and export versions and the build provenance."""
|
|
86
|
+
emit(version_command.build_payload(), as_json=json_output, title="adopt version")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def doctor(json_output: JsonOption = False) -> None:
|
|
91
|
+
"""Report every configuration key with its resolved value and source.
|
|
92
|
+
|
|
93
|
+
Exits `4` when there are findings: degraded success, not failure. `doctor`
|
|
94
|
+
never repairs what it reports.
|
|
95
|
+
"""
|
|
96
|
+
payload, findings = doctor_command.build_payload()
|
|
97
|
+
emit(payload, as_json=json_output, title="adopt doctor")
|
|
98
|
+
if findings:
|
|
99
|
+
raise typer.Exit(ExitCode.DEGRADED_WITH_FINDINGS)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _wants_json(argv: list[str] | None) -> bool:
|
|
103
|
+
return "--json" in (argv if argv is not None else sys.argv[1:])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _exit_code_of(error: click.ClickException | click.exceptions.Exit) -> int:
|
|
107
|
+
if isinstance(error, click.exceptions.Exit):
|
|
108
|
+
return int(error.exit_code)
|
|
109
|
+
if isinstance(error, click.UsageError):
|
|
110
|
+
error.show()
|
|
111
|
+
return ExitCode.USAGE_ERROR
|
|
112
|
+
error.show()
|
|
113
|
+
return ExitCode.OPERATIONAL_FAILURE
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main(argv: list[str] | None = None) -> int:
|
|
117
|
+
"""Console-script entry point carrying the contracts §13 exit-code mapping.
|
|
118
|
+
|
|
119
|
+
A typed `AdoptError` escaping a command is rendered as the one documented
|
|
120
|
+
envelope and mapped to its category's exit code here, so no command has to
|
|
121
|
+
remember to do it. Click's own control-flow exceptions are translated in the
|
|
122
|
+
same place for the same reason.
|
|
123
|
+
|
|
124
|
+
**`app(...)` is invoked with `standalone_mode=False`, and in that mode Click
|
|
125
|
+
*returns* the exit code of a `typer.Exit` rather than raising it.** Discarding
|
|
126
|
+
the return value therefore silently turned every deliberate non-zero exit
|
|
127
|
+
into `0` -- including the `4` that contracts §14 gives `adopt doctor` and
|
|
128
|
+
`adopt coverage recompute` when there are findings. The `except` clause below
|
|
129
|
+
still catches an `Exit` raised from outside a command, so both paths are
|
|
130
|
+
covered; neither on its own is.
|
|
131
|
+
"""
|
|
132
|
+
log = get_logger("adopt_cli")
|
|
133
|
+
try:
|
|
134
|
+
result = app(args=argv, standalone_mode=False)
|
|
135
|
+
except AdoptError as error:
|
|
136
|
+
emit_error(error.to_envelope(), as_json=_wants_json(argv))
|
|
137
|
+
log.error("cli.failed", code=str(error.code), category=str(error.category))
|
|
138
|
+
return error.exit_code
|
|
139
|
+
except click.exceptions.Abort:
|
|
140
|
+
log.warn("cli.aborted")
|
|
141
|
+
return ExitCode.OPERATIONAL_FAILURE
|
|
142
|
+
except (click.ClickException, click.exceptions.Exit) as error:
|
|
143
|
+
return _exit_code_of(error)
|
|
144
|
+
# A command returning an `int` returned it through `typer.Exit`; commands
|
|
145
|
+
# return `None` otherwise, so there is no value here to confuse with a code.
|
|
146
|
+
return result if isinstance(result, int) else ExitCode.SUCCESS
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__": # pragma: no cover -- exercised through the release entry point
|
|
150
|
+
sys.exit(main())
|
adopt_cli/py.typed
ADDED
|
File without changes
|