Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions fastapi_to_skill/generators/skill_generator.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from __future__ import annotations

import json
import re
from collections import defaultdict
from pathlib import Path

from jinja2 import Environment, FileSystemLoader

from ..models import APISpec
from .cli_generator import _body_schema_help

TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
TEMPLATE_NAME = "skill.md.jinja2"
Expand Down Expand Up @@ -39,6 +41,44 @@ def _group_by_tag(spec: APISpec) -> dict[str, list]:
return dict(groups)


def _body_schema_help_md(schema: dict) -> str:
"""Render body schema help with real newlines, for embedding in Markdown
(the cli_generator version uses "\\n" so it survives as a Python docstring)."""
return _body_schema_help(schema).replace("\\n", "\n")


def _example_value(field: dict):
"""Produce a realistic example value for a resolved JSON Schema field."""
if "example" in field:
return field["example"]
if "default" in field:
return field["default"]
if "enum" in field:
return field["enum"][0]
ftype = field.get("type", "string")
if ftype == "object" and "properties" in field:
return _example_payload(field)
if ftype == "array":
return [_example_value(field.get("items", {}))]
return {
"string": "string",
"integer": 0,
"number": 0.0,
"boolean": True,
}.get(ftype, "value")


def _example_payload(schema: dict) -> dict:
"""Build a realistic example request-body payload from a resolved schema."""
return {name: _example_value(field) for name, field in schema.get("properties", {}).items()}


def _example_payload_json(schema: dict) -> str:
if not schema.get("properties"):
return '{"key": "value"}'
return json.dumps(_example_payload(schema))


def _get_env_vars(spec: APISpec, env_prefix: str) -> list[str]:
"""Collect all environment variables the generated CLI uses."""
env_vars = [f"{env_prefix}_BASE_URL"]
Expand All @@ -61,6 +101,8 @@ def generate_skill(spec: APISpec, output_dir: Path) -> Path:
env.filters["slugify"] = _slugify
env.filters["oneline"] = _oneline
env.filters["truncate"] = _truncate
env.filters["body_schema_help"] = _body_schema_help_md
env.filters["example_payload_json"] = _example_payload_json

template = env.get_template(TEMPLATE_NAME)

Expand Down
10 changes: 9 additions & 1 deletion fastapi_to_skill/templates/skill.md.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,22 @@ python ${CLAUDE_SKILL_DIR}/cli.py search "<query>"
{%- for p in ep.parameters if p.location == "query" %} `--{{ p.name | replace('_', '-') }}`{% endfor %}
{%- if ep.request_body %} `--body '{...}'`{% endif %}

{% if ep.request_body and ep.request_body.schema_dict %}
```
{% for line in (ep.request_body.schema_dict | body_schema_help).split('\n') %}
{{ line }}
{% endfor %}
```
{% endif %}

{% endfor %}
{% endfor %}

## Examples
{% for ep in spec.endpoints[:5] %}

```bash
python ${CLAUDE_SKILL_DIR}/cli.py {{ ep.operation_id | replace('_', '-') }}{% for p in ep.parameters if p.location == "path" %} <{{ p.name }}>{% endfor %}{% for p in ep.parameters if p.location == "query" %} --{{ p.name | replace('_', '-') }} value{% endfor %}{% if ep.request_body %} --body '{"key": "value"}'{% endif %}
python ${CLAUDE_SKILL_DIR}/cli.py {{ ep.operation_id | replace('_', '-') }}{% for p in ep.parameters if p.location == "path" %} <{{ p.name }}>{% endfor %}{% for p in ep.parameters if p.location == "query" %} --{{ p.name | replace('_', '-') }} value{% endfor %}{% if ep.request_body %} --body '{{ ep.request_body.schema_dict | example_payload_json }}'{% endif %}

```
{% endfor %}
34 changes: 34 additions & 0 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,37 @@ def test_complex_full_pipeline(complex_spec, tmp_path):
assert cli_path.exists()
assert skill_path.exists()
ast.parse(cli_path.read_text())


# --- SKILL.md body schema inlining ---


def test_skill_md_inlines_body_field_schema(complex_spec, tmp_path):
"""SKILL.md should show the real body fields, not just a generic --body placeholder."""
api = parse_spec(complex_spec)
skill_path = generate_skill(api, tmp_path)
content = skill_path.read_text()
assert "Body fields:" in content
assert "name: string (required)" in content
assert "email: string (required)" in content
assert "role: enum('admin', 'customer', 'seller')" in content
# No literal "\n" escape sequences should leak into the Markdown
assert "\\n" not in content


def test_skill_md_realistic_example_payload(complex_spec, tmp_path):
"""The --body example should use real field names/values, not {"key": "value"}."""
api = parse_spec(complex_spec)
skill_path = generate_skill(api, tmp_path)
content = skill_path.read_text()
assert '{"key": "value"}' not in content
assert '"name": "string"' in content
assert '"email": "string"' in content
assert '"role": "customer"' in content


def test_skill_md_example_payload_defaults_for_no_properties(petstore_spec, tmp_path):
"""Endpoints without a body keep no --body at all; this just guards the fallback."""
from fastapi_to_skill.generators.skill_generator import _example_payload_json

assert _example_payload_json({}) == '{"key": "value"}'