diff --git a/core/mode_resolver.py b/core/mode_resolver.py new file mode 100644 index 0000000..af863b0 --- /dev/null +++ b/core/mode_resolver.py @@ -0,0 +1,94 @@ +"""Nova client-mode resolver (P1, REQ-327, D-226). + +Priority: --mode flag → NOVA_CLIENT_MODE env → credential type → TTY. +No silent fallbacks: every return carries a non-empty selection_reason. + +INV-13: invalid env values are ignored + warned, then fall through. +INV-14: credential_type developer_pat/nova_oidc_token + TTY → +interactive; + no-TTY → agent. TTY check is sys.stdin.isatty() (D-226). +""" + +from __future__ import annotations + +import json +import logging +import os +import sys +from pathlib import Path +from typing import Optional, Tuple + +log = logging.getLogger("nova.mode_resolver") + +_VALID_MODES = ("agent", "interactive") +_CRED_MODE_TYPES = ("developer_pat", "nova_oidc_token") + + +def resolve_mode( + flag: Optional[str] = None, + env_var: Optional[str] = None, + credential_type: Optional[str] = None, + stdin_isatty: bool = False, +) -> Tuple[str, str]: + """Return (mode, selection_reason) honoring D-226 priority.""" + if flag is not None and flag in _VALID_MODES: + return flag, "flag" + if env_var is not None and env_var != "": + if env_var in _VALID_MODES: + return env_var, "env" + log.warning( + "NOVA_CLIENT_MODE=%r invalid (expected one of %s); ignoring", + env_var, + _VALID_MODES, + ) + if credential_type in _CRED_MODE_TYPES: + mode = "interactive" if stdin_isatty else "agent" + return mode, f"credential:{credential_type}" + mode = "interactive" if stdin_isatty else "agent" + return mode, "tty" + + +def _read_credential_type(path: Path) -> Optional[str]: + """Read the active credential's type from ~/.nova/credentials.json.""" + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None + active_jti = data.get("active_credential_jti") + for cred in data.get("credentials", []) or []: + if cred.get("jti") == active_jti: + return cred.get("type") + return None + + +def resolve_mode_from_env(credential_type: Optional[str] = None) -> Tuple[str, str]: + """Resolve mode using sys.argv, NOVA_CLIENT_MODE, credentials, and TTY. + + Best-effort --mode scan of sys.argv (no full argparse); env var; + ~/.nova/credentials.json active credential type; sys.stdin.isatty(). + """ + flag: Optional[str] = None + argv = sys.argv[1:] + for i, tok in enumerate(argv): + if tok == "--mode" and i + 1 < len(argv): + flag = argv[i + 1] + break + if tok.startswith("--mode="): + flag = tok.split("=", 1)[1] + break + env_var = os.environ.get("NOVA_CLIENT_MODE") + if env_var is not None and env_var == "": + env_var = "" + if credential_type is None: + cred_path = Path.home() / ".nova" / "credentials.json" + credential_type = _read_credential_type(cred_path) + return resolve_mode( + flag=flag, + env_var=env_var, + credential_type=credential_type, + stdin_isatty=sys.stdin.isatty(), + ) + + +if __name__ == "__main__": + mode, reason = resolve_mode_from_env() + print(f"mode={mode} reason={reason}") \ No newline at end of file