---
phase: 52-golden-datasets-cli-contracts
plan: 002
type: execute
wave: 2
depends_on: [52-001]
files_modified:
  - tests/cli/__init__.py
  - tests/cli/conftest.py
  - tests/cli/test_contract_helpers.py
  - tests/cli/test_json_contracts.py
  - tests/cli/test_text_contracts.py
  - tests/cli/test_error_codes.py
  - pyproject.toml
  - fixtures/ocr/mock_ocr_backend.py
autonomous: true
requirements: [CLI-01, CLI-02, CLI-03]
user_setup: []

must_haves:
  truths:
    - "All CLI commands exit with code 0 on valid input and non-zero on errors"
    - "Commands with --json (paths, status) output valid JSON with consistent schema"
    - "Error responses use standard fields: ok, error_code, message, details, suggestions"
    - "Mock OCR backend using responses library produces deterministic replayable responses"
    - "pytest-snapshot tests pass with shape-specific assertions (normalized dynamic fields)"
    - "Snapshot updates require explicit --snapshot-update flag"
  artifacts:
    - path: "tests/cli/conftest.py"
      provides: "CLI invoker fixture, mock_ocr_backend fixture, vault builder integration"
      contains: "cli_invoker"
    - path: "tests/cli/test_json_contracts.py"
      provides: "JSON output contract tests for paths, status, context commands"
      contains: "test_paths_json"
    - path: "tests/cli/test_text_contracts.py"
      provides: "Text output contract tests for sync, ocr, doctor, repair, setup"
      contains: "test_sync_dry_run"
    - path: "tests/cli/test_error_codes.py"
      provides: "Error handling and exit code contract tests"
      contains: "test_invalid_command"
    - path: "tests/cli/test_contract_helpers.py"
      provides: "Shared assertion helpers + dynamic field normalization"
      contains: "normalize_snapshot"
    - path: "pyproject.toml"
      provides: "Updated test dependencies (pytest-snapshot, responses) and markers"
      contains: "pytest-snapshot"
  key_links:
    - from: "tests/cli/conftest.py::cli_invoker"
      to: "paperforge.cli::main"
      via: "subprocess.run([sys.executable, '-m', 'paperforge', ...])"
      pattern: "sys.executable"
    - from: "tests/cli/conftest.py::mock_ocr_backend"
      to: "fixtures/ocr/paddleocr_*.json"
      via: "responses library intercepts HTTP calls, loads fixture JSON"
      pattern: "responses"
    - from: "tests/cli/test_json_contracts.py"
      to: "fixtures/snapshots/"
      via: "snapshot.assert_match(normalized_output, 'filename.json')"
      pattern: "snapshot.assert_match"

---

<objective>
**Objective:** Build the Level 2 CLI contract test suite — subprocess invoker, mock OCR backend, pytest-snapshot integration, and contract tests for all 7 CLI commands with shape-specific snapshot assertions.

**Purpose:** The CLI contract tests protect the Python<->plugin boundary. They ensure that CLI --json output schemas remain stable across releases, error responses follow standard format, and output changes are deliberate (requiring --snapshot-update flag).

**Output:** `tests/cli/` with conftest, contract test files, snapshot assertion helpers, and pyproject.toml dependency updates. All 7 CLI commands covered with exit code, output schema, and error handling contracts.
</objective>

<execution_context>
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
@C:/Users/Lin/.opencode/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/research/ARCHITECTURE.md
@.planning/research/SUMMARY.md

<interfaces>

