scripts.secrets_cli

Reference › scripts › secrets_cli

scripts/secrets_cli.py is the command-line tool that owns the encrypt and decrypt lifecycle for the repository's secrets, using SOPS with age keys. A human runs it when a machine is set up or the secrets change. Other scripts only consume its output; for example provision_worktree_env materialises a worktree's .env. It has no internal imports.

The program name is secrets and it has five subcommands, dispatched by main: doctor (a preflight of everything needed to decrypt), init (create an age identity and .sops.yaml if absent), encrypt (encrypt the plaintext .env, or --source, into the tracked ciphertext), decrypt and status. They share --repo-root and --identity (default SOPS_AGE_KEY_FILE or the standard age keys path); the others add --sops-config and --ciphertext. Helpers include resolve_identity_path, derive_public_key (the age recipient for an identity), load_sops_config, find_creation_rule and write_sops_config, and diff_keys, which compares plaintext and ciphertext by key names only, never by value. _run wraps the sops and age subprocesses. An unhandled command returns the failure code instead of falling through to success.

At a glance

Source scripts/secrets_cli.py
Lines of code 1003
Classes 8
Functions 23
Imports (internal) none
Imported by none
Tests that import it none

Class diagram

Class diagram scripts.secrets_cli

Dependency diagram

No internal imports and no importers.

API

Classes

SecretsError

Bases: RuntimeError · Line 73

A sops/age invocation failed in a way the caller must see.

CommandResult (dataclass)

Decorators: dataclass · Line 83

Attribute Type Default
returncode int
stdout str
stderr str
Method Signature Summary
ok property ok() -> bool

DoctorCheck (dataclass)

Decorators: dataclass · Line 279

Attribute Type Default
label str
passed bool
detail str ''
Method Signature Summary
render render() -> str

DoctorReport (dataclass)

Decorators: dataclass · Line 291

Attribute Type Default
checks list[DoctorCheck] field(default_factory=list)
Method Signature Summary
all_passed property all_passed() -> bool
render render() -> str

InitResult (dataclass)

Decorators: dataclass · Line 419

Attribute Type Default
ok bool
identity_created bool
identity_path Path
recipient str | None
message str

EncryptResult (dataclass)

Decorators: dataclass · Line 492

Attribute Type Default
ok bool
written bool
key_count int
message str

DecryptResult (dataclass)

Decorators: dataclass · Line 600

Attribute Type Default
ok bool
written bool
refused bool
differing_keys list[str]
message str

StatusReport (dataclass)

Decorators: dataclass · Line 732

Attribute Type Default
key_present bool
ciphertext_present bool
source_present bool
in_sync bool | None
key_count int
differing_keys list[str]
error str | None None
Method Signature Summary
to_dict to_dict() -> dict
render render() -> str

Functions

