proxyctl 0.4.6__tar.gz → 0.5.0__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.4.6 → proxyctl-0.5.0}/PKG-INFO +13 -4
  2. {proxyctl-0.4.6 → proxyctl-0.5.0}/README.md +12 -3
  3. {proxyctl-0.4.6 → proxyctl-0.5.0}/man/proxyctl.1 +1 -1
  4. {proxyctl-0.4.6 → proxyctl-0.5.0}/pyproject.toml +1 -1
  5. proxyctl-0.5.0/src/proxyctl/autostart.py +521 -0
  6. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/cli.py +303 -0
  7. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/explain.py +487 -35
  8. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/status.py +17 -4
  9. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/subscription.py +165 -7
  10. proxyctl-0.5.0/src/proxyctl/suggest.py +313 -0
  11. proxyctl-0.5.0/src/proxyctl/suggest_rules.py +395 -0
  12. {proxyctl-0.4.6 → proxyctl-0.5.0}/.gitignore +0 -0
  13. {proxyctl-0.4.6 → proxyctl-0.5.0}/LICENSE +0 -0
  14. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/__init__.py +0 -0
  15. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/_io.py +0 -0
  16. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/audit.py +0 -0
  17. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  18. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
  19. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  20. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/check.py +0 -0
  21. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/completion.py +0 -0
  22. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/core/__init__.py +0 -0
  23. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/core/plugin.py +0 -0
  24. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/engine/__init__.py +0 -0
  25. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/engine/base.py +0 -0
  26. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/engine/mihomo.py +0 -0
  27. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/engine/singbox.py +0 -0
  28. {proxyctl-0.4.6 → proxyctl-0.5.0}/src/proxyctl/trace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyctl
3
- Version: 0.4.6
3
+ Version: 0.5.0
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
@@ -85,7 +85,9 @@ agent 会:自描述协议 → 读取本机现状 → 用 dry-run 演示要做
85
85
  4. 健康打底
86
86
  - `proxyctl check --json` —— 任一 stage 失败时 envelope.hints
87
87
  已聚合真凶摘要(v0.4.3+),不必挖 stages.*.ok
88
- - `proxyctl doctor --json` —— 5 项快速打分
88
+ - `proxyctl doctor --json` —— 5 项快速打分 + `data.suggestions[]`
89
+ 引导建议(v0.5.0+ 含 21 条:订阅过期 / autostart plist 与 PATH 版本不一致 /
90
+ weak API secret / geoip 数据过期 等)
89
91
  - `proxyctl audit 7 --json` —— 扫近 7 天访问日志,找走代理但落地国内的域名
90
92
 
91
93
  5. 给我可执行的改进列表
@@ -110,7 +112,8 @@ proxyctl --version --json # schema_version + supported_features 探
110
112
  proxyctl commands --json # 全部命令元数据(机读)
111
113
  proxyctl commands --schema # 上面 JSON 的 JSON Schema
112
114
  proxyctl explain # "我想改 X 去哪?" 速查
113
- proxyctl doctor --json # 5 项健康打分 + healthy 布尔字段
115
+ proxyctl doctor --json # 5 项健康打分 + data.suggestions[] 引导建议
116
+ proxyctl explain suggestion:<id> # v0.5.0+ 每条 suggestion 都有详情 topic
114
117
  PROXYCTL_AGENT=1 proxyctl <cmd> # 一键 --json + 关色 + 非交互
