Skip to content

Index

axm_doctor

axm-doctor.

Env bootstrap + auth-status doctor (detect, propose, orchestrate).

Re-exports are resolved lazily (PEP 562 __getattr__): importing the package does NOT eager-load :mod:axm_doctor.orchestrate (which imports axm-vault) or :mod:axm_doctor.tools (which imports axm.tools.base). The stdlib-only detection surface (detect_tool / detect_auth) therefore stays reachable as the bootstrap probe even on a machine where the rest of AXM is not yet installable — the heavier symbols are imported only when first accessed.

AuthStatus

Bases: BaseModel

Frozen read-only auth state for a third-party binary.

Carries the command to recover from logged_out (login_cmd) but NEVER a token value — detection is existence/exit-code only.

Source code in packages/axm-doctor/src/axm_doctor/detect.py
Python
class AuthStatus(BaseModel, frozen=True):  # type: ignore[explicit-any]
    """Frozen read-only auth state for a third-party binary.

    Carries the command to recover from ``logged_out`` (``login_cmd``) but
    NEVER a token value — detection is existence/exit-code only.
    """

    tool: str
    state: AuthState
    login_cmd: str | None = None

AuthStatusTool

Report third-party auth state — never a token value (mirror of vault).

Source code in packages/axm-doctor/src/axm_doctor/tools.py
Python
class AuthStatusTool:
    """Report third-party auth state — never a token value (mirror of vault)."""

    agent_hint = (
        "Report third-party binary auth state as {tool: {state, login_cmd}}; "
        "the token value is NEVER returned."
    )
    domain = "doctor"
    tags = frozenset({"doctor", "auth", "login"})

    @property
    def name(self) -> str:
        """Unique tool identifier."""
        return "auth_status"

    def execute(self) -> ToolResult:
        """Return value-free auth state; any error becomes a failure ToolResult."""
        try:
            auth = _auth_map()
        except Exception as exc:  # noqa: BLE001 # MCP boundary: any error -> failure
            return ToolResult(success=False, error=str(exc))
        return ToolResult(success=True, data={"auth": auth})
name property

Unique tool identifier.

execute()

Return value-free auth state; any error becomes a failure ToolResult.

Source code in packages/axm-doctor/src/axm_doctor/tools.py
Python
def execute(self) -> ToolResult:
    """Return value-free auth state; any error becomes a failure ToolResult."""
    try:
        auth = _auth_map()
    except Exception as exc:  # noqa: BLE001 # MCP boundary: any error -> failure
        return ToolResult(success=False, error=str(exc))
    return ToolResult(success=True, data={"auth": auth})

EnvDoctorTool

Read-only env report: tool presence/version + auth + missing secrets.

Source code in packages/axm-doctor/src/axm_doctor/tools.py
Python
class EnvDoctorTool:
    """Read-only env report: tool presence/version + auth + missing secrets."""

    agent_hint = (
        "Read-only env doctor: report each external tool's presence/version, "
        "third-party auth state, and missing (value-free) secrets. Never installs."
    )
    domain = "doctor"
    tags = frozenset({"doctor", "env", "bootstrap", "detect"})

    @property
    def name(self) -> str:
        """Unique tool identifier."""
        return "env_doctor"

    def execute(self) -> ToolResult:
        """Return the full env report; any error becomes a failure ToolResult."""
        try:
            tools = {
                name: {"state": status.state, "version": status.version}
                for name in PROBED_TOOLS
                for status in (detect_tool(name),)
            }
            secrets = [secret.model_dump() for secret in missing_secrets()]
        except Exception as exc:  # noqa: BLE001 # MCP boundary: any error -> failure
            return ToolResult(success=False, error=str(exc))
        return ToolResult(
            success=True,
            data={
                "tools": tools,
                "auth": _auth_map(),
                "secrets": secrets,
                "config": _config_map(),
            },
        )
name property

Unique tool identifier.

execute()

Return the full env report; any error becomes a failure ToolResult.

