@oneciel-ai/claude-any 0.1.97 → 0.1.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/claude_any.py +483 -23
  2. package/package.json +1 -1
package/claude_any.py CHANGED
@@ -60,6 +60,7 @@ WEB_TOOLS_MCP_CONFIG = CONFIG_DIR / "web-tools-mcp.json"
60
60
  DUCKDUCKGO_MCP_CONFIG = CONFIG_DIR / "duckduckgo-mcp.json"
61
61
  CHANNEL_MCP_CONFIG = CONFIG_DIR / "channel-mcp.json"
62
62
  CHANNEL_MCP_CURSOR_PATH = CONFIG_DIR / "channel-mcp-cursor.json"
63
+ CHANNEL_PROBE_CACHE_PATH = CONFIG_DIR / "channel-probe-cache.json"
63
64
  MCP_PROXY_CONFIG = CONFIG_DIR / "mcp-proxy.json"
64
65
  ROUTER_HOST = os.environ.get("CLAUDE_ANY_ROUTER_CLIENT_HOST", "127.0.0.1").strip() or "127.0.0.1"
65
66
  ROUTER_PORT = 8799
@@ -106,7 +107,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
106
107
  "fakechat": "plugin:fakechat@claude-plugins-official",
107
108
  }
108
109
  APP_NAME = "Claude Any"
109
- VERSION = "0.1.97"
110
+ VERSION = "0.1.98"
110
111
  CREDITS = "Credits: One Ciel LLC"
111
112
 
112
113
  LOG_LEVELS = {"SILENT": 0, "ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4, "TRACE": 5}
@@ -1061,6 +1062,7 @@ UI_TEXT = {
1061
1062
  "test": "Test compatibility",
1062
1063
  "options": "LLM options",
1063
1064
  "channel_delivery": "Channel delivery",
1065
+ "channels": "Channels",
1064
1066
  "log_level": "Log level",
1065
1067
  "presets": "LLM presets",
1066
1068
  "context_setup": "Context setup",
@@ -1083,6 +1085,7 @@ UI_TEXT = {
1083
1085
  "test": "호환성 테스트",
1084
1086
  "options": "LLM 옵션",
1085
1087
  "channel_delivery": "채널 전달 방식",
1088
+ "channels": "채널",
1086
1089
  "log_level": "로그 레벨",
1087
1090
  "presets": "LLM 프리셋",
1088
1091
  "context_setup": "컨텍스트 설정",
@@ -1105,6 +1108,7 @@ UI_TEXT = {
1105
1108
  "test": "互換性テスト",
1106
1109
  "options": "LLMオプション",
1107
1110
  "channel_delivery": "チャンネル配信方式",
1111
+ "channels": "チャンネル",
1108
1112
  "log_level": "ログレベル",
1109
1113
  "presets": "LLMプリセット",
1110
1114
  "context_setup": "コンテキスト設定",
@@ -1127,6 +1131,7 @@ UI_TEXT = {
1127
1131
  "test": "兼容性测试",
1128
1132
  "options": "LLM 选项",
1129
1133
  "channel_delivery": "频道投递方式",
1134
+ "channels": "频道",
1130
1135
  "log_level": "日志级别",
1131
1136
  "presets": "LLM 预设",
1132
1137
  "context_setup": "上下文设置",
@@ -9821,6 +9826,215 @@ def auto_discovered_mcp_channel_specs(
9821
9826
  return _dedupe_strings(specs)
9822
9827
 
9823
9828
 
9829
+ CHANNEL_PROBE_CACHE_VERSION = 1
9830
+
9831
+
9832
+ def _builtin_router_probe_record() -> dict[str, Any]:
9833
+ return {
9834
+ "name": "claude-any-router",
9835
+ "capable": True,
9836
+ "transport": "sse",
9837
+ "source_path": "<built-in>",
9838
+ "url": f"{ROUTER_BASE}/ca/mcp/sse",
9839
+ "response_bytes": 0,
9840
+ "reason": "built-in",
9841
+ }
9842
+
9843
+
9844
+ def _server_transport_label(server: dict[str, Any]) -> str:
9845
+ if not isinstance(server, dict):
9846
+ return "unknown"
9847
+ declared = str(server.get("type") or "").strip().lower()
9848
+ if declared:
9849
+ return declared
9850
+ if server.get("url"):
9851
+ return "sse"
9852
+ if server.get("command"):
9853
+ return "stdio"
9854
+ return "unknown"
9855
+
9856
+
9857
+ def _probe_mcp_servers_to_records(
9858
+ paths: Iterable[str],
9859
+ cwd: Path,
9860
+ *,
9861
+ include_router_self: bool = True,
9862
+ timeout_per_server: float = 3.0,
9863
+ ) -> list[dict[str, Any]]:
9864
+ """Probe every MCP server referenced from the given config paths and
9865
+ return one record per server (capable and non-capable alike) for cache
9866
+ consumers and menu rendering."""
9867
+ records: list[dict[str, Any]] = []
9868
+ seen: set[str] = set()
9869
+ if include_router_self:
9870
+ records.append(_builtin_router_probe_record())
9871
+ seen.add("claude-any-router")
9872
+ for path_str in paths:
9873
+ if not path_str:
9874
+ continue
9875
+ path = Path(path_str)
9876
+ if not path.exists() or not path.is_file():
9877
+ continue
9878
+ for name, server in _read_mcp_servers_from_json(path, cwd):
9879
+ if name in seen:
9880
+ continue
9881
+ seen.add(name)
9882
+ if name == "claude-any-router":
9883
+ continue
9884
+ transport = _server_transport_label(server)
9885
+ record: dict[str, Any] = {
9886
+ "name": name,
9887
+ "capable": False,
9888
+ "transport": transport,
9889
+ "source_path": str(path),
9890
+ "response_bytes": 0,
9891
+ "reason": "",
9892
+ }
9893
+ if isinstance(server.get("url"), str):
9894
+ record["url"] = str(server.get("url"))
9895
+ if not _mcp_server_is_stdio(server):
9896
+ record["reason"] = "non_stdio_probe_not_implemented"
9897
+ records.append(record)
9898
+ continue
9899
+ try:
9900
+ capable = probe_stdio_mcp_for_channel_capability(name, server, timeout=timeout_per_server)
9901
+ record["capable"] = bool(capable)
9902
+ if not capable:
9903
+ record["reason"] = "no_experimental_claude_channel_or_timeout"
9904
+ except Exception as exc:
9905
+ record["reason"] = f"probe_exception:{type(exc).__name__}"
9906
+ router_log("WARN", f"channel_probe_exception server={name} error={type(exc).__name__}: {exc}")
9907
+ records.append(record)
9908
+ return records
9909
+
9910
+
9911
+ def read_channel_probe_cache() -> dict[str, Any]:
9912
+ if not CHANNEL_PROBE_CACHE_PATH.exists():
9913
+ return {"version": CHANNEL_PROBE_CACHE_VERSION, "probed_at": 0.0, "servers": []}
9914
+ try:
9915
+ data = json.loads(CHANNEL_PROBE_CACHE_PATH.read_text(encoding="utf-8"))
9916
+ except Exception as exc:
9917
+ router_log("WARN", f"channel_probe_cache_read_failed error={type(exc).__name__}: {exc}")
9918
+ return {"version": CHANNEL_PROBE_CACHE_VERSION, "probed_at": 0.0, "servers": []}
9919
+ if not isinstance(data, dict):
9920
+ return {"version": CHANNEL_PROBE_CACHE_VERSION, "probed_at": 0.0, "servers": []}
9921
+ data.setdefault("version", CHANNEL_PROBE_CACHE_VERSION)
9922
+ data.setdefault("probed_at", 0.0)
9923
+ servers = data.get("servers")
9924
+ data["servers"] = [item for item in servers if isinstance(item, dict)] if isinstance(servers, list) else []
9925
+ return data
9926
+
9927
+
9928
+ def _write_channel_probe_cache(cache: dict[str, Any]) -> None:
9929
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
9930
+ tmp = CHANNEL_PROBE_CACHE_PATH.with_suffix(".json.tmp")
9931
+ tmp.write_text(json.dumps(cache, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
9932
+ tmp.replace(CHANNEL_PROBE_CACHE_PATH)
9933
+ try:
9934
+ os.chmod(CHANNEL_PROBE_CACHE_PATH, 0o600)
9935
+ except Exception:
9936
+ pass
9937
+
9938
+
9939
+ def refresh_channel_probe_cache(
9940
+ passthrough: list[str] | None = None,
9941
+ cwd: Path | None = None,
9942
+ home: Path | None = None,
9943
+ timeout_per_server: float = 3.0,
9944
+ ) -> dict[str, Any]:
9945
+ """Re-scan known MCP config files, probe each stdio entry for the
9946
+ channel capability, write the result to disk and return it. Only called
9947
+ on explicit user action (menu refresh or CLI subcommand)."""
9948
+ cwd = cwd or Path.cwd()
9949
+ paths = [str(p) for p in claude_mcp_config_paths(passthrough or [], cwd, home)]
9950
+ records = _probe_mcp_servers_to_records(paths, cwd, timeout_per_server=timeout_per_server)
9951
+ cache = {
9952
+ "version": CHANNEL_PROBE_CACHE_VERSION,
9953
+ "probed_at": time.time(),
9954
+ "servers": records,
9955
+ }
9956
+ _write_channel_probe_cache(cache)
9957
+ capable = [r["name"] for r in records if r.get("capable")]
9958
+ router_log(
9959
+ "INFO",
9960
+ f"channel_probe_cache_refreshed total={len(records)} capable={len(capable)} servers={','.join(capable) or '-'}",
9961
+ )
9962
+ return cache
9963
+
9964
+
9965
+ def cached_channel_probe_servers() -> list[dict[str, Any]]:
9966
+ cache = read_channel_probe_cache()
9967
+ servers = cache.get("servers")
9968
+ if isinstance(servers, list):
9969
+ return [item for item in servers if isinstance(item, dict)]
9970
+ return []
9971
+
9972
+
9973
+ def cached_channel_capable_server_names() -> list[str]:
9974
+ """Capable server names from cache, with claude-any-router always
9975
+ present (because the router's own MCP bridge is built into claude-any)."""
9976
+ names = [str(r.get("name")) for r in cached_channel_probe_servers() if r.get("capable") and r.get("name")]
9977
+ if "claude-any-router" not in names:
9978
+ names.insert(0, "claude-any-router")
9979
+ return _dedupe_strings(names)
9980
+
9981
+
9982
+ def parse_passthrough_channel_specs(passthrough: list[str]) -> list[str]:
9983
+ """Extract channel specs from passthrough args of either
9984
+ --channels or --dangerously-load-development-channels (and the
9985
+ =VALUE inline form)."""
9986
+ specs: list[str] = []
9987
+ i = 0
9988
+ while i < len(passthrough):
9989
+ arg = passthrough[i]
9990
+ if arg in ("--channels", "--dangerously-load-development-channels"):
9991
+ i += 1
9992
+ while i < len(passthrough) and is_channel_spec_tagged(passthrough[i]):
9993
+ specs.append(passthrough[i])
9994
+ i += 1
9995
+ continue
9996
+ if arg.startswith("--channels=") or arg.startswith("--dangerously-load-development-channels="):
9997
+ value = arg.split("=", 1)[1].strip()
9998
+ if value and is_channel_spec_tagged(value):
9999
+ specs.append(value)
10000
+ i += 1
10001
+ continue
10002
+ i += 1
10003
+ return _dedupe_strings(specs)
10004
+
10005
+
10006
+ def auto_import_passthrough_channels(passthrough: list[str]) -> list[str]:
10007
+ """Add channel specs that arrived as CLI passthrough to the persisted
10008
+ cfg.channels list, so they show up alongside auto-detected entries in
10009
+ the menu and survive subsequent launches. Returns the newly added specs."""
10010
+ specs = parse_passthrough_channel_specs(passthrough)
10011
+ if not specs:
10012
+ return []
10013
+ cfg = load_config()
10014
+ existing = set(channel_specs(cfg))
10015
+ if all(spec in existing for spec in specs):
10016
+ return []
10017
+ cc = cfg.setdefault("claude_code", {})
10018
+ merged = list(channel_specs(cfg))
10019
+ added: list[str] = []
10020
+ for spec in specs:
10021
+ if spec in existing:
10022
+ continue
10023
+ merged.append(spec)
10024
+ existing.add(spec)
10025
+ added.append(spec)
10026
+ if not added:
10027
+ return []
10028
+ cc["channels"] = merged
10029
+ save_config(cfg)
10030
+ invalidate_config_cache()
10031
+ router_log(
10032
+ "INFO",
10033
+ f"channels_auto_imported_from_passthrough count={len(added)} specs={','.join(added)}",
10034
+ )
10035
+ return added
10036
+
10037
+
9824
10038
  def _mcp_sse_servers_from_mapping(mapping: Any) -> list[dict[str, Any]]:
9825
10039
  if not isinstance(mapping, dict):
9826
10040
  return []
@@ -10023,6 +10237,29 @@ def cmd_channels(args: argparse.Namespace) -> None:
10023
10237
  for line in clear_channel_specs():
10024
10238
  print(line)
10025
10239
  return
10240
+ if head in ("detect", "probe", "refresh"):
10241
+ try:
10242
+ result = refresh_channel_probe_cache()
10243
+ except Exception as exc:
10244
+ raise SystemExit(f"Channel probe failed: {type(exc).__name__}: {exc}")
10245
+ servers = result.get("servers") or []
10246
+ capable = [r for r in servers if r.get("capable")]
10247
+ non_capable = [r for r in servers if not r.get("capable")]
10248
+ probed_at = result.get("probed_at") or 0
10249
+ ts_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(probed_at)) if probed_at else "-"
10250
+ print(f"channel probe complete (cached at {ts_str})")
10251
+ print(f" capable : {len(capable)}")
10252
+ for r in capable:
10253
+ transport = r.get("transport") or "?"
10254
+ source = r.get("source_path") or ""
10255
+ suffix = " built-in" if source == "<built-in>" else f" {source}"
10256
+ print(f" * {r.get('name')} ({transport}){suffix}")
10257
+ print(f" non-capable : {len(non_capable)}")
10258
+ for r in non_capable:
10259
+ transport = r.get("transport") or "?"
10260
+ reason = r.get("reason") or "-"
10261
+ print(f" {r.get('name')} ({transport}) reason={reason}")
10262
+ return
10026
10263
  if head in ("delivery", "mode"):
10027
10264
  if len(values) < 2:
10028
10265
  print(f"channel_delivery: {channel_delivery_mode(cfg)}")
@@ -13239,9 +13476,10 @@ def main_menu_rows(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any], lan
13239
13476
  f"5. {ui_text('advisor_model', lang)} [{compact_text(pcfg.get('advisor_model') or 'off', 62)}]",
13240
13477
  f"6. {ui_text('options', lang)} [{compact_text(llm_options_status(provider, pcfg), 62)}]",
13241
13478
  f"7. {ui_text('channel_delivery', lang)} [{channel_delivery_mode(cfg)}]",
13242
- f"8. {ui_text('log_level', lang)} [{log_level_status()}]",
13243
- f"9. {ui_text('test', lang)}",
13244
- f"10. {ui_text('launch', lang)}",
13479
+ f"8. {ui_text('channels', lang)} [{channel_status_text(cfg)}]",
13480
+ f"9. {ui_text('log_level', lang)} [{log_level_status()}]",
13481
+ f"10. {ui_text('test', lang)}",
13482
+ f"11. {ui_text('launch', lang)}",
13245
13483
  ui_text("quit", lang),
13246
13484
  ]
13247
13485
 
@@ -13338,13 +13576,71 @@ def advisor_model_panel_rows(provider: str, pcfg: dict[str, Any]) -> tuple[list[
13338
13576
 
13339
13577
 
13340
13578
  def channel_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
13341
- channels = channel_specs(cfg)
13579
+ channels = set(channel_specs(cfg))
13580
+ cache = read_channel_probe_cache()
13581
+ records = cache.get("servers") or []
13582
+ probed_at = cache.get("probed_at") or 0
13583
+ capable_records = [r for r in records if r.get("capable")]
13584
+ non_capable_records = [r for r in records if not r.get("capable")]
13585
+
13342
13586
  rows: list[str] = []
13343
13587
  values: list[str] = []
13344
- for name, spec in OFFICIAL_CHANNEL_PLUGINS.items():
13588
+
13589
+ rows.append("[Auto-detected channel-capable]")
13590
+ values.append("__heading__")
13591
+ if capable_records:
13592
+ for r in capable_records:
13593
+ name = str(r.get("name") or "")
13594
+ spec = f"server:{name}"
13595
+ mark = "*" if spec in channels else " "
13596
+ transport = str(r.get("transport") or "?")
13597
+ source = str(r.get("source_path") or "")
13598
+ if source == "<built-in>":
13599
+ rows.append(f"{mark} {name:<14} ({transport}, built-in)")
13600
+ else:
13601
+ rows.append(f"{mark} {name:<14} ({transport})")
13602
+ values.append(spec)
13603
+ else:
13604
+ hint = "press Re-probe now" if not probed_at else "none capable"
13605
+ rows.append(f" ({hint})")
13606
+ values.append("__noop__")
13607
+
13608
+ if non_capable_records:
13609
+ rows.append("[Detected but not channel-capable]")
13610
+ values.append("__heading__")
13611
+ for r in non_capable_records:
13612
+ name = str(r.get("name") or "")
13613
+ transport = str(r.get("transport") or "?")
13614
+ reason = str(r.get("reason") or "-")
13615
+ rows.append(f" {name:<14} ({transport}) {reason}")
13616
+ values.append("__noop__")
13617
+
13618
+ rows.append("[Official plugins]")
13619
+ values.append("__heading__")
13620
+ for plugin_name, spec in OFFICIAL_CHANNEL_PLUGINS.items():
13345
13621
  mark = "*" if spec in channels else " "
13346
- rows.append(f"{mark} {name:<10} {spec}")
13622
+ rows.append(f"{mark} {plugin_name:<10} {spec}")
13347
13623
  values.append(spec)
13624
+
13625
+ covered: set[str] = set(OFFICIAL_CHANNEL_PLUGINS.values())
13626
+ for r in capable_records:
13627
+ covered.add(f"server:{r.get('name')}")
13628
+ custom_specs = [spec for spec in channel_specs(cfg) if spec not in covered]
13629
+ if custom_specs:
13630
+ rows.append("[Configured custom / imported]")
13631
+ values.append("__heading__")
13632
+ for spec in custom_specs:
13633
+ rows.append(f"* {spec}")
13634
+ values.append(spec)
13635
+
13636
+ rows.append("[Actions]")
13637
+ values.append("__heading__")
13638
+ if probed_at:
13639
+ ts_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(probed_at))
13640
+ rows.append(f"Re-probe now (last: {ts_str})")
13641
+ else:
13642
+ rows.append("Re-probe now (no cache yet)")
13643
+ values.append("__reprobe__")
13348
13644
  rows.append("+ Add custom channel...")
13349
13645
  values.append("__add_custom__")
13350
13646
  if channels:
@@ -13357,6 +13653,25 @@ def channel_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
13357
13653
  return rows, values
13358
13654
 
13359
13655
 
13656
+ def _channel_panel_first_selectable(values: list[str]) -> int:
13657
+ for idx, value in enumerate(values):
13658
+ if value not in ("__heading__", "__noop__"):
13659
+ return idx
13660
+ return 0
13661
+
13662
+
13663
+ def _channel_panel_step(values: list[str], start: int, delta: int) -> int:
13664
+ if not values:
13665
+ return 0
13666
+ n = len(values)
13667
+ idx = start
13668
+ for _ in range(n):
13669
+ idx = (idx + delta) % n
13670
+ if values[idx] not in ("__heading__", "__noop__"):
13671
+ return idx
13672
+ return start
13673
+
13674
+
13360
13675
  def channel_delivery_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
13361
13676
  current = channel_delivery_mode(cfg)
13362
13677
  rows = [
@@ -13668,9 +13983,10 @@ def portable_language_menu() -> int:
13668
13983
  return 0
13669
13984
 
13670
13985
 
13671
- def portable_prelaunch_menu() -> int:
13986
+ def portable_prelaunch_menu(passthrough: list[str] | None = None) -> int:
13987
+ passthrough = list(passthrough or [])
13672
13988
  enable_ansi()
13673
- main_idx = 10 if settings_ready_except_api_key() else 0
13989
+ main_idx = 11 if settings_ready_except_api_key() else 0
13674
13990
  panel: str | None = None
13675
13991
  panel_idx = 0
13676
13992
  panel_rows: list[str] = []
@@ -13718,6 +14034,8 @@ def portable_prelaunch_menu() -> int:
13718
14034
  panel_rows, panel_values = log_level_panel_rows(cfg)
13719
14035
  elif name == "channels":
13720
14036
  panel_rows, panel_values = channel_panel_rows(cfg)
14037
+ if panel_values:
14038
+ panel_idx = _channel_panel_first_selectable(panel_values)
13721
14039
  elif name == "context":
13722
14040
  panel_rows, panel_values = context_setup_panel_rows(provider, pcfg, cfg.get("language", "en"))
13723
14041
  elif name == "preset":
@@ -13785,11 +14103,17 @@ def portable_prelaunch_menu() -> int:
13785
14103
  if panel:
13786
14104
  panel_name = panel
13787
14105
  if key in ("up", "k"):
13788
- panel_idx = (panel_idx - 1) % max(1, len(panel_rows))
14106
+ if panel == "channels":
14107
+ panel_idx = _channel_panel_step(panel_values, panel_idx, -1)
14108
+ else:
14109
+ panel_idx = (panel_idx - 1) % max(1, len(panel_rows))
13789
14110
  panel_last_idx[panel_name] = panel_idx
13790
14111
  continue
13791
14112
  if key in ("down", "j"):
13792
- panel_idx = (panel_idx + 1) % max(1, len(panel_rows))
14113
+ if panel == "channels":
14114
+ panel_idx = _channel_panel_step(panel_values, panel_idx, 1)
14115
+ else:
14116
+ panel_idx = (panel_idx + 1) % max(1, len(panel_rows))
13793
14117
  panel_last_idx[panel_name] = panel_idx
13794
14118
  continue
13795
14119
  if key in ("esc", "left", "q"):
@@ -13894,7 +14218,7 @@ def portable_prelaunch_menu() -> int:
13894
14218
  messages = lines[-8:] if lines else ["Test produced no output."]
13895
14219
  panel_rows, panel_values = ["Run compatibility test again", "Back"], ["run", "back"]
13896
14220
  refresh_checks()
13897
- main_idx = 10 if "Compatibility: OK" in out else 4
14221
+ main_idx = 11 if "Compatibility: OK" in out else 4
13898
14222
  elif panel == "log-level":
13899
14223
  if value == "back":
13900
14224
  close_panel()
@@ -13916,23 +14240,43 @@ def portable_prelaunch_menu() -> int:
13916
14240
  elif panel == "channels":
13917
14241
  if value == "back":
13918
14242
  close_panel()
14243
+ elif value in ("__heading__", "__noop__"):
14244
+ continue
14245
+ elif value == "__reprobe__":
14246
+ panel_rows, panel_values = ["Re-probing MCP channel capability..."], []
14247
+ first_render = render_prelaunch_screen(main_idx, panel, 0, panel_rows, checks, messages, first_render)
14248
+ try:
14249
+ result = refresh_channel_probe_cache(passthrough)
14250
+ capable = [r for r in result.get("servers") or [] if r.get("capable")]
14251
+ messages = [f"Probe complete: {len(capable)} channel-capable server(s)."]
14252
+ except Exception as exc:
14253
+ messages = [f"Re-probe failed: {type(exc).__name__}: {exc}"]
14254
+ cfg = load_config()
14255
+ panel_rows, panel_values = channel_panel_rows(cfg)
14256
+ if panel_values:
14257
+ panel_idx = _channel_panel_first_selectable(panel_values)
13919
14258
  elif value == "__add_custom__":
13920
14259
  spec = prompt_menu_value("Channel spec (for example plugin:ainet@local or server:ainet)", restore_tty=restore_line_mode, raw_tty=restore_raw_mode)
13921
14260
  if spec:
13922
14261
  messages = add_channel_spec(spec)
13923
14262
  cfg = load_config()
13924
14263
  panel_rows, panel_values = channel_panel_rows(cfg)
14264
+ if panel_values:
14265
+ panel_idx = _channel_panel_first_selectable(panel_values)
13925
14266
  elif value == "__remove__":
13926
14267
  spec = prompt_menu_value("Channel spec to remove", "", restore_tty=restore_line_mode, raw_tty=restore_raw_mode)
13927
14268
  if spec:
13928
14269
  messages = remove_channel_spec(spec)
13929
14270
  cfg = load_config()
13930
14271
  panel_rows, panel_values = channel_panel_rows(cfg)
14272
+ if panel_values:
14273
+ panel_idx = _channel_panel_first_selectable(panel_values)
13931
14274
  elif value == "__clear__":
13932
14275
  messages = clear_channel_specs()
13933
14276
  cfg = load_config()
13934
14277
  panel_rows, panel_values = channel_panel_rows(cfg)
13935
- panel_idx = 0
14278
+ if panel_values:
14279
+ panel_idx = _channel_panel_first_selectable(panel_values)
13936
14280
  elif value:
13937
14281
  if value in channel_specs(cfg):
13938
14282
  messages = remove_channel_spec(value)
@@ -14049,7 +14393,7 @@ def portable_prelaunch_menu() -> int:
14049
14393
  elif key in ("esc", "q"):
14050
14394
  return 10
14051
14395
  elif key == "enter":
14052
- actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "channel-delivery", "log-level", "test", "launch", "quit"]
14396
+ actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "channel-delivery", "channels", "log-level", "test", "launch", "quit"]
14053
14397
  action = actions[main_idx]
14054
14398
  if action == "launch":
14055
14399
  blockers = launch_readiness_errors()
@@ -14096,14 +14440,16 @@ def run_prelaunch_menu(passthrough: list[str], skip_menu: bool = False, force_me
14096
14440
  rc = run_external_menu("claude-any-menu")
14097
14441
  if rc is not None:
14098
14442
  return rc
14099
- return portable_prelaunch_menu()
14443
+ return portable_prelaunch_menu(passthrough)
14100
14444
 
14101
14445
 
14102
14446
  def start_router_if_needed() -> None:
14103
14447
  if router_up():
14448
+ router_log("INFO", f"router_check_state running=True spawn=False base={ROUTER_BASE}")
14104
14449
  return
14105
14450
  stop_router_processes(quiet=True)
14106
14451
  if router_up():
14452
+ router_log("INFO", f"router_check_state running=True spawn=False after_stop=True base={ROUTER_BASE}")
14107
14453
  return
14108
14454
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
14109
14455
  cmd = [sys.executable, str(Path(__file__).resolve()), "serve"]
@@ -14114,11 +14460,13 @@ def start_router_if_needed() -> None:
14114
14460
  kwargs["creationflags"] = flags
14115
14461
  else:
14116
14462
  kwargs["start_new_session"] = True
14463
+ router_log("INFO", f"router_check_state running=False spawn=True base={ROUTER_BASE}")
14117
14464
  with open(LOG_PATH, "ab", buffering=0) as log:
14118
14465
  subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=log, stderr=log, **kwargs)
14119
14466
  deadline = time.time() + 30
14120
14467
  while time.time() < deadline:
14121
14468
  if router_up():
14469
+ router_log("INFO", f"router_spawned running=True base={ROUTER_BASE} elapsed={time.time()-(deadline-30):.1f}s")
14122
14470
  return
14123
14471
  time.sleep(0.5)
14124
14472
  raise RuntimeError(f"claude-any router did not start. See {LOG_PATH}")
@@ -15344,6 +15692,7 @@ def launch_claude(
15344
15692
  update_check = False
15345
15693
  self_update_check = False
15346
15694
  run_claude_any_update_check(enabled=self_update_check)
15695
+ auto_import_passthrough_channels(passthrough)
15347
15696
  rc = run_prelaunch_menu(passthrough, skip_menu=skip_menu, force_menu=force_menu)
15348
15697
  if rc == 10:
15349
15698
  return 0
@@ -15422,19 +15771,17 @@ def launch_claude(
15422
15771
  if should_append_compat_prompt(provider, cfg) and not has_passthrough_option(launch_passthrough, "--system-prompt"):
15423
15772
  extra_args.extend(["--append-system-prompt", NON_ANTHROPIC_COMPAT_PROMPT])
15424
15773
  detected_channel_specs: list[str] = []
15425
- if native_channel_bridge and mcp_config_paths:
15774
+ if native_channel_bridge:
15426
15775
  try:
15427
- detected_servers = detect_channel_capable_mcp_servers(
15428
- mcp_config_paths,
15429
- Path(os.getcwd()),
15430
- )
15431
- detected_channel_specs = [f"server:{name}" for name in detected_servers]
15776
+ capable_names = cached_channel_capable_server_names()
15777
+ detected_channel_specs = [f"server:{name}" for name in capable_names]
15778
+ cache_age = read_channel_probe_cache().get("probed_at") or 0
15432
15779
  router_log(
15433
15780
  "INFO",
15434
- f"channel_probe_detected count={len(detected_servers)} servers={','.join(detected_servers) or '-'}",
15781
+ f"channel_probe_loaded source=cache cache_age_ts={int(cache_age)} count={len(capable_names)} servers={','.join(capable_names) or '-'}",
15435
15782
  )
15436
15783
  except Exception as exc:
15437
- router_log("WARN", f"channel_probe_failed error={type(exc).__name__}: {exc}")
15784
+ router_log("WARN", f"channel_probe_cache_load_failed error={type(exc).__name__}: {exc}")
15438
15785
  extra_args.extend(
15439
15786
  claude_channel_args(
15440
15787
  cfg,
@@ -15452,11 +15799,124 @@ def launch_claude(
15452
15799
  cmd.extend(["--model", model])
15453
15800
  cmd.extend(extra_args)
15454
15801
  cmd.extend(claude_passthrough)
15802
+ _log_claude_command_for_diagnostics(cmd, env)
15803
+ capture_stderr = env_bool(os.environ.get("CLAUDE_ANY_CAPTURE_CC_STDERR"), False)
15455
15804
  if stdin_channel_proxy:
15456
15805
  return subprocess_call_with_channel_wake_proxy(cmd, env)
15806
+ if capture_stderr:
15807
+ return _subprocess_call_capturing_stderr(cmd, env)
15457
15808
  return subprocess.call(cmd, env=env)
15458
15809
 
15459
15810
 
15811
+ CLAUDE_CODE_STDERR_LOG = CONFIG_DIR / "claude-code-stderr.log"
15812
+
15813
+
15814
+ def _log_claude_command_for_diagnostics(cmd: list[str], env: dict[str, str]) -> None:
15815
+ try:
15816
+ mcp_idx = cmd.index("--mcp-config") if "--mcp-config" in cmd else -1
15817
+ mcp_value = cmd[mcp_idx + 1] if 0 <= mcp_idx < len(cmd) - 1 else "-"
15818
+ except Exception:
15819
+ mcp_value = "-"
15820
+ channel_specs_in_cmd: list[str] = []
15821
+ if "--dangerously-load-development-channels" in cmd:
15822
+ start = cmd.index("--dangerously-load-development-channels") + 1
15823
+ for arg in cmd[start:]:
15824
+ if arg.startswith("--"):
15825
+ break
15826
+ channel_specs_in_cmd.append(arg)
15827
+ router_log(
15828
+ "INFO",
15829
+ "claude_launch_cmd mcp_config=%s channels=%s argv_len=%d"
15830
+ % (
15831
+ mcp_value,
15832
+ ",".join(channel_specs_in_cmd) or "-",
15833
+ len(cmd),
15834
+ ),
15835
+ )
15836
+ relevant_env_keys = (
15837
+ "ANTHROPIC_BASE_URL",
15838
+ "ANTHROPIC_MODEL",
15839
+ "ANTHROPIC_API_KEY",
15840
+ "ANTHROPIC_AUTH_TOKEN",
15841
+ "CLAUDE_ANY_PROVIDER",
15842
+ "CLAUDE_ANY_MODEL_ALIAS",
15843
+ "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS",
15844
+ )
15845
+ env_summary = []
15846
+ for key in relevant_env_keys:
15847
+ if key in env:
15848
+ val = env[key]
15849
+ if "KEY" in key or "TOKEN" in key:
15850
+ val = mask_secret(val)
15851
+ env_summary.append(f"{key}={val}")
15852
+ if env_summary:
15853
+ router_log("INFO", "claude_launch_env " + " ".join(env_summary))
15854
+
15855
+
15856
+ def _subprocess_call_capturing_stderr(cmd: list[str], env: dict[str, str]) -> int:
15857
+ """Like subprocess.call but tees Claude Code's stderr into
15858
+ ~/.config/claude-any/claude-code-stderr.log so the user can collect
15859
+ the exact context around messages like
15860
+ `--dangerously-load-development-channels ignored (server:...)`.
15861
+
15862
+ Enabled via CLAUDE_ANY_CAPTURE_CC_STDERR=1."""
15863
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
15864
+ try:
15865
+ if CLAUDE_CODE_STDERR_LOG.exists() and CLAUDE_CODE_STDERR_LOG.stat().st_size > 2_000_000:
15866
+ CLAUDE_CODE_STDERR_LOG.replace(CLAUDE_CODE_STDERR_LOG.with_suffix(".log.1"))
15867
+ except Exception:
15868
+ pass
15869
+ try:
15870
+ log_handle = CLAUDE_CODE_STDERR_LOG.open("ab", buffering=0)
15871
+ except Exception as exc:
15872
+ router_log("WARN", f"claude_stderr_capture_open_failed error={type(exc).__name__}: {exc}")
15873
+ return subprocess.call(cmd, env=env)
15874
+ header = f"\n===== claude launch at {time.strftime('%Y-%m-%dT%H:%M:%S')} =====\n".encode("utf-8")
15875
+ try:
15876
+ log_handle.write(header)
15877
+ except Exception:
15878
+ pass
15879
+ try:
15880
+ proc = subprocess.Popen(cmd, env=env, stderr=subprocess.PIPE)
15881
+ except Exception as exc:
15882
+ router_log("WARN", f"claude_stderr_capture_spawn_failed error={type(exc).__name__}: {exc}")
15883
+ try:
15884
+ log_handle.close()
15885
+ except Exception:
15886
+ pass
15887
+ return subprocess.call(cmd, env=env)
15888
+
15889
+ def _tee_stderr() -> None:
15890
+ try:
15891
+ if proc.stderr is None:
15892
+ return
15893
+ while True:
15894
+ chunk = proc.stderr.read(4096)
15895
+ if not chunk:
15896
+ break
15897
+ try:
15898
+ sys.stderr.buffer.write(chunk)
15899
+ sys.stderr.buffer.flush()
15900
+ except Exception:
15901
+ pass
15902
+ try:
15903
+ log_handle.write(chunk)
15904
+ except Exception:
15905
+ pass
15906
+ finally:
15907
+ try:
15908
+ log_handle.close()
15909
+ except Exception:
15910
+ pass
15911
+
15912
+ tee_thread = threading.Thread(target=_tee_stderr, daemon=True, name="claude-stderr-tee")
15913
+ tee_thread.start()
15914
+ rc = proc.wait()
15915
+ tee_thread.join(timeout=2.0)
15916
+ router_log("INFO", f"claude_exit code={rc} stderr_log={CLAUDE_CODE_STDERR_LOG}")
15917
+ return rc
15918
+
15919
+
15460
15920
  def cli_usage() -> str:
15461
15921
  return """Usage:
15462
15922
  claude-any Launch Claude Code through claude-any router
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/claude-any",
3
- "version": "0.1.97",
3
+ "version": "0.1.98",
4
4
  "description": "Claude Code provider selector for Anthropic, Ollama, Ollama Cloud, vLLM, NVIDIA hosted, and self-hosted NIM.",
5
5
  "license": "MIT",
6
6
  "author": "One Ciel LLC",