@oneciel-ai/claude-any 0.1.71 → 0.1.72

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 +428 -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,137 @@ 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 channel_development_enabled(cfg: dict[str, Any] | None = None) -> bool:
8763
+ cfg = cfg or load_config()
8764
+ return bool(cfg.setdefault("claude_code", {}).get("development_channels", False))
8765
+
8766
+
8767
+ def channel_status_text(cfg: dict[str, Any] | None = None) -> str:
8768
+ cfg = cfg or load_config()
8769
+ channels = channel_specs(cfg)
8770
+ if not channels:
8771
+ return "off"
8772
+ suffix = "; dev" if channel_development_enabled(cfg) else ""
8773
+ return f"{len(channels)} channel{'s' if len(channels) != 1 else ''}{suffix}"
8774
+
8775
+
8776
+ def set_channel_development_enabled(enabled: bool) -> list[str]:
8777
+ cfg = load_config()
8778
+ cfg.setdefault("claude_code", {})["development_channels"] = bool(enabled)
8779
+ save_config(cfg)
8780
+ return [f"Development channels: {'on' if enabled else 'off'}."]
8781
+
8782
+
8783
+ def add_channel_spec(spec: str, *, development: bool = False) -> list[str]:
8784
+ spec = spec.strip()
8785
+ if not spec:
8786
+ return ["Channel spec was empty."]
8787
+ cfg = load_config()
8788
+ cc = cfg.setdefault("claude_code", {})
8789
+ channels = channel_specs(cfg)
8790
+ if spec not in channels:
8791
+ channels.append(spec)
8792
+ cc["channels"] = channels
8793
+ if development:
8794
+ cc["development_channels"] = True
8795
+ save_config(cfg)
8796
+ lines = [f"Channel added: {spec}."]
8797
+ if development:
8798
+ lines.append("Development channels: on.")
8799
+ return lines
8800
+
8801
+
8802
+ def remove_channel_spec(spec: str) -> list[str]:
8803
+ cfg = load_config()
8804
+ cc = cfg.setdefault("claude_code", {})
8805
+ before = channel_specs(cfg)
8806
+ after = [item for item in before if item != spec]
8807
+ cc["channels"] = after
8808
+ save_config(cfg)
8809
+ return [f"Channel removed: {spec}." if len(after) != len(before) else f"Channel was not configured: {spec}."]
8810
+
8811
+
8812
+ def clear_channel_specs() -> list[str]:
8813
+ cfg = load_config()
8814
+ cfg.setdefault("claude_code", {})["channels"] = []
8815
+ save_config(cfg)
8816
+ return ["Claude Code channels cleared."]
8817
+
8818
+
8819
+ def cmd_channels(args: argparse.Namespace) -> None:
8820
+ cfg = load_config()
8821
+ values = list(getattr(args, "values", []) or [])
8822
+ if not values:
8823
+ print(f"channels: {channel_status_text(cfg)}")
8824
+ for name, spec in OFFICIAL_CHANNEL_PLUGINS.items():
8825
+ mark = "*" if spec in channel_specs(cfg) else " "
8826
+ print(f" {mark} {name:<10} {spec}")
8827
+ for spec in channel_specs(cfg):
8828
+ if spec not in OFFICIAL_CHANNEL_PLUGINS.values():
8829
+ print(f" * custom {spec}")
8830
+ print(f"development_channels: {'on' if channel_development_enabled(cfg) else 'off'}")
8831
+ return
8832
+ head = values[0].strip().lower()
8833
+ if head in ("on", "enable", "add"):
8834
+ if len(values) < 2:
8835
+ raise SystemExit("Usage: claude-any channels add CHANNEL_SPEC")
8836
+ for line in add_channel_spec(values[1]):
8837
+ print(line)
8838
+ return
8839
+ if head in ("dev", "development"):
8840
+ if len(values) >= 2 and values[1].lower() in ("on", "off", "true", "false", "1", "0"):
8841
+ enabled = values[1].lower() in ("on", "true", "1")
8842
+ for line in set_channel_development_enabled(enabled):
8843
+ print(line)
8844
+ return
8845
+ if len(values) < 2:
8846
+ raise SystemExit("Usage: claude-any channels dev CHANNEL_SPEC | claude-any channels dev on|off")
8847
+ for line in add_channel_spec(values[1], development=True):
8848
+ print(line)
8849
+ return
8850
+ if head in ("off", "disable", "remove", "rm"):
8851
+ if len(values) < 2:
8852
+ raise SystemExit("Usage: claude-any channels remove CHANNEL_SPEC")
8853
+ for line in remove_channel_spec(values[1]):
8854
+ print(line)
8855
+ return
8856
+ if head in ("clear", "reset"):
8857
+ for line in clear_channel_specs():
8858
+ print(line)
8859
+ return
8860
+ if head in OFFICIAL_CHANNEL_PLUGINS:
8861
+ spec = OFFICIAL_CHANNEL_PLUGINS[head]
8862
+ if spec in channel_specs(cfg):
8863
+ for line in remove_channel_spec(spec):
8864
+ print(line)
8865
+ else:
8866
+ for line in add_channel_spec(spec):
8867
+ print(line)
8868
+ return
8869
+ for line in add_channel_spec(values[0]):
8870
+ print(line)
8871
+
8872
+
8754
8873
  def cmd_ollama_native(args: argparse.Namespace) -> None:
8755
8874
  cfg = load_config()
8756
8875
  pcfg = cfg["providers"]["ollama"]
@@ -11937,8 +12056,9 @@ def main_menu_rows(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any], lan
11937
12056
  f"4. {ui_text('model', lang)} [{compact_text(pcfg.get('current_model', 'unset'), 62)}]",
11938
12057
  f"5. {ui_text('advisor_model', lang)} [{compact_text(pcfg.get('advisor_model') or 'off', 62)}]",
11939
12058
  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)}",
12059
+ f"7. Channels [{channel_status_text(cfg)}]",
12060
+ f"8. {ui_text('test', lang)}",
12061
+ f"9. {ui_text('launch', lang)}",
11942
12062
  ui_text("quit", lang),
11943
12063
  ]
11944
12064
 
@@ -12010,6 +12130,29 @@ def advisor_model_panel_rows(provider: str, pcfg: dict[str, Any]) -> tuple[list[
12010
12130
  return rows, deduped_values
12011
12131
 
12012
12132
 
12133
+ def channel_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
12134
+ channels = channel_specs(cfg)
12135
+ dev_enabled = channel_development_enabled(cfg)
12136
+ rows: list[str] = []
12137
+ values: list[str] = []
12138
+ rows.append(f"Development channel loading [{'on' if dev_enabled else 'off'}]")
12139
+ values.append("__toggle_dev__")
12140
+ for name, spec in OFFICIAL_CHANNEL_PLUGINS.items():
12141
+ mark = "*" if spec in channels else " "
12142
+ rows.append(f"{mark} {name:<10} {spec}")
12143
+ values.append(spec)
12144
+ rows.append("+ Add development/custom channel...")
12145
+ values.append("__add_custom__")
12146
+ if channels:
12147
+ rows.append("- Remove channel...")
12148
+ values.append("__remove__")
12149
+ rows.append("Clear all channels")
12150
+ values.append("__clear__")
12151
+ rows.append("Back")
12152
+ values.append("back")
12153
+ return rows, values
12154
+
12155
+
12013
12156
  def api_key_panel_rows(provider: str) -> tuple[list[str], list[str]]:
12014
12157
  rows = [
12015
12158
  "Type or paste API key as hidden input",
@@ -12310,7 +12453,7 @@ def portable_language_menu() -> int:
12310
12453
 
12311
12454
  def portable_prelaunch_menu() -> int:
12312
12455
  enable_ansi()
12313
- main_idx = 7 if settings_ready_except_api_key() else 0
12456
+ main_idx = 9 if settings_ready_except_api_key() else 0
12314
12457
  panel: str | None = None
12315
12458
  panel_idx = 0
12316
12459
  panel_rows: list[str] = []
@@ -12352,6 +12495,8 @@ def portable_prelaunch_menu() -> int:
12352
12495
  panel_rows, panel_values = ["Run compatibility test", "Back"], ["run", "back"]
12353
12496
  elif name == "options":
12354
12497
  panel_rows, panel_values = llm_option_panel_rows(provider, pcfg, cfg.get("language", "en"))
12498
+ elif name == "channels":
12499
+ panel_rows, panel_values = channel_panel_rows(cfg)
12355
12500
  elif name == "context":
12356
12501
  panel_rows, panel_values = context_setup_panel_rows(provider, pcfg, cfg.get("language", "en"))
12357
12502
  elif name == "preset":
@@ -12528,7 +12673,40 @@ def portable_prelaunch_menu() -> int:
12528
12673
  messages = lines[-8:] if lines else ["Test produced no output."]
12529
12674
  panel_rows, panel_values = ["Run compatibility test again", "Back"], ["run", "back"]
12530
12675
  refresh_checks()
12531
- main_idx = 8 if "Compatibility: OK" in out else 4
12676
+ main_idx = 9 if "Compatibility: OK" in out else 4
12677
+ elif panel == "channels":
12678
+ if value == "back":
12679
+ close_panel()
12680
+ elif value == "__toggle_dev__":
12681
+ messages = set_channel_development_enabled(not channel_development_enabled(cfg))
12682
+ cfg = load_config()
12683
+ panel_rows, panel_values = channel_panel_rows(cfg)
12684
+ panel_idx = 0
12685
+ elif value == "__add_custom__":
12686
+ spec = prompt_menu_value("Channel spec (for example plugin:ainet@local or server:ainet)", restore_tty=restore_line_mode, raw_tty=restore_raw_mode)
12687
+ if spec:
12688
+ messages = add_channel_spec(spec, development=True)
12689
+ cfg = load_config()
12690
+ panel_rows, panel_values = channel_panel_rows(cfg)
12691
+ elif value == "__remove__":
12692
+ spec = prompt_menu_value("Channel spec to remove", "", restore_tty=restore_line_mode, raw_tty=restore_raw_mode)
12693
+ if spec:
12694
+ messages = remove_channel_spec(spec)
12695
+ cfg = load_config()
12696
+ panel_rows, panel_values = channel_panel_rows(cfg)
12697
+ elif value == "__clear__":
12698
+ messages = clear_channel_specs()
12699
+ cfg = load_config()
12700
+ panel_rows, panel_values = channel_panel_rows(cfg)
12701
+ panel_idx = 0
12702
+ elif value:
12703
+ if value in channel_specs(cfg):
12704
+ messages = remove_channel_spec(value)
12705
+ else:
12706
+ messages = add_channel_spec(value)
12707
+ cfg = load_config()
12708
+ panel_rows, panel_values = channel_panel_rows(cfg)
12709
+ refresh_checks()
12532
12710
  elif panel == "options":
12533
12711
  if value == "back":
12534
12712
  close_panel()
@@ -12627,13 +12805,13 @@ def portable_prelaunch_menu() -> int:
12627
12805
  continue
12628
12806
 
12629
12807
  if key in ("up", "k"):
12630
- main_idx = (main_idx - 1) % 10
12808
+ main_idx = (main_idx - 1) % 11
12631
12809
  elif key in ("down", "j"):
12632
- main_idx = (main_idx + 1) % 10
12810
+ main_idx = (main_idx + 1) % 11
12633
12811
  elif key in ("esc", "q"):
12634
12812
  return 10
12635
12813
  elif key == "enter":
12636
- actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "test", "launch", "quit"]
12814
+ actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "channels", "test", "launch", "quit"]
12637
12815
  action = actions[main_idx]
12638
12816
  if action == "launch":
12639
12817
  blockers = launch_readiness_errors()
@@ -12722,6 +12900,18 @@ def has_passthrough_option(passthrough: list[str], *names: str) -> bool:
12722
12900
  return any(arg in names or any(arg.startswith(name + "=") for name in names) for arg in passthrough)
12723
12901
 
12724
12902
 
12903
+ def claude_channel_args(cfg: dict[str, Any], passthrough: list[str]) -> list[str]:
12904
+ channels = channel_specs(cfg)
12905
+ if not channels or has_passthrough_option(passthrough, "--channels"):
12906
+ return []
12907
+ args: list[str] = []
12908
+ if channel_development_enabled(cfg) and not has_passthrough_option(passthrough, "--dangerously-load-development-channels"):
12909
+ args.append("--dangerously-load-development-channels")
12910
+ args.append("--channels")
12911
+ args.extend(channels)
12912
+ return args
12913
+
12914
+
12725
12915
  def write_web_tools_mcp_config(cfg: dict[str, Any]) -> Path:
12726
12916
  web = cfg.get("web_search", {})
12727
12917
  package = web.get("package") or "ddg-mcp-search"
@@ -13061,6 +13251,7 @@ def launch_claude(
13061
13251
  extra_args.extend(["--mcp-config", str(write_duckduckgo_mcp_config(cfg))])
13062
13252
  if should_append_compat_prompt(provider, cfg) and not has_passthrough_option(passthrough, "--system-prompt"):
13063
13253
  extra_args.extend(["--append-system-prompt", NON_ANTHROPIC_COMPAT_PROMPT])
13254
+ extra_args.extend(claude_channel_args(cfg, passthrough))
13064
13255
  cmd = [
13065
13256
  claude,
13066
13257
  "--dangerously-skip-permissions",
@@ -13091,6 +13282,7 @@ Control plane, runs before Claude Code and does not require LLM connectivity:
13091
13282
  claude-any set-api-key PROVIDER KEY
13092
13283
  claude-any web-search [on|off] Auto-attach DuckDuckGo MCP for non-native providers
13093
13284
  claude-any web-fetch [on|off] Auto-attach fetch MCP for web page content
13285
+ claude-any channels [cmd] Configure Claude Code --channels auto-injection
13094
13286
  claude-any ollama-native [on|off] Use Ollama's official Claude Code env path
13095
13287
  claude-any ollama-options [provider] [key=value ...]
13096
13288
  Set Ollama num_ctx/options/keep_alive/think
@@ -13129,6 +13321,11 @@ Headless setup flags, namespaced to avoid Claude CLI collisions:
13129
13321
  claude-any --ca-no-web-search Disable DuckDuckGo MCP for this launch
13130
13322
  claude-any --ca-web-fetch Enable fetch MCP
13131
13323
  claude-any --ca-no-web-fetch Disable fetch MCP
13324
+ claude-any --ca-channel SPEC Add an official/approved Claude Code channel
13325
+ claude-any --ca-dev-channel SPEC Add a development channel and enable dev loading
13326
+ claude-any --ca-development-channels on|off
13327
+ Auto-add --dangerously-load-development-channels
13328
+ claude-any --ca-clear-channels Clear saved channel auto-injection specs
13132
13329
  claude-any --ca-no-self-update-check
13133
13330
  Skip Claude Any npm self-update check
13134
13331
  claude-any --ca-no-update-check Skip Claude Code update check for this launch
@@ -13236,6 +13433,28 @@ def apply_headless_env_config() -> tuple[bool, bool | None, bool | None, bool |
13236
13433
  if ollama_values:
13237
13434
  cmd_ollama_options(argparse.Namespace(values=ollama_values))
13238
13435
  skip_menu = True
13436
+ channel_values = [
13437
+ item.strip()
13438
+ for item in re.split(r"[\s,]+", os.environ.get("CLAUDE_ANY_CHANNELS", "").strip())
13439
+ if item.strip()
13440
+ ]
13441
+ for channel_value in channel_values:
13442
+ add_channel_spec(channel_value)
13443
+ skip_menu = True
13444
+ dev_channel_values = [
13445
+ item.strip()
13446
+ for item in re.split(r"[\s,]+", os.environ.get("CLAUDE_ANY_DEV_CHANNELS", "").strip())
13447
+ if item.strip()
13448
+ ]
13449
+ for channel_value in dev_channel_values:
13450
+ add_channel_spec(channel_value, development=True)
13451
+ skip_menu = True
13452
+ dev_channels = os.environ.get("CLAUDE_ANY_DEVELOPMENT_CHANNELS", "").strip().lower()
13453
+ if dev_channels:
13454
+ if dev_channels not in ("on", "off", "true", "false", "1", "0"):
13455
+ raise SystemExit("CLAUDE_ANY_DEVELOPMENT_CHANNELS must be on or off")
13456
+ set_channel_development_enabled(dev_channels in ("on", "true", "1"))
13457
+ skip_menu = True
13239
13458
  return skip_menu, web_search_override, update_check_override, self_update_check_override, force_menu
13240
13459
 
13241
13460
 
@@ -13295,6 +13514,9 @@ def run_cli(argv: list[str]) -> int:
13295
13514
  if head in ("web-fetch", "webfetch"):
13296
13515
  cmd_web_fetch(argparse.Namespace(value=rest[0] if rest else None))
13297
13516
  return 0
13517
+ if head in ("channels", "channel"):
13518
+ cmd_channels(argparse.Namespace(values=rest))
13519
+ return 0
13298
13520
  if head in ("ollama-native", "ollama-compat"):
13299
13521
  cmd_ollama_native(argparse.Namespace(value=rest[0] if rest else None))
13300
13522
  return 0
@@ -13629,6 +13851,48 @@ def run_cli(argv: list[str]) -> int:
13629
13851
  cmd_web_fetch(argparse.Namespace(value="off"))
13630
13852
  skip_menu = True
13631
13853
  i += 1
13854
+ elif arg == "--ca-channel" or arg.startswith("--ca-channel="):
13855
+ value = arg.split("=", 1)[1] if "=" in arg else None
13856
+ if value is None:
13857
+ if i + 1 >= len(argv):
13858
+ raise SystemExit("Missing channel spec for --ca-channel")
13859
+ value = argv[i + 1]
13860
+ i += 2
13861
+ else:
13862
+ i += 1
13863
+ for line in add_channel_spec(value):
13864
+ print(line)
13865
+ skip_menu = True
13866
+ elif arg == "--ca-dev-channel" or arg.startswith("--ca-dev-channel="):
13867
+ value = arg.split("=", 1)[1] if "=" in arg else None
13868
+ if value is None:
13869
+ if i + 1 >= len(argv):
13870
+ raise SystemExit("Missing channel spec for --ca-dev-channel")
13871
+ value = argv[i + 1]
13872
+ i += 2
13873
+ else:
13874
+ i += 1
13875
+ for line in add_channel_spec(value, development=True):
13876
+ print(line)
13877
+ skip_menu = True
13878
+ elif arg == "--ca-development-channels" or arg.startswith("--ca-development-channels="):
13879
+ value = arg.split("=", 1)[1] if "=" in arg else None
13880
+ if value is None:
13881
+ if i + 1 >= len(argv):
13882
+ raise SystemExit("Missing on/off for --ca-development-channels")
13883
+ value = argv[i + 1]
13884
+ i += 2
13885
+ else:
13886
+ i += 1
13887
+ enabled = value.strip().lower() in ("on", "enable", "enabled", "true", "1")
13888
+ for line in set_channel_development_enabled(enabled):
13889
+ print(line)
13890
+ skip_menu = True
13891
+ elif arg == "--ca-clear-channels":
13892
+ for line in clear_channel_specs():
13893
+ print(line)
13894
+ skip_menu = True
13895
+ i += 1
13632
13896
  elif arg == "--ca-no-update-check":
13633
13897
  update_check = False
13634
13898
  skip_menu = True
@@ -13699,6 +13963,9 @@ def build_parser() -> argparse.ArgumentParser:
13699
13963
  wf = sub.add_parser("web-fetch")
13700
13964
  wf.add_argument("value", nargs="?")
13701
13965
  wf.set_defaults(func=cmd_web_fetch)
13966
+ ch = sub.add_parser("channels")
13967
+ ch.add_argument("values", nargs="*")
13968
+ ch.set_defaults(func=cmd_channels)
13702
13969
  on = sub.add_parser("ollama-native")
13703
13970
  on.add_argument("value", nargs="?")
13704
13971
  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.72",
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",