@oneciel-ai/claude-any 0.1.75 → 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 +1008 -65
- 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,13 +8982,300 @@ def channel_specs(cfg: dict[str, Any] | None = None) -> list[str]:
|
|
|
8759
8982
|
return channels
|
|
8760
8983
|
|
|
8761
8984
|
|
|
8762
|
-
def
|
|
8763
|
-
|
|
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
|
|
8764
8995
|
|
|
8765
8996
|
|
|
8766
|
-
def
|
|
8767
|
-
|
|
8768
|
-
|
|
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
|
+
|
|
9277
|
+
def is_channel_spec_tagged(spec: str) -> bool:
|
|
9278
|
+
return spec.startswith("plugin:") or spec.startswith("server:")
|
|
8769
9279
|
|
|
8770
9280
|
|
|
8771
9281
|
def channel_status_text(cfg: dict[str, Any] | None = None) -> str:
|
|
@@ -8773,15 +9283,11 @@ def channel_status_text(cfg: dict[str, Any] | None = None) -> str:
|
|
|
8773
9283
|
channels = channel_specs(cfg)
|
|
8774
9284
|
if not channels:
|
|
8775
9285
|
return "off"
|
|
8776
|
-
|
|
8777
|
-
return f"{len(channels)} channel{'s' if len(channels) != 1 else ''}{suffix}"
|
|
9286
|
+
return f"{len(channels)} channel{'s' if len(channels) != 1 else ''}"
|
|
8778
9287
|
|
|
8779
9288
|
|
|
8780
9289
|
def set_channel_development_enabled(enabled: bool) -> list[str]:
|
|
8781
|
-
|
|
8782
|
-
cfg.setdefault("claude_code", {})["development_channels"] = bool(enabled)
|
|
8783
|
-
save_config(cfg)
|
|
8784
|
-
return [f"Development channels: {'on' if enabled else 'off'}."]
|
|
9290
|
+
return ["Channel wake delivery is always enabled by Claude Any."]
|
|
8785
9291
|
|
|
8786
9292
|
|
|
8787
9293
|
def add_channel_spec(spec: str, *, development: bool = False) -> list[str]:
|
|
@@ -8796,13 +9302,8 @@ def add_channel_spec(spec: str, *, development: bool = False) -> list[str]:
|
|
|
8796
9302
|
if spec not in channels:
|
|
8797
9303
|
channels.append(spec)
|
|
8798
9304
|
cc["channels"] = channels
|
|
8799
|
-
if development:
|
|
8800
|
-
cc["development_channels"] = True
|
|
8801
9305
|
save_config(cfg)
|
|
8802
|
-
|
|
8803
|
-
if development:
|
|
8804
|
-
lines.append("Development channels: on.")
|
|
8805
|
-
return lines
|
|
9306
|
+
return [f"Channel added: {spec}."]
|
|
8806
9307
|
|
|
8807
9308
|
|
|
8808
9309
|
def remove_channel_spec(spec: str) -> list[str]:
|
|
@@ -8833,7 +9334,6 @@ def cmd_channels(args: argparse.Namespace) -> None:
|
|
|
8833
9334
|
for spec in channel_specs(cfg):
|
|
8834
9335
|
if spec not in OFFICIAL_CHANNEL_PLUGINS.values():
|
|
8835
9336
|
print(f" * custom {spec}")
|
|
8836
|
-
print(f"development_channels: {'on' if channel_development_enabled(cfg) else 'off'}")
|
|
8837
9337
|
return
|
|
8838
9338
|
head = values[0].strip().lower()
|
|
8839
9339
|
if head in ("on", "enable", "add"):
|
|
@@ -8844,13 +9344,12 @@ def cmd_channels(args: argparse.Namespace) -> None:
|
|
|
8844
9344
|
return
|
|
8845
9345
|
if head in ("dev", "development"):
|
|
8846
9346
|
if len(values) >= 2 and values[1].lower() in ("on", "off", "true", "false", "1", "0"):
|
|
8847
|
-
|
|
8848
|
-
for line in set_channel_development_enabled(enabled):
|
|
9347
|
+
for line in set_channel_development_enabled(True):
|
|
8849
9348
|
print(line)
|
|
8850
9349
|
return
|
|
8851
9350
|
if len(values) < 2:
|
|
8852
|
-
raise SystemExit("Usage: claude-any channels
|
|
8853
|
-
for line in add_channel_spec(values[1]
|
|
9351
|
+
raise SystemExit("Usage: claude-any channels add CHANNEL_SPEC")
|
|
9352
|
+
for line in add_channel_spec(values[1]):
|
|
8854
9353
|
print(line)
|
|
8855
9354
|
return
|
|
8856
9355
|
if head in ("off", "disable", "remove", "rm"):
|
|
@@ -12062,7 +12561,7 @@ def main_menu_rows(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any], lan
|
|
|
12062
12561
|
f"4. {ui_text('model', lang)} [{compact_text(pcfg.get('current_model', 'unset'), 62)}]",
|
|
12063
12562
|
f"5. {ui_text('advisor_model', lang)} [{compact_text(pcfg.get('advisor_model') or 'off', 62)}]",
|
|
12064
12563
|
f"6. {ui_text('options', lang)} [{compact_text(llm_options_status(provider, pcfg), 62)}]",
|
|
12065
|
-
f"7.
|
|
12564
|
+
f"7. {ui_text('log_level', lang)} [{log_level_status()}]",
|
|
12066
12565
|
f"8. {ui_text('test', lang)}",
|
|
12067
12566
|
f"9. {ui_text('launch', lang)}",
|
|
12068
12567
|
ui_text("quit", lang),
|
|
@@ -12092,6 +12591,30 @@ def language_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
|
12092
12591
|
return rows, values
|
|
12093
12592
|
|
|
12094
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
|
+
|
|
12095
12618
|
def model_panel_rows(provider: str, pcfg: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
12096
12619
|
values = unique_model_ids(provider, upstream_model_ids(provider, pcfg))
|
|
12097
12620
|
rows: list[str] = []
|
|
@@ -12138,16 +12661,13 @@ def advisor_model_panel_rows(provider: str, pcfg: dict[str, Any]) -> tuple[list[
|
|
|
12138
12661
|
|
|
12139
12662
|
def channel_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
12140
12663
|
channels = channel_specs(cfg)
|
|
12141
|
-
dev_enabled = channel_development_enabled(cfg)
|
|
12142
12664
|
rows: list[str] = []
|
|
12143
12665
|
values: list[str] = []
|
|
12144
|
-
rows.append(f"Development channel loading [{'on' if dev_enabled else 'off'}]")
|
|
12145
|
-
values.append("__toggle_dev__")
|
|
12146
12666
|
for name, spec in OFFICIAL_CHANNEL_PLUGINS.items():
|
|
12147
12667
|
mark = "*" if spec in channels else " "
|
|
12148
12668
|
rows.append(f"{mark} {name:<10} {spec}")
|
|
12149
12669
|
values.append(spec)
|
|
12150
|
-
rows.append("+ Add
|
|
12670
|
+
rows.append("+ Add custom channel...")
|
|
12151
12671
|
values.append("__add_custom__")
|
|
12152
12672
|
if channels:
|
|
12153
12673
|
rows.append("- Remove channel...")
|
|
@@ -12501,6 +13021,8 @@ def portable_prelaunch_menu() -> int:
|
|
|
12501
13021
|
panel_rows, panel_values = ["Run compatibility test", "Back"], ["run", "back"]
|
|
12502
13022
|
elif name == "options":
|
|
12503
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)
|
|
12504
13026
|
elif name == "channels":
|
|
12505
13027
|
panel_rows, panel_values = channel_panel_rows(cfg)
|
|
12506
13028
|
elif name == "context":
|
|
@@ -12680,18 +13202,22 @@ def portable_prelaunch_menu() -> int:
|
|
|
12680
13202
|
panel_rows, panel_values = ["Run compatibility test again", "Back"], ["run", "back"]
|
|
12681
13203
|
refresh_checks()
|
|
12682
13204
|
main_idx = 9 if "Compatibility: OK" in out else 4
|
|
12683
|
-
elif panel == "
|
|
13205
|
+
elif panel == "log-level":
|
|
12684
13206
|
if value == "back":
|
|
12685
13207
|
close_panel()
|
|
12686
|
-
elif value
|
|
12687
|
-
messages =
|
|
13208
|
+
elif value:
|
|
13209
|
+
messages = set_log_level_config(value)
|
|
13210
|
+
refresh_checks()
|
|
12688
13211
|
cfg = load_config()
|
|
12689
|
-
panel_rows, panel_values =
|
|
12690
|
-
panel_idx = 0
|
|
13212
|
+
panel_rows, panel_values = log_level_panel_rows(cfg)
|
|
13213
|
+
panel_idx = max(0, min(panel_idx, len(panel_rows) - 1))
|
|
13214
|
+
elif panel == "channels":
|
|
13215
|
+
if value == "back":
|
|
13216
|
+
close_panel()
|
|
12691
13217
|
elif value == "__add_custom__":
|
|
12692
13218
|
spec = prompt_menu_value("Channel spec (for example plugin:ainet@local or server:ainet)", restore_tty=restore_line_mode, raw_tty=restore_raw_mode)
|
|
12693
13219
|
if spec:
|
|
12694
|
-
messages = add_channel_spec(spec
|
|
13220
|
+
messages = add_channel_spec(spec)
|
|
12695
13221
|
cfg = load_config()
|
|
12696
13222
|
panel_rows, panel_values = channel_panel_rows(cfg)
|
|
12697
13223
|
elif value == "__remove__":
|
|
@@ -12817,7 +13343,7 @@ def portable_prelaunch_menu() -> int:
|
|
|
12817
13343
|
elif key in ("esc", "q"):
|
|
12818
13344
|
return 10
|
|
12819
13345
|
elif key == "enter":
|
|
12820
|
-
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"]
|
|
12821
13347
|
action = actions[main_idx]
|
|
12822
13348
|
if action == "launch":
|
|
12823
13349
|
blockers = launch_readiness_errors()
|
|
@@ -12931,17 +13457,16 @@ def normalize_channel_passthrough(passthrough: list[str]) -> list[str]:
|
|
|
12931
13457
|
return normalized
|
|
12932
13458
|
|
|
12933
13459
|
|
|
12934
|
-
def
|
|
12935
|
-
|
|
12936
|
-
if not channels or has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels"):
|
|
12937
|
-
return []
|
|
12938
|
-
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")
|
|
12939
13462
|
|
|
12940
13463
|
|
|
12941
|
-
def
|
|
12942
|
-
|
|
12943
|
-
|
|
12944
|
-
|
|
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)
|
|
12945
13470
|
|
|
12946
13471
|
|
|
12947
13472
|
def write_web_tools_mcp_config(cfg: dict[str, Any]) -> Path:
|
|
@@ -12984,6 +13509,351 @@ def write_duckduckgo_mcp_config(cfg: dict[str, Any]) -> Path:
|
|
|
12984
13509
|
return path
|
|
12985
13510
|
|
|
12986
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
|
+
|
|
12987
13857
|
def run_claude_update_check(claude: str, enabled: bool = True) -> None:
|
|
12988
13858
|
if not enabled:
|
|
12989
13859
|
return
|
|
@@ -13076,6 +13946,29 @@ def npm_latest_package_version(npm: str, package_spec: str, timeout: float = 8.0
|
|
|
13076
13946
|
return out.splitlines()[-1].strip() if out else ""
|
|
13077
13947
|
|
|
13078
13948
|
|
|
13949
|
+
def npm_global_package_root(npm: str, package_name: str = "@oneciel-ai/claude-any", timeout: float = 8.0) -> Path | None:
|
|
13950
|
+
try:
|
|
13951
|
+
p = subprocess.run(
|
|
13952
|
+
[npm, "root", "-g"],
|
|
13953
|
+
text=True,
|
|
13954
|
+
stdout=subprocess.PIPE,
|
|
13955
|
+
stderr=subprocess.DEVNULL,
|
|
13956
|
+
timeout=timeout,
|
|
13957
|
+
)
|
|
13958
|
+
except Exception:
|
|
13959
|
+
return None
|
|
13960
|
+
if p.returncode != 0:
|
|
13961
|
+
return None
|
|
13962
|
+
root = (p.stdout or "").strip()
|
|
13963
|
+
if not root:
|
|
13964
|
+
return None
|
|
13965
|
+
package_path = Path(root)
|
|
13966
|
+
for part in package_name.split("/"):
|
|
13967
|
+
if part:
|
|
13968
|
+
package_path /= part
|
|
13969
|
+
return package_path
|
|
13970
|
+
|
|
13971
|
+
|
|
13079
13972
|
def claude_code_current_version(claude: str) -> str:
|
|
13080
13973
|
try:
|
|
13081
13974
|
p = subprocess.run(
|
|
@@ -13100,6 +13993,26 @@ def running_from_npm_package() -> bool:
|
|
|
13100
13993
|
return "/node_modules/@oneciel-ai/claude-any/" in path
|
|
13101
13994
|
|
|
13102
13995
|
|
|
13996
|
+
def claude_any_restart_user_args() -> list[str]:
|
|
13997
|
+
args = list(sys.argv[1:])
|
|
13998
|
+
if args and args[0] == "cli":
|
|
13999
|
+
return args[1:]
|
|
14000
|
+
return args
|
|
14001
|
+
|
|
14002
|
+
|
|
14003
|
+
def restart_claude_any_after_update(npm: str) -> None:
|
|
14004
|
+
os.environ["CLAUDE_ANY_SKIP_SELF_UPDATE"] = "1"
|
|
14005
|
+
user_args = claude_any_restart_user_args()
|
|
14006
|
+
package_root = npm_global_package_root(npm)
|
|
14007
|
+
package_script = package_root / "claude_any.py" if package_root else None
|
|
14008
|
+
if package_script and package_script.exists():
|
|
14009
|
+
os.execv(sys.executable, [sys.executable, str(package_script), "cli", *user_args])
|
|
14010
|
+
launcher = find_executable("claude-any")
|
|
14011
|
+
if launcher:
|
|
14012
|
+
raise SystemExit(subprocess.call([launcher, *user_args], env=os.environ.copy()))
|
|
14013
|
+
os.execv(sys.executable, [sys.executable, *sys.argv])
|
|
14014
|
+
|
|
14015
|
+
|
|
13103
14016
|
def run_claude_any_update_check(enabled: bool = True) -> bool:
|
|
13104
14017
|
if not enabled:
|
|
13105
14018
|
return False
|
|
@@ -13142,9 +14055,10 @@ def run_claude_any_update_check(enabled: bool = True) -> bool:
|
|
|
13142
14055
|
print(f"Claude Any update exited with {update.returncode}; continuing with current version.", flush=True)
|
|
13143
14056
|
return False
|
|
13144
14057
|
print("Claude Any updated. Restarting with the new version...", flush=True)
|
|
13145
|
-
os.environ["CLAUDE_ANY_SKIP_SELF_UPDATE"] = "1"
|
|
13146
14058
|
try:
|
|
13147
|
-
|
|
14059
|
+
restart_claude_any_after_update(npm)
|
|
14060
|
+
except SystemExit:
|
|
14061
|
+
raise
|
|
13148
14062
|
except Exception as exc:
|
|
13149
14063
|
print(f"Restart failed ({type(exc).__name__}); continuing with the current process.", flush=True)
|
|
13150
14064
|
return True
|
|
@@ -13244,14 +14158,16 @@ def launch_claude(
|
|
|
13244
14158
|
use_native_anthropic = native_anthropic_enabled(provider)
|
|
13245
14159
|
use_ollama_native = ollama_native_compat_enabled(provider, pcfg)
|
|
13246
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)
|
|
13247
14162
|
cleanup_managed_services_for_provider(provider, pcfg, cfg, quiet=True)
|
|
13248
|
-
if
|
|
14163
|
+
if use_router_mode:
|
|
13249
14164
|
start_router_if_needed()
|
|
13250
14165
|
env = os.environ.copy()
|
|
13251
14166
|
env["PATH"] = str(HOME / ".local" / "bin") + os.pathsep + env.get("PATH", "")
|
|
13252
14167
|
launch_env = env_vars(cfg)
|
|
13253
14168
|
launch_passthrough = normalize_channel_passthrough(passthrough)
|
|
13254
14169
|
if claude_channels_requested(cfg, launch_passthrough):
|
|
14170
|
+
env.pop("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", None)
|
|
13255
14171
|
launch_env.pop("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", None)
|
|
13256
14172
|
if use_native_anthropic:
|
|
13257
14173
|
for key in (
|
|
@@ -13282,8 +14198,21 @@ def launch_claude(
|
|
|
13282
14198
|
run_claude_update_check(claude, enabled=update_check)
|
|
13283
14199
|
claude = find_executable("claude") or claude
|
|
13284
14200
|
extra_args: list[str] = []
|
|
14201
|
+
mcp_config_paths: list[str] = []
|
|
13285
14202
|
if should_attach_web_search(provider, cfg, web_search_override):
|
|
13286
|
-
|
|
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])
|
|
13287
14216
|
if should_append_compat_prompt(provider, cfg) and not has_passthrough_option(launch_passthrough, "--system-prompt"):
|
|
13288
14217
|
extra_args.extend(["--append-system-prompt", NON_ANTHROPIC_COMPAT_PROMPT])
|
|
13289
14218
|
extra_args.extend(claude_channel_args(cfg, launch_passthrough))
|
|
@@ -13295,7 +14224,9 @@ def launch_claude(
|
|
|
13295
14224
|
if model:
|
|
13296
14225
|
cmd.extend(["--model", model])
|
|
13297
14226
|
cmd.extend(extra_args)
|
|
13298
|
-
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)
|
|
13299
14230
|
return subprocess.call(cmd, env=env)
|
|
13300
14231
|
|
|
13301
14232
|
|
|
@@ -13317,7 +14248,8 @@ Control plane, runs before Claude Code and does not require LLM connectivity:
|
|
|
13317
14248
|
claude-any set-api-key PROVIDER KEY
|
|
13318
14249
|
claude-any web-search [on|off] Auto-attach DuckDuckGo MCP for non-native providers
|
|
13319
14250
|
claude-any web-fetch [on|off] Auto-attach fetch MCP for web page content
|
|
13320
|
-
claude-any
|
|
14251
|
+
claude-any log-level [LEVEL] Show or set router log level
|
|
14252
|
+
claude-any channels [cmd] Configure external channel specs
|
|
13321
14253
|
claude-any ollama-native [on|off] Use Ollama's official Claude Code env path
|
|
13322
14254
|
claude-any ollama-options [provider] [key=value ...]
|
|
13323
14255
|
Set Ollama num_ctx/options/keep_alive/think
|
|
@@ -13352,15 +14284,13 @@ Headless setup flags, namespaced to avoid Claude CLI collisions:
|
|
|
13352
14284
|
claude-any --ca-rate-limit-status on|off
|
|
13353
14285
|
claude-any --ca-stream on|off
|
|
13354
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
|
|
13355
14288
|
claude-any --ca-web-search Force DuckDuckGo MCP for this launch
|
|
13356
14289
|
claude-any --ca-no-web-search Disable DuckDuckGo MCP for this launch
|
|
13357
14290
|
claude-any --ca-web-fetch Enable fetch MCP
|
|
13358
14291
|
claude-any --ca-no-web-fetch Disable fetch MCP
|
|
13359
14292
|
claude-any --ca-channel SPEC Add an official/approved Claude Code channel
|
|
13360
|
-
claude-any --ca-
|
|
13361
|
-
claude-any --ca-development-channels on|off
|
|
13362
|
-
Use tagged specs with --dangerously-load-development-channels
|
|
13363
|
-
claude-any --ca-clear-channels Clear saved channel auto-injection specs
|
|
14293
|
+
claude-any --ca-clear-channels Clear saved channel specs
|
|
13364
14294
|
claude-any --ca-no-self-update-check
|
|
13365
14295
|
Skip Claude Any npm self-update check
|
|
13366
14296
|
claude-any --ca-no-update-check Skip Claude Code update check for this launch
|
|
@@ -13482,18 +14412,14 @@ def apply_headless_env_config() -> tuple[bool, bool | None, bool | None, bool |
|
|
|
13482
14412
|
if item.strip()
|
|
13483
14413
|
]
|
|
13484
14414
|
for channel_value in dev_channel_values:
|
|
13485
|
-
add_channel_spec(channel_value
|
|
13486
|
-
skip_menu = True
|
|
13487
|
-
dev_channels = os.environ.get("CLAUDE_ANY_DEVELOPMENT_CHANNELS", "").strip().lower()
|
|
13488
|
-
if dev_channels:
|
|
13489
|
-
if dev_channels not in ("on", "off", "true", "false", "1", "0"):
|
|
13490
|
-
raise SystemExit("CLAUDE_ANY_DEVELOPMENT_CHANNELS must be on or off")
|
|
13491
|
-
set_channel_development_enabled(dev_channels in ("on", "true", "1"))
|
|
14415
|
+
add_channel_spec(channel_value)
|
|
13492
14416
|
skip_menu = True
|
|
13493
14417
|
return skip_menu, web_search_override, update_check_override, self_update_check_override, force_menu
|
|
13494
14418
|
|
|
13495
14419
|
|
|
13496
14420
|
def run_cli(argv: list[str]) -> int:
|
|
14421
|
+
if argv and argv[0] == "mcp-proxy":
|
|
14422
|
+
return cmd_mcp_proxy(argv[1:])
|
|
13497
14423
|
if argv and argv[0] in ("help", "--help", "-h"):
|
|
13498
14424
|
print(cli_usage())
|
|
13499
14425
|
return 0
|
|
@@ -13549,6 +14475,9 @@ def run_cli(argv: list[str]) -> int:
|
|
|
13549
14475
|
if head in ("web-fetch", "webfetch"):
|
|
13550
14476
|
cmd_web_fetch(argparse.Namespace(value=rest[0] if rest else None))
|
|
13551
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
|
|
13552
14481
|
if head in ("channels", "channel"):
|
|
13553
14482
|
cmd_channels(argparse.Namespace(values=rest))
|
|
13554
14483
|
return 0
|
|
@@ -13870,6 +14799,18 @@ def run_cli(argv: list[str]) -> int:
|
|
|
13870
14799
|
i += 1
|
|
13871
14800
|
cmd_provider_options(argparse.Namespace(values=[f"stream_word_chunking={value}"]))
|
|
13872
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
|
|
13873
14814
|
elif arg == "--ca-web-search":
|
|
13874
14815
|
web_search_override = True
|
|
13875
14816
|
skip_menu = True
|
|
@@ -13907,7 +14848,7 @@ def run_cli(argv: list[str]) -> int:
|
|
|
13907
14848
|
i += 2
|
|
13908
14849
|
else:
|
|
13909
14850
|
i += 1
|
|
13910
|
-
for line in add_channel_spec(value
|
|
14851
|
+
for line in add_channel_spec(value):
|
|
13911
14852
|
print(line)
|
|
13912
14853
|
skip_menu = True
|
|
13913
14854
|
elif arg == "--ca-development-channels" or arg.startswith("--ca-development-channels="):
|
|
@@ -13919,8 +14860,7 @@ def run_cli(argv: list[str]) -> int:
|
|
|
13919
14860
|
i += 2
|
|
13920
14861
|
else:
|
|
13921
14862
|
i += 1
|
|
13922
|
-
|
|
13923
|
-
for line in set_channel_development_enabled(enabled):
|
|
14863
|
+
for line in set_channel_development_enabled(True):
|
|
13924
14864
|
print(line)
|
|
13925
14865
|
skip_menu = True
|
|
13926
14866
|
elif arg == "--ca-clear-channels":
|
|
@@ -13998,6 +14938,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
13998
14938
|
wf = sub.add_parser("web-fetch")
|
|
13999
14939
|
wf.add_argument("value", nargs="?")
|
|
14000
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)
|
|
14001
14944
|
ch = sub.add_parser("channels")
|
|
14002
14945
|
ch.add_argument("values", nargs="*")
|
|
14003
14946
|
ch.set_defaults(func=cmd_channels)
|