proxyctl 0.3.1__tar.gz → 0.3.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 (25) hide show
  1. {proxyctl-0.3.1 → proxyctl-0.3.3}/PKG-INFO +1 -1
  2. {proxyctl-0.3.1 → proxyctl-0.3.3}/man/proxyctl.1 +5 -2
  3. {proxyctl-0.3.1 → proxyctl-0.3.3}/pyproject.toml +1 -1
  4. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/__init__.py +1 -1
  5. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/audit.py +1 -1
  6. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/check.py +1 -1
  7. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/cli.py +5 -2
  8. proxyctl-0.3.3/src/proxyctl/completion.py +381 -0
  9. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/explain.py +161 -20
  10. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/trace.py +4 -4
  11. proxyctl-0.3.1/src/proxyctl/completion.py +0 -223
  12. {proxyctl-0.3.1 → proxyctl-0.3.3}/.gitignore +0 -0
  13. {proxyctl-0.3.1 → proxyctl-0.3.3}/LICENSE +0 -0
  14. {proxyctl-0.3.1 → proxyctl-0.3.3}/README.md +0 -0
  15. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/_io.py +0 -0
  16. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  17. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
  18. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  19. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/core/__init__.py +0 -0
  20. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/core/plugin.py +0 -0
  21. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/engine/__init__.py +0 -0
  22. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/engine/base.py +0 -0
  23. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/engine/mihomo.py +0 -0
  24. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/engine/singbox.py +0 -0
  25. {proxyctl-0.3.1 → proxyctl-0.3.3}/src/proxyctl/status.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyctl
3
- Version: 0.3.1
3
+ Version: 0.3.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
@@ -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.1"
3
+ version = "0.3.3"
4
4
  description = "Proxy configuration lifecycle management for macOS and Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """proxyctl — Proxy configuration lifecycle management."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.2"
@@ -439,7 +439,7 @@ def cmd_audit(audit_days: int, api_base: str, api_secret: str, do_apply: bool):
439
439
  if candidates and not do_apply:
440
440
  print(f"\n执行 {BOLD}proxyctl audit apply{NC} 自动写入双 config")
441
441
 
442
- _audit_emit(as_json, _sys, _real_stdout, collector)
442
+ _audit_emit(as_json, as_plain, _sys, _real_stdout, collector)
443
443
 
444
444
 
445
445
  def _audit_emit(as_json: bool, as_plain: bool, _sys, _real_stdout,
@@ -944,7 +944,7 @@ def cmd_check(engine, api: str, api_secret: str,
944
944
  "detail": "skipped_in_structured_modes"},
945
945
  {"stage": "connectivity",
946
946
  "ok": all(c.get("ok") for c in conn) if conn else False,
947
- "detail": ";".join(f"{c.get('target')}={c.get('http_code') or 'X'}"
947
+ "detail": ";".join(f"{c.get('name')}={'ok' if c.get('ok') else 'X'}"
948
948
  for c in conn)},
949
949
  {"stage": "outbound_ip",
950
950
  "ok": bool(out_ip),
@@ -1230,6 +1230,7 @@ def cmd_dns_unlock(config: dict):
1230
1230
  print(f"{YELLOW}dns-unlock 仅支持 macOS{NC}")
1231
1231
  return
1232
1232
  dns_lock_label = config.get("dns_lock_label", DEFAULTS["dns_lock_label"])
1233
+ dns_lock_plist = f"/Library/LaunchDaemons/{dns_lock_label}.plist"
1233
1234
 
1234
1235
  r = run(["launchctl", "bootout", f"system/{dns_lock_label}"], sudo=True, capture=True)
1235
1236
  if r.returncode == 0:
@@ -1570,6 +1571,8 @@ def cmd_version_print() -> None:
1570
1571
  "agents_md": True,
1571
1572
  "commands_schema": True,
1572
1573
  "doctor_extended": True,
1574
+ "doctor_healthy_field": True, # 0.3.3
1575
+ "agent_guide_sections": True, # 0.3.3
1573
1576
  "log_ndjson_v2": True,
1574
1577
  },
1575
1578
  }
@@ -1723,7 +1726,7 @@ def _plan_mode(backend, target: str) -> list[dict]:
1723
1726
  "reversible": True,
1724
1727
  "side_effects": ["config-write"]},
1725
1728
  {"action": "subprocess",
1726
- "target": f"launchctl kickstart -k system/{backend.label}",
1729
+ "target": f"launchctl kickstart -k {backend.label}",
1727
1730
  "summary": f"重启 launchd 服务以读取新 mode",
1728
1731
  "reversible": True, "requires_sudo": True,
1729
1732
  "side_effects": ["process"]},
@@ -1740,7 +1743,7 @@ def _plan_engine(backend, target: str) -> list[dict]:
1740
1743
  new_plist = f"/Library/LaunchDaemons/<{target}>.plist"