Function Signature Summary
_run _run(cmd: list[str], *, cwd: Path, env: Mapping[str, str], input_text: str | None = None) -> CommandResult Run one subprocess, real stdin/stdout/stderr, never raising on a nonzero exit — the caller decides what a failure means.
resolve_identity_path resolve_identity_path(env: Mapping[str, str] | None = None) -> Path SOPS_AGE_KEY_FILE if set (read from env, not os.environ directly, so a caller can inject a throwaway identity without mutating process state), else the XDG default.
_sops_env _sops_env(identity_path: Path, base_env: Mapping[str, str] | None = None) -> dict[str, str]
derive_public_key derive_public_key(identity_path: Path, *, cwd: Path, env: Mapping[str, str]) -> str The age recipient (public key) for an identity file, via age-keygen -y — the same derivation used to populate .sops.yaml and to check it hasn't drifted.
load_sops_config load_sops_config(config_path: Path) -> dict
find_creation_rule find_creation_rule(config: dict, path_regex: str) -> dict | None
write_sops_config write_sops_config(config_path: Path, recipient: str, path_regex: str) -> None
_canonical_from_ciphertext _canonical_from_ciphertext(ciphertext_path: Path, *, cwd: Path, env: Mapping[str, str]) -> dict[str, str]
_canonical_from_plaintext _canonical_from_plaintext(source_path: Path, *, cwd: Path, env: Mapping[str, str], filename_override: str = CIPHERTEXT_RELPATH) -> dict[str, str] SOPS's own parse of a plaintext dotenv file, as key/value JSON — never a hand-rolled dotenv parser.
diff_keys diff_keys(a: Mapping[str, str], b: Mapping[str, str]) -> list[str] Key NAMES where the two maps disagree — added, removed, or changed.
run_doctor run_doctor(*, repo_root: Path = REPO_ROOT, identity_path: Path | None = None, sops_config_path: Path | None = None, ciphertext_path: Path | None = None, base_env: Mapping[str, str] | None = None) -> DoctorReport
run_init run_init(*, repo_root: Path = REPO_ROOT, identity_path: Path | None = None, sops_config_path: Path | None = None, base_env: Mapping[str, str] | None = None) -> InitResult
run_encrypt run_encrypt(*, repo_root: Path = REPO_ROOT, source_path: Path | None = None, ciphertext_path: Path | None = None, sops_config_path: Path | None = None, identity_path: Path | None = None, base_env: Mapping[str, str] | None = None) -> EncryptResult
_atomic_write _atomic_write(path: Path, content: str, *, mode: int) -> None Write via a temp file + os.replace so an interrupted write can never leave a truncated file at path.
run_decrypt run_decrypt(*, repo_root: Path = REPO_ROOT, dest_path: Path | None = None, ciphertext_path: Path | None = None, identity_path: Path | None = None, force: bool = False, base_env: Mapping[str, str] | None = None) -> DecryptResult
run_status run_status(*, repo_root: Path = REPO_ROOT, source_path: Path | None = None, ciphertext_path: Path | None = None, identity_path: Path | None = None, base_env: Mapping[str, str] | None = None) -> StatusReport
build_parser build_parser() -> argparse.ArgumentParser
cmd_doctor cmd_doctor(args: argparse.Namespace) -> int
cmd_init cmd_init(args: argparse.Namespace) -> int
cmd_encrypt cmd_encrypt(args: argparse.Namespace) -> int
cmd_decrypt cmd_decrypt(args: argparse.Namespace) -> int
cmd_status cmd_status(args: argparse.Namespace) -> int
main main(argv: list[str] | None = None) -> int

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/scripts/secrets_cli.py

#!/usr/bin/env python3
"""CLI that owns the encrypt/decrypt lifecycle for the repo's secrets.

Threat model: ``.env`` holds live API keys and passwords. It must be
recoverable from the repo on any machine that has THE KEY, and unreadable to
anyone who does not. Plaintext must be structurally incapable of reaching a
commit.

Crypto: SOPS (system binary) + age (system binary) — no PyPI crypto
dependency. The age identity (PRIVATE key) lives outside the repo, at
``~/.config/sops/age/keys.txt`` by default (overridable via
``SOPS_AGE_KEY_FILE``, or the ``--identity`` flag on every subcommand). The
age recipient (PUBLIC key) lives in the tracked ``.sops.yaml``.

Tracked ciphertext: ``secrets/env.sops.env`` — SOPS dotenv format, so a
``git diff`` shows WHICH setting changed without revealing what it changed
to. Plaintext ``.env`` stays gitignored forever; nothing here installs a
clean/smudge filter, because a filter absent on a fresh clone would silently
commit plaintext.

Idempotence: SOPS mints a fresh data key on every ``encrypt`` invocation, so
a naive "always write" implementation churns the ciphertext (and git) on
every run even when nothing changed. ``encrypt`` decrypts the EXISTING
ciphertext (if any), canonicalizes it and the source into key/value maps via
SOPS's own JSON codec (never a hand-rolled dotenv parser — comments, blank
lines, and literal quoting inside values are SOPS's call, not ours), and
skips the write when the maps are equal.

Usage::

    python scripts/secrets_cli.py doctor
    python scripts/secrets_cli.py init
    python scripts/secrets_cli.py encrypt [--source .env]
    python scripts/secrets_cli.py decrypt [--dest .env] [--force]
    python scripts/secrets_cli.py status [--json]
"""

from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path

import yaml

REPO_ROOT = Path(__file__).resolve().parent.parent

DEFAULT_IDENTITY_PATH = Path.home() / ".config" / "sops" / "age" / "keys.txt"
DEFAULT_SOURCE = REPO_ROOT / ".env"
DEFAULT_CIPHERTEXT = REPO_ROOT / "secrets" / "env.sops.env"
DEFAULT_SOPS_CONFIG = REPO_ROOT / ".sops.yaml"

# The ciphertext's path RELATIVE TO THE REPO ROOT. This is both the tracked
# artifact's real location and the string SOPS matches .sops.yaml
# creation_rules against (via --filename-override, since the real source
# file we hand SOPS is .env, not secrets/env.sops.env — the plaintext is
# never the thing SOPS's own path-based rule matching sees).
CIPHERTEXT_RELPATH = "secrets/env.sops.env"
PATH_REGEX = r"secrets/env\.sops\.env$"

