proxyctl 0.5.2__tar.gz → 0.5.3__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 (28) hide show
  1. {proxyctl-0.5.2 → proxyctl-0.5.3}/.gitignore +4 -1
  2. {proxyctl-0.5.2 → proxyctl-0.5.3}/PKG-INFO +31 -5
  3. {proxyctl-0.5.2 → proxyctl-0.5.3}/README.md +30 -4
  4. {proxyctl-0.5.2 → proxyctl-0.5.3}/pyproject.toml +1 -1
  5. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/autostart.py +12 -6
  6. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/check.py +82 -28
  7. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/explain.py +64 -2
  8. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/suggest_rules.py +45 -9
  9. {proxyctl-0.5.2 → proxyctl-0.5.3}/LICENSE +0 -0
  10. {proxyctl-0.5.2 → proxyctl-0.5.3}/man/proxyctl.1 +0 -0
  11. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/__init__.py +0 -0
  12. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/_io.py +0 -0
  13. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/audit.py +0 -0
  14. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  15. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
  16. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  17. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/cli.py +0 -0
  18. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/completion.py +0 -0
  19. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/core/__init__.py +0 -0
  20. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/core/plugin.py +0 -0
  21. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/engine/__init__.py +0 -0
  22. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/engine/base.py +0 -0
  23. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/engine/mihomo.py +0 -0
  24. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/engine/singbox.py +0 -0
  25. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/status.py +0 -0
  26. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/subscription.py +0 -0
  27. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/suggest.py +0 -0
  28. {proxyctl-0.5.2 → proxyctl-0.5.3}/src/proxyctl/trace.py +0 -0
@@ -72,4 +72,7 @@ claude-proxy.log
72
72
  .coverage.*
73
73
  htmlcov/
74
74
  coverage.xml
75
- junit.xml
75
+ junit.xml
76
+
77
+ # release.yml 抽取 CHANGELOG section 用的临时文件(CI 跑完会删)
78
+ .release-notes*.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyctl
3
- Version: 0.5.2
3
+ Version: 0.5.3
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
@@ -107,7 +107,7 @@ proxyctl 把 agent 友好度做成一等公民。完整接入协议见
107
107
 
108
108
  ```bash
109
109
  proxyctl agent-guide # Agent 入门 markdown(注入当前路径/端口)
110
- proxyctl agent-guide --list-sections # 15 个段,按需取小块(省 token)
110
+ proxyctl agent-guide --list-sections # 19 个段,按需取小块(省 token)
111
111
  proxyctl --version --json # schema_version + supported_features 探测
112
112
  proxyctl commands --json # 全部命令元数据(机读)
113
113
  proxyctl commands --schema # 上面 JSON 的 JSON Schema
@@ -191,6 +191,30 @@ proxyctl recover # 不重启引擎,热重载 + 清 fakeip +
191
191
  proxyctl recover --dry-run # 看 3 个 Clash API endpoint 再决定
192
192
  ```
193
193
 
194
+ ### `doctor` —— 5 项健康分 + 引导建议(v0.5.0+)
195
+ ```bash
196
+ proxyctl doctor # 5 项布尔健康分 + suggestions (warn/advisory)
197
+ proxyctl doctor --json # data.suggestions[] 含 21 条规则的检测结果
198
+ proxyctl doctor --suggest-only # 跳过 score 探测,仅跑建议引擎 (~0.18s)
199
+ proxyctl doctor --since 0.4.7 # 屏蔽指定版本后引入的规则 (CI 平滑迁移)
200
+ proxyctl doctor --no-suggest # 关闭建议引擎,恢复 v0.4.x 极简体验
201
+ proxyctl explain suggestion:<id> # 每条规则有触发条件 + 修复路径 topic
202
+ ```
203
+ 21 条规则覆盖订阅过期 / autostart binary 与 PATH 漂移 / API 安全配置 /
204
+ GeoIP 数据时效 / 代理组健康。详见 [AGENTS.md](AGENTS.md) "Doctor suggestions" 段。
205
+ 用户屏蔽某条告警:`~/.config/proxyctl/suggestions.ignore`(一行一个 id 或 fingerprint)。
206
+
207
+ ### `autostart` —— 自动启动 unit 管理(v0.5.0+)
208
+ ```bash
209
+ proxyctl autostart # plist/unit 当前状态
210
+ proxyctl autostart inspect --json # 结构化输出含 binary/version/enabled/config_dir
211
+ proxyctl autostart sync --dry-run # 预览 PlanStep[]
212
+ proxyctl autostart sync # 同步 plist/unit 到当前 PATH binary + config_dir
213
+ ```
214
+ 当 doctor 报 `autostart.binary_mismatch` / `version_mismatch` /
215
+ `config_dir_mismatch` 时一键修复。macOS plist 用 plistlib 原地改保留
216
+ KeepAlive / RunAtLoad 等用户定制;Linux ExecStart 缺失时拒绝执行防误覆盖。
217
+
194
218
  ---
195
219
 
196
220
  ## 安装
@@ -202,7 +226,7 @@ uv tool install proxyctl # uv(推荐)
202
226
  pipx install proxyctl # 或 pipx
203
227
  pip install --user proxyctl # 或 pip
204
228
 
205
- proxyctl --version # → proxyctl v0.4.7
229
+ proxyctl --version # → proxyctl v0.5.1
206
230
  proxyctl --help
207
231
  ```
208
232
 
@@ -268,13 +292,14 @@ no_proxy_extra: # 自 0.1.5 起追加 NO_PROXY
268
292
  | `start / stop / restart / restart-clean` | 启停引擎 + DNS/代理注入 | ✅ | ✅ |
269
293
  | `status` | 一站式系统面板 | — | ✅ |
270
294
  | `check` | 4 阶段健康检查 | — | ✅ |
271
- | `doctor` | 5 项健康打分 | — | ✅ |
295
+ | `doctor` | 5 项健康打分 + 21 条引导建议 (v0.5.0+) | — | ✅ |
272
296
  | `trace <domain>` | 域名链路诊断 | — | ✅ |
273
297
  | `audit [days] [apply]` | 日志驱动配置优化 | ✅ | ✅ |
274
298
  | `bench [groups]` | 节点测速 (NDJSON) | — | ✅ |
275
299
  | `fix` | 修复 DNS / 代理 / 热重载 | ✅ | ✅ |
276
300
  | `recover` | 切网后软恢复 | ✅ | ✅ |
277
301
  | `mode tun\|proxy` | 切换 TUN / 代理模式 | ✅ | ✅ |
302
+ | `autostart [inspect\|sync]` | 自动启动 unit 管理 (v0.5.0+) | ✅ | ✅ |
278
303
  | `dns-lock / dns-unlock` | 启停 DNS 看门狗 | ✅ | ✅ |
279
304
  | `daemon` | 管理 extra_daemons | ✅ | ✅ |
280
305
  | `agent-guide / commands / explain` | agent 自描述三件套 | — | ✅ |
@@ -290,6 +315,7 @@ no_proxy_extra: # 自 0.1.5 起追加 NO_PROXY
290
315
  | audit (含 apply) | ✅ | ✅ |
291
316
  | recover (切网软恢复) | ✅ | ✅ |
292
317
  | mode tun \| proxy 切换 | ✅ | ✅ |
318
+ | autostart inspect / sync | ✅ plistlib | ✅ systemd unit ExecStart |
293
319
  | dns-lock 看门狗 | ✅ | N/A* |
294
320
 
295
321
  \* Linux 下系统 DNS 由 systemd-resolved / NetworkManager / resolvconf
@@ -328,7 +354,7 @@ sing-box JSON config 解析、`trace` 的 sing-box 日志 grep、`engine` /
328
354
  git clone https://github.com/crhan/proxyctl.git
329
355
  cd proxyctl
330
356
  uv sync --group dev # 装运行 + 测试依赖
331
- uv run pytest # 跑 523 个测试
357
+ uv run pytest # 跑 685 个测试
332
358
  uv run proxyctl status # 用本地源码版本
333
359
 
334
360
  export PROXYCTL_DEBUG=1 # 调试模式
@@ -78,7 +78,7 @@ proxyctl 把 agent 友好度做成一等公民。完整接入协议见
78
78
 
79
79
  ```bash
80
80
  proxyctl agent-guide # Agent 入门 markdown(注入当前路径/端口)
81
- proxyctl agent-guide --list-sections # 15 个段,按需取小块(省 token)
81
+ proxyctl agent-guide --list-sections # 19 个段,按需取小块(省 token)
82
82
  proxyctl --version --json # schema_version + supported_features 探测
83
83
  proxyctl commands --json # 全部命令元数据(机读)
84
84
  proxyctl commands --schema # 上面 JSON 的 JSON Schema
@@ -162,6 +162,30 @@ proxyctl recover # 不重启引擎,热重载 + 清 fakeip +
162
162
  proxyctl recover --dry-run # 看 3 个 Clash API endpoint 再决定
