From cf83d8f8609aa8cd10e985549f47b19a485af8c0 Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Thu, 5 Feb 2026 11:01:16 +0000 Subject: [PATCH] Initial upload: Project Scaffold CLI with multi-language templates and CI/CD --- project_scaffold_cli/cli.py | 366 ++++++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 project_scaffold_cli/cli.py diff --git a/project_scaffold_cli/cli.py b/project_scaffold_cli/cli.py new file mode 100644 index 0000000..4f879fd --- /dev/null +++ b/project_scaffold_cli/cli.py @@ -0,0 +1,366 @@ +"""Main CLI entry point for Project Scaffold CLI.""" + +import os +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()