EXIT_OK = 0
EXIT_FAIL = 1
EXIT_PRECONDITION = 2


class SecretsError(RuntimeError):
    """A sops/age invocation failed in a way the caller must see."""


# ---------------------------------------------------------------------------
# Subprocess plumbing — the ONE place sops/age get invoked.
# ---------------------------------------------------------------------------


@dataclass
class CommandResult:
    returncode: int
    stdout: str
    stderr: str

    @property
    def ok(self) -> bool:
        return self.returncode == 0


def _run(
    cmd: list[str],
    *,
    cwd: Path,
    env: Mapping[str, str],
    input_text: str | None = None,
) -> CommandResult:
    """Run one subprocess, real stdin/stdout/stderr, never raising on a
    nonzero exit — the caller decides what a failure means.

    ``subprocess.run`` raises the same ``FileNotFoundError`` whether the
    executable is missing or ``cwd`` doesn't exist; checking ``cwd`` first
    keeps the reported cause honest instead of blaming the binary for a
    caller bug in the working directory.
    """
    if not Path(cwd).is_dir():
        return CommandResult(
            returncode=127, stdout="", stderr=f"cwd does not exist: {cwd}"
        )
    try:
        proc = subprocess.run(
            cmd,
            cwd=str(cwd),
            env=dict(env),
            input=input_text,
            capture_output=True,
            text=True,
            encoding="utf-8",
        )
    except FileNotFoundError:
        return CommandResult(
            returncode=127, stdout="", stderr=f"{cmd[0]}: command not found on PATH"
        )
    return CommandResult(proc.returncode, proc.stdout, proc.stderr)


def resolve_identity_path(env: Mapping[str, str] | None = None) -> Path:
    """SOPS_AGE_KEY_FILE if set (read from ``env``, not ``os.environ``
    directly, so a caller can inject a throwaway identity without mutating
    process state), else the XDG default."""
    source = env if env is not None else os.environ
    override = (source.get("SOPS_AGE_KEY_FILE") or "").strip()
    if override:
        return Path(override)
    return DEFAULT_IDENTITY_PATH


def _sops_env(
    identity_path: Path, base_env: Mapping[str, str] | None = None
) -> dict[str, str]:
    merged = dict(base_env if base_env is not None else os.environ)
    merged["SOPS_AGE_KEY_FILE"] = str(identity_path)
    return merged


def derive_public_key(identity_path: Path, *, cwd: Path, env: Mapping[str, str]) -> str:
    """The age recipient (public key) for an identity file, via
    ``age-keygen -y`` — the same derivation used to populate .sops.yaml and
    to check it hasn't drifted."""
    result = _run(["age-keygen", "-y", str(identity_path)], cwd=cwd, env=env)
    if not result.ok:
        raise SecretsError(f"age-keygen -y failed: {result.stderr.strip()}")
    pub = result.stdout.strip()
    if not pub:
        raise SecretsError("age-keygen -y produced no public key")
    return pub


# ---------------------------------------------------------------------------
# .sops.yaml
# ---------------------------------------------------------------------------


def load_sops_config(config_path: Path) -> dict:
    if not config_path.exists():
        return {}
    loaded = yaml.safe_load(config_path.read_text(encoding="utf-8"))
    return loaded or {}


def find_creation_rule(config: dict, path_regex: str) -> dict | None:
    for rule in config.get("creation_rules") or []:
        if rule.get("path_regex") == path_regex:
            return rule
    return None


def write_sops_config(config_path: Path, recipient: str, path_regex: str) -> None:
    config = load_sops_config(config_path)
    rules = list(config.get("creation_rules") or [])
    rule = find_creation_rule({"creation_rules": rules}, path_regex)
    if rule is not None:
        rule["age"] = recipient
    else:
        rules.append({"path_regex": path_regex, "age": recipient})
    config["creation_rules"] = rules
    config_path.write_text(
        yaml.safe_dump(config, sort_keys=False, default_flow_style=False),
        encoding="utf-8",
    )


# ---------------------------------------------------------------------------
# Canonical key/value view — always via SOPS's own dotenv<->json codec.
# ---------------------------------------------------------------------------


