@oneciel-ai/claude-any 0.1.71 → 0.1.73

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 +431 -161
  2. package/package.json +1 -1
package/claude_any.py CHANGED
@@ -95,6 +95,12 @@ PROVIDER_LABELS = {
95
95
  "nvidia-hosted": "Nvidia Hosted",
96
96
  "self-hosted-nim": "Self Hosted NIM",
97
97
  }
98
+ OFFICIAL_CHANNEL_PLUGINS = {
99
+ "telegram": "plugin:telegram@claude-plugins-official",
100
+ "discord": "plugin:discord@claude-plugins-official",
101
+ "imessage": "plugin:imessage@claude-plugins-official",
102
+ "fakechat": "plugin:fakechat@claude-plugins-official",
103
+ }
98
104
  APP_NAME = "Claude Any"
99
105
  VERSION = "0.1.71"
100
106
  CREDITS = "Credits: One Ciel LLC"
@@ -116,7 +122,6 @@ _CHANNEL_SSE_LOCK = threading.Lock()
116
122
  _CHANNEL_SSE_CONNECTIONS: dict[str, dict[str, Any]] = {}
117
123
  EVENT_BUS = EventBus()
118
124
  ADVISOR_FEEDBACK_MARKER = "CLAUDE_ANY_ADVISOR_FEEDBACK"
119
- CHANNEL_BRIDGE_MARKER = "CLAUDE_ANY_CHANNEL_BRIDGE"
120
125
  PLAN_GUARD_MARKER = "[claude-any-plan-guard]"
121
126
  TASK_UPDATE_STATUSES = {"pending", "in_progress", "completed", "deleted"}
122
127
  TASK_UPDATE_STATUS_ALIASES = {
@@ -189,8 +194,6 @@ NON_ANTHROPIC_COMPAT_PROMPT = (
189
194
  "TaskList: no input. TaskUpdate: taskId (string), optional status enum exactly one of pending, in_progress, completed, deleted. "
190
195
  "CronCreate: cron (standard 5-field local-time cron string), prompt (string), optional recurring (boolean), optional durable (boolean). "
191
196
  "CronDelete: id (string returned by CronCreate). CronList: no input. "
192
- "Claude Any channel bridge: external systems can post messages to /ca/channel/messages or /ca/channel/notify. "
193
- "Use /channel status, /channel poll, or /channel wait to inspect bridge messages when the user asks about external channels. "
194
197
  "Never write pseudo tool calls, partial JSON, or markdown code fences when a real Claude Code tool call is required."
195
198
  )
196
199
  LANGUAGES = {
@@ -1248,6 +1251,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
1248
1251
  "router_debug_message_preview_chars": 0,
1249
1252
  "claude_code": {
1250
1253
  "compat_prompt_for_non_anthropic": True,
1254
+ "channels": [],
1255
+ "development_channels": False,
1251
1256
  },
1252
1257
  "cleanup": {
1253
1258
  "managed_services_on_launch": True,
@@ -2173,32 +2178,21 @@ Value: $ARGUMENTS
2173
2178
  Toggle claude-any router debug external access. With no argument, this toggles the current state. Use `on`, `off`, or `status` for explicit control.
2174
2179
  """
2175
2180
 
2176
- CHANNEL_SLASH_COMMAND = """---
2177
- description: Inspect or send messages through the claude-any channel bridge
2178
- argument-hint: [status|poll|wait|send|sse key=value ...]
2179
- ---
2180
-
2181
- CLAUDE_ANY_CHANNEL_BRIDGE
2182
-
2183
- Args: $ARGUMENTS
2184
-
2185
- Use the claude-any channel bridge for external agent/channel messages. Examples:
2186
- - `/channel status`
2187
- - `/channel poll channel=default after=0 recipient=claude`
2188
- - `/channel wait channel=default after=12 timeout=60`
2189
- - `/channel send channel=default to=all message="hello"`
2190
- - `/channel sse`
2191
- """
2192
-
2193
-
2194
2181
  def install_claude_any_slash_commands() -> None:
2195
2182
  try:
2196
2183
  CLAUDE_COMMANDS_DIR.mkdir(parents=True, exist_ok=True)
2197
2184
  commands = {
2198
2185
  "advisor.md": ADVISOR_SLASH_COMMAND,
2199
2186
  "router-debug.md": ROUTER_DEBUG_SLASH_COMMAND,
2200
- "channel.md": CHANNEL_SLASH_COMMAND,
2201
2187
  }
2188
+ stale_channel = CLAUDE_COMMANDS_DIR / "channel.md"
2189
+ if stale_channel.exists():
2190
+ try:
2191
+ stale_text = stale_channel.read_text(encoding="utf-8", errors="replace")
2192
+ if "CLAUDE_ANY_CHANNEL_BRIDGE" in stale_text or "claude-any channel bridge" in stale_text:
2193
+ stale_channel.unlink()
2194
+ except Exception:
2195
+ pass
2202
2196
  for name, content in commands.items():
2203
2197
  path = CLAUDE_COMMANDS_DIR / name
2204
2198
  if path.exists() and path.read_text(encoding="utf-8") == content:
@@ -2468,7 +2462,7 @@ def router_event_message_preview(body: dict[str, Any], cfg: dict[str, Any] | Non
2468
2462
  text = latest_user_text(body).strip()
2469
2463
  if not text:
2470
2464
  return {"message_preview_chars": limit, "message_preview": "", "message_preview_truncated": False}
2471
- normalized = re.sub(r"\s+", " ", text)
2465
+ normalized = re.sub(r"\s+", " ", redact_sensitive_text(text))
2472
2466
  truncated = len(normalized) > limit
2473
2467
  return {
2474
2468
  "message_preview_chars": limit,
@@ -4286,8 +4280,16 @@ def read_chat_messages(after_id: int = 0, channel: str | None = None, recipient:
4286
4280
  continue
4287
4281
  except Exception:
4288
4282
  continue
4289
- if channel and item.get("channel") != channel:
4290
- continue
4283
+ if channel:
4284
+ meta = item.get("meta") if isinstance(item.get("meta"), dict) else {}
4285
+ aliases = {
4286
+ str(item.get("channel") or ""),
4287
+ str(meta.get("room_id") or ""),
4288
+ str(meta.get("room") or ""),
4289
+ str(meta.get("channel") or ""),
4290
+ }
4291
+ if channel not in aliases:
4292
+ continue
4291
4293
  if not _message_visible_to(item, recipient):
4292
4294
  continue
4293
4295
  messages.append(item)
@@ -4350,6 +4352,74 @@ def channel_sse_status() -> dict[str, Any]:
4350
4352
  return {name: _channel_sse_status_public(name, state) for name, state in _CHANNEL_SSE_CONNECTIONS.items()}
4351
4353
 
4352
4354
 
4355
+ def _first_present_dict_value(*sources: Any, keys: tuple[str, ...]) -> Any:
4356
+ for source in sources:
4357
+ if not isinstance(source, dict):
4358
+ continue
4359
+ for key in keys:
4360
+ value = source.get(key)
4361
+ if value is not None:
4362
+ return value
4363
+ return None
4364
+
4365
+
4366
+ def _event_payload_text(value: Any, depth: int = 0) -> str | None:
4367
+ if value is None or depth > 5:
4368
+ return None
4369
+ if isinstance(value, str):
4370
+ return value.strip() or None
4371
+ if not isinstance(value, dict):
4372
+ return str(value)
4373
+ direct = _first_present_dict_value(value, keys=("content", "message", "text", "body", "summary"))
4374
+ if isinstance(direct, str) and direct.strip():
4375
+ return direct.strip()
4376
+ if isinstance(direct, dict):
4377
+ nested = _event_payload_text(direct, depth + 1)
4378
+ if nested:
4379
+ return nested
4380
+ for key in ("data", "event", "payload", "message", "notification", "item"):
4381
+ nested = _event_payload_text(value.get(key), depth + 1)
4382
+ if nested:
4383
+ return nested
4384
+ event_type = value.get("type") or value.get("event_type") or value.get("kind")
4385
+ if event_type:
4386
+ payload = value.get("payload") if isinstance(value.get("payload"), dict) else value.get("data")
4387
+ if payload is not None:
4388
+ return f"{event_type}: {json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}"
4389
+ return str(event_type)
4390
+ return None
4391
+
4392
+
4393
+ def _event_meta_from_sources(*sources: Any) -> dict[str, Any]:
4394
+ meta: dict[str, Any] = {}
4395
+ for source in sources:
4396
+ if not isinstance(source, dict):
4397
+ continue
4398
+ source_meta = source.get("meta")
4399
+ if isinstance(source_meta, dict):
4400
+ meta.update(source_meta)
4401
+ for key in (
4402
+ "room_id",
4403
+ "room",
4404
+ "channel",
4405
+ "thread_id",
4406
+ "parent_id",
4407
+ "message_id",
4408
+ "task_id",
4409
+ "round_id",
4410
+ "agent_id",
4411
+ "sender_id",
4412
+ "recipient_id",
4413
+ "type",
4414
+ "event_type",
4415
+ "kind",
4416
+ ):
4417
+ value = source.get(key)
4418
+ if value is not None and key not in meta:
4419
+ meta[key] = value
4420
+ return meta
4421
+
4422
+
4353
4423
  def _sse_payload_to_chat_payload(data_text: str, event_name: str, defaults: dict[str, Any]) -> dict[str, Any] | None:
4354
4424
  text = (data_text or "").strip()
4355
4425
  if not text or text == "[DONE]":
@@ -4375,28 +4445,24 @@ def _sse_payload_to_chat_payload(data_text: str, event_name: str, defaults: dict
4375
4445
  method = str(parsed.get("method") or event_name or "message")
4376
4446
  params = parsed.get("params") if isinstance(parsed.get("params"), dict) else {}
4377
4447
  payload = parsed.get("payload") if isinstance(parsed.get("payload"), dict) else {}
4378
- meta.update({k: v for k, v in (params.get("meta") if isinstance(params.get("meta"), dict) else {}).items()})
4379
- if isinstance(parsed.get("meta"), dict):
4380
- meta.update(parsed["meta"])
4381
- for source in (params, payload, parsed):
4382
- for key in ("content", "message", "text", "body"):
4383
- value = source.get(key)
4384
- if value is not None:
4385
- content = str(value)
4386
- break
4387
- if content != text:
4388
- break
4448
+ data = params.get("data") if isinstance(params.get("data"), dict) else {}
4449
+ event = params.get("event") if isinstance(params.get("event"), dict) else {}
4450
+ nested_payload = (
4451
+ data.get("payload") if isinstance(data.get("payload"), dict) else
4452
+ event.get("payload") if isinstance(event.get("payload"), dict) else {}
4453
+ )
4454
+ meta.update(_event_meta_from_sources(parsed, params, payload, data, event, nested_payload))
4455
+ content = _event_payload_text(params) or _event_payload_text(payload) or _event_payload_text(data) or _event_payload_text(event) or content
4389
4456
  kind = method.replace("notifications/claude/", "").replace("/", ".") if method else "sse"
4390
- for key in ("room_id", "room", "thread_id", "parent_id", "message_id", "task_id", "round_id"):
4391
- value = params.get(key, payload.get(key, parsed.get(key)))
4392
- if value is not None:
4393
- meta[key] = value
4394
4457
  if allowed_events and method not in allowed_events and (event_name or "message") not in allowed_events:
4395
4458
  return None
4459
+ channel = defaults.get("channel") or "default"
4460
+ if str(channel) == "default" and meta.get("channel"):
4461
+ channel = meta.get("channel")
4396
4462
  return {
4397
- "channel": defaults.get("channel") or "default",
4398
- "sender_id": defaults.get("sender_id") or "sse",
4399
- "recipients": defaults.get("recipient") or defaults.get("recipients") or "all",
4463
+ "channel": channel,
4464
+ "sender_id": meta.get("sender_id") or meta.get("agent_id") or defaults.get("sender_id") or "sse",
4465
+ "recipients": meta.get("recipient_id") or defaults.get("recipient") or defaults.get("recipients") or "all",
4400
4466
  "thread_id": meta.get("thread_id"),
4401
4467
  "parent_id": meta.get("parent_id"),
4402
4468
  "kind": kind,
@@ -4901,24 +4967,6 @@ def is_router_debug_request(body: dict[str, Any]) -> bool:
4901
4967
  return "CLAUDE_ANY_ROUTER_DEBUG_ACCESS" in latest_user_text(body)
4902
4968
 
4903
4969
 
4904
- def is_channel_bridge_request(body: dict[str, Any]) -> bool:
4905
- return CHANNEL_BRIDGE_MARKER in latest_user_text(body)
4906
-
4907
-
4908
- def channel_bridge_args_from_body(body: dict[str, Any]) -> str:
4909
- text = latest_user_text(body)
4910
- if CHANNEL_BRIDGE_MARKER not in text:
4911
- return "status"
4912
- tail = text.split(CHANNEL_BRIDGE_MARKER, 1)[1]
4913
- for line in tail.splitlines():
4914
- stripped = line.strip()
4915
- if not stripped:
4916
- continue
4917
- if stripped.lower().startswith("args:"):
4918
- return stripped.split(":", 1)[1].strip() or "status"
4919
- return tail.strip() or "status"
4920
-
4921
-
4922
4970
  def parse_channel_bridge_args(raw: str) -> tuple[str, dict[str, str]]:
4923
4971
  text = (raw or "").strip()
4924
4972
  if not text:
@@ -6512,96 +6560,6 @@ def maybe_handle_router_debug_request(handler: BaseHTTPRequestHandler, body: dic
6512
6560
  return True
6513
6561
 
6514
6562
 
6515
- def maybe_handle_channel_bridge_request(handler: BaseHTTPRequestHandler, body: dict[str, Any]) -> bool:
6516
- if not is_channel_bridge_request(body):
6517
- return False
6518
- stream = bool(body.get("stream", True))
6519
- raw_args = channel_bridge_args_from_body(body)
6520
- command, options = parse_channel_bridge_args(raw_args)
6521
- try:
6522
- after = max(0, int(options.get("after") or "0"))
6523
- except Exception:
6524
- after = 0
6525
- channel = options.get("channel") or None
6526
- recipient = options.get("recipient") or options.get("recipient_id") or options.get("to") or None
6527
- try:
6528
- limit = max(1, min(100, int(options.get("limit") or "20")))
6529
- except Exception:
6530
- limit = 20
6531
- model = str(body.get("model") or current_alias(load_config()))
6532
-
6533
- if command == "status":
6534
- latest = read_chat_messages(0, None, None, 1_000_000)
6535
- last_id = int(latest[-1]["id"]) if latest else 0
6536
- lines = [
6537
- "Claude Any channel bridge is available.",
6538
- "This is the router bridge API, separate from Claude Code's gated native --channels feature.",
6539
- f"Last message id: {last_id}.",
6540
- f"Poll: {ROUTER_BASE}/ca/channel/messages?after={last_id}&channel=default",
6541
- f"Wait: {ROUTER_BASE}/ca/channel/wait?after={last_id}&timeout=60",
6542
- f"SSE: {ROUTER_BASE}/ca/channel/stream?after={last_id}",
6543
- f"Notify: POST {ROUTER_BASE}/ca/channel/notify with {{\"params\":{{\"content\":\"...\",\"meta\":{{}}}}}}",
6544
- f"SSE connector status: {ROUTER_BASE}/ca/channel/sse/status",
6545
- "SSE connector control: POST /ca/channel/sse/connect or /ca/channel/sse/disconnect.",
6546
- "Slash usage: `/channel poll after=0`, `/channel wait after=0 timeout=60`, `/channel send channel=default to=all message=\"hello\"`, or `/channel sse`.",
6547
- ]
6548
- write_anthropic_text_response(handler, model, "\n".join(lines), stream)
6549
- return True
6550
-
6551
- if command == "sse":
6552
- statuses = channel_sse_status()
6553
- if not statuses:
6554
- text = "No channel SSE connectors are configured."
6555
- else:
6556
- lines = ["Channel SSE connectors:"]
6557
- for name, state in statuses.items():
6558
- running = "running" if state.get("running") else "stopped"
6559
- received = int(state.get("messages_received") or 0)
6560
- error = state.get("last_error") or ""
6561
- suffix = f", last_error={error}" if error else ""
6562
- lines.append(f"- {name}: {running}, received={received}, url={state.get('url')}{suffix}")
6563
- text = "\n".join(lines)
6564
- write_anthropic_text_response(handler, model, text, stream)
6565
- return True
6566
-
6567
- if command in {"poll", "wait"}:
6568
- timeout = 0.0
6569
- if command == "wait":
6570
- try:
6571
- timeout = max(0.0, min(300.0, float(options.get("timeout") or "60")))
6572
- except Exception:
6573
- timeout = 60.0
6574
- deadline = time.time() + timeout
6575
- messages = read_chat_messages(after, channel, recipient, limit)
6576
- while not messages and timeout > 0 and time.time() < deadline:
6577
- with _CHAT_CONDITION:
6578
- _CHAT_CONDITION.wait(timeout=min(5.0, max(0.0, deadline - time.time())))
6579
- messages = read_chat_messages(after, channel, recipient, limit)
6580
- write_anthropic_text_response(handler, model, format_channel_messages(messages, after), stream)
6581
- return True
6582
-
6583
- if command in {"send", "post"}:
6584
- message_text = options.get("message") or options.get("text") or ""
6585
- if not message_text:
6586
- write_anthropic_text_response(handler, model, "Channel send failed: provide message=\"...\".", stream)
6587
- return True
6588
- message = append_chat_message({
6589
- "channel": channel or "default",
6590
- "sender_id": options.get("sender") or options.get("sender_id") or "claude",
6591
- "recipients": recipient or options.get("recipients") or "all",
6592
- "thread_id": options.get("thread_id"),
6593
- "parent_id": options.get("parent_id"),
6594
- "kind": "message",
6595
- "message": message_text,
6596
- "meta": {"source": "slash_command"},
6597
- })
6598
- write_anthropic_text_response(handler, model, f"Channel message posted as id {message['id']}.", stream)
6599
- return True
6600
-
6601
- write_anthropic_text_response(handler, model, "Usage: `/channel status`, `/channel poll`, `/channel wait`, or `/channel send message=\"...\"`.", stream)
6602
- return True
6603
-
6604
-
6605
6563
  def normalize_tool_arguments(tool_name: str, args: Any) -> dict[str, Any]:
6606
6564
  if isinstance(args, dict):
6607
6565
  return args
@@ -8230,9 +8188,6 @@ class RouterHandler(BaseHTTPRequestHandler):
8230
8188
  if maybe_handle_router_debug_request(self, body):
8231
8189
  EVENT_BUS.publish(level="info", category="router_debug.short_circuit", message="router debug request handled locally", request_id=request_id, provider=provider, model=str(body.get("model") or ""))
8232
8190
  return
8233
- if maybe_handle_channel_bridge_request(self, body):
8234
- EVENT_BUS.publish(level="info", category="channel.short_circuit", message="channel bridge request handled locally", request_id=request_id, provider=provider, model=str(body.get("model") or ""))
8235
- return
8236
8191
  if maybe_handle_advisor_request(self, provider, pcfg, body):
8237
8192
  EVENT_BUS.publish(level="info", category="advisor.short_circuit", message="advisor request handled locally", request_id=request_id, provider=provider, model=str(body.get("model") or ""))
8238
8193
  return
@@ -8469,6 +8424,38 @@ def mask_secret(value: str | None) -> str:
8469
8424
  return f"{text[:4]}...{text[-4:]}"
8470
8425
 
8471
8426
 
8427
+ SECRET_TEXT_PATTERNS = (
8428
+ re.compile(r"ak_key_[A-Za-z0-9_-]+_secret_[A-Za-z0-9_-]+"),
8429
+ re.compile(r"(AINET_API_KEY\s*=\s*)(\S+)", re.IGNORECASE),
8430
+ re.compile(r"(Authorization\s*:\s*Bearer\s+)(\S+)", re.IGNORECASE),
8431
+ re.compile(r"(token=)(ak_key_[A-Za-z0-9_-]+_secret_[A-Za-z0-9_-]+)", re.IGNORECASE),
8432
+ )
8433
+
8434
+
8435
+ def redact_sensitive_text(text: str) -> str:
8436
+ redacted = text
8437
+ redacted = SECRET_TEXT_PATTERNS[0].sub(lambda m: mask_secret(m.group(0)), redacted)
8438
+ for pattern in SECRET_TEXT_PATTERNS[1:]:
8439
+ redacted = pattern.sub(lambda m: f"{m.group(1)}{mask_secret(m.group(2))}", redacted)
8440
+ return redacted
8441
+
8442
+
8443
+ def redact_sensitive_obj(value: Any) -> Any:
8444
+ if isinstance(value, str):
8445
+ return redact_sensitive_text(value)
8446
+ if isinstance(value, list):
8447
+ return [redact_sensitive_obj(item) for item in value]
8448
+ if isinstance(value, dict):
8449
+ redacted: dict[str, Any] = {}
8450
+ for key, item in value.items():
8451
+ if str(key).lower() in {"api_key", "apikey", "token", "authorization", "bearer_token"}:
8452
+ redacted[key] = mask_secret(str(item))
8453
+ else:
8454
+ redacted[key] = redact_sensitive_obj(item)
8455
+ return redacted
8456
+ return value
8457
+
8458
+
8472
8459
  def stored_api_key_mask(provider: str, pcfg: dict[str, Any]) -> str:
8473
8460
  if provider == "nvidia-hosted":
8474
8461
  return mask_secret(nvidia_api_key())
@@ -8658,6 +8645,7 @@ def status_lines() -> list[str]:
8658
8645
  *([f"request_timeout_ms: {pcfg.get('request_timeout_ms', 'default')}"] if provider in ("vllm", "nvidia-hosted", "self-hosted-nim") else []),
8659
8646
  *([f"stream_idle_timeout_ms: {pcfg.get('stream_idle_timeout_ms', 'auto')}"] if provider in ("vllm", "nvidia-hosted", "self-hosted-nim") else []),
8660
8647
  f"claude_model: {current_upstream_model_id(provider, pcfg) if direct_native else current_alias(cfg)}",
8648
+ f"channels: {channel_status_text(cfg)}",
8661
8649
  f"router: {'bypassed for native provider compatibility' if direct_native else (('up' if router_up() else 'down') + ' ' + ROUTER_BASE)}",
8662
8650
  f"config: {CONFIG_PATH}",
8663
8651
  ]
@@ -8751,6 +8739,143 @@ def cmd_web_fetch(args: argparse.Namespace) -> None:
8751
8739
  print(f"mcp_config: {WEB_TOOLS_MCP_CONFIG}")
8752
8740
 
8753
8741
 
8742
+ def channel_specs(cfg: dict[str, Any] | None = None) -> list[str]:
8743
+ cfg = cfg or load_config()
8744
+ raw = cfg.setdefault("claude_code", {}).get("channels", [])
8745
+ if isinstance(raw, str):
8746
+ items = [raw]
8747
+ elif isinstance(raw, list):
8748
+ items = raw
8749
+ else:
8750
+ items = []
8751
+ channels: list[str] = []
8752
+ seen: set[str] = set()
8753
+ for item in items:
8754
+ spec = str(item).strip()
8755
+ if not spec or spec in seen:
8756
+ continue
8757
+ seen.add(spec)
8758
+ channels.append(spec)
8759
+ return channels
8760
+
8761
+
8762
+ def is_channel_spec_tagged(spec: str) -> bool:
8763
+ return spec.startswith("plugin:") or spec.startswith("server:")
8764
+
8765
+
8766
+ def channel_development_enabled(cfg: dict[str, Any] | None = None) -> bool:
8767
+ cfg = cfg or load_config()
8768
+ return bool(cfg.setdefault("claude_code", {}).get("development_channels", False))
8769
+
8770
+
8771
+ def channel_status_text(cfg: dict[str, Any] | None = None) -> str:
8772
+ cfg = cfg or load_config()
8773
+ channels = channel_specs(cfg)
8774
+ if not channels:
8775
+ return "off"
8776
+ suffix = "; dev" if channel_development_enabled(cfg) else ""
8777
+ return f"{len(channels)} channel{'s' if len(channels) != 1 else ''}{suffix}"
8778
+
8779
+
8780
+ def set_channel_development_enabled(enabled: bool) -> list[str]:
8781
+ cfg = load_config()
8782
+ cfg.setdefault("claude_code", {})["development_channels"] = bool(enabled)
8783
+ save_config(cfg)
8784
+ return [f"Development channels: {'on' if enabled else 'off'}."]
8785
+
8786
+
8787
+ def add_channel_spec(spec: str, *, development: bool = False) -> list[str]:
8788
+ spec = spec.strip()
8789
+ if not spec:
8790
+ return ["Channel spec was empty."]
8791
+ if not is_channel_spec_tagged(spec):
8792
+ return ["Channel spec must start with plugin: or server:."]
8793
+ cfg = load_config()
8794
+ cc = cfg.setdefault("claude_code", {})
8795
+ channels = channel_specs(cfg)
8796
+ if spec not in channels:
8797
+ channels.append(spec)
8798
+ cc["channels"] = channels
8799
+ if development:
8800
+ cc["development_channels"] = True
8801
+ save_config(cfg)
8802
+ lines = [f"Channel added: {spec}."]
8803
+ if development:
8804
+ lines.append("Development channels: on.")
8805
+ return lines
8806
+
8807
+
8808
+ def remove_channel_spec(spec: str) -> list[str]:
8809
+ cfg = load_config()
8810
+ cc = cfg.setdefault("claude_code", {})
8811
+ before = channel_specs(cfg)
8812
+ after = [item for item in before if item != spec]
8813
+ cc["channels"] = after
8814
+ save_config(cfg)
8815
+ return [f"Channel removed: {spec}." if len(after) != len(before) else f"Channel was not configured: {spec}."]
8816
+
8817
+
8818
+ def clear_channel_specs() -> list[str]:
8819
+ cfg = load_config()
8820
+ cfg.setdefault("claude_code", {})["channels"] = []
8821
+ save_config(cfg)
8822
+ return ["Claude Code channels cleared."]
8823
+
8824
+
8825
+ def cmd_channels(args: argparse.Namespace) -> None:
8826
+ cfg = load_config()
8827
+ values = list(getattr(args, "values", []) or [])
8828
+ if not values:
8829
+ print(f"channels: {channel_status_text(cfg)}")
8830
+ for name, spec in OFFICIAL_CHANNEL_PLUGINS.items():
8831
+ mark = "*" if spec in channel_specs(cfg) else " "
8832
+ print(f" {mark} {name:<10} {spec}")
8833
+ for spec in channel_specs(cfg):
8834
+ if spec not in OFFICIAL_CHANNEL_PLUGINS.values():
8835
+ print(f" * custom {spec}")
8836
+ print(f"development_channels: {'on' if channel_development_enabled(cfg) else 'off'}")
8837
+ return
8838
+ head = values[0].strip().lower()
8839
+ if head in ("on", "enable", "add"):
8840
+ if len(values) < 2:
8841
+ raise SystemExit("Usage: claude-any channels add CHANNEL_SPEC")
8842
+ for line in add_channel_spec(values[1]):
8843
+ print(line)
8844
+ return
8845
+ if head in ("dev", "development"):
8846
+ if len(values) >= 2 and values[1].lower() in ("on", "off", "true", "false", "1", "0"):
8847
+ enabled = values[1].lower() in ("on", "true", "1")
8848
+ for line in set_channel_development_enabled(enabled):
8849
+ print(line)
8850
+ return
8851
+ if len(values) < 2:
8852
+ raise SystemExit("Usage: claude-any channels dev CHANNEL_SPEC | claude-any channels dev on|off")
8853
+ for line in add_channel_spec(values[1], development=True):
8854
+ print(line)
8855
+ return
8856
+ if head in ("off", "disable", "remove", "rm"):
8857
+ if len(values) < 2:
8858
+ raise SystemExit("Usage: claude-any channels remove CHANNEL_SPEC")
8859
+ for line in remove_channel_spec(values[1]):
8860
+ print(line)
8861
+ return
8862
+ if head in ("clear", "reset"):
8863
+ for line in clear_channel_specs():
8864
+ print(line)
8865
+ return
8866
+ if head in OFFICIAL_CHANNEL_PLUGINS:
8867
+ spec = OFFICIAL_CHANNEL_PLUGINS[head]
8868
+ if spec in channel_specs(cfg):
8869
+ for line in remove_channel_spec(spec):
8870
+ print(line)
8871
+ else:
8872
+ for line in add_channel_spec(spec):
8873
+ print(line)
8874
+ return
8875
+ for line in add_channel_spec(values[0]):
8876
+ print(line)
8877
+
8878
+
8754
8879
  def cmd_ollama_native(args: argparse.Namespace) -> None:
8755
8880
  cfg = load_config()
8756
8881
  pcfg = cfg["providers"]["ollama"]
@@ -11937,8 +12062,9 @@ def main_menu_rows(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any], lan
11937
12062
  f"4. {ui_text('model', lang)} [{compact_text(pcfg.get('current_model', 'unset'), 62)}]",
11938
12063
  f"5. {ui_text('advisor_model', lang)} [{compact_text(pcfg.get('advisor_model') or 'off', 62)}]",
11939
12064
  f"6. {ui_text('options', lang)} [{compact_text(llm_options_status(provider, pcfg), 62)}]",
11940
- f"7. {ui_text('test', lang)}",
11941
- f"8. {ui_text('launch', lang)}",
12065
+ f"7. Channels [{channel_status_text(cfg)}]",
12066
+ f"8. {ui_text('test', lang)}",
12067
+ f"9. {ui_text('launch', lang)}",
11942
12068
  ui_text("quit", lang),
11943
12069
  ]
11944
12070
 
@@ -12010,6 +12136,29 @@ def advisor_model_panel_rows(provider: str, pcfg: dict[str, Any]) -> tuple[list[
12010
12136
  return rows, deduped_values
12011
12137
 
12012
12138
 
12139
+ def channel_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
12140
+ channels = channel_specs(cfg)
12141
+ dev_enabled = channel_development_enabled(cfg)
12142
+ rows: list[str] = []
12143
+ values: list[str] = []
12144
+ rows.append(f"Development channel loading [{'on' if dev_enabled else 'off'}]")
12145
+ values.append("__toggle_dev__")
12146
+ for name, spec in OFFICIAL_CHANNEL_PLUGINS.items():
12147
+ mark = "*" if spec in channels else " "
12148
+ rows.append(f"{mark} {name:<10} {spec}")
12149
+ values.append(spec)
12150
+ rows.append("+ Add development/custom channel...")
12151
+ values.append("__add_custom__")
12152
+ if channels:
12153
+ rows.append("- Remove channel...")
12154
+ values.append("__remove__")
12155
+ rows.append("Clear all channels")
12156
+ values.append("__clear__")
12157
+ rows.append("Back")
12158
+ values.append("back")
12159
+ return rows, values
12160
+
12161
+
12013
12162
  def api_key_panel_rows(provider: str) -> tuple[list[str], list[str]]:
12014
12163
  rows = [
12015
12164
  "Type or paste API key as hidden input",
@@ -12310,7 +12459,7 @@ def portable_language_menu() -> int:
12310
12459
 
12311
12460
  def portable_prelaunch_menu() -> int:
12312
12461
  enable_ansi()
12313
- main_idx = 7 if settings_ready_except_api_key() else 0
12462
+ main_idx = 9 if settings_ready_except_api_key() else 0
12314
12463
  panel: str | None = None
12315
12464
  panel_idx = 0
12316
12465
  panel_rows: list[str] = []
@@ -12352,6 +12501,8 @@ def portable_prelaunch_menu() -> int:
12352
12501
  panel_rows, panel_values = ["Run compatibility test", "Back"], ["run", "back"]
12353
12502
  elif name == "options":
12354
12503
  panel_rows, panel_values = llm_option_panel_rows(provider, pcfg, cfg.get("language", "en"))
12504
+ elif name == "channels":
12505
+ panel_rows, panel_values = channel_panel_rows(cfg)
12355
12506
  elif name == "context":
12356
12507
  panel_rows, panel_values = context_setup_panel_rows(provider, pcfg, cfg.get("language", "en"))
12357
12508
  elif name == "preset":
@@ -12528,7 +12679,40 @@ def portable_prelaunch_menu() -> int:
12528
12679
  messages = lines[-8:] if lines else ["Test produced no output."]
12529
12680
  panel_rows, panel_values = ["Run compatibility test again", "Back"], ["run", "back"]
12530
12681
  refresh_checks()
12531
- main_idx = 8 if "Compatibility: OK" in out else 4
12682
+ main_idx = 9 if "Compatibility: OK" in out else 4
12683
+ elif panel == "channels":
12684
+ if value == "back":
12685
+ close_panel()
12686
+ elif value == "__toggle_dev__":
12687
+ messages = set_channel_development_enabled(not channel_development_enabled(cfg))
12688
+ cfg = load_config()
12689
+ panel_rows, panel_values = channel_panel_rows(cfg)
12690
+ panel_idx = 0
12691
+ elif value == "__add_custom__":
12692
+ 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
+ if spec:
12694
+ messages = add_channel_spec(spec, development=True)
12695
+ cfg = load_config()
12696
+ panel_rows, panel_values = channel_panel_rows(cfg)
12697
+ elif value == "__remove__":
12698
+ spec = prompt_menu_value("Channel spec to remove", "", restore_tty=restore_line_mode, raw_tty=restore_raw_mode)
12699
+ if spec:
12700
+ messages = remove_channel_spec(spec)
12701
+ cfg = load_config()
12702
+ panel_rows, panel_values = channel_panel_rows(cfg)
12703
+ elif value == "__clear__":
12704
+ messages = clear_channel_specs()
12705
+ cfg = load_config()
12706
+ panel_rows, panel_values = channel_panel_rows(cfg)
12707
+ panel_idx = 0
12708
+ elif value:
12709
+ if value in channel_specs(cfg):
12710
+ messages = remove_channel_spec(value)
12711
+ else:
12712
+ messages = add_channel_spec(value)
12713
+ cfg = load_config()
12714
+ panel_rows, panel_values = channel_panel_rows(cfg)
12715
+ refresh_checks()
12532
12716
  elif panel == "options":
12533
12717
  if value == "back":
12534
12718
  close_panel()
@@ -12627,13 +12811,13 @@ def portable_prelaunch_menu() -> int:
12627
12811
  continue
12628
12812
 
12629
12813
  if key in ("up", "k"):
12630
- main_idx = (main_idx - 1) % 10
12814
+ main_idx = (main_idx - 1) % 11
12631
12815
  elif key in ("down", "j"):
12632
- main_idx = (main_idx + 1) % 10
12816
+ main_idx = (main_idx + 1) % 11
12633
12817
  elif key in ("esc", "q"):
12634
12818
  return 10
12635
12819
  elif key == "enter":
12636
- actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "test", "launch", "quit"]
12820
+ actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "channels", "test", "launch", "quit"]
12637
12821
  action = actions[main_idx]
12638
12822
  if action == "launch":
12639
12823
  blockers = launch_readiness_errors()
@@ -12722,6 +12906,15 @@ def has_passthrough_option(passthrough: list[str], *names: str) -> bool:
12722
12906
  return any(arg in names or any(arg.startswith(name + "=") for name in names) for arg in passthrough)
12723
12907
 
12724
12908
 
12909
+ def claude_channel_args(cfg: dict[str, Any], passthrough: list[str]) -> list[str]:
12910
+ channels = [spec for spec in channel_specs(cfg) if is_channel_spec_tagged(spec)]
12911
+ if not channels or has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels"):
12912
+ return []
12913
+ if channel_development_enabled(cfg):
12914
+ return ["--dangerously-load-development-channels", *channels]
12915
+ return ["--channels", *channels]
12916
+
12917
+
12725
12918
  def write_web_tools_mcp_config(cfg: dict[str, Any]) -> Path:
12726
12919
  web = cfg.get("web_search", {})
12727
12920
  package = web.get("package") or "ddg-mcp-search"
@@ -13061,6 +13254,7 @@ def launch_claude(
13061
13254
  extra_args.extend(["--mcp-config", str(write_duckduckgo_mcp_config(cfg))])
13062
13255
  if should_append_compat_prompt(provider, cfg) and not has_passthrough_option(passthrough, "--system-prompt"):
13063
13256
  extra_args.extend(["--append-system-prompt", NON_ANTHROPIC_COMPAT_PROMPT])
13257
+ extra_args.extend(claude_channel_args(cfg, passthrough))
13064
13258
  cmd = [
13065
13259
  claude,
13066
13260
  "--dangerously-skip-permissions",
@@ -13091,6 +13285,7 @@ Control plane, runs before Claude Code and does not require LLM connectivity:
13091
13285
  claude-any set-api-key PROVIDER KEY
13092
13286
  claude-any web-search [on|off] Auto-attach DuckDuckGo MCP for non-native providers
13093
13287
  claude-any web-fetch [on|off] Auto-attach fetch MCP for web page content
13288
+ claude-any channels [cmd] Configure Claude Code --channels auto-injection
13094
13289
  claude-any ollama-native [on|off] Use Ollama's official Claude Code env path
13095
13290
  claude-any ollama-options [provider] [key=value ...]
13096
13291
  Set Ollama num_ctx/options/keep_alive/think
@@ -13129,6 +13324,11 @@ Headless setup flags, namespaced to avoid Claude CLI collisions:
13129
13324
  claude-any --ca-no-web-search Disable DuckDuckGo MCP for this launch
13130
13325
  claude-any --ca-web-fetch Enable fetch MCP
13131
13326
  claude-any --ca-no-web-fetch Disable fetch MCP
13327
+ claude-any --ca-channel SPEC Add an official/approved Claude Code channel
13328
+ claude-any --ca-dev-channel SPEC Add a development channel and enable dev loading
13329
+ claude-any --ca-development-channels on|off
13330
+ Use tagged specs with --dangerously-load-development-channels
13331
+ claude-any --ca-clear-channels Clear saved channel auto-injection specs
13132
13332
  claude-any --ca-no-self-update-check
13133
13333
  Skip Claude Any npm self-update check
13134
13334
  claude-any --ca-no-update-check Skip Claude Code update check for this launch
@@ -13236,6 +13436,28 @@ def apply_headless_env_config() -> tuple[bool, bool | None, bool | None, bool |
13236
13436
  if ollama_values:
13237
13437
  cmd_ollama_options(argparse.Namespace(values=ollama_values))
13238
13438
  skip_menu = True
13439
+ channel_values = [
13440
+ item.strip()
13441
+ for item in re.split(r"[\s,]+", os.environ.get("CLAUDE_ANY_CHANNELS", "").strip())
13442
+ if item.strip()
13443
+ ]
13444
+ for channel_value in channel_values:
13445
+ add_channel_spec(channel_value)
13446
+ skip_menu = True
13447
+ dev_channel_values = [
13448
+ item.strip()
13449
+ for item in re.split(r"[\s,]+", os.environ.get("CLAUDE_ANY_DEV_CHANNELS", "").strip())
13450
+ if item.strip()
13451
+ ]
13452
+ for channel_value in dev_channel_values:
13453
+ add_channel_spec(channel_value, development=True)
13454
+ skip_menu = True
13455
+ dev_channels = os.environ.get("CLAUDE_ANY_DEVELOPMENT_CHANNELS", "").strip().lower()
13456
+ if dev_channels:
13457
+ if dev_channels not in ("on", "off", "true", "false", "1", "0"):
13458
+ raise SystemExit("CLAUDE_ANY_DEVELOPMENT_CHANNELS must be on or off")
13459
+ set_channel_development_enabled(dev_channels in ("on", "true", "1"))
13460
+ skip_menu = True
13239
13461
  return skip_menu, web_search_override, update_check_override, self_update_check_override, force_menu
13240
13462
 
13241
13463
 
@@ -13295,6 +13517,9 @@ def run_cli(argv: list[str]) -> int:
13295
13517
  if head in ("web-fetch", "webfetch"):
13296
13518
  cmd_web_fetch(argparse.Namespace(value=rest[0] if rest else None))
13297
13519
  return 0
13520
+ if head in ("channels", "channel"):
13521
+ cmd_channels(argparse.Namespace(values=rest))
13522
+ return 0
13298
13523
  if head in ("ollama-native", "ollama-compat"):
13299
13524
  cmd_ollama_native(argparse.Namespace(value=rest[0] if rest else None))
13300
13525
  return 0
@@ -13629,6 +13854,48 @@ def run_cli(argv: list[str]) -> int:
13629
13854
  cmd_web_fetch(argparse.Namespace(value="off"))
13630
13855
  skip_menu = True
13631
13856
  i += 1
13857
+ elif arg == "--ca-channel" or arg.startswith("--ca-channel="):
13858
+ value = arg.split("=", 1)[1] if "=" in arg else None
13859
+ if value is None:
13860
+ if i + 1 >= len(argv):
13861
+ raise SystemExit("Missing channel spec for --ca-channel")
13862
+ value = argv[i + 1]
13863
+ i += 2
13864
+ else:
13865
+ i += 1
13866
+ for line in add_channel_spec(value):
13867
+ print(line)
13868
+ skip_menu = True
13869
+ elif arg == "--ca-dev-channel" or arg.startswith("--ca-dev-channel="):
13870
+ value = arg.split("=", 1)[1] if "=" in arg else None
13871
+ if value is None:
13872
+ if i + 1 >= len(argv):
13873
+ raise SystemExit("Missing channel spec for --ca-dev-channel")
13874
+ value = argv[i + 1]
13875
+ i += 2
13876
+ else:
13877
+ i += 1
13878
+ for line in add_channel_spec(value, development=True):
13879
+ print(line)
13880
+ skip_menu = True
13881
+ elif arg == "--ca-development-channels" or arg.startswith("--ca-development-channels="):
13882
+ value = arg.split("=", 1)[1] if "=" in arg else None
13883
+ if value is None:
13884
+ if i + 1 >= len(argv):
13885
+ raise SystemExit("Missing on/off for --ca-development-channels")
13886
+ value = argv[i + 1]
13887
+ i += 2
13888
+ else:
13889
+ i += 1
13890
+ enabled = value.strip().lower() in ("on", "enable", "enabled", "true", "1")
13891
+ for line in set_channel_development_enabled(enabled):
13892
+ print(line)
13893
+ skip_menu = True
13894
+ elif arg == "--ca-clear-channels":
13895
+ for line in clear_channel_specs():
13896
+ print(line)
13897
+ skip_menu = True
13898
+ i += 1
13632
13899
  elif arg == "--ca-no-update-check":
13633
13900
  update_check = False
13634
13901
  skip_menu = True
@@ -13699,6 +13966,9 @@ def build_parser() -> argparse.ArgumentParser:
13699
13966
  wf = sub.add_parser("web-fetch")
13700
13967
  wf.add_argument("value", nargs="?")
13701
13968
  wf.set_defaults(func=cmd_web_fetch)
13969
+ ch = sub.add_parser("channels")
13970
+ ch.add_argument("values", nargs="*")
13971
+ ch.set_defaults(func=cmd_channels)
13702
13972
  on = sub.add_parser("ollama-native")
13703
13973
  on.add_argument("value", nargs="?")
13704
13974
  on.set_defaults(func=cmd_ollama_native)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/claude-any",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
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",