proxyctl 0.4.1__tar.gz → 0.4.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 (24) hide show
  1. {proxyctl-0.4.1 → proxyctl-0.4.3}/PKG-INFO +9 -2
  2. {proxyctl-0.4.1 → proxyctl-0.4.3}/README.md +8 -1
  3. {proxyctl-0.4.1 → proxyctl-0.4.3}/man/proxyctl.1 +17 -5
  4. {proxyctl-0.4.1 → proxyctl-0.4.3}/pyproject.toml +1 -1
  5. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/check.py +50 -2
  6. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/cli.py +262 -5
  7. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/completion.py +4 -2
  8. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/explain.py +18 -5
  9. {proxyctl-0.4.1 → proxyctl-0.4.3}/.gitignore +0 -0
  10. {proxyctl-0.4.1 → proxyctl-0.4.3}/LICENSE +0 -0
  11. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/__init__.py +0 -0
  12. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/_io.py +0 -0
  13. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/audit.py +0 -0
  14. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  15. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
  16. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  17. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/core/__init__.py +0 -0
  18. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/core/plugin.py +0 -0
  19. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/engine/__init__.py +0 -0
  20. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/engine/base.py +0 -0
  21. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/engine/mihomo.py +0 -0
  22. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/engine/singbox.py +0 -0
  23. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/status.py +0 -0
  24. {proxyctl-0.4.1 → proxyctl-0.4.3}/src/proxyctl/trace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyctl
3
- Version: 0.4.1
3
+ Version: 0.4.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
@@ -64,11 +64,18 @@ PROXYCTL_AGENT=1 proxyctl <cmd> # 一键 --json + 关色 + 非交互
64
64
  - envelope schema v2:`schema_version / cmd / ok / data / error / code / hints[] / warnings[] / doc / meta{{ts,elapsed_ms,proxyctl_version,request_id}}`
65
65
  - 退出码分语义:`0 OK / 2 USAGE / 3 NOT_FOUND / 4 PERMISSION / 5 ENGINE_DOWN / 6 CONFIG_ERR / 7 NETWORK_ERR / 8 LOCKED / 9 TIMEOUT / 10 DEPENDENCY_MISSING`
66
66
  - 写命令支持 `--dry-run` 输出结构化 plan(`data.plan = [PlanStep, ...]`);自 0.4.0 起
67
- `plan.target` 全部真实化,`action=="subprocess"` 的 target.split() 可直接当 argv 复读:
67
+ `plan.target` 全部真实化,`action=="subprocess"` 的 target.split() 可直接当 argv 复读;
68
+ 自 0.4.2 起 `start / stop / restart / restart-clean / recover` 也加入 `--dry-run` 行列
69
+ (之前是文档承诺但实际真跑,已修复):
68
70
  ```bash
69
71
  proxyctl dns-unlock --dry-run --json | jq -r '.data.plan[] | select(.action=="subprocess").target'
70
72
  # → launchctl bootout system/com.proxyctl.dns-lock
71
73
  # → rm -f /Library/LaunchDaemons/com.proxyctl.dns-lock.plist
74
+
75
+ proxyctl stop --dry-run --json | jq -r '.data.plan[] | select(.action=="subprocess").target'
76
+ # macOS → launchctl bootout system/com.proxyctl.dns-lock
77
+ # → launchctl bootout system/com.mihomo.tun
78
+ # Linux → systemctl --user stop mihomo.service
72
79
  ```
73
80
  PlanStep.action 枚举:`subprocess / system_op / fs_write / fs_copy / fs_write_atomic / fs_remove / edit_yaml / scan_log / http_put`。
74
81
  CI 层 contract test(`tests/integration/test_plan_exec_contract.py`)保证 plan ↔ exec 永不漂移。
@@ -35,11 +35,18 @@ PROXYCTL_AGENT=1 proxyctl <cmd> # 一键 --json + 关色 + 非交互
35
35
  - envelope schema v2:`schema_version / cmd / ok / data / error / code / hints[] / warnings[] / doc / meta{{ts,elapsed_ms,proxyctl_version,request_id}}`
36
36
  - 退出码分语义:`0 OK / 2 USAGE / 3 NOT_FOUND / 4 PERMISSION / 5 ENGINE_DOWN / 6 CONFIG_ERR / 7 NETWORK_ERR / 8 LOCKED / 9 TIMEOUT / 10 DEPENDENCY_MISSING`
37
37
  - 写命令支持 `--dry-run` 输出结构化 plan(`data.plan = [PlanStep, ...]`);自 0.4.0 起
38
- `plan.target` 全部真实化,`action=="subprocess"` 的 target.split() 可直接当 argv 复读:
38
+ `plan.target` 全部真实化,`action=="subprocess"` 的 target.split() 可直接当 argv 复读;
39
+ 自 0.4.2 起 `start / stop / restart / restart-clean / recover` 也加入 `--dry-run` 行列
40
+ (之前是文档承诺但实际真跑,已修复):
39
41
  ```bash
40
42
  proxyctl dns-unlock --dry-run --json | jq -r '.data.plan[] | select(.action=="subprocess").target'
41
43
  # → launchctl bootout system/com.proxyctl.dns-lock
42
44
  # → rm -f /Library/LaunchDaemons/com.proxyctl.dns-lock.plist
45
+
46
+ proxyctl stop --dry-run --json | jq -r '.data.plan[] | select(.action=="subprocess").target'
47
+ # macOS → launchctl bootout system/com.proxyctl.dns-lock
48
+ # → launchctl bootout system/com.mihomo.tun
49
+ # Linux → systemctl --user stop mihomo.service
43
50
  ```
44
51
  PlanStep.action 枚举:`subprocess / system_op / fs_write / fs_copy / fs_write_atomic / fs_remove / edit_yaml / scan_log / http_put`。
45
52
  CI 层 contract test(`tests/integration/test_plan_exec_contract.py`)保证 plan ↔ exec 永不漂移。
@@ -1,4 +1,4 @@
1
- .TH PROXYCTL 1 "2026-05" "proxyctl 0.4.1" "User Commands"
1
+ .TH PROXYCTL 1 "2026-05" "proxyctl 0.4.3" "User Commands"
2
2
  .SH NAME
3
3
  proxyctl \- Proxy configuration lifecycle management for macOS / Linux
4
4
  .SH SYNOPSIS
@@ -33,14 +33,19 @@ audit / check 支持 \fB--plain\fR TSV 输出;
33
33
  写命令预演:输出 \fIdata.plan = [PlanStep, ...]\fR(不真正执行)。
34
34
  PlanStep 字段:step / action / target / reversible / requires_sudo /
35
35
  side_effects / summary。
36
- 适用:mode / engine / fix / audit apply / config set / daemon /
37
- dns-lock / dns-unlock。
36
+ 适用:\fBstart / stop / restart / restart-clean / recover\fR(自 0.4.2)/
37
+ mode / engine / fix / audit apply / config set / daemon /
38
+ claude-proxy / dns-lock / dns-unlock。
38
39
  .IP
39
40
  \fB自 0.4.0\fR:plan.target 全部真实化(无 \fI<...>\fR 占位符);
40
41
  action=\fBsubprocess\fR 的 target.split() 可直接当 argv(run 时
41
42
  prepend sudo)。action 枚举:subprocess / system_op / fs_write /
42
43
  fs_copy / fs_write_atomic / fs_remove / edit_yaml / scan_log /
43
44
  http_put。CI 层 contract test 保证 plan ↔ exec 永不漂移。
45
+ .IP
46
+ \fB自 0.4.2\fR:原本 dispatcher 未接卫语句、`--dry-run` 被静默吃掉真执行的
47
+ 5 个写命令补齐 plan helper;lifecycle plan 按 \fIIS_MACOS\fR 平台分流,
48
+ macOS 列 launchctl + 系统联动,Linux 列 systemctl --user。
44
49
  .TP
45
50
  .BR --no-color
46
51
  关闭 ANSI 颜色(默认按 isatty / NO_COLOR / TERM=dumb / PROXYCTL_NO_COLOR 自动判断)。
@@ -90,17 +95,24 @@ engine / mode / port / config_path / engine_config_path / lock_held / lock_path
90
95
  .TP
91
96
  \fBproxyctl completion\fR \fIbash\fR | \fIzsh\fR | \fIfish\fR
92
97
  生成 shell 补全脚本。
98
+ .TP
99
+ .B proxyctl version [--json]
100
+ 显示版本号 + supported_features。等价 \fB--version\fR flag(自 0.4.2 起也提供
101
+ 子命令形式,方便 agent 通过 DISPATCH 探测)。
93
102
 
94
103
  .SS 生命周期(写:sudo + process + system)
95
104
  .TP
