Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/adapters/claude_sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,24 @@ Runnable scripts: [examples/claude_sdk/](../../examples/claude_sdk/).

When the account or model can't run `"auto"`, the CLI starts the session in
`"default"`, and the adapter logs a warning.
- **CLI launch options never override the adapter's own wiring.**
`cli=ClaudeCLIOptions(...)` sets the executable, plugin folders, extra
directories, env and extra flags, but cannot replace the Band MCP server, the
tool allowlist or `setting_sources`: an `extra_args` flag in
`RESERVED_CLI_FLAGS` raises `ValueError`, as does a dash-prefixed key. That
covers every flag the adapter sets (use its typed option instead) plus flags
such as `settings`, `agents`, `bare` and `dangerously-skip-permissions` that
would bypass Band's wiring, permission gating or host-config isolation. `env`
reaches only the Claude CLI process; the host's `os.environ` is untouched.

```python
import pytest

from band.adapters.claude_sdk import ClaudeCLIOptions, ClaudeSDKAdapter

adapter = ClaudeSDKAdapter(cli=ClaudeCLIOptions(extra_args={"debug-to-stderr": None}))
assert adapter.cli.extra_args == {"debug-to-stderr": None}

with pytest.raises(ValueError, match="adapter-owned CLI flags"):
ClaudeCLIOptions(extra_args={"mcp-config": "{}"})
```
3 changes: 2 additions & 1 deletion src/band/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
)
from band.adapters.agno import AgnoAdapter as AgnoAdapter
from band.adapters.anthropic import AnthropicAdapter as AnthropicAdapter
from band.adapters.claude_sdk import ClaudeCLIOptions as ClaudeCLIOptions
from band.adapters.claude_sdk import ClaudeSDKAdapter as ClaudeSDKAdapter
from band.adapters.codex import (
CodexAdapter as CodexAdapter,
Expand Down Expand Up @@ -115,7 +116,7 @@
langgraph=["LangGraphAdapter"],
anthropic=["AnthropicAdapter"],
pydantic_ai=["PydanticAIAdapter"],
claude_sdk=["ClaudeSDKAdapter"],
claude_sdk=["ClaudeCLIOptions", "ClaudeSDKAdapter"],
copilot_sdk=["CopilotSDKAdapter", "CopilotSDKAdapterConfig"],
copilot_acp=["CopilotACPAdapter", "CopilotACPAdapterConfig"],
cursor_acp=["CursorACPAdapter", "CursorACPAdapterConfig"],
Expand Down
107 changes: 105 additions & 2 deletions src/band/adapters/claude_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
PermissionMode,
PermissionResultAllow,
PermissionResultDeny,
SdkPluginConfig,
ToolPermissionContext,
)

Expand All @@ -56,6 +57,7 @@
_CLAUDE_SDK_AVAILABLE = False

from band_sdk_core import AgentFailure, is_authorized_sender
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing_extensions import Unpack

