proxyctl 0.4.5__tar.gz → 0.4.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {proxyctl-0.4.5 → proxyctl-0.4.7}/PKG-INFO +2 -2
  2. {proxyctl-0.4.5 → proxyctl-0.4.7}/README.md +1 -1
  3. {proxyctl-0.4.5 → proxyctl-0.4.7}/man/proxyctl.1 +1 -1
  4. {proxyctl-0.4.5 → proxyctl-0.4.7}/pyproject.toml +1 -1
  5. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/cli.py +65 -0
  6. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/explain.py +9 -4
  7. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/status.py +17 -4
  8. {proxyctl-0.4.5 → proxyctl-0.4.7}/.gitignore +0 -0
  9. {proxyctl-0.4.5 → proxyctl-0.4.7}/LICENSE +0 -0
  10. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/__init__.py +0 -0
  11. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/_io.py +0 -0
  12. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/audit.py +0 -0
  13. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  14. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
  15. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  16. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/check.py +0 -0
  17. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/completion.py +0 -0
  18. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/core/__init__.py +0 -0
  19. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/core/plugin.py +0 -0
  20. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/engine/__init__.py +0 -0
  21. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/engine/base.py +0 -0
  22. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/engine/mihomo.py +0 -0
  23. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/engine/singbox.py +0 -0
  24. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/subscription.py +0 -0
  25. {proxyctl-0.4.5 → proxyctl-0.4.7}/src/proxyctl/trace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyctl
3
- Version: 0.4.5
3
+ Version: 0.4.7
4
4
  Summary: Proxy configuration lifecycle management for macOS and Linux
5
5
  Project-URL: Homepage, https://github.com/crhan/proxyctl
6
6
  Project-URL: Issues, https://github.com/crhan/proxyctl/issues
@@ -193,7 +193,7 @@ uv tool install proxyctl # uv(推荐)
193
193
  pipx install proxyctl # 或 pipx
194
194
  pip install --user proxyctl # 或 pip
195
195
 
196
- proxyctl --version # → proxyctl v0.4.3
196
+ proxyctl --version # → proxyctl v0.4.7
197
197
  proxyctl --help
198
198
  ```
199
199
 
@@ -164,7 +164,7 @@ uv tool install proxyctl # uv(推荐)
164
164
  pipx install proxyctl # 或 pipx
165
165
  pip install --user proxyctl # 或 pip
166
166
 
167
- proxyctl --version # → proxyctl v0.4.3
167
+ proxyctl --version # → proxyctl v0.4.7
168
168
  proxyctl --help
169
169
  ```
170
170
 
@@ -1,4 +1,4 @@
1
- .TH PROXYCTL 1 "2026-05" "proxyctl 0.4.5" "User Commands"
1
+ .TH PROXYCTL 1 "2026-05" "proxyctl 0.4.7" "User Commands"
2
2
  .SH NAME
3
3
  proxyctl \- Proxy configuration lifecycle management for macOS / Linux
4
4
  .SH SYNOPSIS
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proxyctl"
3
- version = "0.4.5"
3
+ version = "0.4.7"
4
4
  description = "Proxy configuration lifecycle management for macOS and Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -6,6 +6,7 @@
6
6
  mode|audit|trace|bench|dns-lock|dns-unlock]
