# First-Class Python API Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Extract the CLI command execution pipeline into a `GraftpunkClient` class so programmatic use doesn't require CLI workarounds.

**Architecture:** New `src/graftpunk/client.py` with `GraftpunkClient`, `_GroupProxy`, and `_CommandCallable`. Shared plugin discovery via `get_plugin()` / `discover_all_plugins()`. CLI callback delegates to the same pipeline. See `docs/plans/2026-02-07-python-api-design.md` for full design.

**Tech Stack:** Python dataclasses, existing graftpunk modules (cache, tokens, plugins, observe)

---

### Task 1: Extract plugin discovery into shared functions

**Files:**
- Modify: `src/graftpunk/plugins/__init__.py`
- Modify: `src/graftpunk/cli/plugin_commands.py` (lines 536-579)
- Test: `tests/unit/test_plugin_discovery.py` (new)

The discovery logic in `register_plugin_commands()` (lines 536-579) runs all three sources (entry points, YAML, Python files), instantiates plugins, and collects errors. We extract this into a shared function that both the CLI and `GraftpunkClient` can use.

**Step 1: Write failing tests**

Create `tests/unit/test_plugin_discovery.py`:

```python
"""Tests for shared plugin discovery functions."""

from unittest.mock import MagicMock, patch

import pytest

from graftpunk.plugins import discover_all_plugins, get_plugin


class TestDiscoverAllPlugins:
    """Tests for discover_all_plugins()."""

    @patch("graftpunk.plugins.discover_site_plugins")
    @patch("graftpunk.plugins.create_yaml_plugins")
    @patch("graftpunk.plugins.discover_python_plugins")
    def test_returns_plugins_from_all_sources(
        self, mock_py_files, mock_yaml, mock_entry_points
    ):
        ep_plugin = MagicMock()
        ep_plugin.site_name = "ep_site"
        ep_plugin._plugin_config_error = None
        ep_plugin.api_version = 1

        yaml_plugin = MagicMock()
        yaml_plugin.site_name = "yaml_site"
        yaml_plugin._plugin_config_error = None
        yaml_plugin.api_version = 1

        py_plugin = MagicMock()
        py_plugin.site_name = "py_site"
        py_plugin._plugin_config_error = None
        py_plugin.api_version = 1

        mock_entry_points.return_value = {"ep_site": MagicMock(return_value=ep_plugin)}
        mock_yaml.return_value = ([yaml_plugin], [])
        py_result = MagicMock()
        py_result.plugins = [py_plugin]
        py_result.errors = []
        mock_py_files.return_value = py_result

        plugins = discover_all_plugins()
        site_names = {p.site_name for p in plugins}
        assert site_names == {"ep_site", "yaml_site", "py_site"}

    @patch("graftpunk.plugins.discover_site_plugins")
    @patch("graftpunk.plugins.create_yaml_plugins")
    @patch("graftpunk.plugins.discover_python_plugins")
    def test_caches_results(self, mock_py_files, mock_yaml, mock_entry_points):
        mock_entry_points.return_value = {}
        mock_yaml.return_value = ([], [])
        py_result = MagicMock()
        py_result.plugins = []
        py_result.errors = []
        mock_py_files.return_value = py_result

        discover_all_plugins.cache_clear()
        result1 = discover_all_plugins()
        result2 = discover_all_plugins()
        assert result1 is result2
        assert mock_entry_points.call_count == 1
        discover_all_plugins.cache_clear()

    @patch("graftpunk.plugins.discover_site_plugins")
    @patch("graftpunk.plugins.create_yaml_plugins")
    @patch("graftpunk.plugins.discover_python_plugins")
    def test_skips_plugins_with_config_errors(
        self, mock_py_files, mock_yaml, mock_entry_points
    ):
        bad_plugin = MagicMock()
        bad_plugin.site_name = "bad"
        bad_plugin._plugin_config_error = "config broken"

        mock_entry_points.return_value = {}
        mock_yaml.return_value = ([bad_plugin], [])
        py_result = MagicMock()
        py_result.plugins = []
        py_result.errors = []
        mock_py_files.return_value = py_result

        discover_all_plugins.cache_clear()
        plugins = discover_all_plugins()
        assert len(plugins) == 0
        discover_all_plugins.cache_clear()


class TestGetPlugin:
    """Tests for get_plugin()."""

    @patch("graftpunk.plugins.discover_all_plugins")
    def test_returns_plugin_by_name(self, mock_discover):
        plugin = MagicMock()
        plugin.site_name = "mybank"
        mock_discover.return_value = [plugin]

        result = get_plugin("mybank")
        assert result is plugin

    @patch("graftpunk.plugins.discover_all_plugins")
    def test_raises_on_unknown_plugin(self, mock_discover):
        mock_discover.return_value = []

        with pytest.raises(PluginError, match="not found"):
            get_plugin("nonexistent")
```

**Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/unit/test_plugin_discovery.py -v`
Expected: ImportError — `discover_all_plugins` and `get_plugin` don't exist yet.

**Step 3: Implement discover_all_plugins and get_plugin**

Add to `src/graftpunk/plugins/__init__.py` (before `__all__`):

```python
from functools import lru_cache

from graftpunk.plugins.cli_plugin import SUPPORTED_API_VERSIONS, CLIPluginProtocol
from graftpunk.plugins.python_loader import discover_python_plugins
from graftpunk.plugins.yaml_plugin import create_yaml_plugins


@lru_cache(maxsize=1)
def discover_all_plugins() -> tuple[CLIPluginProtocol, ...]:
    """Discover all plugins from all sources. Results are cached.

    Runs entry point, YAML, and Python file discovery. Skips plugins
    with config errors or unsupported API versions. Returns a tuple
    (for hashability/caching).
    """
    all_plugins: list[CLIPluginProtocol] = []

    # Entry point plugins
    try:
        for _name, plugin_class in discover_site_plugins().items():
            try:
                all_plugins.append(plugin_class())
            except Exception:  # noqa: BLE001
                LOG.warning("plugin_instantiation_failed", name=_name, exc_info=True)
    except Exception:  # noqa: BLE001
        LOG.exception("entry_point_discovery_failed")

    # YAML plugins
    try:
        yaml_plugins, _yaml_errors = create_yaml_plugins()
        all_plugins.extend(yaml_plugins)
    except Exception:  # noqa: BLE001
        LOG.exception("yaml_discovery_failed")

    # Python file plugins
    try:
        py_result = discover_python_plugins()
        all_plugins.extend(py_result.plugins)
    except Exception:  # noqa: BLE001
        LOG.exception("python_file_discovery_failed")

    # Filter out broken plugins
    valid: list[CLIPluginProtocol] = []
    for plugin in all_plugins:
        if getattr(plugin, "_plugin_config_error", None):
            continue
        if not plugin.site_name:
            continue
        if plugin.api_version not in SUPPORTED_API_VERSIONS:
            continue
        valid.append(plugin)

    return tuple(valid)


def get_plugin(name: str) -> CLIPluginProtocol:
    """Look up a plugin by site_name. Raises PluginError if not found."""
    for plugin in discover_all_plugins():
        if plugin.site_name == name:
            return plugin
    available = [p.site_name for p in discover_all_plugins()]
    raise PluginError(
        f"Plugin '{name}' not found. "
        f"Available: {', '.join(sorted(available)) or '(none)'}"
    )
```

Add the missing import at the top of the file:

```python
from graftpunk.exceptions import PluginError
```

Add to `__all__`:

```python
    "discover_all_plugins",
    "get_plugin",
```

**Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/unit/test_plugin_discovery.py -v`
Expected: All pass.

**Step 5: Refactor register_plugin_commands to use discover_all_plugins**

In `src/graftpunk/cli/plugin_commands.py`, replace lines 536-579 (the three discovery blocks) with:

```python
    from graftpunk.plugins import discover_all_plugins

    all_plugins = list(discover_all_plugins())
```

The filtering (config errors, missing site_name, API version) is already done by `discover_all_plugins()`, but `register_plugin_commands` also does collision detection, setup hooks, and CLI wiring which stay.

Note: `discover_all_plugins()` caches, but CLI needs fresh discovery when called. Add `discover_all_plugins.cache_clear()` at the top of `register_plugin_commands()`.

**Step 6: Run full test suite**

Run: `uv run pytest tests/ -v`
Expected: All 1775+ tests pass. No behavioral change.

**Step 7: Commit**

```bash
git add src/graftpunk/plugins/__init__.py tests/unit/test_plugin_discovery.py
git commit -m "feat: add discover_all_plugins() and get_plugin() shared discovery"
```

---

### Task 2: Create GraftpunkClient skeleton with command lookup

**Files:**
- Create: `src/graftpunk/client.py`
- Test: `tests/unit/test_client.py` (new)

Build the class hierarchy with `__getattr__`, `_GroupProxy`, `_CommandCallable`, and `execute()`. No execution yet — commands raise `NotImplementedError` until Task 3.