def _canonical_from_ciphertext(
    ciphertext_path: Path, *, cwd: Path, env: Mapping[str, str]
) -> dict[str, str]:
    result = _run(
        [
            "sops",
            "decrypt",
            "--input-type",
            "dotenv",
            "--output-type",
            "json",
            str(ciphertext_path),
        ],
        cwd=cwd,
        env=env,
    )
    if not result.ok:
        raise SecretsError(f"sops decrypt failed: {result.stderr.strip()}")
    return json.loads(result.stdout or "{}")


def _canonical_from_plaintext(
    source_path: Path,
    *,
    cwd: Path,
    env: Mapping[str, str],
    filename_override: str = CIPHERTEXT_RELPATH,
) -> dict[str, str]:
    """SOPS's own parse of a plaintext dotenv file, as key/value JSON —
    never a hand-rolled dotenv parser. Round-trips the plaintext through a
    throwaway in-memory encrypt+decrypt so comments/blank-lines/quoting are
    canonicalized exactly the way the real ciphertext canonicalizes them.
    Nothing is written to disk by this call.
    """
    encrypted = _run(
        [
            "sops",
            "encrypt",
            "--input-type",
            "dotenv",
            "--output-type",
            "json",
            "--filename-override",
            filename_override,
            str(source_path),
        ],
        cwd=cwd,
        env=env,
    )
    if not encrypted.ok:
        raise SecretsError(
            f"sops encrypt (canonicalization) failed: {encrypted.stderr.strip()}"
        )
    decrypted = _run(
        ["sops", "decrypt", "--input-type", "json", "--output-type", "json"],
        cwd=cwd,
        env=env,
        input_text=encrypted.stdout,
    )
    if not decrypted.ok:
        raise SecretsError(
            f"sops decrypt (canonicalization) failed: {decrypted.stderr.strip()}"
        )
    return json.loads(decrypted.stdout or "{}")


def diff_keys(a: Mapping[str, str], b: Mapping[str, str]) -> list[str]:
    """Key NAMES where the two maps disagree — added, removed, or changed.
    Never returns a value, only the name of the key that differs."""
    keys = set(a) | set(b)
    return sorted(k for k in keys if a.get(k) != b.get(k))


# ---------------------------------------------------------------------------
# doctor
# ---------------------------------------------------------------------------


@dataclass
class DoctorCheck:
    label: str
    passed: bool
    detail: str = ""

    def render(self) -> str:
        status = "PASS" if self.passed else "FAIL"
        suffix = f" — {self.detail}" if self.detail else ""
        return f"[{status}] {self.label}{suffix}"


@dataclass
class DoctorReport:
    checks: list[DoctorCheck] = field(default_factory=list)

    @property
    def all_passed(self) -> bool:
        return all(c.passed for c in self.checks)

    def render(self) -> str:
        return "\n".join(c.render() for c in self.checks)


def run_doctor(
    *,
    repo_root: Path = REPO_ROOT,
    identity_path: Path | None = None,
    sops_config_path: Path | None = None,
    ciphertext_path: Path | None = None,
    base_env: Mapping[str, str] | None = None,
) -> DoctorReport:
    base_env = dict(base_env if base_env is not None else os.environ)
    identity_path = identity_path or resolve_identity_path(base_env)
    sops_config_path = sops_config_path or (repo_root / ".sops.yaml")
    ciphertext_path = ciphertext_path or (repo_root / CIPHERTEXT_RELPATH)

    checks: list[DoctorCheck] = []

    sops_bin = shutil.which("sops")
    if sops_bin:
        version_result = _run(["sops", "--version"], cwd=repo_root, env=base_env)
        version = (
            version_result.stdout.strip().splitlines()[0]
            if version_result.ok
            else "unknown version"
        )
        checks.append(DoctorCheck("sops on PATH", True, version))
    else:
        checks.append(DoctorCheck("sops on PATH", False, "not found"))

    age_bin = shutil.which("age")
    checks.append(
        DoctorCheck("age on PATH", age_bin is not None, age_bin or "not found")
    )

    age_keygen_bin = shutil.which("age-keygen")
    checks.append(
        DoctorCheck(
            "age-keygen on PATH",
            age_keygen_bin is not None,
            age_keygen_bin or "not found",
        )
    )

    identity_present = identity_path.exists()
    identity_readable = identity_present and os.access(identity_path, os.R_OK)
    checks.append(
        DoctorCheck(
            "identity file present and readable",
            identity_present and identity_readable,
            (
                str(identity_path)
                if identity_present
                else f"{identity_path} does not exist"
            ),
        )
    )

    config_present = sops_config_path.exists()
    checks.append(
        DoctorCheck(
            ".sops.yaml present",
            config_present,
            (
                str(sops_config_path)
                if config_present
                else f"{sops_config_path} does not exist"
            ),
        )
    )

    recipient_matches = False
    recipient_detail = "cannot check — identity or .sops.yaml missing"
    if identity_readable and config_present and age_keygen_bin:
        try:
            derived = derive_public_key(identity_path, cwd=repo_root, env=base_env)
            config = load_sops_config(sops_config_path)
            rule = find_creation_rule(config, PATH_REGEX)
            configured = rule.get("age") if rule else None
            if configured is None:
                recipient_detail = f"no creation_rules entry for {PATH_REGEX!r}"
            elif configured == derived:
                recipient_matches = True
                recipient_detail = derived
            else:
                recipient_detail = (
                    f".sops.yaml has {configured}, identity derives {derived}"
                )
        except SecretsError as exc:
            recipient_detail = str(exc)
    checks.append(
        DoctorCheck(
            "recipient in .sops.yaml matches the identity",
            recipient_matches,
            recipient_detail,
        )
    )

    ciphertext_present = ciphertext_path.exists()
    checks.append(
        DoctorCheck(
            "ciphertext artifact present",
            ciphertext_present,
            (
                str(ciphertext_path)
                if ciphertext_present
                else f"{ciphertext_path} does not exist"
            ),
        )
    )

    return DoctorReport(checks=checks)


