"""Main CLI entry point for Project Scaffold CLI.""" import sys from pathlib import Path import click from click_completion import init from . import __version__ from .config import Config from .gitignore import GitignoreGenerator from .prompts import ProjectPrompts from .template_engine import TemplateEngine CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) def get_installed_shells(): """Return list of supported shells for completion.""" return ["bash", "zsh", "fish", "powershell"] @click.group(context_settings=CONTEXT_SETTINGS) @click.version_option(version=__version__, prog_name="psc") @click.option( "--config", "-c", type=click.Path(exists=True), help="Path to configuration file", ) @click.pass_context def main(ctx, config): """Project Scaffold CLI - Generate standardized project scaffolding.""" ctx.ensure_object(dict) cfg = Config.load(config) if config else Config.load() ctx.obj["config"] = cfg ctx.obj["config_path"] = config @main.command() @click.argument("project_name", required=False) @click.option( "--language", "-l", type=click.Choice(["python", "nodejs", "go", "rust"]), help="Project language", ) @click.option("--author", "-a", help="Author name") @click.option("--email", "-e", help="Author email") @click.option("--description", "-d", help="Project description") @click.option( "--license", "-L", type=click.Choice(["MIT", "Apache-2.0", "GPL-3.0", "BSD-3-Clause", "None"]), help="License type", ) @click.option( "--ci", type=click.Choice(["github", "gitlab", "none"]), help="CI/CD provider", ) @click.option( "--template", "-t", help="Custom template path or name", ) @click.option( "--yes", "-y", is_flag=True, default=False, help="Skip prompts and use defaults", ) @click.option( "--force", "-f", is_flag=True, default=False, help="Force overwrite existing directory", ) @click.pass_context def create( ctx, project_name, language, author, email, description, license, ci, template, yes, force, ): """Create a new project scaffold.""" config = ctx.obj.get("config", Config()) if project_name and not _validate_project_name(project_name): click.echo( click.style( "Error: Invalid project name. Use lowercase letters, numbers, and hyphens only.", fg="red", ) ) sys.exit(1) if not yes: prompts = ProjectPrompts(config) project_name, language, author, email, description, license, ci, template = ( prompts.collect_inputs( project_name, language, author, email, description, license, ci, template ) ) if not project_name: click.echo( click.style("Error: Project name is required.", fg="red") ) sys.exit(1) if not language: language = config.default_language or "python" project_slug = _to_kebab_case(project_name) year = str(2024) author = author or config.author or "Your Name" email = email or config.email or "your.email@example.com" description = description or config.description or "A new project" license = license or config.license or "MIT" ci = ci or config.ci or "none" context = { "project_name": project_name, "project_slug": project_slug, "author": author, "email": email, "description": description, "license": license, "year": year, "language": language, } output_dir = Path(project_name).resolve() if output_dir.exists() and not force: click.echo( click.style( f"Error: Directory '{project_name}' already exists. Use --force to overwrite.", fg="red", ) ) sys.exit(1) output_dir.mkdir(parents=True, exist_ok=True) try: engine = TemplateEngine(template, config) if template: engine.render_project(context, output_dir, template) else: engine.render_language_template(language, context, output_dir) gitignore_gen = GitignoreGenerator() gitignore_path = output_dir / ".gitignore" gitignore_gen.generate(language, gitignore_path) if ci != "none": engine.render_ci_template(ci, context, output_dir) click.echo( click.style( f"Successfully created project '{project_name}' at {output_dir}", fg="green", ) ) if license == "None": license_file = output_dir / "LICENSE" license_file.unlink(missing_ok=True) except Exception as e: click.echo(click.style(f"Error creating project: {e}", fg="red")) sys.exit(1) @main.group() def template(): """Manage custom templates.""" pass @template.command(name="list") @click.pass_context def list_templates(ctx): """List all custom templates.""" config = ctx.obj.get("config", Config()) template_dirs = config.get_template_dirs() templates_found = False for template_dir in template_dirs: if Path(template_dir).exists(): for item in Path(template_dir).iterdir(): if item.is_dir() and not item.name.startswith("."): click.echo(f"- {item.name} ({item})") templates_found = True if not templates_found: click.echo("No custom templates found.") @template.command(name="save") @click.argument("name") @click.argument("path", type=click.Path(exists=True, file_okay=False)) @click.pass_context def save_template(ctx, name, path): """Save a new custom template.""" config = ctx.obj.get("config", Config()) template_dir = config.get_custom_templates_dir() target_dir = Path(template_dir) / name if target_dir.exists(): click.echo( click.style( f"Template '{name}' already exists. Use --force to overwrite.", fg="yellow", ) ) return import shutil try: shutil.copytree(path, target_dir) click.echo( click.style( f"Template '{name}' saved to {target_dir}", fg="green", ) ) except Exception as e: click.echo(click.style(f"Error saving template: {e}", fg="red")) sys.exit(1) @template.command(name="delete") @click.argument("name") @click.pass_context def delete_template(ctx, name): """Delete a custom template.""" config = ctx.obj.get("config", Config()) template_dir = config.get_custom_templates_dir() target_dir = Path(template_dir) / name if not target_dir.exists(): click.echo( click.style(f"Template '{name}' not found.", fg="yellow") ) return import shutil try: shutil.rmtree(target_dir) click.echo( click.style( f"Template '{name}' deleted.", fg="green", ) ) except Exception as e: click.echo(click.style(f"Error deleting template: {e}", fg="red")) sys.exit(1) @main.command() @click.option( "--output", "-o", type=click.Path(), default="project.yaml", help="Output file path", ) def init_config(output): """Generate a template configuration file.""" config_content = '''project: author: "Your Name" email: "your.email@example.com" license: "MIT" description: "A brief description of your project" defaults: language: "python" ci: "github" template: null template_vars: python: version: "3.8+" nodejs: version: "16+" ''' output_path = Path(output) if output_path.exists(): click.echo( click.style( f"File '{output}' already exists. Use --force to overwrite.", fg="yellow", ) ) return try: output_path.write_text(config_content) click.echo( click.style( f"Configuration file created at {output_path}", fg="green", ) ) except Exception as e: click.echo( click.style(f"Error creating configuration file: {e}", fg="red") ) sys.exit(1) @main.command() @click.argument("shells", nargs=-1, type=click.Choice(get_installed_shells())) def completions(shells): """Install shell completion for psc.""" if not shells: shells = get_installed_shells() for shell in shells: init(shell) click.echo(f"Completion for {shell} installed.") def _validate_project_name(name: str) -> bool: """Validate project name format.""" if not name: return False import re return bool(re.match(r"^[a-z][a-z0-9-]*$", name)) def _to_kebab_case(name: str) -> str: """Convert project name to kebab-case.""" import re name = name.lower().strip() name = re.sub(r"[^a-z0-9]+", "-", name) return name.strip("-") def get_project_root(): """Get the project root directory (where templates are stored).""" return Path(__file__).parent.parent / "templates" if __name__ == "__main__": main()