# Vivesca v0.1 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build vivesca — an opinionated Python framework for scaffolding MCP tool ecosystems with enforced structured output, tool annotations, and service-layer separation.

**Architecture:** Python CLI (click) + Jinja2 templates + Pydantic base schemas. `vivesca init` scaffolds a complete MCP server project built on FastMCP 3.x. `vivesca add tool/prompt/resource` adds components following conventions. `vivesca check` validates a project against conventions. The framework is both a CLI tool and a runtime library (projects depend on `vivesca` for base schemas).

**Tech Stack:** Python 3.11+, click (CLI), Jinja2 (templates), Pydantic (schemas), FastMCP 3.x (runtime dependency for generated projects), uv (package management), hatchling (build), pytest (tests)

---

## File Structure

```
~/code/vivesca/
├── pyproject.toml                    # Package config: vivesca CLI entry point, deps
├── AGENTS.md                         # Context for delegate agents
├── README.md                         # Quickstart + philosophy
├── src/vivesca/
│   ├── __init__.py                   # Version string
│   ├── cli.py                        # Click CLI: init, add, check commands
│   ├── scaffold/
│   │   ├── __init__.py
│   │   ├── init.py                   # vivesca init — scaffold full project
│   │   ├── add.py                    # vivesca add tool/prompt/resource
│   │   └── check.py                  # vivesca check — validate conventions
│   ├── schemas/
│   │   ├── __init__.py               # Re-exports: ToolOutput, ErrorOutput
│   │   └── base.py                   # Base Pydantic models for structured output
│   └── templates/                    # Jinja2 templates for scaffolding
│       ├── project/
│       │   ├── pyproject.toml.j2     # Generated pyproject.toml
│       │   ├── server.py.j2          # Generated server.py (with FileSystemProvider)
│       │   ├── __init__.py.j2        # Generated __init__.py with version
│       │   ├── __main__.py.j2        # Generated __main__.py for `python -m`
│       │   └── test_handshake.py.j2  # Generated MCP handshake test
│       ├── tool.py.j2               # Tool file template
│       ├── prompt.py.j2             # Prompt file template
│       ├── resource.py.j2           # Resource file template
│       └── test_component.py.j2     # Test file template for any component
└── tests/
    ├── test_schemas.py               # Schema base class tests
    ├── test_scaffold_init.py         # vivesca init tests
    ├── test_scaffold_add.py          # vivesca add tests
    ├── test_scaffold_check.py        # vivesca check tests
    └── test_cli.py                   # CLI integration tests
```

## Reference: Praeses patterns to formalize

The templates encode these patterns from praeses (~/code/praeses/), upgraded with structured output:

**Current praeses tool pattern (what we're upgrading FROM):**
```python
@tool(name="domain_verb", description="...", annotations=ToolAnnotations(readOnlyHint=True))
def domain_verb(param: str) -> str:
    return run_cli(BINARY, ["subcommand", param])
```

**Vivesca tool pattern (what we're upgrading TO):**
```python
from pydantic import BaseModel
from vivesca.schemas import ToolOutput

class VerbResult(ToolOutput):
    field: str

@tool(name="domain_verb", description="...", annotations=ToolAnnotations(readOnlyHint=True))
def domain_verb(param: str) -> VerbResult:
    raw = run_cli(BINARY, ["subcommand", param, "--json"])
    return VerbResult(**json.loads(raw))
```

Key differences: Pydantic return type → auto `outputSchema` + `structuredContent`. CLI called with `--json`. Agents get lean JSON, not parsed prose.

---

### Task 1: Project Bootstrap

**Files:**
- Create: `~/code/vivesca/pyproject.toml`
- Create: `~/code/vivesca/AGENTS.md`
- Create: `~/code/vivesca/src/vivesca/__init__.py`
- Create: `~/code/vivesca/src/vivesca/cli.py`

- [ ] **Step 1: Initialize git repo**

```bash
cd ~/code/vivesca
git init
echo "__pycache__/
*.egg-info/
dist/
.venv/
*.pyc
.pytest_cache/
" > .gitignore
```

- [ ] **Step 2: Write pyproject.toml**

```toml
[project]
name = "vivesca"
version = "0.1.0"
description = "An opinionated framework for building MCP tool ecosystems that evolve."
requires-python = ">=3.11"
dependencies = [
    "click>=8.0",
    "jinja2>=3.1",
    "pydantic>=2.0",
]

[project.scripts]
vivesca = "vivesca.cli:cli"

[dependency-groups]
dev = ["pytest", "pytest-asyncio"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/vivesca"]

[tool.pytest.ini_options]
testpaths = ["tests"]
```

- [ ] **Step 3: Write minimal CLI entry point**

```python
# src/vivesca/cli.py
"""vivesca CLI — scaffold and validate MCP tool ecosystems."""

import click

@click.group()
@click.version_option()
def cli():
    """Vivesca — opinionated MCP tool ecosystem framework."""
    pass

@cli.command()
@click.argument("name")
def init(name: str):
    """Scaffold a new MCP server project."""
    click.echo(f"TODO: scaffold {name}")

@cli.group()
def add():
    """Add a component to the current project."""
    pass

@add.command("tool")
@click.argument("name")
def add_tool(name: str):
    """Add a tool to the current project."""
    click.echo(f"TODO: add tool {name}")

@add.command("prompt")
@click.argument("name")
def add_prompt(name: str):
    """Add a prompt to the current project."""
    click.echo(f"TODO: add prompt {name}")

@add.command("resource")
@click.argument("name")
def add_resource(name: str):
    """Add a resource to the current project."""
    click.echo(f"TODO: add resource {name}")

@cli.command()
def check():
    """Validate project against vivesca conventions."""
    click.echo("TODO: check conventions")
```

```python
# src/vivesca/__init__.py
"""Vivesca — opinionated MCP tool ecosystem framework."""

__version__ = "0.1.0"
```

- [ ] **Step 4: Write AGENTS.md**

```markdown
# AGENTS.md — Vivesca

## What this is
Vivesca is a Python framework for scaffolding opinionated MCP (Model Context Protocol) tool ecosystems.
It provides CLI commands (vivesca init/add/check), Jinja2 templates, and Pydantic base schemas.

## Build & test
```
uv sync
uv run pytest tests/ -v
```

## Conventions
- Python 3.11+, type hints everywhere
- Tests in tests/, mirror src structure
- Click for CLI, Jinja2 for templates, Pydantic for schemas
- Templates live in src/vivesca/templates/ as .j2 files
- TDD: write failing test first, then implement
```

- [ ] **Step 5: Install deps and verify CLI stub works**

```bash
cd ~/code/vivesca
uv sync
uv run vivesca --version
```
Expected: `vivesca, version 0.1.0`

```bash
uv run vivesca init mytest
```
Expected: `TODO: scaffold mytest`

- [ ] **Step 6: Commit**

```bash
cd ~/code/vivesca
git add -A
git commit -m "feat: project bootstrap with CLI stub"
```

---

### Task 2: Schema Library

**Files:**
- Create: `~/code/vivesca/src/vivesca/schemas/__init__.py`
- Create: `~/code/vivesca/src/vivesca/schemas/base.py`
- Create: `~/code/vivesca/tests/test_schemas.py`

- [ ] **Step 1: Write the failing tests**

```python
# tests/test_schemas.py
"""Tests for vivesca base output schemas."""

from pydantic import BaseModel


def test_tool_output_is_base_model():
    from vivesca.schemas import ToolOutput
    assert issubclass(ToolOutput, BaseModel)


def test_tool_output_allows_extra_fields():
    from vivesca.schemas import ToolOutput
    obj = ToolOutput.model_validate({"unexpected": "value"})
    assert obj.unexpected == "value"


def test_tool_output_json_schema():
    from vivesca.schemas import ToolOutput

    class MyOutput(ToolOutput):
        name: str
        count: int

    schema = MyOutput.model_json_schema()
    assert "name" in schema["properties"]
    assert "count" in schema["properties"]


def test_error_output_fields():
    from vivesca.schemas import ErrorOutput
    err = ErrorOutput(error="not found", code="NOT_FOUND")
    assert err.error == "not found"
    assert err.code == "NOT_FOUND"
    assert err.details == {}


def test_error_output_with_details():
    from vivesca.schemas import ErrorOutput
    err = ErrorOutput(error="timeout", code="TIMEOUT", details={"elapsed": 30})
    assert err.details["elapsed"] == 30


def test_error_output_json_roundtrip():
    from vivesca.schemas import ErrorOutput
    err = ErrorOutput(error="fail", code="ERR")
    data = err.model_dump()
    restored = ErrorOutput.model_validate(data)
    assert restored == err


def test_list_output_fields():
    from vivesca.schemas import ListOutput
    out = ListOutput(items=[{"id": 1}, {"id": 2}], count=2)
    assert out.count == 2
    assert len(out.items) == 2


def test_list_output_auto_count():
    from vivesca.schemas import ListOutput
    out = ListOutput(items=[{"a": 1}, {"b": 2}, {"c": 3}])
    assert out.count == 3


def test_status_output_fields():
    from vivesca.schemas import StatusOutput
    out = StatusOutput(status="ok", message="All systems nominal")
    assert out.status == "ok"
    assert out.details == {}


def test_action_output_fields():
    from vivesca.schemas import ActionOutput
    out = ActionOutput(success=True, message="Created event")
    assert out.success is True
    assert out.data == {}
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
cd ~/code/vivesca && uv run pytest tests/test_schemas.py -v
```
Expected: FAIL (import errors)

- [ ] **Step 3: Implement schemas**

```python
# src/vivesca/schemas/base.py
"""Base Pydantic models for vivesca structured output.

All vivesca tool outputs should inherit from ToolOutput.
This ensures consistent outputSchema generation and
forward-compatible structured content.
"""

from pydantic import BaseModel, ConfigDict


class ToolOutput(BaseModel):
    """Base class for all vivesca tool outputs.

    Inherit from this to get:
    - Automatic outputSchema generation via FastMCP
    - Forward-compatible extra fields
    - Consistent JSON serialization
    """

    model_config = ConfigDict(extra="allow")


class ErrorOutput(BaseModel):
    """Standard error format for tool failures.

    Use this as the return type when a tool needs to
    communicate structured error information.
    """

    error: str
    code: str = "unknown"
    details: dict = {}


class ListOutput(ToolOutput):
    """Standard pattern for tools that return a list of items.

    If count is not provided, it defaults to len(items).
    """

    items: list[dict]
    count: int = 0

    def model_post_init(self, __context) -> None:
        if self.count == 0 and self.items:
            self.count = len(self.items)


class StatusOutput(ToolOutput):
    """Standard pattern for health/status checks."""

    status: str  # "ok", "warning", "error"
    message: str
    details: dict = {}


class ActionOutput(ToolOutput):
    """Standard pattern for mutation results."""

    success: bool
    message: str
    data: dict = {}
```

```python
# src/vivesca/schemas/__init__.py
"""Vivesca output schemas — base classes for structured tool output."""

from vivesca.schemas.base import (
    ActionOutput,
    ErrorOutput,
    ListOutput,
    StatusOutput,
    ToolOutput,
)

__all__ = [
    "ToolOutput",
    "ErrorOutput",
    "ListOutput",
    "StatusOutput",
    "ActionOutput",
]
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
cd ~/code/vivesca && uv run pytest tests/test_schemas.py -v
```
Expected: All 10 tests PASS

- [ ] **Step 5: Commit**

```bash
cd ~/code/vivesca
git add src/vivesca/schemas/ tests/test_schemas.py
git commit -m "feat: output schemas (ToolOutput, ErrorOutput, ListOutput, StatusOutput, ActionOutput)"
```

---

### Task 3: Jinja2 Templates

**Files:**
- Create: `~/code/vivesca/src/vivesca/templates/project/pyproject.toml.j2`
- Create: `~/code/vivesca/src/vivesca/templates/project/server.py.j2`
- Create: `~/code/vivesca/src/vivesca/templates/project/__init__.py.j2`
- Create: `~/code/vivesca/src/vivesca/templates/project/test_handshake.py.j2`
- Create: `~/code/vivesca/src/vivesca/templates/tool.py.j2`
- Create: `~/code/vivesca/src/vivesca/templates/prompt.py.j2`
- Create: `~/code/vivesca/src/vivesca/templates/resource.py.j2`
- Create: `~/code/vivesca/src/vivesca/templates/test_component.py.j2`

These are pure data — no logic to test directly. They get tested through the scaffold commands in Tasks 4-6.

- [ ] **Step 1: Create project templates**

`pyproject.toml.j2`:
```jinja2
[project]
name = "{{ name }}"
version = "0.1.0"
description = "{{ description }}"
requires-python = ">=3.11"
dependencies = [
    "fastmcp>=3.0.0",
    "mcp>=1.0.0",
    "vivesca>=0.1.0",
]

[project.scripts]
{{ name }} = "{{ module }}.server:main"

[dependency-groups]
dev = ["pytest", "pytest-asyncio"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/{{ module }}"]

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
```

`server.py.j2`:
```jinja2
"""{{ name }} — MCP server.

Built with vivesca conventions:
- Structured output (Pydantic return types → outputSchema)
- Tool annotations (readOnlyHint, destructiveHint)
- Service-layer separation (tools are thin wrappers)
"""

from pathlib import Path

from fastmcp import FastMCP
from fastmcp.server.providers import FileSystemProvider

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


def create_server() -> FastMCP:
    """Build and return the {{ name }} FastMCP server.

    FileSystemProvider auto-discovers @tool/@resource/@prompt decorated
    functions from .py files in each directory. External pip imports
    (fastmcp, vivesca, mcp) work normally. For cross-module imports
    within this project, use the sys.path workaround:

        import sys
        sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
        from {{ module }}.services.my_service import my_func
    """
    mcp = FastMCP(
        "{{ name }}",
        instructions="{{ description }}",
        providers=[
            FileSystemProvider(_project_root / "src" / "{{ module }}" / "tools"),
            FileSystemProvider(_project_root / "src" / "{{ module }}" / "resources"),
            FileSystemProvider(_project_root / "src" / "{{ module }}" / "prompts"),
        ],
    )
    return mcp


mcp = create_server()


def main():
    """Entry point for {{ name }} CLI (stdio transport)."""
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()
```

`__main__.py.j2`:
```jinja2
"""Allow running as: python -m {{ module }}"""

from {{ module }}.server import main

main()
```

`__init__.py.j2`:
```jinja2
"""{{ name }} — {{ description }}"""

__version__ = "0.1.0"
```

`test_handshake.py.j2`:
```jinja2
"""Test MCP initialize handshake for {{ name }}."""

import asyncio
import sys
from pathlib import Path


async def test_handshake():
    """Connect to {{ name }} via stdio and verify the MCP handshake."""
    from mcp import ClientSession, StdioServerParameters
    from mcp.client.stdio import stdio_client

    server_params = StdioServerParameters(
        command=sys.executable,
        args=["-m", "{{ module }}"],
        cwd=str(Path(__file__).resolve().parent.parent),
    )

    async with stdio_client(server_params) as (read_stream, write_stream):
        async with ClientSession(read_stream, write_stream) as session:
            result = await session.initialize()
            assert result.serverInfo.name == "{{ name }}"
            assert result.capabilities.tools is not None
            print(f"Handshake OK: {result.serverInfo.name}")


if __name__ == "__main__":
    asyncio.run(test_handshake())
```

- [ ] **Step 2: Create component templates**

`tool.py.j2`:
```jinja2
"""{{ domain }} — {{ description }}.

Tools:
  {{ domain }}_{{ verb }} — {{ description }}
"""

from pydantic import BaseModel
from vivesca.schemas import ToolOutput

from fastmcp.tools import tool
from mcp.types import ToolAnnotations


class {{ class_name }}Result(ToolOutput):
    """Structured output for {{ domain }}_{{ verb }}.

    Define your output fields here. Every field becomes part of
    the outputSchema that agents can rely on.
    """

    # TODO: Define output fields
    message: str


@tool(
    name="{{ domain }}_{{ verb }}",
    description="{{ description }}",
    annotations=ToolAnnotations(readOnlyHint={{ read_only }}),
)
def {{ domain }}_{{ verb }}() -> {{ class_name }}Result:
    """{{ description }}.

    Args:
        TODO: Define parameters above and document them here.
    """
    # TODO: Implement — call a service or CLI, return structured output
    raise NotImplementedError("Implement {{ domain }}_{{ verb }}")
```

`prompt.py.j2`:
```jinja2
"""{{ name }} prompt template.

Prompts:
  {{ name }} — {{ description }}
"""

from fastmcp.prompts import prompt


@prompt(
    name="{{ name }}",
    description="{{ description }}",
)
def {{ func_name }}(
    topic: str,
    context: str = "",
) -> str:
    """{{ description }}.

    Args:
        topic: The subject for this prompt.
        context: Optional background context.
    """
    context_block = f"\nContext: {context}\n" if context else ""

    return (
        f"{{ description }}\n\n"
        f"Topic: {topic}\n"
        f"{context_block}"
    )
```

`resource.py.j2`:
```jinja2
"""{{ name }} resource.

Resources:
  {{ uri }} — {{ description }}
"""

from fastmcp.resources import resource


@resource("{{ uri }}")
def {{ func_name }}() -> str:
    """{{ description }}.

    Returns context that agents can read on demand.
    """
    # TODO: Implement — return useful context
    raise NotImplementedError("Implement {{ func_name }}")
```

`test_component.py.j2`:
```jinja2
"""Tests for {{ component_type }}: {{ name }}."""


def test_{{ func_name }}_exists():
    """Verify {{ name }} can be imported."""
    from {{ module }}.{{ component_dir }}.{{ file_stem }} import {{ func_name }}
    assert callable({{ func_name }})
```

- [ ] **Step 3: Commit**

```bash
cd ~/code/vivesca
git add src/vivesca/templates/
git commit -m "feat: Jinja2 templates for project and component scaffolding"
```

---

### Task 4: `vivesca init` Command

**Files:**
- Create: `~/code/vivesca/src/vivesca/scaffold/__init__.py`
- Create: `~/code/vivesca/src/vivesca/scaffold/init.py`
- Modify: `~/code/vivesca/src/vivesca/cli.py` — wire up init command
- Create: `~/code/vivesca/tests/test_scaffold_init.py`

- [ ] **Step 1: Write the failing tests**

```python
# tests/test_scaffold_init.py
"""Tests for vivesca init command."""

import os
from pathlib import Path


def test_init_creates_project_directory(tmp_path):
    from vivesca.scaffold.init import scaffold_project

    scaffold_project("myserver", target=tmp_path / "myserver", description="Test server")
    project_dir = tmp_path / "myserver"

    assert project_dir.exists()
    assert (project_dir / "pyproject.toml").exists()
    assert (project_dir / "src" / "myserver" / "server.py").exists()
    assert (project_dir / "src" / "myserver" / "__init__.py").exists()
    assert (project_dir / "src" / "myserver" / "__main__.py").exists()
    assert (project_dir / "tests" / "test_handshake.py").exists()


def test_init_creates_component_dirs(tmp_path):
    from vivesca.scaffold.init import scaffold_project

    scaffold_project("myserver", target=tmp_path / "myserver", description="Test server")
    src = tmp_path / "myserver" / "src" / "myserver"

    assert (src / "tools").is_dir()
    assert (src / "prompts").is_dir()
    assert (src / "resources").is_dir()
    assert (src / "schemas").is_dir()


def test_init_pyproject_contains_name(tmp_path):
    from vivesca.scaffold.init import scaffold_project

    scaffold_project("myserver", target=tmp_path / "myserver", description="Test server")
    content = (tmp_path / "myserver" / "pyproject.toml").read_text()

    assert 'name = "myserver"' in content
    assert "fastmcp" in content
    assert "vivesca" in content


def test_init_server_contains_name(tmp_path):
    from vivesca.scaffold.init import scaffold_project

    scaffold_project("myserver", target=tmp_path / "myserver", description="Test server")
    content = (tmp_path / "myserver" / "src" / "myserver" / "server.py").read_text()

    assert '"myserver"' in content
    assert "FastMCP" in content
    assert "FileSystemProvider" in content


def test_init_handles_hyphenated_name(tmp_path):
    from vivesca.scaffold.init import scaffold_project

    scaffold_project("my-server", target=tmp_path / "my-server", description="Test")
    src = tmp_path / "my-server" / "src" / "my_server"

    assert src.exists()
    assert (src / "server.py").exists()

    content = (tmp_path / "my-server" / "pyproject.toml").read_text()
    assert 'name = "my-server"' in content
    assert 'packages = ["src/my_server"]' in content


def test_init_refuses_existing_directory(tmp_path):
    import click
    import pytest
    from vivesca.scaffold.init import scaffold_project

    target = tmp_path / "existing"
    target.mkdir()
    (target / "somefile.txt").write_text("existing content")

    with pytest.raises(click.exceptions.ClickException):
        scaffold_project("existing", target=target, description="Test")
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
cd ~/code/vivesca && uv run pytest tests/test_scaffold_init.py -v
```
Expected: FAIL (import errors)

- [ ] **Step 3: Implement scaffold_project**

```python
# src/vivesca/scaffold/__init__.py
"""Scaffolding utilities for vivesca projects and components."""
```

```python
# src/vivesca/scaffold/init.py
"""vivesca init — scaffold a new MCP server project."""

from pathlib import Path

import click
from jinja2 import Environment, PackageLoader

_env = Environment(
    loader=PackageLoader("vivesca", "templates"),
    keep_trailing_newline=True,
)


def _to_module(name: str) -> str:
    """Convert project name to valid Python module name."""
    return name.replace("-", "_")


def scaffold_project(name: str, target: Path, description: str) -> Path:
    """Scaffold a complete MCP server project.

    Args:
        name: Project name (may contain hyphens).
        target: Directory to create.
        description: One-line description.

    Returns:
        Path to the created project directory.
    """
    if target.exists() and any(target.iterdir()):
        raise click.ClickException(f"Directory {target} already exists and is not empty")

    module = _to_module(name)
    ctx = {"name": name, "module": module, "description": description}

    # Create directory structure
    src = target / "src" / module
    for d in [
        src / "tools",
        src / "prompts",
        src / "resources",
        src / "schemas",
        target / "tests",
    ]:
        d.mkdir(parents=True, exist_ok=True)

    # Create __init__.py files for packages
    for d in [src, src / "tools", src / "prompts", src / "resources", src / "schemas"]:
        (d / "__init__.py").touch()

    # Render templates
    templates = {
        "project/pyproject.toml.j2": target / "pyproject.toml",
        "project/server.py.j2": src / "server.py",
        "project/__init__.py.j2": src / "__init__.py",
        "project/__main__.py.j2": src / "__main__.py",
        "project/test_handshake.py.j2": target / "tests" / "test_handshake.py",
    }

    for template_name, output_path in templates.items():
        template = _env.get_template(template_name)
        output_path.write_text(template.render(**ctx))

    return target
```

- [ ] **Step 4: Wire up CLI**

Update `src/vivesca/cli.py` — replace the init command stub:

```python
@cli.command()
@click.argument("name")
@click.option("--description", "-d", default="An MCP server built with vivesca.", help="Project description.")
def init(name: str, description: str):
    """Scaffold a new MCP server project."""
    from vivesca.scaffold.init import scaffold_project
    from pathlib import Path

    target = Path.cwd() / name
    scaffold_project(name, target, description)
    click.echo(f"Created {name}/ — cd {name} && uv sync to get started.")
```

- [ ] **Step 5: Run tests to verify they pass**

```bash
cd ~/code/vivesca && uv run pytest tests/test_scaffold_init.py -v
```
Expected: All 6 tests PASS

- [ ] **Step 6: Smoke test CLI**

```bash
cd /tmp && uv run --project ~/code/vivesca vivesca init testproject -d "A test MCP server"
ls -la testproject/
ls -la testproject/src/testproject/
cat testproject/pyproject.toml
rm -rf testproject
```
Expected: Full project structure with correct content

- [ ] **Step 7: Commit**

```bash
cd ~/code/vivesca
git add src/vivesca/scaffold/ src/vivesca/cli.py tests/test_scaffold_init.py
git commit -m "feat: vivesca init — scaffold MCP server projects"
```

---

### Task 5: `vivesca add tool` Command

**Files:**
- Create: `~/code/vivesca/src/vivesca/scaffold/add.py`
- Modify: `~/code/vivesca/src/vivesca/cli.py` — wire up add commands
- Create: `~/code/vivesca/tests/test_scaffold_add.py`

- [ ] **Step 1: Write the failing tests**

```python
# tests/test_scaffold_add.py
"""Tests for vivesca add commands."""

from pathlib import Path
from vivesca.scaffold.init import scaffold_project


def _make_project(tmp_path: Path) -> Path:
    """Helper: scaffold a project for testing add commands."""
    target = tmp_path / "myserver"
    scaffold_project("myserver", target=target, description="Test server")
    return target


def test_add_tool_creates_file(tmp_path):
    from vivesca.scaffold.add import add_tool_to_project

    project = _make_project(tmp_path)
    add_tool_to_project(project, domain="weather", verb="fetch", description="Fetch weather data")

    tool_file = project / "src" / "myserver" / "tools" / "weather.py"
    assert tool_file.exists()

    content = tool_file.read_text()
    assert "weather_fetch" in content
    assert "ToolOutput" in content
    assert "ToolAnnotations" in content
    assert "class" in content  # Has a Pydantic output model


def test_add_tool_creates_test(tmp_path):
    from vivesca.scaffold.add import add_tool_to_project

    project = _make_project(tmp_path)
    add_tool_to_project(project, domain="weather", verb="fetch", description="Fetch weather data")

    test_file = project / "tests" / "test_weather.py"
    assert test_file.exists()

    content = test_file.read_text()
    assert "weather_fetch" in content


def test_add_tool_read_only_flag(tmp_path):
    from vivesca.scaffold.add import add_tool_to_project

    project = _make_project(tmp_path)
    add_tool_to_project(project, domain="weather", verb="fetch", description="Test", read_only=True)

    content = (project / "src" / "myserver" / "tools" / "weather.py").read_text()
    assert "readOnlyHint=True" in content


def test_add_tool_destructive_flag(tmp_path):
    from vivesca.scaffold.add import add_tool_to_project

    project = _make_project(tmp_path)
    add_tool_to_project(project, domain="cache", verb="purge", description="Purge cache", read_only=False)

    content = (project / "src" / "myserver" / "tools" / "cache.py").read_text()
    assert "readOnlyHint=False" in content


def test_add_prompt_creates_file(tmp_path):
    from vivesca.scaffold.add import add_prompt_to_project

    project = _make_project(tmp_path)
    add_prompt_to_project(project, name="research", description="Research brief")

    prompt_file = project / "src" / "myserver" / "prompts" / "research.py"
    assert prompt_file.exists()

    content = prompt_file.read_text()
    assert "research" in content
    assert "@prompt" in content


def test_add_resource_creates_file(tmp_path):
    from vivesca.scaffold.add import add_resource_to_project

    project = _make_project(tmp_path)
    add_resource_to_project(project, name="status", description="Server status", uri_path="status")

    resource_file = project / "src" / "myserver" / "resources" / "status.py"
    assert resource_file.exists()

    content = resource_file.read_text()
    assert "myserver://status" in content
    assert "@resource" in content


def test_add_tool_detects_module_name(tmp_path):
    """Hyphenated project names should use underscore module name."""
    target = tmp_path / "my-server"
    scaffold_project("my-server", target=target, description="Test")

    from vivesca.scaffold.add import add_tool_to_project
    add_tool_to_project(target, domain="weather", verb="fetch", description="Test")

    tool_file = target / "src" / "my_server" / "tools" / "weather.py"
    assert tool_file.exists()
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
cd ~/code/vivesca && uv run pytest tests/test_scaffold_add.py -v
```
Expected: FAIL (import errors)

- [ ] **Step 3: Implement add commands**

```python
# src/vivesca/scaffold/add.py
"""vivesca add — add components to an existing project."""

from pathlib import Path

import click
from jinja2 import Environment, PackageLoader

_env = Environment(
    loader=PackageLoader("vivesca", "templates"),
    keep_trailing_newline=True,
)


def _detect_module(project_dir: Path) -> str:
    """Detect the Python module name from project structure."""
    src = project_dir / "src"
    if not src.exists():
        raise click.ClickException(f"No src/ directory found in {project_dir}")
    modules = [d.name for d in src.iterdir() if d.is_dir() and not d.name.startswith(".")]
    if len(modules) != 1:
        raise click.ClickException(f"Expected one module in src/, found: {modules}")
    return modules[0]


def _to_class_name(s: str) -> str:
    """Convert snake_case to PascalCase."""
    return "".join(word.capitalize() for word in s.split("_"))


def add_tool_to_project(
    project_dir: Path,
    domain: str,
    verb: str,
    description: str,
    read_only: bool = True,
) -> Path:
    """Add a tool file and test to an existing vivesca project."""
    module = _detect_module(project_dir)
    class_name = _to_class_name(f"{domain}_{verb}")

    ctx = {
        "domain": domain,
        "verb": verb,
        "description": description,
        "class_name": class_name,
        "read_only": read_only,
        "module": module,
        "component_dir": "tools",
        "file_stem": domain,
        "func_name": f"{domain}_{verb}",
        "name": f"{domain}_{verb}",
        "component_type": "tool",
    }

    tool_file = project_dir / "src" / module / "tools" / f"{domain}.py"
    tool_file.write_text(_env.get_template("tool.py.j2").render(**ctx))

    test_file = project_dir / "tests" / f"test_{domain}.py"
    test_file.write_text(_env.get_template("test_component.py.j2").render(**ctx))

    return tool_file


def add_prompt_to_project(
    project_dir: Path,
    name: str,
    description: str,
) -> Path:
    """Add a prompt file to an existing vivesca project."""
    module = _detect_module(project_dir)
    func_name = name.replace("-", "_")

    ctx = {
        "name": name,
        "func_name": func_name,
        "description": description,
        "module": module,
        "component_dir": "prompts",
        "file_stem": name.replace("-", "_"),
        "component_type": "prompt",
    }

    prompt_file = project_dir / "src" / module / "prompts" / f"{func_name}.py"
    prompt_file.write_text(_env.get_template("prompt.py.j2").render(**ctx))

    test_file = project_dir / "tests" / f"test_{func_name}.py"
    test_file.write_text(_env.get_template("test_component.py.j2").render(**ctx))

    return prompt_file


def add_resource_to_project(
    project_dir: Path,
    name: str,
    description: str,
    uri_path: str = "",
) -> Path:
    """Add a resource file to an existing vivesca project."""
    module = _detect_module(project_dir)
    func_name = name.replace("-", "_")
    # Detect project name from pyproject.toml or use module name
    project_name = module.replace("_", "-")
    uri = f"{project_name}://{uri_path or name}"

    ctx = {
        "name": name,
        "func_name": func_name,
        "description": description,
        "uri": uri,
        "module": module,
        "component_dir": "resources",
        "file_stem": name.replace("-", "_"),
        "component_type": "resource",
    }

    resource_file = project_dir / "src" / module / "resources" / f"{func_name}.py"
    resource_file.write_text(_env.get_template("resource.py.j2").render(**ctx))

    test_file = project_dir / "tests" / f"test_{func_name}.py"
    test_file.write_text(_env.get_template("test_component.py.j2").render(**ctx))

    return resource_file
```

- [ ] **Step 4: Wire up CLI commands**

Update `src/vivesca/cli.py` — replace the add command stubs:

```python
@add.command("tool")
@click.argument("name")
@click.option("--domain", "-d", help="Tool domain prefix (default: derived from name).")
@click.option("--verb", "-v", help="Tool verb (default: derived from name).")
@click.option("--description", default="TODO: describe this tool.", help="Tool description.")
@click.option("--read-only/--no-read-only", default=True, help="Whether tool is read-only.")
def add_tool(name: str, domain: str | None, verb: str | None, description: str, read_only: bool):
    """Add a tool to the current project.

    NAME can be 'domain_verb' (e.g., 'weather_fetch') or just 'domain'
    with --verb flag.
    """
    from vivesca.scaffold.add import add_tool_to_project
    from pathlib import Path

    if "_" in name and not domain:
        parts = name.split("_", 1)
        domain, verb = parts[0], parts[1]
    elif not domain:
        domain = name
    if not verb:
        verb = "get"

    path = add_tool_to_project(Path.cwd(), domain=domain, verb=verb, description=description, read_only=read_only)
    click.echo(f"Created {path.relative_to(Path.cwd())}")


@add.command("prompt")
@click.argument("name")
@click.option("--description", default="TODO: describe this prompt.", help="Prompt description.")
def add_prompt(name: str, description: str):
    """Add a prompt to the current project."""
    from vivesca.scaffold.add import add_prompt_to_project
    from pathlib import Path

    path = add_prompt_to_project(Path.cwd(), name=name, description=description)
    click.echo(f"Created {path.relative_to(Path.cwd())}")


@add.command("resource")
@click.argument("name")
@click.option("--description", default="TODO: describe this resource.", help="Resource description.")
@click.option("--uri-path", default="", help="Custom URI path (default: name).")
def add_resource(name: str, description: str, uri_path: str):
    """Add a resource to the current project."""
    from vivesca.scaffold.add import add_resource_to_project
    from pathlib import Path

    path = add_resource_to_project(Path.cwd(), name=name, description=description, uri_path=uri_path)
    click.echo(f"Created {path.relative_to(Path.cwd())}")
```

- [ ] **Step 5: Run tests to verify they pass**

```bash
cd ~/code/vivesca && uv run pytest tests/test_scaffold_add.py -v
```
Expected: All 7 tests PASS

- [ ] **Step 6: Commit**

```bash
cd ~/code/vivesca
git add src/vivesca/scaffold/add.py src/vivesca/cli.py tests/test_scaffold_add.py
git commit -m "feat: vivesca add tool/prompt/resource — component scaffolding"
```

---

### Task 6: `vivesca check` Command

**Files:**
- Create: `~/code/vivesca/src/vivesca/scaffold/check.py`
- Modify: `~/code/vivesca/src/vivesca/cli.py` — wire up check command
- Create: `~/code/vivesca/tests/test_scaffold_check.py`

- [ ] **Step 1: Write the failing tests**

```python
# tests/test_scaffold_check.py
"""Tests for vivesca check command."""

from pathlib import Path
from vivesca.scaffold.init import scaffold_project
from vivesca.scaffold.add import add_tool_to_project


def _make_project_with_tool(tmp_path: Path) -> Path:
    """Helper: scaffold project + add a valid tool."""
    target = tmp_path / "myserver"
    scaffold_project("myserver", target=target, description="Test server")
    add_tool_to_project(target, domain="weather", verb="fetch", description="Fetch weather")
    return target


def test_check_valid_project_passes(tmp_path):
    from vivesca.scaffold.check import check_project

    project = _make_project_with_tool(tmp_path)
    issues = check_project(project)
    assert len(issues) == 0


def test_check_detects_missing_return_type(tmp_path):
    from vivesca.scaffold.check import check_project

    project = _make_project_with_tool(tmp_path)
    # Break the tool: remove return type annotation
    tool_file = project / "src" / "myserver" / "tools" / "weather.py"
    content = tool_file.read_text()
    content = content.replace("-> WeatherFetchResult:", ":")
    tool_file.write_text(content)

    issues = check_project(project)
    assert any("return type" in i.lower() for i in issues)


def test_check_detects_missing_annotations(tmp_path):
    from vivesca.scaffold.check import check_project

    project = _make_project_with_tool(tmp_path)
    # Break the tool: remove ToolAnnotations
    tool_file = project / "src" / "myserver" / "tools" / "weather.py"
    content = tool_file.read_text()
    content = content.replace(
        "    annotations=ToolAnnotations(readOnlyHint=True),\n", ""
    )
    tool_file.write_text(content)

    issues = check_project(project)
    assert any("annotation" in i.lower() for i in issues)


def test_check_detects_missing_description(tmp_path):
    from vivesca.scaffold.check import check_project

    project = _make_project_with_tool(tmp_path)
    # Break the tool: remove description
    tool_file = project / "src" / "myserver" / "tools" / "weather.py"
    content = tool_file.read_text()
    content = content.replace(
        '    description="Fetch weather",\n', ""
    )
    tool_file.write_text(content)

    issues = check_project(project)
    assert any("description" in i.lower() for i in issues)


def test_check_validates_prompts(tmp_path):
    from vivesca.scaffold.check import check_project
    from vivesca.scaffold.add import add_prompt_to_project

    project = _make_project_with_tool(tmp_path)
    add_prompt_to_project(project, name="research", description="Research brief")
    issues = check_project(project)
    assert len(issues) == 0  # Valid prompt passes


def test_check_validates_resources(tmp_path):
    from vivesca.scaffold.check import check_project
    from vivesca.scaffold.add import add_resource_to_project

    project = _make_project_with_tool(tmp_path)
    add_resource_to_project(project, name="status", description="Status", uri_path="status")
    issues = check_project(project)
    assert len(issues) == 0  # Valid resource passes
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
cd ~/code/vivesca && uv run pytest tests/test_scaffold_check.py -v
```
Expected: FAIL (import errors)

- [ ] **Step 3: Implement check_project**

```python
# src/vivesca/scaffold/check.py
"""vivesca check — validate a project against vivesca conventions.

Uses AST analysis to check:
- Tools: return type annotations, ToolAnnotations, description
- Prompts: description in @prompt() decorator
- Resources: URI in @resource() decorator
"""

import ast
from pathlib import Path


def check_project(project_dir: Path) -> list[str]:
    """Check a vivesca project for convention violations.

    Returns a list of issue descriptions. Empty list = all good.
    """
    issues: list[str] = []
    module = _detect_module(project_dir)

    # Check tools — return type, annotations, description
    tools_dir = project_dir / "src" / module / "tools"
    if tools_dir.exists():
        for py_file in sorted(tools_dir.glob("*.py")):
            if py_file.name == "__init__.py":
                continue
            issues.extend(_check_tool_file(py_file))

    # Check prompts — @prompt decorator must have description
    prompts_dir = project_dir / "src" / module / "prompts"
    if prompts_dir.exists():
        for py_file in sorted(prompts_dir.glob("*.py")):
            if py_file.name == "__init__.py":
                continue
            issues.extend(_check_prompt_file(py_file))

    # Check resources — @resource decorator must have a URI
    resources_dir = project_dir / "src" / module / "resources"
    if resources_dir.exists():
        for py_file in sorted(resources_dir.glob("*.py")):
            if py_file.name == "__init__.py":
                continue
            issues.extend(_check_resource_file(py_file))

    return issues


def _detect_module(project_dir: Path) -> str:
    """Detect the Python module name from project structure."""
    src = project_dir / "src"
    modules = [d.name for d in src.iterdir() if d.is_dir() and not d.name.startswith(".")]
    if len(modules) != 1:
        return ""
    return modules[0]


def _check_tool_file(path: Path) -> list[str]:
    """Check a single tool file for convention violations."""
    issues: list[str] = []
    rel = path.name

    try:
        tree = ast.parse(path.read_text())
    except SyntaxError as e:
        return [f"{rel}: syntax error — {e}"]

    for node in ast.walk(tree):
        if not isinstance(node, ast.FunctionDef):
            continue

        # Check if this function has a @tool decorator
        is_tool = any(
            _is_tool_decorator(d) for d in node.decorator_list
        )
        if not is_tool:
            continue

        func_name = node.name

        # Check 1: Return type annotation
        if node.returns is None:
            issues.append(f"{rel}: {func_name} — missing return type annotation (required for outputSchema)")

        # Check 2: ToolAnnotations in decorator
        if not _has_annotations_kwarg(node):
            issues.append(f"{rel}: {func_name} — missing annotations=ToolAnnotations(...) (required for client hints)")

        # Check 3: Description in decorator
        if not _has_description_kwarg(node):
            issues.append(f"{rel}: {func_name} — missing description (required for discoverability)")

    return issues


def _is_tool_decorator(decorator: ast.expr) -> bool:
    """Check if a decorator is @tool(...)."""
    if isinstance(decorator, ast.Call):
        func = decorator.func
        if isinstance(func, ast.Name) and func.id == "tool":
            return True
        if isinstance(func, ast.Attribute) and func.attr == "tool":
            return True
    if isinstance(decorator, ast.Name) and decorator.id == "tool":
        return True
    return False


def _has_annotations_kwarg(node: ast.FunctionDef) -> bool:
    """Check if @tool() decorator has annotations= keyword."""
    for d in node.decorator_list:
        if isinstance(d, ast.Call) and _is_tool_decorator(d):
            return any(kw.arg == "annotations" for kw in d.keywords)
    return False


def _has_description_kwarg(node: ast.FunctionDef) -> bool:
    """Check if @tool() decorator has description= keyword."""
    for d in node.decorator_list:
        if isinstance(d, ast.Call) and _is_tool_decorator(d):
            return any(kw.arg == "description" for kw in d.keywords)
    return False


def _is_decorator(decorator: ast.expr, name: str) -> bool:
    """Check if a decorator matches a given name."""
    if isinstance(decorator, ast.Call):
        func = decorator.func
        if isinstance(func, ast.Name) and func.id == name:
            return True
        if isinstance(func, ast.Attribute) and func.attr == name:
            return True
    if isinstance(decorator, ast.Name) and decorator.id == name:
        return True
    return False


def _check_prompt_file(path: Path) -> list[str]:
    """Check a prompt file for convention violations."""
    issues: list[str] = []
    rel = path.name

    try:
        tree = ast.parse(path.read_text())
    except SyntaxError as e:
        return [f"{rel}: syntax error — {e}"]

    for node in ast.walk(tree):
        if not isinstance(node, ast.FunctionDef):
            continue
        is_prompt = any(_is_decorator(d, "prompt") for d in node.decorator_list)
        if not is_prompt:
            continue

        # Check: description in @prompt()
        has_desc = False
        for d in node.decorator_list:
            if isinstance(d, ast.Call) and _is_decorator(d, "prompt"):
                has_desc = any(kw.arg == "description" for kw in d.keywords)
        if not has_desc:
            issues.append(f"{rel}: {node.name} — missing description in @prompt() (required for discoverability)")

    return issues


def _check_resource_file(path: Path) -> list[str]:
    """Check a resource file for convention violations."""
    issues: list[str] = []
    rel = path.name

    try:
        tree = ast.parse(path.read_text())
    except SyntaxError as e:
        return [f"{rel}: syntax error — {e}"]

    for node in ast.walk(tree):
        if not isinstance(node, ast.FunctionDef):
            continue
        is_resource = any(_is_decorator(d, "resource") for d in node.decorator_list)
        if not is_resource:
            continue

        # Check: @resource() has a URI argument
        has_uri = False
        for d in node.decorator_list:
            if isinstance(d, ast.Call) and _is_decorator(d, "resource"):
                has_uri = len(d.args) > 0  # URI is the positional arg
        if not has_uri:
            issues.append(f"{rel}: {node.name} — missing URI in @resource() (required for discovery)")

    return issues
```

- [ ] **Step 4: Wire up CLI command**

Update `src/vivesca/cli.py` — replace the check command stub:

```python
@cli.command()
def check():
    """Validate project against vivesca conventions."""
    from vivesca.scaffold.check import check_project
    from pathlib import Path

    issues = check_project(Path.cwd())
    if not issues:
        click.echo("All checks passed.")
    else:
        click.echo(f"Found {len(issues)} issue(s):\n")
        for issue in issues:
            click.echo(f"  - {issue}")
        raise SystemExit(1)
```

- [ ] **Step 5: Run tests to verify they pass**

```bash
cd ~/code/vivesca && uv run pytest tests/test_scaffold_check.py -v
```
Expected: All 4 tests PASS

- [ ] **Step 6: Commit**

```bash
cd ~/code/vivesca
git add src/vivesca/scaffold/check.py src/vivesca/cli.py tests/test_scaffold_check.py
git commit -m "feat: vivesca check — AST-based convention validation"
```

**IMPORTANT — Complete `cli.py` at this point (after Tasks 4, 5, 6 have each modified it):**

The final `src/vivesca/cli.py` should be exactly:

```python
# src/vivesca/cli.py
"""vivesca CLI — scaffold and validate MCP tool ecosystems."""

from pathlib import Path

import click


@click.group()
@click.version_option()
def cli():
    """Vivesca — opinionated MCP tool ecosystem framework."""
    pass


@cli.command()
@click.argument("name")
@click.option("--description", "-d", default="An MCP server built with vivesca.", help="Project description.")
def init(name: str, description: str):
    """Scaffold a new MCP server project."""
    from vivesca.scaffold.init import scaffold_project

    target = Path.cwd() / name
    scaffold_project(name, target, description)
    click.echo(f"Created {name}/ — cd {name} && uv sync to get started.")


@cli.group()
def add():
    """Add a component to the current project."""
    pass


@add.command("tool")
@click.argument("name")
@click.option("--domain", "-d", help="Tool domain prefix (default: derived from name).")
@click.option("--verb", "-v", help="Tool verb (default: derived from name).")
@click.option("--description", default="TODO: describe this tool.", help="Tool description.")
@click.option("--read-only/--no-read-only", default=True, help="Whether tool is read-only.")
def add_tool(name: str, domain: str | None, verb: str | None, description: str, read_only: bool):
    """Add a tool to the current project.

    NAME can be 'domain_verb' (e.g., 'weather_fetch') or just 'domain'
    with --verb flag.
    """
    from vivesca.scaffold.add import add_tool_to_project

    if "_" in name and not domain:
        parts = name.split("_", 1)
        domain, verb = parts[0], parts[1]
    elif not domain:
        domain = name
    if not verb:
        verb = "get"

    path = add_tool_to_project(Path.cwd(), domain=domain, verb=verb, description=description, read_only=read_only)
    click.echo(f"Created {path.relative_to(Path.cwd())}")


@add.command("prompt")
@click.argument("name")
@click.option("--description", default="TODO: describe this prompt.", help="Prompt description.")
def add_prompt(name: str, description: str):
    """Add a prompt to the current project."""
    from vivesca.scaffold.add import add_prompt_to_project

    path = add_prompt_to_project(Path.cwd(), name=name, description=description)
    click.echo(f"Created {path.relative_to(Path.cwd())}")


@add.command("resource")
@click.argument("name")
@click.option("--description", default="TODO: describe this resource.", help="Resource description.")
@click.option("--uri-path", default="", help="Custom URI path (default: name).")
def add_resource(name: str, description: str, uri_path: str):
    """Add a resource to the current project."""
    from vivesca.scaffold.add import add_resource_to_project

    path = add_resource_to_project(Path.cwd(), name=name, description=description, uri_path=uri_path)
    click.echo(f"Created {path.relative_to(Path.cwd())}")


@cli.command()
def check():
    """Validate project against vivesca conventions."""
    from vivesca.scaffold.check import check_project

    issues = check_project(Path.cwd())
    if not issues:
        click.echo("All checks passed.")
    else:
        click.echo(f"Found {len(issues)} issue(s):\n")
        for issue in issues:
            click.echo(f"  - {issue}")
        raise SystemExit(1)
```

If the file differs from this after Tasks 4-6, overwrite it with this version.

---

### Task 7: CLI Integration Test

**Files:**
- Create: `~/code/vivesca/tests/test_cli.py`

- [ ] **Step 1: Write integration tests**

```python
# tests/test_cli.py
"""Integration tests — CLI end-to-end via Click test runner."""

from click.testing import CliRunner
from vivesca.cli import cli


def test_cli_version():
    runner = CliRunner()
    result = runner.invoke(cli, ["--version"])
    assert result.exit_code == 0
    assert "0.1.0" in result.output


def test_cli_init_creates_project(tmp_path):
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        result = runner.invoke(cli, ["init", "demo", "-d", "Demo MCP server"])
        assert result.exit_code == 0
        assert "Created demo/" in result.output

        from pathlib import Path
        assert (Path("demo") / "pyproject.toml").exists()
        assert (Path("demo") / "src" / "demo" / "server.py").exists()


def test_cli_add_tool_in_project(tmp_path):
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        # Init project first
        runner.invoke(cli, ["init", "demo"])

        import os
        os.chdir("demo")

        # Add a tool
        result = runner.invoke(cli, ["add", "tool", "weather_fetch", "--description", "Fetch weather"])
        assert result.exit_code == 0
        assert "weather.py" in result.output


def test_cli_check_passes_for_valid_project(tmp_path):
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        runner.invoke(cli, ["init", "demo"])

        import os
        os.chdir("demo")

        runner.invoke(cli, ["add", "tool", "weather_fetch"])

        result = runner.invoke(cli, ["check"])
        assert result.exit_code == 0
        assert "passed" in result.output.lower()


def test_cli_init_then_add_then_check_roundtrip(tmp_path):
    """Full roundtrip: init → add tool → add prompt → add resource → check."""
    runner = CliRunner()
    with runner.isolated_filesystem(temp_dir=tmp_path):
        # Init
        result = runner.invoke(cli, ["init", "myapp"])
        assert result.exit_code == 0

        import os
        os.chdir("myapp")

        # Add components
        result = runner.invoke(cli, ["add", "tool", "data_fetch", "--description", "Fetch data"])
        assert result.exit_code == 0

        result = runner.invoke(cli, ["add", "prompt", "analyze", "--description", "Analyze data"])
        assert result.exit_code == 0

        result = runner.invoke(cli, ["add", "resource", "config", "--description", "App config"])
        assert result.exit_code == 0

        # Verify structure
        from pathlib import Path
        assert (Path("src/myapp/tools/data.py")).exists()
        assert (Path("src/myapp/prompts/analyze.py")).exists()
        assert (Path("src/myapp/resources/config.py")).exists()

        # Check conventions
        result = runner.invoke(cli, ["check"])
        assert result.exit_code == 0
```

- [ ] **Step 2: Run all tests**

```bash
cd ~/code/vivesca && uv run pytest tests/ -v
```
Expected: All tests PASS

- [ ] **Step 3: Commit**

```bash
cd ~/code/vivesca
git add tests/test_cli.py
git commit -m "test: CLI integration tests — init/add/check roundtrip"
```

---

### Task 8: README + GitHub Push

**Files:**
- Create: `~/code/vivesca/README.md`

- [ ] **Step 1: Write README**

```markdown
# vivesca

An opinionated framework for building MCP tool ecosystems that evolve.

## Install

```bash
uv add vivesca
```

## Quick Start

```bash
# Scaffold a new MCP server project
vivesca init my-server -d "My MCP tool server"
cd my-server

# Add components
vivesca add tool weather_fetch --description "Fetch weather data"
vivesca add prompt research --description "Structured research brief"
vivesca add resource config --description "Server configuration"

# Validate conventions
vivesca check

# Install and run
uv sync
uv run my-server
```

## What it enforces

- **Structured output** — Pydantic return types generate `outputSchema` automatically
- **Tool annotations** — `readOnlyHint`, `destructiveHint` required on every tool
- **Service separation** — tools are thin wrappers; business logic lives elsewhere

## Philosophy

Token economics creates selection pressure. Context windows are finite environments.
Tools that waste tokens get replaced. Tools that return structured, lean output survive.

Vivesca scaffolds MCP tool ecosystems that are born fit — with conventions that make
the right thing the easy thing.

[vivesca.org](https://vivesca.org)
```

- [ ] **Step 2: Push to GitHub**

```bash
cd ~/code/vivesca
git add README.md
git commit -m "docs: README with quickstart"
git remote add origin git@github.com:vivesca/vivesca.git
git branch -M main
git push -u origin main
```

- [ ] **Step 3: Final test suite run**

```bash
cd ~/code/vivesca && uv run pytest tests/ -v --tb=short
```
Expected: All tests PASS

---

## Post-v0.1 (not in scope)

- **`vivesca migrate`** — analyze an existing MCP server and suggest vivesca convention upgrades (for praeses migration)
- **Metabolism engine** — usage tracking, fitness signals, mutation proposals
- **Ecosystem manifest** — declare multi-server topology, inter-server dependencies
- **`vivesca publish`** — publish to PyPI with version bumping
- **MCP prompt migration** — move skill content to evolvable MCP prompts
