# claude


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

``` python
from fastcore.test import test_eq, test_fail
```

## The wire

One turn is one `query()` or one `claude -p` process. Claude Code has a
real system-prompt channel, so unlike Cursor the briefing goes there and
only the conversation is rendered into the prompt.

The tools are the point. Claude Code declares a caller’s tools to the
model as an in-process MCP server, and an organisation-managed
configuration forbids every dynamic MCP server there is - so on a
managed machine that path leaves the model with no tools at all. This
backend never opens one: it declares an empty MCP configuration on both
paths and the schemas go out as tags in the system prompt, which
[`parse_tool_tags`](https://vedicreader.github.io/rishi/core.html#parse_tool_tags)
reads back off the reply. A managed policy has nothing to refuse.

What it must *not* do is claim `--strict-mcp-config` /
`strict_mcp_config=True`. That flag is refused outright where an
enterprise configuration exists - “You cannot use –strict-mcp-config
when an enterprise MCP config is present” - so the obvious way to say
“only my servers, please” is the one shape a managed machine rejects.
Declare nothing, claim nothing.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/claude.py#L79"
target="_blank" style="float:right; font-size:smaller">source</a>

### norm_claude

``` python
def norm_claude(
    d, model:NoneType=None
):
```

*A Claude Code result -\> a rishi
[`Resp`](https://vedicreader.github.io/rishi/core.html#resp), with
`<tool_call>` tags read out of the text.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/claude.py#L71"
target="_blank" style="float:right; font-size:smaller">source</a>

### norm_claude_usage

``` python
def norm_claude_usage(
    u, model:NoneType=None
):
```

*Claude Code’s usage block -\> rishi’s, so a Claude turn adds up with a
local one.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/claude.py#L63"
target="_blank" style="float:right; font-size:smaller">source</a>

### claude_via

``` python
def claude_via(
    via:NoneType=None
):
```

*Which path to take: what you named, else the SDK when it is installed,
else the CLI.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/claude.py#L56"
target="_blank" style="float:right; font-size:smaller">source</a>

### sdk_available

``` python
def sdk_available():
```

*Is the Claude Agent SDK importable here?*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/claude.py#L49"
target="_blank" style="float:right; font-size:smaller">source</a>

### claude_bin

``` python
def claude_bin(
    bin:str='claude'
):
```

*Absolute path to the `claude` binary, or a `FileNotFoundError` that
says how to get one.*

``` python
test_eq(claude_via('cli'), 'cli')
test_fail(lambda: claude_via('rest'), contains='must be')
r = norm_claude({'result': 'ok\n<tool_call>\n{"name": "ls", "arguments": {"path": "."}}\n</tool_call>',
                 'usage': {'input_tokens': 10, 'output_tokens': 5, 'cache_read_input_tokens': 90}}, opus5)
test_eq(resp_text(r), 'ok')
test_eq(r['tool_calls'][0]['function']['name'], 'ls')
test_eq(r['usage']['total_tokens'], 105)          # cache reads are prompt tokens too
test_fail(lambda: norm_claude({'is_error': True, 'result': 'nope'}), contains='nope')
```

## The chat

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/rishi/blob/main/rishi/claude.py#L91"
target="_blank" style="float:right; font-size:smaller">source</a>

### ClaudeChat

``` python
def ClaudeChat(
    model:NoneType=None, runtime:NoneType=None, model_path:NoneType=None, sp:str='', messages:NoneType=None,
    tools:NoneType=None, ctx_limit:NoneType=None, approve:NoneType=None, tool_max_len:NoneType=None,
    max_steps:int=10, parallel_tools:bool=False, max_parallel_tools:NoneType=None,
    final_prompt:str="You've reached the tool-call budget for this turn. Stop calling tools and answer with what you already have.",
    permission_mode:str='auto', # Claude Code's gate on its *own* tools; yours are rishi's
    claude_tools:NoneType=None, # Claude Code's own tools, allowlisted; None -> its default
    claude_disallowed:tuple=('Bash', 'Write', 'Edit', 'NotebookEdit'), # ...and the ones it may never use
    workspace:NoneType=None, # directory Claude Code works in; None -> the cwd
    effort:NoneType=None, # 'low'/'medium'/'high'/'xhigh'/'max'; None -> the default
    via:NoneType=None, # 'sdk' or 'cli'; None -> the SDK when it is installed
    bin:str='claude', timeout:int=600, settings:NoneType=None, cbs:NoneType=None, default_cbs:bool=True, **opts
):
```

*Chat against a Claude Code model - the same
[`rishi.core.Chat`](https://vedicreader.github.io/rishi/core.html#chat)
API, over the Agent SDK or the CLI.*

## The CLI path

`claude -p` with `--output-format json`. `--strict-mcp-config` with an
empty `--mcp-config` is the whole enterprise story: no dynamic server is
declared, so no policy has anything to refuse.

## The SDK path

`claude_agent_sdk.query` is one turn, asynchronous, yielding messages.
The options carry the same refusal to open an MCP server that `_cmd`
does.

## The two steps [`ToolLoopMixin`](https://vedicreader.github.io/rishi/core.html#toolloopmixin) drives

## Tests

Nothing here starts a model: what is asserted is the command line and
the options - which is where the enterprise contract lives, and the part
that fails silently if it regresses.

``` python
def _fn(query: str) -> str:
    "Search the code."
    return ''

c = ClaudeChat('claude/claude-opus-5', sp='be brief', tools=[_fn], via='cli',
               claude_disallowed=('Bash',))
test_eq(c.model_id, 'claude-opus-5')          # the `claude/` prefix is stripped, the id is not
test_eq(c.local, False)

cmd = c._cmd('json')
# The enterprise contract: no dynamic server is declared, and `--strict-mcp-config` is *not* claimed.
# A managed machine refuses that flag outright, so asking for it is how this path used to fail.
test_eq(cmd[cmd.index('--mcp-config') + 1], NO_MCP)
test_eq('--strict-mcp-config' in cmd, False)
test_eq(cmd[cmd.index('--model') + 1], 'claude-opus-5')
test_eq(cmd[cmd.index('--disallowed-tools') + 1], 'Bash')
# and the prompt is not on the command line at all: `--disallowed-tools` is variadic and would eat it
test_eq(c._prompt() in cmd, False)

# ...so the schemas have to be somewhere, and the system prompt is where
sp = cmd[cmd.index('--system-prompt') + 1]
test_eq('be brief' in sp and '"_fn"' in sp and '<tool_call>' in sp, True)
# and the briefing is *not* also in the prompt, which has a channel of its own here
test_eq('be brief' not in c._prompt(), True)
```

``` python
# The same contract on the SDK path. `eval: false` only because it needs the SDK installed.
o = ClaudeChat('claude/claude-opus-5', sp='be brief', tools=[_fn], via='sdk')._opts('be brief')
test_eq(o.mcp_servers, {})
test_eq(o.strict_mcp_config, False)
test_eq(o.max_turns, None)
test_eq(o.system_prompt, 'be brief')
```