**Step 1: Write failing tests**

Create `tests/unit/test_client.py`:

```python
"""Tests for GraftpunkClient."""

from unittest.mock import MagicMock, patch

import pytest

from graftpunk.client import GraftpunkClient, _CommandCallable, _GroupProxy


def _make_spec(name, group=None, handler=None):
    """Create a mock CommandSpec."""
    spec = MagicMock()
    spec.name = name
    spec.group = group
    spec.handler = handler or MagicMock()
    spec.requires_session = None
    spec.max_retries = 0
    spec.rate_limit = None
    spec.saves_session = False
    return spec


def _make_plugin(site_name="testsite", commands=None):
    """Create a mock plugin."""
    plugin = MagicMock()
    plugin.site_name = site_name
    plugin.session_name = site_name
    plugin.requires_session = True
    plugin.api_version = 1
    plugin.backend = "nodriver"
    plugin.base_url = "https://example.com"
    plugin.help_text = "Test plugin"
    plugin.token_config = None
    plugin.get_commands.return_value = commands or []
    return plugin


class TestClientInit:
    """Tests for GraftpunkClient initialization."""

    @patch("graftpunk.client.get_plugin")
    def test_creates_client_for_plugin(self, mock_get):
        mock_get.return_value = _make_plugin()
        client = GraftpunkClient("testsite")
        mock_get.assert_called_once_with("testsite")

    @patch("graftpunk.client.get_plugin")
    def test_builds_command_hierarchy(self, mock_get):
        commands = [
            _make_spec("list", group="invoice"),
            _make_spec("detail", group="invoice"),
            _make_spec("get", group="cart"),
            _make_spec("login", group=None),
        ]
        mock_get.return_value = _make_plugin(commands=commands)
        client = GraftpunkClient("testsite")
        assert "invoice" in client._groups
        assert "cart" in client._groups
        assert "login" in client._top_commands


class TestAttributeAccess:
    """Tests for __getattr__ proxy chain."""

    @patch("graftpunk.client.get_plugin")
    def test_group_access_returns_proxy(self, mock_get):
        commands = [_make_spec("list", group="invoice")]
        mock_get.return_value = _make_plugin(commands=commands)
        client = GraftpunkClient("testsite")

        proxy = client.invoice
        assert isinstance(proxy, _GroupProxy)

    @patch("graftpunk.client.get_plugin")
    def test_top_level_command_returns_callable(self, mock_get):
        commands = [_make_spec("login")]
        mock_get.return_value = _make_plugin(commands=commands)
        client = GraftpunkClient("testsite")

        cmd = client.login
        assert isinstance(cmd, _CommandCallable)

    @patch("graftpunk.client.get_plugin")
    def test_group_command_returns_callable(self, mock_get):
        commands = [_make_spec("list", group="invoice")]
        mock_get.return_value = _make_plugin(commands=commands)
        client = GraftpunkClient("testsite")

        cmd = client.invoice.list
        assert isinstance(cmd, _CommandCallable)

    @patch("graftpunk.client.get_plugin")
    def test_unknown_attribute_raises(self, mock_get):
        mock_get.return_value = _make_plugin(commands=[])
        client = GraftpunkClient("testsite")

        with pytest.raises(AttributeError, match="nonexistent"):
            client.nonexistent

    @patch("graftpunk.client.get_plugin")
    def test_unknown_group_command_raises(self, mock_get):
        commands = [_make_spec("list", group="invoice")]
        mock_get.return_value = _make_plugin(commands=commands)
        client = GraftpunkClient("testsite")

        with pytest.raises(AttributeError, match="bogus"):
            client.invoice.bogus


class TestStringDispatch:
    """Tests for client.execute() string dispatch."""

    @patch("graftpunk.client.get_plugin")
    def test_execute_top_level(self, mock_get):
        handler = MagicMock(return_value={"ok": True})
        commands = [_make_spec("login", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)
        client = GraftpunkClient("testsite")

        # Should find the command (execution tested in Task 3)
        cmd = client._resolve_command("login")
        assert cmd.name == "login"

    @patch("graftpunk.client.get_plugin")
    def test_execute_grouped(self, mock_get):
        handler = MagicMock(return_value=[])
        commands = [_make_spec("list", group="invoice", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)
        client = GraftpunkClient("testsite")

        cmd = client._resolve_command("invoice", "list")
        assert cmd.name == "list"

    @patch("graftpunk.client.get_plugin")
    def test_execute_unknown_raises(self, mock_get):
        mock_get.return_value = _make_plugin(commands=[])
        client = GraftpunkClient("testsite")

        with pytest.raises(AttributeError):
            client._resolve_command("nonexistent")


class TestContextManager:
    """Tests for with-statement support."""

    @patch("graftpunk.client.get_plugin")
    def test_context_manager(self, mock_get):
        mock_get.return_value = _make_plugin()
        with GraftpunkClient("testsite") as client:
            assert isinstance(client, GraftpunkClient)
```

**Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/unit/test_client.py -v`
Expected: ImportError — `graftpunk.client` doesn't exist.

**Step 3: Implement GraftpunkClient skeleton**

Create `src/graftpunk/client.py`:

```python
"""First-class Python API for executing plugin commands.

Example::

    from graftpunk import GraftpunkClient

    bek = GraftpunkClient("bek")
    cart = bek.cart.get()
    invoices = bek.invoice.list(status="open")

See ``docs/plans/2026-02-07-python-api-design.md`` for full design.
"""

from __future__ import annotations

from typing import Any

from graftpunk.logging import get_logger
from graftpunk.plugins import get_plugin
from graftpunk.plugins.cli_plugin import CLIPluginProtocol, CommandResult, CommandSpec

LOG = get_logger(__name__)


class GraftpunkClient:
    """Stateful client for a single plugin.

    Provides attribute-based command access and string dispatch::

        client = GraftpunkClient("mybank")
        client.invoice.list(status="open")      # attribute access
        client.execute("invoice", "list", status="open")  # string dispatch
    """

    def __init__(self, plugin_name: str) -> None:
        self._plugin: CLIPluginProtocol = get_plugin(plugin_name)
        self._session = None
        self._session_dirty = False

        # Build command hierarchy from plugin
        self._top_commands: dict[str, CommandSpec] = {}
        self._groups: dict[str, dict[str, CommandSpec]] = {}

        for spec in self._plugin.get_commands():
            if spec.group is None:
                self._top_commands[spec.name] = spec
            else:
                self._groups.setdefault(spec.group, {})[spec.name] = spec

    def __getattr__(self, name: str) -> _GroupProxy | _CommandCallable:
        # Check top-level commands first
        if name in self._top_commands:
            return _CommandCallable(self, self._top_commands[name])
        # Then check groups
        if name in self._groups:
            return _GroupProxy(self, self._groups[name])
        raise AttributeError(
            f"Plugin '{self._plugin.site_name}' has no command or "
            f"group '{name}'"
        )

    def execute(self, *args: str, **kwargs: Any) -> CommandResult:
        """String dispatch for command execution.

        Usage::

            client.execute("login")
            client.execute("invoice", "list", status="open")
        """
        spec = self._resolve_command(*args)
        return self._execute_command(spec, **kwargs)

    def _resolve_command(self, *args: str) -> CommandSpec:
        """Resolve positional string args to a CommandSpec."""
        if len(args) == 1:
            name = args[0]
            if name in self._top_commands:
                return self._top_commands[name]
            raise AttributeError(
                f"Plugin '{self._plugin.site_name}' has no "
                f"command '{name}'"
            )
        if len(args) == 2:
            group_name, cmd_name = args
            group = self._groups.get(group_name)
            if group is None:
                raise AttributeError(
                    f"Plugin '{self._plugin.site_name}' has no "
                    f"group '{group_name}'"
                )
            spec = group.get(cmd_name)
            if spec is None:
                raise AttributeError(
                    f"Group '{group_name}' has no command '{cmd_name}'"
                )
            return spec
        raise ValueError(
            "execute() takes 1 arg (command) or 2 args (group, command)"
        )

    def _execute_command(self, spec: CommandSpec, **kwargs: Any) -> CommandResult:
        """Execute a command through the full pipeline.

        Handles session loading, token injection, retries, 403 recovery,
        and session persistence.
        """
        raise NotImplementedError("Execution pipeline added in Task 3")

    def close(self) -> None:
        """Persist session if dirty and run plugin teardown."""
        if self._session is not None and self._session_dirty:
            from graftpunk.cache import update_session_cookies

            update_session_cookies(self._session, self._plugin.session_name)
            self._session_dirty = False
        try:
            self._plugin.teardown()
        except Exception:  # noqa: BLE001
            LOG.debug("plugin_teardown_error", exc_info=True)

    def __enter__(self) -> GraftpunkClient:
        return self

    def __exit__(self, *exc: object) -> None:
        self.close()


