@oneciel-ai/claude-any 0.1.76 → 0.1.77
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 +951 -18
- package/package.json +1 -1
package/claude_any.py
CHANGED
|
@@ -24,7 +24,7 @@ import urllib.request
|
|
|
24
24
|
from email.utils import parsedate_to_datetime
|
|
25
25
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
26
26
|
from pathlib import Path
|
|
27
|
-
from typing import Any, Callable
|
|
27
|
+
from typing import Any, Callable, Iterable
|
|
28
28
|
|
|
29
29
|
from claude_any_support.observability import EventBus, render_events_html
|
|
30
30
|
from claude_any_support.transcript_filter import (
|
|
@@ -57,6 +57,8 @@ PID_PATH = CONFIG_DIR / "router.pid"
|
|
|
57
57
|
MODEL_LIST_CACHE_PATH = CONFIG_DIR / "model-list-cache.json"
|
|
58
58
|
WEB_TOOLS_MCP_CONFIG = CONFIG_DIR / "web-tools-mcp.json"
|
|
59
59
|
DUCKDUCKGO_MCP_CONFIG = CONFIG_DIR / "duckduckgo-mcp.json"
|
|
60
|
+
CHANNEL_MCP_CONFIG = CONFIG_DIR / "channel-mcp.json"
|
|
61
|
+
MCP_PROXY_CONFIG = CONFIG_DIR / "mcp-proxy.json"
|
|
60
62
|
ROUTER_HOST = os.environ.get("CLAUDE_ANY_ROUTER_CLIENT_HOST", "127.0.0.1").strip() or "127.0.0.1"
|
|
61
63
|
ROUTER_PORT = 8799
|
|
62
64
|
ROUTER_BASE = f"http://{ROUTER_HOST}:{ROUTER_PORT}"
|
|
@@ -102,7 +104,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
|
|
|
102
104
|
"fakechat": "plugin:fakechat@claude-plugins-official",
|
|
103
105
|
}
|
|
104
106
|
APP_NAME = "Claude Any"
|
|
105
|
-
VERSION = "0.1.
|
|
107
|
+
VERSION = "0.1.77"
|
|
106
108
|
CREDITS = "Credits: One Ciel LLC"
|
|
107
109
|
|
|
108
110
|
LOG_LEVELS = {"SILENT": 0, "ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4, "TRACE": 5}
|
|
@@ -120,6 +122,8 @@ _CHAT_CONDITION = threading.Condition()
|
|
|
120
122
|
_CHAT_NEXT_ID: int | None = None
|
|
121
123
|
_CHANNEL_SSE_LOCK = threading.Lock()
|
|
122
124
|
_CHANNEL_SSE_CONNECTIONS: dict[str, dict[str, Any]] = {}
|
|
125
|
+
_CHANNEL_MCP_LOCK = threading.Lock()
|
|
126
|
+
_CHANNEL_MCP_SESSIONS: dict[str, dict[str, Any]] = {}
|
|
123
127
|
EVENT_BUS = EventBus()
|
|
124
128
|
ADVISOR_FEEDBACK_MARKER = "CLAUDE_ANY_ADVISOR_FEEDBACK"
|
|
125
129
|
PLAN_GUARD_MARKER = "[claude-any-plan-guard]"
|
|
@@ -1048,6 +1052,7 @@ UI_TEXT = {
|
|
|
1048
1052
|
"advisor_model": "Advisor Model",
|
|
1049
1053
|
"test": "Test compatibility",
|
|
1050
1054
|
"options": "LLM options",
|
|
1055
|
+
"log_level": "Log level",
|
|
1051
1056
|
"presets": "LLM presets",
|
|
1052
1057
|
"context_setup": "Context setup",
|
|
1053
1058
|
"timeout_preset": "Timeout preset",
|
|
@@ -1068,6 +1073,7 @@ UI_TEXT = {
|
|
|
1068
1073
|
"advisor_model": "Advisor Model",
|
|
1069
1074
|
"test": "호환성 테스트",
|
|
1070
1075
|
"options": "LLM 옵션",
|
|
1076
|
+
"log_level": "로그 레벨",
|
|
1071
1077
|
"presets": "LLM 프리셋",
|
|
1072
1078
|
"context_setup": "컨텍스트 설정",
|
|
1073
1079
|
"timeout_preset": "타임아웃 프리셋",
|
|
@@ -1088,6 +1094,7 @@ UI_TEXT = {
|
|
|
1088
1094
|
"advisor_model": "Advisor Model",
|
|
1089
1095
|
"test": "互換性テスト",
|
|
1090
1096
|
"options": "LLMオプション",
|
|
1097
|
+
"log_level": "ログレベル",
|
|
1091
1098
|
"presets": "LLMプリセット",
|
|
1092
1099
|
"context_setup": "コンテキスト設定",
|
|
1093
1100
|
"timeout_preset": "timeout プリセット",
|
|
@@ -1108,6 +1115,7 @@ UI_TEXT = {
|
|
|
1108
1115
|
"advisor_model": "Advisor Model",
|
|
1109
1116
|
"test": "兼容性测试",
|
|
1110
1117
|
"options": "LLM 选项",
|
|
1118
|
+
"log_level": "日志级别",
|
|
1111
1119
|
"presets": "LLM 预设",
|
|
1112
1120
|
"context_setup": "上下文设置",
|
|
1113
1121
|
"timeout_preset": "Timeout 预设",
|
|
@@ -2247,6 +2255,74 @@ def current_log_level() -> int:
|
|
|
2247
2255
|
return level
|
|
2248
2256
|
|
|
2249
2257
|
|
|
2258
|
+
def reset_log_level_cache() -> None:
|
|
2259
|
+
_LOG_LEVEL_CACHE.update({"value": None, "checked_at": 0.0, "file_mtime": 0.0})
|
|
2260
|
+
|
|
2261
|
+
|
|
2262
|
+
def log_level_name(value: int | None = None) -> str:
|
|
2263
|
+
if value is None:
|
|
2264
|
+
value = current_log_level()
|
|
2265
|
+
return str(LOG_LEVEL_NAMES.get(int(value), value))
|
|
2266
|
+
|
|
2267
|
+
|
|
2268
|
+
def log_level_source() -> str:
|
|
2269
|
+
if LOG_LEVEL_PATH.exists():
|
|
2270
|
+
return "file"
|
|
2271
|
+
if os.environ.get("CLAUDE_ANY_LOG_LEVEL", "").strip():
|
|
2272
|
+
return "env"
|
|
2273
|
+
return "default"
|
|
2274
|
+
|
|
2275
|
+
|
|
2276
|
+
def log_level_status() -> str:
|
|
2277
|
+
return f"{log_level_name()} ({log_level_source()})"
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
def normalize_log_level(value: str) -> str | None:
|
|
2281
|
+
raw = str(value or "").strip()
|
|
2282
|
+
if not raw:
|
|
2283
|
+
raise ValueError("log level is empty")
|
|
2284
|
+
upper = raw.upper()
|
|
2285
|
+
aliases = {
|
|
2286
|
+
"OFF": "SILENT",
|
|
2287
|
+
"NONE": "SILENT",
|
|
2288
|
+
"QUIET": "SILENT",
|
|
2289
|
+
"WARNING": "WARN",
|
|
2290
|
+
"WARNINGS": "WARN",
|
|
2291
|
+
}
|
|
2292
|
+
upper = aliases.get(upper, upper)
|
|
2293
|
+
if upper in ("DEFAULT", "RESET", "UNSET", "AUTO"):
|
|
2294
|
+
return None
|
|
2295
|
+
if upper in LOG_LEVELS:
|
|
2296
|
+
return upper
|
|
2297
|
+
if upper.isdigit():
|
|
2298
|
+
numeric = max(0, min(5, int(upper)))
|
|
2299
|
+
return LOG_LEVEL_NAMES[numeric]
|
|
2300
|
+
raise ValueError(f"unknown log level: {value}")
|
|
2301
|
+
|
|
2302
|
+
|
|
2303
|
+
def set_log_level_config(value: str) -> list[str]:
|
|
2304
|
+
try:
|
|
2305
|
+
level = normalize_log_level(value)
|
|
2306
|
+
except ValueError as exc:
|
|
2307
|
+
known = ", ".join(LOG_LEVELS)
|
|
2308
|
+
return [f"{exc}. Known levels: {known}, DEFAULT."]
|
|
2309
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
2310
|
+
if level is None:
|
|
2311
|
+
try:
|
|
2312
|
+
LOG_LEVEL_PATH.unlink()
|
|
2313
|
+
except FileNotFoundError:
|
|
2314
|
+
pass
|
|
2315
|
+
reset_log_level_cache()
|
|
2316
|
+
return [f"Log level reset to {log_level_status()}."]
|
|
2317
|
+
LOG_LEVEL_PATH.write_text(level + "\n", encoding="utf-8")
|
|
2318
|
+
try:
|
|
2319
|
+
os.chmod(LOG_LEVEL_PATH, 0o600)
|
|
2320
|
+
except Exception:
|
|
2321
|
+
pass
|
|
2322
|
+
reset_log_level_cache()
|
|
2323
|
+
return [f"Log level set to {level}."]
|
|
2324
|
+
|
|
2325
|
+
|
|
2250
2326
|
def router_log(level: str, message: str) -> None:
|
|
2251
2327
|
"""Append a line to router.log if the active level allows it.
|
|
2252
2328
|
Rotates router.log when it exceeds ROUTER_LOG_MAX_BYTES."""
|
|
@@ -4677,6 +4753,134 @@ def stop_channel_sse_connection(name: str | None = None) -> dict[str, Any]:
|
|
|
4677
4753
|
return {"stopped": stopped, "connections": channel_sse_status()}
|
|
4678
4754
|
|
|
4679
4755
|
|
|
4756
|
+
def _channel_mcp_session_id() -> str:
|
|
4757
|
+
return f"s{os.getpid()}-{time.time_ns()}"
|
|
4758
|
+
|
|
4759
|
+
|
|
4760
|
+
def _channel_mcp_notification(message: dict[str, Any]) -> dict[str, Any]:
|
|
4761
|
+
text = re.sub(r"\s+", " ", str(message.get("message") or "")).strip()
|
|
4762
|
+
channel = str(message.get("channel") or "default")
|
|
4763
|
+
sender = str(message.get("sender_id") or "channel")
|
|
4764
|
+
prefix = f"[{channel}] {sender}"
|
|
4765
|
+
content = f"{prefix}: {text}" if text else prefix
|
|
4766
|
+
meta = message.get("meta") if isinstance(message.get("meta"), dict) else {}
|
|
4767
|
+
merged_meta = {
|
|
4768
|
+
**meta,
|
|
4769
|
+
"claude_any_message_id": message.get("id"),
|
|
4770
|
+
"channel": channel,
|
|
4771
|
+
"sender_id": sender,
|
|
4772
|
+
"thread_id": message.get("thread_id"),
|
|
4773
|
+
"parent_id": message.get("parent_id"),
|
|
4774
|
+
}
|
|
4775
|
+
return {
|
|
4776
|
+
"jsonrpc": "2.0",
|
|
4777
|
+
"method": "notifications/claude/channel",
|
|
4778
|
+
"params": {
|
|
4779
|
+
"content": content,
|
|
4780
|
+
"meta": merged_meta,
|
|
4781
|
+
},
|
|
4782
|
+
}
|
|
4783
|
+
|
|
4784
|
+
|
|
4785
|
+
def _write_sse_event(handler: BaseHTTPRequestHandler, event: str, data: Any, event_id: int | None = None) -> None:
|
|
4786
|
+
if event_id is not None:
|
|
4787
|
+
handler.wfile.write(f"id: {event_id}\n".encode("utf-8"))
|
|
4788
|
+
handler.wfile.write(f"event: {event}\n".encode("utf-8"))
|
|
4789
|
+
payload = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
4790
|
+
for line in payload.splitlines() or [""]:
|
|
4791
|
+
handler.wfile.write(f"data: {line}\n".encode("utf-8"))
|
|
4792
|
+
handler.wfile.write(b"\n")
|
|
4793
|
+
handler.wfile.flush()
|
|
4794
|
+
|
|
4795
|
+
|
|
4796
|
+
def handle_channel_mcp_get(handler: BaseHTTPRequestHandler, path: str) -> bool:
|
|
4797
|
+
if path == "/ca/mcp/health":
|
|
4798
|
+
write_json(handler, {"ok": True, "name": "claude-any-router", "sse": "/ca/mcp/sse"})
|
|
4799
|
+
return True
|
|
4800
|
+
if path != "/ca/mcp/sse":
|
|
4801
|
+
return False
|
|
4802
|
+
session = _channel_mcp_session_id()
|
|
4803
|
+
last_id = _chat_init_next_id() - 1
|
|
4804
|
+
with _CHANNEL_MCP_LOCK:
|
|
4805
|
+
_CHANNEL_MCP_SESSIONS[session] = {"created_at": time.time(), "last_id": last_id, "initialized": False}
|
|
4806
|
+
handler.send_response(200)
|
|
4807
|
+
handler.send_header("content-type", "text/event-stream")
|
|
4808
|
+
handler.send_header("cache-control", "no-cache")
|
|
4809
|
+
handler.send_header("connection", "close")
|
|
4810
|
+
handler.end_headers()
|
|
4811
|
+
_write_sse_event(handler, "endpoint", f"/ca/mcp/messages?session={urllib.parse.quote(session)}")
|
|
4812
|
+
try:
|
|
4813
|
+
while True:
|
|
4814
|
+
with _CHANNEL_MCP_LOCK:
|
|
4815
|
+
state = _CHANNEL_MCP_SESSIONS.get(session)
|
|
4816
|
+
if not state:
|
|
4817
|
+
return True
|
|
4818
|
+
last_id = int(state.get("last_id") or 0)
|
|
4819
|
+
messages = read_chat_messages(last_id, None, None, 100)
|
|
4820
|
+
if messages:
|
|
4821
|
+
for message in messages:
|
|
4822
|
+
last_id = max(last_id, int(message.get("id") or 0))
|
|
4823
|
+
_write_sse_event(handler, "message", _channel_mcp_notification(message), last_id)
|
|
4824
|
+
with _CHANNEL_MCP_LOCK:
|
|
4825
|
+
state = _CHANNEL_MCP_SESSIONS.get(session)
|
|
4826
|
+
if state:
|
|
4827
|
+
state["last_id"] = last_id
|
|
4828
|
+
continue
|
|
4829
|
+
handler.wfile.write(b": keepalive\n\n")
|
|
4830
|
+
handler.wfile.flush()
|
|
4831
|
+
with _CHAT_CONDITION:
|
|
4832
|
+
_CHAT_CONDITION.wait(timeout=15.0)
|
|
4833
|
+
except (BrokenPipeError, ConnectionError, ConnectionResetError):
|
|
4834
|
+
return True
|
|
4835
|
+
finally:
|
|
4836
|
+
with _CHANNEL_MCP_LOCK:
|
|
4837
|
+
_CHANNEL_MCP_SESSIONS.pop(session, None)
|
|
4838
|
+
|
|
4839
|
+
|
|
4840
|
+
def handle_channel_mcp_post(handler: BaseHTTPRequestHandler, path: str, body: dict[str, Any]) -> bool:
|
|
4841
|
+
if path != "/ca/mcp/messages":
|
|
4842
|
+
return False
|
|
4843
|
+
params = _query_params(handler)
|
|
4844
|
+
session = _first_param(params, "session")
|
|
4845
|
+
with _CHANNEL_MCP_LOCK:
|
|
4846
|
+
if session and session in _CHANNEL_MCP_SESSIONS:
|
|
4847
|
+
_CHANNEL_MCP_SESSIONS[session]["last_seen_at"] = time.time()
|
|
4848
|
+
method = str(body.get("method") or "")
|
|
4849
|
+
request_id = body.get("id")
|
|
4850
|
+
if method == "initialize":
|
|
4851
|
+
protocol = "2024-11-05"
|
|
4852
|
+
req_params = body.get("params") if isinstance(body.get("params"), dict) else {}
|
|
4853
|
+
if req_params.get("protocolVersion"):
|
|
4854
|
+
protocol = str(req_params["protocolVersion"])
|
|
4855
|
+
with _CHANNEL_MCP_LOCK:
|
|
4856
|
+
if session and session in _CHANNEL_MCP_SESSIONS:
|
|
4857
|
+
_CHANNEL_MCP_SESSIONS[session]["initialized"] = True
|
|
4858
|
+
write_json(
|
|
4859
|
+
handler,
|
|
4860
|
+
{
|
|
4861
|
+
"jsonrpc": "2.0",
|
|
4862
|
+
"id": request_id,
|
|
4863
|
+
"result": {
|
|
4864
|
+
"protocolVersion": protocol,
|
|
4865
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
4866
|
+
"serverInfo": {"name": "claude-any-router", "version": VERSION},
|
|
4867
|
+
},
|
|
4868
|
+
},
|
|
4869
|
+
)
|
|
4870
|
+
return True
|
|
4871
|
+
if method == "tools/list":
|
|
4872
|
+
write_json(handler, {"jsonrpc": "2.0", "id": request_id, "result": {"tools": []}})
|
|
4873
|
+
return True
|
|
4874
|
+
if method == "ping":
|
|
4875
|
+
write_json(handler, {"jsonrpc": "2.0", "id": request_id, "result": {}})
|
|
4876
|
+
return True
|
|
4877
|
+
if request_id is None:
|
|
4878
|
+
write_json(handler, {"ok": True})
|
|
4879
|
+
return True
|
|
4880
|
+
write_json(handler, {"jsonrpc": "2.0", "id": request_id, "result": {}})
|
|
4881
|
+
return True
|
|
4882
|
+
|
|
4883
|
+
|
|
4680
4884
|
def _query_params(handler: BaseHTTPRequestHandler) -> dict[str, list[str]]:
|
|
4681
4885
|
return urllib.parse.parse_qs(urllib.parse.urlparse(handler.path).query, keep_blank_values=True)
|
|
4682
4886
|
|
|
@@ -8123,6 +8327,8 @@ class RouterHandler(BaseHTTPRequestHandler):
|
|
|
8123
8327
|
return
|
|
8124
8328
|
if handle_llm_config_get(self, path):
|
|
8125
8329
|
return
|
|
8330
|
+
if handle_channel_mcp_get(self, path):
|
|
8331
|
+
return
|
|
8126
8332
|
if handle_chat_get(self, path) or handle_plan_get(self, path):
|
|
8127
8333
|
return
|
|
8128
8334
|
provider, pcfg = get_current_provider(cfg)
|
|
@@ -8153,6 +8359,8 @@ class RouterHandler(BaseHTTPRequestHandler):
|
|
|
8153
8359
|
body = parse_json_body(raw)
|
|
8154
8360
|
if handle_llm_config_post(self, path, body):
|
|
8155
8361
|
return
|
|
8362
|
+
if handle_channel_mcp_post(self, path, body):
|
|
8363
|
+
return
|
|
8156
8364
|
if handle_chat_post(self, path, body) or handle_plan_post(self, path, body):
|
|
8157
8365
|
return
|
|
8158
8366
|
provider, pcfg = get_current_provider(cfg)
|
|
@@ -8645,6 +8853,7 @@ def status_lines() -> list[str]:
|
|
|
8645
8853
|
*([f"request_timeout_ms: {pcfg.get('request_timeout_ms', 'default')}"] if provider in ("vllm", "nvidia-hosted", "self-hosted-nim") else []),
|
|
8646
8854
|
*([f"stream_idle_timeout_ms: {pcfg.get('stream_idle_timeout_ms', 'auto')}"] if provider in ("vllm", "nvidia-hosted", "self-hosted-nim") else []),
|
|
8647
8855
|
f"claude_model: {current_upstream_model_id(provider, pcfg) if direct_native else current_alias(cfg)}",
|
|
8856
|
+
f"log_level: {log_level_status()}",
|
|
8648
8857
|
f"channels: {channel_status_text(cfg)}",
|
|
8649
8858
|
f"router: {'bypassed for native provider compatibility' if direct_native else (('up' if router_up() else 'down') + ' ' + ROUTER_BASE)}",
|
|
8650
8859
|
f"config: {CONFIG_PATH}",
|
|
@@ -8655,6 +8864,20 @@ def cmd_status(_: argparse.Namespace) -> None:
|
|
|
8655
8864
|
print("\n".join(status_lines()))
|
|
8656
8865
|
|
|
8657
8866
|
|
|
8867
|
+
def cmd_log_level(args: argparse.Namespace) -> None:
|
|
8868
|
+
value = getattr(args, "value", None)
|
|
8869
|
+
if not value:
|
|
8870
|
+
print(f"log_level: {log_level_status()}")
|
|
8871
|
+
for numeric in sorted(LOG_LEVEL_NAMES):
|
|
8872
|
+
name = LOG_LEVEL_NAMES[numeric]
|
|
8873
|
+
mark = "*" if name == log_level_name() else " "
|
|
8874
|
+
print(f" {mark} {name:<6} {numeric}")
|
|
8875
|
+
print(" DEFAULT reset to environment/default")
|
|
8876
|
+
return
|
|
8877
|
+
for line in set_log_level_config(str(value)):
|
|
8878
|
+
print(line)
|
|
8879
|
+
|
|
8880
|
+
|
|
8658
8881
|
def cmd_language(args: argparse.Namespace) -> None:
|
|
8659
8882
|
cfg = load_config()
|
|
8660
8883
|
if not args.value:
|
|
@@ -8759,6 +8982,298 @@ def channel_specs(cfg: dict[str, Any] | None = None) -> list[str]:
|
|
|
8759
8982
|
return channels
|
|
8760
8983
|
|
|
8761
8984
|
|
|
8985
|
+
def _dedupe_strings(values: Iterable[str]) -> list[str]:
|
|
8986
|
+
out: list[str] = []
|
|
8987
|
+
seen: set[str] = set()
|
|
8988
|
+
for value in values:
|
|
8989
|
+
text = str(value or "").strip()
|
|
8990
|
+
if not text or text in seen:
|
|
8991
|
+
continue
|
|
8992
|
+
seen.add(text)
|
|
8993
|
+
out.append(text)
|
|
8994
|
+
return out
|
|
8995
|
+
|
|
8996
|
+
|
|
8997
|
+
def _path_for_compare(path: Path | str) -> str:
|
|
8998
|
+
try:
|
|
8999
|
+
return str(Path(path).expanduser().resolve()).replace("\\", "/").rstrip("/").casefold()
|
|
9000
|
+
except Exception:
|
|
9001
|
+
return str(path).replace("\\", "/").rstrip("/").casefold()
|
|
9002
|
+
|
|
9003
|
+
|
|
9004
|
+
def _project_key_matches_cwd(project_key: str, cwd: Path) -> bool:
|
|
9005
|
+
key = str(project_key or "").strip()
|
|
9006
|
+
if not key:
|
|
9007
|
+
return False
|
|
9008
|
+
try:
|
|
9009
|
+
project_path = Path(key).expanduser()
|
|
9010
|
+
except Exception:
|
|
9011
|
+
return False
|
|
9012
|
+
if not project_path.is_absolute():
|
|
9013
|
+
return False
|
|
9014
|
+
project = _path_for_compare(project_path)
|
|
9015
|
+
current = _path_for_compare(cwd)
|
|
9016
|
+
return current == project or current.startswith(project + "/")
|
|
9017
|
+
|
|
9018
|
+
|
|
9019
|
+
def _mcp_server_names_from_mapping(mapping: Any) -> list[str]:
|
|
9020
|
+
if not isinstance(mapping, dict):
|
|
9021
|
+
return []
|
|
9022
|
+
names: list[str] = []
|
|
9023
|
+
for key in ("mcpServers", "servers"):
|
|
9024
|
+
servers = mapping.get(key)
|
|
9025
|
+
if isinstance(servers, dict):
|
|
9026
|
+
names.extend(str(name).strip() for name in servers if str(name).strip())
|
|
9027
|
+
return _dedupe_strings(names)
|
|
9028
|
+
|
|
9029
|
+
|
|
9030
|
+
def _mcp_servers_from_mapping(mapping: Any) -> list[tuple[str, dict[str, Any]]]:
|
|
9031
|
+
if not isinstance(mapping, dict):
|
|
9032
|
+
return []
|
|
9033
|
+
found: list[tuple[str, dict[str, Any]]] = []
|
|
9034
|
+
seen: set[str] = set()
|
|
9035
|
+
for key in ("mcpServers", "servers"):
|
|
9036
|
+
servers = mapping.get(key)
|
|
9037
|
+
if not isinstance(servers, dict):
|
|
9038
|
+
continue
|
|
9039
|
+
for raw_name, raw_server in servers.items():
|
|
9040
|
+
name = str(raw_name or "").strip()
|
|
9041
|
+
if not name or name in seen or not isinstance(raw_server, dict):
|
|
9042
|
+
continue
|
|
9043
|
+
seen.add(name)
|
|
9044
|
+
found.append((name, dict(raw_server)))
|
|
9045
|
+
return found
|
|
9046
|
+
|
|
9047
|
+
|
|
9048
|
+
def _read_mcp_server_names_from_json(path: Path, cwd: Path) -> list[str]:
|
|
9049
|
+
try:
|
|
9050
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
9051
|
+
except Exception:
|
|
9052
|
+
return []
|
|
9053
|
+
names = _mcp_server_names_from_mapping(data)
|
|
9054
|
+
if path.name == ".claude.json" and isinstance(data, dict):
|
|
9055
|
+
projects = data.get("projects")
|
|
9056
|
+
if isinstance(projects, dict):
|
|
9057
|
+
for project_key, project_data in projects.items():
|
|
9058
|
+
if _project_key_matches_cwd(str(project_key), cwd):
|
|
9059
|
+
names.extend(_mcp_server_names_from_mapping(project_data))
|
|
9060
|
+
return _dedupe_strings(names)
|
|
9061
|
+
|
|
9062
|
+
|
|
9063
|
+
def _read_mcp_servers_from_json(path: Path, cwd: Path) -> list[tuple[str, dict[str, Any]]]:
|
|
9064
|
+
try:
|
|
9065
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
9066
|
+
except Exception:
|
|
9067
|
+
return []
|
|
9068
|
+
servers = _mcp_servers_from_mapping(data)
|
|
9069
|
+
if path.name == ".claude.json" and isinstance(data, dict):
|
|
9070
|
+
projects = data.get("projects")
|
|
9071
|
+
if isinstance(projects, dict):
|
|
9072
|
+
for project_key, project_data in projects.items():
|
|
9073
|
+
if _project_key_matches_cwd(str(project_key), cwd):
|
|
9074
|
+
servers.extend(_mcp_servers_from_mapping(project_data))
|
|
9075
|
+
out: list[tuple[str, dict[str, Any]]] = []
|
|
9076
|
+
seen: set[str] = set()
|
|
9077
|
+
for name, server in servers:
|
|
9078
|
+
if name in seen:
|
|
9079
|
+
continue
|
|
9080
|
+
seen.add(name)
|
|
9081
|
+
out.append((name, server))
|
|
9082
|
+
return out
|
|
9083
|
+
|
|
9084
|
+
|
|
9085
|
+
def _mcp_server_is_stdio(server: dict[str, Any]) -> bool:
|
|
9086
|
+
if not isinstance(server, dict):
|
|
9087
|
+
return False
|
|
9088
|
+
server_type = str(server.get("type") or "").strip().lower()
|
|
9089
|
+
if server_type and server_type not in ("stdio", "command"):
|
|
9090
|
+
return False
|
|
9091
|
+
command = str(server.get("command") or "").strip()
|
|
9092
|
+
if not command:
|
|
9093
|
+
return False
|
|
9094
|
+
joined = " ".join([command, *[str(item) for item in server.get("args", []) if item is not None]])
|
|
9095
|
+
return "mcp-proxy" not in joined
|
|
9096
|
+
|
|
9097
|
+
|
|
9098
|
+
def _mcp_config_passthrough_values(passthrough: list[str]) -> list[str]:
|
|
9099
|
+
values: list[str] = []
|
|
9100
|
+
i = 0
|
|
9101
|
+
while i < len(passthrough):
|
|
9102
|
+
arg = passthrough[i]
|
|
9103
|
+
if arg == "--mcp-config":
|
|
9104
|
+
i += 1
|
|
9105
|
+
while i < len(passthrough) and not passthrough[i].startswith("-"):
|
|
9106
|
+
values.append(passthrough[i])
|
|
9107
|
+
i += 1
|
|
9108
|
+
continue
|
|
9109
|
+
if arg.startswith("--mcp-config="):
|
|
9110
|
+
value = arg.split("=", 1)[1].strip()
|
|
9111
|
+
if value:
|
|
9112
|
+
values.append(value)
|
|
9113
|
+
i += 1
|
|
9114
|
+
return values
|
|
9115
|
+
|
|
9116
|
+
|
|
9117
|
+
def strip_mcp_config_passthrough(passthrough: list[str]) -> list[str]:
|
|
9118
|
+
stripped: list[str] = []
|
|
9119
|
+
i = 0
|
|
9120
|
+
while i < len(passthrough):
|
|
9121
|
+
arg = passthrough[i]
|
|
9122
|
+
if arg == "--mcp-config":
|
|
9123
|
+
i += 1
|
|
9124
|
+
while i < len(passthrough) and not passthrough[i].startswith("-"):
|
|
9125
|
+
i += 1
|
|
9126
|
+
continue
|
|
9127
|
+
if arg.startswith("--mcp-config="):
|
|
9128
|
+
i += 1
|
|
9129
|
+
continue
|
|
9130
|
+
stripped.append(arg)
|
|
9131
|
+
i += 1
|
|
9132
|
+
return stripped
|
|
9133
|
+
|
|
9134
|
+
|
|
9135
|
+
def _safe_mcp_proxy_name(name: str) -> str:
|
|
9136
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", name.strip())
|
|
9137
|
+
return safe[:80] or "server"
|
|
9138
|
+
|
|
9139
|
+
|
|
9140
|
+
def _mcp_config_paths_from_passthrough(passthrough: list[str]) -> list[Path]:
|
|
9141
|
+
return [Path(value).expanduser() for value in _mcp_config_passthrough_values(passthrough)]
|
|
9142
|
+
|
|
9143
|
+
|
|
9144
|
+
def claude_mcp_config_paths(passthrough: list[str] | None = None, cwd: Path | None = None, home: Path | None = None) -> list[Path]:
|
|
9145
|
+
cwd = cwd or Path.cwd()
|
|
9146
|
+
home = home or HOME
|
|
9147
|
+
paths: list[Path] = []
|
|
9148
|
+
paths.extend(_mcp_config_paths_from_passthrough(passthrough or []))
|
|
9149
|
+
current = cwd
|
|
9150
|
+
visited: set[str] = set()
|
|
9151
|
+
while True:
|
|
9152
|
+
key = _path_for_compare(current)
|
|
9153
|
+
if key in visited:
|
|
9154
|
+
break
|
|
9155
|
+
visited.add(key)
|
|
9156
|
+
paths.append(current / ".mcp.json")
|
|
9157
|
+
if current == current.parent:
|
|
9158
|
+
break
|
|
9159
|
+
current = current.parent
|
|
9160
|
+
paths.extend([
|
|
9161
|
+
home / ".mcp.json",
|
|
9162
|
+
home / ".claude" / "settings.json",
|
|
9163
|
+
home / ".claude.json",
|
|
9164
|
+
])
|
|
9165
|
+
out: list[Path] = []
|
|
9166
|
+
seen: set[str] = set()
|
|
9167
|
+
for path in paths:
|
|
9168
|
+
key = _path_for_compare(path)
|
|
9169
|
+
if key in seen:
|
|
9170
|
+
continue
|
|
9171
|
+
seen.add(key)
|
|
9172
|
+
out.append(path)
|
|
9173
|
+
return out
|
|
9174
|
+
|
|
9175
|
+
|
|
9176
|
+
def auto_discovered_mcp_channel_specs(
|
|
9177
|
+
passthrough: list[str] | None = None,
|
|
9178
|
+
cwd: Path | None = None,
|
|
9179
|
+
home: Path | None = None,
|
|
9180
|
+
) -> list[str]:
|
|
9181
|
+
cwd = cwd or Path.cwd()
|
|
9182
|
+
specs: list[str] = []
|
|
9183
|
+
for path in claude_mcp_config_paths(passthrough, cwd, home):
|
|
9184
|
+
if not path.exists() or not path.is_file():
|
|
9185
|
+
continue
|
|
9186
|
+
for name in _read_mcp_server_names_from_json(path, cwd):
|
|
9187
|
+
if re.search(r"\s", name):
|
|
9188
|
+
continue
|
|
9189
|
+
specs.append(f"server:{name}" if not is_channel_spec_tagged(name) else name)
|
|
9190
|
+
return _dedupe_strings(specs)
|
|
9191
|
+
|
|
9192
|
+
|
|
9193
|
+
def _mcp_sse_servers_from_mapping(mapping: Any) -> list[dict[str, Any]]:
|
|
9194
|
+
if not isinstance(mapping, dict):
|
|
9195
|
+
return []
|
|
9196
|
+
found: list[dict[str, Any]] = []
|
|
9197
|
+
for key in ("mcpServers", "servers"):
|
|
9198
|
+
servers = mapping.get(key)
|
|
9199
|
+
if not isinstance(servers, dict):
|
|
9200
|
+
continue
|
|
9201
|
+
for raw_name, raw_server in servers.items():
|
|
9202
|
+
name = str(raw_name or "").strip()
|
|
9203
|
+
if not name or not isinstance(raw_server, dict):
|
|
9204
|
+
continue
|
|
9205
|
+
url = str(raw_server.get("url") or raw_server.get("endpoint") or "").strip()
|
|
9206
|
+
if not url.startswith(("http://", "https://")):
|
|
9207
|
+
continue
|
|
9208
|
+
server_type = str(raw_server.get("type") or "").strip().lower()
|
|
9209
|
+
if server_type and server_type not in ("sse", "http", "streamable-http"):
|
|
9210
|
+
continue
|
|
9211
|
+
headers = raw_server.get("headers") if isinstance(raw_server.get("headers"), dict) else {}
|
|
9212
|
+
found.append(
|
|
9213
|
+
{
|
|
9214
|
+
"name": f"mcp-{name}",
|
|
9215
|
+
"url": url,
|
|
9216
|
+
"headers": {str(k): str(v) for k, v in headers.items() if str(k).strip()},
|
|
9217
|
+
"channel": name,
|
|
9218
|
+
"sender_id": name,
|
|
9219
|
+
"recipient": "all",
|
|
9220
|
+
"mcp": True,
|
|
9221
|
+
}
|
|
9222
|
+
)
|
|
9223
|
+
return found
|
|
9224
|
+
|
|
9225
|
+
|
|
9226
|
+
def _read_mcp_sse_servers_from_json(path: Path, cwd: Path) -> list[dict[str, Any]]:
|
|
9227
|
+
try:
|
|
9228
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
9229
|
+
except Exception:
|
|
9230
|
+
return []
|
|
9231
|
+
servers = _mcp_sse_servers_from_mapping(data)
|
|
9232
|
+
if path.name == ".claude.json" and isinstance(data, dict):
|
|
9233
|
+
projects = data.get("projects")
|
|
9234
|
+
if isinstance(projects, dict):
|
|
9235
|
+
for project_key, project_data in projects.items():
|
|
9236
|
+
if _project_key_matches_cwd(str(project_key), cwd):
|
|
9237
|
+
servers.extend(_mcp_sse_servers_from_mapping(project_data))
|
|
9238
|
+
out: list[dict[str, Any]] = []
|
|
9239
|
+
seen: set[str] = set()
|
|
9240
|
+
for server in servers:
|
|
9241
|
+
key = f"{server.get('name')}|{server.get('url')}"
|
|
9242
|
+
if key in seen:
|
|
9243
|
+
continue
|
|
9244
|
+
seen.add(key)
|
|
9245
|
+
out.append(server)
|
|
9246
|
+
return out
|
|
9247
|
+
|
|
9248
|
+
|
|
9249
|
+
def auto_start_sse_channels_from_mcp_configs(
|
|
9250
|
+
passthrough: list[str] | None = None,
|
|
9251
|
+
cwd: Path | None = None,
|
|
9252
|
+
home: Path | None = None,
|
|
9253
|
+
) -> list[dict[str, Any]]:
|
|
9254
|
+
cwd = cwd or Path.cwd()
|
|
9255
|
+
started: list[dict[str, Any]] = []
|
|
9256
|
+
for path in claude_mcp_config_paths(passthrough, cwd, home):
|
|
9257
|
+
if not path.exists() or not path.is_file():
|
|
9258
|
+
continue
|
|
9259
|
+
for server in _read_mcp_sse_servers_from_json(path, cwd):
|
|
9260
|
+
try:
|
|
9261
|
+
status = start_channel_sse_connection(server)
|
|
9262
|
+
started.append(status)
|
|
9263
|
+
router_log("INFO", f"channel_sse_auto_started name={status.get('name')} url={status.get('url')}")
|
|
9264
|
+
except Exception as exc:
|
|
9265
|
+
router_log("WARN", f"channel_sse_auto_start_failed path={path} error={type(exc).__name__}: {exc}")
|
|
9266
|
+
return started
|
|
9267
|
+
|
|
9268
|
+
|
|
9269
|
+
def channel_specs_for_launch(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> list[str]:
|
|
9270
|
+
configured = [spec for spec in channel_specs(cfg) if is_channel_spec_tagged(spec)]
|
|
9271
|
+
specs = configured
|
|
9272
|
+
if extra_specs:
|
|
9273
|
+
specs = [*specs, *extra_specs]
|
|
9274
|
+
return _dedupe_strings(spec for spec in specs if is_channel_spec_tagged(spec))
|
|
9275
|
+
|
|
9276
|
+
|
|
8762
9277
|
def is_channel_spec_tagged(spec: str) -> bool:
|
|
8763
9278
|
return spec.startswith("plugin:") or spec.startswith("server:")
|
|
8764
9279
|
|
|
@@ -12046,7 +12561,7 @@ def main_menu_rows(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any], lan
|
|
|
12046
12561
|
f"4. {ui_text('model', lang)} [{compact_text(pcfg.get('current_model', 'unset'), 62)}]",
|
|
12047
12562
|
f"5. {ui_text('advisor_model', lang)} [{compact_text(pcfg.get('advisor_model') or 'off', 62)}]",
|
|
12048
12563
|
f"6. {ui_text('options', lang)} [{compact_text(llm_options_status(provider, pcfg), 62)}]",
|
|
12049
|
-
f"7.
|
|
12564
|
+
f"7. {ui_text('log_level', lang)} [{log_level_status()}]",
|
|
12050
12565
|
f"8. {ui_text('test', lang)}",
|
|
12051
12566
|
f"9. {ui_text('launch', lang)}",
|
|
12052
12567
|
ui_text("quit", lang),
|
|
@@ -12076,6 +12591,30 @@ def language_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
|
12076
12591
|
return rows, values
|
|
12077
12592
|
|
|
12078
12593
|
|
|
12594
|
+
def log_level_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
12595
|
+
rows: list[str] = []
|
|
12596
|
+
values: list[str] = []
|
|
12597
|
+
current = log_level_name()
|
|
12598
|
+
descriptions = {
|
|
12599
|
+
"SILENT": "no router log writes",
|
|
12600
|
+
"ERROR": "errors only",
|
|
12601
|
+
"WARN": "warnings and errors",
|
|
12602
|
+
"INFO": "normal diagnostics",
|
|
12603
|
+
"DEBUG": "verbose diagnostics",
|
|
12604
|
+
"TRACE": "request/response trace detail",
|
|
12605
|
+
}
|
|
12606
|
+
for numeric in sorted(LOG_LEVEL_NAMES):
|
|
12607
|
+
name = LOG_LEVEL_NAMES[numeric]
|
|
12608
|
+
mark = "*" if name == current else " "
|
|
12609
|
+
rows.append(f"{mark} {name:<6} {numeric} {descriptions.get(name, '')}")
|
|
12610
|
+
values.append(name)
|
|
12611
|
+
rows.append(f"Reset to default/env [{log_level_status()}]")
|
|
12612
|
+
values.append("DEFAULT")
|
|
12613
|
+
rows.append(ui_text("back", cfg.get("language", "en")))
|
|
12614
|
+
values.append("back")
|
|
12615
|
+
return rows, values
|
|
12616
|
+
|
|
12617
|
+
|
|
12079
12618
|
def model_panel_rows(provider: str, pcfg: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
12080
12619
|
values = unique_model_ids(provider, upstream_model_ids(provider, pcfg))
|
|
12081
12620
|
rows: list[str] = []
|
|
@@ -12482,6 +13021,8 @@ def portable_prelaunch_menu() -> int:
|
|
|
12482
13021
|
panel_rows, panel_values = ["Run compatibility test", "Back"], ["run", "back"]
|
|
12483
13022
|
elif name == "options":
|
|
12484
13023
|
panel_rows, panel_values = llm_option_panel_rows(provider, pcfg, cfg.get("language", "en"))
|
|
13024
|
+
elif name == "log-level":
|
|
13025
|
+
panel_rows, panel_values = log_level_panel_rows(cfg)
|
|
12485
13026
|
elif name == "channels":
|
|
12486
13027
|
panel_rows, panel_values = channel_panel_rows(cfg)
|
|
12487
13028
|
elif name == "context":
|
|
@@ -12661,6 +13202,15 @@ def portable_prelaunch_menu() -> int:
|
|
|
12661
13202
|
panel_rows, panel_values = ["Run compatibility test again", "Back"], ["run", "back"]
|
|
12662
13203
|
refresh_checks()
|
|
12663
13204
|
main_idx = 9 if "Compatibility: OK" in out else 4
|
|
13205
|
+
elif panel == "log-level":
|
|
13206
|
+
if value == "back":
|
|
13207
|
+
close_panel()
|
|
13208
|
+
elif value:
|
|
13209
|
+
messages = set_log_level_config(value)
|
|
13210
|
+
refresh_checks()
|
|
13211
|
+
cfg = load_config()
|
|
13212
|
+
panel_rows, panel_values = log_level_panel_rows(cfg)
|
|
13213
|
+
panel_idx = max(0, min(panel_idx, len(panel_rows) - 1))
|
|
12664
13214
|
elif panel == "channels":
|
|
12665
13215
|
if value == "back":
|
|
12666
13216
|
close_panel()
|
|
@@ -12793,7 +13343,7 @@ def portable_prelaunch_menu() -> int:
|
|
|
12793
13343
|
elif key in ("esc", "q"):
|
|
12794
13344
|
return 10
|
|
12795
13345
|
elif key == "enter":
|
|
12796
|
-
actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "
|
|
13346
|
+
actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "log-level", "test", "launch", "quit"]
|
|
12797
13347
|
action = actions[main_idx]
|
|
12798
13348
|
if action == "launch":
|
|
12799
13349
|
blockers = launch_readiness_errors()
|
|
@@ -12907,17 +13457,16 @@ def normalize_channel_passthrough(passthrough: list[str]) -> list[str]:
|
|
|
12907
13457
|
return normalized
|
|
12908
13458
|
|
|
12909
13459
|
|
|
12910
|
-
def
|
|
12911
|
-
|
|
12912
|
-
if not channels or has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels"):
|
|
12913
|
-
return []
|
|
12914
|
-
return ["--dangerously-load-development-channels", *channels]
|
|
13460
|
+
def native_channel_passthrough_requested(passthrough: list[str]) -> bool:
|
|
13461
|
+
return has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels")
|
|
12915
13462
|
|
|
12916
13463
|
|
|
12917
|
-
def
|
|
12918
|
-
|
|
12919
|
-
|
|
12920
|
-
|
|
13464
|
+
def claude_channel_args(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> list[str]:
|
|
13465
|
+
return []
|
|
13466
|
+
|
|
13467
|
+
|
|
13468
|
+
def claude_channels_requested(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> bool:
|
|
13469
|
+
return native_channel_passthrough_requested(passthrough)
|
|
12921
13470
|
|
|
12922
13471
|
|
|
12923
13472
|
def write_web_tools_mcp_config(cfg: dict[str, Any]) -> Path:
|
|
@@ -12960,6 +13509,351 @@ def write_duckduckgo_mcp_config(cfg: dict[str, Any]) -> Path:
|
|
|
12960
13509
|
return path
|
|
12961
13510
|
|
|
12962
13511
|
|
|
13512
|
+
def write_channel_mcp_config() -> Path:
|
|
13513
|
+
data = {
|
|
13514
|
+
"mcpServers": {
|
|
13515
|
+
"claude-any-router": {
|
|
13516
|
+
"type": "sse",
|
|
13517
|
+
"url": f"{ROUTER_BASE}/ca/mcp/sse",
|
|
13518
|
+
}
|
|
13519
|
+
}
|
|
13520
|
+
}
|
|
13521
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
13522
|
+
CHANNEL_MCP_CONFIG.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
13523
|
+
try:
|
|
13524
|
+
os.chmod(CHANNEL_MCP_CONFIG, 0o600)
|
|
13525
|
+
except Exception:
|
|
13526
|
+
pass
|
|
13527
|
+
return CHANNEL_MCP_CONFIG
|
|
13528
|
+
|
|
13529
|
+
|
|
13530
|
+
def write_mcp_proxy_config(
|
|
13531
|
+
passthrough: list[str],
|
|
13532
|
+
*,
|
|
13533
|
+
extra_config_paths: list[Path | str] | None = None,
|
|
13534
|
+
cwd: Path | None = None,
|
|
13535
|
+
home: Path | None = None,
|
|
13536
|
+
) -> Path | None:
|
|
13537
|
+
cwd = cwd or Path.cwd()
|
|
13538
|
+
extra = [Path(item).expanduser() for item in (extra_config_paths or [])]
|
|
13539
|
+
paths = [*extra, *claude_mcp_config_paths(passthrough, cwd, home)]
|
|
13540
|
+
servers: dict[str, Any] = {}
|
|
13541
|
+
seen: set[str] = set()
|
|
13542
|
+
server_dir = CONFIG_DIR / "mcp-proxy-servers"
|
|
13543
|
+
for path in paths:
|
|
13544
|
+
if not path.exists() or not path.is_file():
|
|
13545
|
+
continue
|
|
13546
|
+
for name, server in _read_mcp_servers_from_json(path, cwd):
|
|
13547
|
+
if name in seen:
|
|
13548
|
+
continue
|
|
13549
|
+
seen.add(name)
|
|
13550
|
+
if _mcp_server_is_stdio(server):
|
|
13551
|
+
server_dir.mkdir(parents=True, exist_ok=True)
|
|
13552
|
+
server_path = server_dir / f"{_safe_mcp_proxy_name(name)}.json"
|
|
13553
|
+
server_path.write_text(json.dumps(server, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
13554
|
+
try:
|
|
13555
|
+
os.chmod(server_path, 0o600)
|
|
13556
|
+
except Exception:
|
|
13557
|
+
pass
|
|
13558
|
+
servers[name] = {
|
|
13559
|
+
"command": sys.executable,
|
|
13560
|
+
"args": [
|
|
13561
|
+
str(Path(__file__).resolve()),
|
|
13562
|
+
"mcp-proxy",
|
|
13563
|
+
"--server-name",
|
|
13564
|
+
name,
|
|
13565
|
+
"--server-config",
|
|
13566
|
+
str(server_path),
|
|
13567
|
+
],
|
|
13568
|
+
}
|
|
13569
|
+
else:
|
|
13570
|
+
servers[name] = server
|
|
13571
|
+
if not servers:
|
|
13572
|
+
return None
|
|
13573
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
13574
|
+
MCP_PROXY_CONFIG.write_text(json.dumps({"mcpServers": servers}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
13575
|
+
try:
|
|
13576
|
+
os.chmod(MCP_PROXY_CONFIG, 0o600)
|
|
13577
|
+
except Exception:
|
|
13578
|
+
pass
|
|
13579
|
+
router_log("INFO", f"mcp_proxy_config_written servers={','.join(sorted(servers))}")
|
|
13580
|
+
return MCP_PROXY_CONFIG
|
|
13581
|
+
|
|
13582
|
+
|
|
13583
|
+
def should_use_channel_stdin_proxy(use_router_mode: bool, passthrough: list[str]) -> bool:
|
|
13584
|
+
return bool(use_router_mode and not native_channel_passthrough_requested(passthrough))
|
|
13585
|
+
|
|
13586
|
+
|
|
13587
|
+
def format_channel_wake_prompt(message: dict[str, Any]) -> str:
|
|
13588
|
+
channel = str(message.get("channel") or "default")
|
|
13589
|
+
sender = str(message.get("sender_id") or "channel")
|
|
13590
|
+
mid = str(message.get("id") or "")
|
|
13591
|
+
meta = message.get("meta") if isinstance(message.get("meta"), dict) else {}
|
|
13592
|
+
room = str(meta.get("room_id") or meta.get("room") or channel)
|
|
13593
|
+
thread = str(message.get("thread_id") or meta.get("thread_id") or "")
|
|
13594
|
+
body = re.sub(r"\s+", " ", str(message.get("message") or "")).strip()
|
|
13595
|
+
fields = [f"channel={channel}", f"room={room}", f"from={sender}"]
|
|
13596
|
+
if mid:
|
|
13597
|
+
fields.append(f"id={mid}")
|
|
13598
|
+
if thread:
|
|
13599
|
+
fields.append(f"thread={thread}")
|
|
13600
|
+
return (
|
|
13601
|
+
"[claude-any external channel message] "
|
|
13602
|
+
+ " ".join(fields)
|
|
13603
|
+
+ f" text={json.dumps(body, ensure_ascii=False)}. "
|
|
13604
|
+
+ "If relevant to current work, respond or act now; otherwise keep working."
|
|
13605
|
+
)
|
|
13606
|
+
|
|
13607
|
+
|
|
13608
|
+
def _write_fd_all(fd: int, data: bytes) -> None:
|
|
13609
|
+
view = memoryview(data)
|
|
13610
|
+
while view:
|
|
13611
|
+
written = os.write(fd, view)
|
|
13612
|
+
view = view[written:]
|
|
13613
|
+
|
|
13614
|
+
|
|
13615
|
+
def _channel_wake_input_bytes(prompt: str) -> bytes:
|
|
13616
|
+
# Ctrl-U clears any stale line editor text before submitting the synthetic prompt.
|
|
13617
|
+
return b"\x15" + prompt.encode("utf-8", errors="replace") + b"\n"
|
|
13618
|
+
|
|
13619
|
+
|
|
13620
|
+
def _inject_pending_channel_messages(master_fd: int, last_id: int) -> int:
|
|
13621
|
+
for message in read_chat_messages(last_id, None, None, 100):
|
|
13622
|
+
try:
|
|
13623
|
+
last_id = max(last_id, int(message.get("id") or 0))
|
|
13624
|
+
except Exception:
|
|
13625
|
+
continue
|
|
13626
|
+
prompt = format_channel_wake_prompt(message)
|
|
13627
|
+
_write_fd_all(master_fd, _channel_wake_input_bytes(prompt))
|
|
13628
|
+
router_log("INFO", f"channel_stdin_proxy_injected message_id={message.get('id')} channel={message.get('channel')}")
|
|
13629
|
+
return last_id
|
|
13630
|
+
|
|
13631
|
+
|
|
13632
|
+
def _chat_messages_file_marker() -> tuple[float, int]:
|
|
13633
|
+
try:
|
|
13634
|
+
stat = CHAT_MESSAGES_PATH.stat()
|
|
13635
|
+
return (stat.st_mtime, stat.st_size)
|
|
13636
|
+
except Exception:
|
|
13637
|
+
return (0.0, 0)
|
|
13638
|
+
|
|
13639
|
+
|
|
13640
|
+
def subprocess_call_with_channel_wake_proxy(cmd: list[str], env: dict[str, str]) -> int:
|
|
13641
|
+
if os.name != "posix" or not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
13642
|
+
router_log("INFO", "channel_stdin_proxy_unavailable; using direct subprocess call")
|
|
13643
|
+
return subprocess.call(cmd, env=env)
|
|
13644
|
+
import pty
|
|
13645
|
+
import select
|
|
13646
|
+
import termios
|
|
13647
|
+
import tty
|
|
13648
|
+
|
|
13649
|
+
master_fd, slave_fd = pty.openpty()
|
|
13650
|
+
proc = subprocess.Popen(cmd, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, env=env, close_fds=True)
|
|
13651
|
+
os.close(slave_fd)
|
|
13652
|
+
stdin_fd = sys.stdin.fileno()
|
|
13653
|
+
stdout_fd = sys.stdout.fileno()
|
|
13654
|
+
old_attrs = termios.tcgetattr(stdin_fd)
|
|
13655
|
+
last_id = _chat_init_next_id() - 1
|
|
13656
|
+
last_channel_poll = 0.0
|
|
13657
|
+
last_channel_marker = _chat_messages_file_marker()
|
|
13658
|
+
try:
|
|
13659
|
+
tty.setraw(stdin_fd)
|
|
13660
|
+
while proc.poll() is None:
|
|
13661
|
+
try:
|
|
13662
|
+
readable, _, _ = select.select([stdin_fd, master_fd], [], [], 0.2)
|
|
13663
|
+
except OSError:
|
|
13664
|
+
break
|
|
13665
|
+
if stdin_fd in readable:
|
|
13666
|
+
data = os.read(stdin_fd, 4096)
|
|
13667
|
+
if data:
|
|
13668
|
+
_write_fd_all(master_fd, data)
|
|
13669
|
+
if master_fd in readable:
|
|
13670
|
+
try:
|
|
13671
|
+
data = os.read(master_fd, 4096)
|
|
13672
|
+
except OSError:
|
|
13673
|
+
break
|
|
13674
|
+
if data:
|
|
13675
|
+
_write_fd_all(stdout_fd, data)
|
|
13676
|
+
now = time.time()
|
|
13677
|
+
if now - last_channel_poll >= 0.5:
|
|
13678
|
+
last_channel_poll = now
|
|
13679
|
+
marker = _chat_messages_file_marker()
|
|
13680
|
+
if marker != last_channel_marker:
|
|
13681
|
+
last_channel_marker = marker
|
|
13682
|
+
last_id = _inject_pending_channel_messages(master_fd, last_id)
|
|
13683
|
+
while True:
|
|
13684
|
+
try:
|
|
13685
|
+
readable, _, _ = select.select([master_fd], [], [], 0)
|
|
13686
|
+
if master_fd not in readable:
|
|
13687
|
+
break
|
|
13688
|
+
data = os.read(master_fd, 4096)
|
|
13689
|
+
if not data:
|
|
13690
|
+
break
|
|
13691
|
+
_write_fd_all(stdout_fd, data)
|
|
13692
|
+
except OSError:
|
|
13693
|
+
break
|
|
13694
|
+
return proc.returncode if proc.returncode is not None else 0
|
|
13695
|
+
finally:
|
|
13696
|
+
try:
|
|
13697
|
+
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
|
|
13698
|
+
except Exception:
|
|
13699
|
+
pass
|
|
13700
|
+
try:
|
|
13701
|
+
os.close(master_fd)
|
|
13702
|
+
except Exception:
|
|
13703
|
+
pass
|
|
13704
|
+
if proc.poll() is None:
|
|
13705
|
+
try:
|
|
13706
|
+
proc.terminate()
|
|
13707
|
+
except Exception:
|
|
13708
|
+
pass
|
|
13709
|
+
|
|
13710
|
+
|
|
13711
|
+
def _mcp_proxy_notification_payload(server_name: str, message: dict[str, Any]) -> dict[str, Any] | None:
|
|
13712
|
+
method = str(message.get("method") or "").strip()
|
|
13713
|
+
if not method.startswith("notifications/"):
|
|
13714
|
+
return None
|
|
13715
|
+
params = message.get("params") if isinstance(message.get("params"), dict) else {}
|
|
13716
|
+
payload = params.get("payload") if isinstance(params.get("payload"), dict) else {}
|
|
13717
|
+
data = params.get("data") if isinstance(params.get("data"), dict) else {}
|
|
13718
|
+
event = params.get("event") if isinstance(params.get("event"), dict) else {}
|
|
13719
|
+
meta: dict[str, Any] = {
|
|
13720
|
+
"mcp_server": server_name,
|
|
13721
|
+
"mcp_method": method,
|
|
13722
|
+
}
|
|
13723
|
+
meta.update(_event_meta_from_sources(message, params, payload, data, event))
|
|
13724
|
+
content = (
|
|
13725
|
+
_event_payload_text(params)
|
|
13726
|
+
or _event_payload_text(payload)
|
|
13727
|
+
or _event_payload_text(data)
|
|
13728
|
+
or _event_payload_text(event)
|
|
13729
|
+
)
|
|
13730
|
+
if not content and params:
|
|
13731
|
+
content = json.dumps(params, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
13732
|
+
if not content:
|
|
13733
|
+
return None
|
|
13734
|
+
channel = str(meta.get("channel") or meta.get("room_id") or meta.get("room") or server_name)
|
|
13735
|
+
return {
|
|
13736
|
+
"channel": channel,
|
|
13737
|
+
"sender_id": str(meta.get("sender_id") or meta.get("agent_id") or server_name),
|
|
13738
|
+
"recipients": meta.get("recipient_id") or "all",
|
|
13739
|
+
"thread_id": meta.get("thread_id"),
|
|
13740
|
+
"parent_id": meta.get("parent_id"),
|
|
13741
|
+
"kind": method.replace("notifications/claude/", "").replace("notifications/", "").replace("/", "."),
|
|
13742
|
+
"message": content,
|
|
13743
|
+
"meta": meta,
|
|
13744
|
+
}
|
|
13745
|
+
|
|
13746
|
+
|
|
13747
|
+
def _mcp_proxy_observe_stdout_line(server_name: str, line: bytes) -> None:
|
|
13748
|
+
try:
|
|
13749
|
+
text = line.decode("utf-8", errors="replace").strip()
|
|
13750
|
+
if not text or not text.startswith("{"):
|
|
13751
|
+
return
|
|
13752
|
+
payload = json.loads(text)
|
|
13753
|
+
except Exception:
|
|
13754
|
+
return
|
|
13755
|
+
if not isinstance(payload, dict):
|
|
13756
|
+
return
|
|
13757
|
+
chat_payload = _mcp_proxy_notification_payload(server_name, payload)
|
|
13758
|
+
if not chat_payload:
|
|
13759
|
+
return
|
|
13760
|
+
try:
|
|
13761
|
+
saved = append_chat_message(chat_payload)
|
|
13762
|
+
router_log(
|
|
13763
|
+
"INFO",
|
|
13764
|
+
f"mcp_proxy_notification server={server_name} method={payload.get('method')} message_id={saved.get('id')}",
|
|
13765
|
+
)
|
|
13766
|
+
except Exception as exc:
|
|
13767
|
+
router_log("WARN", f"mcp_proxy_notification_failed server={server_name} error={type(exc).__name__}: {exc}")
|
|
13768
|
+
|
|
13769
|
+
|
|
13770
|
+
def _mcp_proxy_forward_stdin(proc: subprocess.Popen[bytes]) -> None:
|
|
13771
|
+
try:
|
|
13772
|
+
while True:
|
|
13773
|
+
chunk = sys.stdin.buffer.read(65536)
|
|
13774
|
+
if not chunk:
|
|
13775
|
+
break
|
|
13776
|
+
if proc.stdin:
|
|
13777
|
+
proc.stdin.write(chunk)
|
|
13778
|
+
proc.stdin.flush()
|
|
13779
|
+
except Exception:
|
|
13780
|
+
pass
|
|
13781
|
+
finally:
|
|
13782
|
+
try:
|
|
13783
|
+
if proc.stdin:
|
|
13784
|
+
proc.stdin.close()
|
|
13785
|
+
except Exception:
|
|
13786
|
+
pass
|
|
13787
|
+
|
|
13788
|
+
|
|
13789
|
+
def _mcp_proxy_forward_stderr(proc: subprocess.Popen[bytes]) -> None:
|
|
13790
|
+
try:
|
|
13791
|
+
if not proc.stderr:
|
|
13792
|
+
return
|
|
13793
|
+
while True:
|
|
13794
|
+
chunk = proc.stderr.read(4096)
|
|
13795
|
+
if not chunk:
|
|
13796
|
+
break
|
|
13797
|
+
sys.stderr.buffer.write(chunk)
|
|
13798
|
+
sys.stderr.buffer.flush()
|
|
13799
|
+
except Exception:
|
|
13800
|
+
pass
|
|
13801
|
+
|
|
13802
|
+
|
|
13803
|
+
def run_mcp_stdio_proxy(server_name: str, server_config_path: Path) -> int:
|
|
13804
|
+
try:
|
|
13805
|
+
server = json.loads(server_config_path.read_text(encoding="utf-8"))
|
|
13806
|
+
except Exception as exc:
|
|
13807
|
+
print(f"claude-any mcp-proxy: cannot read server config: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
|
|
13808
|
+
return 2
|
|
13809
|
+
if not isinstance(server, dict) or not _mcp_server_is_stdio(server):
|
|
13810
|
+
print("claude-any mcp-proxy: server config is not a stdio MCP server", file=sys.stderr, flush=True)
|
|
13811
|
+
return 2
|
|
13812
|
+
command = str(server.get("command") or "").strip()
|
|
13813
|
+
args = [str(item) for item in server.get("args", [])] if isinstance(server.get("args"), list) else []
|
|
13814
|
+
env = os.environ.copy()
|
|
13815
|
+
raw_env = server.get("env")
|
|
13816
|
+
if isinstance(raw_env, dict):
|
|
13817
|
+
env.update({str(k): str(v) for k, v in raw_env.items() if str(k)})
|
|
13818
|
+
cwd_value = server.get("cwd") or server.get("workingDirectory")
|
|
13819
|
+
cwd = str(cwd_value) if cwd_value else None
|
|
13820
|
+
try:
|
|
13821
|
+
proc = subprocess.Popen(
|
|
13822
|
+
[command, *args],
|
|
13823
|
+
stdin=subprocess.PIPE,
|
|
13824
|
+
stdout=subprocess.PIPE,
|
|
13825
|
+
stderr=subprocess.PIPE,
|
|
13826
|
+
cwd=cwd,
|
|
13827
|
+
env=env,
|
|
13828
|
+
)
|
|
13829
|
+
except Exception as exc:
|
|
13830
|
+
print(f"claude-any mcp-proxy: failed to start {command}: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
|
|
13831
|
+
return 127
|
|
13832
|
+
threading.Thread(target=_mcp_proxy_forward_stdin, args=(proc,), daemon=True, name=f"mcp-proxy-stdin-{server_name}").start()
|
|
13833
|
+
threading.Thread(target=_mcp_proxy_forward_stderr, args=(proc,), daemon=True, name=f"mcp-proxy-stderr-{server_name}").start()
|
|
13834
|
+
try:
|
|
13835
|
+
if proc.stdout:
|
|
13836
|
+
for line in iter(proc.stdout.readline, b""):
|
|
13837
|
+
_mcp_proxy_observe_stdout_line(server_name, line)
|
|
13838
|
+
sys.stdout.buffer.write(line)
|
|
13839
|
+
sys.stdout.buffer.flush()
|
|
13840
|
+
return proc.wait()
|
|
13841
|
+
finally:
|
|
13842
|
+
if proc.poll() is None:
|
|
13843
|
+
try:
|
|
13844
|
+
proc.terminate()
|
|
13845
|
+
except Exception:
|
|
13846
|
+
pass
|
|
13847
|
+
|
|
13848
|
+
|
|
13849
|
+
def cmd_mcp_proxy(argv: list[str]) -> int:
|
|
13850
|
+
parser = argparse.ArgumentParser(prog="claude-any mcp-proxy")
|
|
13851
|
+
parser.add_argument("--server-name", required=True)
|
|
13852
|
+
parser.add_argument("--server-config", required=True)
|
|
13853
|
+
args = parser.parse_args(argv)
|
|
13854
|
+
return run_mcp_stdio_proxy(args.server_name, Path(args.server_config).expanduser())
|
|
13855
|
+
|
|
13856
|
+
|
|
12963
13857
|
def run_claude_update_check(claude: str, enabled: bool = True) -> None:
|
|
12964
13858
|
if not enabled:
|
|
12965
13859
|
return
|
|
@@ -13264,14 +14158,16 @@ def launch_claude(
|
|
|
13264
14158
|
use_native_anthropic = native_anthropic_enabled(provider)
|
|
13265
14159
|
use_ollama_native = ollama_native_compat_enabled(provider, pcfg)
|
|
13266
14160
|
use_provider_native = provider_native_compat_enabled(provider, pcfg)
|
|
14161
|
+
use_router_mode = not (use_native_anthropic or use_ollama_native or use_provider_native)
|
|
13267
14162
|
cleanup_managed_services_for_provider(provider, pcfg, cfg, quiet=True)
|
|
13268
|
-
if
|
|
14163
|
+
if use_router_mode:
|
|
13269
14164
|
start_router_if_needed()
|
|
13270
14165
|
env = os.environ.copy()
|
|
13271
14166
|
env["PATH"] = str(HOME / ".local" / "bin") + os.pathsep + env.get("PATH", "")
|
|
13272
14167
|
launch_env = env_vars(cfg)
|
|
13273
14168
|
launch_passthrough = normalize_channel_passthrough(passthrough)
|
|
13274
14169
|
if claude_channels_requested(cfg, launch_passthrough):
|
|
14170
|
+
env.pop("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", None)
|
|
13275
14171
|
launch_env.pop("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", None)
|
|
13276
14172
|
if use_native_anthropic:
|
|
13277
14173
|
for key in (
|
|
@@ -13302,8 +14198,21 @@ def launch_claude(
|
|
|
13302
14198
|
run_claude_update_check(claude, enabled=update_check)
|
|
13303
14199
|
claude = find_executable("claude") or claude
|
|
13304
14200
|
extra_args: list[str] = []
|
|
14201
|
+
mcp_config_paths: list[str] = []
|
|
13305
14202
|
if should_attach_web_search(provider, cfg, web_search_override):
|
|
13306
|
-
|
|
14203
|
+
mcp_config_paths.append(str(write_duckduckgo_mcp_config(cfg)))
|
|
14204
|
+
claude_passthrough = list(launch_passthrough)
|
|
14205
|
+
if should_use_channel_stdin_proxy(use_router_mode, launch_passthrough):
|
|
14206
|
+
auto_start_sse_channels_from_mcp_configs(launch_passthrough)
|
|
14207
|
+
proxy_config = write_mcp_proxy_config(
|
|
14208
|
+
launch_passthrough,
|
|
14209
|
+
extra_config_paths=[Path(path) for path in mcp_config_paths],
|
|
14210
|
+
)
|
|
14211
|
+
if proxy_config:
|
|
14212
|
+
mcp_config_paths = [str(proxy_config)]
|
|
14213
|
+
claude_passthrough = strip_mcp_config_passthrough(launch_passthrough)
|
|
14214
|
+
if mcp_config_paths:
|
|
14215
|
+
extra_args.extend(["--mcp-config", *mcp_config_paths])
|
|
13307
14216
|
if should_append_compat_prompt(provider, cfg) and not has_passthrough_option(launch_passthrough, "--system-prompt"):
|
|
13308
14217
|
extra_args.extend(["--append-system-prompt", NON_ANTHROPIC_COMPAT_PROMPT])
|
|
13309
14218
|
extra_args.extend(claude_channel_args(cfg, launch_passthrough))
|
|
@@ -13315,7 +14224,9 @@ def launch_claude(
|
|
|
13315
14224
|
if model:
|
|
13316
14225
|
cmd.extend(["--model", model])
|
|
13317
14226
|
cmd.extend(extra_args)
|
|
13318
|
-
cmd.extend(
|
|
14227
|
+
cmd.extend(claude_passthrough)
|
|
14228
|
+
if should_use_channel_stdin_proxy(use_router_mode, launch_passthrough):
|
|
14229
|
+
return subprocess_call_with_channel_wake_proxy(cmd, env)
|
|
13319
14230
|
return subprocess.call(cmd, env=env)
|
|
13320
14231
|
|
|
13321
14232
|
|
|
@@ -13337,7 +14248,8 @@ Control plane, runs before Claude Code and does not require LLM connectivity:
|
|
|
13337
14248
|
claude-any set-api-key PROVIDER KEY
|
|
13338
14249
|
claude-any web-search [on|off] Auto-attach DuckDuckGo MCP for non-native providers
|
|
13339
14250
|
claude-any web-fetch [on|off] Auto-attach fetch MCP for web page content
|
|
13340
|
-
claude-any
|
|
14251
|
+
claude-any log-level [LEVEL] Show or set router log level
|
|
14252
|
+
claude-any channels [cmd] Configure external channel specs
|
|
13341
14253
|
claude-any ollama-native [on|off] Use Ollama's official Claude Code env path
|
|
13342
14254
|
claude-any ollama-options [provider] [key=value ...]
|
|
13343
14255
|
Set Ollama num_ctx/options/keep_alive/think
|
|
@@ -13372,12 +14284,13 @@ Headless setup flags, namespaced to avoid Claude CLI collisions:
|
|
|
13372
14284
|
claude-any --ca-rate-limit-status on|off
|
|
13373
14285
|
claude-any --ca-stream on|off
|
|
13374
14286
|
claude-any --ca-stream-word-chunking on|off
|
|
14287
|
+
claude-any --ca-log-level LEVEL Set router log level: SILENT, ERROR, WARN, INFO, DEBUG, TRACE
|
|
13375
14288
|
claude-any --ca-web-search Force DuckDuckGo MCP for this launch
|
|
13376
14289
|
claude-any --ca-no-web-search Disable DuckDuckGo MCP for this launch
|
|
13377
14290
|
claude-any --ca-web-fetch Enable fetch MCP
|
|
13378
14291
|
claude-any --ca-no-web-fetch Disable fetch MCP
|
|
13379
14292
|
claude-any --ca-channel SPEC Add an official/approved Claude Code channel
|
|
13380
|
-
claude-any --ca-clear-channels Clear saved channel
|
|
14293
|
+
claude-any --ca-clear-channels Clear saved channel specs
|
|
13381
14294
|
claude-any --ca-no-self-update-check
|
|
13382
14295
|
Skip Claude Any npm self-update check
|
|
13383
14296
|
claude-any --ca-no-update-check Skip Claude Code update check for this launch
|
|
@@ -13505,6 +14418,8 @@ def apply_headless_env_config() -> tuple[bool, bool | None, bool | None, bool |
|
|
|
13505
14418
|
|
|
13506
14419
|
|
|
13507
14420
|
def run_cli(argv: list[str]) -> int:
|
|
14421
|
+
if argv and argv[0] == "mcp-proxy":
|
|
14422
|
+
return cmd_mcp_proxy(argv[1:])
|
|
13508
14423
|
if argv and argv[0] in ("help", "--help", "-h"):
|
|
13509
14424
|
print(cli_usage())
|
|
13510
14425
|
return 0
|
|
@@ -13560,6 +14475,9 @@ def run_cli(argv: list[str]) -> int:
|
|
|
13560
14475
|
if head in ("web-fetch", "webfetch"):
|
|
13561
14476
|
cmd_web_fetch(argparse.Namespace(value=rest[0] if rest else None))
|
|
13562
14477
|
return 0
|
|
14478
|
+
if head in ("log-level", "loglevel", "logging"):
|
|
14479
|
+
cmd_log_level(argparse.Namespace(value=rest[0] if rest else None))
|
|
14480
|
+
return 0
|
|
13563
14481
|
if head in ("channels", "channel"):
|
|
13564
14482
|
cmd_channels(argparse.Namespace(values=rest))
|
|
13565
14483
|
return 0
|
|
@@ -13881,6 +14799,18 @@ def run_cli(argv: list[str]) -> int:
|
|
|
13881
14799
|
i += 1
|
|
13882
14800
|
cmd_provider_options(argparse.Namespace(values=[f"stream_word_chunking={value}"]))
|
|
13883
14801
|
skip_menu = True
|
|
14802
|
+
elif arg == "--ca-log-level" or arg.startswith("--ca-log-level="):
|
|
14803
|
+
value = arg.split("=", 1)[1] if "=" in arg else None
|
|
14804
|
+
if value is None:
|
|
14805
|
+
if i + 1 >= len(argv):
|
|
14806
|
+
raise SystemExit("Missing level for --ca-log-level")
|
|
14807
|
+
value = argv[i + 1]
|
|
14808
|
+
i += 2
|
|
14809
|
+
else:
|
|
14810
|
+
i += 1
|
|
14811
|
+
for line in set_log_level_config(value):
|
|
14812
|
+
print(line)
|
|
14813
|
+
skip_menu = True
|
|
13884
14814
|
elif arg == "--ca-web-search":
|
|
13885
14815
|
web_search_override = True
|
|
13886
14816
|
skip_menu = True
|
|
@@ -14008,6 +14938,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
14008
14938
|
wf = sub.add_parser("web-fetch")
|
|
14009
14939
|
wf.add_argument("value", nargs="?")
|
|
14010
14940
|
wf.set_defaults(func=cmd_web_fetch)
|
|
14941
|
+
ll = sub.add_parser("log-level")
|
|
14942
|
+
ll.add_argument("value", nargs="?")
|
|
14943
|
+
ll.set_defaults(func=cmd_log_level)
|
|
14011
14944
|
ch = sub.add_parser("channels")
|
|
14012
14945
|
ch.add_argument("values", nargs="*")
|
|
14013
14946
|
ch.set_defaults(func=cmd_channels)
|
package/package.json
CHANGED