163
163
  ```
164
164
 
165
+ ### `doctor` —— 5 项健康分 + 引导建议(v0.5.0+)
166
+ ```bash
167
+ proxyctl doctor # 5 项布尔健康分 + suggestions (warn/advisory)
168
+ proxyctl doctor --json # data.suggestions[] 含 21 条规则的检测结果
169
+ proxyctl doctor --suggest-only # 跳过 score 探测,仅跑建议引擎 (~0.18s)
170
+ proxyctl doctor --since 0.4.7 # 屏蔽指定版本后引入的规则 (CI 平滑迁移)
171
+ proxyctl doctor --no-suggest # 关闭建议引擎,恢复 v0.4.x 极简体验
172
+ proxyctl explain suggestion:<id> # 每条规则有触发条件 + 修复路径 topic
173
+ ```
174
+ 21 条规则覆盖订阅过期 / autostart binary 与 PATH 漂移 / API 安全配置 /
175
+ GeoIP 数据时效 / 代理组健康。详见 [AGENTS.md](AGENTS.md) "Doctor suggestions" 段。
176
+ 用户屏蔽某条告警:`~/.config/proxyctl/suggestions.ignore`(一行一个 id 或 fingerprint)。
177
+
178
+ ### `autostart` —— 自动启动 unit 管理(v0.5.0+)
179
+ ```bash
180
+ proxyctl autostart # plist/unit 当前状态
181
+ proxyctl autostart inspect --json # 结构化输出含 binary/version/enabled/config_dir
182
+ proxyctl autostart sync --dry-run # 预览 PlanStep[]
183
+ proxyctl autostart sync # 同步 plist/unit 到当前 PATH binary + config_dir
184
+ ```
185
+ 当 doctor 报 `autostart.binary_mismatch` / `version_mismatch` /
186
+ `config_dir_mismatch` 时一键修复。macOS plist 用 plistlib 原地改保留
187
+ KeepAlive / RunAtLoad 等用户定制;Linux ExecStart 缺失时拒绝执行防误覆盖。
188
+
165
189
  ---
166
190
 
167
191
  ## 安装
@@ -173,7 +197,7 @@ uv tool install proxyctl # uv(推荐)
173
197
  pipx install proxyctl # 或 pipx
174
198
  pip install --user proxyctl # 或 pip
175
199
 
176
- proxyctl --version # → proxyctl v0.4.7
200
+ proxyctl --version # → proxyctl v0.5.1
177
201
  proxyctl --help
178
202
  ```
179
203
 
@@ -239,13 +263,14 @@ no_proxy_extra: # 自 0.1.5 起追加 NO_PROXY
239
263
  | `start / stop / restart / restart-clean` | 启停引擎 + DNS/代理注入 | ✅ | ✅ |
240
264
  | `status` | 一站式系统面板 | — | ✅ |
241
265
  | `check` | 4 阶段健康检查 | — | ✅ |
242
- | `doctor` | 5 项健康打分 | — | ✅ |
266
+ | `doctor` | 5 项健康打分 + 21 条引导建议 (v0.5.0+) | — | ✅ |
243
267
  | `trace <domain>` | 域名链路诊断 | — | ✅ |
244
268
  | `audit [days] [apply]` | 日志驱动配置优化 | ✅ | ✅ |
245
269
  | `bench [groups]` | 节点测速 (NDJSON) | — | ✅ |
246
270
  | `fix` | 修复 DNS / 代理 / 热重载 | ✅ | ✅ |
247
271
  | `recover` | 切网后软恢复 | ✅ | ✅ |
248
272
  | `mode tun\|proxy` | 切换 TUN / 代理模式 | ✅ | ✅ |
273
+ | `autostart [inspect\|sync]` | 自动启动 unit 管理 (v0.5.0+) | ✅ | ✅ |
249
274
  | `dns-lock / dns-unlock` | 启停 DNS 看门狗 | ✅ | ✅ |
250
275
  | `daemon` | 管理 extra_daemons | ✅ | ✅ |
251
276
  | `agent-guide / commands / explain` | agent 自描述三件套 | — | ✅ |
@@ -261,6 +286,7 @@ no_proxy_extra: # 自 0.1.5 起追加 NO_PROXY
261
286
  | audit (含 apply) | ✅ | ✅ |
262
287
  | recover (切网软恢复) | ✅ | ✅ |
263
288
  | mode tun \| proxy 切换 | ✅ | ✅ |
289
+ | autostart inspect / sync | ✅ plistlib | ✅ systemd unit ExecStart |
264
290
  | dns-lock 看门狗 | ✅ | N/A* |
265
291
 
266
292
  \* Linux 下系统 DNS 由 systemd-resolved / NetworkManager / resolvconf
@@ -299,7 +325,7 @@ sing-box JSON config 解析、`trace` 的 sing-box 日志 grep、`engine` /
299
325
  git clone https://github.com/crhan/proxyctl.git
300
326
  cd proxyctl
301
327
  uv sync --group dev # 装运行 + 测试依赖
302
- uv run pytest # 跑 523 个测试
328
+ uv run pytest # 跑 685 个测试
303
329
  uv run proxyctl status # 用本地源码版本
304
330
 