class _GroupProxy:
    """Proxy for a command group. Returned by client.__getattr__."""

    __slots__ = ("_client", "_commands")

    def __init__(
        self, client: GraftpunkClient, commands: dict[str, CommandSpec]
    ) -> None:
        self._client = client
        self._commands = commands

    def __getattr__(self, name: str) -> _CommandCallable:
        spec = self._commands.get(name)
        if spec is None:
            raise AttributeError(
                f"Group has no command '{name}'. "
                f"Available: {', '.join(sorted(self._commands))}"
            )
        return _CommandCallable(self._client, spec)


class _CommandCallable:
    """A bound command ready to call. Returned by attribute access."""

    __slots__ = ("_client", "_spec")

    def __init__(self, client: GraftpunkClient, spec: CommandSpec) -> None:
        self._client = client
        self._spec = spec

    def __call__(self, **kwargs: Any) -> CommandResult:
        return self._client._execute_command(self._spec, **kwargs)
```

**Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/unit/test_client.py -v`
Expected: All pass (execution tests not written yet, skeleton uses NotImplementedError).

**Step 5: Commit**

```bash
git add src/graftpunk/client.py tests/unit/test_client.py
git commit -m "feat: add GraftpunkClient skeleton with command lookup and proxies"
```

---

### Task 3: Implement the execution pipeline

**Files:**
- Modify: `src/graftpunk/client.py`
- Test: `tests/unit/test_client.py`

Replace `_execute_command` NotImplementedError with the full pipeline extracted from the CLI callback (`plugin_commands.py:290-417`).

**Step 1: Write failing tests**

Add to `tests/unit/test_client.py`:

