From 451b5cc21a42bfe17d37a112702f11747437c087 Mon Sep 17 00:00:00 2001 From: gaoyunlong <2785866137@qq.com> Date: Tue, 11 Aug 2026 22:58:37 +0800 Subject: [PATCH] feat(create): rework --describe with generated-project layout hints From-scratch implementation of template inspection for `agentseek create`: print template spec, description from templates/index.json, cookiecutter.json input variables, and the project layout the template will generate. Does not run cookiecutter or create any files; external specs are rejected. --- src/agentseek/cli/commands/create.py | 66 +++++++++++++++++++++++----- tests/cli_commands/test_create.py | 48 ++++++++++++++++++++ 2 files changed, 103 insertions(+), 11 deletions(-) diff --git a/src/agentseek/cli/commands/create.py b/src/agentseek/cli/commands/create.py index dc49169c..7314d10c 100644 --- a/src/agentseek/cli/commands/create.py +++ b/src/agentseek/cli/commands/create.py @@ -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. """ @@ -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() diff --git a/tests/cli_commands/test_create.py b/tests/cli_commands/test_create.py index 6b4ea540..6471a1a9 100644 --- a/tests/cli_commands/test_create.py +++ b/tests/cli_commands/test_create.py @@ -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)