from band.converters.claude_sdk import (
Expand All @@ -71,6 +73,7 @@
from band.core.simple_adapter import SimpleAdapter
from band.core.turn_lifecycle import ApprovalInterruptMixin
from band.core.types import (
ApprovalMode,
Capability,
Emit,
FeatureKwargs,
Expand Down Expand Up @@ -146,8 +149,103 @@

_PROVIDER = "claude_sdk"

# Approval flow types (mirrors Codex adapter patterns)
ApprovalMode = Literal["auto_accept", "auto_decline", "manual"]
# Flags the SDK emits from the options the adapter builds (pinned against the
# SDK's real command by test_cli_options.py).
SDK_OWNED_CLI_FLAGS: frozenset[str] = frozenset(
{
"add-dir",
"allowedTools",
"append-system-prompt",
"effort",
"fallback-model",
"input-format",
"max-thinking-tokens",
"mcp-config",
"model",
"output-format",
"permission-mode",
"permission-prompt-tool",
"plugin-dir",
"resume",
"setting-sources",
"verbose",
}
)
# Flags the adapter never sets that would still unhook the Band tools, bypass
# its permission gating or host-config isolation, or break per-room sessions.
BAND_UNSAFE_CLI_FLAGS: frozenset[str] = frozenset(
{
"allowed-tools",
"disallowedTools",
"disallowed-tools",
"tools",
"strict-mcp-config",
"system-prompt",
"system-prompt-file",
"settings",
"agents",
"bare",
"dangerously-skip-permissions",
"allow-dangerously-skip-permissions",
"continue",
"fork-session",
"session-id",
"print",
}
)
RESERVED_CLI_FLAGS: frozenset[str] = SDK_OWNED_CLI_FLAGS | BAND_UNSAFE_CLI_FLAGS


class ClaudeCLIOptions(BaseModel):
"""How the Claude CLI process is launched; never how Band is wired into it.

Attributes:
cli_path: ``claude`` executable to launch instead of the one bundled
with ``claude-agent-sdk``.
plugin_dirs: Local Claude Code plugin folders to load; their skills
load without changing ``cwd`` or ``setting_sources``.
add_dirs: Additional directories Claude may access (``--add-dir``).
env: Extra environment variables for the CLI process only; the host's
``os.environ`` is left untouched.
extra_args: Additional CLI flags by bare name, ``{"flag": "value"}``
or ``{"flag": None}`` for a bare flag. Flags in
``RESERVED_CLI_FLAGS`` are rejected.
"""

model_config = ConfigDict(frozen=True)

cli_path: str | None = None
plugin_dirs: tuple[str, ...] = ()
add_dirs: tuple[str, ...] = ()
env: dict[str, str] = Field(default_factory=dict)
extra_args: dict[str, str | None] = Field(default_factory=dict)

@field_validator("extra_args")
@classmethod
def _reject_unsafe_flags(
cls, extra_args: dict[str, str | None]
) -> dict[str, str | None]:
if dashed := sorted(flag for flag in extra_args if flag.startswith("-")):
raise ValueError(f"extra_args keys are bare flag names: {dashed}")
if reserved := sorted(RESERVED_CLI_FLAGS.intersection(extra_args)):
raise ValueError(
f"extra_args may not set adapter-owned CLI flags: {reserved}"
)
return extra_args

def sdk_fields(self) -> dict[str, Any]:
"""The ``ClaudeAgentOptions`` fields these options own."""
return {
"cli_path": self.cli_path,
"plugins": [
SdkPluginConfig(type="local", path=path) for path in self.plugin_dirs
],
"add_dirs": list(self.add_dirs),
"env": dict(self.env),
"extra_args": dict(self.extra_args),
}


ApprovalDecision = Literal["accept", "decline"]

# dontAsk denies every prompt without calling can_use_tool, so no approval_mode
Expand Down Expand Up @@ -333,6 +431,7 @@ def __init__(
additional_tools: list[CustomToolDef] | None = None,
cwd: str | None = None,
setting_sources: list[str] | None = None,
cli: ClaudeCLIOptions | None = None,
# Chat-based approval flow (opt-in)
approval_mode: ApprovalMode | None = None,
approval_text_notifications: bool = True,
Expand Down Expand Up @@ -369,6 +468,8 @@ def __init__(
tuples. These are converted to MCP tools internally.
cwd: Working directory for Claude Code sessions. If set, Claude Code
will operate in this directory (e.g., a mounted git repo).
cli: How the Claude CLI process is launched (executable, plugins,
extra directories, env, extra flags); see ``ClaudeCLIOptions``.
approval_mode: Chat-based approval mode. ``None`` (default) disables
chat-based approval -- the SDK's ``permission_mode`` controls
approvals entirely. Set to ``"manual"`` to route approval
Expand Down Expand Up @@ -420,6 +521,7 @@ def __init__(
if cwd and not Path(cwd).is_dir():
raise ValueError(f"cwd does not exist or is not a directory: {cwd}")
self.cwd = cwd
self.cli = cli or ClaudeCLIOptions()
# Which host settings the CLI loads (skills/subagents/settings from
# ~/.claude and ./.claude). Default isolates the bridged agent so its
# capabilities are defined here, not by whatever config sits on the host
Expand Down Expand Up @@ -542,6 +644,7 @@ async def on_started(self, agent_name: str, agent_description: str) -> None:
# cast: the public param is list[str]; the SDK types it as a list of the
# "user"/"project"/"local" literals. The CLI validates the values.
setting_sources=cast("Any", self.setting_sources),
**self.cli.sdk_fields(),
)

# Add extended thinking if configured
Expand Down
2 changes: 1 addition & 1 deletion src/band/adapters/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from band.core.turn_lifecycle import ApprovalInterruptMixin
from band.core.types import (
AgentInput,
ApprovalMode,
Capability,
Emit,
FeatureKwargs,
Expand Down Expand Up @@ -111,7 +112,6 @@ def _image_content_items(result: dict[str, Any]) -> list[dict[str, Any]]:


TransportKind = Literal["stdio", "ws"]
ApprovalMode = Literal["auto_accept", "auto_decline", "manual"]
ApprovalDecision = Literal["accept", "acceptForSession", "decline"]
_REASONING_SUMMARIES = {"auto", "concise", "detailed", "none"}

Expand Down
3 changes: 1 addition & 2 deletions src/band/adapters/cursor_acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from band.client.streaming import ControlMode
from band.core.protocols import AgentToolsProtocol
from band.core.types import FeatureKwargs, PlatformMessage
from band.core.types import ApprovalMode, FeatureKwargs, PlatformMessage
from band.integrations.acp.client_adapter import (
ACPClientAdapter,
ACPPermissionRequest,
Expand Down Expand Up @@ -45,7 +45,6 @@
logger = logging.getLogger(__name__)

DEFAULT_CURSOR_ACP_COMMAND: tuple[str, ...] = ("agent", "acp")
ApprovalMode = Literal["manual", "auto_accept", "auto_decline"]
QuestionMode = Literal["manual", "auto_first", "auto_cancel"]
PlanMode = Literal["manual", "auto_accept", "auto_decline"]
DecisionKind = Literal["permission", "question", "plan"]
Expand Down
3 changes: 2 additions & 1 deletion src/band/adapters/opencode/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

from pydantic_settings import BaseSettings, SettingsConfigDict

ApprovalMode = Literal["manual", "auto_accept", "auto_decline"]
from band.core.types import ApprovalMode

QuestionMode = Literal["manual", "auto_reject"]
ApprovalReply = Literal["once", "always", "reject"]

Expand Down
5 changes: 5 additions & 0 deletions src/band/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,11 @@ class PlatformConnection:
ws_url: str


# How an adapter settles a tool-permission request: ask in chat, or answer
# it automatically.
ApprovalMode = Literal["manual", "auto_accept", "auto_decline"]


class FeatureKwargs(TypedDict, total=False):
"""The feature keywords every ``SimpleAdapter`` constructor accepts.

Expand Down
103 changes: 103 additions & 0 deletions tests/adapters/claude_sdk/test_cli_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""CLI launch options a host sets on the adapter reach the Claude CLI."""

from __future__ import annotations

from collections.abc import Awaitable, Callable
from pathlib import Path

import pytest
from claude_agent_sdk import ClaudeAgentOptions
from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
from claude_agent_sdk.types import _configure_can_use_tool

from band.adapters.claude_sdk import SDK_OWNED_CLI_FLAGS, ClaudeCLIOptions
from tests.adapters.claude_sdk.helpers import SEND_MESSAGE_MCP_NAME, ClaudeRoom
from tests.paths import host_absolute_path

OpenRoom = Callable[..., Awaitable[ClaudeRoom]]


async def _cli_options(
room: ClaudeRoom, session_id: str | None = None
) -> ClaudeAgentOptions:
"""The options the room's CLI process was started with."""
room.claude.script([room.model_reply("Hello.")])
await room.send("hi", session_id=session_id)
[session] = room.claude.sessions
return session.options


def _emitted_flags(options: ClaudeAgentOptions) -> set[str]:
"""The ``--flags`` the SDK's own connect path would launch the CLI with."""
transport = SubprocessCLITransport(
prompt="", options=_configure_can_use_tool(options)
)
# _build_command refuses to run until connect() has located the binary.
transport._cli_path = "claude"
return {
token.removeprefix("--").partition("=")[0]
for token in transport._build_command()
if token.startswith("--")
}


async def test_cli_options_reach_the_cli(claude_room: OpenRoom, tmp_path: Path) -> None:
cli = ClaudeCLIOptions(
cli_path=host_absolute_path("opt", "claude", "bin", "claude"),
plugin_dirs=(str(tmp_path / "plugin"),),
add_dirs=(str(tmp_path / "shared"),),
env={"ANTHROPIC_API_KEY": "per-agent-key"},
extra_args={"debug-to-stderr": None},
)
room = await claude_room(cli=cli)

options = await _cli_options(room)

assert options.plugins == [{"type": "local", "path": str(tmp_path / "plugin")}]
assert options.cli_path == cli.cli_path
assert options.env == {"ANTHROPIC_API_KEY": "per-agent-key"}
assert options.add_dirs == [str(tmp_path / "shared")]
assert options.extra_args == {"debug-to-stderr": None}


async def test_plugins_leave_band_wiring_in_place(
claude_room: OpenRoom, tmp_path: Path
) -> None:
room = await claude_room(cli=ClaudeCLIOptions(plugin_dirs=(str(tmp_path),)))

options = await _cli_options(room)

assert list(options.mcp_servers) == ["band"]
assert SEND_MESSAGE_MCP_NAME in options.allowed_tools
assert options.setting_sources == []


async def test_sdk_owned_flags_match_what_the_adapter_makes_the_sdk_emit(
claude_room: OpenRoom, tmp_path: Path
) -> None:
room = await claude_room(
model="sonnet",
fallback_model="haiku",
effort="high",
max_thinking_tokens=1024,
cwd=str(tmp_path),
approval_mode="manual",
cli=ClaudeCLIOptions(plugin_dirs=(str(tmp_path),), add_dirs=(str(tmp_path),)),
)

options = await _cli_options(room, session_id="sess-1")

assert _emitted_flags(options) == SDK_OWNED_CLI_FLAGS


@pytest.mark.parametrize(
"flag", ["mcp-config", "effort", "settings", "dangerously-skip-permissions"]
)
def test_extra_args_cannot_replace_adapter_owned_flags(flag: str) -> None:
with pytest.raises(ValueError, match="adapter-owned CLI flags"):
ClaudeCLIOptions(extra_args={flag: "x"})


def test_extra_args_keys_are_bare_flag_names() -> None:
with pytest.raises(ValueError, match="bare flag names"):
ClaudeCLIOptions(extra_args={"--debug-to-stderr": None})
Loading