```python
from graftpunk.plugins.cli_plugin import CommandContext, CommandResult


class TestExecutionPipeline:
    """Tests for _execute_command() pipeline."""

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    def test_basic_execution(self, mock_load, mock_get):
        mock_load.return_value = MagicMock()
        handler = MagicMock(return_value={"items": [1, 2, 3]})
        commands = [_make_spec("get", group="cart", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)

        client = GraftpunkClient("testsite")
        result = client.cart.get()

        assert isinstance(result, CommandResult)
        assert result.data == {"items": [1, 2, 3]}
        handler.assert_called_once()

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    def test_returns_command_result_passthrough(self, mock_load, mock_get):
        mock_load.return_value = MagicMock()
        cr = CommandResult(data={"x": 1}, metadata={"page": 1})
        handler = MagicMock(return_value=cr)
        commands = [_make_spec("list", group="items", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)

        client = GraftpunkClient("testsite")
        result = client.items.list()

        assert result is cr

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    def test_passes_kwargs_to_handler(self, mock_load, mock_get):
        mock_load.return_value = MagicMock()
        handler = MagicMock(return_value={})
        commands = [_make_spec("list", group="invoice", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)

        client = GraftpunkClient("testsite")
        client.invoice.list(status="open", page=2)

        _, call_kwargs = handler.call_args
        assert call_kwargs["status"] == "open"
        assert call_kwargs["page"] == 2

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    def test_handler_receives_command_context(self, mock_load, mock_get):
        mock_load.return_value = MagicMock()
        handler = MagicMock(return_value={})
        commands = [_make_spec("get", group="cart", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)

        client = GraftpunkClient("testsite")
        client.cart.get()

        ctx = handler.call_args[0][0]
        assert isinstance(ctx, CommandContext)
        assert ctx.plugin_name == "testsite"
        assert ctx.command_name == "get"

    @patch("graftpunk.client.get_plugin")
    def test_sessionless_command(self, mock_get):
        handler = MagicMock(return_value={"ok": True})
        spec = _make_spec("health", handler=handler)
        spec.requires_session = False
        plugin = _make_plugin(commands=[spec])
        plugin.requires_session = False
        mock_get.return_value = plugin

        client = GraftpunkClient("testsite")
        result = client.health()
        assert result.data == {"ok": True}

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    def test_session_loaded_lazily(self, mock_load, mock_get):
        mock_load.return_value = MagicMock()
        handler = MagicMock(return_value={})
        commands = [_make_spec("get", group="cart", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)

        client = GraftpunkClient("testsite")
        mock_load.assert_not_called()

        client.cart.get()
        mock_load.assert_called_once_with("testsite")

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    def test_session_reused_across_calls(self, mock_load, mock_get):
        mock_load.return_value = MagicMock()
        handler = MagicMock(return_value={})
        commands = [_make_spec("get", group="cart", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)

        client = GraftpunkClient("testsite")
        client.cart.get()
        client.cart.get()
        mock_load.assert_called_once()

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    def test_string_dispatch_executes(self, mock_load, mock_get):
        mock_load.return_value = MagicMock()
        handler = MagicMock(return_value={"ok": True})
        commands = [_make_spec("list", group="invoice", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)

        client = GraftpunkClient("testsite")
        result = client.execute("invoice", "list", status="open")
        assert result.data == {"ok": True}


class TestTokenInjection:
    """Tests for automatic token injection."""

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    @patch("graftpunk.client.prepare_session")
    def test_tokens_injected_when_configured(
        self, mock_prepare, mock_load, mock_get
    ):
        mock_load.return_value = MagicMock()
        handler = MagicMock(return_value={})
        commands = [_make_spec("get", group="cart", handler=handler)]
        plugin = _make_plugin(commands=commands)
        plugin.token_config = MagicMock()
        mock_get.return_value = plugin

        client = GraftpunkClient("testsite")
        client.cart.get()
        mock_prepare.assert_called_once()

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    @patch("graftpunk.client.prepare_session")
    def test_no_tokens_when_not_configured(
        self, mock_prepare, mock_load, mock_get
    ):
        mock_load.return_value = MagicMock()
        handler = MagicMock(return_value={})
        commands = [_make_spec("get", group="cart", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)

        client = GraftpunkClient("testsite")
        client.cart.get()
        mock_prepare.assert_not_called()


class TestTokenRetry:
    """Tests for 403 token retry."""

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    @patch("graftpunk.client.prepare_session")
    @patch("graftpunk.client.clear_cached_tokens")
    def test_403_triggers_token_refresh_and_retry(
        self, mock_clear, mock_prepare, mock_load, mock_get
    ):
        import requests as req

        mock_load.return_value = MagicMock()
        response_403 = MagicMock()
        response_403.status_code = 403
        http_error = req.exceptions.HTTPError(response=response_403)

        handler = MagicMock(side_effect=[http_error, {"ok": True}])
        commands = [_make_spec("get", group="cart", handler=handler)]
        plugin = _make_plugin(commands=commands)
        plugin.token_config = MagicMock()
        mock_get.return_value = plugin

        client = GraftpunkClient("testsite")
        result = client.cart.get()

        assert result.data == {"ok": True}
        assert handler.call_count == 2
        mock_clear.assert_called_once()


class TestSessionPersistence:
    """Tests for auto-persist on dirty session."""

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    @patch("graftpunk.client.update_session_cookies")
    def test_persists_when_saves_session(
        self, mock_update, mock_load, mock_get
    ):
        mock_load.return_value = MagicMock()
        handler = MagicMock(return_value={})
        spec = _make_spec("checkout", group="cart", handler=handler)
        spec.saves_session = True
        mock_get.return_value = _make_plugin(commands=[spec])

        client = GraftpunkClient("testsite")
        client.cart.checkout()
        mock_update.assert_called_once()

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    @patch("graftpunk.client.update_session_cookies")
    def test_no_persist_when_not_dirty(
        self, mock_update, mock_load, mock_get
    ):
        mock_load.return_value = MagicMock()
        handler = MagicMock(return_value={})
        commands = [_make_spec("get", group="cart", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)

        client = GraftpunkClient("testsite")
        client.cart.get()
        mock_update.assert_not_called()


class TestErrorHandling:
    """Tests for exception propagation."""

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    def test_command_error_propagates(self, mock_load, mock_get):
        from graftpunk.exceptions import CommandError

        mock_load.return_value = MagicMock()
        handler = MagicMock(
            side_effect=CommandError("Not found", "Item not found")
        )
        commands = [_make_spec("get", group="cart", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)

        client = GraftpunkClient("testsite")
        with pytest.raises(CommandError, match="Not found"):
            client.cart.get()

    @patch("graftpunk.client.get_plugin")
    @patch("graftpunk.client.load_session_for_api")
    def test_session_not_found_propagates(self, mock_load, mock_get):
        from graftpunk.exceptions import SessionNotFoundError

        mock_load.side_effect = SessionNotFoundError("testsite")
        handler = MagicMock()
        commands = [_make_spec("get", group="cart", handler=handler)]
        mock_get.return_value = _make_plugin(commands=commands)

        client = GraftpunkClient("testsite")
        with pytest.raises(SessionNotFoundError):
            client.cart.get()
```

**Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/unit/test_client.py::TestExecutionPipeline -v`
Expected: NotImplementedError.

**Step 3: Implement _execute_command**

Replace the `_execute_command` method in `src/graftpunk/client.py`:

```python
import asyncio
import time

import requests

from graftpunk.cache import load_session_for_api, update_session_cookies
from graftpunk.observe import build_observe_context
from graftpunk.plugins.cli_plugin import CommandContext, CommandResult
from graftpunk.tokens import clear_cached_tokens, prepare_session


def _execute_command(self, spec: CommandSpec, **kwargs: Any) -> CommandResult:
    # 1. Resolve session requirement
    needs_session = (
        spec.requires_session
        if spec.requires_session is not None
        else self._plugin.requires_session
    )

    # 2. Lazy-load session
    if needs_session and self._session is None:
        self._session = load_session_for_api(self._plugin.session_name)
        base_url = getattr(self._plugin, "base_url", "")
        if base_url and hasattr(self._session, "gp_base_url"):
            setattr(self._session, "gp_base_url", base_url)

    session = self._session if needs_session else requests.Session()

    # 3. Token injection
    token_config = getattr(self._plugin, "token_config", None)
    if token_config is not None and needs_session:
        prepare_session(
            session, token_config, getattr(self._plugin, "base_url", "")
        )

    # 4. Build context
    observe_ctx = build_observe_context(
        self._plugin.site_name,
        self._plugin.backend,
        None,  # No browser driver in API mode
        "off",
    )
    ctx = CommandContext(
        session=session,
        plugin_name=self._plugin.site_name,
        command_name=spec.name,
        api_version=self._plugin.api_version,
        base_url=getattr(self._plugin, "base_url", ""),
        config=getattr(self._plugin, "_plugin_config", None),
        observe=observe_ctx,
        _session_name=self._plugin.session_name if needs_session else "",
    )

    # 5. Execute with retries
    try:
        result = self._execute_with_limits(spec.handler, ctx, spec, **kwargs)
    except requests.exceptions.HTTPError as exc:
        # 6. Handle 403 token retry
        if (
            exc.response is not None
            and exc.response.status_code == 403
            and token_config is not None
        ):
            LOG.info("token_403_retry", command=spec.name)
            clear_cached_tokens(session)
            prepare_session(
                session,
                token_config,
                getattr(self._plugin, "base_url", ""),
            )
            ctx._session_dirty = True
            result = self._execute_with_limits(
                spec.handler, ctx, spec, **kwargs
            )
        else:
            raise

    # 7. Auto-persist session if dirty
    if (spec.saves_session or ctx._session_dirty) and needs_session:
        update_session_cookies(session, self._plugin.session_name)

    # 8. Normalize to CommandResult
    if isinstance(result, CommandResult):
        return result
    return CommandResult(data=result)
```

Also add `_execute_with_limits` method (extracted from `plugin_commands.py:176-235`):

```python
def _execute_with_limits(
    self, handler: Any, ctx: CommandContext, spec: CommandSpec, **kwargs: Any
) -> Any:
    attempts = 1 + spec.max_retries
    last_exc: Exception | None = None
    command_key = f"{ctx.plugin_name}.{spec.name}"

    for attempt in range(attempts):
        try:
            if spec.rate_limit:
                self._enforce_rate_limit(command_key, spec.rate_limit)
            result = handler(ctx, **kwargs)
            if asyncio.iscoroutine(result):
                result = asyncio.run(result)
            return result
        except (
            requests.RequestException,
            ConnectionError,
            TimeoutError,
            OSError,
        ) as exc:
            last_exc = exc
            if attempt < attempts - 1:
                backoff = 2**attempt
                LOG.warning(
                    "command_retry",
                    command=spec.name,
                    attempt=attempt + 1,
                    max_attempts=attempts,
                    backoff=backoff,
                )
                time.sleep(backoff)
            else:
                raise
    raise last_exc  # type: ignore[misc]
```

And the rate limit helper:

```python
_last_execution: dict[str, float]

def __init__(self, plugin_name: str) -> None:
    # ... existing init ...
    self._last_execution: dict[str, float] = {}

def _enforce_rate_limit(self, command_key: str, rate_limit: float) -> None:
    now = time.monotonic()
    last = self._last_execution.get(command_key)
    if last is not None:
        elapsed = now - last
        if elapsed < rate_limit:
            time.sleep(rate_limit - elapsed)
    self._last_execution[command_key] = time.monotonic()
