Files
local-llm-prompt-manager/project_scaffold_cli/cli.py
Developer 155bc36ded fix: correct pyproject.toml for project-scaffold-cli
- Fixed package name from auto-readme-cli to project-scaffold-cli
- Fixed dependencies to match project-scaffold-cli requirements
- Fixed linting import sorting issues in test files
2026-02-05 11:49:49 +00:00

366 lines
9.1 KiB
Python

"""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()