115
118
  ```
116
119
 
@@ -127,6 +130,12 @@ PROXYCTL_AGENT=1 proxyctl <cmd> # 一键 --json + 关色 + 非交互
127
130
  - **CI 层 contract test**(`tests/integration/test_plan_exec_contract.py`)
128
131
  保证 plan ↔ exec **永不漂移**
129
132
  - **错误带可执行 hints + explain topic**:agent 可路由下一步而不需要规则匹配
133
+ - **doctor suggestions(v0.5.0+)**:与 score 解耦的引导维度。21 条规则覆盖
134
+ 订阅 / autostart plist 路径与版本一致性 / Clash API 安全 / GeoIP 数据时效。
135
+ 每条带稳定 `id` + `fingerprint` + `first_seen`,agent 可跨次去重。
136
+ `severity` 三档(info/advisory/warn)永不影响 exit code,按
137
+ `severity desc, id asc` 固定排序。详见 [AGENTS.md](AGENTS.md)
138
+ 的 "Doctor suggestions" 段
130
139
  - **`audit/check` 支持 `--plain` TSV**:4 行 stage/ok/detail,agent 友好的备选
131
140
  - **`proxyctl help <cmd>` 与 `<cmd> --help` 同源**
132
141
  - **非 TTY 自动关色 / 不读 stdin / 不 prompt / 写操作 fcntl.flock 互斥**
@@ -193,7 +202,7 @@ uv tool install proxyctl # uv(推荐)
193
202
  pipx install proxyctl # 或 pipx
194
203
  pip install --user proxyctl # 或 pip
195
204
 
196
- proxyctl --version # → proxyctl v0.4.6
205
+ proxyctl --version # → proxyctl v0.4.7
197
206
  proxyctl --help
198
207
  ```
199
208
 
@@ -56,7 +56,9 @@ agent 会:自描述协议 → 读取本机现状 → 用 dry-run 演示要做
56
56
  4. 健康打底
57
57
  - `proxyctl check --json` —— 任一 stage 失败时 envelope.hints
58
58
  已聚合真凶摘要(v0.4.3+),不必挖 stages.*.ok
59
- - `proxyctl doctor --json` —— 5 项快速打分
59
+ - `proxyctl doctor --json` —— 5 项快速打分 + `data.suggestions[]`
60
+ 引导建议(v0.5.0+ 含 21 条:订阅过期 / autostart plist 与 PATH 版本不一致 /
61
+ weak API secret / geoip 数据过期 等)
60
62
  - `proxyctl audit 7 --json` —— 扫近 7 天访问日志,找走代理但落地国内的域名
61
63
 
62
64
  5. 给我可执行的改进列表
@@ -81,7 +83,8 @@ proxyctl --version --json # schema_version + supported_features 探
81
83
  proxyctl commands --json # 全部命令元数据(机读)
82
84
  proxyctl commands --schema # 上面 JSON 的 JSON Schema
83
85
  proxyctl explain # "我想改 X 去哪?" 速查
84
- proxyctl doctor --json # 5 项健康打分 + healthy 布尔字段
86
+ proxyctl doctor --json # 5 项健康打分 + data.suggestions[] 引导建议
87
+ proxyctl explain suggestion:<id> # v0.5.0+ 每条 suggestion 都有详情 topic
85
88
  PROXYCTL_AGENT=1 proxyctl <cmd> # 一键 --json + 关色 + 非交互
86
89
  ```
87
90
 
@@ -98,6 +101,12 @@ PROXYCTL_AGENT=1 proxyctl <cmd> # 一键 --json + 关色 + 非交互
98
101
  - **CI 层 contract test**(`tests/integration/test_plan_exec_contract.py`)
99
102
  保证 plan ↔ exec **永不漂移**
100
103
  - **错误带可执行 hints + explain topic**:agent 可路由下一步而不需要规则匹配
104
+ - **doctor suggestions(v0.5.0+)**:与 score 解耦的引导维度。21 条规则覆盖
105
+ 订阅 / autostart plist 路径与版本一致性 / Clash API 安全 / GeoIP 数据时效。
106
+ 每条带稳定 `id` + `fingerprint` + `first_seen`,agent 可跨次去重。
107
+ `severity` 三档(info/advisory/warn)永不影响 exit code,按
108
+ `severity desc, id asc` 固定排序。详见 [AGENTS.md](AGENTS.md)
109
+ 的 "Doctor suggestions" 段
101
110
  - **`audit/check` 支持 `--plain` TSV**:4 行 stage/ok/detail,agent 友好的备选
102
111
  - **`proxyctl help <cmd>` 与 `<cmd> --help` 同源**
103
112
  - **非 TTY 自动关色 / 不读 stdin / 不 prompt / 写操作 fcntl.flock 互斥**
@@ -164,7 +173,7 @@ uv tool install proxyctl # uv(推荐)
164
173
  pipx install proxyctl # 或 pipx
165
174
  pip install --user proxyctl # 或 pip
166
175
 
167
- proxyctl --version # → proxyctl v0.4.6
176
+ proxyctl --version # → proxyctl v0.4.7
168
177
  proxyctl --help
169
178
  ```