```

**Step 4: Run tests**

Run: `uv run pytest tests/unit/test_client.py -v`
Expected: All pass.

**Step 5: Run full test suite**

Run: `uv run pytest tests/ -v`
Expected: All 1775+ tests pass.

**Step 6: Commit**

```bash
git add src/graftpunk/client.py tests/unit/test_client.py
git commit -m "feat: implement GraftpunkClient execution pipeline"
```

---

### Task 4: Export GraftpunkClient from top-level package

**Files:**
- Modify: `src/graftpunk/__init__.py`
- Modify: `src/graftpunk/plugins/__init__.py`

**Step 1: Update __init__.py**

Add to `src/graftpunk/__init__.py` imports:

```python
from graftpunk.client import GraftpunkClient
```

Add `"GraftpunkClient"` to `__all__`.

**Step 2: Update plugins/__init__.py**

Verify `get_plugin` and `discover_all_plugins` are already in `__all__` (done in Task 1).

**Step 3: Verify import works**

Run: `uv run python -c "from graftpunk import GraftpunkClient; print(GraftpunkClient)"`
Expected: `<class 'graftpunk.client.GraftpunkClient'>`

**Step 4: Run quality checks**

Run: `uvx ruff check . && uvx ruff format . && uvx ty check src/`

**Step 5: Run full test suite**

Run: `uv run pytest tests/ -v`

**Step 6: Commit**

```bash
git add src/graftpunk/__init__.py src/graftpunk/plugins/__init__.py
git commit -m "feat: export GraftpunkClient from graftpunk package"
```

---

### Task 5: Refactor CLI callback to use shared pipeline

**Files:**
- Modify: `src/graftpunk/cli/plugin_commands.py` (lines 290-417)
- Test: existing CLI tests must pass unchanged

This is the "CLI becomes a thin facade" step. The callback in `_create_plugin_command` delegates to the same execution logic the client uses, then adds formatting and error handling.

**Step 1: Extract _execute_plugin_command as a shared function**

Add to `src/graftpunk/client.py`:

```python
def execute_plugin_command(
    plugin: CLIPluginProtocol,
    spec: CommandSpec,
    session: Any | None = None,
    **kwargs: Any,
) -> CommandResult:
    """Execute a plugin command through the full pipeline.

    This is the shared execution core used by both GraftpunkClient and
    the CLI callback. Handles token injection, retries, 403 recovery,
    and session persistence.

    Args:
        plugin: The plugin instance.
        spec: The command to execute.
        session: Pre-loaded session, or None for sessionless commands.
        **kwargs: Arguments to pass to the command handler.

    Returns:
        CommandResult with the handler's return value.

    Raises:
        SessionNotFoundError: If session is required but not provided.
        CommandError: If the handler raises CommandError.
        requests.exceptions.HTTPError: On unrecoverable HTTP errors.
    """
    # ... shared pipeline logic (same as _execute_command but takes
    #     plugin and session as args instead of reading from self) ...
```

**Step 2: Refactor CLI callback**

Replace the callback body in `_create_plugin_command` (lines 290-417) to:

1. Load session (same as before — this is CLI-specific due to error formatting)
2. Call `execute_plugin_command(plugin, spec, session, **kwargs)`
3. Format output
4. Handle errors with CLI-friendly messages

The callback shrinks from ~130 lines to ~30 lines.

**Step 3: Refactor GraftpunkClient._execute_command**

Have it call `execute_plugin_command()` too, handling lazy session loading before the call.

**Step 4: Run full test suite**

Run: `uv run pytest tests/ -v`
Expected: All tests pass — behavior is identical, just restructured.

**Step 5: Run quality checks**

Run: `uvx ruff check . && uvx ruff format . && uvx ty check src/`

**Step 6: Commit**

```bash
git add src/graftpunk/client.py src/graftpunk/cli/plugin_commands.py
git commit -m "refactor: CLI callback delegates to shared execution pipeline"
```

---

### Task 6: Final verification and cleanup

**Step 1: Run full test suite**

Run: `uv run pytest tests/ -v`

**Step 2: Run all quality checks**

Run: `uvx ruff check . && uvx ruff format . && uvx ty check src/`

**Step 3: Verify file sizes**

Check that `client.py` is under 800 lines and `plugin_commands.py` didn't grow.

**Step 4: Check line counts**

Run: `wc -l src/graftpunk/client.py src/graftpunk/cli/plugin_commands.py`

**Step 5: Commit any fixups**

If quality checks required changes, commit as:

```bash
git commit -m "style: fix lint/format issues in client module"
```