# ---------------------------------------------------------------------------
# init
# ---------------------------------------------------------------------------


@dataclass
class InitResult:
    ok: bool
    identity_created: bool
    identity_path: Path
    recipient: str | None
    message: str


def run_init(
    *,
    repo_root: Path = REPO_ROOT,
    identity_path: Path | None = None,
    sops_config_path: Path | None = None,
    base_env: Mapping[str, str] | None = None,
) -> InitResult:
    base_env = dict(base_env if base_env is not None else os.environ)
    identity_path = identity_path or resolve_identity_path(base_env)
    sops_config_path = sops_config_path or (repo_root / ".sops.yaml")

    identity_created = False
    if identity_path.exists():
        message_lines = [
            f"age identity already present at {identity_path} — refusing to overwrite it."
        ]
    else:
        identity_path.parent.mkdir(parents=True, exist_ok=True)
        os.chmod(identity_path.parent, 0o700)
        env = _sops_env(identity_path, base_env)
        result = _run(["age-keygen", "-o", str(identity_path)], cwd=repo_root, env=env)
        if not result.ok:
            return InitResult(
                ok=False,
                identity_created=False,
                identity_path=identity_path,
                recipient=None,
                message=f"age-keygen failed: {result.stderr.strip()}",
            )
        os.chmod(identity_path, 0o600)
        identity_created = True
        message_lines = [f"created identity at {identity_path}"]

    env = _sops_env(identity_path, base_env)
    try:
        recipient = derive_public_key(identity_path, cwd=repo_root, env=env)
    except SecretsError as exc:
        return InitResult(
            ok=False,
            identity_created=identity_created,
            identity_path=identity_path,
            recipient=None,
            message=str(exc),
        )

    write_sops_config(sops_config_path, recipient, PATH_REGEX)
    message_lines.append(f"public recipient {recipient}")
    message_lines.append(f"identity path {identity_path}")
    message_lines.append(f"wrote {sops_config_path}")

    return InitResult(
        ok=True,
        identity_created=identity_created,
        identity_path=identity_path,
        recipient=recipient,
        message="\n".join(message_lines),
    )


# ---------------------------------------------------------------------------
# encrypt
# ---------------------------------------------------------------------------


@dataclass
class EncryptResult:
    ok: bool
    written: bool
    key_count: int
    message: str


def run_encrypt(
    *,
    repo_root: Path = REPO_ROOT,
    source_path: Path | None = None,
    ciphertext_path: Path | None = None,
    sops_config_path: Path | None = None,
    identity_path: Path | None = None,
    base_env: Mapping[str, str] | None = None,
) -> EncryptResult:
    base_env = dict(base_env if base_env is not None else os.environ)
    source_path = source_path or (repo_root / ".env")
    ciphertext_path = ciphertext_path or (repo_root / CIPHERTEXT_RELPATH)
    sops_config_path = sops_config_path or (repo_root / ".sops.yaml")
    identity_path = identity_path or resolve_identity_path(base_env)

    if not source_path.exists():
        return EncryptResult(
            ok=False,
            written=False,
            key_count=0,
            message=f"no source file at {source_path}",
        )
    if not identity_path.exists():
        return EncryptResult(
            ok=False,
            written=False,
            key_count=0,
            message=f"no age identity at {identity_path} — run 'secrets.py init' first",
        )
    if not sops_config_path.exists():
        return EncryptResult(
            ok=False,
            written=False,
            key_count=0,
            message=f"no {sops_config_path} — run 'secrets.py init' first",
        )

    env = _sops_env(identity_path, base_env)

    try:
        source_canonical = _canonical_from_plaintext(
            source_path, cwd=repo_root, env=env
        )
    except SecretsError as exc:
        return EncryptResult(ok=False, written=False, key_count=0, message=str(exc))

    if ciphertext_path.exists():
        try:
            existing_canonical = _canonical_from_ciphertext(
                ciphertext_path, cwd=repo_root, env=env
            )
        except SecretsError:
            existing_canonical = None
        if existing_canonical is not None and existing_canonical == source_canonical:
            return EncryptResult(
                ok=True,
                written=False,
                key_count=len(source_canonical),
                message=f"unchanged — {ciphertext_path} already matches {source_path} ({len(source_canonical)} keys)",
            )

    encrypted = _run(
        [
            "sops",
            "--input-type",
            "dotenv",
            "--output-type",
            "dotenv",
            "--filename-override",
            CIPHERTEXT_RELPATH,
            "--encrypt",
            str(source_path),
        ],
        cwd=repo_root,
        env=env,
    )
    if not encrypted.ok:
        return EncryptResult(
            ok=False,
            written=False,
            key_count=0,
            message=f"sops encrypt failed: {encrypted.stderr.strip()}",
        )

    ciphertext_path.parent.mkdir(parents=True, exist_ok=True)
    _atomic_write(ciphertext_path, encrypted.stdout, mode=0o644)

    return EncryptResult(
        ok=True,
        written=True,
        key_count=len(source_canonical),
        message=f"wrote {ciphertext_path} ({len(source_canonical)} keys)",
    )


# ---------------------------------------------------------------------------
# decrypt
# ---------------------------------------------------------------------------


@dataclass
class DecryptResult:
    ok: bool
    written: bool
    refused: bool
    differing_keys: list[str]
    message: str


def _atomic_write(path: Path, content: str, *, mode: int) -> None:
    """Write via a temp file + os.replace so an interrupted write can never
    leave a truncated file at ``path``."""
    fd, tmp_name = tempfile.mkstemp(
        dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
    )
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            handle.write(content)
        os.chmod(tmp_name, mode)
        os.replace(tmp_name, path)
    except BaseException:
        Path(tmp_name).unlink(missing_ok=True)
        raise


def run_decrypt(
    *,
    repo_root: Path = REPO_ROOT,
    dest_path: Path | None = None,
    ciphertext_path: Path | None = None,
    identity_path: Path | None = None,
    force: bool = False,
    base_env: Mapping[str, str] | None = None,
) -> DecryptResult:
    base_env = dict(base_env if base_env is not None else os.environ)
    dest_path = dest_path or (repo_root / ".env")
    ciphertext_path = ciphertext_path or (repo_root / CIPHERTEXT_RELPATH)
    identity_path = identity_path or resolve_identity_path(base_env)

    if not ciphertext_path.exists():
        return DecryptResult(
            ok=False,
            written=False,
            refused=False,
            differing_keys=[],
            message=f"no ciphertext at {ciphertext_path} — run 'secrets.py encrypt' first",
        )
    if not identity_path.exists():
        return DecryptResult(
            ok=False,
            written=False,
            refused=False,
            differing_keys=[],
            message=f"no age identity at {identity_path} — run 'secrets.py init' first",
        )

    env = _sops_env(identity_path, base_env)

    try:
        ciphertext_canonical = _canonical_from_ciphertext(
            ciphertext_path, cwd=repo_root, env=env
        )
    except SecretsError as exc:
        return DecryptResult(
            ok=False, written=False, refused=False, differing_keys=[], message=str(exc)
        )

    if dest_path.exists() and not force:
        try:
            dest_canonical = _canonical_from_plaintext(
                dest_path, cwd=repo_root, env=env
            )
        except SecretsError as exc:
            return DecryptResult(
                ok=False,
                written=False,
                refused=False,
                differing_keys=[],
                message=str(exc),
            )
        differing = diff_keys(dest_canonical, ciphertext_canonical)
        if differing:
            return DecryptResult(
                ok=False,
                written=False,
                refused=True,
                differing_keys=differing,
                message=(
                    f"{dest_path} differs from {ciphertext_path} in keys: "
                    f"{', '.join(differing)} — refusing without --force"
                ),
            )

    decrypted = _run(
        [
            "sops",
            "--input-type",
            "dotenv",
            "--output-type",
            "dotenv",
            "--decrypt",
            str(ciphertext_path),
        ],
        cwd=repo_root,
        env=env,
    )
    if not decrypted.ok:
        return DecryptResult(
            ok=False,
            written=False,
            refused=False,
            differing_keys=[],
            message=f"sops decrypt failed: {decrypted.stderr.strip()}",
        )

    dest_path.parent.mkdir(parents=True, exist_ok=True)
    _atomic_write(dest_path, decrypted.stdout, mode=0o600)

    return DecryptResult(
        ok=True,
        written=True,
        refused=False,
        differing_keys=[],
        message=f"wrote {dest_path} ({len(ciphertext_canonical)} keys)",
    )


# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------


@dataclass
class StatusReport:
    key_present: bool
    ciphertext_present: bool
    source_present: bool
    in_sync: bool | None
    key_count: int
    differing_keys: list[str]
    error: str | None = None

    def to_dict(self) -> dict:
        return {
            "key_present": self.key_present,
            "ciphertext_present": self.ciphertext_present,
            "source_present": self.source_present,
            "in_sync": self.in_sync,
            "key_count": self.key_count,
            "differing_keys": self.differing_keys,
            "error": self.error,
        }

    def render(self) -> str:
        lines = [
            f"age identity present: {self.key_present}",
            f"ciphertext present: {self.ciphertext_present}",
            f"plaintext source present: {self.source_present}",
        ]
        if self.error:
            lines.append(f"error: {self.error}")
        elif self.in_sync is None:
            lines.append("in sync: unknown (missing identity/ciphertext/source)")
        else:
            lines.append(f"in sync: {self.in_sync}")
            lines.append(f"key count: {self.key_count}")
            if not self.in_sync:
                lines.append(f"differing keys: {', '.join(self.differing_keys)}")
        return "\n".join(lines)


def run_status(
    *,
    repo_root: Path = REPO_ROOT,
    source_path: Path | None = None,
    ciphertext_path: Path | None = None,
    identity_path: Path | None = None,
    base_env: Mapping[str, str] | None = None,
) -> StatusReport:
    base_env = dict(base_env if base_env is not None else os.environ)
    source_path = source_path or (repo_root / ".env")
    ciphertext_path = ciphertext_path or (repo_root / CIPHERTEXT_RELPATH)
    identity_path = identity_path or resolve_identity_path(base_env)

    key_present = identity_path.exists()
    ciphertext_present = ciphertext_path.exists()
    source_present = source_path.exists()

    if not (key_present and ciphertext_present and source_present):
        return StatusReport(
            key_present=key_present,
            ciphertext_present=ciphertext_present,
            source_present=source_present,
            in_sync=None,
            key_count=0,
            differing_keys=[],
        )

    env = _sops_env(identity_path, base_env)
    try:
        ciphertext_canonical = _canonical_from_ciphertext(
            ciphertext_path, cwd=repo_root, env=env
        )
        source_canonical = _canonical_from_plaintext(
            source_path, cwd=repo_root, env=env
        )
    except SecretsError as exc:
        return StatusReport(
            key_present=key_present,
            ciphertext_present=ciphertext_present,
            source_present=source_present,
            in_sync=None,
            key_count=0,
            differing_keys=[],
            error=str(exc),
        )

    differing = diff_keys(source_canonical, ciphertext_canonical)
    return StatusReport(
        key_present=key_present,
        ciphertext_present=ciphertext_present,
        source_present=source_present,
        in_sync=not differing,
        key_count=len(ciphertext_canonical),
        differing_keys=differing,
    )