Source code in packages/axm-doctor/src/axm_doctor/tools.py
Python
def execute(self) -> ToolResult:
    """Return the full env report; any error becomes a failure ToolResult."""
    try:
        tools = {
            name: {"state": status.state, "version": status.version}
            for name in PROBED_TOOLS
            for status in (detect_tool(name),)
        }
        secrets = [secret.model_dump() for secret in missing_secrets()]
    except Exception as exc:  # noqa: BLE001 # MCP boundary: any error -> failure
        return ToolResult(success=False, error=str(exc))
    return ToolResult(
        success=True,
        data={
            "tools": tools,
            "auth": _auth_map(),
            "secrets": secrets,
            "config": _config_map(),
        },
    )

GhConfigStatus

Bases: BaseModel

Frozen verdict on whether gh carries a base configuration.

configured when gh config get git_protocol exits 0, unconfigured otherwise, not_installed when the gh binary is absent. The config value itself is never read.

Source code in packages/axm-doctor/src/axm_doctor/detect.py
Python
class GhConfigStatus(BaseModel, frozen=True):  # type: ignore[explicit-any]
    """Frozen verdict on whether ``gh`` carries a base configuration.

    ``configured`` when ``gh config get git_protocol`` exits 0, ``unconfigured``
    otherwise, ``not_installed`` when the ``gh`` binary is absent. The config
    value itself is never read.
    """

    state: GhConfigState

GitIdentityStatus

Bases: BaseModel

Frozen verdict on whether a git committer identity is resolvable.

state is decided from the presence of a [git].default store entry or the exit code of git config --get user.email — the identity value itself is never read.

Source code in packages/axm-doctor/src/axm_doctor/detect.py
Python
class GitIdentityStatus(BaseModel, frozen=True):  # type: ignore[explicit-any]
    """Frozen verdict on whether a git committer identity is resolvable.

    ``state`` is decided from the *presence* of a ``[git].default`` store entry
    or the exit code of ``git config --get user.email`` — the identity value
    itself is never read.
    """

    state: GitIdentityState

InstallPlan

Bases: BaseModel

A proposed install command for a single tool — description only.

Building a plan runs nothing. argv is the program + arguments executed as a bare exec (NO shell). fetch_url, when set, marks a script-installer plan (e.g. the uv curl | sh recipe): instead of a shell pipe, the script is downloaded to a temp file and run as sh <tmpfile> — both steps are argv execs, never shell=True. human_command is the copy-pasteable form a human would type.

Source code in packages/axm-doctor/src/axm_doctor/install.py
Python
class InstallPlan(BaseModel, frozen=True):  # type: ignore[explicit-any]
    """A proposed install command for a single tool — description only.

    Building a plan runs nothing. ``argv`` is the program + arguments executed
    as a bare exec (NO shell). ``fetch_url``, when set, marks a script-installer
    plan (e.g. the uv ``curl | sh`` recipe): instead of a shell pipe, the script
    is downloaded to a temp file and run as ``sh <tmpfile>`` — both steps are
    argv execs, never ``shell=True``. ``human_command`` is the copy-pasteable
    form a human would type.
    """

    tool: str
    argv: list[str]
    human_command: str
    fetch_url: str | None = None

InstallResult

Bases: BaseModel

Outcome of :func:run_install.

On a dry-run (confirm=False) executed is False, returncode is None, and post_check is None — nothing was installed, so nothing is re-detected. On a confirmed run, post_check carries the re-detected :class:~axm_doctor.detect.ToolStatus.

Source code in packages/axm-doctor/src/axm_doctor/install.py
Python
class InstallResult(BaseModel, frozen=True):  # type: ignore[explicit-any]
    """Outcome of :func:`run_install`.

    On a dry-run (``confirm=False``) ``executed`` is False, ``returncode`` is
    None, and ``post_check`` is None — nothing was installed, so nothing is
    re-detected. On a confirmed run, ``post_check`` carries the re-detected
    :class:`~axm_doctor.detect.ToolStatus`.
    """

    command: str
    executed: bool
    returncode: int | None = None
    post_check: ToolStatus | None = None

MissingSecret

Bases: BaseModel

A credential spec that resolves to "missing" across every layer.

Value-less by construction: it carries only the coordinates of the spec and a copy-pasteable recovery hint (setup_hint). The secret value itself NEVER transits axm_doctor.

Source code in packages/axm-doctor/src/axm_doctor/orchestrate.py
Python
class MissingSecret(BaseModel, frozen=True):  # type: ignore[explicit-any]
    """A credential spec that resolves to ``"missing"`` across every layer.

    Value-less by construction: it carries only the coordinates of the spec
    and a copy-pasteable recovery hint (``setup_hint``). The secret value
    itself NEVER transits axm_doctor.
    """

    group: str
    name: str
    package: str
    setup_hint: str