96
- .B proxyctl start / stop / restart / restart-clean
105
+ .B proxyctl start / stop / restart / restart-clean [\fB--dry-run\fR]
97
106
  启停代理引擎(写系统 DNS / 代理 / launchd)。restart-clean 额外清缓存。
107
+ 全部支持 \fB--dry-run\fR(自 0.4.2);plan 按平台分流(macOS launchctl /
108
+ Linux systemctl --user)。
98
109
  .TP
99
110
  .B proxyctl fix [--dry-run]
100
111
  修复 DNS / 代理 / 热重载(system + cache)。
101
112
  .TP
102
- .B proxyctl recover
113
+ .B proxyctl recover [\fB--dry-run\fR]
103
114
  切网后软恢复:热重载 + 清 fakeip + 重测代理组(不重启进程)。
115
+ \fB--dry-run\fR 自 0.4.2 起列出三个 Clash API endpoint。
104
116
 
105
117
  .SS 模式 / 引擎(写:sudo + config-write)
106
118
  .TP
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proxyctl"
3
- version = "0.4.1"
3
+ version = "0.4.3"
4
4
  description = "Proxy configuration lifecycle management for macOS and Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -532,6 +532,52 @@ def _fetch_probe(probe, env_clean: dict, proxy_port: int = 7890) -> str:
532
532
  return text
533
533
 
534
534
 
535
+ def _collect_fail_hints(collector: dict, *, dns_bad: bool, failed: bool) -> list[str]:
536
+ """从 check 的 collector 提取每个失败 stage 的简短摘要。
537
+
538
+ 设计目标:envelope.ok=False 时 agent 不需要挖 stages.*.ok 自行定位真凶。
539
+ 本函数为 cmd_check 在 emit_json 之前调用,输出 hints 列表。
540
+
541
+ Args:
542
+ collector: cmd_check 沉淀的事实快照(见 cmd_check.collector)
543
+ dns_bad: basic.network.dns 不健康
544
+ failed: 整体是否任一 stage fail(fail 变量真值)
545
+
546
+ Returns:
547
+ 摘要 hint 行列表。整体 pass 时返回空列表。
548
+ """
549
+ hints: list[str] = []
550
+ if not failed:
551
+ return hints
552
+
553
+ stages = collector.get("stages") or {}
554
+ basic = stages.get("basic") or {}
555
+ ports = basic.get("ports") or {}
556
+ fail_ports = ports.get("fail") or []
557
+ if fail_ports:
558
+ hints.append(f"missing ports: {' '.join(fail_ports)}")
559
+
560
+ if basic.get("daemon_up") is False:
561
+ hints.append("engine not running")
562
+
563
+ # connectivity: 列表形式,每项 {name, url, ok, message}
564
+ conn = stages.get("connectivity") or []
565
+ failed_conns = [c.get("name", "?") for c in conn
566
+ if isinstance(c, dict) and not c.get("ok")]
567
+ if failed_conns:
568
+ hints.append(f"connectivity failed: {','.join(failed_conns)}")
569
+
570
+ split = stages.get("split_routing")
571
+ if isinstance(split, dict) and split.get("ok") is False:
572
+ hints.append("split routing inactive (proxy == direct egress)")
573
+
574
+ if dns_bad:
575
+ # 保留旧行为:DNS 异常时引导 proxyctl fix
576
+ hints.append("DNS unhealthy — try `proxyctl fix`")
577
+
578
+ return hints
579
+
580
+
535
581
  def cmd_check(engine, api: str, api_secret: str,
536
582
  config: dict, mode_str: str = "", registry=None):
537
583
  """proxyctl check — 4 阶段全面健康检查。
@@ -923,11 +969,13 @@ def cmd_check(engine, api: str, api_secret: str,
923
969
  if as_json:
924
970
  _sys.stdout = _real_stdout
925
971
  from proxyctl._io import emit_json, envelope, OK, GENERIC
926
- hint = "proxyctl fix" if dns_bad else None
972
+ # 失败时聚合每个失败 stage 的简短摘要到 hints。
973
+ # 不让 agent 在 envelope.ok=False 时还要挖 stages.*.ok 才定位真凶。
974
+ hints = _collect_fail_hints(collector, dns_bad=dns_bad, failed=fail)
927
975
  emit_json(envelope("check", data=collector,
928
976
  ok=(not fail),
929
977
  code=OK if not fail else GENERIC,
930
- hint=hint))
978
+ hints=hints))
931
979
  _sys.exit(0 if not fail else 1)
932
980
  if as_plain:
933
981
  _sys.stdout = _real_stdout
@@ -1581,6 +1581,8 @@ def cmd_version_print() -> None:
1581
1581
  "doctor_healthy_field": True, # 0.3.3
1582
1582
  "agent_guide_sections": True, # 0.3.3
1583
1583
  "log_ndjson_v2": True,
1584
+ "lifecycle_dry_run": True, # 0.4.2: start/stop/restart/recover --dry-run
1585
+ "version_subcommand": True, # 0.4.2: `proxyctl version` 子命令
1584
1586
  },
1585
1587
  }
1586
1588
  _io.emit_json(_io.envelope("version", data=data))
@@ -1814,6 +1816,50 @@ def _dns_unlock_subprocess_argvs(plist_path: str,
1814
1816
  ]
1815
1817
 
1816
1818
 
1819
+ def _service_start_argvs(backend: Backend) -> list[list[str]]:
1820
+ """返回 service_start 的 argv list(不含 sudo),平台分流。
1821
+
1822
+ macOS:[cp <src> <dst>, launchctl bootstrap system <plist>]
1823
+ cp 是 conditional(plist 已存在时跳过),plan 作为上界总展示。
1824
+ Linux:[systemctl --user start <unit>]
1825
+ """
1826
+ if IS_MACOS:
1827
+ src = os.path.join(DEFAULT_CONFIG_DIR, "launchdaemons",
1828
+ os.path.basename(backend.plist))
1829
+ return [
1830
+ ["cp", src, backend.plist],
1831
+ ["launchctl", "bootstrap", "system", backend.plist],
1832
+ ]
1833
+ return [["systemctl", "--user", "start", backend.unit]]
1834
+
1835
+
1836
+ def _service_stop_argvs(backend: Backend) -> list[list[str]]:
1837
+ """返回 service_stop 的 argv list(不含 sudo),平台分流。"""
1838
+ if IS_MACOS:
1839
+ return [["launchctl", "bootout", backend.label]]
1840
+ return [["systemctl", "--user", "stop", backend.unit]]
1841
+
1842
+
1843
+ def _service_restart_argvs(backend: Backend) -> list[list[str]]:
1844
+ """返回 service_restart 的 argv list(不含 sudo),平台分流。"""
1845
+ if IS_MACOS:
1846
+ return [["launchctl", "kickstart", "-k", backend.label]]
1847
+ return [["systemctl", "--user", "restart", backend.unit]]
1848
+
1849
+
1850
+ def _recover_curl_endpoints(api_base: str) -> dict:
1851
+ """返回 cmd_recover 三个 Clash API endpoint 的 URL。
1852
+
1853
+ 每个 endpoint 进入 plan 时作为 http_put action 的 target;contract test
1854
+ 用宽松断言(actual curl argv 中包含该 URL 子串)验证 plan ↔ exec 一致。
1855
+ """
1856
+ return {
1857
+ "configs_reload": f"{api_base}/configs?force=true",
1858
+ "fakeip_flush": f"{api_base}/cache/fakeip/flush",
1859
+ "proxies": f"{api_base}/proxies",
1860
+ }
1861
+
1862
+
1817
1863
  def _plan_mode(backend, target: str) -> list[dict]:
1818
1864
  """mode tun / mode proxy 的 plan。
1819
1865
 
@@ -2048,6 +2094,192 @@ def _plan_dns_unlock(config: dict) -> list[dict]:
2048
2094
  ]
2049
2095
 
2050
2096
 
2097
+ # ── start / stop / restart / restart-clean / recover plan helpers ───────────
2098
+ #
2099
+ # 0.4.2 起,原本完全没接 _maybe_dry_run 的 5 个写命令补上 plan:
2100
+ # - 跨平台:plan 按 IS_MACOS 分流,macOS 列 launchctl + 系统联动;
2101
+ # Linux 列 systemctl --user
2102
+ # - actual ⊆ plan:DNS 注入 / proxy 激活等 iterating system_op 用描述性
2103
+ # target;conditional 步骤(首次 cp、dns-lock conditional bootstrap、
2104
+ # proxy_activate 仅 mode==proxy)作为上界总列出,contract test 用
2105
+ # silence_helpers + actual ⊆ plan 方向天然容纳。
2106
+
2107
+ def _plan_start(backend, config) -> list[dict]:
2108
+ """start 的 plan:启动引擎 + macOS 系统联动(DNS/dns-lock/proxy)。"""
2109
+ argvs = _service_start_argvs(backend)
2110
+ if not IS_MACOS:
2111
+ return [
2112
+ {"action": "subprocess",
2113
+ "target": " ".join(argvs[0]),
2114
+ "summary": f"通过 systemctl --user 启动 {backend.unit}",
2115
+ "reversible": True, "requires_sudo": False,
2116
+ "side_effects": ["process"]},
2117
+ ]
2118
+ dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
2119
+ dns_lock_plist = f"/Library/LaunchDaemons/{dns_lock_label}.plist"
2120
+ proxy_port = config.get("proxy_port", DEFAULTS["proxy_port"])
2121
+ return [
2122
+ {"action": "subprocess",
2123
+ "target": " ".join(argvs[0]),
2124
+ "summary": f"如 {backend.plist} 不存在则从源拷贝 plist(首次安装)",
2125
+ "reversible": True, "requires_sudo": True,
2126
+ "side_effects": ["config-write"]},
2127
+ {"action": "subprocess",
2128
+ "target": " ".join(argvs[1]),
2129
+ "summary": f"启动引擎 {backend.name}",
2130
+ "reversible": True, "requires_sudo": True,
2131
+ "side_effects": ["process"]},
2132
+ {"action": "system_op",
2133
+ "target": "networksetup -setdnsservers (per service) 127.0.0.1 + "
2134
+ "scutil 三层注入 + dscacheutil -flushcache",
2135
+ "summary": "系统 DNS → 127.0.0.1(三层防线)",
2136
+ "reversible": True, "requires_sudo": True,
2137
+ "side_effects": ["system"]},
2138
+ {"action": "subprocess",
2139
+ "target": " ".join(["launchctl", "bootstrap", "system",
2140
+ dns_lock_plist]),
2141
+ "summary": f"启动 DNS 看门狗 daemon(如已部署但未运行)",
2142
+ "reversible": True, "requires_sudo": True,
2143
+ "side_effects": ["process"]},
2144
+ {"action": "system_op",
2145
+ "target": f"networksetup -setwebproxy* / -setsecurewebproxy* / "
2146
+ f"-setsocksfirewallproxy* (per service) 127.0.0.1:{proxy_port}",
2147
+ "summary": "开启系统代理(仅 mode=proxy 时;mode=tun 时跳过)",
2148
+ "reversible": True, "requires_sudo": True,
2149
+ "side_effects": ["system"]},
2150
+ ]
2151
+
2152
+
2153
+ def _plan_stop(backend, config) -> list[dict]:
2154
+ """stop 的 plan:停止引擎 + macOS 还原系统配置。"""
2155
+ argvs = _service_stop_argvs(backend)
2156
+ if not IS_MACOS:
2157
+ return [
2158
+ {"action": "subprocess",
2159
+ "target": " ".join(argvs[0]),
2160
+ "summary": f"通过 systemctl --user 停止 {backend.unit}",
2161
+ "reversible": True, "requires_sudo": False,
2162
+ "side_effects": ["process"]},
2163
+ ]
2164
+ dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
2165
+ full_lock_label = f"system/{dns_lock_label}"
2166
+ return [
2167
+ {"action": "subprocess",
2168
+ "target": " ".join(["launchctl", "bootout", full_lock_label]),
2169
+ "summary": "停止 DNS 看门狗 daemon(如在跑)",
2170
+ "reversible": True, "requires_sudo": True,
2171
+ "side_effects": ["process"]},
2172
+ {"action": "system_op",
2173
+ "target": "networksetup -setdnsservers (per service) empty + "
2174
+ "scutil remove + dscacheutil -flushcache",
2175
+ "summary": "还原系统 DNS(清除三层注入)",
2176
+ "reversible": True, "requires_sudo": True,
2177
+ "side_effects": ["system"]},
2178
+ {"action": "system_op",
2179
+ "target": "networksetup -setwebproxystate / -setsecurewebproxystate / "
2180
+ "-setsocksfirewallproxystate (per service) off",
2181
+ "summary": "关闭系统代理",
2182
+ "reversible": True, "requires_sudo": True,
2183
+ "side_effects": ["system"]},
2184
+ {"action": "subprocess",
2185
+ "target": " ".join(argvs[0]),
2186
+ "summary": f"停止引擎 {backend.name}",
2187
+ "reversible": True, "requires_sudo": True,
2188
+ "side_effects": ["process"]},
2189
+ ]
2190
+
2191
+
2192
+ def _plan_restart(backend, config, *, clean: bool = False) -> list[dict]:
2193
+ """restart / restart-clean 的 plan。
2194
+
2195
+ clean=True 时额外加 fs_remove backend.cache_file。
2196
+ """
2197
+ steps: list[dict] = []
2198
+ if clean:
2199
+ steps.append({
2200
+ "action": "fs_remove",
2201
+ "target": backend.cache_file,
2202
+ "summary": f"删除引擎缓存 {backend.cache_file}",
2203
+ "reversible": False,
2204
+ "side_effects": ["cache"],
2205
+ })
2206
+ steps.append({
2207
+ "action": "fs_remove",
2208
+ "target": "/tmp/proxyctl-recover-* /tmp/sb-recover-* /tmp/sb-proxy-fail",
2209
+ "summary": "清空 watchdog 失败状态文件(避免误判触顶窗口)",
2210
+ "reversible": False,
2211
+ "side_effects": ["cache"],
2212
+ })
2213
+ argvs = _service_restart_argvs(backend)
2214
+ if not IS_MACOS:
2215
+ steps.append({
2216
+ "action": "subprocess",
2217
+ "target": " ".join(argvs[0]),
2218
+ "summary": f"通过 systemctl --user 重启 {backend.unit}",
2219
+ "reversible": True, "requires_sudo": False,
2220
+ "side_effects": ["process"],
2221
+ })
2222
+ return steps
2223
+ proxy_port = config.get("proxy_port", DEFAULTS["proxy_port"])
2224
+ dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
2225
+ dns_lock_plist = f"/Library/LaunchDaemons/{dns_lock_label}.plist"
2226
+ steps.extend([
2227
+ {"action": "subprocess",
2228
+ "target": " ".join(argvs[0]),
2229
+ "summary": f"重启引擎 {backend.name}",
2230
+ "reversible": True, "requires_sudo": True,
2231
+ "side_effects": ["process"]},
2232
+ {"action": "system_op",
2233
+ "target": "networksetup -setdnsservers (per service) 127.0.0.1 + "
2234
+ "scutil 三层注入 + dscacheutil -flushcache",
2235
+ "summary": "系统 DNS → 127.0.0.1(刷新三层防线)",
2236
+ "reversible": True, "requires_sudo": True,
2237
+ "side_effects": ["system"]},
2238
+ {"action": "subprocess",
2239
+ "target": " ".join(["launchctl", "bootstrap", "system",
2240
+ dns_lock_plist]),
2241
+ "summary": "启动 DNS 看门狗 daemon(如已部署但未运行)",
2242
+ "reversible": True, "requires_sudo": True,
2243
+ "side_effects": ["process"]},
2244
+ {"action": "system_op",
2245
+ "target": f"networksetup -setwebproxy* / -setsecurewebproxy* / "
2246
+ f"-setsocksfirewallproxy* (per service) 127.0.0.1:{proxy_port}",
2247
+ "summary": "开启系统代理(mode=proxy 时)/ 关闭(mode=tun 时)",
2248
+ "reversible": True, "requires_sudo": True,
2249
+ "side_effects": ["system"]},
2250
+ ])
2251
+ return steps
2252
+
2253
+
2254
+ def _plan_recover(backend, config) -> list[dict]:
2255
+ """recover 的 plan:3 个 Clash API 请求,不重启进程。
2256
+
2257
+ 用 http_put action(与 _plan_fix 风格一致),实际 cmd_recover 还会对
2258
+ /group/<name>/delay endpoint 并发请求 N 次做 healthcheck —— plan 把
2259
+ healthcheck 整体折成一个 http_put step,target=描述性 endpoint pattern。
2260
+ """
2261
+ api_base = config.get("api_base", DEFAULTS["api_base"])
2262
+ eps = _recover_curl_endpoints(api_base)
2263
+ return [
2264
+ {"action": "http_put",
2265
+ "target": eps["configs_reload"],
2266
+ "summary": "热重载配置(PUT /configs?force=true,清 DNS 缓存)",
2267
+ "reversible": True,
2268
+ "side_effects": ["network-io"]},
2269
+ {"action": "http_put",
2270
+ "target": eps["fakeip_flush"],
2271
+ "summary": "清空 fakeip 缓存(POST /cache/fakeip/flush)",
2272
+ "reversible": True,
2273
+ "side_effects": ["cache", "network-io"]},
2274
+ {"action": "http_put",
2275
+ "target": eps["proxies"],
2276
+ "summary": "拉取 proxies 列表 + 对 URLTest/Fallback/LoadBalance 组并发 "
2277
+ "GET /group/<name>/delay 触发 healthcheck",
2278
+ "reversible": True,
2279
+ "side_effects": ["network-io"]},
2280
+ ]
2281
+
2282
+
2051
2283
  # ── 主入口 ────────────────────────────────────────────────────────────────────
2052
2284
 
2053
2285
  def main():
@@ -2138,12 +2370,31 @@ def main():
2138
2370
 
2139
2371
  # ── Dispatch handlers + 路由表 ────────────────────────────────────────────
2140
2372
 
2141
- def _h_start(ctx): cmd_start(ctx["backend"], ctx["config"], registry=ctx["registry"])
2142
- def _h_stop(ctx): cmd_stop(ctx["backend"], ctx["config"], registry=ctx["registry"])
2143
- def _h_restart(ctx): cmd_restart(ctx["backend"], ctx["config"], registry=ctx["registry"])
2373
+ def _h_start(ctx):
2374
+ _maybe_dry_run("start", lambda: _plan_start(ctx["backend"], ctx["config"]))
2375
+ cmd_start(ctx["backend"], ctx["config"], registry=ctx["registry"])
2376
+
2377
+ def _h_stop(ctx):
2378
+ _maybe_dry_run("stop", lambda: _plan_stop(ctx["backend"], ctx["config"]))
2379
+ cmd_stop(ctx["backend"], ctx["config"], registry=ctx["registry"])
2380
+
2381
+ def _h_restart(ctx):
2382
+ _maybe_dry_run(
2383
+ "restart",
2384
+ lambda: _plan_restart(ctx["backend"], ctx["config"], clean=False))
2385
+ cmd_restart(ctx["backend"], ctx["config"], registry=ctx["registry"])
2386
+
2144
2387
  def _h_restart_clean(ctx):
