toolbelt-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolbelt/__init__.py +4 -0
- toolbelt/cli.py +46 -0
- toolbelt/commands/__init__.py +1 -0
- toolbelt/commands/audit.py +63 -0
- toolbelt/commands/commit.py +84 -0
- toolbelt/commands/new.py +116 -0
- toolbelt/commands/version.py +47 -0
- toolbelt/services/__init__.py +1 -0
- toolbelt/services/auditor.py +295 -0
- toolbelt/services/llm.py +105 -0
- toolbelt/services/scaffolder.py +91 -0
- toolbelt/templates/java/.gitignore.j2 +7 -0
- toolbelt/templates/java/LICENSE.j2 +39 -0
- toolbelt/templates/java/README.md.j2 +26 -0
- toolbelt/templates/java/pom.xml.j2 +53 -0
- toolbelt/templates/java/src/main/java/com/{{project_name}}/App.java.j2 +7 -0
- toolbelt/templates/java/src/test/java/com/{{project_name}}/AppTest.java.j2 +13 -0
- toolbelt/templates/node/.eslintrc.json.j2 +10 -0
- toolbelt/templates/node/.gitignore.j2 +7 -0
- toolbelt/templates/node/.prettierrc.j2 +5 -0
- toolbelt/templates/node/LICENSE.j2 +39 -0
- toolbelt/templates/node/README.md.j2 +26 -0
- toolbelt/templates/node/package.json.j2 +20 -0
- toolbelt/templates/node/src/index.test.ts.j2 +6 -0
- toolbelt/templates/node/src/index.ts.j2 +7 -0
- toolbelt/templates/node/tsconfig.json.j2 +14 -0
- toolbelt/templates/python/.github/workflows/ci.yml.j2 +28 -0
- toolbelt/templates/python/.gitignore.j2 +42 -0
- toolbelt/templates/python/.pre-commit-config.yaml.j2 +13 -0
- toolbelt/templates/python/Dockerfile.j2 +10 -0
- toolbelt/templates/python/LICENSE.j2 +39 -0
- toolbelt/templates/python/Makefile.j2 +17 -0
- toolbelt/templates/python/README.md.j2 +19 -0
- toolbelt/templates/python/pyproject.toml.j2 +28 -0
- toolbelt/templates/python/src/{{project_name}}/__init__.py.j2 +1 -0
- toolbelt/templates/python/src/{{project_name}}/main.py.j2 +7 -0
- toolbelt/templates/python/tests/test_main.py.j2 +8 -0
- toolbelt/utils/__init__.py +1 -0
- toolbelt/utils/console.py +43 -0
- toolbelt/utils/git.py +54 -0
- toolbelt_cli-0.2.0.dist-info/METADATA +382 -0
- toolbelt_cli-0.2.0.dist-info/RECORD +46 -0
- toolbelt_cli-0.2.0.dist-info/WHEEL +5 -0
- toolbelt_cli-0.2.0.dist-info/entry_points.txt +3 -0
- toolbelt_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- toolbelt_cli-0.2.0.dist-info/top_level.txt +1 -0
toolbelt/__init__.py
ADDED
toolbelt/cli.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Entry point for the Toolbelt CLI using Typer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .commands import audit, commit, new, version
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="toolbelt",
|
|
11
|
+
help=(
|
|
12
|
+
"Toolbelt – developer productivity CLI: scaffolding, AI commits, and audits."
|
|
13
|
+
),
|
|
14
|
+
rich_markup_mode="rich",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Explicitly register subcommands with designated names
|
|
19
|
+
app.command(
|
|
20
|
+
name="new",
|
|
21
|
+
help="Scaffold a new project (python, node, java) with opinionated defaults.",
|
|
22
|
+
)(new.main)
|
|
23
|
+
|
|
24
|
+
app.command(
|
|
25
|
+
name="commit",
|
|
26
|
+
help="Generate Conventional Commit messages from diff using Claude API.",
|
|
27
|
+
)(commit.main)
|
|
28
|
+
|
|
29
|
+
app.command(
|
|
30
|
+
name="audit",
|
|
31
|
+
help="Audit repo for README sections, .gitignore, licenses, and CI.",
|
|
32
|
+
)(audit.main)
|
|
33
|
+
|
|
34
|
+
app.command(
|
|
35
|
+
name="version",
|
|
36
|
+
help="Print installed version and check PyPI for updates.",
|
|
37
|
+
)(version.main)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main() -> None:
|
|
41
|
+
"""Run the Toolbelt CLI application."""
|
|
42
|
+
app()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Toolbelt command modules."""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Implementation of `toolbelt audit [path]` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich import box
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from ..services.auditor import Auditor
|
|
13
|
+
from ..utils.console import get_console
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(
|
|
17
|
+
path: str = typer.Argument(".", help="Path to repository to audit."),
|
|
18
|
+
json_output: bool = typer.Option(
|
|
19
|
+
False, "--json", help="Output machine-readable JSON."
|
|
20
|
+
),
|
|
21
|
+
fix: bool = typer.Option(
|
|
22
|
+
False, "--fix", help="Auto-create missing .gitignore and LICENSE."
|
|
23
|
+
),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Audit repo for README sections, .gitignore, licenses, and CI."""
|
|
26
|
+
console = get_console()
|
|
27
|
+
repo_path = Path(path).resolve()
|
|
28
|
+
|
|
29
|
+
if not repo_path.exists() or not repo_path.is_dir():
|
|
30
|
+
console.print(f"[bold red]Error:[/bold red] Invalid directory: {path}")
|
|
31
|
+
raise typer.Exit(code=1)
|
|
32
|
+
|
|
33
|
+
auditor = Auditor(repo_path, console=console)
|
|
34
|
+
results = auditor.run(fix=fix)
|
|
35
|
+
|
|
36
|
+
if json_output:
|
|
37
|
+
data = [r.to_dict() for r in results]
|
|
38
|
+
console.print(json.dumps(data, indent=2))
|
|
39
|
+
else:
|
|
40
|
+
table = Table(
|
|
41
|
+
title=f"Repository Audit: {repo_path.name}",
|
|
42
|
+
box=box.ROUNDED,
|
|
43
|
+
show_header=True,
|
|
44
|
+
header_style="bold cyan",
|
|
45
|
+
)
|
|
46
|
+
table.add_column("Check", style="bold white", min_width=25)
|
|
47
|
+
table.add_column("Status", justify="center", width=8)
|
|
48
|
+
table.add_column("Details", style="dim white")
|
|
49
|
+
|
|
50
|
+
for res in results:
|
|
51
|
+
status_symbol = (
|
|
52
|
+
"[bold green]✅[/bold green]"
|
|
53
|
+
if res.passed
|
|
54
|
+
else "[bold red]❌[/bold red]"
|
|
55
|
+
)
|
|
56
|
+
table.add_row(res.name, status_symbol, res.details)
|
|
57
|
+
|
|
58
|
+
console.print(table)
|
|
59
|
+
|
|
60
|
+
all_passed = all(r.passed for r in results)
|
|
61
|
+
if not all_passed:
|
|
62
|
+
raise typer.Exit(code=1)
|
|
63
|
+
raise typer.Exit(code=0)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Implementation of `toolbelt commit` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.prompt import Prompt
|
|
8
|
+
|
|
9
|
+
from ..services.llm import CommitMessageGenerator
|
|
10
|
+
from ..utils.console import get_console
|
|
11
|
+
from ..utils.git import git_commit, git_diff_staged
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(
|
|
15
|
+
dry_run: bool = typer.Option(
|
|
16
|
+
False, "--dry-run", help="Print proposed commit message without committing."
|
|
17
|
+
),
|
|
18
|
+
commit_type: str | None = typer.Option(
|
|
19
|
+
None, "--type", "-t", help="Force commit type (feat, fix, docs, etc.)."
|
|
20
|
+
),
|
|
21
|
+
scope: str | None = typer.Option(None, "--scope", "-s", help="Force commit scope."),
|
|
22
|
+
yes: bool = typer.Option(
|
|
23
|
+
False, "--yes", "-y", help="Skip confirmation prompt and commit immediately."
|
|
24
|
+
),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Generate Conventional Commit message from staged diff using Claude."""
|
|
27
|
+
console = get_console()
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
diff = git_diff_staged()
|
|
31
|
+
except Exception as exc:
|
|
32
|
+
console.print(f"[bold red]Git error:[/bold red] {exc}")
|
|
33
|
+
raise typer.Exit(code=1) from exc
|
|
34
|
+
|
|
35
|
+
if not diff or not diff.strip():
|
|
36
|
+
console.print("[bold red]Nothing staged. Run git add first.[/bold red]")
|
|
37
|
+
raise typer.Exit(code=1)
|
|
38
|
+
|
|
39
|
+
generator = CommitMessageGenerator()
|
|
40
|
+
message = generator.generate(diff, commit_type=commit_type, scope=scope)
|
|
41
|
+
|
|
42
|
+
if len(message.strip()) < 10:
|
|
43
|
+
console.print(
|
|
44
|
+
"[bold red]Commit message too short (< 10 chars); aborting.[/bold red]"
|
|
45
|
+
)
|
|
46
|
+
raise typer.Exit(code=1)
|
|
47
|
+
|
|
48
|
+
console.print(Panel(message, title="Proposed Commit Message", border_style="cyan"))
|
|
49
|
+
|
|
50
|
+
if dry_run:
|
|
51
|
+
console.print("[green]Dry run – commit not performed.[/green]")
|
|
52
|
+
raise typer.Exit(code=0)
|
|
53
|
+
|
|
54
|
+
if not yes:
|
|
55
|
+
choice = (
|
|
56
|
+
Prompt.ask(
|
|
57
|
+
"Apply commit? [y/n/edit]",
|
|
58
|
+
choices=["y", "n", "edit", "e"],
|
|
59
|
+
default="y",
|
|
60
|
+
)
|
|
61
|
+
.strip()
|
|
62
|
+
.lower()
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if choice in {"n", "no"}:
|
|
66
|
+
console.print("[yellow]Commit aborted by user.[/yellow]")
|
|
67
|
+
raise typer.Exit(code=1)
|
|
68
|
+
|
|
69
|
+
if choice in {"edit", "e"}:
|
|
70
|
+
edited = Prompt.ask("Edit message", default=message).strip()
|
|
71
|
+
if len(edited) < 10:
|
|
72
|
+
console.print(
|
|
73
|
+
"[bold red]Commit message too short (< 10 chars).[/bold red]"
|
|
74
|
+
)
|
|
75
|
+
raise typer.Exit(code=1)
|
|
76
|
+
message = edited
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
git_commit(message)
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
console.print(f"[bold red]Commit failed:[/bold red] {exc}")
|
|
82
|
+
raise typer.Exit(code=1) from exc
|
|
83
|
+
|
|
84
|
+
console.print("[bold green]✅ Commit created successfully![/bold green]")
|
toolbelt/commands/new.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Implementation of `toolbelt new <type> <name>` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich import box
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.prompt import Confirm, Prompt
|
|
11
|
+
|
|
12
|
+
from ..services.scaffolder import Scaffolder
|
|
13
|
+
from ..utils.console import get_console
|
|
14
|
+
from ..utils.git import get_git_user_name
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(
|
|
18
|
+
project_type: str = typer.Argument(..., help="Project type: python, node, java"),
|
|
19
|
+
name: str = typer.Argument(..., help="Project directory / package name"),
|
|
20
|
+
author: str | None = typer.Option(None, "--author", "-a", help="Author name"),
|
|
21
|
+
license_choice: str | None = typer.Option(
|
|
22
|
+
None, "--license", "-l", help="License: MIT, Apache-2.0, none"
|
|
23
|
+
),
|
|
24
|
+
ci: bool | None = typer.Option(
|
|
25
|
+
None, "--ci/--no-ci", help="Include CI configuration"
|
|
26
|
+
),
|
|
27
|
+
docker: bool | None = typer.Option(
|
|
28
|
+
None, "--docker/--no-docker", help="Include Dockerfile"
|
|
29
|
+
),
|
|
30
|
+
yes: bool = typer.Option(
|
|
31
|
+
False, "--yes", "-y", help="Accept all defaults without prompting"
|
|
32
|
+
),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Scaffold a new Python, Node, or Java project with opinionated structure."""
|
|
35
|
+
console = get_console()
|
|
36
|
+
normalized_type = project_type.strip().lower()
|
|
37
|
+
|
|
38
|
+
if normalized_type not in {"python", "node", "java"}:
|
|
39
|
+
console.print(
|
|
40
|
+
f"[bold red]Error:[/bold red] Unsupported project type '{project_type}'. "
|
|
41
|
+
"Choose from: python, node, java."
|
|
42
|
+
)
|
|
43
|
+
raise typer.Exit(code=1)
|
|
44
|
+
|
|
45
|
+
target_dir = Path(name).resolve()
|
|
46
|
+
if target_dir.exists() and any(target_dir.iterdir()):
|
|
47
|
+
console.print(
|
|
48
|
+
f"[bold red]Error:[/bold red] Target directory '{name}' "
|
|
49
|
+
"already exists and is not empty."
|
|
50
|
+
)
|
|
51
|
+
raise typer.Exit(code=1)
|
|
52
|
+
|
|
53
|
+
# Interactive prompts (or flag values / defaults)
|
|
54
|
+
default_author = get_git_user_name() or "Developer"
|
|
55
|
+
if author is None:
|
|
56
|
+
if yes:
|
|
57
|
+
author = default_author
|
|
58
|
+
else:
|
|
59
|
+
author = Prompt.ask("Author name", default=default_author)
|
|
60
|
+
|
|
61
|
+
if license_choice is None:
|
|
62
|
+
if yes:
|
|
63
|
+
license_choice = "MIT"
|
|
64
|
+
else:
|
|
65
|
+
license_choice = Prompt.ask(
|
|
66
|
+
"License",
|
|
67
|
+
choices=["MIT", "Apache-2.0", "none"],
|
|
68
|
+
default="MIT",
|
|
69
|
+
)
|
|
70
|
+
elif license_choice not in {"MIT", "Apache-2.0", "none"}:
|
|
71
|
+
console.print(
|
|
72
|
+
f"[bold red]Error:[/bold red] Invalid license '{license_choice}'. "
|
|
73
|
+
"Choose MIT, Apache-2.0, or none."
|
|
74
|
+
)
|
|
75
|
+
raise typer.Exit(code=1)
|
|
76
|
+
|
|
77
|
+
if ci is None:
|
|
78
|
+
if yes:
|
|
79
|
+
ci = True
|
|
80
|
+
else:
|
|
81
|
+
ci = Confirm.ask("Include CI? (y/n)", default=True)
|
|
82
|
+
|
|
83
|
+
if docker is None:
|
|
84
|
+
if yes:
|
|
85
|
+
docker = False
|
|
86
|
+
else:
|
|
87
|
+
docker = Confirm.ask("Include Docker? (y/n)", default=False)
|
|
88
|
+
|
|
89
|
+
scaffolder = Scaffolder(
|
|
90
|
+
project_type=normalized_type,
|
|
91
|
+
project_name=target_dir.name,
|
|
92
|
+
author=author,
|
|
93
|
+
license=license_choice,
|
|
94
|
+
include_ci=ci,
|
|
95
|
+
include_docker=docker,
|
|
96
|
+
destination=target_dir,
|
|
97
|
+
)
|
|
98
|
+
scaffolder.run()
|
|
99
|
+
|
|
100
|
+
if normalized_type == "python":
|
|
101
|
+
commands = f"cd {target_dir.name} && make install && make test"
|
|
102
|
+
elif normalized_type == "node":
|
|
103
|
+
commands = f"cd {target_dir.name} && npm install && npm test"
|
|
104
|
+
else:
|
|
105
|
+
commands = f"cd {target_dir.name} && mvn clean test"
|
|
106
|
+
|
|
107
|
+
console.print(
|
|
108
|
+
Panel(
|
|
109
|
+
f"[bold green]✨ Project successfully scaffolded![/bold green]\n\n"
|
|
110
|
+
f"[dim]Next steps:[/dim]\n"
|
|
111
|
+
f"[bold cyan]{commands}[/bold cyan]",
|
|
112
|
+
title="🎉 Toolbelt Scaffold",
|
|
113
|
+
border_style="green",
|
|
114
|
+
box=box.ROUNDED,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Implementation of `toolbelt version` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
from ..utils.console import get_console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
"""Print the current version and check PyPI for newer releases."""
|
|
15
|
+
console = get_console()
|
|
16
|
+
try:
|
|
17
|
+
current_version = importlib.metadata.version("toolbelt-cli")
|
|
18
|
+
except Exception:
|
|
19
|
+
current_version = "0.2.0"
|
|
20
|
+
|
|
21
|
+
console.print(
|
|
22
|
+
Panel(
|
|
23
|
+
f"[bold]toolbelt-cli version:[/bold] {current_version}",
|
|
24
|
+
style="green",
|
|
25
|
+
border_style="green",
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
pypi_url = "https://pypi.org/pypi/toolbelt-cli/json"
|
|
30
|
+
try:
|
|
31
|
+
resp = httpx.get(pypi_url, timeout=5.0)
|
|
32
|
+
resp.raise_for_status()
|
|
33
|
+
data = resp.json()
|
|
34
|
+
latest = data.get("info", {}).get("version")
|
|
35
|
+
if latest and latest != current_version:
|
|
36
|
+
console.print(
|
|
37
|
+
Panel(
|
|
38
|
+
f"[yellow]A newer version {latest} is available![/yellow]\n"
|
|
39
|
+
f"Run: [bold cyan]pip install -U toolbelt-cli[/bold cyan]",
|
|
40
|
+
style="yellow",
|
|
41
|
+
border_style="yellow",
|
|
42
|
+
title="Update Available",
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
except Exception:
|
|
46
|
+
# Ignore network errors or unavailable PyPI endpoint
|
|
47
|
+
pass
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Service layer for Toolbelt CLI."""
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Repository auditing service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.prompt import Confirm, Prompt
|
|
11
|
+
|
|
12
|
+
MIT_TEMPLATE = """MIT License
|
|
13
|
+
|
|
14
|
+
Copyright (c) {year} {author}
|
|
15
|
+
|
|
16
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
17
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
18
|
+
in the Software without restriction, including without limitation the rights
|
|
19
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
20
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
21
|
+
furnished to do so, subject to the following conditions:
|
|
22
|
+
|
|
23
|
+
The above copyright notice and this permission notice shall be included in all
|
|
24
|
+
copies or substantial portions of the Software.
|
|
25
|
+
|
|
26
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
27
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
28
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
29
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
30
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
31
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
32
|
+
SOFTWARE.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
APACHE_TEMPLATE = """ Apache License
|
|
36
|
+
Version 2.0, January 2004
|
|
37
|
+
http://www.apache.org/licenses/
|
|
38
|
+
|
|
39
|
+
Copyright (c) {year} {author}
|
|
40
|
+
|
|
41
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
42
|
+
you may not use this file except in compliance with the License.
|
|
43
|
+
You may obtain a copy of the License at
|
|
44
|
+
|
|
45
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
46
|
+
|
|
47
|
+
Unless required by applicable law or agreed to in writing, software
|
|
48
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
49
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
50
|
+
See the License for the specific language governing permissions and
|
|
51
|
+
limitations under the License.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
DEFAULT_GITIGNORE = """# Python
|
|
55
|
+
__pycache__/
|
|
56
|
+
*.py[cod]
|
|
57
|
+
*$py.class
|
|
58
|
+
*.so
|
|
59
|
+
build/
|
|
60
|
+
develop-eggs/
|
|
61
|
+
dist/
|
|
62
|
+
downloads/
|
|
63
|
+
eggs/
|
|
64
|
+
.eggs/
|
|
65
|
+
lib/
|
|
66
|
+
lib64/
|
|
67
|
+
parts/
|
|
68
|
+
sdist/
|
|
69
|
+
var/
|
|
70
|
+
wheels/
|
|
71
|
+
*.egg-info/
|
|
72
|
+
.installed.cfg
|
|
73
|
+
*.egg
|
|
74
|
+
.env
|
|
75
|
+
.venv
|
|
76
|
+
env/
|
|
77
|
+
venv/
|
|
78
|
+
ENV/
|
|
79
|
+
|
|
80
|
+
# Node
|
|
81
|
+
node_modules/
|
|
82
|
+
npm-debug.log*
|
|
83
|
+
yarn-debug.log*
|
|
84
|
+
yarn-error.log*
|
|
85
|
+
|
|
86
|
+
# Java
|
|
87
|
+
target/
|
|
88
|
+
*.class
|
|
89
|
+
|
|
90
|
+
# IDE
|
|
91
|
+
.idea/
|
|
92
|
+
.vscode/
|
|
93
|
+
*.swp
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AuditResult:
|
|
98
|
+
"""Individual audit check result."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, name: str, passed: bool, details: str = "") -> None:
|
|
101
|
+
self.name = name
|
|
102
|
+
self.passed = passed
|
|
103
|
+
self.details = details
|
|
104
|
+
|
|
105
|
+
def to_dict(self) -> dict[str, Any]:
|
|
106
|
+
return {
|
|
107
|
+
"name": self.name,
|
|
108
|
+
"passed": self.passed,
|
|
109
|
+
"status": "✅" if self.passed else "❌",
|
|
110
|
+
"details": self.details,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Auditor:
|
|
115
|
+
"""Inspects a repository path for standards, files, and conventions."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, repo_path: Path, console: Console | None = None) -> None:
|
|
118
|
+
self.repo_path = repo_path.resolve()
|
|
119
|
+
self.console = console or Console()
|
|
120
|
+
|
|
121
|
+
def _file_content(self, relative_path: str) -> str | None:
|
|
122
|
+
target = self.repo_path / relative_path
|
|
123
|
+
if target.is_file():
|
|
124
|
+
try:
|
|
125
|
+
return target.read_text(encoding="utf-8")
|
|
126
|
+
except Exception:
|
|
127
|
+
return None
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def fix_missing(self, interactive: bool = True) -> None:
|
|
131
|
+
"""Prompt to fix missing .gitignore and LICENSE."""
|
|
132
|
+
gitignore_path = self.repo_path / ".gitignore"
|
|
133
|
+
license_path = self.repo_path / "LICENSE"
|
|
134
|
+
|
|
135
|
+
if not gitignore_path.exists():
|
|
136
|
+
create_gi = True
|
|
137
|
+
if interactive:
|
|
138
|
+
try:
|
|
139
|
+
create_gi = Confirm.ask(
|
|
140
|
+
"Missing .gitignore. Create default .gitignore?", default=True
|
|
141
|
+
)
|
|
142
|
+
except Exception:
|
|
143
|
+
create_gi = True
|
|
144
|
+
if create_gi:
|
|
145
|
+
gitignore_path.write_text(DEFAULT_GITIGNORE, encoding="utf-8")
|
|
146
|
+
self.console.print("[green]Created .gitignore[/green]")
|
|
147
|
+
|
|
148
|
+
if not license_path.exists():
|
|
149
|
+
create_lic = True
|
|
150
|
+
choice = "MIT"
|
|
151
|
+
if interactive:
|
|
152
|
+
try:
|
|
153
|
+
create_lic = Confirm.ask(
|
|
154
|
+
"Missing LICENSE file. Create one?", default=True
|
|
155
|
+
)
|
|
156
|
+
if create_lic:
|
|
157
|
+
choice = Prompt.ask(
|
|
158
|
+
"Select license",
|
|
159
|
+
choices=["MIT", "Apache-2.0"],
|
|
160
|
+
default="MIT",
|
|
161
|
+
)
|
|
162
|
+
except Exception:
|
|
163
|
+
create_lic = True
|
|
164
|
+
choice = "MIT"
|
|
165
|
+
if create_lic:
|
|
166
|
+
template = APACHE_TEMPLATE if choice == "Apache-2.0" else MIT_TEMPLATE
|
|
167
|
+
content = template.format(year="2026", author="Author")
|
|
168
|
+
license_path.write_text(content, encoding="utf-8")
|
|
169
|
+
self.console.print(f"[green]Created LICENSE ({choice})[/green]")
|
|
170
|
+
|
|
171
|
+
def run(self, fix: bool = False, interactive: bool = True) -> list[AuditResult]:
|
|
172
|
+
"""Run all audit checks."""
|
|
173
|
+
if fix:
|
|
174
|
+
self.fix_missing(interactive=interactive)
|
|
175
|
+
|
|
176
|
+
results: list[AuditResult] = []
|
|
177
|
+
|
|
178
|
+
# 1. README.md exists
|
|
179
|
+
readme_content = self._file_content("README.md")
|
|
180
|
+
if readme_content is not None:
|
|
181
|
+
line_count = len(readme_content.splitlines())
|
|
182
|
+
results.append(AuditResult("README.md exists", True, f"{line_count} lines"))
|
|
183
|
+
else:
|
|
184
|
+
results.append(AuditResult("README.md exists", False, "file missing"))
|
|
185
|
+
|
|
186
|
+
# 2. README has Install
|
|
187
|
+
if readme_content and re.search(
|
|
188
|
+
r"##\s+Install(ation)?", readme_content, re.IGNORECASE
|
|
189
|
+
):
|
|
190
|
+
results.append(
|
|
191
|
+
AuditResult("README has Install", True, 'found "## Installation"')
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
results.append(
|
|
195
|
+
AuditResult(
|
|
196
|
+
"README has Install", False, 'missing "## Installation" section'
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# 3. README has Usage
|
|
201
|
+
if readme_content and re.search(r"##\s+Usage", readme_content, re.IGNORECASE):
|
|
202
|
+
results.append(AuditResult("README has Usage", True, 'found "## Usage"'))
|
|
203
|
+
else:
|
|
204
|
+
results.append(
|
|
205
|
+
AuditResult("README has Usage", False, 'missing "## Usage" section')
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# 4. README has License
|
|
209
|
+
if readme_content and re.search(r"##\s+License", readme_content, re.IGNORECASE):
|
|
210
|
+
results.append(
|
|
211
|
+
AuditResult("README has License", True, 'found "## License"')
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
results.append(
|
|
215
|
+
AuditResult("README has License", False, "no License section")
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# 5. LICENSE file
|
|
219
|
+
license_content = self._file_content("LICENSE")
|
|
220
|
+
if license_content is not None:
|
|
221
|
+
if "Apache" in license_content:
|
|
222
|
+
lic_type = "Apache-2.0"
|
|
223
|
+
elif "MIT" in license_content:
|
|
224
|
+
lic_type = "MIT"
|
|
225
|
+
else:
|
|
226
|
+
first_line = (
|
|
227
|
+
license_content.strip().splitlines()[0][:20]
|
|
228
|
+
if license_content.strip()
|
|
229
|
+
else "Found"
|
|
230
|
+
)
|
|
231
|
+
lic_type = first_line
|
|
232
|
+
results.append(AuditResult("LICENSE file", True, lic_type))
|
|
233
|
+
else:
|
|
234
|
+
results.append(AuditResult("LICENSE file", False, "missing"))
|
|
235
|
+
|
|
236
|
+
# 6. .gitignore exists
|
|
237
|
+
gitignore_content = self._file_content(".gitignore")
|
|
238
|
+
if gitignore_content is not None:
|
|
239
|
+
entries = [
|
|
240
|
+
line
|
|
241
|
+
for line in gitignore_content.splitlines()
|
|
242
|
+
if line.strip() and not line.strip().startswith("#")
|
|
243
|
+
]
|
|
244
|
+
results.append(
|
|
245
|
+
AuditResult(".gitignore exists", True, f"{len(entries)} entries")
|
|
246
|
+
)
|
|
247
|
+
else:
|
|
248
|
+
results.append(AuditResult(".gitignore exists", False, "file missing"))
|
|
249
|
+
|
|
250
|
+
# 7. .gitignore has __pycache__
|
|
251
|
+
if gitignore_content is not None and "__pycache__" in gitignore_content:
|
|
252
|
+
results.append(AuditResult(".gitignore has __pycache__", True, ""))
|
|
253
|
+
else:
|
|
254
|
+
results.append(
|
|
255
|
+
AuditResult(
|
|
256
|
+
".gitignore has __pycache__", False, 'missing "__pycache__"'
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# 8. Tests directory
|
|
261
|
+
tests_dir = self.repo_path / "tests"
|
|
262
|
+
test_dir = self.repo_path / "test"
|
|
263
|
+
src_test_dir = self.repo_path / "src" / "test"
|
|
264
|
+
has_tests = (
|
|
265
|
+
(tests_dir.is_dir() and any(tests_dir.iterdir()))
|
|
266
|
+
or (test_dir.is_dir() and any(test_dir.iterdir()))
|
|
267
|
+
or (src_test_dir.is_dir() and any(src_test_dir.iterdir()))
|
|
268
|
+
)
|
|
269
|
+
if has_tests:
|
|
270
|
+
results.append(AuditResult("Tests directory", True, "tests/ found"))
|
|
271
|
+
else:
|
|
272
|
+
results.append(AuditResult("Tests directory", False, "missing"))
|
|
273
|
+
|
|
274
|
+
# 9. CI config
|
|
275
|
+
ci_yml = self.repo_path / ".github" / "workflows" / "ci.yml"
|
|
276
|
+
ci_yaml = self.repo_path / ".github" / "workflows" / "ci.yaml"
|
|
277
|
+
workflows_dir = self.repo_path / ".github" / "workflows"
|
|
278
|
+
has_ci = (
|
|
279
|
+
ci_yml.exists()
|
|
280
|
+
or ci_yaml.exists()
|
|
281
|
+
or (workflows_dir.is_dir() and any(workflows_dir.glob("*.y*ml")))
|
|
282
|
+
)
|
|
283
|
+
if has_ci:
|
|
284
|
+
results.append(AuditResult("CI config", True, ".github/workflows/ci.yml"))
|
|
285
|
+
else:
|
|
286
|
+
results.append(AuditResult("CI config", False, "missing"))
|
|
287
|
+
|
|
288
|
+
# 10. pyproject.toml
|
|
289
|
+
pyproject = self.repo_path / "pyproject.toml"
|
|
290
|
+
if pyproject.is_file():
|
|
291
|
+
results.append(AuditResult("pyproject.toml", True, ""))
|
|
292
|
+
else:
|
|
293
|
+
results.append(AuditResult("pyproject.toml", False, "missing"))
|
|
294
|
+
|
|
295
|
+
return results
|