# ---------------------------------------------------------------------------
# argparse CLI
# ---------------------------------------------------------------------------


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="secrets",
        description="SOPS + age lifecycle for the repo's encrypted secrets.",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    def _add_common(p: argparse.ArgumentParser) -> None:
        p.add_argument(
            "--repo-root",
            type=Path,
            default=REPO_ROOT,
            help="Root .env/.sops.yaml/secrets/ resolve against.",
        )
        p.add_argument(
            "--identity",
            type=Path,
            default=None,
            help="age identity path (default: $SOPS_AGE_KEY_FILE or ~/.config/sops/age/keys.txt).",
        )

    doctor = sub.add_parser("doctor", help="Preflight: everything needed to decrypt.")
    _add_common(doctor)
    doctor.add_argument("--sops-config", type=Path, default=None)
    doctor.add_argument("--ciphertext", type=Path, default=None)

    init = sub.add_parser(
        "init", help="Generate an age identity + .sops.yaml if absent."
    )
    _add_common(init)
    init.add_argument("--sops-config", type=Path, default=None)

    encrypt = sub.add_parser(
        "encrypt", help="Encrypt the plaintext .env into the tracked ciphertext."
    )
    _add_common(encrypt)
    encrypt.add_argument(
        "--source",
        type=Path,
        default=None,
        help="Plaintext source (default: <repo-root>/.env).",
    )
    encrypt.add_argument("--sops-config", type=Path, default=None)
    encrypt.add_argument("--ciphertext", type=Path, default=None)

    decrypt = sub.add_parser(
        "decrypt", help="Decrypt the tracked ciphertext to a plaintext .env."
    )
    _add_common(decrypt)
    decrypt.add_argument(
        "--dest",
        type=Path,
        default=None,
        help="Plaintext destination (default: <repo-root>/.env).",
    )
    decrypt.add_argument("--ciphertext", type=Path, default=None)
    decrypt.add_argument(
        "--force", action="store_true", help="Overwrite a differing destination."
    )

    status = sub.add_parser(
        "status", help="Report sync state between plaintext and ciphertext."
    )
    _add_common(status)
    status.add_argument("--source", type=Path, default=None)
    status.add_argument("--ciphertext", type=Path, default=None)
    status.add_argument(
        "--json", action="store_true", help="Emit machine-readable JSON."
    )

    return parser


def cmd_doctor(args: argparse.Namespace) -> int:
    report = run_doctor(
        repo_root=args.repo_root,
        identity_path=args.identity,
        sops_config_path=args.sops_config,
        ciphertext_path=args.ciphertext,
    )
    print(report.render())
    return EXIT_OK if report.all_passed else EXIT_PRECONDITION


def cmd_init(args: argparse.Namespace) -> int:
    result = run_init(
        repo_root=args.repo_root,
        identity_path=args.identity,
        sops_config_path=args.sops_config,
    )
    stream = sys.stdout if result.ok else sys.stderr
    print(result.message, file=stream)
    return EXIT_OK if result.ok else EXIT_FAIL


def cmd_encrypt(args: argparse.Namespace) -> int:
    result = run_encrypt(
        repo_root=args.repo_root,
        source_path=args.source,
        ciphertext_path=args.ciphertext,
        sops_config_path=args.sops_config,
        identity_path=args.identity,
    )
    if not result.ok:
        print(f"secrets encrypt: {result.message}", file=sys.stderr)
        return (
            EXIT_FAIL if "sops encrypt failed" in result.message else EXIT_PRECONDITION
        )
    print(result.message)
    return EXIT_OK


def cmd_decrypt(args: argparse.Namespace) -> int:
    result = run_decrypt(
        repo_root=args.repo_root,
        dest_path=args.dest,
        ciphertext_path=args.ciphertext,
        identity_path=args.identity,
        force=args.force,
    )
    if not result.ok:
        print(f"secrets decrypt: {result.message}", file=sys.stderr)
        if result.refused:
            return EXIT_FAIL
        return (
            EXIT_PRECONDITION
            if "no ciphertext" in result.message or "no age identity" in result.message
            else EXIT_FAIL
        )
    print(result.message)
    return EXIT_OK


def cmd_status(args: argparse.Namespace) -> int:
    report = run_status(
        repo_root=args.repo_root,
        source_path=args.source,
        ciphertext_path=args.ciphertext,
        identity_path=args.identity,
    )
    if args.json:
        print(json.dumps(report.to_dict(), indent=2, sort_keys=True))
    else:
        print(report.render())
    if report.error:
        return EXIT_FAIL
    if report.in_sync is None:
        return EXIT_PRECONDITION
    return EXIT_OK if report.in_sync else EXIT_FAIL


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    if args.command == "doctor":
        return cmd_doctor(args)
    if args.command == "init":
        return cmd_init(args)
    if args.command == "encrypt":
        return cmd_encrypt(args)
    if args.command == "decrypt":
        return cmd_decrypt(args)
    if args.command == "status":
        return cmd_status(args)
    # argparse's required=True makes this unreachable; returning an error
    # rather than falling through keeps an added subcommand from silently
    # exiting 0 before it is wired.
    print(f"secrets: unhandled command {args.command!r}", file=sys.stderr)
    return EXIT_FAIL


if __name__ == "__main__":
    sys.exit(main())