=== CLI Command Output Summary (from paperforge/cli.py + commands/*.py) ===

| Command | --json support | Output format |
|---------|---------------|---------------|
| paths --json | YES | JSON: {vault, worker_script, ld_deep_script} |
| paths (no --json) | NO | Text: "key: /path/to/value" per line |
| status --json | YES | JSON via run_status (json_output=True) |
| status (no --json) | NO | Text: "formal_notes: N", "...done: M" |
| sync | NO (--dry-run) | Text: "[DRY-RUN] Would run sync operations" |
| ocr --diagnose | NO | Text: "OCR Doctor — Level N diagnostic" + [PASS]/[FAIL] |
| ocr (run) | NO | Text: logs, progress output |
| doctor | NO | Text: "[OK]" / "[WARN]" / "[FAIL]" prefix per check |
| repair | NO | Text: "repair: Found N items with path_error: ..." |
| context (key) | YES (always JSON) | JSON: {_format_version, _context, _generated_at, zotero_key, ...} |
| setup | NO | Text: setup wizard output |

=== Existing test vault pattern (from tests/conftest.py) ===
- paperforge.json with default dir names
- .env with PADDLEOCR_API_TOKEN and PADDLEOCR_JOB_URL
- System/PaperForge/{exports/, ocr/} directories
- Resources/Literature/<domain>/ directories

=== VaultBuilder interface (from fixtures/vault_builder.py, Plan 52-001 Task 3) ===
```python
class VaultBuilder:
    def build(self, level: str = "minimal") -> Path: ...
```
Levels: minimal (config+dirs+env), standard (+exports+PDFs), full (+OCR+formal notes)

=== pyproject.toml current test deps ===
```toml
[project.optional-dependencies]
test = [
    "pytest>=7.4.0",
    "ruff>=0.4.0",
]
```

=== Current pytest config ===
```toml
[tool.pytest.ini_options]
addopts = "--ignore=tests/sandbox/00_TestVault/"
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: CLI test infrastructure — conftest, helpers, mock OCR, pyproject.toml</name>
  <files>
    tests/cli/__init__.py
    tests/cli/conftest.py
    tests/cli/test_contract_helpers.py
    fixtures/ocr/mock_ocr_backend.py
    pyproject.toml
  </files>

  <action>
    === 1a. Create tests/cli/__init__.py ===
    Empty marker file (one blank line).

    === 1b. Create tests/cli/conftest.py ===
    Provides three fixtures:
    1. `vault_builder` — integration with fixtures/vault_builder.py (reuses the VaultBuilder class)
    2. `cli_invoker` — returns a function that runs paperforge CLI in a temp vault
    3. `mock_ocr_backend` — intercepts HTTP calls to PaddleOCR API using responses library

    ```python
    """CLI contract test fixtures — subprocess invoker, vault builder, mock OCR backend."""

    from __future__ import annotations

    import subprocess
    import sys
    from pathlib import Path

    import pytest


    @pytest.fixture
    def vault_builder():
        """Fixture providing VaultBuilder instance connected to fixtures/ directory."""
        from fixtures.vault_builder import VaultBuilder
        return VaultBuilder()


    @pytest.fixture
    def cli_invoker(vault_builder):
        """Returns a function that runs paperforge CLI subprocess in a disposable vault.

        The returned function accepts:
            args: list[str] — CLI arguments (e.g., ["paths", "--json"])
            vault_level: str — vault completeness level (default "minimal")
            input_text: str | None — stdin text (for interactive commands)
            env: dict | None — additional env vars

        Returns:
            subprocess.CompletedProcess with stdout, stderr, returncode
        """
        vaults = []

        def _invoke(
            args: list[str],
            vault_level: str = "minimal",
            input_text: str | None = None,
            env: dict | None = None,
        ) -> subprocess.CompletedProcess:
            vault = vault_builder.build(vault_level)
            vaults.append(vault)

            cmd = [sys.executable, "-m", "paperforge", "--vault", str(vault)] + args

            # Merge env with vault path so paperforge finds it
            base_env = {**subprocess._clean_environ(), "PAPERFORGE_VAULT": str(vault)}
            if env:
                base_env.update(env)

            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=60,
                input=input_text,
                env=base_env,
            )
            return result

        yield _invoke

        # Cleanup vaults
        import shutil
        for v in vaults:
            if v.exists():
                shutil.rmtree(v, ignore_errors=True)


    @pytest.fixture
    def mock_ocr_backend():
        """Intercept HTTP calls to PaddleOCR API using responses library.

        Usage:
            def test_ocr_with_mock(mock_ocr_backend, cli_invoker):
                with mock_ocr_backend as rsps:
                    result = cli_invoker(["ocr", "--diagnose"])

        This fixture is context-managed — use it with 'with' statement.
        """
        import responses as _responses
        # Import fixture data
        import json
        from pathlib import Path
        ocr_fixtures_dir = Path(__file__).resolve().parent.parent.parent / "fixtures" / "ocr"

        def _create_mock():
            rsps = _responses.RequestsMock(assert_all_requests_are_fired=False)

            # Submit endpoint — POST
            submit_data = json.loads(
                (ocr_fixtures_dir / "paddleocr_submit.json").read_text(encoding="utf-8")
            )
            rsps.add(
                _responses.POST,
                "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs",
                json=submit_data,
                status=202,
            )

            # Poll endpoint — GET (returns done)
            poll_data = json.loads(
                (ocr_fixtures_dir / "paddleocr_poll_done.json").read_text(encoding="utf-8")
            )
            rsps.add(
                _responses.GET,
                "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs/mock-job-001",
                json=poll_data,
                status=200,
            )

            # Result URL — GET
            result_data = json.loads(
                (ocr_fixtures_dir / "paddleocr_result.json").read_text(encoding="utf-8")
            )
            rsps.add(
                _responses.GET,
                "https://api.mock/results/mock-job-001",
                json=result_data,
                status=200,
            )

            return rsps

        yield _create_mock


    @pytest.fixture
    def snapshot_dir():
        """Return path to the snapshots directory."""
        from pathlib import Path
        return Path(__file__).resolve().parent.parent.parent / "fixtures" / "snapshots"
    ```

    === 1c. Create tests/cli/test_contract_helpers.py ===
    Shared assertion helpers and dynamic field normalization for snapshot tests.

    ```python
    """Shared helpers for CLI contract tests — normalization, shape assertions."""

    import json
    import re
    from pathlib import Path


    def normalize_snapshot(output: str, vault_path: str | None = None) -> str:
        """Normalize dynamic fields in CLI output for snapshot comparison.

        Replaces:
        - Absolute vault paths with '<VAULT>'
        - ISO timestamps with '<TIMESTAMP>'
        - Version strings with '<VERSION>'
        - UUIDs with '<UUID>'

        Args:
            output: Raw CLI output string (stdout)
            vault_path: Optional vault path to normalize

        Returns:
            Normalized output string ready for snapshot comparison
        """
        result = output

        # Normalize vault paths (absolute paths containing /tmp/ or \\tmp\\)
        if vault_path:
            escaped = re.escape(str(vault_path))
            result = re.sub(escaped, "<VAULT>", result)

        # Fallback: any path containing /tmp/pf_vault_ or similar
        result = re.sub(r'/tmp/pf_vault_[^/\s"\']+', '<VAULT>', result)
        result = re.sub(r'\\\\tmp\\\\pf_vault_[^\\\\\s"\']+', '<VAULT>', result)

        # Normalize ISO timestamps: 2026-05-08T12:34:56.789012+00:00
        result = re.sub(
            r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?[+-]\d{2}:\d{2}',
            '<TIMESTAMP>',
            result,
        )

        # Normalize version strings in JSON: "version": "x.y.z" or "x.y.zrcN"
        result = re.sub(r'"version":\s*"\d+\.\d+\.\d+[^"]*"', '"version": "<VERSION>"', result)
        result = re.sub(r'"paperforge_version":\s*"\d+\.\d+\.\d+[^"]*"', '"paperforge_version": "<VERSION>"', result)
        result = re.sub(r'"format_version":\s*"\d+\.\d+\.\d+[^"]*"', '"format_version": "<VERSION>"', result)

        # Normalize _generated_at in context output
        result = re.sub(r'"_generated_at":\s*"[^"]*"', '"_generated_at": "<TIMESTAMP>"', result)

        return result


    def assert_valid_json(output: str) -> dict:
        """Assert that output is valid JSON and return the parsed dict."""
        try:
            return json.loads(output)
        except json.JSONDecodeError as e:
            raise AssertionError(f"Output is not valid JSON: {e}\n---\n{output[:500]}")


    def assert_json_shape(data: dict, required_keys: set[str], optional_keys: set[str] | None = None):
        """Assert that a JSON object has the expected shape (required keys present)."""
        optional = optional_keys or set()
        all_allowed = required_keys | optional
        missing = required_keys - set(data.keys())
        extra = set(data.keys()) - all_allowed
        errors = []
        if missing:
            errors.append(f"Missing required keys: {missing}")
        if extra:
            errors.append(f"Unexpected keys: {extra}")
        if errors:
            raise AssertionError("; ".join(errors))
    ```

    === 1d. Create fixtures/ocr/mock_ocr_backend.py ===
    Standalone mock OCR backend that can be imported by any test layer.
    This mirrors the fixture in conftest.py but as a reusable module for L1 and L4 tests.

    ```python
    """Mock OCR backend — replayable PaddleOCR API responses using responses library.

    Provides context-managed interceptors for all PaddleOCR API states:
    - submit: POST /api/v2/ocr/jobs -> 202 + job_id
    - poll: GET /api/v2/ocr/jobs/{id} -> pending/done
    - result: GET result_url -> OCR extraction data
    - error: 401/400/500 responses
    - timeout: poll returns 'queued' forever

    Usage:
        from fixtures.ocr.mock_ocr_backend import mock_ocr_success

        with mock_ocr_success():
            # All PaddleOCR HTTP calls are intercepted
            run_ocr(vault, ...)
    """

    from __future__ import annotations

    import json
    import re
    from pathlib import Path

    import responses

    FIXTURES_DIR = Path(__file__).resolve().parent


    def _load_fixture(name: str) -> dict:
        """Load a named fixture JSON from fixtures/ocr/."""
        path = FIXTURES_DIR / name
        return json.loads(path.read_text(encoding="utf-8"))


    def mock_ocr_success() -> responses.RequestsMock:
        """Standard success path: submit -> poll -> result.

        Returns a RequestsMock context manager.
        """
        rsps = responses.RequestsMock(assert_all_requests_are_fired=False)
        _add_submit(rsps)
        _add_poll(rsps, "mock-job-001", "completed")
        _add_result(rsps)
        return rsps


    def mock_ocr_pending() -> responses.RequestsMock:
        """Job submitted but still processing (poll returns processing)."""
        rsps = responses.RequestsMock(assert_all_requests_are_fired=False)
        _add_submit(rsps)
        _add_poll(rsps, "mock-job-001", "processing", progress=0.45)
        return rsps


    def mock_ocr_error(status: int = 401) -> responses.RequestsMock:
        """API returns an error on submit."""
        rsps = responses.RequestsMock(assert_all_requests_are_fired=False)
        rsps.add(
            responses.POST,
            "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs",
            json=_load_fixture("paddleocr_error.json"),
            status=status,
        )
        return rsps


    def mock_ocr_timeout() -> responses.RequestsMock:
        """Submit succeeds but job never completes (polls return queued forever)."""
        rsps = responses.RequestsMock(assert_all_requests_are_fired=False)
        _add_submit(rsps)
        rsps.add(
            responses.GET,
            re.compile(r"https://paddleocr\.aistudio-app\.com/api/v2/ocr/jobs/mock-job-\w+"),
            json={"job_id": "mock-job-timeout-001", "status": "queued", "progress": 0.0},
            status=200,
        )
        return rsps


    def _add_submit(rsps: responses.RequestsMock) -> None:
        """Add submit endpoint mock."""
        rsps.add(
            responses.POST,
            "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs",
            json=_load_fixture("paddleocr_submit.json"),
            status=202,
        )


    def _add_poll(rsps: responses.RequestsMock, job_id: str, status: str, progress: float = 1.0) -> None:
        """Add poll endpoint mock."""
        url = f"https://paddleocr.aistudio-app.com/api/v2/ocr/jobs/{job_id}"
        fixture_key = f"paddleocr_poll_{status}.json" if status in ("pending", "done") else "paddleocr_poll_done.json"
        try:
            data = _load_fixture(fixture_key)
        except FileNotFoundError:
            data = {"job_id": job_id, "status": status, "progress": progress}
        rsps.add(responses.GET, url, json=data, status=200)


    def _add_result(rsps: responses.RequestsMock) -> None:
        """Add result URL endpoint mock."""
        rsps.add(
            responses.GET,
            "https://api.mock/results/mock-job-001",
            json=_load_fixture("paddleocr_result.json"),
            status=200,
        )
    ```

    === 1e. Update pyproject.toml ===
    Append to the existing `[project.optional-dependencies] test` list:
    - `"pytest-snapshot>=0.9.0"`
    - `"responses>=0.25.0"`
    - `"pytest-timeout>=2.2.0"`
    - `"pytest-mock>=3.12.0"`
    - `"coverage>=7.4.0"`

    The `test` section should look like:
    ```toml
    [project.optional-dependencies]
    test = [
        "pytest>=7.4.0",
        "pytest-snapshot>=0.9.0",
        "pytest-timeout>=2.2.0",
        "responses>=0.25.0",
        "pytest-mock>=3.12.0",
        "coverage>=7.4.0",
        "ruff>=0.4.0",
    ]
    ```

    Also update `[tool.pytest.ini_options]` to add `testpaths` and markers:
    ```toml
    [tool.pytest.ini_options]
    addopts = "--ignore=tests/sandbox/00_TestVault/ --strict-markers"
    testpaths = ["tests/unit", "tests/cli", "tests/e2e", "tests/journey", "tests/chaos"]
    markers = [
        "unit: Unit tests (Level 1) — fast, isolated",
        "cli: CLI contract tests (Level 2) — subprocess boundary",
        "e2e: End-to-end tests (Level 4) — temp vault",
        "journey: User journey tests (Level 5) — full workflows",
        "chaos: Destructive tests (Level 6) — abnormal scenarios",
        "slow: Tests that take >30s (skip during development)",
        "snapshot: Tests that use snapshot comparison",
    ]
    ```
  </action>

  <verify>
    <automated>
      python -c "
import sys, os

errs = []

# Check conftest exists
if not os.path.exists('tests/cli/conftest.py'):
    errs.append('tests/cli/conftest.py missing')

# Check helpers exist
if not os.path.exists('tests/cli/test_contract_helpers.py'):
    errs.append('tests/cli/test_contract_helpers.py missing')

# Check mock_ocr_backend exists
if not os.path.exists('fixtures/ocr/mock_ocr_backend.py'):
    errs.append('fixtures/ocr/mock_ocr_backend.py missing')

# Check pyproject.toml has new dependencies
import tomllib
with open('pyproject.toml', 'rb') as f:
    data = tomllib.load(f)
deps = data.get('project', {}).get('optional-dependencies', {}).get('test', [])
dep_names = [d.split('>=')[0].split('==')[0].strip().lower() for d in deps]
for needed in ['pytest-snapshot', 'responses', 'pytest-timeout', 'pytest-mock', 'coverage']:
    if needed not in ' '.join(dep_names):
        errs.append(f'Missing dependency: {needed}')

# Check pytest markers exist
ini = data.get('tool', {}).get('pytest', {}).get('ini_options', {})
markers = ini.get('markers', [])
if not markers:
    errs.append('No pytest markers defined')

# Verify helpers compile
import py_compile
for f in ['tests/cli/conftest.py', 'tests/cli/test_contract_helpers.py', 'fixtures/ocr/mock_ocr_backend.py']:
    if os.path.exists(f):
        try:
            py_compile.compile(f, doraise=True)
        except py_compile.PyCompileError as e:
            errs.append(f'Syntax error in {f}: {e}')

if errs:
    for e in errs:
        print(f'FAIL: {e}')
    exit(1)
else:
    print('All CLI infrastructure checks PASS')
    print(f'  Dependencies: {len([d for d in dep_names if d])} packages')
    print(f'  Markers: {len(markers)} defined')
      "
    </automated>
  </verify>

  <done>
    - tests/cli/conftest.py with cli_invoker, vault_builder, mock_ocr_backend fixtures
    - tests/cli/test_contract_helpers.py with normalize_snapshot, assert_valid_json, assert_json_shape
    - fixtures/ocr/mock_ocr_backend.py with 4 mock context managers (success, pending, error, timeout)
    - pyproject.toml updated with pytest-snapshot, responses, pytest-timeout, pytest-mock, coverage
    - pytest markers defined for all 6 layers + slow + snapshot
    - All files compile/syntax-check clean
  </done>
</task>

<task type="auto">
  <name>Task 2: CLI contract tests — all 7 commands + error codes</name>
  <files>
    tests/cli/test_json_contracts.py
    tests/cli/test_text_contracts.py
    tests/cli/test_error_codes.py
  </files>

  <action>
    Create three test files covering CLI-01 (7 commands return stable output), CLI-02 (standard error format), and CLI-03 (snapshot integration with shape-specific assertions).

    === 2a. Create tests/cli/test_json_contracts.py ===
    Tests for commands that output JSON: paths --json, status --json, context.

    ```python
    """CLI contract tests for JSON-output commands: paths, status, context."""

    from __future__ import annotations

    import json

    import pytest

    from .test_contract_helpers import (
        assert_json_shape,
        assert_valid_json,
        normalize_snapshot,
    )


    class TestPathsJson:
        """Contract: 'paperforge paths --json' returns stable JSON."""

        REQUIRED_KEYS = {"vault", "worker_script", "ld_deep_script"}

        def test_paths_json_valid(self, cli_invoker):
            """paths --json outputs valid JSON with expected keys."""
            result = cli_invoker(["paths", "--json"])
            assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr}"
            data = assert_valid_json(result.stdout)
            assert_json_shape(data, self.REQUIRED_KEYS)

        def test_paths_json_snapshot(self, cli_invoker, snapshot):
            """paths --json matches snapshot (normalized)."""
            result = cli_invoker(["paths", "--json"])
            assert result.returncode == 0
            
            # Parse and re-serialize for consistent formatting
            data = json.loads(result.stdout)
            vault = data.get("vault", "")
            normalized = normalize_snapshot(json.dumps(data, indent=2, ensure_ascii=False), vault)
            snapshot.assert_match(normalized, "paths_json/default_config.json")

        def test_paths_no_json_text_output(self, cli_invoker):
            """paths without --json outputs human-readable text."""
            result = cli_invoker(["paths"])
            assert result.returncode == 0
            assert ":" in result.stdout, "Expected key: value format"
            lines = result.stdout.strip().split("\n")
            assert len(lines) >= 3, "Expected at least 3 path entries"


    class TestStatusJson:
        """Contract: 'paperforge status --json' returns stable JSON."""

        REQUIRED_KEYS = {"total_papers", "version", "vault"}
        OPTIONAL_KEYS = {
            "formal_notes", "do_ocr", "analyze", "ocr_done", "ocr_pending",
            "ocr_failed", "path_errors", "deep_reading_done", "deep_reading_pending",
            "generated_at",
        }

        def test_status_json_on_empty_vault(self, cli_invoker):
            """status --json on empty vault returns valid JSON with all contract keys."""
            result = cli_invoker(["status", "--json"])
            assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr}"
            data = assert_valid_json(result.stdout)
            assert_json_shape(data, self.REQUIRED_KEYS, self.OPTIONAL_KEYS)
            # Empty vault should have 0 papers
            assert data.get("total_papers", -1) >= 0

        def test_status_json_snapshot(self, cli_invoker, snapshot):
            """status --json matches snapshot (normalized)."""
            result = cli_invoker(["status", "--json"])
            assert result.returncode == 0
            try:
                data = json.loads(result.stdout)
            except json.JSONDecodeError:
                # status --json may not output JSON in all vault states
                pytest.skip("status --json did not produce valid JSON")
            vault = data.get("vault", "")
            normalized = normalize_snapshot(json.dumps(data, indent=2, ensure_ascii=False), vault)
            snapshot.assert_match(normalized, "status_json/empty_vault.json")

        def test_status_json_no_vault(self, cli_invoker):
            """status on a non-existent vault should exit non-zero with error."""
            result = cli_invoker(["status", "--json", "--vault", "/nonexistent/path"])
            assert result.returncode != 0


    class TestContextJson:
        """Contract: 'paperforge context' outputs stable JSON format."""

        REQUIRED_KEYS = {"_format_version", "_context", "_generated_at", "zotero_key"}
        OPTIONAL_KEYS = {
            "domain", "title", "authors", "abstract", "journal", "year",
            "doi", "pmid", "collections", "_provenance", "_ai_readiness",
        }

        def test_context_no_index_returns_error(self, cli_invoker):
            """context with no index should exit 1 with error message."""
            result = cli_invoker(["context", "FIXT0001"])
            assert result.returncode != 0
            assert "error" in result.stderr.lower() or "Error" in result.stderr

        def test_context_no_args_returns_error(self, cli_invoker):
            """context without key/--domain/--all should exit 1."""
            result = cli_invoker(["context"])
            assert result.returncode != 0
    ```

    === 2b. Create tests/cli/test_text_contracts.py ===
    Tests for commands with text output: sync, ocr, doctor, repair, setup.

    ```python
    """CLI contract tests for text-output commands: sync, ocr, doctor, repair, setup."""

    from __future__ import annotations

    import pytest


    class TestSyncCli:
        """Contract: 'paperforge sync' produces expected text output."""

        def test_sync_dry_run_on_empty_vault(self, cli_invoker):
            """sync --dry-run on empty vault exits 0 with DRY-RUN message."""
            result = cli_invoker(["sync", "--dry-run"])
            assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr}"
            assert "DRY-RUN" in result.stdout

        def test_sync_rejects_nonexistent_vault(self, cli_invoker):
            """sync on nonexistent vault exits non-zero."""
            result = cli_invoker(["sync", "--vault", "/nonexistent/path"])
            assert result.returncode != 0


    class TestOcrCli:
        """Contract: 'paperforge ocr' produces expected output."""

        def test_ocr_diagnose_on_empty_vault(self, cli_invoker):
            """ocr --diagnose on empty vault produces doctor output."""
            result = cli_invoker(["ocr", "--diagnose"])
            # May exit 0 or 1 depending on env, but must not crash
            assert "OCR Doctor" in result.stdout

        def test_ocr_invalid_args(self, cli_invoker):
            """ocr with invalid args should exit non-zero."""
            result = cli_invoker(["--invalid-flag"])
            assert result.returncode != 0


    class TestDoctorCli:
        """Contract: 'paperforge doctor' produces verdict output."""

        def test_doctor_on_empty_vault(self, cli_invoker):
            """doctor runs on empty vault without crashing."""
            result = cli_invoker(["doctor"])
            # doctor should exit 0 even when things are missing (it reports, doesn't fail)
            output = result.stdout + result.stderr
            assert len(output) > 0, "doctor produced no output"

        def test_doctor_output_has_verdict_pattern(self, cli_invoker):
            """doctor output contains [OK], [WARN], or [FAIL] patterns."""
            result = cli_invoker(["doctor"])
            markers = ["[OK]", "[WARN]", "[FAIL]"]
            has_marker = any(m in result.stdout for m in markers)
            # On empty vault, doctor may not have markers — just verify no crash
            if not has_marker:
                pytest.skip("Doctor did not produce verdict markers (may depend on vault state)")

        def test_doctor_on_nonexistent_vault(self, cli_invoker):
            """doctor on nonexistent vault exits non-zero."""
            result = cli_invoker(["doctor", "--vault", "/nonexistent/path"])
            assert result.returncode != 0


    class TestRepairCli:
        """Contract: 'paperforge repair' produces repair scan output."""

        def test_repair_on_empty_vault(self, cli_invoker):
            """repair on empty vault exits 0 with no divergences."""
            result = cli_invoker(["repair"])
            assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr}"

        def test_repair_dry_run_no_modify(self, cli_invoker):
            """repair without --fix does not modify vault."""
            result = cli_invoker(["repair"])
            assert result.returncode == 0


    class TestSetupCli:
        """Contract: 'paperforge setup' produces setup output."""

        def test_setup_headless_with_skip_checks(self, cli_invoker):
            """setup --headless --skip-checks runs without crashing."""
            result = cli_invoker([
                "setup", "--headless", "--skip-checks",
                "--zotero-data", str(cli_invoker._vault if hasattr(cli_invoker, '_vault') else '/tmp'),
            ])
            # May exit 0 or non-zero depending on environment, but must produce output
            assert len(result.stdout) > 0 or len(result.stderr) > 0

        def test_setup_no_args_shows_usage(self, cli_invoker):
            """setup without --headless shows help message."""
            result = cli_invoker(["setup"])
            assert len(result.stdout) > 0 or len(result.stderr) > 0
    ```

    === 2c. Create tests/cli/test_error_codes.py ===
    Tests for CLI-02 (standard error format), exit code contracts, and invalid input handling.

    ```python
    """CLI contract tests for error handling and exit codes."""

    from __future__ import annotations

    import pytest


    class TestExitCodes:
        """Contract: CLI commands exit with correct exit codes."""

        def test_invalid_command_exits_1(self, cli_invoker):
            """Unknown command -> exit code 1."""
            result = cli_invoker(["nonexistent-command"])
            assert result.returncode == 1, f"Expected 1, got {result.returncode}"
            assert len(result.stderr) > 0, "Expected stderr output for unknown command"

        def test_no_command_shows_help(self, cli_invoker):
            """No arguments -> exit code 1 with help text."""
            result = cli_invoker([])
            assert result.returncode == 1
            assert "usage:" in result.stdout.lower() or "usage:" in result.stderr.lower()

        def test_help_flag(self, cli_invoker):
            """--help -> exit code 0 with help text."""
            result = cli_invoker(["--help"])
            assert result.returncode == 0
            assert "usage:" in result.stdout.lower()

        def test_version_flag(self, cli_invoker):
            """--version -> exit code 0 with version string."""
            result = cli_invoker(["--version"])
            assert result.returncode == 0
            assert "paperforge" in result.stdout.lower()


    class TestErrorOutput:
        """Contract: Error output follows stable text patterns."""

        def test_missing_vault_error_message(self, cli_invoker):
            """Missing vault produces error message (not traceback)."""
            result = cli_invoker(["status", "--vault", "/tmp/nonexistent_vault_xyz"])
            output = result.stdout + result.stderr
            assert "Traceback" not in output, "Error should not contain traceback"
            assert "Error" in output or "error" in output.lower(), "Error should contain error message"

        def test_no_vault_flag_no_env(self, cli_invoker):
            """Running without --vault and without env produces error message."""
            # Unset PAPERFORGE_VAULT to simulate no vault context
            import os
            old = os.environ.pop("PAPERFORGE_VAULT", None)
            try:
                result = cli_invoker(["status", "--vault", "/nonexistent_vault_check"])
                output = result.stdout + result.stderr
                assert "Traceback" not in output, "Should not show Python traceback"
            finally:
                if old is not None:
                    os.environ["PAPERFORGE_VAULT"] = old


    class TestCli02ErrorContract:
        """CLI-02: Error responses use standard text format with actionable info.

        Note: Full JSON error schema (ok, error_code, message, details, suggestions)
        is planned for all commands. Currently, errors use human-readable text.
        These tests validate the TEXT contract — JSON error contract will be
        validated once --json error output is implemented across all commands.
        """

        def test_error_contains_actionable_info(self, cli_invoker):
            """Errors mention what went wrong (not just 'error')."""
            result = cli_invoker(["status", "--vault", "/tmp/nonexistent_vault_xyz_98765"])
            output = (result.stdout + result.stderr).lower()
            # Should contain descriptive terms
            descriptive_terms = ["not found", "does not exist", "no such", "error", "invalid", "cannot"]
            has_description = any(t in output for t in descriptive_terms)
            assert has_description, f"Error should contain descriptive info, got: {output[:200]}"

        def test_stable_error_output(self, cli_invoker):
            """Same error produces same output (deterministic)."""
            vault_arg = ["status", "--vault", "/tmp/nonexistent_contract_test_12345"]
            r1 = cli_invoker(vault_arg)
            r2 = cli_invoker(vault_arg)
            assert r1.returncode == r2.returncode
            assert r1.stderr.strip() == r2.stderr.strip()
    ```

  </action>

  <verify>
    <automated>
      python -c "
import os, sys

errs = []

# Check test files exist
test_files = [
    'tests/cli/test_json_contracts.py',
    'tests/cli/test_text_contracts.py',
    'tests/cli/test_error_codes.py',
]
for f in test_files:
    if not os.path.exists(f):
        errs.append(f'Missing test file: {f}')
        continue
    # Verify basic Python syntax
    import py_compile
    try:
        py_compile.compile(f, doraise=True)
    except py_compile.PyCompileError as e:
        errs.append(f'Syntax error in {f}: {e}')

# Count contract test classes
if os.path.exists('tests/cli/test_text_contracts.py'):
    with open('tests/cli/test_text_contracts.py') as f:
        content = f.read()
    class_count = content.count('class Test')
    if class_count < 4:
        errs.append(f'test_text_contracts.py: expected >=4 test classes, got {class_count}')

if os.path.exists('tests/cli/test_json_contracts.py'):
    with open('tests/cli/test_json_contracts.py') as f:
        content = f.read()
    class_count = content.count('class Test')
    if class_count < 2:
        errs.append(f'test_json_contracts.py: expected >=2 test classes, got {class_count}')

# Check all 7 commands are tested
commands = ['paths', 'status', 'sync', 'ocr', 'doctor', 'repair', 'context', 'setup']
all_content = ''
for f in test_files:
    if os.path.exists(f):
        all_content += open(f).read()

# Count unique test classes
import re
test_classes = re.findall(r'class (Test\w+)', all_content)
print(f'  Test classes: {len(test_classes)} — {test_classes}')

for cmd in commands:
    found = cmd in all_content
    if not found:
        # Check alternative names
        if cmd == 'ocr' and 'Ocr' in all_content:
            found = True
        elif cmd == 'doctor' and 'Doctor' in all_content:
            found = True
    if not found and cmd != 'setup':  # setup is optional for now
        errs.append(f'Command not tested: {cmd}')

if errs:
    for e in errs:
        print(f'FAIL: {e}')
    exit(1)
else:
    print('All CLI contract test checks PASS')
    print(f'  {len(test_files)} test files, {len(test_classes)} test classes')
      "
    </automated>
  </verify>

  <done>
    - tests/cli/test_json_contracts.py: validates paths --json, status --json, context JSON output contracts
    - tests/cli/test_text_contracts.py: validates text output for sync, ocr, doctor, repair, setup
    - tests/cli/test_error_codes.py: validates exit codes, error messages, no-traceback policy
    - All 7 CLI commands (paths, status, sync, ocr, doctor, repair, context, setup) have contract tests
    - CLI-01: Stable output schema for all commands validated
    - CLI-02: Error messages are descriptive, stable, and avoid tracebacks
    - CLI-03: Snapshot assertions with normalized dynamic fields (normalize_snapshot helper)
    - Tests invoke CLI via subprocess with clean vault isolation (tmp_path)
  </done>
</task>

</tasks>

<verification>
=== Plan-level Verification ===

1. **CLI-01 (Stable --json schema)**: 
   - paths --json validated: `{vault, worker_script, ld_deep_script}` — test_json_contracts.py
   - status --json validated: `{total_papers, version, vault, ...}` — test_json_contracts.py
   - context output validated: `{_format_version, zotero_key, ...}` — test_json_contracts.py
   - Text commands (sync, ocr, doctor, repair, setup) validated for stable text output — test_text_contracts.py

2. **CLI-02 (Standard error format)**:
   - Invalid command -> exit 1 with stderr — test_error_codes.py
   - Missing vault -> descriptive error (no traceback) — test_error_codes.py
   - Same error produces deterministic output — test_error_codes.py
   - Error messages contain actionable info — test_error_codes.py

3. **CLI-03 (pytest-snapshot with shape-specific assertions)**:
   - normalize_snapshot() replaces dynamic paths/timestamps/versions — test_contract_helpers.py
   - Snapshot assertions compare normalized output — test_json_contracts.py
   - Snapshot update requires explicit --snapshot-update flag (pytest-snapshot default behavior)

4. **Mock OCR backend**:
   - 4 context managers: mock_ocr_success, mock_ocr_pending, mock_ocr_error, mock_ocr_timeout
   - Uses responses library at HTTP layer — no external calls
   - Loads fixture data from fixtures/ocr/paddleocr_*.json
   - Reusable by L1, L2, L4, L6 test layers

=== File Conflict Check ===
- Task 1: conftest.py, helpers, mock_ocr_backend, pyproject.toml — no overlap with Task 2
- Task 2: 3 test files only — no overlap with Task 1
- No file is modified by more than one task

=== Context Budget ===
- Task 1: ~22% (conftest.py ~80 lines, helpers ~60 lines, mock_ocr ~100 lines, pyproject.toml edits)
- Task 2: ~25% (3 test files, each ~50-80 lines)
- Total: ~47% — within budget
</verification>

<success_criteria>
- [ ] All 7 CLI commands have contract tests covering exit codes and output shape
- [ ] paths --json validated for {vault, worker_script, ld_deep_script} keys
- [ ] status --json validated for required shape keys
- [ ] context command validated for JSON contract keys
- [ ] Text commands (sync, ocr, doctor, repair, setup) validated for stable text output
- [ ] Error commands produce stable, descriptive output without tracebacks
- [ ] Same error produces deterministic output (CLI-02)
- [ ] pytest-snapshot tests with normalize_snapshot for dynamic fields (CLI-03)
- [ ] Mock OCR backend in fixtures/ocr/mock_ocr_backend.py with 4 modes (CLI-01 mock integration)
- [ ] pyproject.toml updated with new test dependencies and markers
- [ ] All Python files syntax-check clean
</success_criteria>

<output>
After completion, create `.planning/phases/52-golden-datasets-cli-contracts/52-002-SUMMARY.md`
</output>