1741
1744
  return [
1742
1745
  {"action": "subprocess",
1743
- "target": f"launchctl bootout system/{backend.label}",
1746
+ "target": f"launchctl bootout {backend.label}",
1744
1747
  "summary": f"停止当前引擎 {backend.name}",
1745
1748
  "reversible": True, "requires_sudo": True,
1746
1749
  "side_effects": ["process"]},
@@ -0,0 +1,381 @@
1
+ """proxyctl completion — shell 补全脚本生成。
2
+
3
+ 支持 bash / zsh / fish。脚本从 COMMANDS_META + TOPICS 动态派生,
4
+ 0.3.x 起补全:
5
+
6
+ - 全局 flag: --help / --version / --json / --no-color / --quiet
7
+ / --dry-run / --plain
8
+ - explain <topic> → topic 列表
9
+ - mode <tun|proxy> → 模式
10
+ - engine <mihomo|singbox> → 引擎
11
+ - config <path|get|set> → 子命令
12
+ - audit <apply|N> → 子命令 / 常用天数
13
+ - commands [--schema] → 0.3.x schema 子模式
14
+ - agent-guide [--section <name> | --list-sections] → 0.3.3 切片
15
+ - help <cmd> → 顶层命令(与 <cmd> --help 同源)
16
+ - completion <bash|zsh|fish> → shell
17
+ - 写命令位置补 --dry-run;audit/check 位置补 --plain
18
+
19
+ 用法:
20
+ eval "$(proxyctl completion zsh)"
21
+ proxyctl completion bash > ~/.proxyctl.bash && echo 'source ~/.proxyctl.bash' >> ~/.bashrc
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import sys
27
+
28
+
29
+ # 0.3.x:写命令支持 --dry-run(与 COMMANDS_META.supports_dry_run 同步)
30
+ _DRY_RUN_CMDS = ("mode", "engine", "fix", "audit", "config",
31
+ "daemon", "claude-proxy", "dns-lock", "dns-unlock")
32
+ # 0.3.x:audit / check 支持 --plain
33
+ _PLAIN_CMDS = ("audit", "check")
34
+ # 0.3.x:commands 支持 --schema 子模式
35
+ _SCHEMA_CMDS = ("commands",)
36
+ # 0.3.3:agent-guide 支持 --section / --list-sections
37
+ _AGENT_GUIDE_FLAGS = ("--section", "--list-sections")
38
+
39
+
40
+ def _command_names() -> list:
41
+ from proxyctl.explain import COMMANDS_META
42
+ return [c["name"] for c in COMMANDS_META]
43
+
44
+
45
+ def _topic_names() -> list:
46
+ from proxyctl.explain import TOPICS
47
+ return sorted(TOPICS.keys())
48
+
49
+
50
+ def _gen_bash() -> str:
51
+ cmds = " ".join(_command_names())
52
+ topics = " ".join(_topic_names())
53
+ dry_run_cmds = " ".join(f'"{c}"' for c in _DRY_RUN_CMDS)
54
+ plain_cmds = " ".join(f'"{c}"' for c in _PLAIN_CMDS)
55
+ return f"""# proxyctl bash completion (0.3.x)
56
+ _proxyctl_complete() {{
57
+ local cur prev cmds topics first
58
+ COMPREPLY=()
59
+ cur="${{COMP_WORDS[COMP_CWORD]}}"
60
+ prev="${{COMP_WORDS[COMP_CWORD-1]}}"
61
+ cmds="{cmds}"
62
+ topics="{topics}"
63
+ first="${{COMP_WORDS[1]}}"
64
+
65
+ # 第二个 token = 子命令
66
+ if [ ${{COMP_CWORD}} -eq 1 ]; then
67
+ COMPREPLY=( $(compgen -W "${{cmds}} help --help --version --json --plain --dry-run --no-color --quiet" -- "${{cur}}") )
68
+ return 0
69
+ fi
70
+
71
+ # help <cmd>(与 <cmd> --help 同源)
72
+ if [ "$first" = "help" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
73
+ COMPREPLY=( $(compgen -W "${{cmds}}" -- "${{cur}}") )
74
+ return 0
75
+ fi
76
+
77
+ # explain <topic>
78
+ if [ "$first" = "explain" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
79
+ COMPREPLY=( $(compgen -W "${{topics}}" -- "${{cur}}") )
80
+ return 0
81
+ fi
82
+
83
+ # mode <tun|proxy>
84
+ if [ "$first" = "mode" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
85
+ COMPREPLY=( $(compgen -W "tun proxy" -- "${{cur}}") )
86
+ return 0
87
+ fi
88
+
89
+ # engine <mihomo|singbox>
90
+ if [ "$first" = "engine" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
91
+ COMPREPLY=( $(compgen -W "mihomo singbox" -- "${{cur}}") )
92
+ return 0
93
+ fi
94
+
95
+ # config path|get|set
96
+ if [ "$first" = "config" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
97
+ COMPREPLY=( $(compgen -W "path get set" -- "${{cur}}") )
98
+ return 0
99
+ fi
100
+
101
+ # audit <apply|days>
102
+ if [ "$first" = "audit" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
103
+ COMPREPLY=( $(compgen -W "apply 1 3 7 30" -- "${{cur}}") )
104
+ return 0
105
+ fi
106
+
107
+ # commands --schema 子模式
108
+ if [ "$first" = "commands" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
109
+ COMPREPLY=( $(compgen -W "--schema --json" -- "${{cur}}") )
110
+ return 0
111
+ fi
112
+
113
+ # agent-guide --section / --list-sections
114
+ if [ "$first" = "agent-guide" ]; then
115
+ if [ "$prev" = "--section" ]; then
116
+ # 这里不知道实际 section list(运行时才有);给个占位
117
+ COMPREPLY=( $(compgen -W "introduction onboarding capabilities envelope locks footgun" -- "${{cur}}") )
118
+ return 0
119
+ fi
120
+ COMPREPLY=( $(compgen -W "--section --list-sections --json" -- "${{cur}}") )
121
+ return 0
122
+ fi
123
+
124
+ # completion shell
125
+ if [ "$first" = "completion" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
126
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "${{cur}}") )
127
+ return 0
128
+ fi
129
+
130
+ # 在写命令的任意位置补 --dry-run
131
+ local dry_run_cmds=({dry_run_cmds})
132
+ for c in "${{dry_run_cmds[@]}}"; do
133
+ if [ "$first" = "$c" ]; then
134
+ COMPREPLY=( $(compgen -W "--dry-run --json --no-color --quiet --help" -- "${{cur}}") )
135
+ return 0
136
+ fi
137
+ done
138
+
139
+ # audit / check 加 --plain
140
+ local plain_cmds=({plain_cmds})
141
+ for c in "${{plain_cmds[@]}}"; do
142
+ if [ "$first" = "$c" ]; then
143
+ COMPREPLY=( $(compgen -W "--plain --json --dry-run --help" -- "${{cur}}") )
144
+ return 0
145
+ fi
146
+ done
147
+
148
+ # log 特有 flag
149
+ if [ "$first" = "log" ]; then
150
+ COMPREPLY=( $(compgen -W "--tail --no-follow --json --help" -- "${{cur}}") )
151
+ return 0
152
+ fi
153
+
154
+ # env 特有 flag
155
+ if [ "$first" = "env" ]; then
156
+ COMPREPLY=( $(compgen -W "--unset" -- "${{cur}}") )
157
+ return 0
158
+ fi
159
+
160
+ # 默认 flag 补全
161
+ COMPREPLY=( $(compgen -W "--help --json --plain --dry-run --no-color --quiet" -- "${{cur}}") )
162
+ }}
163
+ complete -F _proxyctl_complete proxyctl
164
+ """
165
+
166
+
167
+ def _gen_zsh() -> str:
168
+ cmds = "\n".join(f" '{name}:{summary}'" for name, summary in _zsh_pairs())
169
+ topics = " ".join(_topic_names())
170
+ return f"""#compdef proxyctl
171
+ # proxyctl zsh completion (0.3.x)
172
+
173
+ _proxyctl_topics() {{
174
+ local -a topics
175
+ topics=({topics})
176
+ _describe 'topic' topics
177
+ }}
178
+
179
+ _proxyctl_cmd_names() {{
180
+ local -a names
181
+ names=({' '.join(_command_names())})
182
+ _describe 'command' names
183
+ }}
184
+
185
+ _proxyctl() {{
186
+ local context state state_descr line
187
+ typeset -A opt_args
188
+
189
+ local -a cmds
190
+ cmds=(
191
+ {cmds}
192
+ 'help:顶层帮助 / 单命令帮助'
193
+ )
194
+
195
+ _arguments -C \\
196
+ '1: :->cmd' \\
197
+ '*: :->args' \\
198
+ '--help[显示帮助]' \\
199
+ '-h[显示帮助]' \\
200
+ '--version[显示版本(加 --json 暴露 supported_features)]' \\
201
+ '-v[显示版本]' \\
202
+ '--json[结构化 envelope v2 输出]' \\
203
+ '--plain[纯 TSV 输出(audit/check);与 --json 互斥]' \\
204
+ '--dry-run[预演写命令;输出 data.plan]' \\
205
+ '--no-color[关闭 ANSI 颜色]' \\
206
+ '--quiet[安静模式]' \\
207
+ '-q[安静模式]'
208
+
209
+ case $state in
210
+ cmd) _describe 'command' cmds ;;
211
+ args)
212
+ case $words[2] in
213
+ help) _proxyctl_cmd_names ;;
214
+ explain) _proxyctl_topics ;;
215
+ mode) _values 'mode' tun proxy ;;
216
+ engine) _values 'engine' mihomo singbox ;;
217
+ config) _values 'subcommand' path get set ;;
218
+ audit) _values 'days_or_apply' apply 1 3 7 30 ;;
219
+ commands) _values 'flag' --schema --json ;;
220
+ completion) _values 'shell' bash zsh fish ;;
221
+ agent-guide)
222
+ if [[ "$words[$CURRENT-1]" == "--section" ]]; then
223
+ # 运行时取真实 section 名(fallback 常见值)
224
+ _values 'section' \\
225
+ introduction onboarding capabilities exclusions \\
226
+ concept-map paths exit-codes envelope decision-tree \\
227
+ non-interactive locks footgun self-discovery repo
228
+ else
229
+ _values 'flag' --section --list-sections --json
230
+ fi
231
+ ;;
232
+ log) _values 'flag' --tail --no-follow --json ;;
233
+ env) _values 'flag' --unset ;;
234
+ esac
235
+ ;;
236
+ esac
237
+ }}
238
+
239
+ compdef _proxyctl proxyctl
240
+ """
241
+
242
+
243
+ def _zsh_pairs() -> list:
244
+ from proxyctl.explain import COMMANDS_META
245
+ out = []
246
+ for c in COMMANDS_META:
247
+ summary = c["summary"].replace("'", "'\\''")
248
+ out.append((c["name"], summary))
249
+ return out
250
+
251
+
252
+ def _gen_fish() -> str:
253
+ from proxyctl.explain import COMMANDS_META
254
+ lines = ["# proxyctl fish completion (0.3.x)",
255
+ "complete -c proxyctl -l help -d '显示帮助'",
256
+ "complete -c proxyctl -l version -d '显示版本'",
257
+ "complete -c proxyctl -l json -d 'envelope v2 输出'",
258
+ "complete -c proxyctl -l plain -d 'TSV 输出(audit/check)'",
259
+ "complete -c proxyctl -l dry-run -d '预演写命令'",
260
+ "complete -c proxyctl -l no-color -d '关闭 ANSI'",
261
+ "complete -c proxyctl -l quiet -d '安静模式'",
262
+ ""]
263
+ # 全局子命令补全(不带子参数时)
264
+ for c in COMMANDS_META:
265
+ summary = c["summary"].replace("'", "")[:60]
266
+ lines.append(
267
+ f"complete -c proxyctl -n '__fish_use_subcommand' "
268
+ f"-a '{c['name']}' -d '{summary}'"
269
+ )
270
+ # help 也作为顶层子命令补
271
+ lines.append(
272
+ "complete -c proxyctl -n '__fish_use_subcommand' "
273
+ "-a 'help' -d '顶层/单命令帮助'"
274
+ )
275
+ # 子参数 / 子 flag
276
+ topics = " ".join(_topic_names())
277
+ lines.append("")
278
+ lines.append(
279
+ f"complete -c proxyctl -n '__fish_seen_subcommand_from explain' "
280
+ f"-a '{topics}'"
281
+ )
282
+ lines.append(
283
+ "complete -c proxyctl -n '__fish_seen_subcommand_from help' "
284
+ f"-a '{' '.join(_command_names())}'"
285
+ )
286
+ lines.append(
287
+ "complete -c proxyctl -n '__fish_seen_subcommand_from mode' "
288
+ "-a 'tun proxy'"
289
+ )
290
+ lines.append(
291
+ "complete -c proxyctl -n '__fish_seen_subcommand_from engine' "
292
+ "-a 'mihomo singbox'"
293
+ )
294
+ lines.append(
295
+ "complete -c proxyctl -n '__fish_seen_subcommand_from config' "
296
+ "-a 'path get set'"
297
+ )
298
+ lines.append(
299
+ "complete -c proxyctl -n '__fish_seen_subcommand_from completion' "
300
+ "-a 'bash zsh fish'"
301
+ )
302
+ # 0.3.x: 写命令补 --dry-run
303
+ for c in _DRY_RUN_CMDS:
304
+ lines.append(
305
+ f"complete -c proxyctl -n '__fish_seen_subcommand_from {c}' "
306
+ f"-l dry-run -d '预演(不真正执行)'"
307
+ )
308
+ # 0.3.x: audit/check 补 --plain
309
+ for c in _PLAIN_CMDS:
310
+ lines.append(
311
+ f"complete -c proxyctl -n '__fish_seen_subcommand_from {c}' "
312
+ f"-l plain -d 'TSV 输出'"
313
+ )
314
+ # 0.3.x: commands --schema
315
+ lines.append(
316
+ "complete -c proxyctl -n '__fish_seen_subcommand_from commands' "
317
+ "-l schema -d '输出 commands JSON Schema'"
318
+ )
319
+ # 0.3.3: agent-guide --section / --list-sections
320
+ lines.append(
321
+ "complete -c proxyctl -n '__fish_seen_subcommand_from agent-guide' "
322
+ "-l section -d '只输出指定 section'"
323
+ )
324
+ lines.append(
325
+ "complete -c proxyctl -n '__fish_seen_subcommand_from agent-guide' "
326
+ "-l list-sections -d '列出所有 section 名'"
327
+ )
328
+ # log / env 特有 flag
329
+ lines.append(
330
+ "complete -c proxyctl -n '__fish_seen_subcommand_from log' "
331
+ "-l tail -d '只取最后 N 行'"
332
+ )
333
+ lines.append(
334
+ "complete -c proxyctl -n '__fish_seen_subcommand_from log' "
335
+ "-l no-follow -d '一次性读取后返回'"
336
+ )
337
+ lines.append(
338
+ "complete -c proxyctl -n '__fish_seen_subcommand_from env' "
339
+ "-l unset -d '清除代理环境变量'"
340
+ )
341
+ return "\n".join(lines) + "\n"
342
+
343
+
344
+ SHELLS = {"bash": _gen_bash, "zsh": _gen_zsh, "fish": _gen_fish}
345
+
346
+
347
+ def cmd_completion(args: list) -> None:
348
+ """proxyctl completion [bash|zsh|fish]"""
349
+ from proxyctl import _io
350
+ try:
351
+ from proxyctl.explain import GLOBAL_FLAGS_REF as _gf
352
+ as_json = bool(_gf().get("json"))
353
+ except Exception:
354
+ as_json = False
355
+
356
+ if not args:
357
+ _io.fail("缺少 shell 参数",
358
+ hint="proxyctl completion [bash|zsh|fish]",
359
+ doc="agent", code=_io.USAGE, cmd="completion",
360
+ as_json=as_json)
361
+ return
362
+
363
+ shell = args[0]
364
+ if shell not in SHELLS:
365
+ import difflib
366
+ suggest = difflib.get_close_matches(shell, list(SHELLS), n=1, cutoff=0.4)
367
+ hints = [f"支持: {', '.join(SHELLS)}"]
368
+ if suggest:
369
+ hints.insert(0, f"是否想要:{suggest[0]}?")
370
+ _io.fail(f"未知 shell:{shell}",
371
+ hints=hints, doc="agent",
372
+ code=_io.USAGE, cmd="completion",
373
+ as_json=as_json)
374
+ return
375
+
376
+ script = SHELLS[shell]()
377
+ if as_json:
378
+ _io.emit_json(_io.envelope("completion",
379
+ data={"shell": shell, "script": script}))
380
+ return
381
+ print(script)
@@ -493,15 +493,143 @@ def _suggest(name: str, candidates: list) -> list:
493
493
  # ── 主入口 2: agent-guide ─────────────────────────────────────────────────
494
494
 
495
495
  def cmd_agent_guide(args: list, backend, config) -> None:
496
- """proxyctl agent-guide [--json] 给 LLM Agent 一份 ≤200 行的自描述文档。"""
496
+ """proxyctl agent-guide [--section <name>] [--list-sections] [--json]
497
+
498
+ 无参 → 输出完整 markdown(≤300 行)。
499
+ `--list-sections` → 列出所有可用 section 名,agent 可挑一个取。
500
+ `--section <name>` → 只输出该 section 的 markdown(H2 标题下 + 直到下一个 H2)。
501
+ name 是大小写不敏感 + 空格容忍的模糊匹配("envelope fields" / "envelope-fields"
502
+ / "Envelope 字段含义表" 都接受)。
503
+ """
497
504
  as_json = GLOBAL_FLAGS_REF().get("json", False)
505
+
506
+ # 解析 args
507
+ positional, flags = _io.extract_flags(
508
+ args, known={"--section": "value", "--list-sections": "bool"})
509
+
498
510
  text = _build_agent_guide(backend, config)
511
+ sections = _split_agent_guide_sections(text)
512
+ section_names = list(sections.keys()) # 保持原顺序
513
+
514
+ if flags.get("list_sections"):
515
+ if as_json:
516
+ emit_json(envelope("agent-guide", data={
517
+ "available_sections": section_names,
518
+ "section_count": len(section_names),
519
+ }))
520
+ return
521
+ print(f"{BOLD}可用 section({len(section_names)} 个):{NC}")
522
+ for s in section_names:
523
+ print(f" {CYAN}{s}{NC}")
524
+ print(f"\n{DIM}用法:proxyctl agent-guide --section <name>{NC}")
525
+ return
526
+
527
+ requested = flags.get("section")
528
+ if requested:
529
+ match = _match_section(requested, section_names)
530
+ if match is None:
531
+ import difflib
532
+ suggest = difflib.get_close_matches(
533
+ requested, section_names, n=3, cutoff=0.3)
534
+ hints = [f"可用 section: {', '.join(section_names)}"]
535
+ if suggest:
536
+ hints.insert(0, f"是否想要:{suggest[0]}?")
537
+ _io.fail(f"未识别 section:{requested}",
538
+ hints=hints, doc="agent",
539
+ code=_io.USAGE, cmd="agent-guide")
540
+ chunk = sections[match]
541
+ if as_json:
542
+ emit_json(envelope("agent-guide", data={
543
+ "section": match,
544
+ "markdown": chunk,
545
+ "available_sections": section_names,
546
+ }))
547
+ return
548
+ print(chunk)
549
+ return
550
+
499
551
  if as_json:
500
- emit_json(envelope("agent-guide", data={"markdown": text}))
552
+ emit_json(envelope("agent-guide", data={
553
+ "markdown": text,
554
+ "available_sections": section_names,
555
+ }))
501
556
  return
502
557
  print(text)
503
558
 
504
559
 
560
+ def _split_agent_guide_sections(md: str) -> dict[str, str]:
561
+ """把 _build_agent_guide 输出的 markdown 按 H2 标题切分。
562
+
563
+ 返回 OrderedDict 形式(python 3.7+ 普通 dict 即有序):
564
+ {section_name: section_markdown_including_heading}
565
+
566
+ section_name 是 H2 标题去 emoji / 中文标点后的归一化版(lowercase + 空格连字符化),
567
+ 便于 agent 用 ASCII 名稳定引用。
568
+ """
569
+ import re
570
+ lines = md.split("\n")
571
+ sections: dict[str, str] = {}
572
+ cur_name: str | None = None
573
+ cur_lines: list[str] = []
574
+ # 文档开头到第一个 H2 之前的内容 → "introduction"
575
+ intro_started = False
576
+ for ln in lines:
577
+ m = re.match(r"^##\s+(.+?)\s*$", ln)
578
+ if m:
579
+ # 收尾上一个 section
580
+ if cur_name is not None:
581
+ sections[cur_name] = "\n".join(cur_lines).rstrip() + "\n"
582
+ elif cur_lines:
583
+ # 文档开头部分作为 "introduction"
584
+ sections["introduction"] = "\n".join(cur_lines).rstrip() + "\n"
585
+ title = m.group(1).strip()
586
+ cur_name = _normalize_section_name(title)
587
+ cur_lines = [ln]
588
+ else:
589
+ cur_lines.append(ln)
590
+ if cur_name is not None:
591
+ sections[cur_name] = "\n".join(cur_lines).rstrip() + "\n"
592
+ elif cur_lines and not sections:
593
+ sections["introduction"] = "\n".join(cur_lines).rstrip() + "\n"
594
+ return sections
595
+
596
+
597
+ def _normalize_section_name(title: str) -> str:
598
+ """H2 标题 → ASCII 友好的 section name(agent 引用稳定)。
599
+
600
+ 约定:H2 推荐写成 ``## English Name — 中文标题``,取破折号(—/–/-)前
601
+ 的英文段做 ID。这样 agent 引用的是稳定 ASCII slug,人类标题仍然中文友好。
602
+
603
+ 映射规则:取破折号前内容(若有)→ 保留 ASCII 字母数字 → 空格合并 →
604
+ 转连字符 → lowercase。全空回退 'section'。
605
+ """
606
+ import re
607
+ # 1. 取破折号前的部分(U+2014 — / U+2013 – / ASCII - 都接受)。
608
+ # 必须两边都有空格才算分隔符,避免误切 "Non-Interactive" / "Self-Discovery"
609
+ # 这类内部含 ASCII 连字符但无空格的英文短语。
610
+ parts = re.split(r"\s+[—–\-]\s+", title, maxsplit=1)
611
+ head = parts[0] if parts else title
612
+ # 2. 留下 ASCII 字母数字、空格、连字符
613
+ s = re.sub(r"[^A-Za-z0-9\s\-]", " ", head)
614
+ s = re.sub(r"\s+", " ", s).strip()
615
+ s = s.replace(" ", "-").lower()
616
+ # 3. 连续连字符合并 + 去边界
617
+ s = re.sub(r"-+", "-", s).strip("-")
618
+ return s or "section"
619
+
620
+
621
+ def _match_section(requested: str, names: list[str]) -> str | None:
622
+ """模糊匹配:requested 可以是归一化后的 name、原始 H2 标题、或子串。"""
623
+ norm_req = _normalize_section_name(requested)
624
+ if norm_req in names:
625
+ return norm_req
626
+ # 子串匹配(防止 agent 拼 'envelope' 想找 'envelope-fields')
627
+ candidates = [n for n in names if norm_req and norm_req in n]
628
+ if len(candidates) == 1:
629
+ return candidates[0]
630
+ return None
631
+
632
+
505
633
  def _build_agent_guide(backend, config) -> str:
506
634
  port = config.get("proxy_port", 7890)
507
635
  mcfg = backend.config_file
@@ -523,7 +651,7 @@ def _build_agent_guide(backend, config) -> str:
523
651
  > 本文档由 `proxyctl agent-guide` 在运行时输出,含当前 backend/路径/端口。
524
652
  > 仓库视角(开发/贡献协议)见仓库根 `AGENTS.md`。
525
653
 
526
- ## Agent 第一次接入:6 步引导路径
654
+ ## Onboarding — Agent 第一次接入:6 步引导路径
527
655
 
528
656
  ```
529
657
  Step 1 proxyctl agent-guide # 你正在看
@@ -537,7 +665,7 @@ Step 6 proxyctl explain <topic> # 深入概念(topic 见下)
537
665
 
538
666
  调用任何写命令前先加 `--dry-run --json` 看 `data.plan`,确认无误再去掉。
539
667
 
540
- ## 能做什么(按副作用三分类)
668
+ ## Capabilities — 能做什么(按副作用三分类)
541
669
 
542
670
  | 类别 | sudo | 命令 |
543
671
  |---|---|---|
@@ -549,7 +677,7 @@ Step 6 proxyctl explain <topic> # 深入概念(topic 见下)
549
677
 
550
678
  完整精确表见 `proxyctl commands --json` 的 `side_effects` 与 `conditional_side_effects` 字段。
551
679
 
552
- ## 不能做什么(去别处改)
680
+ ## Exclusions — 不能做什么(去别处改)
553
681
 
554
682
  - 添加 / 修改 / 删除分流规则 → 编辑 `{mcfg}` 的 `rules:` 段
555
683
  - 添加节点 / 改订阅 → 编辑 `{mcfg}` 的 `proxies:` / `proxy-providers:` 段
@@ -558,7 +686,7 @@ Step 6 proxyctl explain <topic> # 深入概念(topic 见下)
558
686
  - 安装 mihomo / sing-box → `brew install mihomo` 等
559
687
  - 重启第三方应用 → 浏览器 / Slack / VSCode 需用户自己重启读 system proxy
560
688
 
561
- ## 概念地图("想改 X 去哪"
689
+ ## Concept Map — "想改 X 去哪"
562
690
 
563
691
  | 想改 | 文件 | 段 / 字段 |
564
692
  |---|---|---|
@@ -571,7 +699,7 @@ Step 6 proxyctl explain <topic> # 深入概念(topic 见下)
571
699
  更多:`proxyctl explain <topic>`,topic:
572
700
  {topics_list}
573
701
 
574
- ## 关键路径(当前 backend = {backend.name})
702
+ ## Paths — 关键路径(当前 backend = {backend.name})
575
703
 
576
704
  - proxyctl 配置: `{pcfg}`
577
705
  - 引擎配置: `{mcfg}`
@@ -581,7 +709,7 @@ Step 6 proxyctl explain <topic> # 深入概念(topic 见下)
581
709
  - 用户插件目录: `~/.config/proxyctl/plugins/*.py`
582
710
  - 锁文件目录: `{lock_dir}/.lock.{{system|config|daemon}}`
583
711
 
584
- ## 退出码
712
+ ## Exit Codes — 退出码
585
713
 
586
714
  ```
587
715
  {exit_lines}
@@ -589,7 +717,7 @@ Step 6 proxyctl explain <topic> # 深入概念(topic 见下)
589
717
 
590
718
  旧路径仍返回 1(GENERIC)。新错误路径使用分语义码。SIGINT → 130。
591
719
 
592
- ## JSON envelope(schema v2)
720
+ ## Envelope — JSON envelope(schema v2)
593
721
 
594
722
  ```json
595
723
  {{
@@ -632,7 +760,7 @@ Step 6 proxyctl explain <topic> # 深入概念(topic 见下)
632
760
  NDJSON 流式:`bench --json` 每节点一行 JSON + 末尾 envelope summary;
633
761
  `log --json` 每行一个 `{{file, line}}` 对象(非 envelope)。
634
762
 
635
- ## 故障决策树(给 Agent 自动化)
763
+ ## Decision Tree — 故障决策树(给 Agent 自动化)
636
764
 
637
765
  1. `proxyctl doctor --json` ← 最快,5 项布尔 + score(+ engine/mode/lock_path 信息字段)
638
766
  2. 如果 `engine_up=false` → `proxyctl start`
@@ -643,13 +771,13 @@ NDJSON 流式:`bench --json` 每节点一行 JSON + 末尾 envelope summary;
643
771
  7. 切网后 → `proxyctl recover`(不重启进程)
644
772
  8. 拿不到锁(exit=8 LOCKED)→ 见下方"锁文件位置 + 手动释放"
645
773
 
646
- ## non-interactive 承诺
774
+ ## Non-Interactive — non-interactive 承诺
647
775
 
648
776
  proxyctl 在 stdin 非 TTY 时**不会**调用 `input()` 等阻塞读取。
649
777
  设置 `PROXYCTL_AGENT=1` 等价同时打开 `--json + --no-color + 非交互`,
650
778
  所有命令默认输出 envelope v2,写操作摘要打到 stderr。
651
779
 
652
- ## 锁文件位置 + 手动释放
780
+ ## Locks — 锁文件位置 + 手动释放
653
781
 
654
782
  写操作(mode/engine/fix/audit apply/config set/daemon/dns-lock 等)通过
655
783
  `fcntl.flock` 保护,并发冲突返回 `LOCKED(8)`。
@@ -663,7 +791,7 @@ proxyctl 在 stdin 非 TTY 时**不会**调用 `input()` 等阻塞读取。
663
791
 
664
792
  LOCKED 错误的 `hints` 列表已包含具体锁路径。
665
793
 
666
- ## footgun(提醒)
794
+ ## Footgun — footgun 提醒
667
795
 
668
796
  - `mode tun` 需要 sudo;macOS launchd 已自动 sudo prompt
669
797
  - `audit apply` 会写引擎 `rules:` 段;建议先 `proxyctl audit apply --dry-run`
@@ -672,7 +800,7 @@ LOCKED 错误的 `hints` 列表已包含具体锁路径。
672
800
  - 节点订阅由 mihomo `proxy-providers` 自管;proxyctl 不会刷新订阅
673
801
  - `--quiet` 仅压制非关键 stderr;envelope / error 仍输出
674
802
 
675
- ## 自发现
803
+ ## Self-Discovery — 自发现
676
804
 
677
805
  - `proxyctl --version --json` — schema_version + supported_features 探测
678
806
  - `proxyctl commands --json` — 全部命令元数据
@@ -681,7 +809,7 @@ LOCKED 错误的 `hints` 列表已包含具体锁路径。
681
809
  - `proxyctl help <cmd>` — 单命令完整说明
682
810
  - `proxyctl doctor --json` — 健康基线
683
811
 
684
- ## 仓库视角
812
+ ## Repo — 仓库视角
685
813
 
686
814
  如果你正在编辑 proxyctl 源码(而非调用安装好的 CLI),仓库根的
687
815
  `AGENTS.md` 含开发约定(DISPATCH 注册 / 错误路径 / 锁 / 提交规范)。
@@ -855,10 +983,14 @@ COMMANDS_META: list[dict] = [
855
983
  "needs_sudo": False, "interactive": False, "exit_codes": [0, 2],
856
984
  "examples": ["proxyctl explain", "proxyctl explain rules --json"]},
857
985
  {"name": "agent-guide", "group": "agent",
858
- "summary": "给 LLM Agent 的入门 markdown(含能力边界、退出码、决策树)",
986
+ "summary": "给 LLM Agent 的入门 markdown(含能力边界、退出码、决策树);"
987
+ "支持 --section <name> 按需取小块",
859
988
  "args": [], "supports_json": True, "side_effects": [],
860
- "needs_sudo": False, "interactive": False, "exit_codes": [0],
861
- "examples": ["proxyctl agent-guide", "proxyctl agent-guide --json"]},
989
+ "needs_sudo": False, "interactive": False, "exit_codes": [0, 2],
990
+ "examples": ["proxyctl agent-guide",
991
+ "proxyctl agent-guide --list-sections",
992
+ "proxyctl agent-guide --section envelope --json",
993
+ "proxyctl agent-guide --json"]},
862
994
  {"name": "commands", "group": "agent",
863
995
  "summary": "所有命令的元数据(含 side_effects / needs_sudo / exit_codes);"
864
996
  "--schema 输出 JSON Schema",
@@ -1233,9 +1365,12 @@ def cmd_doctor(args: list, backend, config) -> None:
1233
1365
  held = []
1234
1366
  lock_path_map = _io.lock_paths()
1235
1367
 
1368
+ healthy = (score == len(flags))
1236
1369
  data = {
1237
1370
  **flags,
1238
- "score": score, "max": len(flags), "hint": hint,
1371
+ "score": score, "max": len(flags),
1372
+ "healthy": healthy, # 0.3.3:agent 不必自己算 score == max
1373
+ "hint": hint,
1239
1374
  # informational fields (W15 in 0.3.0):
1240
1375
  "engine": backend.name,
1241
1376
  "mode": mode_str,
@@ -1245,7 +1380,6 @@ def cmd_doctor(args: list, backend, config) -> None:
1245
1380
  "lock_held": held,
1246
1381
  "lock_path": lock_path_map,
1247
1382
  }
1248
- healthy = (score == len(flags))
1249
1383
  code = OK if healthy else ENGINE_DOWN
1250
1384
 
1251
1385
  if as_json:
@@ -1342,5 +1476,12 @@ def GLOBAL_FLAGS_REF() -> dict:
1342
1476
 
1343
1477
 
1344
1478
  def set_global_flags(flags: dict) -> None:
1479
+ """让 explain 模块 + _io 模块的 json 模式保持同步。
1480
+
1481
+ cli.main() 调一次即可;测试若想绕过 cli.main 直接调子命令,也应该用
1482
+ 这个 setter(而不是手工写 _GLOBAL_FLAGS),否则 _io.fail / _io.is_json_mode
1483
+ 会读到旧值,错误路径不输出 envelope。
1484
+ """
1345
1485
  _GLOBAL_FLAGS.clear()
1346
1486
  _GLOBAL_FLAGS.update(flags)
1487
+ _io.set_json_mode(bool(flags.get("json", False)))
@@ -575,11 +575,12 @@ def cmd_trace(raw_input: str, api: str, secret: str, config: dict = None):
575
575
  }
576
576
 
577
577
  t.join()
578
- lines, conn_ok = connectivity_result[0]
578
+ lines, remote_ip = connectivity_result[0]
579
579
  for line in lines:
580
580
  print(line)
581
581
  collector["stages"]["connectivity"] = {
582
- "ok": bool(conn_ok),
582
+ "ok": bool(remote_ip),
583
+ "remote_ip": remote_ip,
583
584
  "lines": [_strip_ansi(line) for line in lines],
584
585
  }
585
586
 
@@ -588,8 +589,7 @@ def cmd_trace(raw_input: str, api: str, secret: str, config: dict = None):
588
589
  if as_json:
589
590
  _sys.stdout = _real_stdout
590
591
  from proxyctl._io import emit_json, envelope, OK
591
- emit_json(envelope("trace", data=collector,
592
- ok=bool(conn_ok), code=OK))
592
+ emit_json(envelope("trace", data=collector, ok=True, code=OK))
593
593
  _sys.exit(0)
594
594
 
595
595
 
@@ -1,223 +0,0 @@
1
- """proxyctl completion — shell 补全脚本生成。
2
-
3
- 支持 bash / zsh / fish。脚本从 COMMANDS_META + TOPICS 动态派生。
4
-
5
- 用法:
6
- eval "$(proxyctl completion zsh)" # 立即生效
7
- proxyctl completion bash > ~/.proxyctl.bash && echo 'source ~/.proxyctl.bash' >> ~/.bashrc
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import sys
13
-
14
-
15
- def _command_names() -> list:
16
- from proxyctl.explain import COMMANDS_META
17
- return [c["name"] for c in COMMANDS_META]
18
-
19
-
20
- def _topic_names() -> list:
21
- from proxyctl.explain import TOPICS
22
- return sorted(TOPICS.keys())
23
-
24
-
25
- def _gen_bash() -> str:
26
- cmds = " ".join(_command_names())
27
- topics = " ".join(_topic_names())
28
- return f"""# proxyctl bash completion
29
- _proxyctl_complete() {{
30
- local cur prev cmds topics
31
- COMPREPLY=()
32
- cur="${{COMP_WORDS[COMP_CWORD]}}"
33
- prev="${{COMP_WORDS[COMP_CWORD-1]}}"
34
- cmds="{cmds}"
35
- topics="{topics}"
36
-
37
- # 第二个 token = 子命令
38
- if [ ${{COMP_CWORD}} -eq 1 ]; then
39
- COMPREPLY=( $(compgen -W "${{cmds}} --help --version --json --no-color --quiet" -- "${{cur}}") )
40
- return 0
41
- fi
42
-
43
- # explain <topic>
44
- if [ "${{COMP_WORDS[1]}}" = "explain" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
45
- COMPREPLY=( $(compgen -W "${{topics}}" -- "${{cur}}") )
46
- return 0
47
- fi
48
-
49
- # mode <tun|proxy>
50
- if [ "${{COMP_WORDS[1]}}" = "mode" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
51
- COMPREPLY=( $(compgen -W "tun proxy" -- "${{cur}}") )
52
- return 0
53
- fi
54
-
55
- # engine <mihomo|singbox>
56
- if [ "${{COMP_WORDS[1]}}" = "engine" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
57
- COMPREPLY=( $(compgen -W "mihomo singbox" -- "${{cur}}") )
58
- return 0
59
- fi
60
-
61
- # config path|get|set
62
- if [ "${{COMP_WORDS[1]}}" = "config" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
63
- COMPREPLY=( $(compgen -W "path get set" -- "${{cur}}") )
64
- return 0
65
- fi
66
-
67
- # audit apply 子参数
68
- if [ "${{COMP_WORDS[1]}}" = "audit" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
69
- COMPREPLY=( $(compgen -W "apply 1 3 7 30" -- "${{cur}}") )
70
- return 0
71
- fi
72
-
73
- # completion shell
74
- if [ "${{COMP_WORDS[1]}}" = "completion" ] && [ ${{COMP_CWORD}} -eq 2 ]; then
75
- COMPREPLY=( $(compgen -W "bash zsh fish" -- "${{cur}}") )
76
- return 0
77
- fi
78
-
79
- # 默认 flag 补全
80
- COMPREPLY=( $(compgen -W "--help --json --no-color --quiet" -- "${{cur}}") )
81
- }}
82
- complete -F _proxyctl_complete proxyctl
83
- """
84
-
85
-
86
- def _gen_zsh() -> str:
87
- cmds = "\n".join(f" '{name}:{summary}'" for name, summary in _zsh_pairs())
88
- topics = " ".join(_topic_names())
89
- return f"""#compdef proxyctl
90
- # proxyctl zsh completion
91
-
92
- _proxyctl_topics() {{
93
- local -a topics
94
- topics=({topics})
95
- _describe 'topic' topics
96
- }}
97
-
98
- _proxyctl() {{
99
- local context state state_descr line
100
- typeset -A opt_args
101
-
102
- local -a cmds
103
- cmds=(
104
- {cmds}
105
- )
106
-
107
- _arguments -C \\
108
- '1: :->cmd' \\
109
- '*: :->args' \\
110
- '--help[显示帮助]' \\
111
- '--version[显示版本]' \\
112
- '--json[结构化 JSON 输出]' \\
113
- '--no-color[关闭 ANSI 颜色]' \\
114
- '--quiet[安静模式]'
115
-
116
- case $state in
117
- cmd) _describe 'command' cmds ;;
118
- args)
119
- case $words[2] in
120
- explain) _proxyctl_topics ;;
121
- mode) _values 'mode' tun proxy ;;
122
- engine) _values 'engine' mihomo singbox ;;
123
- config) _values 'subcommand' path get set ;;
124
- audit) _values 'days_or_apply' apply 1 3 7 30 ;;
125
- completion) _values 'shell' bash zsh fish ;;
126
- esac
127
- ;;
128
- esac
129
- }}
130
-
131
- compdef _proxyctl proxyctl
132
- """
133
-
134
-
135
- def _zsh_pairs() -> list:
136
- from proxyctl.explain import COMMANDS_META
137
- out = []
138
- for c in COMMANDS_META:
139
- summary = c["summary"].replace("'", "'\\''")
140
- out.append((c["name"], summary))
141
- return out
142
-
143
-
144
- def _gen_fish() -> str:
145
- from proxyctl.explain import COMMANDS_META
146
- lines = ["# proxyctl fish completion",
147
- "complete -c proxyctl -l help -d '显示帮助'",
148
- "complete -c proxyctl -l version -d '显示版本'",
149
- "complete -c proxyctl -l json -d '结构化 JSON 输出'",
150
- "complete -c proxyctl -l no-color -d '关闭 ANSI'",
151
- "complete -c proxyctl -l quiet -d '安静模式'",
152
- ""]
153
- # 全局子命令补全(不带子参数时)
154
- for c in COMMANDS_META:
155
- summary = c["summary"].replace("'", "")[:60]
156
- lines.append(
157
- f"complete -c proxyctl -n '__fish_use_subcommand' "
158
- f"-a '{c['name']}' -d '{summary}'"
159
- )
160
- # explain topic
161
- topics = " ".join(_topic_names())
162
- lines.append("")
163
- lines.append(
164
- f"complete -c proxyctl -n '__fish_seen_subcommand_from explain' "
165
- f"-a '{topics}'"
166
- )
167
- lines.append(
168
- "complete -c proxyctl -n '__fish_seen_subcommand_from mode' "
169
- "-a 'tun proxy'"
170
- )
171
- lines.append(
172
- "complete -c proxyctl -n '__fish_seen_subcommand_from engine' "
173
- "-a 'mihomo singbox'"
174
- )
175
- lines.append(
176
- "complete -c proxyctl -n '__fish_seen_subcommand_from config' "
177
- "-a 'path get set'"
178
- )
179
- lines.append(
180
- "complete -c proxyctl -n '__fish_seen_subcommand_from completion' "
181
- "-a 'bash zsh fish'"
182
- )
183
- return "\n".join(lines) + "\n"
184
-
185
-
186
- SHELLS = {"bash": _gen_bash, "zsh": _gen_zsh, "fish": _gen_fish}
187
-
188
-
189
- def cmd_completion(args: list) -> None:
190
- """proxyctl completion [bash|zsh|fish]"""
191
- from proxyctl import _io
192
- try:
193
- from proxyctl.explain import GLOBAL_FLAGS_REF as _gf
194
- as_json = bool(_gf().get("json"))
195
- except Exception:
196
- as_json = False
197
-
198
- if not args:
199
- _io.fail("缺少 shell 参数",
200
- hint="proxyctl completion [bash|zsh|fish]",
201
- doc="agent", code=_io.USAGE, cmd="completion",
202
- as_json=as_json)
203
- return
204
-
205
- shell = args[0]
206
- if shell not in SHELLS:
207
- import difflib
208
- suggest = difflib.get_close_matches(shell, list(SHELLS), n=1, cutoff=0.4)
209
- hints = [f"支持: {', '.join(SHELLS)}"]
210
- if suggest:
211
- hints.insert(0, f"是否想要:{suggest[0]}?")
212
- _io.fail(f"未知 shell:{shell}",
213
- hints=hints, doc="agent",
214
- code=_io.USAGE, cmd="completion",
215
- as_json=as_json)
216
- return
217
-
218
- script = SHELLS[shell]()
219
- if as_json:
220
- _io.emit_json(_io.envelope("completion",
221
- data={"shell": shell, "script": script}))
222
- return
223
- print(script)
File without changes
File without changes
File without changes
File without changes