305
331
  export PROXYCTL_DEBUG=1 # 调试模式
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proxyctl"
3
- version = "0.5.2"
3
+ version = "0.5.3"
4
4
  description = "Proxy configuration lifecycle management for macOS and Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -218,7 +218,7 @@ def inspect_runtime(static_result: dict[str, Any], backend, *,
218
218
  r"^\S+\s+\S+\s+(\S+)\s+(\S+\s+\S+)\s+with\s+go(\S+)\s+(\S+)",
219
219
  raw)
220
220
  if m:
221
- out["autostart_version"] = m.group(1)
221
+ out["autostart_version"] = m.group(1).lstrip("v")
222
222
  except (subprocess.TimeoutExpired, OSError):
223
223
  pass
224
224
 
@@ -439,17 +439,23 @@ def to_suggestions(inspect_result: dict[str, Any] | None, *,
439
439
  })
440
440
 
441
441
  # 5. version_mismatch —— 优先于 binary_mismatch 之外的独立信号
442
+ # 同一 binary 路径不可能版本不同;直接跳过避免 strip 不一致引发的误报。
442
443
  autostart_ver = inspect_result.get("autostart_version")
443
- if autostart_ver and path_version and autostart_ver != path_version:
444
+ same_binary = bool(binary and path_binary and binary == path_binary)
445
+ a_ver_norm = (autostart_ver or "").lstrip("v")
446
+ p_ver_norm = (path_version or "").lstrip("v")
447
+ if (autostart_ver and path_version
448
+ and not same_binary
449
+ and a_ver_norm != p_ver_norm):
444
450
  out.append({
445
451
  "id": "autostart.version_mismatch",
446
452
  "severity": "advisory",
447
453
  "actor": "user",
448
- "title": (f"autostart 引擎版本 v{autostart_ver} "
449
- f"≠ PATH v{path_version}"),
454
+ "title": (f"autostart 引擎版本 v{a_ver_norm} "
455
+ f"≠ PATH v{p_ver_norm}"),
450
456
  "evidence": {
451
- "autostart_version": autostart_ver,
452
- "path_version": path_version,
457
+ "autostart_version": a_ver_norm,
458
+ "path_version": p_ver_norm,
453
459
  "autostart_binary": binary,
454
460
  "path_binary": path_binary,
455
461
  },
@@ -202,6 +202,9 @@ def _proxy_groups_section(api_base: str, api_secret: str,
202
202
  if col > 0:
203
203
  print(line.rstrip())
204
204
 
205
+ # 跟踪本次 check 输出已经展开过节点列表的子组(跨 print_group 调用复用)
206
+ expanded_subgroups: set[str] = set()
207
+
205
208
  def print_group(gname: str):
206
209
  """打印单个组的摘要 + 成员;若 selector 成员也是组则递归展开。"""
207
210
  g = proxies.get(gname)
@@ -248,31 +251,39 @@ def _proxy_groups_section(api_base: str, api_secret: str,
248
251
  print(f" {CYAN}{gname}{NC}({gtype}) → {BOLD}{gnow}{NC} "
249
252
  f"{ds(gnow_d)}ms [{count_str} of {total}]{tested_str}")
250
253
 
251
- # selector/fallback 的成员如果也是组,展开子组详情
252
- if g.get("type") in ("Selector", "Fallback"):
254
+ # v0.5.4:selector/fallback 类型 + now 指向子组时,
255
+ # 跳过顶层成员列表(避免跟子组节点重复),只展开 now 那一个子组。
256
+ # now 指向真节点时仍打印顶层成员(用户可能想看其他可选项)。
257
+ sub = (proxies.get(gnow) if g.get("type") in ("Selector", "Fallback")
258
+ else None)
259
+ now_is_subgroup = bool(sub and sub.get("all"))
260
+ if g.get("type") in ("Selector", "Fallback") and not now_is_subgroup:
253
261
  print_members(gmembers, gnow)
254
- for m in gmembers:
255
- sub = proxies.get(m)
256
- if sub and sub.get("all"):
257
- sub_type = "url" if sub.get("type") == "URLTest" else "sel"
258
- sub_now = sub.get("now", "?")
259
- sub_now_d = get_delay(sub_now)
260
- sub_members = sub.get("all", [])
261
- s_alive = sum(1 for x in sub_members if get_delay(x) > 0)
262
- s_dead = sum(1 for x in sub_members if get_delay(x) == 0)
263
- s_nodata = sum(1 for x in sub_members if get_delay(x) < 0)
264
- sc = []
265
- if s_alive: sc.append(f"{GREEN}{s_alive}✓{NC}")
266
- if s_dead: sc.append(f"{RED}{s_dead}✗{NC}")
267
- if s_nodata: sc.append(f"{YELLOW}{s_nodata}—{NC}")
268
- st = group_tested_ago(sub_members)
269
- st_str = f" {DIM}{st}{NC}" if st else ""
270
- active = " ←" if m == gnow else ""
271
- print(f" {CYAN}{m}{NC}({sub_type}) → {BOLD}{sub_now}{NC} "
272
- f"{ds(sub_now_d)}ms [{'/'.join(sc)} of {len(sub_members)}]{st_str}{active}")
273
- print_members(sub_members, sub_now)
274
- else:
262
+ elif g.get("type") not in ("Selector", "Fallback"):
275
263
  print_members(gmembers, gnow)
264
+ if now_is_subgroup:
265
+ sub_type = "url" if sub.get("type") == "URLTest" else "sel"
266
+ sub_now = sub.get("now", "?")
267
+ sub_now_d = get_delay(sub_now)
268
+ sub_members = sub.get("all", [])
269
+ s_alive = sum(1 for x in sub_members if get_delay(x) > 0)
270
+ s_dead = sum(1 for x in sub_members if get_delay(x) == 0)
271
+ s_nodata = sum(1 for x in sub_members if get_delay(x) < 0)
272
+ sc = []
273
+ if s_alive: sc.append(f"{GREEN}{s_alive}✓{NC}")
274
+ if s_dead: sc.append(f"{RED}{s_dead}✗{NC}")
275
+ if s_nodata: sc.append(f"{YELLOW}{s_nodata}—{NC}")
276
+ st = group_tested_ago(sub_members)
277
+ st_str = f" {DIM}{st}{NC}" if st else ""
278
+ already = gnow in expanded_subgroups
279
+ tail = f" {DIM}(详见上方){NC}" if already else ""
280
+ print(f" {CYAN}{gnow}{NC}({sub_type}) → {BOLD}{sub_now}{NC} "
281
+ f"{ds(sub_now_d)}ms [{'/'.join(sc)} of {len(sub_members)}]{st_str}{tail}")
282
+ if not already:
283
+ print_members(sub_members, sub_now)
284
+ expanded_subgroups.add(gnow)
285
+ # 顶层组本身展开完后也标记,避免被下层组重新展开
286
+ expanded_subgroups.add(gname)
276
287
 
277
288
  # 跟踪哪些组已经作为 selector 子组展开过,避免重复
278
289
  shown = set()
@@ -405,19 +416,26 @@ def cmd_bench(api: str, api_secret: str, groups: list = None,
405
416
  print(f" {YELLOW}—{NC} API 响应解析失败")
406
417
  return
407
418
 
419
+ # v0.5.3:穿透 selector 子组到真叶子节点;伪节点(DIRECT/REJECT)排除
420
+ from proxyctl.suggest_rules import _collect_leaves, PSEUDO_NODE_TYPES
408
421
  group_members: dict = {}
422
+ raw_count_total = 0 # 去重前总和(用于显示"省了多少次")
409
423
  for gname in target_groups:
410
424
  g = proxies.get(gname)
411
425
  if not g:
412
426
  if not as_json:
413
427
  print(f" {YELLOW}—{NC} 组 {BOLD}{gname}{NC} 不存在,跳过")
414
428
  continue
415
- members = g.get("all", [])
416
- if not members:
429
+ leaves = [
430
+ l for l in _collect_leaves(gname, proxies, seen=set())
431
+ if proxies.get(l, {}).get("type") not in PSEUDO_NODE_TYPES
432
+ ]
433
+ if not leaves:
417
434
  if not as_json:
418
- print(f" {YELLOW}—{NC} 组 {BOLD}{gname}{NC} 无成员")
435
+ print(f" {YELLOW}—{NC} 组 {BOLD}{gname}{NC} 无可测叶子节点")
419
436
  continue
420
- group_members[gname] = members
437
+ group_members[gname] = leaves
438
+ raw_count_total += len(leaves)
421
439
 
422
440
  if not group_members:
423
441
  if as_json:
@@ -438,9 +456,13 @@ def cmd_bench(api: str, api_secret: str, groups: list = None,
438
456
  seen.add(m)
439
457
 
440
458
  total = len(all_nodes)
459
+ dedup_saved = raw_count_total - total # 去重省掉的探测次数
441
460
  group_names = ", ".join(group_members.keys())
442
461
  if not as_json:
443
- print(f"{BOLD}测速{NC} 组: {CYAN}{group_names}{NC} 节点: {BOLD}{total}{NC}")
462
+ dedup_note = (f" {DIM}(去重省 {dedup_saved} 次){NC}"
463
+ if dedup_saved > 0 else "")
464
+ print(f"{BOLD}测速{NC} 组: {CYAN}{group_names}{NC} "
465
+ f"节点: {BOLD}{total}{NC}{dedup_note}")
444
466
 
445
467
  import threading
446
468
  done_count = [0]
@@ -496,6 +518,8 @@ def cmd_bench(api: str, api_secret: str, groups: list = None,
496
518
  summary = {
497
519
  "groups": list(group_members.keys()),
498
520
  "total": total,
521
+ "raw_count": raw_count_total, # 去重前各组叶子数之和
522
+ "dedup_saved": dedup_saved, # 重复线路省下的探测次数
499
523
  "ok_count": ok_count,
500
524
  "fail_count": total - ok_count,
501
525
  "avg_rtt_ms": (sum(rtts) // len(rtts)) if rtts else None,
@@ -575,6 +599,13 @@ def _collect_fail_hints(collector: dict, *, dns_bad: bool, failed: bool) -> list
575
599
  # 保留旧行为:DNS 异常时引导 proxyctl fix
576
600
  hints.append("DNS unhealthy — try `proxyctl fix`")
577
601
 
602
+ dead = stages.get("dead_groups") or []
603
+ if dead:
604
+ names = ",".join(
605
+ f"{d.get('name','?')}({d.get('dead_count')}/{d.get('total_count')})"
606
+ for d in dead if isinstance(d, dict))
607
+ hints.append(f"proxy groups mostly dead: {names} — try `proxyctl bench`")
608
+
578
609
  return hints
579
610
 
580
611
 
@@ -859,6 +890,26 @@ def cmd_check(engine, api: str, api_secret: str,
859
890
  if as_json:
860
891
  collector["stages"]["groups"] = groups_data
861
892
 
893
+ # 任何组 ≥70% 节点挂掉的全局判定(不限于 check_groups 显示的组)
894
+ # 与 doctor 的 proxy_group.mostly_dead suggestion 共用底层规则。
895
+ from proxyctl import suggest_rules as _sr
896
+ proxies_payload = _sr.fetch_proxies(api, api_secret, timeout=1.0)
897
+ dead_groups = _sr.proxy_group_rules(proxies_payload)
898
+ if dead_groups:
899
+ for s in dead_groups:
900
+ ev = s.get("evidence") or {}
901
+ print(f" {RED}✗{NC} {ev.get('group_name','?')}: "
902
+ f"{ev.get('dead_count')}/{ev.get('total_count')} 节点不可达 "
903
+ f"({ev.get('dead_pct_at_check')}%)")
904
+ fail = True
905
+ collector["stages"]["dead_groups"] = [
906
+ {"name": (s.get("evidence") or {}).get("group_name"),
907
+ "dead_count": (s.get("evidence") or {}).get("dead_count"),
908
+ "total_count": (s.get("evidence") or {}).get("total_count"),
909
+ "dead_pct": (s.get("evidence") or {}).get("dead_pct_at_check")}
910
+ for s in dead_groups
911
+ ]
912
+
862
913
  # ── 3. 连通性 ─────────────────────────────────────────────────────────────
863
914
  # 从所有插件收集 check_targets。corp-network 等内置插件根据 ctx.corp_net 决定是否启用。
864
915
  ctx = {"corp_net": corp_net, "mode": mode, "engine": engine.name}
@@ -966,6 +1017,9 @@ def cmd_check(engine, api: str, api_secret: str,
966
1017
  if dns_bad:
967
1018
  print(f"{CYAN}DNS 异常,执行 {BOLD}sb fix{NC}{CYAN} 修复。{NC}")
968
1019
 
1020
+ if not as_json and not as_plain:
1021
+ _sys.exit(0 if not fail else 1)
1022
+
969
1023
  if as_json:
970
1024
  _sys.stdout = _real_stdout
971
1025
  from proxyctl._io import emit_json, envelope, OK, GENERIC
@@ -91,11 +91,48 @@ def _t_nodes(backend, config) -> TopicCard:
91
91
  def _t_config(backend, config) -> TopicCard:
92
92
  return {
93
93
  "topic": "config",
94
- "summary": "proxyctl 自身配置(不是 mihomo 配置)。控制后端选择、Clash API、端口、企业 DNS 等。",
94
+ "summary": (
95
+ "proxyctl 自身配置(不是 mihomo 配置)。控制后端选择、Clash API、端口、"
96
+ "企业 DNS 等。**建议把整个 ~/.config/proxyctl/ 目录纳入 git 追踪**(详见 "
97
+ "explain config-tracking),订阅源 URL / Clash secret 等 secret 文件用 "
98
+ ".gitignore 排除,配置改动留得下回放历史。"
99
+ ),
95
100
  "file": _io_proxyctl_config_path(),
96
101
  "edit": f"$EDITOR {_io_proxyctl_config_path()}",
97
102
  "verify": "proxyctl config get <key>",
98
- "next_commands": ["explain ports", "explain corp-dns", "explain extra-daemons"],
103
+ "next_commands": ["explain config-tracking", "explain ports",
104
+ "explain corp-dns", "explain extra-daemons"],
105
+ }
106
+
107
+
108
+ @topic("config-tracking")
109
+ def _t_config_tracking(backend, config) -> TopicCard:
110
+ cfg_dir = os.path.join(os.path.expanduser("~"), ".config", "proxyctl")
111
+ return {
112
+ "topic": "config-tracking",
113
+ "summary": (
114
+ "用 git 追踪 ~/.config/proxyctl/ 的变化。proxyctl 自己不做版本控制,"
115
+ "但用户的 config.yaml / 插件 / 订阅契约文件经常被多个工具(用户、cron、"
116
+ "proxyctl autostart sync、update-subscription.sh、agent)改动,没有"
117
+ "版本历史时很难 diff 出'谁改了什么'。git 是最便宜的解法。"
118
+ ),
119
+ "file": f"{cfg_dir}/.git",
120
+ "edit": (
121
+ f" cd {cfg_dir} && git init\n"
122
+ " # .gitignore 必须排除 secret + 高频抖动文件(agent 别忘):\n"
123
+ " cat > .gitignore <<'EOF'\n"
124
+ " subscription-source.env # 订阅 URL 含 UUID secret\n"
125
+ " subscription.json # 流量/到期高频变化\n"
126
+ " .lock.* # proxyctl 运行时锁文件\n"
127
+ " .ipgeo-* # ipgeo 探针缓存\n"
128
+ " *.bak # config set 自动备份\n"
129
+ " EOF\n"
130
+ " git add . && git commit -m 'init: 本机 proxyctl 配置基线'\n"
131
+ " # 后续改 config 之前:git status 看上次留下了啥;改完 commit"
132
+ ),
133
+ "verify": f"cd {cfg_dir} && git log --oneline",
134
+ "next_commands": ["explain config", "explain plugins",
135
+ "config get backend"],
99
136
  }
100
137
 
101
138
 
@@ -780,6 +817,31 @@ proxyctl 才能区分「过期 / 网络挂 / 订阅服务方挂」。
780
817
  - 用户插件目录: `~/.config/proxyctl/plugins/*.py`
781
818
  - 锁文件目录: `{lock_dir}/.lock.{{system|config|daemon}}`
782
819
 
820
+ ## Config Tracking — 用户配置目录建议纳入 git
821
+
822
+ `~/.config/proxyctl/` 是用户的私人配置目录(不是 proxyctl 项目源代码)。
823
+ 被多方写入:用户手改、`config set`、`proxyctl autostart sync`、订阅脚本、
824
+ cron、agent。**强烈建议把它纳入 git 追踪**,让你能回放"上次配置是什么样"。
825
+
826
+ ```bash
827
+ cd {lock_dir} && git init
828
+ # .gitignore 必须排除 secret + 高频抖动文件:
829
+ cat > .gitignore <<'EOF'
830
+ subscription-source.env # 订阅 URL(含 UUID secret,禁止入库)
831
+ subscription.json # 流量/到期高频变化
832
+ .lock.* # proxyctl 运行时锁
833
+ .ipgeo-* # ipgeo 探针缓存
834
+ *.bak # config set 自动备份
835
+ EOF
836
+ git add . && git commit -m 'init: 本机 proxyctl 配置基线'
837
+ ```
838
+
839
+ agent 在用户机器上首次跑 proxyctl 时,**应该提示用户是否要做这一步**
840
+ (首次见到 `{lock_dir}` 但无 `.git` 即可提示)。proxyctl 自己不动 git——
841
+ 版本控制是用户/agent 的职责,跟拉订阅是同一类边界。
842
+
843
+ 详见 `proxyctl explain config-tracking`。
844
+
783
845
  ## Exit Codes — 退出码
784
846
 
785
847
  ```
@@ -278,6 +278,9 @@ def engine_rules(current_version: str | None,
278
278
  DEAD_CHECK_GROUP_TYPES = ("URLTest", "Selector", "Fallback", "LoadBalance",
279
279
  "Smart")
280
280
 
281
+ # 伪节点(DIRECT/REJECT/Pass),不参与 dead 统计——它们没有"可达性"概念
282
+ PSEUDO_NODE_TYPES = ("Direct", "Reject", "Pass", "Compatible")
283
+
281
284
  # 节点数少于此值的组不报告(小组本来就容易脏)
282
285
  MIN_GROUP_SIZE_FOR_DEAD_CHECK = 3
283
286
 
@@ -285,6 +288,35 @@ MIN_GROUP_SIZE_FOR_DEAD_CHECK = 3
285
288
  DEAD_PCT_THRESHOLD = 70.0
286
289
 
287
290
 
291
+ def _collect_leaves(group_name: str, proxies: dict[str, Any],
292
+ seen: set[str]) -> list[str]:
293
+ """穿透 selector/URLTest/Fallback 子组,递归收集所有真叶子节点。
294
+
295
+ 返回去重后的叶子节点名列表。伪节点(DIRECT/REJECT)排除。
296
+ seen 跟踪本次调用栈访问过的组名,防止循环引用死循环。
297
+ """
298
+ if group_name in seen:
299
+ return []
300
+ seen = seen | {group_name}
301
+ info = proxies.get(group_name)
302
+ if not isinstance(info, dict):
303
+ return []
304
+ typ = info.get("type")
305
+ if typ in PSEUDO_NODE_TYPES:
306
+ return []
307
+ if typ not in DEAD_CHECK_GROUP_TYPES:
308
+ # 真叶子(Shadowsocks / Vmess / Trojan / TUIC / Hysteria / ...)
309
+ return [group_name]
310
+ leaves: list[str] = []
311
+ for m in info.get("all") or []:
312
+ if not isinstance(m, str):
313
+ continue
314
+ for leaf in _collect_leaves(m, proxies, seen):
315
+ if leaf not in leaves:
316
+ leaves.append(leaf)
317
+ return leaves
318
+
319
+
288
320
  def fetch_proxies(api_base: str, api_secret: str = "", *,
289
321
  timeout: float = 0.5) -> dict[str, Any] | None:
290
322
  """调 mihomo `/proxies` API,本地 HTTP,0 外网。
@@ -320,11 +352,16 @@ def fetch_proxies(api_base: str, api_secret: str = "", *,
320
352
 
321
353
 
322
354
  def proxy_group_rules(proxies_payload: dict[str, Any] | None) -> list[dict[str, Any]]:
323
- """proxy_group.mostly_dead 规则。
355
+ """proxy_group.mostly_dead 规则(v0.5.4 起穿透子组到叶子统计)。
324
356
 
325
357
  每个挂掉的组输出**独立**一条 suggestion(fingerprint 含 group_name),
326
358
  agent 可分别跟踪。
327
359
 
360
+ v0.5.4 行为变更:判定从"直接成员 delay==0"改为"穿透到叶子节点 delay==0"。
361
+ GLOBAL 这种 selector-of-selectors 组下的 13 个分流子组(电报/苹果/Steam
362
+ 等)若无 latency history(mihomo 没给它们测过延迟)不再被算作 dead,
363
+ 伪节点(DIRECT/REJECT)也排除。dead_pct 现在基于真叶子节点数。
364
+
328
365
  Args:
329
366
  proxies_payload: fetch_proxies() 返回;None 表示跳过整组
330
367
  """
@@ -339,14 +376,13 @@ def proxy_group_rules(proxies_payload: dict[str, Any] | None) -> list[dict[str,
339
376
  continue
340
377
  if info.get("type") not in DEAD_CHECK_GROUP_TYPES:
341
378
  continue
342
- members = info.get("all") or []
343
- if not isinstance(members, list) or len(members) < MIN_GROUP_SIZE_FOR_DEAD_CHECK:
379
+ leaves = _collect_leaves(name, proxies, seen=set())
380
+ if len(leaves) < MIN_GROUP_SIZE_FOR_DEAD_CHECK:
344
381
  continue
345
382
  dead = 0
346
- for m in members:
347
- node = proxies.get(m)
383
+ for leaf in leaves:
384
+ node = proxies.get(leaf)
348
385
  if not isinstance(node, dict):
349
- # 拿不到节点信息时算"未知",不计入死亡
350
386
  continue
351
387
  history = node.get("history") or []
352
388
  if not history:
@@ -355,19 +391,19 @@ def proxy_group_rules(proxies_payload: dict[str, Any] | None) -> list[dict[str,
355
391
  last = history[-1] if isinstance(history[-1], dict) else {}
356
392
  if last.get("delay", 0) == 0:
357
393
  dead += 1
358
- dead_pct = (dead / len(members)) * 100.0
394
+ dead_pct = (dead / len(leaves)) * 100.0
359
395
  if dead_pct >= DEAD_PCT_THRESHOLD:
360
396
  out.append({
361
397
  "id": "proxy_group.mostly_dead",
362
398
  "severity": "warn",
363
399
  "actor": "user",
364
- "title": (f"代理组 {name} 中 {dead}/{len(members)} 节点不可达 "
400
+ "title": (f"代理组 {name} 中 {dead}/{len(leaves)} 叶子节点不可达 "
365
401
  f"({dead_pct:.0f}%)"),
366
402
  "evidence": {
367
403
  "group_name": name, # 进 fingerprint 的稳定字段
368
404
  "group_type": info.get("type"),
369
405
  "dead_count": dead,
370
- "total_count": len(members),
406
+ "total_count": len(leaves),
371
407
  # dead_pct 不进 fingerprint(抖动字段),仅供人看
372
408
  "dead_pct_at_check": round(dead_pct, 1),
373
409
  },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes