# Plan: Command Provider

Add a new provider type `command` that shells out to an external program, passing the full conversation on stdin and reading the response from stdout. This lets users wire up any CLI-based LLM (e.g. `claude -p`, `ollama run`, a custom wrapper script) without needing an OpenAI-compatible HTTP server.

## Design Decision: Text-Only Provider

The command provider is a **text-only provider**. It does not participate in the tool-calling loop. When `provider=command`, swival passes `tools=None` everywhere tools flow through the agent loop — LLM calls, token estimation, output clamping, compaction retries, and post-turn stats. The model produces a final text answer each turn, no tool dispatch. This is the right fit because:

- Target commands (`claude -p`, `ollama run`) speak plain text, not OpenAI function-calling JSON.
- Swival's built-in tools (read_file, write_file, etc.) are always present in `build_tools()` — but the command provider never sees them and their schemas are excluded from all token accounting.
- The agent loop already handles `tools=None` correctly: it skips tool_choice, and when `msg.tool_calls` is falsy the loop returns the text answer.

Users who want a command that does tool calling should front it with an OpenAI-compatible server and use `--provider generic` instead.

## How It Works

The command behaves like a unix filter:

- **stdin**: receives the conversation as a rendered text transcript (system prompt, all turns, tool results with names — the full context the model would normally see)
- **stdout**: the model's text response
- **stderr**: captured; printed to swival's stderr on success, used for error message on failure
- **exit code**: 0 = success, non-zero = error

Example usage:
```sh
swival --provider command --model "claude -p"
swival --provider command --model ./my-llm-wrapper.sh
```

Or in `swival.toml`:
```toml
provider = "command"
model = "claude -p"
```

The `model` field holds the command string. It's split with `shlex.split()` to support arguments. `--base-url` and `--api-key` are unused and ignored for this provider.

---

## Changes by File

### 1. `swival/agent.py`

#### CLI argument: add `"command"` to `--provider` choices (~line 2181)

```python
choices=["lmstudio", "huggingface", "openrouter", "generic", "google", "chatgpt", "command"],
```

#### `resolve_provider()`: add command branch (~line 2663)

```python
elif provider == "command":
    if not model:
        raise ConfigError("--model is required for 'command' provider (the command to run)")
    import shlex
    parts = shlex.split(model)
    if not parts:
        raise ConfigError("--model is empty for 'command' provider")
    if not shutil.which(parts[0]):
        raise ConfigError(f"command not found: {parts[0]}")
    return (model, None, None, max_context_tokens, {"provider": "command"})
```

Key: `context_length` = `max_context_tokens` (not `None`). This preserves the user's `--max-context-tokens` setting so that `clamp_output_tokens()` and overflow-driven compaction work. When the user doesn't pass `--max-context-tokens`, it's `None` and compaction won't fire — which is correct since we can't know the command's actual limit. But users who know their command's context window can set it explicitly and get compaction for free.

Returns: `model_id` = raw command string, `api_base` = None, `api_key` = None.

#### Agent loop: use `effective_tools` everywhere tools appear

In `run_agent_loop()`, tools flow to **seven** places. All must use `None` for the command provider:

```python
# Near top of loop body, before any tools usage
effective_tools = None if provider == "command" else tools
```

Then substitute `effective_tools` for `tools` at every call site:

1. **Token estimation before LLM call** (~line 3622): `estimate_tokens(messages, effective_tools)`
2. **Output clamping** (~line 3628): `clamp_output_tokens(messages, effective_tools, context_length, max_output_tokens)`
3. **call_llm()** (~line 3636): pass `effective_tools`
4. **Compaction retry — token estimate before** (~line 3708): `estimate_tokens(messages, effective_tools)`
5. **Compaction retry — re-clamp** (~line 3712): `clamp_output_tokens(messages, effective_tools, context_length, max_output_tokens)`
6. **Compaction retry — token estimate after** (~line 3715): `estimate_tokens(messages, effective_tools)`
7. **Post-turn context stats** (~line 4088): `estimate_tokens(messages, effective_tools)`

This is a mechanical substitution — one variable definition, seven replacements. The `tools` variable itself stays unchanged so that `build_tools()` output is preserved for other purposes (system prompt generation, etc.).

#### `call_llm()`: add command branch **before** the provider dispatch block

The command branch goes right after the `sanitize_thinking` default (~line 1686), before the `if provider == "lmstudio":` block at line 1693 — otherwise the `else: raise AgentError("unknown provider")` at line 1714 fires first.

```python
if provider == "command":
    return _call_command(model_id, messages, verbose)
```

Returns early, bypassing all litellm logic. This intentionally skips:
- **Cache** (`cache` kwarg, line 1749): not applicable — the command is an opaque subprocess, caching would need the command string + transcript as key which adds complexity for little value in v1.
- **sanitize_thinking** (line 1816): unsupported — raw commands can emit `<think>` tags but stripping is bypassed. See "sanitize_thinking" section below for details.
- **`_pick_best_choice()`** (line 1815): not applicable — there's a single response.

These exclusions are intentional, not accidental.

#### `_resolve_model_str()`: add passthrough

```python
elif provider == "command":
    return model_id
```

Not strictly needed (call_llm returns before model_str is used), but prevents crashes if the function is called from elsewhere.

#### New function: `_call_command()`

```python
def _call_command(command_str, messages, verbose):
    """Run an external command as the LLM, passing the conversation on stdin."""
    import shlex

    parts = shlex.split(command_str)
    transcript = _render_transcript(messages)

    if verbose:
        fmt.model_info(f"Running command: {command_str}")

    try:
        proc = subprocess.run(
            parts,
            input=transcript,
            capture_output=True,
            text=True,
            timeout=300,
        )
    except subprocess.TimeoutExpired as e:
        raise AgentError(f"command timed out after 300s: {command_str}") from e
    except OSError as e:
        raise AgentError(f"command failed to start: {e}") from e

    if proc.returncode != 0:
        error_text = (proc.stderr.strip() or proc.stdout.strip()
                      or f"exit code {proc.returncode}")
        raise AgentError(f"command provider failed: {error_text}")

    # Print stderr on success only (on failure it's already in the AgentError)
    if proc.stderr.strip():
        print(proc.stderr, end="", file=sys.stderr)

    response_text = proc.stdout.strip()
    if not response_text:
        raise AgentError("command provider returned empty output")

    msg = _make_synthetic_message(response_text)
    return msg, "stop"
```

#### New function: `_render_transcript()`

Renders the messages list as a readable text transcript. Builds a `tool_call_id → function_name` index from assistant messages so that tool-result messages get proper labels (tool results carry `tool_call_id` but not `name` — see `handle_tool_call` at line 1494).

Images in multipart content are replaced with a `[image omitted]` placeholder rather than silently dropped, so the command (and the user reviewing the transcript) knows image data was present but excluded.

```python
def _render_transcript(messages):
    """Render a messages list as a plain-text transcript for command provider.

    Builds a tool_call_id → name index so tool results get labeled.
    """
    # First pass: index tool_call_id → function name from assistant messages
    tc_names = {}  # tool_call_id → function name
    for m in messages:
        tool_calls = None
        if isinstance(m, dict):
            tool_calls = m.get("tool_calls")
        else:
            tool_calls = getattr(m, "tool_calls", None)
        if tool_calls:
            for tc in tool_calls:
                if isinstance(tc, dict):
                    tc_id = tc.get("id", "")
                    fn = tc.get("function", {})
                    name = fn.get("name", "tool")
                else:
                    tc_id = getattr(tc, "id", "")
                    fn = getattr(tc, "function", None)
                    name = getattr(fn, "name", "tool") if fn else "tool"
                tc_names[tc_id] = name

    # Second pass: render
    lines = []
    for m in messages:
        if isinstance(m, dict):
            role = m.get("role", "unknown")
            content = m.get("content", "")
            tool_call_id = m.get("tool_call_id")
        else:
            role = getattr(m, "role", "unknown")
            content = getattr(m, "content", "")
            tool_call_id = getattr(m, "tool_call_id", None)

        # Handle multipart content (text + images)
        if isinstance(content, list):
            parts = []
            for p in content:
                if isinstance(p, dict):
                    if p.get("type") == "text":
                        parts.append(p.get("text", ""))
                    elif p.get("type") in ("image_url", "image"):
                        parts.append("[image omitted]")
            content = "\n".join(parts)

        if not content:
            continue

        if role == "system":
            lines.append(f"[system]\n{content}")
        elif role == "user":
            lines.append(f"[user]\n{content}")
        elif role == "assistant":
            lines.append(f"[assistant]\n{content}")
        elif role == "tool":
            tool_name = tc_names.get(tool_call_id, "tool")
            lines.append(f"[tool:{tool_name}]\n{content}")
        else:
            lines.append(f"[{role}]\n{content}")

    return "\n\n".join(lines)
```

#### Token estimation for command provider

`estimate_tokens()` operates on the raw messages list, not on `_render_transcript()` output. Tool schemas are already excluded (`effective_tools=None`), but two other sources create overcounts — see the "Token estimation accuracy" section under "Interaction With Agent Loop Features" for the full analysis.

#### New function: `_make_synthetic_message()`

Returns a namespace object compatible with the agent loop's attribute access pattern:

```python
def _make_synthetic_message(text):
    """Build a synthetic message object compatible with the agent loop.

    The loop uses: msg.content, msg.tool_calls, msg.model_dump(exclude_none=True),
    getattr(msg, "content", None), getattr(msg, "tool_calls", None).
    """
    class _SyntheticMessage:
        __slots__ = ("role", "content", "tool_calls")

        def __init__(self, content):
            self.role = "assistant"
            self.content = content
            self.tool_calls = None

        def model_dump(self, **kwargs):
            d = {"role": self.role, "content": self.content}
            if kwargs.get("exclude_none"):
                return {k: v for k, v in d.items() if v is not None}
            d["tool_calls"] = self.tool_calls
            return d

    return _SyntheticMessage(text)
```

Agent loop compatibility checklist:
- `getattr(msg, "content", None)` — works (line 3826)
- `getattr(msg, "tool_calls", None)` — returns None (line 3826)
- `msg.content` — works (lines 3855, 3868, 3892)
- `msg.tool_calls` — None, so no tool dispatch (line 3871)
- `msg.model_dump(exclude_none=True)` — works (line 3848)
- `hasattr(msg, "model_dump")` — True (line 3848)

### 2. `swival/config.py`

#### Path resolution for command model string

Resolve path-like first tokens in the `model` field when `provider=command`, using the same pattern as `_resolve_reviewer_command()` (~line 221). Do this during config merging, not in `resolve_provider()`, to match the existing convention and avoid threading a `config_dir` parameter through all three `resolve_provider()` call sites (session.py:186, agent.py:3079, reviewer.py:133).

Add `_resolve_command_model()` called from the same config-merge path that calls `_resolve_reviewer_command()`:

```python
def _resolve_command_model(config: dict, config_dir: Path, source: str) -> None:
    """Shell-split the model value when provider=command, resolve path-like first tokens."""
    if config.get("provider") != "command" or "model" not in config:
        return
    try:
        parts = shlex.split(config["model"])
    except ValueError as e:
        raise ConfigError(f"{source}: malformed command model: {e}")
    if not parts:
        raise ConfigError(f"{source}: command model is empty")
    exe = parts[0]
    if _PATH_LIKE.match(exe):
        expanded = Path(exe).expanduser()
        if expanded.is_absolute():
            parts[0] = str(expanded)
        else:
            parts[0] = str((config_dir / expanded).resolve())
        config["model"] = shlex.join(parts)
```

This resolves `./my-wrapper.sh` relative to the config file's directory (matching reviewer behavior), not relative to `base_dir`.

### 3. `swival/session.py`

No changes needed. Session passes provider/model through to `resolve_provider()` unchanged. Path resolution already happened in config merging.

### 4. `tests/test_command_provider.py`

New test file.

```python
class TestResolveCommand:
    def test_requires_model(self):
        with pytest.raises(ConfigError):
            resolve_provider("command", None, None, None, None, False)

    def test_empty_model_rejected(self):
        with pytest.raises(ConfigError):
            resolve_provider("command", "  ", None, None, None, False)

    def test_returns_correct_shape(self):
        model_id, base, key, ctx, kwargs = resolve_provider(
            "command", "echo hello", None, None, None, False
        )
        assert "echo" in model_id
        assert base is None
        assert key is None
        assert kwargs["provider"] == "command"

    def test_preserves_max_context_tokens(self):
        _, _, _, ctx, _ = resolve_provider(
            "command", "echo hello", None, None, 8192, False
        )
        assert ctx == 8192

    def test_none_context_when_no_max(self):
        _, _, _, ctx, _ = resolve_provider(
            "command", "echo hello", None, None, None, False
        )
        assert ctx is None

    def test_rejects_missing_command(self):
        with pytest.raises(ConfigError, match="command not found"):
            resolve_provider("command", "nonexistent_binary_xyz", None, None, None, False)


class TestCallCommand:
    def test_simple_echo(self):
        msg, reason = call_llm(
            None, "echo 'hello world'",
            [{"role": "user", "content": "hi"}],
            100, 0.5, 1.0, None, None, False,
            provider="command",
        )
        assert msg.content == "hello world"
        assert msg.tool_calls is None
        assert reason == "stop"

    def test_receives_full_transcript_on_stdin(self):
        """cat returns whatever it receives — verify it includes full conversation."""
        msg, _ = call_llm(
            None, "cat",
            [
                {"role": "system", "content": "You are helpful."},
                {"role": "user", "content": "test input"},
            ],
            100, 0.5, 1.0, None, None, False,
            provider="command",
        )
        assert "[system]" in msg.content
        assert "You are helpful." in msg.content
        assert "[user]" in msg.content
        assert "test input" in msg.content

    def test_model_dump_exclude_none(self):
        msg, _ = call_llm(
            None, "echo ok",
            [{"role": "user", "content": "hi"}],
            100, 0.5, 1.0, None, None, False,
            provider="command",
        )
        dumped = msg.model_dump(exclude_none=True)
        assert dumped == {"role": "assistant", "content": "ok"}
        assert "tool_calls" not in dumped

    def test_model_dump_full(self):
        msg, _ = call_llm(
            None, "echo ok",
            [{"role": "user", "content": "hi"}],
            100, 0.5, 1.0, None, None, False,
            provider="command",
        )
        dumped = msg.model_dump()
        assert dumped["tool_calls"] is None

    def test_nonzero_exit(self):
        with pytest.raises(AgentError):
            call_llm(
                None, "false",
                [{"role": "user", "content": "hi"}],
                100, 0.5, 1.0, None, None, False,
                provider="command",
            )

    def test_timeout(self, monkeypatch):
        """Timeout raises AgentError."""
        import subprocess as sp
        orig_run = sp.run
        def fake_run(*a, **kw):
            raise sp.TimeoutExpired(cmd=a[0], timeout=1)
        monkeypatch.setattr(sp, "run", fake_run)
        with pytest.raises(AgentError, match="timed out"):
            call_llm(
                None, "sleep 999",
                [{"role": "user", "content": "hi"}],
                100, 0.5, 1.0, None, None, False,
                provider="command",
            )

    def test_os_error(self):
        with pytest.raises(AgentError, match="failed to start"):
            call_llm(
                None, "/dev/null",
                [{"role": "user", "content": "hi"}],
                100, 0.5, 1.0, None, None, False,
                provider="command",
            )


class TestRenderTranscript:
    def test_system_user_assistant(self):
        transcript = _render_transcript([
            {"role": "system", "content": "Be helpful."},
            {"role": "user", "content": "Hello"},
            {"role": "assistant", "content": "Hi there"},
        ])
        assert "[system]\nBe helpful." in transcript
        assert "[user]\nHello" in transcript
        assert "[assistant]\nHi there" in transcript

    def test_tool_results_get_function_name(self):
        """Tool results are labeled with the function name, not 'tool'."""
        transcript = _render_transcript([
            {"role": "user", "content": "Read foo.py"},
            {"role": "assistant", "content": "I'll read that file.",
             "tool_calls": [{"id": "tc_1", "function": {"name": "read_file", "arguments": "{}"}}]},
            {"role": "tool", "tool_call_id": "tc_1", "content": "print('hello')"},
        ])
        assert "[tool:read_file]" in transcript
        assert "print('hello')" in transcript

    def test_tool_results_without_matching_id_fallback(self):
        """Tool results with unknown tool_call_id fall back to 'tool'."""
        transcript = _render_transcript([
            {"role": "tool", "tool_call_id": "unknown_id", "content": "some result"},
        ])
        assert "[tool:tool]" in transcript

    def test_image_placeholder(self):
        """Images are replaced with [image omitted], not silently dropped."""
        transcript = _render_transcript([
            {"role": "user", "content": [
                {"type": "text", "text": "Look at this"},
                {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
            ]},
        ])
        assert "Look at this" in transcript
        assert "[image omitted]" in transcript

    def test_multipart_text_only(self):
        transcript = _render_transcript([
            {"role": "user", "content": [
                {"type": "text", "text": "First"},
                {"type": "text", "text": "Second"},
            ]},
        ])
        assert "First" in transcript
        assert "Second" in transcript

    def test_empty_content_skipped(self):
        transcript = _render_transcript([
            {"role": "assistant", "content": ""},
            {"role": "user", "content": "hello"},
        ])
        assert "[assistant]" not in transcript
        assert "[user]\nhello" in transcript


class TestConfigResolution:
    def test_relative_path_resolved_against_config_dir(self, tmp_path):
        """./script.sh resolves relative to config file location."""
        script = tmp_path / "script.sh"
        script.write_text("#!/bin/sh\necho hi")
        script.chmod(0o755)
        config = {"provider": "command", "model": "./script.sh"}
        _resolve_command_model(config, tmp_path, "test")
        assert config["model"] == str(script)

    def test_absolute_path_unchanged(self, tmp_path):
        script = tmp_path / "script.sh"
        script.write_text("#!/bin/sh\necho hi")
        script.chmod(0o755)
        config = {"provider": "command", "model": str(script)}
        _resolve_command_model(config, tmp_path, "test")
        assert config["model"] == str(script)

    def test_bare_command_unchanged(self, tmp_path):
        """Non-path commands (e.g. 'claude -p') are not modified."""
        config = {"provider": "command", "model": "claude -p"}
        _resolve_command_model(config, tmp_path, "test")
        assert config["model"] == "claude -p"

    def test_noop_for_other_providers(self, tmp_path):
        config = {"provider": "lmstudio", "model": "./script.sh"}
        _resolve_command_model(config, tmp_path, "test")
        assert config["model"] == "./script.sh"  # unchanged
```

---

## Interaction With Agent Loop Features

### Tools
Disabled for this provider. `effective_tools = None` replaces `tools` at all seven reference sites in the agent loop. The built-in tool list is still constructed (needed for system prompt building in non-command providers), but the command never sees tool schemas and they don't inflate token counts.

### Context window / compaction
`resolve_provider()` returns `context_length = max_context_tokens`. When the user sets `--max-context-tokens 8192`, compaction works normally: `clamp_output_tokens()` fires when prompt tokens approach the limit, and `ContextOverflowError` triggers graduated compaction. When the user doesn't set it, `context_length = None` and `clamp_output_tokens()` returns `requested_max_output` unchanged (line 1047-1048) — no compaction, which is correct since we can't know the command's limit.

### Token estimation accuracy
`estimate_tokens()` operates on the raw messages list, not on `_render_transcript()` output. This creates overcounts from two sources:

- **Assistant tool_calls arguments**: `estimate_tokens()` counts tool_call function names and arguments (line 315). For tools like `write_file` and `edit_file`, arguments contain entire file contents and can be thousands of tokens. But `_render_transcript()` only emits assistant `.content` text, not tool_calls payloads — so these tokens are charged but never sent to the command. After a session with large file writes, the overcount can be substantial.
- **Images**: `_IMAGE_TOKEN_ESTIMATE` (85 tokens) vs `[image omitted]` (~3 tokens) — ~82 tokens per image. Minor individually but adds up.

The overcount is conservative (triggers compaction earlier than necessary, which is safer than undercounting and blowing the command's context). The alternative — tokenizing `_render_transcript(messages)` directly — would be accurate but couples token estimation to the transcript renderer and adds a second estimation code path. Acceptable tradeoff for v1. If users with `--max-context-tokens` report premature compaction, a future version can add transcript-aware estimation.

### Multi-turn (REPL)
Works naturally. Each REPL turn invokes the command with the full accumulated transcript. The command sees the entire conversation history on each call — it's stateless but given full context, like `claude -p`.

### System prompt / memory
Both are included in the transcript as `[system]` blocks. The command sees them.

### Images
Multipart content with images gets a `[image omitted]` placeholder in the transcript. The command and the user know image data was present but excluded. This is visible and unambiguous — no silent data loss.

### Streaming (event_callback)
No streaming. The command runs to completion, then the full response is emitted as one `EVENT_TEXT_CHUNK`. Acceptable for v1.

### Continue-here
Works. Saved state is messages, which get rendered into the transcript on the resumed session.

### stderr handling
`capture_output=True` captures stderr. On failure, stderr is included in the `AgentError` message (not printed separately). On success, stderr is printed to swival's stderr so the user sees command diagnostics without duplication.

### Cache
Not supported. The `call_llm()` early return bypasses the cache path. Adding it would require defining a cache key from command string + transcript, which adds complexity for a provider that's typically local and fast. Can be added later if needed.

### sanitize_thinking
Unsupported for this provider, even when explicitly configured via `--sanitize-thinking`. The early return in `call_llm()` bypasses `_sanitize_assistant_message()`. A raw command can emit `<think>` tags or similar markers, and they will pass through to the response unmodified. This is a behavior change vs. other providers where `sanitize_thinking=True` strips such tags. If this becomes a problem in practice, a future version can apply the same regex-based stripping to `_SyntheticMessage.content` before returning from `_call_command()`.

---

## Implementation Order

1. `_make_synthetic_message()` and `_render_transcript()` — standalone, testable immediately
2. `call_llm()` — add the `command` early-return before the provider dispatch block at line 1693
3. `_resolve_model_str()` — add `command` passthrough
4. `resolve_provider()` — add `command` branch with empty-argv guard, preserve `max_context_tokens`
5. `_resolve_command_model()` in config.py — path resolution during config merge
6. CLI — add `"command"` to `--provider` choices
7. Agent loop — define `effective_tools`, substitute at all seven `tools` reference sites
8. Tests
9. Docs — update provider lists in:
   - `README.md` (~line 27, provider enumeration)
   - `swival/config.py` (~line 812, help text listing supported providers)
   - `CLAUDE.md` (architecture section, provider list)
   - `docs.md/getting-started.md` (~line 37)
   - `docs.md/usage.md` (~line 88)
   - `docs.md/providers.md` (~line 3, likely needs a new section for command)
   - `docs.md/reports.md` (~line 67)