7
7
  """
8
8
 
9
+ import functools
9
10
  import json
10
11
  import os
11
12
  import platform
@@ -250,6 +251,69 @@ def run_out(cmd: list, *, sudo: bool = False) -> str:
250
251
  return r.stdout.strip() if r.returncode == 0 else ""
251
252
 
252
253
 
254
+ @functools.lru_cache(maxsize=4)
255
+ def get_engine_version(backend_name: str) -> dict | None:
256
+ """运行 `<backend> -v` 解析引擎版本信息。
257
+
258
+ proxyctl 是引擎生命周期管理器,但之前没在任何 status 里显示引擎版本——
259
+ 导致 debug TUIC / DNS 问题时无法快速判定"是不是引擎版本 regression"。
260
+ 本函数填补此缺陷(v0.4.7+)。
261
+
262
+ Args:
263
+ backend_name: "mihomo" / "singbox" / 等等。
264
+
265
+ Returns:
266
+ dict 或 None(binary 找不到 / -v 失败 / 解析失败):
267
+ {
268
+ "binary": "/opt/homebrew/bin/mihomo",
269
+ "version": "1.19.25",
270
+ "platform": "darwin arm64",
271
+ "go_version": "1.26.3",
272
+ "build_date": "2026-05-16",
273
+ "raw": "Mihomo Meta 1.19.25 darwin arm64 with go1.26.3 2026-05-16T14:37:07Z",
274
+ }
275
+
276
+ mihomo -v 输出范本:
277
+ Mihomo Meta 1.19.25 darwin arm64 with go1.26.3 2026-05-16T14:37:07Z
278
+ Use tags: with_gvisor
279
+
280
+ sing-box version 输出范本 (供后续扩展):
281
+ sing-box version 1.10.0
282
+ Environment: go1.22.5 darwin/arm64
283
+ Tags: ...
284
+
285
+ LRU cache 4 项:避免每次 status 都跑 subprocess(同进程内常量)。
286
+ """
287
+ import shutil
288
+ import re
289
+ binary = shutil.which(backend_name)
290
+ if not binary:
291
+ return None
292
+ try:
293
+ r = subprocess.run([binary, "-v"], capture_output=True, text=True, timeout=3)
294
+ except (subprocess.TimeoutExpired, OSError):
295
+ return None
296
+ out = (r.stdout or "").strip()
297
+ if not out or r.returncode != 0:
298
+ return None
299
+ raw = out.split("\n", 1)[0].strip()
300
+ info: dict = {"binary": binary, "raw": raw}
301
+ # 匹配 mihomo: "Mihomo Meta 1.19.25 darwin arm64 with go1.26.3 2026-05-16T14:37:07Z"
302
+ m = re.match(
303
+ r"^\S+\s+\S+\s+(\S+)\s+(\S+\s+\S+)\s+with\s+go(\S+)\s+(\S+)",
304
+ raw,
305
+ )
306
+ if m:
307
+ info["version"] = m.group(1)
308
+ info["platform"] = m.group(2)
309
+ info["go_version"] = m.group(3)
310
+ info["build_date"] = m.group(4).split("T")[0]
311
+ else:
312
+ # 兜底:能拿到 raw 就行,version 字段留空让 agent 自己处理
313
+ info["version"] = None
314
+ return info
315
+
316
+
253
317
  def wait_port(port: int, timeout: float = 10.0) -> bool:
254
318
  """轮询直到 127.0.0.1:port 就绪,超时返回 False。"""
255
319
  deadline = time.monotonic() + timeout
@@ -1584,6 +1648,7 @@ def cmd_version_print() -> None:
1584
1648
  "lifecycle_dry_run": True, # 0.4.2: start/stop/restart/recover --dry-run
1585
1649
  "version_subcommand": True, # 0.4.2: `proxyctl version` 子命令
1586
1650
  "status_subscription": True, # 0.4.4: status envelope.data.subscription
1651
+ "engine_version": True, # 0.4.7: status/doctor data.engine_version (mihomo -v)
1587
1652
  },
1588
1653
  }
1589
1654
  _io.emit_json(_io.envelope("version", data=data))
@@ -119,7 +119,9 @@ def _t_engine(backend, config) -> TopicCard:
119
119
  "topic": "engine",
120
120
  "summary": (
121
121
  f"代理引擎(后端)。当前:{backend.name}。"
122
- "支持 mihomo / sing-box;通过 proxyctl engine <name> 切换后端,"
122
+ "支持 mihomo(首发,端到端验证)/ sing-box(预留,未端到端验证 "
123
+ "类 / 路径 / audit / trace 解析已实现,但完整启停闭环未跑过生产);"
124
+ "通过 proxyctl engine <name> 切换后端,"
123
125
  "通过 proxyctl mode <tun|proxy> 切换流量入站方式。"
124
126
  ),
125
127
  "file": f"{backend.config_file}",
@@ -997,7 +999,7 @@ COMMANDS_META: list[dict] = [
997
999
  "supports_json": False, "side_effects": ["config-write"],
998
1000
  "supports_dry_run": True,
999
1001
  "needs_sudo": True, "interactive": False,
1000
- "exit_codes": [0, 1, 2, 4, 6, 8],
1002
+ "exit_codes": [0, 1, 2],
1001
1003
  "examples": ["proxyctl mode tun", "proxyctl mode tun --dry-run"]},
1002
1004
  {"name": "engine", "group": "config", "summary": "切换代理引擎后端",
1003
1005
  "args": [{"name": "target", "choices": ["mihomo", "singbox"], "required": False}],
@@ -1441,7 +1443,7 @@ def cmd_doctor(args: list, backend, config) -> None:
1441
1443
  as_json = GLOBAL_FLAGS_REF().get("json", False)
1442
1444
 
1443
1445
  # 从 cli.py 复用 service_running,但为避免循环 import 在这里独立判断
1444
- from proxyctl.cli import service_running, get_mode
1446
+ from proxyctl.cli import service_running, get_mode, get_engine_version
1445
1447
  port = config.get("proxy_port", 7890)
1446
1448
  api_base = config.get("api_base", "http://127.0.0.1:9090")
1447
1449
 
@@ -1480,6 +1482,7 @@ def cmd_doctor(args: list, backend, config) -> None:
1480
1482
  "hint": hint,
1481
1483
  # informational fields (W15 in 0.3.0):
1482
1484
  "engine": backend.name,
1485
+ "engine_version": get_engine_version(backend.name), # v0.4.7+, None 即未知
1483
1486
  "mode": mode_str,
1484
1487
  "port": port,
1485
1488
  "config_path": _io_proxyctl_config_path(),
@@ -1495,8 +1498,10 @@ def cmd_doctor(args: list, backend, config) -> None:
1495
1498
  sys.exit(code)
1496
1499
 
1497
1500
  icon = lambda b: f"{GREEN}✓{NC}" if b else f"{RED}✗{NC}"
1501
+ ev = data.get("engine_version") or {}
1502
+ ev_tag = f" v{ev['version']}" if ev.get("version") else ""
1498
1503
  print(f"{BOLD}proxyctl doctor{NC} ({score}/{len(flags)}) "
1499
- f"{DIM}engine={backend.name} mode={mode_str} port={port}{NC}")
1504
+ f"{DIM}engine={backend.name}{ev_tag} mode={mode_str} port={port}{NC}")
1500
1505
  print(f" {icon(engine_up)} engine_up ({backend.name} 服务运行中)")
1501
1506
  print(f" {icon(port_listen)} port_listen (127.0.0.1:{port})")
1502
1507
  print(f" {icon(dns_ok)} dns_ok (系统 DNS 含 127.0.0.1)")
@@ -16,6 +16,7 @@ GREEN = "\033[0;32m"
16
16
  YELLOW = "\033[0;33m"
17
17
  CYAN = "\033[0;36m"
18
18
  BOLD = "\033[1m"
19
+ DIM = "\033[2m"
19
20
  NC = "\033[0m"
20
21
 
21
22
  from proxyctl._io import maybe_disable_module_colors as _pc_maybe_disable
@@ -68,7 +69,10 @@ def _ifconfig_ip(iface: str) -> str:
68
69
  # ── 数据采集函数(可并发) ────────────────────────────────────────────────────
69
70
 
70
71
  def _gather_engine(engine) -> dict:
71
- """采集引擎进程信息:PID、运行次数、运行时间。"""
72
+ """采集引擎进程信息:PID、运行次数、运行时间、版本(v0.4.7+)。"""
73
+ from proxyctl.cli import get_engine_version
74
+ version_info = get_engine_version(engine.name) # None / dict
75
+
72
76
  if IS_LINUX:
73
77
  # systemd --user:用 systemctl show 获取 PID
74
78
  r = subprocess.run(
@@ -83,7 +87,8 @@ def _gather_engine(engine) -> dict:
83
87
  r2 = subprocess.run(["ps", "-o", "etime=", "-p", pid],
84
88
  capture_output=True, text=True)
85
89
  etime = r2.stdout.strip()
86
- return {"pid": pid, "runs": runs, "daemon_up": daemon_up, "etime": etime}
90
+ return {"pid": pid, "runs": runs, "daemon_up": daemon_up,
91
+ "etime": etime, "version": version_info}
87
92
 
88
93
  # macOS: launchctl
89
94
  pid = _launchctl_pid(engine.label)
@@ -94,7 +99,8 @@ def _gather_engine(engine) -> dict:
94
99
  r = subprocess.run(["ps", "-o", "etime=", "-p", pid],
95
100
  capture_output=True, text=True)
96
101
  etime = r.stdout.strip()
97
- return {"pid": pid, "runs": runs, "daemon_up": daemon_up, "etime": etime}
102
+ return {"pid": pid, "runs": runs, "daemon_up": daemon_up,
103
+ "etime": etime, "version": version_info}
98
104
 
99
105
 
100
106
  def _gather_ports(claude_proxy_label: str,
@@ -312,7 +318,14 @@ def _print_engine(engine, mode: str, d_engine: dict, d_ports: dict):
312
318
  else:
313
319
  mode_tag = f"{YELLOW}{mode}{NC}"
314
320
 
315
- print(f"{BOLD}引擎{NC} {GREEN}{engine.name}{NC} · {mode_tag}")
321
+ # 引擎名 + 模式 + 版本号(v0.4.7+,引擎 binary 找不到时只显示名字)
322
+ ver = d_engine.get("version") or {}
323
+ ver_str = ""
324
+ if ver.get("version"):
325
+ ver_str = f" {DIM}v{ver['version']}{NC}"
326
+ if ver.get("build_date"):
327
+ ver_str += f" {DIM}({ver['build_date']}, go{ver.get('go_version','?')}){NC}"
328
+ print(f"{BOLD}引擎{NC} {GREEN}{engine.name}{NC}{ver_str} · {mode_tag}")
316
329
 
317
330
  if d_engine["daemon_up"]:
318
331
  runs_str = (f" {YELLOW}runs={d_engine['runs']}{NC}"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes