From 00dbedc2b5e71849d4f2272a4d30b3cca24489eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Dec 2025 08:39:17 +0000 Subject: [PATCH 1/2] feat(docs): add render command for existing projects Add `agentspaces docs render` command designed for adding documentation templates to existing Python projects without clobbering existing files: - Defaults to current directory (unlike scaffold which requires target) - Excludes root files (README.md, CLAUDE.md, TODO.md) by default - Auto-detects project name from pyproject.toml or directory name - Supports --include/-i and --exclude/-e for template group filtering - Adds --dry-run option to preview changes before writing - Skips existing files by default (use --force to overwrite) Template groups: root, claude, docs, adr --- src/agentspaces/cli/docs.py | 259 +++++++++++++++++++++++++++++ tests/unit/cli/test_docs.py | 316 ++++++++++++++++++++++++++++++++++++ 2 files changed, 575 insertions(+) create mode 100644 tests/unit/cli/test_docs.py diff --git a/src/agentspaces/cli/docs.py b/src/agentspaces/cli/docs.py index 7ccfaa6..869410c 100644 --- a/src/agentspaces/cli/docs.py +++ b/src/agentspaces/cli/docs.py @@ -271,6 +271,17 @@ def create( "adr-example": "docs/adr/001-example.md", } +# Template groups for selective rendering +TEMPLATE_GROUPS: dict[str, list[str]] = { + "root": ["readme", "claude-md", "todo-md"], + "claude": ["agents-readme", "commands-readme"], + "docs": ["architecture", "development-standards", "deployment"], + "adr": ["adr-template", "adr-example"], +} + +# All group names for validation +ALL_GROUPS: list[str] = list(TEMPLATE_GROUPS.keys()) + @app.command("scaffold") def scaffold( @@ -347,3 +358,251 @@ def scaffold( rel = path.relative_to(target) console.print(f" [dim]•[/dim] {rel}") console.print("[dim]Use --force to overwrite[/dim]") + + +def _get_templates_for_groups( + include: list[str] | None, exclude: list[str] | None +) -> dict[str, str]: + """Get filtered templates based on include/exclude groups. + + Args: + include: Groups to include (None means all except root by default). + exclude: Groups to exclude. + + Returns: + Filtered mapping of template names to output paths. + """ + # Default: include all groups except root for existing projects + included_groups = {"claude", "docs", "adr"} if include is None else set(include) + + # Apply exclusions + if exclude: + included_groups -= set(exclude) + + # Build template list from selected groups + selected_templates: set[str] = set() + for group in included_groups: + if group in TEMPLATE_GROUPS: + selected_templates.update(TEMPLATE_GROUPS[group]) + + # Filter SCAFFOLD_STRUCTURE to selected templates + return { + name: path + for name, path in SCAFFOLD_STRUCTURE.items() + if name in selected_templates + } + + +@app.command("render") +def render( + target: Annotated[ + Path | None, + typer.Argument(help="Target directory (default: current directory)"), + ] = None, + project_name: Annotated[ + str | None, + typer.Option( + "--project-name", "-n", help="Project name (auto-detected if not provided)" + ), + ] = None, + project_description: Annotated[ + str | None, + typer.Option("--project-description", "-d", help="Project description"), + ] = None, + include: Annotated[ + list[str] | None, + typer.Option( + "--include", + "-i", + help="Template groups to include (root, claude, docs, adr)", + ), + ] = None, + exclude: Annotated[ + list[str] | None, + typer.Option( + "--exclude", + "-e", + help="Template groups to exclude", + ), + ] = None, + force: Annotated[ + bool, + typer.Option("--force", "-f", help="Overwrite existing files"), + ] = False, + dry_run: Annotated[ + bool, + typer.Option("--dry-run", help="Show what would be created without writing"), + ] = False, +) -> None: + """Render documentation templates into an existing project. + + Unlike 'scaffold', this command is designed for existing projects: + - Defaults to current directory + - Excludes root files (README.md, CLAUDE.md, TODO.md) by default + - Auto-detects project name from directory or pyproject.toml + - Skips existing files (use --force to overwrite) + + Template groups: + - root: README.md, CLAUDE.md, TODO.md + - claude: .claude/agents/, .claude/commands/ + - docs: docs/design/ (architecture, development-standards), docs/planning/ + - adr: docs/adr/ (ADR templates) + + \b + Examples: + agentspaces docs render # Render docs to current directory + agentspaces docs render -i root -i docs # Include root and docs only + agentspaces docs render -e adr # Exclude ADR templates + agentspaces docs render --dry-run # Preview without writing + agentspaces docs render ./my-project -n "MyApp" + """ + # Resolve target directory + target_dir = (target or Path.cwd()).resolve() + + if not target_dir.exists(): + error_console.print(f"[red]✗[/red] Directory does not exist: {target_dir}") + raise typer.Exit(1) + + if not target_dir.is_dir(): + error_console.print(f"[red]✗[/red] Not a directory: {target_dir}") + raise typer.Exit(1) + + # Validate group names + all_groups = set(ALL_GROUPS) + if include: + invalid = set(include) - all_groups + if invalid: + error_console.print( + f"[red]✗[/red] Invalid groups: {', '.join(invalid)}. " + f"Available: {', '.join(ALL_GROUPS)}" + ) + raise typer.Exit(1) + + if exclude: + invalid = set(exclude) - all_groups + if invalid: + error_console.print( + f"[red]✗[/red] Invalid groups: {', '.join(invalid)}. " + f"Available: {', '.join(ALL_GROUPS)}" + ) + raise typer.Exit(1) + + # Auto-detect project name if not provided + detected_name = project_name + if not detected_name: + detected_name = _detect_project_name(target_dir) + + if not detected_name: + error_console.print( + "[red]✗[/red] Could not detect project name. Please provide --project-name" + ) + raise typer.Exit(1) + + # Use placeholder for description if not provided + description = project_description or f"{detected_name} project" + + # Get filtered templates + templates = _get_templates_for_groups(include, exclude) + + if not templates: + console.print("[yellow]![/yellow] No templates selected after filtering") + raise typer.Exit(0) + + # Build context + context: dict[str, Any] = { + "project_name": detected_name, + "project_description": description, + # Defaults for ADR template + "adr_number": "000", + "adr_title": "ADR Template", + } + + # Track results + created: list[Path] = [] + skipped: list[Path] = [] + would_create: list[Path] = [] + + if dry_run: + console.print(f"[cyan]Dry run[/cyan] - previewing changes to {target_dir}\n") + + for template_name, relative_path in templates.items(): + output_path = target_dir / relative_path + + if output_path.exists() and not force: + skipped.append(output_path) + continue + + if dry_run: + would_create.append(output_path) + continue + + # Create parent directories + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + render_design_template(template_name, context, output_path) + created.append(output_path) + except DesignError as e: + error_console.print(f"[red]✗[/red] {template_name}: {e}") + + # Summary + console.print() + + if dry_run: + if would_create: + console.print(f"[cyan]Would create[/cyan] {len(would_create)} files:") + for path in would_create: + rel = path.relative_to(target_dir) + console.print(f" [dim]+[/dim] {rel}") + if skipped: + console.print( + f"\n[yellow]Would skip[/yellow] {len(skipped)} existing files:" + ) + for path in skipped: + rel = path.relative_to(target_dir) + console.print(f" [dim]•[/dim] {rel}") + console.print("[dim]Use --force to overwrite[/dim]") + return + + if created: + console.print(f"[green]✓[/green] Created {len(created)} files in {target_dir}") + for path in created: + rel = path.relative_to(target_dir) + console.print(f" [dim]+[/dim] {rel}") + + if skipped: + console.print(f"\n[yellow]![/yellow] Skipped {len(skipped)} existing files") + for path in skipped: + rel = path.relative_to(target_dir) + console.print(f" [dim]•[/dim] {rel}") + console.print("[dim]Use --force to overwrite[/dim]") + + if not created and not skipped: + console.print("[green]✓[/green] No changes needed") + + +def _detect_project_name(target_dir: Path) -> str | None: + """Detect project name from pyproject.toml or directory name. + + Args: + target_dir: Directory to check. + + Returns: + Detected project name or None. + """ + # Try pyproject.toml first + pyproject = target_dir / "pyproject.toml" + if pyproject.exists(): + try: + import tomllib + + content = pyproject.read_text(encoding="utf-8") + data = tomllib.loads(content) + name = data.get("project", {}).get("name") + if name: + return str(name) + except Exception: + pass + + # Fall back to directory name + return target_dir.name diff --git a/tests/unit/cli/test_docs.py b/tests/unit/cli/test_docs.py new file mode 100644 index 0000000..2ac5540 --- /dev/null +++ b/tests/unit/cli/test_docs.py @@ -0,0 +1,316 @@ +"""Tests for docs CLI commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from typer.testing import CliRunner + +from agentspaces.cli.docs import ( + ALL_GROUPS, + SCAFFOLD_STRUCTURE, + TEMPLATE_GROUPS, + _detect_project_name, + _get_templates_for_groups, + app, +) + +if TYPE_CHECKING: + from pathlib import Path + +runner = CliRunner() + + +class TestGetTemplatesForGroups: + """Tests for _get_templates_for_groups helper function.""" + + def test_default_excludes_root_templates(self) -> None: + """Default behavior should exclude root templates.""" + templates = _get_templates_for_groups(include=None, exclude=None) + root_templates = set(TEMPLATE_GROUPS["root"]) + assert not any(t in templates for t in root_templates) + + def test_default_includes_claude_docs_adr(self) -> None: + """Default behavior should include claude, docs, and adr groups.""" + templates = _get_templates_for_groups(include=None, exclude=None) + for group in ["claude", "docs", "adr"]: + for template_name in TEMPLATE_GROUPS[group]: + assert template_name in templates + + def test_include_only_specified_groups(self) -> None: + """Should only include specified groups when include is set.""" + templates = _get_templates_for_groups(include=["root"], exclude=None) + root_templates = set(TEMPLATE_GROUPS["root"]) + assert all(t in templates for t in root_templates) + # Should not include other groups + for other_group in ["claude", "docs", "adr"]: + for template_name in TEMPLATE_GROUPS[other_group]: + assert template_name not in templates + + def test_exclude_removes_from_defaults(self) -> None: + """Exclude should remove groups from default set.""" + templates = _get_templates_for_groups(include=None, exclude=["adr"]) + for template_name in TEMPLATE_GROUPS["adr"]: + assert template_name not in templates + # Other groups should still be included + for group in ["claude", "docs"]: + for template_name in TEMPLATE_GROUPS[group]: + assert template_name in templates + + def test_include_and_exclude_together(self) -> None: + """Include and exclude should work together.""" + templates = _get_templates_for_groups( + include=["root", "docs"], exclude=["docs"] + ) + # Only root should remain + for template_name in TEMPLATE_GROUPS["root"]: + assert template_name in templates + for template_name in TEMPLATE_GROUPS["docs"]: + assert template_name not in templates + + def test_empty_result_when_all_excluded(self) -> None: + """Should return empty dict when all groups excluded.""" + templates = _get_templates_for_groups( + include=None, exclude=["claude", "docs", "adr"] + ) + assert templates == {} + + +class TestDetectProjectName: + """Tests for _detect_project_name function.""" + + def test_returns_directory_name_as_fallback(self, temp_dir: Path) -> None: + """Should return directory name when no pyproject.toml exists.""" + project_dir = temp_dir / "my-cool-project" + project_dir.mkdir() + assert _detect_project_name(project_dir) == "my-cool-project" + + def test_reads_name_from_pyproject_toml(self, temp_dir: Path) -> None: + """Should read project name from pyproject.toml.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + pyproject = project_dir / "pyproject.toml" + pyproject.write_text('[project]\nname = "awesome-lib"\n', encoding="utf-8") + assert _detect_project_name(project_dir) == "awesome-lib" + + def test_fallback_when_pyproject_has_no_name(self, temp_dir: Path) -> None: + """Should fall back to directory name if pyproject has no name.""" + project_dir = temp_dir / "fallback-project" + project_dir.mkdir() + pyproject = project_dir / "pyproject.toml" + pyproject.write_text("[tool.ruff]\nline-length = 88\n", encoding="utf-8") + assert _detect_project_name(project_dir) == "fallback-project" + + +class TestRenderCommand: + """Tests for the render command.""" + + def test_renders_default_templates_to_directory(self, temp_dir: Path) -> None: + """Should render default templates (claude, docs, adr) to directory.""" + result = runner.invoke( + app, + [ + "render", + str(temp_dir), + "-n", + "TestProject", + "-d", + "A test project", + ], + ) + assert result.exit_code == 0 + # Check that docs files were created + assert (temp_dir / "docs/design/architecture.md").exists() + assert (temp_dir / "docs/design/development-standards.md").exists() + assert (temp_dir / ".claude/agents/README.md").exists() + assert (temp_dir / "docs/adr/000-template.md").exists() + # Root files should NOT be created by default + assert not (temp_dir / "README.md").exists() + assert not (temp_dir / "CLAUDE.md").exists() + + def test_skips_existing_files(self, temp_dir: Path) -> None: + """Should skip existing files without --force.""" + # Create an existing file + docs_dir = temp_dir / "docs" / "design" + docs_dir.mkdir(parents=True) + existing = docs_dir / "architecture.md" + existing.write_text("# Existing content\n", encoding="utf-8") + + result = runner.invoke( + app, + ["render", str(temp_dir), "-n", "TestProject", "-d", "Test"], + ) + assert result.exit_code == 0 + assert "Skipped" in result.output + # File should not be overwritten + assert existing.read_text() == "# Existing content\n" + + def test_force_overwrites_existing_files(self, temp_dir: Path) -> None: + """Should overwrite existing files with --force.""" + docs_dir = temp_dir / "docs" / "design" + docs_dir.mkdir(parents=True) + existing = docs_dir / "architecture.md" + existing.write_text("# Existing content\n", encoding="utf-8") + + result = runner.invoke( + app, + ["render", str(temp_dir), "-n", "TestProject", "-d", "Test", "-f"], + ) + assert result.exit_code == 0 + # File should be overwritten + content = existing.read_text() + assert "Existing content" not in content + assert "TestProject" in content + + def test_dry_run_shows_preview_without_writing(self, temp_dir: Path) -> None: + """Should show preview without writing files in dry-run mode.""" + result = runner.invoke( + app, + [ + "render", + str(temp_dir), + "-n", + "TestProject", + "-d", + "Test", + "--dry-run", + ], + ) + assert result.exit_code == 0 + assert "Dry run" in result.output + assert "Would create" in result.output + # No files should be created + assert not (temp_dir / "docs/design/architecture.md").exists() + + def test_include_specific_groups(self, temp_dir: Path) -> None: + """Should only render specified groups with --include.""" + result = runner.invoke( + app, + [ + "render", + str(temp_dir), + "-n", + "TestProject", + "-d", + "Test", + "-i", + "root", + ], + ) + assert result.exit_code == 0 + # Root files should be created + assert (temp_dir / "README.md").exists() + assert (temp_dir / "CLAUDE.md").exists() + # Other groups should NOT be created + assert not (temp_dir / "docs/design/architecture.md").exists() + assert not (temp_dir / ".claude/agents/README.md").exists() + + def test_exclude_specific_groups(self, temp_dir: Path) -> None: + """Should exclude specified groups with --exclude.""" + result = runner.invoke( + app, + [ + "render", + str(temp_dir), + "-n", + "TestProject", + "-d", + "Test", + "-e", + "adr", + ], + ) + assert result.exit_code == 0 + # ADR files should NOT be created + assert not (temp_dir / "docs/adr/000-template.md").exists() + # Other groups should be created + assert (temp_dir / "docs/design/architecture.md").exists() + assert (temp_dir / ".claude/agents/README.md").exists() + + def test_auto_detects_project_name(self, temp_dir: Path) -> None: + """Should auto-detect project name from pyproject.toml.""" + pyproject = temp_dir / "pyproject.toml" + pyproject.write_text('[project]\nname = "auto-detected"\n', encoding="utf-8") + + result = runner.invoke( + app, + ["render", str(temp_dir), "-d", "Test project", "-i", "docs"], + ) + assert result.exit_code == 0 + content = (temp_dir / "docs/design/architecture.md").read_text() + assert "auto-detected" in content + + def test_fails_for_nonexistent_directory(self, temp_dir: Path) -> None: + """Should fail for non-existent directory.""" + result = runner.invoke( + app, + [ + "render", + str(temp_dir / "nonexistent"), + "-n", + "Test", + "-d", + "Test", + ], + ) + assert result.exit_code == 1 + assert "does not exist" in result.output + + def test_fails_for_invalid_group_name(self, temp_dir: Path) -> None: + """Should fail for invalid group names.""" + result = runner.invoke( + app, + [ + "render", + str(temp_dir), + "-n", + "Test", + "-d", + "Test", + "-i", + "invalid_group", + ], + ) + assert result.exit_code == 1 + assert "Invalid groups" in result.output + + +class TestTemplateGroups: + """Tests for template group constants.""" + + def test_all_groups_matches_template_groups_keys(self) -> None: + """ALL_GROUPS should contain all TEMPLATE_GROUPS keys.""" + assert set(ALL_GROUPS) == set(TEMPLATE_GROUPS.keys()) + + def test_all_template_names_in_scaffold_structure(self) -> None: + """All template names in groups should exist in SCAFFOLD_STRUCTURE.""" + for group_name, templates in TEMPLATE_GROUPS.items(): + for template_name in templates: + assert template_name in SCAFFOLD_STRUCTURE, ( + f"{template_name} from {group_name} not in SCAFFOLD_STRUCTURE" + ) + + +class TestScaffoldCommand: + """Tests for the scaffold command (existing functionality).""" + + def test_creates_all_templates(self, temp_dir: Path) -> None: + """Should create all templates in scaffold structure.""" + target = temp_dir / "new-project" + target.mkdir() + + result = runner.invoke( + app, + [ + "scaffold", + str(target), + "-n", + "NewProject", + "-d", + "A new project", + ], + ) + assert result.exit_code == 0 + # Check all files from SCAFFOLD_STRUCTURE were created + for relative_path in SCAFFOLD_STRUCTURE.values(): + assert (target / relative_path).exists(), f"Missing: {relative_path}" From dab75c40a0d249ecb8db400694d36e4faaec1d59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Dec 2025 08:56:06 +0000 Subject: [PATCH 2/2] fix(docs): include root templates by default in render command Root files (README.md, CLAUDE.md, TODO.md) are now included by default when running `docs render`. Existing files are still skipped unless --force is used, so this won't clobber existing content. --- src/agentspaces/cli/docs.py | 5 ++--- tests/unit/cli/test_docs.py | 29 ++++++++++++----------------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/agentspaces/cli/docs.py b/src/agentspaces/cli/docs.py index 869410c..24d2803 100644 --- a/src/agentspaces/cli/docs.py +++ b/src/agentspaces/cli/docs.py @@ -372,8 +372,8 @@ def _get_templates_for_groups( Returns: Filtered mapping of template names to output paths. """ - # Default: include all groups except root for existing projects - included_groups = {"claude", "docs", "adr"} if include is None else set(include) + # Default: include all groups + included_groups = set(ALL_GROUPS) if include is None else set(include) # Apply exclusions if exclude: @@ -438,7 +438,6 @@ def render( Unlike 'scaffold', this command is designed for existing projects: - Defaults to current directory - - Excludes root files (README.md, CLAUDE.md, TODO.md) by default - Auto-detects project name from directory or pyproject.toml - Skips existing files (use --force to overwrite) diff --git a/tests/unit/cli/test_docs.py b/tests/unit/cli/test_docs.py index 2ac5540..10ff706 100644 --- a/tests/unit/cli/test_docs.py +++ b/tests/unit/cli/test_docs.py @@ -24,16 +24,10 @@ class TestGetTemplatesForGroups: """Tests for _get_templates_for_groups helper function.""" - def test_default_excludes_root_templates(self) -> None: - """Default behavior should exclude root templates.""" + def test_default_includes_all_templates(self) -> None: + """Default behavior should include all template groups.""" templates = _get_templates_for_groups(include=None, exclude=None) - root_templates = set(TEMPLATE_GROUPS["root"]) - assert not any(t in templates for t in root_templates) - - def test_default_includes_claude_docs_adr(self) -> None: - """Default behavior should include claude, docs, and adr groups.""" - templates = _get_templates_for_groups(include=None, exclude=None) - for group in ["claude", "docs", "adr"]: + for group in ALL_GROUPS: for template_name in TEMPLATE_GROUPS[group]: assert template_name in templates @@ -53,7 +47,7 @@ def test_exclude_removes_from_defaults(self) -> None: for template_name in TEMPLATE_GROUPS["adr"]: assert template_name not in templates # Other groups should still be included - for group in ["claude", "docs"]: + for group in ["root", "claude", "docs"]: for template_name in TEMPLATE_GROUPS[group]: assert template_name in templates @@ -71,7 +65,7 @@ def test_include_and_exclude_together(self) -> None: def test_empty_result_when_all_excluded(self) -> None: """Should return empty dict when all groups excluded.""" templates = _get_templates_for_groups( - include=None, exclude=["claude", "docs", "adr"] + include=None, exclude=["root", "claude", "docs", "adr"] ) assert templates == {} @@ -105,8 +99,8 @@ def test_fallback_when_pyproject_has_no_name(self, temp_dir: Path) -> None: class TestRenderCommand: """Tests for the render command.""" - def test_renders_default_templates_to_directory(self, temp_dir: Path) -> None: - """Should render default templates (claude, docs, adr) to directory.""" + def test_renders_all_templates_to_directory(self, temp_dir: Path) -> None: + """Should render all templates including root files to directory.""" result = runner.invoke( app, [ @@ -119,14 +113,15 @@ def test_renders_default_templates_to_directory(self, temp_dir: Path) -> None: ], ) assert result.exit_code == 0 - # Check that docs files were created + # Check that all files were created assert (temp_dir / "docs/design/architecture.md").exists() assert (temp_dir / "docs/design/development-standards.md").exists() assert (temp_dir / ".claude/agents/README.md").exists() assert (temp_dir / "docs/adr/000-template.md").exists() - # Root files should NOT be created by default - assert not (temp_dir / "README.md").exists() - assert not (temp_dir / "CLAUDE.md").exists() + # Root files should also be created + assert (temp_dir / "README.md").exists() + assert (temp_dir / "CLAUDE.md").exists() + assert (temp_dir / "TODO.md").exists() def test_skips_existing_files(self, temp_dir: Path) -> None: """Should skip existing files without --force."""