proxyctl 0.3.2__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 (26) hide show
  1. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/PKG-INFO +1 -1
  2. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/man/proxyctl.1 +6 -3
  3. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/pyproject.toml +1 -1
  4. proxyctl-0.4.0a1/src/proxyctl/__init__.py +8 -0
  5. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/cli.py +243 -74
  6. proxyctl-0.4.0a1/src/proxyctl/completion.py +381 -0
  7. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/explain.py +183 -20
  8. proxyctl-0.3.2/src/proxyctl/__init__.py +0 -3
  9. proxyctl-0.3.2/src/proxyctl/completion.py +0 -223
  10. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/.gitignore +0 -0
  11. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/LICENSE +0 -0
  12. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/README.md +0 -0
  13. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/_io.py +0 -0
  14. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/audit.py +0 -0
  15. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  16. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
  17. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  18. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/check.py +0 -0
  19. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/core/__init__.py +0 -0
  20. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/core/plugin.py +0 -0
  21. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/engine/__init__.py +0 -0
  22. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/engine/base.py +0 -0
  23. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/engine/mihomo.py +0 -0
  24. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/engine/singbox.py +0 -0
  25. {proxyctl-0.3.2 → proxyctl-0.4.0a1}/src/proxyctl/status.py +0 -0
  26. {proxyctl-0.3.2 → 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.2
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
@@ -53,8 +53,10 @@ dns-lock / dns-unlock。
53
53
  .SH COMMANDS
54
54
  .SS 自描述(Agent 入口)
55
55
  .TP
56
- .B proxyctl agent-guide
56
+ \fBproxyctl agent-guide\fR [\fB--list-sections\fR | \fB--section\fR \fINAME\fR]
57
57
  输出给 LLM 的运行时入门 markdown(能力地图 / 引导路径 / 决策树 / envelope 字段表 / 锁文件位置 / footgun)。
58
+ v0.3.3 新增:\fB--list-sections\fR 列出所有 ASCII slug 形式的 section 名;
59
+ \fB--section <name>\fR 只输出该 section(模糊匹配 + did-you-mean),agent 按需取小块。
58
60
  .TP
59
61
  \fBproxyctl explain\fR [\fITOPIC\fR]
60
62
  无参输出"想改 X 去哪?"速查表;带 topic 输出卡片。
@@ -74,7 +76,8 @@ supports_dry_run / needs_sudo / interactive / exit_codes / examples。
74
76
  .B proxyctl doctor [--json]
75
77
  极简 5 项健康打分:engine_up / port_listen / dns_ok / system_proxy_ok /
76
78
  connectivity_ok。\fB--json\fR 额外含 informational 字段:
77
- engine / mode / port / config_path / engine_config_path / lock_held / lock_path
79
+ engine / mode / port / config_path / engine_config_path / lock_held / lock_path /
80
+ \fBhealthy\fR (v0.3.3 新增 bool,agent 不必再算 score==max)。
78
81
  .TP
79
82
  .B proxyctl help [\fICOMMAND\fR]
80
83
  顶层帮助 / 单命令完整说明。等价 \fBproxyctl --help\fR / \fBproxyctl <cmd> --help\fR。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proxyctl"
3
- version = "0.3.2"
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
 
@@ -1571,6 +1578,8 @@ def cmd_version_print() -> None:
1571
1578
  "agents_md": True,
1572
1579
  "commands_schema": True,
1573
1580
  "doctor_extended": True,
1581
+ "doctor_healthy_field": True, # 0.3.3
1582
+ "agent_guide_sections": True, # 0.3.3
1574
1583
  "log_ndjson_v2": True,
1575
1584
  },
1576
1585
  }
@@ -1716,38 +1725,145 @@ def _maybe_dry_run(cmd_name: str, plan_fn) -> None:
1716
1725
  sys.exit(0)
1717
1726
 
1718
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
+
1719
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 "关闭系统代理"
1720
1825
  return [
1721
1826
  {"action": "edit_yaml",
1722
1827
  "target": backend.config_file,
1723
1828
  "summary": f"修改 {backend.config_file}:切换 mode 为 {target}",
1724
1829
  "reversible": True,
1725
1830
  "side_effects": ["config-write"]},
1726
- {"action": "subprocess",
1727
- "target": f"launchctl kickstart -k {backend.label}",
1728
- "summary": f"重启 launchd 服务以读取新 mode",
1729
- "reversible": True, "requires_sudo": True,
1730
- "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"]},
1731
1837
  ]
1732
1838
 
1733
1839
 
1734
1840
  def _plan_engine(backend, target: str) -> list[dict]:
1735
- from proxyctl.cli import get_backend, DEFAULT_CONFIG_DIR
1736
1841
  new_cfg = {"backend": target}
1737
1842
  try:
1738
1843
  new_backend = get_backend(new_cfg)
1739
1844
  new_plist = new_backend.plist
1740
1845
  except Exception:
1741
- 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")
1742
1853
  return [
1743
1854
  {"action": "subprocess",
1744
- "target": f"launchctl bootout {backend.label}",
1855
+ "target": " ".join(argvs[0]),
1745
1856
  "summary": f"停止当前引擎 {backend.name}",
1746
1857
  "reversible": True, "requires_sudo": True,
1747
1858
  "side_effects": ["process"]},
1748
- {"action": "fs_write",
1749
- "target": new_plist,
1750
- "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}",
1751
1867
  "reversible": True, "requires_sudo": True,
1752
1868
  "side_effects": ["config-write"]},
1753
1869
  {"action": "fs_write",
@@ -1756,7 +1872,7 @@ def _plan_engine(backend, target: str) -> list[dict]:
1756
1872
  "reversible": True,
1757
1873
  "side_effects": ["config-write"]},
1758
1874
  {"action": "subprocess",
1759
- "target": f"launchctl bootstrap system {new_plist}",
1875
+ "target": " ".join(argvs[3]),
1760
1876
  "summary": f"启动新引擎 {target}",
1761
1877
  "reversible": True, "requires_sudo": True,
1762
1878
  "side_effects": ["process"]},
@@ -1764,39 +1880,56 @@ def _plan_engine(backend, target: str) -> list[dict]:
1764
1880
 
1765
1881
 
1766
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')
1767
1892
  return [
1768
- {"action": "subprocess",
1769
- "target": "networksetup -setdnsservers <svc> 127.0.0.1",
1893
+ {"action": "system_op",
1894
+ "target": "networksetup -setdnsservers (per service) 127.0.0.1",
1770
1895
  "summary": "重置系统 DNS 指向 127.0.0.1(对抗 DHCP 续租)",
1771
1896
  "reversible": True, "requires_sudo": True,
1772
1897
  "side_effects": ["system"]},
1773
- {"action": "subprocess",
1774
- "target": "scutil + dscacheutil",
1898
+ {"action": "system_op",
1899
+ "target": "scutil DNS reset + dscacheutil -flushcache",
1775
1900
  "summary": "清 macOS DNS 缓存(含 fakeip 表)",
1776
1901
  "reversible": True, "requires_sudo": True,
1777
1902
  "side_effects": ["cache"]},
1778
1903
  {"action": "http_put",
1779
- "target": f"{config.get('api_base', 'http://127.0.0.1:9090')}/configs?force=true",
1904
+ "target": f"{api_base}/configs?force=true",
1780
1905
  "summary": "向 Clash API 发热重载请求(不重启进程)",
1781
1906
  "reversible": True,
1782
1907
  "side_effects": ["network-io"]},
1783
1908
  ]
1784
1909
 
1785
1910
 
1786
- 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')
1787
1920
  return [
1788
1921
  {"action": "scan_log",
1789
- "target": "<engine log>",
1922
+ "target": log_path,
1790
1923
  "summary": f"扫描最近 {days} 天后端日志,找疑似应直连的域名",
1791
1924
  "reversible": True,
1792
1925
  "side_effects": []},
1793
1926
  {"action": "edit_yaml",
1794
- "target": "<engine config>.yaml [rules:]",
1927
+ "target": f"{backend.config_file} [rules:]",
1795
1928
  "summary": "把候选域名作为 DOMAIN-SUFFIX,...,DIRECT 加到 rules 段顶部",
1796
1929
  "reversible": True,
1797
1930
  "side_effects": ["config-write"]},
1798
1931
  {"action": "http_put",
1799
- "target": "Clash API /configs?force=true",
1932
+ "target": f"{api_base}/configs?force=true",
1800
1933
  "summary": "热重载配置使新规则生效",
1801
1934
  "reversible": True,
1802
1935
  "side_effects": ["network-io"]},
@@ -1818,32 +1951,46 @@ def _plan_config_set(path: str, key: str, value_repr: str) -> list[dict]:
1818
1951
  ]
1819
1952
 
1820
1953
 
1821
- 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
+ """
1822
1963
  if subcmd == "start":
1964
+ argvs = _daemon_subprocess_argvs("start", plist_src, plist_dst,
1965
+ full_label)
1823
1966
  return [
1824
- {"action": "fs_write",
1825
- "target": plist_dst,
1826
- "summary": f"如缺失则部署 plist {plist_dst}",
1967
+ {"action": "subprocess",
1968
+ "target": " ".join(argvs[0]),
1969
+ "summary": f" {plist_dst} 不存在则从 {plist_src} 拷贝",
1827
1970
  "reversible": True, "requires_sudo": True,
1828
1971
  "side_effects": ["config-write"]},
1829
1972
  {"action": "subprocess",
1830
- "target": f"launchctl bootstrap system {plist_dst}",
1973
+ "target": " ".join(argvs[1]),
1831
1974
  "summary": f"启动 daemon {name}",
1832
1975
  "reversible": True, "requires_sudo": True,
1833
1976
  "side_effects": ["process"]},
1834
1977
  ]
1835
1978
  if subcmd == "stop":
1979
+ argvs = _daemon_subprocess_argvs("stop", plist_src, plist_dst,
1980
+ full_label)
1836
1981
  return [
1837
1982
  {"action": "subprocess",
1838
- "target": f"launchctl bootout system/<{name}.label>",
1983
+ "target": " ".join(argvs[0]),
1839
1984
  "summary": f"停止 daemon {name}",
1840
1985
  "reversible": True, "requires_sudo": True,
1841
1986
  "side_effects": ["process"]},
1842
1987
  ]
1843
1988
  if subcmd == "restart":
1989
+ argvs = _daemon_subprocess_argvs("restart", plist_src, plist_dst,
1990
+ full_label)
1844
1991
  return [
1845
1992
  {"action": "subprocess",
1846
- "target": f"launchctl kickstart -k system/<{name}.label>",
1993
+ "target": " ".join(argvs[0]),
1847
1994
  "summary": f"重启 daemon {name}",
1848
1995
  "reversible": True, "requires_sudo": True,
1849
1996
  "side_effects": ["process"]},
@@ -1851,40 +1998,51 @@ def _plan_daemon(name: str, subcmd: str, plist_dst: str) -> list[dict]:
1851
1998
  return []
1852
1999
 
1853
2000
 
1854
- def _plan_dns_lock(reload: bool) -> list[dict]:
1855
- plan: list[dict] = []
1856
- if reload:
1857
- plan.append({
1858
- "action": "subprocess",
1859
- "target": "launchctl bootout system/<dns-lock.label>",
1860
- "summary": "如已注册,先 bootout 重装",
1861
- "reversible": True, "requires_sudo": True,
1862
- "side_effects": ["process"]})
1863
- plan += [
1864
- {"action": "fs_write",
1865
- "target": "/Library/LaunchDaemons/<dns-lock>.plist",
1866
- "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}",
1867
2022
  "reversible": True, "requires_sudo": True,
1868
2023
  "side_effects": ["config-write"]},
1869
2024
  {"action": "subprocess",
1870
- "target": "launchctl bootstrap system <plist>",
2025
+ "target": " ".join(argvs[2]),
1871
2026
  "summary": "启动 DNS 看门狗 daemon",
1872
2027
  "reversible": True, "requires_sudo": True,
1873
2028
  "side_effects": ["process"]},
1874
2029
  ]
1875
- return plan
1876
2030
 
1877
2031
 
1878
- 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)
1879
2037
  return [
1880
2038
  {"action": "subprocess",
1881
- "target": "launchctl bootout system/<dns-lock.label>",
2039
+ "target": " ".join(argvs[0]),
1882
2040
  "summary": "停止 DNS 看门狗",
1883
2041
  "reversible": True, "requires_sudo": True,
1884
2042
  "side_effects": ["process"]},
1885
- {"action": "fs_remove",
1886
- "target": "/Library/LaunchDaemons/<dns-lock>.plist",
1887
- "summary": "删除 launchd plist(可选;保留下次 dns-lock 直接启动)",
2043
+ {"action": "subprocess",
2044
+ "target": " ".join(argvs[1]),
2045
+ "summary": f"删除 launchd plist {plist}(源文件保留)",
1888
2046
  "reversible": False, "requires_sudo": True,
1889
2047
  "side_effects": ["config-write"]},
1890
2048
  ]
@@ -1987,7 +2145,7 @@ def _h_restart_clean(ctx):
1987
2145
  cmd_restart(ctx["backend"], ctx["config"], clean=True, registry=ctx["registry"])
1988
2146
  def _h_recover(ctx): cmd_recover(ctx["backend"], ctx["config"])
1989
2147
  def _h_dns_unlock(ctx):
1990
- _maybe_dry_run("dns-unlock", lambda: _plan_dns_unlock())
2148
+ _maybe_dry_run("dns-unlock", lambda: _plan_dns_unlock(ctx["config"]))
1991
2149
  _exec_with_lock("daemon", "dns-unlock", cmd_dns_unlock, ctx["config"])
1992
2150
  def _h_plugins(ctx): cmd_plugins(ctx["registry"])
1993
2151
 
@@ -2020,7 +2178,8 @@ def _h_fix(ctx):
2020
2178
 
2021
2179
  def _h_dns_lock(ctx):
2022
2180
  reload = "--reload" in ctx["args"]
2023
- _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))
2024
2183
  _exec_with_lock("daemon", "dns-lock", cmd_dns_lock,
2025
2184
  ctx["config"], ctx["backend"], reload=reload)
2026
2185
 
@@ -2041,8 +2200,13 @@ def _h_daemon(ctx):
2041
2200
  name = ctx["args"][0] if ctx["args"] else ""
2042
2201
  subcmd = ctx["args"][1] if len(ctx["args"]) > 1 else ""
2043
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 ("", "", "")
2044
2207
  _maybe_dry_run("daemon",
2045
- lambda: _plan_daemon(name, subcmd, "<plist_dst>"))
2208
+ lambda: _plan_daemon(name, subcmd, plist_src,
2209
+ plist_dst, full_label))
2046
2210
  _exec_with_lock("daemon", "daemon", cmd_daemon, name, subcmd, ctx["config"])
2047
2211
  else:
2048
2212
  cmd_daemon(name, subcmd, ctx["config"])
@@ -2051,8 +2215,11 @@ def _h_claude_proxy(ctx):
2051
2215
  """daemon claude-proxy <subcmd> 的别名,向后兼容。"""
2052
2216
  subcmd = ctx["args"][0] if ctx["args"] else "status"
2053
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 ("", "", "")
2054
2220
  _maybe_dry_run("claude-proxy",
2055
- lambda: _plan_daemon("claude-proxy", subcmd, "<plist_dst>"))
2221
+ lambda: _plan_daemon("claude-proxy", subcmd, plist_src,
2222
+ plist_dst, full_label))
2056
2223
  _exec_with_lock("daemon", "claude-proxy",
2057
2224
  cmd_daemon, "claude-proxy", subcmd, ctx["config"])
2058
2225
  else:
@@ -2080,7 +2247,9 @@ def _h_audit(ctx):
2080
2247
  days = 1
2081
2248
  from proxyctl.audit import cmd_audit
2082
2249
  if apply_mode:
2083
- _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"]))
2084
2253
  _exec_with_lock("config", "audit", cmd_audit,
2085
2254
  days, ctx["api_base"], ctx["api_secret"], apply_mode)
2086
2255
  else: