diff --git a/CHANGELOG.md b/CHANGELOG.md index 6664dce..2608215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented in this file. The format foll ## [Unreleased] +### Added + +- Added `mca login` and `mca logout` for hidden terminal credential entry and private per-user storage, plus an automatic login prompt when an interactive agent starts without credentials. + +### Changed + +- Kept environment variables and `--env-file` for automation while removing manual env-file editing from the default provider setup path. + ## [0.4.0] - 2026-07-31 ### Added diff --git a/README.zh-CN.md b/README.zh-CN.md index ce042f0..40ec762 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -205,13 +205,24 @@ Demo 会证明成功场景在 commit 前没有修改源仓库;随后在第二 ## 配置密钥 -默认在用户配置目录创建私有 `0600` env 文件: +像 pi-agent 一样,直接在终端登录即可。CLI 会隐藏密钥输入,并自动保存到用户配置目录的私有 `0600` 文件,无需手动打开配置文件: ```bash -mca init +mca login +# 或直接指定 provider +mca login deepseek +mca login openai ``` -使用真实 provider 前,请先在生成的文件中填入 provider key。之后 `mca run` 和 `mca chat` 会自动加载这个默认文件;只有使用其他位置时才需要传 `--env-file`。 +`mca login` 会交互选择 provider。需要删除已保存的凭据时运行: + +```bash +mca logout deepseek +``` + +交互式启动 `mca run`、`mca chat` 或 `mca tx run` 时,如果所选 provider 还没有凭据,CLI 也会就地提示登录。之后的运行会自动加载已保存凭据。 + +环境变量和自定义 `--env-file` 仍然受支持,适合 CI 和脚本。`mca init` 仅用于需要手动维护完整 env 配置的高级场景。 默认路径: @@ -221,7 +232,7 @@ mca init | Linux | `${XDG_CONFIG_HOME:-~/.config}/mini-code-agent/env` | | Windows | `%APPDATA%\mini-code-agent\env` | -也可指定项目外的路径: +高级用法中也可创建项目外的 env 模板: ```bash mca init --path ~/.config/mca.env diff --git a/docs/runtime-operations.md b/docs/runtime-operations.md index e6f655b..a3d1b3f 100644 --- a/docs/runtime-operations.md +++ b/docs/runtime-operations.md @@ -4,13 +4,16 @@ The package retains the `mca` CLI and the `mini-code-agent-langgraph` PyPI name. ## Configure a provider -Create a private environment-file template, populate it with a provider key, and inspect prerequisites without exposing secret values: +Sign in from the terminal. The key prompt is hidden and the credential is stored in a private per-user file, so no config file needs to be opened manually: ```bash -mca init +mca login deepseek +# or: mca login openai mca doctor --cwd /path/to/repo --sandbox auto --provider auto ``` +Run `mca logout deepseek` (or `openai`) to remove a saved credential. Interactive agent startup also offers this login flow when the selected provider has no credential. Environment variables, `--env-file`, and the advanced `mca init` env-template workflow remain available for automation and custom endpoints. + ## One-shot and chat integrations ```bash diff --git a/src/mini_code_agent/cli.py b/src/mini_code_agent/cli.py index cd1d8c8..1e9582d 100644 --- a/src/mini_code_agent/cli.py +++ b/src/mini_code_agent/cli.py @@ -1,8 +1,10 @@ from __future__ import annotations import argparse +import getpass import math import os +import re import secrets import shlex import shutil @@ -32,6 +34,14 @@ READ_ONLY_CHAT_TOOLS = {"list_files", "search_files", "read_file", "git_diff"} +PROVIDER_KEY_NAMES = { + "deepseek": "DEEPSEEK_API_KEY", + "openai": "OPENAI_API_KEY", +} +PROVIDER_LABELS = { + "deepseek": "DeepSeek", + "openai": "OpenAI", +} def _load_mini_code_agent(): @@ -273,11 +283,151 @@ def _load_runtime_env(explicit: Path | None) -> Path | None: return candidate +def _read_secure_config(path: Path) -> str: + try: + metadata = path.lstat() + except FileNotFoundError as exc: + raise FileNotFoundError(f"env file does not exist: {path}") from exc + if path.is_symlink() or not stat.S_ISREG(metadata.st_mode): + raise RuntimeError(f"env file must be a regular, non-symlink file: {path}") + if hasattr(os, "getuid") and metadata.st_uid != os.getuid(): + raise PermissionError(f"env file is not owned by this user: {path}") + if os.name != "nt" and stat.S_IMODE(metadata.st_mode) & 0o077: + raise RuntimeError( + f"env file permissions are too broad: {path}; run chmod 600 {path}" + ) + + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags) + try: + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode): + raise RuntimeError(f"env file must be a regular file: {path}") + if hasattr(os, "getuid") and opened.st_uid != os.getuid(): + raise PermissionError(f"env file is not owned by this user: {path}") + if os.name != "nt" and stat.S_IMODE(opened.st_mode) & 0o077: + raise RuntimeError( + f"env file permissions are too broad: {path}; run chmod 600 {path}" + ) + with os.fdopen(descriptor, "r", encoding="utf-8") as handle: + descriptor = -1 + return handle.read() + finally: + if descriptor >= 0: + os.close(descriptor) + + +def _resolved_provider(provider: str, model: str) -> str: + if provider != "auto": + return provider + return ( + "deepseek" + if model == "deepseek" or model.startswith("deepseek-") + else "openai" + ) + + +def _credential_is_configured(provider: str) -> bool: + return bool(os.getenv(PROVIDER_KEY_NAMES[provider]) or os.getenv("MCA_API_KEY")) + + +def _write_config_value(name: str, value: str | None) -> Path: + if value is not None and (not value.strip() or "\n" in value or "\r" in value): + raise ValueError("API key must be a non-empty single-line value") + + directory = _ensure_private_directory(_config_root()) + path = directory / "env" + existing = "" + if path.exists(): + existing = _read_secure_config(path) + + pattern = re.compile(rf"^\s*#?\s*{re.escape(name)}\s*=.*$") + replacement = f"{name}={value}" if value is not None else f"# {name}=" + lines = existing.splitlines(keepends=True) + matching_indexes = [ + index + for index, line in enumerate(lines) + if pattern.fullmatch(line.rstrip("\r\n")) + ] + if matching_indexes: + first = matching_indexes[0] + newline = "\r\n" if lines[first].endswith("\r\n") else "\n" + lines[first] = replacement + newline + for index in reversed(matching_indexes[1:]): + del lines[index] + content = "".join(lines) + else: + separator = "" if not existing or existing.endswith("\n") else "\n" + content = f"{existing}{separator}{replacement}\n" + + descriptor, temporary_name = tempfile.mkstemp(prefix=".env-", dir=directory) + temporary = Path(temporary_name) + try: + if os.name != "nt": + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = -1 + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + if os.name != "nt": + path.chmod(0o600) + finally: + if descriptor >= 0: + os.close(descriptor) + try: + temporary.unlink() + except FileNotFoundError: + pass + return path + + +def _select_provider() -> str: + if not sys.stdin.isatty(): + raise RuntimeError("provider is required outside an interactive terminal") + print("Select a provider:") + print(" 1. DeepSeek") + print(" 2. OpenAI / OpenAI-compatible") + choice = input("Provider [1/2]: ").strip().lower() + providers = { + "1": "deepseek", + "2": "openai", + "deepseek": "deepseek", + "openai": "openai", + } + try: + return providers[choice] + except KeyError as exc: + raise RuntimeError("provider must be 1 (DeepSeek) or 2 (OpenAI)") from exc + + +def _prompt_and_store_credential(provider: str) -> tuple[Path, str]: + if not sys.stdin.isatty(): + raise RuntimeError("login needs an interactive terminal") + key = getpass.getpass(f"{PROVIDER_LABELS[provider]} API key: ") + path = _write_config_value(PROVIDER_KEY_NAMES[provider], key) + return path, key + + def _model_from_args(args: argparse.Namespace): + provider = _resolved_provider(args.provider, args.model) + prompted_key = None + if ( + not _credential_is_configured(provider) + and args.env_file is None + and sys.stdin.isatty() + ): + print(f"No {PROVIDER_LABELS[provider]} credential found. Sign in to continue.") + path, prompted_key = _prompt_and_store_credential(provider) + print(f"Credential saved securely to {path}") return _load_create_model()( args.model, provider=args.provider, base_url=args.base_url, + api_key=prompted_key, request_timeout=args.request_timeout, max_retries=args.max_retries, deepseek_thinking=args.deepseek_thinking, @@ -505,6 +655,12 @@ def build_parser() -> argparse.ArgumentParser: help="Env file path. Defaults to the per-user config directory.", ) + login = subparsers.add_parser("login", help="Save a provider API key from the terminal.") + login.add_argument("provider", nargs="?", choices=["deepseek", "openai"]) + + logout = subparsers.add_parser("logout", help="Remove a saved provider API key.") + logout.add_argument("provider", nargs="?", choices=["deepseek", "openai"]) + subparsers.add_parser( "demo", help="Run a deterministic no-key coding demo in a temporary workspace.", @@ -762,6 +918,10 @@ def main() -> None: raise SystemExit(undo_command(args)) if args.command == "init": raise SystemExit(init_command(args)) + if args.command == "login": + raise SystemExit(login_command(args)) + if args.command == "logout": + raise SystemExit(logout_command(args)) if args.command == "demo": raise SystemExit(demo_command(args)) if args.command == "sandbox" and args.sandbox_command == "probe": @@ -835,6 +995,24 @@ def init_command(args: argparse.Namespace) -> int: return 0 +def login_command(args: argparse.Namespace) -> int: + provider = args.provider or _select_provider() + path, _key = _prompt_and_store_credential(provider) + print(f"Saved {PROVIDER_LABELS[provider]} credentials to {path}") + return 0 + + +def logout_command(args: argparse.Namespace) -> int: + provider = args.provider or _select_provider() + configured_path = _config_root() / "env" + if not configured_path.exists(): + print(f"No saved {PROVIDER_LABELS[provider]} credentials found") + return 0 + path = _write_config_value(PROVIDER_KEY_NAMES[provider], None) + print(f"Removed {PROVIDER_LABELS[provider]} credentials from {path}") + return 0 + + def _write_demo_fixture(root: Path) -> None: root.mkdir(mode=0o700, parents=True, exist_ok=True) if os.name != "nt": diff --git a/src/mini_code_agent/model.py b/src/mini_code_agent/model.py index 6be781e..12f45d8 100644 --- a/src/mini_code_agent/model.py +++ b/src/mini_code_agent/model.py @@ -283,7 +283,8 @@ def create_model( resolved_api_key = api_key or os.getenv("DEEPSEEK_API_KEY") or os.getenv("MCA_API_KEY") if not resolved_api_key: raise RuntimeError( - "DeepSeek API key is missing. Set DEEPSEEK_API_KEY, MCA_API_KEY, or use --env-file." + "DeepSeek API key is missing. Run `mca login deepseek`, set " + "DEEPSEEK_API_KEY/MCA_API_KEY, or use --env-file." ) model_options: dict[str, Any] = { "model": resolved_model, @@ -305,7 +306,8 @@ def create_model( resolved_api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("MCA_API_KEY") if not resolved_api_key: raise RuntimeError( - "OpenAI API key is missing. Set OPENAI_API_KEY, MCA_API_KEY, or use --env-file. " + "OpenAI API key is missing. Run `mca login openai`, set " + "OPENAI_API_KEY/MCA_API_KEY, or use --env-file. " "For a keyless local compatible server, set MCA_API_KEY=not-needed explicitly." ) from langchain_openai import ChatOpenAI diff --git a/tests/test_cli_launch.py b/tests/test_cli_launch.py index 4f40501..f99a3e8 100644 --- a/tests/test_cli_launch.py +++ b/tests/test_cli_launch.py @@ -16,6 +16,8 @@ "langchain_openai", "langgraph", } +DEEPSEEK_KEY_NAME = "DEEPSEEK" + "_API_KEY" +OPENAI_KEY_NAME = "OPENAI" + "_API_KEY" def _imported_top_level_modules(code: str) -> set[str]: @@ -101,6 +103,132 @@ def test_parser_accepts_transaction_demo(): assert args.transaction_command == "demo" +def test_parser_accepts_login_and_logout_with_optional_provider(): + parser = cli_module.build_parser() + + assert parser.parse_args(["login", "deepseek"]).provider == "deepseek" + assert parser.parse_args(["logout", "openai"]).provider == "openai" + assert parser.parse_args(["login"]).provider is None + + +def test_login_prompts_without_echo_and_saves_private_credentials( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + class TtyInput: + @staticmethod + def isatty() -> bool: + return True + + config = tmp_path / "config" + secret = "test-secret-value" + monkeypatch.setenv("MCA_CONFIG_DIR", str(config)) + monkeypatch.setattr(cli_module.sys, "stdin", TtyInput()) + monkeypatch.setattr(cli_module.getpass, "getpass", lambda _prompt: secret) + + result = cli_module.login_command( + cli_module.build_parser().parse_args(["login", "deepseek"]) + ) + + env_file = config / "env" + assert result == 0 + assert env_file.read_text(encoding="utf-8") == f"{DEEPSEEK_KEY_NAME}={secret}\n" + if os.name != "nt": + assert env_file.stat().st_mode & 0o777 == 0o600 + assert secret not in capsys.readouterr().out + + +def test_login_without_provider_selects_one_in_the_terminal( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + class TtyInput: + @staticmethod + def isatty() -> bool: + return True + + monkeypatch.setenv("MCA_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(cli_module.sys, "stdin", TtyInput()) + monkeypatch.setattr("builtins.input", lambda _prompt: "2") + monkeypatch.setattr(cli_module.getpass, "getpass", lambda _prompt: "openai-secret") + + result = cli_module.login_command(cli_module.build_parser().parse_args(["login"])) + + assert result == 0 + content = (tmp_path / "config" / "env").read_text(encoding="utf-8") + assert "OPENAI_API_KEY" in content + + +def test_logout_without_saved_credentials_does_not_create_a_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + config = tmp_path / "config" + monkeypatch.setenv("MCA_CONFIG_DIR", str(config)) + + result = cli_module.logout_command( + cli_module.build_parser().parse_args(["logout", "openai"]) + ) + + assert result == 0 + assert not config.exists() + + +def test_login_updates_existing_provider_and_logout_removes_secret( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + config = tmp_path / "config" + config.mkdir() + env_file = config / "env" + env_file.write_text( + f"{DEEPSEEK_KEY_NAME}=old-secret\n" + f"{DEEPSEEK_KEY_NAME}=duplicate-secret\n" + f"# {OPENAI_KEY_NAME}=\nMCA_BASE_URL=http://localhost/v1\n", + encoding="utf-8", + ) + env_file.chmod(0o600) + monkeypatch.setenv("MCA_CONFIG_DIR", str(config)) + + cli_module._write_config_value("DEEPSEEK_API_KEY", "new-secret") + cli_module._write_config_value("DEEPSEEK_API_KEY", None) + + content = env_file.read_text(encoding="utf-8") + assert "old-secret" not in content + assert "duplicate-secret" not in content + assert "new-secret" not in content + assert content.count(DEEPSEEK_KEY_NAME) == 1 + assert f"# {DEEPSEEK_KEY_NAME}=" in content + assert "MCA_BASE_URL=http://localhost/v1" in content + + +def test_model_startup_prompts_for_missing_provider_credential( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + class TtyInput: + @staticmethod + def isatty() -> bool: + return True + + captured: dict[str, object] = {} + + def fake_create_model(model: str, **kwargs): + captured.update(model=model, **kwargs) + return object() + + monkeypatch.setenv("MCA_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + monkeypatch.delenv("MCA_API_KEY", raising=False) + monkeypatch.setattr(cli_module.sys, "stdin", TtyInput()) + monkeypatch.setattr(cli_module.getpass, "getpass", lambda _prompt: "entered-key") + monkeypatch.setattr(cli_module, "_load_create_model", lambda: fake_create_model) + args = cli_module.build_parser().parse_args(["chat", "--model", "deepseek"]) + + cli_module._model_from_args(args) + + assert captured["api_key"] == "entered-key" + assert os.getenv("DEEPSEEK_API_KEY") is None + assert (tmp_path / "config" / "env").exists() + + def test_transaction_demo_proves_commit_and_conflict_paths( tmp_path: Path, monkeypatch: pytest.MonkeyPatch,