Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 配置的高级场景。

默认路径:

Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions docs/runtime-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 178 additions & 0 deletions src/mini_code_agent/cli.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import argparse
import getpass
import math
import os
import re
import secrets
import shlex
import shutil
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down
6 changes: 4 additions & 2 deletions src/mini_code_agent/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading