proxyctl 0.3.3__tar.gz → 0.4.0a1__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.3.3 → proxyctl-0.4.0a1}/PKG-INFO +1 -1
  2. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/man/proxyctl.1 +1 -1
  3. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/pyproject.toml +1 -1
  4. proxyctl-0.4.0a1/src/proxyctl/__init__.py +8 -0
  5. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/cli.py +241 -74
  6. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/explain.py +22 -0
  7. proxyctl-0.3.3/src/proxyctl/__init__.py +0 -3
  8. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/.gitignore +0 -0
  9. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/LICENSE +0 -0
  10. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/README.md +0 -0
  11. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/_io.py +0 -0
  12. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/audit.py +0 -0
  13. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  14. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
  15. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  16. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/check.py +0 -0
  17. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/completion.py +0 -0
  18. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/core/__init__.py +0 -0
  19. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/core/plugin.py +0 -0
  20. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/engine/__init__.py +0 -0
  21. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/engine/base.py +0 -0
  22. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/engine/mihomo.py +0 -0
  23. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/engine/singbox.py +0 -0
  24. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/status.py +0 -0
  25. {proxyctl-0.3.3 → proxyctl-0.4.0a1}/src/proxyctl/trace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyctl
3
- Version: 0.3.3
3
+ Version: 0.4.0a1
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
@@ -1,4 +1,4 @@
1
- .TH PROXYCTL 1 "2026-05" "proxyctl 0.3.0" "User Commands"
1
+ .TH PROXYCTL 1 "2026-05" "proxyctl 0.4.0a1" "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.3.3"
3
+ version = "0.4.0a1"
4
4
  description = "Proxy configuration lifecycle management for macOS and Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,8 @@