2145
- cmd_restart(ctx["backend"], ctx["config"], clean=True, registry=ctx["registry"])
2146
- def _h_recover(ctx): cmd_recover(ctx["backend"], ctx["config"])
2388
+ _maybe_dry_run(
2389
+ "restart-clean",
2390
+ lambda: _plan_restart(ctx["backend"], ctx["config"], clean=True))
2391
+ cmd_restart(ctx["backend"], ctx["config"], clean=True,
2392
+ registry=ctx["registry"])
2393
+
2394
+ def _h_recover(ctx):
2395
+ _maybe_dry_run("recover",
2396
+ lambda: _plan_recover(ctx["backend"], ctx["config"]))
2397
+ cmd_recover(ctx["backend"], ctx["config"])
2147
2398
  def _h_dns_unlock(ctx):
2148
2399
  _maybe_dry_run("dns-unlock", lambda: _plan_dns_unlock(ctx["config"]))
2149
2400
  _exec_with_lock("daemon", "dns-unlock", cmd_dns_unlock, ctx["config"])
@@ -2317,6 +2568,11 @@ def _h_help(ctx):
2317
2568
  cmd_help()
2318
2569
 
2319
2570
 
2571
+ def _h_version(ctx):
2572
+ """proxyctl version — 子命令形式,与 --version flag 输出一致。"""
2573
+ cmd_version_print()
2574
+
2575
+
2320
2576
  DISPATCH: dict = {
2321
2577
  "start": _h_start,
2322
2578
  "stop": _h_stop,
@@ -2346,6 +2602,7 @@ DISPATCH: dict = {
2346
2602
  "doctor": _h_doctor,
2347
2603
  "completion": _h_completion,
2348
2604
  "help": _h_help,
2605
+ "version": _h_version,
2349
2606
  }
2350
2607
 
2351
2608
 
@@ -26,8 +26,10 @@ from __future__ import annotations
26
26
  import sys
27
27
 
28
28
 
29
- # 0.3.x:写命令支持 --dry-run(与 COMMANDS_META.supports_dry_run 同步)
30
- _DRY_RUN_CMDS = ("mode", "engine", "fix", "audit", "config",
29
+ # 写命令支持 --dry-run(与 COMMANDS_META.supports_dry_run 同步)
30
+ # 0.4.2 起,lifecycle 4 + recover 也补齐 --dry-run。
31
+ _DRY_RUN_CMDS = ("start", "stop", "restart", "restart-clean", "recover",
32
+ "mode", "engine", "fix", "audit", "config",
31
33
  "daemon", "claude-proxy", "dns-lock", "dns-unlock")
32
34
  # 0.3.x:audit / check 支持 --plain
33
35
  _PLAIN_CMDS = ("audit", "check")
@@ -858,23 +858,30 @@ COMMANDS_META: list[dict] = [
858
858
  {"name": "start", "group": "lifecycle", "summary": "启动引擎 + 注入 DNS/代理",
859
859
  "args": [], "supports_json": False,
860
860
  "side_effects": ["process", "system"],
861
+ "supports_dry_run": True,
861
862
  "needs_sudo": True, "interactive": False,
862
- "exit_codes": [0, 1, 5], "examples": ["proxyctl start"]},
863
+ "exit_codes": [0, 1, 5],
864
+ "examples": ["proxyctl start", "proxyctl start --dry-run"]},
863
865
  {"name": "stop", "group": "lifecycle", "summary": "停止引擎 + 还原系统配置",
864
866
  "args": [], "supports_json": False,
865
867
  "side_effects": ["process", "system"],
868
+ "supports_dry_run": True,
866
869
  "needs_sudo": True, "interactive": False,
867
- "exit_codes": [0, 1], "examples": ["proxyctl stop"]},
870
+ "exit_codes": [0, 1],
871
+ "examples": ["proxyctl stop", "proxyctl stop --dry-run"]},
868
872
  {"name": "restart", "group": "lifecycle", "summary": "重启引擎",
869
873
  "args": [], "supports_json": False,
870
874
  "side_effects": ["process", "system"],
875
+ "supports_dry_run": True,
871
876
  "needs_sudo": True, "interactive": False, "exit_codes": [0, 1],
872
- "examples": ["proxyctl restart"]},
877
+ "examples": ["proxyctl restart", "proxyctl restart --dry-run"]},
873
878
  {"name": "restart-clean", "group": "lifecycle", "summary": "重启并清除缓存",
874
879
  "args": [], "supports_json": False,
875
880
  "side_effects": ["process", "system", "cache"],
881
+ "supports_dry_run": True,
876
882
  "needs_sudo": True, "interactive": False, "exit_codes": [0, 1],
877
- "examples": ["proxyctl restart-clean"]},
883
+ "examples": ["proxyctl restart-clean",
884
+ "proxyctl restart-clean --dry-run"]},
878
885
  # diagnostic
879
886
  {"name": "status", "group": "diagnostic", "summary": "系统状态面板",
880
887
  "args": [], "supports_json": True, "side_effects": [],
@@ -935,8 +942,9 @@ COMMANDS_META: list[dict] = [
935
942
  {"name": "recover", "group": "maintenance",
936
943
  "summary": "切网后软恢复(清 DNS 缓存 + 重测代理组,不重启)",
937
944
  "args": [], "supports_json": False, "side_effects": ["cache", "network-io"],
945
+ "supports_dry_run": True,
938
946
  "needs_sudo": False, "interactive": False, "exit_codes": [0, 1, 2, 5, 7],
939
- "examples": ["proxyctl recover"]},
947
+ "examples": ["proxyctl recover", "proxyctl recover --dry-run"]},
940
948
  # daemon / dns-lock
941
949
  {"name": "daemon", "group": "daemon",
942
950
  "summary": "管理 extra_daemons(如 claude-proxy)",
@@ -1050,6 +1058,11 @@ COMMANDS_META: list[dict] = [
1050
1058
  "needs_sudo": False, "interactive": False, "exit_codes": [0, 2],
1051
1059
  "examples": ["proxyctl help", "proxyctl help mode",
1052
1060
  "proxyctl mode --help"]},
1061
+ {"name": "version", "group": "agent",
1062
+ "summary": "版本号 + supported_features(等价 --version;--json 输出 envelope)",
1063
+ "args": [], "supports_json": True, "side_effects": [],
1064
+ "needs_sudo": False, "interactive": False, "exit_codes": [0],
1065
+ "examples": ["proxyctl version", "proxyctl version --json"]},
1053
1066
  ]
1054
1067
 
1055
1068
 
File without changes
File without changes
File without changes
File without changes
File without changes