Skip to content
Merged
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
66 changes: 55 additions & 11 deletions src/agentseek/cli/commands/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -1338,12 +1338,50 @@ def _load_cookiecutter_context(template_dir: Path) -> dict[str, object] | None:
return data


def _template_layout_hints(template_dir: Path) -> list[str]:
"""Derive the project layout a template will generate.

Cookiecutter renders only the templated project directory — the child
whose name contains ``cookiecutter`` and Jinja2 braces (e.g.
``{{cookiecutter.project_slug}}``) — and ignores everything else at the
template root: ``cookiecutter.json``, the ``hooks/`` directory, and
author-facing metadata such as a root ``README.md``. Directories are
listed with a trailing ``/`` and expanded one level so ``--describe``
surfaces the key generated-project structure without walking the whole
tree.
"""
if not template_dir.is_dir():
return []
try:
entries = sorted(template_dir.iterdir(), key=lambda p: p.name)
except OSError:
return []
# Cookiecutter's selection semantics (see cookiecutter.find.find_template):
# the project template is the child whose name carries the cookiecutter
# context variable, e.g. "{{cookiecutter.project_slug}}".
project_root = next(
(entry for entry in entries if "cookiecutter" in entry.name and "{{" in entry.name and "}}" in entry.name),
None,
)
if project_root is None or not project_root.is_dir():
return []
hints = [f"{project_root.name}/"]
try:
children = sorted(project_root.iterdir(), key=lambda p: (not p.is_dir(), p.name))
except OSError:
return hints
# Cap per-directory expansion so hints stay readable.
for child in children[:20]:
hints.append(f" {child.name}{'/' if child.is_dir() else ''}")
return hints


def _describe_template(
source: TemplateSource,
*,
catalog: _PreparedCatalog,
) -> None:
"""Print template spec, description, and cookiecutter variables.
"""Print template spec, description, cookiecutter variables, and layout hints.

Does **not** run cookiecutter or create any files.
"""
Expand Down Expand Up @@ -1374,17 +1412,23 @@ def _describe_template(
context = _load_cookiecutter_context(template_dir)
if context is None:
typer.echo(" Cookiecutter variables: (none)")
typer.echo()
return
else:
typer.echo(f" Cookiecutter variables ({len(context)}):")
for key, value in context.items():
display_key = _terminal_safe(str(key))
# Keep non-string values (lists, dicts) terminal-safe too.
display = _terminal_safe(json.dumps(value) if not isinstance(value, str) else value)
# Truncate long values for readability.
if len(display) > 80:
display = display[:77] + "..."
typer.echo(f" {display_key}: {display}")

layout = _template_layout_hints(template_dir)
if layout:
typer.echo(" Generated project layout:")
for line in layout:
typer.echo(f" {_terminal_safe(line)}")

typer.echo(f" Cookiecutter variables ({len(context)}):")
for key, value in context.items():
display_key = _terminal_safe(str(key))
display = _terminal_safe(value) if isinstance(value, str) else json.dumps(value)
# Truncate long values for readability.
if len(display) > 80:
display = display[:77] + "..."
typer.echo(f" {display_key}: {display}")
typer.echo()


Expand Down
48 changes: 48 additions & 0 deletions tests/cli_commands/test_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,54 @@ def fake_runner(source: TemplateSource, *, output_dir: Path, no_input: bool) ->
assert "called" not in captured


def test_describe_prints_generated_layout_hints(monkeypatch, tmp_path: Path) -> None:
"""``--describe`` should show the project layout the template will generate."""
_use_local_default_catalog(monkeypatch)
monkeypatch.chdir(tmp_path)

result = _runner().invoke(
build_command_app(),
["create", "bub/default", "--describe"],
)

assert result.exit_code == 0, result.output
assert "Generated project layout" in result.output
assert "{{cookiecutter.project_slug}}/" in result.output
assert "pyproject.toml" in result.output
# cookiecutter.json is config, not part of the generated project layout.
assert "cookiecutter.json" not in result.output
_assert_no_next_steps(result.output)


def test_template_layout_hints_uses_cookiecutter_project_dir(tmp_path: Path) -> None:
"""Layout hints derive from the templated project dir, not the template root."""
template = tmp_path / "template"
template.mkdir()
(template / "cookiecutter.json").write_text('{"project_slug": "demo"}', encoding="utf-8")
# Author-facing metadata at the template root must not appear in the layout.
(template / "README.md").write_text("template docs", encoding="utf-8")
(template / "hooks").mkdir()
project_root = template / "{{cookiecutter.project_slug}}"
project_root.mkdir()
(project_root / "src").mkdir()
(project_root / "pyproject.toml").write_text("", encoding="utf-8")

hints = create_module._template_layout_hints(template)

assert hints == ["{{cookiecutter.project_slug}}/", " src/", " pyproject.toml"]
assert "README.md" not in hints


def test_template_layout_hints_empty_without_templated_dir(tmp_path: Path) -> None:
"""A template without a cookiecutter project dir yields no layout hints."""
template = tmp_path / "template"
template.mkdir()
(template / "cookiecutter.json").write_text("{}", encoding="utf-8")
(template / "README.md").write_text("template docs", encoding="utf-8")

assert create_module._template_layout_hints(template) == []


def test_describe_does_not_create_files(monkeypatch, tmp_path: Path) -> None:
"""``--describe`` must not run cookiecutter or create any files."""
_use_local_default_catalog(monkeypatch)
Expand Down
Loading