From 1daf010e6ed9f4b60a710ec5f0f13879c529e69d Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Wed, 23 Sep 2026 15:31:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(env):=20=E6=89=98=E7=AE=A1=20agent=20CLI?= =?UTF-8?q?=20=E7=9A=84=20PI=5FPROXY=5F*=20=E5=8F=98=E9=87=8F=EF=BC=88writ?= =?UTF-8?q?e/install=20+=20status/doctor=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通用 HTTP(S)_PROXY 覆盖不到所有 agent transport:omp 的 anthropic-messages transport(cowork-fetch)只认 PI_PROXY_,没有这个变量就直连, 在受限地区拿到 403 Request not allowed;而 usage 探针等普通 fetch 走 env proxy 正常,表现为"探针活着、模型请求全红"。proxyctl 是 7890 / 兜底 7891 的持有者,因此由它把这组变量按生命周期托管起来。 - 新增 proxyctl.agent_env:出口按存活解析(引擎端口 → agent_env.fallback_daemon 的 port → 都没有),落成可 source 的契约文件 ~/.config/proxyctl/agent-env.sh;无存活出口时删除文件,避免 agent 打到死口 - start / stop / restart / fix / daemon / engine 后自动刷新;env --write / --install / --uninstall 显式管理,三种写模式支持 --dry-run(_plan_env) - env 无参输出追加 PI_PROXY_* export 行(无存活出口时输出 unset), 新增 --json;--unset 同时清 PI_PROXY_* - status 新增 AGENT ENV 段与 envelope.data.agent_env - doctor 新增 agent_env.dead_endpoint(warn) / agent_env.missing / agent_env.not_sourced,均带 fix_command 且 auto_fixable=true - config.yaml.example 新增 agent_env: 段;man / CHANGELOG / bash+zsh+fish 补全同步 - 刻意不接管通用 HTTP(S)_PROXY:那属于用户 shell 的静态约定,引擎停时不应被 proxyctl 悄悄摘掉 Tests: uv run pytest -q → 855 passed(新增 tests/unit/test_agent_env.py 37 条) --- CHANGELOG.md | 29 ++ config.yaml.example | 18 + man/proxyctl.1 | 29 +- src/proxyctl/agent_env.py | 384 ++++++++++++++++ src/proxyctl/cli.py | 258 ++++++++++- src/proxyctl/completion.py | 20 +- src/proxyctl/explain.py | 99 +++- src/proxyctl/status.py | 41 ++ src/proxyctl/suggest.py | 7 + src/proxyctl/suggest_rules.py | 97 +++- tests/unit/test_agent_env.py | 428 ++++++++++++++++++ tests/unit/test_completion.py | 24 + .../unit/test_suggest_explain_completeness.py | 5 +- 13 files changed, 1400 insertions(+), 39 deletions(-) create mode 100644 src/proxyctl/agent_env.py create mode 100644 tests/unit/test_agent_env.py diff --git a/CHANGELOG.md b/CHANGELOG.md index da7d1ec..72c378e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ ## [Unreleased] +### Added + +- **`proxyctl env --write` / `--install` / `--uninstall`:把 `PI_PROXY_*` 托管给 agent CLI。** + 通用 `HTTP(S)_PROXY` 覆盖不到所有 agent:omp 的 anthropic-messages transport + 只认 `PI_PROXY_`,没有这个变量就直连,受限地区直接 `403 Request + not allowed`。现在 proxyctl 负责把出口写成可 source 的契约文件 + `~/.config/proxyctl/agent-env.sh`,并在 `start` / `stop` / `restart` / `fix` / + `daemon` / `engine` 之后自动刷新:引擎端口在听 → 指向引擎;否则指向 + `agent_env.fallback_daemon` 声明的 port;两个都没在听 → 删掉文件(避免 agent + 一直打到死口)。`--install` 幂等注入 shell rc 的 marker 块,`--uninstall` + 一步收摊。 +- `proxyctl status` 新增 `AGENT ENV` 段;`status --json` 新增 `data.agent_env` + (契约文件路径、当前出口与来源、出口是否可达、`in_sync`、shell rc 是否已注入)。 +- `config.yaml` 新增 `agent_env:` 段:`enabled` / `providers` / `fallback_daemon` + / `shell_rc`。 +- doctor 新增 3 条建议:`agent_env.dead_endpoint`(warn,文件指向已停的出口)、 + `agent_env.missing`(有存活出口但没生成文件)、`agent_env.not_sourced` + (文件在但 shell rc 没 source 它);三条都带 `fix_command`、`auto_fixable=true`。 + +### Changed + +- `proxyctl env`(无参)现在额外输出 `PI_PROXY_` 的 `export` 行; + 当前没有存活出口时改输出 `unset`,避免 `eval "$(proxyctl env)"` 之后残留指向 + 死口的旧值。`proxyctl env --unset` 同时清除 `PI_PROXY_*`。 +- `proxyctl env` 支持 `--json`(`data.vars` + `data.agent_env`)。 +- `proxyctl --version --json` 的 `supported_features` 新增 `agent_env_contract`、 + `status_agent_env`。 +- 相关 flag 已进 bash / zsh / fish 补全。 + ## [0.5.13] — 2026-07-17 ### Fixed diff --git a/config.yaml.example b/config.yaml.example index 6151d76..1a8f83c 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -75,6 +75,24 @@ extra_daemons: {} # - 100.64.0.0/10 # Tailscale CGNAT # - .ts.net # Tailscale MagicDNS +# ───────────────────────────────────────────────────────────── +# agent CLI 的 provider 级代理变量(v0.5.14+) +# ───────────────────────────────────────────────────────────── +# 通用 HTTP(S)_PROXY 覆盖不到全部 agent:omp 的 anthropic-messages transport +# (cowork-fetch)只认 PI_PROXY_,缺它就直连 → 受限地区 403。 +# proxyctl 按生命周期把出口写成可 source 的契约文件(默认 +# ~/.config/proxyctl/agent-env.sh),start/stop/restart/fix/daemon 自动刷新: +# 引擎端口在听 → 指向引擎;否则指向 fallback_daemon 的 port;都没有 → 删文件。 +# +# proxyctl env --write # 立即生成/刷新 +# proxyctl env --install # 再往 shell rc 注入 source 块(幂等) +# proxyctl env --uninstall # 收摊 +agent_env: + enabled: true + providers: [anthropic] # → PI_PROXY_ANTHROPIC;可加 openai-codex 等 + fallback_daemon: claude-proxy # extra_daemons 里的名字;null 关闭兜底 + shell_rc: ~/.zprofile # --install 注入的目标 + # ───────────────────────────────────────────────────────────── # 其他配置 # ───────────────────────────────────────────────────────────── diff --git a/man/proxyctl.1 b/man/proxyctl.1 index 9adb5da..080488a 100644 --- a/man/proxyctl.1 +++ b/man/proxyctl.1 @@ -184,8 +184,20 @@ rulePayload / host / IP 聚合目的地,输出 app_contexts / candidate_contex .SS 工具 .TP -.B proxyctl env [--unset] -输出 / 清除 HTTP_PROXY / HTTPS_PROXY / NO_PROXY 环境变量(可 \fIeval\fR)。 +.B proxyctl env [--unset | --write | --install | --uninstall] [--shell-rc \fIPATH\fR] [--json] +无参:输出 HTTP_PROXY / HTTPS_PROXY / NO_PROXY 环境变量 + provider 级 +\fBPI_PROXY_\fR\fIPROVIDER\fR(可 \fIeval\fR;无存活出口时输出 \fBunset\fR)。 +\fB--unset\fR 清除同一组变量。 +.PP +\fB--write\fR 把 \fBPI_PROXY_*\fR 写成契约文件 +\fI~/.config/proxyctl/agent-env.sh\fR:引擎端口在听就指向引擎,否则指向 +\fIconfig.yaml\fR 中 \fBagent_env.fallback_daemon\fR 的 port,都没在听则删除文件。 +\fBstart\fR / \fBstop\fR / \fBrestart\fR / \fBfix\fR / \fBdaemon\fR / \fBengine\fR +之后会自动刷新,无需手工调用。 +.PP +\fB--install\fR 额外把 source 块(marker 包裹,幂等)写进 \fIshell_rc\fR +(默认 \fI~/.zprofile\fR,可用 \fB--shell-rc\fR 覆盖);\fB--uninstall\fR 摘掉该块 +并删除契约文件。三种写模式都支持 \fB--dry-run\fR。 .TP .B proxyctl log [--tail \fIN\fR] [--no-follow] [--json] 查看后端日志。默认 \fItail -f\fR;\fB--json\fR 为 NDJSON(首行 meta header + 数据行)。 @@ -268,10 +280,21 @@ DEPENDENCY_MISSING — 依赖二进制 / 脚本缺失 .TP .B NO_COLOR 遵循 https://no-color.org/。 +.TP +.B PROXYCTL_AGENT_ENV_PATH +覆盖 agent 代理变量契约文件路径(默认 \fI~/.config/proxyctl/agent-env.sh\fR)。 +.TP +.B PROXYCTL_SHELL_RC +覆盖 \fBproxyctl env --install\fR 注入的 shell rc(默认取配置里的 +\fIagent_env.shell_rc\fR,即 \fI~/.zprofile\fR)。 .SH FILES .TP .I ~/.config/proxyctl/config.yaml -proxyctl 自身配置(API token / 端口 / 企业 DNS / extra_daemons / no_proxy_extra / plugins_disabled)。 +proxyctl 自身配置(API token / 端口 / 企业 DNS / extra_daemons / no_proxy_extra / agent_env / plugins_disabled)。 +.TP +.I ~/.config/proxyctl/agent-env.sh +agent 代理变量契约文件(\fBPI_PROXY_\fR);由 \fBproxyctl env --write\fR +与生命周期命令生成,勿手改。 .TP .I ~/.config/mihomo/config.yaml mihomo 引擎配置(rules / proxies / proxy-providers / dns / tun)。 diff --git a/src/proxyctl/agent_env.py b/src/proxyctl/agent_env.py new file mode 100644 index 0000000..d779ccd --- /dev/null +++ b/src/proxyctl/agent_env.py @@ -0,0 +1,384 @@ +"""Agent CLI 代理变量托管(`PI_PROXY_*`)— v0.5.14+。 + +背景:通用 `HTTP(S)_PROXY` 并非所有 agent CLI 的 provider transport 都读。 +omp(pi 系)的 anthropic-messages transport(`cowork-fetch`)只认 +`PI_PROXY[_]`,没有就直连 → 地区封锁 `403 Request not allowed`; +usage 探针、OAuth 刷新等普通 fetch 却走 env proxy,于是出现"探针活着、 +opus 请求全红"的错觉。 + +proxyctl 是 7890(引擎)与 7891(claude-proxy 逃生通道)的持有者,所以由它 +把这组变量写成一个**可 source 的契约文件**: + + ~/.config/proxyctl/agent-env.sh + +渲染规则: + - 目标出口按「引擎端口在听 → 用引擎;否则兜底 daemon 端口在听 → 用它; + 都不在 → 删文件」,所以 start/stop/fix/daemon 之后文件永远指向活着的口。 + - 只写 provider 级变量(`PI_PROXY_ANTHROPIC`),**不写**通用 HTTP_PROXY—— + 通用变量由用户 shell 静态管理,引擎停了也不该被 proxyctl 悄悄摘掉。 + +契约文件首行是机器可读的元信息(source/endpoint/providers),`load()` 依赖它, +不要手改;`proxyctl env --write` 与生命周期命令都会重写。 +""" + +from __future__ import annotations + +import os +import re +import socket +from datetime import datetime +from typing import Any + +SCHEMA_VERSION = 1 +DEFAULT_PATH = "~/.config/proxyctl/agent-env.sh" +DEFAULT_SHELL_RC = "~/.zprofile" + +# config.yaml 的 agent_env: 段默认值(用户段整体覆盖同名字段) +DEFAULTS: dict[str, Any] = { + "enabled": True, + # provider 名 → 变量名 PI_PROXY_(非字母数字转下划线、大写) + "providers": ["anthropic"], + # 引擎不可用时的兜底出口:config.extra_daemons 里的名字(None 关闭兜底) + "fallback_daemon": "claude-proxy", + # `proxyctl env --install` 写入的 shell rc + "shell_rc": DEFAULT_SHELL_RC, +} + +SHELL_BEGIN = "# >>> proxyctl agent-env >>>" +SHELL_END = "# <<< proxyctl agent-env <<<" + +_HEADER_RE = re.compile(r"^#\s*proxyctl-agent-env\s+v(?P\d+)\s+(?P.*)$") +_EXPORT_RE = re.compile(r'^export\s+(?P[A-Za-z_][A-Za-z0-9_]*)="(?P[^"]*)"$') + + +# ── 路径 / 配置 ────────────────────────────────────────────────────────────── + +def env_path() -> str: + """契约文件路径。允许 PROXYCTL_AGENT_ENV_PATH 覆盖(测试用)。""" + raw = os.environ.get("PROXYCTL_AGENT_ENV_PATH") or DEFAULT_PATH + return os.path.expanduser(raw) + + +def shell_rc_path(cfg: dict[str, Any]) -> str: + """shell rc 路径。PROXYCTL_SHELL_RC 优先于配置(测试用)。""" + raw = (os.environ.get("PROXYCTL_SHELL_RC") + or cfg.get("shell_rc") or DEFAULT_SHELL_RC) + return os.path.expanduser(raw) + + +def resolve(config: dict[str, Any] | None) -> dict[str, Any]: + """把 config.yaml 的 agent_env 段合并到默认值上,并规范化字段类型。""" + cfg = dict(DEFAULTS) + user = (config or {}).get("agent_env") + if isinstance(user, dict): + cfg.update(user) + + providers = cfg.get("providers") + if isinstance(providers, str): + providers = [p.strip() for p in providers.split(",")] + cfg["providers"] = [str(p).strip() for p in (providers or []) if str(p).strip()] + cfg["enabled"] = bool(cfg.get("enabled", True)) + if not cfg.get("fallback_daemon"): + cfg["fallback_daemon"] = None + return cfg + + +def provider_var(provider: str) -> str: + """anthropic → PI_PROXY_ANTHROPIC(与 pi 系 CLI 的变量名规则一致)。""" + return "PI_PROXY_" + re.sub(r"[^A-Z0-9]", "_", provider.upper()) + + +def var_names(cfg: dict[str, Any]) -> list[str]: + return [provider_var(p) for p in cfg.get("providers") or []] + + +# ── 出口解析 ──────────────────────────────────────────────────────────────── + +def _port_open(port: int, timeout: float = 0.5) -> bool: + """TCP 连一下 127.0.0.1:port。测试 monkeypatch 这一层即可离线跑。""" + try: + with socket.create_connection(("127.0.0.1", int(port)), timeout=timeout): + return True + except OSError: + return False + + +def target(config: dict[str, Any], cfg: dict[str, Any] | None = None) -> tuple[str | None, str]: + """当前该指向的出口 → (endpoint, source),source ∈ engine / daemon: / none。""" + cfg = cfg or resolve(config) + engine_port = int((config or {}).get("proxy_port", 7890)) + if _port_open(engine_port): + return f"http://127.0.0.1:{engine_port}", "engine" + + name = cfg.get("fallback_daemon") + if name: + daemon = ((config or {}).get("extra_daemons") or {}).get(name) or {} + port = daemon.get("port") + if port and _port_open(int(port)): + return f"http://127.0.0.1:{int(port)}", f"daemon:{name}" + return None, "none" + + +# ── 渲染 / 解析 ───────────────────────────────────────────────────────────── + +def _now_iso() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def render(endpoint: str, source: str, cfg: dict[str, Any], *, + now: str | None = None) -> str: + """渲染契约文件全文(幂等:同输入同输出,便于比对避免无谓重写)。""" + providers = ",".join(cfg.get("providers") or []) + fallback = cfg.get("fallback_daemon") or "-" + lines = [ + f"# proxyctl-agent-env v{SCHEMA_VERSION} generated={now or _now_iso()}" + f" source={source} endpoint={endpoint} providers={providers}" + f" fallback={fallback}", + "# 由 `proxyctl env --write` 生成;start/stop/restart/fix 会自动刷新,勿手改。", + "# 用法:在 shell rc 里 `. 本文件`(`proxyctl env --install` 会自动加)。", + ] + for provider in cfg.get("providers") or []: + lines.append(f'export {provider_var(provider)}="{endpoint}"') + return "\n".join(lines) + "\n" + + +def _parse_header(line: str) -> dict[str, Any] | None: + m = _HEADER_RE.match(line) + if not m: + return None + meta: dict[str, Any] = {} + for token in m.group("kv").split(): + if "=" in token: + k, v = token.split("=", 1) + meta[k] = v + meta["schema_version"] = int(m.group("ver")) + return meta + + +def load() -> dict[str, Any] | None: + """读契约文件 → state dict;文件不存在 / 头部无法解析 → None(无声)。""" + p = env_path() + if not os.path.isfile(p): + return None + try: + with open(p, encoding="utf-8") as f: + lines = f.read().splitlines() + except OSError: + return None + + meta: dict[str, Any] | None = None + variables: dict[str, str] = {} + for line in lines: + if meta is None: + parsed = _parse_header(line) + if parsed is not None: + meta = parsed + continue + m = _EXPORT_RE.match(line.strip()) + if m: + variables[m.group("name")] = m.group("value") + if meta is None: + return None + + providers = [p for p in (meta.get("providers") or "").split(",") if p] + fallback = meta.get("fallback") + return { + "schema_version": meta.get("schema_version"), + "path": p, + "generated_at": meta.get("generated"), + "source": meta.get("source"), + "endpoint": meta.get("endpoint"), + "providers": providers, + "fallback_daemon": None if fallback in (None, "-") else fallback, + "vars": variables, + } + + +# ── 写入 / 清除 ───────────────────────────────────────────────────────────── + +def _write_atomic(path: str, text: str) -> None: + d = os.path.dirname(path) or "." + os.makedirs(d, exist_ok=True) + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + os.replace(tmp, path) + + +def clear() -> bool: + """删除契约文件。返回是否真的删了。""" + p = env_path() + try: + os.remove(p) + return True + except FileNotFoundError: + return False + except OSError: + return False + + +def sync(config: dict[str, Any]) -> dict[str, Any]: + """按当前出口刷新(或清除)契约文件。 + + Returns: + {"action": "written" | "unchanged" | "removed" | "none", + "path", "endpoint", "source", "vars", "changed"} + """ + cfg = resolve(config) + p = env_path() + + if not cfg["enabled"] or not cfg["providers"]: + removed = clear() + return {"action": "removed" if removed else "none", "path": p, + "endpoint": None, "source": "none", "vars": {}, "changed": removed} + + endpoint, source = target(config, cfg) + if not endpoint: + removed = clear() + return {"action": "removed" if removed else "none", "path": p, + "endpoint": None, "source": "none", "vars": {}, "changed": removed} + + body = render(endpoint, source, cfg) + # 头部含 generated= 时间戳 → 比对时忽略它,避免每次 start 都算"变了" + old = "" + if os.path.isfile(p): + try: + with open(p, encoding="utf-8") as f: + old = f.read() + except OSError: + old = "" + if _same_payload(old, body): + return {"action": "unchanged", "path": p, "endpoint": endpoint, + "source": source, "vars": _vars_for(endpoint, cfg), "changed": False} + + _write_atomic(p, body) + return {"action": "written", "path": p, "endpoint": endpoint, + "source": source, "vars": _vars_for(endpoint, cfg), "changed": True} + + +def _vars_for(endpoint: str, cfg: dict[str, Any]) -> dict[str, str]: + return {provider_var(p): endpoint for p in cfg.get("providers") or []} + + +def _same_payload(old: str, new: str) -> bool: + return _strip_header(old) == _strip_header(new) + + +def _strip_header(text: str) -> list[str]: + out = [] + for line in text.splitlines(): + if _HEADER_RE.match(line): + # 去掉 generated= 与 source=... (source 由出口决定,保留) + parts = [t for t in line.split() if not t.startswith("generated=")] + out.append(" ".join(parts)) + else: + out.append(line) + return out + + +# ── shell rc 安装 ─────────────────────────────────────────────────────────── + +def _source_line() -> str: + return f'[ -f "{env_path()}" ] && . "{env_path()}"' + + +def shell_rc_installed(cfg: dict[str, Any]) -> bool: + rc = shell_rc_path(cfg) + try: + with open(rc, encoding="utf-8") as f: + return SHELL_BEGIN in f.read() + except OSError: + return False + + +def install_shell(cfg: dict[str, Any]) -> dict[str, Any]: + """把 source 块写进 shell rc(幂等)。返回 {rc, action}。""" + rc = shell_rc_path(cfg) + block = f"{SHELL_BEGIN}\n{_source_line()}\n{SHELL_END}\n" + old = "" + if os.path.isfile(rc): + try: + with open(rc, encoding="utf-8") as f: + old = f.read() + except OSError: + old = "" + new = _replace_block(old, block) + if new == old: + return {"rc": rc, "action": "unchanged"} + _write_atomic(rc, new) + return {"rc": rc, "action": "updated" if old else "created"} + + +def uninstall_shell(cfg: dict[str, Any]) -> dict[str, Any]: + """摘掉 shell rc 里的 source 块(幂等)。返回 {rc, action}。""" + rc = shell_rc_path(cfg) + if not os.path.isfile(rc): + return {"rc": rc, "action": "missing"} + try: + with open(rc, encoding="utf-8") as f: + old = f.read() + except OSError: + return {"rc": rc, "action": "missing"} + new = _replace_block(old, "") + if new == old: + return {"rc": rc, "action": "unchanged"} + _write_atomic(rc, new) + return {"rc": rc, "action": "removed"} + + +def _replace_block(text: str, block: str) -> str: + """替换(或删除)marker 之间的块,保留文件其余内容与结尾换行风格。""" + lines = text.splitlines(keepends=True) + kept: list[str] = [] + skipping = False + for line in lines: + if line.strip() == SHELL_BEGIN: + skipping = True + continue + if skipping: + if line.strip() == SHELL_END: + skipping = False + continue + kept.append(line) + body = "".join(kept).rstrip("\n") + if not block: + return body + "\n" if body else "" + return (body + "\n" if body else "") + block + + +# ── 状态(status / doctor 输入)────────────────────────────────────────────── + +def status(config: dict[str, Any]) -> dict[str, Any]: + """契约文件当前状态 vs 期望状态,供 status 面板与 doctor 规则使用。""" + cfg = resolve(config) + state = load() + expected_endpoint, expected_source = target(config, cfg) + + present = state is not None + endpoint = state.get("endpoint") if state else None + port = None + if endpoint: + try: + from urllib.parse import urlparse + port = urlparse(endpoint).port + except Exception: + port = None + reachable = bool(port) and _port_open(int(port)) if port else False + + return { + "enabled": cfg["enabled"], + "path": env_path(), + "providers": cfg["providers"], + "present": present, + "vars": (state or {}).get("vars") or {}, + "endpoint": endpoint, + "source": (state or {}).get("source"), + "generated_at": (state or {}).get("generated_at"), + "reachable": reachable, + "stale": bool(present and port and not reachable), + "expected_endpoint": expected_endpoint, + "expected_source": expected_source, + "in_sync": bool(present and endpoint == expected_endpoint), + "shell_rc": shell_rc_path(cfg), + "shell_rc_installed": shell_rc_installed(cfg), + } diff --git a/src/proxyctl/cli.py b/src/proxyctl/cli.py index 1eb1847..f90ce57 100755 --- a/src/proxyctl/cli.py +++ b/src/proxyctl/cli.py @@ -18,6 +18,7 @@ import time from proxyctl import _io +from proxyctl import agent_env from proxyctl._io import maybe_disable_module_colors maybe_disable_module_colors(__name__) @@ -55,6 +56,10 @@ # 个人附加的 NO_PROXY 项(追加到默认 localhost/私网集合之后) # 例: ["corp.example.com", "intranet.local"] 或 "corp.example.com,intranet.local" "no_proxy_extra": [], + # agent CLI 的 provider 级代理变量托管(PI_PROXY_)。 + # 通用 HTTP(S)_PROXY 之外,部分 agent transport(如 omp 的 anthropic-messages) + # 只认 PI_PROXY_*;proxyctl 按生命周期把出口写进 agent-env.sh。 + "agent_env": dict(agent_env.DEFAULTS), } SCRIPTS_DIR = os.path.dirname(os.path.realpath(__file__)) @@ -656,6 +661,7 @@ def cmd_start(backend: Backend, config: dict, registry=None): _apply_route_hooks(registry, {"engine": backend.name, "config": config, "phase": "start"}, "activate") + _agent_env_sync(config) # ── 命令:stop ──────────────────────────────────────────────────────────────── @@ -673,6 +679,7 @@ def cmd_stop(backend: Backend, config: dict, registry=None): service_stop(backend) print(f"{backend.name} stopped") + _agent_env_sync(config) # ── 命令:restart ───────────────────────────────────────────────────────────── @@ -704,6 +711,7 @@ def cmd_restart(backend: Backend, config: dict, *, clean: bool = False, registry _apply_route_hooks(registry, {"engine": backend.name, "config": config, "phase": "restart"}, "activate") + _agent_env_sync(config) # ── 命令:fix ───────────────────────────────────────────────────────────────── @@ -794,6 +802,8 @@ def cmd_fix(backend: Backend, config: dict, registry=None): print(f"{YELLOW}引擎已停止,无需修复。{NC}") print(f" 使用 {BOLD}proxyctl start{NC} 启动引擎") + _agent_env_sync(config) + # ── 命令:recover ───────────────────────────────────────────────────────────── @@ -1136,6 +1146,7 @@ def cmd_engine(backend: Backend, target: str, config: dict): print("系统代理 → 127.0.0.1:7890") print(f"{GREEN}引擎已切换到 {new_backend.name}{NC}") print(f"{CYAN}提示: 把 engine: {target} 写到 ~/.config/proxyctl/config.yaml 保持持久{NC}") + _agent_env_sync(config) def cmd_daemon(name: str, subcmd: str, config: dict): @@ -1224,6 +1235,7 @@ def cmd_daemon(name: str, subcmd: str, config: dict): print(f"{GREEN}✓{NC} {name} started (127.0.0.1:{port})") else: print(f"{GREEN}✓{NC} {name} started") + _agent_env_sync(config) elif subcmd == "stop": if launchctl_running(full_label, sudo=True): @@ -1232,11 +1244,13 @@ def cmd_daemon(name: str, subcmd: str, config: dict): print(f"{name} stopped") else: print(f"{name} 未在运行") + _agent_env_sync(config) elif subcmd == "restart": argvs = _daemon_subprocess_argvs("restart", plist_src, plist_dst, full_label) run(argvs[0], sudo=True) # launchctl kickstart -k print(f"{name} restarted") + _agent_env_sync(config) elif subcmd == "log": if not log_path: @@ -1352,24 +1366,46 @@ def _normalize_no_proxy_extra(extra) -> list[str]: return out -def cmd_env(config: dict, unset: bool = False): - """输出设置/清除代理环境变量的 shell 语句。 +def _env_flag_value(args: list, name: str) -> str | None: + """取 `--flag value` / `--flag=value` 的值;未给出返回 None。""" + for i, a in enumerate(args or []): + if a == name and i + 1 < len(args): + return args[i + 1] + if a.startswith(name + "="): + return a.split("=", 1)[1] + return None - 用法: - eval $(proxyctl env) # 设置代理 - eval $(proxyctl env --unset) # 清除代理 - Args: - config: 全局配置字典 - unset: True 则输出 unset 语句 - """ - if unset: - for var in ("http_proxy", "https_proxy", "all_proxy", - "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", - "no_proxy", "NO_PROXY"): - print(f"unset {var};") - return +def _config_with_shell_rc(config: dict, shell_rc: str) -> dict: + """把 --shell-rc 覆盖合进 config.agent_env(不改原 dict)。""" + merged = dict(config.get("agent_env") or {}) + merged["shell_rc"] = shell_rc + out = dict(config) + out["agent_env"] = merged + return out + +def _env_mode(args: list) -> tuple[str, str | None]: + """解析 `proxyctl env` 的工作模式 → (mode, shell_rc)。 + + mode ∈ print | unset | write | install | uninstall。 + 优先级:uninstall > install > write > unset(off) > print。 + plan(_plan_env)与实际执行(cmd_env)共用这一处判定。 + """ + argv = list(args or []) + shell_rc = _env_flag_value(argv, "--shell-rc") + for flag, mode in (("--uninstall", "uninstall"), + ("--install", "install"), + ("--write", "write")): + if flag in argv: + return mode, shell_rc + if "--unset" in argv or "off" in argv: + return "unset", None + return "print", None + + +def _env_generic_vars(config: dict) -> dict[str, str]: + """通用代理环境变量(http_proxy / https_proxy / all_proxy / no_proxy 家族)。""" port = int(config.get("proxy_port", DEFAULTS["proxy_port"])) # mixed-port proxy_http = f"http://127.0.0.1:{port}" proxy_socks = f"socks5://127.0.0.1:{port}" @@ -1379,14 +1415,137 @@ def cmd_env(config: dict, unset: bool = False): if extra: no_proxy = no_proxy + "," + ",".join(extra) - for var in ("http_proxy", "HTTP_PROXY"): - print(f"export {var}={proxy_http};") - for var in ("https_proxy", "HTTPS_PROXY"): - print(f"export {var}={proxy_http};") + out: dict[str, str] = {} + for var in ("http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"): + out[var] = proxy_http for var in ("all_proxy", "ALL_PROXY"): - print(f"export {var}={proxy_socks};") + out[var] = proxy_socks for var in ("no_proxy", "NO_PROXY"): - print(f"export {var}={no_proxy};") + out[var] = no_proxy + return out + + +def _env_agent_vars(config: dict) -> tuple[dict[str, str], str | None]: + """provider 级变量(PI_PROXY_*)→ (vars, endpoint)。 + + 出口按当前存活情况解析(引擎 → 兜底 daemon)。无出口 / 未启用时返回空 dict, + 调用方据此输出 unset——否则 eval 之后会残留指向死口的旧值。 + """ + cfg = agent_env.resolve(config) + if not cfg["enabled"] or not cfg["providers"]: + return {}, None + endpoint, source = agent_env.target(config, cfg) + if not endpoint: + return {}, None + return {name: endpoint for name in agent_env.var_names(cfg)}, endpoint + + +def _print_agent_env_sync(st: dict, *, verbose: bool = False) -> None: + """生命周期命令 / env --write 的契约文件一行摘要。""" + path = st.get("path", "") + action = st.get("action") + if action in ("written", "unchanged"): + pairs = " ".join(f"{k}={v}" for k, v in (st.get("vars") or {}).items()) + if action == "written": + print(f" {GREEN}✓{NC} agent env → {pairs} {DIM}({path}){NC}") + elif verbose: + print(f" {DIM}agent env 已是最新 → {pairs} ({path}){NC}") + elif action == "removed": + print(f" {YELLOW}—{NC} agent env 已清除(无存活出口):{DIM}{path}{NC}") + elif verbose: + print(f" {DIM}agent env 未启用 / 无 provider,无需写入{NC}") + + +def _agent_env_sync(config: dict) -> None: + """生命周期命令刷新契约文件:失败只 warning,绝不中断主流程。""" + try: + st = agent_env.sync(config) + except Exception as e: # pragma: no cover - 防御性 + print(f"{YELLOW}!{NC} agent env 刷新失败:{e}", file=sys.stderr) + return + _print_agent_env_sync(st) + + +def cmd_env(config: dict, unset: bool = False, *, mode: str | None = None, + shell_rc: str | None = None) -> None: + """proxyctl env [...] — 代理环境变量的打印 / 清除 / 托管。 + + 用法: + eval "$(proxyctl env)" # 当前 shell 设置(通用变量 + PI_PROXY_*) + eval "$(proxyctl env --unset)" # 当前 shell 清除 + proxyctl env --write # 写 ~/.config/proxyctl/agent-env.sh + # (start/stop/restart/fix/daemon 也会自动刷新) + proxyctl env --install # --write + 往 shell rc 注入 source 块(幂等) + proxyctl env --uninstall # 摘掉 source 块 + 删契约文件 + + Args: + config: 全局配置字典 + unset: 兼容旧签名;等价于 mode="unset" + mode: print | unset | write | install | uninstall(None 时由 unset 推导) + shell_rc: 覆盖 config.agent_env.shell_rc(--install/--uninstall 用) + """ + mode = mode or ("unset" if unset else "print") + as_json = GLOBAL_FLAGS.get("json", False) + if shell_rc: + config = _config_with_shell_rc(config, shell_rc) + cfg = agent_env.resolve(config) + + if mode in ("write", "install"): + st = agent_env.sync(config) + shell = (agent_env.install_shell(cfg) if mode == "install" else None) + if as_json: + _io.emit_json(_io.envelope("env", data={ + "agent_env": agent_env.status(config), + "result": st, + "shell": shell, + })) + return + _print_agent_env_sync(st, verbose=True) + if shell is not None: + print(f" {GREEN}✓{NC} shell rc {shell['action']}: " + f"{shell['rc']}") + if shell["action"] in ("created", "updated"): + print(f" {DIM}新开的 shell 生效;当前 shell 直接 " + f'eval "$(proxyctl env)"{NC}') + return + + if mode == "uninstall": + shell = agent_env.uninstall_shell(cfg) + removed = agent_env.clear() + if as_json: + _io.emit_json(_io.envelope("env", data={ + "agent_env": agent_env.status(config), + "shell": shell, + "file_removed": removed, + })) + return + print(f" {GREEN}✓{NC} shell rc {shell['action']}: {shell['rc']}") + print(f" {GREEN}✓{NC} 契约文件{'已删除' if removed else '本就不存在'}:" + f"{agent_env.env_path()}") + return + + if mode == "unset": + for var in list(_env_generic_vars(config)) + agent_env.var_names(cfg): + print(f"unset {var};") + return + + agent_vars, _endpoint = _env_agent_vars(config) + if as_json: + _io.emit_json(_io.envelope("env", data={ + "vars": {**_env_generic_vars(config), **agent_vars}, + "agent_env": agent_env.status(config), + })) + return + + for var, value in _env_generic_vars(config).items(): + print(f"export {var}={value};") + if agent_vars: + for var, value in agent_vars.items(): + print(f"export {var}={value};") + else: + # 无存活出口:显式 unset,避免 eval 后残留死口 + for var in agent_env.var_names(cfg): + print(f"unset {var};") # ── 命令:log ───────────────────────────────────────────────────────────────── @@ -1705,6 +1864,8 @@ def cmd_version_print() -> None: "proxy_group_dead_check": True, # 0.5.0: proxy_group.mostly_dead 规则 "traffic_snapshot": True, # 0.5.6+: active /connections traffic grouping "traffic_history": True, # 0.5.6+: sample/watch/report recorded deltas + "agent_env_contract": True, # 0.5.14+: PI_PROXY_* 契约文件 + env --write/--install + "status_agent_env": True, # 0.5.14+: status envelope.data.agent_env }, } _io.emit_json(_io.envelope("version", data=data)) @@ -2111,6 +2272,48 @@ def _plan_audit_apply(days: int, backend, config: dict) -> list[dict]: ] +def _plan_env(mode: str, path: str, rc: str) -> list[dict]: + """env --write / --install / --uninstall 的 plan。 + + 契约文件路径与 shell rc 由 cli/agent_env 的同一套解析给出,避免 plan 与执行漂移。 + """ + write_step = { + "action": "fs_write_atomic", + "target": path, + "summary": f"按当前出口刷新(或清除){path}(tmp + rename,内容不变则跳过)", + "reversible": True, + "side_effects": ["config-write"], + } + rc_write_step = { + "action": "fs_write_atomic", + "target": rc, + "summary": f"在 {rc} 注入/替换 source 块(marker 幂等)", + "reversible": True, + "side_effects": ["config-write"], + } + rc_remove_step = { + "action": "fs_write_atomic", + "target": rc, + "summary": f"从 {rc} 摘掉 source 块(marker 幂等)", + "reversible": True, + "side_effects": ["config-write"], + } + remove_step = { + "action": "fs_remove", + "target": path, + "summary": f"删除契约文件 {path}", + "reversible": False, + "side_effects": ["config-write"], + } + if mode == "write": + return [write_step] + if mode == "install": + return [write_step, rc_write_step] + if mode == "uninstall": + return [rc_remove_step, remove_step] + return [] + + def _plan_config_set(path: str, key: str, value_repr: str) -> list[dict]: return [ {"action": "fs_copy", @@ -2798,8 +3001,17 @@ def _h_dns_lock(ctx): ctx["config"], ctx["backend"], reload=reload) def _h_env(ctx): - unset = "--unset" in ctx["args"] or "off" in ctx["args"] - cmd_env(ctx["config"], unset=unset) + mode, shell_rc = _env_mode(ctx["args"]) + if mode in ("write", "install", "uninstall"): + cfg = agent_env.resolve( + _config_with_shell_rc(ctx["config"], shell_rc) if shell_rc + else ctx["config"]) + _maybe_dry_run( + "env", + lambda: _plan_env(mode, agent_env.env_path(), + agent_env.shell_rc_path(cfg))) + cmd_env(ctx["config"], unset=(mode == "unset"), mode=mode, + shell_rc=shell_rc) def _h_engine(ctx): target = ctx["args"][0] if ctx["args"] else "" diff --git a/src/proxyctl/completion.py b/src/proxyctl/completion.py index 5747c2a..7ab0e94 100644 --- a/src/proxyctl/completion.py +++ b/src/proxyctl/completion.py @@ -155,7 +155,7 @@ def _gen_bash() -> str: # env 特有 flag if [ "$first" = "env" ]; then - COMPREPLY=( $(compgen -W "--unset" -- "${{cur}}") ) + COMPREPLY=( $(compgen -W "--unset --write --install --uninstall --shell-rc --dry-run -n --json --help" -- "${{cur}}") ) return 0 fi @@ -233,7 +233,7 @@ def _gen_zsh() -> str: fi ;; log) _values 'flag' --tail --no-follow --json ;; - env) _values 'flag' --unset ;; + env) _values 'flag' --unset --write --install --uninstall --shell-rc --dry-run --json ;; esac ;; esac @@ -341,6 +341,22 @@ def _gen_fish() -> str: "complete -c proxyctl -n '__fish_seen_subcommand_from env' " "-l unset -d '清除代理环境变量'" ) + lines.append( + "complete -c proxyctl -n '__fish_seen_subcommand_from env' " + "-l write -d '写 PI_PROXY_* 契约文件(按当前出口)'" + ) + lines.append( + "complete -c proxyctl -n '__fish_seen_subcommand_from env' " + "-l install -d '写文件 + 注入 shell rc(幂等)'" + ) + lines.append( + "complete -c proxyctl -n '__fish_seen_subcommand_from env' " + "-l uninstall -d '摘掉 shell rc 块 + 删契约文件'" + ) + lines.append( + "complete -c proxyctl -n '__fish_seen_subcommand_from env' " + "-l shell-rc -d '覆盖 shell rc 路径'" + ) return "\n".join(lines) + "\n" diff --git a/src/proxyctl/explain.py b/src/proxyctl/explain.py index 2e0079f..6581f8c 100644 --- a/src/proxyctl/explain.py +++ b/src/proxyctl/explain.py @@ -210,19 +210,29 @@ def _t_extra_daemons(backend, config) -> TopicCard: @topic("env") def _t_env(backend, config) -> TopicCard: port = config.get("proxy_port", 7890) + from proxyctl import agent_env as _ae + contract = _ae.env_path() return { "topic": "env", "summary": ( - "代理环境变量(HTTP_PROXY / HTTPS_PROXY / NO_PROXY 等)。" - f"`proxyctl env` 输出 export 行,可 eval 进当前 shell(端口 {port})。" + "代理环境变量。两条线:(1) 通用 HTTP_PROXY / HTTPS_PROXY / NO_PROXY" + f"——`proxyctl env` 输出 export 行,可 eval 进当前 shell(端口 {port});" + "(2) provider 级 PI_PROXY_——由 proxyctl 写成契约文件 " + f"{contract},start/stop/restart/fix/daemon 自动刷新。" + "部分 agent transport(omp 的 anthropic-messages / cowork-fetch)只认 " + "PI_PROXY_*,不读通用变量;缺它就是直连,受限地区直接 403。" ), - "file": _io_proxyctl_config_path() + " [no_proxy_extra: 字段]", + "file": contract + " [agent_env: 段;no_proxy_extra: 字段]", "edit": ( " # no_proxy_extra: 追加内网域名 / IPv4 CIDR / 企业 host\n" - " # 裸 IPv6 CIDR 会被跳过,避免 Python/httpx 误解析 NO_PROXY" + " # 裸 IPv6 CIDR 会被跳过,避免 Python/httpx 误解析 NO_PROXY\n" + " # agent_env.providers: [anthropic] → PI_PROXY_ANTHROPIC\n" + " # agent_env.fallback_daemon: claude-proxy → 引擎停时改指向 7891\n" + " # 生成/注入:\n" + " proxyctl env --write && proxyctl env --install" ), "verify": "eval \"$(proxyctl env)\" && env | grep -i proxy", - "next_commands": ["env", "env --unset"], + "next_commands": ["env", "env --write", "status", "doctor"], } @@ -1204,10 +1214,23 @@ def _build_agent_guide(backend, config) -> str: "needs_sudo": True, "interactive": False, "exit_codes": [0, 1], "examples": ["proxyctl dns-unlock", "proxyctl dns-unlock --dry-run"]}, # tools / agent - {"name": "env", "group": "tool", "summary": "输出代理环境变量(可 eval)", - "args": [], "supports_json": False, "side_effects": [], - "needs_sudo": False, "interactive": False, "exit_codes": [0], - "examples": ["proxyctl env", "proxyctl env --unset"]}, + {"name": "env", "group": "tool", + "summary": "代理环境变量:打印(可 eval)/ 托管 PI_PROXY_* 给 agent CLI", + "args": [{"name": "--unset", "required": False}, + {"name": "--write", "required": False}, + {"name": "--install", "required": False}, + {"name": "--uninstall", "required": False}, + {"name": "--shell-rc", "required": False}], + "supports_json": True, "side_effects": [], + "conditional_side_effects": {"write": ["config-write"], + "install": ["config-write"], + "uninstall": ["config-write"]}, + "supports_dry_run": True, + "needs_sudo": False, "interactive": False, "exit_codes": [0, 2], + "examples": ["proxyctl env", "proxyctl env --unset", + "proxyctl env --write", + "proxyctl env --install", + "proxyctl env --uninstall"]}, {"name": "log", "group": "tool", "summary": "查看后端日志:默认 tail -f;支持 --tail N / --no-follow / --json", "args": [], "supports_json": True, "side_effects": [], @@ -1793,6 +1816,14 @@ def _build_doctor_suggestions(backend, config, engine_ver, *, except Exception: proxies_payload = None + # agent 代理变量契约文件(PI_PROXY_*)— 纯本地读 + 端口探测 + agent_env_state = None + try: + from proxyctl import agent_env as _agent_env + agent_env_state = _agent_env.status(config) + except Exception: + agent_env_state = None + return _suggest.build_suggestions( sub=sub, autostart_inspect=autostart_inspect, @@ -1803,6 +1834,7 @@ def _build_doctor_suggestions(backend, config, engine_ver, *, known_versions=known_versions, engine_config_dir=expected_cfg_dir, proxies_payload=proxies_payload, + agent_env_state=agent_env_state, since=since_filter, apply_user_ignore=True, ) @@ -2177,6 +2209,55 @@ def set_global_flags(flags: dict) -> None: "verify": "proxyctl check --json | jq '.data.stages.groups'", "next_commands": ["bench", "check", "status"], }, + # ── agent 代理变量 3 条(v0.5.14+)────────────────────────────────── + "agent_env.dead_endpoint": { + "summary": ( + "agent 代理变量契约文件(~/.config/proxyctl/agent-env.sh)指向的出口" + "已经没在监听——通常是引擎停了、兜底 daemon 也停了,文件没跟上。" + "此时 agent CLI 的请求会打到死口(连接被拒),比直连更糟。" + ), + "file": "~/.config/proxyctl/agent-env.sh", + "edit": ( + " # 自动:按当前存活出口重写(没有存活出口则删文件)\n" + " proxyctl env --write\n" + " # 手动:新开的 shell 里清掉旧变量\n" + " eval \"$(proxyctl env --unset)\"" + ), + "verify": "proxyctl status --json | jq .data.agent_env", + "next_commands": ["status", "env", "doctor"], + }, + "agent_env.missing": { + "summary": ( + "引擎/兜底出口活着,但没有生成 agent 代理变量契约文件。" + "部分 agent CLI(omp 的 anthropic-messages transport)不读通用 " + "HTTPS_PROXY,只认 PI_PROXY_,缺变量就会直连并在受限地区" + "拿到 403。" + ), + "file": "~/.config/proxyctl/agent-env.sh", + "edit": ( + " # 写文件(生命周期命令 start/restart/fix 也会自动刷新)\n" + " proxyctl env --write\n" + " # 让新 shell 自动带上:注入 shell rc\n" + " proxyctl env --install" + ), + "verify": "proxyctl status --json | jq .data.agent_env", + "next_commands": ["env", "status", "doctor"], + }, + "agent_env.not_sourced": { + "summary": ( + "契约文件已生成,但 shell rc 没 source 它——新开的终端 / agent 进程" + "不会带上 PI_PROXY_*,等于没托管。" + ), + "file": "~/.zprofile", + "edit": ( + " # 幂等注入 marker 块(可 --uninstall 摘掉)\n" + " proxyctl env --install\n" + " # 或手工加一行:\n" + " # . ~/.config/proxyctl/agent-env.sh" + ), + "verify": "proxyctl status --json | jq .data.agent_env.shell_rc_installed", + "next_commands": ["env", "status"], + }, } diff --git a/src/proxyctl/status.py b/src/proxyctl/status.py index 8b8a037..842ab3d 100644 --- a/src/proxyctl/status.py +++ b/src/proxyctl/status.py @@ -130,6 +130,15 @@ def _gather_ports(claude_proxy_label: str, "cp_pid": cp_pid, "cp_port": cp_port} +def _gather_agent_env(config: dict) -> dict: + """采集 agent 代理变量托管状态(契约文件 + 出口存活 + shell rc 注入)。""" + try: + from proxyctl import agent_env as _ae + return _ae.status(config) + except Exception as e: # pragma: no cover - 防御性 + return {"error": str(e)} + + def _gather_tun(engine, daemon_up: bool) -> dict: """采集 TUN 专属数据(Linux 最小集不启用 TUN,返回空数据)。""" tun_iface = addr = mtu = "" @@ -523,6 +532,7 @@ def cmd_status(engine, api: str, api_secret: str, f_proxy = pool.submit(_gather_proxy_settings) f_dns = pool.submit(_gather_dns, dns_lock_label) f_network = pool.submit(_gather_network, engine) + f_agent = pool.submit(_gather_agent_env, config) # 插件 status_sections:每个 section 并发跑 gather ctx = {"engine": engine.name, "mode": mode, "config": config} @@ -537,6 +547,7 @@ def cmd_status(engine, api: str, api_secret: str, d_proxy = f_proxy.result() d_dns = f_dns.result() d_network = f_network.result() + d_agent_env = f_agent.result() daemon_up = d_engine["daemon_up"] d_sections: dict = {} @@ -565,6 +576,7 @@ def cmd_status(engine, api: str, api_secret: str, "network": d_network, "sections": _jsonify(d_sections), "env": env_proxy, + "agent_env": d_agent_env, "mode": mode, "backend": engine.name, "subscription": sub_data, @@ -584,6 +596,7 @@ def cmd_status(engine, api: str, api_secret: str, _print_proxy_settings(d_proxy, daemon_up, mode) _print_dns(daemon_up, d_dns, mode) _print_network(d_network) + _print_agent_env(d_agent_env) # 插件 sections(VPN/Tailscale/TUIC relay 等本机特例都走这里) for section in sections: try: @@ -602,6 +615,34 @@ def cmd_status(engine, api: str, api_secret: str, print(f" {' '.join(f'{k}={v}' for k, v in env_proxy.items())}") +def _print_agent_env(st: dict) -> None: + """打印 agent 代理变量托管一段(PI_PROXY_*)。文件不存在不报错,只提示。""" + if st.get("error"): + return + print(f"\n{BOLD}AGENT ENV{NC}") + if not st.get("enabled"): + print(f" {DIM}—{NC} 未启用(config.agent_env.enabled = false)") + return + + vars_txt = " ".join(f"{k}={v}" for k, v in (st.get("vars") or {}).items()) + if not st.get("present"): + print(f" {YELLOW}—{NC} 契约文件未生成:{DIM}{st.get('path')}{NC}") + if st.get("expected_endpoint"): + print(f" {DIM}引擎在线;运行 {CYAN}proxyctl env --write{DIM} 或重启一次即可{DIM}{NC}") + return + + if st.get("stale"): + print(f" {RED}✗{NC} {vars_txt} {DIM}(出口不可达){NC}") + else: + print(f" {GREEN}✓{NC} {vars_txt} {DIM}({st.get('source')}){NC}") + if not st.get("in_sync") and st.get("expected_endpoint"): + print(f" {YELLOW}期望指向 {st['expected_endpoint']}" + f",运行 {CYAN}proxyctl env --write{YELLOW} 刷新{NC}") + if not st.get("shell_rc_installed"): + print(f" {YELLOW}!{NC} {st.get('shell_rc')} 未注入 source 块" + f"(新 shell 不会生效)→ {CYAN}proxyctl env --install{NC}") + + def _print_subscription(sub: dict) -> None: """打印订阅状态一段。受 severity 染色。""" from proxyctl import subscription as _sub diff --git a/src/proxyctl/suggest.py b/src/proxyctl/suggest.py index eaa5457..fe07ab9 100644 --- a/src/proxyctl/suggest.py +++ b/src/proxyctl/suggest.py @@ -234,6 +234,7 @@ def build_suggestions(*, sub: dict[str, Any] | None = None, known_versions: dict[str, Any] | None = None, engine_config_dir: str | None = None, proxies_payload: dict[str, Any] | None = None, + agent_env_state: dict[str, Any] | None = None, since: str | None = None, ignore_set: set[str] | None = None, apply_user_ignore: bool = True, @@ -253,6 +254,8 @@ def build_suggestions(*, sub: dict[str, Any] | None = None, path_version: PATH binary 的版本(来自 cli.get_engine_version) expected_config_dir: backend 期望的 config_dir (用于 autostart.config_dir_mismatch) + agent_env_state: proxyctl.agent_env.status() 返回; + None 表示跳过 agent_env.* 组 persist_state: 是否写 state 文件(测试可关) _extra_raw: 测试/未来扩展注入额外 raw suggestions(不经过 subscription) @@ -292,6 +295,10 @@ def build_suggestions(*, sub: dict[str, Any] | None = None, from proxyctl import suggest_rules as _rules raw.extend(_rules.proxy_group_rules(proxies_payload)) + if agent_env_state is not None: + from proxyctl import suggest_rules as _rules + raw.extend(_rules.agent_env_rules(agent_env_state)) + if _extra_raw: raw.extend(_extra_raw) diff --git a/src/proxyctl/suggest_rules.py b/src/proxyctl/suggest_rules.py index 72e9bd4..2ae411f 100644 --- a/src/proxyctl/suggest_rules.py +++ b/src/proxyctl/suggest_rules.py @@ -1,12 +1,15 @@ """proxyctl.suggest_rules — 安全/引擎/数据/分组类规则(v0.5.0+)。 -容纳 controller / engine / data / proxy_group 四组规则的纯函数: +容纳 controller / engine / data / proxy_group / agent_env 五组规则的纯函数: controller.empty_secret — external-controller secret == "" controller.weak_secret — secret 长度 < 16 controller.public_bind — external-controller bind 到 0.0.0.0 / 公网 engine.outdated — 当前版本 < known_versions.json 的 safe_min data.geo_stale — geoip.dat / geosite.dat mtime > 30 天 proxy_group.mostly_dead — 单组 ≥ 70% 节点 delay==0(多组各自指纹) + agent_env.dead_endpoint — PI_PROXY_* 契约文件指向的出口已停 + agent_env.missing — 有存活出口但契约文件没生成 + agent_env.not_sourced — 契约文件在但 shell rc 没 source 它 每条规则都是纯函数:输入预解析过的字典,输出 Suggestion list。 读 mihomo config / known_versions.json / geo 文件 mtime / /proxies API @@ -457,3 +460,95 @@ def geo_rules(engine_config_dir: str | None, "doc": "suggestion:data.geo_stale", "since": "0.5.0", }] + + +# ──────────────────────────────────────────────────────────────────────────── +# agent_env.* — agent CLI 的 PI_PROXY_* 契约文件(v0.5.14+) +# ──────────────────────────────────────────────────────────────────────────── + +AGENT_ENV_WRITE_CMD = "proxyctl env --write" +AGENT_ENV_INSTALL_CMD = "proxyctl env --install" + + +def agent_env_rules(state: dict[str, Any] | None) -> list[dict[str, Any]]: + """agent 代理变量契约文件规则。输入 = proxyctl.agent_env.status()。 + + 三条: + agent_env.dead_endpoint — 文件指向的出口已不可达(引擎/兜底 daemon 都停了) + agent_env.missing — 有存活出口但文件没生成(agent 走直连) + agent_env.not_sourced — 文件在,但 shell rc 没注入 source 块(新 shell 不生效) + + state 为 None / 未启用 / 采集失败时整组跳过(不制造噪音)。 + """ + if not state or state.get("error") or not state.get("enabled"): + return [] + + out: list[dict[str, Any]] = [] + present = bool(state.get("present")) + expected = state.get("expected_endpoint") + path = state.get("path") + vars_map = state.get("vars") or {} + + if present and not state.get("reachable"): + out.append({ + "id": "agent_env.dead_endpoint", + "severity": "warn", + "actor": "agent", + "title": (f"agent 代理变量指向已停的出口:{state.get('endpoint')}" + f"({path})"), + "evidence": { + "path": path, + "endpoint": state.get("endpoint"), + "source": state.get("source"), + "reachable": False, + "expected_endpoint": expected, + "vars": vars_map, + }, + "inspect_command": "proxyctl status --json | jq .data.agent_env", + "fix_command": AGENT_ENV_WRITE_CMD, + "auto_fixable": True, + "doc": "suggestion:agent_env.dead_endpoint", + "since": "0.5.14", + }) + + if not present and expected: + out.append({ + "id": "agent_env.missing", + "severity": "advisory", + "actor": "agent", + "title": (f"有存活出口({expected})但未生成 agent 代理变量:" + f"agent CLI(omp 等)会走直连"), + "evidence": { + "path": path, + "expected_endpoint": expected, + "expected_source": state.get("expected_source"), + "providers": state.get("providers") or [], + }, + "inspect_command": "proxyctl status --json | jq .data.agent_env", + "fix_command": AGENT_ENV_WRITE_CMD, + "auto_fixable": True, + "doc": "suggestion:agent_env.missing", + "since": "0.5.14", + }) + + if present and not state.get("shell_rc_installed"): + out.append({ + "id": "agent_env.not_sourced", + "severity": "advisory", + "actor": "agent", + "title": (f"{state.get('shell_rc')} 未 source {path}" + f":新开的 shell / agent 不会带上 PI_PROXY_*"), + "evidence": { + "path": path, + "shell_rc": state.get("shell_rc"), + "shell_rc_installed": False, + "vars": vars_map, + }, + "inspect_command": "proxyctl status --json | jq .data.agent_env", + "fix_command": AGENT_ENV_INSTALL_CMD, + "auto_fixable": True, + "doc": "suggestion:agent_env.not_sourced", + "since": "0.5.14", + }) + + return out diff --git a/tests/unit/test_agent_env.py b/tests/unit/test_agent_env.py new file mode 100644 index 0000000..2fe2e73 --- /dev/null +++ b/tests/unit/test_agent_env.py @@ -0,0 +1,428 @@ +"""测试 proxyctl.agent_env — agent 代理变量契约文件(v0.5.14+)。 + +策略:端口存活探测(`_port_open`)全部 monkeypatch,测试不依赖真实监听; +HOME 由 conftest 隔离到 tmp_path,契约文件写在沙箱里。 +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from proxyctl import agent_env, cli, suggest_rules + + +@pytest.fixture +def open_ports(monkeypatch): + """可编程的端口存活表:open_ports({7890}) → 只有 7890 算活着。""" + def _set(ports): + alive = set(int(p) for p in ports) + monkeypatch.setattr(agent_env, "_port_open", + lambda port, timeout=0.5: int(port) in alive) + _set(set()) + return _set + + +CONFIG = {"proxy_port": 7890, + "extra_daemons": {"claude-proxy": {"port": 7891}}} + + +# ──────────────────────────────────────────────────────────────────────────── +# 变量名 / 配置解析 +# ──────────────────────────────────────────────────────────────────────────── + +def test_provider_var_matches_pi_naming(): + """变量名规则与 pi 系 CLI 的 PI_PROXY_ 变换一致。""" + assert agent_env.provider_var("anthropic") == "PI_PROXY_ANTHROPIC" + assert agent_env.provider_var("claude-code") == "PI_PROXY_CLAUDE_CODE" + assert agent_env.provider_var("openai.codex") == "PI_PROXY_OPENAI_CODEX" + + +def test_resolve_defaults_and_user_override(): + assert agent_env.resolve({})["providers"] == ["anthropic"] + cfg = agent_env.resolve({"agent_env": {"providers": "anthropic,openai-codex", + "fallback_daemon": None}}) + assert cfg["providers"] == ["anthropic", "openai-codex"] + assert cfg["fallback_daemon"] is None + # 单字段覆盖不影响其余默认值 + assert cfg["shell_rc"] == agent_env.DEFAULTS["shell_rc"] + + +def test_var_names_uses_config_providers(): + cfg = agent_env.resolve({"agent_env": {"providers": ["anthropic", "openai"]}}) + assert agent_env.var_names(cfg) == ["PI_PROXY_ANTHROPIC", "PI_PROXY_OPENAI"] + + +# ──────────────────────────────────────────────────────────────────────────── +# 出口解析:引擎 → 兜底 daemon → 无 +# ──────────────────────────────────────────────────────────────────────────── + +def test_target_prefers_engine(open_ports): + open_ports({7890, 7891}) + assert agent_env.target(CONFIG) == ("http://127.0.0.1:7890", "engine") + + +def test_target_falls_back_to_daemon(open_ports): + open_ports({7891}) + assert agent_env.target(CONFIG) == ("http://127.0.0.1:7891", + "daemon:claude-proxy") + + +def test_target_none_when_nothing_alive(open_ports): + open_ports(set()) + assert agent_env.target(CONFIG) == (None, "none") + + +def test_target_without_fallback_daemon(open_ports): + open_ports({7891}) + cfg = {"proxy_port": 7890, "extra_daemons": {"claude-proxy": {"port": 7891}}, + "agent_env": {"fallback_daemon": None}} + assert agent_env.target(cfg) == (None, "none") + + +# ──────────────────────────────────────────────────────────────────────────── +# 渲染 / 解析 +# ──────────────────────────────────────────────────────────────────────────── + +def test_render_roundtrip_via_load(open_ports): + open_ports({7890}) + cfg = agent_env.resolve(CONFIG) + agent_env.sync(CONFIG) + + st = agent_env.load() + assert st is not None + assert st["schema_version"] == agent_env.SCHEMA_VERSION + assert st["source"] == "engine" + assert st["endpoint"] == "http://127.0.0.1:7890" + assert st["providers"] == ["anthropic"] + assert st["vars"] == {"PI_PROXY_ANTHROPIC": "http://127.0.0.1:7890"} + assert st["fallback_daemon"] == "claude-proxy" + assert st["path"].endswith(".config/proxyctl/agent-env.sh") + + +def test_rendered_file_is_sourceable_shell(open_ports): + """契约文件本身可被 sh source:export 行 + 无语法噪声。""" + open_ports({7890}) + agent_env.sync(CONFIG) + text = Path(agent_env.env_path()).read_text(encoding="utf-8") + body = [l for l in text.splitlines() if l and not l.startswith("#")] + assert body == ['export PI_PROXY_ANTHROPIC="http://127.0.0.1:7890"'] + + +# ──────────────────────────────────────────────────────────────────────────── +# sync:语义 = 有存活出口就写、没有就删 +# ──────────────────────────────────────────────────────────────────────────── + +def test_sync_writes_then_is_idempotent(open_ports, monkeypatch): + open_ports({7890}) + first = agent_env.sync(CONFIG) + assert first["action"] == "written" + + before = Path(agent_env.env_path()).read_text(encoding="utf-8") + monkeypatch.setattr(agent_env, "_now_iso", + lambda: "2099-01-01T00:00:00+08:00") + second = agent_env.sync(CONFIG) + assert second["action"] == "unchanged" + # 时间戳变了也不重写(否则每次 start 都会"脏"一次文件) + assert Path(agent_env.env_path()).read_text(encoding="utf-8") == before + + +def test_sync_removes_file_when_no_endpoint(open_ports): + open_ports({7890}) + agent_env.sync(CONFIG) + assert os.path.isfile(agent_env.env_path()) + + open_ports(set()) + st = agent_env.sync(CONFIG) + assert st["action"] == "removed" + assert not os.path.isfile(agent_env.env_path()) + + +def test_sync_removes_file_when_disabled(open_ports): + open_ports({7890}) + agent_env.sync(CONFIG) + st = agent_env.sync({**CONFIG, "agent_env": {"enabled": False}}) + assert st["action"] == "removed" + assert not os.path.isfile(agent_env.env_path()) + + +def test_sync_rewrites_when_endpoint_switches_to_daemon(open_ports): + open_ports({7890}) + agent_env.sync(CONFIG) + open_ports({7891}) + st = agent_env.sync(CONFIG) + assert st["action"] == "written" + assert st["source"] == "daemon:claude-proxy" + assert agent_env.load()["vars"]["PI_PROXY_ANTHROPIC"] == "http://127.0.0.1:7891" + + +# ──────────────────────────────────────────────────────────────────────────── +# shell rc 注入 +# ──────────────────────────────────────────────────────────────────────────── + +def test_install_shell_is_idempotent_and_preserves_content(open_ports, tmp_path): + open_ports({7890}) + rc = tmp_path / "home/.zprofile" + rc.parent.mkdir(parents=True, exist_ok=True) + rc.write_text("export PATH=/x:$PATH\n", encoding="utf-8") + + first = agent_env.install_shell(agent_env.resolve(CONFIG)) + assert first["action"] == "updated" + text = rc.read_text(encoding="utf-8") + assert "export PATH=/x:$PATH" in text + assert agent_env.SHELL_BEGIN in text and agent_env.SHELL_END in text + assert agent_env.env_path() in text + # 同一行只出现一次 + assert text.count(agent_env.SHELL_BEGIN) == 1 + + assert agent_env.install_shell(agent_env.resolve(CONFIG))["action"] == "unchanged" + assert rc.read_text(encoding="utf-8").count(agent_env.SHELL_BEGIN) == 1 + + +def test_install_shell_creates_missing_rc(open_ports): + open_ports({7890}) + res = agent_env.install_shell(agent_env.resolve(CONFIG)) + assert res["action"] == "created" + assert agent_env.shell_rc_installed(agent_env.resolve(CONFIG)) is True + + +def test_uninstall_shell_removes_only_marker_block(open_ports, tmp_path): + open_ports({7890}) + rc = tmp_path / "home/.zprofile" + rc.parent.mkdir(parents=True, exist_ok=True) + rc.write_text("line-before\n", encoding="utf-8") + agent_env.install_shell(agent_env.resolve(CONFIG)) + with open(rc, "a", encoding="utf-8") as f: + f.write("line-after\n") + + res = agent_env.uninstall_shell(agent_env.resolve(CONFIG)) + assert res["action"] == "removed" + text = rc.read_text(encoding="utf-8") + assert agent_env.SHELL_BEGIN not in text + assert "line-before" in text and "line-after" in text + assert agent_env.uninstall_shell(agent_env.resolve(CONFIG))["action"] == "unchanged" + + +# ──────────────────────────────────────────────────────────────────────────── +# status(status 面板 / doctor 的输入) +# ──────────────────────────────────────────────────────────────────────────── + +def test_status_before_write(open_ports): + open_ports({7890}) + st = agent_env.status(CONFIG) + assert st["present"] is False + assert st["expected_endpoint"] == "http://127.0.0.1:7890" + assert st["in_sync"] is False + + +def test_status_stale_when_endpoint_dead(open_ports): + open_ports({7890}) + agent_env.sync(CONFIG) + open_ports(set()) # 出口挂了,文件还在 + st = agent_env.status(CONFIG) + assert st["present"] is True and st["reachable"] is False + assert st["stale"] is True + + +def test_status_in_sync_after_write(open_ports): + open_ports({7890}) + agent_env.sync(CONFIG) + st = agent_env.status(CONFIG) + assert st["in_sync"] is True and st["reachable"] is True + assert st["vars"] == {"PI_PROXY_ANTHROPIC": "http://127.0.0.1:7890"} + + +# ──────────────────────────────────────────────────────────────────────────── +# doctor 规则 +# ──────────────────────────────────────────────────────────────────────────── + +def _state(**over): + base = {"enabled": True, "present": True, "reachable": True, + "stale": False, "in_sync": True, "shell_rc_installed": True, + "path": "/tmp/agent-env.sh", "endpoint": "http://127.0.0.1:7890", + "source": "engine", "expected_endpoint": "http://127.0.0.1:7890", + "expected_source": "engine", "shell_rc": "/tmp/.zprofile", + "providers": ["anthropic"], "vars": {}} + base.update(over) + return base + + +def test_agent_env_rules_clean_state_is_quiet(): + assert suggest_rules.agent_env_rules(_state()) == [] + + +def test_agent_env_rules_skip_when_disabled_or_missing_input(): + assert suggest_rules.agent_env_rules(None) == [] + assert suggest_rules.agent_env_rules({"enabled": False}) == [] + assert suggest_rules.agent_env_rules({"error": "boom"}) == [] + + +def test_agent_env_rules_dead_endpoint_is_warn_and_autofixable(): + rules = suggest_rules.agent_env_rules(_state(reachable=False, stale=True, + expected_endpoint=None)) + assert [r["id"] for r in rules] == ["agent_env.dead_endpoint"] + r = rules[0] + assert r["severity"] == "warn" and r["auto_fixable"] is True + assert r["fix_command"] == "proxyctl env --write" + assert r["doc"] == "suggestion:agent_env.dead_endpoint" + + +def test_agent_env_rules_missing_file_with_live_endpoint(): + rules = suggest_rules.agent_env_rules(_state(present=False, in_sync=False)) + assert [r["id"] for r in rules] == ["agent_env.missing"] + assert rules[0]["severity"] == "advisory" + + +def test_agent_env_rules_missing_file_without_endpoint_is_quiet(): + """没有存活出口时"文件缺失"不是问题——没什么可写的。""" + assert suggest_rules.agent_env_rules( + _state(present=False, in_sync=False, expected_endpoint=None)) == [] + + +def test_agent_env_rules_not_sourced(): + rules = suggest_rules.agent_env_rules(_state(shell_rc_installed=False)) + assert [r["id"] for r in rules] == ["agent_env.not_sourced"] + assert rules[0]["fix_command"] == "proxyctl env --install" + + +# ──────────────────────────────────────────────────────────────────────────── +# CLI:proxyctl env +# ──────────────────────────────────────────────────────────────────────────── + +def _reset_flags(json_mode=False): + cli.GLOBAL_FLAGS.update({"json": json_mode, "quiet": False, + "dry_run": False, "no_color": True}) + + +def test_cmd_env_prints_agent_var_when_endpoint_alive(open_ports, capsys): + open_ports({7890}) + _reset_flags() + cli.cmd_env(dict(CONFIG)) + out = capsys.readouterr().out + assert "export https_proxy=http://127.0.0.1:7890;" in out + assert 'export PI_PROXY_ANTHROPIC=http://127.0.0.1:7890;' in out + + +def test_cmd_env_unsets_agent_var_when_nothing_alive(open_ports, capsys): + """无存活出口 → 输出 unset,避免 eval 后残留死口。""" + open_ports(set()) + _reset_flags() + cli.cmd_env(dict(CONFIG)) + out = capsys.readouterr().out + assert "unset PI_PROXY_ANTHROPIC;" in out + assert "export PI_PROXY_ANTHROPIC" not in out + + +def test_cmd_env_unset_includes_agent_vars(open_ports, capsys): + open_ports({7890}) + _reset_flags() + cli.cmd_env(dict(CONFIG), unset=True) + out = capsys.readouterr().out + assert "unset https_proxy;" in out + assert "unset PI_PROXY_ANTHROPIC;" in out + + +def test_cmd_env_json_exposes_agent_env(open_ports, capsys): + open_ports({7890}) + agent_env.sync(CONFIG) + _reset_flags(json_mode=True) + cli.cmd_env(dict(CONFIG)) + env = json.loads(capsys.readouterr().out) + assert env["cmd"] == "env" + assert env["data"]["vars"]["PI_PROXY_ANTHROPIC"] == "http://127.0.0.1:7890" + assert env["data"]["agent_env"]["present"] is True + + +def test_cmd_env_write_creates_contract_file(open_ports, capsys): + open_ports({7890}) + _reset_flags() + cli.cmd_env(dict(CONFIG), mode="write") + assert "agent env" in capsys.readouterr().out + assert agent_env.load()["endpoint"] == "http://127.0.0.1:7890" + + +def test_cmd_env_install_then_uninstall(open_ports, capsys): + open_ports({7890}) + _reset_flags() + cli.cmd_env(dict(CONFIG), mode="install") + out = capsys.readouterr().out + assert "shell rc" in out + assert agent_env.status(CONFIG)["shell_rc_installed"] is True + assert os.path.isfile(agent_env.env_path()) + + cli.cmd_env(dict(CONFIG), mode="uninstall") + capsys.readouterr() + assert agent_env.status(CONFIG)["shell_rc_installed"] is False + assert not os.path.isfile(agent_env.env_path()) + + +def test_cmd_env_shell_rc_flag_overrides_config(open_ports, capsys, tmp_path): + open_ports({7890}) + _reset_flags() + alt = tmp_path / "alt-rc" + cli.cmd_env(dict(CONFIG), mode="install", shell_rc=str(alt)) + capsys.readouterr() + assert agent_env.SHELL_BEGIN in alt.read_text(encoding="utf-8") + + +# ── 模式解析 / dry-run plan ──────────────────────────────────────────────── + +def test_env_mode_precedence(): + assert cli._env_mode([]) == ("print", None) + assert cli._env_mode(["--unset"])[0] == "unset" + assert cli._env_mode(["off"])[0] == "unset" + assert cli._env_mode(["--write"])[0] == "write" + assert cli._env_mode(["--install", "--write"])[0] == "install" + assert cli._env_mode(["--uninstall", "--install"])[0] == "uninstall" + + +def test_env_mode_reads_shell_rc_value_and_equals_form(): + assert cli._env_mode(["--install", "--shell-rc", "/tmp/rc"]) == ("install", "/tmp/rc") + assert cli._env_mode(["--install", "--shell-rc=/tmp/rc"]) == ("install", "/tmp/rc") + + +def test_plan_env_covers_write_install_uninstall(): + w = cli._plan_env("write", "/p/agent-env.sh", "/p/.zprofile") + assert [s["target"] for s in w] == ["/p/agent-env.sh"] + + i = cli._plan_env("install", "/p/agent-env.sh", "/p/.zprofile") + assert [s["target"] for s in i] == ["/p/agent-env.sh", "/p/.zprofile"] + + u = cli._plan_env("uninstall", "/p/agent-env.sh", "/p/.zprofile") + assert [s["target"] for s in u] == ["/p/.zprofile", "/p/agent-env.sh"] + assert all("config-write" in s["side_effects"] for s in w + i + u) + + +def test_h_env_dry_run_does_not_write(open_ports, capsys): + """--dry-run 只输出 plan,不落盘。""" + open_ports({7890}) + _reset_flags(json_mode=True) + cli.GLOBAL_FLAGS["dry_run"] = True + try: + with pytest.raises(SystemExit) as exc: + cli._h_env({"config": dict(CONFIG), "args": ["--install"]}) + assert exc.value.code == 0 + env = json.loads(capsys.readouterr().out) + assert [s["target"] for s in env["data"]["plan"]] == [ + agent_env.env_path(), agent_env.shell_rc_path(agent_env.resolve(CONFIG))] + assert not os.path.isfile(agent_env.env_path()) + finally: + cli.GLOBAL_FLAGS["dry_run"] = False + + +def test_h_env_keeps_legacy_unset_kwarg(open_ports, monkeypatch): + """集成 smoke 依赖 cmd_env(unset=...) 的调用形状,不能被重构掉。""" + seen = {} + + def fake_cmd_env(config, unset=False, **kw): + seen["unset"] = unset + seen["mode"] = kw.get("mode") + + monkeypatch.setattr(cli, "cmd_env", fake_cmd_env) + cli._h_env({"config": dict(CONFIG), "args": ["--unset"]}) + assert seen == {"unset": True, "mode": "unset"} + cli._h_env({"config": dict(CONFIG), "args": []}) + assert seen == {"unset": False, "mode": "print"} diff --git a/tests/unit/test_completion.py b/tests/unit/test_completion.py index 7f17db0..18de01f 100644 --- a/tests/unit/test_completion.py +++ b/tests/unit/test_completion.py @@ -175,3 +175,27 @@ def test_fish_completion_includes_log_tail_no_follow(capsys): "-l tail") in out assert ("complete -c proxyctl -n '__fish_seen_subcommand_from log' " "-l no-follow") in out + + +# ── 0.5.14:env 的 PI_PROXY_* 托管 flag ──────────────────────────────────── +def test_bash_completion_includes_env_contract_flags(capsys): + completion.cmd_completion(["bash"]) + out = capsys.readouterr().out + for flag in ("--write", "--install", "--uninstall", "--shell-rc"): + assert flag in out, f"bash 补全缺 env 的 {flag}" + + +def test_zsh_completion_handles_env_contract_flags(capsys): + completion.cmd_completion(["zsh"]) + out = capsys.readouterr().out + line = next(l for l in out.splitlines() if l.strip().startswith("env)")) + for flag in ("--write", "--install", "--uninstall", "--shell-rc"): + assert flag in line, f"zsh 补全缺 env 的 {flag}" + + +def test_fish_completion_includes_env_contract_flags(capsys): + completion.cmd_completion(["fish"]) + out = capsys.readouterr().out + for flag in ("write", "install", "uninstall", "shell-rc"): + assert ("complete -c proxyctl -n '__fish_seen_subcommand_from env' " + f"-l {flag}") in out, f"fish 补全缺 env 的 -l {flag}" diff --git a/tests/unit/test_suggest_explain_completeness.py b/tests/unit/test_suggest_explain_completeness.py index d89e95d..e1f6ac3 100644 --- a/tests/unit/test_suggest_explain_completeness.py +++ b/tests/unit/test_suggest_explain_completeness.py @@ -30,13 +30,16 @@ "autostart.placeholder_unrendered", "autostart.disabled", "autostart.flapping", - # controller / engine / data / proxy_group(src/proxyctl/suggest_rules.py) + # controller / engine / data / proxy_group / agent_env(src/proxyctl/suggest_rules.py) "controller.empty_secret", "controller.weak_secret", "controller.public_bind", "engine.outdated", "data.geo_stale", "proxy_group.mostly_dead", + "agent_env.dead_endpoint", + "agent_env.missing", + "agent_env.not_sourced", ]