@oneciel-ai/claude-any 0.1.96 → 0.1.98
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.
- package/claude_any.py +740 -18
- package/package.json +1 -1
package/claude_any.py
CHANGED
|
@@ -9,6 +9,7 @@ import importlib.util
|
|
|
9
9
|
import json
|
|
10
10
|
import math
|
|
11
11
|
import os
|
|
12
|
+
import queue
|
|
12
13
|
import re
|
|
13
14
|
import signal
|
|
14
15
|
import shlex
|
|
@@ -59,6 +60,7 @@ WEB_TOOLS_MCP_CONFIG = CONFIG_DIR / "web-tools-mcp.json"
|
|
|
59
60
|
DUCKDUCKGO_MCP_CONFIG = CONFIG_DIR / "duckduckgo-mcp.json"
|
|
60
61
|
CHANNEL_MCP_CONFIG = CONFIG_DIR / "channel-mcp.json"
|
|
61
62
|
CHANNEL_MCP_CURSOR_PATH = CONFIG_DIR / "channel-mcp-cursor.json"
|
|
63
|
+
CHANNEL_PROBE_CACHE_PATH = CONFIG_DIR / "channel-probe-cache.json"
|
|
62
64
|
MCP_PROXY_CONFIG = CONFIG_DIR / "mcp-proxy.json"
|
|
63
65
|
ROUTER_HOST = os.environ.get("CLAUDE_ANY_ROUTER_CLIENT_HOST", "127.0.0.1").strip() or "127.0.0.1"
|
|
64
66
|
ROUTER_PORT = 8799
|
|
@@ -105,7 +107,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
|
|
|
105
107
|
"fakechat": "plugin:fakechat@claude-plugins-official",
|
|
106
108
|
}
|
|
107
109
|
APP_NAME = "Claude Any"
|
|
108
|
-
VERSION = "0.1.
|
|
110
|
+
VERSION = "0.1.98"
|
|
109
111
|
CREDITS = "Credits: One Ciel LLC"
|
|
110
112
|
|
|
111
113
|
LOG_LEVELS = {"SILENT": 0, "ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4, "TRACE": 5}
|
|
@@ -1060,6 +1062,7 @@ UI_TEXT = {
|
|
|
1060
1062
|
"test": "Test compatibility",
|
|
1061
1063
|
"options": "LLM options",
|
|
1062
1064
|
"channel_delivery": "Channel delivery",
|
|
1065
|
+
"channels": "Channels",
|
|
1063
1066
|
"log_level": "Log level",
|
|
1064
1067
|
"presets": "LLM presets",
|
|
1065
1068
|
"context_setup": "Context setup",
|
|
@@ -1082,6 +1085,7 @@ UI_TEXT = {
|
|
|
1082
1085
|
"test": "호환성 테스트",
|
|
1083
1086
|
"options": "LLM 옵션",
|
|
1084
1087
|
"channel_delivery": "채널 전달 방식",
|
|
1088
|
+
"channels": "채널",
|
|
1085
1089
|
"log_level": "로그 레벨",
|
|
1086
1090
|
"presets": "LLM 프리셋",
|
|
1087
1091
|
"context_setup": "컨텍스트 설정",
|
|
@@ -1104,6 +1108,7 @@ UI_TEXT = {
|
|
|
1104
1108
|
"test": "互換性テスト",
|
|
1105
1109
|
"options": "LLMオプション",
|
|
1106
1110
|
"channel_delivery": "チャンネル配信方式",
|
|
1111
|
+
"channels": "チャンネル",
|
|
1107
1112
|
"log_level": "ログレベル",
|
|
1108
1113
|
"presets": "LLMプリセット",
|
|
1109
1114
|
"context_setup": "コンテキスト設定",
|
|
@@ -1126,6 +1131,7 @@ UI_TEXT = {
|
|
|
1126
1131
|
"test": "兼容性测试",
|
|
1127
1132
|
"options": "LLM 选项",
|
|
1128
1133
|
"channel_delivery": "频道投递方式",
|
|
1134
|
+
"channels": "频道",
|
|
1129
1135
|
"log_level": "日志级别",
|
|
1130
1136
|
"presets": "LLM 预设",
|
|
1131
1137
|
"context_setup": "上下文设置",
|
|
@@ -9498,6 +9504,233 @@ def _mcp_server_is_stdio(server: dict[str, Any]) -> bool:
|
|
|
9498
9504
|
return "mcp-proxy" not in args
|
|
9499
9505
|
|
|
9500
9506
|
|
|
9507
|
+
def _channel_probe_initialize_payload() -> bytes:
|
|
9508
|
+
payload = {
|
|
9509
|
+
"jsonrpc": "2.0",
|
|
9510
|
+
"id": 1,
|
|
9511
|
+
"method": "initialize",
|
|
9512
|
+
"params": {
|
|
9513
|
+
"protocolVersion": "2024-11-05",
|
|
9514
|
+
"capabilities": {},
|
|
9515
|
+
"clientInfo": {"name": "claude-any-channel-probe", "version": VERSION},
|
|
9516
|
+
},
|
|
9517
|
+
}
|
|
9518
|
+
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
9519
|
+
|
|
9520
|
+
|
|
9521
|
+
def _channel_probe_parse_framed_responses(buffer: bytes) -> list[dict[str, Any]]:
|
|
9522
|
+
out: list[dict[str, Any]] = []
|
|
9523
|
+
idx = 0
|
|
9524
|
+
while idx < len(buffer):
|
|
9525
|
+
header_end = buffer.find(b"\r\n\r\n", idx)
|
|
9526
|
+
if header_end < 0:
|
|
9527
|
+
return out
|
|
9528
|
+
header = buffer[idx:header_end].decode("ascii", errors="replace")
|
|
9529
|
+
length: int | None = None
|
|
9530
|
+
for line in header.split("\r\n"):
|
|
9531
|
+
if line.lower().startswith("content-length:"):
|
|
9532
|
+
try:
|
|
9533
|
+
length = int(line.split(":", 1)[1].strip())
|
|
9534
|
+
except Exception:
|
|
9535
|
+
return out
|
|
9536
|
+
break
|
|
9537
|
+
if length is None:
|
|
9538
|
+
return out
|
|
9539
|
+
body_start = header_end + 4
|
|
9540
|
+
body_end = body_start + length
|
|
9541
|
+
if len(buffer) < body_end:
|
|
9542
|
+
return out
|
|
9543
|
+
try:
|
|
9544
|
+
msg = json.loads(buffer[body_start:body_end].decode("utf-8", errors="replace"))
|
|
9545
|
+
except Exception:
|
|
9546
|
+
idx = body_end
|
|
9547
|
+
continue
|
|
9548
|
+
if isinstance(msg, dict):
|
|
9549
|
+
out.append(msg)
|
|
9550
|
+
idx = body_end
|
|
9551
|
+
return out
|
|
9552
|
+
|
|
9553
|
+
|
|
9554
|
+
def _channel_probe_parse_jsonl_responses(buffer: bytes) -> list[dict[str, Any]]:
|
|
9555
|
+
out: list[dict[str, Any]] = []
|
|
9556
|
+
for raw_line in buffer.split(b"\n"):
|
|
9557
|
+
line = raw_line.strip()
|
|
9558
|
+
if not line:
|
|
9559
|
+
continue
|
|
9560
|
+
try:
|
|
9561
|
+
msg = json.loads(line.decode("utf-8", errors="replace"))
|
|
9562
|
+
except Exception:
|
|
9563
|
+
continue
|
|
9564
|
+
if isinstance(msg, dict):
|
|
9565
|
+
out.append(msg)
|
|
9566
|
+
return out
|
|
9567
|
+
|
|
9568
|
+
|
|
9569
|
+
def _channel_probe_find_initialize_response(buffer: bytes, framed: bool) -> dict[str, Any] | None:
|
|
9570
|
+
msgs = _channel_probe_parse_framed_responses(buffer) if framed else _channel_probe_parse_jsonl_responses(buffer)
|
|
9571
|
+
for msg in msgs:
|
|
9572
|
+
if msg.get("id") == 1 and "result" in msg:
|
|
9573
|
+
return msg
|
|
9574
|
+
return None
|
|
9575
|
+
|
|
9576
|
+
|
|
9577
|
+
def _channel_probe_capability_present(initialize_response: dict[str, Any]) -> bool:
|
|
9578
|
+
result = initialize_response.get("result")
|
|
9579
|
+
if not isinstance(result, dict):
|
|
9580
|
+
return False
|
|
9581
|
+
capabilities = result.get("capabilities")
|
|
9582
|
+
if not isinstance(capabilities, dict):
|
|
9583
|
+
return False
|
|
9584
|
+
experimental = capabilities.get("experimental")
|
|
9585
|
+
if not isinstance(experimental, dict):
|
|
9586
|
+
return False
|
|
9587
|
+
value = experimental.get("claude/channel")
|
|
9588
|
+
return value is not None and value is not False
|
|
9589
|
+
|
|
9590
|
+
|
|
9591
|
+
def probe_stdio_mcp_for_channel_capability(server_name: str, server: dict[str, Any], timeout: float = 3.0) -> bool:
|
|
9592
|
+
if not _mcp_server_is_stdio(server):
|
|
9593
|
+
return False
|
|
9594
|
+
command = str(server.get("command") or "").strip()
|
|
9595
|
+
args_raw = server.get("args", [])
|
|
9596
|
+
args = [str(item) for item in args_raw] if isinstance(args_raw, list) else []
|
|
9597
|
+
if not command:
|
|
9598
|
+
return False
|
|
9599
|
+
command, args = resolve_mcp_server_process(command, args)
|
|
9600
|
+
env = os.environ.copy()
|
|
9601
|
+
raw_env = server.get("env")
|
|
9602
|
+
if isinstance(raw_env, dict):
|
|
9603
|
+
env.update({str(k): str(v) for k, v in raw_env.items() if str(k)})
|
|
9604
|
+
cwd_value = server.get("cwd") or server.get("workingDirectory")
|
|
9605
|
+
cwd = str(cwd_value) if cwd_value else None
|
|
9606
|
+
framed = _mcp_proxy_stdio_mode(server) != "jsonl"
|
|
9607
|
+
|
|
9608
|
+
proc: subprocess.Popen[bytes] | None = None
|
|
9609
|
+
try:
|
|
9610
|
+
proc = subprocess.Popen(
|
|
9611
|
+
[command, *args],
|
|
9612
|
+
stdin=subprocess.PIPE,
|
|
9613
|
+
stdout=subprocess.PIPE,
|
|
9614
|
+
stderr=subprocess.DEVNULL,
|
|
9615
|
+
cwd=cwd,
|
|
9616
|
+
env=env,
|
|
9617
|
+
bufsize=0,
|
|
9618
|
+
close_fds=True,
|
|
9619
|
+
)
|
|
9620
|
+
except Exception as exc:
|
|
9621
|
+
router_log("DEBUG", f"channel_probe_spawn_failed server={server_name} error={type(exc).__name__}: {exc}")
|
|
9622
|
+
return False
|
|
9623
|
+
|
|
9624
|
+
chunks_queue: queue.Queue[bytes | None] = queue.Queue()
|
|
9625
|
+
|
|
9626
|
+
def _reader() -> None:
|
|
9627
|
+
try:
|
|
9628
|
+
assert proc is not None
|
|
9629
|
+
stdout = proc.stdout
|
|
9630
|
+
if stdout is None:
|
|
9631
|
+
return
|
|
9632
|
+
while True:
|
|
9633
|
+
chunk = stdout.read(4096)
|
|
9634
|
+
if not chunk:
|
|
9635
|
+
break
|
|
9636
|
+
chunks_queue.put(chunk)
|
|
9637
|
+
except Exception:
|
|
9638
|
+
pass
|
|
9639
|
+
finally:
|
|
9640
|
+
chunks_queue.put(None)
|
|
9641
|
+
|
|
9642
|
+
threading.Thread(target=_reader, daemon=True, name=f"channel-probe-stdout-{server_name}").start()
|
|
9643
|
+
|
|
9644
|
+
body = _channel_probe_initialize_payload()
|
|
9645
|
+
if framed:
|
|
9646
|
+
frame = b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n\r\n" + body
|
|
9647
|
+
else:
|
|
9648
|
+
frame = body + b"\n"
|
|
9649
|
+
try:
|
|
9650
|
+
if proc.stdin:
|
|
9651
|
+
proc.stdin.write(frame)
|
|
9652
|
+
proc.stdin.flush()
|
|
9653
|
+
except Exception:
|
|
9654
|
+
pass
|
|
9655
|
+
|
|
9656
|
+
deadline = time.time() + timeout
|
|
9657
|
+
stdout_buf = bytearray()
|
|
9658
|
+
capable = False
|
|
9659
|
+
try:
|
|
9660
|
+
while time.time() < deadline:
|
|
9661
|
+
wait = min(0.2, max(0.001, deadline - time.time()))
|
|
9662
|
+
try:
|
|
9663
|
+
chunk = chunks_queue.get(timeout=wait)
|
|
9664
|
+
except queue.Empty:
|
|
9665
|
+
continue
|
|
9666
|
+
if chunk is None:
|
|
9667
|
+
break
|
|
9668
|
+
stdout_buf.extend(chunk)
|
|
9669
|
+
response = _channel_probe_find_initialize_response(bytes(stdout_buf), framed)
|
|
9670
|
+
if response is not None:
|
|
9671
|
+
capable = _channel_probe_capability_present(response)
|
|
9672
|
+
break
|
|
9673
|
+
finally:
|
|
9674
|
+
try:
|
|
9675
|
+
if proc.stdin:
|
|
9676
|
+
proc.stdin.close()
|
|
9677
|
+
except Exception:
|
|
9678
|
+
pass
|
|
9679
|
+
try:
|
|
9680
|
+
proc.terminate()
|
|
9681
|
+
proc.wait(timeout=1.0)
|
|
9682
|
+
except Exception:
|
|
9683
|
+
try:
|
|
9684
|
+
proc.kill()
|
|
9685
|
+
except Exception:
|
|
9686
|
+
pass
|
|
9687
|
+
|
|
9688
|
+
router_log(
|
|
9689
|
+
"INFO",
|
|
9690
|
+
f"channel_probe_result server={server_name} channel_capable={capable} bytes={len(stdout_buf)}",
|
|
9691
|
+
)
|
|
9692
|
+
return capable
|
|
9693
|
+
|
|
9694
|
+
|
|
9695
|
+
def detect_channel_capable_mcp_servers(
|
|
9696
|
+
mcp_config_paths: Iterable[str],
|
|
9697
|
+
cwd: Path,
|
|
9698
|
+
*,
|
|
9699
|
+
include_router_self: bool = True,
|
|
9700
|
+
timeout_per_server: float = 3.0,
|
|
9701
|
+
) -> list[str]:
|
|
9702
|
+
"""Probe MCP servers declared in given config files; return names that declare experimental['claude/channel']."""
|
|
9703
|
+
capable: list[str] = []
|
|
9704
|
+
seen: set[str] = set()
|
|
9705
|
+
if include_router_self:
|
|
9706
|
+
capable.append("claude-any-router")
|
|
9707
|
+
seen.add("claude-any-router")
|
|
9708
|
+
for path_str in mcp_config_paths:
|
|
9709
|
+
if not path_str:
|
|
9710
|
+
continue
|
|
9711
|
+
path = Path(path_str)
|
|
9712
|
+
if not path.exists():
|
|
9713
|
+
continue
|
|
9714
|
+
for name, server in _read_mcp_servers_from_json(path, cwd):
|
|
9715
|
+
if name in seen:
|
|
9716
|
+
continue
|
|
9717
|
+
seen.add(name)
|
|
9718
|
+
if name == "claude-any-router":
|
|
9719
|
+
continue
|
|
9720
|
+
if not _mcp_server_is_stdio(server):
|
|
9721
|
+
# Non-stdio (sse/http) probing not implemented; skip silently.
|
|
9722
|
+
continue
|
|
9723
|
+
try:
|
|
9724
|
+
if probe_stdio_mcp_for_channel_capability(name, server, timeout=timeout_per_server):
|
|
9725
|
+
capable.append(name)
|
|
9726
|
+
except Exception as exc:
|
|
9727
|
+
router_log(
|
|
9728
|
+
"WARN",
|
|
9729
|
+
f"channel_probe_exception server={name} error={type(exc).__name__}: {exc}",
|
|
9730
|
+
)
|
|
9731
|
+
return capable
|
|
9732
|
+
|
|
9733
|
+
|
|
9501
9734
|
def _mcp_config_passthrough_values(passthrough: list[str]) -> list[str]:
|
|
9502
9735
|
values: list[str] = []
|
|
9503
9736
|
i = 0
|
|
@@ -9593,6 +9826,215 @@ def auto_discovered_mcp_channel_specs(
|
|
|
9593
9826
|
return _dedupe_strings(specs)
|
|
9594
9827
|
|
|
9595
9828
|
|
|
9829
|
+
CHANNEL_PROBE_CACHE_VERSION = 1
|
|
9830
|
+
|
|
9831
|
+
|
|
9832
|
+
def _builtin_router_probe_record() -> dict[str, Any]:
|
|
9833
|
+
return {
|
|
9834
|
+
"name": "claude-any-router",
|
|
9835
|
+
"capable": True,
|
|
9836
|
+
"transport": "sse",
|
|
9837
|
+
"source_path": "<built-in>",
|
|
9838
|
+
"url": f"{ROUTER_BASE}/ca/mcp/sse",
|
|
9839
|
+
"response_bytes": 0,
|
|
9840
|
+
"reason": "built-in",
|
|
9841
|
+
}
|
|
9842
|
+
|
|
9843
|
+
|
|
9844
|
+
def _server_transport_label(server: dict[str, Any]) -> str:
|
|
9845
|
+
if not isinstance(server, dict):
|
|
9846
|
+
return "unknown"
|
|
9847
|
+
declared = str(server.get("type") or "").strip().lower()
|
|
9848
|
+
if declared:
|
|
9849
|
+
return declared
|
|
9850
|
+
if server.get("url"):
|
|
9851
|
+
return "sse"
|
|
9852
|
+
if server.get("command"):
|
|
9853
|
+
return "stdio"
|
|
9854
|
+
return "unknown"
|
|
9855
|
+
|
|
9856
|
+
|
|
9857
|
+
def _probe_mcp_servers_to_records(
|
|
9858
|
+
paths: Iterable[str],
|
|
9859
|
+
cwd: Path,
|
|
9860
|
+
*,
|
|
9861
|
+
include_router_self: bool = True,
|
|
9862
|
+
timeout_per_server: float = 3.0,
|
|
9863
|
+
) -> list[dict[str, Any]]:
|
|
9864
|
+
"""Probe every MCP server referenced from the given config paths and
|
|
9865
|
+
return one record per server (capable and non-capable alike) for cache
|
|
9866
|
+
consumers and menu rendering."""
|
|
9867
|
+
records: list[dict[str, Any]] = []
|
|
9868
|
+
seen: set[str] = set()
|
|
9869
|
+
if include_router_self:
|
|
9870
|
+
records.append(_builtin_router_probe_record())
|
|
9871
|
+
seen.add("claude-any-router")
|
|
9872
|
+
for path_str in paths:
|
|
9873
|
+
if not path_str:
|
|
9874
|
+
continue
|
|
9875
|
+
path = Path(path_str)
|
|
9876
|
+
if not path.exists() or not path.is_file():
|
|
9877
|
+
continue
|
|
9878
|
+
for name, server in _read_mcp_servers_from_json(path, cwd):
|
|
9879
|
+
if name in seen:
|
|
9880
|
+
continue
|
|
9881
|
+
seen.add(name)
|
|
9882
|
+
if name == "claude-any-router":
|
|
9883
|
+
continue
|
|
9884
|
+
transport = _server_transport_label(server)
|
|
9885
|
+
record: dict[str, Any] = {
|
|
9886
|
+
"name": name,
|
|
9887
|
+
"capable": False,
|
|
9888
|
+
"transport": transport,
|
|
9889
|
+
"source_path": str(path),
|
|
9890
|
+
"response_bytes": 0,
|
|
9891
|
+
"reason": "",
|
|
9892
|
+
}
|
|
9893
|
+
if isinstance(server.get("url"), str):
|
|
9894
|
+
record["url"] = str(server.get("url"))
|
|
9895
|
+
if not _mcp_server_is_stdio(server):
|
|
9896
|
+
record["reason"] = "non_stdio_probe_not_implemented"
|
|
9897
|
+
records.append(record)
|
|
9898
|
+
continue
|
|
9899
|
+
try:
|
|
9900
|
+
capable = probe_stdio_mcp_for_channel_capability(name, server, timeout=timeout_per_server)
|
|
9901
|
+
record["capable"] = bool(capable)
|
|
9902
|
+
if not capable:
|
|
9903
|
+
record["reason"] = "no_experimental_claude_channel_or_timeout"
|
|
9904
|
+
except Exception as exc:
|
|
9905
|
+
record["reason"] = f"probe_exception:{type(exc).__name__}"
|
|
9906
|
+
router_log("WARN", f"channel_probe_exception server={name} error={type(exc).__name__}: {exc}")
|
|
9907
|
+
records.append(record)
|
|
9908
|
+
return records
|
|
9909
|
+
|
|
9910
|
+
|
|
9911
|
+
def read_channel_probe_cache() -> dict[str, Any]:
|
|
9912
|
+
if not CHANNEL_PROBE_CACHE_PATH.exists():
|
|
9913
|
+
return {"version": CHANNEL_PROBE_CACHE_VERSION, "probed_at": 0.0, "servers": []}
|
|
9914
|
+
try:
|
|
9915
|
+
data = json.loads(CHANNEL_PROBE_CACHE_PATH.read_text(encoding="utf-8"))
|
|
9916
|
+
except Exception as exc:
|
|
9917
|
+
router_log("WARN", f"channel_probe_cache_read_failed error={type(exc).__name__}: {exc}")
|
|
9918
|
+
return {"version": CHANNEL_PROBE_CACHE_VERSION, "probed_at": 0.0, "servers": []}
|
|
9919
|
+
if not isinstance(data, dict):
|
|
9920
|
+
return {"version": CHANNEL_PROBE_CACHE_VERSION, "probed_at": 0.0, "servers": []}
|
|
9921
|
+
data.setdefault("version", CHANNEL_PROBE_CACHE_VERSION)
|
|
9922
|
+
data.setdefault("probed_at", 0.0)
|
|
9923
|
+
servers = data.get("servers")
|
|
9924
|
+
data["servers"] = [item for item in servers if isinstance(item, dict)] if isinstance(servers, list) else []
|
|
9925
|
+
return data
|
|
9926
|
+
|
|
9927
|
+
|
|
9928
|
+
def _write_channel_probe_cache(cache: dict[str, Any]) -> None:
|
|
9929
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
9930
|
+
tmp = CHANNEL_PROBE_CACHE_PATH.with_suffix(".json.tmp")
|
|
9931
|
+
tmp.write_text(json.dumps(cache, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
9932
|
+
tmp.replace(CHANNEL_PROBE_CACHE_PATH)
|
|
9933
|
+
try:
|
|
9934
|
+
os.chmod(CHANNEL_PROBE_CACHE_PATH, 0o600)
|
|
9935
|
+
except Exception:
|
|
9936
|
+
pass
|
|
9937
|
+
|
|
9938
|
+
|
|
9939
|
+
def refresh_channel_probe_cache(
|
|
9940
|
+
passthrough: list[str] | None = None,
|
|
9941
|
+
cwd: Path | None = None,
|
|
9942
|
+
home: Path | None = None,
|
|
9943
|
+
timeout_per_server: float = 3.0,
|
|
9944
|
+
) -> dict[str, Any]:
|
|
9945
|
+
"""Re-scan known MCP config files, probe each stdio entry for the
|
|
9946
|
+
channel capability, write the result to disk and return it. Only called
|
|
9947
|
+
on explicit user action (menu refresh or CLI subcommand)."""
|
|
9948
|
+
cwd = cwd or Path.cwd()
|
|
9949
|
+
paths = [str(p) for p in claude_mcp_config_paths(passthrough or [], cwd, home)]
|
|
9950
|
+
records = _probe_mcp_servers_to_records(paths, cwd, timeout_per_server=timeout_per_server)
|
|
9951
|
+
cache = {
|
|
9952
|
+
"version": CHANNEL_PROBE_CACHE_VERSION,
|
|
9953
|
+
"probed_at": time.time(),
|
|
9954
|
+
"servers": records,
|
|
9955
|
+
}
|
|
9956
|
+
_write_channel_probe_cache(cache)
|
|
9957
|
+
capable = [r["name"] for r in records if r.get("capable")]
|
|
9958
|
+
router_log(
|
|
9959
|
+
"INFO",
|
|
9960
|
+
f"channel_probe_cache_refreshed total={len(records)} capable={len(capable)} servers={','.join(capable) or '-'}",
|
|
9961
|
+
)
|
|
9962
|
+
return cache
|
|
9963
|
+
|
|
9964
|
+
|
|
9965
|
+
def cached_channel_probe_servers() -> list[dict[str, Any]]:
|
|
9966
|
+
cache = read_channel_probe_cache()
|
|
9967
|
+
servers = cache.get("servers")
|
|
9968
|
+
if isinstance(servers, list):
|
|
9969
|
+
return [item for item in servers if isinstance(item, dict)]
|
|
9970
|
+
return []
|
|
9971
|
+
|
|
9972
|
+
|
|
9973
|
+
def cached_channel_capable_server_names() -> list[str]:
|
|
9974
|
+
"""Capable server names from cache, with claude-any-router always
|
|
9975
|
+
present (because the router's own MCP bridge is built into claude-any)."""
|
|
9976
|
+
names = [str(r.get("name")) for r in cached_channel_probe_servers() if r.get("capable") and r.get("name")]
|
|
9977
|
+
if "claude-any-router" not in names:
|
|
9978
|
+
names.insert(0, "claude-any-router")
|
|
9979
|
+
return _dedupe_strings(names)
|
|
9980
|
+
|
|
9981
|
+
|
|
9982
|
+
def parse_passthrough_channel_specs(passthrough: list[str]) -> list[str]:
|
|
9983
|
+
"""Extract channel specs from passthrough args of either
|
|
9984
|
+
--channels or --dangerously-load-development-channels (and the
|
|
9985
|
+
=VALUE inline form)."""
|
|
9986
|
+
specs: list[str] = []
|
|
9987
|
+
i = 0
|
|
9988
|
+
while i < len(passthrough):
|
|
9989
|
+
arg = passthrough[i]
|
|
9990
|
+
if arg in ("--channels", "--dangerously-load-development-channels"):
|
|
9991
|
+
i += 1
|
|
9992
|
+
while i < len(passthrough) and is_channel_spec_tagged(passthrough[i]):
|
|
9993
|
+
specs.append(passthrough[i])
|
|
9994
|
+
i += 1
|
|
9995
|
+
continue
|
|
9996
|
+
if arg.startswith("--channels=") or arg.startswith("--dangerously-load-development-channels="):
|
|
9997
|
+
value = arg.split("=", 1)[1].strip()
|
|
9998
|
+
if value and is_channel_spec_tagged(value):
|
|
9999
|
+
specs.append(value)
|
|
10000
|
+
i += 1
|
|
10001
|
+
continue
|
|
10002
|
+
i += 1
|
|
10003
|
+
return _dedupe_strings(specs)
|
|
10004
|
+
|
|
10005
|
+
|
|
10006
|
+
def auto_import_passthrough_channels(passthrough: list[str]) -> list[str]:
|
|
10007
|
+
"""Add channel specs that arrived as CLI passthrough to the persisted
|
|
10008
|
+
cfg.channels list, so they show up alongside auto-detected entries in
|
|
10009
|
+
the menu and survive subsequent launches. Returns the newly added specs."""
|
|
10010
|
+
specs = parse_passthrough_channel_specs(passthrough)
|
|
10011
|
+
if not specs:
|
|
10012
|
+
return []
|
|
10013
|
+
cfg = load_config()
|
|
10014
|
+
existing = set(channel_specs(cfg))
|
|
10015
|
+
if all(spec in existing for spec in specs):
|
|
10016
|
+
return []
|
|
10017
|
+
cc = cfg.setdefault("claude_code", {})
|
|
10018
|
+
merged = list(channel_specs(cfg))
|
|
10019
|
+
added: list[str] = []
|
|
10020
|
+
for spec in specs:
|
|
10021
|
+
if spec in existing:
|
|
10022
|
+
continue
|
|
10023
|
+
merged.append(spec)
|
|
10024
|
+
existing.add(spec)
|
|
10025
|
+
added.append(spec)
|
|
10026
|
+
if not added:
|
|
10027
|
+
return []
|
|
10028
|
+
cc["channels"] = merged
|
|
10029
|
+
save_config(cfg)
|
|
10030
|
+
invalidate_config_cache()
|
|
10031
|
+
router_log(
|
|
10032
|
+
"INFO",
|
|
10033
|
+
f"channels_auto_imported_from_passthrough count={len(added)} specs={','.join(added)}",
|
|
10034
|
+
)
|
|
10035
|
+
return added
|
|
10036
|
+
|
|
10037
|
+
|
|
9596
10038
|
def _mcp_sse_servers_from_mapping(mapping: Any) -> list[dict[str, Any]]:
|
|
9597
10039
|
if not isinstance(mapping, dict):
|
|
9598
10040
|
return []
|
|
@@ -9795,6 +10237,29 @@ def cmd_channels(args: argparse.Namespace) -> None:
|
|
|
9795
10237
|
for line in clear_channel_specs():
|
|
9796
10238
|
print(line)
|
|
9797
10239
|
return
|
|
10240
|
+
if head in ("detect", "probe", "refresh"):
|
|
10241
|
+
try:
|
|
10242
|
+
result = refresh_channel_probe_cache()
|
|
10243
|
+
except Exception as exc:
|
|
10244
|
+
raise SystemExit(f"Channel probe failed: {type(exc).__name__}: {exc}")
|
|
10245
|
+
servers = result.get("servers") or []
|
|
10246
|
+
capable = [r for r in servers if r.get("capable")]
|
|
10247
|
+
non_capable = [r for r in servers if not r.get("capable")]
|
|
10248
|
+
probed_at = result.get("probed_at") or 0
|
|
10249
|
+
ts_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(probed_at)) if probed_at else "-"
|
|
10250
|
+
print(f"channel probe complete (cached at {ts_str})")
|
|
10251
|
+
print(f" capable : {len(capable)}")
|
|
10252
|
+
for r in capable:
|
|
10253
|
+
transport = r.get("transport") or "?"
|
|
10254
|
+
source = r.get("source_path") or ""
|
|
10255
|
+
suffix = " built-in" if source == "<built-in>" else f" {source}"
|
|
10256
|
+
print(f" * {r.get('name')} ({transport}){suffix}")
|
|
10257
|
+
print(f" non-capable : {len(non_capable)}")
|
|
10258
|
+
for r in non_capable:
|
|
10259
|
+
transport = r.get("transport") or "?"
|
|
10260
|
+
reason = r.get("reason") or "-"
|
|
10261
|
+
print(f" {r.get('name')} ({transport}) reason={reason}")
|
|
10262
|
+
return
|
|
9798
10263
|
if head in ("delivery", "mode"):
|
|
9799
10264
|
if len(values) < 2:
|
|
9800
10265
|
print(f"channel_delivery: {channel_delivery_mode(cfg)}")
|
|
@@ -13011,9 +13476,10 @@ def main_menu_rows(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any], lan
|
|
|
13011
13476
|
f"5. {ui_text('advisor_model', lang)} [{compact_text(pcfg.get('advisor_model') or 'off', 62)}]",
|
|
13012
13477
|
f"6. {ui_text('options', lang)} [{compact_text(llm_options_status(provider, pcfg), 62)}]",
|
|
13013
13478
|
f"7. {ui_text('channel_delivery', lang)} [{channel_delivery_mode(cfg)}]",
|
|
13014
|
-
f"8. {ui_text('
|
|
13015
|
-
f"9. {ui_text('
|
|
13016
|
-
f"10. {ui_text('
|
|
13479
|
+
f"8. {ui_text('channels', lang)} [{channel_status_text(cfg)}]",
|
|
13480
|
+
f"9. {ui_text('log_level', lang)} [{log_level_status()}]",
|
|
13481
|
+
f"10. {ui_text('test', lang)}",
|
|
13482
|
+
f"11. {ui_text('launch', lang)}",
|
|
13017
13483
|
ui_text("quit", lang),
|
|
13018
13484
|
]
|
|
13019
13485
|
|
|
@@ -13110,13 +13576,71 @@ def advisor_model_panel_rows(provider: str, pcfg: dict[str, Any]) -> tuple[list[
|
|
|
13110
13576
|
|
|
13111
13577
|
|
|
13112
13578
|
def channel_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
13113
|
-
channels = channel_specs(cfg)
|
|
13579
|
+
channels = set(channel_specs(cfg))
|
|
13580
|
+
cache = read_channel_probe_cache()
|
|
13581
|
+
records = cache.get("servers") or []
|
|
13582
|
+
probed_at = cache.get("probed_at") or 0
|
|
13583
|
+
capable_records = [r for r in records if r.get("capable")]
|
|
13584
|
+
non_capable_records = [r for r in records if not r.get("capable")]
|
|
13585
|
+
|
|
13114
13586
|
rows: list[str] = []
|
|
13115
13587
|
values: list[str] = []
|
|
13116
|
-
|
|
13588
|
+
|
|
13589
|
+
rows.append("[Auto-detected channel-capable]")
|
|
13590
|
+
values.append("__heading__")
|
|
13591
|
+
if capable_records:
|
|
13592
|
+
for r in capable_records:
|
|
13593
|
+
name = str(r.get("name") or "")
|
|
13594
|
+
spec = f"server:{name}"
|
|
13595
|
+
mark = "*" if spec in channels else " "
|
|
13596
|
+
transport = str(r.get("transport") or "?")
|
|
13597
|
+
source = str(r.get("source_path") or "")
|
|
13598
|
+
if source == "<built-in>":
|
|
13599
|
+
rows.append(f"{mark} {name:<14} ({transport}, built-in)")
|
|
13600
|
+
else:
|
|
13601
|
+
rows.append(f"{mark} {name:<14} ({transport})")
|
|
13602
|
+
values.append(spec)
|
|
13603
|
+
else:
|
|
13604
|
+
hint = "press Re-probe now" if not probed_at else "none capable"
|
|
13605
|
+
rows.append(f" ({hint})")
|
|
13606
|
+
values.append("__noop__")
|
|
13607
|
+
|
|
13608
|
+
if non_capable_records:
|
|
13609
|
+
rows.append("[Detected but not channel-capable]")
|
|
13610
|
+
values.append("__heading__")
|
|
13611
|
+
for r in non_capable_records:
|
|
13612
|
+
name = str(r.get("name") or "")
|
|
13613
|
+
transport = str(r.get("transport") or "?")
|
|
13614
|
+
reason = str(r.get("reason") or "-")
|
|
13615
|
+
rows.append(f" {name:<14} ({transport}) {reason}")
|
|
13616
|
+
values.append("__noop__")
|
|
13617
|
+
|
|
13618
|
+
rows.append("[Official plugins]")
|
|
13619
|
+
values.append("__heading__")
|
|
13620
|
+
for plugin_name, spec in OFFICIAL_CHANNEL_PLUGINS.items():
|
|
13117
13621
|
mark = "*" if spec in channels else " "
|
|
13118
|
-
rows.append(f"{mark} {
|
|
13622
|
+
rows.append(f"{mark} {plugin_name:<10} {spec}")
|
|
13119
13623
|
values.append(spec)
|
|
13624
|
+
|
|
13625
|
+
covered: set[str] = set(OFFICIAL_CHANNEL_PLUGINS.values())
|
|
13626
|
+
for r in capable_records:
|
|
13627
|
+
covered.add(f"server:{r.get('name')}")
|
|
13628
|
+
custom_specs = [spec for spec in channel_specs(cfg) if spec not in covered]
|
|
13629
|
+
if custom_specs:
|
|
13630
|
+
rows.append("[Configured custom / imported]")
|
|
13631
|
+
values.append("__heading__")
|
|
13632
|
+
for spec in custom_specs:
|
|
13633
|
+
rows.append(f"* {spec}")
|
|
13634
|
+
values.append(spec)
|
|
13635
|
+
|
|
13636
|
+
rows.append("[Actions]")
|
|
13637
|
+
values.append("__heading__")
|
|
13638
|
+
if probed_at:
|
|
13639
|
+
ts_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(probed_at))
|
|
13640
|
+
rows.append(f"Re-probe now (last: {ts_str})")
|
|
13641
|
+
else:
|
|
13642
|
+
rows.append("Re-probe now (no cache yet)")
|
|
13643
|
+
values.append("__reprobe__")
|
|
13120
13644
|
rows.append("+ Add custom channel...")
|
|
13121
13645
|
values.append("__add_custom__")
|
|
13122
13646
|
if channels:
|
|
@@ -13129,6 +13653,25 @@ def channel_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
|
13129
13653
|
return rows, values
|
|
13130
13654
|
|
|
13131
13655
|
|
|
13656
|
+
def _channel_panel_first_selectable(values: list[str]) -> int:
|
|
13657
|
+
for idx, value in enumerate(values):
|
|
13658
|
+
if value not in ("__heading__", "__noop__"):
|
|
13659
|
+
return idx
|
|
13660
|
+
return 0
|
|
13661
|
+
|
|
13662
|
+
|
|
13663
|
+
def _channel_panel_step(values: list[str], start: int, delta: int) -> int:
|
|
13664
|
+
if not values:
|
|
13665
|
+
return 0
|
|
13666
|
+
n = len(values)
|
|
13667
|
+
idx = start
|
|
13668
|
+
for _ in range(n):
|
|
13669
|
+
idx = (idx + delta) % n
|
|
13670
|
+
if values[idx] not in ("__heading__", "__noop__"):
|
|
13671
|
+
return idx
|
|
13672
|
+
return start
|
|
13673
|
+
|
|
13674
|
+
|
|
13132
13675
|
def channel_delivery_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
13133
13676
|
current = channel_delivery_mode(cfg)
|
|
13134
13677
|
rows = [
|
|
@@ -13440,9 +13983,10 @@ def portable_language_menu() -> int:
|
|
|
13440
13983
|
return 0
|
|
13441
13984
|
|
|
13442
13985
|
|
|
13443
|
-
def portable_prelaunch_menu() -> int:
|
|
13986
|
+
def portable_prelaunch_menu(passthrough: list[str] | None = None) -> int:
|
|
13987
|
+
passthrough = list(passthrough or [])
|
|
13444
13988
|
enable_ansi()
|
|
13445
|
-
main_idx =
|
|
13989
|
+
main_idx = 11 if settings_ready_except_api_key() else 0
|
|
13446
13990
|
panel: str | None = None
|
|
13447
13991
|
panel_idx = 0
|
|
13448
13992
|
panel_rows: list[str] = []
|
|
@@ -13490,6 +14034,8 @@ def portable_prelaunch_menu() -> int:
|
|
|
13490
14034
|
panel_rows, panel_values = log_level_panel_rows(cfg)
|
|
13491
14035
|
elif name == "channels":
|
|
13492
14036
|
panel_rows, panel_values = channel_panel_rows(cfg)
|
|
14037
|
+
if panel_values:
|
|
14038
|
+
panel_idx = _channel_panel_first_selectable(panel_values)
|
|
13493
14039
|
elif name == "context":
|
|
13494
14040
|
panel_rows, panel_values = context_setup_panel_rows(provider, pcfg, cfg.get("language", "en"))
|
|
13495
14041
|
elif name == "preset":
|
|
@@ -13557,11 +14103,17 @@ def portable_prelaunch_menu() -> int:
|
|
|
13557
14103
|
if panel:
|
|
13558
14104
|
panel_name = panel
|
|
13559
14105
|
if key in ("up", "k"):
|
|
13560
|
-
|
|
14106
|
+
if panel == "channels":
|
|
14107
|
+
panel_idx = _channel_panel_step(panel_values, panel_idx, -1)
|
|
14108
|
+
else:
|
|
14109
|
+
panel_idx = (panel_idx - 1) % max(1, len(panel_rows))
|
|
13561
14110
|
panel_last_idx[panel_name] = panel_idx
|
|
13562
14111
|
continue
|
|
13563
14112
|
if key in ("down", "j"):
|
|
13564
|
-
|
|
14113
|
+
if panel == "channels":
|
|
14114
|
+
panel_idx = _channel_panel_step(panel_values, panel_idx, 1)
|
|
14115
|
+
else:
|
|
14116
|
+
panel_idx = (panel_idx + 1) % max(1, len(panel_rows))
|
|
13565
14117
|
panel_last_idx[panel_name] = panel_idx
|
|
13566
14118
|
continue
|
|
13567
14119
|
if key in ("esc", "left", "q"):
|
|
@@ -13666,7 +14218,7 @@ def portable_prelaunch_menu() -> int:
|
|
|
13666
14218
|
messages = lines[-8:] if lines else ["Test produced no output."]
|
|
13667
14219
|
panel_rows, panel_values = ["Run compatibility test again", "Back"], ["run", "back"]
|
|
13668
14220
|
refresh_checks()
|
|
13669
|
-
main_idx =
|
|
14221
|
+
main_idx = 11 if "Compatibility: OK" in out else 4
|
|
13670
14222
|
elif panel == "log-level":
|
|
13671
14223
|
if value == "back":
|
|
13672
14224
|
close_panel()
|
|
@@ -13688,23 +14240,43 @@ def portable_prelaunch_menu() -> int:
|
|
|
13688
14240
|
elif panel == "channels":
|
|
13689
14241
|
if value == "back":
|
|
13690
14242
|
close_panel()
|
|
14243
|
+
elif value in ("__heading__", "__noop__"):
|
|
14244
|
+
continue
|
|
14245
|
+
elif value == "__reprobe__":
|
|
14246
|
+
panel_rows, panel_values = ["Re-probing MCP channel capability..."], []
|
|
14247
|
+
first_render = render_prelaunch_screen(main_idx, panel, 0, panel_rows, checks, messages, first_render)
|
|
14248
|
+
try:
|
|
14249
|
+
result = refresh_channel_probe_cache(passthrough)
|
|
14250
|
+
capable = [r for r in result.get("servers") or [] if r.get("capable")]
|
|
14251
|
+
messages = [f"Probe complete: {len(capable)} channel-capable server(s)."]
|
|
14252
|
+
except Exception as exc:
|
|
14253
|
+
messages = [f"Re-probe failed: {type(exc).__name__}: {exc}"]
|
|
14254
|
+
cfg = load_config()
|
|
14255
|
+
panel_rows, panel_values = channel_panel_rows(cfg)
|
|
14256
|
+
if panel_values:
|
|
14257
|
+
panel_idx = _channel_panel_first_selectable(panel_values)
|
|
13691
14258
|
elif value == "__add_custom__":
|
|
13692
14259
|
spec = prompt_menu_value("Channel spec (for example plugin:ainet@local or server:ainet)", restore_tty=restore_line_mode, raw_tty=restore_raw_mode)
|
|
13693
14260
|
if spec:
|
|
13694
14261
|
messages = add_channel_spec(spec)
|
|
13695
14262
|
cfg = load_config()
|
|
13696
14263
|
panel_rows, panel_values = channel_panel_rows(cfg)
|
|
14264
|
+
if panel_values:
|
|
14265
|
+
panel_idx = _channel_panel_first_selectable(panel_values)
|
|
13697
14266
|
elif value == "__remove__":
|
|
13698
14267
|
spec = prompt_menu_value("Channel spec to remove", "", restore_tty=restore_line_mode, raw_tty=restore_raw_mode)
|
|
13699
14268
|
if spec:
|
|
13700
14269
|
messages = remove_channel_spec(spec)
|
|
13701
14270
|
cfg = load_config()
|
|
13702
14271
|
panel_rows, panel_values = channel_panel_rows(cfg)
|
|
14272
|
+
if panel_values:
|
|
14273
|
+
panel_idx = _channel_panel_first_selectable(panel_values)
|
|
13703
14274
|
elif value == "__clear__":
|
|
13704
14275
|
messages = clear_channel_specs()
|
|
13705
14276
|
cfg = load_config()
|
|
13706
14277
|
panel_rows, panel_values = channel_panel_rows(cfg)
|
|
13707
|
-
|
|
14278
|
+
if panel_values:
|
|
14279
|
+
panel_idx = _channel_panel_first_selectable(panel_values)
|
|
13708
14280
|
elif value:
|
|
13709
14281
|
if value in channel_specs(cfg):
|
|
13710
14282
|
messages = remove_channel_spec(value)
|
|
@@ -13821,7 +14393,7 @@ def portable_prelaunch_menu() -> int:
|
|
|
13821
14393
|
elif key in ("esc", "q"):
|
|
13822
14394
|
return 10
|
|
13823
14395
|
elif key == "enter":
|
|
13824
|
-
actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "channel-delivery", "log-level", "test", "launch", "quit"]
|
|
14396
|
+
actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "channel-delivery", "channels", "log-level", "test", "launch", "quit"]
|
|
13825
14397
|
action = actions[main_idx]
|
|
13826
14398
|
if action == "launch":
|
|
13827
14399
|
blockers = launch_readiness_errors()
|
|
@@ -13868,14 +14440,16 @@ def run_prelaunch_menu(passthrough: list[str], skip_menu: bool = False, force_me
|
|
|
13868
14440
|
rc = run_external_menu("claude-any-menu")
|
|
13869
14441
|
if rc is not None:
|
|
13870
14442
|
return rc
|
|
13871
|
-
return portable_prelaunch_menu()
|
|
14443
|
+
return portable_prelaunch_menu(passthrough)
|
|
13872
14444
|
|
|
13873
14445
|
|
|
13874
14446
|
def start_router_if_needed() -> None:
|
|
13875
14447
|
if router_up():
|
|
14448
|
+
router_log("INFO", f"router_check_state running=True spawn=False base={ROUTER_BASE}")
|
|
13876
14449
|
return
|
|
13877
14450
|
stop_router_processes(quiet=True)
|
|
13878
14451
|
if router_up():
|
|
14452
|
+
router_log("INFO", f"router_check_state running=True spawn=False after_stop=True base={ROUTER_BASE}")
|
|
13879
14453
|
return
|
|
13880
14454
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
13881
14455
|
cmd = [sys.executable, str(Path(__file__).resolve()), "serve"]
|
|
@@ -13886,11 +14460,13 @@ def start_router_if_needed() -> None:
|
|
|
13886
14460
|
kwargs["creationflags"] = flags
|
|
13887
14461
|
else:
|
|
13888
14462
|
kwargs["start_new_session"] = True
|
|
14463
|
+
router_log("INFO", f"router_check_state running=False spawn=True base={ROUTER_BASE}")
|
|
13889
14464
|
with open(LOG_PATH, "ab", buffering=0) as log:
|
|
13890
14465
|
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=log, stderr=log, **kwargs)
|
|
13891
14466
|
deadline = time.time() + 30
|
|
13892
14467
|
while time.time() < deadline:
|
|
13893
14468
|
if router_up():
|
|
14469
|
+
router_log("INFO", f"router_spawned running=True base={ROUTER_BASE} elapsed={time.time()-(deadline-30):.1f}s")
|
|
13894
14470
|
return
|
|
13895
14471
|
time.sleep(0.5)
|
|
13896
14472
|
raise RuntimeError(f"claude-any router did not start. See {LOG_PATH}")
|
|
@@ -13939,8 +14515,21 @@ def native_channel_passthrough_requested(passthrough: list[str]) -> bool:
|
|
|
13939
14515
|
return has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels")
|
|
13940
14516
|
|
|
13941
14517
|
|
|
13942
|
-
def claude_channel_args(
|
|
13943
|
-
|
|
14518
|
+
def claude_channel_args(
|
|
14519
|
+
cfg: dict[str, Any],
|
|
14520
|
+
passthrough: list[str],
|
|
14521
|
+
extra_specs: list[str] | None = None,
|
|
14522
|
+
*,
|
|
14523
|
+
native_channel_bridge: bool = False,
|
|
14524
|
+
) -> list[str]:
|
|
14525
|
+
if not native_channel_bridge:
|
|
14526
|
+
return []
|
|
14527
|
+
if native_channel_passthrough_requested(passthrough):
|
|
14528
|
+
return []
|
|
14529
|
+
specs = list(channel_specs_for_launch(cfg, passthrough, extra_specs))
|
|
14530
|
+
if not specs:
|
|
14531
|
+
return []
|
|
14532
|
+
return ["--dangerously-load-development-channels", *specs]
|
|
13944
14533
|
|
|
13945
14534
|
|
|
13946
14535
|
def claude_channels_requested(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> bool:
|
|
@@ -15103,6 +15692,7 @@ def launch_claude(
|
|
|
15103
15692
|
update_check = False
|
|
15104
15693
|
self_update_check = False
|
|
15105
15694
|
run_claude_any_update_check(enabled=self_update_check)
|
|
15695
|
+
auto_import_passthrough_channels(passthrough)
|
|
15106
15696
|
rc = run_prelaunch_menu(passthrough, skip_menu=skip_menu, force_menu=force_menu)
|
|
15107
15697
|
if rc == 10:
|
|
15108
15698
|
return 0
|
|
@@ -15180,7 +15770,26 @@ def launch_claude(
|
|
|
15180
15770
|
extra_args.extend(["--mcp-config", *mcp_config_paths])
|
|
15181
15771
|
if should_append_compat_prompt(provider, cfg) and not has_passthrough_option(launch_passthrough, "--system-prompt"):
|
|
15182
15772
|
extra_args.extend(["--append-system-prompt", NON_ANTHROPIC_COMPAT_PROMPT])
|
|
15183
|
-
|
|
15773
|
+
detected_channel_specs: list[str] = []
|
|
15774
|
+
if native_channel_bridge:
|
|
15775
|
+
try:
|
|
15776
|
+
capable_names = cached_channel_capable_server_names()
|
|
15777
|
+
detected_channel_specs = [f"server:{name}" for name in capable_names]
|
|
15778
|
+
cache_age = read_channel_probe_cache().get("probed_at") or 0
|
|
15779
|
+
router_log(
|
|
15780
|
+
"INFO",
|
|
15781
|
+
f"channel_probe_loaded source=cache cache_age_ts={int(cache_age)} count={len(capable_names)} servers={','.join(capable_names) or '-'}",
|
|
15782
|
+
)
|
|
15783
|
+
except Exception as exc:
|
|
15784
|
+
router_log("WARN", f"channel_probe_cache_load_failed error={type(exc).__name__}: {exc}")
|
|
15785
|
+
extra_args.extend(
|
|
15786
|
+
claude_channel_args(
|
|
15787
|
+
cfg,
|
|
15788
|
+
launch_passthrough,
|
|
15789
|
+
extra_specs=detected_channel_specs,
|
|
15790
|
+
native_channel_bridge=native_channel_bridge,
|
|
15791
|
+
)
|
|
15792
|
+
)
|
|
15184
15793
|
cmd = [
|
|
15185
15794
|
claude,
|
|
15186
15795
|
"--dangerously-skip-permissions",
|
|
@@ -15190,11 +15799,124 @@ def launch_claude(
|
|
|
15190
15799
|
cmd.extend(["--model", model])
|
|
15191
15800
|
cmd.extend(extra_args)
|
|
15192
15801
|
cmd.extend(claude_passthrough)
|
|
15802
|
+
_log_claude_command_for_diagnostics(cmd, env)
|
|
15803
|
+
capture_stderr = env_bool(os.environ.get("CLAUDE_ANY_CAPTURE_CC_STDERR"), False)
|
|
15193
15804
|
if stdin_channel_proxy:
|
|
15194
15805
|
return subprocess_call_with_channel_wake_proxy(cmd, env)
|
|
15806
|
+
if capture_stderr:
|
|
15807
|
+
return _subprocess_call_capturing_stderr(cmd, env)
|
|
15195
15808
|
return subprocess.call(cmd, env=env)
|
|
15196
15809
|
|
|
15197
15810
|
|
|
15811
|
+
CLAUDE_CODE_STDERR_LOG = CONFIG_DIR / "claude-code-stderr.log"
|
|
15812
|
+
|
|
15813
|
+
|
|
15814
|
+
def _log_claude_command_for_diagnostics(cmd: list[str], env: dict[str, str]) -> None:
|
|
15815
|
+
try:
|
|
15816
|
+
mcp_idx = cmd.index("--mcp-config") if "--mcp-config" in cmd else -1
|
|
15817
|
+
mcp_value = cmd[mcp_idx + 1] if 0 <= mcp_idx < len(cmd) - 1 else "-"
|
|
15818
|
+
except Exception:
|
|
15819
|
+
mcp_value = "-"
|
|
15820
|
+
channel_specs_in_cmd: list[str] = []
|
|
15821
|
+
if "--dangerously-load-development-channels" in cmd:
|
|
15822
|
+
start = cmd.index("--dangerously-load-development-channels") + 1
|
|
15823
|
+
for arg in cmd[start:]:
|
|
15824
|
+
if arg.startswith("--"):
|
|
15825
|
+
break
|
|
15826
|
+
channel_specs_in_cmd.append(arg)
|
|
15827
|
+
router_log(
|
|
15828
|
+
"INFO",
|
|
15829
|
+
"claude_launch_cmd mcp_config=%s channels=%s argv_len=%d"
|
|
15830
|
+
% (
|
|
15831
|
+
mcp_value,
|
|
15832
|
+
",".join(channel_specs_in_cmd) or "-",
|
|
15833
|
+
len(cmd),
|
|
15834
|
+
),
|
|
15835
|
+
)
|
|
15836
|
+
relevant_env_keys = (
|
|
15837
|
+
"ANTHROPIC_BASE_URL",
|
|
15838
|
+
"ANTHROPIC_MODEL",
|
|
15839
|
+
"ANTHROPIC_API_KEY",
|
|
15840
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
15841
|
+
"CLAUDE_ANY_PROVIDER",
|
|
15842
|
+
"CLAUDE_ANY_MODEL_ALIAS",
|
|
15843
|
+
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS",
|
|
15844
|
+
)
|
|
15845
|
+
env_summary = []
|
|
15846
|
+
for key in relevant_env_keys:
|
|
15847
|
+
if key in env:
|
|
15848
|
+
val = env[key]
|
|
15849
|
+
if "KEY" in key or "TOKEN" in key:
|
|
15850
|
+
val = mask_secret(val)
|
|
15851
|
+
env_summary.append(f"{key}={val}")
|
|
15852
|
+
if env_summary:
|
|
15853
|
+
router_log("INFO", "claude_launch_env " + " ".join(env_summary))
|
|
15854
|
+
|
|
15855
|
+
|
|
15856
|
+
def _subprocess_call_capturing_stderr(cmd: list[str], env: dict[str, str]) -> int:
|
|
15857
|
+
"""Like subprocess.call but tees Claude Code's stderr into
|
|
15858
|
+
~/.config/claude-any/claude-code-stderr.log so the user can collect
|
|
15859
|
+
the exact context around messages like
|
|
15860
|
+
`--dangerously-load-development-channels ignored (server:...)`.
|
|
15861
|
+
|
|
15862
|
+
Enabled via CLAUDE_ANY_CAPTURE_CC_STDERR=1."""
|
|
15863
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
15864
|
+
try:
|
|
15865
|
+
if CLAUDE_CODE_STDERR_LOG.exists() and CLAUDE_CODE_STDERR_LOG.stat().st_size > 2_000_000:
|
|
15866
|
+
CLAUDE_CODE_STDERR_LOG.replace(CLAUDE_CODE_STDERR_LOG.with_suffix(".log.1"))
|
|
15867
|
+
except Exception:
|
|
15868
|
+
pass
|
|
15869
|
+
try:
|
|
15870
|
+
log_handle = CLAUDE_CODE_STDERR_LOG.open("ab", buffering=0)
|
|
15871
|
+
except Exception as exc:
|
|
15872
|
+
router_log("WARN", f"claude_stderr_capture_open_failed error={type(exc).__name__}: {exc}")
|
|
15873
|
+
return subprocess.call(cmd, env=env)
|
|
15874
|
+
header = f"\n===== claude launch at {time.strftime('%Y-%m-%dT%H:%M:%S')} =====\n".encode("utf-8")
|
|
15875
|
+
try:
|
|
15876
|
+
log_handle.write(header)
|
|
15877
|
+
except Exception:
|
|
15878
|
+
pass
|
|
15879
|
+
try:
|
|
15880
|
+
proc = subprocess.Popen(cmd, env=env, stderr=subprocess.PIPE)
|
|
15881
|
+
except Exception as exc:
|
|
15882
|
+
router_log("WARN", f"claude_stderr_capture_spawn_failed error={type(exc).__name__}: {exc}")
|
|
15883
|
+
try:
|
|
15884
|
+
log_handle.close()
|
|
15885
|
+
except Exception:
|
|
15886
|
+
pass
|
|
15887
|
+
return subprocess.call(cmd, env=env)
|
|
15888
|
+
|
|
15889
|
+
def _tee_stderr() -> None:
|
|
15890
|
+
try:
|
|
15891
|
+
if proc.stderr is None:
|
|
15892
|
+
return
|
|
15893
|
+
while True:
|
|
15894
|
+
chunk = proc.stderr.read(4096)
|
|
15895
|
+
if not chunk:
|
|
15896
|
+
break
|
|
15897
|
+
try:
|
|
15898
|
+
sys.stderr.buffer.write(chunk)
|
|
15899
|
+
sys.stderr.buffer.flush()
|
|
15900
|
+
except Exception:
|
|
15901
|
+
pass
|
|
15902
|
+
try:
|
|
15903
|
+
log_handle.write(chunk)
|
|
15904
|
+
except Exception:
|
|
15905
|
+
pass
|
|
15906
|
+
finally:
|
|
15907
|
+
try:
|
|
15908
|
+
log_handle.close()
|
|
15909
|
+
except Exception:
|
|
15910
|
+
pass
|
|
15911
|
+
|
|
15912
|
+
tee_thread = threading.Thread(target=_tee_stderr, daemon=True, name="claude-stderr-tee")
|
|
15913
|
+
tee_thread.start()
|
|
15914
|
+
rc = proc.wait()
|
|
15915
|
+
tee_thread.join(timeout=2.0)
|
|
15916
|
+
router_log("INFO", f"claude_exit code={rc} stderr_log={CLAUDE_CODE_STDERR_LOG}")
|
|
15917
|
+
return rc
|
|
15918
|
+
|
|
15919
|
+
|
|
15198
15920
|
def cli_usage() -> str:
|
|
15199
15921
|
return """Usage:
|
|
15200
15922
|
claude-any Launch Claude Code through claude-any router
|
package/package.json
CHANGED