170
179
 
@@ -1,4 +1,4 @@
1
- .TH PROXYCTL 1 "2026-05" "proxyctl 0.4.6" "User Commands"
1
+ .TH PROXYCTL 1 "2026-05" "proxyctl 0.4.7" "User Commands"
2
2
  .SH NAME
3
3
  proxyctl \- Proxy configuration lifecycle management for macOS / Linux
4
4
  .SH SYNOPSIS
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proxyctl"
3
- version = "0.4.6"
3
+ version = "0.5.0"
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,521 @@
1
+ """proxyctl.autostart — 自动启动 unit 解析与诊断(v0.5.0+)。
2
+
3
+ 哥的需求:"plist 里跑的 mihomo 路径和版本是不是也应该检测"——
4
+ 长期被忽略的 doctor 盲区。一个常见诡异场景:
5
+ PATH 装了 mihomo v1.20,但 plist 还指向 v1.15 的旧 binary
6
+ → 用户跑 `mihomo -v` 看到新版,实际服务跑老版
7
+ → 调试 TUIC/QUIC race 时怎么也对不上号
8
+
9
+ 本模块两层 API:
10
+ inspect_static(backend) 纯文件读取,无 subprocess,<10ms
11
+ inspect_runtime(...) 额外跑 launchctl print / systemctl / binary -v
12
+
13
+ doctor 调用:先 static 推 6 条规则,再 runtime 补 2 条(disabled / flapping)。
14
+
15
+ 接入点:proxyctl.suggest.build_suggestions(autostart=..., path_engine=...)。
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import platform as _platform
22
+ import plistlib
23
+ import re
24
+ import shutil
25
+ import subprocess
26
+ from typing import Any
27
+
28
+ PLACEHOLDER_RE = re.compile(r"yourname|your[_-]name|/Users/yourname", re.IGNORECASE)
29
+
30
+
31
+ def _expand(path: str) -> str:
32
+ """展开 ~ 和 systemd %h 等占位符。"""
33
+ if not path:
34
+ return path
35
+ # systemd %h → $HOME
36
+ if "%h" in path:
37
+ path = path.replace("%h", os.path.expanduser("~"))
38
+ return os.path.expanduser(path)
39
+
40
+
41
+ def _parse_plist(plist_path: str) -> dict[str, Any]:
42
+ """解析 macOS LaunchDaemon plist,返回结构化字段。
43
+
44
+ 失败时返回 {"errors": [...]},调用方决定怎么降级。
45
+ """
46
+ out: dict[str, Any] = {"errors": []}
47
+ try:
48
+ with open(plist_path, "rb") as f:
49
+ data = plistlib.load(f)
50
+ except (OSError, plistlib.InvalidFileException) as e:
51
+ out["errors"].append(f"plist 解析失败: {e}")
52
+ return out
53
+ args = data.get("ProgramArguments") or []
54
+ if isinstance(args, list) and args:
55
+ out["binary"] = args[0]
56
+ # 找 -d 参数后的目录(mihomo 用 `-d <dir>` 指定 config 目录)
57
+ for i, a in enumerate(args):
58
+ if a == "-d" and i + 1 < len(args):
59
+ out["config_dir"] = _expand(args[i + 1])
60
+ break
61
+ # sing-box 用 `-c <file>` 指定 config 文件
62
+ if a == "-c" and i + 1 < len(args):
63
+ out["config_dir"] = os.path.dirname(_expand(args[i + 1]))
64
+ break
65
+ out["label"] = data.get("Label")
66
+ return out
67
+
68
+
69
+ def _parse_systemd_unit(unit_path: str) -> dict[str, Any]:
70
+ """解析 systemd user unit,提取 ExecStart 中的 binary 和 -d 后的目录。
71
+
72
+ 我们的模板形如:
73
+ ExecStart=/bin/sh -c 'exec %h/.local/bin/mihomo -d %h/.config/mihomo >> ...'
74
+ 用正则提取 exec 后的真实命令。
75
+ """
76
+ out: dict[str, Any] = {"errors": []}
77
+ try:
78
+ with open(unit_path, encoding="utf-8") as f:
79
+ text = f.read()
80
+ except OSError as e:
81
+ out["errors"].append(f"unit 读取失败: {e}")
82
+ return out
83
+
84
+ # 提取 ExecStart= 整行
85
+ m = re.search(r"^ExecStart=(.+)$", text, re.M)
86
+ if not m:
87
+ out["errors"].append("ExecStart 字段缺失")
88
+ return out
89
+ exec_line = m.group(1).strip()
90
+
91
+ # 处理 /bin/sh -c 'exec <real-cmd>' 包装
92
+ sh_m = re.search(r"['\"]exec\s+(.+?)['\"]?$", exec_line)
93
+ real_cmd = sh_m.group(1) if sh_m else exec_line
94
+ # 去掉重定向部分
95
+ real_cmd = re.split(r"\s+>>?\s+", real_cmd)[0].strip()
96
+
97
+ tokens = real_cmd.split()
98
+ if not tokens:
99
+ out["errors"].append("ExecStart 解析后为空")
100
+ return out
101
+ out["binary"] = _expand(tokens[0])
102
+ for i, t in enumerate(tokens):
103
+ if t == "-d" and i + 1 < len(tokens):
104
+ out["config_dir"] = _expand(tokens[i + 1])
105
+ break
106
+ if t == "-c" and i + 1 < len(tokens):
107
+ out["config_dir"] = os.path.dirname(_expand(tokens[i + 1]))
108
+ break
109
+ return out
110
+
111
+
112
+ def inspect_static(backend, *, platform: str | None = None) -> dict[str, Any]:
113
+ """读取 autostart unit 的纯文件状态——无 subprocess,<10ms。
114
+
115
+ Args:
116
+ backend: cli.Backend 实例(用 backend.plist / backend.unit)
117
+ platform: "darwin" | "linux",默认自动检测;测试可注入
118
+
119
+ Returns:
120
+ dict 见模块 docstring。errors 字段为非空时调用方按需降级,
121
+ 但所有规则函数都允许在 inspect 失败时跳过(不抛异常)。
122
+ """
123
+ plat = platform or _platform.system().lower()
124
+ if plat.startswith("darwin"):
125
+ plat = "darwin"
126
+ elif plat.startswith("linux"):
127
+ plat = "linux"
128
+
129
+ out: dict[str, Any] = {
130
+ "platform": plat,
131
+ "unit_path": None,
132
+ "unit_exists": False,
133
+ "binary": None,
134
+ "binary_exists": False,
135
+ "config_dir": None,
136
+ "placeholder_unrendered": False,
137
+ "raw_snippet": "",
138
+ "errors": [],
139
+ }
140
+
141
+ if plat == "darwin":
142
+ unit_path = backend.plist
143
+ elif plat == "linux":
144
+ # 模板部署位置:~/.config/systemd/user/<unit>
145
+ unit_path = os.path.join(
146
+ os.path.expanduser("~"), ".config", "systemd", "user", backend.unit)
147
+ else:
148
+ out["errors"].append(f"unsupported platform: {plat}")
149
+ return out
150
+
151
+ out["unit_path"] = unit_path
152
+ if not os.path.isfile(unit_path):
153
+ return out # unit_exists=False,调用方据此推 unit_missing 规则
154
+
155
+ out["unit_exists"] = True
156
+
157
+ # 读 raw 内容用于 placeholder 检测和 debug
158
+ try:
159
+ with open(unit_path, encoding="utf-8", errors="replace") as f:
160
+ raw = f.read()
161
+ out["raw_snippet"] = raw[:500]
162
+ if PLACEHOLDER_RE.search(raw):
163
+ out["placeholder_unrendered"] = True
164
+ except OSError as e:
165
+ out["errors"].append(f"unit 读取失败: {e}")
166
+
167
+ # 解析结构化字段
168
+ if plat == "darwin":
169
+ parsed = _parse_plist(unit_path)
170
+ else:
171
+ parsed = _parse_systemd_unit(unit_path)
172
+
173
+ out["errors"].extend(parsed.get("errors", []))
174
+ binary = parsed.get("binary")
175
+ if binary:
176
+ out["binary"] = binary
177
+ out["binary_exists"] = os.path.isfile(binary)
178
+ if parsed.get("config_dir"):
179
+ out["config_dir"] = parsed["config_dir"]
180
+ return out
181
+
182
+
183
+ def inspect_runtime(static_result: dict[str, Any], backend, *,
184
+ platform: str | None = None,
185
+ timeout: float = 1.5) -> dict[str, Any]:
186
+ """在 inspect_static 基础上跑命令拿 enabled / flapping / autostart_version。
187
+
188
+ 所有 subprocess 都 wrap try-except,失败时字段填 None 不报错。
189
+ timeout 1.5s 总预算(autostart 4 个规则不应拖垮 doctor <500ms 承诺)。
190
+
191
+ Args:
192
+ static_result: inspect_static 返回值
193
+ backend: cli.Backend
194
+ platform: 同 inspect_static
195
+ timeout: 单个子命令超时秒
196
+ """
197
+ plat = static_result.get("platform") or platform or _platform.system().lower()
198
+ out = dict(static_result)
199
+ out["enabled"] = None
200
+ out["last_exit_status"] = None
201
+ out["is_failed"] = None
202
+ out["autostart_version"] = None
203
+
204
+ if not static_result.get("unit_exists"):
205
+ return out
206
+
207
+ # autostart binary -v(拿 autostart 实际跑的版本)
208
+ binary = static_result.get("binary")
209
+ if binary and static_result.get("binary_exists"):
210
+ try:
211
+ r = subprocess.run([binary, "-v"], capture_output=True, text=True,
212
+ timeout=timeout)
213
+ if r.returncode == 0 and r.stdout:
214
+ raw = r.stdout.strip().split("\n", 1)[0]
215
+ out["autostart_version_raw"] = raw
216
+ # 复用 cli.get_engine_version 的解析逻辑(mihomo 输出格式)
217
+ m = re.match(
218
+ r"^\S+\s+\S+\s+(\S+)\s+(\S+\s+\S+)\s+with\s+go(\S+)\s+(\S+)",
219
+ raw)
220
+ if m:
221
+ out["autostart_version"] = m.group(1)
222
+ except (subprocess.TimeoutExpired, OSError):
223
+ pass
224
+
225
+ if plat == "darwin":
226
+ # launchctl print system/<label> —— 不带 sudo 也能拿到部分信息
227
+ try:
228
+ r = subprocess.run(["launchctl", "print", backend.label],
229
+ capture_output=True, text=True, timeout=timeout)
230
+ if r.returncode == 0:
231
+ out["enabled"] = True
232
+ m = re.search(r"last exit code\s*=\s*(-?\d+)", r.stdout)
233
+ if m:
234
+ out["last_exit_status"] = int(m.group(1))
235
+ else:
236
+ out["enabled"] = False
237
+ except (subprocess.TimeoutExpired, OSError):
238
+ pass
239
+ elif plat == "linux":
240
+ try:
241
+ r = subprocess.run(["systemctl", "--user", "is-enabled", backend.unit],
242
+ capture_output=True, text=True, timeout=timeout)
243
+ out["enabled"] = (r.returncode == 0
244
+ and r.stdout.strip() == "enabled")
245
+ except (subprocess.TimeoutExpired, OSError):
246
+ pass
247
+ try:
248
+ r = subprocess.run(["systemctl", "--user", "is-failed", backend.unit],
249
+ capture_output=True, text=True, timeout=timeout)
250
+ # is-failed 返回 0 表示"是失败状态",1 表示"非失败"
251
+ out["is_failed"] = (r.returncode == 0
252
+ and r.stdout.strip() == "failed")
253
+ except (subprocess.TimeoutExpired, OSError):
254
+ pass
255
+
256
+ return out
257
+
258
+
259
+ def compute_sync(inspect_result: dict[str, Any], *,
260
+ target_binary: str,
261
+ target_config_dir: str) -> dict[str, Any]:
262
+ """计算 sync 操作的"差异 + 写入内容"。纯函数,无副作用。
263
+
264
+ Returns:
265
+ {
266
+ "needs_update": bool, # 是否需要写
267
+ "changes": list[str], # 人类可读的变更描述
268
+ "new_content_bytes": bytes, # macOS plist 的新字节内容(plistlib.dumps)
269
+ Linux 用 new_content_text
270
+ "new_content_text": str, # Linux unit 的新文本(保留其他段)
271
+ "platform": str,
272
+ "unit_path": str,
273
+ "errors": list[str],
274
+ }
275
+ """
276
+ out: dict[str, Any] = {
277
+ "needs_update": False,
278
+ "changes": [],
279
+ "new_content_bytes": None,
280
+ "new_content_text": None,
281
+ "platform": inspect_result.get("platform", ""),
282
+ "unit_path": inspect_result.get("unit_path", ""),
283
+ "errors": [],
284
+ }
285
+ if not inspect_result.get("unit_exists"):
286
+ out["errors"].append("unit 文件不存在,无法 sync(先 install)")
287
+ return out
288
+
289
+ current_binary = inspect_result.get("binary")
290
+ current_cfg = inspect_result.get("config_dir")
291
+ plat = inspect_result["platform"]
292
+
293
+ if current_binary != target_binary:
294
+ out["changes"].append(f"binary: {current_binary} → {target_binary}")
295
+ if current_cfg and os.path.normpath(current_cfg) != os.path.normpath(target_config_dir):
296
+ out["changes"].append(f"config_dir: {current_cfg} → {target_config_dir}")
297
+ if not out["changes"]:
298
+ return out # no-op
299
+
300
+ out["needs_update"] = True
301
+ unit_path = inspect_result["unit_path"]
302
+
303
+ if plat == "darwin":
304
+ try:
305
+ with open(unit_path, "rb") as f:
306
+ data = plistlib.load(f)
307
+ except (OSError, plistlib.InvalidFileException) as e:
308
+ out["errors"].append(f"plist 解析失败: {e}")
309
+ out["needs_update"] = False
310
+ return out
311
+ # in-place 改 ProgramArguments:保留 binary 之后的所有参数,
312
+ # 仅替换 binary 和 -d/-c 后面的目录
313
+ args = list(data.get("ProgramArguments") or [])
314
+ if args:
315
+ args[0] = target_binary
316
+ for i, a in enumerate(args):
317
+ if a == "-d" and i + 1 < len(args):
318
+ args[i + 1] = target_config_dir
319
+ break
320
+ if a == "-c" and i + 1 < len(args):
321
+ # sing-box 用 -c 指 config 文件——保留文件名,仅换目录
322
+ fname = os.path.basename(args[i + 1])
323
+ args[i + 1] = os.path.join(target_config_dir, fname)
324
+ break
325
+ data["ProgramArguments"] = args
326
+ out["new_content_bytes"] = plistlib.dumps(data)
327
+ elif plat == "linux":
328
+ try:
329
+ with open(unit_path, encoding="utf-8") as f:
330
+ text = f.read()
331
+ except OSError as e:
332
+ out["errors"].append(f"unit 读取失败: {e}")
333
+ out["needs_update"] = False
334
+ return out
335
+ # 保守做法:仅替换 ExecStart= 整行,保留其他段
336
+ # 我们的模板形如:
337
+ # ExecStart=/bin/sh -c 'exec %h/.local/bin/mihomo -d %h/.config/mihomo >> ...'
338
+ # 用户可能改了 sh 包装或日志重定向;为安全起见,**重新生成完整 ExecStart**
339
+ # 用模板:exec <binary> -d <config_dir> >> <config_dir>/<name>.log 2>> <name>.err
340
+ engine_name = os.path.basename(target_binary).replace("-", "_")
341
+ new_exec = (
342
+ f"ExecStart=/bin/sh -c 'exec {target_binary} -d {target_config_dir} "
343
+ f">> {target_config_dir}/{engine_name}.log "
344
+ f"2>> {target_config_dir}/{engine_name}.err'"
345
+ )
346
+ new_text = re.sub(r"^ExecStart=.*$", new_exec, text, flags=re.M)
347
+ if new_text == text:
348
+ # 没匹配到 ExecStart=:可能 unit 被改得面目全非,不动
349
+ out["errors"].append("unit 中未找到 ExecStart= 行,sync 拒绝执行")
350
+ out["needs_update"] = False
351
+ return out
352
+ out["new_content_text"] = new_text
353
+ return out
354
+
355
+
356
+ def to_suggestions(inspect_result: dict[str, Any] | None, *,
357
+ path_binary: str | None = None,
358
+ path_version: str | None = None,
359
+ expected_config_dir: str | None = None,
360
+ ) -> list[dict[str, Any]]:
361
+ """8 条 autostart 规则推导。
362
+
363
+ 分层短路:unit 不存在时只报 unit_missing;其他规则跳过。
364
+ 所有 evidence 都填实际值,agent 直接消费不必 regex title。
365
+
366
+ Args:
367
+ inspect_result: inspect_static / inspect_runtime 返回;None 表示跳过整组
368
+ path_binary: shutil.which(backend_name) 结果
369
+ path_version: PATH binary 的版本(来自 cli.get_engine_version)
370
+ expected_config_dir: backend 期望的 config_dir
371
+ (= dirname(backend.config_file))
372
+ """
373
+ if not inspect_result:
374
+ return []
375
+
376
+ out: list[dict[str, Any]] = []
377
+ unit_path = inspect_result.get("unit_path") or "?"
378
+ plat = inspect_result.get("platform") or "?"
379
+
380
+ # 1. unit_missing —— 优先级最高,触发后短路其他规则
381
+ if not inspect_result.get("unit_exists"):
382
+ out.append({
383
+ "id": "autostart.unit_missing",
384
+ "severity": "warn",
385
+ "actor": "user",
386
+ "title": "自动启动 unit 未安装",
387
+ "evidence": {"unit_path": unit_path, "platform": plat},
388
+ "inspect_command": (f"ls -la {unit_path}" if unit_path != "?" else None),
389
+ "fix_command": (
390
+ "sudo cp ~/.config/proxyctl/launchdaemons/*.plist /Library/LaunchDaemons/"
391
+ if plat == "darwin" else
392
+ "systemctl --user enable --now mihomo.service"),
393
+ "doc": "suggestion:autostart.unit_missing",
394
+ "since": "0.5.0",
395
+ })
396
+ return out # 短路
397
+
398
+ # 2. placeholder_unrendered —— 模板未渲染
399
+ if inspect_result.get("placeholder_unrendered"):
400
+ out.append({
401
+ "id": "autostart.placeholder_unrendered",
402
+ "severity": "warn",
403
+ "actor": "user",
404
+ "title": "autostart 模板未替换占位符(yourname)",
405
+ "evidence": {"unit_path": unit_path},
406
+ "inspect_command": f"grep -n yourname {unit_path}",
407
+ "doc": "suggestion:autostart.placeholder_unrendered",
408
+ "since": "0.5.0",
409
+ })
410
+
411
+ # 3. binary_missing
412
+ binary = inspect_result.get("binary")
413
+ if binary and not inspect_result.get("binary_exists"):
414
+ out.append({
415
+ "id": "autostart.binary_missing",
416
+ "severity": "warn",
417
+ "actor": "user",
418
+ "title": f"autostart 引用的引擎二进制不存在:{binary}",
419
+ "evidence": {"binary": binary, "unit_path": unit_path},
420
+ "inspect_command": f"ls -la {binary}",
421
+ "doc": "suggestion:autostart.binary_missing",
422
+ "since": "0.5.0",
423
+ })
424
+
425
+ # 4. binary_mismatch —— autostart binary 与 PATH binary 不同路径
426
+ if binary and path_binary and binary != path_binary:
427
+ out.append({
428
+ "id": "autostart.binary_mismatch",
429
+ "severity": "advisory",
430
+ "actor": "user",
431
+ "title": "autostart 与 PATH 引用不同的引擎二进制",
432
+ "evidence": {
433
+ "autostart_binary": binary,
434
+ "path_binary": path_binary,
435
+ },
436
+ "inspect_command": f"diff <({binary} -v) <({path_binary} -v)",
437
+ "doc": "suggestion:autostart.binary_mismatch",
438
+ "since": "0.5.0",
439
+ })
440
+
441
+ # 5. version_mismatch —— 优先于 binary_mismatch 之外的独立信号
442
+ autostart_ver = inspect_result.get("autostart_version")
443
+ if autostart_ver and path_version and autostart_ver != path_version:
444
+ out.append({
445
+ "id": "autostart.version_mismatch",
446
+ "severity": "advisory",
447
+ "actor": "user",
448
+ "title": (f"autostart 引擎版本 v{autostart_ver} "
449
+ f"≠ PATH v{path_version}"),
450
+ "evidence": {
451
+ "autostart_version": autostart_ver,
452
+ "path_version": path_version,
453
+ "autostart_binary": binary,
454
+ "path_binary": path_binary,
455
+ },
456
+ "inspect_command": "proxyctl status --json | jq .data.engine",
457
+ "doc": "suggestion:autostart.version_mismatch",
458
+ "since": "0.5.0",
459
+ })
460
+
461
+ # 6. config_dir_mismatch —— autostart 跑的配置目录 ≠ proxyctl 看到的
462
+ autostart_cfg = inspect_result.get("config_dir")
463
+ if (autostart_cfg and expected_config_dir
464
+ and os.path.normpath(autostart_cfg)
465
+ != os.path.normpath(expected_config_dir)):
466
+ out.append({
467
+ "id": "autostart.config_dir_mismatch",
468
+ "severity": "warn",
469
+ "actor": "user",
470
+ "title": "autostart 配置目录与 proxyctl 不一致",
471
+ "evidence": {
472
+ "autostart_config_dir": autostart_cfg,
473
+ "expected_config_dir": expected_config_dir,
474
+ },
475
+ "inspect_command": (f"diff -r {autostart_cfg} {expected_config_dir} | head"),
476
+ "doc": "suggestion:autostart.config_dir_mismatch",
477
+ "since": "0.5.0",
478
+ })
479
+
480
+ # 7. disabled —— unit 在但未 bootstrap / enable
481
+ enabled = inspect_result.get("enabled")
482
+ if enabled is False:
483
+ out.append({
484
+ "id": "autostart.disabled",
485
+ "severity": "info",
486
+ "actor": "user",
487
+ "title": "自动启动未启用",
488
+ "evidence": {"unit_path": unit_path, "platform": plat},
489
+ "fix_command": ("sudo launchctl bootstrap system " + unit_path
490
+ if plat == "darwin" else
491
+ "systemctl --user enable mihomo.service"),
492
+ "doc": "suggestion:autostart.disabled",
493
+ "since": "0.5.0",
494
+ })
495
+
496
+ # 8. flapping —— 服务最近异常退出
497
+ flapping = False
498
+ flap_evidence: dict[str, Any] = {}
499
+ if plat == "darwin":
500
+ les = inspect_result.get("last_exit_status")
501
+ if les is not None and les != 0:
502
+ flapping = True
503
+ flap_evidence = {"last_exit_status": les}
504
+ elif plat == "linux":
505
+ if inspect_result.get("is_failed") is True:
506
+ flapping = True
507
+ flap_evidence = {"systemd_state": "failed"}
508
+ if flapping:
509
+ out.append({
510
+ "id": "autostart.flapping",
511
+ "severity": "warn",
512
+ "actor": "user",
513
+ "title": "autostart 服务最近异常退出",
514
+ "evidence": flap_evidence,
515
+ "inspect_command": (f"launchctl print {unit_path}" if plat == "darwin"
516
+ else "journalctl --user -u mihomo.service -n 50"),
517
+ "doc": "suggestion:autostart.flapping",
518
+ "since": "0.5.0",
519
+ })
520
+
521
+ return out