ProvisionResult

Bases: BaseModel

Outcome of :func:provision_missing.

On a dry-run (confirm=False) provisioned is False and groups lists the groups it WOULD prompt for. On a confirmed run provisioned is True ONLY when a post-setup re-scan confirms every previously-missing spec now resolves — delegating to vault's setup driver is not proof the user actually supplied the secrets (they may skip/empty the prompts). still_missing lists the specs that remain unresolved after the run (always empty on a dry-run), so a partial provisioning is reported truthfully rather than as a false green.

Source code in packages/axm-doctor/src/axm_doctor/orchestrate.py
Python
class ProvisionResult(BaseModel, frozen=True):  # type: ignore[explicit-any]
    """Outcome of :func:`provision_missing`.

    On a dry-run (``confirm=False``) ``provisioned`` is False and ``groups``
    lists the groups it WOULD prompt for. On a confirmed run ``provisioned``
    is True ONLY when a post-setup re-scan confirms every previously-missing
    spec now resolves — delegating to vault's setup driver is not proof the
    user actually supplied the secrets (they may skip/empty the prompts).
    ``still_missing`` lists the specs that remain unresolved after the run
    (always empty on a dry-run), so a partial provisioning is reported truthfully
    rather than as a false green.
    """

    provisioned: bool
    groups: list[str]
    still_missing: list[str] = []
    reason: str | None = None

ToolStatus

Bases: BaseModel

Frozen result of probing a single external tool on PATH.

Source code in packages/axm-doctor/src/axm_doctor/detect.py
Python
class ToolStatus(BaseModel, frozen=True):  # type: ignore[explicit-any]
    """Frozen result of probing a single external tool on ``PATH``."""

    name: str
    state: ToolState
    version: str | None = None
    path: str | None = None

__dir__()

Expose the lazily-resolvable names to dir() / autocompletion.

Source code in packages/axm-doctor/src/axm_doctor/__init__.py
Python
def __dir__() -> list[str]:
    """Expose the lazily-resolvable names to ``dir()`` / autocompletion."""
    return sorted(__all__)

__getattr__(name)

Resolve a public symbol from its submodule on first access (PEP 562).

Source code in packages/axm-doctor/src/axm_doctor/__init__.py
Python
def __getattr__(name: str) -> object:
    """Resolve a public symbol from its submodule on first access (PEP 562)."""
    module = _LAZY.get(name)
    if module is None:
        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
    import importlib

    return getattr(importlib.import_module(f"{__name__}.{module}"), name)

detect_auth(tool)

Report read-only auth state for a third-party binary.

gh is probed via the exit code of gh auth status; credential-file tools (claude, codex) via the existence of their credential file under ~ — the file is never opened, so no token is ever read.

Source code in packages/axm-doctor/src/axm_doctor/detect.py
Python
def detect_auth(tool: str) -> AuthStatus:
    """Report read-only auth state for a third-party binary.

    ``gh`` is probed via the exit code of ``gh auth status``; credential-file
    tools (``claude``, ``codex``) via the *existence* of their credential file
    under ``~`` — the file is never opened, so no token is ever read.
    """
    login_cmd = _LOGIN_CMDS.get(tool)
    if tool == "gh":
        state = _detect_gh_auth()
    elif tool in _CRED_FILES:
        # On darwin the token may live in the login Keychain; check it first,
        # then fall back to the credential file. Either source present ->
        # logged_in, so a file-backed session (container, CI, CLAUDE_CONFIG_DIR,
        # a locked/absent Keychain) is never mis-reported as logged_out.
        keychain_ok = (
            sys.platform == "darwin"
            and tool in _KEYCHAIN_SERVICES
            and _detect_keychain_auth(_KEYCHAIN_SERVICES[tool]) == "logged_in"
        )
        state = "logged_in" if keychain_ok or _cred_file_present(tool) else "logged_out"
    else:
        # Unknown auth tool: when its binary IS on PATH, "not_installed" would
        # be misleading — we simply cannot verify its login, so report
        # logged_out (recovery hint follows if known). Only when the binary is
        # absent is "not_installed" the honest state.
        state = "logged_out" if shutil.which(tool) is not None else "not_installed"
    return AuthStatus(
        tool=tool,
        state=state,
        login_cmd=login_cmd if state == "logged_out" else None,
    )

detect_gh_config()

Report whether gh carries a base config, value-free.

Probes the exit code of gh config get git_protocol (its stdout is captured and discarded). gh absent → not_installed; any OSError / SubprocessError degrades to unconfigured. This is distinct from and additional to the gh auth status login check.

Source code in packages/axm-doctor/src/axm_doctor/detect.py
Python
def detect_gh_config() -> GhConfigStatus:
    """Report whether ``gh`` carries a base config, value-free.

    Probes the exit code of ``gh config get git_protocol`` (its stdout is
    captured and discarded). ``gh`` absent → ``not_installed``; any
    ``OSError`` / ``SubprocessError`` degrades to ``unconfigured``. This is
    distinct from and additional to the ``gh auth status`` login check.
    """
    if shutil.which("gh") is None:
        return GhConfigStatus(state="not_installed")
    try:
        proc = subprocess.run(
            ["gh", "config", "get", "git_protocol"],  # noqa: S607 - controlled binary
            capture_output=True,
            text=True,
            timeout=_VERSION_TIMEOUT_S,
            check=False,
        )
    except (OSError, subprocess.SubprocessError):
        return GhConfigStatus(state="unconfigured")
    state: GhConfigState = "configured" if proc.returncode == 0 else "unconfigured"
    return GhConfigStatus(state=state)

detect_git_identity()

Report whether a git committer identity is resolvable, value-free.

Cheapest source first: a truthy [git].default in the axm-config store means an identity exists. Otherwise fall back to the exit code of git config --get user.email (its stdout — the email — is captured and discarded, never returned). Any missing binary / OSError / SubprocessError degrades to unconfigured without raising.

Source code in packages/axm-doctor/src/axm_doctor/detect.py
Python
def detect_git_identity() -> GitIdentityStatus:
    """Report whether a git committer identity is resolvable, value-free.

    Cheapest source first: a truthy ``[git].default`` in the ``axm-config``
    store means an identity exists. Otherwise fall back to the exit code of
    ``git config --get user.email`` (its stdout — the email — is captured and
    discarded, never returned). Any missing binary / ``OSError`` /
    ``SubprocessError`` degrades to ``unconfigured`` without raising.
    """
    import axm_config

    if axm_config.get("git", "default", default=None):
        return GitIdentityStatus(state="configured")
    if shutil.which("git") is None:
        return GitIdentityStatus(state="unconfigured")
    try:
        proc = subprocess.run(
            ["git", "config", "--get", "user.email"],  # noqa: S607 - controlled binary
            capture_output=True,
            text=True,
            timeout=_VERSION_TIMEOUT_S,
            check=False,
        )
    except (OSError, subprocess.SubprocessError):
        return GitIdentityStatus(state="unconfigured")
    state: GitIdentityState = "configured" if proc.returncode == 0 else "unconfigured"
    return GitIdentityStatus(state=state)

detect_tool(name)

Probe name on PATH and parse <name> --version.

Returns present with the parsed version string when found, absent otherwise. Never raises on a missing or misbehaving tool.

Source code in packages/axm-doctor/src/axm_doctor/detect.py
Python
def detect_tool(name: str) -> ToolStatus:
    """Probe ``name`` on ``PATH`` and parse ``<name> --version``.

    Returns ``present`` with the parsed version string when found, ``absent``
    otherwise. Never raises on a missing or misbehaving tool.
    """
    path = shutil.which(name)
    if path is None:
        return ToolStatus(name=name, state="absent")
    return ToolStatus(
        name=name,
        state="present",
        version=_probe_version(name),
        path=path,
    )

install_command(tool)

Return the official :class:InstallPlan for tool, or None.

Runs nothing. An unknown tool returns None rather than guessing a command.

Source code in packages/axm-doctor/src/axm_doctor/install.py
Python
def install_command(tool: str) -> InstallPlan | None:
    """Return the official :class:`InstallPlan` for ``tool``, or None.

    Runs nothing. An unknown tool returns None rather than guessing a
    command.
    """
    return _REGISTRY.get(tool)

missing_secrets()

