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.
@@ -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