1
+ """proxyctl — Proxy configuration lifecycle management."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as _v
4
+
5
+ try:
6
+ __version__ = _v("proxyctl")
7
+ except PackageNotFoundError: # editable / source tree without metadata
8
+ __version__ = "unknown"
@@ -1025,10 +1025,12 @@ def cmd_engine(backend: Backend, target: str, config: dict):
1025
1025
  dns_lock_stop(config)
1026
1026
  dns_deactivate(config)
1027
1027
  proxy_deactivate()
1028
- run(["launchctl", "bootout", backend.label], sudo=True)
1029
- run(["/bin/rm", "-f", backend.plist], sudo=True)
1028
+ argvs = _engine_subprocess_argvs(backend, plist_src,
1029
+ new_backend.plist, new_backend.label)
1030
+ run(argvs[0], sudo=True) # launchctl bootout old
1031
+ run(argvs[1], sudo=True) # /bin/rm -f old.plist
1030
1032
 
1031
- r = run(["/bin/cp", plist_src, new_backend.plist], sudo=True, capture=True)
1033
+ r = run(argvs[2], sudo=True, capture=True) # /bin/cp src new.plist
1032
1034
  if r.returncode != 0:
1033
1035
  _io.fail("部署 plist 失败",
1034
1036
  hints=[r.stderr.strip()] if r.stderr else None,
@@ -1040,8 +1042,7 @@ def cmd_engine(backend: Backend, target: str, config: dict):
1040
1042
  f.write(target)
1041
1043
 
1042
1044
  print(f"启动 {new_backend.name} ...")
1043
- r = run(["launchctl", "bootstrap", "system", new_backend.plist],
1044
- sudo=True, capture=True)
1045
+ r = run(argvs[3], sudo=True, capture=True) # launchctl bootstrap new.plist
1045
1046
  if r.returncode != 0:
1046
1047
  _io.fail("启动失败",
1047
1048
  hints=[r.stderr.strip()] if r.stderr else None,
@@ -1099,16 +1100,17 @@ def cmd_daemon(name: str, subcmd: str, config: dict):
1099
1100
  as_json=GLOBAL_FLAGS.get("json", False))
1100
1101
 
1101
1102
  label = d_cfg.get("label", "")
1102
- plist_src = os.path.expanduser(d_cfg.get("plist_src", ""))
1103
- log_path = os.path.expanduser(d_cfg.get("log_path", ""))
1104
- port = d_cfg.get("port")
1103
+ log_path = os.path.expanduser(d_cfg.get("log_path", ""))
1104
+ port = d_cfg.get("port")
1105
1105
  if not label:
1106
1106
  _io.fail(f"daemon {name} 缺少 label 字段",
1107
1107
  hint="检查 config.yaml 的 extra_daemons[<name>].label",
1108
1108
  doc="extra-daemons", code=_io.CONFIG_ERR, cmd="daemon")
1109
1109
 
1110
- full_label = f"system/{label}"
1111
- plist_dst = f"/Library/LaunchDaemons/{label}.plist"
1110
+ # plist 路径与 launchctl label 由共享 helper 派生,与 _plan_daemon 同源
1111
+ paths = _resolve_daemon_paths(name, config)
1112
+ assert paths is not None # 上面 d_cfg/label 检查已确保
1113
+ plist_src, plist_dst, full_label = paths
1112
1114
 
1113
1115
  subcmd = subcmd or "status"
1114
1116
  valid_subcmds = ("start", "stop", "restart", "log", "status")
@@ -1125,15 +1127,15 @@ def cmd_daemon(name: str, subcmd: str, config: dict):
1125
1127
  if launchctl_running(full_label, sudo=True):
1126
1128
  print(f"{name} 已在运行")
1127
1129
  return
1130
+ argvs = _daemon_subprocess_argvs("start", plist_src, plist_dst, full_label)
1128
1131
  if not os.path.isfile(plist_dst):
1129
1132
  if not os.path.isfile(plist_src):
1130
1133
  _io.fail(f"plist 源文件不存在: {plist_src}",
1131
1134
  hint="检查 extra_daemons.plist_src 配置",
1132
1135
  doc="extra-daemons",
1133
1136
  code=_io.NOT_FOUND, cmd="daemon")
1134
- run(["/bin/cp", plist_src, plist_dst], sudo=True)
1135
- r = run(["launchctl", "bootstrap", "system", plist_dst],
1136
- sudo=True, capture=True)
1137
+ run(argvs[0], sudo=True) # /bin/cp src dst
1138
+ r = run(argvs[1], sudo=True, capture=True) # launchctl bootstrap
1137
1139
  if r.returncode != 0:
1138
1140
  _io.fail(f"{name} 启动失败",
1139
1141
  hints=[r.stderr.strip()] if r.stderr else None,
@@ -1147,13 +1149,15 @@ def cmd_daemon(name: str, subcmd: str, config: dict):
1147
1149
 
1148
1150
  elif subcmd == "stop":
1149
1151
  if launchctl_running(full_label, sudo=True):
1150
- run(["launchctl", "bootout", full_label], sudo=True)
1152
+ argvs = _daemon_subprocess_argvs("stop", plist_src, plist_dst, full_label)
1153
+ run(argvs[0], sudo=True) # launchctl bootout
1151
1154
  print(f"{name} stopped")
1152
1155
  else:
1153
1156
  print(f"{name} 未在运行")
1154
1157
 
1155
1158
  elif subcmd == "restart":
1156
- run(["launchctl", "kickstart", "-k", full_label], sudo=True)
1159
+ argvs = _daemon_subprocess_argvs("restart", plist_src, plist_dst, full_label)
1160
+ run(argvs[0], sudo=True) # launchctl kickstart -k
1157
1161
  print(f"{name} restarted")
1158
1162
 
1159
1163
  elif subcmd == "log":
@@ -1198,23 +1202,24 @@ def cmd_dns_lock(config: dict, backend: Backend, *, reload: bool = False):
1198
1202
  hint="把 scripts/dns-watchdog 安装到该位置(或重新跑 install.sh)",
1199
1203
  doc="dns", code=_io.DEPENDENCY_MISSING, cmd="dns-lock")
1200
1204
 
1205
+ argvs = _dns_lock_subprocess_argvs(dns_lock_plist, full_label)
1206
+
1201
1207
  # reload 时先 bootout
1202
1208
  if already_registered:
1203
- r0 = run(["launchctl", "bootout", full_label], sudo=True, capture=True)
1209
+ r0 = run(argvs[0], sudo=True, capture=True) # launchctl bootout
1204
1210
  if r0.returncode != 0:
1205
1211
  print(f"{YELLOW}⚠{NC} bootout 失败(继续尝试 bootstrap): {r0.stderr.strip()}")
1206
1212
 
1207
1213
  rendered = _render_dns_lock_plist(backend, config)
1208
1214
  # 通过 sudo tee 写入(sudoers 允许 /usr/bin/tee <target.plist>)
1209
- r = run(["tee", dns_lock_plist], sudo=True, stdin_text=rendered, capture=True)
1215
+ r = run(argvs[1], sudo=True, stdin_text=rendered, capture=True) # tee plist
1210
1216
  if r.returncode != 0:
1211
1217
  _io.fail(
1212
1218
  f"写入 plist 失败: {r.stderr or '权限不足,请检查 sudoers'}",
1213
1219
  hint="确认 sudoers 允许 /usr/bin/tee 写 LaunchDaemons",
1214
1220
  doc="dns", code=_io.PERMISSION, cmd="dns-lock")
1215
1221
 
1216
- r2 = run(["launchctl", "bootstrap", "system", dns_lock_plist],
1217
- sudo=True, capture=True)
1222
+ r2 = run(argvs[2], sudo=True, capture=True) # launchctl bootstrap
1218
1223
  if r2.returncode != 0:
1219
1224
  _io.fail(f"bootstrap 失败: {r2.stderr}",
1220
1225
  doc="dns", code=_io.PERMISSION, cmd="dns-lock")
@@ -1231,13 +1236,15 @@ def cmd_dns_unlock(config: dict):
1231
1236
  return
1232
1237
  dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
1233
1238
  dns_lock_plist = f"/Library/LaunchDaemons/{dns_lock_label}.plist"
1239
+ full_label = f"system/{dns_lock_label}"
1240
+ argvs = _dns_unlock_subprocess_argvs(dns_lock_plist, full_label)
1234
1241
 
1235
- r = run(["launchctl", "bootout", f"system/{dns_lock_label}"], sudo=True, capture=True)
1242
+ r = run(argvs[0], sudo=True, capture=True) # launchctl bootout
1236
1243
  if r.returncode == 0:
1237
1244
  print(f"{GREEN}dns-lock daemon 已停止{NC}")
1238
1245
  else:
1239
1246
  print("dns-lock daemon 未在运行")
1240
- run(["rm", "-f", dns_lock_plist], sudo=True)
1247
+ run(argvs[1], sudo=True) # rm -f plist
1241
1248
  print(f"已删除 {dns_lock_plist} (源文件保留在 {DEFAULT_CONFIG_DIR}/launchdaemons/)")
1242
1249
 
1243
1250
 
@@ -1718,38 +1725,145 @@ def _maybe_dry_run(cmd_name: str, plan_fn) -> None:
1718
1725
  sys.exit(0)
1719
1726
 
1720
1727
 
1728
+ # ── plan/exec 共享 helper(plan 与 cmd 的单一事实来源)─────────────────────────
1729
+ #
1730
+ # 这些 helper 返回**不含 sudo 前缀**的 argv list。plan_fn 把它们 split 成
1731
+ # PlanStep.target 字符串;cmd_* 把它们传给 run(..., sudo=True) 执行。两边共享
1732
+ # 同一份逻辑,CI 层 contract test 进一步断言 plan 与实际 subprocess.run 调用
1733
+ # 完全一致,杜绝 0.3.2 那种 system/system/ 双前缀类漂移。
1734
+
1735
+ def _resolve_daemon_paths(
1736
+ name: str, config: dict
1737
+ ) -> tuple[str, str, str] | None:
1738
+ """从 config.extra_daemons[name] 解析 (plist_src, plist_dst, full_label)。
1739
+
1740
+ 用于消除 _h_daemon lambda 中的 <plist_dst> 占位符,并保证 cmd_daemon /
1741
+ _plan_daemon / _h_daemon 三处口径一致。
1742
+
1743
+ name 未声明或 label 缺失时返回 None;调用方决定是 fail 还是降级。
1744
+ """
1745
+ daemons = (config.get("extra_daemons") or {})
1746
+ d_cfg = daemons.get(name)
1747
+ if not d_cfg:
1748
+ return None
1749
+ label = d_cfg.get("label", "")
1750
+ if not label:
1751
+ return None
1752
+ plist_src = os.path.expanduser(d_cfg.get("plist_src", ""))
1753
+ plist_dst = f"/Library/LaunchDaemons/{label}.plist"
1754
+ full_label = f"system/{label}"
1755
+ return (plist_src, plist_dst, full_label)
1756
+
1757
+
1758
+ def _engine_subprocess_argvs(backend_old: Backend, plist_src: str,
1759
+ new_plist: str, new_label: str) -> list[list[str]]:
1760
+ """返回 cmd_engine 切换引擎时 4 个 subprocess 的 argv list(不含 sudo)。
1761
+
1762
+ 顺序对应 cmd_engine cli.py 中的 launchctl bootout → /bin/rm 旧 plist →
1763
+ /bin/cp 新 plist → launchctl bootstrap。run() 会自动加 sudo 前缀。
1764
+ """
1765
+ return [
1766
+ ["launchctl", "bootout", backend_old.label],
1767
+ ["/bin/rm", "-f", backend_old.plist],
1768
+ ["/bin/cp", plist_src, new_plist],
1769
+ ["launchctl", "bootstrap", "system", new_plist],
1770
+ ]
1771
+
1772
+
1773
+ def _daemon_subprocess_argvs(subcmd: str, plist_src: str,
1774
+ plist_dst: str, full_label: str) -> list[list[str]]:
1775
+ """返回 cmd_daemon <subcmd> 的 argv list(不含 sudo)。
1776
+
1777
+ start: 始终列 [/bin/cp, launchctl bootstrap];cp 是 conditional
1778
+ (cmd_daemon 在 plist_dst 已存在时跳过),但 plan 作为上界总展示,
1779
+ contract test 走 actual ⊆ plan 方向天然容纳。
1780
+ """
1781
+ if subcmd == "start":
1782
+ return [
1783
+ ["/bin/cp", plist_src, plist_dst],
1784
+ ["launchctl", "bootstrap", "system", plist_dst],
1785
+ ]
1786
+ if subcmd == "stop":
1787
+ return [["launchctl", "bootout", full_label]]
1788
+ if subcmd == "restart":
1789
+ return [["launchctl", "kickstart", "-k", full_label]]
1790
+ return []
1791
+
1792
+
1793
+ def _dns_lock_subprocess_argvs(plist_path: str,
1794
+ full_label: str) -> list[list[str]]:
1795
+ """返回 cmd_dns_lock 的 argv list(不含 sudo)。
1796
+
1797
+ plan 始终展示完整 reload 路径:[bootout, tee, bootstrap]。default 模式
1798
+ (already_registered=True 且 reload=False)实际只跑 0 步,但 plan 作为
1799
+ dry-run 上界依然完整展示。contract test 用 actual ⊆ plan 方向。
1800
+ """
1801
+ return [
1802
+ ["launchctl", "bootout", full_label],
1803
+ ["tee", plist_path],
1804
+ ["launchctl", "bootstrap", "system", plist_path],
1805
+ ]
1806
+
1807
+
1808
+ def _dns_unlock_subprocess_argvs(plist_path: str,
1809
+ full_label: str) -> list[list[str]]:
1810
+ """返回 cmd_dns_unlock 的 argv list(不含 sudo):[bootout, rm -f plist]。"""
1811
+ return [
1812
+ ["launchctl", "bootout", full_label],
1813
+ ["rm", "-f", plist_path],
1814
+ ]
1815
+
1816
+
1721
1817
  def _plan_mode(backend, target: str) -> list[dict]:
1818
+ """mode tun / mode proxy 的 plan。
1819
+
1820
+ cmd_mode 实际不调 launchctl —— 仅改 config 文件 + 切换系统代理;
1821
+ 需手动 restart 引擎才生效(cmd_mode 末尾会提示)。plan 同源展示。
1822
+ """
1823
+ proxy_action = "开启系统代理 127.0.0.1:7890" if target == "proxy" \
1824
+ else "关闭系统代理"
1722
1825
  return [
1723
1826
  {"action": "edit_yaml",
1724
1827
  "target": backend.config_file,
1725
1828
  "summary": f"修改 {backend.config_file}:切换 mode 为 {target}",
1726
1829
  "reversible": True,
1727
1830
  "side_effects": ["config-write"]},
1728
- {"action": "subprocess",
1729
- "target": f"launchctl kickstart -k {backend.label}",
1730
- "summary": f"重启 launchd 服务以读取新 mode",
1731
- "reversible": True, "requires_sudo": True,
1732
- "side_effects": ["process"]},
1831
+ {"action": "system_op",
1832
+ "target": f"networksetup -setwebproxy* / -setsecurewebproxy* "
1833
+ f"(per service)",
1834
+ "summary": proxy_action,
1835
+ "reversible": True, "requires_sudo": False,
1836
+ "side_effects": ["system"]},
1733
1837
  ]
1734
1838
 
1735
1839
 
1736
1840
  def _plan_engine(backend, target: str) -> list[dict]:
1737
- from proxyctl.cli import get_backend, DEFAULT_CONFIG_DIR
1738
1841
  new_cfg = {"backend": target}
1739
1842
  try:
1740
1843
  new_backend = get_backend(new_cfg)
1741
1844
  new_plist = new_backend.plist
1742
1845
  except Exception:
1743
- new_plist = f"/Library/LaunchDaemons/<{target}>.plist"
1846
+ new_backend = None
1847
+ new_plist = f"/Library/LaunchDaemons/com.{target}.tun.plist"
1848
+ plist_src = os.path.join(DEFAULT_CONFIG_DIR, "launchdaemons",
1849
+ os.path.basename(new_plist))
1850
+ argvs = _engine_subprocess_argvs(backend, plist_src, new_plist,
1851
+ new_backend.label if new_backend
1852
+ else f"system/com.{target}.tun")
1744
1853
  return [
1745
1854
  {"action": "subprocess",
1746
- "target": f"launchctl bootout {backend.label}",
1855
+ "target": " ".join(argvs[0]),
1747
1856
  "summary": f"停止当前引擎 {backend.name}",
1748
1857
  "reversible": True, "requires_sudo": True,
1749
1858
  "side_effects": ["process"]},
1750
- {"action": "fs_write",
1751
- "target": new_plist,
1752
- "summary": f"部署新引擎 plist {new_plist}",
1859
+ {"action": "subprocess",
1860
+ "target": " ".join(argvs[1]),
1861
+ "summary": f"删除旧 plist {backend.plist}",
1862
+ "reversible": False, "requires_sudo": True,
1863
+ "side_effects": ["config-write"]},
1864
+ {"action": "subprocess",
1865
+ "target": " ".join(argvs[2]),
1866
+ "summary": f"部署新 plist 到 {new_plist}",
1753
1867
  "reversible": True, "requires_sudo": True,
1754
1868
  "side_effects": ["config-write"]},
1755
1869
  {"action": "fs_write",
@@ -1758,7 +1872,7 @@ def _plan_engine(backend, target: str) -> list[dict]:
1758
1872
  "reversible": True,
1759
1873
  "side_effects": ["config-write"]},
1760
1874
  {"action": "subprocess",
1761
- "target": f"launchctl bootstrap system {new_plist}",
1875
+ "target": " ".join(argvs[3]),
1762
1876
  "summary": f"启动新引擎 {target}",
1763
1877
  "reversible": True, "requires_sudo": True,
1764
1878
  "side_effects": ["process"]},
@@ -1766,39 +1880,56 @@ def _plan_engine(backend, target: str) -> list[dict]:
1766
1880
 
1767
1881
 
1768
1882
  def _plan_fix(backend, config) -> list[dict]:
1883
+ """fix 的 plan。
1884
+
1885
+ networksetup / scutil 迭代型操作不可逐条 argv 化(每个网络服务调一次),
1886
+ 标 action=system_op + 描述性 target。http_put 是单次精确请求,target
1887
+ 为完整 URL。cmd_fix 实际还会调插件 route_hooks + 第二次 curl 清 fakeip,
1888
+ contract test 不做 subprocess 子集校验(plugin 可注入),只校验
1889
+ placeholder 已消除(< / > 字符不出现)。
1890
+ """
1891
+ api_base = config.get('api_base', 'http://127.0.0.1:9090')
1769
1892
  return [
1770
- {"action": "subprocess",
1771
- "target": "networksetup -setdnsservers <svc> 127.0.0.1",
1893
+ {"action": "system_op",
1894
+ "target": "networksetup -setdnsservers (per service) 127.0.0.1",
1772
1895
  "summary": "重置系统 DNS 指向 127.0.0.1(对抗 DHCP 续租)",
1773
1896
  "reversible": True, "requires_sudo": True,
1774
1897
  "side_effects": ["system"]},
1775
- {"action": "subprocess",
1776
- "target": "scutil + dscacheutil",
1898
+ {"action": "system_op",
1899
+ "target": "scutil DNS reset + dscacheutil -flushcache",
1777
1900
  "summary": "清 macOS DNS 缓存(含 fakeip 表)",
1778
1901
  "reversible": True, "requires_sudo": True,
1779
1902
  "side_effects": ["cache"]},
1780
1903
  {"action": "http_put",
1781
- "target": f"{config.get('api_base', 'http://127.0.0.1:9090')}/configs?force=true",
1904
+ "target": f"{api_base}/configs?force=true",
1782
1905
  "summary": "向 Clash API 发热重载请求(不重启进程)",
1783
1906
  "reversible": True,
1784
1907
  "side_effects": ["network-io"]},
1785
1908
  ]
1786
1909
 
1787
1910
 
1788
- def _plan_audit_apply(days: int) -> list[dict]:
1911
+ def _plan_audit_apply(days: int, backend, config: dict) -> list[dict]:
1912
+ """audit apply 的 plan。
1913
+
1914
+ scan_log target 等于 audit 模块实际扫描的路径(audit.MH_LOG / SB_LOG),
1915
+ 不是 backend.log_file —— sing-box 这两者不同(review Q2)。
1916
+ """
1917
+ from proxyctl import audit
1918
+ log_path = audit.MH_LOG if backend.name == "mihomo" else audit.SB_LOG
1919
+ api_base = config.get('api_base', 'http://127.0.0.1:9090')
1789
1920
  return [
1790
1921
  {"action": "scan_log",
1791
- "target": "<engine log>",
1922
+ "target": log_path,
1792
1923
  "summary": f"扫描最近 {days} 天后端日志,找疑似应直连的域名",
1793
1924
  "reversible": True,
1794
1925
  "side_effects": []},
1795
1926
  {"action": "edit_yaml",
1796
- "target": "<engine config>.yaml [rules:]",
1927
+ "target": f"{backend.config_file} [rules:]",
1797
1928
  "summary": "把候选域名作为 DOMAIN-SUFFIX,...,DIRECT 加到 rules 段顶部",
1798
1929
  "reversible": True,
1799
1930
  "side_effects": ["config-write"]},
1800
1931
  {"action": "http_put",
1801
- "target": "Clash API /configs?force=true",
1932
+ "target": f"{api_base}/configs?force=true",
1802
1933
  "summary": "热重载配置使新规则生效",
1803
1934
  "reversible": True,
1804
1935
  "side_effects": ["network-io"]},
@@ -1820,32 +1951,46 @@ def _plan_config_set(path: str, key: str, value_repr: str) -> list[dict]:
1820
1951
  ]
1821
1952
 
1822
1953
 
1823
- def _plan_daemon(name: str, subcmd: str, plist_dst: str) -> list[dict]:
1954
+ def _plan_daemon(name: str, subcmd: str, plist_src: str,
1955
+ plist_dst: str, full_label: str) -> list[dict]:
1956
+ """daemon <name> start/stop/restart 的 plan。
1957
+
1958
+ 所有 subprocess.target 由 _daemon_subprocess_argvs 派生,与 cmd_daemon
1959
+ 实际跑的 argv 共享单一事实来源。start 步骤总是展示 cp + bootstrap,
1960
+ cp 是 conditional(plist_dst 已存在时跳过)但 plan 作为 dry-run 上界
1961
+ 总是列出,contract test 走 actual ⊆ plan 方向。
1962
+ """
1824
1963
  if subcmd == "start":
1964
+ argvs = _daemon_subprocess_argvs("start", plist_src, plist_dst,
1965
+ full_label)
1825
1966
  return [
1826
- {"action": "fs_write",
1827
- "target": plist_dst,
1828
- "summary": f"如缺失则部署 plist {plist_dst}",
1967
+ {"action": "subprocess",
1968
+ "target": " ".join(argvs[0]),
1969
+ "summary": f" {plist_dst} 不存在则从 {plist_src} 拷贝",
1829
1970
  "reversible": True, "requires_sudo": True,
1830
1971
  "side_effects": ["config-write"]},
1831
1972
  {"action": "subprocess",
1832
- "target": f"launchctl bootstrap system {plist_dst}",
1973
+ "target": " ".join(argvs[1]),
1833
1974
  "summary": f"启动 daemon {name}",
1834
1975
  "reversible": True, "requires_sudo": True,
1835
1976
  "side_effects": ["process"]},
1836
1977
  ]
1837
1978
  if subcmd == "stop":
1979
+ argvs = _daemon_subprocess_argvs("stop", plist_src, plist_dst,
1980
+ full_label)
1838
1981
  return [
1839
1982
  {"action": "subprocess",
1840
- "target": f"launchctl bootout system/<{name}.label>",
1983
+ "target": " ".join(argvs[0]),
1841
1984
  "summary": f"停止 daemon {name}",
1842
1985
  "reversible": True, "requires_sudo": True,
1843
1986
  "side_effects": ["process"]},
1844
1987
  ]
1845
1988
  if subcmd == "restart":
1989
+ argvs = _daemon_subprocess_argvs("restart", plist_src, plist_dst,
1990
+ full_label)
1846
1991
  return [
1847
1992
  {"action": "subprocess",
1848
- "target": f"launchctl kickstart -k system/<{name}.label>",
1993
+ "target": " ".join(argvs[0]),
1849
1994
  "summary": f"重启 daemon {name}",
1850
1995
  "reversible": True, "requires_sudo": True,
1851
1996
  "side_effects": ["process"]},
@@ -1853,40 +1998,51 @@ def _plan_daemon(name: str, subcmd: str, plist_dst: str) -> list[dict]:
1853
1998
  return []
1854
1999
 
1855
2000
 
1856
- def _plan_dns_lock(reload: bool) -> list[dict]:
1857
- plan: list[dict] = []
1858
- if reload:
1859
- plan.append({
1860
- "action": "subprocess",
1861
- "target": "launchctl bootout system/<dns-lock.label>",
1862
- "summary": "如已注册,先 bootout 重装",
1863
- "reversible": True, "requires_sudo": True,
1864
- "side_effects": ["process"]})
1865
- plan += [
1866
- {"action": "fs_write",
1867
- "target": "/Library/LaunchDaemons/<dns-lock>.plist",
1868
- "summary": "渲染并写入 dns-lock launchd plist",
2001
+ def _plan_dns_lock(config: dict, *, reload: bool = False) -> list[dict]:
2002
+ """dns-lock plan。
2003
+
2004
+ plan 始终展示完整 reload 路径 [bootout, tee, bootstrap];reload=False
2005
+ already_registered=True 时 cmd_dns_lock 实际跑 0 步——这是 dry-run
2006
+ 上界,contract test actual ⊆ plan 方向天然容纳。
2007
+ """
2008
+ dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
2009
+ plist = f"/Library/LaunchDaemons/{dns_lock_label}.plist"
2010
+ full_label = f"system/{dns_lock_label}"
2011
+ argvs = _dns_lock_subprocess_argvs(plist, full_label)
2012
+ reload_hint = "(reload 时 / 已注册时)" if not reload else ""
2013
+ return [
2014
+ {"action": "subprocess",
2015
+ "target": " ".join(argvs[0]),
2016
+ "summary": f"如已注册,先 bootout 重装{reload_hint}",
2017
+ "reversible": True, "requires_sudo": True,
2018
+ "side_effects": ["process"]},
2019
+ {"action": "subprocess",
2020
+ "target": " ".join(argvs[1]),
2021
+ "summary": f"通过 sudo tee 写入渲染好的 plist 到 {plist}",
1869
2022
  "reversible": True, "requires_sudo": True,
1870
2023
  "side_effects": ["config-write"]},
1871
2024
  {"action": "subprocess",
1872
- "target": "launchctl bootstrap system <plist>",
2025
+ "target": " ".join(argvs[2]),
1873
2026
  "summary": "启动 DNS 看门狗 daemon",
1874
2027
  "reversible": True, "requires_sudo": True,
1875
2028
  "side_effects": ["process"]},
1876
2029
  ]
1877
- return plan
1878
2030
 
1879
2031
 
1880
- def _plan_dns_unlock() -> list[dict]:
2032
+ def _plan_dns_unlock(config: dict) -> list[dict]:
2033
+ dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
2034
+ plist = f"/Library/LaunchDaemons/{dns_lock_label}.plist"
2035
+ full_label = f"system/{dns_lock_label}"
2036
+ argvs = _dns_unlock_subprocess_argvs(plist, full_label)
1881
2037
  return [
1882
2038
  {"action": "subprocess",
1883
- "target": "launchctl bootout system/<dns-lock.label>",
2039
+ "target": " ".join(argvs[0]),
1884
2040
  "summary": "停止 DNS 看门狗",
1885
2041
  "reversible": True, "requires_sudo": True,
1886
2042
  "side_effects": ["process"]},
1887
- {"action": "fs_remove",
1888
- "target": "/Library/LaunchDaemons/<dns-lock>.plist",
1889
- "summary": "删除 launchd plist(可选;保留下次 dns-lock 直接启动)",
2043
+ {"action": "subprocess",
2044
+ "target": " ".join(argvs[1]),
2045
+ "summary": f"删除 launchd plist {plist}(源文件保留)",
1890
2046
  "reversible": False, "requires_sudo": True,
1891
2047
  "side_effects": ["config-write"]},
1892
2048
  ]
@@ -1989,7 +2145,7 @@ def _h_restart_clean(ctx):
1989
2145
  cmd_restart(ctx["backend"], ctx["config"], clean=True, registry=ctx["registry"])
1990
2146
  def _h_recover(ctx): cmd_recover(ctx["backend"], ctx["config"])
1991
2147
  def _h_dns_unlock(ctx):
1992
- _maybe_dry_run("dns-unlock", lambda: _plan_dns_unlock())
2148
+ _maybe_dry_run("dns-unlock", lambda: _plan_dns_unlock(ctx["config"]))
1993
2149
  _exec_with_lock("daemon", "dns-unlock", cmd_dns_unlock, ctx["config"])
1994
2150
  def _h_plugins(ctx): cmd_plugins(ctx["registry"])
1995
2151
 
@@ -2022,7 +2178,8 @@ def _h_fix(ctx):
2022
2178
 
2023
2179
  def _h_dns_lock(ctx):
2024
2180
  reload = "--reload" in ctx["args"]
2025
- _maybe_dry_run("dns-lock", lambda: _plan_dns_lock(reload))
2181
+ _maybe_dry_run("dns-lock",
2182
+ lambda: _plan_dns_lock(ctx["config"], reload=reload))
2026
2183
  _exec_with_lock("daemon", "dns-lock", cmd_dns_lock,
2027
2184
  ctx["config"], ctx["backend"], reload=reload)
2028
2185
 
@@ -2043,8 +2200,13 @@ def _h_daemon(ctx):
2043
2200
  name = ctx["args"][0] if ctx["args"] else ""
2044
2201
  subcmd = ctx["args"][1] if len(ctx["args"]) > 1 else ""
2045
2202
  if subcmd in ("start", "stop", "restart"):
2203
+ paths = _resolve_daemon_paths(name, ctx["config"])
2204
+ # name 未声明或 label 缺失:plan 用空字符串占位(cmd_daemon 会 fail
2205
+ # 出友好 hint,所以 dry-run 报 plan 也无害)。
2206
+ plist_src, plist_dst, full_label = paths or ("", "", "")
2046
2207
  _maybe_dry_run("daemon",
2047
- lambda: _plan_daemon(name, subcmd, "<plist_dst>"))
2208
+ lambda: _plan_daemon(name, subcmd, plist_src,
2209
+ plist_dst, full_label))
2048
2210
  _exec_with_lock("daemon", "daemon", cmd_daemon, name, subcmd, ctx["config"])
2049
2211
  else:
2050
2212
  cmd_daemon(name, subcmd, ctx["config"])
@@ -2053,8 +2215,11 @@ def _h_claude_proxy(ctx):
2053
2215
  """daemon claude-proxy <subcmd> 的别名,向后兼容。"""
2054
2216
  subcmd = ctx["args"][0] if ctx["args"] else "status"
2055
2217
  if subcmd in ("start", "stop", "restart"):
2218
+ paths = _resolve_daemon_paths("claude-proxy", ctx["config"])
2219
+ plist_src, plist_dst, full_label = paths or ("", "", "")
2056
2220
  _maybe_dry_run("claude-proxy",
2057
- lambda: _plan_daemon("claude-proxy", subcmd, "<plist_dst>"))
2221
+ lambda: _plan_daemon("claude-proxy", subcmd, plist_src,
2222
+ plist_dst, full_label))
2058
2223
  _exec_with_lock("daemon", "claude-proxy",
2059
2224
  cmd_daemon, "claude-proxy", subcmd, ctx["config"])
2060
2225
  else:
@@ -2082,7 +2247,9 @@ def _h_audit(ctx):
2082
2247
  days = 1
2083
2248
  from proxyctl.audit import cmd_audit
2084
2249
  if apply_mode:
2085
- _maybe_dry_run("audit", lambda: _plan_audit_apply(days))
2250
+ _maybe_dry_run("audit",
2251
+ lambda: _plan_audit_apply(days, ctx["backend"],
2252
+ ctx["config"]))
2086
2253
  _exec_with_lock("config", "audit", cmd_audit,
2087
2254
  days, ctx["api_base"], ctx["api_secret"], apply_mode)
2088
2255
  else:
@@ -760,6 +760,28 @@ Step 6 proxyctl explain <topic> # 深入概念(topic 见下)
760
760
  NDJSON 流式:`bench --json` 每节点一行 JSON + 末尾 envelope summary;
761
761
  `log --json` 每行一个 `{{file, line}}` 对象(非 envelope)。
762
762
 
763
+ ## Plan — `data.plan[].action` 类型枚举(dry-run 输出)
764
+
765
+ 写命令的 `--dry-run` 在 `data.plan` 输出 step 列表。从 v0.4.0a1 起,所有
766
+ step.target 字符串都不再含 `<...>` 占位符,agent 可原样使用。
767
+
768
+ | action | target 是什么 | agent 可怎么用 |
769
+ |---|---|---|
770
+ | `subprocess` | 可执行 shell 命令字符串(`target.split()` 即得 argv) | 复读 / 加 sudo 后直接跑 |
771
+ | `system_op` | 迭代型系统操作的描述(如 networksetup 遍历所有 service) | **不可**直接复读;理解副作用 |
772
+ | `fs_write` / `fs_copy` / `fs_write_atomic` | 绝对路径(fs_copy 形如 `src → dst`) | 文件写入意图 |
773
+ | `fs_remove` | 绝对路径 | 文件删除意图 |
774
+ | `edit_yaml` | `path [section:]` | 配置就地编辑意图 |
775
+ | `scan_log` | 日志文件绝对路径 | 日志扫描意图 |
776
+ | `http_put` | 完整 HTTP URL | Clash API 热重载等 |
777
+
778
+ `subprocess` step 的 `target` 是 dry-run 上界 —— cmd 实际执行可能跳过某些
779
+ conditional 步骤(如 daemon-start 的 cp 在 plist 已存在时跳过)。所有 step
780
+ 也有 `requires_sudo` / `reversible` / `side_effects` / `summary` 等元信息。
781
+
782
+ CI 层 contract test(`tests/integration/test_plan_exec_contract.py`)保证
783
+ `_plan_<cmd>` 与 `cmd_<cmd>` 的 subprocess argv 永不漂移。
784
+
763
785
  ## Decision Tree — 故障决策树(给 Agent 自动化)
764
786
 
765
787
  1. `proxyctl doctor --json` ← 最快,5 项布尔 + score(+ engine/mode/lock_path 信息字段)
@@ -1,3 +0,0 @@
1
- """proxyctl — Proxy configuration lifecycle management."""
2
-
3
- __version__ = "0.3.2"
File without changes
File without changes
File without changes
File without changes