Return the catalog specs that resolve to "missing", value-free.

Reads the vault catalog and the value-free provenance report; a spec is reported when no resolver layer supplies it. An empty catalog (the nominal state for vault today) yields [] gracefully.

Source code in packages/axm-doctor/src/axm_doctor/orchestrate.py
Python
def missing_secrets() -> list[MissingSecret]:
    """Return the catalog specs that resolve to ``"missing"``, value-free.

    Reads the vault catalog and the value-free provenance report; a spec is
    reported when no resolver layer supplies it. An empty catalog (the
    nominal state for vault today) yields ``[]`` gracefully.
    """
    catalog = load_catalog()
    provenance = doctor_data(catalog=catalog)
    missing: list[MissingSecret] = []
    for group in catalog.groups():
        for spec in group.specs:
            entry = provenance.get(f"{group.id}.{spec.name}")
            if entry is None or entry.get("layer") != _MISSING:
                continue
            missing.append(
                MissingSecret(
                    group=group.id,
                    name=spec.name,
                    package=group.package,
                    setup_hint=f"axm-vault set {group.id}.{spec.name}",
                )
            )
    return missing

provision_missing(*, confirm=False)

Plan (and on confirm execute) provisioning of missing secrets.

Collects the distinct groups owning at least one missing spec. With confirm=False it returns the plan without prompting or storing. With confirm=True it delegates to vault's :func:run_setup (one call per group, restricted via only=); doctor never stores a secret itself.

Source code in packages/axm-doctor/src/axm_doctor/orchestrate.py
Python
def provision_missing(*, confirm: bool = False) -> ProvisionResult:
    """Plan (and on ``confirm`` execute) provisioning of missing secrets.

    Collects the distinct groups owning at least one missing spec. With
    ``confirm=False`` it returns the plan without prompting or storing. With
    ``confirm=True`` it delegates to vault's :func:`run_setup` (one call per
    group, restricted via ``only=``); doctor never stores a secret itself.
    """
    groups: list[str] = []
    for secret in missing_secrets():
        if secret.group not in groups:
            groups.append(secret.group)
    if confirm and not sys.stdin.isatty():
        return ProvisionResult(
            provisioned=False,
            groups=groups,
            reason="non-interactive shell: cannot prompt for secrets",
        )
    if not confirm:
        return ProvisionResult(provisioned=False, groups=groups)
    for group in groups:
        try:
            run_setup(only=group)
        except SystemExit as exc:  # vault's setup driver aborts via SystemExit
            return ProvisionResult(
                provisioned=False,
                groups=groups,
                reason=f"vault setup aborted for {group} (exit {exc.code})",
            )
    # Re-scan: delegating to run_setup is NOT proof a secret was supplied (the
    # user may skip/empty a prompt). Truth comes from re-resolving the catalog.
    still_missing = [f"{s.group}.{s.name}" for s in missing_secrets()]
    provisioned = bool(groups) and not still_missing
    reason = None if provisioned else "some secrets remain unresolved after setup"
    return ProvisionResult(
        provisioned=provisioned,
        groups=groups,
        still_missing=still_missing,
        reason=reason if still_missing else None,
    )

run_install(plan, *, confirm=False)

Execute plan only when confirm is True; otherwise dry-run.

With the default confirm=False this NEVER installs: it returns the command it would run with executed=False and returncode=None. With confirm=True it runs the command, then re-detects the tool via :func:~axm_doctor.detect.detect_tool and reports the post-install state.

Source code in packages/axm-doctor/src/axm_doctor/install.py
Python
def run_install(plan: InstallPlan, *, confirm: bool = False) -> InstallResult:
    """Execute ``plan`` only when ``confirm is True``; otherwise dry-run.

    With the default ``confirm=False`` this NEVER installs: it returns the
    command it *would* run with ``executed=False`` and ``returncode=None``.
    With ``confirm=True`` it runs the command, then re-detects the tool via
    :func:`~axm_doctor.detect.detect_tool` and reports the post-install state.
    """
    if not confirm:
        return InstallResult(command=plan.human_command, executed=False)

    returncode = _run_fetch_install(plan) if plan.fetch_url else _run_argv(plan.argv)
    return InstallResult(
        command=plan.human_command,
        executed=True,
        returncode=returncode,
        post_check=detect_tool(plan.tool),
    )