clash-routes · diff

git:20260531.ef1794a to git:20260831.fe75ff5

97 added, 164 removed. Audit A to A.

---
name: clash-routes
- description: 查看指定进程的代理线路。通过 Mihomo API 查询当前活跃连接,显示进程匹配的规则和代理链路。用于确认某个进程(如 claude、chrome)走的是哪条订阅线路
+ description: >
+ 查看本机进程的 Mihomo 实时代理链。当用户问 Claude、Codex、Gemini、ChatGPT、
+ 浏览器或其他进程走哪个代理时使用。只读。Profile 拓扑和写入使用 clash-doctor,
+ 出口 IP 质量使用 ip-check。
allowed-tools: Bash
metadata:
- argument-hint: "[进程名,默认显示全部]"
+ argument-hint: "[process,默认显示全部]"
---
# Clash 线路查看工具
- 查看当前活跃连接的代理线路信息,确认指定进程走的是哪条订阅/代理链。
-
- 用户传入的参数:$ARGUMENTS
- 如果用户没有传入参数,显示所有活跃连接(按进程分组)。
+ 查看本机当前活跃连接,确认指定进程命中的规则、策略组与真实出口。不接受 SSH 参数;远程机器应先通过 Tailscale SSH 登录,再在目标机器执行同一只读流程。
- ## 执行流程
+ 用户传入的参数:$ARGUMENTS。没有参数时列出所有活跃连接。诊断 Gemini、ChatGPT 或浏览器流量时,先不加过滤获取实际 `metadata.process`,再使用观察到的进程名;不要假定它们属于 `claude`。
- ### 第一步:获取 Mihomo API 凭证
+ ## 获取凭证
- 读取 Clash Verge 配置获取 API secret:
+ 读取 Clash Verge 配置,但不要打印 secret:
```bash
SECRET=$(grep '^secret:' "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/clash-verge.yaml" 2>/dev/null | awk '{print $2}')
[ -z "$SECRET" ] && SECRET=$(grep '^secret:' "$HOME/.config/clash/config.yaml" 2>/dev/null | awk '{print $2}')
- echo "Secret: ${SECRET:-(未找到)}"
+ [ -n "$SECRET" ] && echo "API secret: configured" || echo "API secret: not configured"
```
- ### 第二步:查询连接信息
+ ## 查询与回退
- 优先使用 Unix socket,fallback 到 HTTP:
+ 请求成功才采用该 endpoint。`/tmp` socket 存在但失效时继续尝试 `/var/tmp`,最后尝试配置的 HTTP controller。所有 endpoint 都失败时明确报错,不返回空数据。
```bash
- # Unix socket 方式(Clash Verge Rev)
- SOCKET="/var/tmp/verge/verge-mihomo.sock"
- if [ -S "$SOCKET" ]; then
- CONNECTIONS=$(curl -s --unix-socket "$SOCKET" "http://localhost/connections" -H "Authorization: Bearer $SECRET" 2>/dev/null)
- else
- # HTTP fallback
- CONTROLLER=$(grep '^external-controller:' "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/clash-verge.yaml" 2>/dev/null | awk '{print $2}' | tr -d "'\"")
- [ -z "$CONTROLLER" ] && CONTROLLER="127.0.0.1:9090"
- CONNECTIONS=$(curl -s "http://$CONTROLLER/connections" -H "Authorization: Bearer $SECRET" 2>/dev/null)
- fi
- ```
-
- ### 第三步:解析并展示线路
-
- 用 Python 解析 JSON,按进程分组显示:
-
- ```python
- import json, sys
-
- data = json.loads(sys.stdin.read())
- connections = data.get("connections", [])
-
- # 过滤进程名(如果指定了参数)
- process_filter = "参数中的进程名" # 从 $ARGUMENTS 获取
-
- results = []
- for conn in connections:
- meta = conn.get("metadata", {})
- process = meta.get("process", "unknown")
- host = meta.get("host", "") or meta.get("destinationIP", "")
- port = meta.get("destinationPort", "")
- rule = conn.get("rule", "") + ("/" + conn.get("rulePayload", "") if conn.get("rulePayload") else "")
- chains = conn.get("chains", [])
- network = meta.get("network", "")
-
- if process_filter and process_filter.lower() not in process.lower():
- continue
-
- results.append({
- "process": process,
- "host": f"{host}:{port}" if port else host,
- "rule": rule,
- "chains": " → ".join(reversed(chains)) if chains else "DIRECT",
- "network": network.upper(),
- })
-
- # 按进程分组
- from collections import defaultdict
- grouped = defaultdict(list)
- for r in results:
- grouped[r["process"]].append(r)
+ request_connections() {
+ for socket_path in \
+ /tmp/verge/verge-mihomo.sock \
+ /var/tmp/verge/verge-mihomo.sock
+ do
+ [ -S "$socket_path" ] || continue
+ if curl --fail --silent --show-error \
+ --unix-socket "$socket_path" \
+ "http://localhost/connections" \
+ -H "Authorization: Bearer $SECRET"
+ then
+ return 0
+ fi
+ done
- for process, conns in sorted(grouped.items()):
- print(f"\n{'='*60}")
- print(f"进程: {process} ({len(conns)} 个连接)")
- print(f"{'='*60}")
+ controller=$(grep '^external-controller:' "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/clash-verge.yaml" 2>/dev/null | awk '{print $2}' | tr -d "'\"")
+ [ -n "$controller" ] || controller="127.0.0.1:9090"
+ curl --fail --silent --show-error \
+ "http://$controller/connections" \
+ -H "Authorization: Bearer $SECRET"
+ }
- # 按链路去重统计
- chain_stats = defaultdict(lambda: {"count": 0, "hosts": set()})
- for c in conns:
- key = f"{c['rule']} → {c['chains']}"
- chain_stats[key]["count"] += 1
- chain_stats[key]["hosts"].add(c["host"])
+ if ! DATA=$(request_connections); then
+ echo "无法从 Mihomo Unix socket 或 HTTP controller 获取连接数据" >&2
+ exit 1
+ fi
- for route, info in sorted(chain_stats.items(), key=lambda x: -x[1]["count"]):
- print(f" 线路: {route}")
- print(f" 连接数: {info['count']}")
- hosts = sorted(info["hosts"])
- if len(hosts) <= 5:
- print(f" 目标: {', '.join(hosts)}")
- else:
- print(f" 目标: {', '.join(hosts[:5])} ... (+{len(hosts)-5})")
- print()
+ if ! printf '%s' "$DATA" | python3 -c 'import json, sys; value=json.load(sys.stdin); assert isinstance(value.get("connections"), list)' 2>/dev/null; then
+ echo "Mihomo 返回了无效的 connections JSON" >&2
+ exit 1
+ fi
```
- ### 完整的一键执行命令
+ ## 解析并展示
- 将上面的步骤组合成一个完整的 bash 命令执行:
+ 通过环境变量传递过滤值,避免把用户输入插进 Python 源码:
```bash
- SECRET=$(grep '^secret:' "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/clash-verge.yaml" 2>/dev/null | awk '{print $2}')
- SOCKET="/var/tmp/verge/verge-mihomo.sock"
- FILTER="$ARGUMENTS"
-
- if [ -S "$SOCKET" ]; then
- DATA=$(curl -s --unix-socket "$SOCKET" "http://localhost/connections" -H "Authorization: Bearer $SECRET" 2>/dev/null)
- else
- CONTROLLER=$(grep '^external-controller:' "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/clash-verge.yaml" 2>/dev/null | awk '{print $2}' | tr -d "'\"")
- [ -z "$CONTROLLER" ] && CONTROLLER="127.0.0.1:9090"
- DATA=$(curl -s "http://$CONTROLLER/connections" -H "Authorization: Bearer $SECRET" 2>/dev/null)
- fi
-
- if [ -z "$DATA" ] || echo "$DATA" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; [ $? -ne 0 ]; then
- # 验证 JSON 有效性
- :
- fi
-
- echo "$DATA" | python3 -c "
- import json, sys
+ printf '%s' "$DATA" | FILTER="$ARGUMENTS" python3 -c '
+ import json
+ import os
+ import sys
from collections import defaultdict
- data = json.loads(sys.stdin.read())
- conns = data.get('connections', [])
- filt = '$FILTER'.strip().lower()
-
+ data = json.load(sys.stdin)
+ process_filter = os.environ.get("FILTER", "").strip().lower()
results = []
- for c in conns:
- m = c.get('metadata', {})
- proc = m.get('process', 'unknown')
- if filt and filt not in proc.lower():
+
+ for connection in data.get("connections", []):
+ metadata = connection.get("metadata", {})
+ process = metadata.get("process", "unknown")
+ if process_filter and process_filter not in process.lower():
continue
- host = m.get('host', '') or m.get('destinationIP', '')
- port = m.get('destinationPort', '')
- rule = c.get('rule', '')
- rp = c.get('rulePayload', '')
- if rp:
- rule += '/' + rp
- chains = c.get('chains', [])
- chain_str = ' → '.join(reversed(chains)) if chains else 'DIRECT'
- results.append({'process': proc, 'host': f'{host}:{port}' if port else host, 'rule': rule, 'chains': chain_str})
+ host = metadata.get("host", "") or metadata.get("destinationIP", "")
+ port = metadata.get("destinationPort", "")
+ rule = connection.get("rule", "")
+ payload = connection.get("rulePayload", "")
+ if payload:
+ rule += "/" + payload
+ chains = connection.get("chains", [])
+ chain_text = " → ".join(reversed(chains)) if chains else "DIRECT"
+ results.append({
+ "process": process,
+ "host": f"{host}:{port}" if port else host,
+ "rule": rule,
+ "chain": chain_text,
+ })
grouped = defaultdict(list)
- for r in results:
- grouped[r['process']].append(r)
+ for result in results:
+ grouped[result["process"]].append(result)
if not grouped:
- target = filt if filt else '任何进程'
- print(f'未找到 {target} 的活跃连接')
- sys.exit(0)
-
- total = sum(len(v) for v in grouped.values())
- print(f'共 {total} 个活跃连接,涉及 {len(grouped)} 个进程')
+ target = process_filter or "任何进程"
+ print(f"未找到 {target} 的活跃连接")
+ raise SystemExit(0)
- for proc, pconns in sorted(grouped.items()):
- print(f'\n{\"=\"*60}')
- print(f'进程: {proc} ({len(pconns)} 个连接)')
- print(f'{\"=\"*60}')
- chain_stats = defaultdict(lambda: {'count': 0, 'hosts': set()})
- for c in pconns:
- key = f'{c[\"rule\"]} → {c[\"chains\"]}'
- chain_stats[key]['count'] += 1
- chain_stats[key]['hosts'].add(c['host'])
- for route, info in sorted(chain_stats.items(), key=lambda x: -x[1]['count']):
- print(f' 线路: {route}')
- print(f' 连接数: {info[\"count\"]}')
- hosts = sorted(info['hosts'])
- if len(hosts) <= 5:
- print(f' 目标: {\", \".join(hosts)}')
- else:
- print(f' 目标: {\", \".join(hosts[:5])} ... (+{len(hosts)-5})')
- print()
- "
+ for process, connections in sorted(grouped.items()):
+ print(f"\n进程: {process} ({len(connections)} 个连接)")
+ route_stats = defaultdict(lambda: {"count": 0, "hosts": set()})
+ for connection in connections:
+ key = "{} → {}".format(connection["rule"], connection["chain"])
+ route_stats[key]["count"] += 1
+ route_stats[key]["hosts"].add(connection["host"])
+ for route, info in sorted(route_stats.items(), key=lambda item: -item[1]["count"]):
+ hosts = sorted(info["hosts"])
+ shown = ", ".join(hosts[:5])
+ suffix = f" ... (+{len(hosts) - 5})" if len(hosts) > 5 else ""
+ print(f" 线路: {route}")
+ print(" 连接数: {}".format(info["count"]))
+ print(f" 目标: {shown}{suffix}")
+ '
```
- ## 输出格式
-
- 按进程分组,每个进程显示:
- - **线路**:匹配规则 → 代理链路(如 `ProcessName/claude → 🤖 AI → 🇯🇵 日本 东京`)
- - **连接数**:该线路上的活跃连接数量
- - **目标**:连接的目标域名/IP
-
- ## 使用示例
+ ## 解释结果
- - `/clash-routes` - 查看所有进程的线路
- - `/clash-routes claude` - 只看 claude 进程
- - `/clash-routes chrome` - 只看 chrome 进程
- - `/clash-routes telegram` - 只看 telegram 进程
+ - `chains[-1]` 是命中的策略组,`chains[0]` 是真实出口;展示时反转为“策略组 → 出口”。
+ - Claude 常见进程是 `claude` 或 `Claude Helper`;Codex CLI 常见进程是 `codex`。
+ - Gemini CLI、ChatGPT 桌面端和浏览器的进程名以未过滤连接表为准。
+ - Host 只有裸 IP 时,检查 `sniffer.parse-pure-ip` 和系统 DNS 是否绕过 Clash,不要直接改 AI 策略组。
+ - 当前 profile 不是预期 Hub,或出口仍是原始订阅节点时,报告 drift;本 skill 不修改 YAML。
- ## 注意事项
+ ## 完成条件
- - 只读操作,不修改任何配置
- - 需要 Clash Verge Rev 或 mihomo 正在运行
- - 通过 Unix socket 访问比 HTTP 更可靠(不受 external-controller 配置影响)
- - 用中文输出所有信息
+ - 至少一个 Mihomo endpoint 返回合法 connections JSON。
+ - 输出明确显示进程、规则、策略组和出口,或明确说明目标进程当前没有活跃连接。
+ - 全程只读且没有打印 API secret。